@moku-labs/web 1.9.0 → 1.11.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/index.cjs CHANGED
@@ -36,6 +36,7 @@ remark_parse = require_convention.__toESM(remark_parse, 1);
36
36
  let remark_rehype = require("remark-rehype");
37
37
  remark_rehype = require_convention.__toESM(remark_rehype, 1);
38
38
  let unist_util_visit = require("unist-util-visit");
39
+ let preact_jsx_runtime = require("preact/jsx-runtime");
39
40
  let hast_util_sanitize = require("hast-util-sanitize");
40
41
  let reading_time = require("reading-time");
41
42
  reading_time = require_convention.__toESM(reading_time, 1);
@@ -1582,13 +1583,14 @@ function validateContentConfig(config) {
1582
1583
  }
1583
1584
  /**
1584
1585
  * 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.
1586
+ * construction). Throws when `mermaid` or `embed` is enabled without
1587
+ * `trustedContent: true`: both emit raw HTML (inline SVG / the embed facade),
1588
+ * which the sanitize pass (the untrusted-content XSS boundary) would strip — so
1589
+ * the combination can never work. Errors use the `[web]` prefix.
1589
1590
  *
1590
1591
  * @param options - The provider options to validate.
1591
- * @throws {Error} If `mermaid` is enabled while `trustedContent` is not `true`.
1592
+ * @throws {Error} If `mermaid` or `embed` is enabled while `trustedContent` is
1593
+ * not `true`.
1592
1594
  * @example
1593
1595
  * ```ts
1594
1596
  * validateFileSystemContentOptions({ contentDir: "./content", trustedContent: true, mermaid: true });
@@ -1596,6 +1598,7 @@ function validateContentConfig(config) {
1596
1598
  */
1597
1599
  function validateFileSystemContentOptions(options) {
1598
1600
  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.");
1601
+ 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
1602
  }
1600
1603
  //#endregion
1601
1604
  //#region src/plugins/content/index.ts
@@ -4051,19 +4054,24 @@ function readCachedContent(ctx) {
4051
4054
  //#endregion
4052
4055
  //#region src/plugins/build/phases/content-images.ts
4053
4056
  /**
4054
- * @file build phase — content-images. Copies each article's co-located image directory
4055
- * (`<contentDir>/<slug>/images/`) to a single shared output dir (`<outDir>/<slug>/images/`) reused by
4056
- * every locale, matching the absolute `/<slug>/images/...` URLs the content renderer emits. Gated by
4057
- * `config.images`.
4057
+ * @file build phase — content-images. Copies each article's co-located asset
4058
+ * directories (`<contentDir>/<slug>/<dir>/`, e.g. `images/` or a pre-built
4059
+ * `game/` embed bundle) to a single shared output location
4060
+ * (`<outDir>/<slug>/<dir>/`) reused by every locale, matching the absolute
4061
+ * `/<slug>/<dir>/...` URLs the content renderer emits (image src + `::embed`
4062
+ * src). Dot- and underscore-prefixed dirs are treated as private and skipped.
4063
+ * Gated by `config.images`.
4058
4064
  */
4059
- /** Conventional per-article image subdirectory name (alongside `<slug>/<locale>.md`). */
4060
- const ARTICLE_IMAGE_DIR = "images";
4061
4065
  /**
4062
- * Copy every article's co-located `images/` directory to `<outDir>/<slug>/images/`. No-op when
4063
- * `config.images` is false or the content directory does not exist.
4066
+ * Copy every article's co-located asset directories to `<outDir>/<slug>/<dir>/`.
4067
+ * Each direct subdirectory of `<contentDir>/<slug>/` rides along (the
4068
+ * conventional `images/` dir plus any other bundle, like an `::embed` game),
4069
+ * except `.`/`_`-prefixed dirs (private). The `.md` source files are never
4070
+ * copied (only directories are). No-op when `config.images` is false or the
4071
+ * content directory does not exist.
4064
4072
  *
4065
4073
  * @param ctx - Plugin context (provides `config`, `log`, `require`).
4066
- * @returns The number of directories copied (one per article that has an `images/` dir).
4074
+ * @returns The number of articles that had at least one asset directory copied.
4067
4075
  * @example
4068
4076
  * ```ts
4069
4077
  * const copied = await copyContentImages(ctx);
@@ -4082,14 +4090,20 @@ async function copyContentImages(ctx) {
4082
4090
  });
4083
4091
  return 0;
4084
4092
  }
4085
- const entries = await (0, node_fs_promises.readdir)(contentDir, { withFileTypes: true });
4093
+ const articleDirectories = await (0, node_fs_promises.readdir)(contentDir, { withFileTypes: true });
4086
4094
  let copied = 0;
4087
- for (const entry of entries) {
4088
- if (!entry.isDirectory()) continue;
4089
- const source = node_path$1.default.join(contentDir, entry.name, ARTICLE_IMAGE_DIR);
4090
- if (!(0, node_fs.existsSync)(source)) continue;
4091
- await (0, node_fs_promises.cp)(source, node_path$1.default.join(ctx.config.outDir, entry.name, ARTICLE_IMAGE_DIR), { recursive: true });
4092
- copied += 1;
4095
+ for (const article of articleDirectories) {
4096
+ if (!article.isDirectory()) continue;
4097
+ const articleDir = node_path$1.default.join(contentDir, article.name);
4098
+ const assetDirectories = await (0, node_fs_promises.readdir)(articleDir, { withFileTypes: true });
4099
+ let copiedAny = false;
4100
+ for (const asset of assetDirectories) {
4101
+ if (!asset.isDirectory()) continue;
4102
+ if (asset.name.startsWith(".") || asset.name.startsWith("_")) continue;
4103
+ await (0, node_fs_promises.cp)(node_path$1.default.join(articleDir, asset.name), node_path$1.default.join(ctx.config.outDir, article.name, asset.name), { recursive: true });
4104
+ copiedAny = true;
4105
+ }
4106
+ if (copiedAny) copied += 1;
4093
4107
  }
4094
4108
  ctx.log.debug("build:content-images", { copied });
4095
4109
  return copied;
@@ -11312,6 +11326,89 @@ function disposeSpa() {
11312
11326
  }
11313
11327
  }
11314
11328
  //#endregion
11329
+ //#region src/plugins/spa/lazy-embed.ts
11330
+ /**
11331
+ * @file `lazyEmbed` island — activates the static embed facades emitted by the
11332
+ * content pipeline's `::embed` directive (pipeline/embed.ts). Mounts on every
11333
+ * `[data-component="lazy-embed"]` figure; a click on the facade's button swaps
11334
+ * it for the real `<iframe loading="lazy">`. Until that click the embedded
11335
+ * document costs the page nothing — no request, no third-party JS, no
11336
+ * scroll-jacking. Register it in `pluginConfigs.spa.components`; all visual
11337
+ * chrome (`.lazy-embed*` classes) is consumer CSS.
11338
+ */
11339
+ /** CSS class on the injected `<iframe>` (consumer CSS sizes it). */
11340
+ const EMBED_FRAME_CLASS = "lazy-embed-frame";
11341
+ /**
11342
+ * Swap a facade `<figure>`'s content for its real `<iframe>`. The iframe
11343
+ * carries `loading="lazy"` plus fullscreen permission, and the figure gains
11344
+ * `data-embed-active` so consumer CSS can restyle the activated state.
11345
+ *
11346
+ * @param figure - The facade element carrying `data-embed-src`/`data-embed-title`.
11347
+ * @example
11348
+ * ```ts
11349
+ * activateEmbed(figure);
11350
+ * ```
11351
+ */
11352
+ function activateEmbed(figure) {
11353
+ const src = figure.dataset.embedSrc;
11354
+ if (!src) return;
11355
+ const iframe = document.createElement("iframe");
11356
+ iframe.src = src;
11357
+ iframe.title = figure.dataset.embedTitle ?? "";
11358
+ iframe.className = EMBED_FRAME_CLASS;
11359
+ iframe.setAttribute("loading", "lazy");
11360
+ iframe.allow = "fullscreen; autoplay; gamepad";
11361
+ iframe.allowFullscreen = true;
11362
+ figure.replaceChildren(iframe);
11363
+ figure.dataset.embedActive = "";
11364
+ }
11365
+ /**
11366
+ * Shared click handler (module-level so mount/unmount detach the same
11367
+ * reference): any click on the not-yet-active facade activates the embed. It
11368
+ * fires on the whole facade — not a specific button class — so a consumer's
11369
+ * custom facade markup (see content `embed.facade`) works without re-wiring;
11370
+ * the default facade's `<button>` keeps it keyboard-accessible. Once active
11371
+ * (`data-embed-active`), clicks fall through to the live iframe.
11372
+ *
11373
+ * @param event - The click event from the facade figure.
11374
+ * @example
11375
+ * ```ts
11376
+ * element.addEventListener("click", onFacadeClick);
11377
+ * ```
11378
+ */
11379
+ function onFacadeClick(event) {
11380
+ const figure = event.currentTarget;
11381
+ if (!(figure instanceof HTMLElement)) return;
11382
+ if (figure.dataset.embedActive !== void 0) return;
11383
+ activateEmbed(figure);
11384
+ }
11385
+ /**
11386
+ * Lazy-embed island: facade button click → real `<iframe loading="lazy">`.
11387
+ * The companion of the content pipeline's `::embed` directive.
11388
+ */
11389
+ const lazyEmbed = createComponent("lazy-embed", {
11390
+ /**
11391
+ * Bind the activation click handler when a facade mounts.
11392
+ *
11393
+ * @param ctx - The island lifecycle context.
11394
+ * @example
11395
+ * onMount(ctx);
11396
+ */
11397
+ onMount(ctx) {
11398
+ ctx.el.addEventListener("click", onFacadeClick);
11399
+ },
11400
+ /**
11401
+ * Remove the activation click handler when the facade is destroyed.
11402
+ *
11403
+ * @param ctx - The island lifecycle context.
11404
+ * @example
11405
+ * onDestroy(ctx);
11406
+ */
11407
+ onDestroy(ctx) {
11408
+ ctx.el.removeEventListener("click", onFacadeClick);
11409
+ }
11410
+ });
11411
+ //#endregion
11315
11412
  //#region src/plugins/spa/index.ts
11316
11413
  /**
11317
11414
  * @file spa — Complex Plugin (WIRING ONLY, ≤30 lines). All logic lives in the
@@ -11586,6 +11683,230 @@ function parseFrontmatter(raw, config) {
11586
11683
  };
11587
11684
  }
11588
11685
  //#endregion
11686
+ //#region src/plugins/content/pipeline/embed-facade.tsx
11687
+ /** CSS class on the facade's activation button. */
11688
+ const EMBED_BUTTON_CLASS = "lazy-embed-button";
11689
+ /** CSS class on the title span inside the activation button. */
11690
+ const EMBED_TITLE_CLASS = "lazy-embed-title";
11691
+ /**
11692
+ * Default `::embed` facade inner content: a single labelled `<button>` carrying
11693
+ * the embed title. The companion `lazyEmbed` island activates the embed on a
11694
+ * click anywhere in the facade, so the button is the keyboard-accessible
11695
+ * control. Provided as the default and as a composable building block for custom
11696
+ * facades.
11697
+ *
11698
+ * @param props - The embed facade props (only `title` is used by the default).
11699
+ * @returns The facade inner-content VNode.
11700
+ * @example
11701
+ * ```tsx
11702
+ * // Compose the default inside a richer custom facade:
11703
+ * const MyFacade = (p: EmbedFacadeProps) => (
11704
+ * <div class="poster"><img src={p.attributes.poster} alt="" /><EmbedFacadeButton {...p} /></div>
11705
+ * );
11706
+ * ```
11707
+ */
11708
+ function EmbedFacadeButton(props) {
11709
+ return /* @__PURE__ */ (0, preact_jsx_runtime.jsx)("button", {
11710
+ type: "button",
11711
+ class: EMBED_BUTTON_CLASS,
11712
+ "aria-label": `Load embed: ${props.title}`,
11713
+ children: /* @__PURE__ */ (0, preact_jsx_runtime.jsx)("span", {
11714
+ class: EMBED_TITLE_CLASS,
11715
+ children: props.title
11716
+ })
11717
+ });
11718
+ }
11719
+ //#endregion
11720
+ //#region src/plugins/content/pipeline/embed.ts
11721
+ /** CSS class on the `<figure>` facade wrapping each embed. */
11722
+ const EMBED_FIGURE_CLASS = "lazy-embed";
11723
+ /** `data-component` name binding the facade to the `lazyEmbed` SPA island. */
11724
+ const EMBED_COMPONENT_NAME = "lazy-embed";
11725
+ /**
11726
+ * Type guard for an `::embed` leaf directive.
11727
+ *
11728
+ * @param node - AST node to test.
11729
+ * @returns `true` when the node is an `::embed` leaf directive.
11730
+ * @example
11731
+ * ```ts
11732
+ * if (isEmbedDirective(node)) console.log(node.attributes?.src);
11733
+ * ```
11734
+ */
11735
+ function isEmbedDirective(node) {
11736
+ return node.type === "leafDirective" && node.name === "embed";
11737
+ }
11738
+ /**
11739
+ * Escape a string for safe interpolation into a double-quoted HTML attribute.
11740
+ *
11741
+ * @param value - The raw attribute value.
11742
+ * @returns The escaped value.
11743
+ * @example
11744
+ * ```ts
11745
+ * escapeAttribute('He said "hi" & left'); // "He said &quot;hi&quot; &amp; left"
11746
+ * ```
11747
+ */
11748
+ function escapeAttribute(value) {
11749
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
11750
+ }
11751
+ /**
11752
+ * Validate an embed `src`. Three forms are embeddable: an `http(s)` URL, a
11753
+ * root-relative path (`/x`), or a co-located relative path (`./x`, `../x`,
11754
+ * `x/…`) resolved later against `/<slug>/`. Everything else — protocol-relative
11755
+ * (`//host`), `javascript:`, `data:`, any other scheme — is rejected.
11756
+ *
11757
+ * @param src - The raw `src` attribute value.
11758
+ * @returns `true` when the URL/path is embeddable.
11759
+ * @example
11760
+ * ```ts
11761
+ * isEmbeddableUrl("https://game.example.com/"); // true
11762
+ * isEmbeddableUrl("./game/index.html"); // true (co-located)
11763
+ * isEmbeddableUrl("javascript:alert(1)"); // false
11764
+ * ```
11765
+ */
11766
+ function isEmbeddableUrl(src) {
11767
+ if (src === "") return false;
11768
+ if (src.startsWith("//")) return false;
11769
+ if (/^[a-z][a-z0-9+.-]*:/i.test(src)) return /^https?:\/\//i.test(src);
11770
+ return true;
11771
+ }
11772
+ /**
11773
+ * Parse + validate the optional `width`/`height` directive attributes. Both
11774
+ * must be supplied together, each a positive integer count of pixels; the pair
11775
+ * is used to reserve the facade box at its true aspect ratio. Returns
11776
+ * `undefined` when neither is set.
11777
+ *
11778
+ * @param width - Raw `width` attribute (or undefined).
11779
+ * @param height - Raw `height` attribute (or undefined).
11780
+ * @returns The parsed dimensions, or `undefined` when both are absent.
11781
+ * @throws {Error} When only one of the pair is set, or a value is not a
11782
+ * positive integer.
11783
+ * @example
11784
+ * ```ts
11785
+ * parseDimensions("400", "711"); // { width: 400, height: 711 }
11786
+ * parseDimensions(undefined, undefined); // undefined
11787
+ * ```
11788
+ */
11789
+ function parseDimensions(width, height) {
11790
+ const hasWidth = width !== void 0 && width !== null && width !== "";
11791
+ const hasHeight = height !== void 0 && height !== null && height !== "";
11792
+ if (!hasWidth && !hasHeight) return void 0;
11793
+ if (!hasWidth || !hasHeight) throw new Error("[web] content: `::embed` width and height must be set together (got only one).");
11794
+ if (!/^\d+$/.test(width) || !/^\d+$/.test(height) || width === "0" || height === "0") throw new Error(`[web] content: \`::embed\` width/height must be positive integers in pixels (got "${width}"×"${height}").`);
11795
+ return {
11796
+ width: Number(width),
11797
+ height: Number(height)
11798
+ };
11799
+ }
11800
+ /**
11801
+ * Collect the directive's raw attribute bag into a plain string record, dropping
11802
+ * `null`/`undefined` values (so a custom facade can read arbitrary extra options).
11803
+ *
11804
+ * @param attributes - The raw directive attributes (or undefined).
11805
+ * @returns A string-valued attribute record.
11806
+ * @example
11807
+ * ```ts
11808
+ * collectAttributes({ src: "x", poster: "/p.jpg", flag: null }); // { src: "x", poster: "/p.jpg" }
11809
+ * ```
11810
+ */
11811
+ function collectAttributes(attributes) {
11812
+ const out = {};
11813
+ for (const [key, value] of Object.entries(attributes ?? {})) if (typeof value === "string") out[key] = value;
11814
+ return out;
11815
+ }
11816
+ /**
11817
+ * Build the static facade HTML for one embed: the framework-owned `<figure>`
11818
+ * (island hooks in data attributes; optional reserved-box `aspect-ratio`/`max-width`
11819
+ * inline style when dimensions are given) wrapping the facade component's inner
11820
+ * content, SSR'd to static markup. The wrapper carries `data-embed-src` (raw —
11821
+ * the provider resolves a relative src) so neither the island contract nor the
11822
+ * URL rewrite depend on the consumer's markup.
11823
+ *
11824
+ * @param facade - The facade component (default {@link EmbedFacadeButton}).
11825
+ * @param props - The facade props (`src`, `title`, optional `width`/`height`, raw `attributes`).
11826
+ * @param dimensions - Optional reserved-box pixel dimensions.
11827
+ * @returns The facade HTML string.
11828
+ * @example
11829
+ * ```ts
11830
+ * embedFacadeHtml(EmbedFacadeButton, { src: "https://g/", title: "G", attributes: {} });
11831
+ * ```
11832
+ */
11833
+ function embedFacadeHtml(facade, props, dimensions) {
11834
+ return `<figure class="${EMBED_FIGURE_CLASS}" data-component="${EMBED_COMPONENT_NAME}" data-embed-src="${escapeAttribute(props.src)}" data-embed-title="${escapeAttribute(props.title)}"${dimensions ? ` data-embed-width="${dimensions.width}" data-embed-height="${dimensions.height}" style="aspect-ratio: ${dimensions.width} / ${dimensions.height}; max-width: ${dimensions.width}px;"` : ""}>${(0, preact_render_to_string.renderToString)((0, preact.h)(facade, props))}</figure>`;
11835
+ }
11836
+ /**
11837
+ * Normalize the provider's `embed` config value (`boolean | options`) to a plain
11838
+ * {@link EmbedOptions} object for the transform factory.
11839
+ *
11840
+ * @param embed - The raw `FileSystemContentOptions.embed` value (truthy).
11841
+ * @returns The options object (`{}` for the bare `true` form).
11842
+ * @example
11843
+ * ```ts
11844
+ * normalizeEmbedOptions(true); // {}
11845
+ * normalizeEmbedOptions({ facade: MyFacade });
11846
+ * ```
11847
+ */
11848
+ function normalizeEmbedOptions(embed) {
11849
+ return typeof embed === "boolean" ? {} : embed;
11850
+ }
11851
+ /**
11852
+ * Mdast transformer rewriting every `::embed` leaf directive to its facade
11853
+ * HTML node. A directive missing `src`/`title`, carrying a non-embeddable URL,
11854
+ * or carrying invalid `width`/`height`, fails the build with the offending
11855
+ * value quoted.
11856
+ *
11857
+ * @param facade - The facade component to render the inner content with.
11858
+ * @param tree - The mdast tree to mutate.
11859
+ * @throws {Error} When an `::embed` directive is missing `src` or `title`, its
11860
+ * `src` is not embeddable, or its dimensions are invalid.
11861
+ * @example
11862
+ * ```ts
11863
+ * embedTransform(EmbedFacadeButton, tree);
11864
+ * ```
11865
+ */
11866
+ function embedTransform(facade, tree) {
11867
+ (0, unist_util_visit.visit)(tree, (node, index, parent) => {
11868
+ if (!isEmbedDirective(node)) return;
11869
+ if (parent === void 0 || index === void 0) return;
11870
+ const src = node.attributes?.src ?? "";
11871
+ const title = node.attributes?.title ?? "";
11872
+ if (src === "" || title === "") throw new Error("[web] content: `::embed` requires both `src` and `title` attributes, e.g. ::embed{src=\"https://…\" title=\"…\"}.");
11873
+ if (!isEmbeddableUrl(src)) throw new Error(`[web] content: \`::embed\` src must be an http(s) URL, a root-relative path, or a co-located relative path (got "${src}").`);
11874
+ const dimensions = parseDimensions(node.attributes?.width, node.attributes?.height);
11875
+ const html = {
11876
+ type: "html",
11877
+ value: embedFacadeHtml(facade, {
11878
+ src,
11879
+ title,
11880
+ ...dimensions ? {
11881
+ width: dimensions.width,
11882
+ height: dimensions.height
11883
+ } : {},
11884
+ attributes: collectAttributes(node.attributes)
11885
+ }, dimensions)
11886
+ };
11887
+ parent.children[index] = html;
11888
+ });
11889
+ }
11890
+ /**
11891
+ * Remark transform factory: rewrites `::embed{src="…" title="…"}` leaf directives
11892
+ * into static click-to-activate facades (no iframe until the reader clicks — see
11893
+ * the file header). Opt-in via the provider's `embed` option; requires
11894
+ * `trustedContent: true` because the facade is raw HTML the sanitize pass would
11895
+ * strip. The facade's inner content is rendered by `options.facade` (a consumer
11896
+ * Preact component) or the built-in {@link EmbedFacadeButton}.
11897
+ *
11898
+ * @param options - Embed options (the optional `facade` component).
11899
+ * @returns An mdast tree transformer.
11900
+ * @example
11901
+ * ```ts
11902
+ * unified().use(embedPlugin, { facade: MyFacade });
11903
+ * ```
11904
+ */
11905
+ function embedPlugin(options = {}) {
11906
+ const facade = options.facade ?? EmbedFacadeButton;
11907
+ return (tree) => embedTransform(facade, tree);
11908
+ }
11909
+ //#endregion
11589
11910
  //#region src/plugins/content/pipeline/mermaid.ts
11590
11911
  /** CSS class on the `<figure>` wrapper around each rendered diagram. */
11591
11912
  const MERMAID_FIGURE_CLASS = "mermaid-diagram";
@@ -11869,14 +12190,17 @@ function sectionDividerPlugin() {
11869
12190
  }
11870
12191
  /**
11871
12192
  * 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).
12193
+ * parse, frontmatter, gfm, directive, pull-quote, the OPT-IN embed + mermaid
12194
+ * transforms, then the mdast→hast bridge (`remark-rehype` with
12195
+ * `allowDangerousHtml`). Pull-quote, embed and mermaid run on the mdast before
12196
+ * the bridge — pull-quote so the directive carries its `hName`/`hProperties`,
12197
+ * embed so the directive is replaced with its raw facade HTML, mermaid so the
12198
+ * fence is replaced with raw SVG HTML before Shiki could ever claim the code
12199
+ * block.
12200
+ *
12201
+ * @param config - Optional provider configuration; only the opt-in flags are
12202
+ * read here: `mermaid` and `embed` (each truthy value enables its transform at
12203
+ * a fixed mdast position).
11880
12204
  * @returns The ordered default remark pluggables.
11881
12205
  * @example
11882
12206
  * ```ts
@@ -11891,6 +12215,7 @@ function defaultRemarkPlugins(config) {
11891
12215
  remark_directive.default,
11892
12216
  pullQuotePlugin
11893
12217
  ];
12218
+ if (config?.embed) plugins.push([embedPlugin, normalizeEmbedOptions(config.embed)]);
11894
12219
  if (config?.mermaid) plugins.push([remarkMermaidDiagrams, normalizeMermaidOptions(config.mermaid)]);
11895
12220
  plugins.push([remark_rehype.default, { allowDangerousHtml: true }]);
11896
12221
  return plugins;
@@ -12132,6 +12457,8 @@ function calculateReadingTime(text) {
12132
12457
  */
12133
12458
  /** Matches an `<img>` `src` that points at the co-located `images/` dir (relative or root-relative). */
12134
12459
  const RELATIVE_IMAGE_SRC = /(<img\b[^>]*?\bsrc=")(?:\.?\/)?images\//g;
12460
+ /** Matches the `data-embed-src` of an `::embed` facade (value captured for path resolution). */
12461
+ const EMBED_SRC_ATTR = /(\bdata-embed-src=")([^"]*)(")/g;
12135
12462
  /**
12136
12463
  * Build a canonical article URL for a locale + slug.
12137
12464
  *
@@ -12162,6 +12489,56 @@ function rewriteImageUrls(html, slug) {
12162
12489
  return html.replaceAll(RELATIVE_IMAGE_SRC, `$1/${slug}/images/`);
12163
12490
  }
12164
12491
  /**
12492
+ * Resolve an `::embed` `src` to the URL the iframe should load. Absolute targets
12493
+ * (`http(s)://…`, root-relative `/…`) pass through unchanged; a co-located
12494
+ * relative path (`./game/index.html`, `../x`, `game/x`) is resolved against the
12495
+ * article base `/<slug>/` into the single shared absolute path the content-assets
12496
+ * build phase copies the bundle to — so it loads identically from every locale
12497
+ * page (mirroring how co-located images resolve). Any `?query`/`#hash` is
12498
+ * preserved verbatim.
12499
+ *
12500
+ * @param value - The raw `data-embed-src` value.
12501
+ * @param slug - Article directory name.
12502
+ * @returns The resolved embed URL.
12503
+ * @example
12504
+ * ```ts
12505
+ * resolveEmbedSource("./game/index.html", "post"); // "/post/game/index.html"
12506
+ * resolveEmbedSource("https://x.dev/", "post"); // "https://x.dev/"
12507
+ * ```
12508
+ */
12509
+ function resolveEmbedSource(value, slug) {
12510
+ if (/^https?:\/\//i.test(value) || value.startsWith("/")) return value;
12511
+ const tailIndex = value.search(/[?#]/);
12512
+ const rawPath = tailIndex === -1 ? value : value.slice(0, tailIndex);
12513
+ const tail = tailIndex === -1 ? "" : value.slice(tailIndex);
12514
+ const out = [];
12515
+ for (const segment of `${slug}/${rawPath}`.split("/")) {
12516
+ if (segment === "" || segment === ".") continue;
12517
+ if (segment === "..") {
12518
+ out.pop();
12519
+ continue;
12520
+ }
12521
+ out.push(segment);
12522
+ }
12523
+ const trailingSlash = rawPath === "" || rawPath.endsWith("/") ? "/" : "";
12524
+ return `/${out.join("/")}${trailingSlash}${tail}`;
12525
+ }
12526
+ /**
12527
+ * Rewrite every `::embed` facade's relative `data-embed-src` to its shared
12528
+ * absolute `/<slug>/…` path (no-op for already-absolute targets).
12529
+ *
12530
+ * @param html - The rendered article HTML.
12531
+ * @param slug - Article directory name.
12532
+ * @returns The HTML with embed `src`s resolved.
12533
+ * @example
12534
+ * ```ts
12535
+ * rewriteEmbedUrls('<figure data-embed-src="./g/">', "post"); // '… data-embed-src="/post/g/"'
12536
+ * ```
12537
+ */
12538
+ function rewriteEmbedUrls(html, slug) {
12539
+ return html.replaceAll(EMBED_SRC_ATTR, (_match, prefix, value, suffix) => `${prefix}${resolveEmbedSource(value, slug)}${suffix}`);
12540
+ }
12541
+ /**
12165
12542
  * Discover slug-like subdirectories of the content root (direct children not
12166
12543
  * starting with `.` or `_`), sorted alphabetically for deterministic ordering.
12167
12544
  *
@@ -12240,7 +12617,7 @@ function fileSystemContent(options) {
12240
12617
  state.dirtyPaths.delete(filePath);
12241
12618
  const { frontmatter, body } = parseFrontmatter(raw, options);
12242
12619
  const processor = ensureProcessor(state, options);
12243
- const html = rewriteImageUrls(String(await processor.process(body)), slug);
12620
+ const html = rewriteEmbedUrls(rewriteImageUrls(String(await processor.process(body)), slug), slug);
12244
12621
  const { readingTime, wordCount } = calculateReadingTime(body);
12245
12622
  return {
12246
12623
  frontmatter,
@@ -12394,6 +12771,7 @@ Object.defineProperty(exports, "Deploy", {
12394
12771
  return types_exports$4;
12395
12772
  }
12396
12773
  });
12774
+ exports.EmbedFacadeButton = EmbedFacadeButton;
12397
12775
  Object.defineProperty(exports, "Env", {
12398
12776
  enumerable: true,
12399
12777
  get: function() {
@@ -12446,6 +12824,7 @@ exports.headPlugin = headPlugin;
12446
12824
  exports.hreflang = hreflang;
12447
12825
  exports.i18nPlugin = i18nPlugin;
12448
12826
  exports.jsonLd = jsonLd;
12827
+ exports.lazyEmbed = lazyEmbed;
12449
12828
  exports.logPlugin = logPlugin;
12450
12829
  exports.meta = meta;
12451
12830
  exports.og = og;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { ComponentChildren, VNode } from "preact";
1
+ import { ComponentChildren, FunctionComponent, VNode } from "preact";
2
2
  import { EmitFn } from "@moku-labs/core";
3
3
  import { BundledTheme, ThemeRegistrationAny } from "shiki";
4
4
  import { Pluggable, Processor } from "unified";
@@ -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, MermaidDiagramOptions, State };
2674
+ export { Api, Article, ArticleCard, ComputedFields, Config, ContentApiContext, ContentEvents, ContentProvider, ContentProviderState, EmbedFacade, EmbedFacadeProps, EmbedOptions, FileSystemContentOptions, Frontmatter, LoadAllOptions, MermaidDiagramOptions, State };
2675
2675
  }
2676
2676
  /**
2677
2677
  * YAML frontmatter parsed from each article file.
@@ -2815,6 +2815,55 @@ type MermaidDiagramOptions = {
2815
2815
  */
2816
2816
  renderDiagrams?: (sources: readonly string[], mermaidConfig?: Record<string, unknown>) => Promise<readonly string[]>;
2817
2817
  };
2818
+ /**
2819
+ * Props handed to an `::embed` facade component (the click-to-activate placeholder
2820
+ * the framework renders to static markup at build time). `width`/`height` are the
2821
+ * parsed pixel dimensions when the directive set them; `attributes` is the full raw
2822
+ * directive attribute bag, so a custom facade can read arbitrary extra options
2823
+ * (e.g. `::embed{… poster="/p.jpg" label="Play"}`).
2824
+ *
2825
+ * @example
2826
+ * ```tsx
2827
+ * const Facade = ({ title, attributes }: EmbedFacadeProps) => (
2828
+ * <button type="button" class="lazy-embed-button">
2829
+ * {attributes.poster ? <img src={attributes.poster} alt="" /> : null}
2830
+ * <span class="lazy-embed-title">{title}</span>
2831
+ * </button>
2832
+ * );
2833
+ * ```
2834
+ */
2835
+ type EmbedFacadeProps = {
2836
+ /** The embed target exactly as written in the directive (the provider resolves it later). */src: string; /** The human-readable embed title (default label + iframe title). */
2837
+ title: string; /** Reserved-box width in pixels, when the directive set `width`/`height`. */
2838
+ width?: number; /** Reserved-box height in pixels, when the directive set `width`/`height`. */
2839
+ height?: number; /** The full raw directive attribute bag (custom options live here). */
2840
+ attributes: Readonly<Record<string, string>>;
2841
+ };
2842
+ /**
2843
+ * A consumer-supplied facade component: a Preact function component over
2844
+ * {@link EmbedFacadeProps}, rendered (at build time, to static markup) as the
2845
+ * facade's inner content — inside the framework-owned `<figure>` that carries the
2846
+ * island hooks + reserved-box sizing. Defaults to the built-in `EmbedFacadeButton`.
2847
+ */
2848
+ type EmbedFacade = FunctionComponent<EmbedFacadeProps>;
2849
+ /**
2850
+ * Options for the `::embed` lazy-iframe feature (the `embed` key of
2851
+ * {@link FileSystemContentOptions}). `embed: true` uses the default facade;
2852
+ * `embed: { facade }` swaps in a consumer Preact component for the placeholder.
2853
+ *
2854
+ * @example
2855
+ * ```ts
2856
+ * fileSystemContent({ contentDir: "./content", trustedContent: true, embed: { facade: MyFacade } });
2857
+ * ```
2858
+ */
2859
+ type EmbedOptions = {
2860
+ /**
2861
+ * Consumer Preact component rendering the facade's inner content (SSR'd to
2862
+ * static markup at build — no client JS). Receives {@link EmbedFacadeProps}.
2863
+ * Defaults to the built-in `EmbedFacadeButton`.
2864
+ */
2865
+ facade?: EmbedFacade;
2866
+ };
2818
2867
  /**
2819
2868
  * Options for the node filesystem provider {@link ContentProvider} `fileSystemContent`.
2820
2869
  * These are the markdown-pipeline + source concerns that used to live on the content
@@ -2849,6 +2898,16 @@ type FileSystemContentOptions = {
2849
2898
  * (plus playwright with an installed browser). Defaults to disabled.
2850
2899
  */
2851
2900
  mermaid?: boolean | MermaidDiagramOptions;
2901
+ /**
2902
+ * Lazy iframe embeds: rewrite `::embed{src="…" title="…"}` leaf directives
2903
+ * into static click-to-activate facades (no iframe — and none of the target's
2904
+ * network/JS cost — until the reader clicks). Pair with the `lazyEmbed` SPA
2905
+ * island, which swaps the facade for the real `<iframe loading="lazy">`.
2906
+ * `true` enables with the default facade; an object passes {@link EmbedOptions}
2907
+ * (e.g. a consumer `facade` Preact component). Requires `trustedContent: true`
2908
+ * (the facade is raw HTML the sanitize pass would strip). Defaults to disabled.
2909
+ */
2910
+ embed?: boolean | EmbedOptions;
2852
2911
  };
2853
2912
  /**
2854
2913
  * Internal mutable state of the filesystem provider: the lazy unified processor and
@@ -3486,6 +3545,13 @@ declare const sitePlugin: import("@moku-labs/core").PluginInstance<"site", Confi
3486
3545
  */
3487
3546
  declare function createComponent(name: string, hooks: ComponentHooks): ComponentDef;
3488
3547
  //#endregion
3548
+ //#region src/plugins/spa/lazy-embed.d.ts
3549
+ /**
3550
+ * Lazy-embed island: facade button click → real `<iframe loading="lazy">`.
3551
+ * The companion of the content pipeline's `::embed` directive.
3552
+ */
3553
+ declare const lazyEmbed: ComponentDef;
3554
+ //#endregion
3489
3555
  //#region src/plugins/spa/index.d.ts
3490
3556
  /**
3491
3557
  * SPA plugin — progressive client-side navigation layered over the static site:
@@ -3581,6 +3647,26 @@ declare function cloudflareBindings(): EnvProvider;
3581
3647
  */
3582
3648
  declare function fileSystemContent(options: FileSystemContentOptions): ContentProvider;
3583
3649
  //#endregion
3650
+ //#region src/plugins/content/pipeline/embed-facade.d.ts
3651
+ /**
3652
+ * Default `::embed` facade inner content: a single labelled `<button>` carrying
3653
+ * the embed title. The companion `lazyEmbed` island activates the embed on a
3654
+ * click anywhere in the facade, so the button is the keyboard-accessible
3655
+ * control. Provided as the default and as a composable building block for custom
3656
+ * facades.
3657
+ *
3658
+ * @param props - The embed facade props (only `title` is used by the default).
3659
+ * @returns The facade inner-content VNode.
3660
+ * @example
3661
+ * ```tsx
3662
+ * // Compose the default inside a richer custom facade:
3663
+ * const MyFacade = (p: EmbedFacadeProps) => (
3664
+ * <div class="poster"><img src={p.attributes.poster} alt="" /><EmbedFacadeButton {...p} /></div>
3665
+ * );
3666
+ * ```
3667
+ */
3668
+ declare function EmbedFacadeButton(props: EmbedFacadeProps): VNode;
3669
+ //#endregion
3584
3670
  //#region src/index.d.ts
3585
3671
  /**
3586
3672
  * Create and initialize a `@moku-labs/web` application — the Layer-3 entry point.
@@ -3687,4 +3773,4 @@ declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs
3687
3773
  */
3688
3774
  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
3775
  //#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 };
3776
+ 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, type EmbedFacade, EmbedFacadeButton, type EmbedFacadeProps, type EmbedOptions, 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 };