@moku-labs/web 1.9.0 → 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.
@@ -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:
@@ -2098,6 +2105,15 @@ type FileSystemContentOptions = {
2098
2105
  * (plus playwright with an installed browser). Defaults to disabled.
2099
2106
  */
2100
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;
2101
2117
  };
2102
2118
  /**
2103
2119
  * Internal mutable state of the filesystem provider: the lazy unified processor and
@@ -2400,4 +2416,4 @@ declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs
2400
2416
  */
2401
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>]>>;
2402
2418
  //#endregion
2403
- 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
@@ -1582,13 +1582,14 @@ function validateContentConfig(config) {
1582
1582
  }
1583
1583
  /**
1584
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.
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
1589
  *
1590
1590
  * @param options - The provider options to validate.
1591
- * @throws {Error} If `mermaid` is enabled while `trustedContent` is not `true`.
1591
+ * @throws {Error} If `mermaid` or `embed` is enabled while `trustedContent` is
1592
+ * not `true`.
1592
1593
  * @example
1593
1594
  * ```ts
1594
1595
  * validateFileSystemContentOptions({ contentDir: "./content", trustedContent: true, mermaid: true });
@@ -1596,6 +1597,7 @@ function validateContentConfig(config) {
1596
1597
  */
1597
1598
  function validateFileSystemContentOptions(options) {
1598
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.");
1599
1601
  }
1600
1602
  //#endregion
1601
1603
  //#region src/plugins/content/index.ts
@@ -11312,6 +11314,84 @@ function disposeSpa() {
11312
11314
  }
11313
11315
  }
11314
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
11315
11395
  //#region src/plugins/spa/index.ts
11316
11396
  /**
11317
11397
  * @file spa — Complex Plugin (WIRING ONLY, ≤30 lines). All logic lives in the
@@ -11586,6 +11666,121 @@ function parseFrontmatter(raw, config) {
11586
11666
  };
11587
11667
  }
11588
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 &quot;hi&quot; &amp; left"
11699
+ * ```
11700
+ */
11701
+ function escapeAttribute(value) {
11702
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
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
11589
11784
  //#region src/plugins/content/pipeline/mermaid.ts
11590
11785
  /** CSS class on the `<figure>` wrapper around each rendered diagram. */
11591
11786
  const MERMAID_FIGURE_CLASS = "mermaid-diagram";
@@ -11869,14 +12064,17 @@ function sectionDividerPlugin() {
11869
12064
  }
11870
12065
  /**
11871
12066
  * The hardcoded framework default remark (Markdown-AST) plugins, in order:
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).
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).
11880
12078
  * @returns The ordered default remark pluggables.
11881
12079
  * @example
11882
12080
  * ```ts
@@ -11891,6 +12089,7 @@ function defaultRemarkPlugins(config) {
11891
12089
  remark_directive.default,
11892
12090
  pullQuotePlugin
11893
12091
  ];
12092
+ if (config?.embed) plugins.push(embedPlugin);
11894
12093
  if (config?.mermaid) plugins.push([remarkMermaidDiagrams, normalizeMermaidOptions(config.mermaid)]);
11895
12094
  plugins.push([remark_rehype.default, { allowDangerousHtml: true }]);
11896
12095
  return plugins;
@@ -12446,6 +12645,7 @@ exports.headPlugin = headPlugin;
12446
12645
  exports.hreflang = hreflang;
12447
12646
  exports.i18nPlugin = i18nPlugin;
12448
12647
  exports.jsonLd = jsonLd;
12648
+ exports.lazyEmbed = lazyEmbed;
12449
12649
  exports.logPlugin = logPlugin;
12450
12650
  exports.meta = meta;
12451
12651
  exports.og = og;
package/dist/index.d.cts CHANGED
@@ -2849,6 +2849,15 @@ type FileSystemContentOptions = {
2849
2849
  * (plus playwright with an installed browser). Defaults to disabled.
2850
2850
  */
2851
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;
2852
2861
  };
2853
2862
  /**
2854
2863
  * Internal mutable state of the filesystem provider: the lazy unified processor and
@@ -3486,6 +3495,13 @@ declare const sitePlugin: import("@moku-labs/core").PluginInstance<"site", Confi
3486
3495
  */
3487
3496
  declare function createComponent(name: string, hooks: ComponentHooks): ComponentDef;
3488
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
3489
3505
  //#region src/plugins/spa/index.d.ts
3490
3506
  /**
3491
3507
  * SPA plugin — progressive client-side navigation layered over the static site:
@@ -3687,4 +3703,4 @@ declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs
3687
3703
  */
3688
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>]>>;
3689
3705
  //#endregion
3690
- 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
@@ -2849,6 +2849,15 @@ type FileSystemContentOptions = {
2849
2849
  * (plus playwright with an installed browser). Defaults to disabled.
2850
2850
  */
2851
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;
2852
2861
  };
2853
2862
  /**
2854
2863
  * Internal mutable state of the filesystem provider: the lazy unified processor and
@@ -3486,6 +3495,13 @@ declare const sitePlugin: import("@moku-labs/core").PluginInstance<"site", Confi
3486
3495
  */
3487
3496
  declare function createComponent(name: string, hooks: ComponentHooks): ComponentDef;
3488
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
3489
3505
  //#region src/plugins/spa/index.d.ts
3490
3506
  /**
3491
3507
  * SPA plugin — progressive client-side navigation layered over the static site:
@@ -3687,4 +3703,4 @@ declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs
3687
3703
  */
3688
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>]>>;
3689
3705
  //#endregion
3690
- 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
@@ -1569,13 +1569,14 @@ function validateContentConfig(config) {
1569
1569
  }
1570
1570
  /**
1571
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.
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
1576
  *
1577
1577
  * @param options - The provider options to validate.
1578
- * @throws {Error} If `mermaid` is enabled while `trustedContent` is not `true`.
1578
+ * @throws {Error} If `mermaid` or `embed` is enabled while `trustedContent` is
1579
+ * not `true`.
1579
1580
  * @example
1580
1581
  * ```ts
1581
1582
  * validateFileSystemContentOptions({ contentDir: "./content", trustedContent: true, mermaid: true });
@@ -1583,6 +1584,7 @@ function validateContentConfig(config) {
1583
1584
  */
1584
1585
  function validateFileSystemContentOptions(options) {
1585
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.");
1586
1588
  }
1587
1589
  //#endregion
1588
1590
  //#region src/plugins/content/index.ts
@@ -11299,6 +11301,84 @@ function disposeSpa() {
11299
11301
  }
11300
11302
  }
11301
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
11302
11382
  //#region src/plugins/spa/index.ts
11303
11383
  /**
11304
11384
  * @file spa — Complex Plugin (WIRING ONLY, ≤30 lines). All logic lives in the
@@ -11573,6 +11653,121 @@ function parseFrontmatter(raw, config) {
11573
11653
  };
11574
11654
  }
11575
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 &quot;hi&quot; &amp; left"
11686
+ * ```
11687
+ */
11688
+ function escapeAttribute(value) {
11689
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
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
11576
11771
  //#region src/plugins/content/pipeline/mermaid.ts
11577
11772
  /** CSS class on the `<figure>` wrapper around each rendered diagram. */
11578
11773
  const MERMAID_FIGURE_CLASS = "mermaid-diagram";
@@ -11856,14 +12051,17 @@ function sectionDividerPlugin() {
11856
12051
  }
11857
12052
  /**
11858
12053
  * The hardcoded framework default remark (Markdown-AST) plugins, in order:
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).
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).
11867
12065
  * @returns The ordered default remark pluggables.
11868
12066
  * @example
11869
12067
  * ```ts
@@ -11878,6 +12076,7 @@ function defaultRemarkPlugins(config) {
11878
12076
  remarkDirective,
11879
12077
  pullQuotePlugin
11880
12078
  ];
12079
+ if (config?.embed) plugins.push(embedPlugin);
11881
12080
  if (config?.mermaid) plugins.push([remarkMermaidDiagrams, normalizeMermaidOptions(config.mermaid)]);
11882
12081
  plugins.push([remarkRehype, { allowDangerousHtml: true }]);
11883
12082
  return plugins;
@@ -12351,4 +12550,4 @@ const createApp = core.createApp;
12351
12550
  */
12352
12551
  const createPlugin = core.createPlugin;
12353
12552
  //#endregion
12354
- 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
@@ -125,5 +125,5 @@
125
125
  "test:build-e2e": "bun test src/plugins/build/__tests__/e2e/",
126
126
  "test:coverage": "vitest run --project unit --project integration --coverage"
127
127
  },
128
- "version": "1.9.0"
128
+ "version": "1.10.0"
129
129
  }