@moku-labs/web 1.0.1 → 1.2.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 CHANGED
@@ -10,7 +10,9 @@ node_path = require_convention.__toESM(node_path);
10
10
  let feed = require("feed");
11
11
  let preact = require("preact");
12
12
  let preact_render_to_string = require("preact-render-to-string");
13
+ let node_child_process = require("node:child_process");
13
14
  let node_readline = require("node:readline");
15
+ let node_url = require("node:url");
14
16
  let node_os = require("node:os");
15
17
  let gray_matter = require("gray-matter");
16
18
  gray_matter = require_convention.__toESM(gray_matter, 1);
@@ -4890,10 +4892,47 @@ function findRootHtml(rendered) {
4890
4892
  return rendered.find((page) => page.url === "/" || page.url === "")?.html ?? null;
4891
4893
  }
4892
4894
  /**
4895
+ * Pages rendered concurrently per batch. Kept small so the macrotask yield between
4896
+ * batches fires frequently — a large batch renders for seconds before yielding, which
4897
+ * leaves a watching dev server's spinner repainting only every few seconds (sluggish).
4898
+ * Smaller batches trade a little write-concurrency for a smooth, responsive spinner.
4899
+ */
4900
+ const RENDER_BATCH_SIZE = 2;
4901
+ /**
4902
+ * Render `items` through `worker` in bounded-size batches, yielding a macrotask
4903
+ * (`setImmediate`) between batches. Beyond bounding peak concurrency/memory for large
4904
+ * sites, the yield lets the single JS thread breathe: one un-yielded `Promise.all` over
4905
+ * hundreds of synchronous `renderToString` calls starves the event loop, which freezes a
4906
+ * watching dev server's progress spinner until the whole phase resolves. Output order is
4907
+ * preserved (batch order + `Promise.all` order within a batch).
4908
+ *
4909
+ * @template Item - The input item type.
4910
+ * @template Out - The rendered output type.
4911
+ * @param items - The items to render.
4912
+ * @param batchSize - Maximum items rendered concurrently per batch.
4913
+ * @param worker - Renders one item to its output.
4914
+ * @returns All rendered outputs in input order.
4915
+ * @example
4916
+ * ```ts
4917
+ * const pages = await renderInBatches(instances, 32, i => renderInstance(ctx, i, shell));
4918
+ * ```
4919
+ */
4920
+ async function renderInBatches(items, batchSize, worker) {
4921
+ const out = [];
4922
+ for (let start = 0; start < items.length; start += batchSize) {
4923
+ const batch = items.slice(start, start + batchSize);
4924
+ out.push(...await Promise.all(batch.map((item) => worker(item))));
4925
+ if (start + batchSize < items.length) await new Promise((resolve) => {
4926
+ setImmediate(resolve);
4927
+ });
4928
+ }
4929
+ return out;
4930
+ }
4931
+ /**
4893
4932
  * Renders every route in the manifest to `outDir/<path>/index.html`. Reads as a
4894
- * pipeline: resolve deps → prepare the shared shell → expand instances → render all
4895
- * concurrently (`Promise.all`, legal intra-plugin concurrency) → write data sidecars
4896
- * (hybrid/spa) → capture the root page's HTML for the root-index phase.
4933
+ * pipeline: resolve deps → prepare the shared shell → expand instances → render in
4934
+ * bounded batches ({@link renderInBatches}) → write data sidecars (hybrid/spa) →
4935
+ * capture the root page's HTML for the root-index phase.
4897
4936
  *
4898
4937
  * @param ctx - Plugin context (provides `require`, `state`, `config`, `log`, `has`).
4899
4938
  * @returns The number of pages rendered and the captured default-page HTML.
@@ -4909,8 +4948,7 @@ async function renderPages(ctx) {
4909
4948
  const locales = ctx.require(i18nPlugin).locales();
4910
4949
  const byPattern = makeEntryMap(router);
4911
4950
  const shell = await prepareShell(ctx);
4912
- const instances = await expandAllInstances(manifest, locales, byPattern, ctx);
4913
- const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance, shell)));
4951
+ const rendered = await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell));
4914
4952
  await writeDataSidecars(ctx, rendered, router.mode());
4915
4953
  ctx.log.debug("build:pages", { count: rendered.length });
4916
4954
  return {
@@ -5774,6 +5812,19 @@ const CHECKOUT_SHA = "11bd71901bbe5b1630ceea73d27597364c9af683";
5774
5812
  const SETUP_BUN_SHA = "4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5";
5775
5813
  /** Pinned `cloudflare/wrangler-action` commit SHA. */
5776
5814
  const WRANGLER_ACTION_SHA = "f84a562284fc78278ff9052435d9526f9c718361";
5815
+ /** The `on:` block for each {@link WorkflowTrigger} (kept indentation-exact for YAML). */
5816
+ const TRIGGER_ON_BLOCKS = {
5817
+ auto: `on:
5818
+ push:
5819
+ branches: [main]
5820
+ workflow_dispatch:`,
5821
+ "versioned-tag": `on:
5822
+ push:
5823
+ tags: ["v*"]
5824
+ workflow_dispatch:`,
5825
+ dispatch: `on:
5826
+ workflow_dispatch:`
5827
+ };
5777
5828
  /**
5778
5829
  * Generate a SHA-pinned GitHub Actions workflow that builds and deploys to
5779
5830
  * Cloudflare Pages. Every action is pinned to a commit SHA (with a `# vX`
@@ -5783,9 +5834,10 @@ const WRANGLER_ACTION_SHA = "f84a562284fc78278ff9052435d9526f9c718361";
5783
5834
  *
5784
5835
  * @param input - The generator inputs.
5785
5836
  * @param input.slug - Cloudflare project-name slug used as the wrangler `--project-name`.
5837
+ * @param input.trigger - What fires the workflow (see {@link WorkflowTrigger}). Default `"auto"`.
5786
5838
  * @returns The workflow YAML.
5787
5839
  * @example
5788
- * generateGithubWorkflow({ slug: "my-site" });
5840
+ * generateGithubWorkflow({ slug: "my-site", trigger: "versioned-tag" });
5789
5841
  */
5790
5842
  function generateGithubWorkflow(input) {
5791
5843
  return `# .github/workflows/deploy.yml — generated by \`app.deploy.init({ ci: true })\`.
@@ -5793,10 +5845,7 @@ function generateGithubWorkflow(input) {
5793
5845
 
5794
5846
  name: Deploy
5795
5847
 
5796
- on:
5797
- push:
5798
- branches: [main]
5799
- workflow_dispatch:
5848
+ ${TRIGGER_ON_BLOCKS[input.trigger ?? "auto"]}
5800
5849
 
5801
5850
  permissions:
5802
5851
  contents: read
@@ -5928,7 +5977,10 @@ async function writeScaffolding(input) {
5928
5977
  });
5929
5978
  if (ci) await reconcile({
5930
5979
  relativePath: WORKFLOW_PATH,
5931
- expected: generateGithubWorkflow({ slug }),
5980
+ expected: generateGithubWorkflow({
5981
+ slug,
5982
+ ...options.workflowTrigger ? { trigger: options.workflowTrigger } : {}
5983
+ }),
5932
5984
  existing: await readMaybe(cwd, WORKFLOW_PATH),
5933
5985
  cwd,
5934
5986
  check,
@@ -6446,6 +6498,224 @@ const deployPlugin = createPlugin$1("deploy", {
6446
6498
  api: createApi$2
6447
6499
  });
6448
6500
  //#endregion
6501
+ //#region src/plugins/cli/deploy-wizard.ts
6502
+ /**
6503
+ * @file cli plugin — the guided deploy wizard (`cli.deploy({ guided: true })`, the default
6504
+ * for `bun run deploy`; the direct `--cli` path stays in `api.ts`). Walks a human through a
6505
+ * Cloudflare Pages deploy: checks prerequisites (wrangler config + the Cloudflare
6506
+ * credentials) with concrete fix guidance, offers to scaffold what is missing (a
6507
+ * `wrangler.jsonc`, and a placeholder `.env` for any missing credentials), HARD-GATES the
6508
+ * deploy on everything being green, runs a local build smoke test, confirms, deploys, then
6509
+ * offers to scaffold a GitHub Actions workflow (auto on push to main, or a versioned/manual
6510
+ * trigger). Every prompt + line of output flows through injectable `state` seams.
6511
+ */
6512
+ /** How to create a Cloudflare API token + where to make it available locally. */
6513
+ const TOKEN_HELP = [
6514
+ "Create one at https://dash.cloudflare.com/profile/api-tokens → Create Token →",
6515
+ "use the \"Cloudflare Pages — Edit\" template (or a custom token with the",
6516
+ "Account › Cloudflare Pages › Edit permission). Then make it available:",
6517
+ " export CLOUDFLARE_API_TOKEN=… (shell) or add it to .env (gitignored)."
6518
+ ].join("\n");
6519
+ /** Where to find the Cloudflare account id + where to make it available locally. */
6520
+ const ACCOUNT_HELP = [
6521
+ "Find it on the Cloudflare dashboard → Workers & Pages: the Account ID is in the",
6522
+ "right-hand sidebar (also in the dashboard URL). Then make it available:",
6523
+ " export CLOUDFLARE_ACCOUNT_ID=… or add it to .env."
6524
+ ].join("\n");
6525
+ /** The GitHub repo secrets the generated workflow consumes. */
6526
+ const SECRETS_HELP = ["Add these repo secrets (GitHub → Settings → Secrets and variables → Actions):", "CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID"].join("\n");
6527
+ /**
6528
+ * Evaluate the three deploy prerequisites against the current project: the Cloudflare
6529
+ * wrangler config exists, and both Cloudflare credentials are present in the environment.
6530
+ *
6531
+ * @param cwd - The project root (where `wrangler.jsonc` lives).
6532
+ * @returns The ordered prerequisite checks.
6533
+ * @example
6534
+ * const prereqs = diagnose(process.cwd());
6535
+ */
6536
+ function diagnose(cwd) {
6537
+ const wranglerOk = (0, node_fs.existsSync)(node_path$1.default.join(cwd, "wrangler.jsonc"));
6538
+ const tokenOk = (process.env.CLOUDFLARE_API_TOKEN ?? "") !== "";
6539
+ const accountOk = (process.env.CLOUDFLARE_ACCOUNT_ID ?? "") !== "";
6540
+ return [
6541
+ {
6542
+ ok: wranglerOk,
6543
+ label: "wrangler.jsonc (Cloudflare project config)",
6544
+ detail: wranglerOk ? void 0 : "Missing — scaffold it (offered below) or run app.deploy.init().",
6545
+ scaffoldable: true
6546
+ },
6547
+ {
6548
+ ok: tokenOk,
6549
+ label: "CLOUDFLARE_API_TOKEN is set",
6550
+ detail: tokenOk ? void 0 : TOKEN_HELP,
6551
+ scaffoldable: false
6552
+ },
6553
+ {
6554
+ ok: accountOk,
6555
+ label: "CLOUDFLARE_ACCOUNT_ID is set",
6556
+ detail: accountOk ? void 0 : ACCOUNT_HELP,
6557
+ scaffoldable: false
6558
+ }
6559
+ ];
6560
+ }
6561
+ /**
6562
+ * Offer to scaffold a missing `wrangler.jsonc` (the only auto-fixable prerequisite),
6563
+ * generating it via the deploy plugin when the user accepts.
6564
+ *
6565
+ * @param ctx - The cli plugin context.
6566
+ * @param prereqs - The current prerequisite checks.
6567
+ * @returns Resolves once any accepted fix has run.
6568
+ * @example
6569
+ * await offerScaffold(ctx, diagnose(cwd));
6570
+ */
6571
+ async function offerScaffold(ctx, prereqs) {
6572
+ if (!prereqs.some((item) => item.scaffoldable && !item.ok)) return;
6573
+ if (!await ctx.state.confirm("Scaffold wrangler.jsonc now?")) return;
6574
+ await ctx.require(deployPlugin).init({});
6575
+ ctx.state.render.check(true, "wrangler.jsonc scaffolded");
6576
+ }
6577
+ /** The Cloudflare credentials the deploy needs, with the comment written above each in a scaffolded `.env`. */
6578
+ const ENV_CREDENTIALS = [{
6579
+ key: "CLOUDFLARE_API_TOKEN",
6580
+ comment: "# Cloudflare API token — https://dash.cloudflare.com/profile/api-tokens (template: Cloudflare Pages — Edit)"
6581
+ }, {
6582
+ key: "CLOUDFLARE_ACCOUNT_ID",
6583
+ comment: "# Cloudflare account id — dashboard → Workers & Pages → right-hand sidebar"
6584
+ }];
6585
+ /**
6586
+ * Offer to scaffold a `.env` with placeholders for whichever Cloudflare credentials are
6587
+ * missing — created when absent, appended to (never clobbering a key already present)
6588
+ * when it exists. The placeholders are empty, so the deploy still hard-gates until the
6589
+ * user fills them in; this just removes the "where do I even put these?" friction.
6590
+ *
6591
+ * @param ctx - The cli plugin context.
6592
+ * @param cwd - The project root (where `.env` lives).
6593
+ * @returns Resolves once any accepted scaffold has been written.
6594
+ * @example
6595
+ * await offerEnvScaffold(ctx, process.cwd());
6596
+ */
6597
+ async function offerEnvScaffold(ctx, cwd) {
6598
+ const missing = ENV_CREDENTIALS.filter(({ key }) => (process.env[key] ?? "") === "");
6599
+ if (missing.length === 0) return;
6600
+ const envPath = node_path$1.default.join(cwd, ".env");
6601
+ const exists = (0, node_fs.existsSync)(envPath);
6602
+ const verb = exists ? "Add placeholders for the missing secret(s) to" : "Create";
6603
+ if (!await ctx.state.confirm(`${verb} .env?`)) return;
6604
+ const lines = exists ? (0, node_fs.readFileSync)(envPath, "utf8").split(/\r?\n/) : [];
6605
+ const toAdd = missing.filter(({ key }) => !lines.some((line) => line.trimStart().startsWith(`${key}=`)));
6606
+ if (toAdd.length === 0) {
6607
+ ctx.state.render.info(".env already lists those keys — fill in their values, then re-run.");
6608
+ return;
6609
+ }
6610
+ (0, node_fs.appendFileSync)(envPath, `${exists ? "\n" : "# Cloudflare Pages deploy credentials — fill these in (keep .env gitignored).\n"}${toAdd.map(({ key, comment }) => `${comment}\n${key}=`).join("\n\n")}\n`);
6611
+ const names = toAdd.map(({ key }) => key).join(", ");
6612
+ ctx.state.render.check(true, `${exists ? "added placeholders to" : "created"} .env`, `fill in ${names}, then re-run \`bun run deploy\`.`);
6613
+ }
6614
+ /**
6615
+ * Map a top-level workflow choice (and, for the versioned option, a sub-choice) to the
6616
+ * concrete {@link WorkflowTrigger}, or `null` when the user chose to skip setup.
6617
+ *
6618
+ * @param ctx - The cli plugin context (for the follow-up sub-choice prompt).
6619
+ * @param choice - The selected zero-based index of the top-level options.
6620
+ * @returns The resolved trigger, or `null` to skip.
6621
+ * @example
6622
+ * const trigger = await resolveTrigger(ctx, 1);
6623
+ */
6624
+ async function resolveTrigger(ctx, choice) {
6625
+ if (choice === 2) return null;
6626
+ if (choice === 0) return "auto";
6627
+ 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";
6628
+ }
6629
+ /**
6630
+ * Offer to scaffold a GitHub Actions deploy workflow, letting the user choose how it is
6631
+ * triggered, then remind them which repo secrets to add. A no-op past a "skip" choice.
6632
+ *
6633
+ * @param ctx - The cli plugin context.
6634
+ * @returns Resolves once any chosen workflow has been scaffolded.
6635
+ * @example
6636
+ * await offerWorkflowSetup(ctx);
6637
+ */
6638
+ async function offerWorkflowSetup(ctx) {
6639
+ ctx.state.render.heading("Automate future deploys (GitHub Actions)");
6640
+ const trigger = await resolveTrigger(ctx, await ctx.state.select("Set up a deploy workflow?", [
6641
+ "Auto-deploy on every push to main",
6642
+ "Manual / versioned deploy (choose trigger)",
6643
+ "Skip for now"
6644
+ ]));
6645
+ if (trigger === null) return;
6646
+ const result = await ctx.require(deployPlugin).init({
6647
+ ci: true,
6648
+ workflowTrigger: trigger
6649
+ });
6650
+ const workflowPath = ".github/workflows/deploy.yml";
6651
+ const wrote = result.written.includes(workflowPath);
6652
+ ctx.state.render.check(true, wrote ? `wrote ${workflowPath}` : `${workflowPath} already exists (left unchanged)`);
6653
+ ctx.state.render.info(SECRETS_HELP);
6654
+ }
6655
+ /**
6656
+ * Run the deploy step: confirm (unless `yes`), then deploy via the deploy plugin and
6657
+ * report the outcome. A declined confirm returns `{ deployed: false, reason: "declined" }`.
6658
+ *
6659
+ * @param ctx - The cli plugin context.
6660
+ * @param options - The deploy options (branch override + `yes`).
6661
+ * @returns The deploy outcome.
6662
+ * @example
6663
+ * const outcome = await runDeployStep(ctx, { yes: true });
6664
+ */
6665
+ async function runDeployStep(ctx, options) {
6666
+ ctx.state.render.heading("Deploy");
6667
+ if (!(options.yes === true || await ctx.state.confirm(`Deploy ${ctx.config.outDir}/ to Cloudflare Pages now?`))) {
6668
+ ctx.state.render.warn("deploy skipped");
6669
+ return {
6670
+ deployed: false,
6671
+ reason: "declined"
6672
+ };
6673
+ }
6674
+ return {
6675
+ deployed: true,
6676
+ ...await ctx.require(deployPlugin).run(options.branch === void 0 ? {} : { branch: options.branch })
6677
+ };
6678
+ }
6679
+ /**
6680
+ * Run the guided deploy wizard end to end: diagnose prerequisites (offering to scaffold
6681
+ * the wrangler config), HARD-GATE on the remaining blockers, run a local build smoke
6682
+ * test, deploy (with confirmation), then offer to scaffold a CI workflow. Returns
6683
+ * `{ deployed: false, reason: "blocked" }` when prerequisites are unmet, so a thin script
6684
+ * can exit non-zero. Assumes the caller already rendered the `deploy` header.
6685
+ *
6686
+ * @param ctx - The cli plugin context (state seams + `require` + config).
6687
+ * @param options - The deploy options (branch override, `yes`, `guided`).
6688
+ * @returns The deploy outcome (`deployed`, or a `declined`/`blocked` skip).
6689
+ * @example
6690
+ * const outcome = await runDeployWizard(ctx, { guided: true });
6691
+ */
6692
+ async function runDeployWizard(ctx, options) {
6693
+ const cwd = process.cwd();
6694
+ ctx.state.render.heading("Checking prerequisites");
6695
+ for (const item of diagnose(cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
6696
+ await offerScaffold(ctx, diagnose(cwd));
6697
+ await offerEnvScaffold(ctx, cwd);
6698
+ const blockers = diagnose(cwd).filter((item) => !item.ok);
6699
+ if (blockers.length > 0) {
6700
+ ctx.state.render.heading("Not ready to deploy");
6701
+ for (const item of blockers) ctx.state.render.check(false, item.label, item.detail);
6702
+ ctx.state.render.warn(`Fix the ${blockers.length} item(s) above, then re-run \`bun run deploy\`.`);
6703
+ return {
6704
+ deployed: false,
6705
+ reason: "blocked"
6706
+ };
6707
+ }
6708
+ ctx.state.render.heading("Local test");
6709
+ const summary = await ctx.require(buildPlugin).run();
6710
+ ctx.state.render.check(true, `built ${summary.pageCount} pages → ${summary.outDir}/`);
6711
+ const notFoundOk = (0, node_fs.existsSync)(node_path$1.default.join(ctx.config.outDir, ctx.config.notFoundFile));
6712
+ 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).");
6713
+ ctx.state.render.info("Tip: run `bun run preview` to eyeball the built site before deploying.");
6714
+ const outcome = await runDeployStep(ctx, options);
6715
+ await offerWorkflowSetup(ctx);
6716
+ return outcome;
6717
+ }
6718
+ //#endregion
6449
6719
  //#region src/plugins/cli/errors.ts
6450
6720
  /** Error prefix for cli config/validation/runtime failures (spec/11 Part-3). */
6451
6721
  const ERROR_PREFIX$3 = "[web] cli";
@@ -6479,11 +6749,12 @@ function injectReloadClient(html) {
6479
6749
  return index === -1 ? html + RELOAD_CLIENT : html.slice(0, index) + RELOAD_CLIENT + html.slice(index);
6480
6750
  }
6481
6751
  /**
6482
- * Run one rebuild and report the result. Skips re-entrancy via the shared `building`
6483
- * flag and routes success to `onReloaded`, failure to `onError`.
6752
+ * Run one rebuild and report the result. Announces the start (`onRebuildStart`), then
6753
+ * routes success to `onReloaded` and failure to `onError`.
6484
6754
  *
6485
6755
  * @param input - The rebuild dependencies + the changed file.
6486
6756
  * @param input.runBuild - Runs one build and resolves with its summary.
6757
+ * @param input.onRebuildStart - Called with the changed file just before the build runs.
6487
6758
  * @param input.onReloaded - Called with the changed file + summary after a rebuild.
6488
6759
  * @param input.onError - Called when a rebuild throws.
6489
6760
  * @param input.file - The changed file to report alongside the summary.
@@ -6492,6 +6763,7 @@ function injectReloadClient(html) {
6492
6763
  * await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md" });
6493
6764
  */
6494
6765
  async function runOneRebuild(input) {
6766
+ input.onRebuildStart?.(input.file);
6495
6767
  try {
6496
6768
  const summary = await input.runBuild();
6497
6769
  input.onReloaded({
@@ -6513,6 +6785,7 @@ async function runOneRebuild(input) {
6513
6785
  * @param input - The rebuild dependencies.
6514
6786
  * @param input.debounceMs - Debounce window in milliseconds.
6515
6787
  * @param input.runBuild - Runs one build and resolves with its summary.
6788
+ * @param input.onRebuildStart - Called with the changed file just before each build runs.
6516
6789
  * @param input.onReloaded - Called with the changed file + summary after a rebuild.
6517
6790
  * @param input.onError - Called when a rebuild throws.
6518
6791
  * @returns The debounced rebuild driver.
@@ -6539,6 +6812,7 @@ function createRebuilder(input) {
6539
6812
  dirty = false;
6540
6813
  await runOneRebuild({
6541
6814
  runBuild: input.runBuild,
6815
+ ...input.onRebuildStart ? { onRebuildStart: input.onRebuildStart } : {},
6542
6816
  onReloaded: input.onReloaded,
6543
6817
  onError: input.onError,
6544
6818
  file: pendingFile
@@ -6592,6 +6866,70 @@ function createRebuilder(input) {
6592
6866
  };
6593
6867
  }
6594
6868
  /**
6869
+ * Whether a changed path (relative to a watched dir) is editor/OS noise that is never a
6870
+ * page source: any hidden segment (`.DS_Store`, anything under `.git/` or `.cache/`,
6871
+ * vim `.*.swp`) or a `~` backup file. Checks every segment, not just the basename.
6872
+ *
6873
+ * @param filename - The changed path relative to its watched directory.
6874
+ * @returns `true` when the change should be ignored as noise.
6875
+ * @example
6876
+ * isNoisePath(".git/HEAD"); // true
6877
+ */
6878
+ function isNoisePath(filename) {
6879
+ return filename.split(/[/\\]/).some((segment) => segment.startsWith(".")) || filename.endsWith("~");
6880
+ }
6881
+ /**
6882
+ * Create a {@link ChangeGate} that drops three kinds of spurious change events before
6883
+ * they reach the debounced rebuilder: editor/OS noise (dotfiles, backups), writes under
6884
+ * `outDir` (the build's own output — a loop guard), and the stale duplicate/parent-dir
6885
+ * echoes macOS fires for one save. Staleness is judged by a build-start high-water mark:
6886
+ * a change whose file mtime is at or before the last build we started was already
6887
+ * captured (or is a late echo), so it is ignored — while a genuinely newer edit (even
6888
+ * one made mid-build) and a deletion (missing file) always pass. The single timestamp
6889
+ * also means no per-path map grows over a long session.
6890
+ *
6891
+ * @param input - The gate dependencies.
6892
+ * @param input.outDir - The build output directory whose writes must never re-trigger a build.
6893
+ * @param input.fileMtime - Resolves a path's mtime in ms (or `null` when missing).
6894
+ * @param input.now - Monotonic wall clock (ms) used for the build-start high-water mark.
6895
+ * @returns The change gate.
6896
+ * @example
6897
+ * const gate = createChangeGate({ outDir: "dist", fileMtime: state.fileMtime, now: state.clock });
6898
+ */
6899
+ function createChangeGate(input) {
6900
+ const outDirAbs = node_path$1.default.resolve(input.outDir);
6901
+ let lastBuildStartedAt = input.now();
6902
+ return {
6903
+ /**
6904
+ * Decide whether a change beneath `dir` warrants a rebuild (see {@link ChangeGate.accept}).
6905
+ *
6906
+ * @param dir - The watched directory the event fired on.
6907
+ * @param filename - The changed path relative to `dir` (or `undefined`).
6908
+ * @returns `true` to schedule a rebuild, `false` to ignore.
6909
+ * @example
6910
+ * gate.accept("content", "post/en.md");
6911
+ */
6912
+ accept(dir, filename) {
6913
+ if (filename === void 0) return true;
6914
+ if (isNoisePath(filename)) return false;
6915
+ const changed = node_path$1.default.resolve(dir, filename);
6916
+ if (changed === outDirAbs || changed.startsWith(`${outDirAbs}${node_path$1.default.sep}`)) return false;
6917
+ const mtime = input.fileMtime(changed);
6918
+ if (mtime !== null && mtime < lastBuildStartedAt) return false;
6919
+ return true;
6920
+ },
6921
+ /**
6922
+ * Advance the high-water mark to now (see {@link ChangeGate.markBuildStart}).
6923
+ *
6924
+ * @example
6925
+ * gate.markBuildStart();
6926
+ */
6927
+ markBuildStart() {
6928
+ lastBuildStartedAt = input.now();
6929
+ }
6930
+ };
6931
+ }
6932
+ /**
6595
6933
  * Install SIGINT/SIGTERM handlers that run `teardown()` and resolve the returned
6596
6934
  * promise, so a long-running command (`serve`/`preview`) unblocks its `await` on
6597
6935
  * Ctrl-C / termination and detaches its own listeners. Used by both servers.
@@ -6623,18 +6961,45 @@ function installSignalTeardown(teardown) {
6623
6961
  const SSE_OPEN = ": connected\n\n";
6624
6962
  /** The SSE frame pushed to reload a connected browser. */
6625
6963
  const SSE_RELOAD = "event: reload\ndata: 1\n\n";
6964
+ /** The SSE comment frame sent on the heartbeat to keep an idle stream warm. */
6965
+ const SSE_PING = ": ping\n\n";
6966
+ /** Default heartbeat interval (ms): one ping well under any 30s+ proxy idle window. */
6967
+ const DEFAULT_HEARTBEAT_MS = 15e3;
6626
6968
  /**
6627
6969
  * Create a {@link ReloadHub} backed by `ReadableStream` controllers. Each `connect()`
6628
6970
  * enqueues into a new stream; `reloadAll()` writes the reload frame to every live
6629
- * controller (dropping any that have closed).
6971
+ * controller (dropping any that have closed). A periodic heartbeat comment keeps idle
6972
+ * streams warm — belt-and-suspenders alongside the dev server's `idleTimeout: 0`, so a
6973
+ * quiet connection is never severed (which the browser surfaces as
6974
+ * `ERR_INCOMPLETE_CHUNKED_ENCODING` and then reconnects in a storm).
6630
6975
  *
6976
+ * @param options - Optional heartbeat tuning.
6977
+ * @param options.heartbeatMs - Heartbeat interval in ms (`0` disables). Default `15000`.
6631
6978
  * @returns The reload hub.
6632
6979
  * @example
6633
6980
  * const hub = createReloadHub();
6634
6981
  */
6635
- function createReloadHub() {
6982
+ function createReloadHub(options = {}) {
6636
6983
  const encoder = new TextEncoder();
6637
6984
  const clients = /* @__PURE__ */ new Set();
6985
+ /**
6986
+ * Enqueue one frame to every live controller, dropping any that have closed.
6987
+ *
6988
+ * @param frame - The SSE wire text to broadcast.
6989
+ * @example
6990
+ * broadcast(SSE_RELOAD);
6991
+ */
6992
+ const broadcast = (frame) => {
6993
+ const bytes = encoder.encode(frame);
6994
+ for (const controller of clients) try {
6995
+ controller.enqueue(bytes);
6996
+ } catch {
6997
+ clients.delete(controller);
6998
+ }
6999
+ };
7000
+ const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
7001
+ const heartbeat = heartbeatMs > 0 ? setInterval(() => broadcast(SSE_PING), heartbeatMs) : void 0;
7002
+ heartbeat?.unref?.();
6638
7003
  return {
6639
7004
  /**
6640
7005
  * Open one SSE connection, register its controller, and return the streaming
@@ -6682,11 +7047,7 @@ function createReloadHub() {
6682
7047
  * hub.reloadAll();
6683
7048
  */
6684
7049
  reloadAll() {
6685
- for (const controller of clients) try {
6686
- controller.enqueue(encoder.encode(SSE_RELOAD));
6687
- } catch {
6688
- clients.delete(controller);
6689
- }
7050
+ broadcast(SSE_RELOAD);
6690
7051
  },
6691
7052
  /**
6692
7053
  * The number of currently-connected clients.
@@ -6697,6 +7058,19 @@ function createReloadHub() {
6697
7058
  */
6698
7059
  size() {
6699
7060
  return clients.size;
7061
+ },
7062
+ /**
7063
+ * Stop the heartbeat and close every live SSE stream (SIGINT/SIGTERM teardown).
7064
+ *
7065
+ * @example
7066
+ * hub.close();
7067
+ */
7068
+ close() {
7069
+ if (heartbeat !== void 0) clearInterval(heartbeat);
7070
+ for (const controller of clients) try {
7071
+ controller.close();
7072
+ } catch {}
7073
+ clients.clear();
6700
7074
  }
6701
7075
  };
6702
7076
  }
@@ -6760,8 +7134,14 @@ async function runDevServer(ctx, port) {
6760
7134
  const hub = createReloadHub();
6761
7135
  const server = ctx.state.serveStatic({
6762
7136
  port,
7137
+ idleTimeout: 0,
6763
7138
  fetch: createDevHandler(ctx, hub)
6764
7139
  });
7140
+ const gate = createChangeGate({
7141
+ outDir: ctx.config.outDir,
7142
+ fileMtime: ctx.state.fileMtime,
7143
+ now: ctx.state.clock
7144
+ });
6765
7145
  const rebuilder = createRebuilder({
6766
7146
  debounceMs: ctx.config.debounceMs,
6767
7147
  /**
@@ -6775,6 +7155,17 @@ async function runDevServer(ctx, port) {
6775
7155
  return ctx.require(buildPlugin).run();
6776
7156
  },
6777
7157
  /**
7158
+ * Show the compact in-place "rebuilding {label}" line before the build runs.
7159
+ *
7160
+ * @param file - The changed watch target shown in the line.
7161
+ * @example
7162
+ * onRebuildStart("content");
7163
+ */
7164
+ onRebuildStart(file) {
7165
+ gate.markBuildStart();
7166
+ ctx.state.render.rebuildStart(file);
7167
+ },
7168
+ /**
6778
7169
  * Render the reload line and push a browser reload after a rebuild.
6779
7170
  *
6780
7171
  * @param info - The changed file plus the rebuild's page count and duration.
@@ -6796,7 +7187,9 @@ async function runDevServer(ctx, port) {
6796
7187
  ctx.state.render.error("rebuild failed", error);
6797
7188
  }
6798
7189
  });
6799
- const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, () => rebuilder.schedule(dir)));
7190
+ const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, (filename) => {
7191
+ if (gate.accept(dir, filename)) rebuilder.schedule(dir);
7192
+ }));
6800
7193
  ctx.state.render.serverReady({
6801
7194
  local: `http://localhost:${port}`,
6802
7195
  network: ctx.state.networkUrl(port),
@@ -6805,7 +7198,9 @@ async function runDevServer(ctx, port) {
6805
7198
  return installSignalTeardown(() => {
6806
7199
  rebuilder.cancel();
6807
7200
  for (const watcher of watchers) watcher.close();
7201
+ hub.close();
6808
7202
  server.stop();
7203
+ ctx.state.render.dispose();
6809
7204
  });
6810
7205
  }
6811
7206
  //#endregion
@@ -7006,6 +7401,28 @@ async function confirmDeploy(ctx, yes) {
7006
7401
  if (!confirmed) ctx.state.render.warn("deploy skipped");
7007
7402
  return confirmed;
7008
7403
  }
7404
+ /** Matches the prerequisite/credential failures a direct deploy most often hits (missing token/account). */
7405
+ const PREREQUISITE_ERROR = /required variable|not defined|cloudflare|token|account|unauthor|wrangler/i;
7406
+ /**
7407
+ * A short, actionable "how to fix" hint for a failed deploy, rendered under the error so
7408
+ * the user is never left at a raw stack trace. A missing-credential/prerequisite failure
7409
+ * (the common case for a first direct deploy) gets the concrete secret-setup steps;
7410
+ * anything else points at the guided deploy, which diagnoses prerequisites step by step.
7411
+ *
7412
+ * @param error - The thrown deploy error.
7413
+ * @returns The multi-line hint (newline-separated; rendered indented under a `›`).
7414
+ * @example
7415
+ * render.info(deployFailureHint(err));
7416
+ */
7417
+ function deployFailureHint(error) {
7418
+ if (PREREQUISITE_ERROR.test(String(error))) return [
7419
+ "how to fix:",
7420
+ "1. run `bun run deploy` (without `--cli`) — the guided setup diagnoses prerequisites and offers to create a starter .env",
7421
+ "2. or set them yourself in .env: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID",
7422
+ " token: https://dash.cloudflare.com/profile/api-tokens (template: Cloudflare Pages — Edit)"
7423
+ ].join("\n");
7424
+ return "how to fix: run `bun run deploy` (without `--cli`) — the guided setup diagnoses prerequisites — then retry";
7425
+ }
7009
7426
  /**
7010
7427
  * Create the cli plugin API surface — exactly `build`, `serve`, `preview`, `deploy`.
7011
7428
  * Each method renders `state.render.header(<command>)` first, then does its work;
@@ -7064,29 +7481,37 @@ function createApi$1(ctx) {
7064
7481
  return runPreviewServer(ctx, port);
7065
7482
  },
7066
7483
  /**
7067
- * Scaffold, then deploy. A y/N confirm is shown only when a human is present (an
7068
- * interactive TTY, with `CI` unset). Non-interactive runs (CI, or any non-TTY)
7069
- * skip the prompt and deploy, so the consumer scripts never hang a pipeline.
7070
- * `options.yes` forces the skip anywhere. An interactive "no" returns
7071
- * `{ deployed: false, reason: "declined" }`.
7484
+ * Deploy to Cloudflare Pages. Two modes: `{ guided: true }` runs the interactive
7485
+ * setup wizard (diagnose prerequisites, guide fixes, gate, local test, deploy, offer
7486
+ * a CI workflow); the default direct path scaffolds, gates on a y/N confirm (shown
7487
+ * only to an interactive TTY with `CI` unset non-interactive runs proceed so a
7488
+ * pipeline never hangs), then deploys. `options.yes` forces the skip. A "no" returns
7489
+ * `{ deployed: false, reason: "declined" }`; the wizard may return `"blocked"`.
7072
7490
  *
7073
- * @param options - Optional branch override and `yes` flag.
7074
- * @returns The deploy outcome (completed details, or `declined` if a TTY user says no).
7491
+ * @param options - Optional branch override, `yes` flag, and `guided` toggle.
7492
+ * @returns The deploy outcome (completed details, or a `declined`/`blocked` skip).
7075
7493
  * @example
7076
- * await api.deploy({ branch: "preview/x", yes: true });
7494
+ * await api.deploy({ guided: true });
7077
7495
  */
7078
7496
  async deploy(options = {}) {
7079
7497
  const { branch, yes = false } = options;
7080
7498
  ctx.state.render.header("deploy");
7081
- await ctx.require(deployPlugin).init({ ci: true });
7082
- if (!await confirmDeploy(ctx, yes)) return {
7083
- deployed: false,
7084
- reason: "declined"
7085
- };
7086
- return {
7087
- deployed: true,
7088
- ...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
7089
- };
7499
+ if (options.guided === true) return runDeployWizard(ctx, options);
7500
+ try {
7501
+ await ctx.require(deployPlugin).init({ ci: true });
7502
+ if (!await confirmDeploy(ctx, yes)) return {
7503
+ deployed: false,
7504
+ reason: "declined"
7505
+ };
7506
+ return {
7507
+ deployed: true,
7508
+ ...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
7509
+ };
7510
+ } catch (error) {
7511
+ ctx.state.render.error("deploy failed", error);
7512
+ ctx.state.render.info(deployFailureHint(error));
7513
+ throw error;
7514
+ }
7090
7515
  }
7091
7516
  };
7092
7517
  }
@@ -7177,6 +7602,61 @@ const ANSI = {
7177
7602
  cyan: `${ESC}[36m`,
7178
7603
  gray: `${ESC}[90m`
7179
7604
  };
7605
+ /**
7606
+ * The Moku brand pink (`#FF1E6F`) as an RGB triple, used for 24-bit truecolor output.
7607
+ * Degrades to {@link ANSI.magenta} on a 16-color TTY and to plain text off a TTY.
7608
+ */
7609
+ const BRAND_PINK = {
7610
+ r: 255,
7611
+ g: 30,
7612
+ b: 111
7613
+ };
7614
+ /**
7615
+ * Build a 24-bit (truecolor) SGR foreground escape for the given RGB triple.
7616
+ *
7617
+ * @param r - Red channel (0–255).
7618
+ * @param g - Green channel (0–255).
7619
+ * @param b - Blue channel (0–255).
7620
+ * @returns The `ESC[38;2;r;g;bm` foreground sequence.
7621
+ * @example
7622
+ * fg24(255, 30, 111); // "\x1b[38;2;255;30;111m"
7623
+ */
7624
+ function fg24(r, g, b) {
7625
+ return `${ESC}[38;2;${r};${g};${b}m`;
7626
+ }
7627
+ /** ANSI: erase the entire current line, leaving the cursor where it is. */
7628
+ const CLEAR_LINE = `${ESC}[2K`;
7629
+ /** ANSI: erase from the cursor to the end of the screen (drops stale trailing rows). */
7630
+ const CLEAR_BELOW = `${ESC}[0J`;
7631
+ /**
7632
+ * Braille spinner frames for live "working…" indicators on a TTY (advance one per tick).
7633
+ * Off a TTY the Panel never animates, so this is unused in plain/CI output.
7634
+ */
7635
+ const SPINNER_FRAMES = [
7636
+ "⠋",
7637
+ "⠙",
7638
+ "⠹",
7639
+ "⠸",
7640
+ "⠼",
7641
+ "⠴",
7642
+ "⠦",
7643
+ "⠧",
7644
+ "⠇",
7645
+ "⠏"
7646
+ ];
7647
+ /**
7648
+ * The ANSI sequence to move the cursor up `n` lines (empty string for `n <= 0`). The
7649
+ * Panel uses it to repaint a live block in place — move up over the previous draw, then
7650
+ * rewrite each row — so progress updates a fixed region instead of scrolling new lines.
7651
+ *
7652
+ * @param n - Number of lines to move the cursor up.
7653
+ * @returns The cursor-up escape sequence, or `""` when `n <= 0`.
7654
+ * @example
7655
+ * cursorUp(3); // "\x1b[3A"
7656
+ */
7657
+ function cursorUp(n) {
7658
+ return n > 0 ? `${ESC}[${n}A` : "";
7659
+ }
7180
7660
  /** Unicode rounded box glyphs used when output is a color-capable TTY. */
7181
7661
  const UNICODE_BOX = {
7182
7662
  topLeft: "╭",
@@ -7215,6 +7695,36 @@ function supportsColor(stream = process.stdout, noColor = process.env.NO_COLOR)
7215
7695
  return stream.isTTY === true && noColor === void 0;
7216
7696
  }
7217
7697
  /**
7698
+ * Whether the terminal advertises 24-bit (truecolor) support via `COLORTERM`, so the
7699
+ * renderer may emit the exact brand pink ({@link BRAND_PINK}) instead of the 16-color
7700
+ * `magenta` approximation. Always layered on top of {@link supportsColor} — truecolor
7701
+ * is never used when color itself is disabled.
7702
+ *
7703
+ * @param colorTerm - The `COLORTERM` value (defaults to `process.env.COLORTERM`).
7704
+ * @returns `true` when `COLORTERM` is `truecolor` or `24bit`.
7705
+ * @example
7706
+ * supportsTruecolor("truecolor"); // true
7707
+ */
7708
+ function supportsTruecolor(colorTerm = process.env.COLORTERM) {
7709
+ return colorTerm === "truecolor" || colorTerm === "24bit";
7710
+ }
7711
+ /**
7712
+ * The braille spinner glyph for a given elapsed time, advancing one frame per
7713
+ * `frameMs`. Deriving the frame from wall-clock elapsed (rather than a tick counter)
7714
+ * keeps the spinner correct even when the animation ticker is briefly starved by a
7715
+ * synchronous build phase and several ticks coalesce — the glyph still reflects real
7716
+ * elapsed time instead of freezing on a stale frame.
7717
+ *
7718
+ * @param elapsedMs - Milliseconds since the live region opened.
7719
+ * @param frameMs - Milliseconds per frame (defaults to `80`).
7720
+ * @returns The active spinner glyph.
7721
+ * @example
7722
+ * spinnerFrameAt(240); // "⠹" (the 4th frame at 80ms/frame)
7723
+ */
7724
+ function spinnerFrameAt(elapsedMs, frameMs = 80) {
7725
+ return SPINNER_FRAMES[Math.floor(Math.max(0, elapsedMs) / frameMs) % SPINNER_FRAMES.length] ?? "⠋";
7726
+ }
7727
+ /**
7218
7728
  * Select the box glyph set for the given color mode (Unicode on a TTY, ASCII off it).
7219
7729
  *
7220
7730
  * @param color - Whether color/Unicode output is enabled.
@@ -7242,12 +7752,14 @@ function visibleWidth(text) {
7242
7752
  * output in CI/pipes.
7243
7753
  *
7244
7754
  * @param color - Whether color is enabled (typically `supportsColor()`).
7755
+ * @param truecolor - Whether 24-bit output is enabled (typically `supportsTruecolor()`);
7756
+ * only consulted by {@link Palette.pink}. Defaults to `false` (16-color magenta).
7245
7757
  * @returns The bound color palette.
7246
7758
  * @example
7247
- * const palette = makePalette(supportsColor());
7759
+ * const palette = makePalette(supportsColor(), supportsTruecolor());
7248
7760
  * const line = palette.green("done");
7249
7761
  */
7250
- function makePalette(color) {
7762
+ function makePalette(color, truecolor = false) {
7251
7763
  return {
7252
7764
  enabled: color,
7253
7765
  /**
@@ -7327,23 +7839,39 @@ function makePalette(color) {
7327
7839
  */
7328
7840
  cyan(text) {
7329
7841
  return this.paint(ANSI.cyan, text);
7842
+ },
7843
+ /**
7844
+ * Color the given text the Moku brand pink: exact `#FF1E6F` (24-bit) when truecolor
7845
+ * is enabled, the 16-color `magenta` approximation otherwise, unchanged in plain mode.
7846
+ *
7847
+ * @param text - The text to colorize.
7848
+ * @returns The pink (or unchanged) text.
7849
+ * @example
7850
+ * palette.pink("▟▙ moku web");
7851
+ */
7852
+ pink(text) {
7853
+ if (!color) return text;
7854
+ if (truecolor) return `${fg24(BRAND_PINK.r, BRAND_PINK.g, BRAND_PINK.b)}${text}${ANSI.reset}`;
7855
+ return this.paint(ANSI.magenta, text);
7330
7856
  }
7331
7857
  };
7332
7858
  }
7333
7859
  /**
7334
7860
  * Frame a list of already-rendered content lines in a box, padding each line to the
7335
- * width of the widest visible line. Uses Unicode borders when `color` is enabled and
7336
- * ASCII otherwise. Visible width ignores embedded ANSI so colored lines align.
7861
+ * widest visible line (or `minInnerWidth`, whichever is larger so several boxes can be
7862
+ * forced to a shared width). Uses Unicode borders when `color` is enabled and ASCII
7863
+ * otherwise. Visible width ignores embedded ANSI so colored lines align.
7337
7864
  *
7338
7865
  * @param lines - The content lines (may contain ANSI color codes).
7339
7866
  * @param color - Whether to use Unicode borders (and assume color-capable output).
7867
+ * @param minInnerWidth - Minimum inner (content) width to pad every row to. Defaults to `0`.
7340
7868
  * @returns The boxed lines (top border, content rows, bottom border).
7341
7869
  * @example
7342
- * box(["Local: http://localhost:4173"], true);
7870
+ * box(["Local: http://localhost:4173"], true, 62);
7343
7871
  */
7344
- function box(lines, color) {
7872
+ function box(lines, color, minInnerWidth = 0) {
7345
7873
  const glyphs = boxGlyphs(color);
7346
- const inner = Math.max(0, ...lines.map((line) => visibleWidth(line)));
7874
+ const inner = Math.max(0, minInnerWidth, ...lines.map((line) => visibleWidth(line)));
7347
7875
  const horizontal = glyphs.horizontal.repeat(inner + 2);
7348
7876
  const top = `${glyphs.topLeft}${horizontal}${glyphs.topRight}`;
7349
7877
  const bottom = `${glyphs.bottomLeft}${horizontal}${glyphs.bottomRight}`;
@@ -7358,13 +7886,70 @@ function box(lines, color) {
7358
7886
  }
7359
7887
  //#endregion
7360
7888
  //#region src/plugins/cli/render/panel.ts
7361
- /** Per-command label shown in the header badge beside the logo. */
7889
+ /** Per-command label shown beside the lockup wordmark. */
7362
7890
  const COMMAND_LABEL = {
7363
7891
  build: "build",
7364
7892
  serve: "serve · dev",
7365
7893
  preview: "preview",
7366
7894
  deploy: "deploy"
7367
7895
  };
7896
+ /** Total visible width the header rule spans and the per-row timing column right-aligns to. */
7897
+ const RAIL_WIDTH = 66;
7898
+ /** Animation repaint cadence (ms) — how often the live region is redrawn when the loop is free. */
7899
+ const TICK_MS = 40;
7900
+ /** Spinner frame interval (ms) — one braille glyph advance per this many elapsed ms. */
7901
+ const SPIN_MS = 60;
7902
+ /** Inner (content) width of the BUILD/server boxes so their right edge lines up with the phase tree. */
7903
+ const BOX_INNER = RAIL_WIDTH - 4;
7904
+ /** The eight block glyphs the per-phase time-profile sparkline maps durations onto. */
7905
+ const SPARK_BARS = "▁▂▃▄▅▆▇█";
7906
+ /**
7907
+ * Build a sparkline from a list of values — one block glyph per value, height scaled to
7908
+ * the largest value so the tallest bar is `█`. A real micro-histogram (no fake data):
7909
+ * under the BUILD summary each bar is one phase's duration, so the slowest phase stands
7910
+ * out at a glance. Returns `""` for an empty list.
7911
+ *
7912
+ * @param values - The values to plot (e.g. per-phase durations in ms).
7913
+ * @returns The sparkline string.
7914
+ * @example
7915
+ * sparkline([12, 1701, 19698, 9]); // "▁▁█▁"
7916
+ */
7917
+ function sparkline(values) {
7918
+ if (values.length === 0) return "";
7919
+ const max = Math.max(...values, 1);
7920
+ return values.map((value) => {
7921
+ return SPARK_BARS[Math.min(7, Math.floor(value / max * 7))] ?? SPARK_BARS[0];
7922
+ }).join("");
7923
+ }
7924
+ /**
7925
+ * The structural glyph set for the active color mode: Unicode on a color-capable TTY,
7926
+ * ASCII fallbacks off it. Only the NEW Velocity chrome (cube, rule, tree, bar, live
7927
+ * dot) degrades here — the `✓ ✗ ~ ➜ ›` status marks stay as-is in both modes.
7928
+ *
7929
+ * @param color - Whether color/Unicode output is enabled.
7930
+ * @returns The matching glyph set.
7931
+ * @example
7932
+ * const g = glyphSet(true);
7933
+ */
7934
+ function glyphSet(color) {
7935
+ return color ? {
7936
+ cube: "▟▙",
7937
+ rule: "─",
7938
+ tree: "├─",
7939
+ barFill: "━",
7940
+ barTrack: "╴",
7941
+ liveOn: "◍",
7942
+ liveOff: "○"
7943
+ } : {
7944
+ cube: "*",
7945
+ rule: "-",
7946
+ tree: "-",
7947
+ barFill: "#",
7948
+ barTrack: "-",
7949
+ liveOn: "*",
7950
+ liveOff: "*"
7951
+ };
7952
+ }
7368
7953
  /**
7369
7954
  * Render one human-readable duration suffix (e.g. `· 84ms`).
7370
7955
  *
@@ -7379,22 +7964,177 @@ function durationSuffix(palette, durationMs) {
7379
7964
  return ` ${palette.dim(`· ${durationMs}ms`)}`;
7380
7965
  }
7381
7966
  /**
7967
+ * Right-align `right` against `left` within {@link RAIL_WIDTH}, measuring visible width
7968
+ * so embedded ANSI never throws the timing column off.
7969
+ *
7970
+ * @param left - The left segment (may contain ANSI).
7971
+ * @param right - The right segment (may contain ANSI).
7972
+ * @param width - Total visible width to fill (defaults to {@link RAIL_WIDTH}).
7973
+ * @returns The padded line.
7974
+ * @example
7975
+ * railLine(" ├─ ✓ pages", "· 12ms");
7976
+ */
7977
+ function railLine(left, right, width = RAIL_WIDTH) {
7978
+ const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
7979
+ return `${left}${" ".repeat(gap)}${right}`;
7980
+ }
7981
+ /**
7982
+ * The runtime facts line shown under the banner: the pinned core version (when known)
7983
+ * plus the live Node/Bun versions + platform — the ACTUAL running runtime, not the
7984
+ * `engines` floor. Every value is real (read from `@moku-labs/core`'s pinned dependency
7985
+ * and `process.versions`), so nothing on this line is faked.
7986
+ *
7987
+ * @param coreVersion - The pinned `@moku-labs/core` version (appended last — it rarely
7988
+ * matters — and omitted entirely when unknown).
7989
+ * @returns The facts string (e.g. `node 24.3.0 · bun 1.3.9 · darwin arm64 · core 0.1.0-alpha.6`).
7990
+ * @example
7991
+ * runtimeFacts("0.1.0-alpha.6");
7992
+ */
7993
+ function runtimeFacts(coreVersion) {
7994
+ const node = `node ${process.versions.node}`;
7995
+ const bun = process.versions.bun ? ` · bun ${process.versions.bun}` : "";
7996
+ const core = coreVersion ? ` · core ${coreVersion}` : "";
7997
+ return `${node}${bun} · ${process.platform} ${process.arch}${core}`;
7998
+ }
7999
+ /**
7382
8000
  * Create the Panel {@link CliRenderer}. Output is written through the injected sink
7383
- * (default `console.log`/`console.error`) and colorized only when color is enabled,
7384
- * so the identical render path yields box-drawn color panels on a TTY and plain
7385
- * ASCII lines in CI/pipes.
8001
+ * (default `console.log`/`console.error`) and colorized only when color is enabled, so
8002
+ * the identical render path yields the animated, box-free Velocity UI on a TTY and
8003
+ * plain ASCII lines in CI/pipes.
7386
8004
  *
7387
- * @param options - Optional sinks + a color override (see {@link PanelOptions}).
8005
+ * @param options - Optional sinks, color/truecolor overrides, clock, and version (see
8006
+ * {@link PanelOptions}).
7388
8007
  * @returns The renderer mounted on `state.render` and driven by the API + hooks.
7389
8008
  * @example
7390
- * const render = createPanelRenderer();
8009
+ * const render = createPanelRenderer({ version: "0.1.0-alpha" });
7391
8010
  * render.header("build");
7392
8011
  */
7393
8012
  function createPanelRenderer(options = {}) {
7394
8013
  const write = options.write ?? ((line) => console.log(line));
7395
8014
  const writeError = options.writeError ?? ((line) => console.error(line));
8015
+ const writeRaw = options.writeRaw ?? ((chunk) => {
8016
+ process.stdout.write(chunk);
8017
+ });
8018
+ const now = options.now ?? Date.now;
7396
8019
  const color = options.color ?? supportsColor();
7397
- const palette = makePalette(color);
8020
+ const palette = makePalette(color, options.truecolor ?? (color && supportsTruecolor()));
8021
+ const version = options.version ?? "dev";
8022
+ const coreVersion = options.coreVersion;
8023
+ const g = glyphSet(color);
8024
+ let phaseRows = [];
8025
+ let phaseDrawn = 0;
8026
+ let phaseOpen = false;
8027
+ let blockStartedAt = 0;
8028
+ let rebuilding = false;
8029
+ let rebuildLabel = "";
8030
+ let rebuildStartedAt = 0;
8031
+ let idle = false;
8032
+ let idleStartedAt = 0;
8033
+ let serveMode = false;
8034
+ let ticker;
8035
+ /**
8036
+ * Render one phase-tree row: a spinning cyan glyph + dim name while running, or a green
8037
+ * `✓` + name with the duration right-aligned in the dim timing column once done.
8038
+ *
8039
+ * @param row - The phase row to render.
8040
+ * @returns The rendered row line (no trailing newline).
8041
+ * @example
8042
+ * renderPhaseRow({ name: "pages", done: true, durationMs: 12 });
8043
+ */
8044
+ const renderPhaseRow = (row) => {
8045
+ const branch = palette.dim(g.tree);
8046
+ if (row.done) return railLine(` ${branch} ${palette.green("✓")} ${row.name}`, palette.dim(`· ${row.durationMs}ms`));
8047
+ return ` ${branch} ${palette.cyan(spinnerFrameAt(now() - blockStartedAt, SPIN_MS))} ${palette.dim(row.name)}`;
8048
+ };
8049
+ /**
8050
+ * Render the indeterminate "comet" build bar — a short pink fill window sweeping across
8051
+ * a dim track — for the given elapsed time. Animated purely from wall-clock elapsed so
8052
+ * it never needs a known phase total.
8053
+ *
8054
+ * @param elapsedMs - Milliseconds since the phase block opened.
8055
+ * @returns The rendered bar row (no trailing newline).
8056
+ * @example
8057
+ * renderBuildBar(300);
8058
+ */
8059
+ const renderBuildBar = (elapsedMs) => {
8060
+ const length = 28;
8061
+ const window = 6;
8062
+ const head = Math.floor(elapsedMs / 28) % 34;
8063
+ let bar = "";
8064
+ for (let index = 0; index < length; index++) {
8065
+ const lit = index <= head && index > head - window;
8066
+ bar += lit ? palette.pink(g.barFill) : palette.dim(g.barTrack);
8067
+ }
8068
+ return ` ${bar}`;
8069
+ };
8070
+ /**
8071
+ * Repaint the live phase block in place (tree rows + animated build bar): move up over
8072
+ * the prior draw, rewrite each row, then the bar, clearing any stale trailing lines.
8073
+ *
8074
+ * @example
8075
+ * paintPhaseBlock();
8076
+ */
8077
+ const paintPhaseBlock = () => {
8078
+ let frame = cursorUp(phaseDrawn);
8079
+ for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
8080
+ frame += `${CLEAR_LINE}${renderBuildBar(now() - blockStartedAt)}\n`;
8081
+ writeRaw(frame + CLEAR_BELOW);
8082
+ phaseDrawn = phaseRows.length + 1;
8083
+ };
8084
+ /**
8085
+ * Repaint the single in-place rebuild line (spinner + label + live elapsed seconds).
8086
+ *
8087
+ * @example
8088
+ * paintRebuildLine();
8089
+ */
8090
+ const paintRebuildLine = () => {
8091
+ const spinner = palette.cyan(spinnerFrameAt(now() - rebuildStartedAt, SPIN_MS));
8092
+ const elapsed = palette.dim(`· ${((now() - rebuildStartedAt) / 1e3).toFixed(1)}s`);
8093
+ writeRaw(`\r${CLEAR_LINE} ${spinner} rebuilding ${rebuildLabel} ${elapsed}`);
8094
+ };
8095
+ /**
8096
+ * Repaint the persistent in-place `◍ live` idle pulse beneath the serve panel — the
8097
+ * dot breathes (pink → dim) on a calm ~0.6s cycle so a quiet dev session always reads
8098
+ * as alive without strobing.
8099
+ *
8100
+ * @example
8101
+ * paintIdleLine();
8102
+ */
8103
+ const paintIdleLine = () => {
8104
+ writeRaw(`\r${CLEAR_LINE} ${Math.floor((now() - idleStartedAt) / 450) % 2 === 0 ? palette.pink(g.liveOn) : palette.dim(g.liveOff)} ${palette.dim("live · waiting for changes…")}`);
8105
+ };
8106
+ /**
8107
+ * Advance whichever live region is active by one frame (driven by the shared ticker).
8108
+ *
8109
+ * @example
8110
+ * onTick();
8111
+ */
8112
+ const onTick = () => {
8113
+ if (rebuilding) paintRebuildLine();
8114
+ else if (phaseOpen) paintPhaseBlock();
8115
+ else if (idle) paintIdleLine();
8116
+ };
8117
+ /**
8118
+ * Start the animation ticker (TTY only; idempotent; `unref`'d so it never blocks exit).
8119
+ *
8120
+ * @example
8121
+ * startTicker();
8122
+ */
8123
+ const startTicker = () => {
8124
+ if (!color || ticker) return;
8125
+ ticker = setInterval(onTick, TICK_MS);
8126
+ ticker.unref?.();
8127
+ };
8128
+ /**
8129
+ * Stop the animation ticker if running.
8130
+ *
8131
+ * @example
8132
+ * stopTicker();
8133
+ */
8134
+ const stopTicker = () => {
8135
+ if (ticker) clearInterval(ticker);
8136
+ ticker = void 0;
8137
+ };
7398
8138
  /**
7399
8139
  * Write each line of a multi-line block through the stdout sink.
7400
8140
  *
@@ -7405,82 +8145,184 @@ function createPanelRenderer(options = {}) {
7405
8145
  const writeBlock = (lines) => {
7406
8146
  for (const line of lines) write(line);
7407
8147
  };
8148
+ /**
8149
+ * Resume the serve idle pulse on a fresh bottom line (TTY serve sessions only). A no-op
8150
+ * outside serve so standalone rebuild/error calls in unit tests never leave a ticker
8151
+ * running.
8152
+ *
8153
+ * @example
8154
+ * resumeIdle();
8155
+ */
8156
+ const resumeIdle = () => {
8157
+ if (!(color && serveMode)) {
8158
+ stopTicker();
8159
+ return;
8160
+ }
8161
+ idle = true;
8162
+ idleStartedAt = now();
8163
+ paintIdleLine();
8164
+ startTicker();
8165
+ };
7408
8166
  return {
7409
8167
  /**
7410
- * Render the boxed `MOKU WEB` logo + command label.
8168
+ * Render the `▟▙ moku web` lockup + per-command label, a dim rule, and the runtime
8169
+ * facts line (live Node/Bun versions + platform). Called once per command (one
8170
+ * command = one process), so it never repeats within a run.
7411
8171
  *
7412
- * @param command - The command being run, shown beside the logo.
8172
+ * @param command - The command being run, shown beside the wordmark.
7413
8173
  * @example
7414
8174
  * render.header("serve");
7415
8175
  */
7416
8176
  header(command) {
7417
- writeBlock(box([`${palette.bold(palette.cyan("MOKU WEB"))} ${palette.dim(COMMAND_LABEL[command])}`], color));
8177
+ writeBlock([
8178
+ railLine(` ${palette.pink(g.cube)} ${palette.pink(palette.bold("moku web"))} ${palette.dim(COMMAND_LABEL[command])}`, palette.dim(version)),
8179
+ ` ${palette.dim(g.rule.repeat(RAIL_WIDTH - 1))}`,
8180
+ ` ${palette.dim(runtimeFacts(coreVersion))}`
8181
+ ]);
7418
8182
  },
7419
8183
  /**
7420
- * Render a live per-phase row from a `build:phase` event.
8184
+ * Render a live per-phase row from a `build:phase` event. On a TTY each phase is ONE
8185
+ * tree row that updates in place (spinning glyph while running → green ✓ + duration
8186
+ * when done) beneath an animated indeterminate build bar; off a TTY one line is
8187
+ * printed per completed phase (no start/done duplication). A no-op while a serve()
8188
+ * rebuild is in flight — those show the compact rebuild line.
7421
8189
  *
7422
8190
  * @param phase - The `build:phase` payload.
7423
8191
  * @example
7424
8192
  * render.phase({ phase: "pages", status: "done", durationMs: 12 });
7425
8193
  */
7426
8194
  phase(phase) {
8195
+ if (rebuilding) return;
8196
+ if (!color) {
8197
+ if (phase.status === "done") write(` ${palette.green("✓")} ${phase.phase}${durationSuffix(palette, phase.durationMs)}`);
8198
+ return;
8199
+ }
8200
+ if (!phaseOpen) {
8201
+ phaseRows = [];
8202
+ phaseDrawn = 0;
8203
+ phaseOpen = true;
8204
+ blockStartedAt = now();
8205
+ }
7427
8206
  const done = phase.status === "done";
7428
- write(` ${done ? palette.green("✓") : palette.dim("•")} ${done ? phase.phase : palette.dim(phase.phase)}${durationSuffix(palette, phase.durationMs)}`);
8207
+ const existing = phaseRows.find((row) => row.name === phase.phase);
8208
+ if (existing) {
8209
+ existing.done = done;
8210
+ existing.durationMs = phase.durationMs;
8211
+ } else phaseRows.push({
8212
+ name: phase.phase,
8213
+ done,
8214
+ durationMs: phase.durationMs
8215
+ });
8216
+ paintPhaseBlock();
8217
+ startTicker();
7429
8218
  },
7430
8219
  /**
7431
- * Render the BUILD summary block from a `build:complete` event.
8220
+ * Render the BUILD summary line + a one-shot throughput sparkline from a
8221
+ * `build:complete` event, finalizing the live phase tree (dropping its animated bar)
8222
+ * first.
7432
8223
  *
7433
8224
  * @param summary - The `build:complete` payload.
7434
8225
  * @example
7435
8226
  * render.built({ outDir: "dist", pageCount: 12, durationMs: 840 });
7436
8227
  */
7437
8228
  built(summary) {
8229
+ if (rebuilding) return;
8230
+ if (color && phaseOpen) {
8231
+ let frame = cursorUp(phaseDrawn);
8232
+ for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
8233
+ writeRaw(frame + CLEAR_BELOW);
8234
+ }
8235
+ const phaseDurations = phaseRows.map((row) => row.durationMs).filter((value) => value !== void 0);
8236
+ phaseOpen = false;
8237
+ phaseDrawn = 0;
8238
+ stopTicker();
7438
8239
  const pages = palette.bold(String(summary.pageCount));
7439
- writeBlock(box([
7440
- `${palette.green("✓")} ${palette.bold("BUILD")} complete`,
7441
- `${palette.dim("pages")} ${pages}`,
7442
- `${palette.dim("time")} ${summary.durationMs}ms`,
7443
- `${palette.dim("out")} ${summary.outDir}/`
7444
- ], color));
8240
+ const dot = palette.dim("·");
8241
+ const lines = [railLine(`${palette.green("✓")} ${palette.bold("BUILD")} ${dot} ${pages} pages`, `${summary.durationMs}ms ${dot} ${summary.outDir}/`, BOX_INNER)];
8242
+ if (color && summary.durationMs > 0) {
8243
+ const rate = Math.max(1, Math.round(summary.pageCount / (summary.durationMs / 1e3)));
8244
+ const spark = phaseDurations.length > 0 ? palette.pink(sparkline(phaseDurations)) : "";
8245
+ const rateLabel = palette.dim(`${rate} pages/s`);
8246
+ lines.push(railLine(spark, rateLabel, BOX_INNER));
8247
+ }
8248
+ writeBlock(box(lines, color, BOX_INNER));
7445
8249
  },
7446
8250
  /**
7447
- * Render the bordered server-ready panel (Local / Network URLs + watched dirs).
8251
+ * Render the server-ready rail (Local / Network URLs + watched dirs) and, on a TTY,
8252
+ * begin the persistent breathing `◍ live` idle pulse beneath it.
7448
8253
  *
7449
8254
  * @param info - Local/Network URLs and optionally the watched directories.
7450
8255
  * @example
7451
8256
  * render.serverReady({ local: "http://localhost:4173", network: null });
7452
8257
  */
7453
8258
  serverReady(info) {
7454
- const lines = [`${palette.green("➜")} ${palette.bold("Local")} ${palette.cyan(info.local)}`, `${palette.green("➜")} ${palette.bold("Network")} ${info.network ? palette.cyan(info.network) : palette.dim("unavailable")}`];
7455
- if (info.watching && info.watching.length > 0) lines.push(`${palette.dim("watching")} ${palette.dim(info.watching.join(", "))}`);
7456
- writeBlock(box(lines, color));
8259
+ const network = info.network ? palette.cyan(info.network) : palette.dim("unavailable");
8260
+ const lines = [`${palette.green("➜")} ${palette.bold("Local")} ${palette.cyan(info.local)}`, `${palette.green("➜")} ${palette.bold("Network")} ${network}`];
8261
+ if (info.watching && info.watching.length > 0) lines.push(`${palette.dim("watching")} ${palette.dim(info.watching.join(", "))}`);
8262
+ writeBlock(box(lines, color, BOX_INNER));
8263
+ if (color) {
8264
+ serveMode = true;
8265
+ idle = true;
8266
+ idleStartedAt = now();
8267
+ paintIdleLine();
8268
+ startTicker();
8269
+ }
7457
8270
  },
7458
8271
  /**
7459
- * Render the post-rebuild line ("~ file" + " rebuilt N pages · Xms · reloaded").
8272
+ * Begin a serve() rebuild: show ONE compact "rebuilding {label}" line (an animated
8273
+ * spinner with live elapsed on a TTY; a plain "~ {label}" line otherwise), taking over
8274
+ * the idle-pulse line, and mute the verbose phase tree + BUILD summary until
8275
+ * {@link reload}/{@link error} settles it.
8276
+ *
8277
+ * @param label - The changed watch target shown in the line.
8278
+ * @example
8279
+ * render.rebuildStart("content");
8280
+ */
8281
+ rebuildStart(label) {
8282
+ rebuilding = true;
8283
+ idle = false;
8284
+ rebuildLabel = label;
8285
+ rebuildStartedAt = now();
8286
+ if (!color) {
8287
+ write(` ${palette.yellow("~")} ${label}`);
8288
+ return;
8289
+ }
8290
+ paintRebuildLine();
8291
+ startTicker();
8292
+ },
8293
+ /**
8294
+ * Settle the current rebuild: replace the in-place "rebuilding…" line with a compact
8295
+ * "✓ rebuilt N pages · Xms · reloaded", then (in a serve session) resume the idle pulse
8296
+ * on a fresh bottom line. Called standalone (no preceding {@link rebuildStart}) it also
8297
+ * prints the "~ file" line so the changed target stays visible.
7460
8298
  *
7461
8299
  * @param info - The changed file plus the rebuild's page count and duration.
7462
8300
  * @example
7463
8301
  * render.reload({ file: "content/a.md", pageCount: 12, durationMs: 84 });
7464
8302
  */
7465
8303
  reload(info) {
7466
- write(` ${palette.yellow("~")} ${info.file}`);
7467
- write(` ${palette.green("✓")} rebuilt ${palette.bold(String(info.pageCount))} pages ${palette.dim(`· ${info.durationMs}ms · browser reloaded`)}`);
8304
+ const settledRebuild = rebuilding;
8305
+ rebuilding = false;
8306
+ const line = ` ${palette.green("✓")} rebuilt ${palette.bold(String(info.pageCount))} pages ${palette.dim(`· ${info.durationMs}ms · reloaded`)}`;
8307
+ if (settledRebuild && color) {
8308
+ writeRaw(`\r${CLEAR_LINE}${line}\n`);
8309
+ resumeIdle();
8310
+ return;
8311
+ }
8312
+ if (!settledRebuild) write(` ${palette.yellow("~")} ${info.file}`);
8313
+ write(line);
7468
8314
  },
7469
8315
  /**
7470
- * Render the deploy result panel from a `deploy:complete` event.
8316
+ * Render the deploy result from a `deploy:complete` event: a `✓ DEPLOYED → url` line
8317
+ * with the URL the hero value, then a dim `branch · id · time` line beneath it.
7471
8318
  *
7472
8319
  * @param result - The `deploy:complete` payload.
7473
8320
  * @example
7474
8321
  * render.deployed({ url: "https://x.pages.dev", deploymentId: "id", branch: "main", durationMs: 1200 });
7475
8322
  */
7476
8323
  deployed(result) {
7477
- writeBlock(box([
7478
- `${palette.green("✓")} ${palette.bold("DEPLOYED")}`,
7479
- `${palette.dim("url")} ${palette.cyan(result.url)}`,
7480
- `${palette.dim("branch")} ${result.branch}`,
7481
- `${palette.dim("id")} ${result.deploymentId}`,
7482
- `${palette.dim("time")} ${result.durationMs}ms`
7483
- ], color));
8324
+ const meta = palette.dim(`branch ${result.branch} · ${result.deploymentId} · ${result.durationMs}ms`);
8325
+ writeBlock([` ${palette.green("✓")} ${palette.bold("DEPLOYED")} ${palette.dim("→")} ${palette.cyan(result.url)}`, ` ${meta}`]);
7484
8326
  },
7485
8327
  /**
7486
8328
  * Render a neutral informational line.
@@ -7490,7 +8332,9 @@ function createPanelRenderer(options = {}) {
7490
8332
  * render.info("watching for changes…");
7491
8333
  */
7492
8334
  info(message) {
7493
- write(` ${palette.cyan("")} ${message}`);
8335
+ const [first = "", ...rest] = message.split("\n");
8336
+ write(` ${palette.cyan("›")} ${first}`);
8337
+ for (const line of rest) write(` ${line}`);
7494
8338
  },
7495
8339
  /**
7496
8340
  * Render a warning line (to stderr).
@@ -7503,7 +8347,8 @@ function createPanelRenderer(options = {}) {
7503
8347
  writeError(` ${palette.yellow("⚠")} ${message}`);
7504
8348
  },
7505
8349
  /**
7506
- * Render an error line (to stderr), optionally with a cause.
8350
+ * Render an error line (to stderr), optionally with a cause. A failing rebuild settles
8351
+ * its in-place spinner line first; in a serve session the idle pulse then resumes.
7507
8352
  *
7508
8353
  * @param message - The error summary to print.
7509
8354
  * @param cause - Optional underlying error/value to print beneath the summary.
@@ -7511,8 +8356,53 @@ function createPanelRenderer(options = {}) {
7511
8356
  * render.error("build failed", err);
7512
8357
  */
7513
8358
  error(message, cause) {
8359
+ const wasRebuilding = rebuilding;
8360
+ if (rebuilding) {
8361
+ rebuilding = false;
8362
+ if (color) writeRaw(`\r${CLEAR_LINE}`);
8363
+ }
7514
8364
  writeError(` ${palette.red("✗")} ${message}`);
7515
8365
  if (cause !== void 0) writeError(String(cause));
8366
+ if (wasRebuilding) resumeIdle();
8367
+ else stopTicker();
8368
+ },
8369
+ /**
8370
+ * Render a section heading (a blank line + a bold pink label) for a multi-step flow.
8371
+ *
8372
+ * @param text - The heading label.
8373
+ * @example
8374
+ * render.heading("Diagnostics");
8375
+ */
8376
+ heading(text) {
8377
+ write("");
8378
+ write(` ${palette.bold(palette.pink(text))}`);
8379
+ },
8380
+ /**
8381
+ * Render a diagnostic line: green `✓` (pass) or red `✗` (fail) + label, with optional
8382
+ * dim, indented detail beneath (e.g. a fix hint for a failing check).
8383
+ *
8384
+ * @param ok - Whether the check passed.
8385
+ * @param label - The check label.
8386
+ * @param detail - Optional multi-line guidance shown indented under the line.
8387
+ * @example
8388
+ * render.check(false, "CLOUDFLARE_API_TOKEN is set", "Create one at …");
8389
+ */
8390
+ check(ok, label, detail) {
8391
+ write(` ${ok ? palette.green("✓") : palette.red("✗")} ${label}`);
8392
+ if (detail !== void 0) for (const line of detail.split("\n")) write(` ${palette.dim(line)}`);
8393
+ },
8394
+ /**
8395
+ * Stop every animation and release the interval timer (serve()'s teardown calls this).
8396
+ *
8397
+ * @example
8398
+ * render.dispose();
8399
+ */
8400
+ dispose() {
8401
+ stopTicker();
8402
+ idle = false;
8403
+ rebuilding = false;
8404
+ phaseOpen = false;
8405
+ serveMode = false;
7516
8406
  }
7517
8407
  };
7518
8408
  }
@@ -7591,18 +8481,44 @@ function defaultConfirm(question) {
7591
8481
  });
7592
8482
  }
7593
8483
  /**
8484
+ * Default stdin single-choice prompt. Prints the choices numbered from 1, reads a line
8485
+ * via `node:readline`, and resolves the chosen zero-based index (empty / out-of-range
8486
+ * falls back to 0). Tests inject a canned selection so no real TTY interaction occurs.
8487
+ *
8488
+ * @param question - The prompt to display.
8489
+ * @param choices - The selectable option labels.
8490
+ * @returns Resolves the chosen zero-based index.
8491
+ * @example
8492
+ * await defaultSelect("Trigger?", ["Auto on push", "Manual only"]);
8493
+ */
8494
+ function defaultSelect(question, choices) {
8495
+ return new Promise((resolve) => {
8496
+ const readline = (0, node_readline.createInterface)({
8497
+ input: process.stdin,
8498
+ output: process.stdout
8499
+ });
8500
+ for (const [index, choice] of choices.entries()) console.log(` ${index + 1}) ${choice}`);
8501
+ readline.question(`${question} [1-${choices.length}] `, (answer) => {
8502
+ readline.close();
8503
+ const picked = Number.parseInt(answer.trim(), 10);
8504
+ resolve(Number.isInteger(picked) && picked >= 1 && picked <= choices.length ? picked - 1 : 0);
8505
+ });
8506
+ });
8507
+ }
8508
+ /**
7594
8509
  * Default recursive directory watcher — wraps `node:fs.watch` with `{ recursive: true }`
7595
8510
  * and adapts its handle to {@link WatchHandle}. Tests inject a fake emitter so no real
7596
8511
  * FS watch is registered.
7597
8512
  *
7598
8513
  * @param dir - The directory to watch recursively.
7599
- * @param onChange - Invoked on any change beneath `dir`.
8514
+ * @param onChange - Invoked on any change beneath `dir`, forwarding the changed path
8515
+ * relative to `dir` when `node:fs.watch` reports it (`undefined` otherwise).
7600
8516
  * @returns A handle whose `close()` detaches the watcher.
7601
8517
  * @example
7602
- * const handle = defaultWatch("content", () => rebuild());
8518
+ * const handle = defaultWatch("content", file => rebuild(file));
7603
8519
  */
7604
8520
  function defaultWatch(dir, onChange) {
7605
- const watcher = (0, node_fs.watch)(dir, { recursive: true }, () => onChange());
8521
+ const watcher = (0, node_fs.watch)(dir, { recursive: true }, (_event, filename) => onChange(typeof filename === "string" ? filename : void 0));
7606
8522
  return {
7607
8523
  /**
7608
8524
  * Detach the underlying `node:fs.watch` listener.
@@ -7615,6 +8531,23 @@ close() {
7615
8531
  } };
7616
8532
  }
7617
8533
  /**
8534
+ * Default file-mtime probe — `node:fs.statSync(path).mtimeMs`, returning `null` for a
8535
+ * missing path (so a deleted file still reads as a change). serve() compares this
8536
+ * across `fs.watch` events to drop the duplicate notifications macOS fires per save.
8537
+ *
8538
+ * @param filePath - The absolute path to stat.
8539
+ * @returns The modification time in epoch milliseconds, or `null` when absent.
8540
+ * @example
8541
+ * const mtime = defaultFileMtime("/abs/content/a.md");
8542
+ */
8543
+ function defaultFileMtime(filePath) {
8544
+ try {
8545
+ return (0, node_fs.statSync)(filePath).mtimeMs;
8546
+ } catch {
8547
+ return null;
8548
+ }
8549
+ }
8550
+ /**
7618
8551
  * Default LAN network-URL deriver — wraps {@link networkUrl} so the production seam
7619
8552
  * reads `node:os` interfaces while tests can inject a deterministic value.
7620
8553
  *
@@ -7626,6 +8559,101 @@ close() {
7626
8559
  function defaultNetworkUrl(port) {
7627
8560
  return networkUrl(port);
7628
8561
  }
8562
+ /** Memoized banner facts — resolution touches the filesystem + git once, then caches. */
8563
+ let cachedBanner;
8564
+ /**
8565
+ * Run a read-only `git` command in `dir`, returning its trimmed stdout (`undefined` on
8566
+ * any failure — not a checkout, git missing, etc.). A thin wrapper so the version
8567
+ * resolver can issue a couple of git queries without repeating the spawn boilerplate.
8568
+ *
8569
+ * @param dir - The working directory to run git in.
8570
+ * @param args - The git arguments (no user input is ever interpolated).
8571
+ * @returns The trimmed command output, or `undefined` on failure.
8572
+ * @example
8573
+ * git("/Users/me/moku/web", ["tag", "--list", "v*"]);
8574
+ */
8575
+ function git(dir, args) {
8576
+ try {
8577
+ return (0, node_child_process.execFileSync)("git", args, {
8578
+ cwd: dir,
8579
+ encoding: "utf8",
8580
+ stdio: [
8581
+ "ignore",
8582
+ "pipe",
8583
+ "ignore"
8584
+ ]
8585
+ }).trim();
8586
+ } catch {
8587
+ return;
8588
+ }
8589
+ }
8590
+ /**
8591
+ * The framework's source/dev version, derived the SAME way the publish workflow computes
8592
+ * a release: the highest semver `v*` tag is the source of truth (`@moku-labs/web` is
8593
+ * released tag-only — the working-tree `package.json` deliberately carries no `version`).
8594
+ * A `-dev` suffix marks it as a local build off that release line (e.g. `v1.1.0-dev`), so
8595
+ * it never masquerades as the published release. Falls back to the short commit (then
8596
+ * `undefined`) only when no tags exist. `undefined` when `dir` is not a git checkout (a
8597
+ * published npm install — which carries its real `version` instead).
8598
+ *
8599
+ * @param dir - A directory inside the framework's own repository (the realpath of the
8600
+ * package root, so a symlinked local checkout reports the framework's tag — not the
8601
+ * consumer's).
8602
+ * @returns The dev version (e.g. `v1.1.0-dev`), or `undefined`.
8603
+ * @example
8604
+ * devVersion("/Users/me/moku/web"); // "v1.1.0-dev"
8605
+ */
8606
+ function devVersion(dir) {
8607
+ const latestTag = git(dir, [
8608
+ "tag",
8609
+ "--list",
8610
+ "v*",
8611
+ "--sort=-v:refname"
8612
+ ])?.split("\n")[0]?.trim();
8613
+ if (latestTag) return `${latestTag}-dev`;
8614
+ const sha = git(dir, [
8615
+ "rev-parse",
8616
+ "--short",
8617
+ "HEAD"
8618
+ ]);
8619
+ return sha ? `${sha}-dev` : void 0;
8620
+ }
8621
+ /**
8622
+ * Resolve the real version/runtime facts shown in the Panel banner (memoized). Reads the
8623
+ * `package.json` shipped beside the built bundle (`dist/../package.json`): a PUBLISHED
8624
+ * release carries a `version` and reports `v{version}`; a source/dev build (no `version`
8625
+ * field — `@moku-labs/web` is released tag-only) reports the latest semver tag + `-dev`
8626
+ * (e.g. `v1.1.0-dev`, the same tag the publish workflow treats as the version source), or
8627
+ * `"dev"` when git is unavailable. The pinned `@moku-labs/core` version comes from the
8628
+ * same file's `dependencies`.
8629
+ *
8630
+ * @returns The resolved {@link BannerFacts}.
8631
+ * @example
8632
+ * resolveBanner(); // { version: "v1.1.0-dev", coreVersion: "0.1.0-alpha.6" }
8633
+ */
8634
+ function resolveBanner() {
8635
+ if (cachedBanner) return cachedBanner;
8636
+ let pkg = {};
8637
+ let pkgDir;
8638
+ try {
8639
+ const pkgUrl = new URL("../package.json", require("url").pathToFileURL(__filename).href);
8640
+ pkgDir = (0, node_fs.realpathSync)(node_path$1.default.dirname((0, node_url.fileURLToPath)(pkgUrl)));
8641
+ pkg = JSON.parse((0, node_fs.readFileSync)(pkgUrl, "utf8"));
8642
+ } catch {}
8643
+ const coreVersion = (pkg.dependencies?.["@moku-labs/core"] ?? "").replace(/^\D*/, "") || "unknown";
8644
+ const released = pkg.version;
8645
+ let version = "dev";
8646
+ if (released) version = `v${released}`;
8647
+ else {
8648
+ const dev = devVersion(pkgDir ?? process.cwd());
8649
+ if (dev) version = dev;
8650
+ }
8651
+ cachedBanner = {
8652
+ version,
8653
+ coreVersion
8654
+ };
8655
+ return cachedBanner;
8656
+ }
7629
8657
  /**
7630
8658
  * Create the initial cli plugin state with the production seams wired. Every field is
7631
8659
  * an injectable seam (`render`, `confirm`, `clock`, `watch`, the server factories,
@@ -7639,14 +8667,20 @@ function defaultNetworkUrl(port) {
7639
8667
  * const state = createState({ global: {}, config });
7640
8668
  */
7641
8669
  function createState$1(_ctx) {
8670
+ const banner = resolveBanner();
7642
8671
  return {
7643
- render: createPanelRenderer(),
8672
+ render: createPanelRenderer({
8673
+ version: banner.version,
8674
+ coreVersion: banner.coreVersion
8675
+ }),
7644
8676
  confirm: defaultConfirm,
8677
+ select: defaultSelect,
7645
8678
  clock: Date.now,
7646
8679
  watch: defaultWatch,
7647
8680
  serveStatic: defaultServeStatic,
7648
8681
  fileResponse: defaultFileResponse,
7649
- networkUrl: defaultNetworkUrl
8682
+ networkUrl: defaultNetworkUrl,
8683
+ fileMtime: defaultFileMtime
7650
8684
  };
7651
8685
  }
7652
8686
  //#endregion