Merge remote-tracking branch 'origin/master' into feat/md-incre-2

Port the two master-side markdown fixes onto the mdast renderer:
cjkFriendlyStrong joins both parse.ts grammars as a micromark extension
(ex remark plugin), and inline-code HTTP(S) URLs gain the safe anchor in
render.tsx without normalizeUri. Re-record the two new web-e2e goldens
whose footer clock spacing diverged from the nine established goldens,
and drop the timing-flaky LLM duration segment one of them captured.
This commit is contained in:
07akioni
2026-08-06 14:37:56 +08:00
430 changed files with 11455 additions and 2427 deletions

View File

@@ -0,0 +1,83 @@
/** Let asterisk strong emphasis close after punctuation when CJK prose continues without whitespace. */
import { attention } from 'micromark-core-commonmark'
import { unicodePunctuation } from 'micromark-util-character'
import { classifyCharacter } from 'micromark-util-classify-character'
import { codes, constants } from 'micromark-util-symbol'
import type { Construct, Extension, State, Tokenizer } from 'micromark-util-types'
const cjkCharacter = new RegExp([
'\\p{Script_Extensions=Han}',
'\\p{Script_Extensions=Hiragana}',
'\\p{Script_Extensions=Katakana}',
'\\p{Script_Extensions=Hangul}',
'\\p{Script_Extensions=Bopomofo}',
].join('|'), 'u')
function isCjkCharacter(code: number | null): boolean {
return code !== null && code >= 0 && cjkCharacter.test(String.fromCodePoint(code))
}
const tokenizeCjkFriendlyAttention: Tokenizer = function (effects, ok, nok) {
const configuredAttentionMarkers = this.parser.constructs.attentionMarkers.null
if (configuredAttentionMarkers === undefined) {
throw new Error('micromark CommonMark attention markers are unavailable')
}
const attentionMarkers = configuredAttentionMarkers
const previous = this.previous
const before = classifyCharacter(previous)
let marker: number | null = codes.eof
return start
function start(code: number | null): State | undefined {
/* v8 ignore next -- this text construct is dispatched only for an asterisk. */
if (code !== codes.asterisk) return nok(code)
marker = code
effects.enter('attentionSequence')
return inside(code)
}
function inside(code: number | null): State | undefined {
if (code === marker) {
effects.consume(code)
return inside
}
const token = effects.exit('attentionSequence')
const after = classifyCharacter(code)
const open = !after || (after === constants.characterGroupPunctuation && Boolean(before))
|| attentionMarkers.includes(code)
const commonMarkClose = !before
|| (before === constants.characterGroupPunctuation && Boolean(after))
|| attentionMarkers.includes(previous)
const markerCount = token.end.offset - token.start.offset
const cjkStrongClose = markerCount >= 2
&& unicodePunctuation(previous)
&& isCjkCharacter(code)
const close = commonMarkClose || cjkStrongClose
token._open = open
token._close = close
return ok(code)
}
}
const cjkFriendlyAttention: Construct = {
name: 'cjkFriendlyAttention',
resolveAll: attention.resolveAll,
tokenize: tokenizeCjkFriendlyAttention,
}
const cjkFriendlyStrongExtension: Extension = {
text: { [codes.asterisk]: cjkFriendlyAttention },
}
/**
* Extend CommonMark asterisk strong emphasis for punctuation-delimited CJK
* prose, as a micromark syntax extension for `fromMarkdown`.
* @returns The micromark syntax extension.
*/
export function cjkFriendlyStrong(): Extension {
return cjkFriendlyStrongExtension
}

View File

@@ -12,6 +12,7 @@ import { gfmFromMarkdown } from 'mdast-util-gfm'
import { mathFromMarkdown } from 'mdast-util-math'
import { gfm } from 'micromark-extension-gfm'
import { math } from 'micromark-extension-math'
import { cjkFriendlyStrong } from './cjkFriendlyStrong.ts'
import { mathCompatibility } from './mathCompatibility.ts'
/**
@@ -22,7 +23,7 @@ import { mathCompatibility } from './mathCompatibility.ts'
*/
export function parseGfm(text: string): Root {
return fromMarkdown(text, {
extensions: [gfm()],
extensions: [gfm(), cjkFriendlyStrong()],
mdastExtensions: [gfmFromMarkdown()],
})
}
@@ -35,7 +36,7 @@ export function parseGfm(text: string): Root {
*/
export function parseGfmWithMath(text: string): Root {
return fromMarkdown(text, {
extensions: [gfm(), mathCompatibility(), math()],
extensions: [gfm(), cjkFriendlyStrong(), mathCompatibility(), math()],
mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown()],
})
}

View File

@@ -208,9 +208,17 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
return <em key={key}>{renderChildren(node.children, context)}</em>
case 'delete':
return <del key={key}>{renderChildren(node.children, context)}</del>
case 'inlineCode':
case 'inlineCode': {
// Parity with mdast-util-to-hast: inline code renders line endings as spaces.
return <code key={key}>{node.value.replace(/\r?\n|\r/g, ' ')}</code>
const value = node.value.replace(/\r?\n|\r/g, ' ')
// An inline-code token that is entirely an absolute HTTP(S) URL keeps
// its code chrome and gains the same safe external anchor as a link;
// commands, partial URLs, and other schemes stay inert. The value is
// authored text, not a parsed destination, so no normalizeUri: port,
// path, and query render unchanged.
const href = inlineCodeHttpUrl(value)
return <code key={key}>{href === undefined ? value : renderSafeLink(href, [value], 'link')}</code>
}
case 'html':
// No HTML parser enters the pipeline: raw HTML stays literal text.
return node.value
@@ -382,8 +390,9 @@ function renderTableRow(
return <tr key={key}>{cells}</tr>
}
function renderAnchor(url: string, children: ReactNode[], key: Key): ReactNode {
const safeHref = sanitizeUrl(normalizeUri(url))
/** Anchor over an already-authored href: allowlisted or unwrapped, external links get the safe attributes. */
function renderSafeLink(href: string, children: ReactNode[], key: Key): ReactNode {
const safeHref = sanitizeUrl(href)
if (safeHref === '') return <Fragment key={key}>{children}</Fragment>
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
return (
@@ -397,6 +406,26 @@ function renderAnchor(url: string, children: ReactNode[], key: Key): ReactNode {
)
}
/** Anchor over a parsed markdown destination, which hast normalized before the allowlist saw it. */
function renderAnchor(url: string, children: ReactNode[], key: Key): ReactNode {
return renderSafeLink(normalizeUri(url), children, key)
}
/**
* The complete inline-code value when it is exactly an absolute HTTP(S) URL
* (no surrounding whitespace); anything else stays inert code.
*/
function inlineCodeHttpUrl(value: string): string | undefined {
if (value.trim() !== value) return undefined
try {
const protocol = new URL(value).protocol
return protocol === 'http:' || protocol === 'https:' ? value : undefined
} catch {
// Not an absolute URL at all — the only way new URL() rejects a string.
return undefined
}
}
function renderImage(url: string, alt: string, key: Key): ReactNode {
const imageSrc = remoteImageUrl(sanitizeUrl(normalizeUri(url)))
if (imageSrc === undefined) {