From 117cb797f2948aa2e82d3c751010e223269d14fc Mon Sep 17 00:00:00 2001 From: Logan Lindquist Land Date: Wed, 9 Sep 2026 23:03:59 -0500 Subject: [PATCH 1/2] feat(theme): highlight code blocks at build time with Shiki Fenced code blocks previously rendered as plain monospace text. Shiki now highlights them during prerendering, with no client JavaScript for the highlighting itself. Output uses CSS classes rather than Shiki's default inline style attributes, so a strict style-src with no unsafe-inline can serve the pages. Syntax colors are added to each of the six themes and ride the existing derived token pipeline, so theme switching restyles already-highlighted markup without re-highlighting. Each block gets a copy button; unknown languages and unfenced blocks fall back to plain rendering. The sanitizer's blanket class allowance is replaced with a scoped allowlist. --- package.json | 2 + pnpm-lock.yaml | 6 + src/config/themes.ts | 87 +++++++++++++ src/lib/codeHighlight.test.ts | 139 +++++++++++++++++++++ src/lib/codeHighlight.ts | 195 ++++++++++++++++++++++++++++++ src/lib/markdown.test.ts | 100 +++++++++++++++ src/lib/markdown.ts | 55 ++++++++- src/pages/content/[...slug].astro | 141 +++++++++++++++++++++ src/styles/global.css | 24 ++++ 9 files changed, 745 insertions(+), 4 deletions(-) create mode 100644 src/lib/codeHighlight.test.ts create mode 100644 src/lib/codeHighlight.ts diff --git a/package.json b/package.json index e37c625..e0fb64f 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@libsql/client": "^0.18.0", "@logan/libsql-search": "jsr:^0.11.1", "@radix-ui/react-dialog": "^1.1.23", + "@shikijs/langs": "^4.4.3", "@tailwindcss/vite": "^4.3.3", "astro": "^7.2.10", "clsx": "^2.1.1", @@ -62,6 +63,7 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "sanitize-html": "^2.17.7", + "shiki": "^4.4.3", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 590be10..c38059b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@radix-ui/react-dialog': specifier: ^1.1.23 version: 1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@shikijs/langs': + specifier: ^4.4.3 + version: 4.4.3 '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) @@ -69,6 +72,9 @@ importers: sanitize-html: specifier: ^2.17.7 version: 2.17.7 + shiki: + specifier: ^4.4.3 + version: 4.4.3 tailwind-merge: specifier: ^3.6.0 version: 3.6.0 diff --git a/src/config/themes.ts b/src/config/themes.ts index e27c7ec..4cc6b05 100644 --- a/src/config/themes.ts +++ b/src/config/themes.ts @@ -57,6 +57,21 @@ export interface Theme { toc: string; tocForeground: string; tocBorder: string; + // Syntax token colors. These ride the same variable pipeline as every other + // token, which is what lets a build-time-highlighted page restyle itself on + // a theme switch without being re-highlighted. + syntaxComment: string; + syntaxKeyword: string; + syntaxString: string; + syntaxStringExpression: string; + syntaxConstant: string; + syntaxFunction: string; + syntaxParameter: string; + syntaxPunctuation: string; + syntaxLink: string; + syntaxInserted: string; + syntaxDeleted: string; + syntaxChanged: string; }; } @@ -98,6 +113,18 @@ export const themes: Theme[] = [ toc: '#262626', tocForeground: '#fafafa', tocBorder: '#1a1a1a', + syntaxComment: '#8b949e', + syntaxKeyword: '#ff7b72', + syntaxString: '#a5d6ff', + syntaxStringExpression: '#a5d6ff', + syntaxConstant: '#79c0ff', + syntaxFunction: '#d2a8ff', + syntaxParameter: '#ffa657', + syntaxPunctuation: '#c9d1d9', + syntaxLink: '#79c0ff', + syntaxInserted: '#3fb950', + syntaxDeleted: '#f85149', + syntaxChanged: '#d29922', }, }, { @@ -137,6 +164,18 @@ export const themes: Theme[] = [ toc: '#fafafa', tocForeground: '#171717', tocBorder: '#e5e7eb', + syntaxComment: '#6e7781', + syntaxKeyword: '#cf222e', + syntaxString: '#0a3069', + syntaxStringExpression: '#0a3069', + syntaxConstant: '#0550ae', + syntaxFunction: '#8250df', + syntaxParameter: '#953800', + syntaxPunctuation: '#24292f', + syntaxLink: '#0550ae', + syntaxInserted: '#1a7f37', + syntaxDeleted: '#cf222e', + syntaxChanged: '#9a6700', }, }, { @@ -176,6 +215,18 @@ export const themes: Theme[] = [ toc: '#0f2942', tocForeground: '#e0f2fe', tocBorder: '#1e3a5f', + syntaxComment: '#7f9cb8', + syntaxKeyword: '#38bdf8', + syntaxString: '#5eead4', + syntaxStringExpression: '#5eead4', + syntaxConstant: '#a5b4fc', + syntaxFunction: '#67e8f9', + syntaxParameter: '#fbbf24', + syntaxPunctuation: '#bae6fd', + syntaxLink: '#38bdf8', + syntaxInserted: '#2dd4bf', + syntaxDeleted: '#f87171', + syntaxChanged: '#fbbf24', }, }, { @@ -215,6 +266,18 @@ export const themes: Theme[] = [ toc: '#14291a', tocForeground: '#d1fae5', tocBorder: '#1a3d23', + syntaxComment: '#7f9e88', + syntaxKeyword: '#34d399', + syntaxString: '#bef264', + syntaxStringExpression: '#bef264', + syntaxConstant: '#86efac', + syntaxFunction: '#5eead4', + syntaxParameter: '#fcd34d', + syntaxPunctuation: '#a7f3d0', + syntaxLink: '#34d399', + syntaxInserted: '#4ade80', + syntaxDeleted: '#f87171', + syntaxChanged: '#fbbf24', }, }, { @@ -254,6 +317,18 @@ export const themes: Theme[] = [ toc: '#2a1510', tocForeground: '#fef3c7', tocBorder: '#3d1f15', + syntaxComment: '#b08968', + syntaxKeyword: '#fb923c', + syntaxString: '#fde68a', + syntaxStringExpression: '#fde68a', + syntaxConstant: '#fca5a5', + syntaxFunction: '#fcd34d', + syntaxParameter: '#f9a8d4', + syntaxPunctuation: '#fed7aa', + syntaxLink: '#fb923c', + syntaxInserted: '#84cc16', + syntaxDeleted: '#ef4444', + syntaxChanged: '#fbbf24', }, }, { @@ -293,6 +368,18 @@ export const themes: Theme[] = [ toc: '#241129', tocForeground: '#f3e8ff', tocBorder: '#2e1a3d', + syntaxComment: '#a78bb9', + syntaxKeyword: '#c084fc', + syntaxString: '#f0abfc', + syntaxStringExpression: '#f0abfc', + syntaxConstant: '#d8b4fe', + syntaxFunction: '#a5b4fc', + syntaxParameter: '#fcd34d', + syntaxPunctuation: '#e9d5ff', + syntaxLink: '#c084fc', + syntaxInserted: '#4ade80', + syntaxDeleted: '#f472b6', + syntaxChanged: '#fbbf24', }, }, ]; diff --git a/src/lib/codeHighlight.test.ts b/src/lib/codeHighlight.test.ts new file mode 100644 index 0000000..30a1bde --- /dev/null +++ b/src/lib/codeHighlight.test.ts @@ -0,0 +1,139 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + CODE_LINE_CLASS, + CODE_PRE_CLASS, + highlightCode, + isSupportedLanguage, + SYNTAX_TOKEN_CLASSES, +} from './codeHighlight'; + +function classNamesIn(html: string): string[] { + return [...html.matchAll(/class="([^"]*)"/g)].flatMap((match) => + (match[1] ?? '').split(/\s+/).filter(Boolean), + ); +} + +describe('isSupportedLanguage', () => { + it('should accept bundled grammar names', () => { + expect(isSupportedLanguage('typescript')).toBe(true); + expect(isSupportedLanguage('python')).toBe(true); + }); + + it('should accept grammar aliases', () => { + expect(isSupportedLanguage('ts')).toBe(true); + expect(isSupportedLanguage('js')).toBe(true); + expect(isSupportedLanguage('sh')).toBe(true); + expect(isSupportedLanguage('md')).toBe(true); + }); + + it('should normalize case and surrounding whitespace', () => { + expect(isSupportedLanguage(' TypeScript ')).toBe(true); + }); + + it('should reject unbundled languages', () => { + expect(isSupportedLanguage('brainfuck')).toBe(false); + expect(isSupportedLanguage('')).toBe(false); + }); +}); + +describe('highlightCode', () => { + it('should return null when no language is given', async () => { + expect(await highlightCode('const a = 1;', undefined)).toBeNull(); + expect(await highlightCode('const a = 1;', ' ')).toBeNull(); + }); + + it('should return null for an unbundled language', async () => { + expect(await highlightCode('x', 'brainfuck')).toBeNull(); + }); + + it('should emit token classes rather than inline styles', async () => { + const html = await highlightCode('const x = 1; // note', 'ts'); + expect(html).not.toBeNull(); + expect(html).not.toMatch(/style=/); + expect(html).toContain('class="sh-keyword"'); + expect(html).toContain('class="sh-comment"'); + }); + + it('should tag the code element with its language', async () => { + const html = await highlightCode('SELECT 1;', 'sql'); + expect(html).toContain(''); + }); + + it('should drop the tabindex and background style Shiki adds to pre', async () => { + const html = await highlightCode('const a = 1;', 'ts'); + expect(html).toContain('
');
+    expect(html).not.toContain('tabindex');
+  });
+
+  it('should escape HTML in the highlighted source', async () => {
+    const html = await highlightCode(
+      'const s = "";',
+      'ts',
+    );
+    expect(html).not.toContain('\n```');
+      expect(result).not.toContain(' {
+      const result = await marked('```
diff --git a/src/styles/global.css b/src/styles/global.css
index 309fe30..a256517 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -46,6 +46,18 @@
   --toc: #fafafa;
   --toc-foreground: #171717;
   --toc-border: #e5e7eb;
+  --syntax-comment: #6e7781;
+  --syntax-keyword: #cf222e;
+  --syntax-string: #0a3069;
+  --syntax-string-expression: #0a3069;
+  --syntax-constant: #0550ae;
+  --syntax-function: #8250df;
+  --syntax-parameter: #953800;
+  --syntax-punctuation: #24292f;
+  --syntax-link: #0550ae;
+  --syntax-inserted: #1a7f37;
+  --syntax-deleted: #cf222e;
+  --syntax-changed: #9a6700;
 }
 
 .dark {
@@ -87,6 +99,18 @@
   --toc: #262626;
   --toc-foreground: #fafafa;
   --toc-border: #1a1a1a;
+  --syntax-comment: #8b949e;
+  --syntax-keyword: #ff7b72;
+  --syntax-string: #a5d6ff;
+  --syntax-string-expression: #a5d6ff;
+  --syntax-constant: #79c0ff;
+  --syntax-function: #d2a8ff;
+  --syntax-parameter: #ffa657;
+  --syntax-punctuation: #c9d1d9;
+  --syntax-link: #79c0ff;
+  --syntax-inserted: #3fb950;
+  --syntax-deleted: #f85149;
+  --syntax-changed: #d29922;
 }
 
 @theme inline {

From e60a9352d7c30f1c3fa527264265eaf513044ec6 Mon Sep 17 00:00:00 2001
From: Logan Lindquist Land 
Date: Wed, 9 Sep 2026 23:36:52 -0500
Subject: [PATCH 2/2] test(theme): cover the highlighter's two failure paths

Body: codecov/patch and codecov/project flagged two uncovered error branches. The unmapped-token-variable path, which exists so a future Shiki release that adds a variable fails to unstyled text rather than leaking an inline style past a strict style-src, is now tested through an exported transformer factory. The highlighter-throws path, which must degrade to a plain code block rather than fail the build, is tested in an isolated file that mocks the Shiki core module, since the highlighter is a module-level singleton. File line coverage goes from 90.69% to 100%.
---
 src/lib/codeHighlight.failure.test.ts | 34 +++++++++++++++
 src/lib/codeHighlight.test.ts         | 62 ++++++++++++++++++++++++++-
 src/lib/codeHighlight.ts              |  2 +-
 3 files changed, 96 insertions(+), 2 deletions(-)
 create mode 100644 src/lib/codeHighlight.failure.test.ts

diff --git a/src/lib/codeHighlight.failure.test.ts b/src/lib/codeHighlight.failure.test.ts
new file mode 100644
index 0000000..7f54deb
--- /dev/null
+++ b/src/lib/codeHighlight.failure.test.ts
@@ -0,0 +1,34 @@
+/**
+ * Isolated because it mocks the Shiki core module, which the highlighter
+ * singleton in codeHighlight.ts resolves once per module instance.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+afterEach(() => {
+  vi.doUnmock('shiki/core');
+  vi.resetModules();
+});
+
+describe('highlightCode when the highlighter fails', () => {
+  it('degrades to plain rendering rather than failing the build', async () => {
+    vi.resetModules();
+    vi.doMock('shiki/core', async () => {
+      const actual =
+        await vi.importActual('shiki/core');
+      return {
+        ...actual,
+        createHighlighterCore: async () => ({
+          codeToHtml: () => {
+            throw new Error('grammar exploded');
+          },
+        }),
+      };
+    });
+
+    const { highlightCode } = await import('./codeHighlight');
+
+    // A thrown highlighter must not fail the build; the code renderer falls
+    // back to a plain block when this returns null.
+    await expect(highlightCode('const a = 1;', 'ts')).resolves.toBeNull();
+  });
+});
diff --git a/src/lib/codeHighlight.test.ts b/src/lib/codeHighlight.test.ts
index 30a1bde..adc5748 100644
--- a/src/lib/codeHighlight.test.ts
+++ b/src/lib/codeHighlight.test.ts
@@ -1,9 +1,11 @@
 import { readFile } from 'node:fs/promises';
 import { resolve } from 'node:path';
-import { describe, expect, it } from 'vitest';
+import { logger } from 'logan-logger';
+import { describe, expect, it, vi } from 'vitest';
 import {
   CODE_LINE_CLASS,
   CODE_PRE_CLASS,
+  createStyleToClassTransformer,
   highlightCode,
   isSupportedLanguage,
   SYNTAX_TOKEN_CLASSES,
@@ -137,3 +139,61 @@ describe('highlightCode', () => {
     }
   });
 });
+
+describe('style-to-class transformer', () => {
+  function spanNode(style: string) {
+    return {
+      type: 'element' as const,
+      tagName: 'span' as const,
+      properties: { style } as Record,
+      children: [],
+    };
+  }
+
+  // The real handler signature carries line, column and token arguments this
+  // one never reads, so the node is all a caller needs to supply.
+  type SpanHandler = (node: { properties: Record }) => void;
+
+  function applySpan(style: string) {
+    const transformer = createStyleToClassTransformer();
+    const node = spanNode(style);
+    (transformer.span as unknown as SpanHandler).call(transformer, node);
+    return node.properties;
+  }
+
+  it('maps a known token variable to its class and drops the style', () => {
+    expect(applySpan('color:var(--shiki-token-keyword)')).toEqual({
+      style: undefined,
+      class: 'sh-keyword',
+    });
+  });
+
+  // A Shiki release that adds a token variable must fail to unstyled text
+  // rather than leaking an inline style past a strict style-src.
+  it('drops an unmapped token variable rather than emitting a style', () => {
+    const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
+
+    const properties = applySpan('color:var(--shiki-token-invented)');
+
+    expect(properties.style).toBeUndefined();
+    expect(properties.class).toBeUndefined();
+    expect(warn).toHaveBeenCalledWith(
+      expect.stringContaining('Unmapped Shiki token variable'),
+      expect.objectContaining({ variable: '--shiki-token-invented' }),
+    );
+    warn.mockRestore();
+  });
+
+  it('leaves a span with no style untouched', () => {
+    const transformer = createStyleToClassTransformer();
+    const node = {
+      type: 'element' as const,
+      tagName: 'span' as const,
+      properties: {} as Record,
+      children: [],
+    };
+    (transformer.span as unknown as SpanHandler).call(transformer, node);
+
+    expect(node.properties.class).toBeUndefined();
+  });
+});
diff --git a/src/lib/codeHighlight.ts b/src/lib/codeHighlight.ts
index 8d41752..ab83e24 100644
--- a/src/lib/codeHighlight.ts
+++ b/src/lib/codeHighlight.ts
@@ -118,7 +118,7 @@ function declarationToClass(declaration: string): string | null {
  * 'unsafe-inline' can serve the page. Class names are fixed rather than derived
  * from the style text, so the matching CSS can live in a static stylesheet.
  */
-function createStyleToClassTransformer(): ShikiTransformer {
+export function createStyleToClassTransformer(): ShikiTransformer {
   return {
     name: 'semantic-docs:style-to-class',
     pre(node) {