A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge remote-tracking branch 'origin/trunk' into pds-record-json-icon

# Conflicts:
# src/lib/reader/highlight.test.ts
# src/lib/reader/highlight.ts

+93 -13
+27 -1
src/lib/reader/highlight.test.ts
··· 1 1 import { describe, expect, it } from 'vitest'; 2 - import { highlightCodeBlocks, highlightJson } from './highlight'; 2 + import { highlightCodeBlocks, highlightJson, highlightSource } from './highlight'; 3 3 4 4 const block = ( inner: string ) => 5 5 `<pre class="wp-block-code"><code>${ inner }</code></pre>`; ··· 99 99 expect( out ).toContain( '</code>' ); 100 100 } ); 101 101 } ); 102 + 103 + describe( 'highlightSource', () => { 104 + it( 'tokenises a known-language source string into hljs spans', () => { 105 + // Unlike the reader blocks, static callers (e.g. the lexicon schema dump) know 106 + // the language up front, so we highlight directly instead of auto-detecting. 107 + const out = highlightSource( '{ "name": "potato", "value": 42 }', 'json' ); 108 + expect( out ).toContain( '<span class="hljs-' ); 109 + expect( out ).toContain( 'potato' ); 110 + } ); 111 + 112 + it( 'escapes angle brackets, never injecting raw markup', () => { 113 + const out = highlightSource( '{ "tag": "<b>" }', 'json' ); 114 + expect( out ).not.toContain( '<b>' ); 115 + expect( out ).toContain( '&lt;b&gt;' ); 116 + } ); 117 + 118 + it( 'falls back to plain escaped text for an unregistered language', () => { 119 + // An unknown grammar makes hljs throw; the source must still render as safe, 120 + // entity-escaped text — never raw markup, never an error bubbling to the page. 121 + const out = highlightSource( 'a < b && c > d', 'no-such-lang' ); 122 + expect( out ).not.toContain( 'hljs-' ); 123 + expect( out ).toContain( '&lt;' ); 124 + expect( out ).toContain( '&amp;' ); 125 + expect( out ).toContain( '&gt;' ); 126 + } ); 127 + } );
+22 -11
src/lib/reader/highlight.ts
··· 81 81 } 82 82 83 83 /** HTML-escape for the fallback path (hljs already escapes its own token output). */ 84 - function escapeHtml( input: string ): string { 85 - return input 84 + function escapeHtml( source: string ): string { 85 + return source 86 86 .replace( /&/g, '&amp;' ) 87 87 .replace( /</g, '&lt;' ) 88 88 .replace( />/g, '&gt;' ); 89 89 } 90 90 91 91 /** 92 + * Highlight a known-language source string into hljs token HTML, for static pages 93 + * (e.g. the lexicon's JSON schema dump) that know their language up front and so 94 + * skip the reader's auto-detection. Returns highlight.js's already-escaped token 95 + * HTML, safe to inject; on any error (unregistered language, etc.) it falls back to 96 + * plain entity-escaped text — the source still renders, never as raw markup. Keeping 97 + * this here preserves the rule that highlight.js is imported in this module only. 98 + */ 99 + export function highlightSource( source: string, language: string ): string { 100 + try { 101 + return hljs.highlight( source, { language } ).value; 102 + } catch { 103 + return escapeHtml( source ); 104 + } 105 + } 106 + 107 + /** 92 108 * Highlight a JSON string as a standalone `<code class="hljs language-json">…</code>` 93 109 * fragment (no `<pre>` wrapper — the caller owns the chrome). Used by the reader's 94 - * record-JSON viewer. `hljs.highlight` HTML-escapes the source, so untrusted record 95 - * values can't inject markup; `record-json.ts` sanitises the result as the final gate. 110 + * record-JSON viewer. `highlightSource` HTML-escapes the source on both the highlight 111 + * and fallback paths, so untrusted record values can't inject markup; `record-json.ts` 112 + * sanitises the result as the final gate. 96 113 */ 97 114 export function highlightJson( source: string ): string { 98 - try { 99 - const { value } = hljs.highlight( source, { language: 'json' } ); 100 - return `<code class="hljs language-json">${ value }</code>`; 101 - } catch { 102 - // Never let a highlighter error break a page — fall back to plain escaped JSON. 103 - return `<code class="hljs language-json">${ escapeHtml( source ) }</code>`; 104 - } 115 + return `<code class="hljs language-json">${ highlightSource( source, 'json' ) }</code>`; 105 116 }
+44 -1
src/pages/lexicon.astro
··· 9 9 contentSchemaFields, 10 10 } from '../lib/lexicon/schema-doc'; 11 11 import { parseInlineCode } from '../lib/lexicon/inline-code'; 12 + import { highlightSource } from '../lib/reader/highlight'; 12 13 13 14 const contentFields = contentSchemaFields(); 14 15 const schemaJson = JSON.stringify( CONTENT_LEXICON, null, 2 ); 16 + // The schema is JSON we control, so highlight it directly (known language) rather 17 + // than auto-detecting. Server-side at build time → no client JS, like the reader. 18 + const schemaJsonHtml = highlightSource( schemaJson, 'json' ); 15 19 16 20 // Hand-authored interop tables (site.standard.* are community-owned; see Decision 0005). 17 21 const documentFields = [ ··· 100 104 <code>textContent</code>, so your article is never unreadable. Here&rsquo;s 101 105 the full schema: 102 106 </p> 103 - <pre class="lex-code"><code>{ schemaJson }</code></pre> 107 + <pre class="lex-code"><code class="hljs language-json" set:html={ schemaJsonHtml } /></pre> 104 108 </section> 105 109 106 110 <section class="lex-section"> ··· 260 264 overflow: auto; 261 265 font-size: 0.85rem; 262 266 line-height: 1.55; 267 + /* String/number hues that read on the light code surface; the warm-orange 268 + and muted token colours below reuse brand tokens that already flip. */ 269 + --code-string: #3f7d4f; 270 + --code-number: #2f6f86; 263 271 } 264 272 .lex-code code { 265 273 font-family: var( --font-mono ); 266 274 background: none; 267 275 color: var( --ink ); 268 276 padding: 0; 277 + } 278 + /* highlight.js token colours, scoped to this light code block (unlike the 279 + reader's dark <pre>). The spans arrive via set:html, so they need :global. 280 + No background/padding on .hljs, so the .lex-code chrome above is untouched. */ 281 + .lex-code :global( .hljs-comment ), 282 + .lex-code :global( .hljs-quote ), 283 + .lex-code :global( .hljs-punctuation ) { 284 + color: var( --muted ); 285 + } 286 + .lex-code :global( .hljs-comment ), 287 + .lex-code :global( .hljs-quote ) { 288 + font-style: italic; 289 + } 290 + .lex-code :global( .hljs-attr ), 291 + .lex-code :global( .hljs-keyword ), 292 + .lex-code :global( .hljs-selector-tag ), 293 + .lex-code :global( .hljs-name ) { 294 + color: var( --sun-strong ); 295 + } 296 + .lex-code :global( .hljs-string ), 297 + .lex-code :global( .hljs-symbol ), 298 + .lex-code :global( .hljs-meta .hljs-string ) { 299 + color: var( --code-string ); 300 + } 301 + .lex-code :global( .hljs-number ), 302 + .lex-code :global( .hljs-literal ), 303 + .lex-code :global( .hljs-built_in ) { 304 + color: var( --code-number ); 305 + } 306 + 307 + @media ( prefers-color-scheme: dark ) { 308 + .lex-code { 309 + --code-string: #8fc98f; 310 + --code-number: #7fb2c9; 311 + } 269 312 } 270 313 271 314 .lex-actions {