From fe8ab9f9616cea8f8d897a301a143ee2d5a19709 Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Sun, 17 May 2026 10:25:17 -0700 Subject: [PATCH] feat(core): load Shiki languages dynamically from the AST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit comark/plugins/highlight pre-registers nine languages and silently falls through to a plain-text fallback for anything else, which reads as a styling bug rather than an error. Switching from a hardcoded import list to a tree walk means each deck only loads what its fences actually reference, new languages cost zero code, and unused ones aren't dragged into the build. - highlight.ts: walk every
 in the parsed tree, normalize
  alias fence strings (py → python, ts → typescript, yml → yaml,
  ...), dynamically import only the canonical Shiki modules the
  deck names, then run comark's highlightCodeBlocks with that set
  and registerDefaults disabled.
- Module cache lives across parses so the dev server pays each
  language's import cost exactly once per process.
- Unknown languages still render via the plugin's plain-text path
  — the catch is silent and cached so failing imports aren't
  retried on every rebuild.
- renderer.ts: drop the eleven hardcoded language imports and the
  highlightPlugin from the parse call; run highlightTree on the
  parsed tree instead. Mermaid plugin still runs during parse, so
  its rewritten  elements never reach the highlight walk.

Co-Authored-By: Claude Opus 4.7 
---
 packages/core/highlight.ts | 140 +++++++++++++++++++++++++++++++++++++
 packages/core/renderer.ts  |  23 +++---
 2 files changed, 152 insertions(+), 11 deletions(-)
 create mode 100644 packages/core/highlight.ts

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[][] = [];
-- 
2.51.2