Files
EvoBGP/apps/web/src/components/reui/code-block/code-block-highlight.tsx
T
Denozordec a3562cc11e chore(web): update vendored ReUI components to latest registry
Обновлены вендоренные компоненты ReUI (base-nova) до актуальных версий
реестра: семейство data-grid (плюс новые data-grid-i18n и
data-grid-cell-selection), filters, cascader, frame, kanban, stepper,
timeline, badge, alert, icon-tile, icon-stack и новый code-block
(подсветка на shiki). Shadcn-примитивы в packages/ui обновлены тем же
запуском CLI. Импорты "cn" из новых файлов переведены на алиас
@evobgp/ui/lib/utils, пакет cn из dependencies удалён, локальные
eslint-disable в вендоренных файлах поправлены.
2026-09-25 21:45:56 +07:00

1159 lines
36 KiB
TypeScript

import type { ReactNode } from "react"
import type { ShikiTransformer } from "shiki"
/**
* One themed slice of a line. `color`/`colorDark` emit as `--cb-c`/`--cb-cd`
* custom properties, so one rule pair on the `<pre>` theme-switches the whole
* block. An uncoloured token keeps both undefined and renders as a bare text
* node with no wrapper.
*/
export type CodeBlockToken = {
content: string
color?: string
colorDark?: string
fontStyle?: CodeBlockFontStyle
word?: boolean
}
export type CodeBlockFontStyle = "italic" | "bold" | "underline"
export type CodeBlockDiffKind = "add" | "remove"
export type CodeBlockLevel = "error" | "warning" | "info"
/**
* Per-line presentation state, resolved once at highlight time.
*
* Two sources merge here: the props on the root, and the classes a shiki
* transformer put on the line, so `@shikijs/transformers` notation such as
* `[!code ++]` reaches the same place without the renderer knowing shiki
* exists. Props win on conflict, because notation lives in the source string,
* which a consumer often does not control.
*/
export type CodeBlockLineState = {
highlighted?: boolean
diff?: CodeBlockDiffKind
focused?: boolean
level?: CodeBlockLevel
}
/**
* A rendered line. `number` is the DISPLAYED number (offset by `startLine`);
* line specs address source lines, so changing `startLine` never invalidates
* them. Instances are reused across passes when unchanged, which is what lets
* the row be a plain `React.memo` and a streamed chunk cost one re-render.
*/
export type CodeBlockLine = {
tokens: CodeBlockToken[]
number: number
text: string
state?: CodeBlockLineState
/**
* Replaces the counter-driven gutter number for this row - a unified patch
* shows "old new" pairs, a hunk header shows dots. Rendered verbatim.
*/
gutter?: string
}
/** Source lines, as `[2, 3, 4]` or as a range string such as `"2-4,7"`. */
export type CodeBlockLineSpec = number[] | string
export type CodeBlockDiffSpec = {
added?: CodeBlockLineSpec
removed?: CodeBlockLineSpec
}
export type CodeBlockLevelSpec = Partial<
Record<CodeBlockLevel, CodeBlockLineSpec>
>
/** A word to mark, optionally restricted to some source lines. */
export type CodeBlockWordSpec =
| string
| { word: string; lines?: CodeBlockLineSpec }
export type CodeBlockThemes = { light: string; dark: string }
/**
* shiki's own transformer type, imported type-only so it erases at compile
* time and costs the bundle nothing. The previous structural stand-in
* (`Record<string, unknown>`) rejected every real `ShikiTransformer`: an
* interface without an index signature is not assignable to it.
*/
export type CodeBlockTransformer = ShikiTransformer
export type CodeBlockHighlightOptions = {
language?: string
themes?: CodeBlockThemes
transformers?: CodeBlockTransformer[]
startLine?: number
/**
* Distinguishes same-signature blocks in the line-reuse cache. Without it,
* two identically-configured streams evict each other's previous document.
* The root passes its own instance id.
*/
instanceKey?: string
highlightedLines?: CodeBlockLineSpec
highlightedWords?: CodeBlockWordSpec[]
focusedLines?: CodeBlockLineSpec
diff?: CodeBlockDiffSpec
lineLevels?: CodeBlockLevelSpec
}
/** What a `CodeBlockLineActions` render prop receives. */
export type CodeBlockLineActionContext = {
line: number
text: string
state?: CodeBlockLineState
}
export type CodeBlockLineActionsRender = (
context: CodeBlockLineActionContext
) => ReactNode
/* -------------------------------------------------------------------------- */
/* Languages */
/* -------------------------------------------------------------------------- */
/**
* A STATIC map, never ``import(`shiki/langs/${lang}.mjs`)``: a template import
* makes bundlers bundle all ~200 grammars. Written out, each entry is its own
* lazy chunk. To add a language, add a line: that is the intended extension
* point of this file.
*/
export const codeBlockLanguages: Record<string, () => Promise<unknown>> = {
bash: () => import("shiki/langs/bash.mjs"),
c: () => import("shiki/langs/c.mjs"),
cpp: () => import("shiki/langs/cpp.mjs"),
csharp: () => import("shiki/langs/csharp.mjs"),
css: () => import("shiki/langs/css.mjs"),
diff: () => import("shiki/langs/diff.mjs"),
docker: () => import("shiki/langs/docker.mjs"),
go: () => import("shiki/langs/go.mjs"),
graphql: () => import("shiki/langs/graphql.mjs"),
html: () => import("shiki/langs/html.mjs"),
java: () => import("shiki/langs/java.mjs"),
javascript: () => import("shiki/langs/javascript.mjs"),
json: () => import("shiki/langs/json.mjs"),
jsx: () => import("shiki/langs/jsx.mjs"),
kotlin: () => import("shiki/langs/kotlin.mjs"),
markdown: () => import("shiki/langs/markdown.mjs"),
php: () => import("shiki/langs/php.mjs"),
python: () => import("shiki/langs/python.mjs"),
ruby: () => import("shiki/langs/ruby.mjs"),
rust: () => import("shiki/langs/rust.mjs"),
scss: () => import("shiki/langs/scss.mjs"),
shell: () => import("shiki/langs/shellscript.mjs"),
sql: () => import("shiki/langs/sql.mjs"),
swift: () => import("shiki/langs/swift.mjs"),
toml: () => import("shiki/langs/toml.mjs"),
tsx: () => import("shiki/langs/tsx.mjs"),
typescript: () => import("shiki/langs/typescript.mjs"),
vue: () => import("shiki/langs/vue.mjs"),
yaml: () => import("shiki/langs/yaml.mjs"),
}
/** Spellings a consumer is likely to pass, mapped onto the map above. */
const LANGUAGE_ALIASES: Record<string, string> = {
"c++": "cpp",
"c#": "csharp",
cs: "csharp",
dockerfile: "docker",
htm: "html",
js: "javascript",
jsonc: "json",
md: "markdown",
mdx: "markdown",
py: "python",
rb: "ruby",
rs: "rust",
sh: "shell",
shellscript: "shell",
ts: "typescript",
yml: "yaml",
zsh: "shell",
}
export const codeBlockThemes: Record<string, () => Promise<unknown>> = {
"github-light": () => import("shiki/themes/github-light.mjs"),
"github-dark": () => import("shiki/themes/github-dark.mjs"),
/**
* Design-token theming: every token colour becomes a `var(--code-token-*)`
* reference, so the palette lives in the consumer's stylesheet and follows
* their themes. Pass as BOTH sides. Variables consumed (prefix `--code-`):
* foreground, token-constant, token-string, token-comment, token-keyword,
* token-parameter, token-function, token-string-expression,
* token-punctuation, token-link.
*/
"css-variables": async () => {
const { createCssVariablesTheme } = await import("shiki/core")
return createCssVariablesTheme({
name: "css-variables",
variablePrefix: "--code-",
fontStyle: true,
})
},
}
export const DEFAULT_CODE_BLOCK_THEMES: CodeBlockThemes = {
light: "github-light",
dark: "github-dark",
}
/** Resolves an alias and reports whether the grammar is actually available. */
export function resolveCodeBlockLanguage(
language?: string
): string | undefined {
if (!language) return undefined
const normalized = language.trim().toLowerCase()
const resolved = LANGUAGE_ALIASES[normalized] ?? normalized
return resolved in codeBlockLanguages ? resolved : undefined
}
/* -------------------------------------------------------------------------- */
/* Pure helpers */
/* -------------------------------------------------------------------------- */
/**
* Turns `[2, 3]` or `"2-4,7"` into a set of source line numbers.
*
* Deliberately total: a reversed range, a negative bound or outright garbage
* yields an empty set rather than throwing. These values often come from user
* content or from a model, and a code viewer that crashes on a bad range is
* worse than one that shows no highlight.
*/
export function parseLineSpec(spec?: CodeBlockLineSpec): Set<number> {
const out = new Set<number>()
if (!spec) return out
if (Array.isArray(spec)) {
for (const value of spec) {
if (Number.isInteger(value) && value > 0) out.add(value)
}
return out
}
for (const part of spec.split(",")) {
const trimmed = part.trim()
if (!trimmed) continue
const range = trimmed.match(/^(\d+)\s*-\s*(\d+)$/)
if (range) {
const start = Number(range[1])
const end = Number(range[2])
if (start > 0 && end >= start) {
for (let line = start; line <= end; line += 1) out.add(line)
}
continue
}
if (/^\d+$/.test(trimmed)) {
const single = Number(trimmed)
if (single > 0) out.add(single)
}
}
return out
}
/** Normalises line endings so every downstream offset is LF-based. */
export function normalizeCode(code: string): string {
return code.replace(/\r\n?/g, "\n")
}
/**
* Character ranges for `highlightedWords`, as shiki `decorations`.
*
* shiki rejects overlapping decorations, so a later match that would overlap an
* earlier one is dropped rather than passed through to throw.
*/
export function buildWordDecorations(
code: string,
words?: CodeBlockWordSpec[]
): { start: number; end: number; properties: { class: string } }[] {
if (!words?.length) return []
const lineStarts: number[] = [0]
for (let index = 0; index < code.length; index += 1) {
if (code[index] === "\n") lineStarts.push(index + 1)
}
const lineAt = (offset: number) => {
let low = 0
let high = lineStarts.length - 1
while (low < high) {
const mid = Math.ceil((low + high) / 2)
if (lineStarts[mid] <= offset) low = mid
else high = mid - 1
}
return low + 1
}
const taken: { start: number; end: number }[] = []
const out: { start: number; end: number; properties: { class: string } }[] =
[]
for (const entry of words) {
const word = typeof entry === "string" ? entry : entry.word
if (!word) continue
const limit =
typeof entry === "string" ? undefined : parseLineSpec(entry.lines)
let from = code.indexOf(word)
while (from !== -1) {
const to = from + word.length
const withinLimit = !limit || limit.size === 0 || limit.has(lineAt(from))
const overlaps = taken.some((r) => from < r.end && to > r.start)
if (withinLimit && !overlaps) {
taken.push({ start: from, end: to })
out.push({ start: from, end: to, properties: { class: "cb-word" } })
}
from = code.indexOf(word, from + word.length)
}
}
return out.sort((a, b) => a.start - b.start)
}
/**
* Strips `[!code ...]` notation from a copy payload: the transformer already
* drops it from RENDERED output, but the raw `code` string would paste the
* comment into someone's editor.
*/
export function stripNotationComments(code: string): string {
return code
.split("\n")
.map((line) =>
line.replace(
/\s*(?:\/\/|#|--|;|%|<!--|\/\*)\s*\[!code[^\]]*\]\s*(?:-->|\*\/)?\s*$/,
""
)
)
.join("\n")
}
/**
* Plain, unhighlighted lines. One token per line, no colour.
*
* This is both the `highlight={false}` renderer and the first paint of a
* streaming block, so the tail of a stream is readable before its grammar pass
* lands and no frame is ever blank.
*/
export function toPlainLines(code: string, startLine = 1): CodeBlockLine[] {
return normalizeCode(code)
.split("\n")
.map((text, index) => ({
tokens: text ? [{ content: text }] : [],
number: startLine + index,
text,
}))
}
/**
* Pulls `code` and `language` out of the props react-markdown gives a `pre`:
* the glue every AI chat app writes by hand, shipped here instead. Tolerant by
* construction, because a still-streaming fence has no closing delimiter and
* often no language yet, and must render as plain text rather than throw.
*/
export function markdownCodeProps(props: {
children?: ReactNode
className?: string
}): { code: string; language?: string } {
let language: string | undefined
let code = ""
const readClassName = (value: unknown) => {
if (typeof value !== "string") return
const match = value.match(/(?:^|\s)language-([\w+#-]+)/)
if (match && !language) language = match[1]
}
const walk = (node: unknown): void => {
if (node === null || node === undefined || node === false) return
if (typeof node === "string") {
code += node
return
}
if (typeof node === "number") {
code += String(node)
return
}
if (Array.isArray(node)) {
for (const child of node) walk(child)
return
}
if (typeof node === "object" && "props" in (node as object)) {
const nodeProps = (node as { props?: Record<string, unknown> }).props
if (!nodeProps) return
readClassName(nodeProps.className)
walk(nodeProps.children)
}
}
readClassName(props.className)
walk(props.children)
return { code: code.replace(/\n$/, ""), language }
}
/** One segment of a markdown string: prose, or a fenced code block. */
export type CodeBlockMarkdownPart = {
type: "text" | "code"
content: string
language?: string
/** True for a fence whose closing delimiter has not arrived yet. */
open: boolean
}
/**
* Splits markdown into prose and fenced code, for transcripts that render a
* raw assistant message without a markdown dependency. The unterminated
* trailing fence is the point: mid-stream it comes back as a code part flagged
* `open`, instead of being dropped or read as prose.
*/
export function markdownFences(markdown: string): CodeBlockMarkdownPart[] {
const parts: CodeBlockMarkdownPart[] = []
const lines = markdown.split("\n")
let inFence = false
let opener = ""
let language: string | undefined
let buffer: string[] = []
const flushText = () => {
const text = buffer.join("\n").trim()
if (text) parts.push({ type: "text", content: text, open: false })
buffer = []
}
const flushCode = (open: boolean) => {
parts.push({
type: "code",
content: buffer.join("\n"),
language,
open,
})
buffer = []
language = undefined
}
for (const line of lines) {
/* CommonMark fences: three or more backticks OR tildes. The closer must
repeat the opener's character at least as many times, or a \`\`\`\`
fence containing \`\`\` examples would close three lines early. */
const fence = line.match(/^\s*(`{3,}|~{3,})([\w+#-]*)\s*$/)
if (fence && !inFence) {
flushText()
inFence = true
opener = fence[1]
language = fence[2] || undefined
continue
}
if (
fence &&
inFence &&
fence[1][0] === opener[0] &&
fence[1].length >= opener.length &&
!fence[2]
) {
flushCode(false)
inFence = false
continue
}
buffer.push(line)
}
/* A fence still open at the end of the string is the streaming case: the
closing delimiter has not arrived. Reporting it as code with `open` set is
what lets a transcript render the partial block instead of dropping it. */
if (inFence) flushCode(true)
else flushText()
return parts
}
/* -------------------------------------------------------------------------- */
/* Engine */
/* -------------------------------------------------------------------------- */
type HighlighterLike = {
codeToHast: (code: string, options: Record<string, unknown>) => unknown
getLoadedLanguages: () => string[]
loadLanguage: (lang: unknown) => Promise<void>
loadTheme: (theme: unknown) => Promise<void>
}
let highlighterPromise: Promise<HighlighterLike> | null = null
const loadedLanguages = new Set<string>()
const loadedThemes = new Set<string>()
/**
* One highlighter per page, on the JavaScript regex engine: oniguruma needs
* WebAssembly, which forces `'wasm-unsafe-eval'` into every consumer's CSP.
* `forgiving` keeps an inexpressible grammar pattern from taking the block
* down.
*/
async function loadHighlighter() {
if (!highlighterPromise) {
highlighterPromise = (async () => {
const [{ createHighlighterCore }, { createJavaScriptRegexEngine }] =
await Promise.all([
import("shiki/core"),
import("shiki/engine/javascript"),
])
return (await createHighlighterCore({
themes: [],
langs: [],
engine: createJavaScriptRegexEngine({ forgiving: true }),
})) as unknown as HighlighterLike
})()
}
return highlighterPromise
}
/**
* Themes register per name, on demand, exactly like languages.
*
* Baking the FIRST caller's pair into the singleton looks correct until a page
* holds two blocks with different `themes` props: the second silently renders
* in the first one's colours, because the singleton never learns about the
* request. Loading by name makes every block's prop actually mean something.
*/
const warnedThemes = new Set<string>()
async function ensureTheme(
highlighter: HighlighterLike,
name: string,
side: "light" | "dark" = "light"
) {
/* Falls back to the MATCHING side (an unknown dark theme used to fall back
to github-light, silently rendering light colours in dark mode). */
const resolved =
name in codeBlockThemes ? name : DEFAULT_CODE_BLOCK_THEMES[side]
if (
resolved !== name &&
process.env.NODE_ENV !== "production" &&
!warnedThemes.has(name)
) {
warnedThemes.add(name)
console.warn(
`[code-block] Unknown theme "${name}" - falling back to "${resolved}". ` +
"Register it in codeBlockThemes to use it."
)
}
if (loadedThemes.has(resolved)) return resolved
await highlighter.loadTheme(await codeBlockThemes[resolved]())
loadedThemes.add(resolved)
return resolved
}
async function ensureLanguage(highlighter: HighlighterLike, language: string) {
if (loadedLanguages.has(language)) return
const loader = codeBlockLanguages[language]
if (!loader) return
await highlighter.loadLanguage(await loader())
loadedLanguages.add(language)
}
/* -------------------------------------------------------------------------- */
/* hast to normalised lines */
/* -------------------------------------------------------------------------- */
type HastNode = {
type: string
tagName?: string
value?: string
properties?: Record<string, unknown>
children?: HastNode[]
}
const FONT_STYLE_BY_DECLARATION: Record<string, CodeBlockFontStyle> = {
"font-style:italic": "italic",
"font-weight:bold": "bold",
"text-decoration:underline": "underline",
}
/** Splits shiki's inline `style` string into the fields a token carries. */
function readTokenStyle(style: unknown): Omit<CodeBlockToken, "content"> {
if (typeof style !== "string") return {}
const out: Omit<CodeBlockToken, "content"> = {}
for (const declaration of style.split(";")) {
const trimmed = declaration.trim()
if (!trimmed) continue
const separator = trimmed.indexOf(":")
if (separator === -1) continue
const property = trimmed.slice(0, separator).trim()
const value = trimmed.slice(separator + 1).trim()
if (property === "color") out.color = value
else if (property === "--shiki-dark") out.colorDark = value
else {
const fontStyle = FONT_STYLE_BY_DECLARATION[`${property}:${value}`]
if (fontStyle) out.fontStyle = fontStyle
}
}
return out
}
/**
* shiki emits raw `class` (string or array), not hast's `className`; reading
* only `className` finds nothing, which looks like a transformer that never
* ran.
*/
function classListOf(node: HastNode): string[] {
const value = node.properties?.class ?? node.properties?.className
if (Array.isArray(value)) return value.map(String)
if (typeof value === "string") return value.split(/\s+/).filter(Boolean)
return []
}
/** Maps the classes shiki transformers put on a line onto line state. */
function stateFromClasses(classes: string[]): CodeBlockLineState | undefined {
const state: CodeBlockLineState = {}
if (classes.includes("highlighted")) state.highlighted = true
if (classes.includes("focused")) state.focused = true
if (classes.includes("diff")) {
if (classes.includes("add")) state.diff = "add"
else if (classes.includes("remove")) state.diff = "remove"
}
for (const level of ["error", "warning", "info"] as const) {
if (classes.includes(level)) state.level = level
}
return Object.keys(state).length ? state : undefined
}
function collectTokens(
node: HastNode,
out: CodeBlockToken[],
inWord: boolean
): void {
for (const child of node.children ?? []) {
if (child.type === "text") {
if (!child.value) continue
out.push(
inWord ? { content: child.value, word: true } : { content: child.value }
)
continue
}
if (child.type !== "element") continue
const classes = classListOf(child)
const childInWord = inWord || classes.includes("cb-word")
const style = readTokenStyle(child.properties?.style)
const hasStyle = Boolean(style.color || style.colorDark || style.fontStyle)
/* A styled leaf is a token; a wrapper (a decoration span) is descended into
so its own children keep their individual colours. */
const onlyText = (child.children ?? []).every(
(grandChild) => grandChild.type === "text"
)
if (hasStyle && onlyText) {
const content = (child.children ?? []).map((c) => c.value ?? "").join("")
if (!content) continue
out.push({
content,
...style,
...(childInWord ? { word: true } : {}),
})
continue
}
collectTokens(child, out, childInWord)
}
}
function findCodeElement(root: HastNode): HastNode | undefined {
if (root.type === "element" && root.tagName === "code") return root
for (const child of root.children ?? []) {
const found = findCodeElement(child)
if (found) return found
}
return undefined
}
/* -------------------------------------------------------------------------- */
/* Identity reuse */
/* -------------------------------------------------------------------------- */
const MAX_TRACKED_DOCUMENTS = 24
/**
* Previous result per option signature, so a growing stream can reuse lines.
* Keyed WITH the caller's instanceKey: without it, two same-configured blocks
* evicted each other's entry on every interleaved pass. Bounded above because
* module state outlives requests on the server.
*/
const previousDocuments = new Map<string, CodeBlockLine[]>()
function sameLine(a: CodeBlockLine, b: CodeBlockLine): boolean {
if (a.number !== b.number || a.text !== b.text) return false
if (a.tokens.length !== b.tokens.length) return false
if (JSON.stringify(a.state ?? null) !== JSON.stringify(b.state ?? null)) {
return false
}
for (let index = 0; index < a.tokens.length; index += 1) {
const left = a.tokens[index]
const right = b.tokens[index]
if (
left.content !== right.content ||
left.color !== right.color ||
left.colorDark !== right.colorDark ||
left.fontStyle !== right.fontStyle ||
left.word !== right.word
) {
return false
}
}
return true
}
/**
* Swaps freshly built lines for the previous pass's objects where nothing
* changed. This is why streaming is cheap: the row is a reference-equality
* `memo`, so without this, appending one token to a 400 line file re-renders
* 400 subtrees per chunk.
*/
function reuseUnchangedLines(
key: string,
next: CodeBlockLine[]
): CodeBlockLine[] {
const previous = previousDocuments.get(key)
if (previous) {
for (let index = 0; index < next.length; index += 1) {
const before = previous[index]
if (before && sameLine(before, next[index])) next[index] = before
}
}
previousDocuments.set(key, next)
if (previousDocuments.size > MAX_TRACKED_DOCUMENTS) {
const oldest = previousDocuments.keys().next().value
if (oldest !== undefined) previousDocuments.delete(oldest)
}
return next
}
/* -------------------------------------------------------------------------- */
/* highlightCode */
/* -------------------------------------------------------------------------- */
function applyPropState(
lines: CodeBlockLine[],
options: CodeBlockHighlightOptions
): void {
const highlighted = parseLineSpec(options.highlightedLines)
const focused = parseLineSpec(options.focusedLines)
const added = parseLineSpec(options.diff?.added)
const removed = parseLineSpec(options.diff?.removed)
const levels = {
error: parseLineSpec(options.lineLevels?.error),
warning: parseLineSpec(options.lineLevels?.warning),
info: parseLineSpec(options.lineLevels?.info),
}
lines.forEach((line, index) => {
const source = index + 1
const state: CodeBlockLineState = { ...line.state }
if (highlighted.has(source)) state.highlighted = true
if (focused.has(source)) state.focused = true
if (added.has(source)) state.diff = "add"
else if (removed.has(source)) state.diff = "remove"
for (const level of ["error", "warning", "info"] as const) {
if (levels[level].has(source)) state.level = level
}
line.state = Object.keys(state).length ? state : undefined
})
}
/**
* Highlights `code` into the renderer's line shape. Built on `codeToHast` so
* consumer transformers run and their line classes land in `line.state`,
* making prop state and `[!code ++]` notation one feature, not two code
* paths. Safe in a server component: the result is plain JSON.
*/
export async function highlightCode(
code: string,
options: CodeBlockHighlightOptions = {}
): Promise<CodeBlockLine[]> {
const source = normalizeCode(code)
const startLine = options.startLine ?? 1
const language = resolveCodeBlockLanguage(options.language)
if (!language) return toPlainLines(source, startLine)
const themes = options.themes ?? DEFAULT_CODE_BLOCK_THEMES
const signature = JSON.stringify([
options.instanceKey ?? null,
language,
themes,
startLine,
options.highlightedLines ?? null,
options.highlightedWords ?? null,
options.focusedLines ?? null,
options.diff ?? null,
options.lineLevels ?? null,
(options.transformers ?? []).length,
])
let root: HastNode
try {
const highlighter = await loadHighlighter()
const [light, dark] = await Promise.all([
ensureTheme(highlighter, themes.light),
ensureTheme(highlighter, themes.dark),
])
await ensureLanguage(highlighter, language)
root = highlighter.codeToHast(source, {
lang: language,
themes: { light, dark },
defaultColor: "light",
cssVariablePrefix: "--shiki-",
decorations: buildWordDecorations(source, options.highlightedWords),
...(options.transformers?.length
? { transformers: options.transformers }
: {}),
}) as HastNode
} catch {
/* A missing grammar, an unloadable theme or a transformer throwing must not
take the surface down. Plain text is always readable. */
return toPlainLines(source, startLine)
}
const codeElement = findCodeElement(root)
if (!codeElement) return toPlainLines(source, startLine)
const lines: CodeBlockLine[] = []
for (const child of codeElement.children ?? []) {
if (child.type !== "element") continue
const tokens: CodeBlockToken[] = []
collectTokens(child, tokens, false)
lines.push({
tokens,
number: startLine + lines.length,
text: tokens.map((token) => token.content).join(""),
state: stateFromClasses(classListOf(child)),
})
}
if (!lines.length) return toPlainLines(source, startLine)
applyPropState(lines, options)
return reuseUnchangedLines(signature, lines)
}
/** Test seam: drops the singleton and every cached document. */
export function resetCodeBlockHighlighter(): void {
highlighterPromise = null
loadedLanguages.clear()
loadedThemes.clear()
previousDocuments.clear()
}
/* -------------------------------------------------------------------------- */
/* ANSI */
/* -------------------------------------------------------------------------- */
/**
* The 16 SGR slots as CSS variables with readable defaults per theme, so a
* consumer retints terminal output next to their other design tokens
* (`--code-ansi-red`, `--code-ansi-bright-blue`, ...). 256-colour and
* truecolor sequences bypass the palette and emit their literal colour.
*/
const ANSI_SLOTS = [
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
"bright-black",
"bright-red",
"bright-green",
"bright-yellow",
"bright-blue",
"bright-magenta",
"bright-cyan",
"bright-white",
] as const
const ANSI_LIGHT = [
"#3f3f46",
"#dc2626",
"#16a34a",
"#a16207",
"#2563eb",
"#9333ea",
"#0891b2",
"#71717a",
"#52525b",
"#ef4444",
"#22c55e",
"#ca8a04",
"#3b82f6",
"#a855f7",
"#06b6d4",
"#a1a1aa",
]
const ANSI_DARK = [
"#a1a1aa",
"#f87171",
"#4ade80",
"#facc15",
"#60a5fa",
"#c084fc",
"#22d3ee",
"#e4e4e7",
"#71717a",
"#fca5a5",
"#86efac",
"#fde047",
"#93c5fd",
"#d8b4fe",
"#67e8f9",
"#fafafa",
]
const ansiVar = (slot: number, fallback: string) =>
`var(--code-ansi-${ANSI_SLOTS[slot]}, ${fallback})`
/** xterm 256-colour index to hex, computed rather than tabled. */
function ansi256(index: number): string {
if (index < 16) return ANSI_DARK[index]
if (index >= 232) {
const v = 8 + (index - 232) * 10
const h = v.toString(16).padStart(2, "0")
return `#${h}${h}${h}`
}
const cube = [0, 95, 135, 175, 215, 255]
const n = index - 16
const to = (v: number) => cube[v].toString(16).padStart(2, "0")
return `#${to(Math.floor(n / 36))}${to(Math.floor(n / 6) % 6)}${to(n % 6)}`
}
// eslint-disable-next-line no-control-regex
const SGR_RE = /\x1b\[([0-9;]*)m/g
// eslint-disable-next-line no-control-regex
/* Everything except SGR (the trailing `m`), which the tokenizer consumes -
this regex once matched SGR too and silently stripped every colour. */
const OTHER_ESCAPES_RE =
// eslint-disable-next-line no-control-regex
/\x1b(?:\[(?![0-9;]*m)[0-9;?]*[A-Za-z]|\][^\x07]*(?:\x07|\x1b\\)|[()][0-9A-B])/g
type AnsiStyle = {
color?: string
colorDark?: string
bold?: boolean
italic?: boolean
underline?: boolean
}
/**
* Terminal output with SGR colour codes, as renderable lines: feed the result
* to the `lines` prop. Covers what agent stdout actually uses - 30-37 / 90-97
* foregrounds, 38;5;n and 38;2;r;g;b, bold, italic, underline and resets.
* Backgrounds and cursor movements are STRIPPED rather than rendered: a code
* surface has its own background, and a partial screen-drawing stream is
* better read as text than half-drawn.
*/
export function ansiToLines(text: string, startLine = 1): CodeBlockLine[] {
const clean = normalizeCode(text).replace(OTHER_ESCAPES_RE, "")
return clean.split("\n").map((raw, index) => {
const tokens: CodeBlockToken[] = []
const style: AnsiStyle = {}
let plain = ""
let last = 0
const flush = (content: string) => {
if (!content) return
const fontStyle = style.bold
? ("bold" as const)
: style.italic
? ("italic" as const)
: style.underline
? ("underline" as const)
: undefined
tokens.push({
content,
color: style.color,
colorDark: style.colorDark,
fontStyle,
})
}
SGR_RE.lastIndex = 0
let match: RegExpExecArray | null
while ((match = SGR_RE.exec(raw))) {
flush(raw.slice(last, match.index))
plain += raw.slice(last, match.index)
last = match.index + match[0].length
const params = (match[1] || "0").split(";").map(Number)
for (let i = 0; i < params.length; i += 1) {
const code = params[i]
if (code === 0) {
delete style.color
delete style.colorDark
style.bold = style.italic = style.underline = false
} else if (code === 1) style.bold = true
else if (code === 3) style.italic = true
else if (code === 4) style.underline = true
else if (code === 22) style.bold = false
else if (code === 23) style.italic = false
else if (code === 24) style.underline = false
else if (code === 39) {
delete style.color
delete style.colorDark
} else if (code >= 30 && code <= 37) {
style.color = ansiVar(code - 30, ANSI_LIGHT[code - 30])
style.colorDark = ansiVar(code - 30, ANSI_DARK[code - 30])
} else if (code >= 90 && code <= 97) {
style.color = ansiVar(code - 82, ANSI_LIGHT[code - 82])
style.colorDark = ansiVar(code - 82, ANSI_DARK[code - 82])
} else if (code === 38 && params[i + 1] === 5) {
const hex = ansi256(params[i + 2] ?? 0)
style.color = style.colorDark = hex
i += 2
} else if (code === 38 && params[i + 1] === 2) {
const [r, g, b] = [
params[i + 2] ?? 0,
params[i + 3] ?? 0,
params[i + 4] ?? 0,
]
const hex = `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`
style.color = style.colorDark = hex
i += 4
} else if (
code === 48 &&
(params[i + 1] === 5 || params[i + 1] === 2)
) {
i += params[i + 1] === 5 ? 2 : 4
}
/* 40-47 / 100-107 backgrounds: consumed by falling through. */
}
}
flush(raw.slice(last))
plain += raw.slice(last)
return { number: startLine + index, text: plain, tokens }
})
}
/* -------------------------------------------------------------------------- */
/* Unified diff */
/* -------------------------------------------------------------------------- */
export type CodeBlockPatchFile = {
/** New-side path, or the old one for a deletion. */
file: string
/** Unified view: context, removed and added lines with dual gutter labels. */
lines: CodeBlockLine[]
added: number
removed: number
hunks: { header: string; at: number }[]
}
/**
* A `git diff` / unified patch, as renderable per-file line sets: feed each
* file's `lines` to the `lines` prop and the diff tints, `+`/`-` glyphs and
* dual old/new gutter numbers all come from the parse - no `diff` prop
* arithmetic against a hand-concatenated string. Tokens are plain; patches
* read by tint, not grammar.
*/
export function parseUnifiedDiff(patch: string): CodeBlockPatchFile[] {
const files: CodeBlockPatchFile[] = []
let current: CodeBlockPatchFile | null = null
let oldNumber = 0
let newNumber = 0
let width = 4
const push = (
text: string,
state: CodeBlockLineState | undefined,
gutter: string
) => {
if (!current) return
current.lines.push({
number: current.lines.length + 1,
text,
tokens: [{ content: text }],
state,
gutter,
})
}
const pad = (value: number | null) =>
(value === null ? "" : String(value)).padStart(width)
for (const raw of normalizeCode(patch).split("\n")) {
const fileHeader = raw.match(/^diff --git a\/(.+) b\/(.+)$/)
const plusHeader = raw.match(/^\+\+\+ (?:b\/)?(.+)$/)
if (fileHeader || plusHeader) {
const name = fileHeader ? fileHeader[2] : plusHeader![1]
if (name !== "/dev/null" && (!current || current.file !== name)) {
current = { file: name, lines: [], added: 0, removed: 0, hunks: [] }
files.push(current)
}
continue
}
if (
/^(---|index |old mode|new mode|new file|deleted file|similarity|rename |Binary )/.test(
raw
)
) {
continue
}
const hunk = raw.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/)
if (hunk && current) {
oldNumber = Number(hunk[1])
newNumber = Number(hunk[3])
width = Math.max(
String(oldNumber + Number(hunk[2] ?? 0)).length,
String(newNumber + Number(hunk[4] ?? 0)).length,
2
)
current.hunks.push({ header: raw, at: current.lines.length + 1 })
push(
hunk[5].trim() || raw,
{ level: "info" },
`${"·".padStart(width)} ${"·".padStart(width)}`
)
continue
}
if (!current || (!raw && files.length === 0)) continue
if (raw.startsWith("+")) {
push(raw.slice(1), { diff: "add" }, `${pad(null)} ${pad(newNumber)}`)
newNumber += 1
current.added += 1
} else if (raw.startsWith("-")) {
push(raw.slice(1), { diff: "remove" }, `${pad(oldNumber)} ${pad(null)}`)
oldNumber += 1
current.removed += 1
} else if (raw.startsWith(" ") || raw === "") {
if (current.lines.length === 0 && raw === "") continue
push(raw.slice(1), undefined, `${pad(oldNumber)} ${pad(newNumber)}`)
oldNumber += 1
newNumber += 1
}
}
return files
}