@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 +315 -81
- package/dist/index.d.cts +30 -4
- package/dist/index.d.mts +30 -4
- package/dist/index.mjs +315 -81
- package/package.json +1 -1
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`)
|
|
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
|
|
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
|
-
*
|
|
4159
|
-
*
|
|
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)
|
|
1671
|
-
*
|
|
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)
|
|
1671
|
-
*
|
|
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`)
|
|
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
|
|
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
|
-
*
|
|
4146
|
-
*
|
|
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