Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
5 changes: 5 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ flanking 규칙대로 닫는 `**` 앞이 구두점이고 뒤가 글자면 닫지
닫는 쪽 뒤에 한글이 온다. 모델에게 수식을 백틱으로 감싸 달라고 부탁하지 않아도 되게 여기서 가른다. 출력은 닫는 쪽과 같은 장치를 탄다 — GitHub `<strong>`, 슬랙은 여는 마커 뒤 · 닫는 마커
앞에 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 와 달리 그 자리에서
Expand Down
13 changes: 13 additions & 0 deletions crates/mdwire-wasm/js/lezer.d.ts
Original file line number Diff line number Diff line change
@@ -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;
242 changes: 242 additions & 0 deletions crates/mdwire-wasm/js/lezer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
// `@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_opening_bracket` — 여는 괄호·따옴표. 바로 뒤의 마커는 닫는 자리가 아니라 여는 자리다. */
function isOpeningBracket(cp) {
return cp >= 0 && "([{「『(【《“‘".includes(String.fromCodePoint(cp));
}
/** 코어 `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)];
}

/** 열린 구분자를 물린다 — 더는 찾히지 않고, 끝에 글자로 남는다(코어의 "글자로 되돌린다"). */
const RETIRED = {};
function retire(delim) {
delim.type = RETIRED;
}

/** `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`)이라 짝짓지 않는다. 앞이 여는 괄호·따옴표면 거울이 아니라 다음 강조의
// 여는 마커다(`2**(n-1) (**주의**)`).
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) &&
!isOpeningBracket(prev) &&
isWord(next) &&
!(isAsciiAlnum(openBefore) && isAsciiAlnum(next))
) {
return close(cx, kind, i, d, from, to);
}
// 막힌 여는 마커가 또 왔다 — 앞의 것은 짝이 없었다. 물리면 글자로 남는다. 이쪽을 새로 연다.
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") {
const [i, d] = same;
// 같은 종류가 열려 있고 앞이 공백이 아니면 여기가 닫는 자리다. 규칙 1.
if (!afterSpace) return close(cx, kind, i, d, from, to);
// 앞이 공백인데 열 수 있다 — 여는 마커가 또 왔다. 먼저 열린 쪽이 진다(물려서 글자로 남는다). 규칙 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);
// 열 수도 닫을 수도 없다. 막힌 여는 마커면 거울 짝을 기다리고, 아니면 글자다. 규칙 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" },
],
};
11 changes: 6 additions & 5 deletions scripts/build-npm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand All @@ -46,7 +46,7 @@ cat > "$OUT/package.json" <<JSON
"license": "MIT",
"repository": { "type": "git", "url": "https://github.com/minjun0219/mdwire" },
"homepage": "https://mdwire.minjun.dev",
"keywords": ["markdown", "llm", "ai", "agent", "chatbot", "telegram", "telegram-bot", "slack", "slack-bot", "github", "notion", "react", "streaming", "wasm"],
"keywords": ["markdown", "llm", "ai", "agent", "chatbot", "telegram", "telegram-bot", "slack", "slack-bot", "github", "notion", "react", "lezer", "codemirror", "streaming", "wasm"],
"type": "module",
"main": "./node/mdwire.js",
"types": "./bundler/mdwire.d.ts",
Expand All @@ -57,10 +57,11 @@ cat > "$OUT/package.json" <<JSON
"default": "./bundler/mdwire.js"
},
"./events": { "types": "./js/events.d.ts", "default": "./js/events.js" },
"./react": { "types": "./js/react.d.ts", "default": "./js/react.js" }
"./react": { "types": "./js/react.d.ts", "default": "./js/react.js" },
"./lezer": { "types": "./js/lezer.d.ts", "default": "./js/lezer.js" }
},
"peerDependencies": { "react": ">=18" },
"peerDependenciesMeta": { "react": { "optional": true } },
"peerDependencies": { "react": ">=18", "@lezer/markdown": ">=1.5" },
"peerDependenciesMeta": { "react": { "optional": true }, "@lezer/markdown": { "optional": true } },
"sideEffects": ["./bundler/mdwire.js"],
"files": ["bundler", "node", "js"],
"publishConfig": { "access": "public" }
Expand Down
Loading
Loading