From e00d1c5bbd4f7cad496b140556987742ea7002af Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Wed, 28 Jan 2026 13:48:56 +0100 Subject: [PATCH] Support code themes customization option, refactor built-in code themes (#3949) --- .changeset/cool-squids-own.md | 6 + bun.lock | 26 +- package.json | 2 +- packages/gitbook/e2e/util.ts | 11 + packages/gitbook/package.json | 2 +- .../CodeBlock/ClientCodeBlock.tsx | 70 +- .../DocumentView/CodeBlock/CodeBlock.tsx | 37 +- .../CodeBlock/CodeBlockRenderer.tsx | 125 ++- .../DocumentView/CodeBlock/PlainCodeBlock.tsx | 17 +- .../DocumentView/CodeBlock/customThemes.ts | 832 ++++++++++++++++++ .../DocumentView/CodeBlock/highlight.test.ts | 91 +- .../DocumentView/CodeBlock/highlight.ts | 107 ++- .../DocumentView/CodeBlock/plain-highlight.ts | 86 +- .../DocumentView/OpenAPI/context.tsx | 12 +- .../components/DocumentView/OpenAPI/style.css | 6 +- .../RootLayout/CustomizationRootLayout.tsx | 4 +- .../src/components/RootLayout/globals.css | 98 +-- packages/gitbook/src/lib/utils.ts | 10 + packages/react-openapi/src/StaticSection.tsx | 8 +- 19 files changed, 1318 insertions(+), 232 deletions(-) create mode 100644 .changeset/cool-squids-own.md create mode 100644 packages/gitbook/src/components/DocumentView/CodeBlock/customThemes.ts diff --git a/.changeset/cool-squids-own.md b/.changeset/cool-squids-own.md new file mode 100644 index 000000000..078796252 --- /dev/null +++ b/.changeset/cool-squids-own.md @@ -0,0 +1,6 @@ +--- +"gitbook": minor +"@gitbook/react-openapi": patch +--- + +Support code themes customization diff --git a/bun.lock b/bun.lock index ba6305615..3ac12a2e5 100644 --- a/bun.lock +++ b/bun.lock @@ -176,7 +176,7 @@ "remark-rehype": "^11.1.1", "rison": "^0.1.1", "server-only": "^0.0.1", - "shiki": "^3.2.0", + "shiki": "^3.21.0", "tailwind-merge": "^2.2.0", "tailwind-shades": "^1.1.2", "unified": "^11.0.5", @@ -345,7 +345,7 @@ "react-dom": "catalog:", }, "catalog": { - "@gitbook/api": "0.158.0", + "@gitbook/api": "0.159.0", "@scalar/api-client-react": "^1.3.46", "@tsconfig/node20": "^20.1.6", "@tsconfig/strictest": "^2.0.6", @@ -724,7 +724,7 @@ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.1.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" } }, "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA=="], - "@gitbook/api": ["@gitbook/api@0.158.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-oWrUY0gQ4rVKyNciHaXQrvi+EoiLCWHhAD0QyAhsHJp9+ndaLRMV6WfK2KbiGQmH8kiWwwBmwmQfMkZFmm1eyQ=="], + "@gitbook/api": ["@gitbook/api@0.159.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-IRZXT2NAcOlKlCcGnzRH4w82AuBXam5I7lw84qfCRbNDDycvFNBwIVMHu3JMOcGUTCkzj7QDElaNKYirsWDwFQ=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], @@ -1320,17 +1320,17 @@ "@scalar/workspace-store": ["@scalar/workspace-store@0.20.0", "", { "dependencies": { "@scalar/code-highlight": "0.2.0", "@scalar/helpers": "0.1.1", "@scalar/json-magic": "0.8.1", "@scalar/openapi-upgrader": "0.1.4", "@scalar/snippetz": "0.5.2", "@scalar/themes": "0.13.23", "@scalar/typebox": "0.1.1", "@scalar/types": "0.4.0", "github-slugger": "^2.0.0", "type-fest": "5.0.0", "vue": "^3.5.21", "yaml": "2.8.0" } }, "sha512-RklQSKjz5V/69rKjxQV70q4u34qzmUZVtWGUJLVvHxPsPxCtxPPRLYY+gdQDkGjTQYol/dR9xyvi2QGk3OWTKA=="], - "@shikijs/core": ["@shikijs/core@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-+5dPz8q6HgNqfQ28ycm/vA8dIVd2lNFOUqVRFCQLbs0KZ6emYI+1apLpX+wuL/aDSPLOkMgARwNjkA5UjGKS1Q=="], + "@shikijs/core": ["@shikijs/core@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.1.0" } }, "sha512-1WrYfaz5YT5aTAIMbYQhxlSHc8ArX+hCDNAIdKRqJHzfWQ3xDgh3PTvrAly+RWGuvi5Q4NlvPlTBdlSAXN6Stg=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-3V7ko+YUAP02I4rUbDjCgvyM/H85hUIZBQAS19FjDcJMKL5SbjWTiG7TRKxX1V4ddxLxt2RO64wZinElp/3ngQ=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ=="], - "@shikijs/langs": ["@shikijs/langs@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0" } }, "sha512-Qze5YIsp223AmC69VZDQolcrcYPrVa9wV6cW2kVqsDrSWlwhW2EQZEn1Iw2oQU1tGYVg8Hj/xdp8mOv+9zI0vg=="], + "@shikijs/langs": ["@shikijs/langs@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA=="], - "@shikijs/themes": ["@shikijs/themes@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0" } }, "sha512-XfzMSTu6iMl2FZIwKykld2OzFKDDlm4KbZrzW6sbKXEeJ1xq61HX4x4bE4+REBFqbbrvAQM8EAH11m/E3cxYDg=="], + "@shikijs/themes": ["@shikijs/themes@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw=="], - "@shikijs/types": ["@shikijs/types@3.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-1uOwfEO0vV+G8n/AO/6Yth7zshNdXvQ1pc4ygTrfE3cyuzVLukrZq72YkFUlsRijam7LvRTvnqL4aT5wx1X2Vw=="], + "@shikijs/types": ["@shikijs/types@3.21.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -1898,8 +1898,6 @@ "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="], - "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -2552,9 +2550,9 @@ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - "oniguruma-parser": ["oniguruma-parser@0.5.4", "", {}, "sha512-yNxcQ8sKvURiTwP0mV6bLQCYE7NKfKRRWunhbZnXgxSmB1OXa1lHrN3o4DZd+0Si0kU5blidK7BcROO8qv5TZA=="], + "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], - "oniguruma-to-es": ["oniguruma-to-es@4.1.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "oniguruma-parser": "^0.5.4", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-SNwG909cSLo4vPyyPbU/VJkEc9WOXqu2ycBlfd1UCXLqk1IijcQktSBb2yRQ2UFPsDhpkaf+C1dtT3PkLK/yWA=="], + "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], "openapi-fetch": ["openapi-fetch@0.13.5", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-AQK8T9GSKFREFlN1DBXTYsLjs7YV2tZcJ7zUWxbjMoQmj8dDSFRrzhLCbHPZWA1TMV3vACqfCxLEZcwf2wxV6Q=="], @@ -2774,7 +2772,7 @@ "shell-quote": ["shell-quote@1.8.1", "", {}, "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA=="], - "shiki": ["shiki@3.2.0", "", { "dependencies": { "@shikijs/core": "3.2.0", "@shikijs/engine-javascript": "3.2.0", "@shikijs/engine-oniguruma": "3.2.0", "@shikijs/langs": "3.2.0", "@shikijs/themes": "3.2.0", "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lOF6wkvZCRVQrdfGilyXclTKIjCWKujPAjD6fddLwtQ6eSmgj43pFDbjUmxivtElDRlsGO8G2dLeeRpwNY4wWg=="], + "shiki": ["shiki@3.21.0", "", { "dependencies": { "@shikijs/core": "3.21.0", "@shikijs/engine-javascript": "3.21.0", "@shikijs/engine-oniguruma": "3.21.0", "@shikijs/langs": "3.21.0", "@shikijs/themes": "3.21.0", "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w=="], "side-channel": ["side-channel@1.0.6", "", { "dependencies": { "call-bind": "^1.0.7", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.4", "object-inspect": "^1.13.1" } }, "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA=="], diff --git a/package.json b/package.json index 64bf2377f..53b8591d5 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "catalog": { "@tsconfig/strictest": "^2.0.6", "@tsconfig/node20": "^20.1.6", - "@gitbook/api": "0.158.0", + "@gitbook/api": "0.159.0", "@scalar/api-client-react": "^1.3.46", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts index d5a00907b..2ca06d4bc 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -2,6 +2,7 @@ import { argosScreenshot } from '@argos-ci/playwright'; import { CustomizationAIMode, CustomizationBackground, + CustomizationCodeTheme, CustomizationCorners, CustomizationDefaultFont, CustomizationDefaultMonospaceFont, @@ -298,6 +299,16 @@ export function getCustomizationURL(partial: DeepPartial, 'block' | 'style'> & { inlines: RenderedInline[]; inlineExprVariables: InlineExpressionVariables; mode: BlockProps['context']['mode']; + themes?: CustomizationThemedCodeTheme; }; export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10; @@ -29,7 +30,7 @@ export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10; * It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting. */ export function ClientCodeBlock(props: ClientBlockProps) { - const { block, mode, style, inlines, inlineExprVariables } = props; + const { block, mode, style, inlines, inlineExprVariables, themes } = props; const blockRef = useRef(null); const isInViewportRef = useRef(false); const [isInViewport, setIsInViewport] = useState(false); @@ -40,17 +41,17 @@ export function ClientCodeBlock(props: ClientBlockProps) { visitorClaims, variables: inlineExprVariables, }); - const plainLines = useMemo( - () => plainHighlight(block, inlines, { evaluateInlineExpression }), - [block, inlines, evaluateInlineExpression] + const plainTheme = useMemo( + () => plainHighlight(block, inlines, { evaluateInlineExpression, themes }), + [block, inlines, evaluateInlineExpression, themes] ); - const [lines, setLines] = useState(null); + const [theme, setTheme] = useState(null); const [highlighting, setHighlighting] = useState(false); // Preload the highlighter when the block is mounted. useEffect(() => { - import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block)); - }, [block]); + import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block, themes)); + }, [block, themes]); // When user scrolls, we need to wait for the scroll to finish before running the highlight const isScrollingRef = useRef(false); @@ -99,14 +100,16 @@ export function ClientCodeBlock(props: ClientBlockProps) { if (typeof window !== 'undefined') { setHighlighting(true); import('./highlight').then(({ highlight }) => { - highlight(block, inlines, { evaluateInlineExpression }).then((lines) => { - if (cancelled) { - return; - } + highlight(block, inlines, { evaluateInlineExpression, themes }).then( + (theme) => { + if (cancelled) { + return; + } - setLines(lines); - setHighlighting(false); - }); + setTheme(theme); + setHighlighting(false); + } + ); }); } @@ -116,12 +119,12 @@ export function ClientCodeBlock(props: ClientBlockProps) { } // Otherwise if the block is not in viewport, we reset to the plain lines - setLines(null); - }, [isInViewport, block, inlines, evaluateInlineExpression]); + setTheme(null); + }, [isInViewport, block, inlines, evaluateInlineExpression, themes]); const expandable = block.data.expandable; - const numberOfLinesOfCode = lines?.length ?? plainLines.length; + const numberOfLinesOfCode = theme?.lines.length ?? plainTheme.lines.length; const collapsedLineCount = block.data.collapsedLineCount || CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT; const isExpandable = Boolean( @@ -136,16 +139,17 @@ export function ClientCodeBlock(props: ClientBlockProps) { aria-busy={highlighting} block={block} style={style} - lines={lines ?? plainLines} + theme={theme ?? plainTheme} id={codeBlockBodyId} /> ); return isExpandable ? ( {renderer} @@ -156,21 +160,25 @@ export function ClientCodeBlock(props: ClientBlockProps) { function CodeBlockExpandable(props: { children: React.ReactNode; - lines: HighlightLine[]; + theme: HighlightTheme; collapsedLineCount: number; controls?: string; + style?: ClassValue; }) { - const { children, controls, lines = [], collapsedLineCount } = props; + const { children, controls, theme, collapsedLineCount, style } = props; const [isExpanded, setIsExpanded] = useState(false); const language = useLanguage(); return ( -
+
{children}
-
+
diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlock.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlock.tsx index 6ef4ee8cf..a9cd28189 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlock.tsx @@ -1,6 +1,10 @@ import * as React from 'react'; -import type { DocumentBlockCode } from '@gitbook/api'; +import type { + CustomizationThemedCodeTheme, + DocumentBlockCode, + SiteCustomizationSettings, +} from '@gitbook/api'; import { getNodeFragmentByType } from '@/lib/document'; @@ -13,8 +17,21 @@ import { type RenderedInline, getInlines, highlight } from './highlight'; /** * Render a code block, can be client-side or server-side. */ -export async function CodeBlock(props: BlockProps) { - const { block, document, style, isEstimatedOffscreen, context } = props; +export async function CodeBlock( + props: BlockProps & { + themeKey?: keyof SiteCustomizationSettings['styling']['codeTheme']; + themes?: CustomizationThemedCodeTheme; + } +) { + const { + block, + document, + style, + isEstimatedOffscreen, + context, + themeKey = 'default', + themes: providedThemes, + } = props; const inlines = getInlines(block); let hasInlineExpression = false; @@ -50,10 +67,19 @@ export async function CodeBlock(props: BlockProps) { return { inline, body }; }); + // Get code themes from customization + const themes = + providedThemes ?? + (context.contentContext && 'customization' in context.contentContext + ? context.contentContext.customization.styling.codeTheme[themeKey] + : undefined); + if (!isEstimatedOffscreen && !hasInlineExpression && !block.data.expandable) { // In v2, we render the code block server-side - const lines = await highlight(block, richInlines); - return ; + const theme = await highlight(block, richInlines, { + themes: themes, + }); + return ; } const variables = context.contentContext @@ -74,6 +100,7 @@ export async function CodeBlock(props: BlockProps) { inlines={richInlines} inlineExprVariables={variables} mode={context.mode} + themes={themes} /> ); diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx index e50e874eb..32d6f711d 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx @@ -7,10 +7,10 @@ import { tcls } from '@/lib/tailwind'; import { AnnotationPopover } from '../Annotation/AnnotationPopover'; import type { BlockProps } from '../Block'; import { CopyCodeButton } from './CopyCodeButton'; -import type { HighlightLine, HighlightToken } from './highlight'; +import type { HighlightLine, HighlightTheme, HighlightToken } from './highlight'; type CodeBlockRendererProps = Pick, 'block' | 'style'> & { - lines: HighlightLine[]; + theme: HighlightTheme; 'aria-busy'?: boolean; id?: string; }; @@ -22,7 +22,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer( props: CodeBlockRendererProps, ref: React.ForwardedRef ) { - const { block, style, lines, 'aria-busy': ariaBusy } = props; + const { block, style, theme, 'aria-busy': ariaBusy } = props; const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1; const withWrap = block.data.overflow === 'wrap'; @@ -30,15 +30,35 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer( const id = useId(); const codeId = props.id || id; + + /* Shiki returns a color + CSS variables in a single string, which isn't supported in React. + So we parse the foreground & background into objects that can be passed into React's `style` prop. */ + const bg = parseShikiColorString(theme.bg); + const fg = parseShikiColorString(theme.fg); + return (
{title ? ( -
+
{title}
) : null} @@ -49,23 +69,32 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer( />
                 
-                    {lines.map((line, index) => (
+                    {theme.lines.map((line, index) => (
                         
                     ))}
@@ -77,13 +106,35 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
 
 function CodeHighlightLine(props: {
     line: HighlightLine;
+    bg?: ShikiColorDefinition;
+    fg?: ShikiColorDefinition;
     isLast: boolean;
     withLineNumbers: boolean;
 }) {
-    const { line, isLast, withLineNumbers } = props;
+    const { line, isLast, withLineNumbers, bg, fg } = props;
     return (
-        
-            {withLineNumbers && }
+        
+            {withLineNumbers && (
+                
+            )}
             
                 
                 {!isLast && '\n'}
@@ -112,13 +163,53 @@ function CodeHighlightToken(props: { token: HighlightToken }) {
             return token.content;
         }
         case 'shiki': {
-            if (!token.token.color) {
+            if (!token.token.htmlStyle) {
                 return token.token.content;
             }
 
-            return {token.token.content};
+            return {token.token.content};
         }
         default:
             assertNever(token);
     }
 }
+
+type ShikiColorDefinition = {
+    color: string | undefined;
+    vars: Record | undefined;
+};
+
+/**
+ * Parse Shiki color string format into separate color and CSS variables.
+ *
+ * Shiki returns colors in a format that combines a default color with CSS variables:
+ * "defaultColor;--shiki-light:value1;--shiki-dark:value2"
+ *
+ * React does not parse these strings as valid CSS because they contain values & properties in one.
+ * We split these so React can apply them separately via the style prop.
+ */
+function parseShikiColorString(
+    originalColor: string | undefined
+): ShikiColorDefinition | undefined {
+    if (!originalColor) {
+        return undefined;
+    }
+
+    const parts = originalColor.split(';');
+    const color = parts[0];
+    const vars = parts.slice(1).reduce(
+        (acc, item) => {
+            const [key, value] = item.split(':');
+            if (key && value) {
+                acc[key] = value;
+            }
+            return acc;
+        },
+        {} as Record
+    );
+
+    return {
+        color,
+        vars: Object.keys(vars).length > 0 ? vars : undefined,
+    };
+}
diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/PlainCodeBlock.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/PlainCodeBlock.tsx
index f303e5072..2b6b92feb 100644
--- a/packages/gitbook/src/components/DocumentView/CodeBlock/PlainCodeBlock.tsx
+++ b/packages/gitbook/src/components/DocumentView/CodeBlock/PlainCodeBlock.tsx
@@ -1,4 +1,8 @@
-import type { JSONDocument } from '@gitbook/api';
+import type {
+    CustomizationThemedCodeTheme,
+    JSONDocument,
+    SiteCustomizationSettings,
+} from '@gitbook/api';
 import { useId } from 'react';
 
 import { CodeBlock } from './CodeBlock';
@@ -8,8 +12,13 @@ import { convertCodeStringToBlock } from './utils';
  * Plain code block with syntax highlighting.
  * For simplicity, this is just a wrapper around the CodeBlock component, emulating a document.
  */
-export function PlainCodeBlock(props: { code: string; syntax: string }) {
-    const { code, syntax } = props;
+export function PlainCodeBlock(props: {
+    code: string;
+    syntax: string;
+    themeKey?: keyof SiteCustomizationSettings['styling']['codeTheme'];
+    themes?: CustomizationThemedCodeTheme;
+}) {
+    const { code, syntax, themeKey, themes } = props;
     const id = useId();
 
     const block = convertCodeStringToBlock({ key: id, code, syntax });
@@ -26,6 +35,8 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
             context={{
                 mode: 'default',
             }}
+            themeKey={themeKey}
+            themes={themes}
             block={block}
             ancestorBlocks={[]}
             // We optimize perf by default
diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/customThemes.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/customThemes.ts
new file mode 100644
index 000000000..2a9d1debc
--- /dev/null
+++ b/packages/gitbook/src/components/DocumentView/CodeBlock/customThemes.ts
@@ -0,0 +1,832 @@
+import type { ThemeRegistration } from 'shiki/core';
+
+export const customThemes: Record = {
+    'default-light': createTheme({
+        name: 'default-light',
+        type: 'light',
+        bg: 'rgb(var(--tint-2))',
+        fg: 'rgb(var(--tint-12))',
+        colors: {
+            basic: {
+                comment: 'rgb(var(--neutral-9))',
+                string: 'rgb(var(--success-10))',
+                constant: 'rgb(var(--warning-10))',
+                escape: 'rgb(var(--success-10))',
+            },
+            keyword: {
+                default: 'rgb(var(--danger-10))',
+                operator: 'rgb(var(--tint-11))',
+                regexpOperator: 'rgb(var(--primary-10))',
+                regexpQuantifier: 'rgb(var(--warning-10))',
+            },
+            variable: {
+                default: 'rgb(var(--tint-12))',
+                language: 'rgb(var(--danger-10))',
+                parameter: 'rgb(var(--warning-10))',
+            },
+            function: {
+                default: 'rgb(var(--primary-10))',
+                support: 'rgb(var(--primary-10))',
+            },
+            type: {
+                custom: 'rgb(var(--primary-10))',
+                primitive: 'rgb(var(--warning-10))',
+                other: 'rgb(var(--tint-12))',
+                classMeta: 'rgb(var(--tint-12))',
+            },
+            entity: {
+                tag: 'rgb(var(--warning-10))',
+                attributeId: 'rgb(var(--primary-10))',
+                label: 'rgb(var(--danger-10))',
+            },
+            markup: {
+                default: 'rgb(var(--primary-10))',
+                code: 'rgb(var(--success-10))',
+                link: 'rgb(var(--primary-10))',
+                linkUrl: 'rgb(var(--warning-10))',
+                separator: 'rgb(var(--tint-7))',
+            },
+            diff: {
+                inserted: 'rgb(var(--success-10))',
+                deleted: 'rgb(var(--danger-10))',
+                changed: 'rgb(var(--tint-12))',
+            },
+            other: {
+                punctuation: 'rgb(var(--tint-11))',
+                preprocessor: 'rgb(var(--danger-10))',
+                default: 'rgb(var(--tint-11))',
+                invalid: 'rgb(var(--danger-10))',
+            },
+        },
+    }),
+    'default-dark': createTheme({
+        name: 'default-dark',
+        type: 'dark',
+        bg: 'rgb(var(--tint-2))',
+        fg: 'rgb(var(--tint-12))',
+        colors: {
+            basic: {
+                comment: 'rgb(var(--neutral-9))',
+                string: 'rgb(var(--success-11))',
+                constant: 'rgb(var(--warning-11))',
+                escape: 'rgb(var(--success-11))',
+            },
+            keyword: {
+                default: 'rgb(var(--danger-11))',
+                operator: 'rgb(var(--tint-11))',
+                regexpOperator: 'rgb(var(--primary-11))',
+                regexpQuantifier: 'rgb(var(--warning-11))',
+            },
+            variable: {
+                default: 'rgb(var(--tint-12))',
+                language: 'rgb(var(--danger-11))',
+                parameter: 'rgb(var(--warning-11))',
+            },
+            function: {
+                default: 'rgb(var(--primary-11))',
+                support: 'rgb(var(--primary-11))',
+            },
+            type: {
+                custom: 'rgb(var(--primary-11))',
+                primitive: 'rgb(var(--warning-11))',
+                other: 'rgb(var(--tint-12))',
+                classMeta: 'rgb(var(--tint-12))',
+            },
+            entity: {
+                tag: 'rgb(var(--warning-11))',
+                attributeId: 'rgb(var(--primary-11))',
+                label: 'rgb(var(--danger-11))',
+            },
+            markup: {
+                default: 'rgb(var(--primary-11))',
+                code: 'rgb(var(--success-11))',
+                link: 'rgb(var(--primary-11))',
+                linkUrl: 'rgb(var(--warning-11))',
+                separator: 'rgb(var(--tint-7))',
+            },
+            diff: {
+                inserted: 'rgb(var(--success-11))',
+                deleted: 'rgb(var(--danger-11))',
+                changed: 'rgb(var(--tint-12))',
+            },
+            other: {
+                punctuation: 'rgb(var(--tint-12))',
+                preprocessor: 'rgb(var(--danger-11))',
+                default: 'rgb(var(--tint-12))',
+                invalid: 'rgb(var(--danger-11))',
+            },
+        },
+    }),
+    'monochrome-light': createTheme({
+        name: 'monochrome-light',
+        type: 'light',
+        bg: 'rgb(var(--tint-2))',
+        fg: 'rgb(var(--tint-12))',
+        colors: {
+            basic: {
+                comment: 'rgb(var(--tint-9))',
+                string: 'rgb(var(--primary-12))',
+                constant: 'rgb(var(--primary-11))',
+                escape: 'rgb(var(--tint-12))',
+            },
+            keyword: {
+                default: 'rgb(var(--primary-10))',
+                operator: 'rgb(var(--primary-11))',
+                regexpOperator: 'rgb(var(--primary-10))',
+                regexpQuantifier: 'rgb(var(--primary-11))',
+            },
+            variable: {
+                default: 'rgb(var(--tint-12))',
+                language: 'rgb(var(--primary-10))',
+                parameter: 'rgb(var(--tint-11))',
+            },
+            function: {
+                default: 'rgb(var(--primary-9))',
+                support: 'rgb(var(--primary-9))',
+            },
+            type: {
+                custom: 'rgb(var(--primary-10))',
+                primitive: 'rgb(var(--primary-11))',
+                other: 'rgb(var(--primary-10))',
+                classMeta: 'rgb(var(--tint-12))',
+            },
+            entity: {
+                tag: 'rgb(var(--primary-11))',
+                attributeId: 'rgb(var(--primary-9))',
+                label: 'rgb(var(--primary-10))',
+            },
+            markup: {
+                default: 'rgb(var(--primary-10))',
+                code: 'rgb(var(--tint-12))',
+                link: 'rgb(var(--primary-10))',
+                linkUrl: 'rgb(var(--primary-11))',
+                separator: 'rgb(var(--tint-7))',
+            },
+            diff: {
+                inserted: 'rgb(var(--success-11))',
+                deleted: 'rgb(var(--danger-11))',
+                changed: 'rgb(var(--tint-12))',
+            },
+            other: {
+                punctuation: 'rgb(var(--tint-10))',
+                preprocessor: 'rgb(var(--primary-10))',
+                default: 'rgb(var(--primary-11))',
+                invalid: 'rgb(var(--danger-11))',
+            },
+        },
+    }),
+    'monochrome-dark': createTheme({
+        name: 'monochrome-dark',
+        type: 'dark',
+        bg: 'rgb(var(--tint-2))',
+        fg: 'rgb(var(--tint-12))',
+        colors: {
+            basic: {
+                comment: 'rgb(var(--tint-9))',
+                string: 'rgb(var(--primary-12))',
+                constant: 'rgb(var(--primary-11))',
+                escape: 'rgb(var(--tint-12))',
+            },
+            keyword: {
+                default: 'rgb(var(--primary-11))',
+                operator: 'rgb(var(--primary-11))',
+                regexpOperator: 'rgb(var(--primary-10))',
+                regexpQuantifier: 'rgb(var(--primary-11))',
+            },
+            variable: {
+                default: 'rgb(var(--tint-12))',
+                language: 'rgb(var(--primary-10))',
+                parameter: 'rgb(var(--tint-9))',
+            },
+            function: {
+                default: 'rgb(var(--primary-9))',
+                support: 'rgb(var(--primary-9))',
+            },
+            type: {
+                custom: 'rgb(var(--primary-10))',
+                primitive: 'rgb(var(--primary-11))',
+                other: 'rgb(var(--primary-10))',
+                classMeta: 'rgb(var(--tint-12))',
+            },
+            entity: {
+                tag: 'rgb(var(--primary-11))',
+                attributeId: 'rgb(var(--primary-9))',
+                label: 'rgb(var(--primary-10))',
+            },
+            markup: {
+                default: 'rgb(var(--primary-10))',
+                code: 'rgb(var(--tint-12))',
+                link: 'rgb(var(--primary-10))',
+                linkUrl: 'rgb(var(--primary-11))',
+                separator: 'rgb(var(--tint-7))',
+            },
+            diff: {
+                inserted: 'rgb(var(--success-11))',
+                deleted: 'rgb(var(--danger-11))',
+                changed: 'rgb(var(--tint-12))',
+            },
+            other: {
+                punctuation: 'rgb(var(--tint-10))',
+                preprocessor: 'rgb(var(--primary-10))',
+                default: 'rgb(var(--primary-11))',
+                invalid: 'rgb(var(--danger-11))',
+            },
+        },
+    }),
+};
+
+/**
+ * Color category mappings for syntax highlighting.
+ * Organized into logical groups for better readability.
+ */
+export type ColorCategories = {
+    /** Basic syntax elements */
+    basic: {
+        /** Comments (//, /*, #, etc.) */
+        comment: string;
+        /** String literals ("...", '...', `...`) */
+        string: string;
+        /** Constants: numeric, language constants (true, false, null), character constants */
+        constant: string;
+        /** Escape sequences in strings (\n, \t, etc.) */
+        escape: string;
+    };
+    /** Keywords and operators */
+    keyword: {
+        /** Control flow (if, else, for, while, return), storage (const, let, var, function, class) */
+        default: string;
+        /** Operators (+, -, *, /, =, ==, etc.) */
+        operator: string;
+        /** Regexp operators (|, ^, $) */
+        regexpOperator: string;
+        /** Regexp quantifiers (*, +, ?, {n}) */
+        regexpQuantifier: string;
+    };
+    /** Variables and parameters */
+    variable: {
+        /** Regular variables */
+        default: string;
+        /** Language variables (this, self, super) */
+        language: string;
+        /** Function/method parameters */
+        parameter: string;
+    };
+    /** Functions */
+    function: {
+        /** User-defined function/method invocations */
+        default: string;
+        /** Built-in/library support functions */
+        support: string;
+    };
+    /** Types and classes */
+    type: {
+        /** Custom type names (DocumentBlockCode, HighlightLine, etc.) */
+        custom: string;
+        /** Primitive types (number, boolean, string, etc.) */
+        primitive: string;
+        /** Other types (support.type, support.class, namespaces) */
+        other: string;
+        /** Class meta contexts (meta.class) */
+        classMeta: string;
+    };
+    /** Entities (tags, attributes, labels) */
+    entity: {
+        /** HTML/XML tags and attributes */
+        tag: string;
+        /** Attribute IDs (entity.other.attribute-name.id) */
+        attributeId: string;
+        /** Labels (entity.name.label) */
+        label: string;
+    };
+    /** Markup elements */
+    markup: {
+        /** Markup elements (bold, italic, headings, lists, quotes, etc.) */
+        default: string;
+        /** Inline code in markup */
+        code: string;
+        /** Links in markup */
+        link: string;
+        /** Link URLs in markup */
+        linkUrl: string;
+        /** Separators (meta.separator) */
+        separator: string;
+    };
+    /** Diff highlighting */
+    diff: {
+        /** Inserted lines */
+        inserted: string;
+        /** Deleted lines */
+        deleted: string;
+        /** Changed lines */
+        changed: string;
+    };
+    /** Other syntax elements */
+    other: {
+        /** Punctuation (., ,, ;, :, {}, [], (), etc.) */
+        punctuation: string;
+        /** Preprocessor directives (#include, #define, etc.) */
+        preprocessor: string;
+        /** Other/miscellaneous elements */
+        default: string;
+        /** Invalid syntax/errors */
+        invalid: string;
+    };
+};
+
+/**
+ * Creates a Shiki theme registration from semantic color categories.
+ *
+ * Maps our semantic color categories (comment, keyword, function, etc.) to Shiki's TextMate scopes.
+ * TextMate scopes are hierarchical selectors (e.g., "keyword.control", "variable.parameter") that
+ * Shiki uses to identify different syntax elements. This abstraction allows us to define themes
+ * using semantic categories rather than low-level scope patterns.
+ */
+function createTheme(options: {
+    name: string;
+    type: 'light' | 'dark';
+    bg: string;
+    fg: string;
+    colors: ColorCategories;
+}): ThemeRegistration {
+    const { name, type, bg, fg, colors } = options;
+    return {
+        name,
+        type,
+        bg,
+        fg,
+        settings: [
+            // Comments
+            {
+                scope: ['comment'],
+                settings: {
+                    foreground: colors.basic.comment,
+                },
+            },
+            // Constants: language constants (true, false, null, etc.)
+            {
+                scope: ['constant.language'],
+                settings: {
+                    foreground: colors.basic.constant,
+                },
+            },
+            // Constants: numeric
+            {
+                scope: [
+                    'constant.numeric',
+                    'variable.other.enummember',
+                    'keyword.operator.plus.exponent',
+                    'keyword.operator.minus.exponent',
+                    'keyword.other.unit',
+                    'constant.sha.git-rebase',
+                ],
+                settings: {
+                    foreground: colors.basic.constant,
+                },
+            },
+            // Constants: regexp
+            {
+                scope: ['constant.regexp'],
+                settings: {
+                    foreground: colors.basic.constant,
+                },
+            },
+            // Constants: character
+            {
+                scope: ['constant.character', 'constant.other.option'],
+                settings: {
+                    foreground: colors.basic.constant,
+                },
+            },
+            // Constants: escape sequences
+            {
+                scope: ['constant.character.escape'],
+                settings: {
+                    foreground: colors.basic.escape,
+                },
+            },
+            // Constants: other
+            {
+                scope: [
+                    'constant.other',
+                    'constant.other.color',
+                    'constant.other.symbol',
+                    'constant.other.placeholder',
+                    'constant.other.character-class.regexp',
+                    'constant.character.set.regexp',
+                    'constant.character.character-class.regexp',
+                    'constant.other.character-class.set.regexp',
+                ],
+                settings: {
+                    foreground: colors.basic.constant,
+                },
+            },
+            // Constants: variable constants
+            {
+                scope: ['variable.other.constant'],
+                settings: {
+                    foreground: colors.basic.constant,
+                },
+            },
+            // Strings
+            {
+                scope: [
+                    'string',
+                    'meta.embedded.assembly',
+                    'string.tag',
+                    'string.value',
+                    'string.regexp',
+                    'meta.preprocessor.string',
+                    'support.constant.property-value',
+                    'support.constant.font-name',
+                    'support.constant.media-type',
+                    'support.constant.media',
+                    'constant.other.color.rgb-value',
+                    'constant.other.rgb-value',
+                    'support.constant.color',
+                    'punctuation.definition.group.regexp',
+                    'punctuation.definition.group.assertion.regexp',
+                    'punctuation.definition.character-class.regexp',
+                    'punctuation.character.set.begin.regexp',
+                    'punctuation.character.set.end.regexp',
+                    'keyword.operator.negation.regexp',
+                    'support.other.parenthesis.regexp',
+                ],
+                settings: {
+                    foreground: colors.basic.string,
+                },
+            },
+            // String expressions (template literals, interpolated)
+            // Only color the punctuation, not the expression content
+            {
+                scope: [
+                    'punctuation.definition.template-expression.begin',
+                    'punctuation.definition.template-expression.end',
+                    'punctuation.section.embedded',
+                ],
+                settings: {
+                    foreground: colors.variable.parameter,
+                },
+            },
+            // Keywords: control flow
+            {
+                scope: [
+                    'keyword.control',
+                    'source.cpp keyword.operator.new',
+                    'keyword.operator.delete',
+                    'keyword.other.using',
+                    'keyword.other.directive.using',
+                    'keyword.other.operator',
+                    'entity.name.operator',
+                    'keyword.operator.new',
+                    'keyword.operator.expression',
+                    'keyword.operator.cast',
+                    'keyword.operator.sizeof',
+                    'keyword.operator.alignof',
+                    'keyword.operator.typeid',
+                    'keyword.operator.alignas',
+                    'keyword.operator.instanceof',
+                    'keyword.operator.logical.python',
+                    'keyword.operator.wordlike',
+                ],
+                settings: {
+                    foreground: colors.keyword.default,
+                },
+            },
+            // Keywords: storage (const, let, var, function, class, etc.)
+            {
+                scope: [
+                    'storage',
+                    'storage.type',
+                    'storage.modifier',
+                    'keyword.operator.noexcept',
+                    'storage.type.numeric.go',
+                    'storage.type.byte.go',
+                    'storage.type.boolean.go',
+                    'storage.type.string.go',
+                    'storage.type.uintptr.go',
+                    'storage.type.error.go',
+                    'storage.type.rune.go',
+                    'storage.type.cs',
+                    'storage.type.generic.cs',
+                    'storage.type.modifier.cs',
+                    'storage.type.variable.cs',
+                    'storage.type.annotation.java',
+                    'storage.type.generic.java',
+                    'storage.type.java',
+                    'storage.type.object.array.java',
+                    'storage.type.primitive.array.java',
+                    'storage.type.primitive.java',
+                    'storage.type.token.java',
+                    'storage.type.groovy',
+                    'storage.type.annotation.groovy',
+                    'storage.type.parameters.groovy',
+                    'storage.type.generic.groovy',
+                    'storage.type.object.array.groovy',
+                    'storage.type.primitive.array.groovy',
+                    'storage.type.primitive.groovy',
+                ],
+                settings: {
+                    foreground: colors.keyword.default,
+                },
+            },
+            // Keywords: general
+            {
+                scope: ['keyword'],
+                settings: {
+                    foreground: colors.keyword.default,
+                },
+            },
+            // Keywords: operators (default)
+            {
+                scope: ['keyword.operator'],
+                settings: {
+                    foreground: colors.keyword.operator,
+                },
+            },
+            // Keywords: regexp operators
+            {
+                scope: ['keyword.operator.or.regexp', 'keyword.control.anchor.regexp'],
+                settings: {
+                    foreground: colors.keyword.regexpOperator,
+                },
+            },
+            // Keywords: regexp quantifiers
+            {
+                scope: ['keyword.operator.quantifier.regexp'],
+                settings: {
+                    foreground: colors.keyword.regexpQuantifier,
+                },
+            },
+            // Variable language (this, self, super, etc.)
+            {
+                scope: ['variable.language'],
+                settings: {
+                    foreground: colors.variable.language,
+                },
+            },
+            // Parameters
+            {
+                scope: ['variable.parameter'],
+                settings: {
+                    foreground: colors.variable.parameter,
+                },
+            },
+            // Variables (regular variables, not parameters)
+            {
+                scope: [
+                    'variable',
+                    'meta.definition.variable.name',
+                    'support.variable',
+                    'entity.name.variable',
+                    'meta.object-literal.key',
+                ],
+                settings: {
+                    foreground: colors.variable.default,
+                },
+            },
+            // Functions: invocations (calls) - user-defined functions
+            {
+                scope: [
+                    'meta.function-call',
+                    'meta.method-call',
+                    'entity.name.function',
+                    'entity.name.method',
+                    'support.constant.handlebars',
+                    'source.powershell variable.other.member',
+                    'entity.name.operator.custom-literal',
+                    'keyword.other.special-method',
+                ],
+                settings: {
+                    foreground: colors.function.default,
+                },
+            },
+            // Support functions: built-in/library functions
+            {
+                scope: ['support.function', 'support.function.git-rebase'],
+                settings: {
+                    foreground: colors.function.support,
+                },
+            },
+            // Types: custom type names (DocumentBlockCode, HighlightLine, etc.) - must come first
+            {
+                scope: ['entity.name.type', 'entity.name.class', 'entity.other.inherited-class'],
+                settings: {
+                    foreground: colors.type.custom,
+                },
+            },
+            // Types: primitive (number, boolean, string, etc.) - must come before support.type
+            {
+                scope: [
+                    'support.type.primitive',
+                    'storage.type.primitive',
+                    'support.type.builtin',
+                    'entity.name.type.primitive',
+                    'support.type',
+                ],
+                settings: {
+                    foreground: colors.type.primitive,
+                },
+            },
+            // Types: other (support.class, etc.)
+            {
+                scope: [
+                    'support.class',
+                    'entity.name.namespace',
+                    'entity.other.attribute',
+                    'entity.name.scope-resolution',
+                    'meta.type.cast.expr',
+                    'meta.type.new.expr',
+                    'support.constant.math',
+                    'support.constant.json',
+                    'punctuation.separator.namespace.ruby',
+                ],
+                settings: {
+                    foreground: colors.type.other,
+                },
+            },
+            // Class meta contexts
+            {
+                scope: ['meta.class'],
+                settings: {
+                    foreground: colors.type.classMeta,
+                },
+            },
+            // DOM constants and namespace references (ReactDOM, window.document, etc.)
+            {
+                scope: [
+                    'support.constant.dom',
+                    'variable.other.object',
+                    'variable.other.object.property',
+                    'entity.name.class.jsx',
+                    'support.class.builtin',
+                ],
+                settings: {
+                    foreground: colors.variable.parameter,
+                },
+            },
+            // Entity names: tags, attributes
+            {
+                scope: [
+                    'entity.name.tag',
+                    'entity.other.attribute-name',
+                    'support.type.vendored.property-name',
+                    'support.type.property-name',
+                    'source.css variable',
+                    'source.coffee.embedded',
+                    'meta.structure.dictionary.key.python',
+                ],
+                settings: {
+                    foreground: colors.entity.tag,
+                },
+            },
+            // Attribute IDs (typically styled like functions)
+            {
+                scope: ['entity.other.attribute-name.id', 'punctuation.definition.entity'],
+                settings: {
+                    foreground: colors.entity.attributeId,
+                },
+            },
+            // Punctuation
+            {
+                scope: [
+                    'punctuation',
+                    'punctuation.definition.tag',
+                    'punctuation.definition.quote.begin.markdown',
+                    'punctuation.definition.list.begin.markdown',
+                    'punctuation.section.embedded.begin.php',
+                    'punctuation.section.embedded.end.php',
+                ],
+                settings: {
+                    foreground: colors.other.punctuation,
+                },
+            },
+            // Links: link text
+            {
+                scope: [
+                    'markup.underline.link',
+                    'string.other.link',
+                    'punctuation.definition.string.end.markdown',
+                    'punctuation.definition.string.begin.markdown',
+                ],
+                settings: {
+                    foreground: colors.markup.link,
+                },
+            },
+            // Links: link URLs
+            {
+                scope: ['meta.link'],
+                settings: {
+                    foreground: colors.markup.linkUrl,
+                },
+            },
+            // Preprocessor
+            {
+                scope: [
+                    'meta.preprocessor',
+                    'entity.name.function.preprocessor',
+                    'meta.preprocessor.numeric',
+                ],
+                settings: {
+                    foreground: colors.other.preprocessor,
+                },
+            },
+            // Diff: inserted
+            {
+                scope: ['markup.inserted', 'markup.inserted.diff'],
+                settings: {
+                    foreground: colors.diff.inserted,
+                },
+            },
+            // Diff: deleted
+            {
+                scope: ['markup.deleted', 'markup.deleted.diff'],
+                settings: {
+                    foreground: colors.diff.deleted,
+                },
+            },
+            // Diff: changed
+            {
+                scope: ['markup.changed', 'markup.changed.diff', 'meta.diff.header'],
+                settings: {
+                    foreground: colors.diff.changed,
+                },
+            },
+            // Markup: bold (with bold font style)
+            {
+                scope: ['markup.bold', 'punctuation.definition.bold'],
+                settings: {
+                    foreground: colors.markup.default,
+                    fontStyle: 'bold',
+                },
+            },
+            // Markup: italic (with italic font style)
+            {
+                scope: ['markup.italic', 'punctuation.definition.italic'],
+                settings: {
+                    foreground: colors.markup.default,
+                    fontStyle: 'italic',
+                },
+            },
+            // Markup: inline code
+            {
+                scope: ['markup.raw.inline'],
+                settings: {
+                    foreground: colors.markup.code,
+                },
+            },
+            // Markup: headings, lists, quotes, underline, strikethrough
+            {
+                scope: [
+                    'markup.heading',
+                    'markup.heading punctuation.definition.heading',
+                    'entity.name.section',
+                    'header',
+                    'markup.list',
+                    'markup.quote',
+                    'markup.underline',
+                    'markup.strikethrough',
+                ],
+                settings: {
+                    foreground: colors.markup.default,
+                },
+            },
+            // Separator
+            {
+                scope: ['meta.separator'],
+                settings: {
+                    background: colors.markup.separator,
+                    foreground: fg,
+                },
+            },
+            // Labels
+            {
+                scope: ['entity.name.label'],
+                settings: {
+                    foreground: colors.entity.label,
+                },
+            },
+            // Other
+            {
+                scope: [
+                    'meta.embedded',
+                    'source.groovy.embedded',
+                    'string meta.image.inline.markdown',
+                    'variable.legacy.builtin.python',
+                    'storage.modifier.import.java',
+                    'variable.language.wildcard.java',
+                    'storage.modifier.package.java',
+                ],
+                settings: {
+                    foreground: colors.other.default,
+                },
+            },
+            // Invalid
+            {
+                scope: ['invalid'],
+                settings: {
+                    foreground: colors.other.invalid,
+                },
+            },
+        ],
+    };
+}
diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.test.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.test.ts
index d330fda94..aedd7d182 100644
--- a/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.test.ts
+++ b/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.test.ts
@@ -8,7 +8,8 @@ async function highlightWithInlines(block: DocumentBlockCode) {
         inline,
         body: null,
     }));
-    return highlight(block, inlines);
+    const result = await highlight(block, inlines);
+    return result.lines;
 }
 
 it('should parse plain code', async () => {
@@ -224,7 +225,19 @@ it('should parse code with an inline on a single line', async () => {
                 {
                     type: 'shiki',
                     token: {
-                        content: '"Hello World"',
+                        content: '"',
+                    },
+                },
+                {
+                    type: 'shiki',
+                    token: {
+                        content: 'Hello World',
+                    },
+                },
+                {
+                    type: 'shiki',
+                    token: {
+                        content: '"',
                     },
                 },
                 {
@@ -375,7 +388,19 @@ it('should parse code with an inline on a multiple line', async () => {
                 {
                     type: 'shiki',
                     token: {
-                        content: '"Hello World"',
+                        content: '"',
+                    },
+                },
+                {
+                    type: 'shiki',
+                    token: {
+                        content: 'Hello World',
+                    },
+                },
+                {
+                    type: 'shiki',
+                    token: {
+                        content: '"',
                     },
                 },
                 {
@@ -631,7 +656,13 @@ it('should support multiple code tokens in an annotation', async () => {
                         {
                             type: 'shiki',
                             token: {
-                                content: '.world',
+                                content: '.',
+                            },
+                        },
+                        {
+                            type: 'shiki',
+                            token: {
+                                content: 'world',
                             },
                         },
                         {
@@ -645,7 +676,13 @@ it('should support multiple code tokens in an annotation', async () => {
                 {
                     type: 'shiki',
                     token: {
-                        content: ');',
+                        content: ')',
+                    },
+                },
+                {
+                    type: 'shiki',
+                    token: {
+                        content: ';',
                     },
                 },
             ],
@@ -699,7 +736,13 @@ it('should handle \\r', async () => {
                 {
                     type: 'shiki',
                     token: {
-                        content: '.log',
+                        content: '.',
+                    },
+                },
+                {
+                    type: 'shiki',
+                    token: {
+                        content: 'log',
                     },
                 },
                 {
@@ -711,7 +754,19 @@ it('should handle \\r', async () => {
                 {
                     type: 'shiki',
                     token: {
-                        content: '"Hello"',
+                        content: '"',
+                    },
+                },
+                {
+                    type: 'shiki',
+                    token: {
+                        content: 'Hello',
+                    },
+                },
+                {
+                    type: 'shiki',
+                    token: {
+                        content: '"',
                     },
                 },
                 {
@@ -734,7 +789,13 @@ it('should handle \\r', async () => {
                 {
                     type: 'shiki',
                     token: {
-                        content: '.log',
+                        content: '.',
+                    },
+                },
+                {
+                    type: 'shiki',
+                    token: {
+                        content: 'log',
                     },
                 },
                 {
@@ -746,7 +807,19 @@ it('should handle \\r', async () => {
                 {
                     type: 'shiki',
                     token: {
-                        content: '"World"',
+                        content: '"',
+                    },
+                },
+                {
+                    type: 'shiki',
+                    token: {
+                        content: 'World',
+                    },
+                },
+                {
+                    type: 'shiki',
+                    token: {
+                        content: '"',
                     },
                 },
                 {
diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts
index d20578fad..43dda7879 100644
--- a/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts
+++ b/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts
@@ -1,20 +1,39 @@
-import type {
-    DocumentBlockCode,
-    DocumentBlockCodeLine,
-    DocumentInlineAnnotation,
+import {
+    CustomizationCodeTheme,
+    type CustomizationThemedCodeTheme,
+    type DocumentBlockCode,
+    type DocumentBlockCodeLine,
+    type DocumentInlineAnnotation,
 } from '@gitbook/api';
 import {
+    type ThemeRegistrationAny,
     type ThemedToken,
-    createCssVariablesTheme,
     createSingletonShorthands,
     createdBundledHighlighter,
 } from 'shiki/core';
 import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
 import { type BundledLanguage, bundledLanguages } from 'shiki/langs';
+import { bundledThemes } from 'shiki/themes';
 
 import { nullIfNever } from '@/lib/typescript';
+import { customThemes } from './customThemes';
 import { plainHighlight } from './plain-highlight';
 
+export const DEFAULT_THEMES: CustomizationThemedCodeTheme = {
+    light: CustomizationCodeTheme.DefaultLight,
+    dark: CustomizationCodeTheme.DefaultDark,
+};
+
+export type HighlightTheme = {
+    bg?: string;
+    fg?: string;
+    themes: {
+        light: ThemeRegistrationAny;
+        dark: ThemeRegistrationAny;
+    };
+    lines: HighlightLine[];
+};
+
 export type HighlightLine = {
     highlighted: boolean;
     tokens: HighlightToken[];
@@ -36,12 +55,12 @@ export type RenderedInline = {
 
 const isSafari =
     typeof navigator !== 'undefined' && /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
-const theme = createCssVariablesTheme();
 
+// Merge bundled Shiki themes with our custom themes so both are available to the highlighter
 const { getSingletonHighlighter } = createSingletonShorthands(
     createdBundledHighlighter({
         langs: bundledLanguages,
-        themes: {},
+        themes: { ...bundledThemes, ...customThemes },
         engine: () => createJavaScriptRegexEngine({ forgiving: true, target: 'ES2018' }),
     })
 );
@@ -49,12 +68,15 @@ const { getSingletonHighlighter } = createSingletonShorthands(
 /**
  * Preload the highlighter for a code block.
  */
-export async function preloadHighlight(block: DocumentBlockCode) {
+export async function preloadHighlight(
+    block: DocumentBlockCode,
+    themes: CustomizationThemedCodeTheme = DEFAULT_THEMES
+) {
     const langName = getBlockLang(block);
     if (langName) {
         await getSingletonHighlighter({
             langs: [langName],
-            themes: [theme],
+            themes: [themes.light, themes.dark],
         });
     }
 }
@@ -67,8 +89,9 @@ export async function highlight(
     inlines: RenderedInline[],
     options?: {
         evaluateInlineExpression?: (expr: string) => string;
+        themes?: CustomizationThemedCodeTheme;
     }
-): Promise {
+): Promise {
     const langName = getBlockLang(block);
 
     if (!langName || (isSafari && ['powershell', 'cpp'].includes(langName))) {
@@ -80,13 +103,20 @@ export async function highlight(
         return plainHighlight(block, inlines, options);
     }
 
+    const themes = options?.themes ?? DEFAULT_THEMES;
+
     const code = getPlainCodeBlock(block, undefined, options);
 
     const highlighter = await getSingletonHighlighter({
         langs: [langName],
-        themes: [theme],
+        themes: [themes.light, themes.dark],
     });
 
+    const resolvedThemes = {
+        light: highlighter.getTheme(themes.light),
+        dark: highlighter.getTheme(themes.dark),
+    };
+
     let tokenizeMaxLineLength = 400;
     // In some cases, people will use unindented code blocks with a single line.
     // In this case, we can safely increase the max line length to avoid not highlighting the code.
@@ -94,38 +124,47 @@ export async function highlight(
         tokenizeMaxLineLength = 5000;
     }
 
-    const lines = highlighter.codeToTokensBase(code, {
+    const result = highlighter.codeToTokens(code, {
         lang: langName,
-        theme,
+        themes: resolvedThemes,
+        // Shiki's light-dark() CSS function provides different colors for light/dark modes based on the resolved themes
+        defaultColor: 'light-dark()',
         tokenizeMaxLineLength,
     });
 
+    const lines = result.tokens;
+
     let currentIndex = 0;
-    return lines.map((tokens, index) => {
-        const lineBlock = block.nodes[index];
-        const result: HighlightToken[] = [];
+    return {
+        bg: result.bg,
+        fg: result.fg,
+        themes: resolvedThemes,
+        lines: lines.map((tokens, index) => {
+            const lineBlock = block.nodes[index];
+            const result: HighlightToken[] = [];
 
-        const eatToken = (): PositionedToken | null => {
-            const token = tokens.shift();
-            if (token) {
-                currentIndex += token.content.length;
+            const eatToken = (): PositionedToken | null => {
+                const token = tokens.shift();
+                if (token) {
+                    currentIndex += token.content.length;
+                }
+                return token
+                    ? { ...token, start: currentIndex - token.content.length, end: currentIndex }
+                    : null;
+            };
+
+            while (tokens.length > 0) {
+                result.push(...matchTokenAndInlines(eatToken, inlines));
             }
-            return token
-                ? { ...token, start: currentIndex - token.content.length, end: currentIndex }
-                : null;
-        };
 
-        while (tokens.length > 0) {
-            result.push(...matchTokenAndInlines(eatToken, inlines));
-        }
+            currentIndex += 1; // for the \n
 
-        currentIndex += 1; // for the \n
-
-        return {
-            highlighted: Boolean(lineBlock?.data.highlighted),
-            tokens: result,
-        };
-    });
+            return {
+                highlighted: Boolean(lineBlock?.data.highlighted),
+                tokens: result,
+            };
+        }),
+    };
 }
 
 /**
diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts
index 433742d5d..78621c599 100644
--- a/packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts
+++ b/packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts
@@ -1,8 +1,9 @@
-import type { DocumentBlockCode } from '@gitbook/api';
+import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/api';
 
 import { getNodeText } from '@/lib/document';
-
-import type { HighlightLine, HighlightToken, RenderedInline } from './highlight';
+import { bundledThemesInfo } from 'shiki/themes';
+import { customThemes } from './customThemes';
+import type { HighlightTheme, HighlightToken, RenderedInline } from './highlight';
 
 /**
  * Parse a code block without highlighting it.
@@ -12,42 +13,59 @@ export function plainHighlight(
     inlines: RenderedInline[],
     options?: {
         evaluateInlineExpression?: (expr: string) => string;
+        themes?: CustomizationThemedCodeTheme;
     }
-): HighlightLine[] {
+): HighlightTheme {
     const inlinesCopy = Array.from(inlines);
 
-    return block.nodes.map((lineBlock) => {
-        const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
-            if (node.object === 'text') {
-                return {
-                    type: 'plain',
-                    content: getNodeText(node),
-                };
-            }
+    // Resolve theme objects: first check bundled Shiki themes, then custom themes, finally fallback to type-only
+    // The type-only fallback ensures HighlightTheme always has valid theme objects even when theme names don't match
+    const themes = {
+        light: bundledThemesInfo.find((theme) => theme.id === options?.themes?.light) ??
+            Object.values(customThemes).find((theme) => theme.name === options?.themes?.light) ?? {
+                type: 'light',
+            },
+        dark: bundledThemesInfo.find((theme) => theme.id === options?.themes?.dark) ??
+            Object.values(customThemes).find((theme) => theme.name === options?.themes?.dark) ?? {
+                type: 'dark',
+            },
+    };
 
-            if (node.type === 'expression') {
-                return {
-                    type: 'plain',
-                    content: options?.evaluateInlineExpression?.(node.data.expression) ?? '',
-                };
-            }
-
-            const inline = inlinesCopy.shift();
-            return {
-                type: 'annotation',
-                body: inline?.body ?? null,
-                children: [
-                    {
+    return {
+        themes: themes,
+        lines: block.nodes.map((lineBlock) => {
+            const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
+                if (node.object === 'text') {
+                    return {
                         type: 'plain',
                         content: getNodeText(node),
-                    },
-                ],
-            };
-        });
+                    };
+                }
 
-        return {
-            highlighted: Boolean(lineBlock.data.highlighted),
-            tokens,
-        };
-    });
+                if (node.type === 'expression') {
+                    return {
+                        type: 'plain',
+                        content: options?.evaluateInlineExpression?.(node.data.expression) ?? '',
+                    };
+                }
+
+                const inline = inlinesCopy.shift();
+                return {
+                    type: 'annotation',
+                    body: inline?.body ?? null,
+                    children: [
+                        {
+                            type: 'plain',
+                            content: getNodeText(node),
+                        },
+                    ],
+                };
+            });
+
+            return {
+                highlighted: Boolean(lineBlock.data.highlighted),
+                tokens,
+            };
+        }),
+    };
 }
diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx
index f3e3ea861..06257171b 100644
--- a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx
+++ b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx
@@ -42,7 +42,17 @@ export function getOpenAPIContext(args: {
             check: ,
             lock: ,
         },
-        renderCodeBlock: (codeProps) => ,
+        renderCodeBlock: (codeProps) => (
+            
+        ),
         renderDocument: (documentProps) => (
             {`
-                    :root {
+                    :root, .light, .dark [data-color-scheme$="light"], .dark [data-follow-color-scheme="true"]:has([data-color-scheme$="light"]) {
                         ${generateColorVariable('primary', customization.styling.primaryColor.light)}
                         ${generateColorVariable('tint', tintColor ? tintColor.light : DEFAULT_TINT_COLOR, { mix: mixColor && { color: mixColor.color.light, ratio: mixColor.ratio.light } })}
                         ${generateColorVariable('neutral', DEFAULT_TINT_COLOR)}
@@ -165,7 +165,7 @@ export async function CustomizationRootLayout(props: {
                         ${generateColorVariable('success', successColor.light)}
                     }
 
-                    .dark {
+                    .dark, :root:not(.dark) [data-color-scheme^="dark"], :root:not(.dark) [data-follow-color-scheme="true"]:has([data-color-scheme^="dark"]) {
                         ${generateColorVariable('primary', customization.styling.primaryColor.dark, { darkMode: true })}
                         ${generateColorVariable('tint', tintColor ? tintColor.dark : DEFAULT_TINT_COLOR, { darkMode: true, mix: mixColor && { color: mixColor?.color.dark, ratio: mixColor.ratio.dark } })}
                         ${generateColorVariable('neutral', DEFAULT_TINT_COLOR, { darkMode: true })}
diff --git a/packages/gitbook/src/components/RootLayout/globals.css b/packages/gitbook/src/components/RootLayout/globals.css
index dec1dc68a..1160c9312 100644
--- a/packages/gitbook/src/components/RootLayout/globals.css
+++ b/packages/gitbook/src/components/RootLayout/globals.css
@@ -296,88 +296,36 @@ html.announcement-hidden #announcement-banner {
     @apply hidden;
 }
 
-/* Shiki theme */
-:root {
-    --shiki-color-text: rgb(var(--tint-11));
-    --shiki-token-punctuation: rgb(var(--tint-11));
-    --shiki-token-comment: rgb(var(--neutral-9));
-    --shiki-token-link: rgb(var(--primary-10));
-
-    --shiki-token-constant: rgb(var(--warning-10));
-    --shiki-token-string: rgb(var(--warning-10));
-    --shiki-token-string-expression: rgb(var(--success-10));
-    --shiki-token-keyword: rgb(var(--danger-10));
-    --shiki-token-parameter: rgb(var(--warning-10));
-    --shiki-token-function: rgb(var(--primary-10));
-
-    --shiki-token-inserted: rgb(var(--success-10));
-    --shiki-token-deleted: rgb(var(--danger-10));
-    --shiki-token-changed: rgb(var(--tint-12));
-}
-
-@media (prefers-contrast: more) {
-    :root {
-        --shiki-color-text: rgb(var(--tint-12));
-        --shiki-token-punctuation: rgb(var(--tint-12));
-        --shiki-token-comment: rgb(var(--neutral-11));
-        --shiki-token-link: rgb(var(--primary-11));
-
-        --shiki-token-constant: rgb(var(--warning-11));
-        --shiki-token-string: rgb(var(--warning-11));
-        --shiki-token-string-expression: rgb(var(--success-11));
-        --shiki-token-keyword: rgb(var(--danger-11));
-        --shiki-token-parameter: rgb(var(--warning-11));
-        --shiki-token-function: rgb(var(--primary-11));
-
-        --shiki-token-inserted: rgb(var(--success-11));
-        --shiki-token-deleted: rgb(var(--danger-11));
-        --shiki-token-changed: rgb(var(--tint-12));
-    }
-}
-
-html.dark {
-    /* Override select colors to have more contrast */
-    --shiki-token-comment: rgb(var(--neutral-9));
-
-    --shiki-token-constant: rgb(var(--warning-11));
-    --shiki-token-string: rgb(var(--warning-11));
-    --shiki-token-string-expression: rgb(var(--success-11));
-    --shiki-token-keyword: rgb(var(--danger-11));
-    --shiki-token-parameter: rgb(var(--warning-11));
-    --shiki-token-function: rgb(var(--primary-11));
-}
-
-.code-monochrome {
-    --shiki-token-constant: rgb(var(--tint-11));
-    --shiki-token-string: rgb(var(--tint-12));
-    --shiki-token-string-expression: rgb(var(--tint-12));
-    --shiki-token-keyword: rgb(var(--primary-10));
-    --shiki-token-parameter: rgb(var(--tint-9));
-    --shiki-token-function: rgb(var(--primary-9));
-}
-
-html.dark.code-monochrome {
-    --shiki-token-constant: rgb(var(--tint-11));
-    --shiki-token-string: rgb(var(--tint-12));
-    --shiki-token-string-expression: rgb(var(--tint-12));
-    --shiki-token-keyword: rgb(var(--primary-11));
-    --shiki-token-parameter: rgb(var(--tint-10));
-    --shiki-token-function: rgb(var(--primary-10));
-}
-
 html.font-Lato {
     /* Lato's default ligatures impact readability, so we turn them off */
     font-variant-ligatures: no-common-ligatures;
 }
 
 /* Code blocks */
+/* Shiki themes can define font styling (style, weight, decoration) via CSS variables.
+ * These variables are prefixed with --shiki-{mode}-{property} and allow themes to customize
+ * typography beyond just colors. We apply them here so code blocks respect theme font styling. */
+.shiki, .shiki span {
+    font-style: var(--shiki-light-font-style) !important;
+    font-weight: var(--shiki-light-font-weight) !important;
+    text-decoration: var(--shiki-light-text-decoration) !important;
+}
+
+html.dark .shiki,
+html.dark .shiki span {
+  font-style: var(--shiki-dark-font-style) !important;
+  font-weight: var(--shiki-dark-font-weight) !important;
+  text-decoration: var(--shiki-dark-text-decoration) !important;
+}
+
 .highlight-line {
-    @apply grid grid-cols-subgrid col-span-2 relative ring-1 ring-transparent hover:ring-tint-hover hover:z-1 rounded-sm;
+    @apply text-tint-strong grid grid-cols-subgrid col-span-2 relative hover:invert-5 hover:z-1 rounded-sm;
     @apply only:hover:ring-transparent;
     @apply [counter-increment:line];
 
     &.highlighted {
-        @apply bg-tint-active;
+        /* Use `invert-` to get a dynamic color that contrasts with the background, regardless of the codeblock's theme. */
+        @apply bg-tint-base invert-10 hover:invert-15;
         @apply first:rounded-t-md first:*:mt-1;
         @apply last:rounded-b-md last:*:mb-1;
         @apply rounded-none;
@@ -397,12 +345,8 @@ html.font-Lato {
 }
 
 .highlight-line-number {
-    @apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-linear-to-r from-80% from-tint-subtle contrast-more:from-tint-base theme-muted:from-tint-base [html.theme-bold.sidebar-filled_&]:from-tint-base to-transparent;
-    @apply before:text-tint before:content-[counter(line)];
-
-    .highlight-line.highlighted > & {
-        @apply before:text-tint bg-linear-to-r from-80% from-tint-active to-transparent;
-    }
+    @apply text-sm text-tint text-right pr-3.5 bg-tint-base rounded-l pl-2 sticky left-[-3px] mask-r-from-80%;
+    @apply before:content-[counter(line)] not-contrast-more:before:opacity-6;
 }
 
 .highlight-line-content {
diff --git a/packages/gitbook/src/lib/utils.ts b/packages/gitbook/src/lib/utils.ts
index 7d48ed798..fa2a1d40b 100644
--- a/packages/gitbook/src/lib/utils.ts
+++ b/packages/gitbook/src/lib/utils.ts
@@ -19,6 +19,16 @@ export function defaultCustomization(): api.SiteCustomizationSettings {
             icons: api.CustomizationIconsStyle.Regular,
             links: api.CustomizationLinksStyle.Default,
             depth: api.CustomizationDepth.Subtle,
+            codeTheme: {
+                default: {
+                    light: api.CustomizationCodeTheme.DefaultLight,
+                    dark: api.CustomizationCodeTheme.DefaultDark,
+                },
+                openapi: {
+                    light: api.CustomizationCodeTheme.DefaultLight,
+                    dark: api.CustomizationCodeTheme.DefaultDark,
+                },
+            },
             sidebar: {
                 background: api.CustomizationSidebarBackgroundStyle.Default,
                 list: api.CustomizationSidebarListStyle.Default,
diff --git a/packages/react-openapi/src/StaticSection.tsx b/packages/react-openapi/src/StaticSection.tsx
index 40dd801a6..43d848631 100644
--- a/packages/react-openapi/src/StaticSection.tsx
+++ b/packages/react-openapi/src/StaticSection.tsx
@@ -2,7 +2,13 @@ import clsx from 'classnames';
 import { type ComponentPropsWithoutRef, forwardRef } from 'react';
 
 export function Section(props: ComponentPropsWithoutRef<'div'>) {
-    return 
; + return ( +
+ ); } export function SectionHeader(props: ComponentPropsWithoutRef<'div'>) {