@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.mjs
CHANGED
|
@@ -5761,6 +5761,19 @@ const CHECKOUT_SHA = "11bd71901bbe5b1630ceea73d27597364c9af683";
|
|
|
5761
5761
|
const SETUP_BUN_SHA = "4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5";
|
|
5762
5762
|
/** Pinned `cloudflare/wrangler-action` commit SHA. */
|
|
5763
5763
|
const WRANGLER_ACTION_SHA = "f84a562284fc78278ff9052435d9526f9c718361";
|
|
5764
|
+
/** The `on:` block for each {@link WorkflowTrigger} (kept indentation-exact for YAML). */
|
|
5765
|
+
const TRIGGER_ON_BLOCKS = {
|
|
5766
|
+
auto: `on:
|
|
5767
|
+
push:
|
|
5768
|
+
branches: [main]
|
|
5769
|
+
workflow_dispatch:`,
|
|
5770
|
+
"versioned-tag": `on:
|
|
5771
|
+
push:
|
|
5772
|
+
tags: ["v*"]
|
|
5773
|
+
workflow_dispatch:`,
|
|
5774
|
+
dispatch: `on:
|
|
5775
|
+
workflow_dispatch:`
|
|
5776
|
+
};
|
|
5764
5777
|
/**
|
|
5765
5778
|
* Generate a SHA-pinned GitHub Actions workflow that builds and deploys to
|
|
5766
5779
|
* Cloudflare Pages. Every action is pinned to a commit SHA (with a `# vX`
|
|
@@ -5770,9 +5783,10 @@ const WRANGLER_ACTION_SHA = "f84a562284fc78278ff9052435d9526f9c718361";
|
|
|
5770
5783
|
*
|
|
5771
5784
|
* @param input - The generator inputs.
|
|
5772
5785
|
* @param input.slug - Cloudflare project-name slug used as the wrangler `--project-name`.
|
|
5786
|
+
* @param input.trigger - What fires the workflow (see {@link WorkflowTrigger}). Default `"auto"`.
|
|
5773
5787
|
* @returns The workflow YAML.
|
|
5774
5788
|
* @example
|
|
5775
|
-
* generateGithubWorkflow({ slug: "my-site" });
|
|
5789
|
+
* generateGithubWorkflow({ slug: "my-site", trigger: "versioned-tag" });
|
|
5776
5790
|
*/
|
|
5777
5791
|
function generateGithubWorkflow(input) {
|
|
5778
5792
|
return `# .github/workflows/deploy.yml — generated by \`app.deploy.init({ ci: true })\`.
|
|
@@ -5780,10 +5794,7 @@ function generateGithubWorkflow(input) {
|
|
|
5780
5794
|
|
|
5781
5795
|
name: Deploy
|
|
5782
5796
|
|
|
5783
|
-
|
|
5784
|
-
push:
|
|
5785
|
-
branches: [main]
|
|
5786
|
-
workflow_dispatch:
|
|
5797
|
+
${TRIGGER_ON_BLOCKS[input.trigger ?? "auto"]}
|
|
5787
5798
|
|
|
5788
5799
|
permissions:
|
|
5789
5800
|
contents: read
|
|
@@ -5915,7 +5926,10 @@ async function writeScaffolding(input) {
|
|
|
5915
5926
|
});
|
|
5916
5927
|
if (ci) await reconcile({
|
|
5917
5928
|
relativePath: WORKFLOW_PATH,
|
|
5918
|
-
expected: generateGithubWorkflow({
|
|
5929
|
+
expected: generateGithubWorkflow({
|
|
5930
|
+
slug,
|
|
5931
|
+
...options.workflowTrigger ? { trigger: options.workflowTrigger } : {}
|
|
5932
|
+
}),
|
|
5919
5933
|
existing: await readMaybe(cwd, WORKFLOW_PATH),
|
|
5920
5934
|
cwd,
|
|
5921
5935
|
check,
|
|
@@ -6433,6 +6447,185 @@ const deployPlugin = createPlugin$1("deploy", {
|
|
|
6433
6447
|
api: createApi$2
|
|
6434
6448
|
});
|
|
6435
6449
|
//#endregion
|
|
6450
|
+
//#region src/plugins/cli/deploy-wizard.ts
|
|
6451
|
+
/**
|
|
6452
|
+
* @file cli plugin — the guided deploy wizard (`cli.deploy({ guided: true })`). Walks a
|
|
6453
|
+
* human through a Cloudflare Pages deploy: checks prerequisites (wrangler config + the
|
|
6454
|
+
* Cloudflare credentials) with concrete fix guidance, offers to scaffold/build what is
|
|
6455
|
+
* missing, HARD-GATES the deploy on everything being green, runs a local build smoke
|
|
6456
|
+
* test, confirms, deploys, then offers to scaffold a GitHub Actions workflow (auto on
|
|
6457
|
+
* push to main, or a versioned/manual trigger). The non-guided `--cli` path stays in
|
|
6458
|
+
* `api.ts`. Every prompt + line of output flows through injectable `state` seams.
|
|
6459
|
+
*/
|
|
6460
|
+
/** How to create a Cloudflare API token + where to make it available locally. */
|
|
6461
|
+
const TOKEN_HELP = [
|
|
6462
|
+
"Create one at https://dash.cloudflare.com/profile/api-tokens → Create Token →",
|
|
6463
|
+
"use the \"Cloudflare Pages — Edit\" template (or a custom token with the",
|
|
6464
|
+
"Account › Cloudflare Pages › Edit permission). Then make it available:",
|
|
6465
|
+
" export CLOUDFLARE_API_TOKEN=… (shell) or add it to .env (gitignored)."
|
|
6466
|
+
].join("\n");
|
|
6467
|
+
/** Where to find the Cloudflare account id + where to make it available locally. */
|
|
6468
|
+
const ACCOUNT_HELP = [
|
|
6469
|
+
"Find it on the Cloudflare dashboard → Workers & Pages: the Account ID is in the",
|
|
6470
|
+
"right-hand sidebar (also in the dashboard URL). Then make it available:",
|
|
6471
|
+
" export CLOUDFLARE_ACCOUNT_ID=… or add it to .env."
|
|
6472
|
+
].join("\n");
|
|
6473
|
+
/** The GitHub repo secrets the generated workflow consumes. */
|
|
6474
|
+
const SECRETS_HELP = ["Add these repo secrets (GitHub → Settings → Secrets and variables → Actions):", "CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID"].join("\n");
|
|
6475
|
+
/**
|
|
6476
|
+
* Evaluate the three deploy prerequisites against the current project: the Cloudflare
|
|
6477
|
+
* wrangler config exists, and both Cloudflare credentials are present in the environment.
|
|
6478
|
+
*
|
|
6479
|
+
* @param cwd - The project root (where `wrangler.jsonc` lives).
|
|
6480
|
+
* @returns The ordered prerequisite checks.
|
|
6481
|
+
* @example
|
|
6482
|
+
* const prereqs = diagnose(process.cwd());
|
|
6483
|
+
*/
|
|
6484
|
+
function diagnose(cwd) {
|
|
6485
|
+
const wranglerOk = existsSync(path.join(cwd, "wrangler.jsonc"));
|
|
6486
|
+
const tokenOk = (process.env.CLOUDFLARE_API_TOKEN ?? "") !== "";
|
|
6487
|
+
const accountOk = (process.env.CLOUDFLARE_ACCOUNT_ID ?? "") !== "";
|
|
6488
|
+
return [
|
|
6489
|
+
{
|
|
6490
|
+
ok: wranglerOk,
|
|
6491
|
+
label: "wrangler.jsonc (Cloudflare project config)",
|
|
6492
|
+
detail: wranglerOk ? void 0 : "Missing — scaffold it (offered below) or run app.deploy.init().",
|
|
6493
|
+
scaffoldable: true
|
|
6494
|
+
},
|
|
6495
|
+
{
|
|
6496
|
+
ok: tokenOk,
|
|
6497
|
+
label: "CLOUDFLARE_API_TOKEN is set",
|
|
6498
|
+
detail: tokenOk ? void 0 : TOKEN_HELP,
|
|
6499
|
+
scaffoldable: false
|
|
6500
|
+
},
|
|
6501
|
+
{
|
|
6502
|
+
ok: accountOk,
|
|
6503
|
+
label: "CLOUDFLARE_ACCOUNT_ID is set",
|
|
6504
|
+
detail: accountOk ? void 0 : ACCOUNT_HELP,
|
|
6505
|
+
scaffoldable: false
|
|
6506
|
+
}
|
|
6507
|
+
];
|
|
6508
|
+
}
|
|
6509
|
+
/**
|
|
6510
|
+
* Offer to scaffold a missing `wrangler.jsonc` (the only auto-fixable prerequisite),
|
|
6511
|
+
* generating it via the deploy plugin when the user accepts.
|
|
6512
|
+
*
|
|
6513
|
+
* @param ctx - The cli plugin context.
|
|
6514
|
+
* @param prereqs - The current prerequisite checks.
|
|
6515
|
+
* @returns Resolves once any accepted fix has run.
|
|
6516
|
+
* @example
|
|
6517
|
+
* await offerScaffold(ctx, diagnose(cwd));
|
|
6518
|
+
*/
|
|
6519
|
+
async function offerScaffold(ctx, prereqs) {
|
|
6520
|
+
if (!prereqs.some((item) => item.scaffoldable && !item.ok)) return;
|
|
6521
|
+
if (!await ctx.state.confirm("Scaffold wrangler.jsonc now?")) return;
|
|
6522
|
+
await ctx.require(deployPlugin).init({});
|
|
6523
|
+
ctx.state.render.check(true, "wrangler.jsonc scaffolded");
|
|
6524
|
+
}
|
|
6525
|
+
/**
|
|
6526
|
+
* Map a top-level workflow choice (and, for the versioned option, a sub-choice) to the
|
|
6527
|
+
* concrete {@link WorkflowTrigger}, or `null` when the user chose to skip setup.
|
|
6528
|
+
*
|
|
6529
|
+
* @param ctx - The cli plugin context (for the follow-up sub-choice prompt).
|
|
6530
|
+
* @param choice - The selected zero-based index of the top-level options.
|
|
6531
|
+
* @returns The resolved trigger, or `null` to skip.
|
|
6532
|
+
* @example
|
|
6533
|
+
* const trigger = await resolveTrigger(ctx, 1);
|
|
6534
|
+
*/
|
|
6535
|
+
async function resolveTrigger(ctx, choice) {
|
|
6536
|
+
if (choice === 2) return null;
|
|
6537
|
+
if (choice === 0) return "auto";
|
|
6538
|
+
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";
|
|
6539
|
+
}
|
|
6540
|
+
/**
|
|
6541
|
+
* Offer to scaffold a GitHub Actions deploy workflow, letting the user choose how it is
|
|
6542
|
+
* triggered, then remind them which repo secrets to add. A no-op past a "skip" choice.
|
|
6543
|
+
*
|
|
6544
|
+
* @param ctx - The cli plugin context.
|
|
6545
|
+
* @returns Resolves once any chosen workflow has been scaffolded.
|
|
6546
|
+
* @example
|
|
6547
|
+
* await offerWorkflowSetup(ctx);
|
|
6548
|
+
*/
|
|
6549
|
+
async function offerWorkflowSetup(ctx) {
|
|
6550
|
+
ctx.state.render.heading("Automate future deploys (GitHub Actions)");
|
|
6551
|
+
const trigger = await resolveTrigger(ctx, await ctx.state.select("Set up a deploy workflow?", [
|
|
6552
|
+
"Auto-deploy on every push to main",
|
|
6553
|
+
"Manual / versioned deploy (choose trigger)",
|
|
6554
|
+
"Skip for now"
|
|
6555
|
+
]));
|
|
6556
|
+
if (trigger === null) return;
|
|
6557
|
+
const result = await ctx.require(deployPlugin).init({
|
|
6558
|
+
ci: true,
|
|
6559
|
+
workflowTrigger: trigger
|
|
6560
|
+
});
|
|
6561
|
+
const workflowPath = ".github/workflows/deploy.yml";
|
|
6562
|
+
const wrote = result.written.includes(workflowPath);
|
|
6563
|
+
ctx.state.render.check(true, wrote ? `wrote ${workflowPath}` : `${workflowPath} already exists (left unchanged)`);
|
|
6564
|
+
ctx.state.render.info(SECRETS_HELP);
|
|
6565
|
+
}
|
|
6566
|
+
/**
|
|
6567
|
+
* Run the deploy step: confirm (unless `yes`), then deploy via the deploy plugin and
|
|
6568
|
+
* report the outcome. A declined confirm returns `{ deployed: false, reason: "declined" }`.
|
|
6569
|
+
*
|
|
6570
|
+
* @param ctx - The cli plugin context.
|
|
6571
|
+
* @param options - The deploy options (branch override + `yes`).
|
|
6572
|
+
* @returns The deploy outcome.
|
|
6573
|
+
* @example
|
|
6574
|
+
* const outcome = await runDeployStep(ctx, { yes: true });
|
|
6575
|
+
*/
|
|
6576
|
+
async function runDeployStep(ctx, options) {
|
|
6577
|
+
ctx.state.render.heading("Deploy");
|
|
6578
|
+
if (!(options.yes === true || await ctx.state.confirm(`Deploy ${ctx.config.outDir}/ to Cloudflare Pages now?`))) {
|
|
6579
|
+
ctx.state.render.warn("deploy skipped");
|
|
6580
|
+
return {
|
|
6581
|
+
deployed: false,
|
|
6582
|
+
reason: "declined"
|
|
6583
|
+
};
|
|
6584
|
+
}
|
|
6585
|
+
return {
|
|
6586
|
+
deployed: true,
|
|
6587
|
+
...await ctx.require(deployPlugin).run(options.branch === void 0 ? {} : { branch: options.branch })
|
|
6588
|
+
};
|
|
6589
|
+
}
|
|
6590
|
+
/**
|
|
6591
|
+
* Run the guided deploy wizard end to end: diagnose prerequisites (offering to scaffold
|
|
6592
|
+
* the wrangler config), HARD-GATE on the remaining blockers, run a local build smoke
|
|
6593
|
+
* test, deploy (with confirmation), then offer to scaffold a CI workflow. Returns
|
|
6594
|
+
* `{ deployed: false, reason: "blocked" }` when prerequisites are unmet, so a thin script
|
|
6595
|
+
* can exit non-zero. Assumes the caller already rendered the `deploy` header.
|
|
6596
|
+
*
|
|
6597
|
+
* @param ctx - The cli plugin context (state seams + `require` + config).
|
|
6598
|
+
* @param options - The deploy options (branch override, `yes`, `guided`).
|
|
6599
|
+
* @returns The deploy outcome (`deployed`, or a `declined`/`blocked` skip).
|
|
6600
|
+
* @example
|
|
6601
|
+
* const outcome = await runDeployWizard(ctx, { guided: true });
|
|
6602
|
+
*/
|
|
6603
|
+
async function runDeployWizard(ctx, options) {
|
|
6604
|
+
const cwd = process.cwd();
|
|
6605
|
+
ctx.state.render.heading("Checking prerequisites");
|
|
6606
|
+
for (const item of diagnose(cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
|
|
6607
|
+
await offerScaffold(ctx, diagnose(cwd));
|
|
6608
|
+
const blockers = diagnose(cwd).filter((item) => !item.ok);
|
|
6609
|
+
if (blockers.length > 0) {
|
|
6610
|
+
ctx.state.render.heading("Not ready to deploy");
|
|
6611
|
+
for (const item of blockers) ctx.state.render.check(false, item.label, item.detail);
|
|
6612
|
+
ctx.state.render.warn(`Fix the ${blockers.length} item(s) above, then re-run \`bun run deploy\`.`);
|
|
6613
|
+
return {
|
|
6614
|
+
deployed: false,
|
|
6615
|
+
reason: "blocked"
|
|
6616
|
+
};
|
|
6617
|
+
}
|
|
6618
|
+
ctx.state.render.heading("Local test");
|
|
6619
|
+
const summary = await ctx.require(buildPlugin).run();
|
|
6620
|
+
ctx.state.render.check(true, `built ${summary.pageCount} pages → ${summary.outDir}/`);
|
|
6621
|
+
const notFoundOk = existsSync(path.join(ctx.config.outDir, ctx.config.notFoundFile));
|
|
6622
|
+
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).");
|
|
6623
|
+
ctx.state.render.info("Tip: run `bun run preview` to eyeball the built site before deploying.");
|
|
6624
|
+
const outcome = await runDeployStep(ctx, options);
|
|
6625
|
+
await offerWorkflowSetup(ctx);
|
|
6626
|
+
return outcome;
|
|
6627
|
+
}
|
|
6628
|
+
//#endregion
|
|
6436
6629
|
//#region src/plugins/cli/errors.ts
|
|
6437
6630
|
/** Error prefix for cli config/validation/runtime failures (spec/11 Part-3). */
|
|
6438
6631
|
const ERROR_PREFIX$3 = "[web] cli";
|
|
@@ -6466,11 +6659,12 @@ function injectReloadClient(html) {
|
|
|
6466
6659
|
return index === -1 ? html + RELOAD_CLIENT : html.slice(0, index) + RELOAD_CLIENT + html.slice(index);
|
|
6467
6660
|
}
|
|
6468
6661
|
/**
|
|
6469
|
-
* Run one rebuild and report the result.
|
|
6470
|
-
*
|
|
6662
|
+
* Run one rebuild and report the result. Announces the start (`onRebuildStart`), then
|
|
6663
|
+
* routes success to `onReloaded` and failure to `onError`.
|
|
6471
6664
|
*
|
|
6472
6665
|
* @param input - The rebuild dependencies + the changed file.
|
|
6473
6666
|
* @param input.runBuild - Runs one build and resolves with its summary.
|
|
6667
|
+
* @param input.onRebuildStart - Called with the changed file just before the build runs.
|
|
6474
6668
|
* @param input.onReloaded - Called with the changed file + summary after a rebuild.
|
|
6475
6669
|
* @param input.onError - Called when a rebuild throws.
|
|
6476
6670
|
* @param input.file - The changed file to report alongside the summary.
|
|
@@ -6479,6 +6673,7 @@ function injectReloadClient(html) {
|
|
|
6479
6673
|
* await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md" });
|
|
6480
6674
|
*/
|
|
6481
6675
|
async function runOneRebuild(input) {
|
|
6676
|
+
input.onRebuildStart?.(input.file);
|
|
6482
6677
|
try {
|
|
6483
6678
|
const summary = await input.runBuild();
|
|
6484
6679
|
input.onReloaded({
|
|
@@ -6500,6 +6695,7 @@ async function runOneRebuild(input) {
|
|
|
6500
6695
|
* @param input - The rebuild dependencies.
|
|
6501
6696
|
* @param input.debounceMs - Debounce window in milliseconds.
|
|
6502
6697
|
* @param input.runBuild - Runs one build and resolves with its summary.
|
|
6698
|
+
* @param input.onRebuildStart - Called with the changed file just before each build runs.
|
|
6503
6699
|
* @param input.onReloaded - Called with the changed file + summary after a rebuild.
|
|
6504
6700
|
* @param input.onError - Called when a rebuild throws.
|
|
6505
6701
|
* @returns The debounced rebuild driver.
|
|
@@ -6526,6 +6722,7 @@ function createRebuilder(input) {
|
|
|
6526
6722
|
dirty = false;
|
|
6527
6723
|
await runOneRebuild({
|
|
6528
6724
|
runBuild: input.runBuild,
|
|
6725
|
+
...input.onRebuildStart ? { onRebuildStart: input.onRebuildStart } : {},
|
|
6529
6726
|
onReloaded: input.onReloaded,
|
|
6530
6727
|
onError: input.onError,
|
|
6531
6728
|
file: pendingFile
|
|
@@ -6579,6 +6776,70 @@ function createRebuilder(input) {
|
|
|
6579
6776
|
};
|
|
6580
6777
|
}
|
|
6581
6778
|
/**
|
|
6779
|
+
* Whether a changed path (relative to a watched dir) is editor/OS noise that is never a
|
|
6780
|
+
* page source: any hidden segment (`.DS_Store`, anything under `.git/` or `.cache/`,
|
|
6781
|
+
* vim `.*.swp`) or a `~` backup file. Checks every segment, not just the basename.
|
|
6782
|
+
*
|
|
6783
|
+
* @param filename - The changed path relative to its watched directory.
|
|
6784
|
+
* @returns `true` when the change should be ignored as noise.
|
|
6785
|
+
* @example
|
|
6786
|
+
* isNoisePath(".git/HEAD"); // true
|
|
6787
|
+
*/
|
|
6788
|
+
function isNoisePath(filename) {
|
|
6789
|
+
return filename.split(/[/\\]/).some((segment) => segment.startsWith(".")) || filename.endsWith("~");
|
|
6790
|
+
}
|
|
6791
|
+
/**
|
|
6792
|
+
* Create a {@link ChangeGate} that drops three kinds of spurious change events before
|
|
6793
|
+
* they reach the debounced rebuilder: editor/OS noise (dotfiles, backups), writes under
|
|
6794
|
+
* `outDir` (the build's own output — a loop guard), and the stale duplicate/parent-dir
|
|
6795
|
+
* echoes macOS fires for one save. Staleness is judged by a build-start high-water mark:
|
|
6796
|
+
* a change whose file mtime is at or before the last build we started was already
|
|
6797
|
+
* captured (or is a late echo), so it is ignored — while a genuinely newer edit (even
|
|
6798
|
+
* one made mid-build) and a deletion (missing file) always pass. The single timestamp
|
|
6799
|
+
* also means no per-path map grows over a long session.
|
|
6800
|
+
*
|
|
6801
|
+
* @param input - The gate dependencies.
|
|
6802
|
+
* @param input.outDir - The build output directory whose writes must never re-trigger a build.
|
|
6803
|
+
* @param input.fileMtime - Resolves a path's mtime in ms (or `null` when missing).
|
|
6804
|
+
* @param input.now - Monotonic wall clock (ms) used for the build-start high-water mark.
|
|
6805
|
+
* @returns The change gate.
|
|
6806
|
+
* @example
|
|
6807
|
+
* const gate = createChangeGate({ outDir: "dist", fileMtime: state.fileMtime, now: state.clock });
|
|
6808
|
+
*/
|
|
6809
|
+
function createChangeGate(input) {
|
|
6810
|
+
const outDirAbs = path.resolve(input.outDir);
|
|
6811
|
+
let lastBuildStartedAt = input.now();
|
|
6812
|
+
return {
|
|
6813
|
+
/**
|
|
6814
|
+
* Decide whether a change beneath `dir` warrants a rebuild (see {@link ChangeGate.accept}).
|
|
6815
|
+
*
|
|
6816
|
+
* @param dir - The watched directory the event fired on.
|
|
6817
|
+
* @param filename - The changed path relative to `dir` (or `undefined`).
|
|
6818
|
+
* @returns `true` to schedule a rebuild, `false` to ignore.
|
|
6819
|
+
* @example
|
|
6820
|
+
* gate.accept("content", "post/en.md");
|
|
6821
|
+
*/
|
|
6822
|
+
accept(dir, filename) {
|
|
6823
|
+
if (filename === void 0) return true;
|
|
6824
|
+
if (isNoisePath(filename)) return false;
|
|
6825
|
+
const changed = path.resolve(dir, filename);
|
|
6826
|
+
if (changed === outDirAbs || changed.startsWith(`${outDirAbs}${path.sep}`)) return false;
|
|
6827
|
+
const mtime = input.fileMtime(changed);
|
|
6828
|
+
if (mtime !== null && mtime < lastBuildStartedAt) return false;
|
|
6829
|
+
return true;
|
|
6830
|
+
},
|
|
6831
|
+
/**
|
|
6832
|
+
* Advance the high-water mark to now (see {@link ChangeGate.markBuildStart}).
|
|
6833
|
+
*
|
|
6834
|
+
* @example
|
|
6835
|
+
* gate.markBuildStart();
|
|
6836
|
+
*/
|
|
6837
|
+
markBuildStart() {
|
|
6838
|
+
lastBuildStartedAt = input.now();
|
|
6839
|
+
}
|
|
6840
|
+
};
|
|
6841
|
+
}
|
|
6842
|
+
/**
|
|
6582
6843
|
* Install SIGINT/SIGTERM handlers that run `teardown()` and resolve the returned
|
|
6583
6844
|
* promise, so a long-running command (`serve`/`preview`) unblocks its `await` on
|
|
6584
6845
|
* Ctrl-C / termination and detaches its own listeners. Used by both servers.
|
|
@@ -6610,18 +6871,45 @@ function installSignalTeardown(teardown) {
|
|
|
6610
6871
|
const SSE_OPEN = ": connected\n\n";
|
|
6611
6872
|
/** The SSE frame pushed to reload a connected browser. */
|
|
6612
6873
|
const SSE_RELOAD = "event: reload\ndata: 1\n\n";
|
|
6874
|
+
/** The SSE comment frame sent on the heartbeat to keep an idle stream warm. */
|
|
6875
|
+
const SSE_PING = ": ping\n\n";
|
|
6876
|
+
/** Default heartbeat interval (ms): one ping well under any 30s+ proxy idle window. */
|
|
6877
|
+
const DEFAULT_HEARTBEAT_MS = 15e3;
|
|
6613
6878
|
/**
|
|
6614
6879
|
* Create a {@link ReloadHub} backed by `ReadableStream` controllers. Each `connect()`
|
|
6615
6880
|
* enqueues into a new stream; `reloadAll()` writes the reload frame to every live
|
|
6616
|
-
* controller (dropping any that have closed).
|
|
6881
|
+
* controller (dropping any that have closed). A periodic heartbeat comment keeps idle
|
|
6882
|
+
* streams warm — belt-and-suspenders alongside the dev server's `idleTimeout: 0`, so a
|
|
6883
|
+
* quiet connection is never severed (which the browser surfaces as
|
|
6884
|
+
* `ERR_INCOMPLETE_CHUNKED_ENCODING` and then reconnects in a storm).
|
|
6617
6885
|
*
|
|
6886
|
+
* @param options - Optional heartbeat tuning.
|
|
6887
|
+
* @param options.heartbeatMs - Heartbeat interval in ms (`0` disables). Default `15000`.
|
|
6618
6888
|
* @returns The reload hub.
|
|
6619
6889
|
* @example
|
|
6620
6890
|
* const hub = createReloadHub();
|
|
6621
6891
|
*/
|
|
6622
|
-
function createReloadHub() {
|
|
6892
|
+
function createReloadHub(options = {}) {
|
|
6623
6893
|
const encoder = new TextEncoder();
|
|
6624
6894
|
const clients = /* @__PURE__ */ new Set();
|
|
6895
|
+
/**
|
|
6896
|
+
* Enqueue one frame to every live controller, dropping any that have closed.
|
|
6897
|
+
*
|
|
6898
|
+
* @param frame - The SSE wire text to broadcast.
|
|
6899
|
+
* @example
|
|
6900
|
+
* broadcast(SSE_RELOAD);
|
|
6901
|
+
*/
|
|
6902
|
+
const broadcast = (frame) => {
|
|
6903
|
+
const bytes = encoder.encode(frame);
|
|
6904
|
+
for (const controller of clients) try {
|
|
6905
|
+
controller.enqueue(bytes);
|
|
6906
|
+
} catch {
|
|
6907
|
+
clients.delete(controller);
|
|
6908
|
+
}
|
|
6909
|
+
};
|
|
6910
|
+
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
6911
|
+
const heartbeat = heartbeatMs > 0 ? setInterval(() => broadcast(SSE_PING), heartbeatMs) : void 0;
|
|
6912
|
+
heartbeat?.unref?.();
|
|
6625
6913
|
return {
|
|
6626
6914
|
/**
|
|
6627
6915
|
* Open one SSE connection, register its controller, and return the streaming
|
|
@@ -6669,11 +6957,7 @@ function createReloadHub() {
|
|
|
6669
6957
|
* hub.reloadAll();
|
|
6670
6958
|
*/
|
|
6671
6959
|
reloadAll() {
|
|
6672
|
-
|
|
6673
|
-
controller.enqueue(encoder.encode(SSE_RELOAD));
|
|
6674
|
-
} catch {
|
|
6675
|
-
clients.delete(controller);
|
|
6676
|
-
}
|
|
6960
|
+
broadcast(SSE_RELOAD);
|
|
6677
6961
|
},
|
|
6678
6962
|
/**
|
|
6679
6963
|
* The number of currently-connected clients.
|
|
@@ -6684,6 +6968,19 @@ function createReloadHub() {
|
|
|
6684
6968
|
*/
|
|
6685
6969
|
size() {
|
|
6686
6970
|
return clients.size;
|
|
6971
|
+
},
|
|
6972
|
+
/**
|
|
6973
|
+
* Stop the heartbeat and close every live SSE stream (SIGINT/SIGTERM teardown).
|
|
6974
|
+
*
|
|
6975
|
+
* @example
|
|
6976
|
+
* hub.close();
|
|
6977
|
+
*/
|
|
6978
|
+
close() {
|
|
6979
|
+
if (heartbeat !== void 0) clearInterval(heartbeat);
|
|
6980
|
+
for (const controller of clients) try {
|
|
6981
|
+
controller.close();
|
|
6982
|
+
} catch {}
|
|
6983
|
+
clients.clear();
|
|
6687
6984
|
}
|
|
6688
6985
|
};
|
|
6689
6986
|
}
|
|
@@ -6747,8 +7044,14 @@ async function runDevServer(ctx, port) {
|
|
|
6747
7044
|
const hub = createReloadHub();
|
|
6748
7045
|
const server = ctx.state.serveStatic({
|
|
6749
7046
|
port,
|
|
7047
|
+
idleTimeout: 0,
|
|
6750
7048
|
fetch: createDevHandler(ctx, hub)
|
|
6751
7049
|
});
|
|
7050
|
+
const gate = createChangeGate({
|
|
7051
|
+
outDir: ctx.config.outDir,
|
|
7052
|
+
fileMtime: ctx.state.fileMtime,
|
|
7053
|
+
now: ctx.state.clock
|
|
7054
|
+
});
|
|
6752
7055
|
const rebuilder = createRebuilder({
|
|
6753
7056
|
debounceMs: ctx.config.debounceMs,
|
|
6754
7057
|
/**
|
|
@@ -6762,6 +7065,17 @@ async function runDevServer(ctx, port) {
|
|
|
6762
7065
|
return ctx.require(buildPlugin).run();
|
|
6763
7066
|
},
|
|
6764
7067
|
/**
|
|
7068
|
+
* Show the compact in-place "rebuilding {label}" line before the build runs.
|
|
7069
|
+
*
|
|
7070
|
+
* @param file - The changed watch target shown in the line.
|
|
7071
|
+
* @example
|
|
7072
|
+
* onRebuildStart("content");
|
|
7073
|
+
*/
|
|
7074
|
+
onRebuildStart(file) {
|
|
7075
|
+
gate.markBuildStart();
|
|
7076
|
+
ctx.state.render.rebuildStart(file);
|
|
7077
|
+
},
|
|
7078
|
+
/**
|
|
6765
7079
|
* Render the reload line and push a browser reload after a rebuild.
|
|
6766
7080
|
*
|
|
6767
7081
|
* @param info - The changed file plus the rebuild's page count and duration.
|
|
@@ -6783,7 +7097,9 @@ async function runDevServer(ctx, port) {
|
|
|
6783
7097
|
ctx.state.render.error("rebuild failed", error);
|
|
6784
7098
|
}
|
|
6785
7099
|
});
|
|
6786
|
-
const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, () =>
|
|
7100
|
+
const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, (filename) => {
|
|
7101
|
+
if (gate.accept(dir, filename)) rebuilder.schedule(dir);
|
|
7102
|
+
}));
|
|
6787
7103
|
ctx.state.render.serverReady({
|
|
6788
7104
|
local: `http://localhost:${port}`,
|
|
6789
7105
|
network: ctx.state.networkUrl(port),
|
|
@@ -6792,6 +7108,7 @@ async function runDevServer(ctx, port) {
|
|
|
6792
7108
|
return installSignalTeardown(() => {
|
|
6793
7109
|
rebuilder.cancel();
|
|
6794
7110
|
for (const watcher of watchers) watcher.close();
|
|
7111
|
+
hub.close();
|
|
6795
7112
|
server.stop();
|
|
6796
7113
|
});
|
|
6797
7114
|
}
|
|
@@ -7051,20 +7368,22 @@ function createApi$1(ctx) {
|
|
|
7051
7368
|
return runPreviewServer(ctx, port);
|
|
7052
7369
|
},
|
|
7053
7370
|
/**
|
|
7054
|
-
*
|
|
7055
|
-
*
|
|
7056
|
-
*
|
|
7057
|
-
* `
|
|
7058
|
-
*
|
|
7371
|
+
* Deploy to Cloudflare Pages. Two modes: `{ guided: true }` runs the interactive
|
|
7372
|
+
* setup wizard (diagnose prerequisites, guide fixes, gate, local test, deploy, offer
|
|
7373
|
+
* a CI workflow); the default direct path scaffolds, gates on a y/N confirm (shown
|
|
7374
|
+
* only to an interactive TTY with `CI` unset — non-interactive runs proceed so a
|
|
7375
|
+
* pipeline never hangs), then deploys. `options.yes` forces the skip. A "no" returns
|
|
7376
|
+
* `{ deployed: false, reason: "declined" }`; the wizard may return `"blocked"`.
|
|
7059
7377
|
*
|
|
7060
|
-
* @param options - Optional branch override and `
|
|
7061
|
-
* @returns The deploy outcome (completed details, or `declined`
|
|
7378
|
+
* @param options - Optional branch override, `yes` flag, and `guided` toggle.
|
|
7379
|
+
* @returns The deploy outcome (completed details, or a `declined`/`blocked` skip).
|
|
7062
7380
|
* @example
|
|
7063
|
-
* await api.deploy({
|
|
7381
|
+
* await api.deploy({ guided: true });
|
|
7064
7382
|
*/
|
|
7065
7383
|
async deploy(options = {}) {
|
|
7066
7384
|
const { branch, yes = false } = options;
|
|
7067
7385
|
ctx.state.render.header("deploy");
|
|
7386
|
+
if (options.guided === true) return runDeployWizard(ctx, options);
|
|
7068
7387
|
await ctx.require(deployPlugin).init({ ci: true });
|
|
7069
7388
|
if (!await confirmDeploy(ctx, yes)) return {
|
|
7070
7389
|
deployed: false,
|
|
@@ -7164,6 +7483,39 @@ const ANSI = {
|
|
|
7164
7483
|
cyan: `${ESC}[36m`,
|
|
7165
7484
|
gray: `${ESC}[90m`
|
|
7166
7485
|
};
|
|
7486
|
+
/** ANSI: erase the entire current line, leaving the cursor where it is. */
|
|
7487
|
+
const CLEAR_LINE = `${ESC}[2K`;
|
|
7488
|
+
/** ANSI: erase from the cursor to the end of the screen (drops stale trailing rows). */
|
|
7489
|
+
const CLEAR_BELOW = `${ESC}[0J`;
|
|
7490
|
+
/**
|
|
7491
|
+
* Braille spinner frames for live "working…" indicators on a TTY (advance one per tick).
|
|
7492
|
+
* Off a TTY the Panel never animates, so this is unused in plain/CI output.
|
|
7493
|
+
*/
|
|
7494
|
+
const SPINNER_FRAMES = [
|
|
7495
|
+
"⠋",
|
|
7496
|
+
"⠙",
|
|
7497
|
+
"⠹",
|
|
7498
|
+
"⠸",
|
|
7499
|
+
"⠼",
|
|
7500
|
+
"⠴",
|
|
7501
|
+
"⠦",
|
|
7502
|
+
"⠧",
|
|
7503
|
+
"⠇",
|
|
7504
|
+
"⠏"
|
|
7505
|
+
];
|
|
7506
|
+
/**
|
|
7507
|
+
* The ANSI sequence to move the cursor up `n` lines (empty string for `n <= 0`). The
|
|
7508
|
+
* Panel uses it to repaint a live block in place — move up over the previous draw, then
|
|
7509
|
+
* rewrite each row — so progress updates a fixed region instead of scrolling new lines.
|
|
7510
|
+
*
|
|
7511
|
+
* @param n - Number of lines to move the cursor up.
|
|
7512
|
+
* @returns The cursor-up escape sequence, or `""` when `n <= 0`.
|
|
7513
|
+
* @example
|
|
7514
|
+
* cursorUp(3); // "\x1b[3A"
|
|
7515
|
+
*/
|
|
7516
|
+
function cursorUp(n) {
|
|
7517
|
+
return n > 0 ? `${ESC}[${n}A` : "";
|
|
7518
|
+
}
|
|
7167
7519
|
/** Unicode rounded box glyphs used when output is a color-capable TTY. */
|
|
7168
7520
|
const UNICODE_BOX = {
|
|
7169
7521
|
topLeft: "╭",
|
|
@@ -7380,8 +7732,97 @@ function durationSuffix(palette, durationMs) {
|
|
|
7380
7732
|
function createPanelRenderer(options = {}) {
|
|
7381
7733
|
const write = options.write ?? ((line) => console.log(line));
|
|
7382
7734
|
const writeError = options.writeError ?? ((line) => console.error(line));
|
|
7735
|
+
const writeRaw = options.writeRaw ?? ((chunk) => {
|
|
7736
|
+
process.stdout.write(chunk);
|
|
7737
|
+
});
|
|
7738
|
+
const now = options.now ?? Date.now;
|
|
7383
7739
|
const color = options.color ?? supportsColor();
|
|
7384
7740
|
const palette = makePalette(color);
|
|
7741
|
+
let phaseRows = [];
|
|
7742
|
+
let phaseDrawn = 0;
|
|
7743
|
+
let phaseOpen = false;
|
|
7744
|
+
let rebuilding = false;
|
|
7745
|
+
let rebuildLabel = "";
|
|
7746
|
+
let rebuildStartedAt = 0;
|
|
7747
|
+
let spinnerFrame = 0;
|
|
7748
|
+
let ticker;
|
|
7749
|
+
/**
|
|
7750
|
+
* The current spinner glyph (with a static fallback under `noUncheckedIndexedAccess`).
|
|
7751
|
+
*
|
|
7752
|
+
* @returns The active braille spinner frame.
|
|
7753
|
+
* @example
|
|
7754
|
+
* frameGlyph(); // "⠙"
|
|
7755
|
+
*/
|
|
7756
|
+
const frameGlyph = () => SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length] ?? "⠋";
|
|
7757
|
+
/**
|
|
7758
|
+
* Render one phase row: a green `✓ name · time` when done, else a spinning cyan glyph
|
|
7759
|
+
* before the dim name.
|
|
7760
|
+
*
|
|
7761
|
+
* @param row - The phase row to render.
|
|
7762
|
+
* @returns The rendered row line (no trailing newline).
|
|
7763
|
+
* @example
|
|
7764
|
+
* renderPhaseRow({ name: "pages", done: true, durationMs: 12 });
|
|
7765
|
+
*/
|
|
7766
|
+
const renderPhaseRow = (row) => {
|
|
7767
|
+
if (row.done) return ` ${palette.green("✓")} ${row.name}${durationSuffix(palette, row.durationMs)}`;
|
|
7768
|
+
return ` ${palette.cyan(frameGlyph())} ${palette.dim(row.name)}`;
|
|
7769
|
+
};
|
|
7770
|
+
/**
|
|
7771
|
+
* Repaint the live phase block in place: move up over the prior draw, then rewrite each
|
|
7772
|
+
* row (clearing any stale trailing lines).
|
|
7773
|
+
*
|
|
7774
|
+
* @example
|
|
7775
|
+
* paintPhaseBlock();
|
|
7776
|
+
*/
|
|
7777
|
+
const paintPhaseBlock = () => {
|
|
7778
|
+
let frame = cursorUp(phaseDrawn);
|
|
7779
|
+
for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
|
|
7780
|
+
writeRaw(frame + CLEAR_BELOW);
|
|
7781
|
+
phaseDrawn = phaseRows.length;
|
|
7782
|
+
};
|
|
7783
|
+
/**
|
|
7784
|
+
* Repaint the single in-place rebuild line (spinner + label + live elapsed seconds).
|
|
7785
|
+
*
|
|
7786
|
+
* @example
|
|
7787
|
+
* paintRebuildLine();
|
|
7788
|
+
*/
|
|
7789
|
+
const paintRebuildLine = () => {
|
|
7790
|
+
const elapsed = ((now() - rebuildStartedAt) / 1e3).toFixed(1);
|
|
7791
|
+
const meta = palette.dim(`· ${elapsed}s`);
|
|
7792
|
+
writeRaw(`\r${CLEAR_LINE} ${palette.cyan(frameGlyph())} rebuilding ${rebuildLabel} ${meta}`);
|
|
7793
|
+
};
|
|
7794
|
+
/**
|
|
7795
|
+
* Advance the spinner one frame and repaint whichever live region is active.
|
|
7796
|
+
*
|
|
7797
|
+
* @example
|
|
7798
|
+
* onTick();
|
|
7799
|
+
*/
|
|
7800
|
+
const onTick = () => {
|
|
7801
|
+
spinnerFrame += 1;
|
|
7802
|
+
if (rebuilding) paintRebuildLine();
|
|
7803
|
+
else if (phaseOpen) paintPhaseBlock();
|
|
7804
|
+
};
|
|
7805
|
+
/**
|
|
7806
|
+
* Start the animation ticker (TTY only; idempotent; `unref`'d so it never blocks exit).
|
|
7807
|
+
*
|
|
7808
|
+
* @example
|
|
7809
|
+
* startTicker();
|
|
7810
|
+
*/
|
|
7811
|
+
const startTicker = () => {
|
|
7812
|
+
if (!color || ticker) return;
|
|
7813
|
+
ticker = setInterval(onTick, 80);
|
|
7814
|
+
ticker.unref?.();
|
|
7815
|
+
};
|
|
7816
|
+
/**
|
|
7817
|
+
* Stop the animation ticker if running.
|
|
7818
|
+
*
|
|
7819
|
+
* @example
|
|
7820
|
+
* stopTicker();
|
|
7821
|
+
*/
|
|
7822
|
+
const stopTicker = () => {
|
|
7823
|
+
if (ticker) clearInterval(ticker);
|
|
7824
|
+
ticker = void 0;
|
|
7825
|
+
};
|
|
7385
7826
|
/**
|
|
7386
7827
|
* Write each line of a multi-line block through the stdout sink.
|
|
7387
7828
|
*
|
|
@@ -7404,15 +7845,38 @@ function createPanelRenderer(options = {}) {
|
|
|
7404
7845
|
writeBlock(box([`${palette.bold(palette.cyan("MOKU WEB"))} ${palette.dim(COMMAND_LABEL[command])}`], color));
|
|
7405
7846
|
},
|
|
7406
7847
|
/**
|
|
7407
|
-
* Render a
|
|
7848
|
+
* Render a per-phase row from a `build:phase` event. On a TTY each phase is ONE row
|
|
7849
|
+
* that updates in place (spinning glyph while running → green ✓ + duration when done);
|
|
7850
|
+
* off a TTY one line is printed per completed phase (no start/done duplication). A
|
|
7851
|
+
* no-op while a serve() rebuild is in flight — those show the compact rebuild line.
|
|
7408
7852
|
*
|
|
7409
7853
|
* @param phase - The `build:phase` payload.
|
|
7410
7854
|
* @example
|
|
7411
7855
|
* render.phase({ phase: "pages", status: "done", durationMs: 12 });
|
|
7412
7856
|
*/
|
|
7413
7857
|
phase(phase) {
|
|
7858
|
+
if (rebuilding) return;
|
|
7859
|
+
if (!color) {
|
|
7860
|
+
if (phase.status === "done") write(` ${palette.green("✓")} ${phase.phase}${durationSuffix(palette, phase.durationMs)}`);
|
|
7861
|
+
return;
|
|
7862
|
+
}
|
|
7863
|
+
if (!phaseOpen) {
|
|
7864
|
+
phaseRows = [];
|
|
7865
|
+
phaseDrawn = 0;
|
|
7866
|
+
phaseOpen = true;
|
|
7867
|
+
}
|
|
7414
7868
|
const done = phase.status === "done";
|
|
7415
|
-
|
|
7869
|
+
const existing = phaseRows.find((row) => row.name === phase.phase);
|
|
7870
|
+
if (existing) {
|
|
7871
|
+
existing.done = done;
|
|
7872
|
+
existing.durationMs = phase.durationMs;
|
|
7873
|
+
} else phaseRows.push({
|
|
7874
|
+
name: phase.phase,
|
|
7875
|
+
done,
|
|
7876
|
+
durationMs: phase.durationMs
|
|
7877
|
+
});
|
|
7878
|
+
paintPhaseBlock();
|
|
7879
|
+
startTicker();
|
|
7416
7880
|
},
|
|
7417
7881
|
/**
|
|
7418
7882
|
* Render the BUILD summary block from a `build:complete` event.
|
|
@@ -7422,6 +7886,10 @@ function createPanelRenderer(options = {}) {
|
|
|
7422
7886
|
* render.built({ outDir: "dist", pageCount: 12, durationMs: 840 });
|
|
7423
7887
|
*/
|
|
7424
7888
|
built(summary) {
|
|
7889
|
+
if (rebuilding) return;
|
|
7890
|
+
phaseOpen = false;
|
|
7891
|
+
phaseDrawn = 0;
|
|
7892
|
+
stopTicker();
|
|
7425
7893
|
const pages = palette.bold(String(summary.pageCount));
|
|
7426
7894
|
writeBlock(box([
|
|
7427
7895
|
`${palette.green("✓")} ${palette.bold("BUILD")} complete`,
|
|
@@ -7443,15 +7911,47 @@ function createPanelRenderer(options = {}) {
|
|
|
7443
7911
|
writeBlock(box(lines, color));
|
|
7444
7912
|
},
|
|
7445
7913
|
/**
|
|
7446
|
-
*
|
|
7914
|
+
* Begin a serve() rebuild: show ONE compact "rebuilding {label}" line (an animated
|
|
7915
|
+
* spinner with live elapsed on a TTY; a plain "~ {label}" line otherwise) and mute
|
|
7916
|
+
* the verbose phase rows + BUILD box until {@link reload}/{@link error} settles it.
|
|
7917
|
+
*
|
|
7918
|
+
* @param label - The changed watch target shown in the line.
|
|
7919
|
+
* @example
|
|
7920
|
+
* render.rebuildStart("content");
|
|
7921
|
+
*/
|
|
7922
|
+
rebuildStart(label) {
|
|
7923
|
+
rebuilding = true;
|
|
7924
|
+
rebuildLabel = label;
|
|
7925
|
+
rebuildStartedAt = now();
|
|
7926
|
+
spinnerFrame = 0;
|
|
7927
|
+
if (!color) {
|
|
7928
|
+
write(` ${palette.yellow("~")} ${label}`);
|
|
7929
|
+
return;
|
|
7930
|
+
}
|
|
7931
|
+
paintRebuildLine();
|
|
7932
|
+
startTicker();
|
|
7933
|
+
},
|
|
7934
|
+
/**
|
|
7935
|
+
* Settle the current rebuild: replace the in-place "rebuilding…" line with a compact
|
|
7936
|
+
* "✓ rebuilt N pages · Xms · reloaded" (on a TTY) and re-enable verbose build output.
|
|
7937
|
+
* Called standalone (no preceding {@link rebuildStart}) it also prints the "~ file"
|
|
7938
|
+
* line so the changed target stays visible.
|
|
7447
7939
|
*
|
|
7448
7940
|
* @param info - The changed file plus the rebuild's page count and duration.
|
|
7449
7941
|
* @example
|
|
7450
7942
|
* render.reload({ file: "content/a.md", pageCount: 12, durationMs: 84 });
|
|
7451
7943
|
*/
|
|
7452
7944
|
reload(info) {
|
|
7453
|
-
|
|
7454
|
-
|
|
7945
|
+
const settledRebuild = rebuilding;
|
|
7946
|
+
rebuilding = false;
|
|
7947
|
+
stopTicker();
|
|
7948
|
+
const line = ` ${palette.green("✓")} rebuilt ${palette.bold(String(info.pageCount))} pages ${palette.dim(`· ${info.durationMs}ms · reloaded`)}`;
|
|
7949
|
+
if (settledRebuild && color) {
|
|
7950
|
+
writeRaw(`\r${CLEAR_LINE}${line}\n`);
|
|
7951
|
+
return;
|
|
7952
|
+
}
|
|
7953
|
+
if (!settledRebuild) write(` ${palette.yellow("~")} ${info.file}`);
|
|
7954
|
+
write(line);
|
|
7455
7955
|
},
|
|
7456
7956
|
/**
|
|
7457
7957
|
* Render the deploy result panel from a `deploy:complete` event.
|
|
@@ -7477,7 +7977,9 @@ function createPanelRenderer(options = {}) {
|
|
|
7477
7977
|
* render.info("watching for changes…");
|
|
7478
7978
|
*/
|
|
7479
7979
|
info(message) {
|
|
7480
|
-
|
|
7980
|
+
const [first = "", ...rest] = message.split("\n");
|
|
7981
|
+
write(` ${palette.cyan("›")} ${first}`);
|
|
7982
|
+
for (const line of rest) write(` ${line}`);
|
|
7481
7983
|
},
|
|
7482
7984
|
/**
|
|
7483
7985
|
* Render a warning line (to stderr).
|
|
@@ -7498,8 +8000,38 @@ function createPanelRenderer(options = {}) {
|
|
|
7498
8000
|
* render.error("build failed", err);
|
|
7499
8001
|
*/
|
|
7500
8002
|
error(message, cause) {
|
|
8003
|
+
if (rebuilding) {
|
|
8004
|
+
rebuilding = false;
|
|
8005
|
+
stopTicker();
|
|
8006
|
+
if (color) writeRaw(`\r${CLEAR_LINE}`);
|
|
8007
|
+
}
|
|
7501
8008
|
writeError(` ${palette.red("✗")} ${message}`);
|
|
7502
8009
|
if (cause !== void 0) writeError(String(cause));
|
|
8010
|
+
},
|
|
8011
|
+
/**
|
|
8012
|
+
* Render a section heading (a blank line + a bold cyan label) for a multi-step flow.
|
|
8013
|
+
*
|
|
8014
|
+
* @param text - The heading label.
|
|
8015
|
+
* @example
|
|
8016
|
+
* render.heading("Diagnostics");
|
|
8017
|
+
*/
|
|
8018
|
+
heading(text) {
|
|
8019
|
+
write("");
|
|
8020
|
+
write(` ${palette.bold(palette.cyan(text))}`);
|
|
8021
|
+
},
|
|
8022
|
+
/**
|
|
8023
|
+
* Render a diagnostic line: green `✓` (pass) or red `✗` (fail) + label, with optional
|
|
8024
|
+
* dim, indented detail beneath (e.g. a fix hint for a failing check).
|
|
8025
|
+
*
|
|
8026
|
+
* @param ok - Whether the check passed.
|
|
8027
|
+
* @param label - The check label.
|
|
8028
|
+
* @param detail - Optional multi-line guidance shown indented under the line.
|
|
8029
|
+
* @example
|
|
8030
|
+
* render.check(false, "CLOUDFLARE_API_TOKEN is set", "Create one at …");
|
|
8031
|
+
*/
|
|
8032
|
+
check(ok, label, detail) {
|
|
8033
|
+
write(` ${ok ? palette.green("✓") : palette.red("✗")} ${label}`);
|
|
8034
|
+
if (detail !== void 0) for (const line of detail.split("\n")) write(` ${palette.dim(line)}`);
|
|
7503
8035
|
}
|
|
7504
8036
|
};
|
|
7505
8037
|
}
|
|
@@ -7578,18 +8110,44 @@ function defaultConfirm(question) {
|
|
|
7578
8110
|
});
|
|
7579
8111
|
}
|
|
7580
8112
|
/**
|
|
8113
|
+
* Default stdin single-choice prompt. Prints the choices numbered from 1, reads a line
|
|
8114
|
+
* via `node:readline`, and resolves the chosen zero-based index (empty / out-of-range
|
|
8115
|
+
* falls back to 0). Tests inject a canned selection so no real TTY interaction occurs.
|
|
8116
|
+
*
|
|
8117
|
+
* @param question - The prompt to display.
|
|
8118
|
+
* @param choices - The selectable option labels.
|
|
8119
|
+
* @returns Resolves the chosen zero-based index.
|
|
8120
|
+
* @example
|
|
8121
|
+
* await defaultSelect("Trigger?", ["Auto on push", "Manual only"]);
|
|
8122
|
+
*/
|
|
8123
|
+
function defaultSelect(question, choices) {
|
|
8124
|
+
return new Promise((resolve) => {
|
|
8125
|
+
const readline = createInterface({
|
|
8126
|
+
input: process.stdin,
|
|
8127
|
+
output: process.stdout
|
|
8128
|
+
});
|
|
8129
|
+
for (const [index, choice] of choices.entries()) console.log(` ${index + 1}) ${choice}`);
|
|
8130
|
+
readline.question(`${question} [1-${choices.length}] `, (answer) => {
|
|
8131
|
+
readline.close();
|
|
8132
|
+
const picked = Number.parseInt(answer.trim(), 10);
|
|
8133
|
+
resolve(Number.isInteger(picked) && picked >= 1 && picked <= choices.length ? picked - 1 : 0);
|
|
8134
|
+
});
|
|
8135
|
+
});
|
|
8136
|
+
}
|
|
8137
|
+
/**
|
|
7581
8138
|
* Default recursive directory watcher — wraps `node:fs.watch` with `{ recursive: true }`
|
|
7582
8139
|
* and adapts its handle to {@link WatchHandle}. Tests inject a fake emitter so no real
|
|
7583
8140
|
* FS watch is registered.
|
|
7584
8141
|
*
|
|
7585
8142
|
* @param dir - The directory to watch recursively.
|
|
7586
|
-
* @param onChange - Invoked on any change beneath `dir
|
|
8143
|
+
* @param onChange - Invoked on any change beneath `dir`, forwarding the changed path
|
|
8144
|
+
* relative to `dir` when `node:fs.watch` reports it (`undefined` otherwise).
|
|
7587
8145
|
* @returns A handle whose `close()` detaches the watcher.
|
|
7588
8146
|
* @example
|
|
7589
|
-
* const handle = defaultWatch("content",
|
|
8147
|
+
* const handle = defaultWatch("content", file => rebuild(file));
|
|
7590
8148
|
*/
|
|
7591
8149
|
function defaultWatch(dir, onChange) {
|
|
7592
|
-
const watcher = watch(dir, { recursive: true }, () => onChange());
|
|
8150
|
+
const watcher = watch(dir, { recursive: true }, (_event, filename) => onChange(typeof filename === "string" ? filename : void 0));
|
|
7593
8151
|
return {
|
|
7594
8152
|
/**
|
|
7595
8153
|
* Detach the underlying `node:fs.watch` listener.
|
|
@@ -7602,6 +8160,23 @@ close() {
|
|
|
7602
8160
|
} };
|
|
7603
8161
|
}
|
|
7604
8162
|
/**
|
|
8163
|
+
* Default file-mtime probe — `node:fs.statSync(path).mtimeMs`, returning `null` for a
|
|
8164
|
+
* missing path (so a deleted file still reads as a change). serve() compares this
|
|
8165
|
+
* across `fs.watch` events to drop the duplicate notifications macOS fires per save.
|
|
8166
|
+
*
|
|
8167
|
+
* @param filePath - The absolute path to stat.
|
|
8168
|
+
* @returns The modification time in epoch milliseconds, or `null` when absent.
|
|
8169
|
+
* @example
|
|
8170
|
+
* const mtime = defaultFileMtime("/abs/content/a.md");
|
|
8171
|
+
*/
|
|
8172
|
+
function defaultFileMtime(filePath) {
|
|
8173
|
+
try {
|
|
8174
|
+
return statSync(filePath).mtimeMs;
|
|
8175
|
+
} catch {
|
|
8176
|
+
return null;
|
|
8177
|
+
}
|
|
8178
|
+
}
|
|
8179
|
+
/**
|
|
7605
8180
|
* Default LAN network-URL deriver — wraps {@link networkUrl} so the production seam
|
|
7606
8181
|
* reads `node:os` interfaces while tests can inject a deterministic value.
|
|
7607
8182
|
*
|
|
@@ -7629,11 +8204,13 @@ function createState$1(_ctx) {
|
|
|
7629
8204
|
return {
|
|
7630
8205
|
render: createPanelRenderer(),
|
|
7631
8206
|
confirm: defaultConfirm,
|
|
8207
|
+
select: defaultSelect,
|
|
7632
8208
|
clock: Date.now,
|
|
7633
8209
|
watch: defaultWatch,
|
|
7634
8210
|
serveStatic: defaultServeStatic,
|
|
7635
8211
|
fileResponse: defaultFileResponse,
|
|
7636
|
-
networkUrl: defaultNetworkUrl
|
|
8212
|
+
networkUrl: defaultNetworkUrl,
|
|
8213
|
+
fileMtime: defaultFileMtime
|
|
7637
8214
|
};
|
|
7638
8215
|
}
|
|
7639
8216
|
//#endregion
|