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