@moku-labs/web 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -3169,6 +3169,26 @@ function composeHead(input) {
3169
3169
  }), ...head.elements ?? []]);
3170
3170
  }
3171
3171
  /**
3172
+ * Resolve the FINAL document title for a route's head config — the same value
3173
+ * {@link composeHead} emits in its `<title>` element. A route-supplied `title`-keyed
3174
+ * element wins the keyed last-wins de-dupe over the templated base title (how a route
3175
+ * pins a bare title past `titleTemplate`), so it must win here too; otherwise the
3176
+ * template is applied to `head.title ?? site.name()`. Reused by `spa` for the client
3177
+ * DATA-path `document.title` sync, so client-side navigation matches the SSG output.
3178
+ *
3179
+ * @param head - The route's head config (may be `undefined` for head-less routes).
3180
+ * @param defaults - The normalized head defaults (provides `titleTemplate`).
3181
+ * @param site - The site slice (title fallback).
3182
+ * @returns The final document title string.
3183
+ * @example composeTitle({ title: "Page 2" }, defaults, site) // "Page 2 — Site"
3184
+ */
3185
+ function composeTitle(head, defaults, site) {
3186
+ const config = head ?? {};
3187
+ const pinned = config.elements?.findLast((element) => element.key === "title");
3188
+ if (pinned?.children !== void 0) return pinned.children;
3189
+ return applyTemplate(config.title ?? site.name(), defaults.titleTemplate);
3190
+ }
3191
+ /**
3172
3192
  * Compose the SITE-LEVEL Open Graph / Twitter block for a bare-path redirect or landing
3173
3193
  * page that has no per-route head of its own. Returns `[]` UNLESS a `defaultOgImage` is
3174
3194
  * configured — so apps that opt out keep a bare redirect (no behavior change). The site
@@ -3338,6 +3358,21 @@ function createApi$4(ctx) {
3338
3358
  url: site.canonical(input.url),
3339
3359
  ...ogLocale === void 0 ? {} : { ogLocale }
3340
3360
  }));
3361
+ },
3362
+ /**
3363
+ * Resolve the FINAL document title for a route's head config — the same value `render`
3364
+ * emits in its `<title>` element. Pulled by `spa` on the client DATA path so a
3365
+ * client-side navigation's `document.title` matches the SSG output.
3366
+ *
3367
+ * @param head - The route's head config (may be `undefined` for head-less routes).
3368
+ * @returns The final document title string.
3369
+ * @example
3370
+ * ```ts
3371
+ * api.composeTitle({ title: "Page 2" }); // "Page 2 — Site"
3372
+ * ```
3373
+ */
3374
+ composeTitle(head) {
3375
+ return composeTitle(head, readDefaults(ctx.state), ctx.require(sitePlugin));
3341
3376
  }
3342
3377
  };
3343
3378
  }
@@ -3486,7 +3521,9 @@ const headPlugin = createPlugin$1("head", {
3486
3521
  //#region src/plugins/build/phases/bundle.ts
3487
3522
  /**
3488
3523
  * @file build phase 1 — bundle. Runs `Bun.build` for CSS and JS separately into
3489
- * outDir (honoring `config.minify`); caches hashed asset paths for the pages phase.
3524
+ * outDir (honoring `config.minify`) with content-hashed output naming; caches the
3525
+ * fingerprinted asset paths for the pages phase and the complete output list for
3526
+ * the cache-headers phase.
3490
3527
  */
3491
3528
  /** Conventional CSS entry candidates (project-relative). */
3492
3529
  const CSS_ENTRY_CANDIDATES = ["src/client/styles.css", "src/styles/main.css"];
@@ -3497,18 +3534,38 @@ const JS_ENTRY_CANDIDATES = [
3497
3534
  "src/main.ts"
3498
3535
  ];
3499
3536
  /**
3537
+ * `Bun.build` output naming with a content hash in EVERY filename (entry points
3538
+ * included — Bun's default only hashes chunks/assets). A bundle's URL therefore
3539
+ * changes whenever its bytes change, which is what lets the cache-headers phase
3540
+ * mark each bundle immutable: a CDN/browser may cache it forever, and a deploy
3541
+ * that changes the code ships a NEW URL instead of fighting a stale cached copy.
3542
+ * Pages always embed bundle URLs via the `state.buildCache` manifest, so hashed
3543
+ * names flow through with no app-side changes (hardcoded asset URLs must move to
3544
+ * the `<!--moku:assets-->` placeholders). Chunk naming keeps Bun's default
3545
+ * `chunk-` prefix (chunks were already hash-only named).
3546
+ */
3547
+ const FINGERPRINT_NAMING = {
3548
+ entry: "[dir]/[name]-[hash].[ext]",
3549
+ chunk: "chunk-[hash].[ext]",
3550
+ asset: "[name]-[hash].[ext]"
3551
+ };
3552
+ /**
3500
3553
  * The default bundler runner — adapts the built-in `Bun.build`.
3501
3554
  *
3502
- * @param options - Entry/outdir/minify/splitting/target settings forwarded to `Bun.build`.
3555
+ * @param options - Entry/outdir/minify/splitting/target/naming settings forwarded to `Bun.build`.
3503
3556
  * @param options.entrypoints - Entry files for this build.
3504
3557
  * @param options.outdir - Output directory.
3505
3558
  * @param options.minify - Whether to minify.
3506
3559
  * @param options.splitting - Whether to split dynamic imports into lazy chunks.
3507
3560
  * @param options.target - The bundling target platform.
3561
+ * @param options.naming - Output naming templates (content-hashed filenames).
3562
+ * @param options.naming.entry - Naming template for entry-point outputs.
3563
+ * @param options.naming.chunk - Naming template for lazy split chunks.
3564
+ * @param options.naming.asset - Naming template for additional emitted assets.
3508
3565
  * @returns The structural build result.
3509
3566
  * @example
3510
3567
  * ```ts
3511
- * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser" });
3568
+ * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser", naming: FINGERPRINT_NAMING });
3512
3569
  * ```
3513
3570
  */
3514
3571
  async function defaultRunner(options) {
@@ -3600,7 +3657,8 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3600
3657
  outdir,
3601
3658
  minify,
3602
3659
  splitting: true,
3603
- target: "browser"
3660
+ target: "browser",
3661
+ naming: FINGERPRINT_NAMING
3604
3662
  });
3605
3663
  if (!result.success) throw new Error(`[web] build.bundle ${kind} build failed`);
3606
3664
  const hashed = {};
@@ -3609,6 +3667,7 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3609
3667
  hashed[node_path$1.default.basename(output.path)] = normalizeAssetPath(output.path, outDir);
3610
3668
  }
3611
3669
  ctx.state.buildCache.set(kind, hashed);
3670
+ ctx.state.buildCache.set(`${kind}:outputs`, result.outputs.map((output) => normalizeAssetPath(output.path, outDir)));
3612
3671
  ctx.log.debug("build:bundle", {
3613
3672
  kind,
3614
3673
  count: result.outputs.length
@@ -3640,6 +3699,269 @@ async function bundle(ctx, options = {}) {
3640
3699
  await Promise.all([runOne(ctx, runner, "css", cssEntrypoints, outDir, assetsDir, minify), runOne(ctx, runner, "js", jsEntrypoints, outDir, assetsDir, minify)]);
3641
3700
  }
3642
3701
  //#endregion
3702
+ //#region src/plugins/build/phases/asset-tags.ts
3703
+ /** Template placeholder for the injected asset tags (stylesheets + scripts). */
3704
+ const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
3705
+ /** Template placeholder for the injected stylesheet `<link>` tags ONLY. */
3706
+ const CSS_ASSETS_PLACEHOLDER = "<!--moku:assets:css-->";
3707
+ /** Template placeholder for the injected `<script>` tags ONLY. */
3708
+ const JS_ASSETS_PLACEHOLDER = "<!--moku:assets:js-->";
3709
+ /**
3710
+ * Read the bundle phase's fingerprinted asset manifest for one kind from
3711
+ * `state.buildCache` as a typed {@link BuildCacheEntry} (no `Map<string, unknown>`
3712
+ * reads at call sites).
3713
+ *
3714
+ * @param ctx - Plugin context (provides `state`).
3715
+ * @param kind - The asset kind key (`"css"` / `"js"`).
3716
+ * @returns The fingerprinted-path manifest entry, or an empty object when absent.
3717
+ * @example
3718
+ * ```ts
3719
+ * readManifest(ctx, "css"); // { "main.css": "assets/main-abc123.css" }
3720
+ * ```
3721
+ */
3722
+ function readManifest(ctx, kind) {
3723
+ const entry = ctx.state.buildCache.get(kind);
3724
+ return entry && typeof entry === "object" ? entry : {};
3725
+ }
3726
+ /**
3727
+ * Read the bundle phase's COMPLETE output list for one kind (entries + lazy split
3728
+ * chunks, web paths relative to the publish root) from `state.buildCache`. Unlike
3729
+ * {@link readManifest} this includes chunks — it feeds the cache-headers phase's
3730
+ * per-file immutable rules, where every fingerprinted file counts, not just the
3731
+ * eagerly embedded entries.
3732
+ *
3733
+ * @param ctx - Plugin context (provides `state`).
3734
+ * @param kind - The asset kind key (`"css"` / `"js"`).
3735
+ * @returns The publish-root-relative output paths, or an empty array when absent.
3736
+ * @example
3737
+ * ```ts
3738
+ * readBundleOutputs(ctx, "js"); // ["assets/spa-abc123.js", "assets/chunk-9f8e.js"]
3739
+ * ```
3740
+ */
3741
+ function readBundleOutputs(ctx, kind) {
3742
+ const entry = ctx.state.buildCache.get(`${kind}:outputs`);
3743
+ return Array.isArray(entry) ? entry : [];
3744
+ }
3745
+ /**
3746
+ * Render the stylesheet `<link>` tags for the fingerprinted CSS manifest.
3747
+ *
3748
+ * @param ctx - Plugin context (provides `state`).
3749
+ * @returns The concatenated `<link rel="stylesheet">` tags (possibly `""`).
3750
+ * @example
3751
+ * ```ts
3752
+ * buildCssTags(ctx); // '<link rel="stylesheet" href="/assets/main-abc123.css">'
3753
+ * ```
3754
+ */
3755
+ function buildCssTags(ctx) {
3756
+ return Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`).join("");
3757
+ }
3758
+ /**
3759
+ * Render the module `<script>` tags for the fingerprinted JS manifest.
3760
+ *
3761
+ * @param ctx - Plugin context (provides `state`).
3762
+ * @returns The concatenated `<script type="module">` tags (possibly `""`).
3763
+ * @example
3764
+ * ```ts
3765
+ * buildJsTags(ctx); // '<script type="module" src="/assets/spa-abc123.js"><\/script>'
3766
+ * ```
3767
+ */
3768
+ function buildJsTags(ctx) {
3769
+ return Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`).join("");
3770
+ }
3771
+ /**
3772
+ * Build the asset tag block from the fingerprinted manifests — both kinds by
3773
+ * default, or a single kind for the split `<!--moku:assets:css/js-->`
3774
+ * placeholders. Returns an empty string when `config.injectAssets === false`.
3775
+ * Asset paths are emitted as absolute (`/`-rooted) URLs.
3776
+ *
3777
+ * @param ctx - Plugin context (provides `state`, `config`).
3778
+ * @param kind - Restrict the block to one asset kind; omit for stylesheets + scripts.
3779
+ * @returns The injected asset tags, or `""` when injection is disabled.
3780
+ * @example
3781
+ * ```ts
3782
+ * buildAssetTags(ctx); // <link …><script …><\/script>
3783
+ * buildAssetTags(ctx, "css"); // <link …> only
3784
+ * ```
3785
+ */
3786
+ function buildAssetTags(ctx, kind) {
3787
+ if (ctx.config.injectAssets === false) return "";
3788
+ if (kind === "css") return buildCssTags(ctx);
3789
+ if (kind === "js") return buildJsTags(ctx);
3790
+ return buildCssTags(ctx) + buildJsTags(ctx);
3791
+ }
3792
+ /**
3793
+ * Substitute every `<!--moku:assets-->` family placeholder in a complete HTML
3794
+ * document: the combined block, the CSS-only block, and the JS-only block. A
3795
+ * document without placeholders passes through byte-identical — substitution is
3796
+ * strictly opt-in for app-owned pages (the not-found page).
3797
+ *
3798
+ * @param ctx - Plugin context (provides `state`, `config`).
3799
+ * @param html - The HTML document to substitute placeholders in.
3800
+ * @returns The document with all asset placeholders replaced.
3801
+ * @example
3802
+ * ```ts
3803
+ * substituteAssetPlaceholders(ctx, "<head><!--moku:assets:css--></head>");
3804
+ * ```
3805
+ */
3806
+ function substituteAssetPlaceholders(ctx, html) {
3807
+ return html.replaceAll(ASSETS_PLACEHOLDER, buildAssetTags(ctx)).replaceAll(CSS_ASSETS_PLACEHOLDER, buildAssetTags(ctx, "css")).replaceAll(JS_ASSETS_PLACEHOLDER, buildAssetTags(ctx, "js"));
3808
+ }
3809
+ /**
3810
+ * Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
3811
+ * preserving the nested directory structure. Skips silently (returns `null`) when
3812
+ * the source directory does not exist.
3813
+ *
3814
+ * @param ctx - Plugin context (provides `config`, `log`).
3815
+ * @returns The copy result, or `null` when the public directory is absent.
3816
+ * @example
3817
+ * ```ts
3818
+ * const result = await copyPublic(ctx);
3819
+ * ```
3820
+ */
3821
+ async function copyPublic(ctx) {
3822
+ const from = ctx.config.publicDir ?? "public";
3823
+ if (!(0, node_fs.existsSync)(from)) {
3824
+ ctx.log.debug("build:public", {
3825
+ skipped: true,
3826
+ from
3827
+ });
3828
+ return null;
3829
+ }
3830
+ await (0, node_fs_promises.cp)(from, ctx.config.outDir, { recursive: true });
3831
+ ctx.log.debug("build:public", {
3832
+ from,
3833
+ dest: ctx.config.outDir
3834
+ });
3835
+ return {
3836
+ from: node_path$1.default.normalize(from),
3837
+ copied: 1
3838
+ };
3839
+ }
3840
+ //#endregion
3841
+ //#region src/plugins/build/phases/cache-headers.ts
3842
+ /**
3843
+ * @file build phase — cache-headers. Emits `outDir/_headers` (Cloudflare Pages
3844
+ * header rules) so the CDN/browser cache can never serve a stale file: every
3845
+ * fingerprinted bundle gets a per-file immutable rule (its URL changes with its
3846
+ * content, so caching it forever is safe), and every OTHER URL — pages, content
3847
+ * images, feeds, data sidecars: stable URLs whose bytes may change between
3848
+ * deploys — gets a catch-all revalidation rule (an unchanged file still answers
3849
+ * `304 Not Modified` via its ETag, so it is effectively cached; a changed file is
3850
+ * picked up immediately). The app's own `<publicDir>/_headers` rules are appended
3851
+ * AFTER the generated ones so the app can override them. Gated by
3852
+ * `config.cacheHeaders` (`false` disables; default on).
3853
+ */
3854
+ /**
3855
+ * `Cache-Control` for fingerprinted bundles: their URL embeds a content hash, so
3856
+ * the bytes behind a given URL can never change — cache them for a year, immutably.
3857
+ */
3858
+ const DEFAULT_ASSETS_CACHE = "public, max-age=31536000, immutable";
3859
+ /**
3860
+ * `Cache-Control` for everything else (stable URLs): always revalidate with the
3861
+ * origin. Unchanged files still serve from cache via a `304` ETag round-trip;
3862
+ * changed files are fetched fresh — never stale, still cheap.
3863
+ */
3864
+ const DEFAULT_PAGES_CACHE = "public, max-age=0, must-revalidate";
3865
+ /**
3866
+ * Cloudflare Pages caps `_headers` at 100 rules and silently ignores the rest —
3867
+ * a site whose bundle count pushes past the cap needs a warning, not silence.
3868
+ */
3869
+ const CLOUDFLARE_RULE_LIMIT = 100;
3870
+ /**
3871
+ * Resolve the two `Cache-Control` values from `config.cacheHeaders` (`true` or an
3872
+ * object — `false` never reaches here; the pipeline gates the phase off).
3873
+ *
3874
+ * @param cacheHeaders - The `config.cacheHeaders` value.
3875
+ * @returns The `assets` (fingerprinted bundles) + `pages` (everything else) values.
3876
+ * @example
3877
+ * ```ts
3878
+ * resolvePolicy(true); // { assets: DEFAULT_ASSETS_CACHE, pages: DEFAULT_PAGES_CACHE }
3879
+ * ```
3880
+ */
3881
+ function resolvePolicy(cacheHeaders) {
3882
+ const policy = typeof cacheHeaders === "object" ? cacheHeaders : {};
3883
+ return {
3884
+ assets: policy.assets ?? DEFAULT_ASSETS_CACHE,
3885
+ pages: policy.pages ?? DEFAULT_PAGES_CACHE
3886
+ };
3887
+ }
3888
+ /**
3889
+ * Compose the generated rule blocks: the catch-all revalidation rule FIRST, then
3890
+ * one immutable rule per fingerprinted bundle file. Cloudflare applies every
3891
+ * matching rule and comma-joins duplicate headers (it does NOT override), so each
3892
+ * per-file rule must detach the catch-all's `Cache-Control` (`! Cache-Control`)
3893
+ * before attaching its own — otherwise a bundle would be served with two joined,
3894
+ * contradictory `Cache-Control` values.
3895
+ *
3896
+ * @param files - The fingerprinted bundle web paths (publish-root-relative).
3897
+ * @param policy - The resolved `Cache-Control` values.
3898
+ * @param policy.assets - The value for fingerprinted bundles.
3899
+ * @param policy.pages - The catch-all value for everything else.
3900
+ * @returns The generated rule blocks, in emission order.
3901
+ * @example
3902
+ * ```ts
3903
+ * composeRules(["assets/main-abc123.css"], { assets: "…", pages: "…" });
3904
+ * ```
3905
+ */
3906
+ function composeRules(files, policy) {
3907
+ return [`/*\n Cache-Control: ${policy.pages}`, ...files.map((file) => `/${file}\n ! Cache-Control\n Cache-Control: ${policy.assets}`)];
3908
+ }
3909
+ /**
3910
+ * Read the app's own `<publicDir>/_headers` SOURCE file (not the copy the public
3911
+ * phase may have placed in outDir — composing from the source keeps this phase
3912
+ * idempotent and independent of phase ordering). Returns `""` when absent.
3913
+ *
3914
+ * @param publicDir - The configured public directory (or the default).
3915
+ * @returns The app's `_headers` content, or `""` when the file does not exist.
3916
+ * @example
3917
+ * ```ts
3918
+ * const appRules = await readAppHeaders("public");
3919
+ * ```
3920
+ */
3921
+ async function readAppHeaders(publicDir) {
3922
+ const source = node_path$1.default.join(publicDir, "_headers");
3923
+ if (!(0, node_fs.existsSync)(source)) return "";
3924
+ return (0, node_fs_promises.readFile)(source, "utf8");
3925
+ }
3926
+ /**
3927
+ * Emits `outDir/_headers`: the generated cache rules (catch-all revalidation +
3928
+ * per-file immutable bundle rules) followed by the app's own
3929
+ * `<publicDir>/_headers` content. App rules come LAST so they can override a
3930
+ * generated header — note Cloudflare comma-joins duplicates, so an app rule that
3931
+ * re-sets a generated header must detach it first (`! Cache-Control`). Overwrites
3932
+ * the verbatim copy the public phase made, which is why this phase must run after
3933
+ * the outputs phase group.
3934
+ *
3935
+ * @param ctx - Plugin context (provides `state`, `config`, `log`).
3936
+ * @returns The written file path + generated rule count.
3937
+ * @example
3938
+ * ```ts
3939
+ * const result = await generateCacheHeaders(ctx);
3940
+ * ```
3941
+ */
3942
+ async function generateCacheHeaders(ctx) {
3943
+ const { outDir, publicDir, cacheHeaders } = ctx.config;
3944
+ const policy = resolvePolicy(cacheHeaders);
3945
+ const rules = composeRules([...readBundleOutputs(ctx, "css"), ...readBundleOutputs(ctx, "js")].toSorted(), policy);
3946
+ const appHeaders = (await readAppHeaders(publicDir ?? "public")).trim();
3947
+ const content = `${(appHeaders === "" ? rules : [...rules, appHeaders]).join("\n\n")}\n`;
3948
+ if (rules.length > CLOUDFLARE_RULE_LIMIT) ctx.log.warn("build:cache-headers", {
3949
+ rules: rules.length,
3950
+ limit: CLOUDFLARE_RULE_LIMIT
3951
+ });
3952
+ await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
3953
+ const file = node_path$1.default.join(outDir, "_headers");
3954
+ await (0, node_fs_promises.writeFile)(file, content, "utf8");
3955
+ ctx.log.debug("build:cache-headers", {
3956
+ path: file,
3957
+ rules: rules.length
3958
+ });
3959
+ return {
3960
+ path: file,
3961
+ ruleCount: rules.length
3962
+ };
3963
+ }
3964
+ //#endregion
3643
3965
  //#region src/plugins/build/phases/content.ts
3644
3966
  /**
3645
3967
  * @file build phase 2 — content. Delegates entirely to the content plugin via
@@ -4114,7 +4436,10 @@ async function generateLocaleRedirects(ctx) {
4114
4436
  //#region src/plugins/build/phases/not-found.ts
4115
4437
  /**
4116
4438
  * @file build phase — not-found. Emits `outDir/404.html` from configured route
4117
- * content or a built-in default. Gated by `config.notFound` (false/unset disables).
4439
+ * content or a built-in default, substituting the `<!--moku:assets-->` family of
4440
+ * placeholders (the bundles are fingerprint-named, so an app-owned 404 page can
4441
+ * no longer hardcode a bundle URL). Gated by `config.notFound` (false/unset
4442
+ * disables).
4118
4443
  */
4119
4444
  /** The built-in default 404 page body when no custom route content is supplied. */
4120
4445
  const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.</p>";
@@ -4154,11 +4479,13 @@ async function resolveHtml(notFound) {
4154
4479
  /**
4155
4480
  * Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
4156
4481
  * default page; `{ body }` writes the supplied HTML body content inside the
4157
- * minimal document shell; `{ path }` writes the referenced HTML page file
4158
- * verbatim (the app owns the whole document). No-op (returns `null`) when
4159
- * `notFound` is false/unset.
4482
+ * minimal document shell; `{ path }` writes the referenced HTML page file (the
4483
+ * app owns the whole document). In every variant the `<!--moku:assets-->` /
4484
+ * `<!--moku:assets:css-->` / `<!--moku:assets:js-->` placeholders are substituted
4485
+ * with the fingerprinted bundle tags — a page without placeholders passes through
4486
+ * byte-for-byte. No-op (returns `null`) when `notFound` is false/unset.
4160
4487
  *
4161
- * @param ctx - Plugin context (provides `config`, `log`).
4488
+ * @param ctx - Plugin context (provides `state`, `config`, `log`).
4162
4489
  * @returns The written file path, or `null` when disabled.
4163
4490
  * @example
4164
4491
  * ```ts
@@ -4171,7 +4498,7 @@ async function generateNotFound(ctx) {
4171
4498
  ctx.log.debug("build:not-found", { skipped: true });
4172
4499
  return null;
4173
4500
  }
4174
- const html = await resolveHtml(notFound);
4501
+ const html = substituteAssetPlaceholders(ctx, await resolveHtml(notFound));
4175
4502
  await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
4176
4503
  const file = node_path$1.default.join(outDir, "404.html");
4177
4504
  await (0, node_fs_promises.writeFile)(file, html, "utf8");
@@ -4910,45 +5237,9 @@ const dataPlugin = createPlugin$1("data", {
4910
5237
  const HEAD_PLACEHOLDER = "<!--moku:head-->";
4911
5238
  /** Template placeholder for the SSR-rendered body HTML. */
4912
5239
  const BODY_PLACEHOLDER = "<!--moku:body-->";
4913
- /** Template placeholder for the injected asset `<link>`/`<script>` tags. */
4914
- const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
4915
5240
  /** Template placeholder for the page's locale (`<html lang>`). */
4916
5241
  const LANG_PLACEHOLDER = "<!--moku:lang-->";
4917
5242
  /**
4918
- * Read the bundle phase's hashed asset manifest for one kind from `state.buildCache`
4919
- * as a typed {@link BuildCacheEntry} (no `Map<string, unknown>` reads).
4920
- *
4921
- * @param ctx - Plugin context (provides `state`).
4922
- * @param kind - The asset kind key (`"css"` / `"js"`).
4923
- * @returns The hashed-path manifest entry, or an empty object when absent.
4924
- * @example
4925
- * ```ts
4926
- * readManifest(ctx, "css");
4927
- * ```
4928
- */
4929
- function readManifest(ctx, kind) {
4930
- const entry = ctx.state.buildCache.get(kind);
4931
- return entry && typeof entry === "object" ? entry : {};
4932
- }
4933
- /**
4934
- * Build the asset `<link>`/`<script>` tag block from the hashed manifests. Returns
4935
- * an empty string when `config.injectAssets === false`. Asset paths are emitted as
4936
- * absolute (`/`-rooted) URLs.
4937
- *
4938
- * @param ctx - Plugin context (provides `state`, `config`).
4939
- * @returns The injected asset tags, or `""` when injection is disabled.
4940
- * @example
4941
- * ```ts
4942
- * buildAssetTags(ctx);
4943
- * ```
4944
- */
4945
- function buildAssetTags(ctx) {
4946
- if (ctx.config.injectAssets === false) return "";
4947
- const css = Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`);
4948
- const js = Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`);
4949
- return [...css, ...js].join("");
4950
- }
4951
- /**
4952
5243
  * Compose the full static HTML document with the in-code shell, injecting the
4953
5244
  * build-id meta tag into `<head>` AFTER the head plugin's composed HTML (build
4954
5245
  * metadata, not content) and the asset tags at the end of `<head>`.
@@ -4967,18 +5258,21 @@ function renderDocument(parts) {
4967
5258
  * Fill a shell template's `<!--moku:lang-->` / `<!--moku:head-->` /
4968
5259
  * `<!--moku:body-->` / `<!--moku:assets-->` placeholders deterministically at build
4969
5260
  * time. `<!--moku:lang-->` carries the page locale (for `<html lang>`), so a single
4970
- * shared template stays locale-correct across every locale.
5261
+ * shared template stays locale-correct across every locale. The split
5262
+ * `<!--moku:assets:css-->` / `<!--moku:assets:js-->` placeholders inject one asset
5263
+ * kind each — for shells that, e.g., link stylesheets in `<head>` but place
5264
+ * scripts at the end of `<body>`.
4971
5265
  *
4972
5266
  * @param template - The raw shell template HTML.
4973
5267
  * @param parts - The composed head/body/assets/locale pieces.
4974
5268
  * @returns The filled document string.
4975
5269
  * @example
4976
5270
  * ```ts
4977
- * fillTemplate(shell, { head, body, assets, locale: "en" });
5271
+ * fillTemplate(shell, { head, body, assets, assetsCss, assetsJs, locale: "en" });
4978
5272
  * ```
4979
5273
  */
4980
5274
  function fillTemplate(template, parts) {
4981
- return template.replaceAll(LANG_PLACEHOLDER, parts.locale).replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
5275
+ return template.replaceAll(LANG_PLACEHOLDER, parts.locale).replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets).replaceAll(CSS_ASSETS_PLACEHOLDER, parts.assetsCss).replaceAll(JS_ASSETS_PLACEHOLDER, parts.assetsJs);
4982
5276
  }
4983
5277
  /**
4984
5278
  * Resolve the compiled entry for a manifest definition, asserting the router
@@ -5329,6 +5623,8 @@ async function renderInstance(ctx, instance, shell, reuse) {
5329
5623
  head: composeHeadHtml(ctx, instance, url, routeContext, data),
5330
5624
  body: renderBodyCached(ctx, instance, routeContext, data, reuse),
5331
5625
  assets: shell.assets,
5626
+ assetsCss: shell.assetsCss,
5627
+ assetsJs: shell.assetsJs,
5332
5628
  locale
5333
5629
  };
5334
5630
  const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
@@ -5368,6 +5664,8 @@ async function prepareShell(ctx) {
5368
5664
  const template = typeof templatePath === "string" && (0, node_fs.existsSync)(templatePath) ? await (0, node_fs_promises.readFile)(templatePath, "utf8") : null;
5369
5665
  return {
5370
5666
  assets: buildAssetTags(ctx),
5667
+ assetsCss: buildAssetTags(ctx, "css"),
5668
+ assetsJs: buildAssetTags(ctx, "js"),
5371
5669
  template,
5372
5670
  defaultLocale: ctx.require(i18nPlugin).defaultLocale()
5373
5671
  };
@@ -5513,37 +5811,6 @@ async function renderPages(ctx, options) {
5513
5811
  rootHtml: findRootHtml(rendered)
5514
5812
  };
5515
5813
  }
5516
- /**
5517
- * Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
5518
- * preserving the nested directory structure. Skips silently (returns `null`) when
5519
- * the source directory does not exist.
5520
- *
5521
- * @param ctx - Plugin context (provides `config`, `log`).
5522
- * @returns The copy result, or `null` when the public directory is absent.
5523
- * @example
5524
- * ```ts
5525
- * const result = await copyPublic(ctx);
5526
- * ```
5527
- */
5528
- async function copyPublic(ctx) {
5529
- const from = ctx.config.publicDir ?? "public";
5530
- if (!(0, node_fs.existsSync)(from)) {
5531
- ctx.log.debug("build:public", {
5532
- skipped: true,
5533
- from
5534
- });
5535
- return null;
5536
- }
5537
- await (0, node_fs_promises.cp)(from, ctx.config.outDir, { recursive: true });
5538
- ctx.log.debug("build:public", {
5539
- from,
5540
- dest: ctx.config.outDir
5541
- });
5542
- return {
5543
- from: node_path$1.default.normalize(from),
5544
- copied: 1
5545
- };
5546
- }
5547
5814
  //#endregion
5548
5815
  //#region src/plugins/build/phases/sitemap.ts
5549
5816
  /**
@@ -5825,6 +6092,7 @@ const PHASE_ORDER = [
5825
6092
  "public",
5826
6093
  "not-found",
5827
6094
  "locale-redirects",
6095
+ "cache-headers",
5828
6096
  "root-index"
5829
6097
  ];
5830
6098
  /**
@@ -5919,7 +6187,7 @@ async function runOutputs(ctx) {
5919
6187
  }
5920
6188
  /**
5921
6189
  * Executes the full SSG pipeline for one run: clean → bundle → content/images →
5922
- * pages → feeds/sitemap/og-images → root-index. Orchestrates `ctx.require` pulls
6190
+ * pages → feeds/sitemap/og-images → cache-headers → root-index. Orchestrates `ctx.require` pulls
5923
6191
  * and `Promise.all` only — never inlines dependency domain logic. Emits a
5924
6192
  * `build:phase` boundary per phase and `build:complete` once at the end.
5925
6193
  *
@@ -5960,6 +6228,7 @@ async function runPipeline(ctx, options) {
5960
6228
  const pages = await withPhase(phaseContext, "pages", () => renderPages(phaseContext, { reuse: plan.renderReuse }));
5961
6229
  await withPhase(phaseContext, "content-images", () => copyContentImages(phaseContext));
5962
6230
  await runOutputs(phaseContext);
6231
+ if (phaseContext.config.cacheHeaders !== false) await withPhase(phaseContext, "cache-headers", () => generateCacheHeaders(phaseContext));
5963
6232
  await withPhase(phaseContext, "root-index", async () => {
5964
6233
  if (pages.rootHtml !== null) await (0, node_fs_promises.writeFile)(node_path$1.default.join(outDir, "index.html"), pages.rootHtml, "utf8");
5965
6234
  });
@@ -10634,16 +10903,19 @@ function currentLocationUrl() {
10634
10903
  /**
10635
10904
  * Apply the matched route's `head` config to the live document (minimal client
10636
10905
  * head-sync for the DATA path: title only — the full meta sync runs on the
10637
- * HTML-over-fetch path from the fetched `<head>`).
10906
+ * HTML-over-fetch path from the fetched `<head>`). The title is resolved through
10907
+ * `head.composeTitle` — the SAME composition `render` uses (`titleTemplate` applied;
10908
+ * a route-pinned `title` element wins) — so a client-side navigation's
10909
+ * `document.title` matches the SSG output instead of the raw route title.
10638
10910
  *
10911
+ * @param head - The head plugin API (resolves the final templated title).
10639
10912
  * @param route - The matched route definition.
10640
10913
  * @param routeContext - The render context (params/data/locale).
10641
10914
  * @example
10642
- * syncDataHead(hit.route, { params, data, locale });
10915
+ * syncDataHead(deps.head, hit.route, { params, data, locale });
10643
10916
  */
10644
- function syncDataHead(route, routeContext) {
10645
- const title = route._handlers.head?.(routeContext)?.title;
10646
- if (title !== void 0 && title !== "") document.title = title;
10917
+ function syncDataHead(head, route, routeContext) {
10918
+ document.title = head.composeTitle(route._handlers.head?.(routeContext));
10647
10919
  }
10648
10920
  /**
10649
10921
  * Builds the single shared SPA kernel — a pure factory over state/config/emit.
@@ -10799,7 +11071,7 @@ function createSpaKernel(state, config, emit, deps) {
10799
11071
  handleStart(pathname);
10800
11072
  const { renderVNode } = await Promise.resolve().then(() => require("./render-DLZEOe4M.cjs"));
10801
11073
  if (signal?.aborted) return;
10802
- syncDataHead(route, routeContext);
11074
+ syncDataHead(deps.head, route, routeContext);
10803
11075
  unmountPageSpecific(state, emit);
10804
11076
  /**
10805
11077
  * Render the VNode into the region and re-mount its islands in one paint — the