diff --git a/experimental/owl/web/src/lib/highlight.ts b/experimental/owl/web/src/lib/highlight.ts index 5b54092..c346cec 100644 --- a/experimental/owl/web/src/lib/highlight.ts +++ b/experimental/owl/web/src/lib/highlight.ts @@ -6,8 +6,9 @@ // themes are emitted as CSS variables and swapped by prefers-color-scheme (see // Shell.astro). -import { codeToHtml, codeToTokens } from 'shiki'; +import { codeToHast, codeToTokens, hastToHtml } from 'shiki'; import type { ShikiTransformer, ThemedToken } from 'shiki'; +import type { Element, ElementContent, Root } from 'hast'; const THEMES = { light: 'github-light', dark: 'github-dark' } as const; @@ -30,28 +31,76 @@ function lineNumbers(startLine: number): ShikiTransformer { }; } -/** - * Highlight code to dual-theme HTML with a line-number gutter. An unknown or - * unavailable grammar degrades to Shiki's `plaintext` (no token colors, identical - * markup) instead of a bespoke fallback, so every code block renders the same way. - */ -export async function highlightToHtml( +// Tokenize a chunk once to a hast tree (dual themes as CSS vars + gutter). An +// unknown or unavailable grammar degrades to `plaintext` — identical markup, no +// token colors — so every code block renders the same way. +async function toHast( code: string, lang: string | undefined, - startLine = 1, -): Promise { + startLine: number, +): Promise { const opts = { themes: THEMES, defaultColor: false, transformers: [lineNumbers(startLine)], } as const; try { - return await codeToHtml(code, { lang: lang ?? PLAIN, ...opts }); + return await codeToHast(code, { lang: lang ?? PLAIN, ...opts }); } catch { - return await codeToHtml(code, { lang: PLAIN, ...opts }); + return await codeToHast(code, { lang: PLAIN, ...opts }); } } +/** + * Highlight a whole chunk to dual-theme HTML with a line-number gutter, numbered + * from `startLine`. Used by the raw and plain views (the entire file in one pass). + */ +export async function highlightToHtml( + code: string, + lang: string | undefined, + startLine = 1, +): Promise { + return hastToHtml(await toHast(code, lang, startLine)); +} + +/** A file tokenized once, exposing contiguous line ranges as separate blocks. */ +export interface HighlightedLines { + /** Number of source lines (1:1 with `code.split('\n')`). */ + count: number; + /** Render source lines `[start, end)` (0-based) as one `
`. */
+  block(start: number, end: number): string;
+}
+
+/**
+ * Tokenize a file ONCE, then hand back its highlighted lines to be sliced into
+ * blocks. The rendered view of a `code` file interleaves prose boxes with source;
+ * re-tokenizing each source run in isolation would lose stateful grammar context
+ * (Astro's `---` frontmatter fence, template literals, multi-line comments), so
+ * every block is cut from this single whole-file pass and stays correctly colored.
+ */
+export async function highlightLines(
+  code: string,
+  lang: string | undefined,
+): Promise {
+  const root = await toHast(code, lang, 1);
+  const pre = root.children.find((c): c is Element => c.type === 'element') as Element;
+  const codeEl = pre.children.find((c): c is Element => c.type === 'element') as Element;
+  const lineEls = codeEl.children.filter((c): c is Element => c.type === 'element');
+  return {
+    count: lineEls.length,
+    block(start, end) {
+      const kids: ElementContent[] = [];
+      for (let k = start; k < end; k++) {
+        if (k > start) kids.push({ type: 'text', value: '\n' });
+        kids.push(lineEls[k]);
+      }
+      const codeNode: Element = { type: 'element', tagName: 'code', properties: {}, children: kids };
+      const preNode: Element = { ...pre, children: [codeNode] };
+      return hastToHtml({ type: 'root', children: [preNode] });
+    },
+  };
+}
+
 // Per-line comment classification — the ONLY place the comment-detection engine
 // lives. A line is a "comment line" when its non-whitespace tokens are all
 // comment-scoped (a whole-line comment or a line inside a block comment); a line
diff --git a/experimental/owl/web/src/lib/render.ts b/experimental/owl/web/src/lib/render.ts
index 22edbcc..be2c1a9 100644
--- a/experimental/owl/web/src/lib/render.ts
+++ b/experimental/owl/web/src/lib/render.ts
@@ -1,12 +1,12 @@
 // Produces the HTML for a file's two views. Both branch on the file's render
 // `kind` (deduced once by `renderPlan` in lang.ts — the single source of truth)
-// and render source through the one highlighter (highlightToHtml). `raw` is the
-// whole file; `rendered` lifts comment runs into markdown boxes and fully renders
-// `.md`, but the surrounding source is highlighted the same way, numbered by its
-// real file lines.
+// and highlight source with Shiki. `raw` is the whole file in one pass; `rendered`
+// lifts comment runs into markdown boxes and fully renders `.md`, but the
+// surrounding source is cut from the same single whole-file pass (so grammar
+// context and colors survive) and numbered by its real file lines.
 
 import { extractSegments, stripMarkers } from './comments';
-import { highlightToHtml } from './highlight';
+import { highlightLines, highlightToHtml } from './highlight';
 import { renderPlan } from './lang';
 import { readTreeFile, type FileEntry } from './manifest';
 import { renderMarkdown } from './markdown';
@@ -33,8 +33,11 @@ export async function renderRenderedHtml(entry: FileEntry): Promise {
   if (plan.kind === 'plain') {
     return highlightToHtml(src, undefined, 1);
   }
-  // kind === 'code': interleave prose boxes (comment runs) with highlighted code,
-  // each code block numbered from its real first line.
+  // kind === 'code': interleave prose boxes (comment runs) with highlighted code.
+  // The file is tokenized ONCE (highlightLines) so grammar context survives; each
+  // code run is a slice of that single pass, still colored and numbered by its
+  // real file lines.
+  const hl = await highlightLines(src, plan.lang);
   const segments = await extractSegments(src, plan.lang);
   const parts: string[] = [];
   for (const seg of segments) {
@@ -42,7 +45,8 @@ export async function renderRenderedHtml(entry: FileEntry): Promise {
       const prose = stripMarkers(seg.text);
       if (prose !== '') parts.push(`
${renderMarkdown(prose)}
`); } else { - parts.push(await highlightToHtml(seg.text, plan.lang, seg.startLine)); + const start = seg.startLine - 1; + parts.push(hl.block(start, start + seg.text.split('\n').length)); } } return parts.join('\n');