@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.mjs CHANGED
@@ -3156,6 +3156,26 @@ function composeHead(input) {
3156
3156
  }), ...head.elements ?? []]);
3157
3157
  }
3158
3158
  /**
3159
+ * Resolve the FINAL document title for a route's head config — the same value
3160
+ * {@link composeHead} emits in its `<title>` element. A route-supplied `title`-keyed
3161
+ * element wins the keyed last-wins de-dupe over the templated base title (how a route
3162
+ * pins a bare title past `titleTemplate`), so it must win here too; otherwise the
3163
+ * template is applied to `head.title ?? site.name()`. Reused by `spa` for the client
3164
+ * DATA-path `document.title` sync, so client-side navigation matches the SSG output.
3165
+ *
3166
+ * @param head - The route's head config (may be `undefined` for head-less routes).
3167
+ * @param defaults - The normalized head defaults (provides `titleTemplate`).
3168
+ * @param site - The site slice (title fallback).
3169
+ * @returns The final document title string.
3170
+ * @example composeTitle({ title: "Page 2" }, defaults, site) // "Page 2 — Site"
3171
+ */
3172
+ function composeTitle(head, defaults, site) {
3173
+ const config = head ?? {};
3174
+ const pinned = config.elements?.findLast((element) => element.key === "title");
3175
+ if (pinned?.children !== void 0) return pinned.children;
3176
+ return applyTemplate(config.title ?? site.name(), defaults.titleTemplate);
3177
+ }
3178
+ /**
3159
3179
  * Compose the SITE-LEVEL Open Graph / Twitter block for a bare-path redirect or landing
3160
3180
  * page that has no per-route head of its own. Returns `[]` UNLESS a `defaultOgImage` is
3161
3181
  * configured — so apps that opt out keep a bare redirect (no behavior change). The site
@@ -3325,6 +3345,21 @@ function createApi$4(ctx) {
3325
3345
  url: site.canonical(input.url),
3326
3346
  ...ogLocale === void 0 ? {} : { ogLocale }
3327
3347
  }));
3348
+ },
3349
+ /**
3350
+ * Resolve the FINAL document title for a route's head config — the same value `render`
3351
+ * emits in its `<title>` element. Pulled by `spa` on the client DATA path so a
3352
+ * client-side navigation's `document.title` matches the SSG output.
3353
+ *
3354
+ * @param head - The route's head config (may be `undefined` for head-less routes).
3355
+ * @returns The final document title string.
3356
+ * @example
3357
+ * ```ts
3358
+ * api.composeTitle({ title: "Page 2" }); // "Page 2 — Site"
3359
+ * ```
3360
+ */
3361
+ composeTitle(head) {
3362
+ return composeTitle(head, readDefaults(ctx.state), ctx.require(sitePlugin));
3328
3363
  }
3329
3364
  };
3330
3365
  }
@@ -3473,7 +3508,9 @@ const headPlugin = createPlugin$1("head", {
3473
3508
  //#region src/plugins/build/phases/bundle.ts
3474
3509
  /**
3475
3510
  * @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.
3511
+ * outDir (honoring `config.minify`) with content-hashed output naming; caches the
3512
+ * fingerprinted asset paths for the pages phase and the complete output list for
3513
+ * the cache-headers phase.
3477
3514
  */
3478
3515
  /** Conventional CSS entry candidates (project-relative). */
3479
3516
  const CSS_ENTRY_CANDIDATES = ["src/client/styles.css", "src/styles/main.css"];
@@ -3484,18 +3521,38 @@ const JS_ENTRY_CANDIDATES = [
3484
3521
  "src/main.ts"
3485
3522
  ];
3486
3523
  /**
3524
+ * `Bun.build` output naming with a content hash in EVERY filename (entry points
3525
+ * included — Bun's default only hashes chunks/assets). A bundle's URL therefore
3526
+ * changes whenever its bytes change, which is what lets the cache-headers phase
3527
+ * mark each bundle immutable: a CDN/browser may cache it forever, and a deploy
3528
+ * that changes the code ships a NEW URL instead of fighting a stale cached copy.
3529
+ * Pages always embed bundle URLs via the `state.buildCache` manifest, so hashed
3530
+ * names flow through with no app-side changes (hardcoded asset URLs must move to
3531
+ * the `<!--moku:assets-->` placeholders). Chunk naming keeps Bun's default
3532
+ * `chunk-` prefix (chunks were already hash-only named).
3533
+ */
3534
+ const FINGERPRINT_NAMING = {
3535
+ entry: "[dir]/[name]-[hash].[ext]",
3536
+ chunk: "chunk-[hash].[ext]",
3537
+ asset: "[name]-[hash].[ext]"
3538
+ };
3539
+ /**
3487
3540
  * The default bundler runner — adapts the built-in `Bun.build`.
3488
3541
  *
3489
- * @param options - Entry/outdir/minify/splitting/target settings forwarded to `Bun.build`.
3542
+ * @param options - Entry/outdir/minify/splitting/target/naming settings forwarded to `Bun.build`.
3490
3543
  * @param options.entrypoints - Entry files for this build.
3491
3544
  * @param options.outdir - Output directory.
3492
3545
  * @param options.minify - Whether to minify.
3493
3546
  * @param options.splitting - Whether to split dynamic imports into lazy chunks.
3494
3547
  * @param options.target - The bundling target platform.
3548
+ * @param options.naming - Output naming templates (content-hashed filenames).
3549
+ * @param options.naming.entry - Naming template for entry-point outputs.
3550
+ * @param options.naming.chunk - Naming template for lazy split chunks.
3551
+ * @param options.naming.asset - Naming template for additional emitted assets.
3495
3552
  * @returns The structural build result.
3496
3553
  * @example
3497
3554
  * ```ts
3498
- * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser" });
3555
+ * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser", naming: FINGERPRINT_NAMING });
3499
3556
  * ```
3500
3557
  */
3501
3558
  async function defaultRunner(options) {
@@ -3587,7 +3644,8 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3587
3644
  outdir,
3588
3645
  minify,
3589
3646
  splitting: true,
3590
- target: "browser"
3647
+ target: "browser",
3648
+ naming: FINGERPRINT_NAMING
3591
3649
  });
3592
3650
  if (!result.success) throw new Error(`[web] build.bundle ${kind} build failed`);
3593
3651
  const hashed = {};
@@ -3596,6 +3654,7 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3596
3654
  hashed[path.basename(output.path)] = normalizeAssetPath(output.path, outDir);
3597
3655
  }
3598
3656
  ctx.state.buildCache.set(kind, hashed);
3657
+ ctx.state.buildCache.set(`${kind}:outputs`, result.outputs.map((output) => normalizeAssetPath(output.path, outDir)));
3599
3658
  ctx.log.debug("build:bundle", {
3600
3659
  kind,
3601
3660
  count: result.outputs.length
@@ -3627,6 +3686,269 @@ async function bundle(ctx, options = {}) {
3627
3686
  await Promise.all([runOne(ctx, runner, "css", cssEntrypoints, outDir, assetsDir, minify), runOne(ctx, runner, "js", jsEntrypoints, outDir, assetsDir, minify)]);
3628
3687
  }
3629
3688
  //#endregion
3689
+ //#region src/plugins/build/phases/asset-tags.ts
3690
+ /** Template placeholder for the injected asset tags (stylesheets + scripts). */
3691
+ const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
3692
+ /** Template placeholder for the injected stylesheet `<link>` tags ONLY. */
3693
+ const CSS_ASSETS_PLACEHOLDER = "<!--moku:assets:css-->";
3694
+ /** Template placeholder for the injected `<script>` tags ONLY. */
3695
+ const JS_ASSETS_PLACEHOLDER = "<!--moku:assets:js-->";
3696
+ /**
3697
+ * Read the bundle phase's fingerprinted asset manifest for one kind from
3698
+ * `state.buildCache` as a typed {@link BuildCacheEntry} (no `Map<string, unknown>`
3699
+ * reads at call sites).
3700
+ *
3701
+ * @param ctx - Plugin context (provides `state`).
3702
+ * @param kind - The asset kind key (`"css"` / `"js"`).
3703
+ * @returns The fingerprinted-path manifest entry, or an empty object when absent.
3704
+ * @example
3705
+ * ```ts
3706
+ * readManifest(ctx, "css"); // { "main.css": "assets/main-abc123.css" }
3707
+ * ```
3708
+ */
3709
+ function readManifest(ctx, kind) {
3710
+ const entry = ctx.state.buildCache.get(kind);
3711
+ return entry && typeof entry === "object" ? entry : {};
3712
+ }
3713
+ /**
3714
+ * Read the bundle phase's COMPLETE output list for one kind (entries + lazy split
3715
+ * chunks, web paths relative to the publish root) from `state.buildCache`. Unlike
3716
+ * {@link readManifest} this includes chunks — it feeds the cache-headers phase's
3717
+ * per-file immutable rules, where every fingerprinted file counts, not just the
3718
+ * eagerly embedded entries.
3719
+ *
3720
+ * @param ctx - Plugin context (provides `state`).
3721
+ * @param kind - The asset kind key (`"css"` / `"js"`).
3722
+ * @returns The publish-root-relative output paths, or an empty array when absent.
3723
+ * @example
3724
+ * ```ts
3725
+ * readBundleOutputs(ctx, "js"); // ["assets/spa-abc123.js", "assets/chunk-9f8e.js"]
3726
+ * ```
3727
+ */
3728
+ function readBundleOutputs(ctx, kind) {
3729
+ const entry = ctx.state.buildCache.get(`${kind}:outputs`);
3730
+ return Array.isArray(entry) ? entry : [];
3731
+ }
3732
+ /**
3733
+ * Render the stylesheet `<link>` tags for the fingerprinted CSS manifest.
3734
+ *
3735
+ * @param ctx - Plugin context (provides `state`).
3736
+ * @returns The concatenated `<link rel="stylesheet">` tags (possibly `""`).
3737
+ * @example
3738
+ * ```ts
3739
+ * buildCssTags(ctx); // '<link rel="stylesheet" href="/assets/main-abc123.css">'
3740
+ * ```
3741
+ */
3742
+ function buildCssTags(ctx) {
3743
+ return Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`).join("");
3744
+ }
3745
+ /**
3746
+ * Render the module `<script>` tags for the fingerprinted JS manifest.
3747
+ *
3748
+ * @param ctx - Plugin context (provides `state`).
3749
+ * @returns The concatenated `<script type="module">` tags (possibly `""`).
3750
+ * @example
3751
+ * ```ts
3752
+ * buildJsTags(ctx); // '<script type="module" src="/assets/spa-abc123.js"><\/script>'
3753
+ * ```
3754
+ */
3755
+ function buildJsTags(ctx) {
3756
+ return Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`).join("");
3757
+ }
3758
+ /**
3759
+ * Build the asset tag block from the fingerprinted manifests — both kinds by
3760
+ * default, or a single kind for the split `<!--moku:assets:css/js-->`
3761
+ * placeholders. Returns an empty string when `config.injectAssets === false`.
3762
+ * Asset paths are emitted as absolute (`/`-rooted) URLs.
3763
+ *
3764
+ * @param ctx - Plugin context (provides `state`, `config`).
3765
+ * @param kind - Restrict the block to one asset kind; omit for stylesheets + scripts.
3766
+ * @returns The injected asset tags, or `""` when injection is disabled.
3767
+ * @example
3768
+ * ```ts
3769
+ * buildAssetTags(ctx); // <link …><script …><\/script>
3770
+ * buildAssetTags(ctx, "css"); // <link …> only
3771
+ * ```
3772
+ */
3773
+ function buildAssetTags(ctx, kind) {
3774
+ if (ctx.config.injectAssets === false) return "";
3775
+ if (kind === "css") return buildCssTags(ctx);
3776
+ if (kind === "js") return buildJsTags(ctx);
3777
+ return buildCssTags(ctx) + buildJsTags(ctx);
3778
+ }
3779
+ /**
3780
+ * Substitute every `<!--moku:assets-->` family placeholder in a complete HTML
3781
+ * document: the combined block, the CSS-only block, and the JS-only block. A
3782
+ * document without placeholders passes through byte-identical — substitution is
3783
+ * strictly opt-in for app-owned pages (the not-found page).
3784
+ *
3785
+ * @param ctx - Plugin context (provides `state`, `config`).
3786
+ * @param html - The HTML document to substitute placeholders in.
3787
+ * @returns The document with all asset placeholders replaced.
3788
+ * @example
3789
+ * ```ts
3790
+ * substituteAssetPlaceholders(ctx, "<head><!--moku:assets:css--></head>");
3791
+ * ```
3792
+ */
3793
+ function substituteAssetPlaceholders(ctx, html) {
3794
+ return html.replaceAll(ASSETS_PLACEHOLDER, buildAssetTags(ctx)).replaceAll(CSS_ASSETS_PLACEHOLDER, buildAssetTags(ctx, "css")).replaceAll(JS_ASSETS_PLACEHOLDER, buildAssetTags(ctx, "js"));
3795
+ }
3796
+ /**
3797
+ * Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
3798
+ * preserving the nested directory structure. Skips silently (returns `null`) when
3799
+ * the source directory does not exist.
3800
+ *
3801
+ * @param ctx - Plugin context (provides `config`, `log`).
3802
+ * @returns The copy result, or `null` when the public directory is absent.
3803
+ * @example
3804
+ * ```ts
3805
+ * const result = await copyPublic(ctx);
3806
+ * ```
3807
+ */
3808
+ async function copyPublic(ctx) {
3809
+ const from = ctx.config.publicDir ?? "public";
3810
+ if (!existsSync(from)) {
3811
+ ctx.log.debug("build:public", {
3812
+ skipped: true,
3813
+ from
3814
+ });
3815
+ return null;
3816
+ }
3817
+ await cp(from, ctx.config.outDir, { recursive: true });
3818
+ ctx.log.debug("build:public", {
3819
+ from,
3820
+ dest: ctx.config.outDir
3821
+ });
3822
+ return {
3823
+ from: path.normalize(from),
3824
+ copied: 1
3825
+ };
3826
+ }
3827
+ //#endregion
3828
+ //#region src/plugins/build/phases/cache-headers.ts
3829
+ /**
3830
+ * @file build phase — cache-headers. Emits `outDir/_headers` (Cloudflare Pages
3831
+ * header rules) so the CDN/browser cache can never serve a stale file: every
3832
+ * fingerprinted bundle gets a per-file immutable rule (its URL changes with its
3833
+ * content, so caching it forever is safe), and every OTHER URL — pages, content
3834
+ * images, feeds, data sidecars: stable URLs whose bytes may change between
3835
+ * deploys — gets a catch-all revalidation rule (an unchanged file still answers
3836
+ * `304 Not Modified` via its ETag, so it is effectively cached; a changed file is
3837
+ * picked up immediately). The app's own `<publicDir>/_headers` rules are appended
3838
+ * AFTER the generated ones so the app can override them. Gated by
3839
+ * `config.cacheHeaders` (`false` disables; default on).
3840
+ */
3841
+ /**
3842
+ * `Cache-Control` for fingerprinted bundles: their URL embeds a content hash, so
3843
+ * the bytes behind a given URL can never change — cache them for a year, immutably.
3844
+ */
3845
+ const DEFAULT_ASSETS_CACHE = "public, max-age=31536000, immutable";
3846
+ /**
3847
+ * `Cache-Control` for everything else (stable URLs): always revalidate with the
3848
+ * origin. Unchanged files still serve from cache via a `304` ETag round-trip;
3849
+ * changed files are fetched fresh — never stale, still cheap.
3850
+ */
3851
+ const DEFAULT_PAGES_CACHE = "public, max-age=0, must-revalidate";
3852
+ /**
3853
+ * Cloudflare Pages caps `_headers` at 100 rules and silently ignores the rest —
3854
+ * a site whose bundle count pushes past the cap needs a warning, not silence.
3855
+ */
3856
+ const CLOUDFLARE_RULE_LIMIT = 100;
3857
+ /**
3858
+ * Resolve the two `Cache-Control` values from `config.cacheHeaders` (`true` or an
3859
+ * object — `false` never reaches here; the pipeline gates the phase off).
3860
+ *
3861
+ * @param cacheHeaders - The `config.cacheHeaders` value.
3862
+ * @returns The `assets` (fingerprinted bundles) + `pages` (everything else) values.
3863
+ * @example
3864
+ * ```ts
3865
+ * resolvePolicy(true); // { assets: DEFAULT_ASSETS_CACHE, pages: DEFAULT_PAGES_CACHE }
3866
+ * ```
3867
+ */
3868
+ function resolvePolicy(cacheHeaders) {
3869
+ const policy = typeof cacheHeaders === "object" ? cacheHeaders : {};
3870
+ return {
3871
+ assets: policy.assets ?? DEFAULT_ASSETS_CACHE,
3872
+ pages: policy.pages ?? DEFAULT_PAGES_CACHE
3873
+ };
3874
+ }
3875
+ /**
3876
+ * Compose the generated rule blocks: the catch-all revalidation rule FIRST, then
3877
+ * one immutable rule per fingerprinted bundle file. Cloudflare applies every
3878
+ * matching rule and comma-joins duplicate headers (it does NOT override), so each
3879
+ * per-file rule must detach the catch-all's `Cache-Control` (`! Cache-Control`)
3880
+ * before attaching its own — otherwise a bundle would be served with two joined,
3881
+ * contradictory `Cache-Control` values.
3882
+ *
3883
+ * @param files - The fingerprinted bundle web paths (publish-root-relative).
3884
+ * @param policy - The resolved `Cache-Control` values.
3885
+ * @param policy.assets - The value for fingerprinted bundles.
3886
+ * @param policy.pages - The catch-all value for everything else.
3887
+ * @returns The generated rule blocks, in emission order.
3888
+ * @example
3889
+ * ```ts
3890
+ * composeRules(["assets/main-abc123.css"], { assets: "…", pages: "…" });
3891
+ * ```
3892
+ */
3893
+ function composeRules(files, policy) {
3894
+ return [`/*\n Cache-Control: ${policy.pages}`, ...files.map((file) => `/${file}\n ! Cache-Control\n Cache-Control: ${policy.assets}`)];
3895
+ }
3896
+ /**
3897
+ * Read the app's own `<publicDir>/_headers` SOURCE file (not the copy the public
3898
+ * phase may have placed in outDir — composing from the source keeps this phase
3899
+ * idempotent and independent of phase ordering). Returns `""` when absent.
3900
+ *
3901
+ * @param publicDir - The configured public directory (or the default).
3902
+ * @returns The app's `_headers` content, or `""` when the file does not exist.
3903
+ * @example
3904
+ * ```ts
3905
+ * const appRules = await readAppHeaders("public");
3906
+ * ```
3907
+ */
3908
+ async function readAppHeaders(publicDir) {
3909
+ const source = path.join(publicDir, "_headers");
3910
+ if (!existsSync(source)) return "";
3911
+ return readFile(source, "utf8");
3912
+ }
3913
+ /**
3914
+ * Emits `outDir/_headers`: the generated cache rules (catch-all revalidation +
3915
+ * per-file immutable bundle rules) followed by the app's own
3916
+ * `<publicDir>/_headers` content. App rules come LAST so they can override a
3917
+ * generated header — note Cloudflare comma-joins duplicates, so an app rule that
3918
+ * re-sets a generated header must detach it first (`! Cache-Control`). Overwrites
3919
+ * the verbatim copy the public phase made, which is why this phase must run after
3920
+ * the outputs phase group.
3921
+ *
3922
+ * @param ctx - Plugin context (provides `state`, `config`, `log`).
3923
+ * @returns The written file path + generated rule count.
3924
+ * @example
3925
+ * ```ts
3926
+ * const result = await generateCacheHeaders(ctx);
3927
+ * ```
3928
+ */
3929
+ async function generateCacheHeaders(ctx) {
3930
+ const { outDir, publicDir, cacheHeaders } = ctx.config;
3931
+ const policy = resolvePolicy(cacheHeaders);
3932
+ const rules = composeRules([...readBundleOutputs(ctx, "css"), ...readBundleOutputs(ctx, "js")].toSorted(), policy);
3933
+ const appHeaders = (await readAppHeaders(publicDir ?? "public")).trim();
3934
+ const content = `${(appHeaders === "" ? rules : [...rules, appHeaders]).join("\n\n")}\n`;
3935
+ if (rules.length > CLOUDFLARE_RULE_LIMIT) ctx.log.warn("build:cache-headers", {
3936
+ rules: rules.length,
3937
+ limit: CLOUDFLARE_RULE_LIMIT
3938
+ });
3939
+ await mkdir(outDir, { recursive: true });
3940
+ const file = path.join(outDir, "_headers");
3941
+ await writeFile(file, content, "utf8");
3942
+ ctx.log.debug("build:cache-headers", {
3943
+ path: file,
3944
+ rules: rules.length
3945
+ });
3946
+ return {
3947
+ path: file,
3948
+ ruleCount: rules.length
3949
+ };
3950
+ }
3951
+ //#endregion
3630
3952
  //#region src/plugins/build/phases/content.ts
3631
3953
  /**
3632
3954
  * @file build phase 2 — content. Delegates entirely to the content plugin via
@@ -4101,7 +4423,10 @@ async function generateLocaleRedirects(ctx) {
4101
4423
  //#region src/plugins/build/phases/not-found.ts
4102
4424
  /**
4103
4425
  * @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).
4426
+ * content or a built-in default, substituting the `<!--moku:assets-->` family of
4427
+ * placeholders (the bundles are fingerprint-named, so an app-owned 404 page can
4428
+ * no longer hardcode a bundle URL). Gated by `config.notFound` (false/unset
4429
+ * disables).
4105
4430
  */
4106
4431
  /** The built-in default 404 page body when no custom route content is supplied. */
4107
4432
  const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.</p>";
@@ -4141,11 +4466,13 @@ async function resolveHtml(notFound) {
4141
4466
  /**
4142
4467
  * Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
4143
4468
  * 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.
4469
+ * minimal document shell; `{ path }` writes the referenced HTML page file (the
4470
+ * app owns the whole document). In every variant the `<!--moku:assets-->` /
4471
+ * `<!--moku:assets:css-->` / `<!--moku:assets:js-->` placeholders are substituted
4472
+ * with the fingerprinted bundle tags — a page without placeholders passes through
4473
+ * byte-for-byte. No-op (returns `null`) when `notFound` is false/unset.
4147
4474
  *
4148
- * @param ctx - Plugin context (provides `config`, `log`).
4475
+ * @param ctx - Plugin context (provides `state`, `config`, `log`).
4149
4476
  * @returns The written file path, or `null` when disabled.
4150
4477
  * @example
4151
4478
  * ```ts
@@ -4158,7 +4485,7 @@ async function generateNotFound(ctx) {
4158
4485
  ctx.log.debug("build:not-found", { skipped: true });
4159
4486
  return null;
4160
4487
  }
4161
- const html = await resolveHtml(notFound);
4488
+ const html = substituteAssetPlaceholders(ctx, await resolveHtml(notFound));
4162
4489
  await mkdir(outDir, { recursive: true });
4163
4490
  const file = path.join(outDir, "404.html");
4164
4491
  await writeFile(file, html, "utf8");
@@ -4897,45 +5224,9 @@ const dataPlugin = createPlugin$1("data", {
4897
5224
  const HEAD_PLACEHOLDER = "<!--moku:head-->";
4898
5225
  /** Template placeholder for the SSR-rendered body HTML. */
4899
5226
  const BODY_PLACEHOLDER = "<!--moku:body-->";
4900
- /** Template placeholder for the injected asset `<link>`/`<script>` tags. */
4901
- const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
4902
5227
  /** Template placeholder for the page's locale (`<html lang>`). */
4903
5228
  const LANG_PLACEHOLDER = "<!--moku:lang-->";
4904
5229
  /**
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
5230
  * Compose the full static HTML document with the in-code shell, injecting the
4940
5231
  * build-id meta tag into `<head>` AFTER the head plugin's composed HTML (build
4941
5232
  * metadata, not content) and the asset tags at the end of `<head>`.
@@ -4954,18 +5245,21 @@ function renderDocument(parts) {
4954
5245
  * Fill a shell template's `<!--moku:lang-->` / `<!--moku:head-->` /
4955
5246
  * `<!--moku:body-->` / `<!--moku:assets-->` placeholders deterministically at build
4956
5247
  * time. `<!--moku:lang-->` carries the page locale (for `<html lang>`), so a single
4957
- * shared template stays locale-correct across every locale.
5248
+ * shared template stays locale-correct across every locale. The split
5249
+ * `<!--moku:assets:css-->` / `<!--moku:assets:js-->` placeholders inject one asset
5250
+ * kind each — for shells that, e.g., link stylesheets in `<head>` but place
5251
+ * scripts at the end of `<body>`.
4958
5252
  *
4959
5253
  * @param template - The raw shell template HTML.
4960
5254
  * @param parts - The composed head/body/assets/locale pieces.
4961
5255
  * @returns The filled document string.
4962
5256
  * @example
4963
5257
  * ```ts
4964
- * fillTemplate(shell, { head, body, assets, locale: "en" });
5258
+ * fillTemplate(shell, { head, body, assets, assetsCss, assetsJs, locale: "en" });
4965
5259
  * ```
4966
5260
  */
4967
5261
  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);
5262
+ 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
5263
  }
4970
5264
  /**
4971
5265
  * Resolve the compiled entry for a manifest definition, asserting the router
@@ -5316,6 +5610,8 @@ async function renderInstance(ctx, instance, shell, reuse) {
5316
5610
  head: composeHeadHtml(ctx, instance, url, routeContext, data),
5317
5611
  body: renderBodyCached(ctx, instance, routeContext, data, reuse),
5318
5612
  assets: shell.assets,
5613
+ assetsCss: shell.assetsCss,
5614
+ assetsJs: shell.assetsJs,
5319
5615
  locale
5320
5616
  };
5321
5617
  const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
@@ -5355,6 +5651,8 @@ async function prepareShell(ctx) {
5355
5651
  const template = typeof templatePath === "string" && existsSync(templatePath) ? await readFile(templatePath, "utf8") : null;
5356
5652
  return {
5357
5653
  assets: buildAssetTags(ctx),
5654
+ assetsCss: buildAssetTags(ctx, "css"),
5655
+ assetsJs: buildAssetTags(ctx, "js"),
5358
5656
  template,
5359
5657
  defaultLocale: ctx.require(i18nPlugin).defaultLocale()
5360
5658
  };
@@ -5500,37 +5798,6 @@ async function renderPages(ctx, options) {
5500
5798
  rootHtml: findRootHtml(rendered)
5501
5799
  };
5502
5800
  }
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
5801
  //#endregion
5535
5802
  //#region src/plugins/build/phases/sitemap.ts
5536
5803
  /**
@@ -5812,6 +6079,7 @@ const PHASE_ORDER = [
5812
6079
  "public",
5813
6080
  "not-found",
5814
6081
  "locale-redirects",
6082
+ "cache-headers",
5815
6083
  "root-index"
5816
6084
  ];
5817
6085
  /**
@@ -5906,7 +6174,7 @@ async function runOutputs(ctx) {
5906
6174
  }
5907
6175
  /**
5908
6176
  * Executes the full SSG pipeline for one run: clean → bundle → content/images →
5909
- * pages → feeds/sitemap/og-images → root-index. Orchestrates `ctx.require` pulls
6177
+ * pages → feeds/sitemap/og-images → cache-headers → root-index. Orchestrates `ctx.require` pulls
5910
6178
  * and `Promise.all` only — never inlines dependency domain logic. Emits a
5911
6179
  * `build:phase` boundary per phase and `build:complete` once at the end.
5912
6180
  *
@@ -5947,6 +6215,7 @@ async function runPipeline(ctx, options) {
5947
6215
  const pages = await withPhase(phaseContext, "pages", () => renderPages(phaseContext, { reuse: plan.renderReuse }));
5948
6216
  await withPhase(phaseContext, "content-images", () => copyContentImages(phaseContext));
5949
6217
  await runOutputs(phaseContext);
6218
+ if (phaseContext.config.cacheHeaders !== false) await withPhase(phaseContext, "cache-headers", () => generateCacheHeaders(phaseContext));
5950
6219
  await withPhase(phaseContext, "root-index", async () => {
5951
6220
  if (pages.rootHtml !== null) await writeFile(path.join(outDir, "index.html"), pages.rootHtml, "utf8");
5952
6221
  });
@@ -10621,16 +10890,19 @@ function currentLocationUrl() {
10621
10890
  /**
10622
10891
  * Apply the matched route's `head` config to the live document (minimal client
10623
10892
  * head-sync for the DATA path: title only — the full meta sync runs on the
10624
- * HTML-over-fetch path from the fetched `<head>`).
10893
+ * HTML-over-fetch path from the fetched `<head>`). The title is resolved through
10894
+ * `head.composeTitle` — the SAME composition `render` uses (`titleTemplate` applied;
10895
+ * a route-pinned `title` element wins) — so a client-side navigation's
10896
+ * `document.title` matches the SSG output instead of the raw route title.
10625
10897
  *
10898
+ * @param head - The head plugin API (resolves the final templated title).
10626
10899
  * @param route - The matched route definition.
10627
10900
  * @param routeContext - The render context (params/data/locale).
10628
10901
  * @example
10629
- * syncDataHead(hit.route, { params, data, locale });
10902
+ * syncDataHead(deps.head, hit.route, { params, data, locale });
10630
10903
  */
10631
- function syncDataHead(route, routeContext) {
10632
- const title = route._handlers.head?.(routeContext)?.title;
10633
- if (title !== void 0 && title !== "") document.title = title;
10904
+ function syncDataHead(head, route, routeContext) {
10905
+ document.title = head.composeTitle(route._handlers.head?.(routeContext));
10634
10906
  }
10635
10907
  /**
10636
10908
  * Builds the single shared SPA kernel — a pure factory over state/config/emit.
@@ -10786,7 +11058,7 @@ function createSpaKernel(state, config, emit, deps) {
10786
11058
  handleStart(pathname);
10787
11059
  const { renderVNode } = await import("./render-BNe0s7fr.mjs");
10788
11060
  if (signal?.aborted) return;
10789
- syncDataHead(route, routeContext);
11061
+ syncDataHead(deps.head, route, routeContext);
10790
11062
  unmountPageSpecific(state, emit);
10791
11063
  /**
10792
11064
  * Render the VNode into the region and re-mount its islands in one paint — the
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.1"
122
122
  }