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