Summary
sc-compose 1.6 turned on HTML autoescape for .html/.xhtml outputs. The default itself matches Jinja/Flask/Django, but it shipped in a minor release with no compatibility switch and no diagnostic, so every consumer template that inserted a pre-built fragment ({{ body_html }}) silently started rendering its markup as literal text. The JSON side of the same feature got json_escape_mode: legacy / --json-escape-mode; the HTML side did not.
Impact in one consumer (randlee/atm-core): five templates, 31 committed evidence sets rendered blank for three weeks before anyone opened one. Fixed on the consumer side in randlee/atm-core#1430 by adding | safe, plus a regression test that renders through the real binary.
This issue proposes two additive, non-breaking changes so this cannot recur.
A. html_escape_mode — a compatibility switch mirroring json_escape_mode
Surface
| Where |
Form |
Values |
Default |
| Template frontmatter |
html_escape_mode: auto | legacy |
auto, legacy |
auto |
| CLI |
--html-escape-mode <auto|legacy> |
same |
unset (defer to frontmatter, then auto) |
Precedence: CLI flag > frontmatter > default auto. Identical to how --json-escape-mode is documented today ("default is auto unless frontmatter declares one").
Semantics
auto (current 1.6 behavior): for outputs whose format resolves to HTML or XHTML (by output extension .html/.xhtml, or an explicit format: frontmatter key), every interpolated value is HTML-escaped unless the placeholder carries | safe.
legacy (pre-1.6 behavior): no automatic escaping for HTML/XHTML output. | e / | escape continue to escape explicitly; | safe is accepted and is a no-op.
- Non-HTML formats are untouched by either value.
Implementation notes
- Resolve the mode in the same place the renderer already resolves
json_escape_mode, so both live in one "escape policy" struct derived from (CLI, frontmatter, output format). Suggested shape:
pub struct EscapePolicy { pub json: JsonEscapeMode, pub html: HtmlEscapeMode }
pub enum HtmlEscapeMode { Auto, Legacy }
- Feed
policy.html == Auto into the environment's autoescape decision for that render; Legacy disables autoescape for the run. Do not special-case individual filters.
- Frontmatter validation: reject unknown values with the same error style as an unknown
json_escape_mode.
- Tests: (1) frontmatter
legacy renders <b> verbatim into .html; (2) CLI flag overrides frontmatter both directions; (3) auto still escapes and | safe still passes through; (4) .md/.txt output unaffected by either value; (5) --help lists the flag next to --json-escape-mode.
- Docs: one paragraph next to the
json_escape_mode docs, plus a CHANGELOG line that names 1.6.0 as the release that changed the HTML default (so readers of old templates know what to set).
B. Unmarked-fragment diagnostic
Heuristic
When rendering to HTML/XHTML in auto mode, for each interpolated value that was autoescaped (i.e. not safe, not already Markup), flag it if the pre-escape string matches either:
- a tag opener:
<[A-Za-z][A-Za-z0-9-]*[\s/>] (catches <h1>, <tr class=…>, <br/>), or
- an entity reference:
&(#[0-9]+|#x[0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]+);
The second pattern is what identifies "already-escaped HTML being escaped again" (& → &amp;), which is exactly the failure mode from #1430. Values that are pure text never match either pattern, so false positives are limited to prose that literally contains something like a < b > c; — rare, and silenceable with | e (explicit escape suppresses the diagnostic because the author has stated intent).
Surface
| Where |
Form |
Values |
Default |
| CLI |
--escape-diagnostics <off|warn|error> |
|
warn |
| Frontmatter |
escape_diagnostics: off | warn | error |
|
inherits CLI |
warn (default): print one line per offending placeholder to stderr, exit code unchanged. Non-breaking.
error: fail the render with exit code 2 (same code as other template errors). Opt-in.
off: silence, for templates that intentionally display markup as text.
- Do not fold
error into the existing --strict in this release; consumers already pass --strict (atm-core's view-site builder does) and would break. Promote it into --strict in the next minor after one release of warnings, with a CHANGELOG notice.
Message shape
warning: html-escape: variable `body_html` in templates/smoke-report/inbound-peer-pane.xhtml.j2:17 contains markup and was autoescaped; add `| safe` for a trusted fragment, `| e` to silence, or set html_escape_mode: legacy
Include template path and line, variable name, and the three remedies. One line per placeholder, deduplicated per (template, line, variable).
Implementation notes
- Hook the check where the autoescape wrapper converts a plain string to escaped output; that is the only point that sees both the raw value and the "was this marked safe" bit. Keep the regexes compiled once per render.
| e on a placeholder should mark the value so the check skips it (explicit intent). | safe never reaches the escaper, so it is skipped naturally.
- Tests: (1)
{{ body_html }} with <h1> warns; (2) {{ body_html | safe }} does not; (3) {{ body_html | e }} does not; (4) & in a scalar warns (double-escape case); (5) error mode exits 2; (6) off prints nothing; (7) .md output never warns; (8) dedup across a loop rendering the same placeholder.
Rollout
- Ship A and B(
warn) together in one minor release (additive; no existing render changes output or exit code).
- Next minor:
--strict implies --escape-diagnostics error; CHANGELOG entry.
- Consumers that want the old behavior set
html_escape_mode: legacy in frontmatter and never see the diagnostic.
References
🤖 Generated with Claude Code
https://claude.ai/code/session_019kon2YPcoTpu2ok838FrgZ
Summary
sc-compose 1.6 turned on HTML autoescape for
.html/.xhtmloutputs. The default itself matches Jinja/Flask/Django, but it shipped in a minor release with no compatibility switch and no diagnostic, so every consumer template that inserted a pre-built fragment ({{ body_html }}) silently started rendering its markup as literal text. The JSON side of the same feature gotjson_escape_mode: legacy/--json-escape-mode; the HTML side did not.Impact in one consumer (randlee/atm-core): five templates, 31 committed evidence sets rendered blank for three weeks before anyone opened one. Fixed on the consumer side in randlee/atm-core#1430 by adding
| safe, plus a regression test that renders through the real binary.This issue proposes two additive, non-breaking changes so this cannot recur.
A.
html_escape_mode— a compatibility switch mirroringjson_escape_modeSurface
html_escape_mode: auto | legacyauto,legacyauto--html-escape-mode <auto|legacy>auto)Precedence: CLI flag > frontmatter > default
auto. Identical to how--json-escape-modeis documented today ("default is auto unless frontmatter declares one").Semantics
auto(current 1.6 behavior): for outputs whose format resolves to HTML or XHTML (by output extension.html/.xhtml, or an explicitformat:frontmatter key), every interpolated value is HTML-escaped unless the placeholder carries| safe.legacy(pre-1.6 behavior): no automatic escaping for HTML/XHTML output.| e/| escapecontinue to escape explicitly;| safeis accepted and is a no-op.Implementation notes
json_escape_mode, so both live in one "escape policy" struct derived from (CLI, frontmatter, output format). Suggested shape:policy.html == Autointo the environment's autoescape decision for that render;Legacydisables autoescape for the run. Do not special-case individual filters.json_escape_mode.legacyrenders<b>verbatim into.html; (2) CLI flag overrides frontmatter both directions; (3)autostill escapes and| safestill passes through; (4).md/.txtoutput unaffected by either value; (5)--helplists the flag next to--json-escape-mode.json_escape_modedocs, plus a CHANGELOG line that names 1.6.0 as the release that changed the HTML default (so readers of old templates know what to set).B. Unmarked-fragment diagnostic
Heuristic
When rendering to HTML/XHTML in
automode, for each interpolated value that was autoescaped (i.e. notsafe, not alreadyMarkup), flag it if the pre-escape string matches either:<[A-Za-z][A-Za-z0-9-]*[\s/>](catches<h1>,<tr class=…>,<br/>), or&(#[0-9]+|#x[0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]+);The second pattern is what identifies "already-escaped HTML being escaped again" (
&→&amp;), which is exactly the failure mode from #1430. Values that are pure text never match either pattern, so false positives are limited to prose that literally contains something likea < b > c;— rare, and silenceable with| e(explicit escape suppresses the diagnostic because the author has stated intent).Surface
--escape-diagnostics <off|warn|error>warnescape_diagnostics: off | warn | errorwarn(default): print one line per offending placeholder to stderr, exit code unchanged. Non-breaking.error: fail the render with exit code 2 (same code as other template errors). Opt-in.off: silence, for templates that intentionally display markup as text.errorinto the existing--strictin this release; consumers already pass--strict(atm-core's view-site builder does) and would break. Promote it into--strictin the next minor after one release of warnings, with a CHANGELOG notice.Message shape
Include template path and line, variable name, and the three remedies. One line per placeholder, deduplicated per (template, line, variable).
Implementation notes
| eon a placeholder should mark the value so the check skips it (explicit intent).| safenever reaches the escaper, so it is skipped naturally.{{ body_html }}with<h1>warns; (2){{ body_html | safe }}does not; (3){{ body_html | e }}does not; (4)&in a scalar warns (double-escape case); (5)errormode exits 2; (6)offprints nothing; (7).mdoutput never warns; (8) dedup across a loop rendering the same placeholder.Rollout
warn) together in one minor release (additive; no existing render changes output or exit code).--strictimplies--escape-diagnostics error; CHANGELOG entry.html_escape_mode: legacyin frontmatter and never see the diagnostic.References
--json-escape-mode, frontmatterjson_escape_mode: legacy🤖 Generated with Claude Code
https://claude.ai/code/session_019kon2YPcoTpu2ok838FrgZ