@moku-labs/web 1.8.2 → 1.10.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.
- package/dist/browser.d.mts +57 -2
- package/dist/browser.mjs +79 -1
- package/dist/index.cjs +383 -10
- package/dist/index.d.cts +57 -2
- package/dist/index.d.mts +57 -2
- package/dist/index.mjs +383 -11
- package/package.json +8 -1
package/dist/browser.d.mts
CHANGED
|
@@ -1734,6 +1734,13 @@ declare const headPlugin: import("@moku-labs/core").PluginInstance<"head", Confi
|
|
|
1734
1734
|
*/
|
|
1735
1735
|
declare function createComponent(name: string, hooks: ComponentHooks): ComponentDef;
|
|
1736
1736
|
//#endregion
|
|
1737
|
+
//#region src/plugins/spa/lazy-embed.d.ts
|
|
1738
|
+
/**
|
|
1739
|
+
* Lazy-embed island: facade button click → real `<iframe loading="lazy">`.
|
|
1740
|
+
* The companion of the content pipeline's `::embed` directive.
|
|
1741
|
+
*/
|
|
1742
|
+
declare const lazyEmbed: ComponentDef;
|
|
1743
|
+
//#endregion
|
|
1737
1744
|
//#region src/plugins/spa/index.d.ts
|
|
1738
1745
|
/**
|
|
1739
1746
|
* SPA plugin — progressive client-side navigation layered over the static site:
|
|
@@ -1920,7 +1927,7 @@ type DataProvider = {
|
|
|
1920
1927
|
*/
|
|
1921
1928
|
declare const dataPlugin: import("@moku-labs/core").PluginInstance<"data", DataConfig, DataState, DataProvider, {}> & Record<never, never>;
|
|
1922
1929
|
declare namespace types_d_exports {
|
|
1923
|
-
export { Api, Article, ArticleCard, ComputedFields, Config, ContentApiContext, ContentEvents, ContentProvider, ContentProviderState, FileSystemContentOptions, Frontmatter, LoadAllOptions, State };
|
|
1930
|
+
export { Api, Article, ArticleCard, ComputedFields, Config, ContentApiContext, ContentEvents, ContentProvider, ContentProviderState, FileSystemContentOptions, Frontmatter, LoadAllOptions, MermaidDiagramOptions, State };
|
|
1924
1931
|
}
|
|
1925
1932
|
/**
|
|
1926
1933
|
* YAML frontmatter parsed from each article file.
|
|
@@ -2034,6 +2041,36 @@ interface ContentProvider {
|
|
|
2034
2041
|
*/
|
|
2035
2042
|
invalidate?(paths: readonly string[]): void;
|
|
2036
2043
|
}
|
|
2044
|
+
/**
|
|
2045
|
+
* Options for build-time Mermaid diagram rendering (the `mermaid` key of
|
|
2046
|
+
* {@link FileSystemContentOptions}). Rendering is delegated to the OPTIONAL
|
|
2047
|
+
* peer dependency `mermaid-isomorphic`, so the config stays loosely typed —
|
|
2048
|
+
* its types are never imported here.
|
|
2049
|
+
*
|
|
2050
|
+
* @example
|
|
2051
|
+
* ```ts
|
|
2052
|
+
* fileSystemContent({ contentDir: "./content", trustedContent: true, mermaid: { mermaidConfig: { theme: "dark" } } });
|
|
2053
|
+
* ```
|
|
2054
|
+
*/
|
|
2055
|
+
type MermaidDiagramOptions = {
|
|
2056
|
+
/**
|
|
2057
|
+
* Mermaid configuration passed straight through to mermaid-isomorphic's
|
|
2058
|
+
* render call (e.g. `{ theme: "dark" }`). Loosely typed as a plain record
|
|
2059
|
+
* because the dependency is optional.
|
|
2060
|
+
*/
|
|
2061
|
+
mermaidConfig?: Record<string, unknown>;
|
|
2062
|
+
/**
|
|
2063
|
+
* TEST-ONLY seam: replaces the real mermaid-isomorphic batch renderer so
|
|
2064
|
+
* unit tests stay deterministic with no headless browser. Receives every
|
|
2065
|
+
* mermaid fence source of one document in order and must resolve to exactly
|
|
2066
|
+
* one SVG string per source. Never set this in an app.
|
|
2067
|
+
*
|
|
2068
|
+
* @param sources - Every mermaid fence source of one document, in order.
|
|
2069
|
+
* @param mermaidConfig - The configured mermaid pass-through config, if any.
|
|
2070
|
+
* @returns One SVG string per source, in order.
|
|
2071
|
+
*/
|
|
2072
|
+
renderDiagrams?: (sources: readonly string[], mermaidConfig?: Record<string, unknown>) => Promise<readonly string[]>;
|
|
2073
|
+
};
|
|
2037
2074
|
/**
|
|
2038
2075
|
* Options for the node filesystem provider {@link ContentProvider} `fileSystemContent`.
|
|
2039
2076
|
* These are the markdown-pipeline + source concerns that used to live on the content
|
|
@@ -2059,6 +2096,24 @@ type FileSystemContentOptions = {
|
|
|
2059
2096
|
*/
|
|
2060
2097
|
shikiTheme?: BundledTheme | ThemeRegistrationAny; /** Author applied to articles whose frontmatter omits author. Defaults to undefined. */
|
|
2061
2098
|
defaultAuthor?: string;
|
|
2099
|
+
/**
|
|
2100
|
+
* Build-time Mermaid diagrams: render fenced `mermaid` code blocks to static
|
|
2101
|
+
* inline SVG during the build (zero client-side JS). `true` enables with
|
|
2102
|
+
* defaults; an object passes {@link MermaidDiagramOptions}. Requires
|
|
2103
|
+
* `trustedContent: true` (the raw inline SVG would be stripped by the
|
|
2104
|
+
* sanitize pass) and the OPTIONAL peer dependency `mermaid-isomorphic`
|
|
2105
|
+
* (plus playwright with an installed browser). Defaults to disabled.
|
|
2106
|
+
*/
|
|
2107
|
+
mermaid?: boolean | MermaidDiagramOptions;
|
|
2108
|
+
/**
|
|
2109
|
+
* Lazy iframe embeds: rewrite `::embed{src="…" title="…"}` leaf directives
|
|
2110
|
+
* into static click-to-activate facades (no iframe — and none of the target's
|
|
2111
|
+
* network/JS cost — until the reader clicks). Pair with the `lazyEmbed` SPA
|
|
2112
|
+
* island, which swaps the facade for the real `<iframe loading="lazy">`.
|
|
2113
|
+
* Requires `trustedContent: true` (the facade is raw HTML the sanitize pass
|
|
2114
|
+
* would strip). Defaults to disabled.
|
|
2115
|
+
*/
|
|
2116
|
+
embed?: boolean;
|
|
2062
2117
|
};
|
|
2063
2118
|
/**
|
|
2064
2119
|
* Internal mutable state of the filesystem provider: the lazy unified processor and
|
|
@@ -2361,4 +2416,4 @@ declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs
|
|
|
2361
2416
|
*/
|
|
2362
2417
|
declare const createPlugin: import("@moku-labs/core").BoundCreatePluginFunction<Config$5, Events, import("@moku-labs/core").CoreApisFromTuple<[import("@moku-labs/core").CorePluginInstance<"log", LogConfig, LogState, LogApi>, import("@moku-labs/core").CorePluginInstance<"env", EnvConfig, EnvState, EnvApi>]>>;
|
|
2363
2418
|
//#endregion
|
|
2364
|
-
export { types_d_exports as Content, types_d_exports$1 as Data, types_d_exports$2 as Env, types_d_exports$3 as Head, types_d_exports$4 as Log, types_d_exports$5 as Router, types_d_exports$6 as Spa, browserEnv, buildArticleHead, canonical, contentPlugin, createApp, createComponent, createPlugin, createUrls, dataPlugin, defineRoutes, envPlugin, feedLink, headPlugin, hreflang, i18nPlugin, jsonLd, logPlugin, meta, og, route, routerPlugin, sitePlugin, spaPlugin, twitter };
|
|
2419
|
+
export { types_d_exports as Content, types_d_exports$1 as Data, types_d_exports$2 as Env, types_d_exports$3 as Head, types_d_exports$4 as Log, types_d_exports$5 as Router, types_d_exports$6 as Spa, browserEnv, buildArticleHead, canonical, contentPlugin, createApp, createComponent, createPlugin, createUrls, dataPlugin, defineRoutes, envPlugin, feedLink, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, route, routerPlugin, sitePlugin, spaPlugin, twitter };
|
package/dist/browser.mjs
CHANGED
|
@@ -4428,6 +4428,84 @@ function disposeSpa() {
|
|
|
4428
4428
|
}
|
|
4429
4429
|
}
|
|
4430
4430
|
//#endregion
|
|
4431
|
+
//#region src/plugins/spa/lazy-embed.ts
|
|
4432
|
+
/**
|
|
4433
|
+
* @file `lazyEmbed` island — activates the static embed facades emitted by the
|
|
4434
|
+
* content pipeline's `::embed` directive (pipeline/embed.ts). Mounts on every
|
|
4435
|
+
* `[data-component="lazy-embed"]` figure; a click on the facade's button swaps
|
|
4436
|
+
* it for the real `<iframe loading="lazy">`. Until that click the embedded
|
|
4437
|
+
* document costs the page nothing — no request, no third-party JS, no
|
|
4438
|
+
* scroll-jacking. Register it in `pluginConfigs.spa.components`; all visual
|
|
4439
|
+
* chrome (`.lazy-embed*` classes) is consumer CSS.
|
|
4440
|
+
*/
|
|
4441
|
+
/** CSS class on the injected `<iframe>` (consumer CSS sizes it). */
|
|
4442
|
+
const EMBED_FRAME_CLASS = "lazy-embed-frame";
|
|
4443
|
+
/**
|
|
4444
|
+
* Swap a facade `<figure>`'s content for its real `<iframe>`. The iframe
|
|
4445
|
+
* carries `loading="lazy"` plus fullscreen permission, and the figure gains
|
|
4446
|
+
* `data-embed-active` so consumer CSS can restyle the activated state.
|
|
4447
|
+
*
|
|
4448
|
+
* @param figure - The facade element carrying `data-embed-src`/`data-embed-title`.
|
|
4449
|
+
* @example
|
|
4450
|
+
* ```ts
|
|
4451
|
+
* activateEmbed(figure);
|
|
4452
|
+
* ```
|
|
4453
|
+
*/
|
|
4454
|
+
function activateEmbed(figure) {
|
|
4455
|
+
const src = figure.dataset.embedSrc;
|
|
4456
|
+
if (!src) return;
|
|
4457
|
+
const iframe = document.createElement("iframe");
|
|
4458
|
+
iframe.src = src;
|
|
4459
|
+
iframe.title = figure.dataset.embedTitle ?? "";
|
|
4460
|
+
iframe.className = EMBED_FRAME_CLASS;
|
|
4461
|
+
iframe.setAttribute("loading", "lazy");
|
|
4462
|
+
iframe.allow = "fullscreen; autoplay; gamepad";
|
|
4463
|
+
iframe.allowFullscreen = true;
|
|
4464
|
+
figure.replaceChildren(iframe);
|
|
4465
|
+
figure.dataset.embedActive = "";
|
|
4466
|
+
}
|
|
4467
|
+
/**
|
|
4468
|
+
* Shared click handler (module-level so mount/unmount detach the same
|
|
4469
|
+
* reference): a click on the facade's button activates the embed.
|
|
4470
|
+
*
|
|
4471
|
+
* @param event - The click event from the facade figure.
|
|
4472
|
+
* @example
|
|
4473
|
+
* ```ts
|
|
4474
|
+
* element.addEventListener("click", onFacadeClick);
|
|
4475
|
+
* ```
|
|
4476
|
+
*/
|
|
4477
|
+
function onFacadeClick(event) {
|
|
4478
|
+
if (!event.target.closest("button.lazy-embed-button")) return;
|
|
4479
|
+
const figure = event.currentTarget;
|
|
4480
|
+
if (figure instanceof HTMLElement) activateEmbed(figure);
|
|
4481
|
+
}
|
|
4482
|
+
/**
|
|
4483
|
+
* Lazy-embed island: facade button click → real `<iframe loading="lazy">`.
|
|
4484
|
+
* The companion of the content pipeline's `::embed` directive.
|
|
4485
|
+
*/
|
|
4486
|
+
const lazyEmbed = createComponent("lazy-embed", {
|
|
4487
|
+
/**
|
|
4488
|
+
* Bind the activation click handler when a facade mounts.
|
|
4489
|
+
*
|
|
4490
|
+
* @param ctx - The island lifecycle context.
|
|
4491
|
+
* @example
|
|
4492
|
+
* onMount(ctx);
|
|
4493
|
+
*/
|
|
4494
|
+
onMount(ctx) {
|
|
4495
|
+
ctx.el.addEventListener("click", onFacadeClick);
|
|
4496
|
+
},
|
|
4497
|
+
/**
|
|
4498
|
+
* Remove the activation click handler when the facade is destroyed.
|
|
4499
|
+
*
|
|
4500
|
+
* @param ctx - The island lifecycle context.
|
|
4501
|
+
* @example
|
|
4502
|
+
* onDestroy(ctx);
|
|
4503
|
+
*/
|
|
4504
|
+
onDestroy(ctx) {
|
|
4505
|
+
ctx.el.removeEventListener("click", onFacadeClick);
|
|
4506
|
+
}
|
|
4507
|
+
});
|
|
4508
|
+
//#endregion
|
|
4431
4509
|
//#region src/plugins/spa/index.ts
|
|
4432
4510
|
/**
|
|
4433
4511
|
* @file spa — Complex Plugin (WIRING ONLY, ≤30 lines). All logic lives in the
|
|
@@ -5115,4 +5193,4 @@ const createApp = core.createApp;
|
|
|
5115
5193
|
*/
|
|
5116
5194
|
const createPlugin = core.createPlugin;
|
|
5117
5195
|
//#endregion
|
|
5118
|
-
export { types_exports as Content, types_exports$1 as Data, types_exports$2 as Env, types_exports$3 as Head, types_exports$4 as Log, types_exports$5 as Router, types_exports$6 as Spa, browserEnv, buildArticleHead, canonical, contentPlugin, createApp, createComponent, createPlugin, createUrls, dataPlugin, defineRoutes, envPlugin, feedLink, headPlugin, hreflang, i18nPlugin, jsonLd, logPlugin, meta, og, route, routerPlugin, sitePlugin, spaPlugin, twitter };
|
|
5196
|
+
export { types_exports as Content, types_exports$1 as Data, types_exports$2 as Env, types_exports$3 as Head, types_exports$4 as Log, types_exports$5 as Router, types_exports$6 as Spa, browserEnv, buildArticleHead, canonical, contentPlugin, createApp, createComponent, createPlugin, createUrls, dataPlugin, defineRoutes, envPlugin, feedLink, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, route, routerPlugin, sitePlugin, spaPlugin, twitter };
|
package/dist/index.cjs
CHANGED
|
@@ -1580,6 +1580,25 @@ 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` or `embed` is enabled without
|
|
1586
|
+
* `trustedContent: true`: both emit raw HTML (inline SVG / the embed facade),
|
|
1587
|
+
* which the sanitize pass (the untrusted-content XSS boundary) would strip — so
|
|
1588
|
+
* the combination can never work. Errors use the `[web]` prefix.
|
|
1589
|
+
*
|
|
1590
|
+
* @param options - The provider options to validate.
|
|
1591
|
+
* @throws {Error} If `mermaid` or `embed` is enabled while `trustedContent` is
|
|
1592
|
+
* not `true`.
|
|
1593
|
+
* @example
|
|
1594
|
+
* ```ts
|
|
1595
|
+
* validateFileSystemContentOptions({ contentDir: "./content", trustedContent: true, mermaid: true });
|
|
1596
|
+
* ```
|
|
1597
|
+
*/
|
|
1598
|
+
function validateFileSystemContentOptions(options) {
|
|
1599
|
+
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.");
|
|
1600
|
+
if (Boolean(options.embed) && options.trustedContent !== true) throw new Error("[web] content: `embed` requires `trustedContent: true`.\n Embed directives render to a raw-HTML facade, which the sanitize pass would strip\n (and embedding third-party iframes is never safe for untrusted Markdown).\n Set trustedContent: true ONLY for fully author-controlled Markdown.");
|
|
1601
|
+
}
|
|
1583
1602
|
//#endregion
|
|
1584
1603
|
//#region src/plugins/content/index.ts
|
|
1585
1604
|
/**
|
|
@@ -11295,6 +11314,84 @@ function disposeSpa() {
|
|
|
11295
11314
|
}
|
|
11296
11315
|
}
|
|
11297
11316
|
//#endregion
|
|
11317
|
+
//#region src/plugins/spa/lazy-embed.ts
|
|
11318
|
+
/**
|
|
11319
|
+
* @file `lazyEmbed` island — activates the static embed facades emitted by the
|
|
11320
|
+
* content pipeline's `::embed` directive (pipeline/embed.ts). Mounts on every
|
|
11321
|
+
* `[data-component="lazy-embed"]` figure; a click on the facade's button swaps
|
|
11322
|
+
* it for the real `<iframe loading="lazy">`. Until that click the embedded
|
|
11323
|
+
* document costs the page nothing — no request, no third-party JS, no
|
|
11324
|
+
* scroll-jacking. Register it in `pluginConfigs.spa.components`; all visual
|
|
11325
|
+
* chrome (`.lazy-embed*` classes) is consumer CSS.
|
|
11326
|
+
*/
|
|
11327
|
+
/** CSS class on the injected `<iframe>` (consumer CSS sizes it). */
|
|
11328
|
+
const EMBED_FRAME_CLASS = "lazy-embed-frame";
|
|
11329
|
+
/**
|
|
11330
|
+
* Swap a facade `<figure>`'s content for its real `<iframe>`. The iframe
|
|
11331
|
+
* carries `loading="lazy"` plus fullscreen permission, and the figure gains
|
|
11332
|
+
* `data-embed-active` so consumer CSS can restyle the activated state.
|
|
11333
|
+
*
|
|
11334
|
+
* @param figure - The facade element carrying `data-embed-src`/`data-embed-title`.
|
|
11335
|
+
* @example
|
|
11336
|
+
* ```ts
|
|
11337
|
+
* activateEmbed(figure);
|
|
11338
|
+
* ```
|
|
11339
|
+
*/
|
|
11340
|
+
function activateEmbed(figure) {
|
|
11341
|
+
const src = figure.dataset.embedSrc;
|
|
11342
|
+
if (!src) return;
|
|
11343
|
+
const iframe = document.createElement("iframe");
|
|
11344
|
+
iframe.src = src;
|
|
11345
|
+
iframe.title = figure.dataset.embedTitle ?? "";
|
|
11346
|
+
iframe.className = EMBED_FRAME_CLASS;
|
|
11347
|
+
iframe.setAttribute("loading", "lazy");
|
|
11348
|
+
iframe.allow = "fullscreen; autoplay; gamepad";
|
|
11349
|
+
iframe.allowFullscreen = true;
|
|
11350
|
+
figure.replaceChildren(iframe);
|
|
11351
|
+
figure.dataset.embedActive = "";
|
|
11352
|
+
}
|
|
11353
|
+
/**
|
|
11354
|
+
* Shared click handler (module-level so mount/unmount detach the same
|
|
11355
|
+
* reference): a click on the facade's button activates the embed.
|
|
11356
|
+
*
|
|
11357
|
+
* @param event - The click event from the facade figure.
|
|
11358
|
+
* @example
|
|
11359
|
+
* ```ts
|
|
11360
|
+
* element.addEventListener("click", onFacadeClick);
|
|
11361
|
+
* ```
|
|
11362
|
+
*/
|
|
11363
|
+
function onFacadeClick(event) {
|
|
11364
|
+
if (!event.target.closest("button.lazy-embed-button")) return;
|
|
11365
|
+
const figure = event.currentTarget;
|
|
11366
|
+
if (figure instanceof HTMLElement) activateEmbed(figure);
|
|
11367
|
+
}
|
|
11368
|
+
/**
|
|
11369
|
+
* Lazy-embed island: facade button click → real `<iframe loading="lazy">`.
|
|
11370
|
+
* The companion of the content pipeline's `::embed` directive.
|
|
11371
|
+
*/
|
|
11372
|
+
const lazyEmbed = createComponent("lazy-embed", {
|
|
11373
|
+
/**
|
|
11374
|
+
* Bind the activation click handler when a facade mounts.
|
|
11375
|
+
*
|
|
11376
|
+
* @param ctx - The island lifecycle context.
|
|
11377
|
+
* @example
|
|
11378
|
+
* onMount(ctx);
|
|
11379
|
+
*/
|
|
11380
|
+
onMount(ctx) {
|
|
11381
|
+
ctx.el.addEventListener("click", onFacadeClick);
|
|
11382
|
+
},
|
|
11383
|
+
/**
|
|
11384
|
+
* Remove the activation click handler when the facade is destroyed.
|
|
11385
|
+
*
|
|
11386
|
+
* @param ctx - The island lifecycle context.
|
|
11387
|
+
* @example
|
|
11388
|
+
* onDestroy(ctx);
|
|
11389
|
+
*/
|
|
11390
|
+
onDestroy(ctx) {
|
|
11391
|
+
ctx.el.removeEventListener("click", onFacadeClick);
|
|
11392
|
+
}
|
|
11393
|
+
});
|
|
11394
|
+
//#endregion
|
|
11298
11395
|
//#region src/plugins/spa/index.ts
|
|
11299
11396
|
/**
|
|
11300
11397
|
* @file spa — Complex Plugin (WIRING ONLY, ≤30 lines). All logic lives in the
|
|
@@ -11569,6 +11666,269 @@ function parseFrontmatter(raw, config) {
|
|
|
11569
11666
|
};
|
|
11570
11667
|
}
|
|
11571
11668
|
//#endregion
|
|
11669
|
+
//#region src/plugins/content/pipeline/embed.ts
|
|
11670
|
+
/** CSS class on the `<figure>` facade wrapping each embed. */
|
|
11671
|
+
const EMBED_FIGURE_CLASS = "lazy-embed";
|
|
11672
|
+
/** `data-component` name binding the facade to the `lazyEmbed` SPA island. */
|
|
11673
|
+
const EMBED_COMPONENT_NAME = "lazy-embed";
|
|
11674
|
+
/** CSS class on the facade's activation button. */
|
|
11675
|
+
const EMBED_BUTTON_CLASS = "lazy-embed-button";
|
|
11676
|
+
/** CSS class on the title span inside the activation button. */
|
|
11677
|
+
const EMBED_TITLE_CLASS = "lazy-embed-title";
|
|
11678
|
+
/**
|
|
11679
|
+
* Type guard for an `::embed` leaf directive.
|
|
11680
|
+
*
|
|
11681
|
+
* @param node - AST node to test.
|
|
11682
|
+
* @returns `true` when the node is an `::embed` leaf directive.
|
|
11683
|
+
* @example
|
|
11684
|
+
* ```ts
|
|
11685
|
+
* if (isEmbedDirective(node)) console.log(node.attributes?.src);
|
|
11686
|
+
* ```
|
|
11687
|
+
*/
|
|
11688
|
+
function isEmbedDirective(node) {
|
|
11689
|
+
return node.type === "leafDirective" && node.name === "embed";
|
|
11690
|
+
}
|
|
11691
|
+
/**
|
|
11692
|
+
* Escape a string for safe interpolation into a double-quoted HTML attribute.
|
|
11693
|
+
*
|
|
11694
|
+
* @param value - The raw attribute value.
|
|
11695
|
+
* @returns The escaped value.
|
|
11696
|
+
* @example
|
|
11697
|
+
* ```ts
|
|
11698
|
+
* escapeAttribute('He said "hi" & left'); // "He said "hi" & left"
|
|
11699
|
+
* ```
|
|
11700
|
+
*/
|
|
11701
|
+
function escapeAttribute(value) {
|
|
11702
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
11703
|
+
}
|
|
11704
|
+
/**
|
|
11705
|
+
* Validate an embed `src` URL: only `https:`/`http:` absolute URLs and
|
|
11706
|
+
* root-relative paths are embeddable — anything else (`javascript:`, `data:`,
|
|
11707
|
+
* scheme-relative, …) fails the build.
|
|
11708
|
+
*
|
|
11709
|
+
* @param src - The raw `src` attribute value.
|
|
11710
|
+
* @returns `true` when the URL is embeddable.
|
|
11711
|
+
* @example
|
|
11712
|
+
* ```ts
|
|
11713
|
+
* isEmbeddableUrl("https://game.example.com/"); // true
|
|
11714
|
+
* isEmbeddableUrl("javascript:alert(1)"); // false
|
|
11715
|
+
* ```
|
|
11716
|
+
*/
|
|
11717
|
+
function isEmbeddableUrl(src) {
|
|
11718
|
+
if (src.startsWith("/") && !src.startsWith("//")) return true;
|
|
11719
|
+
return /^https?:\/\//i.test(src);
|
|
11720
|
+
}
|
|
11721
|
+
/**
|
|
11722
|
+
* Build the static facade HTML for one embed: the `<figure>` carrying the
|
|
11723
|
+
* target in data attributes plus the activation `<button>` (the button label
|
|
11724
|
+
* is the embed's title; visual chrome is consumer CSS).
|
|
11725
|
+
*
|
|
11726
|
+
* @param src - The validated embed URL.
|
|
11727
|
+
* @param title - The human-readable embed title (button label, iframe title).
|
|
11728
|
+
* @returns The facade HTML string.
|
|
11729
|
+
* @example
|
|
11730
|
+
* ```ts
|
|
11731
|
+
* embedFacadeHtml("https://game.example.com/", "My Game");
|
|
11732
|
+
* ```
|
|
11733
|
+
*/
|
|
11734
|
+
function embedFacadeHtml(src, title) {
|
|
11735
|
+
const safeSource = escapeAttribute(src);
|
|
11736
|
+
const safeTitle = escapeAttribute(title);
|
|
11737
|
+
return `<figure class="${EMBED_FIGURE_CLASS}" data-component="${EMBED_COMPONENT_NAME}" data-embed-src="${safeSource}" data-embed-title="${safeTitle}"><button type="button" class="${EMBED_BUTTON_CLASS}" aria-label="Load embed: ${safeTitle}"><span class="${EMBED_TITLE_CLASS}">${safeTitle}</span></button></figure>`;
|
|
11738
|
+
}
|
|
11739
|
+
/**
|
|
11740
|
+
* Mdast transformer rewriting every `::embed` leaf directive to its facade
|
|
11741
|
+
* HTML node. A directive missing `src`/`title`, or carrying a non-embeddable
|
|
11742
|
+
* URL, fails the build with the offending value quoted.
|
|
11743
|
+
*
|
|
11744
|
+
* @param tree - The mdast tree to mutate.
|
|
11745
|
+
* @throws {Error} When an `::embed` directive is missing `src` or `title`, or
|
|
11746
|
+
* its `src` is not an embeddable URL.
|
|
11747
|
+
* @example
|
|
11748
|
+
* ```ts
|
|
11749
|
+
* embedTransform(tree);
|
|
11750
|
+
* ```
|
|
11751
|
+
*/
|
|
11752
|
+
function embedTransform(tree) {
|
|
11753
|
+
(0, unist_util_visit.visit)(tree, (node, index, parent) => {
|
|
11754
|
+
if (!isEmbedDirective(node)) return;
|
|
11755
|
+
if (parent === void 0 || index === void 0) return;
|
|
11756
|
+
const src = node.attributes?.src ?? "";
|
|
11757
|
+
const title = node.attributes?.title ?? "";
|
|
11758
|
+
if (src === "" || title === "") throw new Error("[web] content: `::embed` requires both `src` and `title` attributes, e.g. ::embed{src=\"https://…\" title=\"…\"}.");
|
|
11759
|
+
if (!isEmbeddableUrl(src)) throw new Error(`[web] content: \`::embed\` src must be an http(s) URL or a root-relative path (got "${src}").`);
|
|
11760
|
+
const html = {
|
|
11761
|
+
type: "html",
|
|
11762
|
+
value: embedFacadeHtml(src, title)
|
|
11763
|
+
};
|
|
11764
|
+
parent.children[index] = html;
|
|
11765
|
+
});
|
|
11766
|
+
}
|
|
11767
|
+
/**
|
|
11768
|
+
* Remark transform: rewrites `::embed{src="…" title="…"}` leaf directives into
|
|
11769
|
+
* static click-to-activate facades (no iframe until the reader clicks — see
|
|
11770
|
+
* the file header). Opt-in via the provider's `embed` option; requires
|
|
11771
|
+
* `trustedContent: true` because the facade is raw HTML the sanitize pass
|
|
11772
|
+
* would strip.
|
|
11773
|
+
*
|
|
11774
|
+
* @returns An mdast tree transformer.
|
|
11775
|
+
* @example
|
|
11776
|
+
* ```ts
|
|
11777
|
+
* unified().use(embedPlugin);
|
|
11778
|
+
* ```
|
|
11779
|
+
*/
|
|
11780
|
+
function embedPlugin() {
|
|
11781
|
+
return embedTransform;
|
|
11782
|
+
}
|
|
11783
|
+
//#endregion
|
|
11784
|
+
//#region src/plugins/content/pipeline/mermaid.ts
|
|
11785
|
+
/** CSS class on the `<figure>` wrapper around each rendered diagram. */
|
|
11786
|
+
const MERMAID_FIGURE_CLASS = "mermaid-diagram";
|
|
11787
|
+
/**
|
|
11788
|
+
* Cached renderer promise — `createMermaidRenderer()` is called ONCE per process
|
|
11789
|
+
* and shared by every document (the underlying headless browser is expensive).
|
|
11790
|
+
*/
|
|
11791
|
+
let cachedRendererPromise;
|
|
11792
|
+
/**
|
|
11793
|
+
* Lazily import `mermaid-isomorphic` and create its batched renderer. The import
|
|
11794
|
+
* happens HERE (never at module load) so the optional dependency is only touched
|
|
11795
|
+
* when a document actually contains a mermaid fence. A failed import is wrapped
|
|
11796
|
+
* in an actionable error naming the missing package.
|
|
11797
|
+
*
|
|
11798
|
+
* @param importModule - Import thunk for the package; injectable so tests can
|
|
11799
|
+
* exercise both outcomes without the real dependency. Defaults to the real
|
|
11800
|
+
* dynamic `import("mermaid-isomorphic")`.
|
|
11801
|
+
* @returns The batched mermaid renderer.
|
|
11802
|
+
* @throws {Error} When the optional dependency cannot be loaded.
|
|
11803
|
+
* @example
|
|
11804
|
+
* ```ts
|
|
11805
|
+
* const renderer = await loadMermaidRenderer();
|
|
11806
|
+
* const results = await renderer(["graph TD; A-->B"]);
|
|
11807
|
+
* ```
|
|
11808
|
+
*/
|
|
11809
|
+
async function loadMermaidRenderer(importModule = () => import("mermaid-isomorphic")) {
|
|
11810
|
+
let moduleExports;
|
|
11811
|
+
try {
|
|
11812
|
+
moduleExports = await importModule();
|
|
11813
|
+
} catch (error) {
|
|
11814
|
+
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 });
|
|
11815
|
+
}
|
|
11816
|
+
return moduleExports.createMermaidRenderer();
|
|
11817
|
+
}
|
|
11818
|
+
/**
|
|
11819
|
+
* Unwrap mermaid-isomorphic's settled results into plain SVG strings, failing
|
|
11820
|
+
* the build on the first rejected diagram. The error quotes the diagram's first
|
|
11821
|
+
* line so the author can locate the broken fence.
|
|
11822
|
+
*
|
|
11823
|
+
* @param sources - The diagram sources, in the order they were rendered.
|
|
11824
|
+
* @param results - The settled render results (one per source).
|
|
11825
|
+
* @returns One SVG string per source, in order.
|
|
11826
|
+
* @throws {Error} When any diagram failed to render.
|
|
11827
|
+
* @example
|
|
11828
|
+
* ```ts
|
|
11829
|
+
* const svgs = unwrapMermaidResults(["graph TD; A-->B"], results);
|
|
11830
|
+
* ```
|
|
11831
|
+
*/
|
|
11832
|
+
function unwrapMermaidResults(sources, results) {
|
|
11833
|
+
return results.map((result, index) => {
|
|
11834
|
+
if (result.status === "rejected") {
|
|
11835
|
+
const firstLine = (sources[index] ?? "").split("\n", 1)[0] ?? "";
|
|
11836
|
+
throw new Error(`[web] content: mermaid diagram failed to render (diagram starts with "${firstLine}"): ${String(result.reason)}`);
|
|
11837
|
+
}
|
|
11838
|
+
return result.value.svg;
|
|
11839
|
+
});
|
|
11840
|
+
}
|
|
11841
|
+
/**
|
|
11842
|
+
* The REAL render path: lazily load mermaid-isomorphic (cached once per
|
|
11843
|
+
* process), render every fence of the document in ONE batched call, and unwrap
|
|
11844
|
+
* the results. Replaced in unit tests by the `renderDiagrams` seam.
|
|
11845
|
+
*
|
|
11846
|
+
* @param sources - Every mermaid fence source of one document, in order.
|
|
11847
|
+
* @param mermaidConfig - Optional mermaid configuration forwarded to the render call.
|
|
11848
|
+
* @returns One SVG string per source, in order.
|
|
11849
|
+
* @example
|
|
11850
|
+
* ```ts
|
|
11851
|
+
* const svgs = await renderWithMermaidIsomorphic(["graph TD; A-->B"]);
|
|
11852
|
+
* ```
|
|
11853
|
+
*/
|
|
11854
|
+
async function renderWithMermaidIsomorphic(sources, mermaidConfig) {
|
|
11855
|
+
cachedRendererPromise ??= loadMermaidRenderer();
|
|
11856
|
+
return unwrapMermaidResults(sources, await (await cachedRendererPromise)([...sources], mermaidConfig ? { mermaidConfig } : void 0));
|
|
11857
|
+
}
|
|
11858
|
+
/**
|
|
11859
|
+
* Collect every fenced `mermaid` code block in the tree (with the parent/index
|
|
11860
|
+
* needed to replace it later), in document order.
|
|
11861
|
+
*
|
|
11862
|
+
* @param tree - The mdast tree to scan.
|
|
11863
|
+
* @returns The fence sites found.
|
|
11864
|
+
* @example
|
|
11865
|
+
* ```ts
|
|
11866
|
+
* const fences = collectMermaidFences(tree);
|
|
11867
|
+
* ```
|
|
11868
|
+
*/
|
|
11869
|
+
function collectMermaidFences(tree) {
|
|
11870
|
+
const fences = [];
|
|
11871
|
+
(0, unist_util_visit.visit)(tree, "code", (node, index, parent) => {
|
|
11872
|
+
if (node.lang !== "mermaid") return;
|
|
11873
|
+
if (parent === void 0 || index === void 0) return;
|
|
11874
|
+
fences.push({
|
|
11875
|
+
node,
|
|
11876
|
+
parent,
|
|
11877
|
+
index
|
|
11878
|
+
});
|
|
11879
|
+
});
|
|
11880
|
+
return fences;
|
|
11881
|
+
}
|
|
11882
|
+
/**
|
|
11883
|
+
* Normalize the provider's `mermaid` config value (`boolean | options`) to a
|
|
11884
|
+
* plain {@link MermaidDiagramOptions} object for the transform factory.
|
|
11885
|
+
*
|
|
11886
|
+
* @param mermaid - The raw `FileSystemContentOptions.mermaid` value (truthy).
|
|
11887
|
+
* @returns The options object (`{}` for the bare `true` form).
|
|
11888
|
+
* @example
|
|
11889
|
+
* ```ts
|
|
11890
|
+
* normalizeMermaidOptions(true); // {}
|
|
11891
|
+
* normalizeMermaidOptions({ mermaidConfig: { theme: "dark" } });
|
|
11892
|
+
* ```
|
|
11893
|
+
*/
|
|
11894
|
+
function normalizeMermaidOptions(mermaid) {
|
|
11895
|
+
return typeof mermaid === "boolean" ? {} : mermaid;
|
|
11896
|
+
}
|
|
11897
|
+
/**
|
|
11898
|
+
* Remark transform factory: replaces every fenced `mermaid` code block with a
|
|
11899
|
+
* `<figure class="mermaid-diagram">` raw-HTML node carrying the diagram as
|
|
11900
|
+
* static inline SVG, rendered at build time (zero client-side JS). Runs at the
|
|
11901
|
+
* mdast stage, BEFORE remark-rehype; the bridge's `allowDangerousHtml` plus the
|
|
11902
|
+
* framework's `rehype-raw` default carry the SVG into the output. Documents
|
|
11903
|
+
* without a mermaid fence return immediately — `mermaid-isomorphic` is never
|
|
11904
|
+
* imported on that path. A diagram that fails to render fails the build with
|
|
11905
|
+
* its first line quoted.
|
|
11906
|
+
*
|
|
11907
|
+
* @param options - Mermaid options: `mermaidConfig` pass-through + the
|
|
11908
|
+
* test-only `renderDiagrams` seam.
|
|
11909
|
+
* @returns An async mdast tree transformer.
|
|
11910
|
+
* @example
|
|
11911
|
+
* ```ts
|
|
11912
|
+
* unified().use(remarkMermaidDiagrams, { mermaidConfig: { theme: "dark" } });
|
|
11913
|
+
* ```
|
|
11914
|
+
*/
|
|
11915
|
+
function remarkMermaidDiagrams(options = {}) {
|
|
11916
|
+
return async (tree) => {
|
|
11917
|
+
const fences = collectMermaidFences(tree);
|
|
11918
|
+
if (fences.length === 0) return;
|
|
11919
|
+
const sources = fences.map((fence) => fence.node.value);
|
|
11920
|
+
const svgs = await (options.renderDiagrams ?? renderWithMermaidIsomorphic)(sources, options.mermaidConfig);
|
|
11921
|
+
if (svgs.length !== sources.length) throw new Error(`[web] content: mermaid renderer returned ${svgs.length} result(s) for ${sources.length} diagram(s).`);
|
|
11922
|
+
for (const [position, fence] of fences.entries()) {
|
|
11923
|
+
const html = {
|
|
11924
|
+
type: "html",
|
|
11925
|
+
value: `<figure class="${MERMAID_FIGURE_CLASS}">${svgs[position] ?? ""}</figure>`
|
|
11926
|
+
};
|
|
11927
|
+
fence.parent.children[fence.index] = html;
|
|
11928
|
+
}
|
|
11929
|
+
};
|
|
11930
|
+
}
|
|
11931
|
+
//#endregion
|
|
11572
11932
|
//#region src/plugins/content/pipeline/plugins.ts
|
|
11573
11933
|
/**
|
|
11574
11934
|
* Type guard for remark-directive nodes (container/leaf/text).
|
|
@@ -11704,25 +12064,35 @@ function sectionDividerPlugin() {
|
|
|
11704
12064
|
}
|
|
11705
12065
|
/**
|
|
11706
12066
|
* The hardcoded framework default remark (Markdown-AST) plugins, in order:
|
|
11707
|
-
* parse, frontmatter, gfm, directive, pull-quote,
|
|
11708
|
-
* (`remark-rehype` with
|
|
11709
|
-
*
|
|
11710
|
-
*
|
|
12067
|
+
* parse, frontmatter, gfm, directive, pull-quote, the OPT-IN embed + mermaid
|
|
12068
|
+
* transforms, then the mdast→hast bridge (`remark-rehype` with
|
|
12069
|
+
* `allowDangerousHtml`). Pull-quote, embed and mermaid run on the mdast before
|
|
12070
|
+
* the bridge — pull-quote so the directive carries its `hName`/`hProperties`,
|
|
12071
|
+
* embed so the directive is replaced with its raw facade HTML, mermaid so the
|
|
12072
|
+
* fence is replaced with raw SVG HTML before Shiki could ever claim the code
|
|
12073
|
+
* block.
|
|
12074
|
+
*
|
|
12075
|
+
* @param config - Optional provider configuration; only the opt-in flags are
|
|
12076
|
+
* read here: `mermaid` and `embed` (each truthy value enables its transform at
|
|
12077
|
+
* a fixed mdast position).
|
|
11711
12078
|
* @returns The ordered default remark pluggables.
|
|
11712
12079
|
* @example
|
|
11713
12080
|
* ```ts
|
|
11714
12081
|
* const remark = defaultRemarkPlugins();
|
|
11715
12082
|
* ```
|
|
11716
12083
|
*/
|
|
11717
|
-
function defaultRemarkPlugins() {
|
|
11718
|
-
|
|
12084
|
+
function defaultRemarkPlugins(config) {
|
|
12085
|
+
const plugins = [
|
|
11719
12086
|
remark_parse.default,
|
|
11720
12087
|
remark_frontmatter.default,
|
|
11721
12088
|
remark_gfm.default,
|
|
11722
12089
|
remark_directive.default,
|
|
11723
|
-
pullQuotePlugin
|
|
11724
|
-
[remark_rehype.default, { allowDangerousHtml: true }]
|
|
12090
|
+
pullQuotePlugin
|
|
11725
12091
|
];
|
|
12092
|
+
if (config?.embed) plugins.push(embedPlugin);
|
|
12093
|
+
if (config?.mermaid) plugins.push([remarkMermaidDiagrams, normalizeMermaidOptions(config.mermaid)]);
|
|
12094
|
+
plugins.push([remark_rehype.default, { allowDangerousHtml: true }]);
|
|
12095
|
+
return plugins;
|
|
11726
12096
|
}
|
|
11727
12097
|
/**
|
|
11728
12098
|
* The hardcoded framework default rehype (HTML-AST) plugins, in order:
|
|
@@ -11863,14 +12233,15 @@ function applyPluggable(processor, plugin) {
|
|
|
11863
12233
|
* replacing, the defaults).
|
|
11864
12234
|
*
|
|
11865
12235
|
* @param processor - The unified processor under construction (mutated in place).
|
|
11866
|
-
* @param config - Resolved plugin configuration (provides `extraRemarkPlugins
|
|
12236
|
+
* @param config - Resolved plugin configuration (provides `extraRemarkPlugins`,
|
|
12237
|
+
* and `mermaid` which the defaults read to enable the mdast mermaid transform).
|
|
11867
12238
|
* @example
|
|
11868
12239
|
* ```ts
|
|
11869
12240
|
* applyRemarkPlugins(processor, config);
|
|
11870
12241
|
* ```
|
|
11871
12242
|
*/
|
|
11872
12243
|
function applyRemarkPlugins(processor, config) {
|
|
11873
|
-
for (const plugin of defaultRemarkPlugins()) applyPluggable(processor, plugin);
|
|
12244
|
+
for (const plugin of defaultRemarkPlugins(config)) applyPluggable(processor, plugin);
|
|
11874
12245
|
for (const plugin of config.extraRemarkPlugins ?? []) applyPluggable(processor, plugin);
|
|
11875
12246
|
}
|
|
11876
12247
|
/**
|
|
@@ -12022,6 +12393,7 @@ async function discoverSlugs(dir) {
|
|
|
12022
12393
|
* ```
|
|
12023
12394
|
*/
|
|
12024
12395
|
function fileSystemContent(options) {
|
|
12396
|
+
validateFileSystemContentOptions(options);
|
|
12025
12397
|
const state = {
|
|
12026
12398
|
processor: null,
|
|
12027
12399
|
slugs: null,
|
|
@@ -12273,6 +12645,7 @@ exports.headPlugin = headPlugin;
|
|
|
12273
12645
|
exports.hreflang = hreflang;
|
|
12274
12646
|
exports.i18nPlugin = i18nPlugin;
|
|
12275
12647
|
exports.jsonLd = jsonLd;
|
|
12648
|
+
exports.lazyEmbed = lazyEmbed;
|
|
12276
12649
|
exports.logPlugin = logPlugin;
|
|
12277
12650
|
exports.meta = meta;
|
|
12278
12651
|
exports.og = og;
|
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,24 @@ 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;
|
|
2852
|
+
/**
|
|
2853
|
+
* Lazy iframe embeds: rewrite `::embed{src="…" title="…"}` leaf directives
|
|
2854
|
+
* into static click-to-activate facades (no iframe — and none of the target's
|
|
2855
|
+
* network/JS cost — until the reader clicks). Pair with the `lazyEmbed` SPA
|
|
2856
|
+
* island, which swaps the facade for the real `<iframe loading="lazy">`.
|
|
2857
|
+
* Requires `trustedContent: true` (the facade is raw HTML the sanitize pass
|
|
2858
|
+
* would strip). Defaults to disabled.
|
|
2859
|
+
*/
|
|
2860
|
+
embed?: boolean;
|
|
2813
2861
|
};
|
|
2814
2862
|
/**
|
|
2815
2863
|
* Internal mutable state of the filesystem provider: the lazy unified processor and
|
|
@@ -3447,6 +3495,13 @@ declare const sitePlugin: import("@moku-labs/core").PluginInstance<"site", Confi
|
|
|
3447
3495
|
*/
|
|
3448
3496
|
declare function createComponent(name: string, hooks: ComponentHooks): ComponentDef;
|
|
3449
3497
|
//#endregion
|
|
3498
|
+
//#region src/plugins/spa/lazy-embed.d.ts
|
|
3499
|
+
/**
|
|
3500
|
+
* Lazy-embed island: facade button click → real `<iframe loading="lazy">`.
|
|
3501
|
+
* The companion of the content pipeline's `::embed` directive.
|
|
3502
|
+
*/
|
|
3503
|
+
declare const lazyEmbed: ComponentDef;
|
|
3504
|
+
//#endregion
|
|
3450
3505
|
//#region src/plugins/spa/index.d.ts
|
|
3451
3506
|
/**
|
|
3452
3507
|
* SPA plugin — progressive client-side navigation layered over the static site:
|
|
@@ -3648,4 +3703,4 @@ declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs
|
|
|
3648
3703
|
*/
|
|
3649
3704
|
declare const createPlugin: import("@moku-labs/core").BoundCreatePluginFunction<Config$8, Events, import("@moku-labs/core").CoreApisFromTuple<[import("@moku-labs/core").CorePluginInstance<"log", LogConfig, LogState, LogApi>, import("@moku-labs/core").CorePluginInstance<"env", EnvConfig, EnvState, EnvApi>]>>;
|
|
3650
3705
|
//#endregion
|
|
3651
|
-
export { types_d_exports as Build, types_d_exports$1 as Cli, types_d_exports$2 as Content, types_d_exports$3 as Data, types_d_exports$4 as Deploy, types_d_exports$5 as Env, types_d_exports$6 as Head, types_d_exports$7 as Log, types_d_exports$8 as Router, types_d_exports$9 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createComponent, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
|
|
3706
|
+
export { types_d_exports as Build, types_d_exports$1 as Cli, types_d_exports$2 as Content, types_d_exports$3 as Data, types_d_exports$4 as Deploy, types_d_exports$5 as Env, types_d_exports$6 as Head, types_d_exports$7 as Log, types_d_exports$8 as Router, types_d_exports$9 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createComponent, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
|
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,24 @@ 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;
|
|
2852
|
+
/**
|
|
2853
|
+
* Lazy iframe embeds: rewrite `::embed{src="…" title="…"}` leaf directives
|
|
2854
|
+
* into static click-to-activate facades (no iframe — and none of the target's
|
|
2855
|
+
* network/JS cost — until the reader clicks). Pair with the `lazyEmbed` SPA
|
|
2856
|
+
* island, which swaps the facade for the real `<iframe loading="lazy">`.
|
|
2857
|
+
* Requires `trustedContent: true` (the facade is raw HTML the sanitize pass
|
|
2858
|
+
* would strip). Defaults to disabled.
|
|
2859
|
+
*/
|
|
2860
|
+
embed?: boolean;
|
|
2813
2861
|
};
|
|
2814
2862
|
/**
|
|
2815
2863
|
* Internal mutable state of the filesystem provider: the lazy unified processor and
|
|
@@ -3447,6 +3495,13 @@ declare const sitePlugin: import("@moku-labs/core").PluginInstance<"site", Confi
|
|
|
3447
3495
|
*/
|
|
3448
3496
|
declare function createComponent(name: string, hooks: ComponentHooks): ComponentDef;
|
|
3449
3497
|
//#endregion
|
|
3498
|
+
//#region src/plugins/spa/lazy-embed.d.ts
|
|
3499
|
+
/**
|
|
3500
|
+
* Lazy-embed island: facade button click → real `<iframe loading="lazy">`.
|
|
3501
|
+
* The companion of the content pipeline's `::embed` directive.
|
|
3502
|
+
*/
|
|
3503
|
+
declare const lazyEmbed: ComponentDef;
|
|
3504
|
+
//#endregion
|
|
3450
3505
|
//#region src/plugins/spa/index.d.ts
|
|
3451
3506
|
/**
|
|
3452
3507
|
* SPA plugin — progressive client-side navigation layered over the static site:
|
|
@@ -3648,4 +3703,4 @@ declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs
|
|
|
3648
3703
|
*/
|
|
3649
3704
|
declare const createPlugin: import("@moku-labs/core").BoundCreatePluginFunction<Config$8, Events, import("@moku-labs/core").CoreApisFromTuple<[import("@moku-labs/core").CorePluginInstance<"log", LogConfig, LogState, LogApi>, import("@moku-labs/core").CorePluginInstance<"env", EnvConfig, EnvState, EnvApi>]>>;
|
|
3650
3705
|
//#endregion
|
|
3651
|
-
export { types_d_exports as Build, types_d_exports$1 as Cli, types_d_exports$2 as Content, types_d_exports$3 as Data, types_d_exports$4 as Deploy, types_d_exports$5 as Env, types_d_exports$6 as Head, types_d_exports$7 as Log, types_d_exports$8 as Router, types_d_exports$9 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createComponent, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
|
|
3706
|
+
export { types_d_exports as Build, types_d_exports$1 as Cli, types_d_exports$2 as Content, types_d_exports$3 as Data, types_d_exports$4 as Deploy, types_d_exports$5 as Env, types_d_exports$6 as Head, types_d_exports$7 as Log, types_d_exports$8 as Router, types_d_exports$9 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createComponent, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
|
package/dist/index.mjs
CHANGED
|
@@ -1567,6 +1567,25 @@ 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` or `embed` is enabled without
|
|
1573
|
+
* `trustedContent: true`: both emit raw HTML (inline SVG / the embed facade),
|
|
1574
|
+
* which the sanitize pass (the untrusted-content XSS boundary) would strip — so
|
|
1575
|
+
* the combination can never work. Errors use the `[web]` prefix.
|
|
1576
|
+
*
|
|
1577
|
+
* @param options - The provider options to validate.
|
|
1578
|
+
* @throws {Error} If `mermaid` or `embed` is enabled while `trustedContent` is
|
|
1579
|
+
* not `true`.
|
|
1580
|
+
* @example
|
|
1581
|
+
* ```ts
|
|
1582
|
+
* validateFileSystemContentOptions({ contentDir: "./content", trustedContent: true, mermaid: true });
|
|
1583
|
+
* ```
|
|
1584
|
+
*/
|
|
1585
|
+
function validateFileSystemContentOptions(options) {
|
|
1586
|
+
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.");
|
|
1587
|
+
if (Boolean(options.embed) && options.trustedContent !== true) throw new Error("[web] content: `embed` requires `trustedContent: true`.\n Embed directives render to a raw-HTML facade, which the sanitize pass would strip\n (and embedding third-party iframes is never safe for untrusted Markdown).\n Set trustedContent: true ONLY for fully author-controlled Markdown.");
|
|
1588
|
+
}
|
|
1570
1589
|
//#endregion
|
|
1571
1590
|
//#region src/plugins/content/index.ts
|
|
1572
1591
|
/**
|
|
@@ -11282,6 +11301,84 @@ function disposeSpa() {
|
|
|
11282
11301
|
}
|
|
11283
11302
|
}
|
|
11284
11303
|
//#endregion
|
|
11304
|
+
//#region src/plugins/spa/lazy-embed.ts
|
|
11305
|
+
/**
|
|
11306
|
+
* @file `lazyEmbed` island — activates the static embed facades emitted by the
|
|
11307
|
+
* content pipeline's `::embed` directive (pipeline/embed.ts). Mounts on every
|
|
11308
|
+
* `[data-component="lazy-embed"]` figure; a click on the facade's button swaps
|
|
11309
|
+
* it for the real `<iframe loading="lazy">`. Until that click the embedded
|
|
11310
|
+
* document costs the page nothing — no request, no third-party JS, no
|
|
11311
|
+
* scroll-jacking. Register it in `pluginConfigs.spa.components`; all visual
|
|
11312
|
+
* chrome (`.lazy-embed*` classes) is consumer CSS.
|
|
11313
|
+
*/
|
|
11314
|
+
/** CSS class on the injected `<iframe>` (consumer CSS sizes it). */
|
|
11315
|
+
const EMBED_FRAME_CLASS = "lazy-embed-frame";
|
|
11316
|
+
/**
|
|
11317
|
+
* Swap a facade `<figure>`'s content for its real `<iframe>`. The iframe
|
|
11318
|
+
* carries `loading="lazy"` plus fullscreen permission, and the figure gains
|
|
11319
|
+
* `data-embed-active` so consumer CSS can restyle the activated state.
|
|
11320
|
+
*
|
|
11321
|
+
* @param figure - The facade element carrying `data-embed-src`/`data-embed-title`.
|
|
11322
|
+
* @example
|
|
11323
|
+
* ```ts
|
|
11324
|
+
* activateEmbed(figure);
|
|
11325
|
+
* ```
|
|
11326
|
+
*/
|
|
11327
|
+
function activateEmbed(figure) {
|
|
11328
|
+
const src = figure.dataset.embedSrc;
|
|
11329
|
+
if (!src) return;
|
|
11330
|
+
const iframe = document.createElement("iframe");
|
|
11331
|
+
iframe.src = src;
|
|
11332
|
+
iframe.title = figure.dataset.embedTitle ?? "";
|
|
11333
|
+
iframe.className = EMBED_FRAME_CLASS;
|
|
11334
|
+
iframe.setAttribute("loading", "lazy");
|
|
11335
|
+
iframe.allow = "fullscreen; autoplay; gamepad";
|
|
11336
|
+
iframe.allowFullscreen = true;
|
|
11337
|
+
figure.replaceChildren(iframe);
|
|
11338
|
+
figure.dataset.embedActive = "";
|
|
11339
|
+
}
|
|
11340
|
+
/**
|
|
11341
|
+
* Shared click handler (module-level so mount/unmount detach the same
|
|
11342
|
+
* reference): a click on the facade's button activates the embed.
|
|
11343
|
+
*
|
|
11344
|
+
* @param event - The click event from the facade figure.
|
|
11345
|
+
* @example
|
|
11346
|
+
* ```ts
|
|
11347
|
+
* element.addEventListener("click", onFacadeClick);
|
|
11348
|
+
* ```
|
|
11349
|
+
*/
|
|
11350
|
+
function onFacadeClick(event) {
|
|
11351
|
+
if (!event.target.closest("button.lazy-embed-button")) return;
|
|
11352
|
+
const figure = event.currentTarget;
|
|
11353
|
+
if (figure instanceof HTMLElement) activateEmbed(figure);
|
|
11354
|
+
}
|
|
11355
|
+
/**
|
|
11356
|
+
* Lazy-embed island: facade button click → real `<iframe loading="lazy">`.
|
|
11357
|
+
* The companion of the content pipeline's `::embed` directive.
|
|
11358
|
+
*/
|
|
11359
|
+
const lazyEmbed = createComponent("lazy-embed", {
|
|
11360
|
+
/**
|
|
11361
|
+
* Bind the activation click handler when a facade mounts.
|
|
11362
|
+
*
|
|
11363
|
+
* @param ctx - The island lifecycle context.
|
|
11364
|
+
* @example
|
|
11365
|
+
* onMount(ctx);
|
|
11366
|
+
*/
|
|
11367
|
+
onMount(ctx) {
|
|
11368
|
+
ctx.el.addEventListener("click", onFacadeClick);
|
|
11369
|
+
},
|
|
11370
|
+
/**
|
|
11371
|
+
* Remove the activation click handler when the facade is destroyed.
|
|
11372
|
+
*
|
|
11373
|
+
* @param ctx - The island lifecycle context.
|
|
11374
|
+
* @example
|
|
11375
|
+
* onDestroy(ctx);
|
|
11376
|
+
*/
|
|
11377
|
+
onDestroy(ctx) {
|
|
11378
|
+
ctx.el.removeEventListener("click", onFacadeClick);
|
|
11379
|
+
}
|
|
11380
|
+
});
|
|
11381
|
+
//#endregion
|
|
11285
11382
|
//#region src/plugins/spa/index.ts
|
|
11286
11383
|
/**
|
|
11287
11384
|
* @file spa — Complex Plugin (WIRING ONLY, ≤30 lines). All logic lives in the
|
|
@@ -11556,6 +11653,269 @@ function parseFrontmatter(raw, config) {
|
|
|
11556
11653
|
};
|
|
11557
11654
|
}
|
|
11558
11655
|
//#endregion
|
|
11656
|
+
//#region src/plugins/content/pipeline/embed.ts
|
|
11657
|
+
/** CSS class on the `<figure>` facade wrapping each embed. */
|
|
11658
|
+
const EMBED_FIGURE_CLASS = "lazy-embed";
|
|
11659
|
+
/** `data-component` name binding the facade to the `lazyEmbed` SPA island. */
|
|
11660
|
+
const EMBED_COMPONENT_NAME = "lazy-embed";
|
|
11661
|
+
/** CSS class on the facade's activation button. */
|
|
11662
|
+
const EMBED_BUTTON_CLASS = "lazy-embed-button";
|
|
11663
|
+
/** CSS class on the title span inside the activation button. */
|
|
11664
|
+
const EMBED_TITLE_CLASS = "lazy-embed-title";
|
|
11665
|
+
/**
|
|
11666
|
+
* Type guard for an `::embed` leaf directive.
|
|
11667
|
+
*
|
|
11668
|
+
* @param node - AST node to test.
|
|
11669
|
+
* @returns `true` when the node is an `::embed` leaf directive.
|
|
11670
|
+
* @example
|
|
11671
|
+
* ```ts
|
|
11672
|
+
* if (isEmbedDirective(node)) console.log(node.attributes?.src);
|
|
11673
|
+
* ```
|
|
11674
|
+
*/
|
|
11675
|
+
function isEmbedDirective(node) {
|
|
11676
|
+
return node.type === "leafDirective" && node.name === "embed";
|
|
11677
|
+
}
|
|
11678
|
+
/**
|
|
11679
|
+
* Escape a string for safe interpolation into a double-quoted HTML attribute.
|
|
11680
|
+
*
|
|
11681
|
+
* @param value - The raw attribute value.
|
|
11682
|
+
* @returns The escaped value.
|
|
11683
|
+
* @example
|
|
11684
|
+
* ```ts
|
|
11685
|
+
* escapeAttribute('He said "hi" & left'); // "He said "hi" & left"
|
|
11686
|
+
* ```
|
|
11687
|
+
*/
|
|
11688
|
+
function escapeAttribute(value) {
|
|
11689
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
11690
|
+
}
|
|
11691
|
+
/**
|
|
11692
|
+
* Validate an embed `src` URL: only `https:`/`http:` absolute URLs and
|
|
11693
|
+
* root-relative paths are embeddable — anything else (`javascript:`, `data:`,
|
|
11694
|
+
* scheme-relative, …) fails the build.
|
|
11695
|
+
*
|
|
11696
|
+
* @param src - The raw `src` attribute value.
|
|
11697
|
+
* @returns `true` when the URL is embeddable.
|
|
11698
|
+
* @example
|
|
11699
|
+
* ```ts
|
|
11700
|
+
* isEmbeddableUrl("https://game.example.com/"); // true
|
|
11701
|
+
* isEmbeddableUrl("javascript:alert(1)"); // false
|
|
11702
|
+
* ```
|
|
11703
|
+
*/
|
|
11704
|
+
function isEmbeddableUrl(src) {
|
|
11705
|
+
if (src.startsWith("/") && !src.startsWith("//")) return true;
|
|
11706
|
+
return /^https?:\/\//i.test(src);
|
|
11707
|
+
}
|
|
11708
|
+
/**
|
|
11709
|
+
* Build the static facade HTML for one embed: the `<figure>` carrying the
|
|
11710
|
+
* target in data attributes plus the activation `<button>` (the button label
|
|
11711
|
+
* is the embed's title; visual chrome is consumer CSS).
|
|
11712
|
+
*
|
|
11713
|
+
* @param src - The validated embed URL.
|
|
11714
|
+
* @param title - The human-readable embed title (button label, iframe title).
|
|
11715
|
+
* @returns The facade HTML string.
|
|
11716
|
+
* @example
|
|
11717
|
+
* ```ts
|
|
11718
|
+
* embedFacadeHtml("https://game.example.com/", "My Game");
|
|
11719
|
+
* ```
|
|
11720
|
+
*/
|
|
11721
|
+
function embedFacadeHtml(src, title) {
|
|
11722
|
+
const safeSource = escapeAttribute(src);
|
|
11723
|
+
const safeTitle = escapeAttribute(title);
|
|
11724
|
+
return `<figure class="${EMBED_FIGURE_CLASS}" data-component="${EMBED_COMPONENT_NAME}" data-embed-src="${safeSource}" data-embed-title="${safeTitle}"><button type="button" class="${EMBED_BUTTON_CLASS}" aria-label="Load embed: ${safeTitle}"><span class="${EMBED_TITLE_CLASS}">${safeTitle}</span></button></figure>`;
|
|
11725
|
+
}
|
|
11726
|
+
/**
|
|
11727
|
+
* Mdast transformer rewriting every `::embed` leaf directive to its facade
|
|
11728
|
+
* HTML node. A directive missing `src`/`title`, or carrying a non-embeddable
|
|
11729
|
+
* URL, fails the build with the offending value quoted.
|
|
11730
|
+
*
|
|
11731
|
+
* @param tree - The mdast tree to mutate.
|
|
11732
|
+
* @throws {Error} When an `::embed` directive is missing `src` or `title`, or
|
|
11733
|
+
* its `src` is not an embeddable URL.
|
|
11734
|
+
* @example
|
|
11735
|
+
* ```ts
|
|
11736
|
+
* embedTransform(tree);
|
|
11737
|
+
* ```
|
|
11738
|
+
*/
|
|
11739
|
+
function embedTransform(tree) {
|
|
11740
|
+
visit(tree, (node, index, parent) => {
|
|
11741
|
+
if (!isEmbedDirective(node)) return;
|
|
11742
|
+
if (parent === void 0 || index === void 0) return;
|
|
11743
|
+
const src = node.attributes?.src ?? "";
|
|
11744
|
+
const title = node.attributes?.title ?? "";
|
|
11745
|
+
if (src === "" || title === "") throw new Error("[web] content: `::embed` requires both `src` and `title` attributes, e.g. ::embed{src=\"https://…\" title=\"…\"}.");
|
|
11746
|
+
if (!isEmbeddableUrl(src)) throw new Error(`[web] content: \`::embed\` src must be an http(s) URL or a root-relative path (got "${src}").`);
|
|
11747
|
+
const html = {
|
|
11748
|
+
type: "html",
|
|
11749
|
+
value: embedFacadeHtml(src, title)
|
|
11750
|
+
};
|
|
11751
|
+
parent.children[index] = html;
|
|
11752
|
+
});
|
|
11753
|
+
}
|
|
11754
|
+
/**
|
|
11755
|
+
* Remark transform: rewrites `::embed{src="…" title="…"}` leaf directives into
|
|
11756
|
+
* static click-to-activate facades (no iframe until the reader clicks — see
|
|
11757
|
+
* the file header). Opt-in via the provider's `embed` option; requires
|
|
11758
|
+
* `trustedContent: true` because the facade is raw HTML the sanitize pass
|
|
11759
|
+
* would strip.
|
|
11760
|
+
*
|
|
11761
|
+
* @returns An mdast tree transformer.
|
|
11762
|
+
* @example
|
|
11763
|
+
* ```ts
|
|
11764
|
+
* unified().use(embedPlugin);
|
|
11765
|
+
* ```
|
|
11766
|
+
*/
|
|
11767
|
+
function embedPlugin() {
|
|
11768
|
+
return embedTransform;
|
|
11769
|
+
}
|
|
11770
|
+
//#endregion
|
|
11771
|
+
//#region src/plugins/content/pipeline/mermaid.ts
|
|
11772
|
+
/** CSS class on the `<figure>` wrapper around each rendered diagram. */
|
|
11773
|
+
const MERMAID_FIGURE_CLASS = "mermaid-diagram";
|
|
11774
|
+
/**
|
|
11775
|
+
* Cached renderer promise — `createMermaidRenderer()` is called ONCE per process
|
|
11776
|
+
* and shared by every document (the underlying headless browser is expensive).
|
|
11777
|
+
*/
|
|
11778
|
+
let cachedRendererPromise;
|
|
11779
|
+
/**
|
|
11780
|
+
* Lazily import `mermaid-isomorphic` and create its batched renderer. The import
|
|
11781
|
+
* happens HERE (never at module load) so the optional dependency is only touched
|
|
11782
|
+
* when a document actually contains a mermaid fence. A failed import is wrapped
|
|
11783
|
+
* in an actionable error naming the missing package.
|
|
11784
|
+
*
|
|
11785
|
+
* @param importModule - Import thunk for the package; injectable so tests can
|
|
11786
|
+
* exercise both outcomes without the real dependency. Defaults to the real
|
|
11787
|
+
* dynamic `import("mermaid-isomorphic")`.
|
|
11788
|
+
* @returns The batched mermaid renderer.
|
|
11789
|
+
* @throws {Error} When the optional dependency cannot be loaded.
|
|
11790
|
+
* @example
|
|
11791
|
+
* ```ts
|
|
11792
|
+
* const renderer = await loadMermaidRenderer();
|
|
11793
|
+
* const results = await renderer(["graph TD; A-->B"]);
|
|
11794
|
+
* ```
|
|
11795
|
+
*/
|
|
11796
|
+
async function loadMermaidRenderer(importModule = () => import("mermaid-isomorphic")) {
|
|
11797
|
+
let moduleExports;
|
|
11798
|
+
try {
|
|
11799
|
+
moduleExports = await importModule();
|
|
11800
|
+
} catch (error) {
|
|
11801
|
+
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 });
|
|
11802
|
+
}
|
|
11803
|
+
return moduleExports.createMermaidRenderer();
|
|
11804
|
+
}
|
|
11805
|
+
/**
|
|
11806
|
+
* Unwrap mermaid-isomorphic's settled results into plain SVG strings, failing
|
|
11807
|
+
* the build on the first rejected diagram. The error quotes the diagram's first
|
|
11808
|
+
* line so the author can locate the broken fence.
|
|
11809
|
+
*
|
|
11810
|
+
* @param sources - The diagram sources, in the order they were rendered.
|
|
11811
|
+
* @param results - The settled render results (one per source).
|
|
11812
|
+
* @returns One SVG string per source, in order.
|
|
11813
|
+
* @throws {Error} When any diagram failed to render.
|
|
11814
|
+
* @example
|
|
11815
|
+
* ```ts
|
|
11816
|
+
* const svgs = unwrapMermaidResults(["graph TD; A-->B"], results);
|
|
11817
|
+
* ```
|
|
11818
|
+
*/
|
|
11819
|
+
function unwrapMermaidResults(sources, results) {
|
|
11820
|
+
return results.map((result, index) => {
|
|
11821
|
+
if (result.status === "rejected") {
|
|
11822
|
+
const firstLine = (sources[index] ?? "").split("\n", 1)[0] ?? "";
|
|
11823
|
+
throw new Error(`[web] content: mermaid diagram failed to render (diagram starts with "${firstLine}"): ${String(result.reason)}`);
|
|
11824
|
+
}
|
|
11825
|
+
return result.value.svg;
|
|
11826
|
+
});
|
|
11827
|
+
}
|
|
11828
|
+
/**
|
|
11829
|
+
* The REAL render path: lazily load mermaid-isomorphic (cached once per
|
|
11830
|
+
* process), render every fence of the document in ONE batched call, and unwrap
|
|
11831
|
+
* the results. Replaced in unit tests by the `renderDiagrams` seam.
|
|
11832
|
+
*
|
|
11833
|
+
* @param sources - Every mermaid fence source of one document, in order.
|
|
11834
|
+
* @param mermaidConfig - Optional mermaid configuration forwarded to the render call.
|
|
11835
|
+
* @returns One SVG string per source, in order.
|
|
11836
|
+
* @example
|
|
11837
|
+
* ```ts
|
|
11838
|
+
* const svgs = await renderWithMermaidIsomorphic(["graph TD; A-->B"]);
|
|
11839
|
+
* ```
|
|
11840
|
+
*/
|
|
11841
|
+
async function renderWithMermaidIsomorphic(sources, mermaidConfig) {
|
|
11842
|
+
cachedRendererPromise ??= loadMermaidRenderer();
|
|
11843
|
+
return unwrapMermaidResults(sources, await (await cachedRendererPromise)([...sources], mermaidConfig ? { mermaidConfig } : void 0));
|
|
11844
|
+
}
|
|
11845
|
+
/**
|
|
11846
|
+
* Collect every fenced `mermaid` code block in the tree (with the parent/index
|
|
11847
|
+
* needed to replace it later), in document order.
|
|
11848
|
+
*
|
|
11849
|
+
* @param tree - The mdast tree to scan.
|
|
11850
|
+
* @returns The fence sites found.
|
|
11851
|
+
* @example
|
|
11852
|
+
* ```ts
|
|
11853
|
+
* const fences = collectMermaidFences(tree);
|
|
11854
|
+
* ```
|
|
11855
|
+
*/
|
|
11856
|
+
function collectMermaidFences(tree) {
|
|
11857
|
+
const fences = [];
|
|
11858
|
+
visit(tree, "code", (node, index, parent) => {
|
|
11859
|
+
if (node.lang !== "mermaid") return;
|
|
11860
|
+
if (parent === void 0 || index === void 0) return;
|
|
11861
|
+
fences.push({
|
|
11862
|
+
node,
|
|
11863
|
+
parent,
|
|
11864
|
+
index
|
|
11865
|
+
});
|
|
11866
|
+
});
|
|
11867
|
+
return fences;
|
|
11868
|
+
}
|
|
11869
|
+
/**
|
|
11870
|
+
* Normalize the provider's `mermaid` config value (`boolean | options`) to a
|
|
11871
|
+
* plain {@link MermaidDiagramOptions} object for the transform factory.
|
|
11872
|
+
*
|
|
11873
|
+
* @param mermaid - The raw `FileSystemContentOptions.mermaid` value (truthy).
|
|
11874
|
+
* @returns The options object (`{}` for the bare `true` form).
|
|
11875
|
+
* @example
|
|
11876
|
+
* ```ts
|
|
11877
|
+
* normalizeMermaidOptions(true); // {}
|
|
11878
|
+
* normalizeMermaidOptions({ mermaidConfig: { theme: "dark" } });
|
|
11879
|
+
* ```
|
|
11880
|
+
*/
|
|
11881
|
+
function normalizeMermaidOptions(mermaid) {
|
|
11882
|
+
return typeof mermaid === "boolean" ? {} : mermaid;
|
|
11883
|
+
}
|
|
11884
|
+
/**
|
|
11885
|
+
* Remark transform factory: replaces every fenced `mermaid` code block with a
|
|
11886
|
+
* `<figure class="mermaid-diagram">` raw-HTML node carrying the diagram as
|
|
11887
|
+
* static inline SVG, rendered at build time (zero client-side JS). Runs at the
|
|
11888
|
+
* mdast stage, BEFORE remark-rehype; the bridge's `allowDangerousHtml` plus the
|
|
11889
|
+
* framework's `rehype-raw` default carry the SVG into the output. Documents
|
|
11890
|
+
* without a mermaid fence return immediately — `mermaid-isomorphic` is never
|
|
11891
|
+
* imported on that path. A diagram that fails to render fails the build with
|
|
11892
|
+
* its first line quoted.
|
|
11893
|
+
*
|
|
11894
|
+
* @param options - Mermaid options: `mermaidConfig` pass-through + the
|
|
11895
|
+
* test-only `renderDiagrams` seam.
|
|
11896
|
+
* @returns An async mdast tree transformer.
|
|
11897
|
+
* @example
|
|
11898
|
+
* ```ts
|
|
11899
|
+
* unified().use(remarkMermaidDiagrams, { mermaidConfig: { theme: "dark" } });
|
|
11900
|
+
* ```
|
|
11901
|
+
*/
|
|
11902
|
+
function remarkMermaidDiagrams(options = {}) {
|
|
11903
|
+
return async (tree) => {
|
|
11904
|
+
const fences = collectMermaidFences(tree);
|
|
11905
|
+
if (fences.length === 0) return;
|
|
11906
|
+
const sources = fences.map((fence) => fence.node.value);
|
|
11907
|
+
const svgs = await (options.renderDiagrams ?? renderWithMermaidIsomorphic)(sources, options.mermaidConfig);
|
|
11908
|
+
if (svgs.length !== sources.length) throw new Error(`[web] content: mermaid renderer returned ${svgs.length} result(s) for ${sources.length} diagram(s).`);
|
|
11909
|
+
for (const [position, fence] of fences.entries()) {
|
|
11910
|
+
const html = {
|
|
11911
|
+
type: "html",
|
|
11912
|
+
value: `<figure class="${MERMAID_FIGURE_CLASS}">${svgs[position] ?? ""}</figure>`
|
|
11913
|
+
};
|
|
11914
|
+
fence.parent.children[fence.index] = html;
|
|
11915
|
+
}
|
|
11916
|
+
};
|
|
11917
|
+
}
|
|
11918
|
+
//#endregion
|
|
11559
11919
|
//#region src/plugins/content/pipeline/plugins.ts
|
|
11560
11920
|
/**
|
|
11561
11921
|
* Type guard for remark-directive nodes (container/leaf/text).
|
|
@@ -11691,25 +12051,35 @@ function sectionDividerPlugin() {
|
|
|
11691
12051
|
}
|
|
11692
12052
|
/**
|
|
11693
12053
|
* The hardcoded framework default remark (Markdown-AST) plugins, in order:
|
|
11694
|
-
* parse, frontmatter, gfm, directive, pull-quote,
|
|
11695
|
-
* (`remark-rehype` with
|
|
11696
|
-
*
|
|
11697
|
-
*
|
|
12054
|
+
* parse, frontmatter, gfm, directive, pull-quote, the OPT-IN embed + mermaid
|
|
12055
|
+
* transforms, then the mdast→hast bridge (`remark-rehype` with
|
|
12056
|
+
* `allowDangerousHtml`). Pull-quote, embed and mermaid run on the mdast before
|
|
12057
|
+
* the bridge — pull-quote so the directive carries its `hName`/`hProperties`,
|
|
12058
|
+
* embed so the directive is replaced with its raw facade HTML, mermaid so the
|
|
12059
|
+
* fence is replaced with raw SVG HTML before Shiki could ever claim the code
|
|
12060
|
+
* block.
|
|
12061
|
+
*
|
|
12062
|
+
* @param config - Optional provider configuration; only the opt-in flags are
|
|
12063
|
+
* read here: `mermaid` and `embed` (each truthy value enables its transform at
|
|
12064
|
+
* a fixed mdast position).
|
|
11698
12065
|
* @returns The ordered default remark pluggables.
|
|
11699
12066
|
* @example
|
|
11700
12067
|
* ```ts
|
|
11701
12068
|
* const remark = defaultRemarkPlugins();
|
|
11702
12069
|
* ```
|
|
11703
12070
|
*/
|
|
11704
|
-
function defaultRemarkPlugins() {
|
|
11705
|
-
|
|
12071
|
+
function defaultRemarkPlugins(config) {
|
|
12072
|
+
const plugins = [
|
|
11706
12073
|
remarkParse,
|
|
11707
12074
|
remarkFrontmatter,
|
|
11708
12075
|
remarkGfm,
|
|
11709
12076
|
remarkDirective,
|
|
11710
|
-
pullQuotePlugin
|
|
11711
|
-
[remarkRehype, { allowDangerousHtml: true }]
|
|
12077
|
+
pullQuotePlugin
|
|
11712
12078
|
];
|
|
12079
|
+
if (config?.embed) plugins.push(embedPlugin);
|
|
12080
|
+
if (config?.mermaid) plugins.push([remarkMermaidDiagrams, normalizeMermaidOptions(config.mermaid)]);
|
|
12081
|
+
plugins.push([remarkRehype, { allowDangerousHtml: true }]);
|
|
12082
|
+
return plugins;
|
|
11713
12083
|
}
|
|
11714
12084
|
/**
|
|
11715
12085
|
* The hardcoded framework default rehype (HTML-AST) plugins, in order:
|
|
@@ -11850,14 +12220,15 @@ function applyPluggable(processor, plugin) {
|
|
|
11850
12220
|
* replacing, the defaults).
|
|
11851
12221
|
*
|
|
11852
12222
|
* @param processor - The unified processor under construction (mutated in place).
|
|
11853
|
-
* @param config - Resolved plugin configuration (provides `extraRemarkPlugins
|
|
12223
|
+
* @param config - Resolved plugin configuration (provides `extraRemarkPlugins`,
|
|
12224
|
+
* and `mermaid` which the defaults read to enable the mdast mermaid transform).
|
|
11854
12225
|
* @example
|
|
11855
12226
|
* ```ts
|
|
11856
12227
|
* applyRemarkPlugins(processor, config);
|
|
11857
12228
|
* ```
|
|
11858
12229
|
*/
|
|
11859
12230
|
function applyRemarkPlugins(processor, config) {
|
|
11860
|
-
for (const plugin of defaultRemarkPlugins()) applyPluggable(processor, plugin);
|
|
12231
|
+
for (const plugin of defaultRemarkPlugins(config)) applyPluggable(processor, plugin);
|
|
11861
12232
|
for (const plugin of config.extraRemarkPlugins ?? []) applyPluggable(processor, plugin);
|
|
11862
12233
|
}
|
|
11863
12234
|
/**
|
|
@@ -12009,6 +12380,7 @@ async function discoverSlugs(dir) {
|
|
|
12009
12380
|
* ```
|
|
12010
12381
|
*/
|
|
12011
12382
|
function fileSystemContent(options) {
|
|
12383
|
+
validateFileSystemContentOptions(options);
|
|
12012
12384
|
const state = {
|
|
12013
12385
|
processor: null,
|
|
12014
12386
|
slugs: null,
|
|
@@ -12178,4 +12550,4 @@ const createApp = core.createApp;
|
|
|
12178
12550
|
*/
|
|
12179
12551
|
const createPlugin = core.createPlugin;
|
|
12180
12552
|
//#endregion
|
|
12181
|
-
export { types_exports as Build, types_exports$1 as Cli, types_exports$2 as Content, types_exports$3 as Data, types_exports$4 as Deploy, types_exports$5 as Env, types_exports$6 as Head, types_exports$7 as Log, types_exports$8 as Router, types_exports$9 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createComponent, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
|
|
12553
|
+
export { types_exports as Build, types_exports$1 as Cli, types_exports$2 as Content, types_exports$3 as Data, types_exports$4 as Deploy, types_exports$5 as Env, types_exports$6 as Head, types_exports$7 as Log, types_exports$8 as Router, types_exports$9 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createComponent, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
|
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.
|
|
128
|
+
"version": "1.10.0"
|
|
122
129
|
}
|