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.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 new file mode 100644 index 0000000..adc5748 --- /dev/null +++ b/src/lib/codeHighlight.test.ts @@ -0,0 +1,199 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +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, +} 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 56cd634..432cee0 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 {