@moku-labs/web 1.0.1 → 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/dist/index.cjs +611 -34
- package/dist/index.d.cts +105 -13
- package/dist/index.d.mts +105 -13
- package/dist/index.mjs +611 -34
- package/package.json +2 -1
package/dist/index.cjs
CHANGED
|
@@ -5774,6 +5774,19 @@ const CHECKOUT_SHA = "11bd71901bbe5b1630ceea73d27597364c9af683";
|
|
|
5774
5774
|
const SETUP_BUN_SHA = "4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5";
|
|
5775
5775
|
/** Pinned `cloudflare/wrangler-action` commit SHA. */
|
|
5776
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
|
+
};
|
|
5777
5790
|
/**
|
|
5778
5791
|
* Generate a SHA-pinned GitHub Actions workflow that builds and deploys to
|
|
5779
5792
|
* Cloudflare Pages. Every action is pinned to a commit SHA (with a `# vX`
|
|
@@ -5783,9 +5796,10 @@ const WRANGLER_ACTION_SHA = "f84a562284fc78278ff9052435d9526f9c718361";
|
|
|
5783
5796
|
*
|
|
5784
5797
|
* @param input - The generator inputs.
|
|
5785
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"`.
|
|
5786
5800
|
* @returns The workflow YAML.
|
|
5787
5801
|
* @example
|
|
5788
|
-
* generateGithubWorkflow({ slug: "my-site" });
|
|
5802
|
+
* generateGithubWorkflow({ slug: "my-site", trigger: "versioned-tag" });
|
|
5789
5803
|
*/
|
|
5790
5804
|
function generateGithubWorkflow(input) {
|
|
5791
5805
|
return `# .github/workflows/deploy.yml — generated by \`app.deploy.init({ ci: true })\`.
|
|
@@ -5793,10 +5807,7 @@ function generateGithubWorkflow(input) {
|
|
|
5793
5807
|
|
|
5794
5808
|
name: Deploy
|
|
5795
5809
|
|
|
5796
|
-
|
|
5797
|
-
push:
|
|
5798
|
-
branches: [main]
|
|
5799
|
-
workflow_dispatch:
|
|
5810
|
+
${TRIGGER_ON_BLOCKS[input.trigger ?? "auto"]}
|
|
5800
5811
|
|
|
5801
5812
|
permissions:
|
|
5802
5813
|
contents: read
|
|
@@ -5928,7 +5939,10 @@ async function writeScaffolding(input) {
|
|
|
5928
5939
|
});
|
|
5929
5940
|
if (ci) await reconcile({
|
|
5930
5941
|
relativePath: WORKFLOW_PATH,
|
|
5931
|
-
expected: generateGithubWorkflow({
|
|
5942
|
+
expected: generateGithubWorkflow({
|
|
5943
|
+
slug,
|
|
5944
|
+
...options.workflowTrigger ? { trigger: options.workflowTrigger } : {}
|
|
5945
|
+
}),
|
|
5932
5946
|
existing: await readMaybe(cwd, WORKFLOW_PATH),
|
|
5933
5947
|
cwd,
|
|
5934
5948
|
check,
|
|
@@ -6446,6 +6460,185 @@ const deployPlugin = createPlugin$1("deploy", {
|
|
|
6446
6460
|
api: createApi$2
|
|
6447
6461
|
});
|
|
6448
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
|
|
6449
6642
|
//#region src/plugins/cli/errors.ts
|
|
6450
6643
|
/** Error prefix for cli config/validation/runtime failures (spec/11 Part-3). */
|
|
6451
6644
|
const ERROR_PREFIX$3 = "[web] cli";
|
|
@@ -6479,11 +6672,12 @@ function injectReloadClient(html) {
|
|
|
6479
6672
|
return index === -1 ? html + RELOAD_CLIENT : html.slice(0, index) + RELOAD_CLIENT + html.slice(index);
|
|
6480
6673
|
}
|
|
6481
6674
|
/**
|
|
6482
|
-
* Run one rebuild and report the result.
|
|
6483
|
-
*
|
|
6675
|
+
* Run one rebuild and report the result. Announces the start (`onRebuildStart`), then
|
|
6676
|
+
* routes success to `onReloaded` and failure to `onError`.
|
|
6484
6677
|
*
|
|
6485
6678
|
* @param input - The rebuild dependencies + the changed file.
|
|
6486
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.
|
|
6487
6681
|
* @param input.onReloaded - Called with the changed file + summary after a rebuild.
|
|
6488
6682
|
* @param input.onError - Called when a rebuild throws.
|
|
6489
6683
|
* @param input.file - The changed file to report alongside the summary.
|
|
@@ -6492,6 +6686,7 @@ function injectReloadClient(html) {
|
|
|
6492
6686
|
* await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md" });
|
|
6493
6687
|
*/
|
|
6494
6688
|
async function runOneRebuild(input) {
|
|
6689
|
+
input.onRebuildStart?.(input.file);
|
|
6495
6690
|
try {
|
|
6496
6691
|
const summary = await input.runBuild();
|
|
6497
6692
|
input.onReloaded({
|
|
@@ -6513,6 +6708,7 @@ async function runOneRebuild(input) {
|
|
|
6513
6708
|
* @param input - The rebuild dependencies.
|
|
6514
6709
|
* @param input.debounceMs - Debounce window in milliseconds.
|
|
6515
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.
|
|
6516
6712
|
* @param input.onReloaded - Called with the changed file + summary after a rebuild.
|
|
6517
6713
|
* @param input.onError - Called when a rebuild throws.
|
|
6518
6714
|
* @returns The debounced rebuild driver.
|
|
@@ -6539,6 +6735,7 @@ function createRebuilder(input) {
|
|
|
6539
6735
|
dirty = false;
|
|
6540
6736
|
await runOneRebuild({
|
|
6541
6737
|
runBuild: input.runBuild,
|
|
6738
|
+
...input.onRebuildStart ? { onRebuildStart: input.onRebuildStart } : {},
|
|
6542
6739
|
onReloaded: input.onReloaded,
|
|
6543
6740
|
onError: input.onError,
|
|
6544
6741
|
file: pendingFile
|
|
@@ -6592,6 +6789,70 @@ function createRebuilder(input) {
|
|
|
6592
6789
|
};
|
|
6593
6790
|
}
|
|
6594
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
|
+
/**
|
|
6595
6856
|
* Install SIGINT/SIGTERM handlers that run `teardown()` and resolve the returned
|
|
6596
6857
|
* promise, so a long-running command (`serve`/`preview`) unblocks its `await` on
|
|
6597
6858
|
* Ctrl-C / termination and detaches its own listeners. Used by both servers.
|
|
@@ -6623,18 +6884,45 @@ function installSignalTeardown(teardown) {
|
|
|
6623
6884
|
const SSE_OPEN = ": connected\n\n";
|
|
6624
6885
|
/** The SSE frame pushed to reload a connected browser. */
|
|
6625
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;
|
|
6626
6891
|
/**
|
|
6627
6892
|
* Create a {@link ReloadHub} backed by `ReadableStream` controllers. Each `connect()`
|
|
6628
6893
|
* enqueues into a new stream; `reloadAll()` writes the reload frame to every live
|
|
6629
|
-
* 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).
|
|
6630
6898
|
*
|
|
6899
|
+
* @param options - Optional heartbeat tuning.
|
|
6900
|
+
* @param options.heartbeatMs - Heartbeat interval in ms (`0` disables). Default `15000`.
|
|
6631
6901
|
* @returns The reload hub.
|
|
6632
6902
|
* @example
|
|
6633
6903
|
* const hub = createReloadHub();
|
|
6634
6904
|
*/
|
|
6635
|
-
function createReloadHub() {
|
|
6905
|
+
function createReloadHub(options = {}) {
|
|
6636
6906
|
const encoder = new TextEncoder();
|
|
6637
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?.();
|
|
6638
6926
|
return {
|
|
6639
6927
|
/**
|
|
6640
6928
|
* Open one SSE connection, register its controller, and return the streaming
|
|
@@ -6682,11 +6970,7 @@ function createReloadHub() {
|
|
|
6682
6970
|
* hub.reloadAll();
|
|
6683
6971
|
*/
|
|
6684
6972
|
reloadAll() {
|
|
6685
|
-
|
|
6686
|
-
controller.enqueue(encoder.encode(SSE_RELOAD));
|
|
6687
|
-
} catch {
|
|
6688
|
-
clients.delete(controller);
|
|
6689
|
-
}
|
|
6973
|
+
broadcast(SSE_RELOAD);
|
|
6690
6974
|
},
|
|
6691
6975
|
/**
|
|
6692
6976
|
* The number of currently-connected clients.
|
|
@@ -6697,6 +6981,19 @@ function createReloadHub() {
|
|
|
6697
6981
|
*/
|
|
6698
6982
|
size() {
|
|
6699
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();
|
|
6700
6997
|
}
|
|
6701
6998
|
};
|
|
6702
6999
|
}
|
|
@@ -6760,8 +7057,14 @@ async function runDevServer(ctx, port) {
|
|
|
6760
7057
|
const hub = createReloadHub();
|
|
6761
7058
|
const server = ctx.state.serveStatic({
|
|
6762
7059
|
port,
|
|
7060
|
+
idleTimeout: 0,
|
|
6763
7061
|
fetch: createDevHandler(ctx, hub)
|
|
6764
7062
|
});
|
|
7063
|
+
const gate = createChangeGate({
|
|
7064
|
+
outDir: ctx.config.outDir,
|
|
7065
|
+
fileMtime: ctx.state.fileMtime,
|
|
7066
|
+
now: ctx.state.clock
|
|
7067
|
+
});
|
|
6765
7068
|
const rebuilder = createRebuilder({
|
|
6766
7069
|
debounceMs: ctx.config.debounceMs,
|
|
6767
7070
|
/**
|
|
@@ -6775,6 +7078,17 @@ async function runDevServer(ctx, port) {
|
|
|
6775
7078
|
return ctx.require(buildPlugin).run();
|
|
6776
7079
|
},
|
|
6777
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
|
+
/**
|
|
6778
7092
|
* Render the reload line and push a browser reload after a rebuild.
|
|
6779
7093
|
*
|
|
6780
7094
|
* @param info - The changed file plus the rebuild's page count and duration.
|
|
@@ -6796,7 +7110,9 @@ async function runDevServer(ctx, port) {
|
|
|
6796
7110
|
ctx.state.render.error("rebuild failed", error);
|
|
6797
7111
|
}
|
|
6798
7112
|
});
|
|
6799
|
-
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
|
+
}));
|
|
6800
7116
|
ctx.state.render.serverReady({
|
|
6801
7117
|
local: `http://localhost:${port}`,
|
|
6802
7118
|
network: ctx.state.networkUrl(port),
|
|
@@ -6805,6 +7121,7 @@ async function runDevServer(ctx, port) {
|
|
|
6805
7121
|
return installSignalTeardown(() => {
|
|
6806
7122
|
rebuilder.cancel();
|
|
6807
7123
|
for (const watcher of watchers) watcher.close();
|
|
7124
|
+
hub.close();
|
|
6808
7125
|
server.stop();
|
|
6809
7126
|
});
|
|
6810
7127
|
}
|
|
@@ -7064,20 +7381,22 @@ function createApi$1(ctx) {
|
|
|
7064
7381
|
return runPreviewServer(ctx, port);
|
|
7065
7382
|
},
|
|
7066
7383
|
/**
|
|
7067
|
-
*
|
|
7068
|
-
*
|
|
7069
|
-
*
|
|
7070
|
-
* `
|
|
7071
|
-
*
|
|
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"`.
|
|
7072
7390
|
*
|
|
7073
|
-
* @param options - Optional branch override and `
|
|
7074
|
-
* @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).
|
|
7075
7393
|
* @example
|
|
7076
|
-
* await api.deploy({
|
|
7394
|
+
* await api.deploy({ guided: true });
|
|
7077
7395
|
*/
|
|
7078
7396
|
async deploy(options = {}) {
|
|
7079
7397
|
const { branch, yes = false } = options;
|
|
7080
7398
|
ctx.state.render.header("deploy");
|
|
7399
|
+
if (options.guided === true) return runDeployWizard(ctx, options);
|
|
7081
7400
|
await ctx.require(deployPlugin).init({ ci: true });
|
|
7082
7401
|
if (!await confirmDeploy(ctx, yes)) return {
|
|
7083
7402
|
deployed: false,
|
|
@@ -7177,6 +7496,39 @@ const ANSI = {
|
|
|
7177
7496
|
cyan: `${ESC}[36m`,
|
|
7178
7497
|
gray: `${ESC}[90m`
|
|
7179
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
|
+
}
|
|
7180
7532
|
/** Unicode rounded box glyphs used when output is a color-capable TTY. */
|
|
7181
7533
|
const UNICODE_BOX = {
|
|
7182
7534
|
topLeft: "╭",
|
|
@@ -7393,8 +7745,97 @@ function durationSuffix(palette, durationMs) {
|
|
|
7393
7745
|
function createPanelRenderer(options = {}) {
|
|
7394
7746
|
const write = options.write ?? ((line) => console.log(line));
|
|
7395
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;
|
|
7396
7752
|
const color = options.color ?? supportsColor();
|
|
7397
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
|
+
};
|
|
7398
7839
|
/**
|
|
7399
7840
|
* Write each line of a multi-line block through the stdout sink.
|
|
7400
7841
|
*
|
|
@@ -7417,15 +7858,38 @@ function createPanelRenderer(options = {}) {
|
|
|
7417
7858
|
writeBlock(box([`${palette.bold(palette.cyan("MOKU WEB"))} ${palette.dim(COMMAND_LABEL[command])}`], color));
|
|
7418
7859
|
},
|
|
7419
7860
|
/**
|
|
7420
|
-
* 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.
|
|
7421
7865
|
*
|
|
7422
7866
|
* @param phase - The `build:phase` payload.
|
|
7423
7867
|
* @example
|
|
7424
7868
|
* render.phase({ phase: "pages", status: "done", durationMs: 12 });
|
|
7425
7869
|
*/
|
|
7426
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
|
+
}
|
|
7427
7881
|
const done = phase.status === "done";
|
|
7428
|
-
|
|
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();
|
|
7429
7893
|
},
|
|
7430
7894
|
/**
|
|
7431
7895
|
* Render the BUILD summary block from a `build:complete` event.
|
|
@@ -7435,6 +7899,10 @@ function createPanelRenderer(options = {}) {
|
|
|
7435
7899
|
* render.built({ outDir: "dist", pageCount: 12, durationMs: 840 });
|
|
7436
7900
|
*/
|
|
7437
7901
|
built(summary) {
|
|
7902
|
+
if (rebuilding) return;
|
|
7903
|
+
phaseOpen = false;
|
|
7904
|
+
phaseDrawn = 0;
|
|
7905
|
+
stopTicker();
|
|
7438
7906
|
const pages = palette.bold(String(summary.pageCount));
|
|
7439
7907
|
writeBlock(box([
|
|
7440
7908
|
`${palette.green("✓")} ${palette.bold("BUILD")} complete`,
|
|
@@ -7456,15 +7924,47 @@ function createPanelRenderer(options = {}) {
|
|
|
7456
7924
|
writeBlock(box(lines, color));
|
|
7457
7925
|
},
|
|
7458
7926
|
/**
|
|
7459
|
-
*
|
|
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.
|
|
7460
7952
|
*
|
|
7461
7953
|
* @param info - The changed file plus the rebuild's page count and duration.
|
|
7462
7954
|
* @example
|
|
7463
7955
|
* render.reload({ file: "content/a.md", pageCount: 12, durationMs: 84 });
|
|
7464
7956
|
*/
|
|
7465
7957
|
reload(info) {
|
|
7466
|
-
|
|
7467
|
-
|
|
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);
|
|
7468
7968
|
},
|
|
7469
7969
|
/**
|
|
7470
7970
|
* Render the deploy result panel from a `deploy:complete` event.
|
|
@@ -7490,7 +7990,9 @@ function createPanelRenderer(options = {}) {
|
|
|
7490
7990
|
* render.info("watching for changes…");
|
|
7491
7991
|
*/
|
|
7492
7992
|
info(message) {
|
|
7493
|
-
|
|
7993
|
+
const [first = "", ...rest] = message.split("\n");
|
|
7994
|
+
write(` ${palette.cyan("›")} ${first}`);
|
|
7995
|
+
for (const line of rest) write(` ${line}`);
|
|
7494
7996
|
},
|
|
7495
7997
|
/**
|
|
7496
7998
|
* Render a warning line (to stderr).
|
|
@@ -7511,8 +8013,38 @@ function createPanelRenderer(options = {}) {
|
|
|
7511
8013
|
* render.error("build failed", err);
|
|
7512
8014
|
*/
|
|
7513
8015
|
error(message, cause) {
|
|
8016
|
+
if (rebuilding) {
|
|
8017
|
+
rebuilding = false;
|
|
8018
|
+
stopTicker();
|
|
8019
|
+
if (color) writeRaw(`\r${CLEAR_LINE}`);
|
|
8020
|
+
}
|
|
7514
8021
|
writeError(` ${palette.red("✗")} ${message}`);
|
|
7515
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)}`);
|
|
7516
8048
|
}
|
|
7517
8049
|
};
|
|
7518
8050
|
}
|
|
@@ -7591,18 +8123,44 @@ function defaultConfirm(question) {
|
|
|
7591
8123
|
});
|
|
7592
8124
|
}
|
|
7593
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
|
+
/**
|
|
7594
8151
|
* Default recursive directory watcher — wraps `node:fs.watch` with `{ recursive: true }`
|
|
7595
8152
|
* and adapts its handle to {@link WatchHandle}. Tests inject a fake emitter so no real
|
|
7596
8153
|
* FS watch is registered.
|
|
7597
8154
|
*
|
|
7598
8155
|
* @param dir - The directory to watch recursively.
|
|
7599
|
-
* @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).
|
|
7600
8158
|
* @returns A handle whose `close()` detaches the watcher.
|
|
7601
8159
|
* @example
|
|
7602
|
-
* const handle = defaultWatch("content",
|
|
8160
|
+
* const handle = defaultWatch("content", file => rebuild(file));
|
|
7603
8161
|
*/
|
|
7604
8162
|
function defaultWatch(dir, onChange) {
|
|
7605
|
-
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));
|
|
7606
8164
|
return {
|
|
7607
8165
|
/**
|
|
7608
8166
|
* Detach the underlying `node:fs.watch` listener.
|
|
@@ -7615,6 +8173,23 @@ close() {
|
|
|
7615
8173
|
} };
|
|
7616
8174
|
}
|
|
7617
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
|
+
/**
|
|
7618
8193
|
* Default LAN network-URL deriver — wraps {@link networkUrl} so the production seam
|
|
7619
8194
|
* reads `node:os` interfaces while tests can inject a deterministic value.
|
|
7620
8195
|
*
|
|
@@ -7642,11 +8217,13 @@ function createState$1(_ctx) {
|
|
|
7642
8217
|
return {
|
|
7643
8218
|
render: createPanelRenderer(),
|
|
7644
8219
|
confirm: defaultConfirm,
|
|
8220
|
+
select: defaultSelect,
|
|
7645
8221
|
clock: Date.now,
|
|
7646
8222
|
watch: defaultWatch,
|
|
7647
8223
|
serveStatic: defaultServeStatic,
|
|
7648
8224
|
fileResponse: defaultFileResponse,
|
|
7649
|
-
networkUrl: defaultNetworkUrl
|
|
8225
|
+
networkUrl: defaultNetworkUrl,
|
|
8226
|
+
fileMtime: defaultFileMtime
|
|
7650
8227
|
};
|
|
7651
8228
|
}
|
|
7652
8229
|
//#endregion
|