Skip to content

Commit 86330c0

Browse files
committed
fix(formula): the hole placement scan follows the tokenizer at tag open and fails closed in a script's escape states (#25189)
A character that opens no tag is reconsumed in the data state, and a script's text stays raw from a comment opener until an end tag that follows a comment closer. The inert-type-writes header now says the email_template wiring is also read by the hole placement rule's door test, and the docs' refused list names SVG and MathML content. Claude-Session: https://claude.ai/code/session_01Fg7vNwYgyAmHMTJm8rAYsD Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f4424b commit 86330c0

3 files changed

Lines changed: 28 additions & 4 deletions

File tree

‎content/docs/automation/email-templates.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ never needs triple braces.
8686
element text and for a quoted attribute value that holds plain text, so those
8787
are the supported placements for `{{path}}`. A hole anywhere else — inside a
8888
`script` or `style` element, a comment, a tag outside any attribute value, an
89-
unquoted attribute value, or an attribute read as a URL (`href`, `src`, …), an
90-
event handler, a style or markup — is refused by `os validate`, `os build`,
89+
unquoted attribute value, an attribute read as a URL (`href`, `src`, …), an
90+
event handler, a style or markup, or SVG or MathML content — is refused by `os validate`, `os build`,
9191
`os lint` and the metadata publish door, and so is a `{{{path}}}` hole outside
9292
the `href` shape above. The refusal (`email-template-hole-placement`, a
9393
`422 INVALID_METADATA` at the publish door) names the placement and the

‎packages/formula/src/mail-html-hole-placement.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,8 @@ function classifyHolePositions(html: string, holes: ReadonlyArray<{ start: numbe
278278
let attrValueStart = -1;
279279
let declaration = '';
280280
let textElement = '';
281+
/** Inside a script's text, past a comment opener and not yet past a closer. */
282+
let scriptEscaped = false;
281283
let foreignDepth = 0;
282284

283285
const emitTag = (selfClosing: boolean) => {
@@ -291,6 +293,7 @@ function classifyHolePositions(html: string, holes: ReadonlyArray<{ start: numbe
291293
}
292294
if (foreignDepth > 0) return 'data' as State;
293295
textElement = tagName;
296+
scriptEscaped = false;
294297
if (tagName === 'plaintext') return 'plaintext' as State;
295298
if (RAW_TEXT_ELEMENTS.has(tagName)) return 'raw-text' as State;
296299
if (ESCAPABLE_TEXT_ELEMENTS.has(tagName)) return 'escapable-text' as State;
@@ -355,8 +358,16 @@ function classifyHolePositions(html: string, holes: ReadonlyArray<{ start: numbe
355358
continue;
356359
}
357360
const c = html[i];
361+
// A script's text has escape states of its own, in which an end tag may
362+
// not end the element. They are not modelled: from a comment opener on,
363+
// the scan stays in raw text until an end tag that follows a comment
364+
// closer, which can only refuse more than the parser would.
365+
if (state === 'raw-text' && textElement === 'script') {
366+
if (html.startsWith('<!--', i)) scriptEscaped = true;
367+
else if (html.startsWith('-->', i)) scriptEscaped = false;
368+
}
358369
// States that read more than one character at a time.
359-
if ((state === 'raw-text' || state === 'escapable-text') && closesTextElement(i)) {
370+
if ((state === 'raw-text' || state === 'escapable-text') && !scriptEscaped && closesTextElement(i)) {
360371
state = 'end-tag-open';
361372
tagName = '';
362373
endTag = true;
@@ -399,7 +410,9 @@ function classifyHolePositions(html: string, holes: ReadonlyArray<{ start: numbe
399410
if (c === '/') return 'end-tag-open';
400411
if (c === '?') return 'bogus-comment';
401412
if (isAlpha(c)) { tagName = c.toLowerCase(); endTag = false; return 'tag-name'; }
402-
return 'data';
413+
// A character that opens no tag is reconsumed in the data state, where
414+
// only another `<` means anything.
415+
return step('data', c);
403416
case 'end-tag-open':
404417
if (isAlpha(c)) { tagName = c.toLowerCase(); endTag = true; return 'tag-name'; }
405418
if (c === '>') return 'data';

‎packages/lint/src/runtime-gate.inert-type-writes.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@
5757
* proved by a TABLE PIN rather than a door reading — the size of that proof is
5858
* stated where those cases are, and it is why the string assertion in the
5959
* first block is load-bearing rather than decorative.
60+
*
61+
* [#25189] `email_template` is silent only for the liveness bridge now. Its
62+
* writes also cross `validateEmailTemplateHolePlacements`, whose own door test
63+
* (`validate-email-template-hole-placements.test.ts`) refuses a written
64+
* template through `runRuntimeAuthoringRules`, so a wrong stack key for that
65+
* type goes red there too. For `mapping` the table pin is still all there is.
6066
*/
6167
import { describe, expect, it } from 'vitest';
6268

@@ -705,6 +711,11 @@ describe('#19542 — group C: wired, dispatched, and silent by ledger (email_tem
705711
* assertion, `each stack key is the collection the crossed rules actually
706712
* read` in the first block of this file.
707713
*
714+
* [#25189] For `email_template` that is no longer the whole proof: the hole
715+
* placement rule judges its writes at this door, and its own test file
716+
* reads the collection the key names. The paragraph below stands for
717+
* `mapping`.
718+
*
708719
* So for group C the wiring is held by a TABLE PIN, not a door reading. That
709720
* is acceptable under the ruling's fence (「the wiring is the whole
710721
* deliverable」, ⛔ no ledger population) — but it is the honest size of the

0 commit comments

Comments
 (0)