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:
@@ -15,6 +15,7 @@
|
||||
"openai": "^7.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"refractor": "^5.0.0",
|
||||
"rehype-prism-plus": "^2.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"openai": "^7.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"refractor": "^5.0.0",
|
||||
"rehype-prism-plus": "^2.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -172,3 +172,111 @@ ul,
|
||||
ol {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,23 @@ import Stack from "../layout/Stack";
|
||||
import Row from "../layout/Row";
|
||||
import Button from "../layout/Button";
|
||||
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 &
|
||||
DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement> & {
|
||||
@@ -22,6 +37,8 @@ type Props = PropsWithChildren &
|
||||
backgroundColor?: string;
|
||||
singleBlock?: boolean;
|
||||
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-block-pre`
|
||||
* @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({
|
||||
children,
|
||||
@@ -38,6 +59,7 @@ export default function CodeBlock({
|
||||
backgroundColor,
|
||||
singleBlock,
|
||||
language,
|
||||
theme = "auto",
|
||||
...props
|
||||
}: Props) {
|
||||
const codeRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -46,24 +68,33 @@ export default function CodeBlock({
|
||||
|
||||
const title = props?.["data-title"];
|
||||
|
||||
const finalBackgroundColor = backgroundColor || "#28272b";
|
||||
const highlighted = React.useMemo(
|
||||
() =>
|
||||
typeof children === "string"
|
||||
? highlightCode({ code: children, language })
|
||||
: null,
|
||||
[children, language],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...wrapperProps}
|
||||
className={twMerge(
|
||||
"outline-[1px] outline-slate-200 dark:outline-white/10",
|
||||
`rounded w-full transition-all items-start`,
|
||||
"outline-[1px] outline-(--twui-code-border) bg-(--twui-code-bg)",
|
||||
`rounded w-full transition-all overflow-hidden`,
|
||||
"relative max-w-[80vw] sm:max-w-[85vw] xl:max-w-[880px]",
|
||||
"twui-code-block-wrapper",
|
||||
`twui-code-block-theme-${theme}`,
|
||||
wrapperProps?.className,
|
||||
)}
|
||||
style={{
|
||||
boxShadow: copied
|
||||
? "0 0 10px 10px rgba(18, 139, 99, 0.2)"
|
||||
: undefined,
|
||||
backgroundColor: finalBackgroundColor,
|
||||
...props.style,
|
||||
...(backgroundColor
|
||||
? ({ "--twui-code-bg": backgroundColor } as React.CSSProperties)
|
||||
: {}),
|
||||
...wrapperProps?.style,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
@@ -74,19 +105,22 @@ export default function CodeBlock({
|
||||
>
|
||||
<Row
|
||||
className={twMerge(
|
||||
"w-full px-1 h-10 sticky top-0 py-2",
|
||||
singleBlock ? "absolute !bg-transparent" : "",
|
||||
"flex-nowrap z-10",
|
||||
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",
|
||||
)}
|
||||
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">
|
||||
{copied ? (
|
||||
<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!
|
||||
</span>
|
||||
<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
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
beforeIcon={<Copy size={17} color="white" />}
|
||||
className="!p-1 !bg-transparent opacity-50"
|
||||
beforeIcon={<Copy size={17} />}
|
||||
className="!p-1 !bg-transparent !text-(--twui-code-muted) hover:!text-(--twui-code-fg)"
|
||||
onClick={() => {
|
||||
const content =
|
||||
codeRef.current?.textContent;
|
||||
@@ -122,12 +156,12 @@ export default function CodeBlock({
|
||||
</div>
|
||||
</Row>
|
||||
{!singleBlock && (
|
||||
<Divider className="!border-white/10 sticky top-10" />
|
||||
<Divider className="!border-(--twui-code-border) sticky top-10 z-10 shrink-0" />
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
`p-1 w-full [&_pre]:!bg-transparent`,
|
||||
singleBlock ? "" : "-mt-1",
|
||||
`px-3 py-2.5 w-full [&_pre]:!bg-transparent`,
|
||||
singleBlock ? "pr-11" : "",
|
||||
"twui-code-pre-wrapper",
|
||||
)}
|
||||
ref={codeRef as any}
|
||||
@@ -135,13 +169,26 @@ export default function CodeBlock({
|
||||
<pre
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"!my-0 whitespace-pre-wrap",
|
||||
"!my-0 whitespace-pre-wrap break-words text-(--twui-code-fg)",
|
||||
language ? `language-${language}` : "",
|
||||
"twui-code-block-pre",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<code
|
||||
className={
|
||||
language ? `language-${language}` : undefined
|
||||
}
|
||||
>
|
||||
{highlighted
|
||||
? highlighted.children.map((node, index) => (
|
||||
<CodeBlockHastNode
|
||||
key={index}
|
||||
node={node}
|
||||
/>
|
||||
))
|
||||
: children}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type MDXComponents } from "mdx/types";
|
||||
import CodeBlock from "../elements/CodeBlock";
|
||||
import CodeBlock, { type TWUICodeBlockTheme } from "../elements/CodeBlock";
|
||||
import H1 from "../layout/H1";
|
||||
import H4 from "../layout/H4";
|
||||
import React from "react";
|
||||
@@ -11,10 +11,12 @@ import H3 from "../layout/H3";
|
||||
type Params = {
|
||||
components: MDXComponents;
|
||||
codeBgColor?: string;
|
||||
codeTheme?: TWUICodeBlockTheme;
|
||||
};
|
||||
|
||||
export function useMDXComponents(params?: Params) {
|
||||
const codeBgColor = params?.codeBgColor || "#000000";
|
||||
const codeBgColor = params?.codeBgColor;
|
||||
const codeTheme = params?.codeTheme;
|
||||
|
||||
return {
|
||||
components: {
|
||||
@@ -24,7 +26,9 @@ export function useMDXComponents(params?: Params) {
|
||||
if (React.isValidElement(children) && children.props) {
|
||||
return (
|
||||
// @ts-ignore
|
||||
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
||||
<CodeBlock {...props} backgroundColor={codeBgColor}
|
||||
theme={codeTheme}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
{children.props.children}
|
||||
</CodeBlock>
|
||||
@@ -32,7 +36,9 @@ export function useMDXComponents(params?: Params) {
|
||||
}
|
||||
return (
|
||||
// @ts-ignore
|
||||
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
||||
<CodeBlock {...props} backgroundColor={codeBgColor}
|
||||
theme={codeTheme}
|
||||
>
|
||||
{children}
|
||||
</CodeBlock>
|
||||
);
|
||||
|
||||
@@ -4,17 +4,19 @@ import H1 from "../../layout/H1";
|
||||
import H2 from "../../layout/H2";
|
||||
import H3 from "../../layout/H3";
|
||||
import H4 from "../../layout/H4";
|
||||
import CodeBlock from "../../elements/CodeBlock";
|
||||
import CodeBlock, { type TWUICodeBlockTheme } from "../../elements/CodeBlock";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Params = {
|
||||
components: MDXComponents;
|
||||
codeBgColor?: string;
|
||||
codeTheme?: TWUICodeBlockTheme;
|
||||
};
|
||||
|
||||
export default function useMDXComponents({
|
||||
components,
|
||||
codeBgColor,
|
||||
codeTheme,
|
||||
}: Params): MDXComponents {
|
||||
return {
|
||||
h1: ({ children }) => <H1>{children}</H1>,
|
||||
@@ -23,7 +25,9 @@ export default function useMDXComponents({
|
||||
if (React.isValidElement(children) && children.props) {
|
||||
return (
|
||||
// @ts-ignore
|
||||
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
||||
<CodeBlock {...props} backgroundColor={codeBgColor}
|
||||
theme={codeTheme}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
{children.props.children}
|
||||
</CodeBlock>
|
||||
@@ -31,7 +35,9 @@ export default function useMDXComponents({
|
||||
}
|
||||
return (
|
||||
// @ts-ignore
|
||||
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
||||
<CodeBlock {...props} backgroundColor={codeBgColor}
|
||||
theme={codeTheme}
|
||||
>
|
||||
{children}
|
||||
</CodeBlock>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"next-mdx-remote": "latest",
|
||||
"remark-gfm": "latest",
|
||||
"rehype-prism-plus": "latest",
|
||||
"html-to-react": "^1.7.0"
|
||||
"html-to-react": "^1.7.0",
|
||||
"refractor": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ace": "latest",
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function HostInterfaceSection({ host, clients }: Props) {
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<CodeBlock wrapperProps={{ className: "max-w-full!" }}>
|
||||
<CodeBlock language="ini" wrapperProps={{ className: "max-w-full!" }}>
|
||||
{buildInterfaceSummary({ config })}
|
||||
</CodeBlock>
|
||||
</Panel>
|
||||
|
||||
+3
-1
@@ -29,7 +29,9 @@ export default function ClientConfigPanel() {
|
||||
}
|
||||
>
|
||||
{config ? (
|
||||
<CodeBlock wrapperProps={{ className: "max-w-full!" }}>{config}</CodeBlock>
|
||||
<CodeBlock language="ini" wrapperProps={{ className: "max-w-full!" }}>
|
||||
{config}
|
||||
</CodeBlock>
|
||||
) : (
|
||||
<EmptyState
|
||||
className="py-8"
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ span code {
|
||||
}
|
||||
|
||||
.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 */
|
||||
|
||||
Reference in New Issue
Block a user