@moku-labs/web 1.10.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.
@@ -1,5 +1,5 @@
1
1
  import { EmitFn } from "@moku-labs/core";
2
- import { ComponentChildren, VNode } from "preact";
2
+ import { ComponentChildren, FunctionComponent, VNode } from "preact";
3
3
  import { BundledTheme, ThemeRegistrationAny } from "shiki";
4
4
  import { Pluggable, Processor } from "unified";
5
5
 
@@ -1927,7 +1927,7 @@ type DataProvider = {
1927
1927
  */
1928
1928
  declare const dataPlugin: import("@moku-labs/core").PluginInstance<"data", DataConfig, DataState, DataProvider, {}> & Record<never, never>;
1929
1929
  declare namespace types_d_exports {
1930
- export { Api, Article, ArticleCard, ComputedFields, Config, ContentApiContext, ContentEvents, ContentProvider, ContentProviderState, FileSystemContentOptions, Frontmatter, LoadAllOptions, MermaidDiagramOptions, State };
1930
+ export { Api, Article, ArticleCard, ComputedFields, Config, ContentApiContext, ContentEvents, ContentProvider, ContentProviderState, EmbedFacade, EmbedFacadeProps, EmbedOptions, FileSystemContentOptions, Frontmatter, LoadAllOptions, MermaidDiagramOptions, State };
1931
1931
  }
1932
1932
  /**
1933
1933
  * YAML frontmatter parsed from each article file.
@@ -2071,6 +2071,55 @@ type MermaidDiagramOptions = {
2071
2071
  */
2072
2072
  renderDiagrams?: (sources: readonly string[], mermaidConfig?: Record<string, unknown>) => Promise<readonly string[]>;
2073
2073
  };
2074
+ /**
2075
+ * Props handed to an `::embed` facade component (the click-to-activate placeholder
2076
+ * the framework renders to static markup at build time). `width`/`height` are the
2077
+ * parsed pixel dimensions when the directive set them; `attributes` is the full raw
2078
+ * directive attribute bag, so a custom facade can read arbitrary extra options
2079
+ * (e.g. `::embed{… poster="/p.jpg" label="Play"}`).
2080
+ *
2081
+ * @example
2082
+ * ```tsx
2083
+ * const Facade = ({ title, attributes }: EmbedFacadeProps) => (
2084
+ * <button type="button" class="lazy-embed-button">
2085
+ * {attributes.poster ? <img src={attributes.poster} alt="" /> : null}
2086
+ * <span class="lazy-embed-title">{title}</span>
2087
+ * </button>
2088
+ * );
2089
+ * ```
2090
+ */
2091
+ type EmbedFacadeProps = {
2092
+ /** 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). */
2093
+ title: string; /** Reserved-box width in pixels, when the directive set `width`/`height`. */
2094
+ width?: number; /** Reserved-box height in pixels, when the directive set `width`/`height`. */
2095
+ height?: number; /** The full raw directive attribute bag (custom options live here). */
2096
+ attributes: Readonly<Record<string, string>>;
2097
+ };
2098
+ /**
2099
+ * A consumer-supplied facade component: a Preact function component over
2100
+ * {@link EmbedFacadeProps}, rendered (at build time, to static markup) as the
2101
+ * facade's inner content — inside the framework-owned `<figure>` that carries the
2102
+ * island hooks + reserved-box sizing. Defaults to the built-in `EmbedFacadeButton`.
2103
+ */
2104
+ type EmbedFacade = FunctionComponent<EmbedFacadeProps>;
2105
+ /**
2106
+ * Options for the `::embed` lazy-iframe feature (the `embed` key of
2107
+ * {@link FileSystemContentOptions}). `embed: true` uses the default facade;
2108
+ * `embed: { facade }` swaps in a consumer Preact component for the placeholder.
2109
+ *
2110
+ * @example
2111
+ * ```ts
2112
+ * fileSystemContent({ contentDir: "./content", trustedContent: true, embed: { facade: MyFacade } });
2113
+ * ```
2114
+ */
2115
+ type EmbedOptions = {
2116
+ /**
2117
+ * Consumer Preact component rendering the facade's inner content (SSR'd to
2118
+ * static markup at build — no client JS). Receives {@link EmbedFacadeProps}.
2119
+ * Defaults to the built-in `EmbedFacadeButton`.
2120
+ */
2121
+ facade?: EmbedFacade;
2122
+ };
2074
2123
  /**
2075
2124
  * Options for the node filesystem provider {@link ContentProvider} `fileSystemContent`.
2076
2125
  * These are the markdown-pipeline + source concerns that used to live on the content
@@ -2110,10 +2159,11 @@ type FileSystemContentOptions = {
2110
2159
  * into static click-to-activate facades (no iframe — and none of the target's
2111
2160
  * network/JS cost — until the reader clicks). Pair with the `lazyEmbed` SPA
2112
2161
  * 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.
2162
+ * `true` enables with the default facade; an object passes {@link EmbedOptions}
2163
+ * (e.g. a consumer `facade` Preact component). Requires `trustedContent: true`
2164
+ * (the facade is raw HTML the sanitize pass would strip). Defaults to disabled.
2115
2165
  */
2116
- embed?: boolean;
2166
+ embed?: boolean | EmbedOptions;
2117
2167
  };
2118
2168
  /**
2119
2169
  * Internal mutable state of the filesystem provider: the lazy unified processor and
package/dist/browser.mjs CHANGED
@@ -4466,7 +4466,11 @@ function activateEmbed(figure) {
4466
4466
  }
4467
4467
  /**
4468
4468
  * Shared click handler (module-level so mount/unmount detach the same
4469
- * reference): a click on the facade's button activates the embed.
4469
+ * reference): any click on the not-yet-active facade activates the embed. It
4470
+ * fires on the whole facade — not a specific button class — so a consumer's
4471
+ * custom facade markup (see content `embed.facade`) works without re-wiring;
4472
+ * the default facade's `<button>` keeps it keyboard-accessible. Once active
4473
+ * (`data-embed-active`), clicks fall through to the live iframe.
4470
4474
  *
4471
4475
  * @param event - The click event from the facade figure.
4472
4476
  * @example
@@ -4475,9 +4479,10 @@ function activateEmbed(figure) {
4475
4479
  * ```
4476
4480
  */
4477
4481
  function onFacadeClick(event) {
4478
- if (!event.target.closest("button.lazy-embed-button")) return;
4479
4482
  const figure = event.currentTarget;
4480
- if (figure instanceof HTMLElement) activateEmbed(figure);
4483
+ if (!(figure instanceof HTMLElement)) return;
4484
+ if (figure.dataset.embedActive !== void 0) return;
4485
+ activateEmbed(figure);
4481
4486
  }
4482
4487
  /**
4483
4488
  * Lazy-embed island: facade button click → real `<iframe loading="lazy">`.
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);
@@ -4053,19 +4054,24 @@ function readCachedContent(ctx) {
4053
4054
  //#endregion
4054
4055
  //#region src/plugins/build/phases/content-images.ts
4055
4056
  /**
4056
- * @file build phase — content-images. Copies each article's co-located image directory
4057
- * (`<contentDir>/<slug>/images/`) to a single shared output dir (`<outDir>/<slug>/images/`) reused by
4058
- * every locale, matching the absolute `/<slug>/images/...` URLs the content renderer emits. Gated by
4059
- * `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`.
4060
4064
  */
4061
- /** Conventional per-article image subdirectory name (alongside `<slug>/<locale>.md`). */
4062
- const ARTICLE_IMAGE_DIR = "images";
4063
4065
  /**
4064
- * Copy every article's co-located `images/` directory to `<outDir>/<slug>/images/`. No-op when
4065
- * `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.
4066
4072
  *
4067
4073
  * @param ctx - Plugin context (provides `config`, `log`, `require`).
4068
- * @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.
4069
4075
  * @example
4070
4076
  * ```ts
4071
4077
  * const copied = await copyContentImages(ctx);
@@ -4084,14 +4090,20 @@ async function copyContentImages(ctx) {
4084
4090
  });
4085
4091
  return 0;
4086
4092
  }
4087
- const entries = await (0, node_fs_promises.readdir)(contentDir, { withFileTypes: true });
4093
+ const articleDirectories = await (0, node_fs_promises.readdir)(contentDir, { withFileTypes: true });
4088
4094
  let copied = 0;
4089
- for (const entry of entries) {
4090
- if (!entry.isDirectory()) continue;
4091
- const source = node_path$1.default.join(contentDir, entry.name, ARTICLE_IMAGE_DIR);
4092
- if (!(0, node_fs.existsSync)(source)) continue;
4093
- await (0, node_fs_promises.cp)(source, node_path$1.default.join(ctx.config.outDir, entry.name, ARTICLE_IMAGE_DIR), { recursive: true });
4094
- 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;
4095
4107
  }
4096
4108
  ctx.log.debug("build:content-images", { copied });
4097
4109
  return copied;
@@ -11352,7 +11364,11 @@ function activateEmbed(figure) {
11352
11364
  }
11353
11365
  /**
11354
11366
  * Shared click handler (module-level so mount/unmount detach the same
11355
- * reference): a click on the facade's button activates the embed.
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.
11356
11372
  *
11357
11373
  * @param event - The click event from the facade figure.
11358
11374
  * @example
@@ -11361,9 +11377,10 @@ function activateEmbed(figure) {
11361
11377
  * ```
11362
11378
  */
11363
11379
  function onFacadeClick(event) {
11364
- if (!event.target.closest("button.lazy-embed-button")) return;
11365
11380
  const figure = event.currentTarget;
11366
- if (figure instanceof HTMLElement) activateEmbed(figure);
11381
+ if (!(figure instanceof HTMLElement)) return;
11382
+ if (figure.dataset.embedActive !== void 0) return;
11383
+ activateEmbed(figure);
11367
11384
  }
11368
11385
  /**
11369
11386
  * Lazy-embed island: facade button click → real `<iframe loading="lazy">`.
@@ -11666,15 +11683,45 @@ function parseFrontmatter(raw, config) {
11666
11683
  };
11667
11684
  }
11668
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
11669
11720
  //#region src/plugins/content/pipeline/embed.ts
11670
11721
  /** CSS class on the `<figure>` facade wrapping each embed. */
11671
11722
  const EMBED_FIGURE_CLASS = "lazy-embed";
11672
11723
  /** `data-component` name binding the facade to the `lazyEmbed` SPA island. */
11673
11724
  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
11725
  /**
11679
11726
  * Type guard for an `::embed` leaf directive.
11680
11727
  *
@@ -11702,83 +11749,162 @@ function escapeAttribute(value) {
11702
11749
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
11703
11750
  }
11704
11751
  /**
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.
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.
11708
11756
  *
11709
11757
  * @param src - The raw `src` attribute value.
11710
- * @returns `true` when the URL is embeddable.
11758
+ * @returns `true` when the URL/path is embeddable.
11711
11759
  * @example
11712
11760
  * ```ts
11713
11761
  * isEmbeddableUrl("https://game.example.com/"); // true
11762
+ * isEmbeddableUrl("./game/index.html"); // true (co-located)
11714
11763
  * isEmbeddableUrl("javascript:alert(1)"); // false
11715
11764
  * ```
11716
11765
  */
11717
11766
  function isEmbeddableUrl(src) {
11718
- if (src.startsWith("/") && !src.startsWith("//")) return true;
11719
- return /^https?:\/\//i.test(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;
11720
11771
  }
11721
11772
  /**
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).
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.
11725
11777
  *
11726
- * @param src - The validated embed URL.
11727
- * @param title - The human-readable embed title (button label, iframe title).
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.
11728
11827
  * @returns The facade HTML string.
11729
11828
  * @example
11730
11829
  * ```ts
11731
- * embedFacadeHtml("https://game.example.com/", "My Game");
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 });
11732
11846
  * ```
11733
11847
  */
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>`;
11848
+ function normalizeEmbedOptions(embed) {
11849
+ return typeof embed === "boolean" ? {} : embed;
11738
11850
  }
11739
11851
  /**
11740
11852
  * 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.
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.
11743
11856
  *
11857
+ * @param facade - The facade component to render the inner content with.
11744
11858
  * @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.
11859
+ * @throws {Error} When an `::embed` directive is missing `src` or `title`, its
11860
+ * `src` is not embeddable, or its dimensions are invalid.
11747
11861
  * @example
11748
11862
  * ```ts
11749
- * embedTransform(tree);
11863
+ * embedTransform(EmbedFacadeButton, tree);
11750
11864
  * ```
11751
11865
  */
11752
- function embedTransform(tree) {
11866
+ function embedTransform(facade, tree) {
11753
11867
  (0, unist_util_visit.visit)(tree, (node, index, parent) => {
11754
11868
  if (!isEmbedDirective(node)) return;
11755
11869
  if (parent === void 0 || index === void 0) return;
11756
11870
  const src = node.attributes?.src ?? "";
11757
11871
  const title = node.attributes?.title ?? "";
11758
11872
  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}").`);
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);
11760
11875
  const html = {
11761
11876
  type: "html",
11762
- value: embedFacadeHtml(src, title)
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)
11763
11886
  };
11764
11887
  parent.children[index] = html;
11765
11888
  });
11766
11889
  }
11767
11890
  /**
11768
- * Remark transform: rewrites `::embed{src="…" title="…"}` leaf directives into
11769
- * static click-to-activate facades (no iframe until the reader clicks — see
11891
+ * Remark transform factory: rewrites `::embed{src="…" title="…"}` leaf directives
11892
+ * into static click-to-activate facades (no iframe until the reader clicks — see
11770
11893
  * 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.
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}.
11773
11897
  *
11898
+ * @param options - Embed options (the optional `facade` component).
11774
11899
  * @returns An mdast tree transformer.
11775
11900
  * @example
11776
11901
  * ```ts
11777
- * unified().use(embedPlugin);
11902
+ * unified().use(embedPlugin, { facade: MyFacade });
11778
11903
  * ```
11779
11904
  */
11780
- function embedPlugin() {
11781
- return embedTransform;
11905
+ function embedPlugin(options = {}) {
11906
+ const facade = options.facade ?? EmbedFacadeButton;
11907
+ return (tree) => embedTransform(facade, tree);
11782
11908
  }
11783
11909
  //#endregion
11784
11910
  //#region src/plugins/content/pipeline/mermaid.ts
@@ -12089,7 +12215,7 @@ function defaultRemarkPlugins(config) {
12089
12215
  remark_directive.default,
12090
12216
  pullQuotePlugin
12091
12217
  ];
12092
- if (config?.embed) plugins.push(embedPlugin);
12218
+ if (config?.embed) plugins.push([embedPlugin, normalizeEmbedOptions(config.embed)]);
12093
12219
  if (config?.mermaid) plugins.push([remarkMermaidDiagrams, normalizeMermaidOptions(config.mermaid)]);
12094
12220
  plugins.push([remark_rehype.default, { allowDangerousHtml: true }]);
12095
12221
  return plugins;
@@ -12331,6 +12457,8 @@ function calculateReadingTime(text) {
12331
12457
  */
12332
12458
  /** Matches an `<img>` `src` that points at the co-located `images/` dir (relative or root-relative). */
12333
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;
12334
12462
  /**
12335
12463
  * Build a canonical article URL for a locale + slug.
12336
12464
  *
@@ -12361,6 +12489,56 @@ function rewriteImageUrls(html, slug) {
12361
12489
  return html.replaceAll(RELATIVE_IMAGE_SRC, `$1/${slug}/images/`);
12362
12490
  }
12363
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
+ /**
12364
12542
  * Discover slug-like subdirectories of the content root (direct children not
12365
12543
  * starting with `.` or `_`), sorted alphabetically for deterministic ordering.
12366
12544
  *
@@ -12439,7 +12617,7 @@ function fileSystemContent(options) {
12439
12617
  state.dirtyPaths.delete(filePath);
12440
12618
  const { frontmatter, body } = parseFrontmatter(raw, options);
12441
12619
  const processor = ensureProcessor(state, options);
12442
- const html = rewriteImageUrls(String(await processor.process(body)), slug);
12620
+ const html = rewriteEmbedUrls(rewriteImageUrls(String(await processor.process(body)), slug), slug);
12443
12621
  const { readingTime, wordCount } = calculateReadingTime(body);
12444
12622
  return {
12445
12623
  frontmatter,
@@ -12593,6 +12771,7 @@ Object.defineProperty(exports, "Deploy", {
12593
12771
  return types_exports$4;
12594
12772
  }
12595
12773
  });
12774
+ exports.EmbedFacadeButton = EmbedFacadeButton;
12596
12775
  Object.defineProperty(exports, "Env", {
12597
12776
  enumerable: true,
12598
12777
  get: function() {
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
@@ -2854,10 +2903,11 @@ type FileSystemContentOptions = {
2854
2903
  * into static click-to-activate facades (no iframe — and none of the target's
2855
2904
  * network/JS cost — until the reader clicks). Pair with the `lazyEmbed` SPA
2856
2905
  * 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.
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.
2859
2909
  */
2860
- embed?: boolean;
2910
+ embed?: boolean | EmbedOptions;
2861
2911
  };
2862
2912
  /**
2863
2913
  * Internal mutable state of the filesystem provider: the lazy unified processor and
@@ -3597,6 +3647,26 @@ declare function cloudflareBindings(): EnvProvider;
3597
3647
  */
3598
3648
  declare function fileSystemContent(options: FileSystemContentOptions): ContentProvider;
3599
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
3600
3670
  //#region src/index.d.ts
3601
3671
  /**
3602
3672
  * Create and initialize a `@moku-labs/web` application — the Layer-3 entry point.
@@ -3703,4 +3773,4 @@ declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs
3703
3773
  */
3704
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>]>>;
3705
3775
  //#endregion
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 };
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 };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { EmitFn } from "@moku-labs/core";
2
- import { ComponentChildren, VNode } from "preact";
2
+ import { ComponentChildren, FunctionComponent, VNode } from "preact";
3
3
  import { Pluggable, Processor } from "unified";
4
4
  import { BundledTheme, ThemeRegistrationAny } from "shiki";
5
5
 
@@ -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
@@ -2854,10 +2903,11 @@ type FileSystemContentOptions = {
2854
2903
  * into static click-to-activate facades (no iframe — and none of the target's
2855
2904
  * network/JS cost — until the reader clicks). Pair with the `lazyEmbed` SPA
2856
2905
  * 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.
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.
2859
2909
  */
2860
- embed?: boolean;
2910
+ embed?: boolean | EmbedOptions;
2861
2911
  };
2862
2912
  /**
2863
2913
  * Internal mutable state of the filesystem provider: the lazy unified processor and
@@ -3597,6 +3647,26 @@ declare function cloudflareBindings(): EnvProvider;
3597
3647
  */
3598
3648
  declare function fileSystemContent(options: FileSystemContentOptions): ContentProvider;
3599
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
3600
3670
  //#region src/index.d.ts
3601
3671
  /**
3602
3672
  * Create and initialize a `@moku-labs/web` application — the Layer-3 entry point.
@@ -3703,4 +3773,4 @@ declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs
3703
3773
  */
3704
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>]>>;
3705
3775
  //#endregion
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 };
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 };
package/dist/index.mjs CHANGED
@@ -24,6 +24,7 @@ import remarkGfm from "remark-gfm";
24
24
  import remarkParse from "remark-parse";
25
25
  import remarkRehype from "remark-rehype";
26
26
  import { visit } from "unist-util-visit";
27
+ import { jsx } from "preact/jsx-runtime";
27
28
  import { defaultSchema } from "hast-util-sanitize";
28
29
  import readingTime from "reading-time";
29
30
  //#region src/plugins/env/api.ts
@@ -4040,19 +4041,24 @@ function readCachedContent(ctx) {
4040
4041
  //#endregion
4041
4042
  //#region src/plugins/build/phases/content-images.ts
4042
4043
  /**
4043
- * @file build phase — content-images. Copies each article's co-located image directory
4044
- * (`<contentDir>/<slug>/images/`) to a single shared output dir (`<outDir>/<slug>/images/`) reused by
4045
- * every locale, matching the absolute `/<slug>/images/...` URLs the content renderer emits. Gated by
4046
- * `config.images`.
4044
+ * @file build phase — content-images. Copies each article's co-located asset
4045
+ * directories (`<contentDir>/<slug>/<dir>/`, e.g. `images/` or a pre-built
4046
+ * `game/` embed bundle) to a single shared output location
4047
+ * (`<outDir>/<slug>/<dir>/`) reused by every locale, matching the absolute
4048
+ * `/<slug>/<dir>/...` URLs the content renderer emits (image src + `::embed`
4049
+ * src). Dot- and underscore-prefixed dirs are treated as private and skipped.
4050
+ * Gated by `config.images`.
4047
4051
  */
4048
- /** Conventional per-article image subdirectory name (alongside `<slug>/<locale>.md`). */
4049
- const ARTICLE_IMAGE_DIR = "images";
4050
4052
  /**
4051
- * Copy every article's co-located `images/` directory to `<outDir>/<slug>/images/`. No-op when
4052
- * `config.images` is false or the content directory does not exist.
4053
+ * Copy every article's co-located asset directories to `<outDir>/<slug>/<dir>/`.
4054
+ * Each direct subdirectory of `<contentDir>/<slug>/` rides along (the
4055
+ * conventional `images/` dir plus any other bundle, like an `::embed` game),
4056
+ * except `.`/`_`-prefixed dirs (private). The `.md` source files are never
4057
+ * copied (only directories are). No-op when `config.images` is false or the
4058
+ * content directory does not exist.
4053
4059
  *
4054
4060
  * @param ctx - Plugin context (provides `config`, `log`, `require`).
4055
- * @returns The number of directories copied (one per article that has an `images/` dir).
4061
+ * @returns The number of articles that had at least one asset directory copied.
4056
4062
  * @example
4057
4063
  * ```ts
4058
4064
  * const copied = await copyContentImages(ctx);
@@ -4071,14 +4077,20 @@ async function copyContentImages(ctx) {
4071
4077
  });
4072
4078
  return 0;
4073
4079
  }
4074
- const entries = await readdir(contentDir, { withFileTypes: true });
4080
+ const articleDirectories = await readdir(contentDir, { withFileTypes: true });
4075
4081
  let copied = 0;
4076
- for (const entry of entries) {
4077
- if (!entry.isDirectory()) continue;
4078
- const source = path.join(contentDir, entry.name, ARTICLE_IMAGE_DIR);
4079
- if (!existsSync(source)) continue;
4080
- await cp(source, path.join(ctx.config.outDir, entry.name, ARTICLE_IMAGE_DIR), { recursive: true });
4081
- copied += 1;
4082
+ for (const article of articleDirectories) {
4083
+ if (!article.isDirectory()) continue;
4084
+ const articleDir = path.join(contentDir, article.name);
4085
+ const assetDirectories = await readdir(articleDir, { withFileTypes: true });
4086
+ let copiedAny = false;
4087
+ for (const asset of assetDirectories) {
4088
+ if (!asset.isDirectory()) continue;
4089
+ if (asset.name.startsWith(".") || asset.name.startsWith("_")) continue;
4090
+ await cp(path.join(articleDir, asset.name), path.join(ctx.config.outDir, article.name, asset.name), { recursive: true });
4091
+ copiedAny = true;
4092
+ }
4093
+ if (copiedAny) copied += 1;
4082
4094
  }
4083
4095
  ctx.log.debug("build:content-images", { copied });
4084
4096
  return copied;
@@ -11339,7 +11351,11 @@ function activateEmbed(figure) {
11339
11351
  }
11340
11352
  /**
11341
11353
  * Shared click handler (module-level so mount/unmount detach the same
11342
- * reference): a click on the facade's button activates the embed.
11354
+ * reference): any click on the not-yet-active facade activates the embed. It
11355
+ * fires on the whole facade — not a specific button class — so a consumer's
11356
+ * custom facade markup (see content `embed.facade`) works without re-wiring;
11357
+ * the default facade's `<button>` keeps it keyboard-accessible. Once active
11358
+ * (`data-embed-active`), clicks fall through to the live iframe.
11343
11359
  *
11344
11360
  * @param event - The click event from the facade figure.
11345
11361
  * @example
@@ -11348,9 +11364,10 @@ function activateEmbed(figure) {
11348
11364
  * ```
11349
11365
  */
11350
11366
  function onFacadeClick(event) {
11351
- if (!event.target.closest("button.lazy-embed-button")) return;
11352
11367
  const figure = event.currentTarget;
11353
- if (figure instanceof HTMLElement) activateEmbed(figure);
11368
+ if (!(figure instanceof HTMLElement)) return;
11369
+ if (figure.dataset.embedActive !== void 0) return;
11370
+ activateEmbed(figure);
11354
11371
  }
11355
11372
  /**
11356
11373
  * Lazy-embed island: facade button click → real `<iframe loading="lazy">`.
@@ -11653,15 +11670,45 @@ function parseFrontmatter(raw, config) {
11653
11670
  };
11654
11671
  }
11655
11672
  //#endregion
11673
+ //#region src/plugins/content/pipeline/embed-facade.tsx
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
+ * Default `::embed` facade inner content: a single labelled `<button>` carrying
11680
+ * the embed title. The companion `lazyEmbed` island activates the embed on a
11681
+ * click anywhere in the facade, so the button is the keyboard-accessible
11682
+ * control. Provided as the default and as a composable building block for custom
11683
+ * facades.
11684
+ *
11685
+ * @param props - The embed facade props (only `title` is used by the default).
11686
+ * @returns The facade inner-content VNode.
11687
+ * @example
11688
+ * ```tsx
11689
+ * // Compose the default inside a richer custom facade:
11690
+ * const MyFacade = (p: EmbedFacadeProps) => (
11691
+ * <div class="poster"><img src={p.attributes.poster} alt="" /><EmbedFacadeButton {...p} /></div>
11692
+ * );
11693
+ * ```
11694
+ */
11695
+ function EmbedFacadeButton(props) {
11696
+ return /* @__PURE__ */ jsx("button", {
11697
+ type: "button",
11698
+ class: EMBED_BUTTON_CLASS,
11699
+ "aria-label": `Load embed: ${props.title}`,
11700
+ children: /* @__PURE__ */ jsx("span", {
11701
+ class: EMBED_TITLE_CLASS,
11702
+ children: props.title
11703
+ })
11704
+ });
11705
+ }
11706
+ //#endregion
11656
11707
  //#region src/plugins/content/pipeline/embed.ts
11657
11708
  /** CSS class on the `<figure>` facade wrapping each embed. */
11658
11709
  const EMBED_FIGURE_CLASS = "lazy-embed";
11659
11710
  /** `data-component` name binding the facade to the `lazyEmbed` SPA island. */
11660
11711
  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
11712
  /**
11666
11713
  * Type guard for an `::embed` leaf directive.
11667
11714
  *
@@ -11689,83 +11736,162 @@ function escapeAttribute(value) {
11689
11736
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
11690
11737
  }
11691
11738
  /**
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.
11739
+ * Validate an embed `src`. Three forms are embeddable: an `http(s)` URL, a
11740
+ * root-relative path (`/x`), or a co-located relative path (`./x`, `../x`,
11741
+ * `x/…`) resolved later against `/<slug>/`. Everything else — protocol-relative
11742
+ * (`//host`), `javascript:`, `data:`, any other scheme — is rejected.
11695
11743
  *
11696
11744
  * @param src - The raw `src` attribute value.
11697
- * @returns `true` when the URL is embeddable.
11745
+ * @returns `true` when the URL/path is embeddable.
11698
11746
  * @example
11699
11747
  * ```ts
11700
11748
  * isEmbeddableUrl("https://game.example.com/"); // true
11749
+ * isEmbeddableUrl("./game/index.html"); // true (co-located)
11701
11750
  * isEmbeddableUrl("javascript:alert(1)"); // false
11702
11751
  * ```
11703
11752
  */
11704
11753
  function isEmbeddableUrl(src) {
11705
- if (src.startsWith("/") && !src.startsWith("//")) return true;
11706
- return /^https?:\/\//i.test(src);
11754
+ if (src === "") return false;
11755
+ if (src.startsWith("//")) return false;
11756
+ if (/^[a-z][a-z0-9+.-]*:/i.test(src)) return /^https?:\/\//i.test(src);
11757
+ return true;
11707
11758
  }
11708
11759
  /**
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).
11760
+ * Parse + validate the optional `width`/`height` directive attributes. Both
11761
+ * must be supplied together, each a positive integer count of pixels; the pair
11762
+ * is used to reserve the facade box at its true aspect ratio. Returns
11763
+ * `undefined` when neither is set.
11712
11764
  *
11713
- * @param src - The validated embed URL.
11714
- * @param title - The human-readable embed title (button label, iframe title).
11765
+ * @param width - Raw `width` attribute (or undefined).
11766
+ * @param height - Raw `height` attribute (or undefined).
11767
+ * @returns The parsed dimensions, or `undefined` when both are absent.
11768
+ * @throws {Error} When only one of the pair is set, or a value is not a
11769
+ * positive integer.
11770
+ * @example
11771
+ * ```ts
11772
+ * parseDimensions("400", "711"); // { width: 400, height: 711 }
11773
+ * parseDimensions(undefined, undefined); // undefined
11774
+ * ```
11775
+ */
11776
+ function parseDimensions(width, height) {
11777
+ const hasWidth = width !== void 0 && width !== null && width !== "";
11778
+ const hasHeight = height !== void 0 && height !== null && height !== "";
11779
+ if (!hasWidth && !hasHeight) return void 0;
11780
+ if (!hasWidth || !hasHeight) throw new Error("[web] content: `::embed` width and height must be set together (got only one).");
11781
+ 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}").`);
11782
+ return {
11783
+ width: Number(width),
11784
+ height: Number(height)
11785
+ };
11786
+ }
11787
+ /**
11788
+ * Collect the directive's raw attribute bag into a plain string record, dropping
11789
+ * `null`/`undefined` values (so a custom facade can read arbitrary extra options).
11790
+ *
11791
+ * @param attributes - The raw directive attributes (or undefined).
11792
+ * @returns A string-valued attribute record.
11793
+ * @example
11794
+ * ```ts
11795
+ * collectAttributes({ src: "x", poster: "/p.jpg", flag: null }); // { src: "x", poster: "/p.jpg" }
11796
+ * ```
11797
+ */
11798
+ function collectAttributes(attributes) {
11799
+ const out = {};
11800
+ for (const [key, value] of Object.entries(attributes ?? {})) if (typeof value === "string") out[key] = value;
11801
+ return out;
11802
+ }
11803
+ /**
11804
+ * Build the static facade HTML for one embed: the framework-owned `<figure>`
11805
+ * (island hooks in data attributes; optional reserved-box `aspect-ratio`/`max-width`
11806
+ * inline style when dimensions are given) wrapping the facade component's inner
11807
+ * content, SSR'd to static markup. The wrapper carries `data-embed-src` (raw —
11808
+ * the provider resolves a relative src) so neither the island contract nor the
11809
+ * URL rewrite depend on the consumer's markup.
11810
+ *
11811
+ * @param facade - The facade component (default {@link EmbedFacadeButton}).
11812
+ * @param props - The facade props (`src`, `title`, optional `width`/`height`, raw `attributes`).
11813
+ * @param dimensions - Optional reserved-box pixel dimensions.
11715
11814
  * @returns The facade HTML string.
11716
11815
  * @example
11717
11816
  * ```ts
11718
- * embedFacadeHtml("https://game.example.com/", "My Game");
11817
+ * embedFacadeHtml(EmbedFacadeButton, { src: "https://g/", title: "G", attributes: {} });
11818
+ * ```
11819
+ */
11820
+ function embedFacadeHtml(facade, props, dimensions) {
11821
+ 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;"` : ""}>${renderToString(h(facade, props))}</figure>`;
11822
+ }
11823
+ /**
11824
+ * Normalize the provider's `embed` config value (`boolean | options`) to a plain
11825
+ * {@link EmbedOptions} object for the transform factory.
11826
+ *
11827
+ * @param embed - The raw `FileSystemContentOptions.embed` value (truthy).
11828
+ * @returns The options object (`{}` for the bare `true` form).
11829
+ * @example
11830
+ * ```ts
11831
+ * normalizeEmbedOptions(true); // {}
11832
+ * normalizeEmbedOptions({ facade: MyFacade });
11719
11833
  * ```
11720
11834
  */
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>`;
11835
+ function normalizeEmbedOptions(embed) {
11836
+ return typeof embed === "boolean" ? {} : embed;
11725
11837
  }
11726
11838
  /**
11727
11839
  * 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.
11840
+ * HTML node. A directive missing `src`/`title`, carrying a non-embeddable URL,
11841
+ * or carrying invalid `width`/`height`, fails the build with the offending
11842
+ * value quoted.
11730
11843
  *
11844
+ * @param facade - The facade component to render the inner content with.
11731
11845
  * @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.
11846
+ * @throws {Error} When an `::embed` directive is missing `src` or `title`, its
11847
+ * `src` is not embeddable, or its dimensions are invalid.
11734
11848
  * @example
11735
11849
  * ```ts
11736
- * embedTransform(tree);
11850
+ * embedTransform(EmbedFacadeButton, tree);
11737
11851
  * ```
11738
11852
  */
11739
- function embedTransform(tree) {
11853
+ function embedTransform(facade, tree) {
11740
11854
  visit(tree, (node, index, parent) => {
11741
11855
  if (!isEmbedDirective(node)) return;
11742
11856
  if (parent === void 0 || index === void 0) return;
11743
11857
  const src = node.attributes?.src ?? "";
11744
11858
  const title = node.attributes?.title ?? "";
11745
11859
  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}").`);
11860
+ 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}").`);
11861
+ const dimensions = parseDimensions(node.attributes?.width, node.attributes?.height);
11747
11862
  const html = {
11748
11863
  type: "html",
11749
- value: embedFacadeHtml(src, title)
11864
+ value: embedFacadeHtml(facade, {
11865
+ src,
11866
+ title,
11867
+ ...dimensions ? {
11868
+ width: dimensions.width,
11869
+ height: dimensions.height
11870
+ } : {},
11871
+ attributes: collectAttributes(node.attributes)
11872
+ }, dimensions)
11750
11873
  };
11751
11874
  parent.children[index] = html;
11752
11875
  });
11753
11876
  }
11754
11877
  /**
11755
- * Remark transform: rewrites `::embed{src="…" title="…"}` leaf directives into
11756
- * static click-to-activate facades (no iframe until the reader clicks — see
11878
+ * Remark transform factory: rewrites `::embed{src="…" title="…"}` leaf directives
11879
+ * into static click-to-activate facades (no iframe until the reader clicks — see
11757
11880
  * 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.
11881
+ * `trustedContent: true` because the facade is raw HTML the sanitize pass would
11882
+ * strip. The facade's inner content is rendered by `options.facade` (a consumer
11883
+ * Preact component) or the built-in {@link EmbedFacadeButton}.
11760
11884
  *
11885
+ * @param options - Embed options (the optional `facade` component).
11761
11886
  * @returns An mdast tree transformer.
11762
11887
  * @example
11763
11888
  * ```ts
11764
- * unified().use(embedPlugin);
11889
+ * unified().use(embedPlugin, { facade: MyFacade });
11765
11890
  * ```
11766
11891
  */
11767
- function embedPlugin() {
11768
- return embedTransform;
11892
+ function embedPlugin(options = {}) {
11893
+ const facade = options.facade ?? EmbedFacadeButton;
11894
+ return (tree) => embedTransform(facade, tree);
11769
11895
  }
11770
11896
  //#endregion
11771
11897
  //#region src/plugins/content/pipeline/mermaid.ts
@@ -12076,7 +12202,7 @@ function defaultRemarkPlugins(config) {
12076
12202
  remarkDirective,
12077
12203
  pullQuotePlugin
12078
12204
  ];
12079
- if (config?.embed) plugins.push(embedPlugin);
12205
+ if (config?.embed) plugins.push([embedPlugin, normalizeEmbedOptions(config.embed)]);
12080
12206
  if (config?.mermaid) plugins.push([remarkMermaidDiagrams, normalizeMermaidOptions(config.mermaid)]);
12081
12207
  plugins.push([remarkRehype, { allowDangerousHtml: true }]);
12082
12208
  return plugins;
@@ -12318,6 +12444,8 @@ function calculateReadingTime(text) {
12318
12444
  */
12319
12445
  /** Matches an `<img>` `src` that points at the co-located `images/` dir (relative or root-relative). */
12320
12446
  const RELATIVE_IMAGE_SRC = /(<img\b[^>]*?\bsrc=")(?:\.?\/)?images\//g;
12447
+ /** Matches the `data-embed-src` of an `::embed` facade (value captured for path resolution). */
12448
+ const EMBED_SRC_ATTR = /(\bdata-embed-src=")([^"]*)(")/g;
12321
12449
  /**
12322
12450
  * Build a canonical article URL for a locale + slug.
12323
12451
  *
@@ -12348,6 +12476,56 @@ function rewriteImageUrls(html, slug) {
12348
12476
  return html.replaceAll(RELATIVE_IMAGE_SRC, `$1/${slug}/images/`);
12349
12477
  }
12350
12478
  /**
12479
+ * Resolve an `::embed` `src` to the URL the iframe should load. Absolute targets
12480
+ * (`http(s)://…`, root-relative `/…`) pass through unchanged; a co-located
12481
+ * relative path (`./game/index.html`, `../x`, `game/x`) is resolved against the
12482
+ * article base `/<slug>/` into the single shared absolute path the content-assets
12483
+ * build phase copies the bundle to — so it loads identically from every locale
12484
+ * page (mirroring how co-located images resolve). Any `?query`/`#hash` is
12485
+ * preserved verbatim.
12486
+ *
12487
+ * @param value - The raw `data-embed-src` value.
12488
+ * @param slug - Article directory name.
12489
+ * @returns The resolved embed URL.
12490
+ * @example
12491
+ * ```ts
12492
+ * resolveEmbedSource("./game/index.html", "post"); // "/post/game/index.html"
12493
+ * resolveEmbedSource("https://x.dev/", "post"); // "https://x.dev/"
12494
+ * ```
12495
+ */
12496
+ function resolveEmbedSource(value, slug) {
12497
+ if (/^https?:\/\//i.test(value) || value.startsWith("/")) return value;
12498
+ const tailIndex = value.search(/[?#]/);
12499
+ const rawPath = tailIndex === -1 ? value : value.slice(0, tailIndex);
12500
+ const tail = tailIndex === -1 ? "" : value.slice(tailIndex);
12501
+ const out = [];
12502
+ for (const segment of `${slug}/${rawPath}`.split("/")) {
12503
+ if (segment === "" || segment === ".") continue;
12504
+ if (segment === "..") {
12505
+ out.pop();
12506
+ continue;
12507
+ }
12508
+ out.push(segment);
12509
+ }
12510
+ const trailingSlash = rawPath === "" || rawPath.endsWith("/") ? "/" : "";
12511
+ return `/${out.join("/")}${trailingSlash}${tail}`;
12512
+ }
12513
+ /**
12514
+ * Rewrite every `::embed` facade's relative `data-embed-src` to its shared
12515
+ * absolute `/<slug>/…` path (no-op for already-absolute targets).
12516
+ *
12517
+ * @param html - The rendered article HTML.
12518
+ * @param slug - Article directory name.
12519
+ * @returns The HTML with embed `src`s resolved.
12520
+ * @example
12521
+ * ```ts
12522
+ * rewriteEmbedUrls('<figure data-embed-src="./g/">', "post"); // '… data-embed-src="/post/g/"'
12523
+ * ```
12524
+ */
12525
+ function rewriteEmbedUrls(html, slug) {
12526
+ return html.replaceAll(EMBED_SRC_ATTR, (_match, prefix, value, suffix) => `${prefix}${resolveEmbedSource(value, slug)}${suffix}`);
12527
+ }
12528
+ /**
12351
12529
  * Discover slug-like subdirectories of the content root (direct children not
12352
12530
  * starting with `.` or `_`), sorted alphabetically for deterministic ordering.
12353
12531
  *
@@ -12426,7 +12604,7 @@ function fileSystemContent(options) {
12426
12604
  state.dirtyPaths.delete(filePath);
12427
12605
  const { frontmatter, body } = parseFrontmatter(raw, options);
12428
12606
  const processor = ensureProcessor(state, options);
12429
- const html = rewriteImageUrls(String(await processor.process(body)), slug);
12607
+ const html = rewriteEmbedUrls(rewriteImageUrls(String(await processor.process(body)), slug), slug);
12430
12608
  const { readingTime, wordCount } = calculateReadingTime(body);
12431
12609
  return {
12432
12610
  frontmatter,
@@ -12550,4 +12728,4 @@ const createApp = core.createApp;
12550
12728
  */
12551
12729
  const createPlugin = core.createPlugin;
12552
12730
  //#endregion
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 };
12731
+ 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, EmbedFacadeButton, 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.10.0"
128
+ "version": "1.11.0"
129
129
  }