diff --git a/bun.lock b/bun.lock index ab0df95..5d5c58a 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/package.json b/package.json index 1468369..7acad4b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/twui/(functions)/code-block/highlight-code.ts b/src/components/twui/(functions)/code-block/highlight-code.ts new file mode 100644 index 0000000..6cad41c --- /dev/null +++ b/src/components/twui/(functions)/code-block/highlight-code.ts @@ -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); +} diff --git a/src/components/twui/(partials)/CodeBlockHastNode.tsx b/src/components/twui/(partials)/CodeBlockHastNode.tsx new file mode 100644 index 0000000..004775b --- /dev/null +++ b/src/components/twui/(partials)/CodeBlockHastNode.tsx @@ -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 ( + + {node.children.map((child, index) => ( + + ))} + + ); +} diff --git a/src/components/twui/base.css b/src/components/twui/base.css index 1e311e3..301953e 100644 --- a/src/components/twui/base.css +++ b/src/components/twui/base.css @@ -172,3 +172,111 @@ ul, ol { margin-left: 25px; } + +/* -------------------------------------------------------------------------- */ +/* CodeBlock themes */ +/* -------------------------------------------------------------------------- */ +/* + * `.twui-code-block-theme-auto` (default) follows the `.dark` class on ; + * `-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); +} diff --git a/src/components/twui/elements/CodeBlock.tsx b/src/components/twui/elements/CodeBlock.tsx index 28b9885..dfebe2b 100644 --- a/src/components/twui/elements/CodeBlock.tsx +++ b/src/components/twui/elements/CodeBlock.tsx @@ -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 ``; `light`/`dark` pin the palette */ +export const TWUICodeBlockThemes = ["auto", "light", "dark"] as const; + +export type TWUICodeBlockTheme = (typeof TWUICodeBlockThemes)[number]; type Props = PropsWithChildren & DetailedHTMLProps, 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(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 (
- {title && {title}} + {title && ( + + {title} + + )}
{copied ? ( - + Copied!
@@ -97,8 +131,8 @@ export default function CodeBlock({
{!singleBlock && ( - + )}
- {children} + + {highlighted + ? highlighted.children.map((node, index) => ( + + )) + : children} +
diff --git a/src/components/twui/mdx/mdx-components.tsx b/src/components/twui/mdx/mdx-components.tsx index 2bd8a3c..3def67c 100644 --- a/src/components/twui/mdx/mdx-components.tsx +++ b/src/components/twui/mdx/mdx-components.tsx @@ -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 - + {/* @ts-ignore */} {children.props.children} @@ -32,7 +36,9 @@ export function useMDXComponents(params?: Params) { } return ( // @ts-ignore - + {children} ); diff --git a/src/components/twui/next-js/hooks/useMDXComponents.tsx b/src/components/twui/next-js/hooks/useMDXComponents.tsx index e719aee..5958834 100644 --- a/src/components/twui/next-js/hooks/useMDXComponents.tsx +++ b/src/components/twui/next-js/hooks/useMDXComponents.tsx @@ -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 }) =>

{children}

, @@ -23,7 +25,9 @@ export default function useMDXComponents({ if (React.isValidElement(children) && children.props) { return ( // @ts-ignore - + {/* @ts-ignore */} {children.props.children} @@ -31,7 +35,9 @@ export default function useMDXComponents({ } return ( // @ts-ignore - + {children} ); diff --git a/src/components/twui/package.json b/src/components/twui/package.json index 1b56ce5..6b15d0b 100644 --- a/src/components/twui/package.json +++ b/src/components/twui/package.json @@ -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", diff --git a/src/pages/admin/hosts/[host_id]/(sections)/host-interface-section.tsx b/src/pages/admin/hosts/[host_id]/(sections)/host-interface-section.tsx index a565d36..b351b2f 100644 --- a/src/pages/admin/hosts/[host_id]/(sections)/host-interface-section.tsx +++ b/src/pages/admin/hosts/[host_id]/(sections)/host-interface-section.tsx @@ -30,7 +30,7 @@ export default function HostInterfaceSection({ host, clients }: Props) { ) : undefined } > - + {buildInterfaceSummary({ config })} diff --git a/src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-config-panel.tsx b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-config-panel.tsx index 77fa869..ab0d7e2 100644 --- a/src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-config-panel.tsx +++ b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-config-panel.tsx @@ -29,7 +29,9 @@ export default function ClientConfigPanel() { } > {config ? ( - {config} + + {config} + ) : (