@takazudo/zfb 0.1.0-next.76 → 0.1.0-next.78

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/config.d.ts CHANGED
@@ -180,6 +180,17 @@ export type ZfbConfig = {
180
180
  * `crates/zfb/src/config.rs`.
181
181
  */
182
182
  prefetch?: PrefetchConfig;
183
+ /**
184
+ * Minify production HTML output from `zfb build`. Default: `false`.
185
+ *
186
+ * The implementation is Rust-only and does not spawn a Node.js minifier
187
+ * subprocess. The first version is intentionally conservative: rendered
188
+ * `.html` pages are candidates, source `.html` passthrough pages remain
189
+ * verbatim, and non-HTML outputs are skipped.
190
+ *
191
+ * Mirrors `Config::minify_html` in `crates/zfb/src/config.rs`.
192
+ */
193
+ minifyHtml?: boolean;
183
194
  /**
184
195
  * Bundler options. `bundle.exclude` lists project-relative globs of
185
196
  * source files to keep out of the esbuild graph (e.g.
@@ -721,15 +732,22 @@ export type FeatureToggle = boolean | FeatureOptions;
721
732
  */
722
733
  export type FeatureOptions = Record<string, never>;
723
734
  /**
724
- * Options for the `githubAutolinks` feature. Requires `repo`.
735
+ * Options for the `githubAutolinks` feature rewrites bare `#123`,
736
+ * `user/repo#456`, and commit-SHA references into GitHub links.
725
737
  *
726
- * TODO: fill in actual fields when the githubAutolinks feature is ported.
738
+ * `repo` is required: `githubAutolinks: {}` (repo absent) is a config error
739
+ * — the Rust pipeline emits a build-blocking diagnostic rather than
740
+ * silently skipping the feature.
727
741
  *
728
- * Mirrors `GithubAutolinksConfig` in crates/zfb/src/config.rs.
742
+ * Mirrors `GithubAutolinksConfig` in `crates/zfb-md-ast/src/features_config.rs`.
729
743
  */
730
744
  export type GithubAutolinksConfig = {
731
- /** GitHub repository reference (`owner/repo`) used to build autolink URLs. */
732
- repo?: string;
745
+ /**
746
+ * GitHub repository reference (`owner/repo`) used to build autolink URLs
747
+ * (e.g. `"owner/repo"` renders `#123` as
748
+ * `https://github.com/owner/repo/issues/123`). Required — see above.
749
+ */
750
+ repo: string;
733
751
  };
734
752
  /**
735
753
  * Options stub for the `codeEnrichment` feature.
@@ -853,9 +871,17 @@ export type MarkdownFeaturesConfig = {
853
871
  tocExport?: TocExportConfig;
854
872
  /** Auto-detect and inject `width`/`height` on `<img>` elements. */
855
873
  imageDimensions?: ImageDimensionsConfig;
856
- /** Validate internal and external links at build time. */
874
+ /**
875
+ * Validate internal links (file-relative paths and anchor fragments) at
876
+ * build time. External URLs are always skipped — network validation is
877
+ * out of scope.
878
+ */
857
879
  linkValidation?: LinkValidationConfig;
858
- /** Transclusion of other MDX files (`![[path]]` syntax). */
880
+ /**
881
+ * Transclusion of other markdown/MDX files via
882
+ * `:::include{file="./path.md"}` — NOT the Obsidian `[[path]]` wikilink
883
+ * syntax.
884
+ */
859
885
  transclude?: TranscludeConfig;
860
886
  /**
861
887
  * Generic `:::name` → component map. You supply the components; no defaults
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,iEAAiE;AACjE,EAAE;AACF,kEAAkE;AAClE,uEAAuE;AACvE,uEAAuE;AACvE,sEAAsE;AACtE,+BAA+B;AAC/B,EAAE;AACF,yEAAyE;AACzE,uEAAuE;AACvE,uEAAuE;AACvE,kEAAkE;AAClE,EAAE;AACF,uEAAuE;AACvE,uEAAuE;AAykCvE;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,MAAiB;IAC5C,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,UAAU,YAAY,CAC1B,aAAqB,EACrB,MAA0B;IAE1B,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;QACpB,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,OAAO;QACL,GAAG,MAAM;QACT,OAAO,EAAE,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE;YACrC,IAAI,MAAM,KAAK,IAAI,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC5E,wEAAwE;gBACxE,mEAAmE;gBACnE,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,GAAG,MAAM,EAAE,CAAC;YACtD,CAAC;YACD,OAAO,MAAM,CAAC;QAChB,CAAC,CAAC;KACH,CAAC;AACJ,CAAC","sourcesContent":["// `zfb/config` — TypeScript helper for the `zfb.config.ts` form.\n//\n// The zfb config loader (`crates/zfb/src/config.rs`) accepts both\n// `zfb.config.ts` and `zfb.config.json`; JSON wins when both files are\n// present, which is the back-compat path for projects predating the TS\n// loader. New projects should prefer the TS form for editor types and\n// `defineConfig` autocomplete.\n//\n// At parse time, zfb bundles the user's `zfb.config.ts` with esbuild and\n// aliases this `zfb/config` import to an internal stub that re-exports\n// `defineConfig` as the identity function — so a user project does not\n// need the `zfb` npm package installed locally just to be parsed.\n//\n// The shape mirrors the Rust `Config` struct one-for-one. Keep them in\n// sync; the `defineConfig` identity helper is the single anchor point.\n\nexport type Framework = \"preact\" | \"react\";\n\nexport type CollectionDef = {\n /** Identifier used at the call site (e.g. `\"blog\"`). */\n name: string;\n /** Directory (relative to the project root) holding the entries. */\n path: string;\n /** Optional schema. Reserved for v1.1 — accepted but not enforced today. */\n schema?: Record<string, unknown>;\n /**\n * Optional include globs (Astro-style, evaluated relative to `path`).\n * When set and non-empty, an entry is kept only if at least one\n * pattern matches its relative path. When omitted or empty, no\n * include-filtering happens. Patterns use the `globset` dialect\n * (Unix-style: `*`, `**`, `?`, `[…]`).\n */\n include?: string[];\n /**\n * Optional exclude globs. When set, an entry is dropped if any\n * pattern matches its relative path. Evaluated AFTER `include`.\n * Together they mirror Astro's `['**\\/*.mdx', '!**\\/*.en.mdx']`\n * convention (zfb splits the negative side into its own field).\n */\n exclude?: string[];\n /**\n * Optional suffix to strip from each kept entry's slug + module\n * specifier. Use with multi-locale layouts where one source\n * directory holds both `foo.mdx` (default locale) and `foo.en.mdx`\n * (locale override) — set `idStripSuffix: \".en\"` so the EN\n * collection's slugs round-trip as `foo` instead of `foo.en`.\n */\n idStripSuffix?: string;\n};\n\nexport type TailwindConfig = {\n /** Whether Tailwind is enabled. Default: `true`. */\n enabled?: boolean;\n};\n\n/**\n * Prefetch options. Mirrors `PrefetchConfig` in `crates/zfb/src/config.rs`.\n */\nexport type PrefetchConfig = {\n /**\n * Disable prefetch entirely.\n *\n * When `true`, the bundler emits `globalThis.__zfb.prefetchDisabled = true`\n * in `entry.mjs`, and `<ClientRouter />` renders\n * `<meta name=\"zfb-prefetch-disabled\" content=\"true\">` in `<head>`.\n * The sibling prefetch-core module reads that meta tag at `init()` time\n * and short-circuits — no prefetch wiring runs.\n *\n * The flag is site-wide and static — set once at bundle-emit time,\n * never recomputed per-page. Default: `false`.\n */\n disabled?: boolean;\n};\n\n/**\n * Bundler options. Mirrors `BundleConfig` in `crates/zfb/src/config.rs`.\n */\nexport type BundleConfig = {\n /**\n * Project-relative glob patterns (gitignore-style) for source files\n * the bundler must NOT pull into the esbuild graph.\n *\n * Why this exists: an eager `import.meta.glob('components/**\\/*.stories.tsx',\n * { eager: true })` expands to a static import of every matched file. If a\n * matched file imports a CJS-only package whose `package.json` resolves only\n * via `main`/`module` or a `require`-only `exports` condition (e.g.\n * `msw` → `path-to-regexp@6`), esbuild — invoked with `--platform=neutral`\n * for the worker bundle — rejects it with \"Could not resolve … Main fields\n * must be configured explicitly when using the neutral platform.\" Listing the\n * offending file here keeps the migration build green.\n *\n * Each pattern is matched against the file's path RELATIVE TO THE PROJECT\n * ROOT, in POSIX form (e.g. `components/Foo.stories.tsx` or\n * `components/**\\/*.stories.tsx`). A matched file is:\n *\n * - never copied/symlinked into the bundler's shadow tree, and\n * - dropped from any eager `import.meta.glob(...)` expansion that would\n * otherwise statically import it.\n *\n * Unset / empty → behaviour is byte-identical to a build without this knob:\n * no files are skipped.\n *\n * Mirrors `Config::bundle` in crates/zfb/src/config.rs.\n */\n exclude?: string[];\n\n /**\n * Explicit esbuild `main-fields` list for the `--platform=neutral` page/SSR\n * pass. Under `neutral` esbuild's main-fields list is EMPTY by default, so a\n * dep resolved purely via `package.json` `main`/`module` (no `exports` map)\n * is rejected (\"The \"main\" field here was ignored. Main fields must be\n * configured explicitly when using the neutral platform.\"). Set e.g.\n * `[\"main\", \"module\"]` to let such CJS-main-only deps resolve (#676 —\n * `msw` → `path-to-regexp@6`). Applies to every framework; unset/empty →\n * byte-identical to a build without the knob (the React-only `main,module`\n * shim still applies).\n *\n * Mirrors `BundleConfig::main_fields` in `crates/zfb/src/config.rs`.\n */\n mainFields?: string[];\n\n /**\n * Bare specifiers to mark external in the `--platform=neutral` page/SSR\n * pass, so esbuild leaves them unbundled instead of resolving them (the\n * other #676 escape hatch — externalize a CJS-only dep rather than\n * resolving it). Appended to the framework-provided externals. Unset/empty\n * → no extra externals.\n *\n * Mirrors `BundleConfig::external` in `crates/zfb/src/config.rs`.\n */\n external?: string[];\n};\n\n/**\n * One plugin entry in `zfb.config.ts`.\n *\n * `name` MUST be a module reference that Node's resolver can locate from\n * the project root. The zfb config loader\n * (`crates/zfb-config-loader/js/config-loader.mjs`) resolves it to an\n * absolute module specifier and the build / dev plugin host loads it via\n * dynamic `import()`:\n *\n * - `\"./plugins/my-plugin.mjs\"` / `\"../shared/plugin.mjs\"` —\n * path-relative to the project root (the dir containing `zfb.config.ts`).\n * - `\"/abs/path/to/plugin.mjs\"` — absolute filesystem path.\n * - `\"@takazudo/zfb-plugin-search\"` / `\"my-plugin\"` — npm bare specifier\n * resolved against the project's `node_modules`.\n *\n * Inline-function hooks are NOT supported; the plugin module's default\n * export must be a [`ZfbPlugin`] (see `@takazudo/zfb/plugins`).\n *\n * `options` is passed verbatim to the plugin's hook contexts; treat\n * the schema as plugin-specific.\n */\nexport type PluginConfig = {\n name: string;\n options?: Record<string, unknown>;\n};\n\nexport type ZfbConfig = {\n /** Output directory for built assets. Default: `dist`. */\n outDir?: string;\n /** Public/static directory copied verbatim. Default: `public`. */\n publicDir?: string;\n /** Optional dev/preview server bind host. */\n host?: string;\n /** Optional dev/preview server port. */\n port?: number;\n /**\n * Host header values the dev/preview server accepts when bound to a\n * non-localhost interface (`--host 0.0.0.0`, the bare `--host` LAN\n * shortcut, or `host` above) — the DNS-rebinding guard, mirroring\n * Vite's `server.allowedHosts`.\n *\n * Defaults: only consulted for non-loopback binds — the default\n * `localhost` bind skips validation entirely. `localhost`, the\n * explicitly bound host, and any IP-literal Host — `127.0.0.1`,\n * `[::1]`, the LAN URLs the startup banner prints — are always\n * allowed (DNS rebinding needs a DNS name, so raw IPs are safe;\n * Vite parity); requests with any other Host get a 403.\n *\n * Matching rules (the request Host's port is stripped first and\n * comparison is case-insensitive):\n *\n * - `\"example.com\"` — matches exactly that host.\n * - `\".example.com\"` (leading dot) — matches `example.com` and every\n * subdomain (`api.example.com`).\n * - IPv6 entries may be written with or without brackets\n * (`\"[::1]\"` / `\"::1\"`).\n *\n * Mirrors `Config::allowed_hosts` in `crates/zfb/src/config.rs`.\n */\n allowedHosts?: string[];\n /** JSX framework runtime. Default: `preact`. */\n framework?: Framework;\n /** Content collections. Mirrors the JSON form one-for-one. */\n collections?: CollectionDef[];\n /** Tailwind options; absent = defaults. */\n tailwind?: TailwindConfig;\n /**\n * Prefetch options. When `disabled: true`, the build emits a meta tag\n * that the runtime's prefetch-core module reads at init time to skip\n * all prefetch wiring. Mirrors `Config::prefetch` in\n * `crates/zfb/src/config.rs`.\n */\n prefetch?: PrefetchConfig;\n /**\n * Bundler options. `bundle.exclude` lists project-relative globs of\n * source files to keep out of the esbuild graph (e.g.\n * `[\"components/*.stories.tsx\"]`) — see {@link BundleConfig.exclude} for\n * why this is needed. Unset → byte-identical to a build without the knob.\n * Mirrors `Config::bundle` in `crates/zfb/src/config.rs`.\n */\n bundle?: BundleConfig;\n /** User-supplied plugins. */\n plugins?: PluginConfig[];\n /**\n * Deploy-target adapter package name. Omit (or `\"none\"`) for a pure\n * static build — any route exporting `prerender = false` is then a\n * hard build error. A package name like\n * `\"@takazudo/zfb-adapter-cloudflare\"` selects the matching adapter,\n * and `zfb build` invokes that package's bin to wrap the SSR bundle\n * into a deploy-ready entry (e.g. `dist/_worker.js` for Cloudflare\n * Workers Static Assets, Pages-compatible).\n *\n * Mirrors `Config::adapter` in crates/zfb/src/config.rs.\n */\n adapter?: string;\n /**\n * Strip `.md` / `.mdx` from internal `<a href>` paths during MDX\n * compilation, and append a trailing `/` so the resulting URL shape\n * converges with the rest of the site (mirrors the JS engine's\n * `rehypeStripMdExtension`). Default: `false`.\n *\n * Enable this when content authors hand-write `[label](other.md)`\n * style references that should resolve to the rendered route URL\n * (e.g. `other/`) instead of a literal file path. Built dist and\n * `pnpm dev` honour the same flag, so previews match shipped output.\n *\n * Mirrors `Config::strip_md_ext` in crates/zfb/src/config.rs.\n */\n stripMdExt?: boolean;\n\n /**\n * Public URL prefix mounted in front of every absolute HTML asset\n * URL the build emits — `<link rel=\"stylesheet\">`, `<script type=\"module\">`,\n * and any other `/assets/...`-prefixed reference rewritten by the\n * production asset pipeline.\n *\n * Use this when the site is deployed under a sub-path (e.g.\n * `https://example.com/pj/zudo-doc/`) instead of the domain root.\n * With `base: \"/pj/zudo-doc/\"` the dist HTML emits\n * `<link rel=\"stylesheet\" href=\"/pj/zudo-doc/assets/styles-<hash>.css\">`\n * instead of the unprefixed `/assets/styles-<hash>.css`.\n *\n * Accepted shapes (all normalised to a single canonical form\n * internally):\n *\n * - omitted / `undefined` / `\"\"` / `\"/\"` — no prefix; behaviour is\n * byte-identical to the pre-`base` build (root-mounted site).\n * - leading-and-trailing-slash path like `\"/pj/zudo-doc/\"` — prefix\n * that path onto every asset URL.\n * - absolute URL like `\"https://cdn.example.com/\"` — emit absolute\n * URLs (CDN-hosted assets).\n *\n * Inputs missing a leading or trailing `/` are normalised at config-\n * load time (paths) or asset-emit time (URL prefixes); callers do\n * not have to pre-trim.\n *\n * Mirrors `Config::base` in crates/zfb/src/config.rs.\n */\n base?: string;\n\n /**\n * Canonical origin URL for the site (e.g. `\"https://example.com\"`).\n *\n * When set, the bundler emits `globalThis.__zfb.site = <value>` in\n * `entry.mjs` so layouts can build canonical `<link>` tags,\n * OpenGraph `og:url` meta, sitemap absolute hrefs, and hreflang\n * `<link rel=\"alternate\">` from a single config-level source of truth.\n *\n * **Distinct from `base`**: `base` is a sub-path mount prefix used\n * for asset URLs (e.g. `\"/pj/my-site/\"`). `site` is the full\n * canonical origin (scheme + host, no path) used to construct\n * absolute page URLs for SEO/social metadata. Both may be set\n * simultaneously.\n *\n * Accepted shape: an absolute HTTP or HTTPS URL. Relative URLs,\n * non-HTTP(S) schemes, and empty strings are rejected at config-load\n * time. Trailing slash normalisation is the consumer's responsibility.\n *\n * When absent, `globalThis.__zfb.site` is not emitted — the build\n * output is byte-for-byte identical to builds without this field.\n *\n * Mirrors `Config::site` in crates/zfb/src/config.rs.\n */\n site?: string;\n\n /**\n * Markdown link resolver (port of `remarkResolveMarkdownLinks`).\n *\n * When `enabled: true`, the build appends `ResolveLinksPlugin` to the\n * mdast pipeline so author-written `[label](./other.mdx)` links are\n * rewritten to the corresponding rendered route URL — bypassing the\n * file→directory transformation that breaks relative paths in dist\n * HTML when `foo.mdx` becomes `foo/index.html`. Extensionless\n * (`./other`) and directory-style (`other/`) targets resolve too,\n * probing `{name}.mdx`, `{name}.md`, `{name}/index.mdx`,\n * `{name}/index.md` in that order. Relative targets resolve from the\n * source file's directory; for a directory-style link written from a\n * non-index page against its rendered URL — which sits one directory\n * deeper, e.g. `../sibling/` from `section/article.mdx` — a URL-space\n * fallback retries the probe from the page's route directory when\n * every file-space candidate misses.\n *\n * Two ways to specify the source dirs:\n *\n * - **Single dir (legacy):** set `docsDir` and the build assumes the\n * `/docs/` route prefix. Convenient for single-locale projects.\n * - **Multi dir (`dirs` non-empty):** explicit `{ dir, routePrefix }`\n * entries — required for any project with locale mirrors (e.g.\n * `docs/` AND `docs-ja/`) so each dir maps to its own route prefix\n * (`/docs/` vs `/ja/docs/`). When `dirs` is non-empty, `docsDir`\n * is ignored.\n *\n * Mirrors `Config::resolve_markdown_links` in crates/zfb/src/config.rs.\n */\n resolveMarkdownLinks?: ResolveMarkdownLinksConfig;\n\n /**\n * Whether the basePath rewriter should append a trailing `/` to\n * extensionless absolute hrefs (`<a href=\"/docs/foo\">` becomes\n * `<a href=\"/pj/zudo-doc/docs/foo/\">` when `base = \"/pj/zudo-doc/\"`\n * and this is `true`).\n *\n * Off by default — preserves byte-for-byte parity with the\n * pre-`trailingSlash` build for projects that haven't opted in.\n * Enable when the deploy target serves canonical URLs with trailing\n * slashes (Cloudflare Pages with `trailingSlash: always`, Netlify\n * pretty URLs, etc.) so the dist HTML doesn't ship non-canonical\n * hrefs that 301-redirect on every click.\n *\n * Only the trailing slash for extensionless hrefs is affected.\n * Hrefs that already end in `/`, that have a file extension\n * (`.png`, `.pdf`, …), or that opt out via `data-no-base` pass\n * through unchanged.\n *\n * Mirrors `Config::trailing_slash` in crates/zfb/src/config.rs.\n */\n trailingSlash?: boolean;\n\n /**\n * Markdown / MDX parsing options. Currently the only knob exposed is\n * [`gfm`](MarkdownConfig.gfm), which toggles GFM constructs\n * (strikethrough, table, autolink-literal, task-list-item,\n * footnote-definition) on or off.\n *\n * Mirrors `Config::markdown` in crates/zfb/src/config.rs.\n */\n markdown?: MarkdownConfig;\n\n /**\n * Extra absolute filesystem paths watched by the dev server in\n * addition to the project-root tree.\n *\n * Use this when project content reads from outside the project root\n * (a sibling knowledge-base repo, a shared filesystem directory, a\n * `file:` dep that ships content alongside code, etc.) and you want\n * `zfb dev` to live-reload when those external files change.\n *\n * Semantics:\n *\n * - Each entry MUST be an absolute path. Relative paths are\n * rejected at config-load time with a clear error message.\n * - Paths are canonicalised when the watcher boots; events match\n * the canonical form.\n * - A path that does NOT exist at boot is skipped with a warning;\n * the watcher does NOT re-watch the path if it appears later.\n * Restart `zfb dev` after creating the path.\n * - Each entry is watched recursively.\n * - Events from outside the project root bypass fine-grained graph\n * classification and may trigger a broader rebuild than equivalent\n * in-tree edits.\n *\n * **Security note:** opt-in only — do NOT point this at unbounded\n * directories like `$HOME` or `/`. On Linux the recursive watcher\n * registers every subdirectory and can hit the inotify\n * `max_user_watches` ceiling on large trees.\n *\n * Mirrors `Config::extra_watch_paths` in crates/zfb/src/config.rs.\n */\n extraWatchPaths?: string[];\n\n /**\n * Whether `zfb build` writes the post-build route manifest to disk\n * at `<outDir>/__zfb/routes.json` (#347).\n *\n * The on-disk file mirrors the in-memory `ctx.routes` shape that the\n * plugin API hands to `postBuild` hooks — same fields, same\n * url-sorted order — so any consumer script wired into `pnpm build`\n * can read the manifest without writing a zfb plugin. The plugin\n * `ctx.routes` and the on-disk `routes.json` are two access shapes\n * over the same data, not two contracts.\n *\n * Default: emit (`undefined` is treated as `true`). Set `false` to\n * skip the write — useful for projects that strip everything but\n * shipped assets out of `dist/` before deploy.\n *\n * Mirrors `Config::emit_routes_manifest` in crates/zfb/src/config.rs.\n */\n emitRoutesManifest?: boolean;\n\n /**\n * Syntect code-highlight options; absent = default theme\n * (`base16-ocean.dark`). See {@link CodeHighlightConfig} for accepted\n * theme names and custom-theme loading.\n *\n * Mirrors `Config::code_highlight` in crates/zfb/src/config.rs.\n */\n codeHighlight?: CodeHighlightConfig;\n\n /**\n * Maximum seconds a single plugin lifecycle hook (preBuild, postBuild,\n * setup, etc.) may run before the build fails with a diagnostic error\n * and the plugin host is force-killed.\n *\n * Absent falls through to the `ZFB_PLUGIN_HOOK_TIMEOUT` env var, then\n * the 120s built-in default. Set this when your plugins do long but\n * bounded work (e.g. large sitemap generation) and you want a tighter\n * or more explicit budget.\n *\n * Mirrors `Config::plugin_hook_timeout_secs` in crates/zfb/src/config.rs.\n */\n pluginHookTimeoutSecs?: number;\n\n /**\n * Whether `copy_public_dir` copies `public/` under the `base`\n * sub-path segment (`true`, default) or flat to the `dist/` root\n * (`false`).\n *\n * - **`true` (default):** files land at\n * `<outDir>/<base-segment>/<rel>`, matching the base-prefixed URLs\n * that `withBase()` emits in the rendered HTML. Use this for\n * projects served directly at their configured sub-path.\n * - **`false`:** files land flat at `<outDir>/<rel>` regardless of\n * `base`. Use this when the deploy pipeline relocates the entire\n * `dist/` tree into the base segment itself (e.g.\n * `cp -a dist/. deploy-root/pj/site/`), so putting the files under\n * `<outDir>/<base>/...` would result in a double-nested path.\n *\n * **Note on `zfb preview`:** with `false`, base-prefixed public-asset\n * URLs 404 under `zfb preview` because the flat copy lives at the\n * dist root and `zfb preview` does not simulate deploy-side\n * relocation. This is a known trade-off of the flat-copy deploy\n * scheme.\n *\n * Mirrors `Config::copy_public_with_base` in crates/zfb/src/config.rs.\n */\n copyPublicWithBase?: boolean;\n\n /**\n * Project output mode. Drives the V8-mode decision the build engine\n * makes right after the no-SSR-without-adapter precondition check\n * (sub-task 4.1b / issue #373):\n *\n * - `\"static\"` — declare a pure-static (SSG-only) project. Errors at\n * build start if any route exports `prerender = false`, pointing\n * at the offending route. Use this on projects that must never\n * accidentally pick up an SSR route as a result of a copy-paste.\n * - `\"hybrid\"` — declare a project that may host SSR routes. V8-on\n * regardless of detection, even when no `prerender = false` route\n * currently exists. Useful for projects that will add SSR routes\n * later and want a stable build topology in the meantime.\n * - `\"auto\"` (default) — detection-driven. Non-empty `prerender =\n * false` route set => V8-on; empty => V8-off.\n *\n * Today's load-bearing role is the `\"static\"` precondition check.\n * The V8-off branch does NOT skip V8 host startup on the shipping\n * `zfb` binary — SSG still needs V8 to render pages. The flag exists\n * as infrastructure for the future shipping path (Tauri sidecar /\n * standalone SSR server). See the\n * [Build engine docs](https://github.com/Takazudo/zudo-front-builder/blob/main/docs/src/content/docs/architecture/build-engine.mdx)\n * for the gate decision table.\n *\n * Mirrors `Config::output` in crates/zfb/src/config.rs.\n */\n output?: OutputMode;\n\n /**\n * Config presets to merge before validation (#1196).\n *\n * Each preset is a partial `ZfbConfig`-shaped object. The merge pass runs\n * BEFORE field validation and folds preset contributions using additive\n * semantics:\n *\n * - **Array fields** (`plugins`, `collections`, `extraWatchPaths`,\n * `allowedHosts`): preset values are prepended so the main config's\n * entries retain their relative position after the preset's.\n * - **Scalar / optional fields**: a preset value fills in only when the\n * main config leaves the field at its default — the main config is\n * authoritative; presets act as defaults.\n *\n * Nested `presets` inside a preset are NOT recursively expanded.\n *\n * Mirrors `Config::presets` in crates/zfb/src/config.rs.\n */\n presets?: Partial<ZfbConfig>[];\n};\n\n/**\n * Project output mode.\n *\n * - `\"static\"` — pure-static (SSG-only); errors on detected SSR routes.\n * - `\"hybrid\"` — may host SSR routes; V8-on regardless of detection.\n * - `\"auto\"` — detection-driven; the default.\n *\n * Mirrors `OutputMode` in crates/zfb/src/config.rs.\n */\nexport type OutputMode = \"static\" | \"hybrid\" | \"auto\";\n\n/**\n * Syntect code-highlight options.\n *\n * Unknown theme names are rejected at build start with a clear error\n * rather than silently falling back.\n *\n * **Single-theme mode** (the default): set `theme` to a syntect theme name,\n * or omit it to use the default (`\"base16-ocean.dark\"`). Tokens are colored\n * with inline `color:`.\n *\n * **Dual-theme mode**: set both `themeLight` and `themeDark`. Tokens are\n * colored with CSS custom properties (`--shiki-light` / `--shiki-dark`),\n * and the consumer applies a `light-dark()` rule to pick the active color.\n * The `<pre>` element carries `class=\"syntect-dual\"` and\n * `--shiki-light-bg` / `--shiki-dark-bg` in its `style` attribute.\n *\n * `theme` and the dual pair are mutually exclusive. Setting only one of\n * `themeLight` / `themeDark` is an error.\n *\n * All theme names are **SYNTECT** built-in or user-loaded names (e.g.\n * `\"base16-ocean.light\"`, `\"base16-ocean.dark\"`, `\"InspiredGitHub\"`,\n * `\"Solarized (dark)\"`), NOT Shiki names like `\"dracula\"`.\n *\n * Mirrors `CodeHighlightConfig` in crates/zfb/src/config.rs.\n */\nexport type CodeHighlightConfig = {\n /**\n * Syntect built-in or user-loaded theme name. When absent the\n * pipeline defaults to `\"base16-ocean.dark\"`.\n *\n * Mutually exclusive with {@link themeLight} / {@link themeDark}.\n * Must be a SYNTECT theme name (e.g. `\"InspiredGitHub\"`), NOT a Shiki name.\n */\n theme?: string;\n /**\n * Path to a directory of `.tmTheme` files, relative to the project\n * root. Every `.tmTheme` file in the directory is loaded and becomes\n * available by its declared `name` via {@link theme}, {@link themeLight},\n * or {@link themeDark}. When absent only syntect's bundled themes are\n * available.\n *\n * The path must be relative and must not escape the project root via\n * `..`. A missing directory is reported as an error at build start.\n *\n * Applies to both single-theme and dual-theme mode.\n */\n themesDir?: string;\n /**\n * Light-mode syntect theme name for dual-theme highlighting.\n *\n * Must be set together with {@link themeDark} — setting only one of\n * the two is a build error. When both are set, tokens are colored with\n * CSS custom properties (`--shiki-light` / `--shiki-dark`) instead of\n * inline `color:`. Mutually exclusive with {@link theme}.\n *\n * Must be a SYNTECT theme name (e.g. `\"base16-ocean.light\"`),\n * NOT a Shiki name like `\"dracula\"`.\n */\n themeLight?: string;\n /**\n * Dark-mode syntect theme name for dual-theme highlighting.\n *\n * Must be set together with {@link themeLight} — setting only one of\n * the two is a build error. Mutually exclusive with {@link theme}.\n *\n * Must be a SYNTECT theme name (e.g. `\"base16-ocean.dark\"`),\n * NOT a Shiki name like `\"dracula\"`.\n */\n themeDark?: string;\n};\n\n/**\n * Table-of-contents options. Wire via `markdown.toc` in `zfb.config.ts`.\n *\n * When present, a TOC `<ul>/<li>` list is inserted as the next sibling\n * of the first heading whose text matches `heading` (case-insensitive).\n * Each `<a href=\"#id\">` links to the deduplicated `id` that\n * `HeadingLinksPlugin` placed on the corresponding heading.\n *\n * Mirrors `TocConfig` in `crates/zfb-content/src/plugins/toc.rs`.\n */\nexport type TocConfig = {\n /**\n * Heading text that triggers TOC insertion. Matched\n * case-insensitively after whitespace trimming. Default: `\"TOC\"`.\n */\n heading?: string;\n\n /**\n * Number of heading levels to include starting from `<h2>`.\n *\n * - `1` — h2 only\n * - `2` (default) — h2 + h3\n * - `3` — h2, h3, h4\n * - …up to `5` (h2 through h6)\n */\n maxDepth?: number;\n};\n\n/**\n * Markdown / MDX parsing options.\n *\n * See [`ZfbConfig.markdown`] for the embed point. Fields: [`gfm`],\n * [`toc`], [`externalLinks`], [`cjkFriendly`], and [`features`].\n * Future markdown knobs would also live here.\n *\n * See the \"Markdown Features\" docs category for the per-feature option\n * reference once individual features are ported.\n *\n * Mirrors `MarkdownConfig` in crates/zfb/src/config.rs.\n */\nexport type MarkdownConfig = {\n /**\n * Enable GFM constructs.\n *\n * Accepts three shapes:\n *\n * - `true` — turn every GFM construct ON (strikethrough, table,\n * autolink-literal, task-list-item, footnote-definition).\n * - `false` — turn every GFM construct OFF.\n * - partial object — set individual fields explicitly; fields you\n * omit fall back to the conservative-default values described\n * below.\n *\n * When `markdown` itself is omitted entirely, the conservative\n * default applies: `strikethrough: true`, `table: true`, every other\n * GFM construct off. This is the smallest behavioural delta from\n * zfb's historical effective state (table-only). Projects that want\n * the full GFM surface should opt in with `gfm: true`.\n */\n gfm?: GfmFlag;\n\n /**\n * Table-of-contents options. When present, a `<ul>/<li>` list is\n * inserted after the first heading whose text matches `heading`\n * (default `\"TOC\"`, case-insensitive). Each link points to the\n * deduplicated `id` that `HeadingLinksPlugin` placed on the heading.\n *\n * Omitting this field entirely leaves the build byte-for-byte identical\n * to the pre-TOC build. See [`TocConfig`] for the available options.\n *\n * Mirrors `MarkdownConfig::toc` in crates/zfb/src/config.rs.\n */\n toc?: TocConfig;\n /**\n * External-link rewriter. When set, every `<a>` whose href is\n * classified as external receives the configured `target` and `rel`\n * attributes.\n *\n * An href is external when it is an absolute HTTP/HTTPS URL AND its\n * origin differs from the top-level `site` URL (if `site` is\n * configured). When `site` is absent, any absolute HTTP/HTTPS URL is\n * treated as external.\n *\n * `mailto:`, `tel:`, and other non-HTTP(S) schemes are always left\n * unchanged. Relative URLs (`/internal/`, `./file.mdx`, `#anchor`) are\n * always internal.\n *\n * Omitting this field keeps the output byte-for-byte identical to the\n * pre-feature behaviour.\n *\n * Mirrors `ExternalLinksConfig` in crates/zfb/src/config.rs.\n */\n externalLinks?: ExternalLinksConfig;\n\n /**\n * Enable CJK-friendly markdown handling.\n *\n * Governs two post-parse fixups that adapt CommonMark/GFM rules to CJK\n * text:\n *\n * 1. **Emphasis/strong flanking** (`CjkFriendlyPlugin`). CommonMark's\n * left-/right-flanking delimiter-run rules treat CJK characters as\n * non-whitespace non-punctuation, which causes `**foo**` adjacent to\n * CJK text (e.g. `**テスト。**テスト`) to render as literal stars\n * instead of `<strong>`.\n * 2. **Bare-URL autolink boundary** (`CjkAutolinkBoundaryPlugin`,\n * zfb#1105). The GFM autolink-literal path grammar terminates only on\n * ASCII whitespace, so a bare URL flush against CJK text\n * (`詳細はhttps://example.com参照`) swallows the trailing CJK run into\n * the `href`. This fixup terminates the link at the first CJK\n * character. Only active when `gfm.autolinkLiteral` is also on.\n *\n * - **absent / `true` (default):** CJK-friendly handling is on.\n * Preserves today's behaviour — existing CJK-content sites are\n * unaffected.\n * - **`false`:** opt-out. Neither plugin is added to the pipeline;\n * emphasis markers and bare-URL autolinks adjacent to CJK characters\n * follow base CommonMark/GFM rules. Rarely the right choice; provided\n * as an escape hatch for projects that need strict CommonMark/GFM\n * output.\n *\n * **GFM strikethrough** (`~~foo~~`) at CJK boundaries is unaffected\n * by this toggle — it is handled by markdown-rs's GFM tokeniser, not\n * by these plugins, and works correctly in both modes.\n *\n * Mirrors `MarkdownConfig::cjk_friendly` in crates/zfb/src/config.rs.\n */\n cjkFriendly?: boolean;\n\n /**\n * Convert every soft line break (a single `\\n` inside a paragraph) into\n * `<br>` (remark-breaks parity).\n *\n * - **absent / `false` (default):** soft line breaks follow standard\n * CommonMark behaviour — collapsed into a single space.\n * - **`true`:** every `\\n` inside a paragraph becomes `<br>`. Use this\n * when your content relies on newline→`<br>` fidelity (e.g. product\n * descriptions, lyrics, or other newline-sensitive prose).\n *\n * Mirrors `MarkdownConfig::hard_breaks` in crates/zfb/src/config.rs.\n */\n hardBreaks?: boolean;\n\n /**\n * Per-feature markdown pipeline toggles.\n *\n * Each field is a [`FeatureToggle`] (`true` / `false` / options object)\n * or a feature-specific config type (for features that require extra\n * parameters). Absent / `undefined` means all features are disabled,\n * preserving the behaviour of the pre-features build byte-for-byte.\n *\n * Unknown keys are rejected at deserialization time by the Rust loader\n * so a typo in `zfb.config.ts` surfaces as a clear error.\n *\n * Mirrors `MarkdownFeaturesConfig` in crates/zfb/src/config.rs.\n */\n features?: MarkdownFeaturesConfig;\n};\n\n/**\n * Per-feature toggle: `boolean` shorthand or an options object.\n *\n * `true` enables the feature with defaults; `false` (or absent) disables it.\n * The object form carries per-feature options (fields vary by feature and\n * are filled in by each feature's port sub-issue — stubs today).\n *\n * Mirrors `FeatureToggle` in crates/zfb/src/config.rs.\n */\nexport type FeatureToggle = boolean | FeatureOptions;\n\n/**\n * Empty options object for features that accept `{ ... }` but have no\n * user-facing knobs yet. Fields are filled in by each feature's port\n * sub-issue; this stub satisfies the schema shape requirement.\n *\n * Mirrors `FeatureOptions` in crates/zfb/src/config.rs.\n */\nexport type FeatureOptions = Record<string, never>;\n\n/**\n * Options for the `githubAutolinks` feature. Requires `repo`.\n *\n * TODO: fill in actual fields when the githubAutolinks feature is ported.\n *\n * Mirrors `GithubAutolinksConfig` in crates/zfb/src/config.rs.\n */\nexport type GithubAutolinksConfig = {\n /** GitHub repository reference (`owner/repo`) used to build autolink URLs. */\n repo?: string;\n};\n\n/**\n * Options stub for the `codeEnrichment` feature.\n *\n * TODO: fill in actual fields when the codeEnrichment feature is ported.\n *\n * Mirrors `CodeEnrichmentConfig` in crates/zfb/src/config.rs.\n */\nexport type CodeEnrichmentConfig = Record<string, never>;\n\n/**\n * Options for the `tocExport` feature.\n *\n * Controls which headings are included in the exported `toc` JSON.\n * `maxDepth` is the **absolute** heading depth (2–6):\n * - `2` → h2 only\n * - `3` (default) → h2 + h3\n *\n * This differs from `headingMarkerToc.maxDepth`, which counts levels\n * starting from h2. The two features are independent.\n *\n * Mirrors `TocExportConfig` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type TocExportConfig = {\n /** Maximum heading depth to include (absolute, 2–6). Default: 3. */\n maxDepth?: number;\n};\n\n/**\n * Options for the `imageDimensions` feature.\n *\n * Auto-detects and injects `width`/`height` on local `<img>` elements. Raster\n * formats are probed header-only; SVGs are read from their markup\n * (`width`/`height`/`viewBox`).\n *\n * Mirrors `ImageDimensionsConfig` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type ImageDimensionsConfig = {\n /**\n * When `true` (the default), `http://` and `https://` image sources are\n * silently skipped and not probed for dimensions. Set to `false` only for\n * testing or unusual setups — remote images require network access at build\n * time and slow the pipeline.\n */\n skipRemote?: boolean;\n};\n\n/**\n * Options for the `linkValidation` feature.\n *\n * Validates internal `[text](file.md#anchor)` and `[text](#anchor)` links at\n * build time. External URLs (`http://`, `https://`, `mailto:`) are always\n * skipped — network validation is out of scope.\n *\n * Mirrors `LinkValidationConfig` in `crates/zfb-md-ast/src/features_config.rs`.\n */\nexport type LinkValidationConfig = {\n /**\n * When `true`, broken links are reported as errors (build can fail).\n * Default: `false` (warn-only).\n */\n failOnBroken?: boolean;\n};\n\n/**\n * Options for the `transclude` feature.\n *\n * Enables `:::include{file=\"./path.md\"}` directives that inline another\n * file's parsed mdast at the include site.\n *\n * Mirrors `TranscludeConfig` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type TranscludeConfig = {\n /**\n * Maximum transclusion depth (chain length A→B→C→…).\n *\n * A depth of `1` allows only direct includes (the included file itself\n * cannot include further files). Default: `5`. A cycle (A→B→A) is\n * always detected regardless of `maxDepth` and treated as an error.\n */\n maxDepth?: number;\n};\n\n/**\n * Options for the `readingTime` feature.\n *\n * Mirrors `ReadingTimeOptions` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type ReadingTimeConfig = {\n /** Words-per-minute rate for the reading-time estimate. Default: 200. */\n wpm?: number;\n};\n\n/**\n * `readingTime` feature value: either a `boolean` shorthand or a\n * {@link ReadingTimeConfig} options object.\n *\n * Mirrors `ReadingTimeFeature` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type ReadingTimeFeature = boolean | ReadingTimeConfig;\n\n/**\n * Per-feature markdown pipeline configuration.\n *\n * All fields are optional; absent = feature disabled, behaviour unchanged\n * from the pre-features build. Unknown keys are rejected at deserialization\n * time by the Rust loader so a typo surfaces as a clear error.\n *\n * Mirrors `MarkdownFeaturesConfig` in crates/zfb/src/config.rs.\n */\nexport type MarkdownFeaturesConfig = {\n /** GitHub-style alert blocks (`> [!NOTE]`, `> [!WARNING]`, etc.). */\n githubAlerts?: FeatureToggle;\n\n /**\n * Reading-time estimate injected into the document frontmatter.\n * Accepts `true` / `false` shorthand or `{ wpm: N }` for a custom rate.\n */\n readingTime?: ReadingTimeFeature;\n\n /** GitHub-style `owner/repo#123` and `SHA` autolinks. Requires `repo`. */\n githubAutolinks?: GithubAutolinksConfig;\n\n /** Code-block enrichment (copy button, language label, etc.). */\n codeEnrichment?: CodeEnrichmentConfig;\n\n /** Grouped code blocks rendered as tabs. */\n codeTabs?: FeatureToggle;\n\n /** Ruby annotation support (`{base}^{ruby}` syntax). */\n ruby?: FeatureToggle;\n\n /** Export the page TOC as structured data (e.g. for sidebar rendering). */\n tocExport?: TocExportConfig;\n\n /** Auto-detect and inject `width`/`height` on `<img>` elements. */\n imageDimensions?: ImageDimensionsConfig;\n\n /** Validate internal and external links at build time. */\n linkValidation?: LinkValidationConfig;\n\n /** Transclusion of other MDX files (`![[path]]` syntax). */\n transclude?: TranscludeConfig;\n\n /**\n * Generic `:::name` → component map. You supply the components; no defaults\n * are registered. Keys are directive names (e.g. `\"foo\"`), values are\n * {@link DirectiveSpec} (bare component name string or options object).\n *\n * Mirrors `directives` in `MarkdownFeaturesConfig` in crates/zfb/src/config.rs.\n */\n directives?: Record<string, DirectiveSpec>;\n\n /** Mermaid diagram rendering. */\n mermaid?: FeatureToggle;\n\n /**\n * Inline heading-marker TOC. Accepts either a `boolean` shorthand\n * (`true` = enable with defaults, `false` = disable) or a full\n * {@link TocConfig} options object — same union shape as the Rust\n * `HeadingMarkerTocFeature` enum.\n */\n headingMarkerToc?: HeadingMarkerTocFeature;\n\n /**\n * Heading-ID strategy for the always-on `HeadingLinks` plugin.\n * Absent → `\"flat\"` (the long-standing github-slugger scheme).\n * `{ strategy: \"hierarchical\" }` opts into ancestor-prefixed anchor\n * IDs (`## Foo` / `### Moo` / `#### Mew` → `foo`, `foo-moo`,\n * `foo-moo-mew`) — see {@link HeadingIdsConfig}.\n */\n headingIds?: HeadingIdsConfig;\n};\n\n/**\n * Options for the `headingIds` entry in `markdown.features`.\n *\n * Configures the always-on `HeadingLinks` plugin rather than toggling an\n * opt-in feature. Note: switching to `\"hierarchical\"` is anchor-breaking\n * for existing deep links to nested headings.\n *\n * Mirrors `HeadingIdsConfig` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type HeadingIdsConfig = {\n /**\n * `\"flat\"` (default): github-slugger slugs with a per-document dedup\n * counter shared across h2–h6 (`overview`, `overview-1`, …).\n * `\"hierarchical\"`: each heading's slug is prefixed with its ancestor\n * chain and deduped on the full path — anchors become reconstructible\n * from the heading outline.\n */\n strategy?: \"flat\" | \"hierarchical\";\n};\n\n/**\n * `headingMarkerToc` feature value: either a `boolean` shorthand or a\n * full {@link TocConfig} options object.\n *\n * Mirrors `HeadingMarkerTocFeature` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type HeadingMarkerTocFeature = boolean | TocConfig;\n\n/**\n * Spec for one user-defined directive: either a bare component name string\n * or a full {@link DirectiveFullSpec} options object.\n *\n * Mirrors `DirectiveSpec` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type DirectiveSpec = string | DirectiveFullSpec;\n\n/**\n * Full options object for one user-defined directive.\n *\n * Mirrors `DirectiveFullSpec` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type DirectiveFullSpec = {\n /** JSX component identifier (e.g. `\"Spoiler\"`, `\"Kbd\"`). */\n component: string;\n /** Container/leaf/text shape. Defaults to `\"container\"` when absent. */\n kind?: \"container\" | \"leaf\" | \"text\";\n /** Whether the bracketed `[label]` becomes a `title` attribute. Defaults to `true`. */\n titleFromLabel?: boolean;\n};\n\n/**\n * Options for the external-link rewriter (port of `rehype-external-links`).\n *\n * All fields are optional; omitting a field applies the documented default.\n *\n * Mirrors `ExternalLinksConfig` in crates/zfb/src/config.rs.\n */\nexport type ExternalLinksConfig = {\n /**\n * `rel` tokens applied to external links.\n *\n * Default: `[\"noopener\", \"noreferrer\"]`.\n *\n * Tokens are deduplicated (case-insensitive) and merged with any\n * existing `rel` attribute on the `<a>` element — existing tokens\n * appear first.\n */\n rel?: string[];\n /**\n * `target` value for external links.\n *\n * Default: `\"_blank\"`.\n */\n target?: string;\n};\n\n/**\n * Either the shorthand boolean form (`true` = all GFM constructs on,\n * `false` = all off) or a partial object that toggles individual\n * constructs.\n *\n * Mirrors `GfmFlag` in crates/zfb/src/config.rs.\n */\nexport type GfmFlag = boolean | GfmConstructs;\n\n/**\n * Per-construct opt-in / opt-out for GFM. Every field is optional;\n * omitted fields fall back to the conservative default\n * (`strikethrough: true`, `table: true`, others `false`).\n *\n * Mirrors `GfmConstructs` in crates/zfb/src/config.rs.\n */\nexport type GfmConstructs = {\n /** GFM strikethrough (`~~text~~` → `<del>text</del>`). */\n strikethrough?: boolean;\n /** GFM pipe-style tables. */\n table?: boolean;\n /**\n * GFM autolink literal — bare URLs like `https://example.com` become\n * clickable links without `<…>` brackets.\n */\n autolinkLiteral?: boolean;\n /** GFM task list items (`- [x]` / `- [ ]`). */\n taskListItem?: boolean;\n /** GFM footnote definitions (`[^ref]: …`). */\n footnoteDefinition?: boolean;\n};\n\n/**\n * What to do when a `.md`/`.mdx` link cannot be resolved.\n *\n * Mirrors `OnBrokenLinks` in crates/zfb/src/config.rs.\n */\nexport type OnBrokenLinks = \"warn\" | \"error\" | \"ignore\";\n\n/**\n * Config for the markdown link resolver. See\n * [`ZfbConfig.resolveMarkdownLinks`] for the design rationale.\n */\nexport type ResolveMarkdownLinksConfig = {\n /** Whether to enable link resolution. Default: `false`. */\n enabled?: boolean;\n\n /**\n * Legacy single-dir field. Used only when [`dirs`] is empty. When\n * non-empty, scanned against the hard-coded `/docs/` route prefix.\n */\n docsDir?: string;\n\n /**\n * Explicit per-dir source map. Each entry is one collection (e.g.\n * EN docs at `src/content/docs/` → `/docs/`, JA docs at\n * `src/content/docs-ja/` → `/ja/docs/`). Takes precedence over\n * [`docsDir`] when non-empty.\n */\n dirs?: ResolveMarkdownLinksDir[];\n\n /** What to do with unresolved `.md`/`.mdx` links. Default: `\"warn\"`. */\n onBrokenLinks?: OnBrokenLinks;\n};\n\n/** One source-dir entry for [`ResolveMarkdownLinksConfig.dirs`]. */\nexport type ResolveMarkdownLinksDir = {\n /**\n * Directory (relative to project root) whose `.md`/`.mdx` files are\n * scanned. Must be relative and must not escape the root via `..`.\n */\n dir: string;\n\n /**\n * Route prefix prepended to each file's slug. Include leading and\n * trailing slashes (e.g. `\"/docs/\"` or `\"/ja/docs/\"`).\n */\n routePrefix: string;\n};\n\n/**\n * Identity helper: returns the supplied config as-is, but typed against\n * [`ZfbConfig`]. Use as the default export of `zfb.config.ts` so editors\n * surface field-level types and typos surface at compile time.\n */\nexport function defineConfig(config: ZfbConfig): ZfbConfig {\n return config;\n}\n\n/**\n * Preset authoring helper: stamps each object entry in `config.plugins`\n * with `source_package: sourcePackage` so the Rust loader can attribute\n * plugin contributions back to the preset package that provided them.\n *\n * - Only plain-object plugin entries are stamped; non-object entries pass\n * through unchanged (defensive — the current schema requires objects,\n * but this guard keeps the helper safe if the schema is ever relaxed).\n * - An entry that ALREADY carries a `source_package` is left untouched, so a\n * preset composing another `definePreset`-returned preset (by spreading its\n * `plugins`) keeps the inner preset's provenance instead of clobbering it\n * with the outer package name (the spread below lets the existing marker win).\n * - When `config.plugins` is absent, the config is returned as-is.\n * - All other fields of `config` pass through unchanged.\n *\n * The key `source_package` (snake_case) mirrors the Rust `PluginConfig`\n * serde field added in T4. `PluginConfig` has no `#[serde(rename_all)]`\n * so the serde key is the field name verbatim — do NOT use camelCase.\n *\n * SYNC REQUIREMENT: keep this implementation behaviourally identical to\n * the stub in crates/zfb-config-loader/js/zfb-config-stub.mjs, which is\n * injected at config-eval time when the user's project does not have the\n * zfb npm package installed locally.\n */\nexport function definePreset(\n sourcePackage: string,\n config: Partial<ZfbConfig>,\n): Partial<ZfbConfig> {\n if (!config.plugins) {\n return config;\n }\n return {\n ...config,\n plugins: config.plugins.map((plugin) => {\n if (plugin !== null && typeof plugin === \"object\" && !Array.isArray(plugin)) {\n // Default first, then spread the plugin so an existing `source_package`\n // (from a composed inner preset) wins over the outer package name.\n return { source_package: sourcePackage, ...plugin };\n }\n return plugin;\n }),\n };\n}\n"]}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,iEAAiE;AACjE,EAAE;AACF,kEAAkE;AAClE,uEAAuE;AACvE,uEAAuE;AACvE,sEAAsE;AACtE,+BAA+B;AAC/B,EAAE;AACF,yEAAyE;AACzE,uEAAuE;AACvE,uEAAuE;AACvE,kEAAkE;AAClE,EAAE;AACF,uEAAuE;AACvE,uEAAuE;AAmmCvE;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,MAAiB;IAC5C,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,UAAU,YAAY,CAC1B,aAAqB,EACrB,MAA0B;IAE1B,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;QACpB,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,OAAO;QACL,GAAG,MAAM;QACT,OAAO,EAAE,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE;YACrC,IAAI,MAAM,KAAK,IAAI,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC5E,wEAAwE;gBACxE,mEAAmE;gBACnE,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,GAAG,MAAM,EAAE,CAAC;YACtD,CAAC;YACD,OAAO,MAAM,CAAC;QAChB,CAAC,CAAC;KACH,CAAC;AACJ,CAAC","sourcesContent":["// `zfb/config` — TypeScript helper for the `zfb.config.ts` form.\n//\n// The zfb config loader (`crates/zfb/src/config.rs`) accepts both\n// `zfb.config.ts` and `zfb.config.json`; JSON wins when both files are\n// present, which is the back-compat path for projects predating the TS\n// loader. New projects should prefer the TS form for editor types and\n// `defineConfig` autocomplete.\n//\n// At parse time, zfb bundles the user's `zfb.config.ts` with esbuild and\n// aliases this `zfb/config` import to an internal stub that re-exports\n// `defineConfig` as the identity function — so a user project does not\n// need the `zfb` npm package installed locally just to be parsed.\n//\n// The shape mirrors the Rust `Config` struct one-for-one. Keep them in\n// sync; the `defineConfig` identity helper is the single anchor point.\n\nexport type Framework = \"preact\" | \"react\";\n\nexport type CollectionDef = {\n /** Identifier used at the call site (e.g. `\"blog\"`). */\n name: string;\n /** Directory (relative to the project root) holding the entries. */\n path: string;\n /** Optional schema. Reserved for v1.1 — accepted but not enforced today. */\n schema?: Record<string, unknown>;\n /**\n * Optional include globs (Astro-style, evaluated relative to `path`).\n * When set and non-empty, an entry is kept only if at least one\n * pattern matches its relative path. When omitted or empty, no\n * include-filtering happens. Patterns use the `globset` dialect\n * (Unix-style: `*`, `**`, `?`, `[…]`).\n */\n include?: string[];\n /**\n * Optional exclude globs. When set, an entry is dropped if any\n * pattern matches its relative path. Evaluated AFTER `include`.\n * Together they mirror Astro's `['**\\/*.mdx', '!**\\/*.en.mdx']`\n * convention (zfb splits the negative side into its own field).\n */\n exclude?: string[];\n /**\n * Optional suffix to strip from each kept entry's slug + module\n * specifier. Use with multi-locale layouts where one source\n * directory holds both `foo.mdx` (default locale) and `foo.en.mdx`\n * (locale override) — set `idStripSuffix: \".en\"` so the EN\n * collection's slugs round-trip as `foo` instead of `foo.en`.\n */\n idStripSuffix?: string;\n};\n\nexport type TailwindConfig = {\n /** Whether Tailwind is enabled. Default: `true`. */\n enabled?: boolean;\n};\n\n/**\n * Prefetch options. Mirrors `PrefetchConfig` in `crates/zfb/src/config.rs`.\n */\nexport type PrefetchConfig = {\n /**\n * Disable prefetch entirely.\n *\n * When `true`, the bundler emits `globalThis.__zfb.prefetchDisabled = true`\n * in `entry.mjs`, and `<ClientRouter />` renders\n * `<meta name=\"zfb-prefetch-disabled\" content=\"true\">` in `<head>`.\n * The sibling prefetch-core module reads that meta tag at `init()` time\n * and short-circuits — no prefetch wiring runs.\n *\n * The flag is site-wide and static — set once at bundle-emit time,\n * never recomputed per-page. Default: `false`.\n */\n disabled?: boolean;\n};\n\n/**\n * Bundler options. Mirrors `BundleConfig` in `crates/zfb/src/config.rs`.\n */\nexport type BundleConfig = {\n /**\n * Project-relative glob patterns (gitignore-style) for source files\n * the bundler must NOT pull into the esbuild graph.\n *\n * Why this exists: an eager `import.meta.glob('components/**\\/*.stories.tsx',\n * { eager: true })` expands to a static import of every matched file. If a\n * matched file imports a CJS-only package whose `package.json` resolves only\n * via `main`/`module` or a `require`-only `exports` condition (e.g.\n * `msw` → `path-to-regexp@6`), esbuild — invoked with `--platform=neutral`\n * for the worker bundle — rejects it with \"Could not resolve … Main fields\n * must be configured explicitly when using the neutral platform.\" Listing the\n * offending file here keeps the migration build green.\n *\n * Each pattern is matched against the file's path RELATIVE TO THE PROJECT\n * ROOT, in POSIX form (e.g. `components/Foo.stories.tsx` or\n * `components/**\\/*.stories.tsx`). A matched file is:\n *\n * - never copied/symlinked into the bundler's shadow tree, and\n * - dropped from any eager `import.meta.glob(...)` expansion that would\n * otherwise statically import it.\n *\n * Unset / empty → behaviour is byte-identical to a build without this knob:\n * no files are skipped.\n *\n * Mirrors `Config::bundle` in crates/zfb/src/config.rs.\n */\n exclude?: string[];\n\n /**\n * Explicit esbuild `main-fields` list for the `--platform=neutral` page/SSR\n * pass. Under `neutral` esbuild's main-fields list is EMPTY by default, so a\n * dep resolved purely via `package.json` `main`/`module` (no `exports` map)\n * is rejected (\"The \"main\" field here was ignored. Main fields must be\n * configured explicitly when using the neutral platform.\"). Set e.g.\n * `[\"main\", \"module\"]` to let such CJS-main-only deps resolve (#676 —\n * `msw` → `path-to-regexp@6`). Applies to every framework; unset/empty →\n * byte-identical to a build without the knob (the React-only `main,module`\n * shim still applies).\n *\n * Mirrors `BundleConfig::main_fields` in `crates/zfb/src/config.rs`.\n */\n mainFields?: string[];\n\n /**\n * Bare specifiers to mark external in the `--platform=neutral` page/SSR\n * pass, so esbuild leaves them unbundled instead of resolving them (the\n * other #676 escape hatch — externalize a CJS-only dep rather than\n * resolving it). Appended to the framework-provided externals. Unset/empty\n * → no extra externals.\n *\n * Mirrors `BundleConfig::external` in `crates/zfb/src/config.rs`.\n */\n external?: string[];\n};\n\n/**\n * One plugin entry in `zfb.config.ts`.\n *\n * `name` MUST be a module reference that Node's resolver can locate from\n * the project root. The zfb config loader\n * (`crates/zfb-config-loader/js/config-loader.mjs`) resolves it to an\n * absolute module specifier and the build / dev plugin host loads it via\n * dynamic `import()`:\n *\n * - `\"./plugins/my-plugin.mjs\"` / `\"../shared/plugin.mjs\"` —\n * path-relative to the project root (the dir containing `zfb.config.ts`).\n * - `\"/abs/path/to/plugin.mjs\"` — absolute filesystem path.\n * - `\"@takazudo/zfb-plugin-search\"` / `\"my-plugin\"` — npm bare specifier\n * resolved against the project's `node_modules`.\n *\n * Inline-function hooks are NOT supported; the plugin module's default\n * export must be a [`ZfbPlugin`] (see `@takazudo/zfb/plugins`).\n *\n * `options` is passed verbatim to the plugin's hook contexts; treat\n * the schema as plugin-specific.\n */\nexport type PluginConfig = {\n name: string;\n options?: Record<string, unknown>;\n};\n\nexport type ZfbConfig = {\n /** Output directory for built assets. Default: `dist`. */\n outDir?: string;\n /** Public/static directory copied verbatim. Default: `public`. */\n publicDir?: string;\n /** Optional dev/preview server bind host. */\n host?: string;\n /** Optional dev/preview server port. */\n port?: number;\n /**\n * Host header values the dev/preview server accepts when bound to a\n * non-localhost interface (`--host 0.0.0.0`, the bare `--host` LAN\n * shortcut, or `host` above) — the DNS-rebinding guard, mirroring\n * Vite's `server.allowedHosts`.\n *\n * Defaults: only consulted for non-loopback binds — the default\n * `localhost` bind skips validation entirely. `localhost`, the\n * explicitly bound host, and any IP-literal Host — `127.0.0.1`,\n * `[::1]`, the LAN URLs the startup banner prints — are always\n * allowed (DNS rebinding needs a DNS name, so raw IPs are safe;\n * Vite parity); requests with any other Host get a 403.\n *\n * Matching rules (the request Host's port is stripped first and\n * comparison is case-insensitive):\n *\n * - `\"example.com\"` — matches exactly that host.\n * - `\".example.com\"` (leading dot) — matches `example.com` and every\n * subdomain (`api.example.com`).\n * - IPv6 entries may be written with or without brackets\n * (`\"[::1]\"` / `\"::1\"`).\n *\n * Mirrors `Config::allowed_hosts` in `crates/zfb/src/config.rs`.\n */\n allowedHosts?: string[];\n /** JSX framework runtime. Default: `preact`. */\n framework?: Framework;\n /** Content collections. Mirrors the JSON form one-for-one. */\n collections?: CollectionDef[];\n /** Tailwind options; absent = defaults. */\n tailwind?: TailwindConfig;\n /**\n * Prefetch options. When `disabled: true`, the build emits a meta tag\n * that the runtime's prefetch-core module reads at init time to skip\n * all prefetch wiring. Mirrors `Config::prefetch` in\n * `crates/zfb/src/config.rs`.\n */\n prefetch?: PrefetchConfig;\n /**\n * Minify production HTML output from `zfb build`. Default: `false`.\n *\n * The implementation is Rust-only and does not spawn a Node.js minifier\n * subprocess. The first version is intentionally conservative: rendered\n * `.html` pages are candidates, source `.html` passthrough pages remain\n * verbatim, and non-HTML outputs are skipped.\n *\n * Mirrors `Config::minify_html` in `crates/zfb/src/config.rs`.\n */\n minifyHtml?: boolean;\n /**\n * Bundler options. `bundle.exclude` lists project-relative globs of\n * source files to keep out of the esbuild graph (e.g.\n * `[\"components/*.stories.tsx\"]`) — see {@link BundleConfig.exclude} for\n * why this is needed. Unset → byte-identical to a build without the knob.\n * Mirrors `Config::bundle` in `crates/zfb/src/config.rs`.\n */\n bundle?: BundleConfig;\n /** User-supplied plugins. */\n plugins?: PluginConfig[];\n /**\n * Deploy-target adapter package name. Omit (or `\"none\"`) for a pure\n * static build — any route exporting `prerender = false` is then a\n * hard build error. A package name like\n * `\"@takazudo/zfb-adapter-cloudflare\"` selects the matching adapter,\n * and `zfb build` invokes that package's bin to wrap the SSR bundle\n * into a deploy-ready entry (e.g. `dist/_worker.js` for Cloudflare\n * Workers Static Assets, Pages-compatible).\n *\n * Mirrors `Config::adapter` in crates/zfb/src/config.rs.\n */\n adapter?: string;\n /**\n * Strip `.md` / `.mdx` from internal `<a href>` paths during MDX\n * compilation, and append a trailing `/` so the resulting URL shape\n * converges with the rest of the site (mirrors the JS engine's\n * `rehypeStripMdExtension`). Default: `false`.\n *\n * Enable this when content authors hand-write `[label](other.md)`\n * style references that should resolve to the rendered route URL\n * (e.g. `other/`) instead of a literal file path. Built dist and\n * `pnpm dev` honour the same flag, so previews match shipped output.\n *\n * Mirrors `Config::strip_md_ext` in crates/zfb/src/config.rs.\n */\n stripMdExt?: boolean;\n\n /**\n * Public URL prefix mounted in front of every absolute HTML asset\n * URL the build emits — `<link rel=\"stylesheet\">`, `<script type=\"module\">`,\n * and any other `/assets/...`-prefixed reference rewritten by the\n * production asset pipeline.\n *\n * Use this when the site is deployed under a sub-path (e.g.\n * `https://example.com/pj/zudo-doc/`) instead of the domain root.\n * With `base: \"/pj/zudo-doc/\"` the dist HTML emits\n * `<link rel=\"stylesheet\" href=\"/pj/zudo-doc/assets/styles-<hash>.css\">`\n * instead of the unprefixed `/assets/styles-<hash>.css`.\n *\n * Accepted shapes (all normalised to a single canonical form\n * internally):\n *\n * - omitted / `undefined` / `\"\"` / `\"/\"` — no prefix; behaviour is\n * byte-identical to the pre-`base` build (root-mounted site).\n * - leading-and-trailing-slash path like `\"/pj/zudo-doc/\"` — prefix\n * that path onto every asset URL.\n * - absolute URL like `\"https://cdn.example.com/\"` — emit absolute\n * URLs (CDN-hosted assets).\n *\n * Inputs missing a leading or trailing `/` are normalised at config-\n * load time (paths) or asset-emit time (URL prefixes); callers do\n * not have to pre-trim.\n *\n * Mirrors `Config::base` in crates/zfb/src/config.rs.\n */\n base?: string;\n\n /**\n * Canonical origin URL for the site (e.g. `\"https://example.com\"`).\n *\n * When set, the bundler emits `globalThis.__zfb.site = <value>` in\n * `entry.mjs` so layouts can build canonical `<link>` tags,\n * OpenGraph `og:url` meta, sitemap absolute hrefs, and hreflang\n * `<link rel=\"alternate\">` from a single config-level source of truth.\n *\n * **Distinct from `base`**: `base` is a sub-path mount prefix used\n * for asset URLs (e.g. `\"/pj/my-site/\"`). `site` is the full\n * canonical origin (scheme + host, no path) used to construct\n * absolute page URLs for SEO/social metadata. Both may be set\n * simultaneously.\n *\n * Accepted shape: an absolute HTTP or HTTPS URL. Relative URLs,\n * non-HTTP(S) schemes, and empty strings are rejected at config-load\n * time. Trailing slash normalisation is the consumer's responsibility.\n *\n * When absent, `globalThis.__zfb.site` is not emitted — the build\n * output is byte-for-byte identical to builds without this field.\n *\n * Mirrors `Config::site` in crates/zfb/src/config.rs.\n */\n site?: string;\n\n /**\n * Markdown link resolver (port of `remarkResolveMarkdownLinks`).\n *\n * When `enabled: true`, the build appends `ResolveLinksPlugin` to the\n * mdast pipeline so author-written `[label](./other.mdx)` links are\n * rewritten to the corresponding rendered route URL — bypassing the\n * file→directory transformation that breaks relative paths in dist\n * HTML when `foo.mdx` becomes `foo/index.html`. Extensionless\n * (`./other`) and directory-style (`other/`) targets resolve too,\n * probing `{name}.mdx`, `{name}.md`, `{name}/index.mdx`,\n * `{name}/index.md` in that order. Relative targets resolve from the\n * source file's directory; for a directory-style link written from a\n * non-index page against its rendered URL — which sits one directory\n * deeper, e.g. `../sibling/` from `section/article.mdx` — a URL-space\n * fallback retries the probe from the page's route directory when\n * every file-space candidate misses.\n *\n * Two ways to specify the source dirs:\n *\n * - **Single dir (legacy):** set `docsDir` and the build assumes the\n * `/docs/` route prefix. Convenient for single-locale projects.\n * - **Multi dir (`dirs` non-empty):** explicit `{ dir, routePrefix }`\n * entries — required for any project with locale mirrors (e.g.\n * `docs/` AND `docs-ja/`) so each dir maps to its own route prefix\n * (`/docs/` vs `/ja/docs/`). When `dirs` is non-empty, `docsDir`\n * is ignored.\n *\n * Mirrors `Config::resolve_markdown_links` in crates/zfb/src/config.rs.\n */\n resolveMarkdownLinks?: ResolveMarkdownLinksConfig;\n\n /**\n * Whether the basePath rewriter should append a trailing `/` to\n * extensionless absolute hrefs (`<a href=\"/docs/foo\">` becomes\n * `<a href=\"/pj/zudo-doc/docs/foo/\">` when `base = \"/pj/zudo-doc/\"`\n * and this is `true`).\n *\n * Off by default — preserves byte-for-byte parity with the\n * pre-`trailingSlash` build for projects that haven't opted in.\n * Enable when the deploy target serves canonical URLs with trailing\n * slashes (Cloudflare Pages with `trailingSlash: always`, Netlify\n * pretty URLs, etc.) so the dist HTML doesn't ship non-canonical\n * hrefs that 301-redirect on every click.\n *\n * Only the trailing slash for extensionless hrefs is affected.\n * Hrefs that already end in `/`, that have a file extension\n * (`.png`, `.pdf`, …), or that opt out via `data-no-base` pass\n * through unchanged.\n *\n * Mirrors `Config::trailing_slash` in crates/zfb/src/config.rs.\n */\n trailingSlash?: boolean;\n\n /**\n * Markdown / MDX parsing options. Currently the only knob exposed is\n * [`gfm`](MarkdownConfig.gfm), which toggles GFM constructs\n * (strikethrough, table, autolink-literal, task-list-item,\n * footnote-definition) on or off.\n *\n * Mirrors `Config::markdown` in crates/zfb/src/config.rs.\n */\n markdown?: MarkdownConfig;\n\n /**\n * Extra absolute filesystem paths watched by the dev server in\n * addition to the project-root tree.\n *\n * Use this when project content reads from outside the project root\n * (a sibling knowledge-base repo, a shared filesystem directory, a\n * `file:` dep that ships content alongside code, etc.) and you want\n * `zfb dev` to live-reload when those external files change.\n *\n * Semantics:\n *\n * - Each entry MUST be an absolute path. Relative paths are\n * rejected at config-load time with a clear error message.\n * - Paths are canonicalised when the watcher boots; events match\n * the canonical form.\n * - A path that does NOT exist at boot is skipped with a warning;\n * the watcher does NOT re-watch the path if it appears later.\n * Restart `zfb dev` after creating the path.\n * - Each entry is watched recursively.\n * - Events from outside the project root bypass fine-grained graph\n * classification and may trigger a broader rebuild than equivalent\n * in-tree edits.\n *\n * **Security note:** opt-in only — do NOT point this at unbounded\n * directories like `$HOME` or `/`. On Linux the recursive watcher\n * registers every subdirectory and can hit the inotify\n * `max_user_watches` ceiling on large trees.\n *\n * Mirrors `Config::extra_watch_paths` in crates/zfb/src/config.rs.\n */\n extraWatchPaths?: string[];\n\n /**\n * Whether `zfb build` writes the post-build route manifest to disk\n * at `<outDir>/__zfb/routes.json` (#347).\n *\n * The on-disk file mirrors the in-memory `ctx.routes` shape that the\n * plugin API hands to `postBuild` hooks — same fields, same\n * url-sorted order — so any consumer script wired into `pnpm build`\n * can read the manifest without writing a zfb plugin. The plugin\n * `ctx.routes` and the on-disk `routes.json` are two access shapes\n * over the same data, not two contracts.\n *\n * Default: emit (`undefined` is treated as `true`). Set `false` to\n * skip the write — useful for projects that strip everything but\n * shipped assets out of `dist/` before deploy.\n *\n * Mirrors `Config::emit_routes_manifest` in crates/zfb/src/config.rs.\n */\n emitRoutesManifest?: boolean;\n\n /**\n * Syntect code-highlight options; absent = default theme\n * (`base16-ocean.dark`). See {@link CodeHighlightConfig} for accepted\n * theme names and custom-theme loading.\n *\n * Mirrors `Config::code_highlight` in crates/zfb/src/config.rs.\n */\n codeHighlight?: CodeHighlightConfig;\n\n /**\n * Maximum seconds a single plugin lifecycle hook (preBuild, postBuild,\n * setup, etc.) may run before the build fails with a diagnostic error\n * and the plugin host is force-killed.\n *\n * Absent falls through to the `ZFB_PLUGIN_HOOK_TIMEOUT` env var, then\n * the 120s built-in default. Set this when your plugins do long but\n * bounded work (e.g. large sitemap generation) and you want a tighter\n * or more explicit budget.\n *\n * Mirrors `Config::plugin_hook_timeout_secs` in crates/zfb/src/config.rs.\n */\n pluginHookTimeoutSecs?: number;\n\n /**\n * Whether `copy_public_dir` copies `public/` under the `base`\n * sub-path segment (`true`, default) or flat to the `dist/` root\n * (`false`).\n *\n * - **`true` (default):** files land at\n * `<outDir>/<base-segment>/<rel>`, matching the base-prefixed URLs\n * that `withBase()` emits in the rendered HTML. Use this for\n * projects served directly at their configured sub-path.\n * - **`false`:** files land flat at `<outDir>/<rel>` regardless of\n * `base`. Use this when the deploy pipeline relocates the entire\n * `dist/` tree into the base segment itself (e.g.\n * `cp -a dist/. deploy-root/pj/site/`), so putting the files under\n * `<outDir>/<base>/...` would result in a double-nested path.\n *\n * **Note on `zfb preview`:** with `false`, base-prefixed public-asset\n * URLs 404 under `zfb preview` because the flat copy lives at the\n * dist root and `zfb preview` does not simulate deploy-side\n * relocation. This is a known trade-off of the flat-copy deploy\n * scheme.\n *\n * Mirrors `Config::copy_public_with_base` in crates/zfb/src/config.rs.\n */\n copyPublicWithBase?: boolean;\n\n /**\n * Project output mode. Drives the V8-mode decision the build engine\n * makes right after the no-SSR-without-adapter precondition check\n * (sub-task 4.1b / issue #373):\n *\n * - `\"static\"` — declare a pure-static (SSG-only) project. Errors at\n * build start if any route exports `prerender = false`, pointing\n * at the offending route. Use this on projects that must never\n * accidentally pick up an SSR route as a result of a copy-paste.\n * - `\"hybrid\"` — declare a project that may host SSR routes. V8-on\n * regardless of detection, even when no `prerender = false` route\n * currently exists. Useful for projects that will add SSR routes\n * later and want a stable build topology in the meantime.\n * - `\"auto\"` (default) — detection-driven. Non-empty `prerender =\n * false` route set => V8-on; empty => V8-off.\n *\n * Today's load-bearing role is the `\"static\"` precondition check.\n * The V8-off branch does NOT skip V8 host startup on the shipping\n * `zfb` binary — SSG still needs V8 to render pages. The flag exists\n * as infrastructure for the future shipping path (Tauri sidecar /\n * standalone SSR server). See the\n * [Build engine docs](https://github.com/Takazudo/zudo-front-builder/blob/main/docs/src/content/docs/architecture/build-engine.mdx)\n * for the gate decision table.\n *\n * Mirrors `Config::output` in crates/zfb/src/config.rs.\n */\n output?: OutputMode;\n\n /**\n * Config presets to merge before validation (#1196).\n *\n * Each preset is a partial `ZfbConfig`-shaped object. The merge pass runs\n * BEFORE field validation and folds preset contributions using additive\n * semantics:\n *\n * - **Array fields** (`plugins`, `collections`, `extraWatchPaths`,\n * `allowedHosts`): preset values are prepended so the main config's\n * entries retain their relative position after the preset's.\n * - **Scalar / optional fields**: a preset value fills in only when the\n * main config leaves the field at its default — the main config is\n * authoritative; presets act as defaults.\n *\n * Nested `presets` inside a preset are NOT recursively expanded.\n *\n * Mirrors `Config::presets` in crates/zfb/src/config.rs.\n */\n presets?: Partial<ZfbConfig>[];\n};\n\n/**\n * Project output mode.\n *\n * - `\"static\"` — pure-static (SSG-only); errors on detected SSR routes.\n * - `\"hybrid\"` — may host SSR routes; V8-on regardless of detection.\n * - `\"auto\"` — detection-driven; the default.\n *\n * Mirrors `OutputMode` in crates/zfb/src/config.rs.\n */\nexport type OutputMode = \"static\" | \"hybrid\" | \"auto\";\n\n/**\n * Syntect code-highlight options.\n *\n * Unknown theme names are rejected at build start with a clear error\n * rather than silently falling back.\n *\n * **Single-theme mode** (the default): set `theme` to a syntect theme name,\n * or omit it to use the default (`\"base16-ocean.dark\"`). Tokens are colored\n * with inline `color:`.\n *\n * **Dual-theme mode**: set both `themeLight` and `themeDark`. Tokens are\n * colored with CSS custom properties (`--shiki-light` / `--shiki-dark`),\n * and the consumer applies a `light-dark()` rule to pick the active color.\n * The `<pre>` element carries `class=\"syntect-dual\"` and\n * `--shiki-light-bg` / `--shiki-dark-bg` in its `style` attribute.\n *\n * `theme` and the dual pair are mutually exclusive. Setting only one of\n * `themeLight` / `themeDark` is an error.\n *\n * All theme names are **SYNTECT** built-in or user-loaded names (e.g.\n * `\"base16-ocean.light\"`, `\"base16-ocean.dark\"`, `\"InspiredGitHub\"`,\n * `\"Solarized (dark)\"`), NOT Shiki names like `\"dracula\"`.\n *\n * Mirrors `CodeHighlightConfig` in crates/zfb/src/config.rs.\n */\nexport type CodeHighlightConfig = {\n /**\n * Syntect built-in or user-loaded theme name. When absent the\n * pipeline defaults to `\"base16-ocean.dark\"`.\n *\n * Mutually exclusive with {@link themeLight} / {@link themeDark}.\n * Must be a SYNTECT theme name (e.g. `\"InspiredGitHub\"`), NOT a Shiki name.\n */\n theme?: string;\n /**\n * Path to a directory of `.tmTheme` files, relative to the project\n * root. Every `.tmTheme` file in the directory is loaded and becomes\n * available by its declared `name` via {@link theme}, {@link themeLight},\n * or {@link themeDark}. When absent only syntect's bundled themes are\n * available.\n *\n * The path must be relative and must not escape the project root via\n * `..`. A missing directory is reported as an error at build start.\n *\n * Applies to both single-theme and dual-theme mode.\n */\n themesDir?: string;\n /**\n * Light-mode syntect theme name for dual-theme highlighting.\n *\n * Must be set together with {@link themeDark} — setting only one of\n * the two is a build error. When both are set, tokens are colored with\n * CSS custom properties (`--shiki-light` / `--shiki-dark`) instead of\n * inline `color:`. Mutually exclusive with {@link theme}.\n *\n * Must be a SYNTECT theme name (e.g. `\"base16-ocean.light\"`),\n * NOT a Shiki name like `\"dracula\"`.\n */\n themeLight?: string;\n /**\n * Dark-mode syntect theme name for dual-theme highlighting.\n *\n * Must be set together with {@link themeLight} — setting only one of\n * the two is a build error. Mutually exclusive with {@link theme}.\n *\n * Must be a SYNTECT theme name (e.g. `\"base16-ocean.dark\"`),\n * NOT a Shiki name like `\"dracula\"`.\n */\n themeDark?: string;\n};\n\n/**\n * Table-of-contents options. Wire via `markdown.toc` in `zfb.config.ts`.\n *\n * When present, a TOC `<ul>/<li>` list is inserted as the next sibling\n * of the first heading whose text matches `heading` (case-insensitive).\n * Each `<a href=\"#id\">` links to the deduplicated `id` that\n * `HeadingLinksPlugin` placed on the corresponding heading.\n *\n * Mirrors `TocConfig` in `crates/zfb-content/src/plugins/toc.rs`.\n */\nexport type TocConfig = {\n /**\n * Heading text that triggers TOC insertion. Matched\n * case-insensitively after whitespace trimming. Default: `\"TOC\"`.\n */\n heading?: string;\n\n /**\n * Number of heading levels to include starting from `<h2>`.\n *\n * - `1` — h2 only\n * - `2` (default) — h2 + h3\n * - `3` — h2, h3, h4\n * - …up to `5` (h2 through h6)\n */\n maxDepth?: number;\n};\n\n/**\n * Markdown / MDX parsing options.\n *\n * See [`ZfbConfig.markdown`] for the embed point. Fields: [`gfm`],\n * [`toc`], [`externalLinks`], [`cjkFriendly`], and [`features`].\n * Future markdown knobs would also live here.\n *\n * See the \"Markdown Features\" docs category for the per-feature option\n * reference once individual features are ported.\n *\n * Mirrors `MarkdownConfig` in crates/zfb/src/config.rs.\n */\nexport type MarkdownConfig = {\n /**\n * Enable GFM constructs.\n *\n * Accepts three shapes:\n *\n * - `true` — turn every GFM construct ON (strikethrough, table,\n * autolink-literal, task-list-item, footnote-definition).\n * - `false` — turn every GFM construct OFF.\n * - partial object — set individual fields explicitly; fields you\n * omit fall back to the conservative-default values described\n * below.\n *\n * When `markdown` itself is omitted entirely, the conservative\n * default applies: `strikethrough: true`, `table: true`, every other\n * GFM construct off. This is the smallest behavioural delta from\n * zfb's historical effective state (table-only). Projects that want\n * the full GFM surface should opt in with `gfm: true`.\n */\n gfm?: GfmFlag;\n\n /**\n * Table-of-contents options. When present, a `<ul>/<li>` list is\n * inserted after the first heading whose text matches `heading`\n * (default `\"TOC\"`, case-insensitive). Each link points to the\n * deduplicated `id` that `HeadingLinksPlugin` placed on the heading.\n *\n * Omitting this field entirely leaves the build byte-for-byte identical\n * to the pre-TOC build. See [`TocConfig`] for the available options.\n *\n * Mirrors `MarkdownConfig::toc` in crates/zfb/src/config.rs.\n */\n toc?: TocConfig;\n /**\n * External-link rewriter. When set, every `<a>` whose href is\n * classified as external receives the configured `target` and `rel`\n * attributes.\n *\n * An href is external when it is an absolute HTTP/HTTPS URL AND its\n * origin differs from the top-level `site` URL (if `site` is\n * configured). When `site` is absent, any absolute HTTP/HTTPS URL is\n * treated as external.\n *\n * `mailto:`, `tel:`, and other non-HTTP(S) schemes are always left\n * unchanged. Relative URLs (`/internal/`, `./file.mdx`, `#anchor`) are\n * always internal.\n *\n * Omitting this field keeps the output byte-for-byte identical to the\n * pre-feature behaviour.\n *\n * Mirrors `ExternalLinksConfig` in crates/zfb/src/config.rs.\n */\n externalLinks?: ExternalLinksConfig;\n\n /**\n * Enable CJK-friendly markdown handling.\n *\n * Governs two post-parse fixups that adapt CommonMark/GFM rules to CJK\n * text:\n *\n * 1. **Emphasis/strong flanking** (`CjkFriendlyPlugin`). CommonMark's\n * left-/right-flanking delimiter-run rules treat CJK characters as\n * non-whitespace non-punctuation, which causes `**foo**` adjacent to\n * CJK text (e.g. `**テスト。**テスト`) to render as literal stars\n * instead of `<strong>`.\n * 2. **Bare-URL autolink boundary** (`CjkAutolinkBoundaryPlugin`,\n * zfb#1105). The GFM autolink-literal path grammar terminates only on\n * ASCII whitespace, so a bare URL flush against CJK text\n * (`詳細はhttps://example.com参照`) swallows the trailing CJK run into\n * the `href`. This fixup terminates the link at the first CJK\n * character. Only active when `gfm.autolinkLiteral` is also on.\n *\n * - **absent / `true` (default):** CJK-friendly handling is on.\n * Preserves today's behaviour — existing CJK-content sites are\n * unaffected.\n * - **`false`:** opt-out. Neither plugin is added to the pipeline;\n * emphasis markers and bare-URL autolinks adjacent to CJK characters\n * follow base CommonMark/GFM rules. Rarely the right choice; provided\n * as an escape hatch for projects that need strict CommonMark/GFM\n * output.\n *\n * **GFM strikethrough** (`~~foo~~`) at CJK boundaries is unaffected\n * by this toggle — it is handled by markdown-rs's GFM tokeniser, not\n * by these plugins, and works correctly in both modes.\n *\n * Mirrors `MarkdownConfig::cjk_friendly` in crates/zfb/src/config.rs.\n */\n cjkFriendly?: boolean;\n\n /**\n * Convert every soft line break (a single `\\n` inside a paragraph) into\n * `<br>` (remark-breaks parity).\n *\n * - **absent / `false` (default):** soft line breaks follow standard\n * CommonMark behaviour — collapsed into a single space.\n * - **`true`:** every `\\n` inside a paragraph becomes `<br>`. Use this\n * when your content relies on newline→`<br>` fidelity (e.g. product\n * descriptions, lyrics, or other newline-sensitive prose).\n *\n * Mirrors `MarkdownConfig::hard_breaks` in crates/zfb/src/config.rs.\n */\n hardBreaks?: boolean;\n\n /**\n * Per-feature markdown pipeline toggles.\n *\n * Each field is a [`FeatureToggle`] (`true` / `false` / options object)\n * or a feature-specific config type (for features that require extra\n * parameters). Absent / `undefined` means all features are disabled,\n * preserving the behaviour of the pre-features build byte-for-byte.\n *\n * Unknown keys are rejected at deserialization time by the Rust loader\n * so a typo in `zfb.config.ts` surfaces as a clear error.\n *\n * Mirrors `MarkdownFeaturesConfig` in crates/zfb/src/config.rs.\n */\n features?: MarkdownFeaturesConfig;\n};\n\n/**\n * Per-feature toggle: `boolean` shorthand or an options object.\n *\n * `true` enables the feature with defaults; `false` (or absent) disables it.\n * The object form carries per-feature options (fields vary by feature and\n * are filled in by each feature's port sub-issue — stubs today).\n *\n * Mirrors `FeatureToggle` in crates/zfb/src/config.rs.\n */\nexport type FeatureToggle = boolean | FeatureOptions;\n\n/**\n * Empty options object for features that accept `{ ... }` but have no\n * user-facing knobs yet. Fields are filled in by each feature's port\n * sub-issue; this stub satisfies the schema shape requirement.\n *\n * Mirrors `FeatureOptions` in crates/zfb/src/config.rs.\n */\nexport type FeatureOptions = Record<string, never>;\n\n/**\n * Options for the `githubAutolinks` feature — rewrites bare `#123`,\n * `user/repo#456`, and commit-SHA references into GitHub links.\n *\n * `repo` is required: `githubAutolinks: {}` (repo absent) is a config error\n * — the Rust pipeline emits a build-blocking diagnostic rather than\n * silently skipping the feature.\n *\n * Mirrors `GithubAutolinksConfig` in `crates/zfb-md-ast/src/features_config.rs`.\n */\nexport type GithubAutolinksConfig = {\n /**\n * GitHub repository reference (`owner/repo`) used to build autolink URLs\n * (e.g. `\"owner/repo\"` renders `#123` as\n * `https://github.com/owner/repo/issues/123`). Required — see above.\n */\n repo: string;\n};\n\n/**\n * Options stub for the `codeEnrichment` feature.\n *\n * TODO: fill in actual fields when the codeEnrichment feature is ported.\n *\n * Mirrors `CodeEnrichmentConfig` in crates/zfb/src/config.rs.\n */\nexport type CodeEnrichmentConfig = Record<string, never>;\n\n/**\n * Options for the `tocExport` feature.\n *\n * Controls which headings are included in the exported `toc` JSON.\n * `maxDepth` is the **absolute** heading depth (2–6):\n * - `2` → h2 only\n * - `3` (default) → h2 + h3\n *\n * This differs from `headingMarkerToc.maxDepth`, which counts levels\n * starting from h2. The two features are independent.\n *\n * Mirrors `TocExportConfig` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type TocExportConfig = {\n /** Maximum heading depth to include (absolute, 2–6). Default: 3. */\n maxDepth?: number;\n};\n\n/**\n * Options for the `imageDimensions` feature.\n *\n * Auto-detects and injects `width`/`height` on local `<img>` elements. Raster\n * formats are probed header-only; SVGs are read from their markup\n * (`width`/`height`/`viewBox`).\n *\n * Mirrors `ImageDimensionsConfig` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type ImageDimensionsConfig = {\n /**\n * When `true` (the default), `http://` and `https://` image sources are\n * silently skipped and not probed for dimensions. Set to `false` only for\n * testing or unusual setups — remote images require network access at build\n * time and slow the pipeline.\n */\n skipRemote?: boolean;\n};\n\n/**\n * Options for the `linkValidation` feature.\n *\n * Validates internal `[text](file.md#anchor)` and `[text](#anchor)` links at\n * build time. External URLs (`http://`, `https://`, `mailto:`) are always\n * skipped — network validation is out of scope.\n *\n * Mirrors `LinkValidationConfig` in `crates/zfb-md-ast/src/features_config.rs`.\n */\nexport type LinkValidationConfig = {\n /**\n * When `true`, broken links are reported as errors (build can fail).\n * Default: `false` (warn-only).\n */\n failOnBroken?: boolean;\n};\n\n/**\n * Options for the `transclude` feature.\n *\n * Enables `:::include{file=\"./path.md\"}` directives that inline another\n * file's parsed mdast at the include site.\n *\n * Mirrors `TranscludeConfig` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type TranscludeConfig = {\n /**\n * Maximum transclusion depth (chain length A→B→C→…).\n *\n * A depth of `1` allows only direct includes (the included file itself\n * cannot include further files). Default: `5`. A cycle (A→B→A) is\n * always detected regardless of `maxDepth` and treated as an error.\n */\n maxDepth?: number;\n};\n\n/**\n * Options for the `readingTime` feature.\n *\n * Mirrors `ReadingTimeOptions` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type ReadingTimeConfig = {\n /** Words-per-minute rate for the reading-time estimate. Default: 200. */\n wpm?: number;\n};\n\n/**\n * `readingTime` feature value: either a `boolean` shorthand or a\n * {@link ReadingTimeConfig} options object.\n *\n * Mirrors `ReadingTimeFeature` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type ReadingTimeFeature = boolean | ReadingTimeConfig;\n\n/**\n * Per-feature markdown pipeline configuration.\n *\n * All fields are optional; absent = feature disabled, behaviour unchanged\n * from the pre-features build. Unknown keys are rejected at deserialization\n * time by the Rust loader so a typo surfaces as a clear error.\n *\n * Mirrors `MarkdownFeaturesConfig` in crates/zfb/src/config.rs.\n */\nexport type MarkdownFeaturesConfig = {\n /** GitHub-style alert blocks (`> [!NOTE]`, `> [!WARNING]`, etc.). */\n githubAlerts?: FeatureToggle;\n\n /**\n * Reading-time estimate injected into the document frontmatter.\n * Accepts `true` / `false` shorthand or `{ wpm: N }` for a custom rate.\n */\n readingTime?: ReadingTimeFeature;\n\n /** GitHub-style `owner/repo#123` and `SHA` autolinks. Requires `repo`. */\n githubAutolinks?: GithubAutolinksConfig;\n\n /** Code-block enrichment (copy button, language label, etc.). */\n codeEnrichment?: CodeEnrichmentConfig;\n\n /** Grouped code blocks rendered as tabs. */\n codeTabs?: FeatureToggle;\n\n /** Ruby annotation support (`{base}^{ruby}` syntax). */\n ruby?: FeatureToggle;\n\n /** Export the page TOC as structured data (e.g. for sidebar rendering). */\n tocExport?: TocExportConfig;\n\n /** Auto-detect and inject `width`/`height` on `<img>` elements. */\n imageDimensions?: ImageDimensionsConfig;\n\n /**\n * Validate internal links (file-relative paths and anchor fragments) at\n * build time. External URLs are always skipped — network validation is\n * out of scope.\n */\n linkValidation?: LinkValidationConfig;\n\n /**\n * Transclusion of other markdown/MDX files via\n * `:::include{file=\"./path.md\"}` — NOT the Obsidian `[[path]]` wikilink\n * syntax.\n */\n transclude?: TranscludeConfig;\n\n /**\n * Generic `:::name` → component map. You supply the components; no defaults\n * are registered. Keys are directive names (e.g. `\"foo\"`), values are\n * {@link DirectiveSpec} (bare component name string or options object).\n *\n * Mirrors `directives` in `MarkdownFeaturesConfig` in crates/zfb/src/config.rs.\n */\n directives?: Record<string, DirectiveSpec>;\n\n /** Mermaid diagram rendering. */\n mermaid?: FeatureToggle;\n\n /**\n * Inline heading-marker TOC. Accepts either a `boolean` shorthand\n * (`true` = enable with defaults, `false` = disable) or a full\n * {@link TocConfig} options object — same union shape as the Rust\n * `HeadingMarkerTocFeature` enum.\n */\n headingMarkerToc?: HeadingMarkerTocFeature;\n\n /**\n * Heading-ID strategy for the always-on `HeadingLinks` plugin.\n * Absent → `\"flat\"` (the long-standing github-slugger scheme).\n * `{ strategy: \"hierarchical\" }` opts into ancestor-prefixed anchor\n * IDs (`## Foo` / `### Moo` / `#### Mew` → `foo`, `foo-moo`,\n * `foo-moo-mew`) — see {@link HeadingIdsConfig}.\n */\n headingIds?: HeadingIdsConfig;\n};\n\n/**\n * Options for the `headingIds` entry in `markdown.features`.\n *\n * Configures the always-on `HeadingLinks` plugin rather than toggling an\n * opt-in feature. Note: switching to `\"hierarchical\"` is anchor-breaking\n * for existing deep links to nested headings.\n *\n * Mirrors `HeadingIdsConfig` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type HeadingIdsConfig = {\n /**\n * `\"flat\"` (default): github-slugger slugs with a per-document dedup\n * counter shared across h2–h6 (`overview`, `overview-1`, …).\n * `\"hierarchical\"`: each heading's slug is prefixed with its ancestor\n * chain and deduped on the full path — anchors become reconstructible\n * from the heading outline.\n */\n strategy?: \"flat\" | \"hierarchical\";\n};\n\n/**\n * `headingMarkerToc` feature value: either a `boolean` shorthand or a\n * full {@link TocConfig} options object.\n *\n * Mirrors `HeadingMarkerTocFeature` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type HeadingMarkerTocFeature = boolean | TocConfig;\n\n/**\n * Spec for one user-defined directive: either a bare component name string\n * or a full {@link DirectiveFullSpec} options object.\n *\n * Mirrors `DirectiveSpec` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type DirectiveSpec = string | DirectiveFullSpec;\n\n/**\n * Full options object for one user-defined directive.\n *\n * Mirrors `DirectiveFullSpec` in crates/zfb-md-ast/src/features_config.rs.\n */\nexport type DirectiveFullSpec = {\n /** JSX component identifier (e.g. `\"Spoiler\"`, `\"Kbd\"`). */\n component: string;\n /** Container/leaf/text shape. Defaults to `\"container\"` when absent. */\n kind?: \"container\" | \"leaf\" | \"text\";\n /** Whether the bracketed `[label]` becomes a `title` attribute. Defaults to `true`. */\n titleFromLabel?: boolean;\n};\n\n/**\n * Options for the external-link rewriter (port of `rehype-external-links`).\n *\n * All fields are optional; omitting a field applies the documented default.\n *\n * Mirrors `ExternalLinksConfig` in crates/zfb/src/config.rs.\n */\nexport type ExternalLinksConfig = {\n /**\n * `rel` tokens applied to external links.\n *\n * Default: `[\"noopener\", \"noreferrer\"]`.\n *\n * Tokens are deduplicated (case-insensitive) and merged with any\n * existing `rel` attribute on the `<a>` element — existing tokens\n * appear first.\n */\n rel?: string[];\n /**\n * `target` value for external links.\n *\n * Default: `\"_blank\"`.\n */\n target?: string;\n};\n\n/**\n * Either the shorthand boolean form (`true` = all GFM constructs on,\n * `false` = all off) or a partial object that toggles individual\n * constructs.\n *\n * Mirrors `GfmFlag` in crates/zfb/src/config.rs.\n */\nexport type GfmFlag = boolean | GfmConstructs;\n\n/**\n * Per-construct opt-in / opt-out for GFM. Every field is optional;\n * omitted fields fall back to the conservative default\n * (`strikethrough: true`, `table: true`, others `false`).\n *\n * Mirrors `GfmConstructs` in crates/zfb/src/config.rs.\n */\nexport type GfmConstructs = {\n /** GFM strikethrough (`~~text~~` → `<del>text</del>`). */\n strikethrough?: boolean;\n /** GFM pipe-style tables. */\n table?: boolean;\n /**\n * GFM autolink literal — bare URLs like `https://example.com` become\n * clickable links without `<…>` brackets.\n */\n autolinkLiteral?: boolean;\n /** GFM task list items (`- [x]` / `- [ ]`). */\n taskListItem?: boolean;\n /** GFM footnote definitions (`[^ref]: …`). */\n footnoteDefinition?: boolean;\n};\n\n/**\n * What to do when a `.md`/`.mdx` link cannot be resolved.\n *\n * Mirrors `OnBrokenLinks` in crates/zfb/src/config.rs.\n */\nexport type OnBrokenLinks = \"warn\" | \"error\" | \"ignore\";\n\n/**\n * Config for the markdown link resolver. See\n * [`ZfbConfig.resolveMarkdownLinks`] for the design rationale.\n */\nexport type ResolveMarkdownLinksConfig = {\n /** Whether to enable link resolution. Default: `false`. */\n enabled?: boolean;\n\n /**\n * Legacy single-dir field. Used only when [`dirs`] is empty. When\n * non-empty, scanned against the hard-coded `/docs/` route prefix.\n */\n docsDir?: string;\n\n /**\n * Explicit per-dir source map. Each entry is one collection (e.g.\n * EN docs at `src/content/docs/` → `/docs/`, JA docs at\n * `src/content/docs-ja/` → `/ja/docs/`). Takes precedence over\n * [`docsDir`] when non-empty.\n */\n dirs?: ResolveMarkdownLinksDir[];\n\n /** What to do with unresolved `.md`/`.mdx` links. Default: `\"warn\"`. */\n onBrokenLinks?: OnBrokenLinks;\n};\n\n/** One source-dir entry for [`ResolveMarkdownLinksConfig.dirs`]. */\nexport type ResolveMarkdownLinksDir = {\n /**\n * Directory (relative to project root) whose `.md`/`.mdx` files are\n * scanned. Must be relative and must not escape the root via `..`.\n */\n dir: string;\n\n /**\n * Route prefix prepended to each file's slug. Include leading and\n * trailing slashes (e.g. `\"/docs/\"` or `\"/ja/docs/\"`).\n */\n routePrefix: string;\n};\n\n/**\n * Identity helper: returns the supplied config as-is, but typed against\n * [`ZfbConfig`]. Use as the default export of `zfb.config.ts` so editors\n * surface field-level types and typos surface at compile time.\n */\nexport function defineConfig(config: ZfbConfig): ZfbConfig {\n return config;\n}\n\n/**\n * Preset authoring helper: stamps each object entry in `config.plugins`\n * with `source_package: sourcePackage` so the Rust loader can attribute\n * plugin contributions back to the preset package that provided them.\n *\n * - Only plain-object plugin entries are stamped; non-object entries pass\n * through unchanged (defensive — the current schema requires objects,\n * but this guard keeps the helper safe if the schema is ever relaxed).\n * - An entry that ALREADY carries a `source_package` is left untouched, so a\n * preset composing another `definePreset`-returned preset (by spreading its\n * `plugins`) keeps the inner preset's provenance instead of clobbering it\n * with the outer package name (the spread below lets the existing marker win).\n * - When `config.plugins` is absent, the config is returned as-is.\n * - All other fields of `config` pass through unchanged.\n *\n * The key `source_package` (snake_case) mirrors the Rust `PluginConfig`\n * serde field added in T4. `PluginConfig` has no `#[serde(rename_all)]`\n * so the serde key is the field name verbatim — do NOT use camelCase.\n *\n * SYNC REQUIREMENT: keep this implementation behaviourally identical to\n * the stub in crates/zfb-config-loader/js/zfb-config-stub.mjs, which is\n * injected at config-eval time when the user's project does not have the\n * zfb npm package installed locally.\n */\nexport function definePreset(\n sourcePackage: string,\n config: Partial<ZfbConfig>,\n): Partial<ZfbConfig> {\n if (!config.plugins) {\n return config;\n }\n return {\n ...config,\n plugins: config.plugins.map((plugin) => {\n if (plugin !== null && typeof plugin === \"object\" && !Array.isArray(plugin)) {\n // Default first, then spread the plugin so an existing `source_package`\n // (from a composed inner preset) wins over the outer package name.\n return { source_package: sourcePackage, ...plugin };\n }\n return plugin;\n }),\n };\n}\n"]}
package/dist/runtime.d.ts CHANGED
@@ -79,7 +79,8 @@ export declare function mountNewIslands(): void;
79
79
  */
80
80
  export declare function cancelPendingIslands(): void;
81
81
  /**
82
- * Unmount all currently-mounted islands within `root` (default: `document.body`).
82
+ * Unmount the mounted islands within `root` (default: `document.body`) that will
83
+ * NOT survive the body swap.
83
84
  *
84
85
  * Walks `root` for `[data-zfb-island]` and `[data-zfb-island-skip-ssr]` elements,
85
86
  * looks up each element's unmount thunk in the `mounted` WeakMap, calls it (which
@@ -89,9 +90,17 @@ export declare function cancelPendingIslands(): void;
89
90
  * Call this before `swapBodyElement(...)` so the OLD body's islands receive proper
90
91
  * framework lifecycle cleanup (useEffect teardowns, etc.) before being discarded.
91
92
  *
93
+ * When `incomingBody` is supplied (the client-router passes the parsed incoming
94
+ * document body), any island whose `data-zfb-transition-persist` id matches a
95
+ * marker in that body is DELIBERATELY SKIPPED: swapBodyElement will physically
96
+ * lift the node into the new body, so its component instance and internal state
97
+ * must survive — unmounting it here would empty the container before the lift and
98
+ * defeat the persist contract (issue #1389). Omit `incomingBody` (or pass null)
99
+ * to unmount everything, the pre-#1389 behavior.
100
+ *
92
101
  * No-op for elements not in the `mounted` map (e.g. never-mounted or already cleaned up).
93
102
  */
94
- export declare function unmountIslands(root?: ParentNode): void;
103
+ export declare function unmountIslands(root?: ParentNode, incomingBody?: ParentNode | null): void;
95
104
  /**
96
105
  * Test-only seam. Replace the module dynamic-import with a fake.
97
106
  * Returns the previous implementation so tests can restore it.
package/dist/runtime.js CHANGED
@@ -183,6 +183,17 @@ function scheduleMedia(target, fire) {
183
183
  },
184
184
  };
185
185
  }
186
+ // data-zfb-transition-persist marker attribute — the client-router's persist
187
+ // contract. Mirrored from client-router/swap-functions.ts: that package owns the
188
+ // body swap (lifting persisted nodes into the incoming body), this package owns
189
+ // island mount/unmount. Both must agree on the literal string. See the port
190
+ // spec at packages/zfb-runtime/docs/client-router/port-spec.md §12.3.
191
+ const PERSIST_ATTR = "data-zfb-transition-persist";
192
+ // Cross-package "needs-remount" flag set by client-router/swap-functions.ts on a
193
+ // persisted island whose props changed across a body swap. Mirrored literal (same
194
+ // cross-package contract as PERSIST_ATTR above — both packages must agree on the
195
+ // string). Consumed by clearMountedForRemount(). See #1389.
196
+ const ISLAND_REMOUNT_ATTR = "data-zfb-island-remount";
186
197
  // WeakMap<Element, unmount thunk> — replaces the old WeakSet.
187
198
  // Value is a per-element function that calls the bundle's unmount(element)
188
199
  // (or a noop if the bundle does not expose one). Used by unmountIslands()
@@ -272,8 +283,13 @@ export function mountNewIslands() {
272
283
  const name = el.getAttribute("data-zfb-island");
273
284
  if (!name)
274
285
  continue;
286
+ // A persisted island whose props changed across the body swap is flagged
287
+ // for remount by swap-functions.swapBodyElement. Clear its surviving mounted
288
+ // entry BEFORE scheduleMount's already-mounted guard so it re-mounts fresh
289
+ // with the refreshed data-props. No-op for every other element.
290
+ const forceRemount = clearMountedForRemount(el);
275
291
  warnIfNestedIsland(el, name);
276
- scheduleMount(manifest, el, name, "hydrate");
292
+ scheduleMount(manifest, el, name, "hydrate", { force: forceRemount });
277
293
  }
278
294
  const skipSsrIslands = document.querySelectorAll("[data-zfb-island-skip-ssr]");
279
295
  for (const el of Array.from(skipSsrIslands)) {
@@ -284,6 +300,50 @@ export function mountNewIslands() {
284
300
  scheduleMount(manifest, el, name, "render");
285
301
  }
286
302
  }
303
+ /**
304
+ * Consume the cross-package "needs-remount" signal for the persist-props hybrid
305
+ * path (port-spec §12.3.1 hybrid case / §12.3.2). When a persisted island's
306
+ * props differ from the incoming markup, `swapBodyElement` refreshes the
307
+ * surviving element's `data-props` and marks it with `ISLAND_REMOUNT_ATTR`.
308
+ * That attribute is the ONLY channel that crosses the zfb-runtime → zfb package
309
+ * boundary — the `mounted` map is module-private to this file, so a shared
310
+ * in-memory "needs-remount" queue between the two packages is impossible; the
311
+ * live DOM node carrying the flag IS the queue.
312
+ *
313
+ * On a flagged mounted element: fire the old instance's unmount thunk (so its
314
+ * useEffect/framework cleanups run against the still-connected node), drop the
315
+ * `mounted` entry so `scheduleMount`'s guard no longer short-circuits, strip the
316
+ * flag, and ask the caller to force the replacement mount through immediately
317
+ * instead of re-entering any deferred scheduler. This keeps a deferred persisted
318
+ * island from blanking while it waits for idle/visible/media to fire again.
319
+ *
320
+ * On a flagged element whose URL import is still pending, leave the flag in
321
+ * place. The already-running import's success handler consumes it after the
322
+ * module resolves and re-reads `data-props` at that point, so a props refresh
323
+ * that happened during the import wins without starting a duplicate import.
324
+ *
325
+ * A no-op for elements without the flag (the common case: fresh markers and
326
+ * props-unchanged persisted islands).
327
+ *
328
+ * Scope: only the `[data-zfb-island]` (hydrated) loop calls this, mirroring the
329
+ * writer side — swapBodyElement sets the flag only for `newTarget.matches(
330
+ * "[data-zfb-island]")`, never for skip-ssr islands.
331
+ */
332
+ function clearMountedForRemount(el) {
333
+ if (!el.hasAttribute(ISLAND_REMOUNT_ATTR))
334
+ return false;
335
+ if (pending.has(el))
336
+ return false;
337
+ const thunk = mounted.get(el);
338
+ if (thunk) {
339
+ thunk();
340
+ mounted.delete(el);
341
+ el.removeAttribute(ISLAND_REMOUNT_ATTR);
342
+ return true;
343
+ }
344
+ el.removeAttribute(ISLAND_REMOUNT_ATTR);
345
+ return false;
346
+ }
287
347
  /**
288
348
  * Cancel deferred-hydration callbacks for all islands in the old body before a
289
349
  * swap. Prevents idle / visibility callbacks from running against orphan elements
@@ -332,7 +392,7 @@ function warnIfNestedIsland(el, componentName) {
332
392
  `Fix: author "${componentName}" bare (remove <Island> from its own render output) ` +
333
393
  `and apply <Island when="..."> at the call site instead.`);
334
394
  }
335
- function scheduleMount(manifest, element, componentName, mode) {
395
+ function scheduleMount(manifest, element, componentName, mode, options = {}) {
336
396
  // Skip elements already mounted OR currently importing — the latter
337
397
  // prevents two concurrent `mountIslands` calls from each firing a
338
398
  // separate dynamic import for the same element.
@@ -357,7 +417,7 @@ function scheduleMount(manifest, element, componentName, mode) {
357
417
  // constructed a mount function for it. Skip the dynamic import
358
418
  // and call the supplied function directly.
359
419
  if (typeof entry !== "string") {
360
- fireInlineMount(element, entry, mode);
420
+ fireInlineMount(element, entry, mode, options);
361
421
  return;
362
422
  }
363
423
  const url = entry;
@@ -419,6 +479,10 @@ function scheduleMount(manifest, element, componentName, mode) {
419
479
  pending.delete(element);
420
480
  return;
421
481
  }
482
+ const shouldRefreshProps = element.hasAttribute(ISLAND_REMOUNT_ATTR);
483
+ const propsForMount = shouldRefreshProps ? readProps(element) : props;
484
+ if (shouldRefreshProps)
485
+ element.removeAttribute(ISLAND_REMOUNT_ATTR);
422
486
  const unmountThunk = mod.unmount
423
487
  ? () => mod.unmount(element)
424
488
  : () => {
@@ -426,7 +490,7 @@ function scheduleMount(manifest, element, componentName, mode) {
426
490
  };
427
491
  mounted.set(element, unmountThunk);
428
492
  pending.delete(element);
429
- fn(props, element, mode);
493
+ fn(propsForMount, element, mode);
430
494
  }, (err) => {
431
495
  // Surface the error in dev so the user notices, then clear
432
496
  // both guards so a later retry (e.g. another scheduleHydrate
@@ -444,6 +508,10 @@ function scheduleMount(manifest, element, componentName, mode) {
444
508
  fire();
445
509
  return;
446
510
  }
511
+ if (options.force) {
512
+ fire();
513
+ return;
514
+ }
447
515
  const { fired, cancel } = scheduleHydrateInternal(element, when, fire);
448
516
  // Track deferred-hydration cancel handle so cancelPendingIslands() can abort
449
517
  // idle / visibility callbacks before a body swap. (W1B §12.5)
@@ -462,7 +530,7 @@ function scheduleMount(manifest, element, componentName, mode) {
462
530
  * coordinate around — we just call `mount` / `default` directly,
463
531
  * gated by the same `data-when` semantics as the URL path.
464
532
  */
465
- function fireInlineMount(element, mod, mode) {
533
+ function fireInlineMount(element, mod, mode, options = {}) {
466
534
  const fn = mod.mount ?? mod.default;
467
535
  if (typeof fn !== "function") {
468
536
  if (typeof process !== "undefined" && process.env && process.env["NODE_ENV"] !== "production") {
@@ -500,6 +568,10 @@ function fireInlineMount(element, mod, mode) {
500
568
  fire();
501
569
  return;
502
570
  }
571
+ if (options.force) {
572
+ fire();
573
+ return;
574
+ }
503
575
  const when = element.getAttribute("data-when") ?? undefined;
504
576
  const { fired, cancel } = scheduleHydrateInternal(element, when, fire);
505
577
  // Track deferred-hydration cancel handle so cancelPendingIslands() can abort
@@ -513,7 +585,8 @@ function fireInlineMount(element, mod, mode) {
513
585
  }
514
586
  }
515
587
  /**
516
- * Unmount all currently-mounted islands within `root` (default: `document.body`).
588
+ * Unmount the mounted islands within `root` (default: `document.body`) that will
589
+ * NOT survive the body swap.
517
590
  *
518
591
  * Walks `root` for `[data-zfb-island]` and `[data-zfb-island-skip-ssr]` elements,
519
592
  * looks up each element's unmount thunk in the `mounted` WeakMap, calls it (which
@@ -523,12 +596,35 @@ function fireInlineMount(element, mod, mode) {
523
596
  * Call this before `swapBodyElement(...)` so the OLD body's islands receive proper
524
597
  * framework lifecycle cleanup (useEffect teardowns, etc.) before being discarded.
525
598
  *
599
+ * When `incomingBody` is supplied (the client-router passes the parsed incoming
600
+ * document body), any island whose `data-zfb-transition-persist` id matches a
601
+ * marker in that body is DELIBERATELY SKIPPED: swapBodyElement will physically
602
+ * lift the node into the new body, so its component instance and internal state
603
+ * must survive — unmounting it here would empty the container before the lift and
604
+ * defeat the persist contract (issue #1389). Omit `incomingBody` (or pass null)
605
+ * to unmount everything, the pre-#1389 behavior.
606
+ *
526
607
  * No-op for elements not in the `mounted` map (e.g. never-mounted or already cleaned up).
527
608
  */
528
- export function unmountIslands(root = document.body) {
609
+ export function unmountIslands(root = document.body, incomingBody) {
529
610
  const selector = "[data-zfb-island],[data-zfb-island-skip-ssr]";
611
+ // Persist ids that `swapBodyElement` will physically LIFT from the old body
612
+ // into the incoming body — an old marker survives iff the incoming body has a
613
+ // marker with the same `data-zfb-transition-persist` id. Those DOM nodes are
614
+ // moved, not discarded, so their component instance and internal state MUST
615
+ // survive the swap: skip their framework unmount here or the persist contract
616
+ // preserves nothing (port-spec §12.3.1 case (a) / issue #1389). A persisted
617
+ // island whose props changed is skipped here too — its refreshed remount runs
618
+ // later in mountNewIslands via the `data-zfb-island-remount` flag (see
619
+ // `clearMountedForRemount`) swapBodyElement sets. With no incoming body (a
620
+ // call outside a swap) nothing is preserved, so the walk is byte-identical to
621
+ // the pre-#1389 behavior.
622
+ const preservedPersistIds = collectPersistIds(incomingBody);
530
623
  const elements = root.querySelectorAll(selector);
531
624
  for (const el of Array.from(elements)) {
625
+ const persistId = el.getAttribute(PERSIST_ATTR);
626
+ if (persistId !== null && preservedPersistIds.has(persistId))
627
+ continue;
532
628
  const thunk = mounted.get(el);
533
629
  if (thunk) {
534
630
  thunk();
@@ -536,6 +632,23 @@ export function unmountIslands(root = document.body) {
536
632
  }
537
633
  }
538
634
  }
635
+ /**
636
+ * Collect the `data-zfb-transition-persist` ids present in the incoming body so
637
+ * `unmountIslands` can tell which old-body islands `swapBodyElement` will lift
638
+ * (and therefore must be left mounted). Returns an empty set when no incoming
639
+ * body is supplied.
640
+ */
641
+ function collectPersistIds(incomingBody) {
642
+ const ids = new Set();
643
+ if (!incomingBody)
644
+ return ids;
645
+ for (const el of incomingBody.querySelectorAll(`[${PERSIST_ATTR}]`)) {
646
+ const id = el.getAttribute(PERSIST_ATTR);
647
+ if (id !== null)
648
+ ids.add(id);
649
+ }
650
+ return ids;
651
+ }
539
652
  function readProps(element) {
540
653
  const raw = element.getAttribute("data-props");
541
654
  if (!raw)
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.js","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"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","sourcesContent":["// Hydration scheduling helper consumed by the hydration runtime (Sub 3).\n//\n// Sub 3 owns the hydration runtime that walks the DOM, finds elements\n// marked with `data-zfb-island`, and dispatches each one through this\n// helper to decide *when* to fire the actual hydrate() call. The helper\n// itself does not know how to hydrate — it only schedules the supplied\n// `fire` callback.\n//\n// The branching matches the `When` union exactly:\n// \"visible\" → IntersectionObserver, threshold 0.0, hydrate on first\n// intersection, then disconnect.\n// \"idle\" → requestIdleCallback if available, otherwise setTimeout(0).\n// \"media\" → matchMedia(target's data-media), hydrate when the query\n// first matches (now or on a later change event), then\n// remove the listener.\n// \"load\" → immediate, synchronous fire.\n//\n// Anything else is treated as \"load\" (with a console.warn in development).\n// The helper is environment-tolerant: callers can run it in jsdom /\n// happy-dom or bare Node, and the absence of `IntersectionObserver` /\n// `requestIdleCallback` / `matchMedia` is handled gracefully.\n\nimport { resolveWhen, type When } from \"./types.js\";\n\n/**\n * Subset of the global object that this module touches. Cast once at the\n * module top so individual scheduler functions don't repeat the inline\n * widening.\n */\ntype SchedulerGlobal = typeof globalThis & {\n requestIdleCallback?: (\n cb: (deadline: { didTimeout: boolean; timeRemaining: () => number }) => void,\n options?: { timeout?: number },\n ) => number;\n cancelIdleCallback?: (handle: number) => void;\n IntersectionObserver?: typeof IntersectionObserver;\n matchMedia?: typeof matchMedia;\n};\n\nconst g = globalThis as SchedulerGlobal;\n\n/**\n * Internal variant of `scheduleHydrate` that also reports whether the fire\n * callback was invoked synchronously. Unexported — call sites in this module\n * use this to decide whether to register a `pendingCancels` entry.\n */\nfunction scheduleHydrateInternal(\n target: Element,\n when: When | string | undefined,\n fire: () => void,\n): { fired: boolean; cancel: () => void } {\n const resolved = resolveWhen(when);\n\n if (resolved === \"load\") {\n fire();\n return { fired: true, cancel: noop };\n }\n\n if (resolved === \"idle\") {\n return { fired: false, cancel: scheduleIdle(fire) };\n }\n\n if (resolved === \"media\") {\n return scheduleMedia(target, fire);\n }\n\n // \"visible\"\n return scheduleVisible(target, fire);\n}\n\n/**\n * Schedule a hydration `fire` callback for `target` according to `when`.\n *\n * Returns a `cancel` function that aborts the scheduling if it has not\n * fired yet. After firing, calling `cancel` is a no-op. If the helper\n * cannot find the relevant browser API (e.g. running in pure Node with no\n * polyfill), it falls back to firing synchronously so server-side smoke\n * tests still observe the call.\n */\nexport function scheduleHydrate(\n target: Element,\n when: When | string | undefined,\n fire: () => void,\n): () => void {\n return scheduleHydrateInternal(target, when, fire).cancel;\n}\n\nfunction noop(): void {\n // intentionally empty\n}\n\n/**\n * Build a one-shot gate around `fn`. The returned `run` invokes `fn`\n * exactly once provided `cancel` has not been called first; `cancel`\n * marks the gate as cancelled (later `run` invocations become no-ops)\n * and reports whether the gate had already fired.\n */\nfunction oneShot(fn: () => void): {\n run: () => void;\n cancel: () => boolean;\n} {\n let fired = false;\n let cancelled = false;\n return {\n run(): void {\n if (cancelled || fired) return;\n fired = true;\n fn();\n },\n cancel(): boolean {\n if (fired) return true;\n cancelled = true;\n return false;\n },\n };\n}\n\nfunction scheduleIdle(fire: () => void): () => void {\n const gate = oneShot(fire);\n\n if (typeof g.requestIdleCallback === \"function\") {\n const handle = g.requestIdleCallback(gate.run);\n return () => {\n const alreadyFired = gate.cancel();\n if (alreadyFired) return;\n if (typeof g.cancelIdleCallback === \"function\") g.cancelIdleCallback(handle);\n };\n }\n\n const handle = setTimeout(gate.run, 0);\n return () => {\n const alreadyFired = gate.cancel();\n if (alreadyFired) return;\n clearTimeout(handle);\n };\n}\n\nfunction scheduleVisible(\n target: Element,\n fire: () => void,\n): { fired: boolean; cancel: () => void } {\n const Observer = g.IntersectionObserver;\n\n // No IntersectionObserver (e.g. very old browsers, bare Node) — fail\n // open and hydrate immediately so the island is at least functional.\n if (typeof Observer !== \"function\") {\n fire();\n return { fired: true, cancel: noop };\n }\n\n const gate = oneShot(fire);\n const observer = new Observer(\n (entries, obs) => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n obs.disconnect();\n gate.run();\n return;\n }\n }\n },\n { threshold: 0 },\n );\n\n observer.observe(target);\n\n return {\n fired: false,\n cancel: () => {\n const alreadyFired = gate.cancel();\n if (alreadyFired) return;\n observer.disconnect();\n },\n };\n}\n\nfunction scheduleMedia(target: Element, fire: () => void): { fired: boolean; cancel: () => void } {\n const query = target.getAttribute(\"data-media\");\n\n // No matchMedia API (e.g. bare Node, very old browser) or missing/empty\n // query — fail open and hydrate immediately so the island is at least\n // functional.\n if (typeof g.matchMedia !== \"function\" || !query) {\n fire();\n return { fired: true, cancel: noop };\n }\n\n const mql = g.matchMedia(query);\n\n // Already matches — fire synchronously (no pending listener needed).\n if (mql.matches) {\n fire();\n return { fired: true, cancel: noop };\n }\n\n const gate = oneShot(fire);\n\n let removeListener = noop;\n\n // Listen for the first change event where the query matches.\n // We do NOT use `{once:true}` because we must ignore un-match events\n // (e.g. viewport widens back above breakpoint) and only fire on the\n // first match event — `{once:true}` would consume any change, including\n // un-match changes.\n const handler = (e: MediaQueryListEvent): void => {\n if (!e.matches) return; // ignore un-match changes\n removeListener();\n gate.run();\n };\n\n // Modern browsers expose the EventTarget API on MediaQueryList; older\n // Safari (<14) only has the deprecated addListener/removeListener pair\n // and throws on addEventListener. Prefer modern, fall back to legacy,\n // and fail open when neither exists (mirrors the missing-matchMedia case).\n if (typeof mql.addEventListener === \"function\") {\n mql.addEventListener(\"change\", handler);\n removeListener = () => mql.removeEventListener(\"change\", handler);\n } else if (typeof mql.addListener === \"function\") {\n mql.addListener(handler);\n removeListener = () => mql.removeListener(handler);\n } else {\n fire();\n return { fired: true, cancel: noop };\n }\n\n return {\n fired: false,\n cancel: () => {\n const alreadyFired = gate.cancel();\n if (alreadyFired) return;\n removeListener();\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// mountIslands — DOM walk + dynamic-import dispatcher.\n//\n// `mountIslands` is the entry point the generated `islands-runtime-<hash>.js`\n// bundle calls at script load time. It walks the DOM for the two island\n// markers emitted by the server-side hydration step and the `<Island>`\n// JSX wrapper:\n//\n// 1. `[data-zfb-island]` — SSR'd islands. We `hydrate()` (Preact) /\n// `hydrateRoot()` (React) against the existing server-rendered\n// DOM, gated by `scheduleHydrate(when)`.\n//\n// 2. `[data-zfb-island-skip-ssr]` — SSR-skip islands. The server\n// emitted no markup for these, so we `render()` (Preact) /\n// `createRoot().render()` (React). Skipping hydrate for this case\n// avoids the hydrate-mismatch warnings React/Preact would emit\n// against an empty DOM container.\n//\n// The per-island bundles each export a `mount(props, element, mode)`\n// function (see zfb_islands::render_island_entry_source). The\n// framework-specific glue lives inside that bundle, so this runtime is\n// framework-agnostic.\n//\n// ## Module-level singleton\n//\n// Dynamic imports of the same URL are cached by the JS runtime, so\n// \"switching pages\" (in an SPA shell) reuses the loaded bundle for free.\n// We keep an extra in-memory dedup map keyed by element so an island is\n// never mounted twice (e.g. on hot-reload / repeat-mount scenarios).\n// ---------------------------------------------------------------------------\n\n/**\n * The shape of the default export each per-island bundle ships.\n *\n * `mode === \"hydrate\"` is used for SSR'd islands, `\"render\"` for\n * SSR-skip islands.\n */\ntype IslandMount = (\n props: Record<string, unknown>,\n element: Element,\n mode: \"hydrate\" | \"render\",\n) => void;\n\ntype IslandUnmount = (element: Element) => void;\n\ninterface IslandModule {\n mount?: IslandMount;\n default?: IslandMount;\n unmount?: IslandUnmount;\n}\n\n/**\n * Map of `componentName → island descriptor` baked into the runtime entry.\n *\n * Two descriptor shapes are accepted so the same `mountIslands` runtime\n * handles both bundling strategies the build emits:\n *\n * 1. `string` — a per-island bundle URL. The runtime fetches it via\n * dynamic `import()` and reads `mount` / `default` off the loaded\n * module. Used by the per-island bundling path\n * (`bundle_per_island` / `render_runtime_entry_source`).\n *\n * 2. `IslandModule` — an inline module-shaped object whose `mount` (or\n * `default`) is called directly. Used by the shared-bundle path\n * (`render_shared_bundle_entry_source`): every island's source code\n * is already in the same bundle, so the synthesised entry can hand\n * the runtime the constructed mount functions inline without a\n * second HTTP fetch. This preserves the one-request shared-bundle\n * contract while giving up nothing on hydration semantics\n * (zudolab/zudo-doc#1355 wave 6).\n */\nexport type IslandManifestValue = string | IslandModule;\nexport type IslandManifest = Readonly<Record<string, IslandManifestValue>>;\n\n// WeakMap<Element, unmount thunk> — replaces the old WeakSet.\n// Value is a per-element function that calls the bundle's unmount(element)\n// (or a noop if the bundle does not expose one). Used by unmountIslands()\n// to fire framework lifecycle cleanups before a body swap.\nconst mounted = new WeakMap<Element, () => void>();\n// Elements for which the nested-island self-wrap warning has already been\n// emitted. Guards against repeated warn spam across re-walks (e.g. SPA swaps).\nconst warnedNested = new WeakSet<Element>();\n// Elements with an in-flight dynamic import that has not yet resolved.\n// Two concurrent `mountIslands` invocations (or two `scheduleMount`\n// calls hitting the same element through different code paths) could\n// otherwise both pass the `mounted` guard and both spawn an\n// `importIsland(url)` -> `fn()` chain, double-mounting the component.\n// Adding the element to `pending` synchronously, before the import is\n// fired, closes that window; the entry is removed in both the success\n// (after `mounted.set`) and failure branches.\nconst pending = new WeakSet<Element>();\n\n// Module-level captured manifest — set by the first `mountIslands` call and reused by\n// `mountNewIslands()` so the client-router does not need to know the manifest directly.\n// Named technical cause (W1B §12.1): the router lives in @takazudo/zfb-runtime; the\n// islands manifest lives in @takazudo/zfb. Passing the manifest through the swap event\n// would require widening the event API or threading manifest into router options. The\n// captured-manifest pattern keeps the package boundary clean.\nlet capturedManifest: IslandManifest | null = null;\n\n// Map of element → cancel-function for deferred-hydration islands\n// (data-when=\"idle\"|\"visible\"|\"media\").\n// Populated in scheduleMount; consulted on `zfb:before-swap` so deferred fires do not run\n// against orphan elements after a body swap. (W1B §12.5)\nconst pendingCancels = new Map<Element, () => void>();\n\n/**\n * Walk the DOM and mount every `[data-zfb-island]` / `[data-zfb-island-skip-ssr]`\n * element using `manifest`.\n *\n * No-op when `document` is undefined (SSR, edge runtime). Safe to call\n * multiple times: each element is mounted at most once thanks to the\n * `mounted` WeakSet guard.\n *\n * The manifest is captured at module level so `mountNewIslands()` can re-use\n * it after an SPA body swap without needing the caller to re-supply it.\n */\nexport function mountIslands(manifest: IslandManifest): void {\n if (typeof document === \"undefined\") return;\n\n // Capture the manifest for post-swap re-walks via mountNewIslands().\n capturedManifest = manifest;\n\n const ssrIslands = document.querySelectorAll<HTMLElement>(\"[data-zfb-island]\");\n for (const el of Array.from(ssrIslands)) {\n // Skip the empty-skeleton case left behind when the server-side\n // rewriter has not run yet (data-zfb-island=\"\" with no component\n // name). The hydration emit step is expected to fill this in\n // before the page reaches the browser; if it didn't, we cannot\n // dispatch.\n const name = el.getAttribute(\"data-zfb-island\");\n if (!name) continue;\n warnIfNestedIsland(el, name);\n scheduleMount(manifest, el, name, \"hydrate\");\n }\n\n const skipSsrIslands = document.querySelectorAll<HTMLElement>(\"[data-zfb-island-skip-ssr]\");\n for (const el of Array.from(skipSsrIslands)) {\n const name = el.getAttribute(\"data-zfb-island-skip-ssr\");\n if (!name) continue;\n warnIfNestedIsland(el, name);\n scheduleMount(manifest, el, name, \"render\");\n }\n}\n\n/**\n * Re-walk the current document body and mount any new island markers introduced\n * by an SPA body swap. Uses the manifest captured by the previous `mountIslands`\n * call — no manifest arg required.\n *\n * The caller (client-router `router.ts`) invokes this after `swap()` + `runScripts()`\n * and before dispatching `zfb:page-load`, per W1B §12.2 contract.\n *\n * No-op when called before `mountIslands` (capturedManifest is null) or when\n * `document` is undefined.\n */\nexport function mountNewIslands(): void {\n if (typeof document === \"undefined\") return;\n if (capturedManifest === null) return;\n\n const manifest = capturedManifest;\n\n const ssrIslands = document.querySelectorAll<HTMLElement>(\"[data-zfb-island]\");\n for (const el of Array.from(ssrIslands)) {\n const name = el.getAttribute(\"data-zfb-island\");\n if (!name) continue;\n warnIfNestedIsland(el, name);\n scheduleMount(manifest, el, name, \"hydrate\");\n }\n\n const skipSsrIslands = document.querySelectorAll<HTMLElement>(\"[data-zfb-island-skip-ssr]\");\n for (const el of Array.from(skipSsrIslands)) {\n const name = el.getAttribute(\"data-zfb-island-skip-ssr\");\n if (!name) continue;\n warnIfNestedIsland(el, name);\n scheduleMount(manifest, el, name, \"render\");\n }\n}\n\n/**\n * Cancel deferred-hydration callbacks for all islands in the old body before a\n * swap. Prevents idle / visibility callbacks from running against orphan elements\n * after `swapBodyElement` removes them from the live document. (W1B §12.5)\n *\n * Call this on `zfb:before-swap` (or equivalently, in the router's swap sequence\n * before `swap()` mutates the DOM). Fire-and-forget; safe to call if nothing is\n * pending.\n */\nexport function cancelPendingIslands(): void {\n for (const [el, cancel] of pendingCancels) {\n cancel();\n pendingCancels.delete(el);\n }\n}\n\n/**\n * Warn (once per element, dev-only) when an island marker element is found\n * nested inside another island marker. Self-wrapping an island — emitting a\n * `data-zfb-island` or `data-zfb-island-skip-ssr` container *inside* another\n * island component's render output — mis-hydrates because the runtime will\n * try to mount both the outer and inner islands independently. The outer\n * island's framework instance owns the inner DOM, so a second `hydrate()` /\n * `render()` call against the inner element races with the outer render and\n * produces undefined behaviour.\n *\n * The fix is to author the inner component bare (no `<Island>` in its own\n * render output) and apply the `<Island when=\"...\">` wrapper at the call site.\n */\nfunction warnIfNestedIsland(el: Element, componentName: string): void {\n if (typeof process === \"undefined\" || !process.env || process.env[\"NODE_ENV\"] === \"production\") {\n return;\n }\n if (warnedNested.has(el)) return;\n const parent = el.parentElement;\n if (!parent || typeof parent.closest !== \"function\") return;\n const ancestor = parent.closest(\"[data-zfb-island],[data-zfb-island-skip-ssr]\");\n if (!ancestor) return;\n warnedNested.add(el);\n // eslint-disable-next-line no-console\n console.warn(\n `[zfb] Island \"${componentName}\" is nested inside another island marker. ` +\n `Self-wrapping an island mis-hydrates: the outer framework instance owns ` +\n `the inner DOM, causing a conflicting mount. ` +\n `Fix: author \"${componentName}\" bare (remove <Island> from its own render output) ` +\n `and apply <Island when=\"...\"> at the call site instead.`,\n );\n}\n\nfunction scheduleMount(\n manifest: IslandManifest,\n element: Element,\n componentName: string,\n mode: \"hydrate\" | \"render\",\n): void {\n // Skip elements already mounted OR currently importing — the latter\n // prevents two concurrent `mountIslands` calls from each firing a\n // separate dynamic import for the same element.\n if (mounted.has(element) || pending.has(element)) return;\n\n const entry = manifest[componentName];\n if (entry == null) {\n if (typeof process !== \"undefined\" && process.env && process.env[\"NODE_ENV\"] !== \"production\") {\n // eslint-disable-next-line no-console\n console.warn(\n `[zfb] no island manifest entry for component \"${componentName}\" — ` +\n `the runtime manifest is out of sync with the rendered HTML.`,\n );\n }\n return;\n }\n\n const when = element.getAttribute(\"data-when\") ?? undefined;\n\n // Two manifest shapes:\n //\n // - `string` (per-island bundle URL): fetch via dynamic `import()`\n // and call `mount` / `default` on the resolved module.\n // - `IslandModule` (inline descriptor): the shared-bundle path has\n // already imported every island's source into the same bundle and\n // constructed a mount function for it. Skip the dynamic import\n // and call the supplied function directly.\n if (typeof entry !== \"string\") {\n fireInlineMount(element, entry, mode);\n return;\n }\n\n const url: string = entry;\n\n const fire = (): void => {\n // Re-check both guards in case `fire` is invoked from a deferred\n // scheduler (rIC/rAF/visibility) after a sibling caller already\n // mounted or started importing for this element.\n if (mounted.has(element) || pending.has(element)) return;\n\n // When the deferred fire actually runs, the cancel handle is no longer\n // needed — remove it so pendingCancels doesn't hold stale entries.\n pendingCancels.delete(element);\n\n // Lazy props parse: read and parse data-props only now that we know we\n // are actually going to mount this island. For deferred strategies\n // (media, visible, idle) this avoids JSON.parse work at boot time for\n // islands that may never hydrate (e.g. media query never matches).\n const props = readProps(element);\n\n // Mark as pending BEFORE firing the import so any concurrent\n // `mountIslands` invocation that arrives during the await window\n // is short-circuited by `scheduleMount`'s guard.\n pending.add(element);\n\n // Dynamic-import is cached by the JS runtime, so repeat hits for\n // the same URL share the resolved module — module-level\n // singletons are fine.\n //\n // We move the element from `pending` to `mounted` only on the\n // success path so a failed import (e.g. transient network blip\n // in dev) doesn't permanently block a retry of the same element.\n let started: Promise<IslandModule>;\n try {\n started = importIsland(url);\n } catch (err) {\n // Some implementations of dynamic-import wrappers can throw\n // synchronously (e.g. URL parsing errors). Treat the same as\n // an async rejection.\n pending.delete(element);\n // eslint-disable-next-line no-console\n console.error(`[zfb] failed to start dynamic import for ${url}`, err);\n return;\n }\n started.then(\n (mod) => {\n const fn = mod.mount ?? mod.default;\n if (typeof fn !== \"function\") {\n pending.delete(element);\n if (\n typeof process !== \"undefined\" &&\n process.env &&\n process.env[\"NODE_ENV\"] !== \"production\"\n ) {\n // eslint-disable-next-line no-console\n console.warn(`[zfb] island bundle at ${url} did not export mount() or default()`);\n }\n return;\n }\n // Stale-mount race guard: if the element was detached while the\n // dynamic import was in-flight (e.g. a body swap happened), skip\n // mounting — the element is no longer in the live document and\n // its useEffect listeners would never receive a cleanup call.\n if (!element.isConnected) {\n pending.delete(element);\n return;\n }\n const unmountThunk = mod.unmount\n ? () => mod.unmount!(element)\n : () => {\n // noop — bundle does not expose unmount\n };\n mounted.set(element, unmountThunk);\n pending.delete(element);\n fn(props, element, mode);\n },\n (err: unknown) => {\n // Surface the error in dev so the user notices, then clear\n // both guards so a later retry (e.g. another scheduleHydrate\n // fire) can attempt the import again.\n pending.delete(element);\n mounted.delete(element);\n // eslint-disable-next-line no-console\n console.error(`[zfb] failed to load island bundle ${url}`, err);\n },\n );\n };\n\n if (mode === \"render\") {\n // SSR-skip islands ignore data-when: there is nothing to defer\n // hydration of, just an empty container we paint into. Mount\n // immediately so the user sees output.\n fire();\n return;\n }\n\n const { fired, cancel } = scheduleHydrateInternal(element, when, fire);\n // Track deferred-hydration cancel handle so cancelPendingIslands() can abort\n // idle / visibility callbacks before a body swap. (W1B §12.5)\n // Only register when the scheduler did NOT fire synchronously — a synchronous\n // fire means the island is already handling its import and there is no\n // deferred callback to cancel. Registering noop after a sync fire would leave\n // a stale pendingCancels entry for an already-handled element. (#743)\n if (when && when !== \"load\" && !fired) {\n pendingCancels.set(element, cancel);\n }\n}\n\n/**\n * Run the mount step for the inline-module manifest shape used by the\n * shared-bundle path. The module is already in memory (it was imported\n * into the bundle at build time), so there is no async window to\n * coordinate around — we just call `mount` / `default` directly,\n * gated by the same `data-when` semantics as the URL path.\n */\nfunction fireInlineMount(element: Element, mod: IslandModule, mode: \"hydrate\" | \"render\"): void {\n const fn = mod.mount ?? mod.default;\n if (typeof fn !== \"function\") {\n if (typeof process !== \"undefined\" && process.env && process.env[\"NODE_ENV\"] !== \"production\") {\n // eslint-disable-next-line no-console\n console.warn(\"[zfb] inline island manifest entry did not export mount() or default()\");\n }\n return;\n }\n\n const fire = (): void => {\n // Re-check the guard in case `fire` is invoked from a deferred\n // scheduler (rIC/rAF/visibility) after a sibling caller already\n // mounted this element.\n if (mounted.has(element)) return;\n // When the deferred fire actually runs, the cancel handle is no longer\n // needed — remove it so pendingCancels doesn't hold stale entries.\n pendingCancels.delete(element);\n // Stale-mount race guard for deferred inline mounts: skip if the element\n // was detached (e.g. body swap) while the idle/visible callback was queued.\n if (!element.isConnected) return;\n // Lazy props parse: read and parse data-props only at mount time.\n // For deferred strategies (media, visible, idle) this avoids JSON.parse\n // work at boot time for islands that may never hydrate.\n const props = readProps(element);\n const unmountThunk = mod.unmount\n ? () => mod.unmount!(element)\n : () => {\n // noop — inline module does not expose unmount\n };\n mounted.set(element, unmountThunk);\n fn(props, element, mode);\n };\n\n if (mode === \"render\") {\n fire();\n return;\n }\n\n const when = element.getAttribute(\"data-when\") ?? undefined;\n const { fired, cancel } = scheduleHydrateInternal(element, when, fire);\n // Track deferred-hydration cancel handle so cancelPendingIslands() can abort\n // idle / visibility callbacks before a body swap. (W1B §12.5)\n // Only register when the scheduler did NOT fire synchronously — a synchronous\n // fire means the island is already handling its mount and there is no\n // deferred callback to cancel. Registering noop after a sync fire would leave\n // a stale pendingCancels entry for an already-handled element. (#743)\n if (when && when !== \"load\" && !fired) {\n pendingCancels.set(element, cancel);\n }\n}\n\n/**\n * Unmount all currently-mounted islands within `root` (default: `document.body`).\n *\n * Walks `root` for `[data-zfb-island]` and `[data-zfb-island-skip-ssr]` elements,\n * looks up each element's unmount thunk in the `mounted` WeakMap, calls it (which\n * triggers `render(null, element)` for Preact or `root.unmount()` for React), and\n * removes the entry from the map so `mountNewIslands()` can re-mount later.\n *\n * Call this before `swapBodyElement(...)` so the OLD body's islands receive proper\n * framework lifecycle cleanup (useEffect teardowns, etc.) before being discarded.\n *\n * No-op for elements not in the `mounted` map (e.g. never-mounted or already cleaned up).\n */\nexport function unmountIslands(root: ParentNode = document.body): void {\n const selector = \"[data-zfb-island],[data-zfb-island-skip-ssr]\";\n const elements = root.querySelectorAll<HTMLElement>(selector);\n for (const el of Array.from(elements)) {\n const thunk = mounted.get(el);\n if (thunk) {\n thunk();\n mounted.delete(el);\n }\n }\n}\n\nfunction readProps(element: Element): Record<string, unknown> {\n const raw = element.getAttribute(\"data-props\");\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw) as unknown;\n // Reject arrays explicitly: `typeof [] === \"object\"` is true but\n // an array is not a valid props bag, and passing it through would\n // mean the component receives index-keyed values where it\n // expected a record. Fall through to the empty-object default\n // instead of forwarding a malformed shape.\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n } catch {\n // fall through\n }\n return {};\n}\n\n/**\n * Indirection so tests can stub the dynamic import without intercepting\n * the global `import()`. In production this is a thin wrapper over\n * native `import(url)`.\n */\nlet importImpl: (url: string) => Promise<IslandModule> = (url) =>\n // Modern bundlers (esbuild, Vite, Rollup, webpack) preserve a plain\n // `import(<dynamic>)` call when the argument isn't a static literal,\n // so we no longer need the `new Function(...)` indirection — which\n // also failed under strict CSPs that disallow `unsafe-eval`.\n import(/* @vite-ignore */ /* webpackIgnore: true */ url) as Promise<IslandModule>;\n\nfunction importIsland(url: string): Promise<IslandModule> {\n return importImpl(url);\n}\n\n/**\n * Test-only seam. Replace the module dynamic-import with a fake.\n * Returns the previous implementation so tests can restore it.\n */\nexport function __setIslandImporterForTests(\n impl: (url: string) => Promise<IslandModule>,\n): (url: string) => Promise<IslandModule> {\n const prev = importImpl;\n importImpl = impl;\n return prev;\n}\n\n/**\n * Test-only seam. Returns whether the given element has an entry in the\n * module-private `pendingCancels` Map. Used to assert that a synchronous\n * scheduler fire does not leave a stale entry behind. (#743)\n */\nexport function __hasPendingCancelForTests(element: Element): boolean {\n return pendingCancels.has(element);\n}\n"]}
1
+ {"version":3,"file":"runtime.js","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"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","sourcesContent":["// Hydration scheduling helper consumed by the hydration runtime (Sub 3).\n//\n// Sub 3 owns the hydration runtime that walks the DOM, finds elements\n// marked with `data-zfb-island`, and dispatches each one through this\n// helper to decide *when* to fire the actual hydrate() call. The helper\n// itself does not know how to hydrate — it only schedules the supplied\n// `fire` callback.\n//\n// The branching matches the `When` union exactly:\n// \"visible\" → IntersectionObserver, threshold 0.0, hydrate on first\n// intersection, then disconnect.\n// \"idle\" → requestIdleCallback if available, otherwise setTimeout(0).\n// \"media\" → matchMedia(target's data-media), hydrate when the query\n// first matches (now or on a later change event), then\n// remove the listener.\n// \"load\" → immediate, synchronous fire.\n//\n// Anything else is treated as \"load\" (with a console.warn in development).\n// The helper is environment-tolerant: callers can run it in jsdom /\n// happy-dom or bare Node, and the absence of `IntersectionObserver` /\n// `requestIdleCallback` / `matchMedia` is handled gracefully.\n\nimport { resolveWhen, type When } from \"./types.js\";\n\n/**\n * Subset of the global object that this module touches. Cast once at the\n * module top so individual scheduler functions don't repeat the inline\n * widening.\n */\ntype SchedulerGlobal = typeof globalThis & {\n requestIdleCallback?: (\n cb: (deadline: { didTimeout: boolean; timeRemaining: () => number }) => void,\n options?: { timeout?: number },\n ) => number;\n cancelIdleCallback?: (handle: number) => void;\n IntersectionObserver?: typeof IntersectionObserver;\n matchMedia?: typeof matchMedia;\n};\n\nconst g = globalThis as SchedulerGlobal;\n\n/**\n * Internal variant of `scheduleHydrate` that also reports whether the fire\n * callback was invoked synchronously. Unexported — call sites in this module\n * use this to decide whether to register a `pendingCancels` entry.\n */\nfunction scheduleHydrateInternal(\n target: Element,\n when: When | string | undefined,\n fire: () => void,\n): { fired: boolean; cancel: () => void } {\n const resolved = resolveWhen(when);\n\n if (resolved === \"load\") {\n fire();\n return { fired: true, cancel: noop };\n }\n\n if (resolved === \"idle\") {\n return { fired: false, cancel: scheduleIdle(fire) };\n }\n\n if (resolved === \"media\") {\n return scheduleMedia(target, fire);\n }\n\n // \"visible\"\n return scheduleVisible(target, fire);\n}\n\n/**\n * Schedule a hydration `fire` callback for `target` according to `when`.\n *\n * Returns a `cancel` function that aborts the scheduling if it has not\n * fired yet. After firing, calling `cancel` is a no-op. If the helper\n * cannot find the relevant browser API (e.g. running in pure Node with no\n * polyfill), it falls back to firing synchronously so server-side smoke\n * tests still observe the call.\n */\nexport function scheduleHydrate(\n target: Element,\n when: When | string | undefined,\n fire: () => void,\n): () => void {\n return scheduleHydrateInternal(target, when, fire).cancel;\n}\n\nfunction noop(): void {\n // intentionally empty\n}\n\n/**\n * Build a one-shot gate around `fn`. The returned `run` invokes `fn`\n * exactly once provided `cancel` has not been called first; `cancel`\n * marks the gate as cancelled (later `run` invocations become no-ops)\n * and reports whether the gate had already fired.\n */\nfunction oneShot(fn: () => void): {\n run: () => void;\n cancel: () => boolean;\n} {\n let fired = false;\n let cancelled = false;\n return {\n run(): void {\n if (cancelled || fired) return;\n fired = true;\n fn();\n },\n cancel(): boolean {\n if (fired) return true;\n cancelled = true;\n return false;\n },\n };\n}\n\nfunction scheduleIdle(fire: () => void): () => void {\n const gate = oneShot(fire);\n\n if (typeof g.requestIdleCallback === \"function\") {\n const handle = g.requestIdleCallback(gate.run);\n return () => {\n const alreadyFired = gate.cancel();\n if (alreadyFired) return;\n if (typeof g.cancelIdleCallback === \"function\") g.cancelIdleCallback(handle);\n };\n }\n\n const handle = setTimeout(gate.run, 0);\n return () => {\n const alreadyFired = gate.cancel();\n if (alreadyFired) return;\n clearTimeout(handle);\n };\n}\n\nfunction scheduleVisible(\n target: Element,\n fire: () => void,\n): { fired: boolean; cancel: () => void } {\n const Observer = g.IntersectionObserver;\n\n // No IntersectionObserver (e.g. very old browsers, bare Node) — fail\n // open and hydrate immediately so the island is at least functional.\n if (typeof Observer !== \"function\") {\n fire();\n return { fired: true, cancel: noop };\n }\n\n const gate = oneShot(fire);\n const observer = new Observer(\n (entries, obs) => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n obs.disconnect();\n gate.run();\n return;\n }\n }\n },\n { threshold: 0 },\n );\n\n observer.observe(target);\n\n return {\n fired: false,\n cancel: () => {\n const alreadyFired = gate.cancel();\n if (alreadyFired) return;\n observer.disconnect();\n },\n };\n}\n\nfunction scheduleMedia(target: Element, fire: () => void): { fired: boolean; cancel: () => void } {\n const query = target.getAttribute(\"data-media\");\n\n // No matchMedia API (e.g. bare Node, very old browser) or missing/empty\n // query — fail open and hydrate immediately so the island is at least\n // functional.\n if (typeof g.matchMedia !== \"function\" || !query) {\n fire();\n return { fired: true, cancel: noop };\n }\n\n const mql = g.matchMedia(query);\n\n // Already matches — fire synchronously (no pending listener needed).\n if (mql.matches) {\n fire();\n return { fired: true, cancel: noop };\n }\n\n const gate = oneShot(fire);\n\n let removeListener = noop;\n\n // Listen for the first change event where the query matches.\n // We do NOT use `{once:true}` because we must ignore un-match events\n // (e.g. viewport widens back above breakpoint) and only fire on the\n // first match event — `{once:true}` would consume any change, including\n // un-match changes.\n const handler = (e: MediaQueryListEvent): void => {\n if (!e.matches) return; // ignore un-match changes\n removeListener();\n gate.run();\n };\n\n // Modern browsers expose the EventTarget API on MediaQueryList; older\n // Safari (<14) only has the deprecated addListener/removeListener pair\n // and throws on addEventListener. Prefer modern, fall back to legacy,\n // and fail open when neither exists (mirrors the missing-matchMedia case).\n if (typeof mql.addEventListener === \"function\") {\n mql.addEventListener(\"change\", handler);\n removeListener = () => mql.removeEventListener(\"change\", handler);\n } else if (typeof mql.addListener === \"function\") {\n mql.addListener(handler);\n removeListener = () => mql.removeListener(handler);\n } else {\n fire();\n return { fired: true, cancel: noop };\n }\n\n return {\n fired: false,\n cancel: () => {\n const alreadyFired = gate.cancel();\n if (alreadyFired) return;\n removeListener();\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// mountIslands — DOM walk + dynamic-import dispatcher.\n//\n// `mountIslands` is the entry point the generated `islands-runtime-<hash>.js`\n// bundle calls at script load time. It walks the DOM for the two island\n// markers emitted by the server-side hydration step and the `<Island>`\n// JSX wrapper:\n//\n// 1. `[data-zfb-island]` — SSR'd islands. We `hydrate()` (Preact) /\n// `hydrateRoot()` (React) against the existing server-rendered\n// DOM, gated by `scheduleHydrate(when)`.\n//\n// 2. `[data-zfb-island-skip-ssr]` — SSR-skip islands. The server\n// emitted no markup for these, so we `render()` (Preact) /\n// `createRoot().render()` (React). Skipping hydrate for this case\n// avoids the hydrate-mismatch warnings React/Preact would emit\n// against an empty DOM container.\n//\n// The per-island bundles each export a `mount(props, element, mode)`\n// function (see zfb_islands::render_island_entry_source). The\n// framework-specific glue lives inside that bundle, so this runtime is\n// framework-agnostic.\n//\n// ## Module-level singleton\n//\n// Dynamic imports of the same URL are cached by the JS runtime, so\n// \"switching pages\" (in an SPA shell) reuses the loaded bundle for free.\n// We keep an extra in-memory dedup map keyed by element so an island is\n// never mounted twice (e.g. on hot-reload / repeat-mount scenarios).\n// ---------------------------------------------------------------------------\n\n/**\n * The shape of the default export each per-island bundle ships.\n *\n * `mode === \"hydrate\"` is used for SSR'd islands, `\"render\"` for\n * SSR-skip islands.\n */\ntype IslandMount = (\n props: Record<string, unknown>,\n element: Element,\n mode: \"hydrate\" | \"render\",\n) => void;\n\ntype IslandUnmount = (element: Element) => void;\n\ninterface IslandModule {\n mount?: IslandMount;\n default?: IslandMount;\n unmount?: IslandUnmount;\n}\n\n/**\n * Map of `componentName → island descriptor` baked into the runtime entry.\n *\n * Two descriptor shapes are accepted so the same `mountIslands` runtime\n * handles both bundling strategies the build emits:\n *\n * 1. `string` — a per-island bundle URL. The runtime fetches it via\n * dynamic `import()` and reads `mount` / `default` off the loaded\n * module. Used by the per-island bundling path\n * (`bundle_per_island` / `render_runtime_entry_source`).\n *\n * 2. `IslandModule` — an inline module-shaped object whose `mount` (or\n * `default`) is called directly. Used by the shared-bundle path\n * (`render_shared_bundle_entry_source`): every island's source code\n * is already in the same bundle, so the synthesised entry can hand\n * the runtime the constructed mount functions inline without a\n * second HTTP fetch. This preserves the one-request shared-bundle\n * contract while giving up nothing on hydration semantics\n * (zudolab/zudo-doc#1355 wave 6).\n */\nexport type IslandManifestValue = string | IslandModule;\nexport type IslandManifest = Readonly<Record<string, IslandManifestValue>>;\n\n// data-zfb-transition-persist marker attribute — the client-router's persist\n// contract. Mirrored from client-router/swap-functions.ts: that package owns the\n// body swap (lifting persisted nodes into the incoming body), this package owns\n// island mount/unmount. Both must agree on the literal string. See the port\n// spec at packages/zfb-runtime/docs/client-router/port-spec.md §12.3.\nconst PERSIST_ATTR = \"data-zfb-transition-persist\";\n\n// Cross-package \"needs-remount\" flag set by client-router/swap-functions.ts on a\n// persisted island whose props changed across a body swap. Mirrored literal (same\n// cross-package contract as PERSIST_ATTR above — both packages must agree on the\n// string). Consumed by clearMountedForRemount(). See #1389.\nconst ISLAND_REMOUNT_ATTR = \"data-zfb-island-remount\";\n\n// WeakMap<Element, unmount thunk> — replaces the old WeakSet.\n// Value is a per-element function that calls the bundle's unmount(element)\n// (or a noop if the bundle does not expose one). Used by unmountIslands()\n// to fire framework lifecycle cleanups before a body swap.\nconst mounted = new WeakMap<Element, () => void>();\n// Elements for which the nested-island self-wrap warning has already been\n// emitted. Guards against repeated warn spam across re-walks (e.g. SPA swaps).\nconst warnedNested = new WeakSet<Element>();\n// Elements with an in-flight dynamic import that has not yet resolved.\n// Two concurrent `mountIslands` invocations (or two `scheduleMount`\n// calls hitting the same element through different code paths) could\n// otherwise both pass the `mounted` guard and both spawn an\n// `importIsland(url)` -> `fn()` chain, double-mounting the component.\n// Adding the element to `pending` synchronously, before the import is\n// fired, closes that window; the entry is removed in both the success\n// (after `mounted.set`) and failure branches.\nconst pending = new WeakSet<Element>();\n\n// Module-level captured manifest — set by the first `mountIslands` call and reused by\n// `mountNewIslands()` so the client-router does not need to know the manifest directly.\n// Named technical cause (W1B §12.1): the router lives in @takazudo/zfb-runtime; the\n// islands manifest lives in @takazudo/zfb. Passing the manifest through the swap event\n// would require widening the event API or threading manifest into router options. The\n// captured-manifest pattern keeps the package boundary clean.\nlet capturedManifest: IslandManifest | null = null;\n\n// Map of element → cancel-function for deferred-hydration islands\n// (data-when=\"idle\"|\"visible\"|\"media\").\n// Populated in scheduleMount; consulted on `zfb:before-swap` so deferred fires do not run\n// against orphan elements after a body swap. (W1B §12.5)\nconst pendingCancels = new Map<Element, () => void>();\n\n/**\n * Walk the DOM and mount every `[data-zfb-island]` / `[data-zfb-island-skip-ssr]`\n * element using `manifest`.\n *\n * No-op when `document` is undefined (SSR, edge runtime). Safe to call\n * multiple times: each element is mounted at most once thanks to the\n * `mounted` WeakSet guard.\n *\n * The manifest is captured at module level so `mountNewIslands()` can re-use\n * it after an SPA body swap without needing the caller to re-supply it.\n */\nexport function mountIslands(manifest: IslandManifest): void {\n if (typeof document === \"undefined\") return;\n\n // Capture the manifest for post-swap re-walks via mountNewIslands().\n capturedManifest = manifest;\n\n const ssrIslands = document.querySelectorAll<HTMLElement>(\"[data-zfb-island]\");\n for (const el of Array.from(ssrIslands)) {\n // Skip the empty-skeleton case left behind when the server-side\n // rewriter has not run yet (data-zfb-island=\"\" with no component\n // name). The hydration emit step is expected to fill this in\n // before the page reaches the browser; if it didn't, we cannot\n // dispatch.\n const name = el.getAttribute(\"data-zfb-island\");\n if (!name) continue;\n warnIfNestedIsland(el, name);\n scheduleMount(manifest, el, name, \"hydrate\");\n }\n\n const skipSsrIslands = document.querySelectorAll<HTMLElement>(\"[data-zfb-island-skip-ssr]\");\n for (const el of Array.from(skipSsrIslands)) {\n const name = el.getAttribute(\"data-zfb-island-skip-ssr\");\n if (!name) continue;\n warnIfNestedIsland(el, name);\n scheduleMount(manifest, el, name, \"render\");\n }\n}\n\n/**\n * Re-walk the current document body and mount any new island markers introduced\n * by an SPA body swap. Uses the manifest captured by the previous `mountIslands`\n * call — no manifest arg required.\n *\n * The caller (client-router `router.ts`) invokes this after `swap()` + `runScripts()`\n * and before dispatching `zfb:page-load`, per W1B §12.2 contract.\n *\n * No-op when called before `mountIslands` (capturedManifest is null) or when\n * `document` is undefined.\n */\nexport function mountNewIslands(): void {\n if (typeof document === \"undefined\") return;\n if (capturedManifest === null) return;\n\n const manifest = capturedManifest;\n\n const ssrIslands = document.querySelectorAll<HTMLElement>(\"[data-zfb-island]\");\n for (const el of Array.from(ssrIslands)) {\n const name = el.getAttribute(\"data-zfb-island\");\n if (!name) continue;\n // A persisted island whose props changed across the body swap is flagged\n // for remount by swap-functions.swapBodyElement. Clear its surviving mounted\n // entry BEFORE scheduleMount's already-mounted guard so it re-mounts fresh\n // with the refreshed data-props. No-op for every other element.\n const forceRemount = clearMountedForRemount(el);\n warnIfNestedIsland(el, name);\n scheduleMount(manifest, el, name, \"hydrate\", { force: forceRemount });\n }\n\n const skipSsrIslands = document.querySelectorAll<HTMLElement>(\"[data-zfb-island-skip-ssr]\");\n for (const el of Array.from(skipSsrIslands)) {\n const name = el.getAttribute(\"data-zfb-island-skip-ssr\");\n if (!name) continue;\n warnIfNestedIsland(el, name);\n scheduleMount(manifest, el, name, \"render\");\n }\n}\n\n/**\n * Consume the cross-package \"needs-remount\" signal for the persist-props hybrid\n * path (port-spec §12.3.1 hybrid case / §12.3.2). When a persisted island's\n * props differ from the incoming markup, `swapBodyElement` refreshes the\n * surviving element's `data-props` and marks it with `ISLAND_REMOUNT_ATTR`.\n * That attribute is the ONLY channel that crosses the zfb-runtime → zfb package\n * boundary — the `mounted` map is module-private to this file, so a shared\n * in-memory \"needs-remount\" queue between the two packages is impossible; the\n * live DOM node carrying the flag IS the queue.\n *\n * On a flagged mounted element: fire the old instance's unmount thunk (so its\n * useEffect/framework cleanups run against the still-connected node), drop the\n * `mounted` entry so `scheduleMount`'s guard no longer short-circuits, strip the\n * flag, and ask the caller to force the replacement mount through immediately\n * instead of re-entering any deferred scheduler. This keeps a deferred persisted\n * island from blanking while it waits for idle/visible/media to fire again.\n *\n * On a flagged element whose URL import is still pending, leave the flag in\n * place. The already-running import's success handler consumes it after the\n * module resolves and re-reads `data-props` at that point, so a props refresh\n * that happened during the import wins without starting a duplicate import.\n *\n * A no-op for elements without the flag (the common case: fresh markers and\n * props-unchanged persisted islands).\n *\n * Scope: only the `[data-zfb-island]` (hydrated) loop calls this, mirroring the\n * writer side — swapBodyElement sets the flag only for `newTarget.matches(\n * \"[data-zfb-island]\")`, never for skip-ssr islands.\n */\nfunction clearMountedForRemount(el: Element): boolean {\n if (!el.hasAttribute(ISLAND_REMOUNT_ATTR)) return false;\n if (pending.has(el)) return false;\n\n const thunk = mounted.get(el);\n if (thunk) {\n thunk();\n mounted.delete(el);\n el.removeAttribute(ISLAND_REMOUNT_ATTR);\n return true;\n }\n el.removeAttribute(ISLAND_REMOUNT_ATTR);\n return false;\n}\n\n/**\n * Cancel deferred-hydration callbacks for all islands in the old body before a\n * swap. Prevents idle / visibility callbacks from running against orphan elements\n * after `swapBodyElement` removes them from the live document. (W1B §12.5)\n *\n * Call this on `zfb:before-swap` (or equivalently, in the router's swap sequence\n * before `swap()` mutates the DOM). Fire-and-forget; safe to call if nothing is\n * pending.\n */\nexport function cancelPendingIslands(): void {\n for (const [el, cancel] of pendingCancels) {\n cancel();\n pendingCancels.delete(el);\n }\n}\n\n/**\n * Warn (once per element, dev-only) when an island marker element is found\n * nested inside another island marker. Self-wrapping an island — emitting a\n * `data-zfb-island` or `data-zfb-island-skip-ssr` container *inside* another\n * island component's render output — mis-hydrates because the runtime will\n * try to mount both the outer and inner islands independently. The outer\n * island's framework instance owns the inner DOM, so a second `hydrate()` /\n * `render()` call against the inner element races with the outer render and\n * produces undefined behaviour.\n *\n * The fix is to author the inner component bare (no `<Island>` in its own\n * render output) and apply the `<Island when=\"...\">` wrapper at the call site.\n */\nfunction warnIfNestedIsland(el: Element, componentName: string): void {\n if (typeof process === \"undefined\" || !process.env || process.env[\"NODE_ENV\"] === \"production\") {\n return;\n }\n if (warnedNested.has(el)) return;\n const parent = el.parentElement;\n if (!parent || typeof parent.closest !== \"function\") return;\n const ancestor = parent.closest(\"[data-zfb-island],[data-zfb-island-skip-ssr]\");\n if (!ancestor) return;\n warnedNested.add(el);\n // eslint-disable-next-line no-console\n console.warn(\n `[zfb] Island \"${componentName}\" is nested inside another island marker. ` +\n `Self-wrapping an island mis-hydrates: the outer framework instance owns ` +\n `the inner DOM, causing a conflicting mount. ` +\n `Fix: author \"${componentName}\" bare (remove <Island> from its own render output) ` +\n `and apply <Island when=\"...\"> at the call site instead.`,\n );\n}\n\nfunction scheduleMount(\n manifest: IslandManifest,\n element: Element,\n componentName: string,\n mode: \"hydrate\" | \"render\",\n options: { force?: boolean } = {},\n): void {\n // Skip elements already mounted OR currently importing — the latter\n // prevents two concurrent `mountIslands` calls from each firing a\n // separate dynamic import for the same element.\n if (mounted.has(element) || pending.has(element)) return;\n\n const entry = manifest[componentName];\n if (entry == null) {\n if (typeof process !== \"undefined\" && process.env && process.env[\"NODE_ENV\"] !== \"production\") {\n // eslint-disable-next-line no-console\n console.warn(\n `[zfb] no island manifest entry for component \"${componentName}\" — ` +\n `the runtime manifest is out of sync with the rendered HTML.`,\n );\n }\n return;\n }\n\n const when = element.getAttribute(\"data-when\") ?? undefined;\n\n // Two manifest shapes:\n //\n // - `string` (per-island bundle URL): fetch via dynamic `import()`\n // and call `mount` / `default` on the resolved module.\n // - `IslandModule` (inline descriptor): the shared-bundle path has\n // already imported every island's source into the same bundle and\n // constructed a mount function for it. Skip the dynamic import\n // and call the supplied function directly.\n if (typeof entry !== \"string\") {\n fireInlineMount(element, entry, mode, options);\n return;\n }\n\n const url: string = entry;\n\n const fire = (): void => {\n // Re-check both guards in case `fire` is invoked from a deferred\n // scheduler (rIC/rAF/visibility) after a sibling caller already\n // mounted or started importing for this element.\n if (mounted.has(element) || pending.has(element)) return;\n\n // When the deferred fire actually runs, the cancel handle is no longer\n // needed — remove it so pendingCancels doesn't hold stale entries.\n pendingCancels.delete(element);\n\n // Lazy props parse: read and parse data-props only now that we know we\n // are actually going to mount this island. For deferred strategies\n // (media, visible, idle) this avoids JSON.parse work at boot time for\n // islands that may never hydrate (e.g. media query never matches).\n const props = readProps(element);\n\n // Mark as pending BEFORE firing the import so any concurrent\n // `mountIslands` invocation that arrives during the await window\n // is short-circuited by `scheduleMount`'s guard.\n pending.add(element);\n\n // Dynamic-import is cached by the JS runtime, so repeat hits for\n // the same URL share the resolved module — module-level\n // singletons are fine.\n //\n // We move the element from `pending` to `mounted` only on the\n // success path so a failed import (e.g. transient network blip\n // in dev) doesn't permanently block a retry of the same element.\n let started: Promise<IslandModule>;\n try {\n started = importIsland(url);\n } catch (err) {\n // Some implementations of dynamic-import wrappers can throw\n // synchronously (e.g. URL parsing errors). Treat the same as\n // an async rejection.\n pending.delete(element);\n // eslint-disable-next-line no-console\n console.error(`[zfb] failed to start dynamic import for ${url}`, err);\n return;\n }\n started.then(\n (mod) => {\n const fn = mod.mount ?? mod.default;\n if (typeof fn !== \"function\") {\n pending.delete(element);\n if (\n typeof process !== \"undefined\" &&\n process.env &&\n process.env[\"NODE_ENV\"] !== \"production\"\n ) {\n // eslint-disable-next-line no-console\n console.warn(`[zfb] island bundle at ${url} did not export mount() or default()`);\n }\n return;\n }\n // Stale-mount race guard: if the element was detached while the\n // dynamic import was in-flight (e.g. a body swap happened), skip\n // mounting — the element is no longer in the live document and\n // its useEffect listeners would never receive a cleanup call.\n if (!element.isConnected) {\n pending.delete(element);\n return;\n }\n const shouldRefreshProps = element.hasAttribute(ISLAND_REMOUNT_ATTR);\n const propsForMount = shouldRefreshProps ? readProps(element) : props;\n if (shouldRefreshProps) element.removeAttribute(ISLAND_REMOUNT_ATTR);\n const unmountThunk = mod.unmount\n ? () => mod.unmount!(element)\n : () => {\n // noop — bundle does not expose unmount\n };\n mounted.set(element, unmountThunk);\n pending.delete(element);\n fn(propsForMount, element, mode);\n },\n (err: unknown) => {\n // Surface the error in dev so the user notices, then clear\n // both guards so a later retry (e.g. another scheduleHydrate\n // fire) can attempt the import again.\n pending.delete(element);\n mounted.delete(element);\n // eslint-disable-next-line no-console\n console.error(`[zfb] failed to load island bundle ${url}`, err);\n },\n );\n };\n\n if (mode === \"render\") {\n // SSR-skip islands ignore data-when: there is nothing to defer\n // hydration of, just an empty container we paint into. Mount\n // immediately so the user sees output.\n fire();\n return;\n }\n\n if (options.force) {\n fire();\n return;\n }\n\n const { fired, cancel } = scheduleHydrateInternal(element, when, fire);\n // Track deferred-hydration cancel handle so cancelPendingIslands() can abort\n // idle / visibility callbacks before a body swap. (W1B §12.5)\n // Only register when the scheduler did NOT fire synchronously — a synchronous\n // fire means the island is already handling its import and there is no\n // deferred callback to cancel. Registering noop after a sync fire would leave\n // a stale pendingCancels entry for an already-handled element. (#743)\n if (when && when !== \"load\" && !fired) {\n pendingCancels.set(element, cancel);\n }\n}\n\n/**\n * Run the mount step for the inline-module manifest shape used by the\n * shared-bundle path. The module is already in memory (it was imported\n * into the bundle at build time), so there is no async window to\n * coordinate around — we just call `mount` / `default` directly,\n * gated by the same `data-when` semantics as the URL path.\n */\nfunction fireInlineMount(\n element: Element,\n mod: IslandModule,\n mode: \"hydrate\" | \"render\",\n options: { force?: boolean } = {},\n): void {\n const fn = mod.mount ?? mod.default;\n if (typeof fn !== \"function\") {\n if (typeof process !== \"undefined\" && process.env && process.env[\"NODE_ENV\"] !== \"production\") {\n // eslint-disable-next-line no-console\n console.warn(\"[zfb] inline island manifest entry did not export mount() or default()\");\n }\n return;\n }\n\n const fire = (): void => {\n // Re-check the guard in case `fire` is invoked from a deferred\n // scheduler (rIC/rAF/visibility) after a sibling caller already\n // mounted this element.\n if (mounted.has(element)) return;\n // When the deferred fire actually runs, the cancel handle is no longer\n // needed — remove it so pendingCancels doesn't hold stale entries.\n pendingCancels.delete(element);\n // Stale-mount race guard for deferred inline mounts: skip if the element\n // was detached (e.g. body swap) while the idle/visible callback was queued.\n if (!element.isConnected) return;\n // Lazy props parse: read and parse data-props only at mount time.\n // For deferred strategies (media, visible, idle) this avoids JSON.parse\n // work at boot time for islands that may never hydrate.\n const props = readProps(element);\n const unmountThunk = mod.unmount\n ? () => mod.unmount!(element)\n : () => {\n // noop — inline module does not expose unmount\n };\n mounted.set(element, unmountThunk);\n fn(props, element, mode);\n };\n\n if (mode === \"render\") {\n fire();\n return;\n }\n\n if (options.force) {\n fire();\n return;\n }\n\n const when = element.getAttribute(\"data-when\") ?? undefined;\n const { fired, cancel } = scheduleHydrateInternal(element, when, fire);\n // Track deferred-hydration cancel handle so cancelPendingIslands() can abort\n // idle / visibility callbacks before a body swap. (W1B §12.5)\n // Only register when the scheduler did NOT fire synchronously — a synchronous\n // fire means the island is already handling its mount and there is no\n // deferred callback to cancel. Registering noop after a sync fire would leave\n // a stale pendingCancels entry for an already-handled element. (#743)\n if (when && when !== \"load\" && !fired) {\n pendingCancels.set(element, cancel);\n }\n}\n\n/**\n * Unmount the mounted islands within `root` (default: `document.body`) that will\n * NOT survive the body swap.\n *\n * Walks `root` for `[data-zfb-island]` and `[data-zfb-island-skip-ssr]` elements,\n * looks up each element's unmount thunk in the `mounted` WeakMap, calls it (which\n * triggers `render(null, element)` for Preact or `root.unmount()` for React), and\n * removes the entry from the map so `mountNewIslands()` can re-mount later.\n *\n * Call this before `swapBodyElement(...)` so the OLD body's islands receive proper\n * framework lifecycle cleanup (useEffect teardowns, etc.) before being discarded.\n *\n * When `incomingBody` is supplied (the client-router passes the parsed incoming\n * document body), any island whose `data-zfb-transition-persist` id matches a\n * marker in that body is DELIBERATELY SKIPPED: swapBodyElement will physically\n * lift the node into the new body, so its component instance and internal state\n * must survive — unmounting it here would empty the container before the lift and\n * defeat the persist contract (issue #1389). Omit `incomingBody` (or pass null)\n * to unmount everything, the pre-#1389 behavior.\n *\n * No-op for elements not in the `mounted` map (e.g. never-mounted or already cleaned up).\n */\nexport function unmountIslands(\n root: ParentNode = document.body,\n incomingBody?: ParentNode | null,\n): void {\n const selector = \"[data-zfb-island],[data-zfb-island-skip-ssr]\";\n // Persist ids that `swapBodyElement` will physically LIFT from the old body\n // into the incoming body — an old marker survives iff the incoming body has a\n // marker with the same `data-zfb-transition-persist` id. Those DOM nodes are\n // moved, not discarded, so their component instance and internal state MUST\n // survive the swap: skip their framework unmount here or the persist contract\n // preserves nothing (port-spec §12.3.1 case (a) / issue #1389). A persisted\n // island whose props changed is skipped here too — its refreshed remount runs\n // later in mountNewIslands via the `data-zfb-island-remount` flag (see\n // `clearMountedForRemount`) swapBodyElement sets. With no incoming body (a\n // call outside a swap) nothing is preserved, so the walk is byte-identical to\n // the pre-#1389 behavior.\n const preservedPersistIds = collectPersistIds(incomingBody);\n const elements = root.querySelectorAll<HTMLElement>(selector);\n for (const el of Array.from(elements)) {\n const persistId = el.getAttribute(PERSIST_ATTR);\n if (persistId !== null && preservedPersistIds.has(persistId)) continue;\n const thunk = mounted.get(el);\n if (thunk) {\n thunk();\n mounted.delete(el);\n }\n }\n}\n\n/**\n * Collect the `data-zfb-transition-persist` ids present in the incoming body so\n * `unmountIslands` can tell which old-body islands `swapBodyElement` will lift\n * (and therefore must be left mounted). Returns an empty set when no incoming\n * body is supplied.\n */\nfunction collectPersistIds(incomingBody?: ParentNode | null): Set<string> {\n const ids = new Set<string>();\n if (!incomingBody) return ids;\n for (const el of incomingBody.querySelectorAll(`[${PERSIST_ATTR}]`)) {\n const id = el.getAttribute(PERSIST_ATTR);\n if (id !== null) ids.add(id);\n }\n return ids;\n}\n\nfunction readProps(element: Element): Record<string, unknown> {\n const raw = element.getAttribute(\"data-props\");\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw) as unknown;\n // Reject arrays explicitly: `typeof [] === \"object\"` is true but\n // an array is not a valid props bag, and passing it through would\n // mean the component receives index-keyed values where it\n // expected a record. Fall through to the empty-object default\n // instead of forwarding a malformed shape.\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n } catch {\n // fall through\n }\n return {};\n}\n\n/**\n * Indirection so tests can stub the dynamic import without intercepting\n * the global `import()`. In production this is a thin wrapper over\n * native `import(url)`.\n */\nlet importImpl: (url: string) => Promise<IslandModule> = (url) =>\n // Modern bundlers (esbuild, Vite, Rollup, webpack) preserve a plain\n // `import(<dynamic>)` call when the argument isn't a static literal,\n // so we no longer need the `new Function(...)` indirection — which\n // also failed under strict CSPs that disallow `unsafe-eval`.\n import(/* @vite-ignore */ /* webpackIgnore: true */ url) as Promise<IslandModule>;\n\nfunction importIsland(url: string): Promise<IslandModule> {\n return importImpl(url);\n}\n\n/**\n * Test-only seam. Replace the module dynamic-import with a fake.\n * Returns the previous implementation so tests can restore it.\n */\nexport function __setIslandImporterForTests(\n impl: (url: string) => Promise<IslandModule>,\n): (url: string) => Promise<IslandModule> {\n const prev = importImpl;\n importImpl = impl;\n return prev;\n}\n\n/**\n * Test-only seam. Returns whether the given element has an entry in the\n * module-private `pendingCancels` Map. Used to assert that a synchronous\n * scheduler fire does not leave a stale entry behind. (#743)\n */\nexport function __hasPendingCancelForTests(element: Element): boolean {\n return pendingCancels.has(element);\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takazudo/zfb",
3
- "version": "0.1.0-next.76",
3
+ "version": "0.1.0-next.78",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Rust-built static-site engine for Astro and Next.js users — millisecond rebuilds, single binary. SDK with islands, content collections, pagination, and config helpers.",
@@ -74,11 +74,11 @@
74
74
  "LICENSE"
75
75
  ],
76
76
  "optionalDependencies": {
77
- "@takazudo/zfb-darwin-arm64": "0.1.0-next.76",
78
- "@takazudo/zfb-darwin-x64": "0.1.0-next.76",
79
- "@takazudo/zfb-linux-arm64-gnu": "0.1.0-next.76",
80
- "@takazudo/zfb-linux-x64-gnu": "0.1.0-next.76",
81
- "@takazudo/zfb-win32-x64-msvc": "0.1.0-next.76"
77
+ "@takazudo/zfb-darwin-arm64": "0.1.0-next.78",
78
+ "@takazudo/zfb-darwin-x64": "0.1.0-next.78",
79
+ "@takazudo/zfb-linux-arm64-gnu": "0.1.0-next.78",
80
+ "@takazudo/zfb-linux-x64-gnu": "0.1.0-next.78",
81
+ "@takazudo/zfb-win32-x64-msvc": "0.1.0-next.78"
82
82
  },
83
83
  "publishConfig": {
84
84
  "access": "public"