diff --git a/packages/core/highlight.ts b/packages/core/highlight.ts new file mode 100644 index 0000000..234078f --- /dev/null +++ b/packages/core/highlight.ts @@ -0,0 +1,140 @@ +/** + * Dynamic Shiki language loading. + * + * comark/plugins/highlight pre-registers a fixed list of languages + * (vue, tsx, svelte, typescript, javascript, bash, json, yaml, + * astro). Anything else falls through to a plain-text fallback at + * render time — which looks like a styling bug, not an error. + * + * Instead of hardcoding a longer list of imports, we walk the parsed + * AST, collect every language name each deck actually uses, + * normalize common aliases (`py` → `python`, `ts` → `typescript`, + * etc.), and dynamically import only those Shiki language modules. + * The cache persists across parses, so the dev server pays the + * import cost once per language per process — typical incremental + * rebuilds touch zero new imports. + */ + +import type { ComarkNode, ComarkTree } from "comark"; +import { highlightCodeBlocks } from "comark/plugins/highlight"; +import type { LanguageRegistration, ThemeRegistration } from "shiki"; + +/** + * Loaded language modules, keyed by Shiki's canonical name. `null` + * means "we tried and Shiki doesn't have it" — cached so repeated + * parses don't retry failing imports. + */ +const moduleCache = new Map(); + +/** + * Markdown fence-string aliases that don't match Shiki's canonical + * language filename. Most fence strings need no entry (`python` maps + * to `python.mjs` directly). These are the common developer short + * forms plus a few Comark-emitted aliases. + * + * A `null` value means the fence is handled internally by + * comark/plugins/highlight (its bundled comark grammar covers md / + * markdown / comark) — nothing to import. + */ +const ALIASES: Record = { + py: "python", + rb: "ruby", + ts: "typescript", + js: "javascript", + yml: "yaml", + sh: "bash", + shell: "bash", + md: null, + markdown: null, + comark: null, +}; + +/** + * Resolve a fence-info string to its Shiki canonical name. Returns + * `null` when the language is handled internally by the plugin and + * shouldn't be loaded. + */ +function resolveLang(name: string): string | null { + return name in ALIASES ? ALIASES[name] : name; +} + +/** + * Walk the tree, rewriting alias fence strings on every `
` attr
+ * to Shiki's canonical name and collecting the canonical set ready
+ * to load. We mutate the attrs in place — the renderer doesn't keep
+ * a copy of the pre-walk tree and a fresh clone here would just
+ * churn allocations for the same effect.
+ */
+function normalizeAndCollect(tree: ComarkTree): Set {
+  const wanted = new Set();
+  const walk = (node: ComarkNode) => {
+    if (!Array.isArray(node)) return;
+    if (node[0] === "pre") {
+      const attrs = node[1] as Record | undefined;
+      const lang = attrs?.language;
+      if (typeof lang === "string" && lang.length > 0) {
+        const canonical = resolveLang(lang);
+        if (canonical && canonical !== lang) {
+          (attrs as Record).language = canonical;
+        }
+        if (canonical !== null) wanted.add(canonical);
+      }
+    }
+    for (let i = 2; i < node.length; i++) {
+      walk(node[i] as ComarkNode);
+    }
+  };
+  for (const node of tree.nodes) walk(node);
+  return wanted;
+}
+
+async function loadLanguageModule(
+  name: string,
+): Promise {
+  const cached = moduleCache.get(name);
+  if (cached !== undefined) return cached;
+  try {
+    const mod = await import(`shiki/dist/langs/${name}.mjs`);
+    // Shiki language files default-export an array of registrations
+    // (some languages include embedded sub-grammars). Cache and
+    // forward whatever the file gave us; highlightCodeBlocks accepts
+    // both shapes.
+    const value = mod.default as LanguageRegistration;
+    moduleCache.set(name, value);
+    return value;
+  } catch {
+    // Either Shiki doesn't ship this language or the dist path
+    // changed in a future version. The plugin's plain-text fallback
+    // still renders the source — just unstyled.
+    moduleCache.set(name, null);
+    return null;
+  }
+}
+
+interface HighlightOptions {
+  themes: { light: ThemeRegistration; dark: ThemeRegistration };
+}
+
+/**
+ * Highlight every `
` in the tree using only the languages
+ * the document actually references. `themes` passes through to
+ * comark's highlightCodeBlocks unchanged.
+ */
+export async function highlightTree(
+  tree: ComarkTree,
+  options: HighlightOptions,
+): Promise {
+  const wanted = normalizeAndCollect(tree);
+  const modules = await Promise.all([...wanted].map(loadLanguageModule));
+  const languages = modules.filter(
+    (m): m is LanguageRegistration => m !== null,
+  );
+  return highlightCodeBlocks(tree, {
+    themes: options.themes,
+    languages,
+    // The deck-specific set is loaded above; the plugin's defaults
+    // would be pure overhead at this point.
+    registerDefaultLanguages: false,
+    registerDefaultThemes: false,
+  });
+}
diff --git a/packages/core/renderer.ts b/packages/core/renderer.ts
index 3eddabe..e574b5f 100644
--- a/packages/core/renderer.ts
+++ b/packages/core/renderer.ts
@@ -1,8 +1,8 @@
 import { parse } from "comark";
 import type { ComarkElement, ComarkNode, ComarkTree } from "comark";
-import highlightPlugin from "comark/plugins/highlight";
 import mermaidPlugin from "comark/plugins/mermaid";
 import rosePineTheme from "@shikijs/themes/rose-pine";
+import { highlightTree } from "./highlight.ts";
 import { renderHTML } from "@comark/html";
 import { generateFavicon } from "./favicon.ts";
 import { Eta } from "@eta-dev/eta";
@@ -31,17 +31,18 @@ export async function renderPresentationHtml(
   const file = await Deno.readTextFile(path);
 
   // Comark plugins do the heavy fence handling at parse time:
-  //  - highlight: walks the tree and replaces 
/ children with
-  //    Shiki-tokenized spans using the Rose Pine theme.
   //  - mermaid: rewrites ```mermaid fences into 
-  //    elements whose content we hand to the client-side mermaid script.
-  const tree = await parse(file, {
-    plugins: [
-      highlightPlugin({
-        themes: { light: rosePineTheme, dark: rosePineTheme },
-      }),
-      mermaidPlugin(),
-    ],
+  //    elements whose content we render to inline SVG via the headless
+  //    Chromium pool in core/mermaid.ts.
+  //
+  // Syntax highlighting runs separately, post-parse, via highlightTree.
+  // We walk the AST, collect the fence languages this deck actually
+  // uses, dynamically import only those Shiki modules, and then apply
+  // the highlighter — so a deck with one Python block doesn't pay to
+  // load Rust, Go, SQL, etc.
+  const rawTree = await parse(file, { plugins: [mermaidPlugin()] });
+  const tree = await highlightTree(rawTree, {
+    themes: { light: rosePineTheme, dark: rosePineTheme },
   });
 
   const slideGroups: ComarkNode[][] = [];