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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,19 @@ Pages that enforce [Trusted Types](https://developer.mozilla.org/en-US/docs/Web/
backend — see [Trusted Types in `docs/EXTENDING.md`](docs/EXTENDING.md#trusted-types)
for the CSP policy names and the `setTrustedTypesPolicy` hook.

Chinese / Japanese / Korean output has an opt-in entry too: `setCjkFriendly(true)`
from `@copse/streaming-markdown/cjk` makes emphasis and bare autolinks behave
around full-width punctuation (`**「強調」**`, `https://example.com。`), and the
optional `styles/cjk.css` carries the line-break / spacing CSS the host owns —
see [CJK / East-Asian text](docs/EXTENDING.md#cjk--east-asian-text). Both are
off by default; Latin output is byte-identical.

## Styling

The renderer emits documented class hooks but ships no styles by default.
Two optional stylesheets are provided (`styles/core.css`, structural only;
`styles/default.css`, a batteries-included theme); both scope every rule under a
Optional stylesheets are provided (`styles/core.css`, structural only;
`styles/default.css`, a batteries-included theme; `styles/cjk.css`, opt-in
East-Asian line-break / spacing); each scopes every rule under a
`.streaming-markdown` class:

```ts
Expand Down
51 changes: 51 additions & 0 deletions docs/EXTENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ it unless you import it. Register each once, before your first render.
| Math prose syntax (override) | `setMathSyntax` | — |
| Custom fenced blocks | `setFenceHandler` | — (you supply the handler) |
| Custom inline syntax | `setInlinePasses` | — (you supply the pass) |
| CJK-friendly emphasis / autolinks | `setCjkFriendly` | `…/cjk` |
| `<a>` routing | `setLinkDecorator` | — |
| Raw `<img>` handling | `setRawImageRenderer` | — |
| Sanitizer allowlist | `setSanitizeExtension` | — |
Expand Down Expand Up @@ -330,6 +331,53 @@ unknown codes pass through, and a half-typed `:smi` holds mid-stream. Extend or
replace the table with `createEmojiInlinePass(customMap)`, or read the shipped
`emojiShortcodes` map from the same entry.

## CJK / East-Asian text

East-Asian (Chinese / Japanese / Korean) output splits cleanly into two layers,
and the honest scope split matters: **most of it is the host's CSS, and only a
small, real slice belongs in the renderer.**

**Renderer layer — opt-in JS behind `@copse/streaming-markdown/cjk`.** CommonMark's
emphasis *flanking* rules count full-width / ideographic punctuation (`「」`,
`。`, `!`, `()`, …) as ordinary Unicode punctuation, so a `**` between a CJK
character and one of those marks fails to flank and the emphasis never pairs —
`これは**「強調」**です` stays literal. That is a documented CommonMark
limitation, not a bug in this renderer (the reference implementation produces the
same literal output), so it is an **extension**, off by default. A post-process
inline pass cannot fix it — by the time passes run, the `**` have already been
left as text — so it is a default-off hook in the flanking classifier instead.
Turn it on (once, before the first render) and it also stops a run-together bare
autolink at the first full-width mark (`https://example.com。次` → link + prose):

```ts
import { setCjkFriendly } from '@copse/streaming-markdown/cjk'

setCjkFriendly(true) // markdown-cjk-friendly emphasis + autolink boundaries
```

Like the other optional backends, the range table lives behind its own entry —
nothing is pulled into your bundle unless you import `…/cjk`. With it off (the
default), Latin output and the CommonMark/GFM conformance suites are
byte-identical. `setCjkFriendly(false)` restores stock flanking.

**Host layer — CSS.** Line breaking (ideographs wrap between any two characters,
Kinsoku start/end constraints), inter-script spacing (the gap between CJK and
Latin/numbers), and full-width-punctuation kerning are **presentation the host
owns** — the renderer emits the same structural HTML for every script and does
*not* guess a language. Ship the ready-made optional sheet and tell the browser
the language:

```ts
import '@copse/streaming-markdown/styles/cjk.css'
el.lang = 'ja' // or 'zh' / 'ko'; or add class 'sm-cjk' to the container
```

`styles/cjk.css` is not imported by `core.css` / `default.css` and is pure CSS
(`word-break`, `line-break: strict`, and progressive `text-autospace` /
`text-spacing-trim`), scoped under `.streaming-markdown` and gated on `:lang()`
or a `.sm-cjk` class hook — see the header comment in the file. It needs no JS,
and the JS entry needs no CSS; use either, both, or neither.

## Link routing (`LinkDecorator`)

A `LinkDecorator` returns the attribute string appended after `href` on every
Expand Down Expand Up @@ -501,6 +549,9 @@ el.classList.add('streaming-markdown')
spacing, or typography. Pair it with your own theme.
- **`styles/default.css`** — imports `core.css` and adds a batteries-included look
(spacing, typography, tables, links, and a highlight.js VS Code Dark+ palette).
- **`styles/cjk.css`** — optional East-Asian line-break / spacing rules, gated on
`:lang()` or a `.sm-cjk` class hook. Not imported by the other two; see the
[CJK / East-Asian text](#cjk--east-asian-text) section above.

Retheme `default.css` by setting `--sm-*` custom properties on `.streaming-markdown`
(or any ancestor) — each has a fallback, so the sheet also stands alone. See the
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@
"import": "./dist/sanitize-browser.js",
"default": "./dist/sanitize-browser.js"
},
"./cjk": {
"types": "./dist/cjk.d.ts",
"import": "./dist/cjk.js",
"default": "./dist/cjk.js"
},
"./entities/full": {
"types": "./dist/entity-decoder-full.d.ts",
"import": "./dist/entity-decoder-full.js",
Expand All @@ -69,7 +74,8 @@
"default": "./dist/smoothing.js"
},
"./styles/core.css": "./styles/core.css",
"./styles/default.css": "./styles/default.css"
"./styles/default.css": "./styles/default.css",
"./styles/cjk.css": "./styles/cjk.css"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
Expand Down
120 changes: 120 additions & 0 deletions src/cjk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { afterEach, describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { isCjkPunctuation, setCjkFriendly } from './cjk.ts'
import { renderInlineSpans } from './inline-spans.ts'
import { renderMarkdown } from './renderer.ts'

// The extension flips shared module-level registries; always restore the stock
// CommonMark flanking so a leaked flag can't bleed into other suites.
afterEach(() => setCjkFriendly(false))

const anchor = (href: string, label: string) =>
`<a href="${href}" target="_blank" rel="noopener noreferrer" data-browser-link="true">${label}</a>`

describe('isCjkPunctuation classifier', () => {
it('matches full-width / ideographic punctuation', () => {
for (const ch of ['。', '、', '「', '」', '『', '』', '(', ')', '【', '】', '!', '?', ':', ';', ',', '.', '・', '。', '「', '・']) {
assert.equal(isCjkPunctuation(ch), true, `expected CJK punctuation: ${ch}`)
}
})

it('rejects ASCII punctuation, CJK letters, and the empty string', () => {
for (const ch of ['.', ',', '!', '?', '(', ')', '*', '_', '-', ':', '中', '文', 'あ', 'ア', '한', 'A', '1', ' ', '']) {
assert.equal(isCjkPunctuation(ch), false, `expected non-CJK-punctuation: ${JSON.stringify(ch)}`)
}
})
})

describe('setCjkFriendly — emphasis around full-width punctuation', () => {
it('is off by default: emphasis wrapping CJK punctuation stays literal (CommonMark)', () => {
assert.equal(renderInlineSpans('これは**「強調」**です'), 'これは**「強調」**です')
})

it('pairs emphasis around CJK bracket punctuation when enabled', () => {
setCjkFriendly(true)
assert.equal(renderInlineSpans('これは**「強調」**です'), 'これは<strong>「強調」</strong>です')
assert.equal(renderInlineSpans('「**注意**」'), '「<strong>注意</strong>」')
})

it('pairs emphasis whose inner edge is a full-width period or bang', () => {
setCjkFriendly(true)
assert.equal(renderInlineSpans('**強調。**です'), '<strong>強調。</strong>です')
assert.equal(renderInlineSpans('テスト*太字!*続き'), 'テスト<em>太字!</em>続き')
})

it('handles a delimiter opening right after ideographic punctuation', () => {
setCjkFriendly(true)
assert.equal(renderInlineSpans('句読点。**強調**'), '句読点。<strong>強調</strong>')
})

it('renders mixed CJK + Latin emphasis correctly', () => {
setCjkFriendly(true)
assert.equal(renderInlineSpans('中文**bold**测试'), '中文<strong>bold</strong>测试')
assert.equal(renderInlineSpans('日本語と *English* の混在。'), '日本語と <em>English</em> の混在。')
})

it('restores stock CommonMark flanking when disabled', () => {
setCjkFriendly(true)
assert.equal(renderInlineSpans('これは**「強調」**です'), 'これは<strong>「強調」</strong>です')
setCjkFriendly(false)
assert.equal(renderInlineSpans('これは**「強調」**です'), 'これは**「強調」**です')
})
})

describe('setCjkFriendly — bare autolink boundaries', () => {
it('is off by default: a run-together CJK tail is swallowed into the href', () => {
assert.equal(
renderMarkdown('参照 https://example.com。次'),
`<p>参照 ${anchor('https://example.com%E3%80%82%E6%AC%A1', 'https://example.com。次')}</p>`,
)
})

it('stops a bare URL at the first CJK punctuation mark when enabled', () => {
setCjkFriendly(true)
assert.equal(
renderMarkdown('参照 https://example.com。次を見て'),
`<p>参照 ${anchor('https://example.com', 'https://example.com')}。次を見て</p>`,
)
})

it('keeps query strings and trims ASCII trailing punctuation before the CJK boundary', () => {
setCjkFriendly(true)
assert.equal(
renderMarkdown('見る https://a.com/p?x=1,そして'),
`<p>見る ${anchor('https://a.com/p?x=1', 'https://a.com/p?x=1')},そして</p>`,
)
assert.equal(
renderMarkdown('(https://a.com/p).、'),
`<p>(${anchor('https://a.com/p', 'https://a.com/p')}).、</p>`,
)
})

it('leaves a URL that starts at a CJK mark untouched', () => {
// The captured run begins with the boundary char, so there is no URL to link.
setCjkFriendly(true)
assert.equal(renderMarkdown('(https://a.com)'), '<p>(https://a.com)</p>')
})
})

describe('setCjkFriendly — no regression to Latin-script output', () => {
// Every non-CJK case must render byte-identically with the extension ON.
const latinFixtures = [
'a **b** c.',
'foo *bar* (baz).',
'text with `code` and *emphasis*, then more.',
'a [link](https://example.com) and https://plain.example.com/path, done.',
'__strong__ and _em_ and ~~strike~~ intraword_snake_case.',
'trailing punctuation: https://example.com/path!',
'**bold _nested em_ tail** and normal.',
'no markup at all, just prose — with an em-dash.',
]

it('is byte-identical for Latin fixtures whether the extension is on or off', () => {
const off = latinFixtures.map((s) => renderMarkdown(s))
setCjkFriendly(true)
const on = latinFixtures.map((s) => renderMarkdown(s))
for (let i = 0; i < latinFixtures.length; i++) {
assert.equal(on[i], off[i], `Latin output changed for: ${latinFixtures[i]}`)
}
})
})
58 changes: 58 additions & 0 deletions src/cjk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Opt-in CJK / East-Asian text handling (`@copse/streaming-markdown/cjk`).
*
* CommonMark's emphasis flanking rules classify every code point as whitespace,
* *punctuation* (Unicode `P`/`S`), or "other". Full-width / ideographic
* punctuation (`「」`, `。`, `!`, `()`, …) is Unicode punctuation, so a
* delimiter that sits between a CJK character and one of these marks fails the
* flanking test and the emphasis never pairs — `これは**「強調」**です` stays
* literal, and LLMs routinely emit exactly that shape. This is a documented
* CommonMark limitation, not a bug in this renderer: the reference
* implementation produces the same literal output, so the fix is an **opt-in
* extension** (markdown-cjk-friendly), never the default.
*
* Turning it on treats full-width punctuation as *non-flanking* punctuation, so
* emphasis pairs around it, and marks it as a bare-autolink boundary, so a
* run-together `https://example.com。次` stops the URL at the `。`. Both hooks
* are default-off registries in the core (`inline-emphasis.ts` /
* `inline-spans.ts`); this module — and the range table below — is only pulled
* into a bundle that imports this entry, exactly like the optional highlighter,
* diagram, and math backends. With the extension off (the default) Latin output
* and the conformance suites are byte-identical.
*
* Line-break and inter-script spacing (no space between two ideographs at a
* soft break, kerning full-width punctuation, `word-break`/`line-break`) are the
* host's CSS to own, not the renderer's — see `styles/cjk.css` and the CJK
* section of `docs/EXTENDING.md`.
*/
import { setFlankingPunctuationExclusion } from './inline-emphasis.ts'
import { setBareUrlCjkBoundary } from './inline-spans.ts'

/**
* Full-width / ideographic punctuation, i.e. the East-Asian-width punctuation
* that markdown-cjk-friendly treats as non-flanking. Covers CJK Symbols and
* Punctuation (`。、「」『』()【】〔〕〈〉《》…` — U+3000 ideographic space is
* already Unicode whitespace and handled as such), the katakana middle dot,
* Vertical Forms, CJK Compatibility Forms, Small Form Variants, the full-width
* ASCII punctuation of the Halfwidth and Fullwidth Forms block (excluding
* full-width alphanumerics), and the half-width katakana punctuation `。「」、・`.
*/
const CJK_PUNCTUATION_RE =
/[ -〿・︐-︙︰-﹯!-/:-@[-`{-・]/u

/** True for a single full-width / ideographic punctuation character. */
export function isCjkPunctuation(ch: string): boolean {
return ch !== '' && CJK_PUNCTUATION_RE.test(ch)
}

/**
* Enable (default) or disable CJK-friendly emphasis and autolink boundaries.
* Set once, before the first render — the registries it flips are shared by the
* at-rest and streaming emitters, like the other opt-in backends. Pass `false`
* to restore stock CommonMark flanking (also the right teardown for tests).
*/
export function setCjkFriendly(enabled = true): void {
const fn = enabled ? isCjkPunctuation : null
setFlankingPunctuationExclusion(fn)
setBareUrlCjkBoundary(fn)
}
20 changes: 19 additions & 1 deletion src/inline-emphasis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,30 @@ import { type LinkReferenceMap } from './link-references.ts'
// and S (spec 354 — `£`/`€` count like `$`).
const UNICODE_PUNCTUATION_RE = /[\p{P}\p{S}]/u

/**
* Opt-in override (default `null`) that excludes characters from the flanking
* *punctuation* class — the seam the CJK entry (`@copse/streaming-markdown/cjk`,
* `src/cjk.ts`) uses to treat full-width/ideographic punctuation as
* non-punctuation so emphasis pairs around `「…」` / `。` the way CJK authors
* expect (markdown-cjk-friendly). Left `null` in the default build, so Latin
* output and the CommonMark/GFM conformance suites are byte-identical — the
* predicate below short-circuits before ever calling it.
*/
let flankingPunctuationExclusion: ((ch: string) => boolean) | null = null

/** Inject (or clear with `null`) the flanking-punctuation exclusion. */
export function setFlankingPunctuationExclusion(fn: ((ch: string) => boolean) | null): void {
flankingPunctuationExclusion = fn
}

function isFlankingWhitespace(ch: string): boolean {
return ch === '' || /\s/.test(ch)
}

function isFlankingPunctuation(ch: string): boolean {
return ch !== '' && UNICODE_PUNCTUATION_RE.test(ch)
if (ch === '' || !UNICODE_PUNCTUATION_RE.test(ch)) return false
if (flankingPunctuationExclusion !== null && flankingPunctuationExclusion(ch)) return false
return true
}

export function isLeftFlanking(prev: string, next: string): boolean {
Expand Down
35 changes: 32 additions & 3 deletions src/inline-spans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,17 +135,46 @@ function renderedBareLink(label: string, href: string): string {
const BARE_HTTP_URL_RE = /(^|[\s(])((?:https?:\/\/)[^\s<]+)/gi
const TRAILING_URL_PUNCTUATION_RE = /[),.;:!?_]+$/

/**
* Opt-in override (default `null`) that flags CJK / full-width punctuation as a
* bare-autolink boundary — the seam the CJK entry
* (`@copse/streaming-markdown/cjk`, `src/cjk.ts`) uses so a run-together URL like
* `https://example.com。次` does not swallow the trailing `。次` into the `href`.
* Left `null` in the default build (Latin URLs never contain these code points),
* so non-CJK output is byte-identical.
*/
let bareUrlCjkBoundary: ((ch: string) => boolean) | null = null

/** Inject (or clear with `null`) the bare-autolink CJK-punctuation boundary. */
export function setBareUrlCjkBoundary(fn: ((ch: string) => boolean) | null): void {
bareUrlCjkBoundary = fn
}

/** Split a captured bare URL at the first CJK-punctuation boundary, if any. */
function splitBareUrlAtCjkBoundary(rawUrl: string): { url: string; tail: string } {
const boundary = bareUrlCjkBoundary
if (boundary !== null) {
for (let i = 0; i < rawUrl.length; i++) {
if (boundary(rawUrl[i] ?? '')) return { url: rawUrl.slice(0, i), tail: rawUrl.slice(i) }
}
}
return { url: rawUrl, tail: '' }
}

function renderBareHttpLinks(text: string): string {
return text
.split(INLINE_HTML_SHIELD_RE)
.map((segment, index) => {
if (index % 2 === 1) return segment
return segment.replace(BARE_HTTP_URL_RE, (_match, prefix: string, rawUrl: string) => {
const trailing = rawUrl.match(TRAILING_URL_PUNCTUATION_RE)?.[0] ?? ''
const url = trailing ? rawUrl.slice(0, -trailing.length) : rawUrl
// A CJK full-width punctuation mark ends the URL and stays as prose.
const { url: beforeCjk, tail: cjkTail } = splitBareUrlAtCjkBoundary(rawUrl)
if (beforeCjk === '') return `${prefix}${rawUrl}`
const asciiTrailing = beforeCjk.match(TRAILING_URL_PUNCTUATION_RE)?.[0] ?? ''
const url = asciiTrailing ? beforeCjk.slice(0, -asciiTrailing.length) : beforeCjk
const href = safeLinkHref(url)
if (!href) return `${prefix}${rawUrl}`
return `${prefix}${renderedBareLink(url, href)}${trailing}`
return `${prefix}${renderedBareLink(url, href)}${asciiTrailing}${cjkTail}`
})
})
.join('')
Expand Down
Loading
Loading