@moku-labs/web 1.3.0 → 1.4.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.
@@ -2028,11 +2028,18 @@ type Config = {
2028
2028
  *
2029
2029
  * @example
2030
2030
  * ```ts
2031
- * { articles: new Map() }
2031
+ * { articles: new Map(), loadedAll: null }
2032
2032
  * ```
2033
2033
  */
2034
2034
  type State = {
2035
2035
  /** Article cache keyed locale -> (slug -> Article). Starts empty. */articles: Map<string, Map<string, Article>>;
2036
+ /**
2037
+ * Memoized full `loadAll()` result, or `null` when not yet loaded / invalidated. List-route
2038
+ * loaders call `loadAll()` once PER PAGE, so without this every page re-reads + re-renders
2039
+ * every article (the dev-loop killer). The memo makes repeated calls O(1); `invalidate()`
2040
+ * clears it so a dev rebuild reloads (re-resolving only the changed slugs). Starts `null`.
2041
+ */
2042
+ loadedAll: Map<string, Article[]> | null;
2036
2043
  };
2037
2044
  /**
2038
2045
  * Notification-only events emitted by the content plugin.
@@ -2082,11 +2089,13 @@ type ContentApiContext = {
2082
2089
  */
2083
2090
  type LoadAllOptions = {
2084
2091
  /**
2085
- * Reuse already-cached articles for slugs NOT dropped by a preceding `invalidate()`,
2086
- * re-reading + re-rendering (Shiki) ONLY the invalidated (dirty) articles. The
2087
- * post-sort `contentId` ordinals are always recomputed across the full set, so order +
2088
- * ids match a full load. Default `false` (a full load that re-reads every article).
2089
- * Used by dev incremental rebuilds; a fresh process / production build never reuses.
2092
+ * Reuse the per-build memo + per-slug cache (re-resolving only slugs a preceding
2093
+ * `invalidate()` dropped). Default `true` this is what keeps repeated `loadAll()` calls
2094
+ * (a list route's loader runs once per page) cheap, and makes a dev rebuild re-render only
2095
+ * changed articles. Set `false` to force a FRESH full reload (cold build / an
2096
+ * unclassifiable change), which re-reads + re-renders every article and rebuilds the memo.
2097
+ * The post-sort `contentId` ordinals are always recomputed across the full set, so order +
2098
+ * ids match a full load either way.
2090
2099
  */
2091
2100
  reuse?: boolean;
2092
2101
  };
@@ -2100,10 +2109,13 @@ type LoadAllOptions = {
2100
2109
  */
2101
2110
  type Api = {
2102
2111
  /**
2103
- * Load every article across every active locale, returning a locale-keyed
2104
- * map of date-descending Article arrays. Emits content:ready.
2112
+ * Load every article across every active locale, returning a locale-keyed map of
2113
+ * date-descending Article arrays. Emits content:ready (once per actual load). Cache-first
2114
+ * + memoized: repeated calls (e.g. a list route's loader on every page) return the SAME
2115
+ * cached result with no re-read — so treat the result as READ-ONLY (do not sort/mutate it
2116
+ * in place; slice/copy first). Pass `{ reuse: false }` to force a fresh full reload.
2105
2117
  *
2106
- * @param options - Optional load behaviour ({@link LoadAllOptions}); omit for a full load.
2118
+ * @param options - Optional load behaviour ({@link LoadAllOptions}); default reuses the cache.
2107
2119
  */
2108
2120
  loadAll(options?: LoadAllOptions): Promise<Map<string, Article[]>>;
2109
2121
  /**
package/dist/browser.mjs CHANGED
@@ -4409,12 +4409,15 @@ function createContentApi(ctx) {
4409
4409
  * Load every article across every active locale (locale fallback, production
4410
4410
  * draft exclusion, date sort, `contentId` after sort), cache them, emit `content:ready`.
4411
4411
  *
4412
- * With `{ reuse: true }` (dev incremental rebuild) cached articles are reused for
4413
- * every slug a preceding `invalidate()` did not drop, so only the dirty articles
4414
- * re-read + re-run the Markdown/Shiki pipeline; the `contentId` ordinals are still
4415
- * recomputed across the FULL sorted set, so ids + order match a full load.
4412
+ * Cache-first by default: repeated calls return the per-build memo (list-route loaders
4413
+ * call this once PER PAGE without the memo every page would re-read + re-render every
4414
+ * article, the dev-loop killer), and a rebuild after `invalidate()` re-resolves only the
4415
+ * dropped slugs while reusing the cached articles for the rest (`contentId` ordinals are
4416
+ * still recomputed across the FULL sorted set, so ids + order match a full load). Pass
4417
+ * `{ reuse: false }` to force a FRESH full reload (cold build / an unclassifiable change
4418
+ * where the caller cannot pinpoint what changed) — this bypasses the memo + per-slug cache.
4416
4419
  *
4417
- * @param options - Optional load behaviour (`reuse`); omit for a full load.
4420
+ * @param options - Optional load behaviour (`reuse`, default `true`).
4418
4421
  * @returns A locale-keyed map of date-descending articles.
4419
4422
  * @example
4420
4423
  * ```ts
@@ -4422,7 +4425,9 @@ function createContentApi(ctx) {
4422
4425
  * ```
4423
4426
  */
4424
4427
  async loadAll(options) {
4425
- const reuse = options?.reuse === true;
4428
+ const reuse = options?.reuse !== false;
4429
+ const memo = ctx.state.loadedAll;
4430
+ if (reuse && memo !== null) return memo;
4426
4431
  const slugs = await ctx.provider.slugs();
4427
4432
  const locales = ctx.locales();
4428
4433
  const result = /* @__PURE__ */ new Map();
@@ -4440,6 +4445,7 @@ function createContentApi(ctx) {
4440
4445
  result.set(locale, present);
4441
4446
  total += present.length;
4442
4447
  }
4448
+ ctx.state.loadedAll = result;
4443
4449
  ctx.emit("content:ready", {
4444
4450
  locales,
4445
4451
  articleCount: total
@@ -4503,6 +4509,7 @@ function createContentApi(ctx) {
4503
4509
  if (slug === void 0) continue;
4504
4510
  for (const cache of ctx.state.articles.values()) cache.delete(slug);
4505
4511
  }
4512
+ if (accepted.length > 0) ctx.state.loadedAll = null;
4506
4513
  ctx.emit("content:invalidated", { paths: accepted });
4507
4514
  },
4508
4515
  /**
@@ -4572,14 +4579,17 @@ const contentEvents = (register) => ({
4572
4579
  * @param _ctx - Minimal context with global and config.
4573
4580
  * @param _ctx.global - Global plugin registry.
4574
4581
  * @param _ctx.config - Resolved plugin configuration.
4575
- * @returns Fresh content shell state: an empty article cache.
4582
+ * @returns Fresh content shell state: an empty article cache + an empty loadAll memo.
4576
4583
  * @example
4577
4584
  * ```ts
4578
4585
  * const state = createContentState({ global: {}, config: { providers: [] } });
4579
4586
  * ```
4580
4587
  */
4581
4588
  function createContentState(_ctx) {
4582
- return { articles: /* @__PURE__ */ new Map() };
4589
+ return {
4590
+ articles: /* @__PURE__ */ new Map(),
4591
+ loadedAll: null
4592
+ };
4583
4593
  }
4584
4594
  //#endregion
4585
4595
  //#region src/plugins/content/validate.ts
package/dist/index.cjs CHANGED
@@ -1349,12 +1349,15 @@ function createContentApi(ctx) {
1349
1349
  * Load every article across every active locale (locale fallback, production
1350
1350
  * draft exclusion, date sort, `contentId` after sort), cache them, emit `content:ready`.
1351
1351
  *
1352
- * With `{ reuse: true }` (dev incremental rebuild) cached articles are reused for
1353
- * every slug a preceding `invalidate()` did not drop, so only the dirty articles
1354
- * re-read + re-run the Markdown/Shiki pipeline; the `contentId` ordinals are still
1355
- * recomputed across the FULL sorted set, so ids + order match a full load.
1352
+ * Cache-first by default: repeated calls return the per-build memo (list-route loaders
1353
+ * call this once PER PAGE without the memo every page would re-read + re-render every
1354
+ * article, the dev-loop killer), and a rebuild after `invalidate()` re-resolves only the
1355
+ * dropped slugs while reusing the cached articles for the rest (`contentId` ordinals are
1356
+ * still recomputed across the FULL sorted set, so ids + order match a full load). Pass
1357
+ * `{ reuse: false }` to force a FRESH full reload (cold build / an unclassifiable change
1358
+ * where the caller cannot pinpoint what changed) — this bypasses the memo + per-slug cache.
1356
1359
  *
1357
- * @param options - Optional load behaviour (`reuse`); omit for a full load.
1360
+ * @param options - Optional load behaviour (`reuse`, default `true`).
1358
1361
  * @returns A locale-keyed map of date-descending articles.
1359
1362
  * @example
1360
1363
  * ```ts
@@ -1362,7 +1365,9 @@ function createContentApi(ctx) {
1362
1365
  * ```
1363
1366
  */
1364
1367
  async loadAll(options) {
1365
- const reuse = options?.reuse === true;
1368
+ const reuse = options?.reuse !== false;
1369
+ const memo = ctx.state.loadedAll;
1370
+ if (reuse && memo !== null) return memo;
1366
1371
  const slugs = await ctx.provider.slugs();
1367
1372
  const locales = ctx.locales();
1368
1373
  const result = /* @__PURE__ */ new Map();
@@ -1380,6 +1385,7 @@ function createContentApi(ctx) {
1380
1385
  result.set(locale, present);
1381
1386
  total += present.length;
1382
1387
  }
1388
+ ctx.state.loadedAll = result;
1383
1389
  ctx.emit("content:ready", {
1384
1390
  locales,
1385
1391
  articleCount: total
@@ -1443,6 +1449,7 @@ function createContentApi(ctx) {
1443
1449
  if (slug === void 0) continue;
1444
1450
  for (const cache of ctx.state.articles.values()) cache.delete(slug);
1445
1451
  }
1452
+ if (accepted.length > 0) ctx.state.loadedAll = null;
1446
1453
  ctx.emit("content:invalidated", { paths: accepted });
1447
1454
  },
1448
1455
  /**
@@ -1512,14 +1519,17 @@ const contentEvents = (register) => ({
1512
1519
  * @param _ctx - Minimal context with global and config.
1513
1520
  * @param _ctx.global - Global plugin registry.
1514
1521
  * @param _ctx.config - Resolved plugin configuration.
1515
- * @returns Fresh content shell state: an empty article cache.
1522
+ * @returns Fresh content shell state: an empty article cache + an empty loadAll memo.
1516
1523
  * @example
1517
1524
  * ```ts
1518
1525
  * const state = createContentState({ global: {}, config: { providers: [] } });
1519
1526
  * ```
1520
1527
  */
1521
1528
  function createContentState(_ctx) {
1522
- return { articles: /* @__PURE__ */ new Map() };
1529
+ return {
1530
+ articles: /* @__PURE__ */ new Map(),
1531
+ loadedAll: null
1532
+ };
1523
1533
  }
1524
1534
  //#endregion
1525
1535
  //#region src/plugins/content/validate.ts
@@ -3832,7 +3842,7 @@ const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.<
3832
3842
  * ```
3833
3843
  */
3834
3844
  function wrap(body) {
3835
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>404 — Not Found</title></head><body>${body}</body></html>`;
3845
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>404 — Not Found</title></head><body>${body}</body></html>`;
3836
3846
  }
3837
3847
  /**
3838
3848
  * Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
@@ -4516,6 +4526,8 @@ const HEAD_PLACEHOLDER = "<!--moku:head-->";
4516
4526
  const BODY_PLACEHOLDER = "<!--moku:body-->";
4517
4527
  /** Template placeholder for the injected asset `<link>`/`<script>` tags. */
4518
4528
  const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
4529
+ /** Template placeholder for the page's locale (`<html lang>`). */
4530
+ const LANG_PLACEHOLDER = "<!--moku:lang-->";
4519
4531
  /**
4520
4532
  * Read the bundle phase's hashed asset manifest for one kind from `state.buildCache`
4521
4533
  * as a typed {@link BuildCacheEntry} (no `Map<string, unknown>` reads).
@@ -4563,14 +4575,16 @@ function buildAssetTags(ctx) {
4563
4575
  * ```
4564
4576
  */
4565
4577
  function renderDocument(parts) {
4566
- return `<!DOCTYPE html><html lang="${parts.locale}"><head>${parts.head}${parts.assets}</head><body>${parts.body}</body></html>`;
4578
+ return `<!DOCTYPE html><html lang="${parts.locale}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${parts.head}${parts.assets}</head><body>${parts.body}</body></html>`;
4567
4579
  }
4568
4580
  /**
4569
- * Fill a shell template's `<!--moku:head-->` / `<!--moku:body-->` /
4570
- * `<!--moku:assets-->` placeholders deterministically at build time.
4581
+ * Fill a shell template's `<!--moku:lang-->` / `<!--moku:head-->` /
4582
+ * `<!--moku:body-->` / `<!--moku:assets-->` placeholders deterministically at build
4583
+ * time. `<!--moku:lang-->` carries the page locale (for `<html lang>`), so a single
4584
+ * shared template stays locale-correct across every locale.
4571
4585
  *
4572
4586
  * @param template - The raw shell template HTML.
4573
- * @param parts - The composed head/body/assets pieces.
4587
+ * @param parts - The composed head/body/assets/locale pieces.
4574
4588
  * @returns The filled document string.
4575
4589
  * @example
4576
4590
  * ```ts
@@ -4578,7 +4592,7 @@ function renderDocument(parts) {
4578
4592
  * ```
4579
4593
  */
4580
4594
  function fillTemplate(template, parts) {
4581
- return template.replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
4595
+ return template.replaceAll(LANG_PLACEHOLDER, parts.locale).replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
4582
4596
  }
4583
4597
  /**
4584
4598
  * Resolve the compiled entry for a manifest definition, asserting the router
@@ -5830,7 +5844,8 @@ function buildWranglerArgs(input) {
5830
5844
  "--project-name",
5831
5845
  input.slug,
5832
5846
  "--branch",
5833
- branch
5847
+ branch,
5848
+ "--commit-dirty=true"
5834
5849
  ];
5835
5850
  }
5836
5851
  /**
@@ -6439,17 +6454,18 @@ function validateConfig$1(ctx) {
6439
6454
  ctx.require(sitePlugin);
6440
6455
  }
6441
6456
  /**
6442
- * Run wrangler for the prepared argv and surface its scrubbed result, translating
6443
- * a non-zero exit into the classified deploy error. The API token is read from env
6444
- * here so it never crosses a logging boundary; only scrubbed output is returned.
6445
- * Shared by `run()` (deploy) and `createProject()` (project create).
6457
+ * Run wrangler for the prepared argv and return its stdout, translating a non-zero
6458
+ * exit into the classified deploy error. The API token is read from env here so it
6459
+ * never crosses a logging boundary; the scrubbed stderr is used only to classify a
6460
+ * failure it is never logged (that was console noise), so nothing leaks. Shared by
6461
+ * `run()` (deploy) and `createProject()` (project create).
6446
6462
  *
6447
6463
  * @param ctx - Plugin context (provides `state.spawn`, `config`, `env`).
6448
6464
  * @param args - The fully-built, pre-validated wrangler argv.
6449
- * @returns The wrangler `stdout` plus the scrubbed `stderr` to log on success.
6465
+ * @returns The wrangler `stdout` (for URL/id parsing on a deploy).
6450
6466
  * @throws {Error} With a `code` from the deploy error taxonomy on a non-zero exit.
6451
6467
  * @example
6452
- * const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
6468
+ * const stdout = await executeWrangler(ctx, args);
6453
6469
  */
6454
6470
  async function executeWrangler(ctx, args) {
6455
6471
  const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
@@ -6463,10 +6479,7 @@ async function executeWrangler(ctx, args) {
6463
6479
  const { code, message } = classifyWranglerError(exitCode, scrubbedStderr);
6464
6480
  throw deployError(code, message);
6465
6481
  }
6466
- return {
6467
- stdout,
6468
- scrubbedStderr
6469
- };
6482
+ return stdout;
6470
6483
  }
6471
6484
  /**
6472
6485
  * Assemble the public {@link DeployResult} from wrangler's stdout, parsing the
@@ -6523,9 +6536,7 @@ function createApi$2(ctx) {
6523
6536
  root
6524
6537
  });
6525
6538
  const start = Date.now();
6526
- const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
6527
- ctx.log.info(scrubbedStderr);
6528
- const result = buildDeployResult(stdout, branch, start);
6539
+ const result = buildDeployResult(await executeWrangler(ctx, args), branch, start);
6529
6540
  ctx.state.lastDeployment = result;
6530
6541
  ctx.emit("deploy:complete", {
6531
6542
  url: result.url,
@@ -6585,11 +6596,10 @@ function createApi$2(ctx) {
6585
6596
  async createProject() {
6586
6597
  const name = toSlug(ctx.require(sitePlugin).name());
6587
6598
  const branch = ctx.config.productionBranch ?? "main";
6588
- const { scrubbedStderr } = await executeWrangler(ctx, buildProjectCreateArgs({
6599
+ await executeWrangler(ctx, buildProjectCreateArgs({
6589
6600
  slug: name,
6590
6601
  branch
6591
6602
  }));
6592
- ctx.log.info(scrubbedStderr);
6593
6603
  return {
6594
6604
  name,
6595
6605
  branch
@@ -7515,23 +7525,23 @@ function createDevHandler(ctx, hub) {
7515
7525
  }
7516
7526
  /**
7517
7527
  * Build the per-run {@link BuildRunOverrides} for a dev build from the session feature
7518
- * opt-ins: minification is always off in dev (no benefit, slower), and each expensive
7519
- * output stays off unless its flag re-enables it (`ogImage: false` disables OG generation
7520
- * regardless of the persisted config). The persisted plugin config is never mutated — the
7521
- * overrides apply to the dev run only.
7528
+ * opt-ins: minification is always off in dev (no benefit, slower), and each expensive,
7529
+ * NON-navigational output stays off unless its flag re-enables it (`ogImage: false`
7530
+ * disables OG generation regardless of the persisted config). Locale-redirects are NOT
7531
+ * overridden they produce navigable pages (the bare `/` → `/{defaultLocale}/` redirect),
7532
+ * so they follow the app's own config. The persisted plugin config is never mutated.
7522
7533
  *
7523
7534
  * @param features - The resolved per-session dev feature opt-ins.
7524
7535
  * @returns The config overrides merged into the dev build run.
7525
7536
  * @example
7526
- * devBuildOverrides({ og: false, sitemap: false, feeds: false, localeRedirects: false });
7537
+ * devBuildOverrides({ og: false, sitemap: false, feeds: false });
7527
7538
  */
7528
7539
  function devBuildOverrides(features) {
7529
7540
  return {
7530
7541
  minify: false,
7531
7542
  ...features.feeds ? {} : { feeds: false },
7532
7543
  ...features.sitemap ? {} : { sitemap: false },
7533
- ...features.og ? {} : { ogImage: false },
7534
- ...features.localeRedirects ? {} : { localeRedirects: false }
7544
+ ...features.og ? {} : { ogImage: false }
7535
7545
  };
7536
7546
  }
7537
7547
  /**
@@ -7891,9 +7901,10 @@ function createApi$1(ctx) {
7891
7901
  /**
7892
7902
  * Dev loop: build once, serve `dist/` in-process (live-reload injected), watch
7893
7903
  * `watchDirs`, debounced + incremental rebuild + reload. For a fast rebuild the dev
7894
- * build disables minification + expensive, preview-irrelevant outputs (feeds /
7895
- * sitemap / og-images / locale-redirects); pass `og`/`sitemap`/`feeds`/
7896
- * `localeRedirects` to re-enable any of them for the session. Resolves on SIGINT/SIGTERM.
7904
+ * build disables minification + expensive, NON-navigational outputs (feeds / sitemap /
7905
+ * og-images); pass `og`/`sitemap`/`feeds` to re-enable any of them for the session.
7906
+ * Locale-redirects are always built per the app config (they emit the navigable bare-path
7907
+ * `/` → `/{defaultLocale}/` redirect). Resolves on SIGINT/SIGTERM.
7897
7908
  *
7898
7909
  * @param options - Optional port override + per-session dev feature opt-ins.
7899
7910
  * @returns Resolves once the server has been torn down.
@@ -7906,8 +7917,7 @@ function createApi$1(ctx) {
7906
7917
  return runDevServer(ctx, port, {
7907
7918
  og: options.og ?? false,
7908
7919
  sitemap: options.sitemap ?? false,
7909
- feeds: options.feeds ?? false,
7910
- localeRedirects: options.localeRedirects ?? false
7920
+ feeds: options.feeds ?? false
7911
7921
  });
7912
7922
  },
7913
7923
  /**
@@ -8756,16 +8766,24 @@ function createPanelRenderer(options = {}) {
8756
8766
  write(line);
8757
8767
  },
8758
8768
  /**
8759
- * Render the deploy result from a `deploy:complete` event: a `✓ DEPLOYED → url` line
8760
- * with the URL the hero value, then a dim `branch · id · time` line beneath it.
8769
+ * Render the deploy result from a `deploy:complete` event as a full-width box (matching
8770
+ * the BUILD panel): a `✓ DEPLOYED · branch` header with the elapsed time right-aligned,
8771
+ * then a `→ url · id` row. The url/id row is omitted entirely when wrangler returned no
8772
+ * URL, so a first-deploy with nothing to parse never renders a dangling `→` or `· ·`.
8761
8773
  *
8762
8774
  * @param result - The `deploy:complete` payload.
8763
8775
  * @example
8764
8776
  * render.deployed({ url: "https://x.pages.dev", deploymentId: "id", branch: "main", durationMs: 1200 });
8765
8777
  */
8766
8778
  deployed(result) {
8767
- const meta = palette.dim(`branch ${result.branch} · ${result.deploymentId} · ${result.durationMs}ms`);
8768
- writeBlock([` ${palette.green("✓")} ${palette.bold("DEPLOYED")} ${palette.dim("→")} ${palette.cyan(result.url)}`, ` ${meta}`]);
8779
+ const dot = palette.dim("·");
8780
+ const time = result.durationMs >= 1e3 ? `${(result.durationMs / 1e3).toFixed(1)}s` : `${result.durationMs}ms`;
8781
+ const lines = [railLine(`${palette.green("✓")} ${palette.bold("DEPLOYED")} ${dot} ${result.branch}`, palette.dim(time), BOX_INNER)];
8782
+ if (result.url) {
8783
+ const id = result.deploymentId ? ` ${dot} ${palette.dim(result.deploymentId)}` : "";
8784
+ lines.push(`${palette.dim("→")} ${palette.cyan(result.url)}${id}`);
8785
+ } else if (result.deploymentId) lines.push(palette.dim(`id ${result.deploymentId}`));
8786
+ writeBlock(box(lines, color, BOX_INNER));
8769
8787
  },
8770
8788
  /**
8771
8789
  * Render a neutral informational line.
@@ -8860,6 +8878,29 @@ function createPanelRenderer(options = {}) {
8860
8878
  */
8861
8879
  /** Matches an explicit affirmative answer (`y`/`yes`, case-insensitive). */
8862
8880
  const YES_PATTERN = /^y(es)?$/i;
8881
+ /** Prompt rail width — matches the renderer's `RAIL_WIDTH` so the hint aligns with other rows. */
8882
+ const PROMPT_WIDTH = 66;
8883
+ /** Whether the interactive prompts render with the MOKU marker styling (color/TTY only). */
8884
+ const PROMPT_COLOR = supportsColor();
8885
+ /** Shared palette for the interactive prompts (same brand colors as the Panel renderer). */
8886
+ const PROMPT_PALETTE = makePalette(PROMPT_COLOR, PROMPT_COLOR && supportsTruecolor());
8887
+ /**
8888
+ * Build the styled y/N confirm prompt: a brand `◆` marker + the question on the left,
8889
+ * a dim `y / N` hint + cyan `›` caret right-aligned to {@link PROMPT_WIDTH}. Falls back
8890
+ * to the plain `question [y/N] ` form off a color TTY (CI/pipes), where prompts rarely run.
8891
+ *
8892
+ * @param question - The yes/no question to display.
8893
+ * @returns The readline prompt string (the typed answer follows the caret).
8894
+ * @example
8895
+ * confirmPrompt("Deploy dist/ to Cloudflare Pages?");
8896
+ */
8897
+ function confirmPrompt(question) {
8898
+ if (!PROMPT_COLOR) return `${question} [y/N] `;
8899
+ const left = ` ${PROMPT_PALETTE.pink("◆")} ${question}`;
8900
+ const right = `${PROMPT_PALETTE.dim("y / N")} ${PROMPT_PALETTE.cyan("›")} `;
8901
+ const gap = Math.max(1, PROMPT_WIDTH - visibleWidth(left) - visibleWidth(right));
8902
+ return `${left}${" ".repeat(gap)}${right}`;
8903
+ }
8863
8904
  /**
8864
8905
  * Resolve the `Bun` runtime global, or `undefined` when not running under Bun.
8865
8906
  *
@@ -8917,7 +8958,7 @@ function defaultConfirm(question) {
8917
8958
  input: process.stdin,
8918
8959
  output: process.stdout
8919
8960
  });
8920
- readline.question(`${question} [y/N] `, (answer) => {
8961
+ readline.question(confirmPrompt(question), (answer) => {
8921
8962
  readline.close();
8922
8963
  resolve(YES_PATTERN.test(answer.trim()));
8923
8964
  });
@@ -8940,8 +8981,8 @@ function defaultSelect(question, choices) {
8940
8981
  input: process.stdin,
8941
8982
  output: process.stdout
8942
8983
  });
8943
- for (const [index, choice] of choices.entries()) console.log(` ${index + 1}) ${choice}`);
8944
- readline.question(`${question} [1-${choices.length}] `, (answer) => {
8984
+ console.log(selectChoicesBlock(question, choices));
8985
+ readline.question(selectPrompt(question, choices.length), (answer) => {
8945
8986
  readline.close();
8946
8987
  const picked = Number.parseInt(answer.trim(), 10);
8947
8988
  resolve(Number.isInteger(picked) && picked >= 1 && picked <= choices.length ? picked - 1 : 0);
@@ -8949,6 +8990,36 @@ function defaultSelect(question, choices) {
8949
8990
  });
8950
8991
  }
8951
8992
  /**
8993
+ * Render the select block: a brand `◆` marker + the question, then each choice as an
8994
+ * indented dim number + label. Off a color TTY, falls back to the plain ` N) label`
8995
+ * list (the question rides the prompt instead).
8996
+ *
8997
+ * @param question - The prompt shown above the choices (styled mode only).
8998
+ * @param choices - The selectable option labels.
8999
+ * @returns The multi-line choices block.
9000
+ * @example
9001
+ * selectChoicesBlock("Set up a workflow?", ["Auto", "Manual", "Skip"]);
9002
+ */
9003
+ function selectChoicesBlock(question, choices) {
9004
+ if (!PROMPT_COLOR) return choices.map((choice, index) => ` ${index + 1}) ${choice}`).join("\n");
9005
+ return [` ${PROMPT_PALETTE.pink("◆")} ${question}`, ...choices.map((choice, index) => ` ${PROMPT_PALETTE.dim(String(index + 1))} ${choice}`)].join("\n");
9006
+ }
9007
+ /**
9008
+ * Build the select input prompt: a dim `pick 1–N` hint + cyan `›` caret in styled mode,
9009
+ * or the plain `question [1-N] ` form off a color TTY (where the question is not printed
9010
+ * separately).
9011
+ *
9012
+ * @param question - The prompt (used only by the plain fallback).
9013
+ * @param count - The number of choices.
9014
+ * @returns The readline prompt string.
9015
+ * @example
9016
+ * selectPrompt("Set up a workflow?", 3);
9017
+ */
9018
+ function selectPrompt(question, count) {
9019
+ if (!PROMPT_COLOR) return `${question} [1-${count}] `;
9020
+ return ` ${PROMPT_PALETTE.dim(`pick 1–${count}`)} ${PROMPT_PALETTE.cyan("›")} `;
9021
+ }
9022
+ /**
8952
9023
  * Default recursive directory watcher — wraps `node:fs.watch` with `{ recursive: true }`
8953
9024
  * and adapts its handle to {@link WatchHandle}. Tests inject a fake emitter so no real
8954
9025
  * FS watch is registered.
package/dist/index.d.cts CHANGED
@@ -1615,7 +1615,17 @@ type Config$3 = {
1615
1615
  body?: string;
1616
1616
  }; /** Emit per-path i18n bare-path redirect HTML pages. Default `false`. */
1617
1617
  localeRedirects?: boolean; /** Authoritative client bundle entry path (overrides the conventional scan). */
1618
- clientEntry?: string; /** HTML shell template with `<!--moku:head-->`/`<!--moku:body-->`/`<!--moku:assets-->` placeholders. */
1618
+ clientEntry?: string;
1619
+ /**
1620
+ * Path to a custom HTML document shell, giving the app full control over the
1621
+ * scaffold (charset, viewport, `<html lang>`, body attributes, wrapper markup).
1622
+ * Placeholders, substituted per page at build time:
1623
+ * `<!--moku:lang-->` (page locale for `<html lang>`),
1624
+ * `<!--moku:head-->` (composed `<head>` inner HTML),
1625
+ * `<!--moku:assets-->` (injected `<link>`/`<script>` tags),
1626
+ * `<!--moku:body-->` (SSR body HTML).
1627
+ * When unset, the built-in shell is used (it emits charset + viewport by default).
1628
+ */
1619
1629
  template?: string;
1620
1630
  };
1621
1631
  /**
@@ -2460,8 +2470,7 @@ type ServeOptions = {
2460
2470
  */
2461
2471
  og?: boolean; /** Re-enable `sitemap.xml` + `robots.txt` for this dev session (maps to `--sitemap`). Defaults to `false`. */
2462
2472
  sitemap?: boolean; /** Re-enable RSS/Atom/JSON feeds for this dev session (maps to `--feeds`). Defaults to `false`. */
2463
- feeds?: boolean; /** Re-enable i18n locale-redirect pages for this dev session (maps to `--locale-redirects`). Defaults to `false`. */
2464
- localeRedirects?: boolean;
2473
+ feeds?: boolean;
2465
2474
  };
2466
2475
  /**
2467
2476
  * Options for `cli.preview()`.
@@ -2736,11 +2745,18 @@ type Config = {
2736
2745
  *
2737
2746
  * @example
2738
2747
  * ```ts
2739
- * { articles: new Map() }
2748
+ * { articles: new Map(), loadedAll: null }
2740
2749
  * ```
2741
2750
  */
2742
2751
  type State = {
2743
2752
  /** Article cache keyed locale -> (slug -> Article). Starts empty. */articles: Map<string, Map<string, Article>>;
2753
+ /**
2754
+ * Memoized full `loadAll()` result, or `null` when not yet loaded / invalidated. List-route
2755
+ * loaders call `loadAll()` once PER PAGE, so without this every page re-reads + re-renders
2756
+ * every article (the dev-loop killer). The memo makes repeated calls O(1); `invalidate()`
2757
+ * clears it so a dev rebuild reloads (re-resolving only the changed slugs). Starts `null`.
2758
+ */
2759
+ loadedAll: Map<string, Article[]> | null;
2744
2760
  };
2745
2761
  /**
2746
2762
  * Notification-only events emitted by the content plugin.
@@ -2790,11 +2806,13 @@ type ContentApiContext = {
2790
2806
  */
2791
2807
  type LoadAllOptions = {
2792
2808
  /**
2793
- * Reuse already-cached articles for slugs NOT dropped by a preceding `invalidate()`,
2794
- * re-reading + re-rendering (Shiki) ONLY the invalidated (dirty) articles. The
2795
- * post-sort `contentId` ordinals are always recomputed across the full set, so order +
2796
- * ids match a full load. Default `false` (a full load that re-reads every article).
2797
- * Used by dev incremental rebuilds; a fresh process / production build never reuses.
2809
+ * Reuse the per-build memo + per-slug cache (re-resolving only slugs a preceding
2810
+ * `invalidate()` dropped). Default `true` this is what keeps repeated `loadAll()` calls
2811
+ * (a list route's loader runs once per page) cheap, and makes a dev rebuild re-render only
2812
+ * changed articles. Set `false` to force a FRESH full reload (cold build / an
2813
+ * unclassifiable change), which re-reads + re-renders every article and rebuilds the memo.
2814
+ * The post-sort `contentId` ordinals are always recomputed across the full set, so order +
2815
+ * ids match a full load either way.
2798
2816
  */
2799
2817
  reuse?: boolean;
2800
2818
  };
@@ -2808,10 +2826,13 @@ type LoadAllOptions = {
2808
2826
  */
2809
2827
  type Api = {
2810
2828
  /**
2811
- * Load every article across every active locale, returning a locale-keyed
2812
- * map of date-descending Article arrays. Emits content:ready.
2829
+ * Load every article across every active locale, returning a locale-keyed map of
2830
+ * date-descending Article arrays. Emits content:ready (once per actual load). Cache-first
2831
+ * + memoized: repeated calls (e.g. a list route's loader on every page) return the SAME
2832
+ * cached result with no re-read — so treat the result as READ-ONLY (do not sort/mutate it
2833
+ * in place; slice/copy first). Pass `{ reuse: false }` to force a fresh full reload.
2813
2834
  *
2814
- * @param options - Optional load behaviour ({@link LoadAllOptions}); omit for a full load.
2835
+ * @param options - Optional load behaviour ({@link LoadAllOptions}); default reuses the cache.
2815
2836
  */
2816
2837
  loadAll(options?: LoadAllOptions): Promise<Map<string, Article[]>>;
2817
2838
  /**
package/dist/index.d.mts CHANGED
@@ -1615,7 +1615,17 @@ type Config$3 = {
1615
1615
  body?: string;
1616
1616
  }; /** Emit per-path i18n bare-path redirect HTML pages. Default `false`. */
1617
1617
  localeRedirects?: boolean; /** Authoritative client bundle entry path (overrides the conventional scan). */
1618
- clientEntry?: string; /** HTML shell template with `<!--moku:head-->`/`<!--moku:body-->`/`<!--moku:assets-->` placeholders. */
1618
+ clientEntry?: string;
1619
+ /**
1620
+ * Path to a custom HTML document shell, giving the app full control over the
1621
+ * scaffold (charset, viewport, `<html lang>`, body attributes, wrapper markup).
1622
+ * Placeholders, substituted per page at build time:
1623
+ * `<!--moku:lang-->` (page locale for `<html lang>`),
1624
+ * `<!--moku:head-->` (composed `<head>` inner HTML),
1625
+ * `<!--moku:assets-->` (injected `<link>`/`<script>` tags),
1626
+ * `<!--moku:body-->` (SSR body HTML).
1627
+ * When unset, the built-in shell is used (it emits charset + viewport by default).
1628
+ */
1619
1629
  template?: string;
1620
1630
  };
1621
1631
  /**
@@ -2460,8 +2470,7 @@ type ServeOptions = {
2460
2470
  */
2461
2471
  og?: boolean; /** Re-enable `sitemap.xml` + `robots.txt` for this dev session (maps to `--sitemap`). Defaults to `false`. */
2462
2472
  sitemap?: boolean; /** Re-enable RSS/Atom/JSON feeds for this dev session (maps to `--feeds`). Defaults to `false`. */
2463
- feeds?: boolean; /** Re-enable i18n locale-redirect pages for this dev session (maps to `--locale-redirects`). Defaults to `false`. */
2464
- localeRedirects?: boolean;
2473
+ feeds?: boolean;
2465
2474
  };
2466
2475
  /**
2467
2476
  * Options for `cli.preview()`.
@@ -2736,11 +2745,18 @@ type Config = {
2736
2745
  *
2737
2746
  * @example
2738
2747
  * ```ts
2739
- * { articles: new Map() }
2748
+ * { articles: new Map(), loadedAll: null }
2740
2749
  * ```
2741
2750
  */
2742
2751
  type State = {
2743
2752
  /** Article cache keyed locale -> (slug -> Article). Starts empty. */articles: Map<string, Map<string, Article>>;
2753
+ /**
2754
+ * Memoized full `loadAll()` result, or `null` when not yet loaded / invalidated. List-route
2755
+ * loaders call `loadAll()` once PER PAGE, so without this every page re-reads + re-renders
2756
+ * every article (the dev-loop killer). The memo makes repeated calls O(1); `invalidate()`
2757
+ * clears it so a dev rebuild reloads (re-resolving only the changed slugs). Starts `null`.
2758
+ */
2759
+ loadedAll: Map<string, Article[]> | null;
2744
2760
  };
2745
2761
  /**
2746
2762
  * Notification-only events emitted by the content plugin.
@@ -2790,11 +2806,13 @@ type ContentApiContext = {
2790
2806
  */
2791
2807
  type LoadAllOptions = {
2792
2808
  /**
2793
- * Reuse already-cached articles for slugs NOT dropped by a preceding `invalidate()`,
2794
- * re-reading + re-rendering (Shiki) ONLY the invalidated (dirty) articles. The
2795
- * post-sort `contentId` ordinals are always recomputed across the full set, so order +
2796
- * ids match a full load. Default `false` (a full load that re-reads every article).
2797
- * Used by dev incremental rebuilds; a fresh process / production build never reuses.
2809
+ * Reuse the per-build memo + per-slug cache (re-resolving only slugs a preceding
2810
+ * `invalidate()` dropped). Default `true` this is what keeps repeated `loadAll()` calls
2811
+ * (a list route's loader runs once per page) cheap, and makes a dev rebuild re-render only
2812
+ * changed articles. Set `false` to force a FRESH full reload (cold build / an
2813
+ * unclassifiable change), which re-reads + re-renders every article and rebuilds the memo.
2814
+ * The post-sort `contentId` ordinals are always recomputed across the full set, so order +
2815
+ * ids match a full load either way.
2798
2816
  */
2799
2817
  reuse?: boolean;
2800
2818
  };
@@ -2808,10 +2826,13 @@ type LoadAllOptions = {
2808
2826
  */
2809
2827
  type Api = {
2810
2828
  /**
2811
- * Load every article across every active locale, returning a locale-keyed
2812
- * map of date-descending Article arrays. Emits content:ready.
2829
+ * Load every article across every active locale, returning a locale-keyed map of
2830
+ * date-descending Article arrays. Emits content:ready (once per actual load). Cache-first
2831
+ * + memoized: repeated calls (e.g. a list route's loader on every page) return the SAME
2832
+ * cached result with no re-read — so treat the result as READ-ONLY (do not sort/mutate it
2833
+ * in place; slice/copy first). Pass `{ reuse: false }` to force a fresh full reload.
2813
2834
  *
2814
- * @param options - Optional load behaviour ({@link LoadAllOptions}); omit for a full load.
2835
+ * @param options - Optional load behaviour ({@link LoadAllOptions}); default reuses the cache.
2815
2836
  */
2816
2837
  loadAll(options?: LoadAllOptions): Promise<Map<string, Article[]>>;
2817
2838
  /**
package/dist/index.mjs CHANGED
@@ -1336,12 +1336,15 @@ function createContentApi(ctx) {
1336
1336
  * Load every article across every active locale (locale fallback, production
1337
1337
  * draft exclusion, date sort, `contentId` after sort), cache them, emit `content:ready`.
1338
1338
  *
1339
- * With `{ reuse: true }` (dev incremental rebuild) cached articles are reused for
1340
- * every slug a preceding `invalidate()` did not drop, so only the dirty articles
1341
- * re-read + re-run the Markdown/Shiki pipeline; the `contentId` ordinals are still
1342
- * recomputed across the FULL sorted set, so ids + order match a full load.
1339
+ * Cache-first by default: repeated calls return the per-build memo (list-route loaders
1340
+ * call this once PER PAGE without the memo every page would re-read + re-render every
1341
+ * article, the dev-loop killer), and a rebuild after `invalidate()` re-resolves only the
1342
+ * dropped slugs while reusing the cached articles for the rest (`contentId` ordinals are
1343
+ * still recomputed across the FULL sorted set, so ids + order match a full load). Pass
1344
+ * `{ reuse: false }` to force a FRESH full reload (cold build / an unclassifiable change
1345
+ * where the caller cannot pinpoint what changed) — this bypasses the memo + per-slug cache.
1343
1346
  *
1344
- * @param options - Optional load behaviour (`reuse`); omit for a full load.
1347
+ * @param options - Optional load behaviour (`reuse`, default `true`).
1345
1348
  * @returns A locale-keyed map of date-descending articles.
1346
1349
  * @example
1347
1350
  * ```ts
@@ -1349,7 +1352,9 @@ function createContentApi(ctx) {
1349
1352
  * ```
1350
1353
  */
1351
1354
  async loadAll(options) {
1352
- const reuse = options?.reuse === true;
1355
+ const reuse = options?.reuse !== false;
1356
+ const memo = ctx.state.loadedAll;
1357
+ if (reuse && memo !== null) return memo;
1353
1358
  const slugs = await ctx.provider.slugs();
1354
1359
  const locales = ctx.locales();
1355
1360
  const result = /* @__PURE__ */ new Map();
@@ -1367,6 +1372,7 @@ function createContentApi(ctx) {
1367
1372
  result.set(locale, present);
1368
1373
  total += present.length;
1369
1374
  }
1375
+ ctx.state.loadedAll = result;
1370
1376
  ctx.emit("content:ready", {
1371
1377
  locales,
1372
1378
  articleCount: total
@@ -1430,6 +1436,7 @@ function createContentApi(ctx) {
1430
1436
  if (slug === void 0) continue;
1431
1437
  for (const cache of ctx.state.articles.values()) cache.delete(slug);
1432
1438
  }
1439
+ if (accepted.length > 0) ctx.state.loadedAll = null;
1433
1440
  ctx.emit("content:invalidated", { paths: accepted });
1434
1441
  },
1435
1442
  /**
@@ -1499,14 +1506,17 @@ const contentEvents = (register) => ({
1499
1506
  * @param _ctx - Minimal context with global and config.
1500
1507
  * @param _ctx.global - Global plugin registry.
1501
1508
  * @param _ctx.config - Resolved plugin configuration.
1502
- * @returns Fresh content shell state: an empty article cache.
1509
+ * @returns Fresh content shell state: an empty article cache + an empty loadAll memo.
1503
1510
  * @example
1504
1511
  * ```ts
1505
1512
  * const state = createContentState({ global: {}, config: { providers: [] } });
1506
1513
  * ```
1507
1514
  */
1508
1515
  function createContentState(_ctx) {
1509
- return { articles: /* @__PURE__ */ new Map() };
1516
+ return {
1517
+ articles: /* @__PURE__ */ new Map(),
1518
+ loadedAll: null
1519
+ };
1510
1520
  }
1511
1521
  //#endregion
1512
1522
  //#region src/plugins/content/validate.ts
@@ -3819,7 +3829,7 @@ const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.<
3819
3829
  * ```
3820
3830
  */
3821
3831
  function wrap(body) {
3822
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>404 — Not Found</title></head><body>${body}</body></html>`;
3832
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>404 — Not Found</title></head><body>${body}</body></html>`;
3823
3833
  }
3824
3834
  /**
3825
3835
  * Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
@@ -4503,6 +4513,8 @@ const HEAD_PLACEHOLDER = "<!--moku:head-->";
4503
4513
  const BODY_PLACEHOLDER = "<!--moku:body-->";
4504
4514
  /** Template placeholder for the injected asset `<link>`/`<script>` tags. */
4505
4515
  const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
4516
+ /** Template placeholder for the page's locale (`<html lang>`). */
4517
+ const LANG_PLACEHOLDER = "<!--moku:lang-->";
4506
4518
  /**
4507
4519
  * Read the bundle phase's hashed asset manifest for one kind from `state.buildCache`
4508
4520
  * as a typed {@link BuildCacheEntry} (no `Map<string, unknown>` reads).
@@ -4550,14 +4562,16 @@ function buildAssetTags(ctx) {
4550
4562
  * ```
4551
4563
  */
4552
4564
  function renderDocument(parts) {
4553
- return `<!DOCTYPE html><html lang="${parts.locale}"><head>${parts.head}${parts.assets}</head><body>${parts.body}</body></html>`;
4565
+ return `<!DOCTYPE html><html lang="${parts.locale}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${parts.head}${parts.assets}</head><body>${parts.body}</body></html>`;
4554
4566
  }
4555
4567
  /**
4556
- * Fill a shell template's `<!--moku:head-->` / `<!--moku:body-->` /
4557
- * `<!--moku:assets-->` placeholders deterministically at build time.
4568
+ * Fill a shell template's `<!--moku:lang-->` / `<!--moku:head-->` /
4569
+ * `<!--moku:body-->` / `<!--moku:assets-->` placeholders deterministically at build
4570
+ * time. `<!--moku:lang-->` carries the page locale (for `<html lang>`), so a single
4571
+ * shared template stays locale-correct across every locale.
4558
4572
  *
4559
4573
  * @param template - The raw shell template HTML.
4560
- * @param parts - The composed head/body/assets pieces.
4574
+ * @param parts - The composed head/body/assets/locale pieces.
4561
4575
  * @returns The filled document string.
4562
4576
  * @example
4563
4577
  * ```ts
@@ -4565,7 +4579,7 @@ function renderDocument(parts) {
4565
4579
  * ```
4566
4580
  */
4567
4581
  function fillTemplate(template, parts) {
4568
- return template.replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
4582
+ return template.replaceAll(LANG_PLACEHOLDER, parts.locale).replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
4569
4583
  }
4570
4584
  /**
4571
4585
  * Resolve the compiled entry for a manifest definition, asserting the router
@@ -5817,7 +5831,8 @@ function buildWranglerArgs(input) {
5817
5831
  "--project-name",
5818
5832
  input.slug,
5819
5833
  "--branch",
5820
- branch
5834
+ branch,
5835
+ "--commit-dirty=true"
5821
5836
  ];
5822
5837
  }
5823
5838
  /**
@@ -6426,17 +6441,18 @@ function validateConfig$1(ctx) {
6426
6441
  ctx.require(sitePlugin);
6427
6442
  }
6428
6443
  /**
6429
- * Run wrangler for the prepared argv and surface its scrubbed result, translating
6430
- * a non-zero exit into the classified deploy error. The API token is read from env
6431
- * here so it never crosses a logging boundary; only scrubbed output is returned.
6432
- * Shared by `run()` (deploy) and `createProject()` (project create).
6444
+ * Run wrangler for the prepared argv and return its stdout, translating a non-zero
6445
+ * exit into the classified deploy error. The API token is read from env here so it
6446
+ * never crosses a logging boundary; the scrubbed stderr is used only to classify a
6447
+ * failure it is never logged (that was console noise), so nothing leaks. Shared by
6448
+ * `run()` (deploy) and `createProject()` (project create).
6433
6449
  *
6434
6450
  * @param ctx - Plugin context (provides `state.spawn`, `config`, `env`).
6435
6451
  * @param args - The fully-built, pre-validated wrangler argv.
6436
- * @returns The wrangler `stdout` plus the scrubbed `stderr` to log on success.
6452
+ * @returns The wrangler `stdout` (for URL/id parsing on a deploy).
6437
6453
  * @throws {Error} With a `code` from the deploy error taxonomy on a non-zero exit.
6438
6454
  * @example
6439
- * const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
6455
+ * const stdout = await executeWrangler(ctx, args);
6440
6456
  */
6441
6457
  async function executeWrangler(ctx, args) {
6442
6458
  const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
@@ -6450,10 +6466,7 @@ async function executeWrangler(ctx, args) {
6450
6466
  const { code, message } = classifyWranglerError(exitCode, scrubbedStderr);
6451
6467
  throw deployError(code, message);
6452
6468
  }
6453
- return {
6454
- stdout,
6455
- scrubbedStderr
6456
- };
6469
+ return stdout;
6457
6470
  }
6458
6471
  /**
6459
6472
  * Assemble the public {@link DeployResult} from wrangler's stdout, parsing the
@@ -6510,9 +6523,7 @@ function createApi$2(ctx) {
6510
6523
  root
6511
6524
  });
6512
6525
  const start = Date.now();
6513
- const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
6514
- ctx.log.info(scrubbedStderr);
6515
- const result = buildDeployResult(stdout, branch, start);
6526
+ const result = buildDeployResult(await executeWrangler(ctx, args), branch, start);
6516
6527
  ctx.state.lastDeployment = result;
6517
6528
  ctx.emit("deploy:complete", {
6518
6529
  url: result.url,
@@ -6572,11 +6583,10 @@ function createApi$2(ctx) {
6572
6583
  async createProject() {
6573
6584
  const name = toSlug(ctx.require(sitePlugin).name());
6574
6585
  const branch = ctx.config.productionBranch ?? "main";
6575
- const { scrubbedStderr } = await executeWrangler(ctx, buildProjectCreateArgs({
6586
+ await executeWrangler(ctx, buildProjectCreateArgs({
6576
6587
  slug: name,
6577
6588
  branch
6578
6589
  }));
6579
- ctx.log.info(scrubbedStderr);
6580
6590
  return {
6581
6591
  name,
6582
6592
  branch
@@ -7502,23 +7512,23 @@ function createDevHandler(ctx, hub) {
7502
7512
  }
7503
7513
  /**
7504
7514
  * Build the per-run {@link BuildRunOverrides} for a dev build from the session feature
7505
- * opt-ins: minification is always off in dev (no benefit, slower), and each expensive
7506
- * output stays off unless its flag re-enables it (`ogImage: false` disables OG generation
7507
- * regardless of the persisted config). The persisted plugin config is never mutated — the
7508
- * overrides apply to the dev run only.
7515
+ * opt-ins: minification is always off in dev (no benefit, slower), and each expensive,
7516
+ * NON-navigational output stays off unless its flag re-enables it (`ogImage: false`
7517
+ * disables OG generation regardless of the persisted config). Locale-redirects are NOT
7518
+ * overridden they produce navigable pages (the bare `/` → `/{defaultLocale}/` redirect),
7519
+ * so they follow the app's own config. The persisted plugin config is never mutated.
7509
7520
  *
7510
7521
  * @param features - The resolved per-session dev feature opt-ins.
7511
7522
  * @returns The config overrides merged into the dev build run.
7512
7523
  * @example
7513
- * devBuildOverrides({ og: false, sitemap: false, feeds: false, localeRedirects: false });
7524
+ * devBuildOverrides({ og: false, sitemap: false, feeds: false });
7514
7525
  */
7515
7526
  function devBuildOverrides(features) {
7516
7527
  return {
7517
7528
  minify: false,
7518
7529
  ...features.feeds ? {} : { feeds: false },
7519
7530
  ...features.sitemap ? {} : { sitemap: false },
7520
- ...features.og ? {} : { ogImage: false },
7521
- ...features.localeRedirects ? {} : { localeRedirects: false }
7531
+ ...features.og ? {} : { ogImage: false }
7522
7532
  };
7523
7533
  }
7524
7534
  /**
@@ -7878,9 +7888,10 @@ function createApi$1(ctx) {
7878
7888
  /**
7879
7889
  * Dev loop: build once, serve `dist/` in-process (live-reload injected), watch
7880
7890
  * `watchDirs`, debounced + incremental rebuild + reload. For a fast rebuild the dev
7881
- * build disables minification + expensive, preview-irrelevant outputs (feeds /
7882
- * sitemap / og-images / locale-redirects); pass `og`/`sitemap`/`feeds`/
7883
- * `localeRedirects` to re-enable any of them for the session. Resolves on SIGINT/SIGTERM.
7891
+ * build disables minification + expensive, NON-navigational outputs (feeds / sitemap /
7892
+ * og-images); pass `og`/`sitemap`/`feeds` to re-enable any of them for the session.
7893
+ * Locale-redirects are always built per the app config (they emit the navigable bare-path
7894
+ * `/` → `/{defaultLocale}/` redirect). Resolves on SIGINT/SIGTERM.
7884
7895
  *
7885
7896
  * @param options - Optional port override + per-session dev feature opt-ins.
7886
7897
  * @returns Resolves once the server has been torn down.
@@ -7893,8 +7904,7 @@ function createApi$1(ctx) {
7893
7904
  return runDevServer(ctx, port, {
7894
7905
  og: options.og ?? false,
7895
7906
  sitemap: options.sitemap ?? false,
7896
- feeds: options.feeds ?? false,
7897
- localeRedirects: options.localeRedirects ?? false
7907
+ feeds: options.feeds ?? false
7898
7908
  });
7899
7909
  },
7900
7910
  /**
@@ -8743,16 +8753,24 @@ function createPanelRenderer(options = {}) {
8743
8753
  write(line);
8744
8754
  },
8745
8755
  /**
8746
- * Render the deploy result from a `deploy:complete` event: a `✓ DEPLOYED → url` line
8747
- * with the URL the hero value, then a dim `branch · id · time` line beneath it.
8756
+ * Render the deploy result from a `deploy:complete` event as a full-width box (matching
8757
+ * the BUILD panel): a `✓ DEPLOYED · branch` header with the elapsed time right-aligned,
8758
+ * then a `→ url · id` row. The url/id row is omitted entirely when wrangler returned no
8759
+ * URL, so a first-deploy with nothing to parse never renders a dangling `→` or `· ·`.
8748
8760
  *
8749
8761
  * @param result - The `deploy:complete` payload.
8750
8762
  * @example
8751
8763
  * render.deployed({ url: "https://x.pages.dev", deploymentId: "id", branch: "main", durationMs: 1200 });
8752
8764
  */
8753
8765
  deployed(result) {
8754
- const meta = palette.dim(`branch ${result.branch} · ${result.deploymentId} · ${result.durationMs}ms`);
8755
- writeBlock([` ${palette.green("✓")} ${palette.bold("DEPLOYED")} ${palette.dim("→")} ${palette.cyan(result.url)}`, ` ${meta}`]);
8766
+ const dot = palette.dim("·");
8767
+ const time = result.durationMs >= 1e3 ? `${(result.durationMs / 1e3).toFixed(1)}s` : `${result.durationMs}ms`;
8768
+ const lines = [railLine(`${palette.green("✓")} ${palette.bold("DEPLOYED")} ${dot} ${result.branch}`, palette.dim(time), BOX_INNER)];
8769
+ if (result.url) {
8770
+ const id = result.deploymentId ? ` ${dot} ${palette.dim(result.deploymentId)}` : "";
8771
+ lines.push(`${palette.dim("→")} ${palette.cyan(result.url)}${id}`);
8772
+ } else if (result.deploymentId) lines.push(palette.dim(`id ${result.deploymentId}`));
8773
+ writeBlock(box(lines, color, BOX_INNER));
8756
8774
  },
8757
8775
  /**
8758
8776
  * Render a neutral informational line.
@@ -8847,6 +8865,29 @@ function createPanelRenderer(options = {}) {
8847
8865
  */
8848
8866
  /** Matches an explicit affirmative answer (`y`/`yes`, case-insensitive). */
8849
8867
  const YES_PATTERN = /^y(es)?$/i;
8868
+ /** Prompt rail width — matches the renderer's `RAIL_WIDTH` so the hint aligns with other rows. */
8869
+ const PROMPT_WIDTH = 66;
8870
+ /** Whether the interactive prompts render with the MOKU marker styling (color/TTY only). */
8871
+ const PROMPT_COLOR = supportsColor();
8872
+ /** Shared palette for the interactive prompts (same brand colors as the Panel renderer). */
8873
+ const PROMPT_PALETTE = makePalette(PROMPT_COLOR, PROMPT_COLOR && supportsTruecolor());
8874
+ /**
8875
+ * Build the styled y/N confirm prompt: a brand `◆` marker + the question on the left,
8876
+ * a dim `y / N` hint + cyan `›` caret right-aligned to {@link PROMPT_WIDTH}. Falls back
8877
+ * to the plain `question [y/N] ` form off a color TTY (CI/pipes), where prompts rarely run.
8878
+ *
8879
+ * @param question - The yes/no question to display.
8880
+ * @returns The readline prompt string (the typed answer follows the caret).
8881
+ * @example
8882
+ * confirmPrompt("Deploy dist/ to Cloudflare Pages?");
8883
+ */
8884
+ function confirmPrompt(question) {
8885
+ if (!PROMPT_COLOR) return `${question} [y/N] `;
8886
+ const left = ` ${PROMPT_PALETTE.pink("◆")} ${question}`;
8887
+ const right = `${PROMPT_PALETTE.dim("y / N")} ${PROMPT_PALETTE.cyan("›")} `;
8888
+ const gap = Math.max(1, PROMPT_WIDTH - visibleWidth(left) - visibleWidth(right));
8889
+ return `${left}${" ".repeat(gap)}${right}`;
8890
+ }
8850
8891
  /**
8851
8892
  * Resolve the `Bun` runtime global, or `undefined` when not running under Bun.
8852
8893
  *
@@ -8904,7 +8945,7 @@ function defaultConfirm(question) {
8904
8945
  input: process.stdin,
8905
8946
  output: process.stdout
8906
8947
  });
8907
- readline.question(`${question} [y/N] `, (answer) => {
8948
+ readline.question(confirmPrompt(question), (answer) => {
8908
8949
  readline.close();
8909
8950
  resolve(YES_PATTERN.test(answer.trim()));
8910
8951
  });
@@ -8927,8 +8968,8 @@ function defaultSelect(question, choices) {
8927
8968
  input: process.stdin,
8928
8969
  output: process.stdout
8929
8970
  });
8930
- for (const [index, choice] of choices.entries()) console.log(` ${index + 1}) ${choice}`);
8931
- readline.question(`${question} [1-${choices.length}] `, (answer) => {
8971
+ console.log(selectChoicesBlock(question, choices));
8972
+ readline.question(selectPrompt(question, choices.length), (answer) => {
8932
8973
  readline.close();
8933
8974
  const picked = Number.parseInt(answer.trim(), 10);
8934
8975
  resolve(Number.isInteger(picked) && picked >= 1 && picked <= choices.length ? picked - 1 : 0);
@@ -8936,6 +8977,36 @@ function defaultSelect(question, choices) {
8936
8977
  });
8937
8978
  }
8938
8979
  /**
8980
+ * Render the select block: a brand `◆` marker + the question, then each choice as an
8981
+ * indented dim number + label. Off a color TTY, falls back to the plain ` N) label`
8982
+ * list (the question rides the prompt instead).
8983
+ *
8984
+ * @param question - The prompt shown above the choices (styled mode only).
8985
+ * @param choices - The selectable option labels.
8986
+ * @returns The multi-line choices block.
8987
+ * @example
8988
+ * selectChoicesBlock("Set up a workflow?", ["Auto", "Manual", "Skip"]);
8989
+ */
8990
+ function selectChoicesBlock(question, choices) {
8991
+ if (!PROMPT_COLOR) return choices.map((choice, index) => ` ${index + 1}) ${choice}`).join("\n");
8992
+ return [` ${PROMPT_PALETTE.pink("◆")} ${question}`, ...choices.map((choice, index) => ` ${PROMPT_PALETTE.dim(String(index + 1))} ${choice}`)].join("\n");
8993
+ }
8994
+ /**
8995
+ * Build the select input prompt: a dim `pick 1–N` hint + cyan `›` caret in styled mode,
8996
+ * or the plain `question [1-N] ` form off a color TTY (where the question is not printed
8997
+ * separately).
8998
+ *
8999
+ * @param question - The prompt (used only by the plain fallback).
9000
+ * @param count - The number of choices.
9001
+ * @returns The readline prompt string.
9002
+ * @example
9003
+ * selectPrompt("Set up a workflow?", 3);
9004
+ */
9005
+ function selectPrompt(question, count) {
9006
+ if (!PROMPT_COLOR) return `${question} [1-${count}] `;
9007
+ return ` ${PROMPT_PALETTE.dim(`pick 1–${count}`)} ${PROMPT_PALETTE.cyan("›")} `;
9008
+ }
9009
+ /**
8939
9010
  * Default recursive directory watcher — wraps `node:fs.watch` with `{ recursive: true }`
8940
9011
  * and adapts its handle to {@link WatchHandle}. Tests inject a fake emitter so no real
8941
9012
  * FS watch is registered.
package/package.json CHANGED
@@ -58,7 +58,7 @@
58
58
  "bun": ">=1.3.14"
59
59
  },
60
60
  "dependencies": {
61
- "@moku-labs/core": "0.1.0-alpha.6",
61
+ "@moku-labs/core": "0.1.1",
62
62
  "@resvg/resvg-js": "2.6.2",
63
63
  "@shikijs/rehype": "3.22.0",
64
64
  "feed": "5.2.0",
@@ -113,5 +113,5 @@
113
113
  "test:cli-e2e": "bun test src/plugins/cli/__tests__/e2e/",
114
114
  "test:coverage": "vitest run --project unit --project integration --coverage"
115
115
  },
116
- "version": "1.3.0"
116
+ "version": "1.4.0"
117
117
  }