Merge remote-tracking branch 'origin/master' into worktree/web-remote-markdown-images
# Conflicts: # packages/client/ui-primitives/README.i18n.yaml # packages/client/ui-primitives/README.md # packages/client/ui-primitives/README.zh.md # packages/client/ui-primitives/src/markdown/MarkdownText.tsx # packages/client/ui-primitives/tests/markdown.spec.tsx
This commit is contained in:
@@ -4,10 +4,10 @@
|
||||
// plain fallback for everything else. Chrome (language banner + copy) matches
|
||||
// deepsuite `@deepseek/md` code blocks; token colors stay on `--shiki-*`.
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { writeClipboard } from '../clipboard.ts'
|
||||
import { highlightToHtml } from './highlight.ts'
|
||||
import { grammarLoadCount, highlightToHtml, subscribeGrammarLoaded } from './highlight.ts'
|
||||
import css from './CodeBlock.module.css'
|
||||
|
||||
export interface CodeBlockProps {
|
||||
@@ -17,11 +17,19 @@ export interface CodeBlockProps {
|
||||
lang?: string | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
/** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
|
||||
copyLabel?: string | undefined
|
||||
/** Copy-button label during the post-copy confirmation window. */
|
||||
copiedLabel?: string | undefined
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
export function CodeBlock({ code, lang, className, copyLabel = '复制', copiedLabel = '复制成功' }: CodeBlockProps) {
|
||||
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
|
||||
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
|
||||
// Re-render when a lazy grammar finishes loading, so a fence that showed plain
|
||||
// text while its language's grammar imported picks up highlighting. The
|
||||
// snapshot value is opaque; only its change across renders drives the memo.
|
||||
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
|
||||
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang, loaded])
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
@@ -55,7 +63,7 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
<div className={css.infostring}>{lang ?? ''}</div>
|
||||
<div className={css.action}>
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? '复制成功' : '复制'}
|
||||
{copied ? copiedLabel : copyLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,17 @@ import css from './JsonBlock.module.css'
|
||||
|
||||
const MAX_CHARS = 20_000
|
||||
|
||||
export function JsonBlock({ label, payload, defaultOpen = false }: {
|
||||
/** Default truncation footer; the owner passes a localized formatter. */
|
||||
function defaultTruncatedLabel(total: number): string {
|
||||
return `… 已截断,共 ${total} 字符`
|
||||
}
|
||||
|
||||
export function JsonBlock({ label, payload, defaultOpen = false, truncatedLabel = defaultTruncatedLabel }: {
|
||||
label: string
|
||||
payload: unknown
|
||||
defaultOpen?: boolean
|
||||
/** Footer appended when the body exceeds the char cap, given the full length (this package is cordis-free, so copy arrives via props). */
|
||||
truncatedLabel?: ((total: number) => string) | undefined
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const body = useMemo(() => {
|
||||
@@ -21,8 +28,8 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
|
||||
} catch {
|
||||
s = String(payload)
|
||||
}
|
||||
return s.length > MAX_CHARS ? `${s.slice(0, MAX_CHARS)}\n… 已截断,共 ${s.length} 字符` : s
|
||||
}, [open, payload])
|
||||
return s.length > MAX_CHARS ? `${s.slice(0, MAX_CHARS)}\n${truncatedLabel(s.length)}` : s
|
||||
}, [open, payload, truncatedLabel])
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<button type="button" className={css.toggle} onClick={() => { setOpen(v => !v) }}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Visual baseline: deepsuite `@deepseek/md` markdown.css, adapted to CSS
|
||||
Modules. Cite pills, KaTeX, header anchors, and thinking-small variants are
|
||||
Modules. Cite pills, header anchors, and thinking-small variants are
|
||||
intentionally absent (no matching DOM). Token names match that sheet. */
|
||||
|
||||
.markdown {
|
||||
@@ -160,6 +160,12 @@
|
||||
font-family: var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
.markdown :global(.katex-display) {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.markdown input[type='checkbox'] {
|
||||
margin: 0 8px 0 0;
|
||||
accent-color: var(--dsw-alias-label-secondary);
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { isValidElement } from 'react'
|
||||
import { isValidElement, useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import type { Components, UrlTransform } from 'react-markdown'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import { CodeBlock } from './CodeBlock.tsx'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import css from './MarkdownText.module.css'
|
||||
|
||||
const remarkPlugins = [remarkGfm]
|
||||
const streamingRemarkPlugins = [remarkGfm]
|
||||
const settledRemarkPlugins = [remarkGfm, remarkMath]
|
||||
const settledRehypePlugins = [rehypeKatex]
|
||||
|
||||
function sanitizeUrl(url: string): string {
|
||||
try {
|
||||
@@ -24,6 +29,14 @@ function sanitizeUrl(url: string): string {
|
||||
|
||||
const safeUrl: UrlTransform = url => sanitizeUrl(url)
|
||||
|
||||
/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
|
||||
export interface MarkdownCodeLabels {
|
||||
/** Copy-button idle label. */
|
||||
copyLabel?: string | undefined
|
||||
/** Copy-button label during the post-copy confirmation window. */
|
||||
copiedLabel?: string | undefined
|
||||
}
|
||||
|
||||
function remoteImageUrl(url: string): string | undefined {
|
||||
try {
|
||||
const protocol = new URL(url).protocol
|
||||
@@ -34,7 +47,7 @@ function remoteImageUrl(url: string): string | undefined {
|
||||
}
|
||||
|
||||
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
|
||||
function buildComponents(streaming: boolean): Components {
|
||||
function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): Components {
|
||||
return {
|
||||
a: ({ href = '', children }) => {
|
||||
const safeHref = sanitizeUrl(href)
|
||||
@@ -84,7 +97,14 @@ function buildComponents(streaming: boolean): Components {
|
||||
// keeps the stock <pre> rather than guessing.
|
||||
if (typeof raw !== 'string') return <pre>{children}</pre>
|
||||
const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1]
|
||||
return <CodeBlock code={raw} lang={streaming ? undefined : lang} />
|
||||
return (
|
||||
<CodeBlock
|
||||
code={raw}
|
||||
lang={streaming ? undefined : lang}
|
||||
copyLabel={codeLabels?.copyLabel}
|
||||
copiedLabel={codeLabels?.copiedLabel}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -95,16 +115,32 @@ const streamingComponents = buildComponents(true)
|
||||
/**
|
||||
* Render untrusted assistant-authored Markdown as semantic React elements.
|
||||
* @param props - Markdown source text preserved by the session projection;
|
||||
* `streaming` renders fences plain (highlighting lands on the finalize swap).
|
||||
* @returns A GFM document with raw HTML, relative destinations, and unsafe
|
||||
* protocols disabled; absolute HTTP(S) images render directly.
|
||||
* `streaming` renders fences and TeX plain (highlighting and KaTeX land on the finalize swap);
|
||||
* `codeLabels` forwards localized copy-button labels to fence CodeBlocks —
|
||||
* pass a reference-stable object (memoized per locale revision), because the
|
||||
* component table memoizes on its identity and a fresh literal per render
|
||||
* would rebuild it every streaming chunk.
|
||||
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
|
||||
* relative links, and unsafe protocols are disabled, while absolute HTTP(S)
|
||||
* images render directly.
|
||||
*/
|
||||
export function MarkdownText({ text, streaming = false }: { text: string; streaming?: boolean }) {
|
||||
export function MarkdownText({ text, streaming = false, codeLabels }: {
|
||||
text: string
|
||||
streaming?: boolean
|
||||
codeLabels?: MarkdownCodeLabels | undefined
|
||||
}) {
|
||||
// The label-free tables stay module-level singletons so the common case
|
||||
// keeps referential stability across renders without a hook.
|
||||
const components = useMemo(() => {
|
||||
if (codeLabels === undefined) return streaming ? streamingComponents : staticComponents
|
||||
return buildComponents(streaming, codeLabels)
|
||||
}, [streaming, codeLabels])
|
||||
return (
|
||||
<div className={css.markdown}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
components={streaming ? streamingComponents : staticComponents}
|
||||
remarkPlugins={streaming ? streamingRemarkPlugins : settledRemarkPlugins}
|
||||
rehypePlugins={streaming ? undefined : settledRehypePlugins}
|
||||
components={components}
|
||||
urlTransform={safeUrl}
|
||||
>
|
||||
{text}
|
||||
|
||||
@@ -5,10 +5,17 @@
|
||||
* theme package's token sheets as `--shiki-*` custom properties (light and
|
||||
* dark blocks), never here — the repo's tokens-only styling rule.
|
||||
*
|
||||
* Grammars are the set the harness actually renders: TypeScript programs
|
||||
* (`run_code` bodies; TS pulls in JS via grammar embedding), shell commands,
|
||||
* and JSON payloads. An unknown or absent language falls back to plain text
|
||||
* (no highlighting, still monospace) — never an error.
|
||||
* Only the three markdown-fence and `run_code` grammars (TypeScript, shell,
|
||||
* JSON) load into the singleton at boot — the set every session renders. The
|
||||
* read card's wider extension set (the file-extension language hints the read
|
||||
* tool's `langFromPath` emits — `packages/fs/tool-fs`: python, rust, yaml,
|
||||
* markup, …) is imported lazily and registered the first time such a language
|
||||
* is requested, so a session that never opens a read card in one of those
|
||||
* languages pays neither the ~1.6 MB of grammar modules nor their synchronous
|
||||
* init. The first render of a lazy language falls back to plain text while its
|
||||
* grammar loads, then {@link onGrammarLoaded} notifies subscribers to re-render
|
||||
* with highlighting. An unknown or absent language falls back to plain text (no
|
||||
* highlighting, still monospace) — never an error.
|
||||
*/
|
||||
|
||||
import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core'
|
||||
@@ -17,12 +24,69 @@ import langTs from '@shikijs/langs/typescript'
|
||||
import langBash from '@shikijs/langs/shellscript'
|
||||
import langJson from '@shikijs/langs/json'
|
||||
import type { HighlighterCore } from 'shiki/core'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
/** A shiki grammar module's default export (a `LanguageRegistration[]`), taken
|
||||
* from a boot grammar so no direct `@shikijs/types` dependency is needed. */
|
||||
type LangModule = { default: typeof langTs }
|
||||
|
||||
/**
|
||||
* Language ids (and aliases) the singleton registers; everything else renders
|
||||
* Grammars the singleton loads at boot; each entry's own `name` is the id
|
||||
* `codeToTokens`/`codeToHtml` resolve. The JS-family aliases (js/jsx/ts/tsx)
|
||||
* resolve to the TypeScript grammar rather than a separate one: it tokenizes
|
||||
* plain TS/JS exactly, and JSX/TSX approximately (shiki's TS grammar is not the
|
||||
* dedicated TSX grammar, so JSX elements tokenize imperfectly) — an accepted
|
||||
* trade to keep the boot set to one JS-family grammar. The read card's wider
|
||||
* set loads lazily through {@link LAZY_GRAMMARS}.
|
||||
*/
|
||||
const LANGS = [langTs, langBash, langJson]
|
||||
|
||||
/**
|
||||
* The read card's extension grammars, each behind a dynamic import so its
|
||||
* module stays out of the boot chunk until a read of that language renders.
|
||||
* Keyed by the grammar id (`LanguageRegistration.name`) the aliases resolve to.
|
||||
* `@shikijs/langs`' default export is a `LanguageRegistration[]`; the loader
|
||||
* hands the whole array to `loadLanguageSync`, which registers each entry
|
||||
* (including embedded sub-grammars). The three boot grammars are absent —
|
||||
* already loaded, so no alias value ever points at a missing entry here.
|
||||
*/
|
||||
const LAZY_GRAMMARS = new Map<string, () => Promise<LangModule>>([
|
||||
['python', () => import('@shikijs/langs/python')],
|
||||
['ruby', () => import('@shikijs/langs/ruby')],
|
||||
['go', () => import('@shikijs/langs/go')],
|
||||
['rust', () => import('@shikijs/langs/rust')],
|
||||
['java', () => import('@shikijs/langs/java')],
|
||||
['c', () => import('@shikijs/langs/c')],
|
||||
['cpp', () => import('@shikijs/langs/cpp')],
|
||||
['csharp', () => import('@shikijs/langs/csharp')],
|
||||
['kotlin', () => import('@shikijs/langs/kotlin')],
|
||||
['swift', () => import('@shikijs/langs/swift')],
|
||||
['php', () => import('@shikijs/langs/php')],
|
||||
['yaml', () => import('@shikijs/langs/yaml')],
|
||||
['toml', () => import('@shikijs/langs/toml')],
|
||||
['ini', () => import('@shikijs/langs/ini')],
|
||||
['markdown', () => import('@shikijs/langs/markdown')],
|
||||
['mdx', () => import('@shikijs/langs/mdx')],
|
||||
['html', () => import('@shikijs/langs/html')],
|
||||
['css', () => import('@shikijs/langs/css')],
|
||||
['scss', () => import('@shikijs/langs/scss')],
|
||||
['less', () => import('@shikijs/langs/less')],
|
||||
['sql', () => import('@shikijs/langs/sql')],
|
||||
['xml', () => import('@shikijs/langs/xml')],
|
||||
['lua', () => import('@shikijs/langs/lua')],
|
||||
])
|
||||
|
||||
/**
|
||||
* Language ids (and aliases) the highlighter accepts; everything else renders
|
||||
* plain. A Map, not an object: fence info strings are assistant-authored, so
|
||||
* a label like `constructor` or `__proto__` must miss instead of resolving an
|
||||
* inherited property and crashing the renderer inside shiki.
|
||||
* inherited property and crashing the renderer inside shiki. Keys cover both
|
||||
* the markdown-fence aliases `CodeBlock` uses and the file-extension hint ids
|
||||
* the read tool's `langFromPath` emits, so both callers resolve the same
|
||||
* grammars. The JS family maps to the TypeScript grammar (see {@link LANGS} for
|
||||
* the JSX/TSX approximation), unchanged from when this was the only
|
||||
* non-shell/JSON grammar. A value not in {@link LANGS} names a
|
||||
* {@link LAZY_GRAMMARS} entry loaded on first use.
|
||||
*/
|
||||
const LANG_ALIASES = new Map<string, string>([
|
||||
['typescript', 'typescript'],
|
||||
@@ -30,6 +94,7 @@ const LANG_ALIASES = new Map<string, string>([
|
||||
['tsx', 'typescript'],
|
||||
['javascript', 'typescript'],
|
||||
['js', 'typescript'],
|
||||
['jsx', 'typescript'],
|
||||
['shellscript', 'shellscript'],
|
||||
['bash', 'shellscript'],
|
||||
['sh', 'shellscript'],
|
||||
@@ -37,6 +102,35 @@ const LANG_ALIASES = new Map<string, string>([
|
||||
['zsh', 'shellscript'],
|
||||
['json', 'json'],
|
||||
['jsonc', 'json'],
|
||||
['py', 'python'],
|
||||
['python', 'python'],
|
||||
['rb', 'ruby'],
|
||||
['ruby', 'ruby'],
|
||||
['go', 'go'],
|
||||
['rs', 'rust'],
|
||||
['rust', 'rust'],
|
||||
['java', 'java'],
|
||||
['c', 'c'],
|
||||
['cpp', 'cpp'],
|
||||
['cs', 'csharp'],
|
||||
['csharp', 'csharp'],
|
||||
['kotlin', 'kotlin'],
|
||||
['swift', 'swift'],
|
||||
['php', 'php'],
|
||||
['yaml', 'yaml'],
|
||||
['yml', 'yaml'],
|
||||
['toml', 'toml'],
|
||||
['ini', 'ini'],
|
||||
['md', 'markdown'],
|
||||
['markdown', 'markdown'],
|
||||
['mdx', 'mdx'],
|
||||
['html', 'html'],
|
||||
['css', 'css'],
|
||||
['scss', 'scss'],
|
||||
['less', 'less'],
|
||||
['sql', 'sql'],
|
||||
['xml', 'xml'],
|
||||
['lua', 'lua'],
|
||||
])
|
||||
|
||||
/** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */
|
||||
@@ -52,12 +146,68 @@ let singleton: HighlighterCore | undefined
|
||||
function highlighter(): HighlighterCore {
|
||||
singleton ??= createHighlighterCoreSync({
|
||||
themes: [cssVariablesTheme],
|
||||
langs: [langTs, langBash, langJson],
|
||||
langs: LANGS,
|
||||
engine: createJavaScriptRegexEngine({ forgiving: true }),
|
||||
})
|
||||
return singleton
|
||||
}
|
||||
|
||||
/** Grammar ids whose lazy import is in flight or done, so it is requested once. */
|
||||
const requested = new Set<string>()
|
||||
/** Subscribers re-rendered after a lazy grammar registers (React callers). */
|
||||
const listeners = new Set<() => void>()
|
||||
/** Bumped on each lazy-grammar load; the `useSyncExternalStore` snapshot. */
|
||||
let loadCount = 0
|
||||
|
||||
/**
|
||||
* Subscribe to lazy-grammar load completions; `listener` fires after a
|
||||
* {@link LAZY_GRAMMARS} grammar finishes registering on the singleton, so a
|
||||
* caller that rendered its plain fallback while the grammar loaded can
|
||||
* re-highlight. Shaped as a `useSyncExternalStore` subscribe: pair it with
|
||||
* {@link grammarLoadCount} as the snapshot. Returns an unsubscribe function.
|
||||
* @param listener - invoked (no args) on each grammar-load completion.
|
||||
* @returns a disposer that removes the listener.
|
||||
*/
|
||||
export function subscribeGrammarLoaded(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The lazy-grammar load counter — a value that changes on every load, so a
|
||||
* `useSyncExternalStore` snapshot re-renders the subscriber when a grammar
|
||||
* registers. Opaque: only its identity across renders matters.
|
||||
* @returns the current load count.
|
||||
*/
|
||||
export function grammarLoadCount(): number {
|
||||
return loadCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the grammar `resolved` names is registered. A boot grammar (not in
|
||||
* {@link LAZY_GRAMMARS}) and an already-loaded lazy grammar report ready
|
||||
* synchronously; a lazy grammar not yet loaded starts its import (once) and
|
||||
* reports not-ready, so the caller renders plain until a
|
||||
* {@link subscribeGrammarLoaded} listener fires.
|
||||
* @param resolved - the grammar id an alias resolved to.
|
||||
* @returns whether the grammar is registered and ready to tokenize now.
|
||||
*/
|
||||
function ensureGrammar(resolved: string): boolean {
|
||||
const load = LAZY_GRAMMARS.get(resolved)
|
||||
// A boot grammar (already registered) has no lazy loader; it is always ready.
|
||||
if (load === undefined) return true
|
||||
if (highlighter().getLoadedLanguages().includes(resolved)) return true
|
||||
if (!requested.has(resolved)) {
|
||||
requested.add(resolved)
|
||||
void load().then((mod) => {
|
||||
highlighter().loadLanguageSync(mod.default)
|
||||
loadCount += 1
|
||||
for (const listener of listeners) listener()
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Engine + grammar construction costs a long task (~120-175ms); building it
|
||||
// during the first finalized fence's render would jank exactly when a stream
|
||||
// completes. Warm the singleton in a deferred task at module load (= plugin
|
||||
@@ -70,13 +220,59 @@ const warmupTimer = setTimeout(() => { highlighter() }, 0)
|
||||
/**
|
||||
* Highlight `code` into shiki's HTML (a single `<pre class="shiki">` tree)
|
||||
* when `lang` maps to a registered grammar; `undefined` means the caller
|
||||
* renders its plain fallback.
|
||||
* renders its plain fallback. A lazy grammar not yet loaded returns `undefined`
|
||||
* for this call and loads in the background; subscribe with
|
||||
* {@link onGrammarLoaded} to re-highlight once it registers.
|
||||
* @param code - the source text.
|
||||
* @param lang - the language hint (a markdown fence info string or a fixed caller id).
|
||||
* @returns the highlighted HTML, or `undefined` for unknown languages.
|
||||
* @returns the highlighted HTML, or `undefined` for unknown or not-yet-loaded languages.
|
||||
*/
|
||||
export function highlightToHtml(code: string, lang: string | undefined): string | undefined {
|
||||
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
|
||||
if (resolved === undefined) return undefined
|
||||
if (!ensureGrammar(resolved)) return undefined
|
||||
return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
|
||||
}
|
||||
|
||||
/**
|
||||
* One highlighted run of a line: the text and the inline style shiki assigned
|
||||
* it. The css-variables theme colors every run through a `--shiki-*` custom
|
||||
* property, so `style.color` is always present; it is held as a style object
|
||||
* rather than a bare color so a run spreads onto a `<span style>` uniformly.
|
||||
*/
|
||||
export interface HighlightSpan {
|
||||
text: string
|
||||
style: CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize `code` into per-line highlighted runs when `lang` maps to a
|
||||
* registered grammar; `undefined` means the caller renders its plain fallback.
|
||||
* A line-numbered view needs the token runs split per line (one gutter number
|
||||
* per line), which the single-`<pre>` {@link highlightToHtml} does not expose,
|
||||
* so this returns shiki's own 2D line/token structure narrowed to what a run
|
||||
* renders. Each run's color is a `--shiki-*` custom property, keeping token
|
||||
* colors on the theme package's sheets exactly as the HTML path does; the
|
||||
* css-variables theme carries no font-style bits, matching that path's
|
||||
* color-only output. The trailing newline shiki appends as a final empty line
|
||||
* is dropped so the run count matches the caller's own line array.
|
||||
* @param code - the source text.
|
||||
* @param lang - the language hint (a file-extension-derived language id).
|
||||
* @returns one entry per source line (each an array of runs), or `undefined` for unknown or not-yet-loaded languages.
|
||||
*/
|
||||
export function highlightLines(code: string, lang: string | undefined): HighlightSpan[][] | undefined {
|
||||
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
|
||||
if (resolved === undefined) return undefined
|
||||
if (!ensureGrammar(resolved)) return undefined
|
||||
const { tokens } = highlighter().codeToTokens(code, { lang: resolved, theme: 'css-variables' })
|
||||
// shiki tokenizes `a\nb` into two lines; a trailing newline (`a\n`) adds a
|
||||
// third, empty line the caller's own line array does not carry. Drop that
|
||||
// one terminator line so the two structures stay in step. The explicit
|
||||
// `last !== undefined` (over `tokens[...]?.length`) keeps a single branch for
|
||||
// per-file coverage, matching TerminalBlock's terminator check.
|
||||
const last = tokens[tokens.length - 1]
|
||||
const lines = tokens.length > 1 && last !== undefined && last.length === 0
|
||||
? tokens.slice(0, -1)
|
||||
: tokens
|
||||
return lines.map(line => line.map(token => ({ text: token.content, style: { color: token.color } })))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user