src/lib/satteri-korean.mjs (8010B)
1 /**
2 * satteri-korean — two CommonMark inline rules that misfire on Korean prose.
3 *
4 * Both are the same kind of bug: a rule written for English, applied to a script whose
5 * word boundaries and punctuation habits are different, producing markup the author never
6 * asked for. Both were live on the built site, not hypothetical. Neither is fixable with a
7 * parser flag — Sätteri's `gfm` option exposes footnotes and nothing else — so they are
8 * repaired on the mdast, from the source text, before anything renders.
9 *
10 * ── 1. A single `~` is a range, not a deletion ───────────────────────────────────────
11 * GFM strikethrough pairs single tildes as well as double ones, and Korean writes ranges
12 * with a tilde constantly: `그림 3~4`, `5~8`, `25~50바이트`, `1Gbps ~ 100Gbps`. Two on one
13 * line are a matched pair, so everything between them is struck through. Without this
14 * plugin, the paragraph of content/writing/Methodology-for-Writing-a-Research-Paper_KR.md
15 * that names figures 1 to 8 renders as
16 *
17 * 그림 3<del>4는 … 그림 5</del>8에는 결과와 분석이 담겨 있을 수 잇다.
18 *
19 * one sentence of the author's prose crossed out because it named two figure ranges.
20 *
21 * The rule installed here is: **`~~` strikes through, `~` is a literal tilde.** That is the
22 * rule an author already carries — `~~` is what GitHub's own documentation shows and what
23 * anyone reaching for strikethrough types — and it is decided on the source text, not
24 * guessed from the content: the delimiter run is read straight out of ctx.source, so a
25 * range and a deletion are told apart by what was written rather than by what it looks
26 * like.
27 *
28 * ── 2. Emphasis that closes on a quote mark ──────────────────────────────────────────
29 * CommonMark closes `**…**` only on a right-flanking delimiter run, and a run preceded by
30 * punctuation is right-flanking only if what FOLLOWS it is whitespace or punctuation. In
31 * English that rule earns its keep (it is what stops `a**b**c` from bolding mid-word). In
32 * Korean it fires constantly, because a quoted phrase is followed immediately by a
33 * particle with no space: `**‘직관적으로 옳다’**고`, `**TSO (TCP Segmentation Offload)**와`.
34 * The closing run is preceded by `’`, followed by `고` — a letter, so not right-flanking —
35 * so it cannot close, and the asterisks ship as literal text in the middle of the prose.
36 *
37 * Without this plugin the corpus has **12 such runs in 3 posts**, and every one of them has
38 * the identical shape — content ending in Unicode punctuation (Pe or Pf, a closing bracket
39 * or a closing quote) with a CJK letter (Lo) immediately after the closing run. That is not
40 * a coincidence, it is the flanking rule's blind spot, and it is what the `cjk-friendly`
41 * CommonMark proposals exist to close upstream.
42 *
43 * So the repair is written as that exact shape rather than as a general "re-emphasise
44 * stray asterisks" pass. It fires only where a CJK letter sits against the delimiter, i.e.
45 * only where the flanking rule was answering a question about a script it does not model.
46 * A literal pair with whitespace on both sides of the content, like `* port *`, is left
47 * alone, which is CommonMark being right.
48 *
49 * Known limits, stated rather than hidden. Both are cases where the pair never reaches this
50 * plugin intact, and both need a parser, not a visitor:
51 * - The repair reads one text node, so it cannot rejoin a run containing other inline
52 * markup: `**‘가’ [링크](x)**고` is three nodes and stays literal.
53 * - Two such runs on ONE line can be mispaired by the parser before anything gets here.
54 * Measured on `앞**‘강조’**뒤 그리고 앞**‘강조’** 뒤.`: the first `**` cannot open and the
55 * second can, so the parser bolds from the second to the third and leaves the outer two
56 * asterisks stranded. One run on a line — the normal way anyone writes — leaves the
57 * whole pair literal, and that is repaired. All 12 in the corpus are repaired,
58 * including the one line that holds two closing-shape runs, a `**` and a `*`.
59 */
60
61 /**
62 * CJK letters, for the flanking question only: Hangul jamo and syllables, CJK ideographs
63 * and their extensions, kana, and the halfwidth/fullwidth forms. This is deliberately
64 * wider than the Hangul-only class in src/lib/hangul.ts — that one measures how Korean a
65 * document is, this one asks whether a character is the kind of letter CommonMark's
66 * flanking rule was not written for, and Japanese and Chinese have the same problem.
67 */
68 const CJK = /[ᄀ-ᇿ⺀-鿿ꥠ-가-힣ힰ-豈-︰-﹏-⦆¢-₩]/;
69
70 /** Unicode punctuation — the character class that broke the flanking test. */
71 const PUNCT = /\p{P}/u;
72
73 /**
74 * A `*` or `**` pair with no whitespace just inside either delimiter and no `*` in the
75 * content. Those two constraints are CommonMark's own (a run cannot open on trailing
76 * whitespace or close on leading whitespace); repeating them here is what keeps the pass
77 * from touching `2 * 3 * 4` or `* port *`.
78 */
79 const PAIR = /(\*\*|\*)(?![\s*])([^*]*[^\s*])\1/g;
80
81 export default function satteriKorean() {
82 return {
83 name: 'korean',
84 // Read by the `delete` visitor only; the emphasis repair works off the node's own text.
85 options: { position: true },
86
87 delete(node, ctx) {
88 const at = node.position;
89 // Defensive: a `delete` built by another plugin carries no position, and a node whose
90 // delimiter cannot be read is left exactly as the parser made it.
91 if (!at) return;
92 if (ctx.source[at.start.offset + 1] === '~') return; // ~~ … ~~ — a real deletion
93
94 /*
95 * Unwrap in place: the node's own children are handed back as replacement content,
96 * between the two literal tildes that were the delimiters. Passing the existing
97 * children rather than copies is what keeps inner markup intact — `~a **b** `c`~`
98 * comes back as `~a <strong>b</strong> <code>c</code>~`, which a deep copy could not
99 * do, because a child's own children resolve lazily and are not present to copy.
100 *
101 * Not `{ raw: … }`: a raw string is re-parsed as a document of its own, and measured,
102 * that splices a block-level <p> into the middle of the paragraph.
103 */
104 ctx.replaceNode(node, [
105 { type: 'text', value: '~' },
106 ...node.children,
107 { type: 'text', value: '~' },
108 ]);
109 },
110
111 text(node, ctx) {
112 const src = node.value;
113 if (!src.includes('*')) return;
114
115 const out = [];
116 let cut = 0;
117 PAIR.lastIndex = 0;
118 for (let m = PAIR.exec(src); m; m = PAIR.exec(src)) {
119 const [whole, run, content] = m;
120 const before = src[m.index - 1] ?? '';
121 const after = src[m.index + whole.length] ?? '';
122
123 /*
124 * Only the two shapes the flanking rule gets wrong, one per side:
125 * closing …옳다’**고 content ends in punctuation, a CJK letter follows
126 * opening 앞**‘강조’… content starts with punctuation, a CJK letter precedes
127 * Anything else that survived as literal asterisks survived on purpose.
128 */
129 const closingCase = PUNCT.test(content.at(-1)) && CJK.test(after);
130 const openingCase = PUNCT.test(content[0]) && CJK.test(before);
131 if (!closingCase && !openingCase) continue;
132
133 if (m.index > cut) out.push({ type: 'text', value: src.slice(cut, m.index) });
134 out.push({
135 type: run === '**' ? 'strong' : 'emphasis',
136 children: [{ type: 'text', value: content }],
137 });
138 cut = m.index + whole.length;
139 }
140
141 if (!out.length) return;
142 if (cut < src.length) out.push({ type: 'text', value: src.slice(cut) });
143 ctx.replaceNode(node, out);
144 },
145 };
146 }