From eca3685138f693c00afccb30f245bd0f64e3d0c7 Mon Sep 17 00:00:00 2001 From: Minjun Kim Date: Fri, 2 Oct 2026 23:54:23 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(npm):=20lezer=20=ED=99=95=EC=9E=A5=20?= =?UTF-8?q?=E2=80=94=20@lezer/markdown=20=EC=9D=B4=20mdwire=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=EC=9C=BC=EB=A1=9C=20=EA=B0=95=EC=A1=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=BD=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mdwire 를 거치지 않고 `@lezer/markdown` · CodeMirror 로 마크다운을 그리는 화면에서 `**설정(config)**을` 의 별표가 그대로 보인다. 코어 `inline.rs` 의 읽기 규칙을 lezer 인라인 파서로 옮긴 확장을 `./lezer` 서브패스로 낸다 (mdwire-16 2단계). WASM 없음, `@lezer/markdown` 은 선택 peer. - lezer 의 기본 구분자 해석은 내장 구분자에만 크기·"3의 배수" 규칙을 적용해 못 쓴다. 확장용 API (`findOpeningDelimiter` · `takeContent`)로 닫는 자리에서 바로 짝을 맺는다 — 코어 규칙 1 과 같은 모양 - 옮긴 규칙: 닫기(앞이 공백 아님) · 열기(`can_open`, `①` 은 글자 아님) · 먼저 연 쪽이 짐 · `_` 단어 안 · `***` 쪼개기 · 막힌 여는 마커의 거울 짝(수식 제외) · `~~`(GFM 노드가 있을 때만) - 코어와 다른 것 셋(의도): 짝 잃은 마커는 글자로, 블록 끝에서 닫지 않음, `****` 이상은 글자 - 스모크가 설치한 패키지로 코어의 텔레그램 출력과 17가지를 대조한다. 실제 문단 6,965개 대조에서 규칙 차이는 위 셋뿐 Co-Authored-By: Claude Fable 5.1 --- README.ko.md | 3 +- README.md | 4 +- SPEC.md | 5 + crates/mdwire-wasm/js/lezer.d.ts | 13 ++ crates/mdwire-wasm/js/lezer.js | 219 +++++++++++++++++++++++++++++++ scripts/build-npm.sh | 11 +- scripts/smoke-lezer.mjs | 69 ++++++++++ scripts/smoke.sh | 6 +- scripts/smoke.ts | 6 + site/public/llms.txt | 2 +- site/src/pages/docs/npm.mdx | 18 +++ site/src/pages/ko/docs/npm.mdx | 18 +++ skills/mdwire/SKILL.md | 3 + 13 files changed, 368 insertions(+), 9 deletions(-) create mode 100644 crates/mdwire-wasm/js/lezer.d.ts create mode 100644 crates/mdwire-wasm/js/lezer.js create mode 100644 scripts/smoke-lezer.mjs diff --git a/README.ko.md b/README.ko.md index 6ebc832..6bc418e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -89,7 +89,8 @@ await append(t.finish()); React 에서는 `@minjun0219/mdwire/react` 가 `createElement` 로 요소를 만들며 `innerHTML` 을 쓰지 않습니다. 이스케이프, 허용 태그, 링크 스킴은 코어의 `html` 채널 한 곳에서 정하고, 태그마다 어떤 컴포넌트로 렌더링할지는 사용하는 쪽이 정합니다. 다른 프레임워크에서는 `@minjun0219/mdwire/events` 로 같은 -출력을 `open` / `text` / `close` 이벤트 목록으로 받을 수 있습니다. +출력을 `open` / `text` / `close` 이벤트 목록으로 받을 수 있습니다. `@lezer/markdown` 이나 CodeMirror 로 마크다운을 +그리는 화면은 `@minjun0219/mdwire/lezer` 로 그 파서가 mdwire 와 같은 규칙으로 강조를 읽게 할 수 있습니다. ```jsx import { Markdown, useMarkdownStream } from "@minjun0219/mdwire/react"; diff --git a/README.md b/README.md index 5964e1a..36d2398 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,9 @@ await append(t.finish()); In React, `@minjun0219/mdwire/react` builds elements with `createElement` — no `innerHTML`. Escaping, the tag set and link schemes are decided once, in the core's `html` channel; you choose which component draws each tag. `@minjun0219/mdwire/events` gives the -same output as an `open` / `text` / `close` event list for other frameworks. +same output as an `open` / `text` / `close` event list for other frameworks. A screen that draws +Markdown with `@lezer/markdown` or CodeMirror can use `@minjun0219/mdwire/lezer` instead, which makes +that parser read emphasis the way mdwire does. ```jsx import { Markdown, useMarkdownStream } from "@minjun0219/mdwire/react"; diff --git a/SPEC.md b/SPEC.md index f37d633..a07587e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -87,6 +87,11 @@ flanking 규칙대로 닫는 `**` 앞이 구두점이고 뒤가 글자면 닫지 닫는 쪽 뒤에 한글이 온다. 모델에게 수식을 백틱으로 감싸 달라고 부탁하지 않아도 되게 여기서 가른다. 출력은 닫는 쪽과 같은 장치를 탄다 — GitHub ``, 슬랙은 여는 마커 뒤 · 닫는 마커 앞에 U+2060. 슬랙 · 노션에서 굵게로 그리는 것을 실측했다. +**lezer 로 그리는 화면도 같은 자리에서 깨진다**(CodeMirror · `@lezer/markdown`). 읽기 규칙을 그 파서의 확장으로 +떼어 냈다(2026-10-02, npm `./lezer` 의 `koreanEmphasis`, `crates/mdwire-wasm/js/lezer.js`). 코어와 같은 답을 내는 +것이 정의이고, 스모크가 코어의 텔레그램 출력과 대조한다. 다른 것은 셋 — 짝 잃은 마커는 버리지 않고 글자로 두고, +블록 끝에서 닫아 주지 않으며, `****` 이상은 글자다. 실제 문단 6,965개 대조에서 규칙 차이는 이 셋뿐이었다. + **슬랙 `markdown_text` 도 같은 자리에서 깨진다**(실측 2026-10-01, `chat.postMessage`). 태그를 못 쓰니 그 짝만 못 읽히는 쪽 마커 안쪽에 U+2060(워드 조이너)을 끼운다. 닫는 마커 앞 글자가 공백도 구두점도 아니게 되어 닫힌다. 보이지 않고, U+200B 와 달리 그 자리에서 diff --git a/crates/mdwire-wasm/js/lezer.d.ts b/crates/mdwire-wasm/js/lezer.d.ts new file mode 100644 index 0000000..4ffd57b --- /dev/null +++ b/crates/mdwire-wasm/js/lezer.d.ts @@ -0,0 +1,13 @@ +import type { MarkdownConfig } from "@lezer/markdown"; + +/** + * A `@lezer/markdown` extension that reads emphasis the way mdwire does: `**설정(config)**을` — bold followed + * directly by a Korean particle — closes instead of leaving the asterisks as text. It replaces the built-in + * `Emphasis` parser and GFM's `Strikethrough` parser by name, so put it after them: + * `parser.configure([GFM, koreanEmphasis])`; for CodeMirror, + * `markdown({ base: markdownLanguage, extensions: [koreanEmphasis] })`. + * + * Markers that pair become `StrongEmphasis` / `Emphasis` / `Strikethrough` nodes; markers that do not pair stay as + * text. The rules are documented at https://mdwire.minjun.dev/docs/emphasis/. + */ +export declare const koreanEmphasis: MarkdownConfig; diff --git a/crates/mdwire-wasm/js/lezer.js b/crates/mdwire-wasm/js/lezer.js new file mode 100644 index 0000000..ebdd676 --- /dev/null +++ b/crates/mdwire-wasm/js/lezer.js @@ -0,0 +1,219 @@ +// `@lezer/markdown` 확장 — 강조·취소선을 mdwire 의 규칙으로 읽는다. +// +// CommonMark 는 닫는 마커 앞이 구두점이고 뒤가 글자면 닫지 않는다. 한국어는 조사가 붙어서 +// `**설정(config)**을` 이 그 자리에 정면으로 걸리고, lezer 로 그리는 화면(CodeMirror · 미리보기)에 +// 별표가 그대로 보인다. 이 확장은 코어 `inline.rs` 의 읽기 규칙을 lezer 의 인라인 파서로 옮긴 것이다 +// (사이트 문서 "조사 앞 강조"). 코어가 바뀌면 여기도 같이 바꾼다 — 코어와 같은 답을 내는 것이 정의다. +// +// lezer 의 기본 구분자 해석(`resolveMarkers`)은 내장 구분자 객체에만 굵게·기울임 크기와 "3의 배수" +// 규칙을 적용해서 쓸 수 없다. 대신 확장용 API(`findOpeningDelimiter` · `takeContent`)로 **닫는 마커가 +// 오는 자리에서 바로 짝을 맺는다** — 코어의 규칙 1("같은 종류가 열려 있고 앞이 공백이 아니면 닫는다")과 +// 같은 모양이다. 짝을 못 맺은 마커는 lezer 가 글자로 둔다. 코어가 짝 잃은 `**` 를 버리는 자리에서는 +// 이 확장은 별표를 그대로 보인다 — 범위가 뒤집히지는 않는다. + +/** @typedef {import("@lezer/markdown").InlineContext} InlineContext */ +/** @typedef {import("@lezer/markdown").DelimiterType} DelimiterType */ + +// 종류마다 구분자 타입 하나. `resolve` 를 두지 않아 lezer 가 저절로 짝짓지 않는다 — 짝은 여기서 맺는다. +// 막힌 여는 마커(`값**(합계)**를` 의 첫 `**`)는 따로 둔다 — 보통 닫는 마커는 그걸 보지 못하고, 거울 +// 모양의 닫는 마커만 짝이 된다. +const STRONG = { mark: "EmphasisMark" }; +const EM = { mark: "EmphasisMark" }; +const STRIKE = { mark: "StrikethroughMark" }; +const HEMMED_STRONG = { mark: "EmphasisMark" }; +const HEMMED_EM = { mark: "EmphasisMark" }; +const HEMMED_STRIKE = { mark: "StrikethroughMark" }; + +const KINDS = { + strong: { type: STRONG, hemmed: HEMMED_STRONG, node: "StrongEmphasis", mark: "EmphasisMark" }, + em: { type: EM, hemmed: HEMMED_EM, node: "Emphasis", mark: "EmphasisMark" }, + strike: { type: STRIKE, hemmed: HEMMED_STRIKE, node: "Strikethrough", mark: "StrikethroughMark" }, +}; + +// 코어 `is_punct` 와 같다 — ASCII 구두점과 LLM 이 쓰는 CJK 구두점. +const CJK_PUNCT = ",。、!?;:·…—~「」『』()【】《》“”‘’"; +const ASCII_PUNCT = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"; +const LETTER = /\p{L}/u; +const ALNUM = /[\p{L}\p{N}]/u; +const SPACE = /\s/u; + +/** @param {number} cp */ +function isWs(cp) { + return cp >= 0 && SPACE.test(String.fromCodePoint(cp)); +} +/** 코어 `is_word_char` — 알파벳(한글 포함)과 ASCII 숫자. `①` 같은 기호는 아니다. */ +function isWord(cp) { + return cp >= 0 && ((cp >= 48 && cp <= 57) || LETTER.test(String.fromCodePoint(cp))); +} +function isAlnum(cp) { + return cp >= 0 && ALNUM.test(String.fromCodePoint(cp)); +} +function isAsciiAlnum(cp) { + return (cp >= 48 && cp <= 57) || (cp >= 65 && cp <= 90) || (cp >= 97 && cp <= 122); +} +/** 코어 `is_punct`. */ +function isPunct(cp) { + if (cp < 0) return false; + const s = String.fromCodePoint(cp); + return (cp < 128 && ASCII_PUNCT.includes(s)) || CJK_PUNCT.includes(s); +} + +/** `pos` 바로 앞의 코드 포인트. 인라인 구간 밖이면 -1. */ +function before(cx, pos) { + if (pos <= cx.offset) return -1; + const lo = cx.char(pos - 1); + if (lo >= 0xdc00 && lo <= 0xdfff && pos - 2 >= cx.offset) { + const hi = cx.char(pos - 2); + if (hi >= 0xd800 && hi <= 0xdbff) return (hi - 0xd800) * 0x400 + (lo - 0xdc00) + 0x10000; + } + return lo; +} +/** `pos` 의 코드 포인트. 끝이면 -1. */ +function at(cx, pos) { + const hi = cx.char(pos); + if (hi >= 0xd800 && hi <= 0xdbff) { + const lo = cx.char(pos + 1); + if (lo >= 0xdc00 && lo <= 0xdfff) return (hi - 0xd800) * 0x400 + (lo - 0xdc00) + 0x10000; + } + return hi; +} + +/** 코어 `can_open` — 뒤가 공백이 아니고, 뒤가 구두점이면 앞이 글자가 아니어야 한다. */ +function canOpen(prev, next) { + return next >= 0 && !isWs(next) && (!isPunct(next) || !isWord(prev)); +} + +/** 이 파서(의 노드 집합)에 취소선 노드가 있는가 — GFM 을 안 켰으면 `~~` 는 글자다. */ +const strikeKnown = new WeakMap(); +function hasStrike(cx) { + let v = strikeKnown.get(cx.parser); + if (v === undefined) { + v = cx.parser.nodeSet.types.some((t) => t.name === "Strikethrough"); + strikeKnown.set(cx.parser, v); + } + return v; +} + +/** 열린 마커 `type` 을 찾아 `[열린 인덱스, 구분자]` 로. */ +function open(cx, type) { + const i = cx.findOpeningDelimiter(type); + return i == null ? null : [i, cx.getDelimiterAt(i)]; +} + +/** `at` 에서 열린 것을 `[from, to)` 의 닫는 마커로 닫는다. */ +function close(cx, kind, index, delim, from, to) { + const content = cx.takeContent(index); + cx.addElement( + cx.elt(kind.node, delim.from, to, [cx.elt(kind.mark, delim.from, delim.to), ...content, cx.elt(kind.mark, from, to)]), + ); + return to; +} + +/** + * 마커 런 하나를 코어 규칙으로 읽는다. `kind` 는 종류, `[from, to)` 는 이 조각, `prev` · `next` 는 런 전체의 + * 앞뒤 글자(쪼갠 조각도 런 전체의 이웃을 본다 — 코어와 같다). 돌려주는 값은 다음 위치. + */ +function marker(cx, kind, from, to, prev, next, intraword) { + const run = to - from; + const afterSpace = prev < 0 || isWs(prev); + const left = canOpen(prev, next) && !intraword; + // 여는 쪽이 막힌 자리 — 앞이 글자, 뒤가 구두점. + const hemmedHere = isWord(prev) && next >= 0 && !isWs(next) && isPunct(next); + + const same = open(cx, kind.type); + const hemmed = open(cx, kind.hemmed); + // 글자·숫자 뒤의 `_` 는 열지 못한다(`snake_case`). 같은 종류가 열려 있으면 닫는 것은 된다(`_진료_가`). + if (intraword && !same && !hemmed) return to; + // 둘 다 열려 있으면 더 가까운 쪽이 코어의 "같은 종류 중 마지막"이다. + const nearest = same && hemmed ? (same[0] > hemmed[0] ? "same" : "hemmed") : same ? "same" : hemmed ? "hemmed" : null; + + if (nearest === "hemmed") { + const [i, d] = hemmed; + // 막힌 여는 마커의 거울 짝 — 같은 줄, 같은 길이, 앞이 구두점, 뒤가 글자(조사). 양 끝이 다 ASCII + // 영숫자면 수식(`x**(y)**z`)이라 짝짓지 않는다. + const openBefore = before(cx, d.from); + if ( + d.to - d.from === run && + !cx.slice(d.to, from).includes("\n") && + prev >= 0 && + !isWord(prev) && + !isWs(prev) && + isWord(next) && + !(isAsciiAlnum(openBefore) && isAsciiAlnum(next)) + ) { + return close(cx, kind, i, d, from, to); + } + // 막힌 여는 마커가 또 왔다 — 앞의 것은 짝이 없었다. lezer 가 글자로 둔다. 이쪽을 새로 연다. + if (hemmedHere) return cx.addDelimiter(kind.hemmed, from, to, true, false); + // 추측으로 연 것은 닫지 않는다. 여는 자리면 새로 열고, 아니면 글자다. + if (left && afterSpace) return cx.addDelimiter(kind.type, from, to, true, false); + return to; + } + if (nearest === "same") { + const [i, d] = same; + // 같은 종류가 열려 있고 앞이 공백이 아니면 여기가 닫는 자리다. 규칙 1. + if (!afterSpace) return close(cx, kind, i, d, from, to); + // 앞이 공백인데 열 수 있다 — 여는 마커가 또 왔다. 먼저 열린 쪽이 진다(글자로 남는다). 규칙 2. + if (left) return cx.addDelimiter(kind.type, from, to, true, false); + return close(cx, kind, i, d, from, to); + } + if (left) return cx.addDelimiter(kind.type, from, to, true, false); + // 열 수도 닫을 수도 없다. 막힌 여는 마커면 거울 짝을 기다리고, 아니면 글자다. 규칙 3. + if (hemmedHere) return cx.addDelimiter(kind.hemmed, from, to, true, false); + return to; +} + +/** `*` · `_` 런 — 코어처럼 길이로 종류를 정하고, `***` 는 `**` 와 `*` 로 쪼갠다. */ +function parseEmphasis(cx, next, start) { + if (next !== 42 && next !== 95) return -1; + let pos = start + 1; + while (cx.char(pos) === next) pos++; + const run = pos - start; + // 넷 이상은 글자다 — 마스킹 번호 `4***-****-****-003*` 의 `****`. + if (run > 3) return pos; + const prev = before(cx, start); + const after = at(cx, pos); + // 코어의 `intraword` — 글자·숫자 뒤의 `_`. 판정은 `marker` 가 한다. + const intraword = next === 95 && isAlnum(prev); + + let i = start; + while (i < pos) { + let take = pos - i; + if (take === 3) { + // `***` 는 `**` 와 `*` 다. 기울임이 열려 있으면 그것부터 닫고, 굵게만 열려 있으면 통째로 닫는 + // 마커이고, 아니면 굵게를 먼저 연다. 코어와 같다. + if (open(cx, EM) || open(cx, HEMMED_EM)) take = 1; + else if (open(cx, STRONG) || open(cx, HEMMED_STRONG)) take = 3; + else take = 2; + } + const kind = take === 1 ? KINDS.em : KINDS.strong; + i = marker(cx, kind, i, i + take, prev, after, intraword); + } + return pos; +} + +/** `~~` — 취소선. 홑 `~` 와 셋 이상은 글자다. GFM 이 없으면 전부 글자다. */ +function parseStrike(cx, next, start) { + if (next !== 126 || cx.char(start + 1) !== 126 || cx.char(start + 2) === 126 || !hasStrike(cx)) return -1; + const prev = before(cx, start); + const after = at(cx, start + 2); + return marker(cx, KINDS.strike, start, start + 2, prev, after, false); +} + +/** + * A `@lezer/markdown` extension that reads emphasis the way mdwire does, so `**설정(config)**을` — bold + * followed directly by a Korean particle — closes instead of leaving the asterisks as text. It replaces the + * built-in `Emphasis` parser and GFM's `Strikethrough` parser by name, so put it after them: + * `parser.configure([GFM, koreanEmphasis])`. For CodeMirror: + * `markdown({ base: markdownLanguage, extensions: [koreanEmphasis] })`. + * + * Markers that pair under mdwire's rules become `StrongEmphasis` / `Emphasis` / `Strikethrough` nodes; markers + * that do not pair stay as text (mdwire drops some of those, this extension never deletes characters). + * @type {import("@lezer/markdown").MarkdownConfig} + */ +export const koreanEmphasis = { + parseInline: [ + { name: "Emphasis", parse: parseEmphasis }, + { name: "Strikethrough", parse: parseStrike, after: "Emphasis" }, + ], +}; diff --git a/scripts/build-npm.sh b/scripts/build-npm.sh index 9291cc6..527fb72 100755 --- a/scripts/build-npm.sh +++ b/scripts/build-npm.sh @@ -33,7 +33,7 @@ rm -f "$OUT"/*/.gitignore # 레지스트리 페이지는 패키지 안의 README 를 보여 준다. 없으면 빈 페이지다. cp README.md LICENSE "$OUT/" # **wasm 을 거치지 않는 JS 는 손으로 쓴 것을 그대로 싣는다** — html 출력을 이벤트로 푸는 -# `events` 와 React 컴포넌트 `react`. 코어를 부르는 쪽은 패키지 이름으로 자기 자신을 import +# `events` 와 React 컴포넌트 `react`, lezer 확장 `lezer`. 코어를 부르는 쪽은 패키지 이름으로 자기 자신을 import # 해서, 환경마다 `exports` 가 고른 빌드(node · bundler)를 그대로 탄다. mkdir -p "$OUT/js" cp crates/mdwire-wasm/js/*.js crates/mdwire-wasm/js/*.d.ts "$OUT/js/" @@ -46,7 +46,7 @@ cat > "$OUT/package.json" < "$OUT/package.json" <=18" }, - "peerDependenciesMeta": { "react": { "optional": true } }, + "peerDependencies": { "react": ">=18", "@lezer/markdown": ">=1.3" }, + "peerDependenciesMeta": { "react": { "optional": true }, "@lezer/markdown": { "optional": true } }, "sideEffects": ["./bundler/mdwire.js"], "files": ["bundler", "node", "js"], "publishConfig": { "access": "public" } diff --git a/scripts/smoke-lezer.mjs b/scripts/smoke-lezer.mjs new file mode 100644 index 0000000..46cbf79 --- /dev/null +++ b/scripts/smoke-lezer.mjs @@ -0,0 +1,69 @@ +// `@minjun0219/mdwire/lezer` — 설치한 패키지의 lezer 확장이 코어와 같은 답을 내는지 본다. +import assert from "node:assert/strict"; +import { GFM, parser } from "@lezer/markdown"; +import { render } from "@minjun0219/mdwire"; +import { koreanEmphasis } from "@minjun0219/mdwire/lezer"; + +// 트리를 텔레그램 HTML 과 같은 모양으로 적는다 — 마커 노드는 지우고 강조·코드만 태그로. +function text(doc, node) { + let out = ""; + let pos = node.from; + for (let c = node.firstChild; c; c = c.nextSibling) { + out += doc.slice(pos, c.from) + show(doc, c); + pos = c.to; + } + return out + doc.slice(pos, node.to); +} +function show(doc, n) { + switch (n.name) { + case "EmphasisMark": + case "StrikethroughMark": + case "CodeMark": + return ""; + case "StrongEmphasis": + return `${text(doc, n)}`; + case "Emphasis": + return `${text(doc, n)}`; + case "Strikethrough": + return `${text(doc, n)}`; + case "InlineCode": + return `${text(doc, n)}`; + default: + return text(doc, n); + } +} +const read = (p, doc) => show(doc, p.parse(doc).topNode); + +const p = parser.configure([GFM, koreanEmphasis]); + +// 코어(텔레그램 HTML)와 같아야 하는 것들. +const same = [ + "**설정(config)**을 바꾼다", + '**「캐시」**가 · **"배포 금지"**는 · **끝.**이라서 · **52%**다', + "*설정(config)*을 ~~예전(구)~~는", + "***중요(필수)***를", + '①**"주간 졸림"**', + "_진료_가 있다", + "snake_case_name 은 그대로", + "값**(합계)**를 본다", + "2**(n-1) 은 거듭제곱", + "x**(y)**z 와 2**(n-1)**2", + "x**(y)**z 와 값**(합계)**를", + "카드 4***-****-****-003* 번호, underfront.* (4개), 2 ** 3", + "** 배포 ** 는 금지", + "**굵게 *기울임* 끝**", + "**`코드`**였다", + "**마통**이 · **(중요)** 다", + "앞 **굵게**\n다음 줄 **굵게2** 끝", +]; +for (const input of same) { + assert.equal(read(p, input), render(input, "telegram-html").join(""), input); +} +// 코어는 짝 잃은 `**` 를 버리지만, 이 확장은 글자로 둔다 — 범위는 같다. +assert.equal(read(p, "채널**이다. 글\n**신분 공개**이"), "채널**이다. 글\n신분 공개이"); +// 쌓는 순서 둘 다 된다. +assert.equal(read(parser.configure(GFM).configure(koreanEmphasis), "**설정(config)**을"), "설정(config)을"); +// GFM 없이도 강조는 되고, 취소선은 글자로 남는다(노드가 없다). +const bare = parser.configure(koreanEmphasis); +assert.equal(read(bare, "**설정(config)**을 ~~x~~가"), "설정(config)을 ~~x~~가"); +console.log("lezer 확장 통과"); diff --git a/scripts/smoke.sh b/scripts/smoke.sh index a522fe4..dbcf015 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -22,10 +22,13 @@ npm pkg set type=module >/dev/null npm install --no-audit --no-fund "$TGZ" >/dev/null # React 는 선택 peer 의존이다 — `./react` 를 쓰는 소비자처럼 같이 깐다. npm install --no-audit --no-fund react react-dom @types/react >/dev/null -cp "$ROOT/scripts/smoke.mjs" "$ROOT/scripts/smoke.ts" . +# `./lezer` 도 선택 peer 의존이다 — lezer 로 그리는 소비자(CodeMirror)처럼 같이 깐다. +npm install --no-audit --no-fund @lezer/markdown >/dev/null +cp "$ROOT/scripts/smoke.mjs" "$ROOT/scripts/smoke.ts" "$ROOT/scripts/smoke-lezer.mjs" . # 1. Node 에서 ESM 으로 import — `node` 조건이 CommonJS 빌드로 이어져야 한다. node smoke.mjs +node smoke-lezer.mjs # 2. Bun — 같은 파일을 그대로. Bun 은 `node` 조건을 골라 CommonJS 빌드를 읽고, wasm 은 # `fs` 로 디스크에서 읽는다(2026-09-29 확인). 빌드 단계 없이 TypeScript ESM 으로 도는 @@ -33,6 +36,7 @@ node smoke.mjs # CI 에서는 건너뛰지 않는다 — 러너에 Bun 이 없어 조용히 건너뛰던 적이 있다. if command -v bun >/dev/null; then bun smoke.mjs | sed 's/^/bun: /' + bun smoke-lezer.mjs | sed 's/^/bun: /' elif [ -n "${CI:-}" ]; then echo "CI 에 bun 이 없다 — ci.yml 에서 깔아야 한다" >&2 exit 1 diff --git a/scripts/smoke.ts b/scripts/smoke.ts index d0ad2a1..fab0404 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -34,3 +34,9 @@ const streamOpts: MarkdownStreamOptions = { onSettled: (html: string, changed: boolean) => void [html, changed], }; void [useMarkdownStream, streamOpts, revised]; + +// lezer 확장 — 서브패스 타입이 서고, `@lezer/markdown` 의 설정 타입에 그대로 들어간다. +import { koreanEmphasis } from "@minjun0219/mdwire/lezer"; +import type { MarkdownConfig } from "@lezer/markdown"; +const lezerConfig: MarkdownConfig = koreanEmphasis; +void lezerConfig; diff --git a/site/public/llms.txt b/site/public/llms.txt index 69c7331..7f76d86 100644 --- a/site/public/llms.txt +++ b/site/public/llms.txt @@ -43,7 +43,7 @@ Things that are easy to get wrong: ## Packages -- [npm `@minjun0219/mdwire`](https://www.npmjs.com/package/@minjun0219/mdwire): `render`, `renderWithReport`, `Streamer`; `@minjun0219/mdwire/react` (`Markdown`, `useMarkdownStream`) and `@minjun0219/mdwire/events` +- [npm `@minjun0219/mdwire`](https://www.npmjs.com/package/@minjun0219/mdwire): `render`, `renderWithReport`, `Streamer`; `@minjun0219/mdwire/react` (`Markdown`, `useMarkdownStream`), `@minjun0219/mdwire/events`, and `@minjun0219/mdwire/lezer` (`koreanEmphasis`, a `@lezer/markdown` extension that reads emphasis the way mdwire does) - [crates.io `mdwire-core`](https://crates.io/crates/mdwire-core): the Rust library, imported as `mdwire` - [crates.io `mdwire-cli`](https://crates.io/crates/mdwire-cli): the `mdwire` command — `mdwire --channel [--stream] [--report]` - [Go `github.com/minjun0219/mdwire/go`](https://pkg.go.dev/github.com/minjun0219/mdwire/go): the Go implementation, library and CLI diff --git a/site/src/pages/docs/npm.mdx b/site/src/pages/docs/npm.mdx index e5fa084..49538de 100644 --- a/site/src/pages/docs/npm.mdx +++ b/site/src/pages/docs/npm.mdx @@ -139,3 +139,21 @@ toEvents(html); // [{ type: "open", tag: "p", attrs: {} }, { type: "text", text: The same `html` output as a flat `open` / `text` / `close` / `void` list, for other frameworks. Attributes carry only what the core emits: `href` on `a`, `class` (`language-…`) on `code`, `style` (`text-align:…`) on `th` and `td`, `start` on `ol`, and `src` · `alt` on `img` when images load. + +## lezer — `@minjun0219/mdwire/lezer` + +```ts +import { GFM, parser } from "@lezer/markdown"; +import { koreanEmphasis } from "@minjun0219/mdwire/lezer"; + +const p = parser.configure([GFM, koreanEmphasis]); // after GFM: it replaces Emphasis and Strikethrough by name +// CodeMirror: markdown({ base: markdownLanguage, extensions: [koreanEmphasis] }) +``` + +For screens that draw Markdown with `@lezer/markdown` or CodeMirror instead of going through mdwire. The extension +reads emphasis with [the same rules](/docs/emphasis/) as the core, so `**설정(config)**을` becomes bold instead of +leaving its asterisks on screen. No WASM; `@lezer/markdown` is an optional peer dependency. + +Three things differ from the core, by design: markers that do not pair stay as text (the core drops some of them), +emphasis left open at the end of a paragraph stays as text (the core closes it), and a run of four or more `*` is +text. Checked against the core's output on 6,965 real paragraphs. diff --git a/site/src/pages/ko/docs/npm.mdx b/site/src/pages/ko/docs/npm.mdx index 136d6a2..6446e89 100644 --- a/site/src/pages/ko/docs/npm.mdx +++ b/site/src/pages/ko/docs/npm.mdx @@ -139,3 +139,21 @@ toEvents(html); // [{ type: "open", tag: "p", attrs: {} }, { type: "text", text: 프레임워크에서 쓰는 진입점입니다. 속성은 코어가 출력하는 것만 담깁니다. `a` 의 `href`, `code` 의 `class`(`language-…`), `th` · `td` 의 `style`(`text-align:…`), `ol` 의 `start`, 이미지를 불러올 때 `img` 의 `src` · `alt` 입니다. + +## lezer — `@minjun0219/mdwire/lezer` + +```ts +import { GFM, parser } from "@lezer/markdown"; +import { koreanEmphasis } from "@minjun0219/mdwire/lezer"; + +const p = parser.configure([GFM, koreanEmphasis]); // GFM 뒤에 둡니다 — Emphasis · Strikethrough 파서를 같은 이름으로 바꿔 끼웁니다 +// CodeMirror: markdown({ base: markdownLanguage, extensions: [koreanEmphasis] }) +``` + +mdwire 를 거치지 않고 `@lezer/markdown` 이나 CodeMirror 로 마크다운을 그리는 화면을 위한 확장입니다. 코어와 +[같은 규칙](/ko/docs/emphasis/)으로 강조를 읽어서 `**설정(config)**을` 이 별표 대신 굵게로 보입니다. WASM 이 없고, +`@lezer/markdown` 은 선택 peer 의존성입니다. + +코어와 다른 점은 셋이고 모두 의도한 것입니다. 짝을 맺지 못한 마커는 글자로 남깁니다(코어는 일부를 버립니다). +문단 끝까지 열려 있는 강조도 글자로 남깁니다(코어는 닫아 줍니다). 별표 넷 이상은 글자입니다. 실제 문단 6,965개로 +코어 출력과 대조했습니다. diff --git a/skills/mdwire/SKILL.md b/skills/mdwire/SKILL.md index 14b14d7..c2861a9 100644 --- a/skills/mdwire/SKILL.md +++ b/skills/mdwire/SKILL.md @@ -38,6 +38,9 @@ Each returned part is one message. Send them in order. and `build.target: "esnext"`. - **Rust** → `cargo add mdwire-core` (imported as `mdwire`). - **Go** → `go get github.com/minjun0219/mdwire/go@latest`. +- **A screen that draws Markdown with `@lezer/markdown` or CodeMirror** → `@minjun0219/mdwire/lezer`: + `parser.configure([GFM, koreanEmphasis])` makes that parser read emphasis the way mdwire does + (`**설정(config)**을` becomes bold instead of showing asterisks). No WASM. - **Python or anything else** → the CLI: `cargo install mdwire-cli`, or `go install github.com/minjun0219/mdwire/go/cmd/mdwire@latest`, or a binary from https://github.com/minjun0219/mdwire/releases. From 294b2667a962a984d84885b9dfcb9f476fbcfb34 Mon Sep 17 00:00:00 2001 From: Minjun Kim Date: Sat, 3 Oct 2026 08:19:34 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(npm):=20lezer=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=EB=8F=84=20=EC=97=AC=EB=8A=94=20=EA=B4=84=ED=98=B8=20=EB=92=A4?= =?UTF-8?q?=20=EB=A7=88=EC=BB=A4=EB=A5=BC=20=EA=B1=B0=EC=9A=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=82=BC=ED=82=A4=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코어에 넣은 규칙(#100 리뷰 반영)을 lezer 확장에도 같이 넣는다. 막힌 여는 마커가 열려 있을 때 앞이 여는 괄호·따옴표인 마커(`2**(n-1) (**주의**)` 의 둘째 `**`)는 거울이 아니라 새 강조의 여는 마커다. 이때 막힌 마커는 물려서(`retire`) 더는 짝 후보가 되지 않고 글자로 남는다 — 코어가 글자로 되돌리는 것과 같다. 막힌 마커가 또 올 때도 앞의 것을 같이 물린다. Co-Authored-By: Claude --- crates/mdwire-wasm/js/lezer.js | 29 ++++++++++++++++++++++++----- scripts/smoke-lezer.mjs | 2 ++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/crates/mdwire-wasm/js/lezer.js b/crates/mdwire-wasm/js/lezer.js index ebdd676..e458803 100644 --- a/crates/mdwire-wasm/js/lezer.js +++ b/crates/mdwire-wasm/js/lezer.js @@ -51,6 +51,10 @@ function isAlnum(cp) { function isAsciiAlnum(cp) { return (cp >= 48 && cp <= 57) || (cp >= 65 && cp <= 90) || (cp >= 97 && cp <= 122); } +/** 코어 `is_opening_bracket` — 여는 괄호·따옴표. 바로 뒤의 마커는 닫는 자리가 아니라 여는 자리다. */ +function isOpeningBracket(cp) { + return cp >= 0 && "([{「『(【《“‘".includes(String.fromCodePoint(cp)); +} /** 코어 `is_punct`. */ function isPunct(cp) { if (cp < 0) return false; @@ -100,6 +104,12 @@ function open(cx, type) { return i == null ? null : [i, cx.getDelimiterAt(i)]; } +/** 열린 구분자를 물린다 — 더는 찾히지 않고, 끝에 글자로 남는다(코어의 "글자로 되돌린다"). */ +const RETIRED = {}; +function retire(delim) { + delim.type = RETIRED; +} + /** `at` 에서 열린 것을 `[from, to)` 의 닫는 마커로 닫는다. */ function close(cx, kind, index, delim, from, to) { const content = cx.takeContent(index); @@ -130,7 +140,8 @@ function marker(cx, kind, from, to, prev, next, intraword) { if (nearest === "hemmed") { const [i, d] = hemmed; // 막힌 여는 마커의 거울 짝 — 같은 줄, 같은 길이, 앞이 구두점, 뒤가 글자(조사). 양 끝이 다 ASCII - // 영숫자면 수식(`x**(y)**z`)이라 짝짓지 않는다. + // 영숫자면 수식(`x**(y)**z`)이라 짝짓지 않는다. 앞이 여는 괄호·따옴표면 거울이 아니라 다음 강조의 + // 여는 마커다(`2**(n-1) (**주의**)`). const openBefore = before(cx, d.from); if ( d.to - d.from === run && @@ -138,15 +149,23 @@ function marker(cx, kind, from, to, prev, next, intraword) { prev >= 0 && !isWord(prev) && !isWs(prev) && + !isOpeningBracket(prev) && isWord(next) && !(isAsciiAlnum(openBefore) && isAsciiAlnum(next)) ) { return close(cx, kind, i, d, from, to); } - // 막힌 여는 마커가 또 왔다 — 앞의 것은 짝이 없었다. lezer 가 글자로 둔다. 이쪽을 새로 연다. - if (hemmedHere) return cx.addDelimiter(kind.hemmed, from, to, true, false); - // 추측으로 연 것은 닫지 않는다. 여는 자리면 새로 열고, 아니면 글자다. - if (left && afterSpace) return cx.addDelimiter(kind.type, from, to, true, false); + // 막힌 여는 마커가 또 왔다 — 앞의 것은 짝이 없었다. 물리면 글자로 남는다. 이쪽을 새로 연다. + if (hemmedHere) { + retire(d); + return cx.addDelimiter(kind.hemmed, from, to, true, false); + } + // 추측으로 연 것은 닫지 않는다. 여는 자리면 새로 열고, 아니면 글자다. 여는 괄호 뒤의 마커는 여는 + // 자리다 — 막힌 마커는 물려서 글자로 남는다. + if (left && (afterSpace || isOpeningBracket(prev))) { + if (!afterSpace) retire(d); + return cx.addDelimiter(kind.type, from, to, true, false); + } return to; } if (nearest === "same") { diff --git a/scripts/smoke-lezer.mjs b/scripts/smoke-lezer.mjs index 46cbf79..1f082ad 100644 --- a/scripts/smoke-lezer.mjs +++ b/scripts/smoke-lezer.mjs @@ -49,6 +49,8 @@ const same = [ "2**(n-1) 은 거듭제곱", "x**(y)**z 와 2**(n-1)**2", "x**(y)**z 와 값**(합계)**를", + "2**(n-1) (**주의**) 를 본다", + "2**(n-1) 【**주의**】 값)**를 본다**.", "카드 4***-****-****-003* 번호, underfront.* (4개), 2 ** 3", "** 배포 ** 는 금지", "**굵게 *기울임* 끝**", From 51a6aee50f1246c637bb25da3d80556868b89df0 Mon Sep 17 00:00:00 2001 From: Minjun Kim Date: Sat, 3 Oct 2026 08:23:39 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(review):=20lezer=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=EC=9D=B4=20=EC=A7=84=20=EC=97=AC=EB=8A=94=20=EB=A7=88=EC=BB=A4?= =?UTF-8?q?=EB=A5=BC=20=EB=AC=BC=EB=A6=AC=EA=B3=A0=20peer=20=EB=B0=94?= =?UTF-8?q?=EB=8B=A5=EC=9D=84=201.5=20=EB=A1=9C=20=EC=98=AC=EB=A6=B0?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #103 의 Codex 리뷰 두 건을 반영한다. - 규칙 2(앞이 공백인 여는 마커가 또 오면 먼저 열린 쪽이 진다)에서 진 구분자를 `cx.parts` 에 그대로 두어 `**old⏎**new** tail**` 의 끝 `**` 가 첫 `**` 와 다시 짝지어 문단 전체가 굵어졌다. 진 구분자를 물려서(`retire`) 더는 짝 후보가 되지 않고 글자로 남게 한다. - `@lezer/markdown` peer 범위를 `>=1.5` 로 올린다 — 확장이 쓰는 `getDelimiterAt` 은 1.5.0 에서 생겼다(1.4.3 에는 없다, 확인). 스모크는 peer 범위의 바닥 판으로 깔아 돌리고 최신판으로 한 번 더 돌린다 — 전에는 최신판만 깔아 바닥이 깨져도 몰랐다. Co-Authored-By: Claude --- crates/mdwire-wasm/js/lezer.js | 8 ++++++-- scripts/build-npm.sh | 2 +- scripts/smoke-lezer.mjs | 2 ++ scripts/smoke.sh | 10 ++++++++-- site/src/pages/docs/npm.mdx | 2 +- site/src/pages/ko/docs/npm.mdx | 2 +- 6 files changed, 19 insertions(+), 7 deletions(-) diff --git a/crates/mdwire-wasm/js/lezer.js b/crates/mdwire-wasm/js/lezer.js index e458803..2eff0f9 100644 --- a/crates/mdwire-wasm/js/lezer.js +++ b/crates/mdwire-wasm/js/lezer.js @@ -172,8 +172,12 @@ function marker(cx, kind, from, to, prev, next, intraword) { const [i, d] = same; // 같은 종류가 열려 있고 앞이 공백이 아니면 여기가 닫는 자리다. 규칙 1. if (!afterSpace) return close(cx, kind, i, d, from, to); - // 앞이 공백인데 열 수 있다 — 여는 마커가 또 왔다. 먼저 열린 쪽이 진다(글자로 남는다). 규칙 2. - if (left) return cx.addDelimiter(kind.type, from, to, true, false); + // 앞이 공백인데 열 수 있다 — 여는 마커가 또 왔다. 먼저 열린 쪽이 진다(물려서 글자로 남는다). 규칙 2. + // 물리지 않으면 `**old⏎**new** tail**` 의 끝 `**` 가 첫 `**` 와 다시 짝지어 문단 전체가 굵어진다. + if (left) { + retire(d); + return cx.addDelimiter(kind.type, from, to, true, false); + } return close(cx, kind, i, d, from, to); } if (left) return cx.addDelimiter(kind.type, from, to, true, false); diff --git a/scripts/build-npm.sh b/scripts/build-npm.sh index 527fb72..24fadb6 100755 --- a/scripts/build-npm.sh +++ b/scripts/build-npm.sh @@ -60,7 +60,7 @@ cat > "$OUT/package.json" <=18", "@lezer/markdown": ">=1.3" }, + "peerDependencies": { "react": ">=18", "@lezer/markdown": ">=1.5" }, "peerDependenciesMeta": { "react": { "optional": true }, "@lezer/markdown": { "optional": true } }, "sideEffects": ["./bundler/mdwire.js"], "files": ["bundler", "node", "js"], diff --git a/scripts/smoke-lezer.mjs b/scripts/smoke-lezer.mjs index 1f082ad..2cc569f 100644 --- a/scripts/smoke-lezer.mjs +++ b/scripts/smoke-lezer.mjs @@ -63,6 +63,8 @@ for (const input of same) { } // 코어는 짝 잃은 `**` 를 버리지만, 이 확장은 글자로 둔다 — 범위는 같다. assert.equal(read(p, "채널**이다. 글\n**신분 공개**이"), "채널**이다. 글\n신분 공개이"); +// 진 여는 마커는 물린다 — 뒤의 짝 잃은 마커와 다시 짝지어 문단 전체가 굵어지면 안 된다. +assert.equal(read(p, "**old\n**new** tail**"), "**old\nnew tail**"); // 쌓는 순서 둘 다 된다. assert.equal(read(parser.configure(GFM).configure(koreanEmphasis), "**설정(config)**을"), "설정(config)을"); // GFM 없이도 강조는 되고, 취소선은 글자로 남는다(노드가 없다). diff --git a/scripts/smoke.sh b/scripts/smoke.sh index dbcf015..b736ca4 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -22,8 +22,10 @@ npm pkg set type=module >/dev/null npm install --no-audit --no-fund "$TGZ" >/dev/null # React 는 선택 peer 의존이다 — `./react` 를 쓰는 소비자처럼 같이 깐다. npm install --no-audit --no-fund react react-dom @types/react >/dev/null -# `./lezer` 도 선택 peer 의존이다 — lezer 로 그리는 소비자(CodeMirror)처럼 같이 깐다. -npm install --no-audit --no-fund @lezer/markdown >/dev/null +# `./lezer` 도 선택 peer 의존이다 — lezer 로 그리는 소비자(CodeMirror)처럼 같이 깐다. **peer 범위의 바닥 판으로 +# 깐다** — 확장이 쓰는 `getDelimiterAt` 은 1.5.0 에서 생겼다. 최신판만 깔면 바닥이 깨져도 모른다. 최신판은 맨 끝에 한 번 더. +LEZER_MIN=$(node -p "require('./node_modules/@minjun0219/mdwire/package.json').peerDependencies['@lezer/markdown'].replace(/^[^0-9]*/, '')") +npm install --no-audit --no-fund "@lezer/markdown@$LEZER_MIN" >/dev/null cp "$ROOT/scripts/smoke.mjs" "$ROOT/scripts/smoke.ts" "$ROOT/scripts/smoke-lezer.mjs" . # 1. Node 에서 ESM 으로 import — `node` 조건이 CommonJS 빌드로 이어져야 한다. @@ -44,6 +46,10 @@ else echo "bun 이 없어 Bun 확인은 건너뛴다" >&2 fi +# 최신 @lezer/markdown 으로도 한 번 — 바닥 판과 최신판 사이에서 API 가 바뀌면 여기서 잡힌다. +npm install --no-audit --no-fund @lezer/markdown@latest >/dev/null +node smoke-lezer.mjs | sed 's/^/latest: /' + # 3. TypeScript — 첫 소비자의 설정(nodenext · verbatimModuleSyntax)으로 타입이 서는지. # `types` 를 비워 둔다. 빈 프로젝트라 @types/node 가 없고, 여기서 보는 것은 우리 # `.d.ts` 가 그 설정에서 서느냐 하나뿐이다. diff --git a/site/src/pages/docs/npm.mdx b/site/src/pages/docs/npm.mdx index 49538de..1f14c17 100644 --- a/site/src/pages/docs/npm.mdx +++ b/site/src/pages/docs/npm.mdx @@ -152,7 +152,7 @@ const p = parser.configure([GFM, koreanEmphasis]); // after GFM: it replaces Emp For screens that draw Markdown with `@lezer/markdown` or CodeMirror instead of going through mdwire. The extension reads emphasis with [the same rules](/docs/emphasis/) as the core, so `**설정(config)**을` becomes bold instead of -leaving its asterisks on screen. No WASM; `@lezer/markdown` is an optional peer dependency. +leaving its asterisks on screen. No WASM; `@lezer/markdown` (1.5 or later) is an optional peer dependency. Three things differ from the core, by design: markers that do not pair stay as text (the core drops some of them), emphasis left open at the end of a paragraph stays as text (the core closes it), and a run of four or more `*` is diff --git a/site/src/pages/ko/docs/npm.mdx b/site/src/pages/ko/docs/npm.mdx index 6446e89..520af54 100644 --- a/site/src/pages/ko/docs/npm.mdx +++ b/site/src/pages/ko/docs/npm.mdx @@ -152,7 +152,7 @@ const p = parser.configure([GFM, koreanEmphasis]); // GFM 뒤에 둡니다 — E mdwire 를 거치지 않고 `@lezer/markdown` 이나 CodeMirror 로 마크다운을 그리는 화면을 위한 확장입니다. 코어와 [같은 규칙](/ko/docs/emphasis/)으로 강조를 읽어서 `**설정(config)**을` 이 별표 대신 굵게로 보입니다. WASM 이 없고, -`@lezer/markdown` 은 선택 peer 의존성입니다. +`@lezer/markdown`(1.5 이상)은 선택 peer 의존성입니다. 코어와 다른 점은 셋이고 모두 의도한 것입니다. 짝을 맺지 못한 마커는 글자로 남깁니다(코어는 일부를 버립니다). 문단 끝까지 열려 있는 강조도 글자로 남깁니다(코어는 닫아 줍니다). 별표 넷 이상은 글자입니다. 실제 문단 6,965개로