Skip to content

Close CJK-punctuation-flanked bold in ja / ko / zh-Hans, and gate it in the built HTML - #201

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-174-cjk-bold-delimiter
Sep 3, 2026
Merged

Close CJK-punctuation-flanked bold in ja / ko / zh-Hans, and gate it in the built HTML#201
hotlong merged 2 commits into
mainfrom
claude/issue-174-cjk-bold-delimiter

Conversation

@hotlong

@hotlong hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes #174

A **bold** span whose delimiter touches CJK punctuation on the inside and a CJK letter on the outside is not a flanking delimiter run, so CommonMark emits the asterisks verbatim. Readers of the four CJK locales saw a literal ** mid-sentence. English almost never trips it because English puts a space where CJK puts a letter.

Two commits: the repairs, then the guard that keeps them repaired.

1. Population, re-measured at my own head (ruling 7)

Built from origin/main @ 4dd647c and counted structurally — parsing the emitted HTML and skipping <code>/<pre> rather than grepping:

locale built pages carrying a literal ** in prose
ja 6
ko 6
zh-Hans 6
zh-Hant 6 (generated twins of the zh-Hans six)
total 24

24 pages, from 37 inert delimiter runs across 18 authored files. The card's 24 at 3b6d563 was a raw grep count; mine is the structural one, and the composition differs (the card had ja 5 / ko 5 / zh-Hans 7 / zh-Hant 7). The two numbers agreeing is a coincidence — a raw grep at 4dd647c returns 27, three of which are correct and must stay green:

  • en/blog/how-to-point-your-agent-at-objectstack/** quoted inside a code sample
  • zh-Hans + zh-Hant objectos-agent-permission-boundaries/<code>138****5678</code>, a masked phone number

Those three are exactly why ruling 3 asks for a structural exclusion instead of a regex.

I cross-checked the source-level analysis against the built HTML hit-for-hit: all 24 pages agree on their hit counts, and neither side lists a page the other does not. That is what makes the worklist exact rather than a best effort.

2. The repairs — the delimiter moves, the emphasis never goes (ruling 4)

Three shapes, applied consistently. In every case the emphasized phrase stays a complete unit:

blocked by repair example
sentence punctuation at the end of the span move the delimiter inside it …しか呼べない。**モ to …しか呼べない**。モ
a trailing parenthetical gloss move the delimiter left, past the gloss **用語(gloss)**は to **用語**(gloss)は
the span's own quotation marks move both delimiters inside the quotes **"引用"**の to "**引用**"の

Where the parenthesis sits mid-phrase rather than trailing, the delimiter moves right instead — the card's own canonical repair (**开箱即用联邦(turn-key federation)体验**) is that case. Same principle, opposite direction: keep the emphasized phrase whole.

The "move left past a trailing gloss" choice also lands where the English originals already put the bold, which is a useful independent check on it:

  • A **detective** control tells you… / A **preventive** control stops…
  • the **confused deputy** / **least privilege.**

No span was dropped, and no sentence was reworded.

3. zh-Hant came free, and I proved it rather than assuming (ruling 5)

Only index.{ja,ko,zh-Hans}.mdx were edited — 18 files. The 6 index.zh-Hant.mdx twins in the diff are output of pnpm gen:zh-hant, which pnpm build runs first. Their diffs mirror the zh-Hans edits with Traditional characters intact.

git status --porcelain is empty after pnpm build at the final commit, so regeneration is idempotent and nothing was hand-edited.

4. The guard (rulings 1, 2, 3)

scripts/content-lint.mjs --dist fails when a blog page renders a literal ** outside <code>/<pre>.

  • It reads the built HTML, not the MDX. The markdown is valid; the defect exists only in rendered output. An MDX-level check would have to re-implement CommonMark's flanking rule tolerantly, and a tolerant re-parse of the thing being gated is how a gate stops gating.
  • The exclusion is structural. The emitted HTML is parsed and <code>/<pre> skipped by element, not by a regex hoping to spot a fence. <script>/<style> are skipped too — their bodies are code, where ** is ordinary and no reader sees it.
  • A run that measured nothing fails. No dist/, or a dist/ with no blog pages, prints the reason and exits non-zero. Verified: from a directory with no dist/ it exits 1 with content lint --dist found no built blog pages under dist/. Without that, the gate would go quietly green the day the build layout moves.
  • Character references for * are resolved before matching, so the rule cannot be satisfied by an escape that changes nothing a reader sees.

package.json moves here because the guard has to run after astro buildpnpm build now ends with it. CI already runs pnpm build, so no workflow change was needed.

5. Ablation — the gate shown failing (ruling 6)

One CJK-punctuation-flanked bold span injected into content/blog/business-app-in-16k-tokens/index.ja.mdx, both legs rebuilt, mutation confirmed on disk before each measurement, restore proven by blob hash.

Mutation leg — anchors on disk went good 1 to 0, bad 0 to 1; blob 4da5e6d to 3c6d5f5; rebuilt; gate red:

✗ ja/blog/business-app-in-16k-tokens/index.html: 2 literal ** in rendered prose
✗ content lint --dist failed (1 page of 474 render a literal **)
build+gate exit: 1

Restore leg — blob back to 4da5e6d, matching the HEAD blob (a non-empty hash, compared explicitly; an empty one is treated as failure, not as "nothing to compare"); git diff HEAD empty; rebuilt again so no mutated render survives in dist/ to poison later readings; gate green:

✓ content lint --dist passed (474 built blog pages checked)
build+gate exit: 0

The restore script uses absolute paths in its trap and re-derives the repo root, so a mid-run kill cannot leave the tree mutated or resolve the checkout against the wrong directory.

6. Browser pass, one page per affected locale (ruling 8)

Served dist/ and drove Chromium over one repaired page in each locale. The check is not "no asterisks": it requires a real <strong> carrying the repaired text and a computed font-weight that is actually bold, because an element that exists but renders at weight 400 would satisfy a DOM check while showing the reader nothing.

locale page <strong> computed weight literal ** in visible text
ja business-app-in-16k-tokens コンテキストサイズのソフトウェア 650 no
ko business-app-in-16k-tokens 컨텍스트 크기의 소프트웨어 650 no
zh-Hans vibe-coding-technical-debt-2026 理解债 650 no
zh-Hant vibe-coding-technical-debt-2026 理解債 650 no

4/4 pass. Screenshots of the repaired sentences rendering bold are described in the report comment on #174.

Gates

All at f41147f, tree clean, exit codes captured before any pipe:

gate verdict line
pnpm content:lint ✓ content lint passed (335 files, 44 glossary terms checked)
pnpm content:lint --published ✓ content lint passed (335 files, 44 glossary terms checked)
pnpm check - 0 errors
pnpm build 867 page(s) built in 60.63s + ✓ content lint --dist passed (474 built blog pages checked)
pnpm seo:smoke SEO smoke test passed (866 HTML pages checked)

One note for the reviewer

The gate is scoped to blog pages, as ruling 3 states. I also ran the same structural scan across every built page and it is currently at zero, so widening the scope site-wide would be free today. I did not widen it on my own: glossary and marketing pages are authored in .ts rather than markdown, so whether a literal ** there is a defect or intended text is a call for the seat, not for me.


Generated by Claude Code

A `**bold**` span whose delimiter sits against CJK punctuation on the
inside and a CJK letter on the outside is not a flanking delimiter run,
so CommonMark emits the asterisks verbatim. Readers of the four CJK
locales saw literal `**` mid-sentence on 24 built pages.

Repaired by moving the delimiter, never by dropping the emphasis, in
three shapes:

  * closing delimiter blocked by sentence punctuation inside the span
    -> move it inside the punctuation:  `...ない。**モ` -> `...ない**。モ`
  * closing delimiter blocked by a trailing parenthetical gloss
    -> move it left, past the gloss:    `**用語(gloss)**は` -> `**用語**(gloss)は`
    (this also matches the English originals, e.g. `A **detective** control`)
  * the span is a quoted phrase and its quote blocks a delimiter
    -> move both delimiters inside the quotes: `**"..."**` -> `"**...**"`

Where the parenthesis is mid-phrase rather than trailing, the delimiter
moves right instead so the emphasized phrase stays a complete unit.

zh-Hant is generated: only the zh-Hans sources were edited and
`pnpm gen:zh-hant` (run by `pnpm build`) carried the six twins.

Measured at 4dd647c: 24 built pages carried a literal `**` outside
`<code>`/`<pre>` (ja 6, ko 6, zh-Hans 6, zh-Hant 6), from 37 inert
delimiter runs across 18 authored files. After this change: 0.

Part of #174

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
Nothing in the chain saw the defect the previous commit repaired: the
markdown is valid, so this script's MDX pass, `astro check`, `astro
build` and `seo:smoke` all pass on a post that renders `**` to the
reader. Without a gate the next translated post reintroduces it silently,
and only a reader of the CJK locales would ever notice.

`content-lint.mjs --dist` reads the BUILT HTML and fails when a blog page
carries a literal `**` outside `<code>`/`<pre>`. Two properties it is
built on:

  * it asserts over `dist/`, after `astro build`, because the defect
    exists only in rendered output — an MDX-level check would have to
    re-implement CommonMark's flanking rule tolerantly, and a tolerant
    re-parse of the thing being gated is how a gate stops gating;
  * the `<code>`/`<pre>` exclusion is structural, not a regex that hopes.
    The emitted HTML is parsed and those elements skipped. Real pages
    depend on it: `<code>138****5678</code>` is a masked phone number and
    an English post quotes `**` in a snippet — both correct, both green.

A run that measured nothing fails rather than passes: no `dist/`, or a
`dist/` with no blog pages, reports the reason and exits non-zero, so the
gate cannot go quietly green if the build layout moves.

Wired into `pnpm build` after `astro build`, which is why package.json
moves here too — CI already runs `pnpm build`, so no workflow change.

Part of #174

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — with the seat's own ablation of the guard, which is the only thing that proves a guard.

The population is gone from the built output. My raw grep over built blog HTML at the union: 3 literal ** left, down from 24. All three are the ones that must stay:

en/blog/how-to-point-your-agent-at-objectstack/       …@objectstack/spec/src/**/*.zod.ts
zh-Hans/blog/objectos-agent-permission-boundaries/    …会把 138****5678 …
zh-Hant/blog/objectos-agent-permission-boundaries/    …會把 138****5678 …

That first one is a better argument for ruling 3 than the ruling was: it is a glob pattern in a code sample. A regex guard would have gone red on src/**/*.zod.ts and on a masked phone number — three false positives on a corpus this size, in a gate meant to be trusted. Parsing the HTML and skipping <code>/<pre> by element is what makes the rule survivable.

Ablation, run by the seat rather than read off the report. Injected one CJK-punctuation-flanked bold span into content/blog/ai-expense-audit/index.ja.mdx, mutation confirmed on disk before measuring, restore proven by blob hash (d87e7ec… == HEAD), tree clean:

leg expected actual
span injected pnpm build 1 1✗ ja/blog/ai-expense-audit/index.html: 2 literal ** in rendered prose · ✗ content lint --dist failed (1 page of 474 render a literal **)
restored pnpm build 0 0✓ content lint --dist passed (474 built blog pages checked)

And the failure message is the best thing in this PR. It does not just say what is wrong — it states the CommonMark rule that caused it, explains why CJK prose trips it where English does not, and shows the repair:

A bold span did not close. CommonMark closes `**` only on a flanking delimiter run:
a closing run preceded by punctuation must also be followed by whitespace or
punctuation… CJK prose puts a letter where English puts a space, so a delimiter
sitting against ()「」。:" does not pair.
  **用語(gloss)**は…   ->   **用語**(gloss)は…      (past a trailing gloss)

An author who hits this at 2am will fix it in thirty seconds and understand it afterwards. That is the difference between a gate that gets obeyed and a gate that gets worked around.

Union with current main (4dd647c + #199 + this branch), all five gates through the shared lock, with the new step running inside pnpm build where it belongs (… && astro build && node scripts/content-lint.mjs --dist): content:lint 0, content:lint --published 0, check 0, build 0 (867 page(s) built, ✓ content lint --dist passed (474 built blog pages checked)), seo:smoke 0, tree clean.

On the package.json line — it is outside the file surface I gave you, and it is correct anyway: the guard reads dist/, so it has to run after astro build, and wiring it into the build chain rather than leaving it as a separate opt-in command is the difference between a check and a gate. Flagging that I noticed, not that it was wrong.

Three more things worth naming:

  • You re-measured and found the card's number was right by coincidence. 24 both times, but different composition (ja 5/ko 5/zh-Hans 7/zh-Hant 7 versus 6/6/6/6), because the card's was a raw grep and yours is structural. Reporting the coincidence rather than quietly agreeing with the card is exactly right.
  • Cross-checking source-level against built HTML hit-for-hit — all 24 pages agreeing on counts, neither side listing a page the other omits — is what makes the worklist exact rather than best-effort.
  • The repair shapes land where the English originals already put the bold (A **detective** control, **least privilege.**). That is an independent check on an editorial choice, and finding one was better than asserting the choice was natural.

Landing on main. #162 and #179 are unblocked.


Generated by Claude Code

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.

Bold that closes against CJK punctuation renders as literal ** — 24 built pages across ja / ko / zh-Hans / zh-Hant

2 participants