From a7b2546ce4a1601ec2f15c2fc6d39db15f4a31bd Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 10 Jul 2026 15:02:18 +0530 Subject: [PATCH 1/3] feat: syntax-highlight blog code blocks via shared SSR tokenizer --- website/app/page.ts | 23 +++---------------- website/lib/highlight.ts | 20 +++++++++++++++++ website/modules/blog/utils/render-post.ts | 14 +++++++++--- website/public/input.css | 27 +++++++++++++++++++++++ 4 files changed, 61 insertions(+), 23 deletions(-) diff --git a/website/app/page.ts b/website/app/page.ts index aa73ac910..b00ee9442 100644 --- a/website/app/page.ts +++ b/website/app/page.ts @@ -105,26 +105,9 @@ export default function LandingPage() { @media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { --code-tag: oklch(0.78 0.13 250); --code-attr: oklch(0.66 0.16 150); --code-str: oklch(0.80 0.15 145); } } - .t-com { color: var(--fg-subtle); font-style: italic; } - .t-str { color: oklch(0.52 0.13 150); } - .t-kw { color: oklch(0.52 0.16 295); font-weight: 600; } - .t-fn { color: oklch(0.52 0.15 250); } - .t-type{ color: oklch(0.52 0.10 200); } - .t-num { color: oklch(0.55 0.12 215); } - .t-punc{ color: var(--fg-muted); } - .t-id { color: var(--fg); } - :root[data-theme='dark'] .t-str { color: oklch(0.80 0.14 150); } - :root[data-theme='dark'] .t-kw { color: oklch(0.76 0.14 295); } - :root[data-theme='dark'] .t-fn { color: oklch(0.75 0.13 250); } - :root[data-theme='dark'] .t-type{ color: oklch(0.80 0.10 200); } - :root[data-theme='dark'] .t-num { color: oklch(0.82 0.12 215); } - @media (prefers-color-scheme: dark) { - :root:not([data-theme='light']) .t-str { color: oklch(0.80 0.14 150); } - :root:not([data-theme='light']) .t-kw { color: oklch(0.76 0.14 295); } - :root:not([data-theme='light']) .t-fn { color: oklch(0.75 0.13 250); } - :root:not([data-theme='light']) .t-type{ color: oklch(0.80 0.10 200); } - :root:not([data-theme='light']) .t-num { color: oklch(0.82 0.12 215); } - } + /* Syntax-highlight token colors (.t-kw / .t-str / ...) are defined + globally in public/input.css so every surface (this page and the + blog code fences) shares one palette. */ /* The live like-button demo: a bare light-DOM button the page styles into a pill (tag-prefixed, per the light-DOM CSS rule). */ like-button button { diff --git a/website/lib/highlight.ts b/website/lib/highlight.ts index 6b058b300..bf9ec9429 100644 --- a/website/lib/highlight.ts +++ b/website/lib/highlight.ts @@ -121,3 +121,23 @@ export function highlight(code: string): TemplateResult[] { return cls ? html`${tok.v}` : html`${tok.v}`; }); } + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +/** + * Highlight a code sample into an HTML string of styled spans, for callers + * that build markup as a string rather than an `html` TemplateResult (the + * blog markdown renderer, `modules/blog/utils/render-post.ts`). Same + * tokenizer and token classes as `highlight()`, so the colors match every + * other WebJs surface. Token text is HTML-escaped, so a sample can contain + * `<`, `&`, and backticks safely. + */ +export function highlightToHtml(code: string): string { + return tokenize(code.replace(/^\n+|\n+$/g, '')).map((tok) => { + const cls = CLASS[tok.t] ?? ''; + const v = esc(tok.v); + return cls ? `${v}` : v; + }).join(''); +} diff --git a/website/modules/blog/utils/render-post.ts b/website/modules/blog/utils/render-post.ts index 0a11c8635..567e6e83b 100644 --- a/website/modules/blog/utils/render-post.ts +++ b/website/modules/blog/utils/render-post.ts @@ -20,6 +20,8 @@ * screen-reader cue); an internal `/path` link navigates in place. */ +import { highlightToHtml } from '#lib/highlight.ts'; + function inline(s: string): string { let out = s .replace(/&/g, '&') @@ -75,9 +77,15 @@ export function renderPostBody(md: string): string { for (const raw of lines) { if (inCode) { if (raw.trim().startsWith('```')) { - const escaped = codeBuf.join('\n') - .replace(/&/g, '&').replace(//g, '>'); - out.push(`
${escaped}
`); + const src = codeBuf.join('\n'); + // JS/TS fences get SSR syntax highlighting via the shared tokenizer + // (colors match the home page and the ui docs). Other langs (sh, plain + // command output) stay as plain escaped text. + const HIGHLIGHTED = new Set(['', 'ts', 'tsx', 'js', 'jsx', 'javascript', 'typescript']); + const body = HIGHLIGHTED.has(codeLang) + ? highlightToHtml(src) + : src.replace(/&/g, '&').replace(//g, '>'); + out.push(`
${body}
`); codeBuf = []; codeLang = ''; inCode = false; diff --git a/website/public/input.css b/website/public/input.css index d021e236e..a5bbf3806 100644 --- a/website/public/input.css +++ b/website/public/input.css @@ -42,3 +42,30 @@ @font-face{font-family:'Inter Tight';font-style:normal;font-weight:100 900;font-display:swap;src:url('/public/fonts/inter-tight.woff2') format('woff2');} @font-face{font-family:'Inter';font-style:normal;font-weight:100 900;font-display:swap;src:url('/public/fonts/inter.woff2') format('woff2');} @font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:100 800;font-display:swap;src:url('/public/fonts/jetbrains-mono.woff2') format('woff2');} + +/* + * Syntax-highlight token colors (theme-aware). Emitted at SSR by + * lib/highlight.ts (home page code windows and blog code fences share the + * one tokenizer, so the palette is defined once here, globally, rather than + * inline per page). Class names: t-com/t-str/t-kw/t-fn/t-type/t-num/t-punc/t-id. + */ +.t-com { color: var(--fg-subtle); font-style: italic; } +.t-str { color: oklch(0.52 0.13 150); } +.t-kw { color: oklch(0.52 0.16 295); font-weight: 600; } +.t-fn { color: oklch(0.52 0.15 250); } +.t-type{ color: oklch(0.52 0.10 200); } +.t-num { color: oklch(0.55 0.12 215); } +.t-punc{ color: var(--fg-muted); } +.t-id { color: var(--fg); } +:root[data-theme='dark'] .t-str { color: oklch(0.80 0.14 150); } +:root[data-theme='dark'] .t-kw { color: oklch(0.76 0.14 295); } +:root[data-theme='dark'] .t-fn { color: oklch(0.75 0.13 250); } +:root[data-theme='dark'] .t-type{ color: oklch(0.80 0.10 200); } +:root[data-theme='dark'] .t-num { color: oklch(0.82 0.12 215); } +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) .t-str { color: oklch(0.80 0.14 150); } + :root:not([data-theme='light']) .t-kw { color: oklch(0.76 0.14 295); } + :root:not([data-theme='light']) .t-fn { color: oklch(0.75 0.13 250); } + :root:not([data-theme='light']) .t-type{ color: oklch(0.80 0.10 200); } + :root:not([data-theme='light']) .t-num { color: oklch(0.82 0.12 215); } +} From e91ecdb93030c898370a3698aa4a4c0e828279db Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 10 Jul 2026 15:04:00 +0530 Subject: [PATCH 2/3] test: cover highlightToHtml and blog fence highlighting --- website/test/highlight/highlight.test.ts | 45 ++++++++++++++++++++---- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/website/test/highlight/highlight.test.ts b/website/test/highlight/highlight.test.ts index f13005b4b..40d503200 100644 --- a/website/test/highlight/highlight.test.ts +++ b/website/test/highlight/highlight.test.ts @@ -14,21 +14,23 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { html } from '@webjsdev/core'; import { renderToString } from '@webjsdev/core/server'; -import { highlight } from '#lib/highlight.ts'; +import { highlight, highlightToHtml } from '#lib/highlight.ts'; +import { renderPostBody } from '#modules/blog/utils/render-post.ts'; const render = (code: string) => renderToString(html`
${highlight(code)}
`); -test('every token class highlight() emits is styled in app/page.ts', () => { - // highlight.ts emits t-* classes; the only consumer that styles them is the - // inline