@moku-labs/web 1.8.2 → 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
  /**
@@ -11569,6 +11586,154 @@ function parseFrontmatter(raw, config) {
11569
11586
  };
11570
11587
  }
11571
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
11572
11737
  //#region src/plugins/content/pipeline/plugins.ts
11573
11738
  /**
11574
11739
  * Type guard for remark-directive nodes (container/leaf/text).
@@ -11704,25 +11869,31 @@ function sectionDividerPlugin() {
11704
11869
  }
11705
11870
  /**
11706
11871
  * The hardcoded framework default remark (Markdown-AST) plugins, in order:
11707
- * parse, frontmatter, gfm, directive, pull-quote, then the mdast→hast bridge
11708
- * (`remark-rehype` with `allowDangerousHtml`). Pull-quote runs on the mdast
11709
- * before the bridge so the directive carries its `hName`/`hProperties`.
11710
- *
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).
11711
11880
  * @returns The ordered default remark pluggables.
11712
11881
  * @example
11713
11882
  * ```ts
11714
11883
  * const remark = defaultRemarkPlugins();
11715
11884
  * ```
11716
11885
  */
11717
- function defaultRemarkPlugins() {
11718
- return [
11886
+ function defaultRemarkPlugins(config) {
11887
+ const plugins = [
11719
11888
  remark_parse.default,
11720
11889
  remark_frontmatter.default,
11721
11890
  remark_gfm.default,
11722
11891
  remark_directive.default,
11723
- pullQuotePlugin,
11724
- [remark_rehype.default, { allowDangerousHtml: true }]
11892
+ pullQuotePlugin
11725
11893
  ];
11894
+ if (config?.mermaid) plugins.push([remarkMermaidDiagrams, normalizeMermaidOptions(config.mermaid)]);
11895
+ plugins.push([remark_rehype.default, { allowDangerousHtml: true }]);
11896
+ return plugins;
11726
11897
  }
11727
11898
  /**
11728
11899
  * The hardcoded framework default rehype (HTML-AST) plugins, in order:
@@ -11863,14 +12034,15 @@ function applyPluggable(processor, plugin) {
11863
12034
  * replacing, the defaults).
11864
12035
  *
11865
12036
  * @param processor - The unified processor under construction (mutated in place).
11866
- * @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).
11867
12039
  * @example
11868
12040
  * ```ts
11869
12041
  * applyRemarkPlugins(processor, config);
11870
12042
  * ```
11871
12043
  */
11872
12044
  function applyRemarkPlugins(processor, config) {
11873
- for (const plugin of defaultRemarkPlugins()) applyPluggable(processor, plugin);
12045
+ for (const plugin of defaultRemarkPlugins(config)) applyPluggable(processor, plugin);
11874
12046
  for (const plugin of config.extraRemarkPlugins ?? []) applyPluggable(processor, plugin);
11875
12047
  }
11876
12048
  /**
@@ -12022,6 +12194,7 @@ async function discoverSlugs(dir) {
12022
12194
  * ```
12023
12195
  */
12024
12196
  function fileSystemContent(options) {
12197
+ validateFileSystemContentOptions(options);
12025
12198
  const state = {
12026
12199
  processor: null,
12027
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
  /**
@@ -11556,6 +11573,154 @@ function parseFrontmatter(raw, config) {
11556
11573
  };
11557
11574
  }
11558
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
11559
11724
  //#region src/plugins/content/pipeline/plugins.ts
11560
11725
  /**
11561
11726
  * Type guard for remark-directive nodes (container/leaf/text).
@@ -11691,25 +11856,31 @@ function sectionDividerPlugin() {
11691
11856
  }
11692
11857
  /**
11693
11858
  * The hardcoded framework default remark (Markdown-AST) plugins, in order:
11694
- * parse, frontmatter, gfm, directive, pull-quote, then the mdast→hast bridge
11695
- * (`remark-rehype` with `allowDangerousHtml`). Pull-quote runs on the mdast
11696
- * before the bridge so the directive carries its `hName`/`hProperties`.
11697
- *
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).
11698
11867
  * @returns The ordered default remark pluggables.
11699
11868
  * @example
11700
11869
  * ```ts
11701
11870
  * const remark = defaultRemarkPlugins();
11702
11871
  * ```
11703
11872
  */
11704
- function defaultRemarkPlugins() {
11705
- return [
11873
+ function defaultRemarkPlugins(config) {
11874
+ const plugins = [
11706
11875
  remarkParse,
11707
11876
  remarkFrontmatter,
11708
11877
  remarkGfm,
11709
11878
  remarkDirective,
11710
- pullQuotePlugin,
11711
- [remarkRehype, { allowDangerousHtml: true }]
11879
+ pullQuotePlugin
11712
11880
  ];
11881
+ if (config?.mermaid) plugins.push([remarkMermaidDiagrams, normalizeMermaidOptions(config.mermaid)]);
11882
+ plugins.push([remarkRehype, { allowDangerousHtml: true }]);
11883
+ return plugins;
11713
11884
  }
11714
11885
  /**
11715
11886
  * The hardcoded framework default rehype (HTML-AST) plugins, in order:
@@ -11850,14 +12021,15 @@ function applyPluggable(processor, plugin) {
11850
12021
  * replacing, the defaults).
11851
12022
  *
11852
12023
  * @param processor - The unified processor under construction (mutated in place).
11853
- * @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).
11854
12026
  * @example
11855
12027
  * ```ts
11856
12028
  * applyRemarkPlugins(processor, config);
11857
12029
  * ```
11858
12030
  */
11859
12031
  function applyRemarkPlugins(processor, config) {
11860
- for (const plugin of defaultRemarkPlugins()) applyPluggable(processor, plugin);
12032
+ for (const plugin of defaultRemarkPlugins(config)) applyPluggable(processor, plugin);
11861
12033
  for (const plugin of config.extraRemarkPlugins ?? []) applyPluggable(processor, plugin);
11862
12034
  }
11863
12035
  /**
@@ -12009,6 +12181,7 @@ async function discoverSlugs(dir) {
12009
12181
  * ```
12010
12182
  */
12011
12183
  function fileSystemContent(options) {
12184
+ validateFileSystemContentOptions(options);
12012
12185
  const state = {
12013
12186
  processor: null,
12014
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.2"
128
+ "version": "1.9.0"
122
129
  }