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:
@@ -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
|
||||
}
|
||||
@@ -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()],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user