blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git

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 }