@moku-labs/web 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1920,7 +1920,7 @@ type DataProvider = {
1920
1920
  */
1921
1921
  declare const dataPlugin: import("@moku-labs/core").PluginInstance<"data", DataConfig, DataState, DataProvider, {}> & Record<never, never>;
1922
1922
  declare namespace types_d_exports {
1923
- export { Api, Article, ArticleCard, ComputedFields, Config, ContentApiContext, ContentEvents, ContentProvider, ContentProviderState, FileSystemContentOptions, Frontmatter, LoadAllOptions, State };
1923
+ export { Api, Article, ArticleCard, ComputedFields, Config, ContentApiContext, ContentEvents, ContentProvider, ContentProviderState, FileSystemContentOptions, Frontmatter, LoadAllOptions, MermaidDiagramOptions, State };
1924
1924
  }
1925
1925
  /**
1926
1926
  * YAML frontmatter parsed from each article file.
@@ -2034,6 +2034,36 @@ interface ContentProvider {
2034
2034
  */
2035
2035
  invalidate?(paths: readonly string[]): void;
2036
2036
  }
2037
+ /**
2038
+ * Options for build-time Mermaid diagram rendering (the `mermaid` key of
2039
+ * {@link FileSystemContentOptions}). Rendering is delegated to the OPTIONAL
2040
+ * peer dependency `mermaid-isomorphic`, so the config stays loosely typed —
2041
+ * its types are never imported here.
2042
+ *
2043
+ * @example
2044
+ * ```ts
2045
+ * fileSystemContent({ contentDir: "./content", trustedContent: true, mermaid: { mermaidConfig: { theme: "dark" } } });
2046
+ * ```
2047
+ */
2048
+ type MermaidDiagramOptions = {
2049
+ /**
2050
+ * Mermaid configuration passed straight through to mermaid-isomorphic's
2051
+ * render call (e.g. `{ theme: "dark" }`). Loosely typed as a plain record
2052
+ * because the dependency is optional.
2053
+ */
2054
+ mermaidConfig?: Record<string, unknown>;
2055
+ /**
2056
+ * TEST-ONLY seam: replaces the real mermaid-isomorphic batch renderer so
2057
+ * unit tests stay deterministic with no headless browser. Receives every
2058
+ * mermaid fence source of one document in order and must resolve to exactly
2059
+ * one SVG string per source. Never set this in an app.
2060
+ *
2061
+ * @param sources - Every mermaid fence source of one document, in order.
2062
+ * @param mermaidConfig - The configured mermaid pass-through config, if any.
2063
+ * @returns One SVG string per source, in order.
2064
+ */
2065
+ renderDiagrams?: (sources: readonly string[], mermaidConfig?: Record<string, unknown>) => Promise<readonly string[]>;
2066
+ };
2037
2067
  /**
2038
2068
  * Options for the node filesystem provider {@link ContentProvider} `fileSystemContent`.
2039
2069
  * These are the markdown-pipeline + source concerns that used to live on the content
@@ -2059,6 +2089,15 @@ type FileSystemContentOptions = {
2059
2089
  */
2060
2090
  shikiTheme?: BundledTheme | ThemeRegistrationAny; /** Author applied to articles whose frontmatter omits author. Defaults to undefined. */
2061
2091
  defaultAuthor?: string;
2092
+ /**
2093
+ * Build-time Mermaid diagrams: render fenced `mermaid` code blocks to static
2094
+ * inline SVG during the build (zero client-side JS). `true` enables with
2095
+ * defaults; an object passes {@link MermaidDiagramOptions}. Requires
2096
+ * `trustedContent: true` (the raw inline SVG would be stripped by the
2097
+ * sanitize pass) and the OPTIONAL peer dependency `mermaid-isomorphic`
2098
+ * (plus playwright with an installed browser). Defaults to disabled.
2099
+ */
2100
+ mermaid?: boolean | MermaidDiagramOptions;
2062
2101
  };
2063
2102
  /**
2064
2103
  * Internal mutable state of the filesystem provider: the lazy unified processor and
package/dist/index.cjs CHANGED
@@ -1580,6 +1580,23 @@ function createContentState(_ctx) {
1580
1580
  function validateContentConfig(config) {
1581
1581
  if (!Array.isArray(config.providers) || config.providers.length === 0) throw new Error("[web] content: no provider composed.\n Add fileSystemContent(...) to pluginConfigs.content.providers.");
1582
1582
  }
1583
+ /**
1584
+ * Validates the `fileSystemContent` provider options (fail-fast at provider
1585
+ * construction). Throws when `mermaid` is enabled without `trustedContent: true`:
1586
+ * mermaid output is raw inline SVG, which the sanitize pass (the untrusted-content
1587
+ * XSS boundary) would strip — so the combination can never work. Errors use the
1588
+ * `[web]` prefix.
1589
+ *
1590
+ * @param options - The provider options to validate.
1591
+ * @throws {Error} If `mermaid` is enabled while `trustedContent` is not `true`.
1592
+ * @example
1593
+ * ```ts
1594
+ * validateFileSystemContentOptions({ contentDir: "./content", trustedContent: true, mermaid: true });
1595
+ * ```
1596
+ */
1597
+ function validateFileSystemContentOptions(options) {
1598
+ if (Boolean(options.mermaid) && options.trustedContent !== true) throw new Error("[web] content: `mermaid` requires `trustedContent: true`.\n Mermaid diagrams render to raw inline SVG, which the sanitize pass would strip.\n Set trustedContent: true ONLY for fully author-controlled Markdown.");
1599
+ }
1583
1600
  //#endregion
1584
1601
  //#region src/plugins/content/index.ts
1585
1602
  /**
@@ -3550,6 +3567,23 @@ const FINGERPRINT_NAMING = {
3550
3567
  asset: "[name]-[hash].[ext]"
3551
3568
  };
3552
3569
  /**
3570
+ * Font url() references the CSS pass leaves EXTERNAL instead of bundling.
3571
+ * Bun's CSS bundler cannot emit url() assets as files — every resolvable
3572
+ * font reference is inlined as a base64 data URI, ballooning the stylesheet
3573
+ * (a site vendoring a font family ships every weight/subset render-blocking
3574
+ * on every page). Marking font extensions external passes the URL through
3575
+ * verbatim, so apps reference fonts root-relative (e.g. `/fonts/x.woff2`)
3576
+ * and serve the files statically via `publicDir`. CSS-only: a font import
3577
+ * left external in JS would be an unresolvable module at runtime.
3578
+ */
3579
+ const CSS_EXTERNAL_FONT_GLOBS = [
3580
+ "*.woff2",
3581
+ "*.woff",
3582
+ "*.ttf",
3583
+ "*.otf",
3584
+ "*.eot"
3585
+ ];
3586
+ /**
3553
3587
  * The default bundler runner — adapts the built-in `Bun.build`.
3554
3588
  *
3555
3589
  * @param options - Entry/outdir/minify/splitting/target/naming settings forwarded to `Bun.build`.
@@ -3562,10 +3596,11 @@ const FINGERPRINT_NAMING = {
3562
3596
  * @param options.naming.entry - Naming template for entry-point outputs.
3563
3597
  * @param options.naming.chunk - Naming template for lazy split chunks.
3564
3598
  * @param options.naming.asset - Naming template for additional emitted assets.
3599
+ * @param options.external - Import/url() globs left unresolved in the output.
3565
3600
  * @returns The structural build result.
3566
3601
  * @example
3567
3602
  * ```ts
3568
- * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser", naming: FINGERPRINT_NAMING });
3603
+ * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser", naming: FINGERPRINT_NAMING, external: [] });
3569
3604
  * ```
3570
3605
  */
3571
3606
  async function defaultRunner(options) {
@@ -3658,7 +3693,8 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3658
3693
  minify,
3659
3694
  splitting: true,
3660
3695
  target: "browser",
3661
- naming: FINGERPRINT_NAMING
3696
+ naming: FINGERPRINT_NAMING,
3697
+ external: kind === "css" ? [...CSS_EXTERNAL_FONT_GLOBS] : []
3662
3698
  });
3663
3699
  if (!result.success) throw new Error(`[web] build.bundle ${kind} build failed`);
3664
3700
  const hashed = {};
@@ -11550,6 +11586,154 @@ function parseFrontmatter(raw, config) {
11550
11586
  };
11551
11587
  }
11552
11588
  //#endregion
11589
+ //#region src/plugins/content/pipeline/mermaid.ts
11590
+ /** CSS class on the `<figure>` wrapper around each rendered diagram. */
11591
+ const MERMAID_FIGURE_CLASS = "mermaid-diagram";
11592
+ /**
11593
+ * Cached renderer promise — `createMermaidRenderer()` is called ONCE per process
11594
+ * and shared by every document (the underlying headless browser is expensive).
11595
+ */
11596
+ let cachedRendererPromise;
11597
+ /**
11598
+ * Lazily import `mermaid-isomorphic` and create its batched renderer. The import
11599
+ * happens HERE (never at module load) so the optional dependency is only touched
11600
+ * when a document actually contains a mermaid fence. A failed import is wrapped
11601
+ * in an actionable error naming the missing package.
11602
+ *
11603
+ * @param importModule - Import thunk for the package; injectable so tests can
11604
+ * exercise both outcomes without the real dependency. Defaults to the real
11605
+ * dynamic `import("mermaid-isomorphic")`.
11606
+ * @returns The batched mermaid renderer.
11607
+ * @throws {Error} When the optional dependency cannot be loaded.
11608
+ * @example
11609
+ * ```ts
11610
+ * const renderer = await loadMermaidRenderer();
11611
+ * const results = await renderer(["graph TD; A-->B"]);
11612
+ * ```
11613
+ */
11614
+ async function loadMermaidRenderer(importModule = () => import("mermaid-isomorphic")) {
11615
+ let moduleExports;
11616
+ try {
11617
+ moduleExports = await importModule();
11618
+ } catch (error) {
11619
+ throw new Error("[web] content: `mermaid` is enabled but the optional dependency \"mermaid-isomorphic\" could not be loaded.\n Install it (plus playwright and a browser):\n bun add -d mermaid-isomorphic playwright && bunx playwright install chromium", { cause: error });
11620
+ }
11621
+ return moduleExports.createMermaidRenderer();
11622
+ }
11623
+ /**
11624
+ * Unwrap mermaid-isomorphic's settled results into plain SVG strings, failing
11625
+ * the build on the first rejected diagram. The error quotes the diagram's first
11626
+ * line so the author can locate the broken fence.
11627
+ *
11628
+ * @param sources - The diagram sources, in the order they were rendered.
11629
+ * @param results - The settled render results (one per source).
11630
+ * @returns One SVG string per source, in order.
11631
+ * @throws {Error} When any diagram failed to render.
11632
+ * @example
11633
+ * ```ts
11634
+ * const svgs = unwrapMermaidResults(["graph TD; A-->B"], results);
11635
+ * ```
11636
+ */
11637
+ function unwrapMermaidResults(sources, results) {
11638
+ return results.map((result, index) => {
11639
+ if (result.status === "rejected") {
11640
+ const firstLine = (sources[index] ?? "").split("\n", 1)[0] ?? "";
11641
+ throw new Error(`[web] content: mermaid diagram failed to render (diagram starts with "${firstLine}"): ${String(result.reason)}`);
11642
+ }
11643
+ return result.value.svg;
11644
+ });
11645
+ }
11646
+ /**
11647
+ * The REAL render path: lazily load mermaid-isomorphic (cached once per
11648
+ * process), render every fence of the document in ONE batched call, and unwrap
11649
+ * the results. Replaced in unit tests by the `renderDiagrams` seam.
11650
+ *
11651
+ * @param sources - Every mermaid fence source of one document, in order.
11652
+ * @param mermaidConfig - Optional mermaid configuration forwarded to the render call.
11653
+ * @returns One SVG string per source, in order.
11654
+ * @example
11655
+ * ```ts
11656
+ * const svgs = await renderWithMermaidIsomorphic(["graph TD; A-->B"]);
11657
+ * ```
11658
+ */
11659
+ async function renderWithMermaidIsomorphic(sources, mermaidConfig) {
11660
+ cachedRendererPromise ??= loadMermaidRenderer();
11661
+ return unwrapMermaidResults(sources, await (await cachedRendererPromise)([...sources], mermaidConfig ? { mermaidConfig } : void 0));
11662
+ }
11663
+ /**
11664
+ * Collect every fenced `mermaid` code block in the tree (with the parent/index
11665
+ * needed to replace it later), in document order.
11666
+ *
11667
+ * @param tree - The mdast tree to scan.
11668
+ * @returns The fence sites found.
11669
+ * @example
11670
+ * ```ts
11671
+ * const fences = collectMermaidFences(tree);
11672
+ * ```
11673
+ */
11674
+ function collectMermaidFences(tree) {
11675
+ const fences = [];
11676
+ (0, unist_util_visit.visit)(tree, "code", (node, index, parent) => {
11677
+ if (node.lang !== "mermaid") return;
11678
+ if (parent === void 0 || index === void 0) return;
11679
+ fences.push({
11680
+ node,
11681
+ parent,
11682
+ index
11683
+ });
11684
+ });
11685
+ return fences;
11686
+ }
11687
+ /**
11688
+ * Normalize the provider's `mermaid` config value (`boolean | options`) to a
11689
+ * plain {@link MermaidDiagramOptions} object for the transform factory.
11690
+ *
11691
+ * @param mermaid - The raw `FileSystemContentOptions.mermaid` value (truthy).
11692
+ * @returns The options object (`{}` for the bare `true` form).
11693
+ * @example
11694
+ * ```ts
11695
+ * normalizeMermaidOptions(true); // {}
11696
+ * normalizeMermaidOptions({ mermaidConfig: { theme: "dark" } });
11697
+ * ```
11698
+ */
11699
+ function normalizeMermaidOptions(mermaid) {
11700
+ return typeof mermaid === "boolean" ? {} : mermaid;
11701
+ }
11702
+ /**
11703
+ * Remark transform factory: replaces every fenced `mermaid` code block with a
11704
+ * `<figure class="mermaid-diagram">` raw-HTML node carrying the diagram as
11705
+ * static inline SVG, rendered at build time (zero client-side JS). Runs at the
11706
+ * mdast stage, BEFORE remark-rehype; the bridge's `allowDangerousHtml` plus the
11707
+ * framework's `rehype-raw` default carry the SVG into the output. Documents
11708
+ * without a mermaid fence return immediately — `mermaid-isomorphic` is never
11709
+ * imported on that path. A diagram that fails to render fails the build with
11710
+ * its first line quoted.
11711
+ *
11712
+ * @param options - Mermaid options: `mermaidConfig` pass-through + the
11713
+ * test-only `renderDiagrams` seam.
11714
+ * @returns An async mdast tree transformer.
11715
+ * @example
11716
+ * ```ts
11717
+ * unified().use(remarkMermaidDiagrams, { mermaidConfig: { theme: "dark" } });
11718
+ * ```
11719
+ */
11720
+ function remarkMermaidDiagrams(options = {}) {
11721
+ return async (tree) => {
11722
+ const fences = collectMermaidFences(tree);
11723
+ if (fences.length === 0) return;
11724
+ const sources = fences.map((fence) => fence.node.value);
11725
+ const svgs = await (options.renderDiagrams ?? renderWithMermaidIsomorphic)(sources, options.mermaidConfig);
11726
+ if (svgs.length !== sources.length) throw new Error(`[web] content: mermaid renderer returned ${svgs.length} result(s) for ${sources.length} diagram(s).`);
11727
+ for (const [position, fence] of fences.entries()) {
11728
+ const html = {
11729
+ type: "html",
11730
+ value: `<figure class="${MERMAID_FIGURE_CLASS}">${svgs[position] ?? ""}</figure>`
11731
+ };
11732
+ fence.parent.children[fence.index] = html;
11733
+ }
11734
+ };
11735
+ }
11736
+ //#endregion
11553
11737
  //#region src/plugins/content/pipeline/plugins.ts
11554
11738
  /**
11555
11739
  * Type guard for remark-directive nodes (container/leaf/text).
@@ -11685,25 +11869,31 @@ function sectionDividerPlugin() {
11685
11869
  }
11686
11870
  /**
11687
11871
  * The hardcoded framework default remark (Markdown-AST) plugins, in order:
11688
- * parse, frontmatter, gfm, directive, pull-quote, then the mdast→hast bridge
11689
- * (`remark-rehype` with `allowDangerousHtml`). Pull-quote runs on the mdast
11690
- * before the bridge so the directive carries its `hName`/`hProperties`.
11691
- *
11872
+ * parse, frontmatter, gfm, directive, pull-quote, the OPT-IN mermaid transform,
11873
+ * then the mdast→hast bridge (`remark-rehype` with `allowDangerousHtml`).
11874
+ * Pull-quote and mermaid run on the mdast before the bridge pull-quote so the
11875
+ * directive carries its `hName`/`hProperties`, mermaid so the fence is replaced
11876
+ * with raw SVG HTML before Shiki could ever claim the code block.
11877
+ *
11878
+ * @param config - Optional provider configuration; only `mermaid` is read here
11879
+ * (truthy enables the mermaid transform at its fixed mdast position).
11692
11880
  * @returns The ordered default remark pluggables.
11693
11881
  * @example
11694
11882
  * ```ts
11695
11883
  * const remark = defaultRemarkPlugins();
11696
11884
  * ```
11697
11885
  */
11698
- function defaultRemarkPlugins() {
11699
- return [
11886
+ function defaultRemarkPlugins(config) {
11887
+ const plugins = [
11700
11888
  remark_parse.default,
11701
11889
  remark_frontmatter.default,
11702
11890
  remark_gfm.default,
11703
11891
  remark_directive.default,
11704
- pullQuotePlugin,
11705
- [remark_rehype.default, { allowDangerousHtml: true }]
11892
+ pullQuotePlugin
11706
11893
  ];
11894
+ if (config?.mermaid) plugins.push([remarkMermaidDiagrams, normalizeMermaidOptions(config.mermaid)]);
11895
+ plugins.push([remark_rehype.default, { allowDangerousHtml: true }]);
11896
+ return plugins;
11707
11897
  }
11708
11898
  /**
11709
11899
  * The hardcoded framework default rehype (HTML-AST) plugins, in order:
@@ -11844,14 +12034,15 @@ function applyPluggable(processor, plugin) {
11844
12034
  * replacing, the defaults).
11845
12035
  *
11846
12036
  * @param processor - The unified processor under construction (mutated in place).
11847
- * @param config - Resolved plugin configuration (provides `extraRemarkPlugins`).
12037
+ * @param config - Resolved plugin configuration (provides `extraRemarkPlugins`,
12038
+ * and `mermaid` which the defaults read to enable the mdast mermaid transform).
11848
12039
  * @example
11849
12040
  * ```ts
11850
12041
  * applyRemarkPlugins(processor, config);
11851
12042
  * ```
11852
12043
  */
11853
12044
  function applyRemarkPlugins(processor, config) {
11854
- for (const plugin of defaultRemarkPlugins()) applyPluggable(processor, plugin);
12045
+ for (const plugin of defaultRemarkPlugins(config)) applyPluggable(processor, plugin);
11855
12046
  for (const plugin of config.extraRemarkPlugins ?? []) applyPluggable(processor, plugin);
11856
12047
  }
11857
12048
  /**
@@ -12003,6 +12194,7 @@ async function discoverSlugs(dir) {
12003
12194
  * ```
12004
12195
  */
12005
12196
  function fileSystemContent(options) {
12197
+ validateFileSystemContentOptions(options);
12006
12198
  const state = {
12007
12199
  processor: null,
12008
12200
  slugs: null,
package/dist/index.d.cts CHANGED
@@ -2671,7 +2671,7 @@ type Api$1 = {
2671
2671
  */
2672
2672
  declare const cliPlugin: import("@moku-labs/core").PluginInstance<"cli", Config$1, State$1, Api$1, {}> & Record<never, never>;
2673
2673
  declare namespace types_d_exports$2 {
2674
- export { Api, Article, ArticleCard, ComputedFields, Config, ContentApiContext, ContentEvents, ContentProvider, ContentProviderState, FileSystemContentOptions, Frontmatter, LoadAllOptions, State };
2674
+ export { Api, Article, ArticleCard, ComputedFields, Config, ContentApiContext, ContentEvents, ContentProvider, ContentProviderState, FileSystemContentOptions, Frontmatter, LoadAllOptions, MermaidDiagramOptions, State };
2675
2675
  }
2676
2676
  /**
2677
2677
  * YAML frontmatter parsed from each article file.
@@ -2785,6 +2785,36 @@ interface ContentProvider {
2785
2785
  */
2786
2786
  invalidate?(paths: readonly string[]): void;
2787
2787
  }
2788
+ /**
2789
+ * Options for build-time Mermaid diagram rendering (the `mermaid` key of
2790
+ * {@link FileSystemContentOptions}). Rendering is delegated to the OPTIONAL
2791
+ * peer dependency `mermaid-isomorphic`, so the config stays loosely typed —
2792
+ * its types are never imported here.
2793
+ *
2794
+ * @example
2795
+ * ```ts
2796
+ * fileSystemContent({ contentDir: "./content", trustedContent: true, mermaid: { mermaidConfig: { theme: "dark" } } });
2797
+ * ```
2798
+ */
2799
+ type MermaidDiagramOptions = {
2800
+ /**
2801
+ * Mermaid configuration passed straight through to mermaid-isomorphic's
2802
+ * render call (e.g. `{ theme: "dark" }`). Loosely typed as a plain record
2803
+ * because the dependency is optional.
2804
+ */
2805
+ mermaidConfig?: Record<string, unknown>;
2806
+ /**
2807
+ * TEST-ONLY seam: replaces the real mermaid-isomorphic batch renderer so
2808
+ * unit tests stay deterministic with no headless browser. Receives every
2809
+ * mermaid fence source of one document in order and must resolve to exactly
2810
+ * one SVG string per source. Never set this in an app.
2811
+ *
2812
+ * @param sources - Every mermaid fence source of one document, in order.
2813
+ * @param mermaidConfig - The configured mermaid pass-through config, if any.
2814
+ * @returns One SVG string per source, in order.
2815
+ */
2816
+ renderDiagrams?: (sources: readonly string[], mermaidConfig?: Record<string, unknown>) => Promise<readonly string[]>;
2817
+ };
2788
2818
  /**
2789
2819
  * Options for the node filesystem provider {@link ContentProvider} `fileSystemContent`.
2790
2820
  * These are the markdown-pipeline + source concerns that used to live on the content
@@ -2810,6 +2840,15 @@ type FileSystemContentOptions = {
2810
2840
  */
2811
2841
  shikiTheme?: BundledTheme | ThemeRegistrationAny; /** Author applied to articles whose frontmatter omits author. Defaults to undefined. */
2812
2842
  defaultAuthor?: string;
2843
+ /**
2844
+ * Build-time Mermaid diagrams: render fenced `mermaid` code blocks to static
2845
+ * inline SVG during the build (zero client-side JS). `true` enables with
2846
+ * defaults; an object passes {@link MermaidDiagramOptions}. Requires
2847
+ * `trustedContent: true` (the raw inline SVG would be stripped by the
2848
+ * sanitize pass) and the OPTIONAL peer dependency `mermaid-isomorphic`
2849
+ * (plus playwright with an installed browser). Defaults to disabled.
2850
+ */
2851
+ mermaid?: boolean | MermaidDiagramOptions;
2813
2852
  };
2814
2853
  /**
2815
2854
  * Internal mutable state of the filesystem provider: the lazy unified processor and
package/dist/index.d.mts CHANGED
@@ -2671,7 +2671,7 @@ type Api$1 = {
2671
2671
  */
2672
2672
  declare const cliPlugin: import("@moku-labs/core").PluginInstance<"cli", Config$1, State$1, Api$1, {}> & Record<never, never>;
2673
2673
  declare namespace types_d_exports$2 {
2674
- export { Api, Article, ArticleCard, ComputedFields, Config, ContentApiContext, ContentEvents, ContentProvider, ContentProviderState, FileSystemContentOptions, Frontmatter, LoadAllOptions, State };
2674
+ export { Api, Article, ArticleCard, ComputedFields, Config, ContentApiContext, ContentEvents, ContentProvider, ContentProviderState, FileSystemContentOptions, Frontmatter, LoadAllOptions, MermaidDiagramOptions, State };
2675
2675
  }
2676
2676
  /**
2677
2677
  * YAML frontmatter parsed from each article file.
@@ -2785,6 +2785,36 @@ interface ContentProvider {
2785
2785
  */
2786
2786
  invalidate?(paths: readonly string[]): void;
2787
2787
  }
2788
+ /**
2789
+ * Options for build-time Mermaid diagram rendering (the `mermaid` key of
2790
+ * {@link FileSystemContentOptions}). Rendering is delegated to the OPTIONAL
2791
+ * peer dependency `mermaid-isomorphic`, so the config stays loosely typed —
2792
+ * its types are never imported here.
2793
+ *
2794
+ * @example
2795
+ * ```ts
2796
+ * fileSystemContent({ contentDir: "./content", trustedContent: true, mermaid: { mermaidConfig: { theme: "dark" } } });
2797
+ * ```
2798
+ */
2799
+ type MermaidDiagramOptions = {
2800
+ /**
2801
+ * Mermaid configuration passed straight through to mermaid-isomorphic's
2802
+ * render call (e.g. `{ theme: "dark" }`). Loosely typed as a plain record
2803
+ * because the dependency is optional.
2804
+ */
2805
+ mermaidConfig?: Record<string, unknown>;
2806
+ /**
2807
+ * TEST-ONLY seam: replaces the real mermaid-isomorphic batch renderer so
2808
+ * unit tests stay deterministic with no headless browser. Receives every
2809
+ * mermaid fence source of one document in order and must resolve to exactly
2810
+ * one SVG string per source. Never set this in an app.
2811
+ *
2812
+ * @param sources - Every mermaid fence source of one document, in order.
2813
+ * @param mermaidConfig - The configured mermaid pass-through config, if any.
2814
+ * @returns One SVG string per source, in order.
2815
+ */
2816
+ renderDiagrams?: (sources: readonly string[], mermaidConfig?: Record<string, unknown>) => Promise<readonly string[]>;
2817
+ };
2788
2818
  /**
2789
2819
  * Options for the node filesystem provider {@link ContentProvider} `fileSystemContent`.
2790
2820
  * These are the markdown-pipeline + source concerns that used to live on the content
@@ -2810,6 +2840,15 @@ type FileSystemContentOptions = {
2810
2840
  */
2811
2841
  shikiTheme?: BundledTheme | ThemeRegistrationAny; /** Author applied to articles whose frontmatter omits author. Defaults to undefined. */
2812
2842
  defaultAuthor?: string;
2843
+ /**
2844
+ * Build-time Mermaid diagrams: render fenced `mermaid` code blocks to static
2845
+ * inline SVG during the build (zero client-side JS). `true` enables with
2846
+ * defaults; an object passes {@link MermaidDiagramOptions}. Requires
2847
+ * `trustedContent: true` (the raw inline SVG would be stripped by the
2848
+ * sanitize pass) and the OPTIONAL peer dependency `mermaid-isomorphic`
2849
+ * (plus playwright with an installed browser). Defaults to disabled.
2850
+ */
2851
+ mermaid?: boolean | MermaidDiagramOptions;
2813
2852
  };
2814
2853
  /**
2815
2854
  * Internal mutable state of the filesystem provider: the lazy unified processor and
package/dist/index.mjs CHANGED
@@ -1567,6 +1567,23 @@ function createContentState(_ctx) {
1567
1567
  function validateContentConfig(config) {
1568
1568
  if (!Array.isArray(config.providers) || config.providers.length === 0) throw new Error("[web] content: no provider composed.\n Add fileSystemContent(...) to pluginConfigs.content.providers.");
1569
1569
  }
1570
+ /**
1571
+ * Validates the `fileSystemContent` provider options (fail-fast at provider
1572
+ * construction). Throws when `mermaid` is enabled without `trustedContent: true`:
1573
+ * mermaid output is raw inline SVG, which the sanitize pass (the untrusted-content
1574
+ * XSS boundary) would strip — so the combination can never work. Errors use the
1575
+ * `[web]` prefix.
1576
+ *
1577
+ * @param options - The provider options to validate.
1578
+ * @throws {Error} If `mermaid` is enabled while `trustedContent` is not `true`.
1579
+ * @example
1580
+ * ```ts
1581
+ * validateFileSystemContentOptions({ contentDir: "./content", trustedContent: true, mermaid: true });
1582
+ * ```
1583
+ */
1584
+ function validateFileSystemContentOptions(options) {
1585
+ if (Boolean(options.mermaid) && options.trustedContent !== true) throw new Error("[web] content: `mermaid` requires `trustedContent: true`.\n Mermaid diagrams render to raw inline SVG, which the sanitize pass would strip.\n Set trustedContent: true ONLY for fully author-controlled Markdown.");
1586
+ }
1570
1587
  //#endregion
1571
1588
  //#region src/plugins/content/index.ts
1572
1589
  /**
@@ -3537,6 +3554,23 @@ const FINGERPRINT_NAMING = {
3537
3554
  asset: "[name]-[hash].[ext]"
3538
3555
  };
3539
3556
  /**
3557
+ * Font url() references the CSS pass leaves EXTERNAL instead of bundling.
3558
+ * Bun's CSS bundler cannot emit url() assets as files — every resolvable
3559
+ * font reference is inlined as a base64 data URI, ballooning the stylesheet
3560
+ * (a site vendoring a font family ships every weight/subset render-blocking
3561
+ * on every page). Marking font extensions external passes the URL through
3562
+ * verbatim, so apps reference fonts root-relative (e.g. `/fonts/x.woff2`)
3563
+ * and serve the files statically via `publicDir`. CSS-only: a font import
3564
+ * left external in JS would be an unresolvable module at runtime.
3565
+ */
3566
+ const CSS_EXTERNAL_FONT_GLOBS = [
3567
+ "*.woff2",
3568
+ "*.woff",
3569
+ "*.ttf",
3570
+ "*.otf",
3571
+ "*.eot"
3572
+ ];
3573
+ /**
3540
3574
  * The default bundler runner — adapts the built-in `Bun.build`.
3541
3575
  *
3542
3576
  * @param options - Entry/outdir/minify/splitting/target/naming settings forwarded to `Bun.build`.
@@ -3549,10 +3583,11 @@ const FINGERPRINT_NAMING = {
3549
3583
  * @param options.naming.entry - Naming template for entry-point outputs.
3550
3584
  * @param options.naming.chunk - Naming template for lazy split chunks.
3551
3585
  * @param options.naming.asset - Naming template for additional emitted assets.
3586
+ * @param options.external - Import/url() globs left unresolved in the output.
3552
3587
  * @returns The structural build result.
3553
3588
  * @example
3554
3589
  * ```ts
3555
- * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser", naming: FINGERPRINT_NAMING });
3590
+ * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser", naming: FINGERPRINT_NAMING, external: [] });
3556
3591
  * ```
3557
3592
  */
3558
3593
  async function defaultRunner(options) {
@@ -3645,7 +3680,8 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3645
3680
  minify,
3646
3681
  splitting: true,
3647
3682
  target: "browser",
3648
- naming: FINGERPRINT_NAMING
3683
+ naming: FINGERPRINT_NAMING,
3684
+ external: kind === "css" ? [...CSS_EXTERNAL_FONT_GLOBS] : []
3649
3685
  });
3650
3686
  if (!result.success) throw new Error(`[web] build.bundle ${kind} build failed`);
3651
3687
  const hashed = {};
@@ -11537,6 +11573,154 @@ function parseFrontmatter(raw, config) {
11537
11573
  };
11538
11574
  }
11539
11575
  //#endregion
11576
+ //#region src/plugins/content/pipeline/mermaid.ts
11577
+ /** CSS class on the `<figure>` wrapper around each rendered diagram. */
11578
+ const MERMAID_FIGURE_CLASS = "mermaid-diagram";
11579
+ /**
11580
+ * Cached renderer promise — `createMermaidRenderer()` is called ONCE per process
11581
+ * and shared by every document (the underlying headless browser is expensive).
11582
+ */
11583
+ let cachedRendererPromise;
11584
+ /**
11585
+ * Lazily import `mermaid-isomorphic` and create its batched renderer. The import
11586
+ * happens HERE (never at module load) so the optional dependency is only touched
11587
+ * when a document actually contains a mermaid fence. A failed import is wrapped
11588
+ * in an actionable error naming the missing package.
11589
+ *
11590
+ * @param importModule - Import thunk for the package; injectable so tests can
11591
+ * exercise both outcomes without the real dependency. Defaults to the real
11592
+ * dynamic `import("mermaid-isomorphic")`.
11593
+ * @returns The batched mermaid renderer.
11594
+ * @throws {Error} When the optional dependency cannot be loaded.
11595
+ * @example
11596
+ * ```ts
11597
+ * const renderer = await loadMermaidRenderer();
11598
+ * const results = await renderer(["graph TD; A-->B"]);
11599
+ * ```
11600
+ */
11601
+ async function loadMermaidRenderer(importModule = () => import("mermaid-isomorphic")) {
11602
+ let moduleExports;
11603
+ try {
11604
+ moduleExports = await importModule();
11605
+ } catch (error) {
11606
+ throw new Error("[web] content: `mermaid` is enabled but the optional dependency \"mermaid-isomorphic\" could not be loaded.\n Install it (plus playwright and a browser):\n bun add -d mermaid-isomorphic playwright && bunx playwright install chromium", { cause: error });
11607
+ }
11608
+ return moduleExports.createMermaidRenderer();
11609
+ }
11610
+ /**
11611
+ * Unwrap mermaid-isomorphic's settled results into plain SVG strings, failing
11612
+ * the build on the first rejected diagram. The error quotes the diagram's first
11613
+ * line so the author can locate the broken fence.
11614
+ *
11615
+ * @param sources - The diagram sources, in the order they were rendered.
11616
+ * @param results - The settled render results (one per source).
11617
+ * @returns One SVG string per source, in order.
11618
+ * @throws {Error} When any diagram failed to render.
11619
+ * @example
11620
+ * ```ts
11621
+ * const svgs = unwrapMermaidResults(["graph TD; A-->B"], results);
11622
+ * ```
11623
+ */
11624
+ function unwrapMermaidResults(sources, results) {
11625
+ return results.map((result, index) => {
11626
+ if (result.status === "rejected") {
11627
+ const firstLine = (sources[index] ?? "").split("\n", 1)[0] ?? "";
11628
+ throw new Error(`[web] content: mermaid diagram failed to render (diagram starts with "${firstLine}"): ${String(result.reason)}`);
11629
+ }
11630
+ return result.value.svg;
11631
+ });
11632
+ }
11633
+ /**
11634
+ * The REAL render path: lazily load mermaid-isomorphic (cached once per
11635
+ * process), render every fence of the document in ONE batched call, and unwrap
11636
+ * the results. Replaced in unit tests by the `renderDiagrams` seam.
11637
+ *
11638
+ * @param sources - Every mermaid fence source of one document, in order.
11639
+ * @param mermaidConfig - Optional mermaid configuration forwarded to the render call.
11640
+ * @returns One SVG string per source, in order.
11641
+ * @example
11642
+ * ```ts
11643
+ * const svgs = await renderWithMermaidIsomorphic(["graph TD; A-->B"]);
11644
+ * ```
11645
+ */
11646
+ async function renderWithMermaidIsomorphic(sources, mermaidConfig) {
11647
+ cachedRendererPromise ??= loadMermaidRenderer();
11648
+ return unwrapMermaidResults(sources, await (await cachedRendererPromise)([...sources], mermaidConfig ? { mermaidConfig } : void 0));
11649
+ }
11650
+ /**
11651
+ * Collect every fenced `mermaid` code block in the tree (with the parent/index
11652
+ * needed to replace it later), in document order.
11653
+ *
11654
+ * @param tree - The mdast tree to scan.
11655
+ * @returns The fence sites found.
11656
+ * @example
11657
+ * ```ts
11658
+ * const fences = collectMermaidFences(tree);
11659
+ * ```
11660
+ */
11661
+ function collectMermaidFences(tree) {
11662
+ const fences = [];
11663
+ visit(tree, "code", (node, index, parent) => {
11664
+ if (node.lang !== "mermaid") return;
11665
+ if (parent === void 0 || index === void 0) return;
11666
+ fences.push({
11667
+ node,
11668
+ parent,
11669
+ index
11670
+ });
11671
+ });
11672
+ return fences;
11673
+ }
11674
+ /**
11675
+ * Normalize the provider's `mermaid` config value (`boolean | options`) to a
11676
+ * plain {@link MermaidDiagramOptions} object for the transform factory.
11677
+ *
11678
+ * @param mermaid - The raw `FileSystemContentOptions.mermaid` value (truthy).
11679
+ * @returns The options object (`{}` for the bare `true` form).
11680
+ * @example
11681
+ * ```ts
11682
+ * normalizeMermaidOptions(true); // {}
11683
+ * normalizeMermaidOptions({ mermaidConfig: { theme: "dark" } });
11684
+ * ```
11685
+ */
11686
+ function normalizeMermaidOptions(mermaid) {
11687
+ return typeof mermaid === "boolean" ? {} : mermaid;
11688
+ }
11689
+ /**
11690
+ * Remark transform factory: replaces every fenced `mermaid` code block with a
11691
+ * `<figure class="mermaid-diagram">` raw-HTML node carrying the diagram as
11692
+ * static inline SVG, rendered at build time (zero client-side JS). Runs at the
11693
+ * mdast stage, BEFORE remark-rehype; the bridge's `allowDangerousHtml` plus the
11694
+ * framework's `rehype-raw` default carry the SVG into the output. Documents
11695
+ * without a mermaid fence return immediately — `mermaid-isomorphic` is never
11696
+ * imported on that path. A diagram that fails to render fails the build with
11697
+ * its first line quoted.
11698
+ *
11699
+ * @param options - Mermaid options: `mermaidConfig` pass-through + the
11700
+ * test-only `renderDiagrams` seam.
11701
+ * @returns An async mdast tree transformer.
11702
+ * @example
11703
+ * ```ts
11704
+ * unified().use(remarkMermaidDiagrams, { mermaidConfig: { theme: "dark" } });
11705
+ * ```
11706
+ */
11707
+ function remarkMermaidDiagrams(options = {}) {
11708
+ return async (tree) => {
11709
+ const fences = collectMermaidFences(tree);
11710
+ if (fences.length === 0) return;
11711
+ const sources = fences.map((fence) => fence.node.value);
11712
+ const svgs = await (options.renderDiagrams ?? renderWithMermaidIsomorphic)(sources, options.mermaidConfig);
11713
+ if (svgs.length !== sources.length) throw new Error(`[web] content: mermaid renderer returned ${svgs.length} result(s) for ${sources.length} diagram(s).`);
11714
+ for (const [position, fence] of fences.entries()) {
11715
+ const html = {
11716
+ type: "html",
11717
+ value: `<figure class="${MERMAID_FIGURE_CLASS}">${svgs[position] ?? ""}</figure>`
11718
+ };
11719
+ fence.parent.children[fence.index] = html;
11720
+ }
11721
+ };
11722
+ }
11723
+ //#endregion
11540
11724
  //#region src/plugins/content/pipeline/plugins.ts
11541
11725
  /**
11542
11726
  * Type guard for remark-directive nodes (container/leaf/text).
@@ -11672,25 +11856,31 @@ function sectionDividerPlugin() {
11672
11856
  }
11673
11857
  /**
11674
11858
  * The hardcoded framework default remark (Markdown-AST) plugins, in order:
11675
- * parse, frontmatter, gfm, directive, pull-quote, then the mdast→hast bridge
11676
- * (`remark-rehype` with `allowDangerousHtml`). Pull-quote runs on the mdast
11677
- * before the bridge so the directive carries its `hName`/`hProperties`.
11678
- *
11859
+ * parse, frontmatter, gfm, directive, pull-quote, the OPT-IN mermaid transform,
11860
+ * then the mdast→hast bridge (`remark-rehype` with `allowDangerousHtml`).
11861
+ * Pull-quote and mermaid run on the mdast before the bridge pull-quote so the
11862
+ * directive carries its `hName`/`hProperties`, mermaid so the fence is replaced
11863
+ * with raw SVG HTML before Shiki could ever claim the code block.
11864
+ *
11865
+ * @param config - Optional provider configuration; only `mermaid` is read here
11866
+ * (truthy enables the mermaid transform at its fixed mdast position).
11679
11867
  * @returns The ordered default remark pluggables.
11680
11868
  * @example
11681
11869
  * ```ts
11682
11870
  * const remark = defaultRemarkPlugins();
11683
11871
  * ```
11684
11872
  */
11685
- function defaultRemarkPlugins() {
11686
- return [
11873
+ function defaultRemarkPlugins(config) {
11874
+ const plugins = [
11687
11875
  remarkParse,
11688
11876
  remarkFrontmatter,
11689
11877
  remarkGfm,
11690
11878
  remarkDirective,
11691
- pullQuotePlugin,
11692
- [remarkRehype, { allowDangerousHtml: true }]
11879
+ pullQuotePlugin
11693
11880
  ];
11881
+ if (config?.mermaid) plugins.push([remarkMermaidDiagrams, normalizeMermaidOptions(config.mermaid)]);
11882
+ plugins.push([remarkRehype, { allowDangerousHtml: true }]);
11883
+ return plugins;
11694
11884
  }
11695
11885
  /**
11696
11886
  * The hardcoded framework default rehype (HTML-AST) plugins, in order:
@@ -11831,14 +12021,15 @@ function applyPluggable(processor, plugin) {
11831
12021
  * replacing, the defaults).
11832
12022
  *
11833
12023
  * @param processor - The unified processor under construction (mutated in place).
11834
- * @param config - Resolved plugin configuration (provides `extraRemarkPlugins`).
12024
+ * @param config - Resolved plugin configuration (provides `extraRemarkPlugins`,
12025
+ * and `mermaid` which the defaults read to enable the mdast mermaid transform).
11835
12026
  * @example
11836
12027
  * ```ts
11837
12028
  * applyRemarkPlugins(processor, config);
11838
12029
  * ```
11839
12030
  */
11840
12031
  function applyRemarkPlugins(processor, config) {
11841
- for (const plugin of defaultRemarkPlugins()) applyPluggable(processor, plugin);
12032
+ for (const plugin of defaultRemarkPlugins(config)) applyPluggable(processor, plugin);
11842
12033
  for (const plugin of config.extraRemarkPlugins ?? []) applyPluggable(processor, plugin);
11843
12034
  }
11844
12035
  /**
@@ -11990,6 +12181,7 @@ async function discoverSlugs(dir) {
11990
12181
  * ```
11991
12182
  */
11992
12183
  function fileSystemContent(options) {
12184
+ validateFileSystemContentOptions(options);
11993
12185
  const state = {
11994
12186
  processor: null,
11995
12187
  slugs: null,
package/package.json CHANGED
@@ -80,9 +80,15 @@
80
80
  "unist-util-visit": "5.1.0"
81
81
  },
82
82
  "peerDependencies": {
83
+ "mermaid-isomorphic": "^3.0.0",
83
84
  "preact": "^10.29.2",
84
85
  "preact-render-to-string": "^6.6.0"
85
86
  },
87
+ "peerDependenciesMeta": {
88
+ "mermaid-isomorphic": {
89
+ "optional": true
90
+ }
91
+ },
86
92
  "devDependencies": {
87
93
  "@biomejs/biome": "2.4.16",
88
94
  "@types/bun": "1.3.14",
@@ -96,6 +102,7 @@
96
102
  "happy-dom": "20.9.0",
97
103
  "jiti": "2.6.1",
98
104
  "lefthook": "2.1.1",
105
+ "mermaid-isomorphic": "3.1.0",
99
106
  "preact": "10.29.2",
100
107
  "preact-render-to-string": "6.6.0",
101
108
  "publint": "0.3.21",
@@ -118,5 +125,5 @@
118
125
  "test:build-e2e": "bun test src/plugins/build/__tests__/e2e/",
119
126
  "test:coverage": "vitest run --project unit --project integration --coverage"
120
127
  },
121
- "version": "1.8.1"
128
+ "version": "1.9.0"
122
129
  }