Skip to content

fix(ui): own static i18n content at the innermost element that carries it - #264

Merged
argszero merged 1 commit into
mainfrom
fix/i18n-content-ownership
Sep 15, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/i18n-content-ownership

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

applyStatic() in ui/js/i18n.js walks every [data-i18n] element and does
els[i].innerHTML = t(key). An 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.

Site The face it shows
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.js only 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 (including id="reg-link"), so text and click (a document-delegated listener keyed on t.id) were never broken; but the static hook was dead, making login.register an 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 dead data-i18n="login.register" attribute (the pack value already carries the <a id="reg-link">).
    • cache-bust i18n.js?v=20260915-4-5.
  • ui/js/i18n.js: removed login.register from both packs (its only hook is gone).
  • src/i18n_pack.rs
    • New scanner scan_i18n_nesting + gate no_data_i18n_attribute_nests_inside_a_data_i18n_element: a tag-stack walk over index.html that fails when any data-i18n* attribute sits inside an ancestor carrying 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". 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 / -label write one 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), that sibling carriers are fine, and that the reported line number is the real one.
    • Positive-control constants recalibrated to the values the gates reported (read, not hand-computed): static attrs 333 → 332 / 308 → 307, key counts 812 → 811 each.
  • 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 rather than papered over

The gate pins the nesting axis only, and the diff says so in the README:

  • m_drop_parent is GREEN by construction. Deleting the heading's own data-i18n 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.
  • JS-cleared containers are the same defect arriving another way: a data-i18n* attribute inside a container whose contents app.js replaces 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 test292 → 294 passed, 0 failed
  • cargo fmt --check — clean
  • New unit tests: the gate plus its negative/positive controls
  • cargo clippy --all-targets — only the pre-existing protocol.rs:662 warning

A/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):

Leg Declared Actual Violation sites reported
FIX (this branch) GREEN GREEN
v0_unfixed (pre-PR markup, both sites) RED RED index.html:79, index.html:617
m1_only79 (half-fix) RED RED index.html:79
m_escape (keep the nesting, re-materialise the hint from the pack value) RED RED index.html:79, index.html:617
m_drop_parent (alternative resolution, different axis) GREEN GREEN — (orphan set +1: 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's innerHTML = that still declare child data-i18n* — so the nesting rule is a complete characterization of dead i18n attributes today.

Checklist

  • Branch naming follows the convention (fix/…)
  • Commit message in Conventional Commits format
  • Single responsibility, minimal diff (4 files, +398/−9)

…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.
@argszero

Copy link
Copy Markdown
Owner Author

Self-review (committer, allow_self_merge: true)

Verified on this head (2fcf718), not on a paraphrase:

The defect is real and the two existing gates are blind to it — not a styling
nit. Confirmed the mechanism from applyStatic (innerHTML = t(key) on
[data-i18n]), reproduced with jsdom (isConnected === false for the child
carrier), and confirmed the fix is not merely cosmetic:

  • admin.raise.sub was a scanner false negative: its key resolves in both
    packs and its literal sits in index.html, so both the static-attribute gate
    and any text-based dead-key scan passed while the attribute could never apply.
  • the heading is redrawn by nothing (app.js writes #raise-requests only), so
    the missing hint never self-healed — a permanent, both-languages gap.

Direction, not just the shape. The A/B harness mutates in place and restores
byte-exactly (md5 checked before and after), and each leg declares what it should
do, so a mismatch is an exit-code failure rather than a printed line that can be
misread: FIX green; v0_unfixed red on exactly index.html:79 +
index.html:617; half-fix (m1_only79) still red; the cosmetic escape (keep the
nesting, re-materialise the hint from the pack value) red — that last one is what
rules out "make the text appear" shortcuts.

One leg is GREEN on purpose, and I am flagging it rather than burying it.
m_drop_parent (delete the heading's own data-i18n) also removes the nesting,
so this gate cannot reject it. What it actually does is move the defect to a
different axis: admin.raise.title then has zero references outside the pack —
measured, the orphan set grows by exactly one. Orphan keys are uncovered today
(dozens already exist) and are out of scope for this gate; the README says so in
the "射程只到『嵌套』这一条轴" paragraph. I did not strengthen the gate to catch
it, because doing so needs a key-reachability check, which is a separate (and
larger) axis — bundling it would be scope creep, and a lexical band-aid here
would risk false positives on the wrap-around forms.

Second declared boundary: JS-cleared containers. Same defect arriving via
app.js's innerHTML = on a container that holds language hooks. Measured at 0
today (tmp/c2151_cleared.py), so deliberately not gated — the id→innerHTML =
approximation is weaker than the tag-stack rule, and an unmotivated lexical gate
would be a liability. Recorded in the README.

Test evidence (this head):

  • cargo test — 294 passed / 0 failed (was 292)
  • cargo fmt --check — clean
  • cargo clippy --all-targets — only the pre-existing protocol.rs:662
  • CI test / fmt / clippypass (run 34940682275)

Counts are read from the gates, not hand-computed (static attrs 332 / 307,
key counts 811 / 811, T literals 542 / 433) — the amended constants are exactly
what the pre-existing positive controls reported after removing the one key.

Merging this as a squash merge per the task's allow_self_merge: true; the
four files (+398/−9) are one responsibility: make the innermost carrier own the
content, and pin that shape in CI.

@argszero
argszero merged commit ba486b2 into main Sep 15, 2026
1 check passed
@argszero
argszero deleted the fix/i18n-content-ownership branch September 15, 2026 07:17
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