Add syntax highlighting and light/dark themes to twui CodeBlock

- Highlight string children with refractor (Prism) when `language` is
  set, rendered as React token spans; add ini/bash/json/yaml/ts support
- Add `theme` prop (auto | light | dark); auto follows the `.dark` class.
  Palettes are `--twui-code-*` CSS variables (GitHub Light/Dark, all
  tokens WCAG AA) so custom themes need no component changes
- Fix layout: real padding, singleBlock copy button no longer covers
  the code, clipped rounded corners, wrapperProps.style honoured,
  long tokens wrap
- MDX helpers take `codeTheme` and no longer force a black background
- Highlight WireGuard config blocks as ini

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-16 06:44:43 +01:00
co-authored by Claude Opus 5.5
parent cad1d32ae4
commit 548a934008
12 changed files with 269 additions and 31 deletions
+1
View File
@@ -15,6 +15,7 @@
"openai": "^7.4.0", "openai": "^7.4.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"refractor": "^5.0.0",
"rehype-prism-plus": "^2.0.2", "rehype-prism-plus": "^2.0.2",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sharp": "^0.35.3", "sharp": "^0.35.3",
+1
View File
@@ -39,6 +39,7 @@
"openai": "^7.4.0", "openai": "^7.4.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"refractor": "^5.0.0",
"rehype-prism-plus": "^2.0.2", "rehype-prism-plus": "^2.0.2",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sharp": "^0.35.3", "sharp": "^0.35.3",
@@ -0,0 +1,34 @@
import { refractor } from "refractor/core";
import bash from "refractor/bash";
import ini from "refractor/ini";
import javascript from "refractor/javascript";
import json from "refractor/json";
import typescript from "refractor/typescript";
import yaml from "refractor/yaml";
import type { Root } from "hast";
refractor.register(bash);
refractor.register(ini);
refractor.register(javascript);
refractor.register(json);
refractor.register(typescript);
refractor.register(yaml);
type Params = {
code: string;
language?: string;
};
/**
* # Highlight a code string with Prism (via refractor)
*
* Returns a hast tree of `.token` spans, or `null` when the
* language is missing or not registered.
*/
export default function highlightCode({ code, language }: Params): Root | null {
if (!language || !refractor.registered(language)) {
return null;
}
return refractor.highlight(code, language);
}
@@ -0,0 +1,32 @@
import type { RootContent } from "hast";
type Props = {
node: RootContent;
};
/**
* # Render one node of a refractor hast tree
*/
export default function CodeBlockHastNode({ node }: Props) {
if (node.type === "text") {
return node.value;
}
if (node.type !== "element") {
return null;
}
const className = node.properties?.className;
return (
<span
className={
Array.isArray(className) ? className.join(" ") : undefined
}
>
{node.children.map((child, index) => (
<CodeBlockHastNode key={index} node={child} />
))}
</span>
);
}
+108
View File
@@ -172,3 +172,111 @@ ul,
ol { ol {
margin-left: 25px; margin-left: 25px;
} }
/* -------------------------------------------------------------------------- */
/* CodeBlock themes */
/* -------------------------------------------------------------------------- */
/*
* `.twui-code-block-theme-auto` (default) follows the `.dark` class on <html>;
* `-light` / `-dark` pin a palette. Override the variables for a custom theme.
*/
.twui-code-block-wrapper {
/* GitHub Light — every token >= 4.5:1 (WCAG AA) on the background */
--twui-code-bg: #f6f8fa;
--twui-code-fg: #1f2328;
--twui-code-muted: #59636e;
--twui-code-border: rgb(31 35 40 / 0.15);
--twui-code-comment: #59636e;
--twui-code-punctuation: #1f2328;
--twui-code-red: #cf222e;
--twui-code-blue: #0550ae;
--twui-code-green: #116329;
--twui-code-orange: #953800;
--twui-code-purple: #8250df;
--twui-code-yellow: #7d4e00;
--twui-code-cyan: #0a3069;
}
.dark .twui-code-block-wrapper.twui-code-block-theme-auto,
.twui-code-block-wrapper.twui-code-block-theme-dark {
/* GitHub Dark — every token >= 6.5:1 on the background */
--twui-code-bg: #0d1117;
--twui-code-fg: #e6edf3;
--twui-code-muted: #9198a1;
--twui-code-border: rgb(240 246 252 / 0.1);
--twui-code-comment: #9198a1;
--twui-code-punctuation: #c9d1d9;
--twui-code-red: #ff7b72;
--twui-code-blue: #79c0ff;
--twui-code-green: #7ee787;
--twui-code-orange: #ffa657;
--twui-code-purple: #d2a8ff;
--twui-code-yellow: #e3b341;
--twui-code-cyan: #a5d6ff;
}
/* -------------------------------------------------------------------------- */
/* CodeBlock syntax highlighting */
/* -------------------------------------------------------------------------- */
.twui-code-block-pre .token.comment,
.twui-code-block-pre .token.prolog,
.twui-code-block-pre .token.doctype,
.twui-code-block-pre .token.cdata {
color: var(--twui-code-comment);
font-style: italic;
}
.twui-code-block-pre .token.punctuation {
color: var(--twui-code-punctuation);
}
.twui-code-block-pre .token.section,
.twui-code-block-pre .token.selector,
.twui-code-block-pre .token.tag,
.twui-code-block-pre .token.deleted {
color: var(--twui-code-red);
}
.twui-code-block-pre .token.key,
.twui-code-block-pre .token.property,
.twui-code-block-pre .token.attr-name,
.twui-code-block-pre .token.variable,
.twui-code-block-pre .token.parameter {
color: var(--twui-code-blue);
}
.twui-code-block-pre .token.string,
.twui-code-block-pre .token.attr-value,
.twui-code-block-pre .token.value,
.twui-code-block-pre .token.char,
.twui-code-block-pre .token.inserted {
color: var(--twui-code-green);
}
.twui-code-block-pre .token.number,
.twui-code-block-pre .token.boolean,
.twui-code-block-pre .token.constant,
.twui-code-block-pre .token.symbol {
color: var(--twui-code-orange);
}
.twui-code-block-pre .token.keyword,
.twui-code-block-pre .token.important,
.twui-code-block-pre .token.atrule {
color: var(--twui-code-purple);
}
.twui-code-block-pre .token.function,
.twui-code-block-pre .token.class-name,
.twui-code-block-pre .token.builtin {
color: var(--twui-code-yellow);
}
.twui-code-block-pre .token.operator,
.twui-code-block-pre .token.entity,
.twui-code-block-pre .token.url,
.twui-code-block-pre .token.regex {
color: var(--twui-code-cyan);
}
+67 -20
View File
@@ -9,8 +9,23 @@ import Stack from "../layout/Stack";
import Row from "../layout/Row"; import Row from "../layout/Row";
import Button from "../layout/Button"; import Button from "../layout/Button";
import Divider from "../layout/Divider"; import Divider from "../layout/Divider";
import highlightCode from "../(functions)/code-block/highlight-code";
import CodeBlockHastNode from "../(partials)/CodeBlockHastNode";
export const TWUIPrismLanguages = ["shell", "javascript"] as const; export const TWUIPrismLanguages = [
"shell",
"bash",
"ini",
"javascript",
"typescript",
"json",
"yaml",
] as const;
/** `auto` follows the `.dark` class on `<html>`; `light`/`dark` pin the palette */
export const TWUICodeBlockThemes = ["auto", "light", "dark"] as const;
export type TWUICodeBlockTheme = (typeof TWUICodeBlockThemes)[number];
type Props = PropsWithChildren & type Props = PropsWithChildren &
DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement> & { DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement> & {
@@ -22,6 +37,8 @@ type Props = PropsWithChildren &
backgroundColor?: string; backgroundColor?: string;
singleBlock?: boolean; singleBlock?: boolean;
language?: (typeof TWUIPrismLanguages)[number]; language?: (typeof TWUIPrismLanguages)[number];
/** Colour palette. Defaults to `auto` (follows the page theme) */
theme?: TWUICodeBlockTheme;
}; };
/** /**
@@ -31,6 +48,10 @@ type Props = PropsWithChildren &
* @className `twui-code-pre-wrapper` * @className `twui-code-pre-wrapper`
* @className `twui-code-block-pre` * @className `twui-code-block-pre`
* @className `twui-code-block-header` * @className `twui-code-block-header`
* @className `twui-code-block-theme-{auto|light|dark}`
*
* Colours come from `--twui-code-*` CSS variables (see `base.css`), so a
* custom palette is a matter of overriding them on the wrapper.
*/ */
export default function CodeBlock({ export default function CodeBlock({
children, children,
@@ -38,6 +59,7 @@ export default function CodeBlock({
backgroundColor, backgroundColor,
singleBlock, singleBlock,
language, language,
theme = "auto",
...props ...props
}: Props) { }: Props) {
const codeRef = React.useRef<HTMLDivElement>(null); const codeRef = React.useRef<HTMLDivElement>(null);
@@ -46,24 +68,33 @@ export default function CodeBlock({
const title = props?.["data-title"]; const title = props?.["data-title"];
const finalBackgroundColor = backgroundColor || "#28272b"; const highlighted = React.useMemo(
() =>
typeof children === "string"
? highlightCode({ code: children, language })
: null,
[children, language],
);
return ( return (
<div <div
{...wrapperProps} {...wrapperProps}
className={twMerge( className={twMerge(
"outline-[1px] outline-slate-200 dark:outline-white/10", "outline-[1px] outline-(--twui-code-border) bg-(--twui-code-bg)",
`rounded w-full transition-all items-start`, `rounded w-full transition-all overflow-hidden`,
"relative max-w-[80vw] sm:max-w-[85vw] xl:max-w-[880px]", "relative max-w-[80vw] sm:max-w-[85vw] xl:max-w-[880px]",
"twui-code-block-wrapper", "twui-code-block-wrapper",
`twui-code-block-theme-${theme}`,
wrapperProps?.className, wrapperProps?.className,
)} )}
style={{ style={{
boxShadow: copied boxShadow: copied
? "0 0 10px 10px rgba(18, 139, 99, 0.2)" ? "0 0 10px 10px rgba(18, 139, 99, 0.2)"
: undefined, : undefined,
backgroundColor: finalBackgroundColor, ...(backgroundColor
...props.style, ? ({ "--twui-code-bg": backgroundColor } as React.CSSProperties)
: {}),
...wrapperProps?.style,
}} }}
> >
<Stack <Stack
@@ -74,19 +105,22 @@ export default function CodeBlock({
> >
<Row <Row
className={twMerge( className={twMerge(
"w-full px-1 h-10 sticky top-0 py-2", "flex-nowrap z-10",
singleBlock ? "absolute !bg-transparent" : "", singleBlock
? "absolute top-0 right-0 p-1.5"
: "w-full h-10 px-3 sticky top-0 shrink-0 bg-(--twui-code-bg)",
"twui-code-block-header", "twui-code-block-header",
)} )}
style={{
backgroundColor: finalBackgroundColor,
}}
> >
{title && <span className="text-white/70">{title}</span>} {title && (
<span className="text-(--twui-code-muted) text-xs truncate">
{title}
</span>
)}
<div className="ml-auto"> <div className="ml-auto">
{copied ? ( {copied ? (
<Row> <Row>
<span className="text-white text-xs twui-code-block-copied-text"> <span className="text-(--twui-code-fg) text-xs twui-code-block-copied-text">
Copied! Copied!
</span> </span>
<div className="w-5 h-5 rounded-full bg-emerald-600 text-white flex items-center justify-center"> <div className="w-5 h-5 rounded-full bg-emerald-600 text-white flex items-center justify-center">
@@ -97,8 +131,8 @@ export default function CodeBlock({
<Button <Button
variant="ghost" variant="ghost"
color="gray" color="gray"
beforeIcon={<Copy size={17} color="white" />} beforeIcon={<Copy size={17} />}
className="!p-1 !bg-transparent opacity-50" className="!p-1 !bg-transparent !text-(--twui-code-muted) hover:!text-(--twui-code-fg)"
onClick={() => { onClick={() => {
const content = const content =
codeRef.current?.textContent; codeRef.current?.textContent;
@@ -122,12 +156,12 @@ export default function CodeBlock({
</div> </div>
</Row> </Row>
{!singleBlock && ( {!singleBlock && (
<Divider className="!border-white/10 sticky top-10" /> <Divider className="!border-(--twui-code-border) sticky top-10 z-10 shrink-0" />
)} )}
<div <div
className={twMerge( className={twMerge(
`p-1 w-full [&_pre]:!bg-transparent`, `px-3 py-2.5 w-full [&_pre]:!bg-transparent`,
singleBlock ? "" : "-mt-1", singleBlock ? "pr-11" : "",
"twui-code-pre-wrapper", "twui-code-pre-wrapper",
)} )}
ref={codeRef as any} ref={codeRef as any}
@@ -135,13 +169,26 @@ export default function CodeBlock({
<pre <pre
{...props} {...props}
className={twMerge( className={twMerge(
"!my-0 whitespace-pre-wrap", "!my-0 whitespace-pre-wrap break-words text-(--twui-code-fg)",
language ? `language-${language}` : "", language ? `language-${language}` : "",
"twui-code-block-pre", "twui-code-block-pre",
props.className, props.className,
)} )}
> >
{children} <code
className={
language ? `language-${language}` : undefined
}
>
{highlighted
? highlighted.children.map((node, index) => (
<CodeBlockHastNode
key={index}
node={node}
/>
))
: children}
</code>
</pre> </pre>
</div> </div>
</Stack> </Stack>
+10 -4
View File
@@ -1,5 +1,5 @@
import { type MDXComponents } from "mdx/types"; import { type MDXComponents } from "mdx/types";
import CodeBlock from "../elements/CodeBlock"; import CodeBlock, { type TWUICodeBlockTheme } from "../elements/CodeBlock";
import H1 from "../layout/H1"; import H1 from "../layout/H1";
import H4 from "../layout/H4"; import H4 from "../layout/H4";
import React from "react"; import React from "react";
@@ -11,10 +11,12 @@ import H3 from "../layout/H3";
type Params = { type Params = {
components: MDXComponents; components: MDXComponents;
codeBgColor?: string; codeBgColor?: string;
codeTheme?: TWUICodeBlockTheme;
}; };
export function useMDXComponents(params?: Params) { export function useMDXComponents(params?: Params) {
const codeBgColor = params?.codeBgColor || "#000000"; const codeBgColor = params?.codeBgColor;
const codeTheme = params?.codeTheme;
return { return {
components: { components: {
@@ -24,7 +26,9 @@ export function useMDXComponents(params?: Params) {
if (React.isValidElement(children) && children.props) { if (React.isValidElement(children) && children.props) {
return ( return (
// @ts-ignore // @ts-ignore
<CodeBlock {...props} backgroundColor={codeBgColor}> <CodeBlock {...props} backgroundColor={codeBgColor}
theme={codeTheme}
>
{/* @ts-ignore */} {/* @ts-ignore */}
{children.props.children} {children.props.children}
</CodeBlock> </CodeBlock>
@@ -32,7 +36,9 @@ export function useMDXComponents(params?: Params) {
} }
return ( return (
// @ts-ignore // @ts-ignore
<CodeBlock {...props} backgroundColor={codeBgColor}> <CodeBlock {...props} backgroundColor={codeBgColor}
theme={codeTheme}
>
{children} {children}
</CodeBlock> </CodeBlock>
); );
@@ -4,17 +4,19 @@ import H1 from "../../layout/H1";
import H2 from "../../layout/H2"; import H2 from "../../layout/H2";
import H3 from "../../layout/H3"; import H3 from "../../layout/H3";
import H4 from "../../layout/H4"; import H4 from "../../layout/H4";
import CodeBlock from "../../elements/CodeBlock"; import CodeBlock, { type TWUICodeBlockTheme } from "../../elements/CodeBlock";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
type Params = { type Params = {
components: MDXComponents; components: MDXComponents;
codeBgColor?: string; codeBgColor?: string;
codeTheme?: TWUICodeBlockTheme;
}; };
export default function useMDXComponents({ export default function useMDXComponents({
components, components,
codeBgColor, codeBgColor,
codeTheme,
}: Params): MDXComponents { }: Params): MDXComponents {
return { return {
h1: ({ children }) => <H1>{children}</H1>, h1: ({ children }) => <H1>{children}</H1>,
@@ -23,7 +25,9 @@ export default function useMDXComponents({
if (React.isValidElement(children) && children.props) { if (React.isValidElement(children) && children.props) {
return ( return (
// @ts-ignore // @ts-ignore
<CodeBlock {...props} backgroundColor={codeBgColor}> <CodeBlock {...props} backgroundColor={codeBgColor}
theme={codeTheme}
>
{/* @ts-ignore */} {/* @ts-ignore */}
{children.props.children} {children.props.children}
</CodeBlock> </CodeBlock>
@@ -31,7 +35,9 @@ export default function useMDXComponents({
} }
return ( return (
// @ts-ignore // @ts-ignore
<CodeBlock {...props} backgroundColor={codeBgColor}> <CodeBlock {...props} backgroundColor={codeBgColor}
theme={codeTheme}
>
{children} {children}
</CodeBlock> </CodeBlock>
); );
+2 -1
View File
@@ -14,7 +14,8 @@
"next-mdx-remote": "latest", "next-mdx-remote": "latest",
"remark-gfm": "latest", "remark-gfm": "latest",
"rehype-prism-plus": "latest", "rehype-prism-plus": "latest",
"html-to-react": "^1.7.0" "html-to-react": "^1.7.0",
"refractor": "latest"
}, },
"devDependencies": { "devDependencies": {
"@types/ace": "latest", "@types/ace": "latest",
@@ -30,7 +30,7 @@ export default function HostInterfaceSection({ host, clients }: Props) {
) : undefined ) : undefined
} }
> >
<CodeBlock wrapperProps={{ className: "max-w-full!" }}> <CodeBlock language="ini" wrapperProps={{ className: "max-w-full!" }}>
{buildInterfaceSummary({ config })} {buildInterfaceSummary({ config })}
</CodeBlock> </CodeBlock>
</Panel> </Panel>
@@ -29,7 +29,9 @@ export default function ClientConfigPanel() {
} }
> >
{config ? ( {config ? (
<CodeBlock wrapperProps={{ className: "max-w-full!" }}>{config}</CodeBlock> <CodeBlock language="ini" wrapperProps={{ className: "max-w-full!" }}>
{config}
</CodeBlock>
) : ( ) : (
<EmptyState <EmptyState
className="py-8" className="py-8"
+1 -1
View File
@@ -231,7 +231,7 @@ span code {
} }
.twui-code-block-pre { .twui-code-block-pre {
@apply font-mono text-sm leading-relaxed text-zinc-100; @apply font-mono text-sm leading-relaxed;
} }
/* Inputs: label sits above the field instead of floating on the border */ /* Inputs: label sits above the field instead of floating on the border */