a code block with syntax highlighting by shiki on the server: marks, diffs, copy
npx shadcn add @ag/codeAlready installed? Preview an update
npx shadcn add @ag/code --dry-runYour installed source stays unchanged until you update it. How updates work
lib/code-tokens: code's highlighter as one ink per character (codeColours,runsOf) for a view drawing text that is not all there yet, anduseCodeColours, the same read in the browser.CODE_THEMESis the one spelling of code's two themes.codeColoursruns on Shiki's core with its JavaScript regex engine, loading only the grammar and the two themes asked for: a page colouring code in the browser no longer downloads Oniguruma's WebAssembly (about 600 KB, 226 KB compressed).useCodeColourstakesgivencolours read ahead, and then loads nothing.
Run pnpm build from your project directory.
npx shadcn add @ag/codeimport { Scrims } from "@/components/ui/scrims"
export default function Page() {
return (
<div className="px-6">
{/* fixed to the viewport, so it lives outside the flow */}
<Scrims top={false} />
<article>…</article>
</div>
)
}import { Scrims } from "@/components/ui/scrims"
export default function Page() {
return (
<div className="px-6">
{/* fixed to the viewport, so it lives outside the flow */}
<Scrims top={false} />
<article>…</article>
</div>
)
}A line is marked with a [!code highlight] comment on the line before it. The mark runs the full width and keeps a bar in the gutter, which is the part that survives scrolling a long line sideways.
const b = panBounds(view, fitted, band)
return { x: clamp(view.x, -b.x, b.x), y: clamp(view.y, -b.y, b.y), s: view.s }A diff is [!code ++] and [!code --]. The sign is a real character in the gutter, not just a colour: colour alone is invisible to a reader who cannot separate red from green.
const b = panBounds(ctx.pose, ctx.fitted, ctx.band)
const dx = boundTick(wheelPx(input.deltaX, input.deltaMode, ctx.band.h))
w = { ...w, x: w.x - dx, y: w.y - dy }
const x = clamp(w.raw0.x + w.x - dx, -b.x, b.x)
const y = clamp(w.raw0.y + w.y - dy, -b.y, b.y)
w = { ...w, x: x - w.raw0.x, y: y - w.raw0.y }
effects.push({
kind: "pose",
pose: { ...ctx.pose, x: overshoot(w.raw0.x + w.x, b.x) },
pose: { ...ctx.pose, x, y },
})[!code focus] steps the rest of the block back. Hover to bring it all forward: a reader who wants the surrounding lines should not have to leave the page for them.
function swipeSlides(travel, slideW) {
assert(slideW > 0, "a slide has no width")
return Math.floor(Math.abs(travel) / slideW + 1 - SWIPE_COMMIT + 1e-9)
}[!code word:momentum] marks a word wherever it appears on the lines below.
// The momentum is the platform's; only the momentum knows when fingers left.For source you do not own, and so cannot write a comment into, highlight="2,5-6" takes the lines from outside. This page uses it: an item page renders its demo file verbatim and must not edit it to document it.
import { Scrims } from "@/components/ui/scrims"
export default function Page() {
return (
<div className="px-6">
{/* fixed to the viewport, so it lives outside the flow */}
<Scrims top={false} />
<article>…</article>
</div>
)
}A shell block, with no filename: the tab falls back to the language.
npx shadcn add @ag/code
# the copy control comes with itthe demo above, verbatim
import { Sample } from "@/app/samples"
import { Code } from "@/registry/base-nova/ui/code"
import { InlineCode } from "@/registry/base-nova/ui/code-inline"
const PLAIN = `import { Scrims } from "@/components/ui/scrims"
export default function Page() {
return (
<div className="px-6">
{/* fixed to the viewport, so it lives outside the flow */}
<Scrims top={false} />
<article>…</article>
</div>
)
}`
// The notations live in the source, so they move with the line they mark when the
// code is edited. Shiki strips them from the render; the clipboard keeps the source.
const MARKED = `const b = panBounds(view, fitted, band)
// [!code highlight]
return { x: clamp(view.x, -b.x, b.x), y: clamp(view.y, -b.y, b.y), s: view.s }`
// The real commit: the wheel pan stopped rubbering and started clamping.
const DIFF = `const b = panBounds(ctx.pose, ctx.fitted, ctx.band)
const dx = boundTick(wheelPx(input.deltaX, input.deltaMode, ctx.band.h))
w = { ...w, x: w.x - dx, y: w.y - dy } // [!code --]
const x = clamp(w.raw0.x + w.x - dx, -b.x, b.x) // [!code ++]
const y = clamp(w.raw0.y + w.y - dy, -b.y, b.y) // [!code ++]
w = { ...w, x: x - w.raw0.x, y: y - w.raw0.y } // [!code ++]
effects.push({
kind: "pose",
pose: { ...ctx.pose, x: overshoot(w.raw0.x + w.x, b.x) }, // [!code --]
pose: { ...ctx.pose, x, y }, // [!code ++]
})`
const FOCUS = `function swipeSlides(travel, slideW) {
assert(slideW > 0, "a slide has no width")
return Math.floor(Math.abs(travel) / slideW + 1 - SWIPE_COMMIT + 1e-9) // [!code focus]
}`
const WORDS = `// [!code word:momentum]
// The momentum is the platform's; only the momentum knows when fingers left.`
const SH = `npx shadcn add @ag/code
# the copy control comes with it`
const Note = ({ children }: { children: React.ReactNode }) => (
<p className="max-w-prose text-[0.9375rem] leading-relaxed text-foreground/70">
{children}
</p>
)
export default function Demo() {
return (
<div className="space-y-12">
<Sample name="inline" glance title="inline code, copyable commands">
<div className="space-y-4">
<p className="text-sm text-foreground/80">
Run <InlineCode>pnpm build</InlineCode> from your project directory.
</p>
<Code compact lang="sh">
npx shadcn add @ag/code
</Code>
</div>
</Sample>
<Code lang="tsx" filename="app/page.tsx">
{PLAIN}
</Code>
<Sample name="glass" title="in glass">
<div className="rounded-xl bg-[url(/lightbox/shore.jpg)] bg-cover bg-center p-6">
<Code lang="tsx" filename="app/page.tsx" material="glass">
{PLAIN}
</Code>
</div>
</Sample>
<div className="space-y-3">
<Note>
A line is marked with a{" "}
<code className="font-mono text-xs">[!code highlight]</code> comment
on the line before it. The mark runs the full width and keeps a bar in
the gutter, which is the part that survives scrolling a long line
sideways.
</Note>
<Code lang="ts" filename="clampPan.ts">
{MARKED}
</Code>
</div>
<div className="space-y-3">
<Note>
A diff is <code className="font-mono text-xs">[!code ++]</code> and{" "}
<code className="font-mono text-xs">[!code --]</code>. The sign is a
real character in the gutter, not just a colour: colour alone is
invisible to a reader who cannot separate red from green.
</Note>
<Code lang="ts" filename="lightbox-wheel.ts">
{DIFF}
</Code>
</div>
<div className="space-y-3">
<Note>
<code className="font-mono text-xs">[!code focus]</code> steps the
rest of the block back. Hover to bring it all forward: a reader who
wants the surrounding lines should not have to leave the page for
them.
</Note>
<Code lang="js" filename="swipe.js">
{FOCUS}
</Code>
</div>
<div className="space-y-3">
<Note>
<code className="font-mono text-xs">[!code word:momentum]</code> marks
a word wherever it appears on the lines below.
</Note>
<Code lang="ts">{WORDS}</Code>
</div>
<div className="space-y-3">
<Note>
For source you do not own, and so cannot write a comment into,{" "}
<code className="font-mono text-xs">highlight="2,5-6"</code> takes the
lines from outside. This page uses it: an item page renders its demo
file verbatim and must not edit it to document it.
</Note>
<Code lang="tsx" filename="app/page.tsx" highlight="2,5-6" lines>
{PLAIN}
</Code>
</div>
<div className="space-y-3">
<Note>
A shell block, with no filename: the tab falls back to the language.
</Note>
<Code lang="bash">{SH}</Code>
</div>
</div>
)
}the source, every file an install writes
// A code block, highlighted on the SERVER. Shiki is the highlighter VS Code uses, so
// the grammars and themes are the ones the reader already knows, and running it at
// build time means the page ships the markup and none of the highlighter: only the
// copy control is client code.
//
// Two themes are baked into one render (`--shiki-light` / `--shiki-dark` on every
// token) and code.css picks with the theme, so switching does not re-highlight and
// cannot flash.
import "server-only"
import {
transformerNotationDiff,
transformerNotationErrorLevel,
transformerNotationFocus,
transformerNotationHighlight,
transformerNotationWordHighlight,
} from "@shikijs/transformers"
import { type BundledLanguage, codeToHtml } from "shiki"
import { CODE_THEMES, linesOf } from "@/registry/base-nova/lib/code-tokens"
import { Copy } from "@/registry/base-nova/ui/copy"
import { Glass } from "@/registry/base-nova/ui/material-glass"
import "./code.css"
import "./edge-fade"
export interface CodeProps {
/** The source. Common leading indentation is removed, so a snippet can be written
* indented inside the code that renders it and still read flush. */
children: string
/** A highlighted command row without a filename bar. Copy stays on the right;
* a command too long for the row scrolls on one line, its hidden side fading. */
compact?: boolean
/** A Shiki language id. `text` when it is not code, which skips the grammar. */
lang?: BundledLanguage | "text"
/** A path or a name for the tab. Without one the frame is bare. */
filename?: string
/** Hide the copy control, for a snippet nobody would want in their clipboard. */
copy?: boolean
/** Lines to mark, `"2"` or `"1,4-6"`, counted from 1. For source you do NOT own:
* a file rendered verbatim cannot carry `[!code highlight]` comments without them
* becoming part of the file. Where you do own it, prefer the comments, which move
* with the lines when the code is edited. */
highlight?: string
/** Number the lines. Off by default: most snippets are short enough that numbers
* are furniture, and they are only worth their width when something refers to one. */
lines?: boolean
/** Read `[!code ...]` comments. ON, except where the source is being shown AS
* ITSELF: a file that writes those comments inside a string would have them eaten
* out of its own listing, and a block promising verbatim would be quietly lying. */
notations?: boolean
/** Its face: `plain`, the page's frame (default), or `glass`, the one glass
* material (`material-glass`), its own border and ground dropped for it. Set
* here rather than read from a provider: the block renders on the server. */
material?: "plain" | "glass"
/** No frame, no bar and no copy of its own: for a frame that holds it (a
* tab of a `Panel` with an example's code, its copy in the panel's head), so a
* block in a frame is never a frame in a frame. `material` is then the
* holder's. */
bare?: boolean
className?: string
}
/** Strip the shortest indentation any non-blank line has, and the blank first and
* last lines a template literal always brings. */
function dedent(src: string): string {
const lines = src.replace(/\t/g, " ").split("\n")
while (lines.length && !lines[0]?.trim()) lines.shift()
while (lines.length && !lines[lines.length - 1]?.trim()) lines.pop()
const pad = Math.min(
...lines
.filter((l) => l.trim())
.map((l) => l.length - l.trimStart().length),
)
return lines.map((l) => l.slice(pad)).join("\n")
}
export async function Code({
children,
compact = false,
lang = "tsx",
filename,
copy = true,
highlight,
lines,
notations = true,
material = "plain",
bare = false,
className,
}: CodeProps) {
const source = dedent(children)
const marked = highlight ? linesOf(highlight) : null
const html = await codeToHtml(source, {
lang,
themes: CODE_THEMES,
defaultColor: false,
transformers: [
// The notations travel INSIDE the source, so they survive an edit that moves a
// line and they are what a reader copies out of a doc. Shiki strips the comment
// from the rendered output; the clipboard gets `source`, which still has it.
...(notations
? [
transformerNotationDiff({ matchAlgorithm: "v3" }),
transformerNotationHighlight({ matchAlgorithm: "v3" }),
transformerNotationWordHighlight({ matchAlgorithm: "v3" }),
transformerNotationFocus({ matchAlgorithm: "v3" }),
transformerNotationErrorLevel({ matchAlgorithm: "v3" }),
]
: []),
// ...and the `highlight` prop for source nobody may write into.
{
name: "ag:highlight-prop",
line(node, line) {
if (marked?.has(line)) this.addClassToHast(node, "highlighted")
},
},
],
})
const body = (
<>
{!compact && (
<div className="ag-code-bar">
<span className="ag-code-name">{filename ?? lang}</span>
{copy && <Copy value={source} label />}
</div>
)}
{/* Shiki's own <pre><code>, styled by code.css. A long line scrolls sideways,
and a block given less height than it needs scrolls down, inside the frame,
with the scrims' edge fade: never a scrollbar drawn over the code, never a
hard cut. */}
<div
className="ag-code-scroll ag-edge-fade-xy"
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki's own output, built on the server from a string prop, never from anything a reader can reach
dangerouslySetInnerHTML={{ __html: html }}
/>
{compact && copy && <Copy value={source} className="mr-2" />}
</>
)
const frame = `ag-code${lines ? " ag-code-numbered" : ""}${className ? ` ${className}` : ""}`
if (bare)
return (
<div data-slot="code" data-bare className={frame}>
<div
className="ag-code-scroll ag-edge-fade-xy"
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki's own output, built on the server from a string prop, never from anything a reader can reach
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
)
if (material === "glass")
return (
<Glass
shape="surface"
data-slot="code"
data-compact={compact || undefined}
className={`${frame} [--glass-radius:0.75rem]`}
>
{body}
</Glass>
)
return (
<div
data-slot="code"
data-material="plain"
data-compact={compact || undefined}
className={frame}
>
{body}
</div>
)
}import type { ComponentPropsWithRef } from "react"
import { cn } from "@/lib/utils"
/** Plain inline code for names and short expressions within prose.
* Use Code with compact and lang for syntax-highlighted command rows. */
export function InlineCode({
children,
className,
...props
}: ComponentPropsWithRef<"code">) {
return (
<code
{...props}
data-slot="code-inline"
className={cn(
"rounded-md bg-foreground/6 px-1.5 py-0.5 font-mono text-[0.875em] text-foreground/85",
className,
)}
>
{children}
</code>
)
}/* The frame around highlighted code. Shiki writes both themes onto every token as
custom properties; this picks one, so a theme switch is a repaint and never a
re-highlight, and there is no flash of the wrong palette on first paint. */
@layer components {
.ag-code {
overflow: hidden;
}
/* The plain face's frame. The glass face is the glass's own (material-glass),
its edge and body; a border or ground here would be a second frame. A
selector on state, which a utility merged with the glass's could not keep
apart. */
.ag-code[data-material="plain"] {
border: 1px solid var(--border);
border-radius: 0.75rem;
background: var(--sidebar);
}
.ag-code-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.375rem 0.5rem 0.375rem 0.875rem;
border-bottom: 1px solid var(--border);
}
.ag-code-name {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.75rem;
color: var(--muted-foreground);
user-select: none;
}
/* The scroller, not the <pre>: the frame keeps its corners while long lines run.
A block is a column, its bar and then the scroller, which takes the rest of the
frame's height: given less height than the code needs (`h-96`, a pane's
`min-h-0 flex-1`), the code scrolls inside the frame, the frame staying whole. */
.ag-code:not([data-compact]) {
display: flex;
flex-direction: column;
}
.ag-code:not([data-compact]) > .ag-code-scroll {
flex: 1 1 auto;
min-height: 0;
}
/* Compact commands keep the same Shiki output and theme, with the copy control
outside the horizontal scroller so long commands never hide the action. */
.ag-code[data-compact] {
display: flex;
width: fit-content;
max-width: 100%;
align-items: center;
gap: 0.5rem;
}
/* Basis auto, not 0: the row's own width is fit-content, and a zero basis
would tell it the command has no length, so it would wrap even with room. */
.ag-code[data-compact] > .ag-code-scroll {
flex: 1 1 auto;
min-width: 0;
}
/* Shiki with `defaultColor: false` writes its palette as custom properties and sets
no colour of its own, so the frame keeps its own surface with nothing to override. */
.ag-code pre {
margin: 0;
padding: 1rem 0.875rem;
background: transparent;
font-size: 0.8125rem;
line-height: 1.65;
tab-size: 2;
}
.ag-code[data-compact] pre {
padding-block: 0.75rem;
}
/* Code never breaks its lines. A line too long for the block scrolls sideways
with the scrims' edge fade (`ag-edge-fade`, set on its scroller in code.tsx): no
scrollbar drawn over it, the hidden side fading. */
.ag-code code {
display: block;
width: fit-content;
min-width: 100%;
font-family: var(--font-mono, ui-monospace, monospace);
}
/* Shiki with `defaultColor: false` writes --shiki-light and --shiki-dark and no
colour of its own, so the theme chooses here. */
.ag-code span {
color: var(--shiki-light);
}
.dark .ag-code span {
color: var(--shiki-dark);
}
/* ---- marked lines.
A marked line is marked EDGE TO EDGE, which is why `.line` is a block that runs the
full scroll width: a tint that stops at the end of the text draws a ragged shape
down the block and reads as damage rather than emphasis. The gutter bar is what
survives being scrolled sideways, when the tint under a long line is off screen. */
.ag-code .line {
display: inline-block;
width: 100%;
padding-inline: 0.875rem;
margin-inline: -0.875rem;
border-left: 2px solid transparent;
}
.ag-code .line.highlighted {
background: color-mix(
in oklab,
var(--brand, var(--foreground)) 10%,
transparent
);
border-left-color: color-mix(
in oklab,
var(--brand, var(--foreground)) 55%,
transparent
);
}
/* An error or a warning is a highlight that has something to say, so it keeps the
shape and changes only the hue. */
.ag-code .line.highlighted.error {
background: color-mix(in oklab, var(--tone-bad) 12%, transparent);
border-left-color: var(--tone-bad);
}
.ag-code .line.highlighted.warning {
background: color-mix(in oklab, var(--tone-warn) 12%, transparent);
border-left-color: var(--tone-warn);
}
/* ---- a diff.
The sign is a real character in the gutter, not a colour: colour alone is invisible
to a reader who cannot separate red from green, and it does not survive a
screenshot pasted into a grey chat. */
.ag-code .line.diff {
position: relative;
}
.ag-code .line.diff::before {
position: absolute;
left: 0.25rem;
opacity: 0.7;
}
.ag-code .line.diff.add {
background: color-mix(in oklab, var(--tone-ok) 14%, transparent);
border-left-color: var(--tone-ok);
}
.ag-code .line.diff.add::before {
content: "+";
color: var(--tone-ok);
}
.ag-code .line.diff.remove {
background: color-mix(in oklab, var(--tone-bad) 12%, transparent);
border-left-color: var(--tone-bad);
}
/* Removed code is still readable, just plainly on its way out. */
.ag-code .line.diff.remove {
opacity: 0.65;
}
.ag-code .line.diff.remove::before {
content: "-";
color: var(--tone-bad);
}
/* ---- focus: everything else steps back, and comes back on hover, because a reader
who wants the surrounding lines should not have to leave the page to see them. */
.ag-code:has(.focused) .line:not(.focused) {
filter: blur(2px);
opacity: 0.5;
transition:
filter 200ms ease,
opacity 200ms ease;
}
.ag-code:has(.focused):hover .line:not(.focused) {
filter: none;
opacity: 1;
}
/* ---- a word, marked inside the line. */
.ag-code .highlighted-word {
border-radius: 0.25rem;
padding: 0.1em 0.25em;
background: color-mix(
in oklab,
var(--brand, var(--foreground)) 14%,
transparent
);
box-shadow: 0 0 0 1px
color-mix(in oklab, var(--brand, var(--foreground)) 30%, transparent);
}
/* ---- line numbers, opt-in. A counter, so nothing is added to the copied text. */
.ag-code-numbered code {
counter-reset: ag-line;
}
.ag-code-numbered .line {
counter-increment: ag-line;
padding-left: 3rem;
}
.ag-code-numbered .line::after {
content: counter(ag-line);
position: absolute;
left: 0;
width: 2.25rem;
text-align: right;
color: var(--muted-foreground);
opacity: 0.5;
user-select: none;
}
.ag-code-numbered .line {
position: relative;
}
.ag-code-numbered .line.diff::before {
left: 2.5rem;
}
@media (prefers-reduced-motion: reduce) {
.ag-code:has(.focused) .line:not(.focused) {
transition: none;
}
}
}// Code's colours, character by character: the same highlighter `code` renders with
// (Shiki, VS Code's own grammars), read as one style per character instead of as
// markup, for a view that draws text which is not all there yet (an editor whose
// file is being typed: each character keeps the colour it has in the finished file,
// so a half-typed attribute already reads as one). Framework-free; Shiki loads on the
// first call, so the page ships none of it until something asks, and then only its
// core, the JavaScript regex engine and the one grammar and two themes asked for.
/** The two themes every code view here wears, light and dark, picked by the scheme
* it stands in. `code` and `markdown` render with them. */
export const CODE_THEMES = {
light: "github-light",
dark: "github-dark",
} as const
/** VS Code's default themes' token colours, which `ide` wears: Dark Modern and
* Light Modern are built on Dark+ and Light+. */
export const VSCODE_THEMES = {
light: "light-plus",
dark: "dark-plus",
} as const
/** Lines to mark, as every code view here spells them: `"2"` or `"1,4-6"`, counted
* from 1. Screams at anything else. */
export function linesOf(spec: string): Set<number> {
const out = new Set<number>()
for (const part of spec.split(",")) {
const range = part.trim()
if (!range) continue
const [a, b = a] = range.split("-").map((n) => Number(n.trim()))
if (!Number.isInteger(a) || !Number.isInteger(b) || a === undefined)
throw new Error(`code: "${range}" is not a line or a range of lines`)
for (let n = a; n <= (b as number); n++) out.add(n)
}
return out
}
/** A pair of Shiki theme ids, light and dark. */
export interface CodeThemes {
light: string
dark: string
}
/** One style a character can wear, in both themes. */
export interface CodeInk {
light: string
dark: string
italic: boolean
bold: boolean
}
/** A text's colours: `inks` the distinct styles, `of[i]` the one character `i` of
* the text wears (an index into `inks`). */
export interface CodeColours {
inks: CodeInk[]
of: Uint16Array
}
/** The language a path is written in: its extension (`html`, `ts`, `md`), which
* Shiki reads as a language or one of its aliases; `text` with none. */
export function langOf(path: string): string {
return /\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase() ?? "text"
}
type Core = import("shiki/core").HighlighterCore
/** One highlighter for the page, made on the first call: Shiki's core with its
* JavaScript regex engine, never the WebAssembly one (Oniguruma, 600 KB a browser
* would download to colour a few lines), each language and theme loaded the first
* time it is asked for, alone. */
let core: Promise<Core> | undefined
async function highlighter(lang: string, themes: CodeThemes): Promise<Core> {
core ??= (async () => {
const [{ createHighlighterCore }, { createJavaScriptRegexEngine }] =
await Promise.all([
import("shiki/core"),
import("shiki/engine/javascript"),
])
return createHighlighterCore({
engine: createJavaScriptRegexEngine(),
themes: [],
langs: [],
})
})()
const h = await core
const [{ bundledLanguages }, { bundledThemes }] = await Promise.all([
import("shiki/langs"),
import("shiki/themes"),
])
const loading: Promise<void>[] = []
if (lang !== "text" && !h.getLoadedLanguages().includes(lang)) {
const grammar = bundledLanguages[lang as keyof typeof bundledLanguages]
if (grammar) loading.push(h.loadLanguage(grammar))
}
for (const name of [themes.light, themes.dark])
if (!h.getLoadedThemes().includes(name)) {
const theme = bundledThemes[name as keyof typeof bundledThemes]
if (!theme) throw new Error(`code-tokens: no Shiki theme ${name}`)
loading.push(h.loadTheme(theme))
}
await Promise.all(loading)
return h
}
/** Every character of `text` coloured by `lang`'s grammar in both `themes`. A
* language Shiki does not know is `text`: one ink, the theme's foreground. Runs
* where it is called: on the server for a still (passed to a view as its
* `colours`), or in the browser for text that changes. */
export async function codeColours(
text: string,
lang: string,
themes: CodeThemes = CODE_THEMES,
): Promise<CodeColours> {
const h = await highlighter(lang, themes)
const lines = h.codeToTokensWithThemes(text, {
lang: h.getLoadedLanguages().includes(lang) ? lang : "text",
themes: { light: themes.light, dark: themes.dark },
})
const inks: CodeInk[] = []
const known = new Map<string, number>()
const of = new Uint16Array(text.length)
for (const line of lines)
for (const token of line) {
const light = token.variants.light
const dark = token.variants.dark
if (!light || !dark)
throw new Error("code-tokens: a token without both themes' colours")
// Shiki's FontStyle flags: 1 italic, 2 bold.
const style = light.fontStyle ?? 0
const ink: CodeInk = {
light: light.color ?? "inherit",
dark: dark.color ?? "inherit",
italic: (style & 1) !== 0,
bold: (style & 2) !== 0,
}
const key = `${ink.light} ${ink.dark} ${style}`
let n = known.get(key)
if (n === undefined) {
n = inks.length
inks.push(ink)
known.set(key, n)
}
of.fill(n, token.offset, token.offset + token.content.length)
}
return { inks, of }
}
/** A stretch of a line in one ink (null: the view's own foreground, before the
* colours have loaded). */
export interface CodeRun {
text: string
ink: CodeInk | null
}
/** `text` cut into lines of runs, each character inked by `colours` at its offset
* in the text they were read from: `origin[i]` for character `i` (a file being
* typed, coloured as it will be), or `i` itself without one. */
export function runsOf(
text: string,
colours: CodeColours | null,
origin?: readonly number[],
): CodeRun[][] {
const lines: CodeRun[][] = [[]]
let line = lines[0] as CodeRun[]
let run: CodeRun | null = null
for (let i = 0; i < text.length; i++) {
const ch = text[i] as string
if (ch === "\n") {
line = []
lines.push(line)
run = null
continue
}
const at = origin ? (origin[i] as number) : i
const ink = colours
? (colours.inks[colours.of[at] as number] ?? null)
: null
if (run && run.ink === ink) run.text += ch
else {
run = { text: ch, ink }
line.push(run)
}
}
return lines
}"use client"
import * as React from "react"
import {
CODE_THEMES,
type CodeColours,
type CodeThemes,
codeColours,
} from "@/registry/base-nova/lib/code-tokens"
/** `text`'s colours in `lang` (lib/code-tokens), read in the browser once it
* renders: null until Shiki has loaded, so a view draws the text plain at once and
* coloured a moment later, never blank. `given`, colours already read (on the
* server, for a still), are used as they are, and the browser loads no Shiki. */
export function useCodeColours(
text: string,
lang: string,
themes: CodeThemes = CODE_THEMES,
given?: CodeColours,
): CodeColours | null {
const [colours, setColours] = React.useState<{
key: string
colours: CodeColours
} | null>(null)
const { light, dark } = themes
const key = `${light} ${dark} ${lang}\n${text}`
React.useEffect(() => {
if (given) return
let live = true
void codeColours(text, lang, { light, dark }).then((c) => {
if (live) setColours({ key, colours: c })
})
return () => {
live = false
}
}, [given, key, text, lang, light, dark])
if (given) return given
return colours?.key === key ? colours.colours : null
}