Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 3 additions & 20 deletions website/app/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions website/lib/highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,23 @@ export function highlight(code: string): TemplateResult[] {
return cls ? html`<span class=${cls}>${tok.v}</span>` : html`${tok.v}`;
});
}

function esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

/**
* 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 ? `<span class="${cls}">${v}</span>` : v;
}).join('');
}
15 changes: 12 additions & 3 deletions website/modules/blog/utils/render-post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '&amp;')
Expand Down Expand Up @@ -75,9 +77,16 @@ export function renderPostBody(md: string): string {
for (const raw of lines) {
if (inCode) {
if (raw.trim().startsWith('```')) {
const escaped = codeBuf.join('\n')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
out.push(`<pre class="bg-bg-subtle border border-border rounded-lg my-[48px] overflow-x-auto"><code class="font-mono text-[13px] leading-[1.7] text-fg whitespace-pre block px-[24px] py-[20px]"${codeLang ? ` data-lang="${codeLang}"` : ''}>${escaped}</code></pre>`);
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). Every other fence,
// INCLUDING a bare no-language fence (sh, plain command output, JSON),
// stays plain escaped text: JS-tokenizing shell output mis-colors it.
const HIGHLIGHTED = new Set(['ts', 'tsx', 'js', 'jsx', 'javascript', 'typescript']);
const body = HIGHLIGHTED.has(codeLang.toLowerCase())
? highlightToHtml(src)
: src.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
out.push(`<pre class="bg-bg-subtle border border-border rounded-lg my-[48px] overflow-x-auto"><code class="font-mono text-[13px] leading-[1.7] text-fg whitespace-pre block px-[24px] py-[20px]"${codeLang ? ` data-lang="${codeLang}"` : ''}>${body}</code></pre>`);
codeBuf = [];
codeLang = '';
inCode = false;
Expand Down
27 changes: 27 additions & 0 deletions website/public/input.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
}
58 changes: 51 additions & 7 deletions website/test/highlight/highlight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`<pre>${highlight(code)}</pre>`);

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 <style> in app/page.ts. A rename/drop on EITHER side passes the
// whole suite while shipping plain (unstyled) code samples, so pin the
test('every token class highlight() emits is styled in public/input.css', () => {
// highlight.ts emits t-* classes; they are styled globally in
// public/input.css (so every surface, the home page code windows and the
// blog code fences, shares one palette). A rename/drop on EITHER side passes
// the whole suite while shipping plain (unstyled) code samples, so pin the
// contract (mirrors the no-animations pin in layout-ssr.test.ts).
const read = (p: string) => readFileSync(fileURLToPath(new URL(p, import.meta.url)), 'utf8');
const classes = [...read('../../lib/highlight.ts').matchAll(/'(t-[a-z]+)'/g)].map((m) => m[1]);
const page = read('../../app/page.ts');
const css = read('../../public/input.css');
assert.ok(classes.length >= 6, `extracted the emitted token classes, got ${classes.join(',')}`);
for (const cls of new Set(classes)) {
assert.ok(page.includes(`.${cls}`), `app/page.ts must style the .${cls} token class`);
assert.ok(css.includes(`.${cls}`), `public/input.css must style the .${cls} token class`);
}
});

Expand Down Expand Up @@ -100,3 +102,45 @@ test('tokenizer edge cases: block comments, backtick strings, hex/underscore num
assert.match(await render('x as Foo'), /<span class="t-kw">as<\/span>/);
assert.match(await render('typeof y'), /<span class="t-kw">typeof<\/span>/);
});

test('highlightToHtml emits the same token spans as a string', () => {
const out = highlightToHtml("const x = 1;");
assert.match(out, /<span class="t-kw">const<\/span>/);
assert.match(out, /<span class="t-num">1<\/span>/);
assert.equal(typeof out, 'string');
});

test('highlightToHtml HTML-escapes token text (no injection)', () => {
const out = highlightToHtml('const t = "<div> & `x`";');
assert.match(out, /&lt;div&gt;/);
assert.match(out, /&amp;/);
assert.ok(!out.includes('<div>'), 'no raw <div> injected');
});

test('blog renderer highlights ts/js fences but leaves sh fences plain', () => {
const out = renderPostBody('```ts\nconst x: number = 1;\n```\n\n```sh\nnpm run dev\n```');
// the ts fence is tokenized
assert.match(out, /<span class="t-kw">const<\/span>/);
// the sh fence is escaped plain text, not tokenized
assert.ok(out.includes('npm run dev'), 'sh fence content present');
assert.ok(!/<span class="t-[a-z]+">npm<\/span>/.test(out), 'sh fence is not tokenized');
});

test('a bare no-language fence stays plain (shell output is not JS-tokenized)', () => {
// A fence with no language often holds command output. Tokenizing it as JS
// mis-colors words like `Forbidden` (as a type) or `403` (as a number).
const out = renderPostBody('```\nnpm error code E403\nForbidden - PUT https://registry.npmjs.org\n```');
assert.ok(out.includes('npm error code E403'), 'output content present');
assert.ok(!/<span class="t-[a-z]+">/.test(out), 'no token spans in a bare fence');
});

test('fence language matching is case-insensitive', () => {
const out = renderPostBody('```TS\nconst x = 1;\n```');
assert.match(out, /<span class="t-kw">const<\/span>/);
});

test('blog renderer escapes angle brackets in a highlighted fence', () => {
const out = renderPostBody('```ts\nconst el = html`<my-tag>`;\n```');
assert.match(out, /&lt;my-tag&gt;/);
assert.ok(!out.includes('<my-tag>'), 'no raw custom element injected');
});
Loading