@moku-labs/web 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -3486,7 +3486,9 @@ const headPlugin = createPlugin$1("head", {
3486
3486
  //#region src/plugins/build/phases/bundle.ts
3487
3487
  /**
3488
3488
  * @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.
3489
+ * outDir (honoring `config.minify`) with content-hashed output naming; caches the
3490
+ * fingerprinted asset paths for the pages phase and the complete output list for
3491
+ * the cache-headers phase.
3490
3492
  */
3491
3493
  /** Conventional CSS entry candidates (project-relative). */
3492
3494
  const CSS_ENTRY_CANDIDATES = ["src/client/styles.css", "src/styles/main.css"];
@@ -3497,18 +3499,38 @@ const JS_ENTRY_CANDIDATES = [
3497
3499
  "src/main.ts"
3498
3500
  ];
3499
3501
  /**
3502
+ * `Bun.build` output naming with a content hash in EVERY filename (entry points
3503
+ * included — Bun's default only hashes chunks/assets). A bundle's URL therefore
3504
+ * changes whenever its bytes change, which is what lets the cache-headers phase
3505
+ * mark each bundle immutable: a CDN/browser may cache it forever, and a deploy
3506
+ * that changes the code ships a NEW URL instead of fighting a stale cached copy.
3507
+ * Pages always embed bundle URLs via the `state.buildCache` manifest, so hashed
3508
+ * names flow through with no app-side changes (hardcoded asset URLs must move to
3509
+ * the `<!--moku:assets-->` placeholders). Chunk naming keeps Bun's default
3510
+ * `chunk-` prefix (chunks were already hash-only named).
3511
+ */
3512
+ const FINGERPRINT_NAMING = {
3513
+ entry: "[dir]/[name]-[hash].[ext]",
3514
+ chunk: "chunk-[hash].[ext]",
3515
+ asset: "[name]-[hash].[ext]"
3516
+ };
3517
+ /**
3500
3518
  * The default bundler runner — adapts the built-in `Bun.build`.
3501
3519
  *
3502
- * @param options - Entry/outdir/minify/splitting/target settings forwarded to `Bun.build`.
3520
+ * @param options - Entry/outdir/minify/splitting/target/naming settings forwarded to `Bun.build`.
3503
3521
  * @param options.entrypoints - Entry files for this build.
3504
3522
  * @param options.outdir - Output directory.
3505
3523
  * @param options.minify - Whether to minify.
3506
3524
  * @param options.splitting - Whether to split dynamic imports into lazy chunks.
3507
3525
  * @param options.target - The bundling target platform.
3526
+ * @param options.naming - Output naming templates (content-hashed filenames).
3527
+ * @param options.naming.entry - Naming template for entry-point outputs.
3528
+ * @param options.naming.chunk - Naming template for lazy split chunks.
3529
+ * @param options.naming.asset - Naming template for additional emitted assets.
3508
3530
  * @returns The structural build result.
3509
3531
  * @example
3510
3532
  * ```ts
3511
- * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser" });
3533
+ * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser", naming: FINGERPRINT_NAMING });
3512
3534
  * ```
3513
3535
  */
3514
3536
  async function defaultRunner(options) {
@@ -3600,7 +3622,8 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3600
3622
  outdir,
3601
3623
  minify,
3602
3624
  splitting: true,
3603
- target: "browser"
3625
+ target: "browser",
3626
+ naming: FINGERPRINT_NAMING
3604
3627
  });
3605
3628
  if (!result.success) throw new Error(`[web] build.bundle ${kind} build failed`);
3606
3629
  const hashed = {};
@@ -3609,6 +3632,7 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3609
3632
  hashed[node_path$1.default.basename(output.path)] = normalizeAssetPath(output.path, outDir);
3610
3633
  }
3611
3634
  ctx.state.buildCache.set(kind, hashed);
3635
+ ctx.state.buildCache.set(`${kind}:outputs`, result.outputs.map((output) => normalizeAssetPath(output.path, outDir)));
3612
3636
  ctx.log.debug("build:bundle", {
3613
3637
  kind,
3614
3638
  count: result.outputs.length
@@ -3640,6 +3664,269 @@ async function bundle(ctx, options = {}) {
3640
3664
  await Promise.all([runOne(ctx, runner, "css", cssEntrypoints, outDir, assetsDir, minify), runOne(ctx, runner, "js", jsEntrypoints, outDir, assetsDir, minify)]);
3641
3665
  }
3642
3666
  //#endregion
3667
+ //#region src/plugins/build/phases/asset-tags.ts
3668
+ /** Template placeholder for the injected asset tags (stylesheets + scripts). */
3669
+ const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
3670
+ /** Template placeholder for the injected stylesheet `<link>` tags ONLY. */
3671
+ const CSS_ASSETS_PLACEHOLDER = "<!--moku:assets:css-->";
3672
+ /** Template placeholder for the injected `<script>` tags ONLY. */
3673
+ const JS_ASSETS_PLACEHOLDER = "<!--moku:assets:js-->";
3674
+ /**
3675
+ * Read the bundle phase's fingerprinted asset manifest for one kind from
3676
+ * `state.buildCache` as a typed {@link BuildCacheEntry} (no `Map<string, unknown>`
3677
+ * reads at call sites).
3678
+ *
3679
+ * @param ctx - Plugin context (provides `state`).
3680
+ * @param kind - The asset kind key (`"css"` / `"js"`).
3681
+ * @returns The fingerprinted-path manifest entry, or an empty object when absent.
3682
+ * @example
3683
+ * ```ts
3684
+ * readManifest(ctx, "css"); // { "main.css": "assets/main-abc123.css" }
3685
+ * ```
3686
+ */
3687
+ function readManifest(ctx, kind) {
3688
+ const entry = ctx.state.buildCache.get(kind);
3689
+ return entry && typeof entry === "object" ? entry : {};
3690
+ }
3691
+ /**
3692
+ * Read the bundle phase's COMPLETE output list for one kind (entries + lazy split
3693
+ * chunks, web paths relative to the publish root) from `state.buildCache`. Unlike
3694
+ * {@link readManifest} this includes chunks — it feeds the cache-headers phase's
3695
+ * per-file immutable rules, where every fingerprinted file counts, not just the
3696
+ * eagerly embedded entries.
3697
+ *
3698
+ * @param ctx - Plugin context (provides `state`).
3699
+ * @param kind - The asset kind key (`"css"` / `"js"`).
3700
+ * @returns The publish-root-relative output paths, or an empty array when absent.
3701
+ * @example
3702
+ * ```ts
3703
+ * readBundleOutputs(ctx, "js"); // ["assets/spa-abc123.js", "assets/chunk-9f8e.js"]
3704
+ * ```
3705
+ */
3706
+ function readBundleOutputs(ctx, kind) {
3707
+ const entry = ctx.state.buildCache.get(`${kind}:outputs`);
3708
+ return Array.isArray(entry) ? entry : [];
3709
+ }
3710
+ /**
3711
+ * Render the stylesheet `<link>` tags for the fingerprinted CSS manifest.
3712
+ *
3713
+ * @param ctx - Plugin context (provides `state`).
3714
+ * @returns The concatenated `<link rel="stylesheet">` tags (possibly `""`).
3715
+ * @example
3716
+ * ```ts
3717
+ * buildCssTags(ctx); // '<link rel="stylesheet" href="/assets/main-abc123.css">'
3718
+ * ```
3719
+ */
3720
+ function buildCssTags(ctx) {
3721
+ return Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`).join("");
3722
+ }
3723
+ /**
3724
+ * Render the module `<script>` tags for the fingerprinted JS manifest.
3725
+ *
3726
+ * @param ctx - Plugin context (provides `state`).
3727
+ * @returns The concatenated `<script type="module">` tags (possibly `""`).
3728
+ * @example
3729
+ * ```ts
3730
+ * buildJsTags(ctx); // '<script type="module" src="/assets/spa-abc123.js"><\/script>'
3731
+ * ```
3732
+ */
3733
+ function buildJsTags(ctx) {
3734
+ return Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`).join("");
3735
+ }
3736
+ /**
3737
+ * Build the asset tag block from the fingerprinted manifests — both kinds by
3738
+ * default, or a single kind for the split `<!--moku:assets:css/js-->`
3739
+ * placeholders. Returns an empty string when `config.injectAssets === false`.
3740
+ * Asset paths are emitted as absolute (`/`-rooted) URLs.
3741
+ *
3742
+ * @param ctx - Plugin context (provides `state`, `config`).
3743
+ * @param kind - Restrict the block to one asset kind; omit for stylesheets + scripts.
3744
+ * @returns The injected asset tags, or `""` when injection is disabled.
3745
+ * @example
3746
+ * ```ts
3747
+ * buildAssetTags(ctx); // <link …><script …><\/script>
3748
+ * buildAssetTags(ctx, "css"); // <link …> only
3749
+ * ```
3750
+ */
3751
+ function buildAssetTags(ctx, kind) {
3752
+ if (ctx.config.injectAssets === false) return "";
3753
+ if (kind === "css") return buildCssTags(ctx);
3754
+ if (kind === "js") return buildJsTags(ctx);
3755
+ return buildCssTags(ctx) + buildJsTags(ctx);
3756
+ }
3757
+ /**
3758
+ * Substitute every `<!--moku:assets-->` family placeholder in a complete HTML
3759
+ * document: the combined block, the CSS-only block, and the JS-only block. A
3760
+ * document without placeholders passes through byte-identical — substitution is
3761
+ * strictly opt-in for app-owned pages (the not-found page).
3762
+ *
3763
+ * @param ctx - Plugin context (provides `state`, `config`).
3764
+ * @param html - The HTML document to substitute placeholders in.
3765
+ * @returns The document with all asset placeholders replaced.
3766
+ * @example
3767
+ * ```ts
3768
+ * substituteAssetPlaceholders(ctx, "<head><!--moku:assets:css--></head>");
3769
+ * ```
3770
+ */
3771
+ function substituteAssetPlaceholders(ctx, html) {
3772
+ return html.replaceAll(ASSETS_PLACEHOLDER, buildAssetTags(ctx)).replaceAll(CSS_ASSETS_PLACEHOLDER, buildAssetTags(ctx, "css")).replaceAll(JS_ASSETS_PLACEHOLDER, buildAssetTags(ctx, "js"));
3773
+ }
3774
+ /**
3775
+ * Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
3776
+ * preserving the nested directory structure. Skips silently (returns `null`) when
3777
+ * the source directory does not exist.
3778
+ *
3779
+ * @param ctx - Plugin context (provides `config`, `log`).
3780
+ * @returns The copy result, or `null` when the public directory is absent.
3781
+ * @example
3782
+ * ```ts
3783
+ * const result = await copyPublic(ctx);
3784
+ * ```
3785
+ */
3786
+ async function copyPublic(ctx) {
3787
+ const from = ctx.config.publicDir ?? "public";
3788
+ if (!(0, node_fs.existsSync)(from)) {
3789
+ ctx.log.debug("build:public", {
3790
+ skipped: true,
3791
+ from
3792
+ });
3793
+ return null;
3794
+ }
3795
+ await (0, node_fs_promises.cp)(from, ctx.config.outDir, { recursive: true });
3796
+ ctx.log.debug("build:public", {
3797
+ from,
3798
+ dest: ctx.config.outDir
3799
+ });
3800
+ return {
3801
+ from: node_path$1.default.normalize(from),
3802
+ copied: 1
3803
+ };
3804
+ }
3805
+ //#endregion
3806
+ //#region src/plugins/build/phases/cache-headers.ts
3807
+ /**
3808
+ * @file build phase — cache-headers. Emits `outDir/_headers` (Cloudflare Pages
3809
+ * header rules) so the CDN/browser cache can never serve a stale file: every
3810
+ * fingerprinted bundle gets a per-file immutable rule (its URL changes with its
3811
+ * content, so caching it forever is safe), and every OTHER URL — pages, content
3812
+ * images, feeds, data sidecars: stable URLs whose bytes may change between
3813
+ * deploys — gets a catch-all revalidation rule (an unchanged file still answers
3814
+ * `304 Not Modified` via its ETag, so it is effectively cached; a changed file is
3815
+ * picked up immediately). The app's own `<publicDir>/_headers` rules are appended
3816
+ * AFTER the generated ones so the app can override them. Gated by
3817
+ * `config.cacheHeaders` (`false` disables; default on).
3818
+ */
3819
+ /**
3820
+ * `Cache-Control` for fingerprinted bundles: their URL embeds a content hash, so
3821
+ * the bytes behind a given URL can never change — cache them for a year, immutably.
3822
+ */
3823
+ const DEFAULT_ASSETS_CACHE = "public, max-age=31536000, immutable";
3824
+ /**
3825
+ * `Cache-Control` for everything else (stable URLs): always revalidate with the
3826
+ * origin. Unchanged files still serve from cache via a `304` ETag round-trip;
3827
+ * changed files are fetched fresh — never stale, still cheap.
3828
+ */
3829
+ const DEFAULT_PAGES_CACHE = "public, max-age=0, must-revalidate";
3830
+ /**
3831
+ * Cloudflare Pages caps `_headers` at 100 rules and silently ignores the rest —
3832
+ * a site whose bundle count pushes past the cap needs a warning, not silence.
3833
+ */
3834
+ const CLOUDFLARE_RULE_LIMIT = 100;
3835
+ /**
3836
+ * Resolve the two `Cache-Control` values from `config.cacheHeaders` (`true` or an
3837
+ * object — `false` never reaches here; the pipeline gates the phase off).
3838
+ *
3839
+ * @param cacheHeaders - The `config.cacheHeaders` value.
3840
+ * @returns The `assets` (fingerprinted bundles) + `pages` (everything else) values.
3841
+ * @example
3842
+ * ```ts
3843
+ * resolvePolicy(true); // { assets: DEFAULT_ASSETS_CACHE, pages: DEFAULT_PAGES_CACHE }
3844
+ * ```
3845
+ */
3846
+ function resolvePolicy(cacheHeaders) {
3847
+ const policy = typeof cacheHeaders === "object" ? cacheHeaders : {};
3848
+ return {
3849
+ assets: policy.assets ?? DEFAULT_ASSETS_CACHE,
3850
+ pages: policy.pages ?? DEFAULT_PAGES_CACHE
3851
+ };
3852
+ }
3853
+ /**
3854
+ * Compose the generated rule blocks: the catch-all revalidation rule FIRST, then
3855
+ * one immutable rule per fingerprinted bundle file. Cloudflare applies every
3856
+ * matching rule and comma-joins duplicate headers (it does NOT override), so each
3857
+ * per-file rule must detach the catch-all's `Cache-Control` (`! Cache-Control`)
3858
+ * before attaching its own — otherwise a bundle would be served with two joined,
3859
+ * contradictory `Cache-Control` values.
3860
+ *
3861
+ * @param files - The fingerprinted bundle web paths (publish-root-relative).
3862
+ * @param policy - The resolved `Cache-Control` values.
3863
+ * @param policy.assets - The value for fingerprinted bundles.
3864
+ * @param policy.pages - The catch-all value for everything else.
3865
+ * @returns The generated rule blocks, in emission order.
3866
+ * @example
3867
+ * ```ts
3868
+ * composeRules(["assets/main-abc123.css"], { assets: "…", pages: "…" });
3869
+ * ```
3870
+ */
3871
+ function composeRules(files, policy) {
3872
+ return [`/*\n Cache-Control: ${policy.pages}`, ...files.map((file) => `/${file}\n ! Cache-Control\n Cache-Control: ${policy.assets}`)];
3873
+ }
3874
+ /**
3875
+ * Read the app's own `<publicDir>/_headers` SOURCE file (not the copy the public
3876
+ * phase may have placed in outDir — composing from the source keeps this phase
3877
+ * idempotent and independent of phase ordering). Returns `""` when absent.
3878
+ *
3879
+ * @param publicDir - The configured public directory (or the default).
3880
+ * @returns The app's `_headers` content, or `""` when the file does not exist.
3881
+ * @example
3882
+ * ```ts
3883
+ * const appRules = await readAppHeaders("public");
3884
+ * ```
3885
+ */
3886
+ async function readAppHeaders(publicDir) {
3887
+ const source = node_path$1.default.join(publicDir, "_headers");
3888
+ if (!(0, node_fs.existsSync)(source)) return "";
3889
+ return (0, node_fs_promises.readFile)(source, "utf8");
3890
+ }
3891
+ /**
3892
+ * Emits `outDir/_headers`: the generated cache rules (catch-all revalidation +
3893
+ * per-file immutable bundle rules) followed by the app's own
3894
+ * `<publicDir>/_headers` content. App rules come LAST so they can override a
3895
+ * generated header — note Cloudflare comma-joins duplicates, so an app rule that
3896
+ * re-sets a generated header must detach it first (`! Cache-Control`). Overwrites
3897
+ * the verbatim copy the public phase made, which is why this phase must run after
3898
+ * the outputs phase group.
3899
+ *
3900
+ * @param ctx - Plugin context (provides `state`, `config`, `log`).
3901
+ * @returns The written file path + generated rule count.
3902
+ * @example
3903
+ * ```ts
3904
+ * const result = await generateCacheHeaders(ctx);
3905
+ * ```
3906
+ */
3907
+ async function generateCacheHeaders(ctx) {
3908
+ const { outDir, publicDir, cacheHeaders } = ctx.config;
3909
+ const policy = resolvePolicy(cacheHeaders);
3910
+ const rules = composeRules([...readBundleOutputs(ctx, "css"), ...readBundleOutputs(ctx, "js")].toSorted(), policy);
3911
+ const appHeaders = (await readAppHeaders(publicDir ?? "public")).trim();
3912
+ const content = `${(appHeaders === "" ? rules : [...rules, appHeaders]).join("\n\n")}\n`;
3913
+ if (rules.length > CLOUDFLARE_RULE_LIMIT) ctx.log.warn("build:cache-headers", {
3914
+ rules: rules.length,
3915
+ limit: CLOUDFLARE_RULE_LIMIT
3916
+ });
3917
+ await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
3918
+ const file = node_path$1.default.join(outDir, "_headers");
3919
+ await (0, node_fs_promises.writeFile)(file, content, "utf8");
3920
+ ctx.log.debug("build:cache-headers", {
3921
+ path: file,
3922
+ rules: rules.length
3923
+ });
3924
+ return {
3925
+ path: file,
3926
+ ruleCount: rules.length
3927
+ };
3928
+ }
3929
+ //#endregion
3643
3930
  //#region src/plugins/build/phases/content.ts
3644
3931
  /**
3645
3932
  * @file build phase 2 — content. Delegates entirely to the content plugin via
@@ -4114,7 +4401,10 @@ async function generateLocaleRedirects(ctx) {
4114
4401
  //#region src/plugins/build/phases/not-found.ts
4115
4402
  /**
4116
4403
  * @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).
4404
+ * content or a built-in default, substituting the `<!--moku:assets-->` family of
4405
+ * placeholders (the bundles are fingerprint-named, so an app-owned 404 page can
4406
+ * no longer hardcode a bundle URL). Gated by `config.notFound` (false/unset
4407
+ * disables).
4118
4408
  */
4119
4409
  /** The built-in default 404 page body when no custom route content is supplied. */
4120
4410
  const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.</p>";
@@ -4154,11 +4444,13 @@ async function resolveHtml(notFound) {
4154
4444
  /**
4155
4445
  * Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
4156
4446
  * 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.
4447
+ * minimal document shell; `{ path }` writes the referenced HTML page file (the
4448
+ * app owns the whole document). In every variant the `<!--moku:assets-->` /
4449
+ * `<!--moku:assets:css-->` / `<!--moku:assets:js-->` placeholders are substituted
4450
+ * with the fingerprinted bundle tags — a page without placeholders passes through
4451
+ * byte-for-byte. No-op (returns `null`) when `notFound` is false/unset.
4160
4452
  *
4161
- * @param ctx - Plugin context (provides `config`, `log`).
4453
+ * @param ctx - Plugin context (provides `state`, `config`, `log`).
4162
4454
  * @returns The written file path, or `null` when disabled.
4163
4455
  * @example
4164
4456
  * ```ts
@@ -4171,7 +4463,7 @@ async function generateNotFound(ctx) {
4171
4463
  ctx.log.debug("build:not-found", { skipped: true });
4172
4464
  return null;
4173
4465
  }
4174
- const html = await resolveHtml(notFound);
4466
+ const html = substituteAssetPlaceholders(ctx, await resolveHtml(notFound));
4175
4467
  await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
4176
4468
  const file = node_path$1.default.join(outDir, "404.html");
4177
4469
  await (0, node_fs_promises.writeFile)(file, html, "utf8");
@@ -4910,45 +5202,9 @@ const dataPlugin = createPlugin$1("data", {
4910
5202
  const HEAD_PLACEHOLDER = "<!--moku:head-->";
4911
5203
  /** Template placeholder for the SSR-rendered body HTML. */
4912
5204
  const BODY_PLACEHOLDER = "<!--moku:body-->";
4913
- /** Template placeholder for the injected asset `<link>`/`<script>` tags. */
4914
- const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
4915
5205
  /** Template placeholder for the page's locale (`<html lang>`). */
4916
5206
  const LANG_PLACEHOLDER = "<!--moku:lang-->";
4917
5207
  /**
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
5208
  * Compose the full static HTML document with the in-code shell, injecting the
4953
5209
  * build-id meta tag into `<head>` AFTER the head plugin's composed HTML (build
4954
5210
  * metadata, not content) and the asset tags at the end of `<head>`.
@@ -4967,18 +5223,21 @@ function renderDocument(parts) {
4967
5223
  * Fill a shell template's `<!--moku:lang-->` / `<!--moku:head-->` /
4968
5224
  * `<!--moku:body-->` / `<!--moku:assets-->` placeholders deterministically at build
4969
5225
  * time. `<!--moku:lang-->` carries the page locale (for `<html lang>`), so a single
4970
- * shared template stays locale-correct across every locale.
5226
+ * shared template stays locale-correct across every locale. The split
5227
+ * `<!--moku:assets:css-->` / `<!--moku:assets:js-->` placeholders inject one asset
5228
+ * kind each — for shells that, e.g., link stylesheets in `<head>` but place
5229
+ * scripts at the end of `<body>`.
4971
5230
  *
4972
5231
  * @param template - The raw shell template HTML.
4973
5232
  * @param parts - The composed head/body/assets/locale pieces.
4974
5233
  * @returns The filled document string.
4975
5234
  * @example
4976
5235
  * ```ts
4977
- * fillTemplate(shell, { head, body, assets, locale: "en" });
5236
+ * fillTemplate(shell, { head, body, assets, assetsCss, assetsJs, locale: "en" });
4978
5237
  * ```
4979
5238
  */
4980
5239
  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);
5240
+ 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
5241
  }
4983
5242
  /**
4984
5243
  * Resolve the compiled entry for a manifest definition, asserting the router
@@ -5329,6 +5588,8 @@ async function renderInstance(ctx, instance, shell, reuse) {
5329
5588
  head: composeHeadHtml(ctx, instance, url, routeContext, data),
5330
5589
  body: renderBodyCached(ctx, instance, routeContext, data, reuse),
5331
5590
  assets: shell.assets,
5591
+ assetsCss: shell.assetsCss,
5592
+ assetsJs: shell.assetsJs,
5332
5593
  locale
5333
5594
  };
5334
5595
  const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
@@ -5368,6 +5629,8 @@ async function prepareShell(ctx) {
5368
5629
  const template = typeof templatePath === "string" && (0, node_fs.existsSync)(templatePath) ? await (0, node_fs_promises.readFile)(templatePath, "utf8") : null;
5369
5630
  return {
5370
5631
  assets: buildAssetTags(ctx),
5632
+ assetsCss: buildAssetTags(ctx, "css"),
5633
+ assetsJs: buildAssetTags(ctx, "js"),
5371
5634
  template,
5372
5635
  defaultLocale: ctx.require(i18nPlugin).defaultLocale()
5373
5636
  };
@@ -5513,37 +5776,6 @@ async function renderPages(ctx, options) {
5513
5776
  rootHtml: findRootHtml(rendered)
5514
5777
  };
5515
5778
  }
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
5779
  //#endregion
5548
5780
  //#region src/plugins/build/phases/sitemap.ts
5549
5781
  /**
@@ -5825,6 +6057,7 @@ const PHASE_ORDER = [
5825
6057
  "public",
5826
6058
  "not-found",
5827
6059
  "locale-redirects",
6060
+ "cache-headers",
5828
6061
  "root-index"
5829
6062
  ];
5830
6063
  /**
@@ -5919,7 +6152,7 @@ async function runOutputs(ctx) {
5919
6152
  }
5920
6153
  /**
5921
6154
  * Executes the full SSG pipeline for one run: clean → bundle → content/images →
5922
- * pages → feeds/sitemap/og-images → root-index. Orchestrates `ctx.require` pulls
6155
+ * pages → feeds/sitemap/og-images → cache-headers → root-index. Orchestrates `ctx.require` pulls
5923
6156
  * and `Promise.all` only — never inlines dependency domain logic. Emits a
5924
6157
  * `build:phase` boundary per phase and `build:complete` once at the end.
5925
6158
  *
@@ -5960,6 +6193,7 @@ async function runPipeline(ctx, options) {
5960
6193
  const pages = await withPhase(phaseContext, "pages", () => renderPages(phaseContext, { reuse: plan.renderReuse }));
5961
6194
  await withPhase(phaseContext, "content-images", () => copyContentImages(phaseContext));
5962
6195
  await runOutputs(phaseContext);
6196
+ if (phaseContext.config.cacheHeaders !== false) await withPhase(phaseContext, "cache-headers", () => generateCacheHeaders(phaseContext));
5963
6197
  await withPhase(phaseContext, "root-index", async () => {
5964
6198
  if (pages.rootHtml !== null) await (0, node_fs_promises.writeFile)(node_path$1.default.join(outDir, "index.html"), pages.rootHtml, "utf8");
5965
6199
  });
package/dist/index.d.cts CHANGED
@@ -1667,8 +1667,13 @@ type Config$3 = {
1667
1667
  * - `true` — the built-in default page.
1668
1668
  * - `{ body }` — literal HTML body content, wrapped in a minimal document shell.
1669
1669
  * - `{ path }` — path to a complete HTML page file (resolved from the project
1670
- * root), written out VERBATIM so the app owns the whole document (its own
1671
- * `<head>`, asset links, and body).
1670
+ * root) so the app owns the whole document (its own `<head>`, asset links,
1671
+ * and body).
1672
+ *
1673
+ * In every variant the `<!--moku:assets-->` / `<!--moku:assets:css-->` /
1674
+ * `<!--moku:assets:js-->` placeholders are substituted with the fingerprinted
1675
+ * bundle tags (bundle filenames embed a content hash, so a 404 page cannot
1676
+ * hardcode them); a page without placeholders is written byte-for-byte.
1672
1677
  *
1673
1678
  * `path` takes precedence over `body` when both are set. Default `false`.
1674
1679
  */
@@ -1685,10 +1690,31 @@ type Config$3 = {
1685
1690
  * `<!--moku:lang-->` (page locale for `<html lang>`),
1686
1691
  * `<!--moku:head-->` (composed `<head>` inner HTML),
1687
1692
  * `<!--moku:assets-->` (injected `<link>`/`<script>` tags),
1693
+ * `<!--moku:assets:css-->` / `<!--moku:assets:js-->` (one asset kind each, for
1694
+ * shells that link stylesheets in `<head>` but script tags elsewhere),
1688
1695
  * `<!--moku:body-->` (SSR body HTML).
1689
1696
  * When unset, the built-in shell is used (it emits charset + viewport by default).
1690
1697
  */
1691
1698
  template?: string;
1699
+ /**
1700
+ * Emit `outDir/_headers` (Cloudflare Pages header rules) for CDN/browser cache
1701
+ * protection. Generated rules: every fingerprinted bundle output gets a
1702
+ * per-file `Cache-Control: <assets>` rule (default immutable, 1 year — its URL
1703
+ * embeds a content hash, so the bytes behind it can never change), and every
1704
+ * other URL — pages, content images, feeds, data sidecars: stable URLs whose
1705
+ * bytes MAY change between deploys — gets the catch-all
1706
+ * `Cache-Control: <pages>` rule (default always-revalidate: unchanged files
1707
+ * still answer `304 Not Modified` from their ETag, changed files are picked up
1708
+ * immediately). The app's own `<publicDir>/_headers` content is appended AFTER
1709
+ * the generated rules so the app can override them (detach a generated header
1710
+ * first with `! Cache-Control` — Cloudflare comma-joins duplicate headers).
1711
+ * `false` disables the phase; an object overrides one or both values.
1712
+ * Default `true`.
1713
+ */
1714
+ cacheHeaders?: boolean | {
1715
+ assets?: string;
1716
+ pages?: string;
1717
+ };
1692
1718
  };
1693
1719
  /**
1694
1720
  * A typed asset-manifest entry for one bundled asset kind (CSS or JS): a map of the
@@ -1755,7 +1781,7 @@ interface State$3 {
1755
1781
  * const phase: PhaseName = "bundle";
1756
1782
  * ```
1757
1783
  */
1758
- type PhaseName = "bundle" | "content" | "images" | "pages" | "content-images" | "feeds" | "sitemap" | "og-images" | "public" | "not-found" | "locale-redirects" | "root-index";
1784
+ type PhaseName = "bundle" | "content" | "images" | "pages" | "content-images" | "feeds" | "sitemap" | "og-images" | "public" | "not-found" | "locale-redirects" | "cache-headers" | "root-index";
1759
1785
  /**
1760
1786
  * Result of a completed build run.
1761
1787
  *
@@ -1784,7 +1810,7 @@ interface BuildResult {
1784
1810
  * const dev: BuildRunOverrides = { minify: false, feeds: false, sitemap: false };
1785
1811
  * ```
1786
1812
  */
1787
- type BuildRunOverrides = Readonly<Partial<Pick<Config$3, "minify" | "feeds" | "sitemap" | "ogImage" | "images" | "localeRedirects" | "notFound">>>;
1813
+ type BuildRunOverrides = Readonly<Partial<Pick<Config$3, "minify" | "feeds" | "sitemap" | "ogImage" | "images" | "localeRedirects" | "notFound" | "cacheHeaders">>>;
1788
1814
  /**
1789
1815
  * Options for a single {@link Api.run} call. All fields are optional; an absent/empty
1790
1816
  * options object runs the full production build (clean + every configured phase). The
package/dist/index.d.mts CHANGED
@@ -1667,8 +1667,13 @@ type Config$3 = {
1667
1667
  * - `true` — the built-in default page.
1668
1668
  * - `{ body }` — literal HTML body content, wrapped in a minimal document shell.
1669
1669
  * - `{ path }` — path to a complete HTML page file (resolved from the project
1670
- * root), written out VERBATIM so the app owns the whole document (its own
1671
- * `<head>`, asset links, and body).
1670
+ * root) so the app owns the whole document (its own `<head>`, asset links,
1671
+ * and body).
1672
+ *
1673
+ * In every variant the `<!--moku:assets-->` / `<!--moku:assets:css-->` /
1674
+ * `<!--moku:assets:js-->` placeholders are substituted with the fingerprinted
1675
+ * bundle tags (bundle filenames embed a content hash, so a 404 page cannot
1676
+ * hardcode them); a page without placeholders is written byte-for-byte.
1672
1677
  *
1673
1678
  * `path` takes precedence over `body` when both are set. Default `false`.
1674
1679
  */
@@ -1685,10 +1690,31 @@ type Config$3 = {
1685
1690
  * `<!--moku:lang-->` (page locale for `<html lang>`),
1686
1691
  * `<!--moku:head-->` (composed `<head>` inner HTML),
1687
1692
  * `<!--moku:assets-->` (injected `<link>`/`<script>` tags),
1693
+ * `<!--moku:assets:css-->` / `<!--moku:assets:js-->` (one asset kind each, for
1694
+ * shells that link stylesheets in `<head>` but script tags elsewhere),
1688
1695
  * `<!--moku:body-->` (SSR body HTML).
1689
1696
  * When unset, the built-in shell is used (it emits charset + viewport by default).
1690
1697
  */
1691
1698
  template?: string;
1699
+ /**
1700
+ * Emit `outDir/_headers` (Cloudflare Pages header rules) for CDN/browser cache
1701
+ * protection. Generated rules: every fingerprinted bundle output gets a
1702
+ * per-file `Cache-Control: <assets>` rule (default immutable, 1 year — its URL
1703
+ * embeds a content hash, so the bytes behind it can never change), and every
1704
+ * other URL — pages, content images, feeds, data sidecars: stable URLs whose
1705
+ * bytes MAY change between deploys — gets the catch-all
1706
+ * `Cache-Control: <pages>` rule (default always-revalidate: unchanged files
1707
+ * still answer `304 Not Modified` from their ETag, changed files are picked up
1708
+ * immediately). The app's own `<publicDir>/_headers` content is appended AFTER
1709
+ * the generated rules so the app can override them (detach a generated header
1710
+ * first with `! Cache-Control` — Cloudflare comma-joins duplicate headers).
1711
+ * `false` disables the phase; an object overrides one or both values.
1712
+ * Default `true`.
1713
+ */
1714
+ cacheHeaders?: boolean | {
1715
+ assets?: string;
1716
+ pages?: string;
1717
+ };
1692
1718
  };
1693
1719
  /**
1694
1720
  * A typed asset-manifest entry for one bundled asset kind (CSS or JS): a map of the
@@ -1755,7 +1781,7 @@ interface State$3 {
1755
1781
  * const phase: PhaseName = "bundle";
1756
1782
  * ```
1757
1783
  */
1758
- type PhaseName = "bundle" | "content" | "images" | "pages" | "content-images" | "feeds" | "sitemap" | "og-images" | "public" | "not-found" | "locale-redirects" | "root-index";
1784
+ type PhaseName = "bundle" | "content" | "images" | "pages" | "content-images" | "feeds" | "sitemap" | "og-images" | "public" | "not-found" | "locale-redirects" | "cache-headers" | "root-index";
1759
1785
  /**
1760
1786
  * Result of a completed build run.
1761
1787
  *
@@ -1784,7 +1810,7 @@ interface BuildResult {
1784
1810
  * const dev: BuildRunOverrides = { minify: false, feeds: false, sitemap: false };
1785
1811
  * ```
1786
1812
  */
1787
- type BuildRunOverrides = Readonly<Partial<Pick<Config$3, "minify" | "feeds" | "sitemap" | "ogImage" | "images" | "localeRedirects" | "notFound">>>;
1813
+ type BuildRunOverrides = Readonly<Partial<Pick<Config$3, "minify" | "feeds" | "sitemap" | "ogImage" | "images" | "localeRedirects" | "notFound" | "cacheHeaders">>>;
1788
1814
  /**
1789
1815
  * Options for a single {@link Api.run} call. All fields are optional; an absent/empty
1790
1816
  * options object runs the full production build (clean + every configured phase). The
package/dist/index.mjs CHANGED
@@ -3473,7 +3473,9 @@ const headPlugin = createPlugin$1("head", {
3473
3473
  //#region src/plugins/build/phases/bundle.ts
3474
3474
  /**
3475
3475
  * @file build phase 1 — bundle. Runs `Bun.build` for CSS and JS separately into
3476
- * outDir (honoring `config.minify`); caches hashed asset paths for the pages phase.
3476
+ * outDir (honoring `config.minify`) with content-hashed output naming; caches the
3477
+ * fingerprinted asset paths for the pages phase and the complete output list for
3478
+ * the cache-headers phase.
3477
3479
  */
3478
3480
  /** Conventional CSS entry candidates (project-relative). */
3479
3481
  const CSS_ENTRY_CANDIDATES = ["src/client/styles.css", "src/styles/main.css"];
@@ -3484,18 +3486,38 @@ const JS_ENTRY_CANDIDATES = [
3484
3486
  "src/main.ts"
3485
3487
  ];
3486
3488
  /**
3489
+ * `Bun.build` output naming with a content hash in EVERY filename (entry points
3490
+ * included — Bun's default only hashes chunks/assets). A bundle's URL therefore
3491
+ * changes whenever its bytes change, which is what lets the cache-headers phase
3492
+ * mark each bundle immutable: a CDN/browser may cache it forever, and a deploy
3493
+ * that changes the code ships a NEW URL instead of fighting a stale cached copy.
3494
+ * Pages always embed bundle URLs via the `state.buildCache` manifest, so hashed
3495
+ * names flow through with no app-side changes (hardcoded asset URLs must move to
3496
+ * the `<!--moku:assets-->` placeholders). Chunk naming keeps Bun's default
3497
+ * `chunk-` prefix (chunks were already hash-only named).
3498
+ */
3499
+ const FINGERPRINT_NAMING = {
3500
+ entry: "[dir]/[name]-[hash].[ext]",
3501
+ chunk: "chunk-[hash].[ext]",
3502
+ asset: "[name]-[hash].[ext]"
3503
+ };
3504
+ /**
3487
3505
  * The default bundler runner — adapts the built-in `Bun.build`.
3488
3506
  *
3489
- * @param options - Entry/outdir/minify/splitting/target settings forwarded to `Bun.build`.
3507
+ * @param options - Entry/outdir/minify/splitting/target/naming settings forwarded to `Bun.build`.
3490
3508
  * @param options.entrypoints - Entry files for this build.
3491
3509
  * @param options.outdir - Output directory.
3492
3510
  * @param options.minify - Whether to minify.
3493
3511
  * @param options.splitting - Whether to split dynamic imports into lazy chunks.
3494
3512
  * @param options.target - The bundling target platform.
3513
+ * @param options.naming - Output naming templates (content-hashed filenames).
3514
+ * @param options.naming.entry - Naming template for entry-point outputs.
3515
+ * @param options.naming.chunk - Naming template for lazy split chunks.
3516
+ * @param options.naming.asset - Naming template for additional emitted assets.
3495
3517
  * @returns The structural build result.
3496
3518
  * @example
3497
3519
  * ```ts
3498
- * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser" });
3520
+ * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser", naming: FINGERPRINT_NAMING });
3499
3521
  * ```
3500
3522
  */
3501
3523
  async function defaultRunner(options) {
@@ -3587,7 +3609,8 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3587
3609
  outdir,
3588
3610
  minify,
3589
3611
  splitting: true,
3590
- target: "browser"
3612
+ target: "browser",
3613
+ naming: FINGERPRINT_NAMING
3591
3614
  });
3592
3615
  if (!result.success) throw new Error(`[web] build.bundle ${kind} build failed`);
3593
3616
  const hashed = {};
@@ -3596,6 +3619,7 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3596
3619
  hashed[path.basename(output.path)] = normalizeAssetPath(output.path, outDir);
3597
3620
  }
3598
3621
  ctx.state.buildCache.set(kind, hashed);
3622
+ ctx.state.buildCache.set(`${kind}:outputs`, result.outputs.map((output) => normalizeAssetPath(output.path, outDir)));
3599
3623
  ctx.log.debug("build:bundle", {
3600
3624
  kind,
3601
3625
  count: result.outputs.length
@@ -3627,6 +3651,269 @@ async function bundle(ctx, options = {}) {
3627
3651
  await Promise.all([runOne(ctx, runner, "css", cssEntrypoints, outDir, assetsDir, minify), runOne(ctx, runner, "js", jsEntrypoints, outDir, assetsDir, minify)]);
3628
3652
  }
3629
3653
  //#endregion
3654
+ //#region src/plugins/build/phases/asset-tags.ts
3655
+ /** Template placeholder for the injected asset tags (stylesheets + scripts). */
3656
+ const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
3657
+ /** Template placeholder for the injected stylesheet `<link>` tags ONLY. */
3658
+ const CSS_ASSETS_PLACEHOLDER = "<!--moku:assets:css-->";
3659
+ /** Template placeholder for the injected `<script>` tags ONLY. */
3660
+ const JS_ASSETS_PLACEHOLDER = "<!--moku:assets:js-->";
3661
+ /**
3662
+ * Read the bundle phase's fingerprinted asset manifest for one kind from
3663
+ * `state.buildCache` as a typed {@link BuildCacheEntry} (no `Map<string, unknown>`
3664
+ * reads at call sites).
3665
+ *
3666
+ * @param ctx - Plugin context (provides `state`).
3667
+ * @param kind - The asset kind key (`"css"` / `"js"`).
3668
+ * @returns The fingerprinted-path manifest entry, or an empty object when absent.
3669
+ * @example
3670
+ * ```ts
3671
+ * readManifest(ctx, "css"); // { "main.css": "assets/main-abc123.css" }
3672
+ * ```
3673
+ */
3674
+ function readManifest(ctx, kind) {
3675
+ const entry = ctx.state.buildCache.get(kind);
3676
+ return entry && typeof entry === "object" ? entry : {};
3677
+ }
3678
+ /**
3679
+ * Read the bundle phase's COMPLETE output list for one kind (entries + lazy split
3680
+ * chunks, web paths relative to the publish root) from `state.buildCache`. Unlike
3681
+ * {@link readManifest} this includes chunks — it feeds the cache-headers phase's
3682
+ * per-file immutable rules, where every fingerprinted file counts, not just the
3683
+ * eagerly embedded entries.
3684
+ *
3685
+ * @param ctx - Plugin context (provides `state`).
3686
+ * @param kind - The asset kind key (`"css"` / `"js"`).
3687
+ * @returns The publish-root-relative output paths, or an empty array when absent.
3688
+ * @example
3689
+ * ```ts
3690
+ * readBundleOutputs(ctx, "js"); // ["assets/spa-abc123.js", "assets/chunk-9f8e.js"]
3691
+ * ```
3692
+ */
3693
+ function readBundleOutputs(ctx, kind) {
3694
+ const entry = ctx.state.buildCache.get(`${kind}:outputs`);
3695
+ return Array.isArray(entry) ? entry : [];
3696
+ }
3697
+ /**
3698
+ * Render the stylesheet `<link>` tags for the fingerprinted CSS manifest.
3699
+ *
3700
+ * @param ctx - Plugin context (provides `state`).
3701
+ * @returns The concatenated `<link rel="stylesheet">` tags (possibly `""`).
3702
+ * @example
3703
+ * ```ts
3704
+ * buildCssTags(ctx); // '<link rel="stylesheet" href="/assets/main-abc123.css">'
3705
+ * ```
3706
+ */
3707
+ function buildCssTags(ctx) {
3708
+ return Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`).join("");
3709
+ }
3710
+ /**
3711
+ * Render the module `<script>` tags for the fingerprinted JS manifest.
3712
+ *
3713
+ * @param ctx - Plugin context (provides `state`).
3714
+ * @returns The concatenated `<script type="module">` tags (possibly `""`).
3715
+ * @example
3716
+ * ```ts
3717
+ * buildJsTags(ctx); // '<script type="module" src="/assets/spa-abc123.js"><\/script>'
3718
+ * ```
3719
+ */
3720
+ function buildJsTags(ctx) {
3721
+ return Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`).join("");
3722
+ }
3723
+ /**
3724
+ * Build the asset tag block from the fingerprinted manifests — both kinds by
3725
+ * default, or a single kind for the split `<!--moku:assets:css/js-->`
3726
+ * placeholders. Returns an empty string when `config.injectAssets === false`.
3727
+ * Asset paths are emitted as absolute (`/`-rooted) URLs.
3728
+ *
3729
+ * @param ctx - Plugin context (provides `state`, `config`).
3730
+ * @param kind - Restrict the block to one asset kind; omit for stylesheets + scripts.
3731
+ * @returns The injected asset tags, or `""` when injection is disabled.
3732
+ * @example
3733
+ * ```ts
3734
+ * buildAssetTags(ctx); // <link …><script …><\/script>
3735
+ * buildAssetTags(ctx, "css"); // <link …> only
3736
+ * ```
3737
+ */
3738
+ function buildAssetTags(ctx, kind) {
3739
+ if (ctx.config.injectAssets === false) return "";
3740
+ if (kind === "css") return buildCssTags(ctx);
3741
+ if (kind === "js") return buildJsTags(ctx);
3742
+ return buildCssTags(ctx) + buildJsTags(ctx);
3743
+ }
3744
+ /**
3745
+ * Substitute every `<!--moku:assets-->` family placeholder in a complete HTML
3746
+ * document: the combined block, the CSS-only block, and the JS-only block. A
3747
+ * document without placeholders passes through byte-identical — substitution is
3748
+ * strictly opt-in for app-owned pages (the not-found page).
3749
+ *
3750
+ * @param ctx - Plugin context (provides `state`, `config`).
3751
+ * @param html - The HTML document to substitute placeholders in.
3752
+ * @returns The document with all asset placeholders replaced.
3753
+ * @example
3754
+ * ```ts
3755
+ * substituteAssetPlaceholders(ctx, "<head><!--moku:assets:css--></head>");
3756
+ * ```
3757
+ */
3758
+ function substituteAssetPlaceholders(ctx, html) {
3759
+ return html.replaceAll(ASSETS_PLACEHOLDER, buildAssetTags(ctx)).replaceAll(CSS_ASSETS_PLACEHOLDER, buildAssetTags(ctx, "css")).replaceAll(JS_ASSETS_PLACEHOLDER, buildAssetTags(ctx, "js"));
3760
+ }
3761
+ /**
3762
+ * Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
3763
+ * preserving the nested directory structure. Skips silently (returns `null`) when
3764
+ * the source directory does not exist.
3765
+ *
3766
+ * @param ctx - Plugin context (provides `config`, `log`).
3767
+ * @returns The copy result, or `null` when the public directory is absent.
3768
+ * @example
3769
+ * ```ts
3770
+ * const result = await copyPublic(ctx);
3771
+ * ```
3772
+ */
3773
+ async function copyPublic(ctx) {
3774
+ const from = ctx.config.publicDir ?? "public";
3775
+ if (!existsSync(from)) {
3776
+ ctx.log.debug("build:public", {
3777
+ skipped: true,
3778
+ from
3779
+ });
3780
+ return null;
3781
+ }
3782
+ await cp(from, ctx.config.outDir, { recursive: true });
3783
+ ctx.log.debug("build:public", {
3784
+ from,
3785
+ dest: ctx.config.outDir
3786
+ });
3787
+ return {
3788
+ from: path.normalize(from),
3789
+ copied: 1
3790
+ };
3791
+ }
3792
+ //#endregion
3793
+ //#region src/plugins/build/phases/cache-headers.ts
3794
+ /**
3795
+ * @file build phase — cache-headers. Emits `outDir/_headers` (Cloudflare Pages
3796
+ * header rules) so the CDN/browser cache can never serve a stale file: every
3797
+ * fingerprinted bundle gets a per-file immutable rule (its URL changes with its
3798
+ * content, so caching it forever is safe), and every OTHER URL — pages, content
3799
+ * images, feeds, data sidecars: stable URLs whose bytes may change between
3800
+ * deploys — gets a catch-all revalidation rule (an unchanged file still answers
3801
+ * `304 Not Modified` via its ETag, so it is effectively cached; a changed file is
3802
+ * picked up immediately). The app's own `<publicDir>/_headers` rules are appended
3803
+ * AFTER the generated ones so the app can override them. Gated by
3804
+ * `config.cacheHeaders` (`false` disables; default on).
3805
+ */
3806
+ /**
3807
+ * `Cache-Control` for fingerprinted bundles: their URL embeds a content hash, so
3808
+ * the bytes behind a given URL can never change — cache them for a year, immutably.
3809
+ */
3810
+ const DEFAULT_ASSETS_CACHE = "public, max-age=31536000, immutable";
3811
+ /**
3812
+ * `Cache-Control` for everything else (stable URLs): always revalidate with the
3813
+ * origin. Unchanged files still serve from cache via a `304` ETag round-trip;
3814
+ * changed files are fetched fresh — never stale, still cheap.
3815
+ */
3816
+ const DEFAULT_PAGES_CACHE = "public, max-age=0, must-revalidate";
3817
+ /**
3818
+ * Cloudflare Pages caps `_headers` at 100 rules and silently ignores the rest —
3819
+ * a site whose bundle count pushes past the cap needs a warning, not silence.
3820
+ */
3821
+ const CLOUDFLARE_RULE_LIMIT = 100;
3822
+ /**
3823
+ * Resolve the two `Cache-Control` values from `config.cacheHeaders` (`true` or an
3824
+ * object — `false` never reaches here; the pipeline gates the phase off).
3825
+ *
3826
+ * @param cacheHeaders - The `config.cacheHeaders` value.
3827
+ * @returns The `assets` (fingerprinted bundles) + `pages` (everything else) values.
3828
+ * @example
3829
+ * ```ts
3830
+ * resolvePolicy(true); // { assets: DEFAULT_ASSETS_CACHE, pages: DEFAULT_PAGES_CACHE }
3831
+ * ```
3832
+ */
3833
+ function resolvePolicy(cacheHeaders) {
3834
+ const policy = typeof cacheHeaders === "object" ? cacheHeaders : {};
3835
+ return {
3836
+ assets: policy.assets ?? DEFAULT_ASSETS_CACHE,
3837
+ pages: policy.pages ?? DEFAULT_PAGES_CACHE
3838
+ };
3839
+ }
3840
+ /**
3841
+ * Compose the generated rule blocks: the catch-all revalidation rule FIRST, then
3842
+ * one immutable rule per fingerprinted bundle file. Cloudflare applies every
3843
+ * matching rule and comma-joins duplicate headers (it does NOT override), so each
3844
+ * per-file rule must detach the catch-all's `Cache-Control` (`! Cache-Control`)
3845
+ * before attaching its own — otherwise a bundle would be served with two joined,
3846
+ * contradictory `Cache-Control` values.
3847
+ *
3848
+ * @param files - The fingerprinted bundle web paths (publish-root-relative).
3849
+ * @param policy - The resolved `Cache-Control` values.
3850
+ * @param policy.assets - The value for fingerprinted bundles.
3851
+ * @param policy.pages - The catch-all value for everything else.
3852
+ * @returns The generated rule blocks, in emission order.
3853
+ * @example
3854
+ * ```ts
3855
+ * composeRules(["assets/main-abc123.css"], { assets: "…", pages: "…" });
3856
+ * ```
3857
+ */
3858
+ function composeRules(files, policy) {
3859
+ return [`/*\n Cache-Control: ${policy.pages}`, ...files.map((file) => `/${file}\n ! Cache-Control\n Cache-Control: ${policy.assets}`)];
3860
+ }
3861
+ /**
3862
+ * Read the app's own `<publicDir>/_headers` SOURCE file (not the copy the public
3863
+ * phase may have placed in outDir — composing from the source keeps this phase
3864
+ * idempotent and independent of phase ordering). Returns `""` when absent.
3865
+ *
3866
+ * @param publicDir - The configured public directory (or the default).
3867
+ * @returns The app's `_headers` content, or `""` when the file does not exist.
3868
+ * @example
3869
+ * ```ts
3870
+ * const appRules = await readAppHeaders("public");
3871
+ * ```
3872
+ */
3873
+ async function readAppHeaders(publicDir) {
3874
+ const source = path.join(publicDir, "_headers");
3875
+ if (!existsSync(source)) return "";
3876
+ return readFile(source, "utf8");
3877
+ }
3878
+ /**
3879
+ * Emits `outDir/_headers`: the generated cache rules (catch-all revalidation +
3880
+ * per-file immutable bundle rules) followed by the app's own
3881
+ * `<publicDir>/_headers` content. App rules come LAST so they can override a
3882
+ * generated header — note Cloudflare comma-joins duplicates, so an app rule that
3883
+ * re-sets a generated header must detach it first (`! Cache-Control`). Overwrites
3884
+ * the verbatim copy the public phase made, which is why this phase must run after
3885
+ * the outputs phase group.
3886
+ *
3887
+ * @param ctx - Plugin context (provides `state`, `config`, `log`).
3888
+ * @returns The written file path + generated rule count.
3889
+ * @example
3890
+ * ```ts
3891
+ * const result = await generateCacheHeaders(ctx);
3892
+ * ```
3893
+ */
3894
+ async function generateCacheHeaders(ctx) {
3895
+ const { outDir, publicDir, cacheHeaders } = ctx.config;
3896
+ const policy = resolvePolicy(cacheHeaders);
3897
+ const rules = composeRules([...readBundleOutputs(ctx, "css"), ...readBundleOutputs(ctx, "js")].toSorted(), policy);
3898
+ const appHeaders = (await readAppHeaders(publicDir ?? "public")).trim();
3899
+ const content = `${(appHeaders === "" ? rules : [...rules, appHeaders]).join("\n\n")}\n`;
3900
+ if (rules.length > CLOUDFLARE_RULE_LIMIT) ctx.log.warn("build:cache-headers", {
3901
+ rules: rules.length,
3902
+ limit: CLOUDFLARE_RULE_LIMIT
3903
+ });
3904
+ await mkdir(outDir, { recursive: true });
3905
+ const file = path.join(outDir, "_headers");
3906
+ await writeFile(file, content, "utf8");
3907
+ ctx.log.debug("build:cache-headers", {
3908
+ path: file,
3909
+ rules: rules.length
3910
+ });
3911
+ return {
3912
+ path: file,
3913
+ ruleCount: rules.length
3914
+ };
3915
+ }
3916
+ //#endregion
3630
3917
  //#region src/plugins/build/phases/content.ts
3631
3918
  /**
3632
3919
  * @file build phase 2 — content. Delegates entirely to the content plugin via
@@ -4101,7 +4388,10 @@ async function generateLocaleRedirects(ctx) {
4101
4388
  //#region src/plugins/build/phases/not-found.ts
4102
4389
  /**
4103
4390
  * @file build phase — not-found. Emits `outDir/404.html` from configured route
4104
- * content or a built-in default. Gated by `config.notFound` (false/unset disables).
4391
+ * content or a built-in default, substituting the `<!--moku:assets-->` family of
4392
+ * placeholders (the bundles are fingerprint-named, so an app-owned 404 page can
4393
+ * no longer hardcode a bundle URL). Gated by `config.notFound` (false/unset
4394
+ * disables).
4105
4395
  */
4106
4396
  /** The built-in default 404 page body when no custom route content is supplied. */
4107
4397
  const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.</p>";
@@ -4141,11 +4431,13 @@ async function resolveHtml(notFound) {
4141
4431
  /**
4142
4432
  * Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
4143
4433
  * default page; `{ body }` writes the supplied HTML body content inside the
4144
- * minimal document shell; `{ path }` writes the referenced HTML page file
4145
- * verbatim (the app owns the whole document). No-op (returns `null`) when
4146
- * `notFound` is false/unset.
4434
+ * minimal document shell; `{ path }` writes the referenced HTML page file (the
4435
+ * app owns the whole document). In every variant the `<!--moku:assets-->` /
4436
+ * `<!--moku:assets:css-->` / `<!--moku:assets:js-->` placeholders are substituted
4437
+ * with the fingerprinted bundle tags — a page without placeholders passes through
4438
+ * byte-for-byte. No-op (returns `null`) when `notFound` is false/unset.
4147
4439
  *
4148
- * @param ctx - Plugin context (provides `config`, `log`).
4440
+ * @param ctx - Plugin context (provides `state`, `config`, `log`).
4149
4441
  * @returns The written file path, or `null` when disabled.
4150
4442
  * @example
4151
4443
  * ```ts
@@ -4158,7 +4450,7 @@ async function generateNotFound(ctx) {
4158
4450
  ctx.log.debug("build:not-found", { skipped: true });
4159
4451
  return null;
4160
4452
  }
4161
- const html = await resolveHtml(notFound);
4453
+ const html = substituteAssetPlaceholders(ctx, await resolveHtml(notFound));
4162
4454
  await mkdir(outDir, { recursive: true });
4163
4455
  const file = path.join(outDir, "404.html");
4164
4456
  await writeFile(file, html, "utf8");
@@ -4897,45 +5189,9 @@ const dataPlugin = createPlugin$1("data", {
4897
5189
  const HEAD_PLACEHOLDER = "<!--moku:head-->";
4898
5190
  /** Template placeholder for the SSR-rendered body HTML. */
4899
5191
  const BODY_PLACEHOLDER = "<!--moku:body-->";
4900
- /** Template placeholder for the injected asset `<link>`/`<script>` tags. */
4901
- const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
4902
5192
  /** Template placeholder for the page's locale (`<html lang>`). */
4903
5193
  const LANG_PLACEHOLDER = "<!--moku:lang-->";
4904
5194
  /**
4905
- * Read the bundle phase's hashed asset manifest for one kind from `state.buildCache`
4906
- * as a typed {@link BuildCacheEntry} (no `Map<string, unknown>` reads).
4907
- *
4908
- * @param ctx - Plugin context (provides `state`).
4909
- * @param kind - The asset kind key (`"css"` / `"js"`).
4910
- * @returns The hashed-path manifest entry, or an empty object when absent.
4911
- * @example
4912
- * ```ts
4913
- * readManifest(ctx, "css");
4914
- * ```
4915
- */
4916
- function readManifest(ctx, kind) {
4917
- const entry = ctx.state.buildCache.get(kind);
4918
- return entry && typeof entry === "object" ? entry : {};
4919
- }
4920
- /**
4921
- * Build the asset `<link>`/`<script>` tag block from the hashed manifests. Returns
4922
- * an empty string when `config.injectAssets === false`. Asset paths are emitted as
4923
- * absolute (`/`-rooted) URLs.
4924
- *
4925
- * @param ctx - Plugin context (provides `state`, `config`).
4926
- * @returns The injected asset tags, or `""` when injection is disabled.
4927
- * @example
4928
- * ```ts
4929
- * buildAssetTags(ctx);
4930
- * ```
4931
- */
4932
- function buildAssetTags(ctx) {
4933
- if (ctx.config.injectAssets === false) return "";
4934
- const css = Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`);
4935
- const js = Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`);
4936
- return [...css, ...js].join("");
4937
- }
4938
- /**
4939
5195
  * Compose the full static HTML document with the in-code shell, injecting the
4940
5196
  * build-id meta tag into `<head>` AFTER the head plugin's composed HTML (build
4941
5197
  * metadata, not content) and the asset tags at the end of `<head>`.
@@ -4954,18 +5210,21 @@ function renderDocument(parts) {
4954
5210
  * Fill a shell template's `<!--moku:lang-->` / `<!--moku:head-->` /
4955
5211
  * `<!--moku:body-->` / `<!--moku:assets-->` placeholders deterministically at build
4956
5212
  * time. `<!--moku:lang-->` carries the page locale (for `<html lang>`), so a single
4957
- * shared template stays locale-correct across every locale.
5213
+ * shared template stays locale-correct across every locale. The split
5214
+ * `<!--moku:assets:css-->` / `<!--moku:assets:js-->` placeholders inject one asset
5215
+ * kind each — for shells that, e.g., link stylesheets in `<head>` but place
5216
+ * scripts at the end of `<body>`.
4958
5217
  *
4959
5218
  * @param template - The raw shell template HTML.
4960
5219
  * @param parts - The composed head/body/assets/locale pieces.
4961
5220
  * @returns The filled document string.
4962
5221
  * @example
4963
5222
  * ```ts
4964
- * fillTemplate(shell, { head, body, assets, locale: "en" });
5223
+ * fillTemplate(shell, { head, body, assets, assetsCss, assetsJs, locale: "en" });
4965
5224
  * ```
4966
5225
  */
4967
5226
  function fillTemplate(template, parts) {
4968
- return template.replaceAll(LANG_PLACEHOLDER, parts.locale).replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
5227
+ 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);
4969
5228
  }
4970
5229
  /**
4971
5230
  * Resolve the compiled entry for a manifest definition, asserting the router
@@ -5316,6 +5575,8 @@ async function renderInstance(ctx, instance, shell, reuse) {
5316
5575
  head: composeHeadHtml(ctx, instance, url, routeContext, data),
5317
5576
  body: renderBodyCached(ctx, instance, routeContext, data, reuse),
5318
5577
  assets: shell.assets,
5578
+ assetsCss: shell.assetsCss,
5579
+ assetsJs: shell.assetsJs,
5319
5580
  locale
5320
5581
  };
5321
5582
  const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
@@ -5355,6 +5616,8 @@ async function prepareShell(ctx) {
5355
5616
  const template = typeof templatePath === "string" && existsSync(templatePath) ? await readFile(templatePath, "utf8") : null;
5356
5617
  return {
5357
5618
  assets: buildAssetTags(ctx),
5619
+ assetsCss: buildAssetTags(ctx, "css"),
5620
+ assetsJs: buildAssetTags(ctx, "js"),
5358
5621
  template,
5359
5622
  defaultLocale: ctx.require(i18nPlugin).defaultLocale()
5360
5623
  };
@@ -5500,37 +5763,6 @@ async function renderPages(ctx, options) {
5500
5763
  rootHtml: findRootHtml(rendered)
5501
5764
  };
5502
5765
  }
5503
- /**
5504
- * Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
5505
- * preserving the nested directory structure. Skips silently (returns `null`) when
5506
- * the source directory does not exist.
5507
- *
5508
- * @param ctx - Plugin context (provides `config`, `log`).
5509
- * @returns The copy result, or `null` when the public directory is absent.
5510
- * @example
5511
- * ```ts
5512
- * const result = await copyPublic(ctx);
5513
- * ```
5514
- */
5515
- async function copyPublic(ctx) {
5516
- const from = ctx.config.publicDir ?? "public";
5517
- if (!existsSync(from)) {
5518
- ctx.log.debug("build:public", {
5519
- skipped: true,
5520
- from
5521
- });
5522
- return null;
5523
- }
5524
- await cp(from, ctx.config.outDir, { recursive: true });
5525
- ctx.log.debug("build:public", {
5526
- from,
5527
- dest: ctx.config.outDir
5528
- });
5529
- return {
5530
- from: path.normalize(from),
5531
- copied: 1
5532
- };
5533
- }
5534
5766
  //#endregion
5535
5767
  //#region src/plugins/build/phases/sitemap.ts
5536
5768
  /**
@@ -5812,6 +6044,7 @@ const PHASE_ORDER = [
5812
6044
  "public",
5813
6045
  "not-found",
5814
6046
  "locale-redirects",
6047
+ "cache-headers",
5815
6048
  "root-index"
5816
6049
  ];
5817
6050
  /**
@@ -5906,7 +6139,7 @@ async function runOutputs(ctx) {
5906
6139
  }
5907
6140
  /**
5908
6141
  * Executes the full SSG pipeline for one run: clean → bundle → content/images →
5909
- * pages → feeds/sitemap/og-images → root-index. Orchestrates `ctx.require` pulls
6142
+ * pages → feeds/sitemap/og-images → cache-headers → root-index. Orchestrates `ctx.require` pulls
5910
6143
  * and `Promise.all` only — never inlines dependency domain logic. Emits a
5911
6144
  * `build:phase` boundary per phase and `build:complete` once at the end.
5912
6145
  *
@@ -5947,6 +6180,7 @@ async function runPipeline(ctx, options) {
5947
6180
  const pages = await withPhase(phaseContext, "pages", () => renderPages(phaseContext, { reuse: plan.renderReuse }));
5948
6181
  await withPhase(phaseContext, "content-images", () => copyContentImages(phaseContext));
5949
6182
  await runOutputs(phaseContext);
6183
+ if (phaseContext.config.cacheHeaders !== false) await withPhase(phaseContext, "cache-headers", () => generateCacheHeaders(phaseContext));
5950
6184
  await withPhase(phaseContext, "root-index", async () => {
5951
6185
  if (pages.rootHtml !== null) await writeFile(path.join(outDir, "index.html"), pages.rootHtml, "utf8");
5952
6186
  });
package/package.json CHANGED
@@ -118,5 +118,5 @@
118
118
  "test:build-e2e": "bun test src/plugins/build/__tests__/e2e/",
119
119
  "test:coverage": "vitest run --project unit --project integration --coverage"
120
120
  },
121
- "version": "1.7.0"
121
+ "version": "1.8.0"
122
122
  }