Repository navigation
Harden the template renderer and fix the CI audit gate for 3.0.2 - #31
Merged
Merged
Conversation
isRenderContext() returned true for any input with a style key, so bare
template data such as { name: 'Dan', style: 'casual' } was read as a
context with no data and every placeholder rendered empty. Require that
style and data, when present, are non-null, non-array objects before
treating the input as a render context.
Fixes #27
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The audit step in CI and release failed on high advisories in nanoid and postcss, both reached only through vitest. Bump the dev tree with npm audit fix, audit with --omit=dev so dev-only advisories do not block publishing, and use npm ci so the lockfile is enforced. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Values substituted inside a {{#block}} were spliced back into the outer
string and then re-scanned for slot and variable syntax, so client data
such as "{{secret}}" or "{{slot:admin}}" in an array item resolved
against the full render data. renderTemplate now walks the template
once, rendering the text between blocks and each block body separately,
so rendered output is never re-scanned. Fixes GHSA-25x2-qphj-x3cv.
Subject and plain-text templates were HTML-escaped, turning O'Brien into
O'Brien in the inbox. renderTemplate takes an optional
{ escape: false } and compile.ts uses it for subject and text; the HTML
body is still escaped. Fixes #26.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Hoist the combined slot and variable pattern to a module constant, drop the unreachable key fallback, reuse the renderer's plain-object check in render-context detection, and align the contributor setup command with the workflows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Lock in the adversarial cases the review ran by hand against the single-pass renderer, and the ordinary block, slot and path semantics it must keep. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…first (#29) The block regex paired an opening tag with the nearest close of the same name, so a block nested under its own key ended at the inner close and left the tail as text. Top-level blocks are now found by scanning block tags with a stack of open names. Stray close tags and unclosed opens stay literal as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
sabind
commented
Oct 17, 2026
Comment on lines
+47
to
+49
| subject: renderTemplate(subject, data, { escape: false }), | ||
| html: applyEmailStyle(renderedHtml, style), | ||
| text: renderTemplate(text, data) | ||
| text: renderTemplate(text, data, { escape: false }) |
Contributor
Author
There was a problem hiding this comment.
Should we default to escape true?
Contributor
Author
There was a problem hiding this comment.
It already does. renderTemplate(template, data) escapes, and the only place the default lives is options.escape ?? true in src/template.ts. The HTML body two lines up uses that default.
Subject and text are the two callers that opt out, because they are not HTML. Escaping them is what produced O'Brien in the subject line (#26), so passing escape: true here would bring that bug back.
If the inline { escape: false } reads too much like a footgun, the alternative is a named helper such as renderPlainText(template, data) that wraps it, so the opt-out is visible by name at the call site. Happy to add that if you prefer it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Four confirmed bugs and one privately reported security issue in 3.0.1, found while reviewing the repo to solidify it:
O'Brienin the subject.stylewas mistaken for a render context and every placeholder rendered empty, with no error.{{#block}}were parsed as template syntax on a second pass. A client-supplied value such as a display name could read any other variable in the same render, or pull in trusted slot HTML unescaped. The HTML body is the only place the library promises escaping, and this bypassed it for every value that passed through a block.What
The renderer now runs in a single pass, so rendered values are never rescanned; subject and text render as plain text; render-context detection only triggers on object-valued
dataandstyle; nested blocks that share a name close innermost first; CI and release install from the lockfile and audit production dependencies. Version bumped to 3.0.2.Changes
src/template.ts, tests, README). Plain text between blocks and each block body are rendered as separate pieces and concatenated, so no output is reparsed. Slot HTML that itself contains{{...}}is now output literally as a side effect. Fixes the advisory.src/compile.ts,src/types.ts, tests, README).renderTemplatetakes anescapeoption, default true; the compiled template turns it off for subject and text. The HTML body is unchanged. Fixes Subject and plain-text bodies are HTML-escaped at render time #26.src/compile.ts, tests, README). Input is a{ data, style }context only when every key is one of those two and both are plain objects when present. Fixes render() silently drops all data when template data has a key named "style" #27.npm audit fix; both workflows usenpm ciandnpm audit --audit-level=high --omit=dev. The advisories were in juice's production tree, not dev-only as the issue and the commit message first said; the lockfile bump is what clears them, and--omit=devstays as policy because the dev tree is not shipped. Fixes CI and release fail on npm audit for dev-only advisories #28.src/template.ts,src/compile.ts, README, CONTRIBUTING, tests). Combined text pattern hoisted to a module constant, an unreachable fallback removed, context detection reuses the renderer's plain-object check, contributor setup saysnpm ci, and eleven tests pin the adversarial and ordinary cases the independent review ran by hand.src/template.ts, tests, README). Top-level blocks are found by scanning open and close tags with a stack, so a close pops the innermost open block of that name. Stray closes and unclosed opens stay literal. Done now because it rewrites the same matcher as the advisory fix and belongs in the same release. Fixes Nested blocks with the same name pair the outer open with the inner close #29.package.json, lockfile). Bumped so the fix for CI and the new version ship together.Testing
npm pack --dry-rununchanged at 29 files.{{token; all held, and every ordinary case was byte-identical to the base.package.jsondependencies unchanged.Risk and rollout
{{...}}is no longer expanded.render({ style: null })is now read as template data rather than a context; output is identical either way.v3.0.2and push the tag to trigger the release workflow, then publish advisory GHSA-25x2-qphj-x3cv against 3.0.2. The first CI run on GitHub's runners is the real confirmation of the audit fix, since the advisory database moves.Notes for reviewers
🤖 Generated with Claude Code