@moku-labs/web 1.0.0 → 1.1.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/README.md +2 -2
- package/dist/browser.mjs +107 -35
- package/dist/index.cjs +1061 -178
- package/dist/index.d.cts +105 -13
- package/dist/index.d.mts +105 -13
- package/dist/index.mjs +1061 -178
- package/package.json +2 -1
package/dist/index.cjs
CHANGED
|
@@ -411,6 +411,40 @@ function isPlainObject$1(value) {
|
|
|
411
411
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
412
412
|
}
|
|
413
413
|
/**
|
|
414
|
+
* Tests whether `actual` is an array that recursively matches every element of
|
|
415
|
+
* the `partial` array (element-wise, with equal length).
|
|
416
|
+
*
|
|
417
|
+
* @param actual - The value to test against (must be an array of equal length).
|
|
418
|
+
* @param partial - The expected partial array shape.
|
|
419
|
+
* @returns `true` when `actual` is an equal-length array matching `partial` element-wise.
|
|
420
|
+
* @example
|
|
421
|
+
* ```ts
|
|
422
|
+
* matchesPartialArray([1, 2], [1, 2]); // true
|
|
423
|
+
* matchesPartialArray([1], [1, 2]); // false (length mismatch)
|
|
424
|
+
* ```
|
|
425
|
+
*/
|
|
426
|
+
function matchesPartialArray(actual, partial) {
|
|
427
|
+
if (!Array.isArray(actual) || actual.length !== partial.length) return false;
|
|
428
|
+
return partial.every((value, index) => matchesPartial(actual[index], value));
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Tests whether `actual` is a plain object in which every `partial` key
|
|
432
|
+
* recursively matches (extra `actual` keys are ignored).
|
|
433
|
+
*
|
|
434
|
+
* @param actual - The value to test against (must be a plain object).
|
|
435
|
+
* @param partial - The expected partial object shape.
|
|
436
|
+
* @returns `true` when every `partial` key exists in `actual` and matches recursively.
|
|
437
|
+
* @example
|
|
438
|
+
* ```ts
|
|
439
|
+
* matchesPartialObject({ a: 1, b: 2 }, { a: 1 }); // true
|
|
440
|
+
* matchesPartialObject({ a: 1 }, { b: 1 }); // false (missing key)
|
|
441
|
+
* ```
|
|
442
|
+
*/
|
|
443
|
+
function matchesPartialObject(actual, partial) {
|
|
444
|
+
if (!isPlainObject$1(actual)) return false;
|
|
445
|
+
return Object.keys(partial).every((key) => key in actual && matchesPartial(actual[key], partial[key]));
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
414
448
|
* Subset-equality matcher: is `partial` a recursive subset of `actual`?
|
|
415
449
|
*
|
|
416
450
|
* Fast path via `Object.is` (covers identical primitives/references and
|
|
@@ -429,14 +463,8 @@ function isPlainObject$1(value) {
|
|
|
429
463
|
*/
|
|
430
464
|
function matchesPartial(actual, partial) {
|
|
431
465
|
if (Object.is(actual, partial)) return true;
|
|
432
|
-
if (Array.isArray(partial))
|
|
433
|
-
|
|
434
|
-
return partial.every((value, index) => matchesPartial(actual[index], value));
|
|
435
|
-
}
|
|
436
|
-
if (isPlainObject$1(partial)) {
|
|
437
|
-
if (!isPlainObject$1(actual)) return false;
|
|
438
|
-
return Object.keys(partial).every((key) => key in actual && matchesPartial(actual[key], partial[key]));
|
|
439
|
-
}
|
|
466
|
+
if (Array.isArray(partial)) return matchesPartialArray(actual, partial);
|
|
467
|
+
if (isPlainObject$1(partial)) return matchesPartialObject(actual, partial);
|
|
440
468
|
return false;
|
|
441
469
|
}
|
|
442
470
|
/**
|
|
@@ -1950,6 +1978,25 @@ function hasValidLangCount(pattern) {
|
|
|
1950
1978
|
return (pattern.match(/\{lang:\?\}/g) ?? []).length <= MAX_LANG_SEGMENTS;
|
|
1951
1979
|
}
|
|
1952
1980
|
/**
|
|
1981
|
+
* Assert a single route's pattern is well-formed, throwing the `[web]`-prefixed
|
|
1982
|
+
* error for the first failure: not rooted at `/`, unbalanced `{…}` braces, or
|
|
1983
|
+
* more than one `{lang:?}` segment. Extracted from {@link validateRoutes} so the
|
|
1984
|
+
* loop body stays flat.
|
|
1985
|
+
*
|
|
1986
|
+
* @param name - The route name key, surfaced in any error message.
|
|
1987
|
+
* @param pattern - The route's user pattern to validate.
|
|
1988
|
+
* @throws {Error} When the pattern is malformed.
|
|
1989
|
+
* @example
|
|
1990
|
+
* ```ts
|
|
1991
|
+
* assertRouteValid("home", "/{slug}/");
|
|
1992
|
+
* ```
|
|
1993
|
+
*/
|
|
1994
|
+
function assertRouteValid(name, pattern) {
|
|
1995
|
+
if (!isPatternRooted(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern must start with "/" (got "${pattern}").`);
|
|
1996
|
+
if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
|
|
1997
|
+
if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
1953
2000
|
* Validate the route map (fail-fast in `onInit`). Throws with the `[web]` prefix
|
|
1954
2001
|
* naming the offending route/pattern on any failure: empty map, a pattern not
|
|
1955
2002
|
* starting with `/`, unbalanced `{…}` braces, or more than one `{lang:?}` segment.
|
|
@@ -1964,12 +2011,7 @@ function hasValidLangCount(pattern) {
|
|
|
1964
2011
|
function validateRoutes(routes) {
|
|
1965
2012
|
const names = Object.keys(routes);
|
|
1966
2013
|
if (names.length === 0) throw new Error(`${ERROR_PREFIX$11}: route map is empty.\n Register at least one route via pluginConfigs.router.routes.`);
|
|
1967
|
-
for (const name of names)
|
|
1968
|
-
const pattern = routes[name]?.pattern ?? "";
|
|
1969
|
-
if (!isPatternRooted(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern must start with "/" (got "${pattern}").`);
|
|
1970
|
-
if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
|
|
1971
|
-
if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
|
|
1972
|
-
}
|
|
2014
|
+
for (const name of names) assertRouteValid(name, routes[name]?.pattern ?? "");
|
|
1973
2015
|
}
|
|
1974
2016
|
/**
|
|
1975
2017
|
* Convert a user pattern to a `URLPattern` source string, in a `withLang` or
|
|
@@ -3551,6 +3593,26 @@ async function generateFeeds(ctx) {
|
|
|
3551
3593
|
/** Conventional source directories scanned for static images to copy. */
|
|
3552
3594
|
const IMAGE_SOURCE_DIRECTORIES = ["public", "static"];
|
|
3553
3595
|
/**
|
|
3596
|
+
* Copy one source directory into the assets target, skipping it when the
|
|
3597
|
+
* directory is absent or empty. The target is created lazily so an all-empty
|
|
3598
|
+
* build never touches `outDir`.
|
|
3599
|
+
*
|
|
3600
|
+
* @param directory - The candidate source directory to copy.
|
|
3601
|
+
* @param target - The assets directory inside `outDir` to copy into.
|
|
3602
|
+
* @returns `true` when the directory was copied, `false` when skipped.
|
|
3603
|
+
* @example
|
|
3604
|
+
* ```ts
|
|
3605
|
+
* const didCopy = await copyImageDirectory("public", "dist/assets");
|
|
3606
|
+
* ```
|
|
3607
|
+
*/
|
|
3608
|
+
async function copyImageDirectory(directory, target) {
|
|
3609
|
+
if (!(0, node_fs.existsSync)(directory)) return false;
|
|
3610
|
+
if ((await (0, node_fs_promises.readdir)(directory)).length === 0) return false;
|
|
3611
|
+
await (0, node_fs_promises.mkdir)(target, { recursive: true });
|
|
3612
|
+
await (0, node_fs_promises.cp)(directory, target, { recursive: true });
|
|
3613
|
+
return true;
|
|
3614
|
+
}
|
|
3615
|
+
/**
|
|
3554
3616
|
* Copies static image directories into the output directory. No-op when
|
|
3555
3617
|
* `config.images` is false or no source directory exists. Image bytes are copied
|
|
3556
3618
|
* verbatim (optimization is a no-op hook point) — build only sequences I/O.
|
|
@@ -3571,13 +3633,7 @@ async function processImages(ctx, options = {}) {
|
|
|
3571
3633
|
const sourceDirectories = options.sourceDirectories ?? IMAGE_SOURCE_DIRECTORIES;
|
|
3572
3634
|
const target = node_path$1.default.join(ctx.config.outDir, "assets");
|
|
3573
3635
|
let copied = 0;
|
|
3574
|
-
for (const directory of sourceDirectories)
|
|
3575
|
-
if (!(0, node_fs.existsSync)(directory)) continue;
|
|
3576
|
-
if ((await (0, node_fs_promises.readdir)(directory)).length === 0) continue;
|
|
3577
|
-
await (0, node_fs_promises.mkdir)(target, { recursive: true });
|
|
3578
|
-
await (0, node_fs_promises.cp)(directory, target, { recursive: true });
|
|
3579
|
-
copied += 1;
|
|
3580
|
-
}
|
|
3636
|
+
for (const directory of sourceDirectories) if (await copyImageDirectory(directory, target)) copied += 1;
|
|
3581
3637
|
ctx.log.debug("build:images", { copied });
|
|
3582
3638
|
return copied;
|
|
3583
3639
|
}
|
|
@@ -3625,6 +3681,37 @@ function pairRoutes(router) {
|
|
|
3625
3681
|
return pairs;
|
|
3626
3682
|
}
|
|
3627
3683
|
/**
|
|
3684
|
+
* Compute the single bare→default redirect job for one generated parameter set, or
|
|
3685
|
+
* `null` when no redirect is needed. The BARE (locale-less) path is derived by
|
|
3686
|
+
* stripping `lang`. `generate()` supplies `lang` (pages need it), so using `params`
|
|
3687
|
+
* as-is makes the "bare" URL already carry the locale → target === bareUrl → NO
|
|
3688
|
+
* redirect is ever emitted. Removing `lang` yields the real lang-less file/URL
|
|
3689
|
+
* (`/`, `/about/`, `/{slug}/`) that must redirect to the default-locale URL.
|
|
3690
|
+
*
|
|
3691
|
+
* @param entry - The compiled `TypedRoute` (owns `toFile`/`toUrl`).
|
|
3692
|
+
* @param raw - One raw parameter set from `generate()` (may be `null`/`undefined`).
|
|
3693
|
+
* @param defaultLocale - The default locale to redirect bare paths to.
|
|
3694
|
+
* @returns The `{ file, target }` redirect job, or `null` when no redirect is needed.
|
|
3695
|
+
* @example
|
|
3696
|
+
* ```ts
|
|
3697
|
+
* redirectJobFor(entry, { lang: "en", slug: "hello" }, "en");
|
|
3698
|
+
* ```
|
|
3699
|
+
*/
|
|
3700
|
+
function redirectJobFor(entry, raw, defaultLocale) {
|
|
3701
|
+
const bareParams = { ...raw ?? {} };
|
|
3702
|
+
delete bareParams.lang;
|
|
3703
|
+
const file = entry.toFile(bareParams);
|
|
3704
|
+
const target = entry.toUrl({
|
|
3705
|
+
...bareParams,
|
|
3706
|
+
lang: defaultLocale
|
|
3707
|
+
});
|
|
3708
|
+
if (!(target !== entry.toUrl(bareParams))) return null;
|
|
3709
|
+
return {
|
|
3710
|
+
file,
|
|
3711
|
+
target
|
|
3712
|
+
};
|
|
3713
|
+
}
|
|
3714
|
+
/**
|
|
3628
3715
|
* Expand one route into bare→default redirect jobs for the default locale. Uses
|
|
3629
3716
|
* `generate?.(defaultLocale)` (or a single empty-params instance) and emits a job
|
|
3630
3717
|
* only when the bare file path differs from the default-locale URL (i.e. the route
|
|
@@ -3649,17 +3736,8 @@ async function expandRedirects(definition, entry, defaultLocale, ctx) {
|
|
|
3649
3736
|
const parameterSets = definition._handlers.generate ? await definition._handlers.generate(generateContext) : [{}];
|
|
3650
3737
|
const jobs = [];
|
|
3651
3738
|
for (const raw of parameterSets) {
|
|
3652
|
-
const
|
|
3653
|
-
|
|
3654
|
-
const file = entry.toFile(bareParams);
|
|
3655
|
-
const target = entry.toUrl({
|
|
3656
|
-
...bareParams,
|
|
3657
|
-
lang: defaultLocale
|
|
3658
|
-
});
|
|
3659
|
-
if (target !== entry.toUrl(bareParams)) jobs.push({
|
|
3660
|
-
file,
|
|
3661
|
-
target
|
|
3662
|
-
});
|
|
3739
|
+
const job = redirectJobFor(entry, raw, defaultLocale);
|
|
3740
|
+
if (job) jobs.push(job);
|
|
3663
3741
|
}
|
|
3664
3742
|
return jobs;
|
|
3665
3743
|
}
|
|
@@ -3820,6 +3898,54 @@ function ogHash(input, template, fontsHash) {
|
|
|
3820
3898
|
].join("|");
|
|
3821
3899
|
return (0, node_crypto.createHash)("sha256").update(payload).digest("hex");
|
|
3822
3900
|
}
|
|
3901
|
+
/** Weight applied to fonts with no explicit `weight`. */
|
|
3902
|
+
const DEFAULT_FONT_WEIGHT = 400;
|
|
3903
|
+
/** Style applied to fonts with no explicit `style`. */
|
|
3904
|
+
const DEFAULT_FONT_STYLE = "normal";
|
|
3905
|
+
/** Family name given to the single fallback font scanned from `fontDir`. */
|
|
3906
|
+
const FALLBACK_FONT_NAME = "OG";
|
|
3907
|
+
/**
|
|
3908
|
+
* Load each explicitly-configured OG font, reading its `path` to a Buffer once and
|
|
3909
|
+
* filling in the default weight/style when omitted.
|
|
3910
|
+
*
|
|
3911
|
+
* @param fonts - The explicit named fonts to load.
|
|
3912
|
+
* @returns The loaded Satori font entries, in configuration order.
|
|
3913
|
+
* @example
|
|
3914
|
+
* ```ts
|
|
3915
|
+
* await loadExplicitFonts([{ name: "Inter", path: "./Inter.ttf" }]);
|
|
3916
|
+
* ```
|
|
3917
|
+
*/
|
|
3918
|
+
async function loadExplicitFonts(fonts) {
|
|
3919
|
+
return Promise.all(fonts.map(async (font) => ({
|
|
3920
|
+
name: font.name,
|
|
3921
|
+
data: await (0, node_fs_promises.readFile)(font.path),
|
|
3922
|
+
weight: font.weight ?? DEFAULT_FONT_WEIGHT,
|
|
3923
|
+
style: font.style ?? DEFAULT_FONT_STYLE
|
|
3924
|
+
})));
|
|
3925
|
+
}
|
|
3926
|
+
/**
|
|
3927
|
+
* Scan `fontDir` for the first recognized font file and load it as a single
|
|
3928
|
+
* 400/normal fallback; yields an empty list when the directory or a usable file
|
|
3929
|
+
* is missing.
|
|
3930
|
+
*
|
|
3931
|
+
* @param fontDir - Directory scanned for a fallback font file.
|
|
3932
|
+
* @returns The single fallback font, or an empty list when none is available.
|
|
3933
|
+
* @example
|
|
3934
|
+
* ```ts
|
|
3935
|
+
* await scanFallbackFont("./fonts");
|
|
3936
|
+
* ```
|
|
3937
|
+
*/
|
|
3938
|
+
async function scanFallbackFont(fontDir) {
|
|
3939
|
+
if (!(0, node_fs.existsSync)(fontDir)) return [];
|
|
3940
|
+
const file = (await (0, node_fs_promises.readdir)(fontDir)).find((name) => FONT_EXTENSIONS$1.some((extension) => name.endsWith(extension)));
|
|
3941
|
+
if (!file) return [];
|
|
3942
|
+
return [{
|
|
3943
|
+
name: FALLBACK_FONT_NAME,
|
|
3944
|
+
data: await (0, node_fs_promises.readFile)(node_path.default.join(fontDir, file)),
|
|
3945
|
+
weight: DEFAULT_FONT_WEIGHT,
|
|
3946
|
+
style: DEFAULT_FONT_STYLE
|
|
3947
|
+
}];
|
|
3948
|
+
}
|
|
3823
3949
|
/**
|
|
3824
3950
|
* Load the configured OG fonts ONCE per build. When `ogImage.fonts` is set, each
|
|
3825
3951
|
* `path` is read to a Buffer (outside any per-image loop) and mapped to a Satori
|
|
@@ -3836,21 +3962,8 @@ function ogHash(input, template, fontsHash) {
|
|
|
3836
3962
|
* ```
|
|
3837
3963
|
*/
|
|
3838
3964
|
async function loadFonts(og) {
|
|
3839
|
-
if (og.fonts && og.fonts.length > 0) return
|
|
3840
|
-
|
|
3841
|
-
data: await (0, node_fs_promises.readFile)(font.path),
|
|
3842
|
-
weight: font.weight ?? 400,
|
|
3843
|
-
style: font.style ?? "normal"
|
|
3844
|
-
})));
|
|
3845
|
-
if (!(0, node_fs.existsSync)(og.fontDir)) return [];
|
|
3846
|
-
const file = (await (0, node_fs_promises.readdir)(og.fontDir)).find((name) => FONT_EXTENSIONS$1.some((extension) => name.endsWith(extension)));
|
|
3847
|
-
if (!file) return [];
|
|
3848
|
-
return [{
|
|
3849
|
-
name: "OG",
|
|
3850
|
-
data: await (0, node_fs_promises.readFile)(node_path.default.join(og.fontDir, file)),
|
|
3851
|
-
weight: 400,
|
|
3852
|
-
style: "normal"
|
|
3853
|
-
}];
|
|
3965
|
+
if (og.fonts !== void 0 && og.fonts.length > 0) return loadExplicitFonts(og.fonts ?? []);
|
|
3966
|
+
return scanFallbackFont(og.fontDir);
|
|
3854
3967
|
}
|
|
3855
3968
|
/**
|
|
3856
3969
|
* The built-in default OG card — a centered title on a dark background. Used when
|
|
@@ -4440,6 +4553,47 @@ function fillTemplate(template, parts) {
|
|
|
4440
4553
|
return template.replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
|
|
4441
4554
|
}
|
|
4442
4555
|
/**
|
|
4556
|
+
* Resolve the compiled entry for a manifest definition, asserting the router
|
|
4557
|
+
* invariant that `manifest()` and `entries()` stay in sync (see {@link makeEntryMap}).
|
|
4558
|
+
*
|
|
4559
|
+
* @param byPattern - The pattern→compiled-`TypedRoute` index.
|
|
4560
|
+
* @param definition - The route definition from the manifest.
|
|
4561
|
+
* @returns The compiled `TypedRoute` for the definition's pattern.
|
|
4562
|
+
* @throws {Error} When no compiled entry exists for the definition's pattern.
|
|
4563
|
+
* @example
|
|
4564
|
+
* ```ts
|
|
4565
|
+
* const entry = resolveEntry(byPattern, definition);
|
|
4566
|
+
* ```
|
|
4567
|
+
*/
|
|
4568
|
+
function resolveEntry(byPattern, definition) {
|
|
4569
|
+
const entry = byPattern.get(definition.pattern);
|
|
4570
|
+
if (!entry) throw new Error(`[web] build.pages: no router entry for pattern "${definition.pattern}" — router.manifest() and router.entries() are out of sync.`);
|
|
4571
|
+
return entry;
|
|
4572
|
+
}
|
|
4573
|
+
/**
|
|
4574
|
+
* Produce the param sets one route generates for a single locale: the route's
|
|
4575
|
+
* `.generate(ctx)` result when present, else a single empty-params instance. The
|
|
4576
|
+
* generate context is the spec `{ locale, require, has }`, so a `.generate()` handler
|
|
4577
|
+
* pulls sibling APIs the spec way.
|
|
4578
|
+
*
|
|
4579
|
+
* @param definition - The route definition from the manifest.
|
|
4580
|
+
* @param locale - The active locale to generate param sets for.
|
|
4581
|
+
* @param ctx - Plugin context (provides `require`/`has` for the generate context).
|
|
4582
|
+
* @returns The param sets for this route+locale (`[{}]` when there is no `.generate()`).
|
|
4583
|
+
* @example
|
|
4584
|
+
* ```ts
|
|
4585
|
+
* const paramSets = await generateParamSets(def, "en", ctx);
|
|
4586
|
+
* ```
|
|
4587
|
+
*/
|
|
4588
|
+
async function generateParameterSets(definition, locale, ctx) {
|
|
4589
|
+
const generateContext = {
|
|
4590
|
+
locale,
|
|
4591
|
+
require: ctx.require,
|
|
4592
|
+
has: ctx.has
|
|
4593
|
+
};
|
|
4594
|
+
return definition._handlers.generate ? await definition._handlers.generate(generateContext) : [{}];
|
|
4595
|
+
}
|
|
4596
|
+
/**
|
|
4443
4597
|
* Expand one route definition into its concrete page instances across all locales,
|
|
4444
4598
|
* using `generate?.(ctx)` when present (else a single empty-params instance per
|
|
4445
4599
|
* locale). The generate context is the spec `{ locale, require, has }`, so a
|
|
@@ -4456,18 +4610,12 @@ function fillTemplate(template, parts) {
|
|
|
4456
4610
|
* ```
|
|
4457
4611
|
*/
|
|
4458
4612
|
async function expandRoute(definition, locales, byPattern, ctx) {
|
|
4459
|
-
const entry = byPattern
|
|
4460
|
-
if (!entry) throw new Error(`[web] build.pages: no router entry for pattern "${definition.pattern}" — router.manifest() and router.entries() are out of sync.`);
|
|
4613
|
+
const entry = resolveEntry(byPattern, definition);
|
|
4461
4614
|
const { name } = entry;
|
|
4462
4615
|
const instances = [];
|
|
4463
4616
|
for (const locale of locales) {
|
|
4464
|
-
const
|
|
4465
|
-
|
|
4466
|
-
require: ctx.require,
|
|
4467
|
-
has: ctx.has
|
|
4468
|
-
};
|
|
4469
|
-
const generated = definition._handlers.generate ? await definition._handlers.generate(generateContext) : [{}];
|
|
4470
|
-
for (const raw of generated) instances.push({
|
|
4617
|
+
const parameterSets = await generateParameterSets(definition, locale, ctx);
|
|
4618
|
+
for (const raw of parameterSets) instances.push({
|
|
4471
4619
|
definition,
|
|
4472
4620
|
entry,
|
|
4473
4621
|
name,
|
|
@@ -5033,6 +5181,23 @@ function resetRun(ctx) {
|
|
|
5033
5181
|
ctx.state.runId = `${Date.now()}-${(0, node_crypto.randomUUID)()}`;
|
|
5034
5182
|
}
|
|
5035
5183
|
/**
|
|
5184
|
+
* Report each rejected outcome from a settled output batch as a `build:outputs`
|
|
5185
|
+
* error, leaving fulfilled outcomes untouched (failures are isolated, not fatal).
|
|
5186
|
+
*
|
|
5187
|
+
* @param ctx - The phase context (used to log rejections).
|
|
5188
|
+
* @param settled - The settled results from the `runOutputs` task batch.
|
|
5189
|
+
* @example
|
|
5190
|
+
* ```ts
|
|
5191
|
+
* reportOutputFailures(ctx, await Promise.allSettled(tasks));
|
|
5192
|
+
* ```
|
|
5193
|
+
*/
|
|
5194
|
+
function reportOutputFailures(ctx, settled) {
|
|
5195
|
+
for (const outcome of settled) {
|
|
5196
|
+
if (outcome.status !== "rejected") continue;
|
|
5197
|
+
ctx.log.error("build:outputs", { reason: String(outcome.reason) });
|
|
5198
|
+
}
|
|
5199
|
+
}
|
|
5200
|
+
/**
|
|
5036
5201
|
* Phase 4 — run feeds / sitemap / og-images / public / not-found / locale-redirects
|
|
5037
5202
|
* concurrently, each gated by its config flag (or, for `public`, the presence of the
|
|
5038
5203
|
* source dir), isolated with `Promise.allSettled` so one failure does not lose the
|
|
@@ -5057,8 +5222,7 @@ async function runOutputs(ctx) {
|
|
|
5057
5222
|
if ((0, node_fs.existsSync)(ctx.config.publicDir ?? "public")) tasks.push(withPhase(ctx, "public", () => copyPublic(ctx)));
|
|
5058
5223
|
if (ctx.config.notFound) tasks.push(withPhase(ctx, "not-found", () => generateNotFound(ctx)));
|
|
5059
5224
|
if (ctx.config.localeRedirects) tasks.push(withPhase(ctx, "locale-redirects", () => generateLocaleRedirects(ctx)));
|
|
5060
|
-
|
|
5061
|
-
for (const outcome of settled) if (outcome.status === "rejected") ctx.log.error("build:outputs", { reason: String(outcome.reason) });
|
|
5225
|
+
reportOutputFailures(ctx, await Promise.allSettled(tasks));
|
|
5062
5226
|
}
|
|
5063
5227
|
/**
|
|
5064
5228
|
* Executes the full SSG pipeline for one run: clean → bundle → content/images →
|
|
@@ -5610,6 +5774,19 @@ const CHECKOUT_SHA = "11bd71901bbe5b1630ceea73d27597364c9af683";
|
|
|
5610
5774
|
const SETUP_BUN_SHA = "4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5";
|
|
5611
5775
|
/** Pinned `cloudflare/wrangler-action` commit SHA. */
|
|
5612
5776
|
const WRANGLER_ACTION_SHA = "f84a562284fc78278ff9052435d9526f9c718361";
|
|
5777
|
+
/** The `on:` block for each {@link WorkflowTrigger} (kept indentation-exact for YAML). */
|
|
5778
|
+
const TRIGGER_ON_BLOCKS = {
|
|
5779
|
+
auto: `on:
|
|
5780
|
+
push:
|
|
5781
|
+
branches: [main]
|
|
5782
|
+
workflow_dispatch:`,
|
|
5783
|
+
"versioned-tag": `on:
|
|
5784
|
+
push:
|
|
5785
|
+
tags: ["v*"]
|
|
5786
|
+
workflow_dispatch:`,
|
|
5787
|
+
dispatch: `on:
|
|
5788
|
+
workflow_dispatch:`
|
|
5789
|
+
};
|
|
5613
5790
|
/**
|
|
5614
5791
|
* Generate a SHA-pinned GitHub Actions workflow that builds and deploys to
|
|
5615
5792
|
* Cloudflare Pages. Every action is pinned to a commit SHA (with a `# vX`
|
|
@@ -5619,9 +5796,10 @@ const WRANGLER_ACTION_SHA = "f84a562284fc78278ff9052435d9526f9c718361";
|
|
|
5619
5796
|
*
|
|
5620
5797
|
* @param input - The generator inputs.
|
|
5621
5798
|
* @param input.slug - Cloudflare project-name slug used as the wrangler `--project-name`.
|
|
5799
|
+
* @param input.trigger - What fires the workflow (see {@link WorkflowTrigger}). Default `"auto"`.
|
|
5622
5800
|
* @returns The workflow YAML.
|
|
5623
5801
|
* @example
|
|
5624
|
-
* generateGithubWorkflow({ slug: "my-site" });
|
|
5802
|
+
* generateGithubWorkflow({ slug: "my-site", trigger: "versioned-tag" });
|
|
5625
5803
|
*/
|
|
5626
5804
|
function generateGithubWorkflow(input) {
|
|
5627
5805
|
return `# .github/workflows/deploy.yml — generated by \`app.deploy.init({ ci: true })\`.
|
|
@@ -5629,10 +5807,7 @@ function generateGithubWorkflow(input) {
|
|
|
5629
5807
|
|
|
5630
5808
|
name: Deploy
|
|
5631
5809
|
|
|
5632
|
-
|
|
5633
|
-
push:
|
|
5634
|
-
branches: [main]
|
|
5635
|
-
workflow_dispatch:
|
|
5810
|
+
${TRIGGER_ON_BLOCKS[input.trigger ?? "auto"]}
|
|
5636
5811
|
|
|
5637
5812
|
permissions:
|
|
5638
5813
|
contents: read
|
|
@@ -5764,7 +5939,10 @@ async function writeScaffolding(input) {
|
|
|
5764
5939
|
});
|
|
5765
5940
|
if (ci) await reconcile({
|
|
5766
5941
|
relativePath: WORKFLOW_PATH,
|
|
5767
|
-
expected: generateGithubWorkflow({
|
|
5942
|
+
expected: generateGithubWorkflow({
|
|
5943
|
+
slug,
|
|
5944
|
+
...options.workflowTrigger ? { trigger: options.workflowTrigger } : {}
|
|
5945
|
+
}),
|
|
5768
5946
|
existing: await readMaybe(cwd, WORKFLOW_PATH),
|
|
5769
5947
|
cwd,
|
|
5770
5948
|
check,
|
|
@@ -5773,6 +5951,21 @@ async function writeScaffolding(input) {
|
|
|
5773
5951
|
return result;
|
|
5774
5952
|
}
|
|
5775
5953
|
/**
|
|
5954
|
+
* Create the parent directory then write the scaffold file to disk.
|
|
5955
|
+
*
|
|
5956
|
+
* @param cwd - Project root the file is written into.
|
|
5957
|
+
* @param relativePath - Path (relative to cwd) of the scaffold file.
|
|
5958
|
+
* @param contents - The content to write.
|
|
5959
|
+
* @returns Resolves once the file (and any missing parents) exist on disk.
|
|
5960
|
+
* @example
|
|
5961
|
+
* await writeScaffoldFile(process.cwd(), "wrangler.jsonc", contents);
|
|
5962
|
+
*/
|
|
5963
|
+
async function writeScaffoldFile(cwd, relativePath, contents) {
|
|
5964
|
+
const absolutePath = node_path$1.default.join(cwd, relativePath);
|
|
5965
|
+
await (0, node_fs_promises.mkdir)(node_path$1.default.dirname(absolutePath), { recursive: true });
|
|
5966
|
+
await (0, node_fs_promises.writeFile)(absolutePath, contents, "utf8");
|
|
5967
|
+
}
|
|
5968
|
+
/**
|
|
5776
5969
|
* Reconcile one scaffold file against disk: in check mode record drift, otherwise
|
|
5777
5970
|
* skip an existing file or write a new one. Mutates the shared {@link InitResult}.
|
|
5778
5971
|
*
|
|
@@ -5789,24 +5982,21 @@ async function writeScaffolding(input) {
|
|
|
5789
5982
|
*/
|
|
5790
5983
|
async function reconcile(input) {
|
|
5791
5984
|
const { relativePath, expected, existing, cwd, check, result } = input;
|
|
5985
|
+
const fileExists = existing !== null;
|
|
5986
|
+
const fileDrifted = fileExists && existing !== expected;
|
|
5792
5987
|
if (check) {
|
|
5793
|
-
if (
|
|
5988
|
+
if (fileDrifted) result.drifted.push(relativePath);
|
|
5794
5989
|
return;
|
|
5795
5990
|
}
|
|
5796
|
-
if (
|
|
5991
|
+
if (fileExists) {
|
|
5797
5992
|
result.skipped.push(relativePath);
|
|
5798
5993
|
return;
|
|
5799
5994
|
}
|
|
5800
|
-
await (
|
|
5801
|
-
await (0, node_fs_promises.writeFile)(node_path$1.default.join(cwd, relativePath), expected, "utf8");
|
|
5995
|
+
await writeScaffoldFile(cwd, relativePath, expected);
|
|
5802
5996
|
result.written.push(relativePath);
|
|
5803
5997
|
}
|
|
5804
5998
|
//#endregion
|
|
5805
5999
|
//#region src/plugins/deploy/preflight.ts
|
|
5806
|
-
/**
|
|
5807
|
-
* @file deploy plugin — preflight validators (cheap → expensive), run in order
|
|
5808
|
-
* and short-circuiting on the first failure.
|
|
5809
|
-
*/
|
|
5810
6000
|
/** Error prefix for deploy preflight failures (spec/11 Part-3). */
|
|
5811
6001
|
const ERROR_PREFIX$5 = "[web] deploy";
|
|
5812
6002
|
/** Cloudflare Pages free-tier file-count limit. */
|
|
@@ -5833,6 +6023,27 @@ function resolveFileLimit(env = process.env) {
|
|
|
5833
6023
|
return Math.min(parsed, PAID_TIER_FILE_LIMIT);
|
|
5834
6024
|
}
|
|
5835
6025
|
/**
|
|
6026
|
+
* Fold one directory entry into the running walk: queue subdirectories, and for
|
|
6027
|
+
* files bump the count and flag the path when it breaches the per-file size cap.
|
|
6028
|
+
*
|
|
6029
|
+
* @param entry - The directory entry being visited.
|
|
6030
|
+
* @param entryPath - The absolute path of `entry`.
|
|
6031
|
+
* @param result - The running walk aggregate, mutated in place.
|
|
6032
|
+
* @param stack - The pending-directory stack, pushed to for subdirectories.
|
|
6033
|
+
* @returns Resolves once the entry has been folded into `result`/`stack`.
|
|
6034
|
+
* @example
|
|
6035
|
+
* await inspectEntry(entry, "/project/dist/app.js", result, stack);
|
|
6036
|
+
*/
|
|
6037
|
+
async function inspectEntry(entry, entryPath, result, stack) {
|
|
6038
|
+
if (entry.isDirectory()) {
|
|
6039
|
+
stack.push(entryPath);
|
|
6040
|
+
return;
|
|
6041
|
+
}
|
|
6042
|
+
if (!entry.isFile()) return;
|
|
6043
|
+
result.fileCount += 1;
|
|
6044
|
+
if ((await (0, node_fs_promises.stat)(entryPath)).size > MAX_FILE_SIZE_BYTES) result.oversizePath = entryPath;
|
|
6045
|
+
}
|
|
6046
|
+
/**
|
|
5836
6047
|
* Recursively walk `dir`, counting files and flagging the first file over the
|
|
5837
6048
|
* per-file size cap. Short-circuits once an oversize file is found.
|
|
5838
6049
|
*
|
|
@@ -5847,20 +6058,13 @@ async function inspectOutdir(dir) {
|
|
|
5847
6058
|
oversizePath: null
|
|
5848
6059
|
};
|
|
5849
6060
|
const stack = [dir];
|
|
5850
|
-
while (stack.length > 0) {
|
|
6061
|
+
while (stack.length > 0 && result.oversizePath === null) {
|
|
5851
6062
|
const current = stack.pop();
|
|
5852
6063
|
if (current === void 0) break;
|
|
5853
6064
|
const entries = await (0, node_fs_promises.readdir)(current, { withFileTypes: true });
|
|
5854
6065
|
for (const entry of entries) {
|
|
5855
|
-
|
|
5856
|
-
if (
|
|
5857
|
-
else if (entry.isFile()) {
|
|
5858
|
-
result.fileCount += 1;
|
|
5859
|
-
if ((await (0, node_fs_promises.stat)(entryPath)).size > MAX_FILE_SIZE_BYTES) {
|
|
5860
|
-
result.oversizePath = entryPath;
|
|
5861
|
-
return result;
|
|
5862
|
-
}
|
|
5863
|
-
}
|
|
6066
|
+
await inspectEntry(entry, node_path$1.default.join(current, entry.name), result, stack);
|
|
6067
|
+
if (result.oversizePath !== null) break;
|
|
5864
6068
|
}
|
|
5865
6069
|
}
|
|
5866
6070
|
return result;
|
|
@@ -6256,6 +6460,185 @@ const deployPlugin = createPlugin$1("deploy", {
|
|
|
6256
6460
|
api: createApi$2
|
|
6257
6461
|
});
|
|
6258
6462
|
//#endregion
|
|
6463
|
+
//#region src/plugins/cli/deploy-wizard.ts
|
|
6464
|
+
/**
|
|
6465
|
+
* @file cli plugin — the guided deploy wizard (`cli.deploy({ guided: true })`). Walks a
|
|
6466
|
+
* human through a Cloudflare Pages deploy: checks prerequisites (wrangler config + the
|
|
6467
|
+
* Cloudflare credentials) with concrete fix guidance, offers to scaffold/build what is
|
|
6468
|
+
* missing, HARD-GATES the deploy on everything being green, runs a local build smoke
|
|
6469
|
+
* test, confirms, deploys, then offers to scaffold a GitHub Actions workflow (auto on
|
|
6470
|
+
* push to main, or a versioned/manual trigger). The non-guided `--cli` path stays in
|
|
6471
|
+
* `api.ts`. Every prompt + line of output flows through injectable `state` seams.
|
|
6472
|
+
*/
|
|
6473
|
+
/** How to create a Cloudflare API token + where to make it available locally. */
|
|
6474
|
+
const TOKEN_HELP = [
|
|
6475
|
+
"Create one at https://dash.cloudflare.com/profile/api-tokens → Create Token →",
|
|
6476
|
+
"use the \"Cloudflare Pages — Edit\" template (or a custom token with the",
|
|
6477
|
+
"Account › Cloudflare Pages › Edit permission). Then make it available:",
|
|
6478
|
+
" export CLOUDFLARE_API_TOKEN=… (shell) or add it to .env (gitignored)."
|
|
6479
|
+
].join("\n");
|
|
6480
|
+
/** Where to find the Cloudflare account id + where to make it available locally. */
|
|
6481
|
+
const ACCOUNT_HELP = [
|
|
6482
|
+
"Find it on the Cloudflare dashboard → Workers & Pages: the Account ID is in the",
|
|
6483
|
+
"right-hand sidebar (also in the dashboard URL). Then make it available:",
|
|
6484
|
+
" export CLOUDFLARE_ACCOUNT_ID=… or add it to .env."
|
|
6485
|
+
].join("\n");
|
|
6486
|
+
/** The GitHub repo secrets the generated workflow consumes. */
|
|
6487
|
+
const SECRETS_HELP = ["Add these repo secrets (GitHub → Settings → Secrets and variables → Actions):", "CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID"].join("\n");
|
|
6488
|
+
/**
|
|
6489
|
+
* Evaluate the three deploy prerequisites against the current project: the Cloudflare
|
|
6490
|
+
* wrangler config exists, and both Cloudflare credentials are present in the environment.
|
|
6491
|
+
*
|
|
6492
|
+
* @param cwd - The project root (where `wrangler.jsonc` lives).
|
|
6493
|
+
* @returns The ordered prerequisite checks.
|
|
6494
|
+
* @example
|
|
6495
|
+
* const prereqs = diagnose(process.cwd());
|
|
6496
|
+
*/
|
|
6497
|
+
function diagnose(cwd) {
|
|
6498
|
+
const wranglerOk = (0, node_fs.existsSync)(node_path$1.default.join(cwd, "wrangler.jsonc"));
|
|
6499
|
+
const tokenOk = (process.env.CLOUDFLARE_API_TOKEN ?? "") !== "";
|
|
6500
|
+
const accountOk = (process.env.CLOUDFLARE_ACCOUNT_ID ?? "") !== "";
|
|
6501
|
+
return [
|
|
6502
|
+
{
|
|
6503
|
+
ok: wranglerOk,
|
|
6504
|
+
label: "wrangler.jsonc (Cloudflare project config)",
|
|
6505
|
+
detail: wranglerOk ? void 0 : "Missing — scaffold it (offered below) or run app.deploy.init().",
|
|
6506
|
+
scaffoldable: true
|
|
6507
|
+
},
|
|
6508
|
+
{
|
|
6509
|
+
ok: tokenOk,
|
|
6510
|
+
label: "CLOUDFLARE_API_TOKEN is set",
|
|
6511
|
+
detail: tokenOk ? void 0 : TOKEN_HELP,
|
|
6512
|
+
scaffoldable: false
|
|
6513
|
+
},
|
|
6514
|
+
{
|
|
6515
|
+
ok: accountOk,
|
|
6516
|
+
label: "CLOUDFLARE_ACCOUNT_ID is set",
|
|
6517
|
+
detail: accountOk ? void 0 : ACCOUNT_HELP,
|
|
6518
|
+
scaffoldable: false
|
|
6519
|
+
}
|
|
6520
|
+
];
|
|
6521
|
+
}
|
|
6522
|
+
/**
|
|
6523
|
+
* Offer to scaffold a missing `wrangler.jsonc` (the only auto-fixable prerequisite),
|
|
6524
|
+
* generating it via the deploy plugin when the user accepts.
|
|
6525
|
+
*
|
|
6526
|
+
* @param ctx - The cli plugin context.
|
|
6527
|
+
* @param prereqs - The current prerequisite checks.
|
|
6528
|
+
* @returns Resolves once any accepted fix has run.
|
|
6529
|
+
* @example
|
|
6530
|
+
* await offerScaffold(ctx, diagnose(cwd));
|
|
6531
|
+
*/
|
|
6532
|
+
async function offerScaffold(ctx, prereqs) {
|
|
6533
|
+
if (!prereqs.some((item) => item.scaffoldable && !item.ok)) return;
|
|
6534
|
+
if (!await ctx.state.confirm("Scaffold wrangler.jsonc now?")) return;
|
|
6535
|
+
await ctx.require(deployPlugin).init({});
|
|
6536
|
+
ctx.state.render.check(true, "wrangler.jsonc scaffolded");
|
|
6537
|
+
}
|
|
6538
|
+
/**
|
|
6539
|
+
* Map a top-level workflow choice (and, for the versioned option, a sub-choice) to the
|
|
6540
|
+
* concrete {@link WorkflowTrigger}, or `null` when the user chose to skip setup.
|
|
6541
|
+
*
|
|
6542
|
+
* @param ctx - The cli plugin context (for the follow-up sub-choice prompt).
|
|
6543
|
+
* @param choice - The selected zero-based index of the top-level options.
|
|
6544
|
+
* @returns The resolved trigger, or `null` to skip.
|
|
6545
|
+
* @example
|
|
6546
|
+
* const trigger = await resolveTrigger(ctx, 1);
|
|
6547
|
+
*/
|
|
6548
|
+
async function resolveTrigger(ctx, choice) {
|
|
6549
|
+
if (choice === 2) return null;
|
|
6550
|
+
if (choice === 0) return "auto";
|
|
6551
|
+
return await ctx.state.select("How should the versioned deploy be triggered?", ["On a version tag push (v*) + the manual Run-workflow button", "Manual Run-workflow button only (workflow_dispatch)"]) === 0 ? "versioned-tag" : "dispatch";
|
|
6552
|
+
}
|
|
6553
|
+
/**
|
|
6554
|
+
* Offer to scaffold a GitHub Actions deploy workflow, letting the user choose how it is
|
|
6555
|
+
* triggered, then remind them which repo secrets to add. A no-op past a "skip" choice.
|
|
6556
|
+
*
|
|
6557
|
+
* @param ctx - The cli plugin context.
|
|
6558
|
+
* @returns Resolves once any chosen workflow has been scaffolded.
|
|
6559
|
+
* @example
|
|
6560
|
+
* await offerWorkflowSetup(ctx);
|
|
6561
|
+
*/
|
|
6562
|
+
async function offerWorkflowSetup(ctx) {
|
|
6563
|
+
ctx.state.render.heading("Automate future deploys (GitHub Actions)");
|
|
6564
|
+
const trigger = await resolveTrigger(ctx, await ctx.state.select("Set up a deploy workflow?", [
|
|
6565
|
+
"Auto-deploy on every push to main",
|
|
6566
|
+
"Manual / versioned deploy (choose trigger)",
|
|
6567
|
+
"Skip for now"
|
|
6568
|
+
]));
|
|
6569
|
+
if (trigger === null) return;
|
|
6570
|
+
const result = await ctx.require(deployPlugin).init({
|
|
6571
|
+
ci: true,
|
|
6572
|
+
workflowTrigger: trigger
|
|
6573
|
+
});
|
|
6574
|
+
const workflowPath = ".github/workflows/deploy.yml";
|
|
6575
|
+
const wrote = result.written.includes(workflowPath);
|
|
6576
|
+
ctx.state.render.check(true, wrote ? `wrote ${workflowPath}` : `${workflowPath} already exists (left unchanged)`);
|
|
6577
|
+
ctx.state.render.info(SECRETS_HELP);
|
|
6578
|
+
}
|
|
6579
|
+
/**
|
|
6580
|
+
* Run the deploy step: confirm (unless `yes`), then deploy via the deploy plugin and
|
|
6581
|
+
* report the outcome. A declined confirm returns `{ deployed: false, reason: "declined" }`.
|
|
6582
|
+
*
|
|
6583
|
+
* @param ctx - The cli plugin context.
|
|
6584
|
+
* @param options - The deploy options (branch override + `yes`).
|
|
6585
|
+
* @returns The deploy outcome.
|
|
6586
|
+
* @example
|
|
6587
|
+
* const outcome = await runDeployStep(ctx, { yes: true });
|
|
6588
|
+
*/
|
|
6589
|
+
async function runDeployStep(ctx, options) {
|
|
6590
|
+
ctx.state.render.heading("Deploy");
|
|
6591
|
+
if (!(options.yes === true || await ctx.state.confirm(`Deploy ${ctx.config.outDir}/ to Cloudflare Pages now?`))) {
|
|
6592
|
+
ctx.state.render.warn("deploy skipped");
|
|
6593
|
+
return {
|
|
6594
|
+
deployed: false,
|
|
6595
|
+
reason: "declined"
|
|
6596
|
+
};
|
|
6597
|
+
}
|
|
6598
|
+
return {
|
|
6599
|
+
deployed: true,
|
|
6600
|
+
...await ctx.require(deployPlugin).run(options.branch === void 0 ? {} : { branch: options.branch })
|
|
6601
|
+
};
|
|
6602
|
+
}
|
|
6603
|
+
/**
|
|
6604
|
+
* Run the guided deploy wizard end to end: diagnose prerequisites (offering to scaffold
|
|
6605
|
+
* the wrangler config), HARD-GATE on the remaining blockers, run a local build smoke
|
|
6606
|
+
* test, deploy (with confirmation), then offer to scaffold a CI workflow. Returns
|
|
6607
|
+
* `{ deployed: false, reason: "blocked" }` when prerequisites are unmet, so a thin script
|
|
6608
|
+
* can exit non-zero. Assumes the caller already rendered the `deploy` header.
|
|
6609
|
+
*
|
|
6610
|
+
* @param ctx - The cli plugin context (state seams + `require` + config).
|
|
6611
|
+
* @param options - The deploy options (branch override, `yes`, `guided`).
|
|
6612
|
+
* @returns The deploy outcome (`deployed`, or a `declined`/`blocked` skip).
|
|
6613
|
+
* @example
|
|
6614
|
+
* const outcome = await runDeployWizard(ctx, { guided: true });
|
|
6615
|
+
*/
|
|
6616
|
+
async function runDeployWizard(ctx, options) {
|
|
6617
|
+
const cwd = process.cwd();
|
|
6618
|
+
ctx.state.render.heading("Checking prerequisites");
|
|
6619
|
+
for (const item of diagnose(cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
|
|
6620
|
+
await offerScaffold(ctx, diagnose(cwd));
|
|
6621
|
+
const blockers = diagnose(cwd).filter((item) => !item.ok);
|
|
6622
|
+
if (blockers.length > 0) {
|
|
6623
|
+
ctx.state.render.heading("Not ready to deploy");
|
|
6624
|
+
for (const item of blockers) ctx.state.render.check(false, item.label, item.detail);
|
|
6625
|
+
ctx.state.render.warn(`Fix the ${blockers.length} item(s) above, then re-run \`bun run deploy\`.`);
|
|
6626
|
+
return {
|
|
6627
|
+
deployed: false,
|
|
6628
|
+
reason: "blocked"
|
|
6629
|
+
};
|
|
6630
|
+
}
|
|
6631
|
+
ctx.state.render.heading("Local test");
|
|
6632
|
+
const summary = await ctx.require(buildPlugin).run();
|
|
6633
|
+
ctx.state.render.check(true, `built ${summary.pageCount} pages → ${summary.outDir}/`);
|
|
6634
|
+
const notFoundOk = (0, node_fs.existsSync)(node_path$1.default.join(ctx.config.outDir, ctx.config.notFoundFile));
|
|
6635
|
+
ctx.state.render.check(notFoundOk, `${ctx.config.notFoundFile} present`, notFoundOk ? void 0 : "Set build.notFound so the SSG emits it (CF Pages else flips to SPA mode).");
|
|
6636
|
+
ctx.state.render.info("Tip: run `bun run preview` to eyeball the built site before deploying.");
|
|
6637
|
+
const outcome = await runDeployStep(ctx, options);
|
|
6638
|
+
await offerWorkflowSetup(ctx);
|
|
6639
|
+
return outcome;
|
|
6640
|
+
}
|
|
6641
|
+
//#endregion
|
|
6259
6642
|
//#region src/plugins/cli/errors.ts
|
|
6260
6643
|
/** Error prefix for cli config/validation/runtime failures (spec/11 Part-3). */
|
|
6261
6644
|
const ERROR_PREFIX$3 = "[web] cli";
|
|
@@ -6289,11 +6672,12 @@ function injectReloadClient(html) {
|
|
|
6289
6672
|
return index === -1 ? html + RELOAD_CLIENT : html.slice(0, index) + RELOAD_CLIENT + html.slice(index);
|
|
6290
6673
|
}
|
|
6291
6674
|
/**
|
|
6292
|
-
* Run one rebuild and report the result.
|
|
6293
|
-
*
|
|
6675
|
+
* Run one rebuild and report the result. Announces the start (`onRebuildStart`), then
|
|
6676
|
+
* routes success to `onReloaded` and failure to `onError`.
|
|
6294
6677
|
*
|
|
6295
6678
|
* @param input - The rebuild dependencies + the changed file.
|
|
6296
6679
|
* @param input.runBuild - Runs one build and resolves with its summary.
|
|
6680
|
+
* @param input.onRebuildStart - Called with the changed file just before the build runs.
|
|
6297
6681
|
* @param input.onReloaded - Called with the changed file + summary after a rebuild.
|
|
6298
6682
|
* @param input.onError - Called when a rebuild throws.
|
|
6299
6683
|
* @param input.file - The changed file to report alongside the summary.
|
|
@@ -6302,6 +6686,7 @@ function injectReloadClient(html) {
|
|
|
6302
6686
|
* await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md" });
|
|
6303
6687
|
*/
|
|
6304
6688
|
async function runOneRebuild(input) {
|
|
6689
|
+
input.onRebuildStart?.(input.file);
|
|
6305
6690
|
try {
|
|
6306
6691
|
const summary = await input.runBuild();
|
|
6307
6692
|
input.onReloaded({
|
|
@@ -6323,6 +6708,7 @@ async function runOneRebuild(input) {
|
|
|
6323
6708
|
* @param input - The rebuild dependencies.
|
|
6324
6709
|
* @param input.debounceMs - Debounce window in milliseconds.
|
|
6325
6710
|
* @param input.runBuild - Runs one build and resolves with its summary.
|
|
6711
|
+
* @param input.onRebuildStart - Called with the changed file just before each build runs.
|
|
6326
6712
|
* @param input.onReloaded - Called with the changed file + summary after a rebuild.
|
|
6327
6713
|
* @param input.onError - Called when a rebuild throws.
|
|
6328
6714
|
* @returns The debounced rebuild driver.
|
|
@@ -6335,10 +6721,32 @@ function createRebuilder(input) {
|
|
|
6335
6721
|
let building = false;
|
|
6336
6722
|
let dirty = false;
|
|
6337
6723
|
/**
|
|
6338
|
-
*
|
|
6339
|
-
*
|
|
6340
|
-
*
|
|
6341
|
-
*
|
|
6724
|
+
* Rebuild repeatedly until no change arrived mid-flight: each pass clears `dirty`,
|
|
6725
|
+
* runs one build, then loops again if a `schedule()` set `dirty` while it ran, so
|
|
6726
|
+
* no change is dropped.
|
|
6727
|
+
*
|
|
6728
|
+
* @returns Resolves once a pass completes with no pending change (errors are routed,
|
|
6729
|
+
* never thrown).
|
|
6730
|
+
* @example
|
|
6731
|
+
* await drainPendingRebuilds();
|
|
6732
|
+
*/
|
|
6733
|
+
const drainPendingRebuilds = async () => {
|
|
6734
|
+
do {
|
|
6735
|
+
dirty = false;
|
|
6736
|
+
await runOneRebuild({
|
|
6737
|
+
runBuild: input.runBuild,
|
|
6738
|
+
...input.onRebuildStart ? { onRebuildStart: input.onRebuildStart } : {},
|
|
6739
|
+
onReloaded: input.onReloaded,
|
|
6740
|
+
onError: input.onError,
|
|
6741
|
+
file: pendingFile
|
|
6742
|
+
});
|
|
6743
|
+
} while (dirty);
|
|
6744
|
+
};
|
|
6745
|
+
/**
|
|
6746
|
+
* Run the queued rebuild once the debounce timer fires. Marks `dirty` (instead of
|
|
6747
|
+
* running) when a rebuild is already underway, otherwise holds the in-flight flag
|
|
6748
|
+
* across a full {@link drainPendingRebuilds} so concurrent changes coalesce into
|
|
6749
|
+
* exactly one extra re-run.
|
|
6342
6750
|
*
|
|
6343
6751
|
* @returns Resolves once the rebuild (and any coalesced re-run) settles (errors are
|
|
6344
6752
|
* routed, never thrown).
|
|
@@ -6352,15 +6760,7 @@ function createRebuilder(input) {
|
|
|
6352
6760
|
return;
|
|
6353
6761
|
}
|
|
6354
6762
|
building = true;
|
|
6355
|
-
|
|
6356
|
-
dirty = false;
|
|
6357
|
-
await runOneRebuild({
|
|
6358
|
-
runBuild: input.runBuild,
|
|
6359
|
-
onReloaded: input.onReloaded,
|
|
6360
|
-
onError: input.onError,
|
|
6361
|
-
file: pendingFile
|
|
6362
|
-
});
|
|
6363
|
-
} while (dirty);
|
|
6763
|
+
await drainPendingRebuilds();
|
|
6364
6764
|
building = false;
|
|
6365
6765
|
};
|
|
6366
6766
|
return {
|
|
@@ -6389,6 +6789,70 @@ function createRebuilder(input) {
|
|
|
6389
6789
|
};
|
|
6390
6790
|
}
|
|
6391
6791
|
/**
|
|
6792
|
+
* Whether a changed path (relative to a watched dir) is editor/OS noise that is never a
|
|
6793
|
+
* page source: any hidden segment (`.DS_Store`, anything under `.git/` or `.cache/`,
|
|
6794
|
+
* vim `.*.swp`) or a `~` backup file. Checks every segment, not just the basename.
|
|
6795
|
+
*
|
|
6796
|
+
* @param filename - The changed path relative to its watched directory.
|
|
6797
|
+
* @returns `true` when the change should be ignored as noise.
|
|
6798
|
+
* @example
|
|
6799
|
+
* isNoisePath(".git/HEAD"); // true
|
|
6800
|
+
*/
|
|
6801
|
+
function isNoisePath(filename) {
|
|
6802
|
+
return filename.split(/[/\\]/).some((segment) => segment.startsWith(".")) || filename.endsWith("~");
|
|
6803
|
+
}
|
|
6804
|
+
/**
|
|
6805
|
+
* Create a {@link ChangeGate} that drops three kinds of spurious change events before
|
|
6806
|
+
* they reach the debounced rebuilder: editor/OS noise (dotfiles, backups), writes under
|
|
6807
|
+
* `outDir` (the build's own output — a loop guard), and the stale duplicate/parent-dir
|
|
6808
|
+
* echoes macOS fires for one save. Staleness is judged by a build-start high-water mark:
|
|
6809
|
+
* a change whose file mtime is at or before the last build we started was already
|
|
6810
|
+
* captured (or is a late echo), so it is ignored — while a genuinely newer edit (even
|
|
6811
|
+
* one made mid-build) and a deletion (missing file) always pass. The single timestamp
|
|
6812
|
+
* also means no per-path map grows over a long session.
|
|
6813
|
+
*
|
|
6814
|
+
* @param input - The gate dependencies.
|
|
6815
|
+
* @param input.outDir - The build output directory whose writes must never re-trigger a build.
|
|
6816
|
+
* @param input.fileMtime - Resolves a path's mtime in ms (or `null` when missing).
|
|
6817
|
+
* @param input.now - Monotonic wall clock (ms) used for the build-start high-water mark.
|
|
6818
|
+
* @returns The change gate.
|
|
6819
|
+
* @example
|
|
6820
|
+
* const gate = createChangeGate({ outDir: "dist", fileMtime: state.fileMtime, now: state.clock });
|
|
6821
|
+
*/
|
|
6822
|
+
function createChangeGate(input) {
|
|
6823
|
+
const outDirAbs = node_path$1.default.resolve(input.outDir);
|
|
6824
|
+
let lastBuildStartedAt = input.now();
|
|
6825
|
+
return {
|
|
6826
|
+
/**
|
|
6827
|
+
* Decide whether a change beneath `dir` warrants a rebuild (see {@link ChangeGate.accept}).
|
|
6828
|
+
*
|
|
6829
|
+
* @param dir - The watched directory the event fired on.
|
|
6830
|
+
* @param filename - The changed path relative to `dir` (or `undefined`).
|
|
6831
|
+
* @returns `true` to schedule a rebuild, `false` to ignore.
|
|
6832
|
+
* @example
|
|
6833
|
+
* gate.accept("content", "post/en.md");
|
|
6834
|
+
*/
|
|
6835
|
+
accept(dir, filename) {
|
|
6836
|
+
if (filename === void 0) return true;
|
|
6837
|
+
if (isNoisePath(filename)) return false;
|
|
6838
|
+
const changed = node_path$1.default.resolve(dir, filename);
|
|
6839
|
+
if (changed === outDirAbs || changed.startsWith(`${outDirAbs}${node_path$1.default.sep}`)) return false;
|
|
6840
|
+
const mtime = input.fileMtime(changed);
|
|
6841
|
+
if (mtime !== null && mtime < lastBuildStartedAt) return false;
|
|
6842
|
+
return true;
|
|
6843
|
+
},
|
|
6844
|
+
/**
|
|
6845
|
+
* Advance the high-water mark to now (see {@link ChangeGate.markBuildStart}).
|
|
6846
|
+
*
|
|
6847
|
+
* @example
|
|
6848
|
+
* gate.markBuildStart();
|
|
6849
|
+
*/
|
|
6850
|
+
markBuildStart() {
|
|
6851
|
+
lastBuildStartedAt = input.now();
|
|
6852
|
+
}
|
|
6853
|
+
};
|
|
6854
|
+
}
|
|
6855
|
+
/**
|
|
6392
6856
|
* Install SIGINT/SIGTERM handlers that run `teardown()` and resolve the returned
|
|
6393
6857
|
* promise, so a long-running command (`serve`/`preview`) unblocks its `await` on
|
|
6394
6858
|
* Ctrl-C / termination and detaches its own listeners. Used by both servers.
|
|
@@ -6420,18 +6884,45 @@ function installSignalTeardown(teardown) {
|
|
|
6420
6884
|
const SSE_OPEN = ": connected\n\n";
|
|
6421
6885
|
/** The SSE frame pushed to reload a connected browser. */
|
|
6422
6886
|
const SSE_RELOAD = "event: reload\ndata: 1\n\n";
|
|
6887
|
+
/** The SSE comment frame sent on the heartbeat to keep an idle stream warm. */
|
|
6888
|
+
const SSE_PING = ": ping\n\n";
|
|
6889
|
+
/** Default heartbeat interval (ms): one ping well under any 30s+ proxy idle window. */
|
|
6890
|
+
const DEFAULT_HEARTBEAT_MS = 15e3;
|
|
6423
6891
|
/**
|
|
6424
6892
|
* Create a {@link ReloadHub} backed by `ReadableStream` controllers. Each `connect()`
|
|
6425
6893
|
* enqueues into a new stream; `reloadAll()` writes the reload frame to every live
|
|
6426
|
-
* controller (dropping any that have closed).
|
|
6894
|
+
* controller (dropping any that have closed). A periodic heartbeat comment keeps idle
|
|
6895
|
+
* streams warm — belt-and-suspenders alongside the dev server's `idleTimeout: 0`, so a
|
|
6896
|
+
* quiet connection is never severed (which the browser surfaces as
|
|
6897
|
+
* `ERR_INCOMPLETE_CHUNKED_ENCODING` and then reconnects in a storm).
|
|
6427
6898
|
*
|
|
6899
|
+
* @param options - Optional heartbeat tuning.
|
|
6900
|
+
* @param options.heartbeatMs - Heartbeat interval in ms (`0` disables). Default `15000`.
|
|
6428
6901
|
* @returns The reload hub.
|
|
6429
6902
|
* @example
|
|
6430
6903
|
* const hub = createReloadHub();
|
|
6431
6904
|
*/
|
|
6432
|
-
function createReloadHub() {
|
|
6905
|
+
function createReloadHub(options = {}) {
|
|
6433
6906
|
const encoder = new TextEncoder();
|
|
6434
6907
|
const clients = /* @__PURE__ */ new Set();
|
|
6908
|
+
/**
|
|
6909
|
+
* Enqueue one frame to every live controller, dropping any that have closed.
|
|
6910
|
+
*
|
|
6911
|
+
* @param frame - The SSE wire text to broadcast.
|
|
6912
|
+
* @example
|
|
6913
|
+
* broadcast(SSE_RELOAD);
|
|
6914
|
+
*/
|
|
6915
|
+
const broadcast = (frame) => {
|
|
6916
|
+
const bytes = encoder.encode(frame);
|
|
6917
|
+
for (const controller of clients) try {
|
|
6918
|
+
controller.enqueue(bytes);
|
|
6919
|
+
} catch {
|
|
6920
|
+
clients.delete(controller);
|
|
6921
|
+
}
|
|
6922
|
+
};
|
|
6923
|
+
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
6924
|
+
const heartbeat = heartbeatMs > 0 ? setInterval(() => broadcast(SSE_PING), heartbeatMs) : void 0;
|
|
6925
|
+
heartbeat?.unref?.();
|
|
6435
6926
|
return {
|
|
6436
6927
|
/**
|
|
6437
6928
|
* Open one SSE connection, register its controller, and return the streaming
|
|
@@ -6479,11 +6970,7 @@ function createReloadHub() {
|
|
|
6479
6970
|
* hub.reloadAll();
|
|
6480
6971
|
*/
|
|
6481
6972
|
reloadAll() {
|
|
6482
|
-
|
|
6483
|
-
controller.enqueue(encoder.encode(SSE_RELOAD));
|
|
6484
|
-
} catch {
|
|
6485
|
-
clients.delete(controller);
|
|
6486
|
-
}
|
|
6973
|
+
broadcast(SSE_RELOAD);
|
|
6487
6974
|
},
|
|
6488
6975
|
/**
|
|
6489
6976
|
* The number of currently-connected clients.
|
|
@@ -6494,9 +6981,42 @@ function createReloadHub() {
|
|
|
6494
6981
|
*/
|
|
6495
6982
|
size() {
|
|
6496
6983
|
return clients.size;
|
|
6984
|
+
},
|
|
6985
|
+
/**
|
|
6986
|
+
* Stop the heartbeat and close every live SSE stream (SIGINT/SIGTERM teardown).
|
|
6987
|
+
*
|
|
6988
|
+
* @example
|
|
6989
|
+
* hub.close();
|
|
6990
|
+
*/
|
|
6991
|
+
close() {
|
|
6992
|
+
if (heartbeat !== void 0) clearInterval(heartbeat);
|
|
6993
|
+
for (const controller of clients) try {
|
|
6994
|
+
controller.close();
|
|
6995
|
+
} catch {}
|
|
6996
|
+
clients.clear();
|
|
6497
6997
|
}
|
|
6498
6998
|
};
|
|
6499
6999
|
}
|
|
7000
|
+
/** The content-type sent on rewritten HTML responses (live-reload injection). */
|
|
7001
|
+
const HTML_CONTENT_TYPE = "text/html; charset=utf-8";
|
|
7002
|
+
/**
|
|
7003
|
+
* Re-render a static file response with the live-reload client injected, preserving
|
|
7004
|
+
* the resolved status. Reads the original body to text so {@link injectReloadClient}
|
|
7005
|
+
* can splice the snippet in before `</body>`.
|
|
7006
|
+
*
|
|
7007
|
+
* @param response - The original static file response to rewrite.
|
|
7008
|
+
* @param status - The resolved status to carry onto the rewritten response.
|
|
7009
|
+
* @returns A fresh HTML response containing the injected reload client.
|
|
7010
|
+
* @example
|
|
7011
|
+
* const injected = await injectReloadResponse(fileResponse, 200);
|
|
7012
|
+
*/
|
|
7013
|
+
async function injectReloadResponse(response, status) {
|
|
7014
|
+
const html = injectReloadClient(await response.text());
|
|
7015
|
+
return new Response(html, {
|
|
7016
|
+
status,
|
|
7017
|
+
headers: { "content-type": HTML_CONTENT_TYPE }
|
|
7018
|
+
});
|
|
7019
|
+
}
|
|
6500
7020
|
/**
|
|
6501
7021
|
* Build the live-reload-aware request handler for the dev server: serves the SSE
|
|
6502
7022
|
* stream at {@link RELOAD_PATH}, injects the reload client into HTML responses (when
|
|
@@ -6516,13 +7036,7 @@ function createDevHandler(ctx, hub) {
|
|
|
6516
7036
|
const resolved = resolveCleanUrl(ctx.config.outDir, pathname);
|
|
6517
7037
|
if (resolved.file === null) return new Response("Not Found", { status: 404 });
|
|
6518
7038
|
const response = ctx.state.fileResponse(resolved.file, resolved.status);
|
|
6519
|
-
if (ctx.config.liveReload && resolved.file.endsWith(".html"))
|
|
6520
|
-
const html = injectReloadClient(await response.text());
|
|
6521
|
-
return new Response(html, {
|
|
6522
|
-
status: resolved.status,
|
|
6523
|
-
headers: { "content-type": "text/html; charset=utf-8" }
|
|
6524
|
-
});
|
|
6525
|
-
}
|
|
7039
|
+
if (ctx.config.liveReload && resolved.file.endsWith(".html")) return injectReloadResponse(response, resolved.status);
|
|
6526
7040
|
return response;
|
|
6527
7041
|
};
|
|
6528
7042
|
}
|
|
@@ -6543,8 +7057,14 @@ async function runDevServer(ctx, port) {
|
|
|
6543
7057
|
const hub = createReloadHub();
|
|
6544
7058
|
const server = ctx.state.serveStatic({
|
|
6545
7059
|
port,
|
|
7060
|
+
idleTimeout: 0,
|
|
6546
7061
|
fetch: createDevHandler(ctx, hub)
|
|
6547
7062
|
});
|
|
7063
|
+
const gate = createChangeGate({
|
|
7064
|
+
outDir: ctx.config.outDir,
|
|
7065
|
+
fileMtime: ctx.state.fileMtime,
|
|
7066
|
+
now: ctx.state.clock
|
|
7067
|
+
});
|
|
6548
7068
|
const rebuilder = createRebuilder({
|
|
6549
7069
|
debounceMs: ctx.config.debounceMs,
|
|
6550
7070
|
/**
|
|
@@ -6558,6 +7078,17 @@ async function runDevServer(ctx, port) {
|
|
|
6558
7078
|
return ctx.require(buildPlugin).run();
|
|
6559
7079
|
},
|
|
6560
7080
|
/**
|
|
7081
|
+
* Show the compact in-place "rebuilding {label}" line before the build runs.
|
|
7082
|
+
*
|
|
7083
|
+
* @param file - The changed watch target shown in the line.
|
|
7084
|
+
* @example
|
|
7085
|
+
* onRebuildStart("content");
|
|
7086
|
+
*/
|
|
7087
|
+
onRebuildStart(file) {
|
|
7088
|
+
gate.markBuildStart();
|
|
7089
|
+
ctx.state.render.rebuildStart(file);
|
|
7090
|
+
},
|
|
7091
|
+
/**
|
|
6561
7092
|
* Render the reload line and push a browser reload after a rebuild.
|
|
6562
7093
|
*
|
|
6563
7094
|
* @param info - The changed file plus the rebuild's page count and duration.
|
|
@@ -6579,7 +7110,9 @@ async function runDevServer(ctx, port) {
|
|
|
6579
7110
|
ctx.state.render.error("rebuild failed", error);
|
|
6580
7111
|
}
|
|
6581
7112
|
});
|
|
6582
|
-
const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, () =>
|
|
7113
|
+
const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, (filename) => {
|
|
7114
|
+
if (gate.accept(dir, filename)) rebuilder.schedule(dir);
|
|
7115
|
+
}));
|
|
6583
7116
|
ctx.state.render.serverReady({
|
|
6584
7117
|
local: `http://localhost:${port}`,
|
|
6585
7118
|
network: ctx.state.networkUrl(port),
|
|
@@ -6588,6 +7121,7 @@ async function runDevServer(ctx, port) {
|
|
|
6588
7121
|
return installSignalTeardown(() => {
|
|
6589
7122
|
rebuilder.cancel();
|
|
6590
7123
|
for (const watcher of watchers) watcher.close();
|
|
7124
|
+
hub.close();
|
|
6591
7125
|
server.stop();
|
|
6592
7126
|
});
|
|
6593
7127
|
}
|
|
@@ -6740,6 +7274,56 @@ function validateConfig(config) {
|
|
|
6740
7274
|
if (typeof config.debounceMs !== "number" || config.debounceMs < 0) throw cliError("ERR_CLI_CONFIG", `${ERROR_PREFIX$3}: debounceMs must be a number >= 0.\n Set pluginConfigs.cli.debounceMs to the rebuild debounce window in milliseconds (e.g. 150).`);
|
|
6741
7275
|
}
|
|
6742
7276
|
/**
|
|
7277
|
+
* Assert the SSG emitted the not-found page, rendering a hint and throwing
|
|
7278
|
+
* `ERR_CLI_NOT_FOUND` when it is missing (CF Pages flips to SPA mode without a
|
|
7279
|
+
* top-level 404). A no-op when the page exists.
|
|
7280
|
+
*
|
|
7281
|
+
* @param ctx - Plugin context (provides `state.render` for the failure hint).
|
|
7282
|
+
* @param page - The absolute path the not-found page is expected at.
|
|
7283
|
+
* @throws {Error} `ERR_CLI_NOT_FOUND` when the not-found page is missing.
|
|
7284
|
+
* @example
|
|
7285
|
+
* assertNotFoundPage(ctx, path.join(ctx.config.outDir, ctx.config.notFoundFile));
|
|
7286
|
+
*/
|
|
7287
|
+
function assertNotFoundPage(ctx, page) {
|
|
7288
|
+
if ((0, node_fs.existsSync)(page)) return;
|
|
7289
|
+
ctx.state.render.error(`${page} missing — set build.notFound (CF Pages would flip to SPA mode)`);
|
|
7290
|
+
throw cliError("ERR_CLI_NOT_FOUND", `${ERROR_PREFIX$3}: ${page} missing after build.\n Set build.notFound so the SSG emits it (CF Pages flips to SPA mode without a top-level 404), or pass { assertNotFound: false } to skip this check.`);
|
|
7291
|
+
}
|
|
7292
|
+
/**
|
|
7293
|
+
* Whether the deploy confirmation prompt should be shown to a human. True only on
|
|
7294
|
+
* an interactive TTY with `CI` unset and when the caller has not passed `yes`; CI
|
|
7295
|
+
* and non-TTY runs are not prompted so consumer scripts never block a pipeline.
|
|
7296
|
+
*
|
|
7297
|
+
* @param yes - The caller's `yes` flag (forces the prompt to be skipped anywhere).
|
|
7298
|
+
* @returns `true` when a confirmation prompt should be shown, otherwise `false`.
|
|
7299
|
+
* @example
|
|
7300
|
+
* if (shouldPromptDeploy(false)) { ... }
|
|
7301
|
+
*/
|
|
7302
|
+
function shouldPromptDeploy(yes) {
|
|
7303
|
+
return process.stdout.isTTY === true && process.env.CI === void 0 && !yes;
|
|
7304
|
+
}
|
|
7305
|
+
/**
|
|
7306
|
+
* Resolve whether a deploy may proceed, handling the human/non-interactive split:
|
|
7307
|
+
* prompts an interactive TTY user (rendering the "skipped" warning on a "no"),
|
|
7308
|
+
* renders the non-interactive notice when no prompt and no `yes`, and otherwise
|
|
7309
|
+
* proceeds silently. Returns whether the caller should run the deploy.
|
|
7310
|
+
*
|
|
7311
|
+
* @param ctx - Plugin context (provides `state.confirm` and `state.render`).
|
|
7312
|
+
* @param yes - The caller's `yes` flag (forces the skip anywhere).
|
|
7313
|
+
* @returns `true` when the deploy should run, `false` when an interactive user declined.
|
|
7314
|
+
* @example
|
|
7315
|
+
* if (!(await confirmDeploy(ctx, false))) return { deployed: false, reason: "declined" };
|
|
7316
|
+
*/
|
|
7317
|
+
async function confirmDeploy(ctx, yes) {
|
|
7318
|
+
if (!shouldPromptDeploy(yes)) {
|
|
7319
|
+
if (!yes) ctx.state.render.info("non-interactive — skipping deploy confirmation");
|
|
7320
|
+
return true;
|
|
7321
|
+
}
|
|
7322
|
+
const confirmed = await ctx.state.confirm(`Deploy ${ctx.config.outDir}/ to cloudflare-pages?`);
|
|
7323
|
+
if (!confirmed) ctx.state.render.warn("deploy skipped");
|
|
7324
|
+
return confirmed;
|
|
7325
|
+
}
|
|
7326
|
+
/**
|
|
6743
7327
|
* Create the cli plugin API surface — exactly `build`, `serve`, `preview`, `deploy`.
|
|
6744
7328
|
* Each method renders `state.render.header(<command>)` first, then does its work;
|
|
6745
7329
|
* live progress is rendered by the hooks wired in `index.ts`, so each method's
|
|
@@ -6766,11 +7350,7 @@ function createApi$1(ctx) {
|
|
|
6766
7350
|
const { assertNotFound = true } = options;
|
|
6767
7351
|
ctx.state.render.header("build");
|
|
6768
7352
|
const result = await ctx.require(buildPlugin).run();
|
|
6769
|
-
|
|
6770
|
-
if (assertNotFound && !(0, node_fs.existsSync)(page)) {
|
|
6771
|
-
ctx.state.render.error(`${page} missing — set build.notFound (CF Pages would flip to SPA mode)`);
|
|
6772
|
-
throw cliError("ERR_CLI_NOT_FOUND", `${ERROR_PREFIX$3}: ${page} missing after build.\n Set build.notFound so the SSG emits it (CF Pages flips to SPA mode without a top-level 404), or pass { assertNotFound: false } to skip this check.`);
|
|
6773
|
-
}
|
|
7353
|
+
if (assertNotFound) assertNotFoundPage(ctx, node_path$1.default.join(ctx.config.outDir, ctx.config.notFoundFile));
|
|
6774
7354
|
return result;
|
|
6775
7355
|
},
|
|
6776
7356
|
/**
|
|
@@ -6801,30 +7381,27 @@ function createApi$1(ctx) {
|
|
|
6801
7381
|
return runPreviewServer(ctx, port);
|
|
6802
7382
|
},
|
|
6803
7383
|
/**
|
|
6804
|
-
*
|
|
6805
|
-
*
|
|
6806
|
-
*
|
|
6807
|
-
* `
|
|
6808
|
-
*
|
|
7384
|
+
* Deploy to Cloudflare Pages. Two modes: `{ guided: true }` runs the interactive
|
|
7385
|
+
* setup wizard (diagnose prerequisites, guide fixes, gate, local test, deploy, offer
|
|
7386
|
+
* a CI workflow); the default direct path scaffolds, gates on a y/N confirm (shown
|
|
7387
|
+
* only to an interactive TTY with `CI` unset — non-interactive runs proceed so a
|
|
7388
|
+
* pipeline never hangs), then deploys. `options.yes` forces the skip. A "no" returns
|
|
7389
|
+
* `{ deployed: false, reason: "declined" }`; the wizard may return `"blocked"`.
|
|
6809
7390
|
*
|
|
6810
|
-
* @param options - Optional branch override and `
|
|
6811
|
-
* @returns The deploy outcome (completed details, or `declined`
|
|
7391
|
+
* @param options - Optional branch override, `yes` flag, and `guided` toggle.
|
|
7392
|
+
* @returns The deploy outcome (completed details, or a `declined`/`blocked` skip).
|
|
6812
7393
|
* @example
|
|
6813
|
-
* await api.deploy({
|
|
7394
|
+
* await api.deploy({ guided: true });
|
|
6814
7395
|
*/
|
|
6815
7396
|
async deploy(options = {}) {
|
|
6816
7397
|
const { branch, yes = false } = options;
|
|
6817
7398
|
ctx.state.render.header("deploy");
|
|
7399
|
+
if (options.guided === true) return runDeployWizard(ctx, options);
|
|
6818
7400
|
await ctx.require(deployPlugin).init({ ci: true });
|
|
6819
|
-
if (
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
|
|
6823
|
-
deployed: false,
|
|
6824
|
-
reason: "declined"
|
|
6825
|
-
};
|
|
6826
|
-
}
|
|
6827
|
-
} else if (!yes) ctx.state.render.info("non-interactive — skipping deploy confirmation");
|
|
7401
|
+
if (!await confirmDeploy(ctx, yes)) return {
|
|
7402
|
+
deployed: false,
|
|
7403
|
+
reason: "declined"
|
|
7404
|
+
};
|
|
6828
7405
|
return {
|
|
6829
7406
|
deployed: true,
|
|
6830
7407
|
...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
|
|
@@ -6919,6 +7496,39 @@ const ANSI = {
|
|
|
6919
7496
|
cyan: `${ESC}[36m`,
|
|
6920
7497
|
gray: `${ESC}[90m`
|
|
6921
7498
|
};
|
|
7499
|
+
/** ANSI: erase the entire current line, leaving the cursor where it is. */
|
|
7500
|
+
const CLEAR_LINE = `${ESC}[2K`;
|
|
7501
|
+
/** ANSI: erase from the cursor to the end of the screen (drops stale trailing rows). */
|
|
7502
|
+
const CLEAR_BELOW = `${ESC}[0J`;
|
|
7503
|
+
/**
|
|
7504
|
+
* Braille spinner frames for live "working…" indicators on a TTY (advance one per tick).
|
|
7505
|
+
* Off a TTY the Panel never animates, so this is unused in plain/CI output.
|
|
7506
|
+
*/
|
|
7507
|
+
const SPINNER_FRAMES = [
|
|
7508
|
+
"⠋",
|
|
7509
|
+
"⠙",
|
|
7510
|
+
"⠹",
|
|
7511
|
+
"⠸",
|
|
7512
|
+
"⠼",
|
|
7513
|
+
"⠴",
|
|
7514
|
+
"⠦",
|
|
7515
|
+
"⠧",
|
|
7516
|
+
"⠇",
|
|
7517
|
+
"⠏"
|
|
7518
|
+
];
|
|
7519
|
+
/**
|
|
7520
|
+
* The ANSI sequence to move the cursor up `n` lines (empty string for `n <= 0`). The
|
|
7521
|
+
* Panel uses it to repaint a live block in place — move up over the previous draw, then
|
|
7522
|
+
* rewrite each row — so progress updates a fixed region instead of scrolling new lines.
|
|
7523
|
+
*
|
|
7524
|
+
* @param n - Number of lines to move the cursor up.
|
|
7525
|
+
* @returns The cursor-up escape sequence, or `""` when `n <= 0`.
|
|
7526
|
+
* @example
|
|
7527
|
+
* cursorUp(3); // "\x1b[3A"
|
|
7528
|
+
*/
|
|
7529
|
+
function cursorUp(n) {
|
|
7530
|
+
return n > 0 ? `${ESC}[${n}A` : "";
|
|
7531
|
+
}
|
|
6922
7532
|
/** Unicode rounded box glyphs used when output is a color-capable TTY. */
|
|
6923
7533
|
const UNICODE_BOX = {
|
|
6924
7534
|
topLeft: "╭",
|
|
@@ -7135,8 +7745,97 @@ function durationSuffix(palette, durationMs) {
|
|
|
7135
7745
|
function createPanelRenderer(options = {}) {
|
|
7136
7746
|
const write = options.write ?? ((line) => console.log(line));
|
|
7137
7747
|
const writeError = options.writeError ?? ((line) => console.error(line));
|
|
7748
|
+
const writeRaw = options.writeRaw ?? ((chunk) => {
|
|
7749
|
+
process.stdout.write(chunk);
|
|
7750
|
+
});
|
|
7751
|
+
const now = options.now ?? Date.now;
|
|
7138
7752
|
const color = options.color ?? supportsColor();
|
|
7139
7753
|
const palette = makePalette(color);
|
|
7754
|
+
let phaseRows = [];
|
|
7755
|
+
let phaseDrawn = 0;
|
|
7756
|
+
let phaseOpen = false;
|
|
7757
|
+
let rebuilding = false;
|
|
7758
|
+
let rebuildLabel = "";
|
|
7759
|
+
let rebuildStartedAt = 0;
|
|
7760
|
+
let spinnerFrame = 0;
|
|
7761
|
+
let ticker;
|
|
7762
|
+
/**
|
|
7763
|
+
* The current spinner glyph (with a static fallback under `noUncheckedIndexedAccess`).
|
|
7764
|
+
*
|
|
7765
|
+
* @returns The active braille spinner frame.
|
|
7766
|
+
* @example
|
|
7767
|
+
* frameGlyph(); // "⠙"
|
|
7768
|
+
*/
|
|
7769
|
+
const frameGlyph = () => SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length] ?? "⠋";
|
|
7770
|
+
/**
|
|
7771
|
+
* Render one phase row: a green `✓ name · time` when done, else a spinning cyan glyph
|
|
7772
|
+
* before the dim name.
|
|
7773
|
+
*
|
|
7774
|
+
* @param row - The phase row to render.
|
|
7775
|
+
* @returns The rendered row line (no trailing newline).
|
|
7776
|
+
* @example
|
|
7777
|
+
* renderPhaseRow({ name: "pages", done: true, durationMs: 12 });
|
|
7778
|
+
*/
|
|
7779
|
+
const renderPhaseRow = (row) => {
|
|
7780
|
+
if (row.done) return ` ${palette.green("✓")} ${row.name}${durationSuffix(palette, row.durationMs)}`;
|
|
7781
|
+
return ` ${palette.cyan(frameGlyph())} ${palette.dim(row.name)}`;
|
|
7782
|
+
};
|
|
7783
|
+
/**
|
|
7784
|
+
* Repaint the live phase block in place: move up over the prior draw, then rewrite each
|
|
7785
|
+
* row (clearing any stale trailing lines).
|
|
7786
|
+
*
|
|
7787
|
+
* @example
|
|
7788
|
+
* paintPhaseBlock();
|
|
7789
|
+
*/
|
|
7790
|
+
const paintPhaseBlock = () => {
|
|
7791
|
+
let frame = cursorUp(phaseDrawn);
|
|
7792
|
+
for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
|
|
7793
|
+
writeRaw(frame + CLEAR_BELOW);
|
|
7794
|
+
phaseDrawn = phaseRows.length;
|
|
7795
|
+
};
|
|
7796
|
+
/**
|
|
7797
|
+
* Repaint the single in-place rebuild line (spinner + label + live elapsed seconds).
|
|
7798
|
+
*
|
|
7799
|
+
* @example
|
|
7800
|
+
* paintRebuildLine();
|
|
7801
|
+
*/
|
|
7802
|
+
const paintRebuildLine = () => {
|
|
7803
|
+
const elapsed = ((now() - rebuildStartedAt) / 1e3).toFixed(1);
|
|
7804
|
+
const meta = palette.dim(`· ${elapsed}s`);
|
|
7805
|
+
writeRaw(`\r${CLEAR_LINE} ${palette.cyan(frameGlyph())} rebuilding ${rebuildLabel} ${meta}`);
|
|
7806
|
+
};
|
|
7807
|
+
/**
|
|
7808
|
+
* Advance the spinner one frame and repaint whichever live region is active.
|
|
7809
|
+
*
|
|
7810
|
+
* @example
|
|
7811
|
+
* onTick();
|
|
7812
|
+
*/
|
|
7813
|
+
const onTick = () => {
|
|
7814
|
+
spinnerFrame += 1;
|
|
7815
|
+
if (rebuilding) paintRebuildLine();
|
|
7816
|
+
else if (phaseOpen) paintPhaseBlock();
|
|
7817
|
+
};
|
|
7818
|
+
/**
|
|
7819
|
+
* Start the animation ticker (TTY only; idempotent; `unref`'d so it never blocks exit).
|
|
7820
|
+
*
|
|
7821
|
+
* @example
|
|
7822
|
+
* startTicker();
|
|
7823
|
+
*/
|
|
7824
|
+
const startTicker = () => {
|
|
7825
|
+
if (!color || ticker) return;
|
|
7826
|
+
ticker = setInterval(onTick, 80);
|
|
7827
|
+
ticker.unref?.();
|
|
7828
|
+
};
|
|
7829
|
+
/**
|
|
7830
|
+
* Stop the animation ticker if running.
|
|
7831
|
+
*
|
|
7832
|
+
* @example
|
|
7833
|
+
* stopTicker();
|
|
7834
|
+
*/
|
|
7835
|
+
const stopTicker = () => {
|
|
7836
|
+
if (ticker) clearInterval(ticker);
|
|
7837
|
+
ticker = void 0;
|
|
7838
|
+
};
|
|
7140
7839
|
/**
|
|
7141
7840
|
* Write each line of a multi-line block through the stdout sink.
|
|
7142
7841
|
*
|
|
@@ -7159,15 +7858,38 @@ function createPanelRenderer(options = {}) {
|
|
|
7159
7858
|
writeBlock(box([`${palette.bold(palette.cyan("MOKU WEB"))} ${palette.dim(COMMAND_LABEL[command])}`], color));
|
|
7160
7859
|
},
|
|
7161
7860
|
/**
|
|
7162
|
-
* Render a
|
|
7861
|
+
* Render a per-phase row from a `build:phase` event. On a TTY each phase is ONE row
|
|
7862
|
+
* that updates in place (spinning glyph while running → green ✓ + duration when done);
|
|
7863
|
+
* off a TTY one line is printed per completed phase (no start/done duplication). A
|
|
7864
|
+
* no-op while a serve() rebuild is in flight — those show the compact rebuild line.
|
|
7163
7865
|
*
|
|
7164
7866
|
* @param phase - The `build:phase` payload.
|
|
7165
7867
|
* @example
|
|
7166
7868
|
* render.phase({ phase: "pages", status: "done", durationMs: 12 });
|
|
7167
7869
|
*/
|
|
7168
7870
|
phase(phase) {
|
|
7871
|
+
if (rebuilding) return;
|
|
7872
|
+
if (!color) {
|
|
7873
|
+
if (phase.status === "done") write(` ${palette.green("✓")} ${phase.phase}${durationSuffix(palette, phase.durationMs)}`);
|
|
7874
|
+
return;
|
|
7875
|
+
}
|
|
7876
|
+
if (!phaseOpen) {
|
|
7877
|
+
phaseRows = [];
|
|
7878
|
+
phaseDrawn = 0;
|
|
7879
|
+
phaseOpen = true;
|
|
7880
|
+
}
|
|
7169
7881
|
const done = phase.status === "done";
|
|
7170
|
-
|
|
7882
|
+
const existing = phaseRows.find((row) => row.name === phase.phase);
|
|
7883
|
+
if (existing) {
|
|
7884
|
+
existing.done = done;
|
|
7885
|
+
existing.durationMs = phase.durationMs;
|
|
7886
|
+
} else phaseRows.push({
|
|
7887
|
+
name: phase.phase,
|
|
7888
|
+
done,
|
|
7889
|
+
durationMs: phase.durationMs
|
|
7890
|
+
});
|
|
7891
|
+
paintPhaseBlock();
|
|
7892
|
+
startTicker();
|
|
7171
7893
|
},
|
|
7172
7894
|
/**
|
|
7173
7895
|
* Render the BUILD summary block from a `build:complete` event.
|
|
@@ -7177,6 +7899,10 @@ function createPanelRenderer(options = {}) {
|
|
|
7177
7899
|
* render.built({ outDir: "dist", pageCount: 12, durationMs: 840 });
|
|
7178
7900
|
*/
|
|
7179
7901
|
built(summary) {
|
|
7902
|
+
if (rebuilding) return;
|
|
7903
|
+
phaseOpen = false;
|
|
7904
|
+
phaseDrawn = 0;
|
|
7905
|
+
stopTicker();
|
|
7180
7906
|
const pages = palette.bold(String(summary.pageCount));
|
|
7181
7907
|
writeBlock(box([
|
|
7182
7908
|
`${palette.green("✓")} ${palette.bold("BUILD")} complete`,
|
|
@@ -7198,15 +7924,47 @@ function createPanelRenderer(options = {}) {
|
|
|
7198
7924
|
writeBlock(box(lines, color));
|
|
7199
7925
|
},
|
|
7200
7926
|
/**
|
|
7201
|
-
*
|
|
7927
|
+
* Begin a serve() rebuild: show ONE compact "rebuilding {label}" line (an animated
|
|
7928
|
+
* spinner with live elapsed on a TTY; a plain "~ {label}" line otherwise) and mute
|
|
7929
|
+
* the verbose phase rows + BUILD box until {@link reload}/{@link error} settles it.
|
|
7930
|
+
*
|
|
7931
|
+
* @param label - The changed watch target shown in the line.
|
|
7932
|
+
* @example
|
|
7933
|
+
* render.rebuildStart("content");
|
|
7934
|
+
*/
|
|
7935
|
+
rebuildStart(label) {
|
|
7936
|
+
rebuilding = true;
|
|
7937
|
+
rebuildLabel = label;
|
|
7938
|
+
rebuildStartedAt = now();
|
|
7939
|
+
spinnerFrame = 0;
|
|
7940
|
+
if (!color) {
|
|
7941
|
+
write(` ${palette.yellow("~")} ${label}`);
|
|
7942
|
+
return;
|
|
7943
|
+
}
|
|
7944
|
+
paintRebuildLine();
|
|
7945
|
+
startTicker();
|
|
7946
|
+
},
|
|
7947
|
+
/**
|
|
7948
|
+
* Settle the current rebuild: replace the in-place "rebuilding…" line with a compact
|
|
7949
|
+
* "✓ rebuilt N pages · Xms · reloaded" (on a TTY) and re-enable verbose build output.
|
|
7950
|
+
* Called standalone (no preceding {@link rebuildStart}) it also prints the "~ file"
|
|
7951
|
+
* line so the changed target stays visible.
|
|
7202
7952
|
*
|
|
7203
7953
|
* @param info - The changed file plus the rebuild's page count and duration.
|
|
7204
7954
|
* @example
|
|
7205
7955
|
* render.reload({ file: "content/a.md", pageCount: 12, durationMs: 84 });
|
|
7206
7956
|
*/
|
|
7207
7957
|
reload(info) {
|
|
7208
|
-
|
|
7209
|
-
|
|
7958
|
+
const settledRebuild = rebuilding;
|
|
7959
|
+
rebuilding = false;
|
|
7960
|
+
stopTicker();
|
|
7961
|
+
const line = ` ${palette.green("✓")} rebuilt ${palette.bold(String(info.pageCount))} pages ${palette.dim(`· ${info.durationMs}ms · reloaded`)}`;
|
|
7962
|
+
if (settledRebuild && color) {
|
|
7963
|
+
writeRaw(`\r${CLEAR_LINE}${line}\n`);
|
|
7964
|
+
return;
|
|
7965
|
+
}
|
|
7966
|
+
if (!settledRebuild) write(` ${palette.yellow("~")} ${info.file}`);
|
|
7967
|
+
write(line);
|
|
7210
7968
|
},
|
|
7211
7969
|
/**
|
|
7212
7970
|
* Render the deploy result panel from a `deploy:complete` event.
|
|
@@ -7232,7 +7990,9 @@ function createPanelRenderer(options = {}) {
|
|
|
7232
7990
|
* render.info("watching for changes…");
|
|
7233
7991
|
*/
|
|
7234
7992
|
info(message) {
|
|
7235
|
-
|
|
7993
|
+
const [first = "", ...rest] = message.split("\n");
|
|
7994
|
+
write(` ${palette.cyan("›")} ${first}`);
|
|
7995
|
+
for (const line of rest) write(` ${line}`);
|
|
7236
7996
|
},
|
|
7237
7997
|
/**
|
|
7238
7998
|
* Render a warning line (to stderr).
|
|
@@ -7253,8 +8013,38 @@ function createPanelRenderer(options = {}) {
|
|
|
7253
8013
|
* render.error("build failed", err);
|
|
7254
8014
|
*/
|
|
7255
8015
|
error(message, cause) {
|
|
8016
|
+
if (rebuilding) {
|
|
8017
|
+
rebuilding = false;
|
|
8018
|
+
stopTicker();
|
|
8019
|
+
if (color) writeRaw(`\r${CLEAR_LINE}`);
|
|
8020
|
+
}
|
|
7256
8021
|
writeError(` ${palette.red("✗")} ${message}`);
|
|
7257
8022
|
if (cause !== void 0) writeError(String(cause));
|
|
8023
|
+
},
|
|
8024
|
+
/**
|
|
8025
|
+
* Render a section heading (a blank line + a bold cyan label) for a multi-step flow.
|
|
8026
|
+
*
|
|
8027
|
+
* @param text - The heading label.
|
|
8028
|
+
* @example
|
|
8029
|
+
* render.heading("Diagnostics");
|
|
8030
|
+
*/
|
|
8031
|
+
heading(text) {
|
|
8032
|
+
write("");
|
|
8033
|
+
write(` ${palette.bold(palette.cyan(text))}`);
|
|
8034
|
+
},
|
|
8035
|
+
/**
|
|
8036
|
+
* Render a diagnostic line: green `✓` (pass) or red `✗` (fail) + label, with optional
|
|
8037
|
+
* dim, indented detail beneath (e.g. a fix hint for a failing check).
|
|
8038
|
+
*
|
|
8039
|
+
* @param ok - Whether the check passed.
|
|
8040
|
+
* @param label - The check label.
|
|
8041
|
+
* @param detail - Optional multi-line guidance shown indented under the line.
|
|
8042
|
+
* @example
|
|
8043
|
+
* render.check(false, "CLOUDFLARE_API_TOKEN is set", "Create one at …");
|
|
8044
|
+
*/
|
|
8045
|
+
check(ok, label, detail) {
|
|
8046
|
+
write(` ${ok ? palette.green("✓") : palette.red("✗")} ${label}`);
|
|
8047
|
+
if (detail !== void 0) for (const line of detail.split("\n")) write(` ${palette.dim(line)}`);
|
|
7258
8048
|
}
|
|
7259
8049
|
};
|
|
7260
8050
|
}
|
|
@@ -7333,18 +8123,44 @@ function defaultConfirm(question) {
|
|
|
7333
8123
|
});
|
|
7334
8124
|
}
|
|
7335
8125
|
/**
|
|
8126
|
+
* Default stdin single-choice prompt. Prints the choices numbered from 1, reads a line
|
|
8127
|
+
* via `node:readline`, and resolves the chosen zero-based index (empty / out-of-range
|
|
8128
|
+
* falls back to 0). Tests inject a canned selection so no real TTY interaction occurs.
|
|
8129
|
+
*
|
|
8130
|
+
* @param question - The prompt to display.
|
|
8131
|
+
* @param choices - The selectable option labels.
|
|
8132
|
+
* @returns Resolves the chosen zero-based index.
|
|
8133
|
+
* @example
|
|
8134
|
+
* await defaultSelect("Trigger?", ["Auto on push", "Manual only"]);
|
|
8135
|
+
*/
|
|
8136
|
+
function defaultSelect(question, choices) {
|
|
8137
|
+
return new Promise((resolve) => {
|
|
8138
|
+
const readline = (0, node_readline.createInterface)({
|
|
8139
|
+
input: process.stdin,
|
|
8140
|
+
output: process.stdout
|
|
8141
|
+
});
|
|
8142
|
+
for (const [index, choice] of choices.entries()) console.log(` ${index + 1}) ${choice}`);
|
|
8143
|
+
readline.question(`${question} [1-${choices.length}] `, (answer) => {
|
|
8144
|
+
readline.close();
|
|
8145
|
+
const picked = Number.parseInt(answer.trim(), 10);
|
|
8146
|
+
resolve(Number.isInteger(picked) && picked >= 1 && picked <= choices.length ? picked - 1 : 0);
|
|
8147
|
+
});
|
|
8148
|
+
});
|
|
8149
|
+
}
|
|
8150
|
+
/**
|
|
7336
8151
|
* Default recursive directory watcher — wraps `node:fs.watch` with `{ recursive: true }`
|
|
7337
8152
|
* and adapts its handle to {@link WatchHandle}. Tests inject a fake emitter so no real
|
|
7338
8153
|
* FS watch is registered.
|
|
7339
8154
|
*
|
|
7340
8155
|
* @param dir - The directory to watch recursively.
|
|
7341
|
-
* @param onChange - Invoked on any change beneath `dir
|
|
8156
|
+
* @param onChange - Invoked on any change beneath `dir`, forwarding the changed path
|
|
8157
|
+
* relative to `dir` when `node:fs.watch` reports it (`undefined` otherwise).
|
|
7342
8158
|
* @returns A handle whose `close()` detaches the watcher.
|
|
7343
8159
|
* @example
|
|
7344
|
-
* const handle = defaultWatch("content",
|
|
8160
|
+
* const handle = defaultWatch("content", file => rebuild(file));
|
|
7345
8161
|
*/
|
|
7346
8162
|
function defaultWatch(dir, onChange) {
|
|
7347
|
-
const watcher = (0, node_fs.watch)(dir, { recursive: true }, () => onChange());
|
|
8163
|
+
const watcher = (0, node_fs.watch)(dir, { recursive: true }, (_event, filename) => onChange(typeof filename === "string" ? filename : void 0));
|
|
7348
8164
|
return {
|
|
7349
8165
|
/**
|
|
7350
8166
|
* Detach the underlying `node:fs.watch` listener.
|
|
@@ -7357,6 +8173,23 @@ close() {
|
|
|
7357
8173
|
} };
|
|
7358
8174
|
}
|
|
7359
8175
|
/**
|
|
8176
|
+
* Default file-mtime probe — `node:fs.statSync(path).mtimeMs`, returning `null` for a
|
|
8177
|
+
* missing path (so a deleted file still reads as a change). serve() compares this
|
|
8178
|
+
* across `fs.watch` events to drop the duplicate notifications macOS fires per save.
|
|
8179
|
+
*
|
|
8180
|
+
* @param filePath - The absolute path to stat.
|
|
8181
|
+
* @returns The modification time in epoch milliseconds, or `null` when absent.
|
|
8182
|
+
* @example
|
|
8183
|
+
* const mtime = defaultFileMtime("/abs/content/a.md");
|
|
8184
|
+
*/
|
|
8185
|
+
function defaultFileMtime(filePath) {
|
|
8186
|
+
try {
|
|
8187
|
+
return (0, node_fs.statSync)(filePath).mtimeMs;
|
|
8188
|
+
} catch {
|
|
8189
|
+
return null;
|
|
8190
|
+
}
|
|
8191
|
+
}
|
|
8192
|
+
/**
|
|
7360
8193
|
* Default LAN network-URL deriver — wraps {@link networkUrl} so the production seam
|
|
7361
8194
|
* reads `node:os` interfaces while tests can inject a deterministic value.
|
|
7362
8195
|
*
|
|
@@ -7384,11 +8217,13 @@ function createState$1(_ctx) {
|
|
|
7384
8217
|
return {
|
|
7385
8218
|
render: createPanelRenderer(),
|
|
7386
8219
|
confirm: defaultConfirm,
|
|
8220
|
+
select: defaultSelect,
|
|
7387
8221
|
clock: Date.now,
|
|
7388
8222
|
watch: defaultWatch,
|
|
7389
8223
|
serveStatic: defaultServeStatic,
|
|
7390
8224
|
fileResponse: defaultFileResponse,
|
|
7391
|
-
networkUrl: defaultNetworkUrl
|
|
8225
|
+
networkUrl: defaultNetworkUrl,
|
|
8226
|
+
fileMtime: defaultFileMtime
|
|
7392
8227
|
};
|
|
7393
8228
|
}
|
|
7394
8229
|
//#endregion
|
|
@@ -7515,6 +8350,22 @@ const ERROR_PREFIX$2 = "[web]";
|
|
|
7515
8350
|
/** The set of legal hook names, frozen for O(1) membership checks. */
|
|
7516
8351
|
const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
|
|
7517
8352
|
/**
|
|
8353
|
+
* Validate a single hook entry: its key must be a known hook name and its value
|
|
8354
|
+
* must be a function. Throws fail-fast on the first violation.
|
|
8355
|
+
*
|
|
8356
|
+
* @param componentName - The owning component name (for error messages).
|
|
8357
|
+
* @param hooks - The hooks object being validated.
|
|
8358
|
+
* @param key - The hook key to validate.
|
|
8359
|
+
* @throws {Error} If `key` is not in `COMPONENT_HOOK_NAMES`.
|
|
8360
|
+
* @throws {TypeError} If the hook value is not a function.
|
|
8361
|
+
* @example
|
|
8362
|
+
* validateHookEntry("counter", hooks, "onMount");
|
|
8363
|
+
*/
|
|
8364
|
+
function validateHookEntry(componentName, hooks, key) {
|
|
8365
|
+
if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown component hook "${key}" on "${componentName}"\n → valid hooks: ${COMPONENT_HOOK_NAMES.join(", ")}`);
|
|
8366
|
+
if (typeof hooks[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} component hook "${key}" on "${componentName}" must be a function\n → provide a function or omit the hook`);
|
|
8367
|
+
}
|
|
8368
|
+
/**
|
|
7518
8369
|
* Create a validated component definition. Validates hook names at registration
|
|
7519
8370
|
* for fail-fast typo detection (e.g. `onMout` throws immediately) and asserts
|
|
7520
8371
|
* each provided hook is a function.
|
|
@@ -7531,10 +8382,7 @@ const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
|
|
|
7531
8382
|
*/
|
|
7532
8383
|
function createComponent(name, hooks) {
|
|
7533
8384
|
if (name.trim() === "") throw new Error(`${ERROR_PREFIX$2} component name must be a non-empty string\n → pass a unique name to createComponent("name", hooks)`);
|
|
7534
|
-
for (const key of Object.keys(hooks))
|
|
7535
|
-
if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown component hook "${key}" on "${name}"\n → valid hooks: ${COMPONENT_HOOK_NAMES.join(", ")}`);
|
|
7536
|
-
if (typeof hooks[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} component hook "${key}" on "${name}" must be a function\n → provide a function or omit the hook`);
|
|
7537
|
-
}
|
|
8385
|
+
for (const key of Object.keys(hooks)) validateHookEntry(name, hooks, key);
|
|
7538
8386
|
return {
|
|
7539
8387
|
name,
|
|
7540
8388
|
hooks
|
|
@@ -7604,6 +8452,36 @@ function makeContext(element, data) {
|
|
|
7604
8452
|
};
|
|
7605
8453
|
}
|
|
7606
8454
|
/**
|
|
8455
|
+
* Mounts a single `data-component` element: classifies persistent vs
|
|
8456
|
+
* page-specific, builds the instance, fires `onCreate` then `onMount`, records
|
|
8457
|
+
* it in state, and emits `spa:component-mount`. No-ops if the element is already
|
|
8458
|
+
* mounted, has no component name, or names an unregistered component.
|
|
8459
|
+
*
|
|
8460
|
+
* @param state - The plugin state (registeredComponents + instances).
|
|
8461
|
+
* @param emit - The event emitter for spa:component-mount.
|
|
8462
|
+
* @param swapArea - The swap-region element, or null when none was found.
|
|
8463
|
+
* @param data - The current page data payload.
|
|
8464
|
+
* @param element - The candidate element carrying a `data-component` attribute.
|
|
8465
|
+
* @example
|
|
8466
|
+
* mountElement(state, emit, swapArea, data, element);
|
|
8467
|
+
*/
|
|
8468
|
+
function mountElement(state, emit, swapArea, data, element) {
|
|
8469
|
+
if (state.instances.has(element)) return;
|
|
8470
|
+
const name = element.dataset.component;
|
|
8471
|
+
if (!name) return;
|
|
8472
|
+
const definition = state.registeredComponents.get(name);
|
|
8473
|
+
if (!definition) return;
|
|
8474
|
+
const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
|
|
8475
|
+
const ctx = makeContext(element, data);
|
|
8476
|
+
runHook(instance, "onCreate", ctx);
|
|
8477
|
+
runHook(instance, "onMount", ctx);
|
|
8478
|
+
state.instances.set(element, instance);
|
|
8479
|
+
emit("spa:component-mount", {
|
|
8480
|
+
name: definition.name,
|
|
8481
|
+
el: element
|
|
8482
|
+
});
|
|
8483
|
+
}
|
|
8484
|
+
/**
|
|
7607
8485
|
* Scans the swap region, mounts components for matching `data-component`
|
|
7608
8486
|
* elements, classifies persistent (outside swap area) vs page-specific (inside),
|
|
7609
8487
|
* fires `onCreate` then `onMount`, and emits `spa:component-mount` per instance.
|
|
@@ -7619,22 +8497,7 @@ function scanAndMount(state, emit, swapSelector) {
|
|
|
7619
8497
|
if (typeof document === "undefined") return;
|
|
7620
8498
|
const swapArea = document.querySelector(swapSelector);
|
|
7621
8499
|
const data = extractPageData(document);
|
|
7622
|
-
for (const element of document.querySelectorAll("[data-component]"))
|
|
7623
|
-
if (state.instances.has(element)) continue;
|
|
7624
|
-
const name = element.dataset.component;
|
|
7625
|
-
if (!name) continue;
|
|
7626
|
-
const definition = state.registeredComponents.get(name);
|
|
7627
|
-
if (!definition) continue;
|
|
7628
|
-
const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
|
|
7629
|
-
const ctx = makeContext(element, data);
|
|
7630
|
-
runHook(instance, "onCreate", ctx);
|
|
7631
|
-
runHook(instance, "onMount", ctx);
|
|
7632
|
-
state.instances.set(element, instance);
|
|
7633
|
-
emit("spa:component-mount", {
|
|
7634
|
-
name: definition.name,
|
|
7635
|
-
el: element
|
|
7636
|
-
});
|
|
7637
|
-
}
|
|
8500
|
+
for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element);
|
|
7638
8501
|
}
|
|
7639
8502
|
/**
|
|
7640
8503
|
* Unmounts page-specific instances inside the swap region (runs `onUnMount`
|
|
@@ -8105,6 +8968,8 @@ function attachRouter(handlers, navigate) {
|
|
|
8105
8968
|
//#region src/plugins/spa/state.ts
|
|
8106
8969
|
/** Error prefix for spa config-validation failures (spec/11 Part-3). */
|
|
8107
8970
|
const ERROR_PREFIX$1 = "[web]";
|
|
8971
|
+
/** Last-resort `swapSelector` when neither config nor defaults supply one. */
|
|
8972
|
+
const FALLBACK_SWAP_SELECTOR = "main > section";
|
|
8108
8973
|
/** Default SPA config (declared as a value — no inline assertion). */
|
|
8109
8974
|
const defaultSpaConfig = {
|
|
8110
8975
|
swapSelector: "main > section",
|
|
@@ -8142,7 +9007,7 @@ function isValidSelector(selector) {
|
|
|
8142
9007
|
* const resolved = resolveSpaConfig({ swapSelector: "main > section" });
|
|
8143
9008
|
*/
|
|
8144
9009
|
function resolveSpaConfig(config) {
|
|
8145
|
-
const swapSelector = config.swapSelector ?? defaultSpaConfig.swapSelector ??
|
|
9010
|
+
const swapSelector = config.swapSelector ?? defaultSpaConfig.swapSelector ?? FALLBACK_SWAP_SELECTOR;
|
|
8146
9011
|
if (swapSelector.trim() === "") throw new Error(`${ERROR_PREFIX$1} spa.swapSelector must be a non-empty string.\n Set a CSS selector for the page region to swap (e.g. "main > section").`);
|
|
8147
9012
|
if (!isValidSelector(swapSelector)) throw new Error(`${ERROR_PREFIX$1} spa.swapSelector is not a valid CSS selector: "${swapSelector}".\n Provide a syntactically valid selector.`);
|
|
8148
9013
|
return {
|
|
@@ -8861,6 +9726,35 @@ function pullQuoteTransform(tree) {
|
|
|
8861
9726
|
}
|
|
8862
9727
|
});
|
|
8863
9728
|
}
|
|
9729
|
+
/** CSS class for the divider wrapper that replaces an `<hr>`. */
|
|
9730
|
+
const SECTION_DIVIDER_CLASS = "section-divider";
|
|
9731
|
+
/** CSS class for the inner ornament span inside the section divider. */
|
|
9732
|
+
const SECTION_DIVIDER_ORNAMENT_CLASS = "section-divider-ornament";
|
|
9733
|
+
/** Glyphs rendered inside the section-divider ornament span. */
|
|
9734
|
+
const SECTION_DIVIDER_ORNAMENT = "***";
|
|
9735
|
+
/**
|
|
9736
|
+
* Rewrite one `<hr>` element in place into an ornamental section divider:
|
|
9737
|
+
* a `<div>` wrapper carrying a single ornament `<span>`.
|
|
9738
|
+
*
|
|
9739
|
+
* @param node - The hast element to rewrite (expected to be an `<hr>`).
|
|
9740
|
+
* @example
|
|
9741
|
+
* ```ts
|
|
9742
|
+
* rewriteHrToDivider(node);
|
|
9743
|
+
* ```
|
|
9744
|
+
*/
|
|
9745
|
+
function rewriteHrToDivider(node) {
|
|
9746
|
+
node.tagName = "div";
|
|
9747
|
+
node.properties = { class: SECTION_DIVIDER_CLASS };
|
|
9748
|
+
node.children = [{
|
|
9749
|
+
type: "element",
|
|
9750
|
+
tagName: "span",
|
|
9751
|
+
properties: { class: SECTION_DIVIDER_ORNAMENT_CLASS },
|
|
9752
|
+
children: [{
|
|
9753
|
+
type: "text",
|
|
9754
|
+
value: SECTION_DIVIDER_ORNAMENT
|
|
9755
|
+
}]
|
|
9756
|
+
}];
|
|
9757
|
+
}
|
|
8864
9758
|
/**
|
|
8865
9759
|
* Hast transformer rewriting `<hr>` into an ornamental section divider.
|
|
8866
9760
|
*
|
|
@@ -8872,19 +9766,8 @@ function pullQuoteTransform(tree) {
|
|
|
8872
9766
|
*/
|
|
8873
9767
|
function sectionDividerTransform(tree) {
|
|
8874
9768
|
(0, unist_util_visit.visit)(tree, "element", (node) => {
|
|
8875
|
-
if (node.tagName
|
|
8876
|
-
|
|
8877
|
-
node.properties = { class: "section-divider" };
|
|
8878
|
-
node.children = [{
|
|
8879
|
-
type: "element",
|
|
8880
|
-
tagName: "span",
|
|
8881
|
-
properties: { class: "section-divider-ornament" },
|
|
8882
|
-
children: [{
|
|
8883
|
-
type: "text",
|
|
8884
|
-
value: "***"
|
|
8885
|
-
}]
|
|
8886
|
-
}];
|
|
8887
|
-
}
|
|
9769
|
+
if (node.tagName !== "hr") return;
|
|
9770
|
+
rewriteHrToDivider(node);
|
|
8888
9771
|
});
|
|
8889
9772
|
}
|
|
8890
9773
|
/**
|