···11import { describe, expect, it } from 'vitest';
22-import { highlightCodeBlocks, highlightJson } from './highlight';
22+import { highlightCodeBlocks, highlightJson, highlightSource } from './highlight';
3344const block = ( inner: string ) =>
55 `<pre class="wp-block-code"><code>${ inner }</code></pre>`;
···9999 expect( out ).toContain( '</code>' );
100100 } );
101101} );
102102+103103+describe( 'highlightSource', () => {
104104+ it( 'tokenises a known-language source string into hljs spans', () => {
105105+ // Unlike the reader blocks, static callers (e.g. the lexicon schema dump) know
106106+ // the language up front, so we highlight directly instead of auto-detecting.
107107+ const out = highlightSource( '{ "name": "potato", "value": 42 }', 'json' );
108108+ expect( out ).toContain( '<span class="hljs-' );
109109+ expect( out ).toContain( 'potato' );
110110+ } );
111111+112112+ it( 'escapes angle brackets, never injecting raw markup', () => {
113113+ const out = highlightSource( '{ "tag": "<b>" }', 'json' );
114114+ expect( out ).not.toContain( '<b>' );
115115+ expect( out ).toContain( '<b>' );
116116+ } );
117117+118118+ it( 'falls back to plain escaped text for an unregistered language', () => {
119119+ // An unknown grammar makes hljs throw; the source must still render as safe,
120120+ // entity-escaped text — never raw markup, never an error bubbling to the page.
121121+ const out = highlightSource( 'a < b && c > d', 'no-such-lang' );
122122+ expect( out ).not.toContain( 'hljs-' );
123123+ expect( out ).toContain( '<' );
124124+ expect( out ).toContain( '&' );
125125+ expect( out ).toContain( '>' );
126126+ } );
127127+} );
+22-11
src/lib/reader/highlight.ts
···8181}
82828383/** HTML-escape for the fallback path (hljs already escapes its own token output). */
8484-function escapeHtml( input: string ): string {
8585- return input
8484+function escapeHtml( source: string ): string {
8585+ return source
8686 .replace( /&/g, '&' )
8787 .replace( /</g, '<' )
8888 .replace( />/g, '>' );
8989}
90909191/**
9292+ * Highlight a known-language source string into hljs token HTML, for static pages
9393+ * (e.g. the lexicon's JSON schema dump) that know their language up front and so
9494+ * skip the reader's auto-detection. Returns highlight.js's already-escaped token
9595+ * HTML, safe to inject; on any error (unregistered language, etc.) it falls back to
9696+ * plain entity-escaped text — the source still renders, never as raw markup. Keeping
9797+ * this here preserves the rule that highlight.js is imported in this module only.
9898+ */
9999+export function highlightSource( source: string, language: string ): string {
100100+ try {
101101+ return hljs.highlight( source, { language } ).value;
102102+ } catch {
103103+ return escapeHtml( source );
104104+ }
105105+}
106106+107107+/**
92108 * Highlight a JSON string as a standalone `<code class="hljs language-json">…</code>`
93109 * fragment (no `<pre>` wrapper — the caller owns the chrome). Used by the reader's
9494- * record-JSON viewer. `hljs.highlight` HTML-escapes the source, so untrusted record
9595- * values can't inject markup; `record-json.ts` sanitises the result as the final gate.
110110+ * record-JSON viewer. `highlightSource` HTML-escapes the source on both the highlight
111111+ * and fallback paths, so untrusted record values can't inject markup; `record-json.ts`
112112+ * sanitises the result as the final gate.
96113 */
97114export function highlightJson( source: string ): string {
9898- try {
9999- const { value } = hljs.highlight( source, { language: 'json' } );
100100- return `<code class="hljs language-json">${ value }</code>`;
101101- } catch {
102102- // Never let a highlighter error break a page — fall back to plain escaped JSON.
103103- return `<code class="hljs language-json">${ escapeHtml( source ) }</code>`;
104104- }
115115+ return `<code class="hljs language-json">${ highlightSource( source, 'json' ) }</code>`;
105116}
+44-1
src/pages/lexicon.astro
···99 contentSchemaFields,
1010} from '../lib/lexicon/schema-doc';
1111import { parseInlineCode } from '../lib/lexicon/inline-code';
1212+import { highlightSource } from '../lib/reader/highlight';
12131314const contentFields = contentSchemaFields();
1415const schemaJson = JSON.stringify( CONTENT_LEXICON, null, 2 );
1616+// The schema is JSON we control, so highlight it directly (known language) rather
1717+// than auto-detecting. Server-side at build time → no client JS, like the reader.
1818+const schemaJsonHtml = highlightSource( schemaJson, 'json' );
15191620// Hand-authored interop tables (site.standard.* are community-owned; see Decision 0005).
1721const documentFields = [
···100104 <code>textContent</code>, so your article is never unreadable. Here’s
101105 the full schema:
102106 </p>
103103- <pre class="lex-code"><code>{ schemaJson }</code></pre>
107107+ <pre class="lex-code"><code class="hljs language-json" set:html={ schemaJsonHtml } /></pre>
104108 </section>
105109106110 <section class="lex-section">
···260264 overflow: auto;
261265 font-size: 0.85rem;
262266 line-height: 1.55;
267267+ /* String/number hues that read on the light code surface; the warm-orange
268268+ and muted token colours below reuse brand tokens that already flip. */
269269+ --code-string: #3f7d4f;
270270+ --code-number: #2f6f86;
263271 }
264272 .lex-code code {
265273 font-family: var( --font-mono );
266274 background: none;
267275 color: var( --ink );
268276 padding: 0;
277277+ }
278278+ /* highlight.js token colours, scoped to this light code block (unlike the
279279+ reader's dark <pre>). The spans arrive via set:html, so they need :global.
280280+ No background/padding on .hljs, so the .lex-code chrome above is untouched. */
281281+ .lex-code :global( .hljs-comment ),
282282+ .lex-code :global( .hljs-quote ),
283283+ .lex-code :global( .hljs-punctuation ) {
284284+ color: var( --muted );
285285+ }
286286+ .lex-code :global( .hljs-comment ),
287287+ .lex-code :global( .hljs-quote ) {
288288+ font-style: italic;
289289+ }
290290+ .lex-code :global( .hljs-attr ),
291291+ .lex-code :global( .hljs-keyword ),
292292+ .lex-code :global( .hljs-selector-tag ),
293293+ .lex-code :global( .hljs-name ) {
294294+ color: var( --sun-strong );
295295+ }
296296+ .lex-code :global( .hljs-string ),
297297+ .lex-code :global( .hljs-symbol ),
298298+ .lex-code :global( .hljs-meta .hljs-string ) {
299299+ color: var( --code-string );
300300+ }
301301+ .lex-code :global( .hljs-number ),
302302+ .lex-code :global( .hljs-literal ),
303303+ .lex-code :global( .hljs-built_in ) {
304304+ color: var( --code-number );
305305+ }
306306+307307+ @media ( prefers-color-scheme: dark ) {
308308+ .lex-code {
309309+ --code-string: #8fc98f;
310310+ --code-number: #7fb2c9;
311311+ }
269312 }
270313271314 .lex-actions {