fix(ui): own static i18n content at the innermost element that carries it - #264
Conversation
…s it `applyStatic()` in `ui/js/i18n.js` walks every `[data-i18n]` element and does `els[i].innerHTML = t(key)`. A element that already carries a *text* `data-i18n` therefore cannot also rely on language hooks on its children: the ancestor's step replaces the whole inner HTML, detaching the child element (and its own `data-i18n*` attribute) from the document; the loop then assigns into that detached node -- no exception, no effect. jsdom measures `isConnected === false`. Two sites did this, and both existing gates are structurally blind to it: `every_static_i18n_attribute_resolves` only asks whether the key exists in both packs (it does), and a "find the key as text" dead-key scan sees the key literal sitting right there in `index.html` (so it counts as used). An attribute that can never take effect is neither. - `index.html:617` -- the raise-request card: `<h3 data-i18n="admin.raise.title">加额申请 <span data-i18n="admin.raise.sub">…</span></h3>`. Both pack values are plain text, so the child span was permanently destroyed: the hint "(member applies -> admin approves / rejects)" did not render in *either* language, and it never recovers -- `app.js` only writes `#raise-requests` and never redraws that heading. Fixed by making the two strings sibling carriers (the repository already uses this shape in `.wallet-hint`), so the heading keeps its own localization. - `index.html:79` -- the login footer: `<p data-i18n="login.foot">…<a id="reg-link" data-i18n="login.register">…</a>…</p>`. `login.foot`'s value *embeds* the same markup (including `id="reg-link"`), so the rendered text and the click (a document-delegated listener keyed on `t.id`) were never broken -- but the static hook was dead, which made `login.register` an orphan key fed only by it. Removed the dead attribute and the now-unreferenced key from both packs (the value already carries the link). Guard rails: - New scanner `scan_i18n_nesting` + test `no_data_i18n_attribute_nests_inside_a_data_i18n_element` in `src/i18n_pack.rs`: a tag-stack walk over `index.html` that fails when any `data-i18n*` attribute sits inside an ancestor with a text `data-i18n`. HTML comments are stripped first (their markup is not structure), void/self-closing elements never enter the stack, and the tag-end scan skips quoted `>` so an attribute value cannot desynchronise the parser. It also reports three positive controls (start tags, text carriers, unclosed stack at EOF) so "0 violations" cannot be misread as "the scanner saw nothing". - `nested_i18n_detector_detects_injected_defects`: a negative control built from the real pre-fix markup, plus positive controls that the legal shapes are not reported -- an ancestor carrying only an *attribute-kind* hook (`data-i18n-title` / `-label` write a single attribute via `setAttribute` and leave child markup alone, which is why `select#tx-range`'s five `<option data-i18n="tx.range.*">` and `div#help-panel`'s `<strong>` are fine), and that the reported line number is the real one. - `ui/README.md`: the convention (sibling carriers; a pack value that embeds its own markup is a second legitimate shape and is exactly why `login.foot` survives), the measured boundary, and the gate's scope. Scope, stated honestly rather than papered over: the gate pins the *nesting* axis only. The A/B leg `m_drop_parent` (delete the heading's own `data-i18n`) is GREEN by construction -- it genuinely removes the nesting, but it trades a never-applied attribute for a lost localization: `admin.raise.title` then has zero references outside the pack (the orphan set grows by exactly one, measured). Orphan keys are a different axis, uncovered today (dozens already exist), and are out of scope here; the A/B declares that leg GREEN instead of pretending it was rejected. The other measured boundary is JS-cleared containers: a `data-i18n*` attribute inside a container whose contents `app.js` replaces wholesale is dead the same way. Measured at 0 sites today, so not gated -- the lexical approximation (id -> `innerHTML =`) is weaker than this rule and can wait. Cache-bust `ui/js/i18n.js?v=20260915-4` -> `-5`; the `i18n_pack` positive-control constants are recalibrated to the values the gates reported (static attrs 333 -> 332 / 308 -> 307, key counts 812 -> 811 each), not hand-computed. Tests: `cargo test` 292 -> 294; `cargo fmt --check` clean; clippy reports only the pre-existing `protocol.rs:662`. A/B (`tmp/c2151_gate_ab.py`) mutates the tree in place, restores it byte-exactly (md5 checked) and declares every leg's expectation: FIX green; unfixed red with exactly the two axis sites (`index.html:79`, `index.html:617`); half-fix (one site only) red; cosmetic escape (keep the nesting, re-materialise the hint from the pack value) red.
|
Self-review (committer, Verified on this head ( The defect is real and the two existing gates are blind to it — not a styling
Direction, not just the shape. The A/B harness mutates in place and restores One leg is GREEN on purpose, and I am flagging it rather than burying it. Second declared boundary: JS-cleared containers. Same defect arriving via Test evidence (this head):
Counts are read from the gates, not hand-computed (static attrs 332 / 307, Merging this as a squash merge per the task's |
Summary
applyStatic()inui/js/i18n.jswalks every[data-i18n]element and doesels[i].innerHTML = t(key). An element that already carries a textdata-i18ntherefore cannot also rely on language hooks on its children: theancestor's step replaces the whole inner HTML, detaching the child element (and
its own
data-i18n*attribute) from the document; the loop then assigns intothat detached node — no exception, no effect. jsdom measures
isConnected === false.Two sites did this, and both existing gates are structurally blind to it:
every_static_i18n_attribute_resolvesonly asks whether the key exists in bothpacks (it does), and a "find the key as text" dead-key scan sees the key literal
sitting right there in
index.html(so it counts as used). An attribute that cannever take effect is neither.
index.html:617— raise-request card<h3 data-i18n="admin.raise.title">加额申请 <span data-i18n="admin.raise.sub">…</span></h3>— both pack values are plain text, so the child span was permanently destroyed: the hint "(member applies → admin approves / rejects)" did not render in either language, and it never recovers (app.jsonly writes#raise-requests, never redraws that heading).index.html:79— login footer<p data-i18n="login.foot">…<a id="reg-link" data-i18n="login.register">…</a>…</p>—login.foot's value embeds the same markup (includingid="reg-link"), so text and click (a document-delegated listener keyed ont.id) were never broken; but the static hook was dead, makinglogin.registeran orphan key fed only by it.Changes
ui/index.html:617: the two strings become sibling carriers (<h3><span data-i18n="admin.raise.title">加额申请</span> <span … data-i18n="admin.raise.sub">…</span></h3>) — the repository already uses this shape in.wallet-hint, so the heading keeps its own localization and the hint becomes reachable.:79: removed the deaddata-i18n="login.register"attribute (the pack value already carries the<a id="reg-link">).i18n.js?v=20260915-4→-5.ui/js/i18n.js: removedlogin.registerfrom both packs (its only hook is gone).src/i18n_pack.rsscan_i18n_nesting+ gateno_data_i18n_attribute_nests_inside_a_data_i18n_element: a tag-stack walk overindex.htmlthat fails when anydata-i18n*attribute sits inside an ancestor carrying a textdata-i18n. HTML comments are stripped first (their markup is not structure), void/self-closing elements never enter the stack, and the tag-end scan skips quoted>so an attribute value cannot desynchronise the parser. It also reports three positive controls (start tags / text carriers / unclosed stack at EOF) so "0 violations" cannot be misread as "the scanner saw nothing". Zero exemption list.nested_i18n_detector_detects_injected_defects: a negative control built from the real pre-fix markup, plus positive controls that the legal shapes are not reported — an ancestor holding only an attribute-kind hook (data-i18n-title/-labelwrite one attribute viasetAttributeand leave child markup alone, which is whyselect#tx-range's five<option data-i18n="tx.range.*">anddiv#help-panel's<strong>are fine), that sibling carriers are fine, and that the reported line number is the real one.ui/README.md: the convention (sibling carriers; a pack value that embeds its own markup is a second legitimate shape and is exactly whylogin.footsurvives), the measured boundary, and the gate's scope.Scope, stated rather than papered over
The gate pins the nesting axis only, and the diff says so in the README:
m_drop_parentis GREEN by construction. Deleting the heading's owndata-i18ngenuinely removes the nesting — but it trades a never-applied attribute for a lost localization:admin.raise.titlethen has zero references outside the pack (the orphan set grows by exactly one, measured). Orphan keys are a different axis, uncovered today (dozens already exist), and are out of scope here. The A/B declares that leg GREEN instead of pretending it was rejected.data-i18n*attribute inside a container whose contentsapp.jsreplaces wholesale is dead too. Measured at 0 sites today, so not gated — the lexical approximation (id →innerHTML =) is weaker than this rule and can wait.Related Issue
None — found by the task's own recon (content-ownership survey of
applyStatic), no issue filed.Tests
cargo test— 292 → 294 passed, 0 failedcargo fmt --check— cleancargo clippy --all-targets— only the pre-existingprotocol.rs:662warningA/B (
tmp/c2151_gate_ab.py: mutate in place, run the axis test, restore byte-exactly with md5 verification; every leg declares its expectation and the harness exits non-zero on any mismatch):v0_unfixed(pre-PR markup, both sites)index.html:79,index.html:617m1_only79(half-fix)index.html:79m_escape(keep the nesting, re-materialise the hint from the pack value)index.html:79,index.html:617m_drop_parent(alternative resolution, different axis)admin.raise.title)All five legs as declared, exit 0, tree restored to the committed md5s. A Python tag-stack prototype over the real file found the same exactly-2 sites, and an independent cross-check found 0 containers cleared by
app.js'sinnerHTML =that still declare childdata-i18n*— so the nesting rule is a complete characterization of dead i18n attributes today.Checklist
fix/…)