Skip to content

🍕 Node 26 and Vite 8, and rewrite on native code: 7 dependencies, node:test, 4x faster to load - #98

Merged
jjpaulino merged 5 commits into
masterfrom
jordan/modernize
Nov 6, 2026
Merged

jjpaulino merged 5 commits into
masterfrom
jordan/modernize

Conversation

@jjpaulino

@jjpaulino jjpaulino commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Moves clayhandlebars to Node 26.12.1 and Vite 8, and replaces most of its third-party code with native code. Five commits, in order:

  1. Test on Node 26.12.1, move CI to GitHub Actions, update handlebars and striptags.
  2. Build the browser bundle with Vite, replacing webpack 2, Babel 6 and Uglify 2.
  3. Rewrite on native code: tests on node:test, a third of the install, 4x faster to load.
  4. Bump the version to 6.0.0.
  5. Publish through npm trusted publishing, and put 6.0.0 on the next dist-tag.

The version is 6.0.0 (commit 4): engines is now >=26.12.1, yaml moved to js-yaml 5 and some helpers changed behavior, so this is a semver major. The release plan: publish 6.0.0 under the next dist-tag, not latest; widen the clayhandlebars ranges to 5 || 6 in amphora-html and claycli (peer) and clay-kiln (dependency); then move latest to 6.0.0. Until then latest stays 5.0.2, so nothing that pins or ranges on 5 changes.

Result

5.0.2 this branch
Direct dependencies 13 7
Whole tree with dev tools 918 packages 143
npm audit 98 vulnerabilities (46 critical), all dev 0
require('clayhandlebars')() 60 ms, 387 modules, 31 MB 14 ms, 116 modules, 14 MB
Browser bundle, minified 209 KB (gzip 75) 148 KB (gzip 59)
npm tarball, unpacked 641 KB, 119 files 278 KB, 75 files
Tests 220 (mocha, chai, sinon, c8) 335 on node:test, 99% of lines

No helper got slower. ifAll, ifAny and ifNone are 2-3x faster, default on arrays 4x, and the read helper 24x (files are cached by path and checked again once a second; CLAYHANDLEBARS_READ_CACHE=false turns it off).

Dependencies

Rule: native code first; a package stays only where nothing native gives the same output.

  • Removed: date-fns (lib/date.js is a port of the date-fns 1 parser and formatter), glob (the helper list is a manifest, and a test fails if a file is missing from it), outdent, comma-it, randomstring and its Buffer polyfill (crypto.getRandomValues), json-stringify-safe, helper-yaml, path-browserify, buffer; lodash from 23 helpers (lib/path.js, lib/is-empty.js, lib/collection.js keep lodash's rules where templates depend on them).
  • Kept, loaded only when used: he (stays on 1.x: 2.0 is ESM-only and Jest cannot require ESM), speakingurl (11 to 14), js-yaml (3 to 5, safe by default), lodash for kebabCase and longestWord only.
  • Kept: handlebars, striptags, clay-log (failures of read and yaml reach logging and error tracking through it; set up on first log).
  • Dev tooling: mocha, chai, sinon, c8 become node:test, node:assert and Node's own coverage. documentation (a 600-package beta tree) becomes docs/generate-readme.js, which reproduces the 5.0.2 README from the 5.0.2 sources; CI runs npm run check-readme. ESLint 4 becomes ESLint 10 with a flat config.
  • package.json files now lists what ships, so local files never end up in the tarball.

Behavior changes (bug fixes, each with a test)

  1. perWordClasses dropped the space after every word equal to the last word ("a a").
  2. striptags with tags to keep, or a replacement, wrote [object Object] (Handlebars' options object) into the text.
  3. compare took constructor, toString and hasOwnProperty for operators.
  4. addOrdinalSuffix returned a String object for a non-number (truthy even when empty).
  5. addCommas("") threw or returned "0.00" depending on what had run before it (comma-it's implicit globals).
  6. map with no function returned booleans instead of the items.
  7. Typos: "Handlerbars" in two errors, and two wrong documentation examples.

yaml now uses js-yaml 5, so the old !!js/function tag is gone and a parse failure is logged through clay-log instead of console.log. Nothing else about the helpers' output changed.

Compatibility notes for consumers

  • amphora-html's old Jest cannot resolve node:fs and mocks fs wholesale, so library code uses plain fs and path, the factory no longer reads the file system, and partials compile on first render.
  • The factory signature (env?), .wrapPartial(name, code) and the browser field (dist/browser.js) are unchanged.

How it was checked

  • Every helper against 5.0.2: about 1.65 million generated calls (undefined, null, NaN, odd strings, arrays, dates, frozen and sealed objects, boxed values, Maps and Sets, typed arrays, a Handlebars options object) on copies of the same input, comparing the return value, the thrown error and what the call did to its arguments. Identical except the fixes above, on the server code and on the browser bundle.
  • Dates: the date module matches date-fns 1 on 725,868 dates, formats and parse strings in 7 time zones.
  • Templates: 634 real component and layout templates from a production Clay site, 4 data sets each (2,536 renders, 2.0M characters), rendered with 5.0.2 and with this branch, nested components included: identical on the server build and the browser bundle. wrapPartial gives the same text for all 634.
  • Consumers: amphora-html's test suite passes as it does with 5.0.2 (77 passed, 1 skipped). Kiln builds with it, and passes its smoke and preloader tests.
  • Clean install: npm ci, npm test, npm run build, check-build, check-readme and npm audit pass from a clean checkout of the branch.

Not in this PR

  • Publishing itself: it starts when the v6.0.0 tag is pushed after the merge, and needs the trusted publisher to be set up on npmjs.com for this repository and release.yml (commit 5 removes the NPM_TOKEN secret the workflow used to read; the repository has none). publishConfig.tag in commit 5 keeps latest on 5.0.2 and goes away when latest moves.
  • Replacing the last lodash use (kebabCase, longestWord), the DOM entity decoder in place of he in the browser, or console in place of clay-log. Each trades output parity or error reporting for size, so they are left for later.

…ars and striptags

- .nvmrc 26.12.1; CI reads it. Travis (Node 6) is replaced by ci.yml (lint and tests)
  and release.yml (npm publish with provenance, after the same checks);
  scripts/release.sh is the shared release script.
- handlebars ^4.7.10 (critical advisories fixed) and striptags ^3.2.0 (the
  non-string input advisory). striptags 2.2.1 and 3.2.0 give the same output on
  20,000 generated well-formed HTML strings; they differ only for broken markup
  (stray < and >). striptags 3 throws for a value that isn't a string, where 2
  returned '' for objects, so wordCount and striptags give '' for those, which
  is what {{wordCount}} with no argument (Handlebars passes the options object) did.
- sinon was a dependency, but only formatLocalDate.test.js uses it: now a
  devDependency. coveralls and istanbul 0.4 are replaced by c8.
- The v1 lockfile is regenerated. 220 tests pass.

Not done: npm run build (webpack 2 + Uglify 2 + Babel 6 behind dist/browser.js)
can't parse the ES2015 that handlebars 4.7.10 and striptags 3 ship, so a release
needs a new bundler for that file first. Kiln imports dist/browser.js.
…d Uglify 2

dist/browser.js is what Kiln loads. The old toolchain could not parse the
ES2015 that handlebars 4.7.10 and striptags 3 ship, so it blocked a
release. vite.config.mjs does what the webpack setup did: it turns the
two require.context() calls in client.js into maps of static requires,
precompiles the .hbs partial, and bundles path and Buffer for browsers
(webpack 2 did both on its own). The output is the same shape,
module.exports = function (env) { ... }, at 209 KB instead of 489 KB.

Compared with the published 5.0.2 bundle: the same 66 helpers and the
partial; 104,720 helper calls over 38 argument values and combinations
(strings, numbers, dates, objects, null, undefined, NaN) give the same
results, and 60 template renders (block helpers, the dynamic partial)
match. The only difference is striptags and wordCount given an ARRAY:
striptags 2 joined its elements, this returns ''. Nothing in sites
passes one.

scripts/check-browser-bundle.js (npm run check-build, also in CI) checks
the bundle registers what index.js registers and renders the same.
prepack builds it on publish; the old prepublish hook ran on npm ci and
is deprecated. Drops 196 devDependency packages.
…r to load, a third of the install

Helpers
- lodash, date-fns, glob, outdent, comma-it, randomstring (and the Buffer
  polyfill it needed in the browser), json-stringify-safe, path-browserify and
  helper-yaml are gone from the helpers and the bundle. What lodash and date-fns
  did for us now lives in small modules under lib/ (path get/set, isEmpty, date
  parse and format, circular-safe stringify) that keep their rules; they were
  compared with the originals on over a million calls and dates in seven time
  zones.
- Helpers are listed once (lib/helpers.js), by index.js and by the browser
  bundle, instead of being found with glob at run time and require.context at
  build time. That drops the Vite plugin that emulated require.context.
- Creating an environment no longer touches the file system: partials are
  compiled the first time one is rendered. (A consumer's tests that mock fs, like
  amphora-html's, broke on the first version of this.)
- The read helper caches files by path and checks them again once a second;
  the yaml helper uses js-yaml 5 (no code-building tags); clay-log is only set up
  when something is logged.
- Dependencies left: clay-log, handlebars, he, js-yaml, lodash (kebabCase and
  longestWord, loaded when used), speakingurl, striptags.

Fixes found on the way (all are covered by tests)
- perWordClasses dropped the space after any word equal to the last word.
- striptags with tags to keep, or a replacement, wrote "[object Object]" (the
  Handlebars options object) into the text.
- compare took "constructor", "toString" ... for operators.
- addOrdinalSuffix gave a String object, not a string, for what isn't a number.
- addCommas leaked four globals and gave a ReferenceError or "0.00" for "" depending
  on what ran before it.
- map with no function gave booleans (lodash's "matches" shorthand); it gives the items.

Tooling
- Tests run on node:test and node:assert with Node's own coverage (99% of lines)
  instead of mocha, chai, sinon and c8; ESLint 10 with a flat config.
- README.md is generated by a small script from the JSDoc of the helpers instead
  of documentation 4.0.0-beta15 (same output as before, checked against the 5.0.2
  README); CI fails if it is out of date. The pre-commit hook for it is gone.
- Node 26.12.1 (engines).

npm install: 918 packages to 143 (dev) and 58 to 44 (production); npm audit 98
vulnerabilities (46 critical) to 0. Loading the package takes 15 ms and 116
modules instead of 61 ms and 387; the browser bundle is 160 KB instead of 209 KB.
@jjpaulino jjpaulino self-assigned this Oct 29, 2026
The Node 26.12.1 floor in engines, js-yaml 5 in the yaml helper and the helper bug fixes make this a major. Plan: publish 6.0.0 under the next dist-tag, widen the clayhandlebars ranges in amphora-html, claycli and clay-kiln to 5 || 6, then move latest to 6.0.0.
…ist-tag

The release workflow no longer reads an NPM_TOKEN secret (the repository has none): once the package's npm settings name this repository and release.yml as a trusted publisher, the job gets its right to publish from GitHub's OIDC token, and the provenance statement comes with it.

publishConfig.tag makes npm publish use the next dist-tag, so tagging v6.0.0 leaves latest on 5.0.2 until the clayhandlebars ranges in amphora-html, claycli and clay-kiln allow 6. Remove it when latest moves to 6.0.0. A hyphenated version still goes to prerelease.

npm logs "with tag latest" for such a publish but sends the next tag (checked against a local registry that recorded the request).
@jjpaulino
jjpaulino marked this pull request as ready for review November 6, 2026 02:16
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-06T02:20:39.633946Z 2750a9b Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@jjpaulino
jjpaulino merged commit ea59073 into master Nov 6, 2026
1 check passed
@jjpaulino
jjpaulino deleted the jordan/modernize branch November 6, 2026 02:19
jjpaulino added a commit to clay/clay-kiln that referenced this pull request Nov 6, 2026
clayhandlebars 6.0.0 (clay/handlebars#98) rewrites the helpers on native code
and needs Node 26.12.1, like this branch. Its browser bundle is self-contained
and 148 KB instead of 490 KB, so with the same build:
- clay-kiln-edit.js 4,033,209 -> 3,539,275 bytes (gzip 913,895 -> 793,317)
- clay-kiln-view.js 1,908,887 -> 1,420,682 bytes (gzip 455,881 -> 334,160)
- view-public and the CSS don't change.

The bundle starts with "use strict" and strictCommonJS keeps it inside its
module wrapper, so helpers still get a primitive `this` in the non-strict IIFE.
The lock drops 17 packages only 5.x pulled in (date-fns 1, comma-it,
randomstring, helper-yaml, sinon 2, ...) and adds clay-log 1.6.0, js-yaml 5.5.0
and argparse 3 under clayhandlebars. Lint, the Jest suite (974 tests) and the
dist smoke test pass.

Helper output changes only where 6.0.0 fixed bugs: perWordClasses spacing,
striptags with tags to keep, compare with "constructor" and the like as
operators, addOrdinalSuffix for non-numbers, addCommas(""), map without a
function, and the yaml helper on js-yaml 5.

6.0.0 is on npm under the next dist-tag while latest stays 5.0.2; ^6.0.0 still
resolves to it. Sites moves to clayhandlebars 6 together with the Node 26
releases of amphora-html, claycli and kiln.
jjpaulino added a commit to clay/clay-kiln that referenced this pull request Nov 6, 2026
clayhandlebars 6.0.0 (clay/handlebars#98) rewrites the helpers on native code
and needs Node 26.12.1, like this branch. Its browser bundle is self-contained
and 148 KB instead of 490 KB, so with the same build:
- clay-kiln-edit.js 4,033,209 -> 3,539,275 bytes (gzip 913,895 -> 793,317)
- clay-kiln-view.js 1,908,887 -> 1,420,682 bytes (gzip 455,881 -> 334,160)
- view-public and the CSS don't change.

The bundle starts with "use strict" and strictCommonJS keeps it inside its
module wrapper, so helpers still get a primitive `this` in the non-strict IIFE.
The lock drops 17 packages only 5.x pulled in (date-fns 1, comma-it,
randomstring, helper-yaml, sinon 2, ...) and adds clay-log 1.6.0, js-yaml 5.5.0
and argparse 3 under clayhandlebars. Lint, the Jest suite (975 passed, 3 skipped) and the
dist smoke test pass.

Helper output changes only where 6.0.0 fixed bugs: perWordClasses spacing,
striptags with tags to keep, compare with "constructor" and the like as
operators, addOrdinalSuffix for non-numbers, addCommas(""), map without a
function, and the yaml helper on js-yaml 5.

6.0.0 is on npm under the next dist-tag while latest stays 5.0.2; ^6.0.0 still
resolves to it. Sites moves to clayhandlebars 6 together with the Node 26
releases of amphora-html, claycli and kiln.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant