Skip to content
C.W.K.
Stream
Lesson 09 of 09 · published

Bold Glued to Korean

~12 min · commonmark, korean, cjk, departures, renderer, v3

Level 0Cold Draft
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"The spec was written for languages that put a space after a word. Korean puts a particle there."

Asterisks That Stayed Asterisks

On 2026-09-24 Dad met it in a travel guide he was reading in Waystone, the family's travel app: a line like **공항 급행버스(€5)**는 rendered with its asterisks showing, and with two such phrases on one line the bold landed on the text between them. It was not a Rekindle bug in the usual sense. CommonMark decides whether a run of ** can open or close emphasis by looking at its neighbours (the "flanking" rules), and one of those rules says a closing run that comes right after punctuation must be followed by whitespace or punctuation. English almost always is. Korean attaches its particles directly to the word — (€5)**는 — so the closer is refused, the asterisks stay literal, and the parser goes looking for another partner. cmark-gfm, Apple's AttributedString(markdown:) and remark all behave the same way, and the frozen Tauri original had exactly the same flaw.

Decide the Pairs Before the Parse

Once a parser has paired the wrong delimiters, nothing after the parse can un-pair them. So the fix runs before it. The sibling kit's CjkStrong (a Swift twin of a TypeScript twin, the same algorithm line for line) scans the source, pairs exact ** runs inside one paragraph with the flanking rules relaxed where a neighbour is a CJK character, and swaps only the pairs that touch CJK for private-use markers (U+E000 to open, U+E001 to close). Each marker is one UTF-16 unit, exactly like each * it replaces, so every source range the editor computes stays valid. A pair that touches no CJK is left to the parser untouched; an unmatched marker reads back as the ** it was written as.

After the parse, each renderer resolves the markers into bold in its own terms. In Rekindle that is three places: the editor's presentation adds strong spans at the marker pairs, the styled-HTML renderer emits <strong>, and the chat renderer resolves them in its own node tree.

The Edges Are Where It Bites

The first version skipped fenced code and backtick spans, but not an indented code block — so a line indented four spaces after a blank line, such as x = **값**이, put private-use characters into a code sample. The same evening it was fixed in two layers: the kit's resolvers and the Rekindle consumers now pass code through untouched, and a pair that straddles code and the paragraph after it bolds nothing. A later kit change also skips link destinations, raw HTML tags and bare URLs. A pre-parse pass sees raw text, so it has to know every place where asterisks are not prose.

A Departure From the Spec, Not Just the Original

Every departure in lesson 5 bent the original app. This one bends the format: Rekindle now renders as bold something CommonMark says is not. The commit says it plainly — the change "deliberately diverges from the frozen original, which had the same flaw" — and Dad's ruling of 2026-09-24 covers every family renderer, not only this editor. Two honest consequences follow. The file stays byte-identical Markdown; only the rendering of Korean-glued bold differs, so another Markdown tool will still show those asterisks. And this ruled divergence is recorded in the app's kit-adoption notes rather than in the architecture's departures list — the very drift lesson 5's newest note points at.

Code

Why CommonMark refuses the closer, and what the pre-parse pass does·text
source     **공항 급행버스(€5)**는 바로 타
                            ^^
                            closing run: preceded by ')' (punctuation),
                            followed by '는' (a letter, no space)
CommonMark not right-flanking -> cannot close -> asterisks stay literal

CjkStrong.mark (before the parse)
  pair ** runs within the paragraph; relax flanking next to CJK
  swap ONLY CJK-touching pairs:  ** -> U+E000 U+E000  ...  ** -> U+E001 U+E001
  same length in UTF-16, so editor ranges stay exact

after the parse
  editor      strong span between the markers
  styled HTML <strong>…</strong>
  chat        bold node in its own tree
  unmatched   reads back as the ** it was
The editor's side: markers become a strong span, never inside code (sketch)·swift
// Before parsing, pair the CJK-touching ** runs.
let marked = CjkStrong.mark(source)        // same UTF-16 length as source
let tree = parseGFM(marked)

// After parsing, the presentation layer bolds between marker pairs,
// skipping any pair inside a code block or an inline code range.
for pair in cjkStrongPairs(in: marked) where !insideCode(pair, tree) {
  spans.append(.strong(range: pair.range))  // the range IS the source range
}

External links

Exercise

In any CommonMark-based editor, try these three lines and note which ones render bold: '**word**.', '**(word)**는', and '이건**(주의)**야'. Then explain, using the flanking rules, why each behaves the way it does. Finally, list every region of a Markdown file where a pre-parse pass must leave asterisks alone.
Hint
The first works: its closer follows a letter, so it can close whatever comes next. The second and third fail because a run that touches punctuation on one side needs whitespace or punctuation on the other, and a Korean particle or word is a letter. The opaque regions: fenced and indented code, inline code, link destinations, raw HTML tags, bare URLs.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.