@moku-labs/web 1.3.1 → 1.4.1

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.
@@ -557,6 +557,32 @@ type Api$3 = {
557
557
  */
558
558
  t(locale: string, key: string): string;
559
559
  };
560
+ //#endregion
561
+ //#region src/plugins/router/iso-match.d.ts
562
+ /**
563
+ * A compiled, engine-agnostic path matcher: the same `.exec({ pathname })` shape the
564
+ * router consumed from `URLPattern`, but backed by a native `RegExp` with named
565
+ * groups. Dropping `URLPattern` keeps route matching alive in every browser engine —
566
+ * Safari < 18.4 and Firefox < ~142 have no `URLPattern` global and would otherwise
567
+ * throw `ReferenceError` the instant the router compiles its table on boot.
568
+ */
569
+ interface PathMatcher {
570
+ /**
571
+ * Match a pathname, mirroring `URLPattern.exec`: the named-group bag (under
572
+ * `pathname.groups`) on a hit, or `null` on a miss.
573
+ *
574
+ * @param input - The match input carrying the `pathname` to test.
575
+ * @param input.pathname - The URL pathname to match, e.g. `/en/hello/`.
576
+ * @returns A `{ pathname: { groups } }` result on a match, or `null` on no match.
577
+ */
578
+ exec(input: {
579
+ readonly pathname: string;
580
+ }): {
581
+ readonly pathname: {
582
+ readonly groups: Record<string, string | undefined>;
583
+ };
584
+ } | null;
585
+ }
560
586
  declare namespace types_d_exports$5 {
561
587
  export { Api$2 as Api, ClientRoute, CompileInput, CompiledRoute, Config$2 as Config, ExtractApi$1 as ExtractApi, ExtractRouteParams, ExtractSegmentParameter, GenerateContext, HeadConfig$1 as HeadConfig, LayoutContext, LoadContext, MatcherTable, Prettify, RouteBuilder, RouteContext, RouteDefinition, RouteHandlers, RouteMap, RouteRequire, RouteState, RouterApi, RouterConfig, RouterState, State$2 as State, TypedRoute, Urls };
562
588
  }
@@ -836,10 +862,10 @@ interface CompiledRoute {
836
862
  readonly pattern: string;
837
863
  /** Dynamic-segment count (lower = more specific = matched first). */
838
864
  readonly dynamicSegmentCount: number;
839
- /** Pre-built URLPattern matchers (lang-aware + bare fallback). */
865
+ /** Pre-built path matchers (lang-aware + bare fallback). */
840
866
  readonly matchers: {
841
- readonly withLang: URLPattern;
842
- readonly bare: URLPattern;
867
+ readonly withLang: PathMatcher;
868
+ readonly bare: PathMatcher;
843
869
  };
844
870
  /** Resolve pathname into params (withLang first, then bare with defaultLocale injected). */
845
871
  readonly matchFn: (pathname: string) => Record<string, string> | null;
package/dist/browser.mjs CHANGED
@@ -1331,22 +1331,101 @@ function extractGroups(groups) {
1331
1331
  }
1332
1332
  return params;
1333
1333
  }
1334
- //#endregion
1335
- //#region src/plugins/router/builders/match.ts
1334
+ /** Regex metacharacters escaped when a static path segment is inlined into a compiled pattern. */
1335
+ const REGEX_METACHARS = /[.*+?^${}()|[\]\\]/g;
1336
+ /** Matches a `:name` or `:name(regex)` URLPattern group occupying one whole segment. */
1337
+ const NAMED_GROUP = /^:([A-Za-z_]\w*)(?:\((.+)\))?$/;
1336
1338
  /**
1337
- * @file router plugin runtime matching domain.
1339
+ * Escape a static path segment so its literal text matches verbatim inside the
1340
+ * compiled `RegExp` (a segment like `c++` must not be read as regex syntax).
1338
1341
  *
1339
- * Pure functions that turn compiled patterns into a pathname matcher: build the
1340
- * lang-aware/bare `URLPattern` pair, the `matchFn` (withLang first, bare fallback
1341
- * injecting `defaultLocale`), and extract/strip params. No `ctx` here.
1342
+ * @param text - The static segment text.
1343
+ * @returns The regex-escaped segment.
1344
+ * @example
1345
+ * ```ts
1346
+ * escapeStaticSegment("about"); // "about"
1347
+ * ```
1342
1348
  */
1349
+ function escapeStaticSegment(text) {
1350
+ return text.replaceAll(REGEX_METACHARS, String.raw`\$&`);
1351
+ }
1343
1352
  /**
1344
- * Build a pathname matcher for a single route: tries the `withLang` URLPattern,
1345
- * then the `bare` pattern injecting `defaultLocale` on miss.
1353
+ * Compile one URLPattern source segment (no surrounding slash) into a regex fragment
1354
+ * that captures a single path segment: `:name` a named `[^/]+` group, `:name(re)`
1355
+ * a named group constrained by `re`, and static text → its escaped literal.
1346
1356
  *
1347
- * @param matchers - The pre-built `withLang` and `bare` URLPattern pair.
1348
- * @param matchers.withLang - The locale-aware URLPattern variant.
1349
- * @param matchers.bare - The bare URLPattern variant (no leading locale segment).
1357
+ * @param segment - One source segment, e.g. `:slug`, `:lang(en|uk)`, or `archive`.
1358
+ * @returns The regex fragment for that segment.
1359
+ * @example
1360
+ * ```ts
1361
+ * segmentToRegex(":lang(en|uk)"); // "(?<lang>en|uk)"
1362
+ * ```
1363
+ */
1364
+ function segmentToRegex(segment) {
1365
+ const named = NAMED_GROUP.exec(segment);
1366
+ if (named) {
1367
+ const [, name, constraint] = named;
1368
+ return `(?<${name}>${constraint ?? "[^/]+"})`;
1369
+ }
1370
+ return escapeStaticSegment(segment);
1371
+ }
1372
+ /**
1373
+ * Compile a URLPattern pathname source string into a {@link PathMatcher} backed by a
1374
+ * native `RegExp` — a drop-in replacement for `new URLPattern({ pathname })` over the
1375
+ * subset the router emits: `:name`, `:name(regex)`, the optional `:name?` segment
1376
+ * (whose leading `/` is absorbed, so `/:lang?` matches `/en` or nothing), static
1377
+ * segments, and a required trailing slash. Anchored full-match, like `URLPattern`.
1378
+ *
1379
+ * @param source - The URLPattern pathname source, e.g. `/:lang?/:slug/`.
1380
+ * @returns A matcher whose `.exec({ pathname })` yields named groups or `null`.
1381
+ * @example
1382
+ * ```ts
1383
+ * const m = createPathMatcher("/:lang?/:slug/");
1384
+ * m.exec({ pathname: "/en/hello/" }); // { pathname: { groups: { lang: "en", slug: "hello" } } }
1385
+ * ```
1386
+ */
1387
+ function createPathMatcher(source) {
1388
+ const segments = source.split("/");
1389
+ let pattern = "^";
1390
+ for (let index = 1; index < segments.length; index += 1) {
1391
+ const segment = segments[index] ?? "";
1392
+ if (segment === "") {
1393
+ pattern += "/";
1394
+ continue;
1395
+ }
1396
+ const optional = segment.endsWith("?");
1397
+ const fragment = segmentToRegex(optional ? segment.slice(0, -1) : segment);
1398
+ pattern += optional ? `(?:/${fragment})?` : `/${fragment}`;
1399
+ }
1400
+ pattern += "$";
1401
+ const regexp = new RegExp(pattern);
1402
+ return {
1403
+ /**
1404
+ * Run the compiled regex over a pathname (the {@link PathMatcher.exec} contract).
1405
+ *
1406
+ * @param input - The match input carrying the `pathname` to test.
1407
+ * @param input.pathname - The URL pathname to match, e.g. `/en/hello/`.
1408
+ * @returns A `{ pathname: { groups } }` result on a match, or `null` on no match.
1409
+ * @example
1410
+ * ```ts
1411
+ * matcher.exec({ pathname: "/en/hello/" });
1412
+ * ```
1413
+ */
1414
+ exec(input) {
1415
+ const result = regexp.exec(input.pathname);
1416
+ if (!result) return null;
1417
+ return { pathname: { groups: result.groups ?? {} } };
1418
+ } };
1419
+ }
1420
+ //#endregion
1421
+ //#region src/plugins/router/builders/match.ts
1422
+ /**
1423
+ * Build a pathname matcher for a single route: tries the `withLang` matcher,
1424
+ * then the `bare` matcher injecting `defaultLocale` on miss.
1425
+ *
1426
+ * @param matchers - The pre-built `withLang` and `bare` matcher pair.
1427
+ * @param matchers.withLang - The locale-aware matcher variant.
1428
+ * @param matchers.bare - The bare matcher variant (no leading locale segment).
1350
1429
  * @param defaultLocale - Locale injected when the bare fallback matches.
1351
1430
  * @returns A function resolving a pathname into params, or `null` on no match.
1352
1431
  * @example
@@ -1390,14 +1469,6 @@ function matchRoute(compiled, pathname) {
1390
1469
  }
1391
1470
  //#endregion
1392
1471
  //#region src/plugins/router/builders/compile.ts
1393
- /**
1394
- * @file router plugin — compilation + validation domain.
1395
- *
1396
- * Pure functions invoked from `onInit`: validate the route map, then compile each
1397
- * route into URLPattern matchers + URL/file builders, count dynamic segments,
1398
- * sort by specificity, and assemble the immutable `MatcherTable`. Receives DATA
1399
- * only (`CompileInput`) — never the plugin ctx.
1400
- */
1401
1472
  /** Shared `[web]` error prefix for router validation failures. */
1402
1473
  const ERROR_PREFIX$6 = "[web] router";
1403
1474
  /** Maximum number of optional `{lang:?}` segments a single pattern may declare. */
@@ -1569,8 +1640,8 @@ function buildFilePath(pattern, params) {
1569
1640
  function buildMatchers(pattern, locales) {
1570
1641
  const langRegex = `(${locales.join("|")})`;
1571
1642
  return {
1572
- withLang: new URLPattern({ pathname: patternToUrlPattern(pattern, "withLang", langRegex) }),
1573
- bare: new URLPattern({ pathname: patternToUrlPattern(pattern, "bare", langRegex) })
1643
+ withLang: createPathMatcher(patternToUrlPattern(pattern, "withLang", langRegex)),
1644
+ bare: createPathMatcher(patternToUrlPattern(pattern, "bare", langRegex))
1574
1645
  };
1575
1646
  }
1576
1647
  /**
package/dist/index.cjs CHANGED
@@ -1889,22 +1889,101 @@ function extractGroups(groups) {
1889
1889
  }
1890
1890
  return params;
1891
1891
  }
1892
- //#endregion
1893
- //#region src/plugins/router/builders/match.ts
1892
+ /** Regex metacharacters escaped when a static path segment is inlined into a compiled pattern. */
1893
+ const REGEX_METACHARS = /[.*+?^${}()|[\]\\]/g;
1894
+ /** Matches a `:name` or `:name(regex)` URLPattern group occupying one whole segment. */
1895
+ const NAMED_GROUP = /^:([A-Za-z_]\w*)(?:\((.+)\))?$/;
1896
+ /**
1897
+ * Escape a static path segment so its literal text matches verbatim inside the
1898
+ * compiled `RegExp` (a segment like `c++` must not be read as regex syntax).
1899
+ *
1900
+ * @param text - The static segment text.
1901
+ * @returns The regex-escaped segment.
1902
+ * @example
1903
+ * ```ts
1904
+ * escapeStaticSegment("about"); // "about"
1905
+ * ```
1906
+ */
1907
+ function escapeStaticSegment(text) {
1908
+ return text.replaceAll(REGEX_METACHARS, String.raw`\$&`);
1909
+ }
1894
1910
  /**
1895
- * @file router plugin runtime matching domain.
1911
+ * Compile one URLPattern source segment (no surrounding slash) into a regex fragment
1912
+ * that captures a single path segment: `:name` → a named `[^/]+` group, `:name(re)` →
1913
+ * a named group constrained by `re`, and static text → its escaped literal.
1896
1914
  *
1897
- * Pure functions that turn compiled patterns into a pathname matcher: build the
1898
- * lang-aware/bare `URLPattern` pair, the `matchFn` (withLang first, bare fallback
1899
- * injecting `defaultLocale`), and extract/strip params. No `ctx` here.
1915
+ * @param segment - One source segment, e.g. `:slug`, `:lang(en|uk)`, or `archive`.
1916
+ * @returns The regex fragment for that segment.
1917
+ * @example
1918
+ * ```ts
1919
+ * segmentToRegex(":lang(en|uk)"); // "(?<lang>en|uk)"
1920
+ * ```
1900
1921
  */
1922
+ function segmentToRegex(segment) {
1923
+ const named = NAMED_GROUP.exec(segment);
1924
+ if (named) {
1925
+ const [, name, constraint] = named;
1926
+ return `(?<${name}>${constraint ?? "[^/]+"})`;
1927
+ }
1928
+ return escapeStaticSegment(segment);
1929
+ }
1901
1930
  /**
1902
- * Build a pathname matcher for a single route: tries the `withLang` URLPattern,
1903
- * then the `bare` pattern injecting `defaultLocale` on miss.
1931
+ * Compile a URLPattern pathname source string into a {@link PathMatcher} backed by a
1932
+ * native `RegExp` a drop-in replacement for `new URLPattern({ pathname })` over the
1933
+ * subset the router emits: `:name`, `:name(regex)`, the optional `:name?` segment
1934
+ * (whose leading `/` is absorbed, so `/:lang?` matches `/en` or nothing), static
1935
+ * segments, and a required trailing slash. Anchored full-match, like `URLPattern`.
1904
1936
  *
1905
- * @param matchers - The pre-built `withLang` and `bare` URLPattern pair.
1906
- * @param matchers.withLang - The locale-aware URLPattern variant.
1907
- * @param matchers.bare - The bare URLPattern variant (no leading locale segment).
1937
+ * @param source - The URLPattern pathname source, e.g. `/:lang?/:slug/`.
1938
+ * @returns A matcher whose `.exec({ pathname })` yields named groups or `null`.
1939
+ * @example
1940
+ * ```ts
1941
+ * const m = createPathMatcher("/:lang?/:slug/");
1942
+ * m.exec({ pathname: "/en/hello/" }); // { pathname: { groups: { lang: "en", slug: "hello" } } }
1943
+ * ```
1944
+ */
1945
+ function createPathMatcher(source) {
1946
+ const segments = source.split("/");
1947
+ let pattern = "^";
1948
+ for (let index = 1; index < segments.length; index += 1) {
1949
+ const segment = segments[index] ?? "";
1950
+ if (segment === "") {
1951
+ pattern += "/";
1952
+ continue;
1953
+ }
1954
+ const optional = segment.endsWith("?");
1955
+ const fragment = segmentToRegex(optional ? segment.slice(0, -1) : segment);
1956
+ pattern += optional ? `(?:/${fragment})?` : `/${fragment}`;
1957
+ }
1958
+ pattern += "$";
1959
+ const regexp = new RegExp(pattern);
1960
+ return {
1961
+ /**
1962
+ * Run the compiled regex over a pathname (the {@link PathMatcher.exec} contract).
1963
+ *
1964
+ * @param input - The match input carrying the `pathname` to test.
1965
+ * @param input.pathname - The URL pathname to match, e.g. `/en/hello/`.
1966
+ * @returns A `{ pathname: { groups } }` result on a match, or `null` on no match.
1967
+ * @example
1968
+ * ```ts
1969
+ * matcher.exec({ pathname: "/en/hello/" });
1970
+ * ```
1971
+ */
1972
+ exec(input) {
1973
+ const result = regexp.exec(input.pathname);
1974
+ if (!result) return null;
1975
+ return { pathname: { groups: result.groups ?? {} } };
1976
+ } };
1977
+ }
1978
+ //#endregion
1979
+ //#region src/plugins/router/builders/match.ts
1980
+ /**
1981
+ * Build a pathname matcher for a single route: tries the `withLang` matcher,
1982
+ * then the `bare` matcher injecting `defaultLocale` on miss.
1983
+ *
1984
+ * @param matchers - The pre-built `withLang` and `bare` matcher pair.
1985
+ * @param matchers.withLang - The locale-aware matcher variant.
1986
+ * @param matchers.bare - The bare matcher variant (no leading locale segment).
1908
1987
  * @param defaultLocale - Locale injected when the bare fallback matches.
1909
1988
  * @returns A function resolving a pathname into params, or `null` on no match.
1910
1989
  * @example
@@ -1948,14 +2027,6 @@ function matchRoute(compiled, pathname) {
1948
2027
  }
1949
2028
  //#endregion
1950
2029
  //#region src/plugins/router/builders/compile.ts
1951
- /**
1952
- * @file router plugin — compilation + validation domain.
1953
- *
1954
- * Pure functions invoked from `onInit`: validate the route map, then compile each
1955
- * route into URLPattern matchers + URL/file builders, count dynamic segments,
1956
- * sort by specificity, and assemble the immutable `MatcherTable`. Receives DATA
1957
- * only (`CompileInput`) — never the plugin ctx.
1958
- */
1959
2030
  /** Shared `[web]` error prefix for router validation failures. */
1960
2031
  const ERROR_PREFIX$11 = "[web] router";
1961
2032
  /** Maximum number of optional `{lang:?}` segments a single pattern may declare. */
@@ -2127,8 +2198,8 @@ function buildFilePath(pattern, params) {
2127
2198
  function buildMatchers(pattern, locales) {
2128
2199
  const langRegex = `(${locales.join("|")})`;
2129
2200
  return {
2130
- withLang: new URLPattern({ pathname: patternToUrlPattern(pattern, "withLang", langRegex) }),
2131
- bare: new URLPattern({ pathname: patternToUrlPattern(pattern, "bare", langRegex) })
2201
+ withLang: createPathMatcher(patternToUrlPattern(pattern, "withLang", langRegex)),
2202
+ bare: createPathMatcher(patternToUrlPattern(pattern, "bare", langRegex))
2132
2203
  };
2133
2204
  }
2134
2205
  /**
@@ -3842,7 +3913,7 @@ const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.<
3842
3913
  * ```
3843
3914
  */
3844
3915
  function wrap(body) {
3845
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>404 — Not Found</title></head><body>${body}</body></html>`;
3916
+ 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>`;
3846
3917
  }
3847
3918
  /**
3848
3919
  * Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
@@ -4526,6 +4597,8 @@ const HEAD_PLACEHOLDER = "<!--moku:head-->";
4526
4597
  const BODY_PLACEHOLDER = "<!--moku:body-->";
4527
4598
  /** Template placeholder for the injected asset `<link>`/`<script>` tags. */
4528
4599
  const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
4600
+ /** Template placeholder for the page's locale (`<html lang>`). */
4601
+ const LANG_PLACEHOLDER = "<!--moku:lang-->";
4529
4602
  /**
4530
4603
  * Read the bundle phase's hashed asset manifest for one kind from `state.buildCache`
4531
4604
  * as a typed {@link BuildCacheEntry} (no `Map<string, unknown>` reads).
@@ -4573,14 +4646,16 @@ function buildAssetTags(ctx) {
4573
4646
  * ```
4574
4647
  */
4575
4648
  function renderDocument(parts) {
4576
- return `<!DOCTYPE html><html lang="${parts.locale}"><head>${parts.head}${parts.assets}</head><body>${parts.body}</body></html>`;
4649
+ 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>`;
4577
4650
  }
4578
4651
  /**
4579
- * Fill a shell template's `<!--moku:head-->` / `<!--moku:body-->` /
4580
- * `<!--moku:assets-->` placeholders deterministically at build time.
4652
+ * Fill a shell template's `<!--moku:lang-->` / `<!--moku:head-->` /
4653
+ * `<!--moku:body-->` / `<!--moku:assets-->` placeholders deterministically at build
4654
+ * time. `<!--moku:lang-->` carries the page locale (for `<html lang>`), so a single
4655
+ * shared template stays locale-correct across every locale.
4581
4656
  *
4582
4657
  * @param template - The raw shell template HTML.
4583
- * @param parts - The composed head/body/assets pieces.
4658
+ * @param parts - The composed head/body/assets/locale pieces.
4584
4659
  * @returns The filled document string.
4585
4660
  * @example
4586
4661
  * ```ts
@@ -4588,7 +4663,7 @@ function renderDocument(parts) {
4588
4663
  * ```
4589
4664
  */
4590
4665
  function fillTemplate(template, parts) {
4591
- return template.replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
4666
+ return template.replaceAll(LANG_PLACEHOLDER, parts.locale).replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
4592
4667
  }
4593
4668
  /**
4594
4669
  * Resolve the compiled entry for a manifest definition, asserting the router
@@ -5840,7 +5915,8 @@ function buildWranglerArgs(input) {
5840
5915
  "--project-name",
5841
5916
  input.slug,
5842
5917
  "--branch",
5843
- branch
5918
+ branch,
5919
+ "--commit-dirty=true"
5844
5920
  ];
5845
5921
  }
5846
5922
  /**
@@ -6449,17 +6525,18 @@ function validateConfig$1(ctx) {
6449
6525
  ctx.require(sitePlugin);
6450
6526
  }
6451
6527
  /**
6452
- * Run wrangler for the prepared argv and surface its scrubbed result, translating
6453
- * a non-zero exit into the classified deploy error. The API token is read from env
6454
- * here so it never crosses a logging boundary; only scrubbed output is returned.
6455
- * Shared by `run()` (deploy) and `createProject()` (project create).
6528
+ * Run wrangler for the prepared argv and return its stdout, translating a non-zero
6529
+ * exit into the classified deploy error. The API token is read from env here so it
6530
+ * never crosses a logging boundary; the scrubbed stderr is used only to classify a
6531
+ * failure it is never logged (that was console noise), so nothing leaks. Shared by
6532
+ * `run()` (deploy) and `createProject()` (project create).
6456
6533
  *
6457
6534
  * @param ctx - Plugin context (provides `state.spawn`, `config`, `env`).
6458
6535
  * @param args - The fully-built, pre-validated wrangler argv.
6459
- * @returns The wrangler `stdout` plus the scrubbed `stderr` to log on success.
6536
+ * @returns The wrangler `stdout` (for URL/id parsing on a deploy).
6460
6537
  * @throws {Error} With a `code` from the deploy error taxonomy on a non-zero exit.
6461
6538
  * @example
6462
- * const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
6539
+ * const stdout = await executeWrangler(ctx, args);
6463
6540
  */
6464
6541
  async function executeWrangler(ctx, args) {
6465
6542
  const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
@@ -6473,10 +6550,7 @@ async function executeWrangler(ctx, args) {
6473
6550
  const { code, message } = classifyWranglerError(exitCode, scrubbedStderr);
6474
6551
  throw deployError(code, message);
6475
6552
  }
6476
- return {
6477
- stdout,
6478
- scrubbedStderr
6479
- };
6553
+ return stdout;
6480
6554
  }
6481
6555
  /**
6482
6556
  * Assemble the public {@link DeployResult} from wrangler's stdout, parsing the
@@ -6533,9 +6607,7 @@ function createApi$2(ctx) {
6533
6607
  root
6534
6608
  });
6535
6609
  const start = Date.now();
6536
- const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
6537
- ctx.log.info(scrubbedStderr);
6538
- const result = buildDeployResult(stdout, branch, start);
6610
+ const result = buildDeployResult(await executeWrangler(ctx, args), branch, start);
6539
6611
  ctx.state.lastDeployment = result;
6540
6612
  ctx.emit("deploy:complete", {
6541
6613
  url: result.url,
@@ -6595,11 +6667,10 @@ function createApi$2(ctx) {
6595
6667
  async createProject() {
6596
6668
  const name = toSlug(ctx.require(sitePlugin).name());
6597
6669
  const branch = ctx.config.productionBranch ?? "main";
6598
- const { scrubbedStderr } = await executeWrangler(ctx, buildProjectCreateArgs({
6670
+ await executeWrangler(ctx, buildProjectCreateArgs({
6599
6671
  slug: name,
6600
6672
  branch
6601
6673
  }));
6602
- ctx.log.info(scrubbedStderr);
6603
6674
  return {
6604
6675
  name,
6605
6676
  branch
@@ -8766,16 +8837,24 @@ function createPanelRenderer(options = {}) {
8766
8837
  write(line);
8767
8838
  },
8768
8839
  /**
8769
- * Render the deploy result from a `deploy:complete` event: a `✓ DEPLOYED → url` line
8770
- * with the URL the hero value, then a dim `branch · id · time` line beneath it.
8840
+ * Render the deploy result from a `deploy:complete` event as a full-width box (matching
8841
+ * the BUILD panel): a `✓ DEPLOYED · branch` header with the elapsed time right-aligned,
8842
+ * then a `→ url · id` row. The url/id row is omitted entirely when wrangler returned no
8843
+ * URL, so a first-deploy with nothing to parse never renders a dangling `→` or `· ·`.
8771
8844
  *
8772
8845
  * @param result - The `deploy:complete` payload.
8773
8846
  * @example
8774
8847
  * render.deployed({ url: "https://x.pages.dev", deploymentId: "id", branch: "main", durationMs: 1200 });
8775
8848
  */
8776
8849
  deployed(result) {
8777
- const meta = palette.dim(`branch ${result.branch} · ${result.deploymentId} · ${result.durationMs}ms`);
8778
- writeBlock([` ${palette.green("✓")} ${palette.bold("DEPLOYED")} ${palette.dim("→")} ${palette.cyan(result.url)}`, ` ${meta}`]);
8850
+ const dot = palette.dim("·");
8851
+ const time = result.durationMs >= 1e3 ? `${(result.durationMs / 1e3).toFixed(1)}s` : `${result.durationMs}ms`;
8852
+ const lines = [railLine(`${palette.green("✓")} ${palette.bold("DEPLOYED")} ${dot} ${result.branch}`, palette.dim(time), BOX_INNER)];
8853
+ if (result.url) {
8854
+ const id = result.deploymentId ? ` ${dot} ${palette.dim(result.deploymentId)}` : "";
8855
+ lines.push(`${palette.dim("→")} ${palette.cyan(result.url)}${id}`);
8856
+ } else if (result.deploymentId) lines.push(palette.dim(`id ${result.deploymentId}`));
8857
+ writeBlock(box(lines, color, BOX_INNER));
8779
8858
  },
8780
8859
  /**
8781
8860
  * Render a neutral informational line.
@@ -8870,6 +8949,29 @@ function createPanelRenderer(options = {}) {
8870
8949
  */
8871
8950
  /** Matches an explicit affirmative answer (`y`/`yes`, case-insensitive). */
8872
8951
  const YES_PATTERN = /^y(es)?$/i;
8952
+ /** Prompt rail width — matches the renderer's `RAIL_WIDTH` so the hint aligns with other rows. */
8953
+ const PROMPT_WIDTH = 66;
8954
+ /** Whether the interactive prompts render with the MOKU marker styling (color/TTY only). */
8955
+ const PROMPT_COLOR = supportsColor();
8956
+ /** Shared palette for the interactive prompts (same brand colors as the Panel renderer). */
8957
+ const PROMPT_PALETTE = makePalette(PROMPT_COLOR, PROMPT_COLOR && supportsTruecolor());
8958
+ /**
8959
+ * Build the styled y/N confirm prompt: a brand `◆` marker + the question on the left,
8960
+ * a dim `y / N` hint + cyan `›` caret right-aligned to {@link PROMPT_WIDTH}. Falls back
8961
+ * to the plain `question [y/N] ` form off a color TTY (CI/pipes), where prompts rarely run.
8962
+ *
8963
+ * @param question - The yes/no question to display.
8964
+ * @returns The readline prompt string (the typed answer follows the caret).
8965
+ * @example
8966
+ * confirmPrompt("Deploy dist/ to Cloudflare Pages?");
8967
+ */
8968
+ function confirmPrompt(question) {
8969
+ if (!PROMPT_COLOR) return `${question} [y/N] `;
8970
+ const left = ` ${PROMPT_PALETTE.pink("◆")} ${question}`;
8971
+ const right = `${PROMPT_PALETTE.dim("y / N")} ${PROMPT_PALETTE.cyan("›")} `;
8972
+ const gap = Math.max(1, PROMPT_WIDTH - visibleWidth(left) - visibleWidth(right));
8973
+ return `${left}${" ".repeat(gap)}${right}`;
8974
+ }
8873
8975
  /**
8874
8976
  * Resolve the `Bun` runtime global, or `undefined` when not running under Bun.
8875
8977
  *
@@ -8927,7 +9029,7 @@ function defaultConfirm(question) {
8927
9029
  input: process.stdin,
8928
9030
  output: process.stdout
8929
9031
  });
8930
- readline.question(`${question} [y/N] `, (answer) => {
9032
+ readline.question(confirmPrompt(question), (answer) => {
8931
9033
  readline.close();
8932
9034
  resolve(YES_PATTERN.test(answer.trim()));
8933
9035
  });
@@ -8950,8 +9052,8 @@ function defaultSelect(question, choices) {
8950
9052
  input: process.stdin,
8951
9053
  output: process.stdout
8952
9054
  });
8953
- for (const [index, choice] of choices.entries()) console.log(` ${index + 1}) ${choice}`);
8954
- readline.question(`${question} [1-${choices.length}] `, (answer) => {
9055
+ console.log(selectChoicesBlock(question, choices));
9056
+ readline.question(selectPrompt(question, choices.length), (answer) => {
8955
9057
  readline.close();
8956
9058
  const picked = Number.parseInt(answer.trim(), 10);
8957
9059
  resolve(Number.isInteger(picked) && picked >= 1 && picked <= choices.length ? picked - 1 : 0);
@@ -8959,6 +9061,36 @@ function defaultSelect(question, choices) {
8959
9061
  });
8960
9062
  }
8961
9063
  /**
9064
+ * Render the select block: a brand `◆` marker + the question, then each choice as an
9065
+ * indented dim number + label. Off a color TTY, falls back to the plain ` N) label`
9066
+ * list (the question rides the prompt instead).
9067
+ *
9068
+ * @param question - The prompt shown above the choices (styled mode only).
9069
+ * @param choices - The selectable option labels.
9070
+ * @returns The multi-line choices block.
9071
+ * @example
9072
+ * selectChoicesBlock("Set up a workflow?", ["Auto", "Manual", "Skip"]);
9073
+ */
9074
+ function selectChoicesBlock(question, choices) {
9075
+ if (!PROMPT_COLOR) return choices.map((choice, index) => ` ${index + 1}) ${choice}`).join("\n");
9076
+ return [` ${PROMPT_PALETTE.pink("◆")} ${question}`, ...choices.map((choice, index) => ` ${PROMPT_PALETTE.dim(String(index + 1))} ${choice}`)].join("\n");
9077
+ }
9078
+ /**
9079
+ * Build the select input prompt: a dim `pick 1–N` hint + cyan `›` caret in styled mode,
9080
+ * or the plain `question [1-N] ` form off a color TTY (where the question is not printed
9081
+ * separately).
9082
+ *
9083
+ * @param question - The prompt (used only by the plain fallback).
9084
+ * @param count - The number of choices.
9085
+ * @returns The readline prompt string.
9086
+ * @example
9087
+ * selectPrompt("Set up a workflow?", 3);
9088
+ */
9089
+ function selectPrompt(question, count) {
9090
+ if (!PROMPT_COLOR) return `${question} [1-${count}] `;
9091
+ return ` ${PROMPT_PALETTE.dim(`pick 1–${count}`)} ${PROMPT_PALETTE.cyan("›")} `;
9092
+ }
9093
+ /**
8962
9094
  * Default recursive directory watcher — wraps `node:fs.watch` with `{ recursive: true }`
8963
9095
  * and adapts its handle to {@link WatchHandle}. Tests inject a fake emitter so no real
8964
9096
  * FS watch is registered.
package/dist/index.d.cts CHANGED
@@ -557,6 +557,32 @@ type Api$6 = {
557
557
  */
558
558
  t(locale: string, key: string): string;
559
559
  };
560
+ //#endregion
561
+ //#region src/plugins/router/iso-match.d.ts
562
+ /**
563
+ * A compiled, engine-agnostic path matcher: the same `.exec({ pathname })` shape the
564
+ * router consumed from `URLPattern`, but backed by a native `RegExp` with named
565
+ * groups. Dropping `URLPattern` keeps route matching alive in every browser engine —
566
+ * Safari < 18.4 and Firefox < ~142 have no `URLPattern` global and would otherwise
567
+ * throw `ReferenceError` the instant the router compiles its table on boot.
568
+ */
569
+ interface PathMatcher {
570
+ /**
571
+ * Match a pathname, mirroring `URLPattern.exec`: the named-group bag (under
572
+ * `pathname.groups`) on a hit, or `null` on a miss.
573
+ *
574
+ * @param input - The match input carrying the `pathname` to test.
575
+ * @param input.pathname - The URL pathname to match, e.g. `/en/hello/`.
576
+ * @returns A `{ pathname: { groups } }` result on a match, or `null` on no match.
577
+ */
578
+ exec(input: {
579
+ readonly pathname: string;
580
+ }): {
581
+ readonly pathname: {
582
+ readonly groups: Record<string, string | undefined>;
583
+ };
584
+ } | null;
585
+ }
560
586
  declare namespace types_d_exports$8 {
561
587
  export { Api$5 as Api, ClientRoute, CompileInput, CompiledRoute, Config$5 as Config, ExtractApi$2 as ExtractApi, ExtractRouteParams, ExtractSegmentParameter, GenerateContext, HeadConfig$1 as HeadConfig, LayoutContext, LoadContext, MatcherTable, Prettify, RouteBuilder, RouteContext, RouteDefinition, RouteHandlers, RouteMap, RouteRequire, RouteState, RouterApi, RouterConfig, RouterState, State$5 as State, TypedRoute, Urls };
562
588
  }
@@ -836,10 +862,10 @@ interface CompiledRoute {
836
862
  readonly pattern: string;
837
863
  /** Dynamic-segment count (lower = more specific = matched first). */
838
864
  readonly dynamicSegmentCount: number;
839
- /** Pre-built URLPattern matchers (lang-aware + bare fallback). */
865
+ /** Pre-built path matchers (lang-aware + bare fallback). */
840
866
  readonly matchers: {
841
- readonly withLang: URLPattern;
842
- readonly bare: URLPattern;
867
+ readonly withLang: PathMatcher;
868
+ readonly bare: PathMatcher;
843
869
  };
844
870
  /** Resolve pathname into params (withLang first, then bare with defaultLocale injected). */
845
871
  readonly matchFn: (pathname: string) => Record<string, string> | null;
@@ -1615,7 +1641,17 @@ type Config$3 = {
1615
1641
  body?: string;
1616
1642
  }; /** Emit per-path i18n bare-path redirect HTML pages. Default `false`. */
1617
1643
  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. */
1644
+ clientEntry?: string;
1645
+ /**
1646
+ * Path to a custom HTML document shell, giving the app full control over the
1647
+ * scaffold (charset, viewport, `<html lang>`, body attributes, wrapper markup).
1648
+ * Placeholders, substituted per page at build time:
1649
+ * `<!--moku:lang-->` (page locale for `<html lang>`),
1650
+ * `<!--moku:head-->` (composed `<head>` inner HTML),
1651
+ * `<!--moku:assets-->` (injected `<link>`/`<script>` tags),
1652
+ * `<!--moku:body-->` (SSR body HTML).
1653
+ * When unset, the built-in shell is used (it emits charset + viewport by default).
1654
+ */
1619
1655
  template?: string;
1620
1656
  };
1621
1657
  /**
package/dist/index.d.mts CHANGED
@@ -557,6 +557,32 @@ type Api$6 = {
557
557
  */
558
558
  t(locale: string, key: string): string;
559
559
  };
560
+ //#endregion
561
+ //#region src/plugins/router/iso-match.d.ts
562
+ /**
563
+ * A compiled, engine-agnostic path matcher: the same `.exec({ pathname })` shape the
564
+ * router consumed from `URLPattern`, but backed by a native `RegExp` with named
565
+ * groups. Dropping `URLPattern` keeps route matching alive in every browser engine —
566
+ * Safari < 18.4 and Firefox < ~142 have no `URLPattern` global and would otherwise
567
+ * throw `ReferenceError` the instant the router compiles its table on boot.
568
+ */
569
+ interface PathMatcher {
570
+ /**
571
+ * Match a pathname, mirroring `URLPattern.exec`: the named-group bag (under
572
+ * `pathname.groups`) on a hit, or `null` on a miss.
573
+ *
574
+ * @param input - The match input carrying the `pathname` to test.
575
+ * @param input.pathname - The URL pathname to match, e.g. `/en/hello/`.
576
+ * @returns A `{ pathname: { groups } }` result on a match, or `null` on no match.
577
+ */
578
+ exec(input: {
579
+ readonly pathname: string;
580
+ }): {
581
+ readonly pathname: {
582
+ readonly groups: Record<string, string | undefined>;
583
+ };
584
+ } | null;
585
+ }
560
586
  declare namespace types_d_exports$8 {
561
587
  export { Api$5 as Api, ClientRoute, CompileInput, CompiledRoute, Config$5 as Config, ExtractApi$2 as ExtractApi, ExtractRouteParams, ExtractSegmentParameter, GenerateContext, HeadConfig$1 as HeadConfig, LayoutContext, LoadContext, MatcherTable, Prettify, RouteBuilder, RouteContext, RouteDefinition, RouteHandlers, RouteMap, RouteRequire, RouteState, RouterApi, RouterConfig, RouterState, State$5 as State, TypedRoute, Urls };
562
588
  }
@@ -836,10 +862,10 @@ interface CompiledRoute {
836
862
  readonly pattern: string;
837
863
  /** Dynamic-segment count (lower = more specific = matched first). */
838
864
  readonly dynamicSegmentCount: number;
839
- /** Pre-built URLPattern matchers (lang-aware + bare fallback). */
865
+ /** Pre-built path matchers (lang-aware + bare fallback). */
840
866
  readonly matchers: {
841
- readonly withLang: URLPattern;
842
- readonly bare: URLPattern;
867
+ readonly withLang: PathMatcher;
868
+ readonly bare: PathMatcher;
843
869
  };
844
870
  /** Resolve pathname into params (withLang first, then bare with defaultLocale injected). */
845
871
  readonly matchFn: (pathname: string) => Record<string, string> | null;
@@ -1615,7 +1641,17 @@ type Config$3 = {
1615
1641
  body?: string;
1616
1642
  }; /** Emit per-path i18n bare-path redirect HTML pages. Default `false`. */
1617
1643
  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. */
1644
+ clientEntry?: string;
1645
+ /**
1646
+ * Path to a custom HTML document shell, giving the app full control over the
1647
+ * scaffold (charset, viewport, `<html lang>`, body attributes, wrapper markup).
1648
+ * Placeholders, substituted per page at build time:
1649
+ * `<!--moku:lang-->` (page locale for `<html lang>`),
1650
+ * `<!--moku:head-->` (composed `<head>` inner HTML),
1651
+ * `<!--moku:assets-->` (injected `<link>`/`<script>` tags),
1652
+ * `<!--moku:body-->` (SSR body HTML).
1653
+ * When unset, the built-in shell is used (it emits charset + viewport by default).
1654
+ */
1619
1655
  template?: string;
1620
1656
  };
1621
1657
  /**
package/dist/index.mjs CHANGED
@@ -1876,22 +1876,101 @@ function extractGroups(groups) {
1876
1876
  }
1877
1877
  return params;
1878
1878
  }
1879
- //#endregion
1880
- //#region src/plugins/router/builders/match.ts
1879
+ /** Regex metacharacters escaped when a static path segment is inlined into a compiled pattern. */
1880
+ const REGEX_METACHARS = /[.*+?^${}()|[\]\\]/g;
1881
+ /** Matches a `:name` or `:name(regex)` URLPattern group occupying one whole segment. */
1882
+ const NAMED_GROUP = /^:([A-Za-z_]\w*)(?:\((.+)\))?$/;
1883
+ /**
1884
+ * Escape a static path segment so its literal text matches verbatim inside the
1885
+ * compiled `RegExp` (a segment like `c++` must not be read as regex syntax).
1886
+ *
1887
+ * @param text - The static segment text.
1888
+ * @returns The regex-escaped segment.
1889
+ * @example
1890
+ * ```ts
1891
+ * escapeStaticSegment("about"); // "about"
1892
+ * ```
1893
+ */
1894
+ function escapeStaticSegment(text) {
1895
+ return text.replaceAll(REGEX_METACHARS, String.raw`\$&`);
1896
+ }
1881
1897
  /**
1882
- * @file router plugin runtime matching domain.
1898
+ * Compile one URLPattern source segment (no surrounding slash) into a regex fragment
1899
+ * that captures a single path segment: `:name` → a named `[^/]+` group, `:name(re)` →
1900
+ * a named group constrained by `re`, and static text → its escaped literal.
1883
1901
  *
1884
- * Pure functions that turn compiled patterns into a pathname matcher: build the
1885
- * lang-aware/bare `URLPattern` pair, the `matchFn` (withLang first, bare fallback
1886
- * injecting `defaultLocale`), and extract/strip params. No `ctx` here.
1902
+ * @param segment - One source segment, e.g. `:slug`, `:lang(en|uk)`, or `archive`.
1903
+ * @returns The regex fragment for that segment.
1904
+ * @example
1905
+ * ```ts
1906
+ * segmentToRegex(":lang(en|uk)"); // "(?<lang>en|uk)"
1907
+ * ```
1887
1908
  */
1909
+ function segmentToRegex(segment) {
1910
+ const named = NAMED_GROUP.exec(segment);
1911
+ if (named) {
1912
+ const [, name, constraint] = named;
1913
+ return `(?<${name}>${constraint ?? "[^/]+"})`;
1914
+ }
1915
+ return escapeStaticSegment(segment);
1916
+ }
1888
1917
  /**
1889
- * Build a pathname matcher for a single route: tries the `withLang` URLPattern,
1890
- * then the `bare` pattern injecting `defaultLocale` on miss.
1918
+ * Compile a URLPattern pathname source string into a {@link PathMatcher} backed by a
1919
+ * native `RegExp` a drop-in replacement for `new URLPattern({ pathname })` over the
1920
+ * subset the router emits: `:name`, `:name(regex)`, the optional `:name?` segment
1921
+ * (whose leading `/` is absorbed, so `/:lang?` matches `/en` or nothing), static
1922
+ * segments, and a required trailing slash. Anchored full-match, like `URLPattern`.
1891
1923
  *
1892
- * @param matchers - The pre-built `withLang` and `bare` URLPattern pair.
1893
- * @param matchers.withLang - The locale-aware URLPattern variant.
1894
- * @param matchers.bare - The bare URLPattern variant (no leading locale segment).
1924
+ * @param source - The URLPattern pathname source, e.g. `/:lang?/:slug/`.
1925
+ * @returns A matcher whose `.exec({ pathname })` yields named groups or `null`.
1926
+ * @example
1927
+ * ```ts
1928
+ * const m = createPathMatcher("/:lang?/:slug/");
1929
+ * m.exec({ pathname: "/en/hello/" }); // { pathname: { groups: { lang: "en", slug: "hello" } } }
1930
+ * ```
1931
+ */
1932
+ function createPathMatcher(source) {
1933
+ const segments = source.split("/");
1934
+ let pattern = "^";
1935
+ for (let index = 1; index < segments.length; index += 1) {
1936
+ const segment = segments[index] ?? "";
1937
+ if (segment === "") {
1938
+ pattern += "/";
1939
+ continue;
1940
+ }
1941
+ const optional = segment.endsWith("?");
1942
+ const fragment = segmentToRegex(optional ? segment.slice(0, -1) : segment);
1943
+ pattern += optional ? `(?:/${fragment})?` : `/${fragment}`;
1944
+ }
1945
+ pattern += "$";
1946
+ const regexp = new RegExp(pattern);
1947
+ return {
1948
+ /**
1949
+ * Run the compiled regex over a pathname (the {@link PathMatcher.exec} contract).
1950
+ *
1951
+ * @param input - The match input carrying the `pathname` to test.
1952
+ * @param input.pathname - The URL pathname to match, e.g. `/en/hello/`.
1953
+ * @returns A `{ pathname: { groups } }` result on a match, or `null` on no match.
1954
+ * @example
1955
+ * ```ts
1956
+ * matcher.exec({ pathname: "/en/hello/" });
1957
+ * ```
1958
+ */
1959
+ exec(input) {
1960
+ const result = regexp.exec(input.pathname);
1961
+ if (!result) return null;
1962
+ return { pathname: { groups: result.groups ?? {} } };
1963
+ } };
1964
+ }
1965
+ //#endregion
1966
+ //#region src/plugins/router/builders/match.ts
1967
+ /**
1968
+ * Build a pathname matcher for a single route: tries the `withLang` matcher,
1969
+ * then the `bare` matcher injecting `defaultLocale` on miss.
1970
+ *
1971
+ * @param matchers - The pre-built `withLang` and `bare` matcher pair.
1972
+ * @param matchers.withLang - The locale-aware matcher variant.
1973
+ * @param matchers.bare - The bare matcher variant (no leading locale segment).
1895
1974
  * @param defaultLocale - Locale injected when the bare fallback matches.
1896
1975
  * @returns A function resolving a pathname into params, or `null` on no match.
1897
1976
  * @example
@@ -1935,14 +2014,6 @@ function matchRoute(compiled, pathname) {
1935
2014
  }
1936
2015
  //#endregion
1937
2016
  //#region src/plugins/router/builders/compile.ts
1938
- /**
1939
- * @file router plugin — compilation + validation domain.
1940
- *
1941
- * Pure functions invoked from `onInit`: validate the route map, then compile each
1942
- * route into URLPattern matchers + URL/file builders, count dynamic segments,
1943
- * sort by specificity, and assemble the immutable `MatcherTable`. Receives DATA
1944
- * only (`CompileInput`) — never the plugin ctx.
1945
- */
1946
2017
  /** Shared `[web]` error prefix for router validation failures. */
1947
2018
  const ERROR_PREFIX$11 = "[web] router";
1948
2019
  /** Maximum number of optional `{lang:?}` segments a single pattern may declare. */
@@ -2114,8 +2185,8 @@ function buildFilePath(pattern, params) {
2114
2185
  function buildMatchers(pattern, locales) {
2115
2186
  const langRegex = `(${locales.join("|")})`;
2116
2187
  return {
2117
- withLang: new URLPattern({ pathname: patternToUrlPattern(pattern, "withLang", langRegex) }),
2118
- bare: new URLPattern({ pathname: patternToUrlPattern(pattern, "bare", langRegex) })
2188
+ withLang: createPathMatcher(patternToUrlPattern(pattern, "withLang", langRegex)),
2189
+ bare: createPathMatcher(patternToUrlPattern(pattern, "bare", langRegex))
2119
2190
  };
2120
2191
  }
2121
2192
  /**
@@ -3829,7 +3900,7 @@ const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.<
3829
3900
  * ```
3830
3901
  */
3831
3902
  function wrap(body) {
3832
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>404 — Not Found</title></head><body>${body}</body></html>`;
3903
+ 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>`;
3833
3904
  }
3834
3905
  /**
3835
3906
  * Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
@@ -4513,6 +4584,8 @@ const HEAD_PLACEHOLDER = "<!--moku:head-->";
4513
4584
  const BODY_PLACEHOLDER = "<!--moku:body-->";
4514
4585
  /** Template placeholder for the injected asset `<link>`/`<script>` tags. */
4515
4586
  const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
4587
+ /** Template placeholder for the page's locale (`<html lang>`). */
4588
+ const LANG_PLACEHOLDER = "<!--moku:lang-->";
4516
4589
  /**
4517
4590
  * Read the bundle phase's hashed asset manifest for one kind from `state.buildCache`
4518
4591
  * as a typed {@link BuildCacheEntry} (no `Map<string, unknown>` reads).
@@ -4560,14 +4633,16 @@ function buildAssetTags(ctx) {
4560
4633
  * ```
4561
4634
  */
4562
4635
  function renderDocument(parts) {
4563
- return `<!DOCTYPE html><html lang="${parts.locale}"><head>${parts.head}${parts.assets}</head><body>${parts.body}</body></html>`;
4636
+ 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>`;
4564
4637
  }
4565
4638
  /**
4566
- * Fill a shell template's `<!--moku:head-->` / `<!--moku:body-->` /
4567
- * `<!--moku:assets-->` placeholders deterministically at build time.
4639
+ * Fill a shell template's `<!--moku:lang-->` / `<!--moku:head-->` /
4640
+ * `<!--moku:body-->` / `<!--moku:assets-->` placeholders deterministically at build
4641
+ * time. `<!--moku:lang-->` carries the page locale (for `<html lang>`), so a single
4642
+ * shared template stays locale-correct across every locale.
4568
4643
  *
4569
4644
  * @param template - The raw shell template HTML.
4570
- * @param parts - The composed head/body/assets pieces.
4645
+ * @param parts - The composed head/body/assets/locale pieces.
4571
4646
  * @returns The filled document string.
4572
4647
  * @example
4573
4648
  * ```ts
@@ -4575,7 +4650,7 @@ function renderDocument(parts) {
4575
4650
  * ```
4576
4651
  */
4577
4652
  function fillTemplate(template, parts) {
4578
- return template.replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
4653
+ return template.replaceAll(LANG_PLACEHOLDER, parts.locale).replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
4579
4654
  }
4580
4655
  /**
4581
4656
  * Resolve the compiled entry for a manifest definition, asserting the router
@@ -5827,7 +5902,8 @@ function buildWranglerArgs(input) {
5827
5902
  "--project-name",
5828
5903
  input.slug,
5829
5904
  "--branch",
5830
- branch
5905
+ branch,
5906
+ "--commit-dirty=true"
5831
5907
  ];
5832
5908
  }
5833
5909
  /**
@@ -6436,17 +6512,18 @@ function validateConfig$1(ctx) {
6436
6512
  ctx.require(sitePlugin);
6437
6513
  }
6438
6514
  /**
6439
- * Run wrangler for the prepared argv and surface its scrubbed result, translating
6440
- * a non-zero exit into the classified deploy error. The API token is read from env
6441
- * here so it never crosses a logging boundary; only scrubbed output is returned.
6442
- * Shared by `run()` (deploy) and `createProject()` (project create).
6515
+ * Run wrangler for the prepared argv and return its stdout, translating a non-zero
6516
+ * exit into the classified deploy error. The API token is read from env here so it
6517
+ * never crosses a logging boundary; the scrubbed stderr is used only to classify a
6518
+ * failure it is never logged (that was console noise), so nothing leaks. Shared by
6519
+ * `run()` (deploy) and `createProject()` (project create).
6443
6520
  *
6444
6521
  * @param ctx - Plugin context (provides `state.spawn`, `config`, `env`).
6445
6522
  * @param args - The fully-built, pre-validated wrangler argv.
6446
- * @returns The wrangler `stdout` plus the scrubbed `stderr` to log on success.
6523
+ * @returns The wrangler `stdout` (for URL/id parsing on a deploy).
6447
6524
  * @throws {Error} With a `code` from the deploy error taxonomy on a non-zero exit.
6448
6525
  * @example
6449
- * const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
6526
+ * const stdout = await executeWrangler(ctx, args);
6450
6527
  */
6451
6528
  async function executeWrangler(ctx, args) {
6452
6529
  const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
@@ -6460,10 +6537,7 @@ async function executeWrangler(ctx, args) {
6460
6537
  const { code, message } = classifyWranglerError(exitCode, scrubbedStderr);
6461
6538
  throw deployError(code, message);
6462
6539
  }
6463
- return {
6464
- stdout,
6465
- scrubbedStderr
6466
- };
6540
+ return stdout;
6467
6541
  }
6468
6542
  /**
6469
6543
  * Assemble the public {@link DeployResult} from wrangler's stdout, parsing the
@@ -6520,9 +6594,7 @@ function createApi$2(ctx) {
6520
6594
  root
6521
6595
  });
6522
6596
  const start = Date.now();
6523
- const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
6524
- ctx.log.info(scrubbedStderr);
6525
- const result = buildDeployResult(stdout, branch, start);
6597
+ const result = buildDeployResult(await executeWrangler(ctx, args), branch, start);
6526
6598
  ctx.state.lastDeployment = result;
6527
6599
  ctx.emit("deploy:complete", {
6528
6600
  url: result.url,
@@ -6582,11 +6654,10 @@ function createApi$2(ctx) {
6582
6654
  async createProject() {
6583
6655
  const name = toSlug(ctx.require(sitePlugin).name());
6584
6656
  const branch = ctx.config.productionBranch ?? "main";
6585
- const { scrubbedStderr } = await executeWrangler(ctx, buildProjectCreateArgs({
6657
+ await executeWrangler(ctx, buildProjectCreateArgs({
6586
6658
  slug: name,
6587
6659
  branch
6588
6660
  }));
6589
- ctx.log.info(scrubbedStderr);
6590
6661
  return {
6591
6662
  name,
6592
6663
  branch
@@ -8753,16 +8824,24 @@ function createPanelRenderer(options = {}) {
8753
8824
  write(line);
8754
8825
  },
8755
8826
  /**
8756
- * Render the deploy result from a `deploy:complete` event: a `✓ DEPLOYED → url` line
8757
- * with the URL the hero value, then a dim `branch · id · time` line beneath it.
8827
+ * Render the deploy result from a `deploy:complete` event as a full-width box (matching
8828
+ * the BUILD panel): a `✓ DEPLOYED · branch` header with the elapsed time right-aligned,
8829
+ * then a `→ url · id` row. The url/id row is omitted entirely when wrangler returned no
8830
+ * URL, so a first-deploy with nothing to parse never renders a dangling `→` or `· ·`.
8758
8831
  *
8759
8832
  * @param result - The `deploy:complete` payload.
8760
8833
  * @example
8761
8834
  * render.deployed({ url: "https://x.pages.dev", deploymentId: "id", branch: "main", durationMs: 1200 });
8762
8835
  */
8763
8836
  deployed(result) {
8764
- const meta = palette.dim(`branch ${result.branch} · ${result.deploymentId} · ${result.durationMs}ms`);
8765
- writeBlock([` ${palette.green("✓")} ${palette.bold("DEPLOYED")} ${palette.dim("→")} ${palette.cyan(result.url)}`, ` ${meta}`]);
8837
+ const dot = palette.dim("·");
8838
+ const time = result.durationMs >= 1e3 ? `${(result.durationMs / 1e3).toFixed(1)}s` : `${result.durationMs}ms`;
8839
+ const lines = [railLine(`${palette.green("✓")} ${palette.bold("DEPLOYED")} ${dot} ${result.branch}`, palette.dim(time), BOX_INNER)];
8840
+ if (result.url) {
8841
+ const id = result.deploymentId ? ` ${dot} ${palette.dim(result.deploymentId)}` : "";
8842
+ lines.push(`${palette.dim("→")} ${palette.cyan(result.url)}${id}`);
8843
+ } else if (result.deploymentId) lines.push(palette.dim(`id ${result.deploymentId}`));
8844
+ writeBlock(box(lines, color, BOX_INNER));
8766
8845
  },
8767
8846
  /**
8768
8847
  * Render a neutral informational line.
@@ -8857,6 +8936,29 @@ function createPanelRenderer(options = {}) {
8857
8936
  */
8858
8937
  /** Matches an explicit affirmative answer (`y`/`yes`, case-insensitive). */
8859
8938
  const YES_PATTERN = /^y(es)?$/i;
8939
+ /** Prompt rail width — matches the renderer's `RAIL_WIDTH` so the hint aligns with other rows. */
8940
+ const PROMPT_WIDTH = 66;
8941
+ /** Whether the interactive prompts render with the MOKU marker styling (color/TTY only). */
8942
+ const PROMPT_COLOR = supportsColor();
8943
+ /** Shared palette for the interactive prompts (same brand colors as the Panel renderer). */
8944
+ const PROMPT_PALETTE = makePalette(PROMPT_COLOR, PROMPT_COLOR && supportsTruecolor());
8945
+ /**
8946
+ * Build the styled y/N confirm prompt: a brand `◆` marker + the question on the left,
8947
+ * a dim `y / N` hint + cyan `›` caret right-aligned to {@link PROMPT_WIDTH}. Falls back
8948
+ * to the plain `question [y/N] ` form off a color TTY (CI/pipes), where prompts rarely run.
8949
+ *
8950
+ * @param question - The yes/no question to display.
8951
+ * @returns The readline prompt string (the typed answer follows the caret).
8952
+ * @example
8953
+ * confirmPrompt("Deploy dist/ to Cloudflare Pages?");
8954
+ */
8955
+ function confirmPrompt(question) {
8956
+ if (!PROMPT_COLOR) return `${question} [y/N] `;
8957
+ const left = ` ${PROMPT_PALETTE.pink("◆")} ${question}`;
8958
+ const right = `${PROMPT_PALETTE.dim("y / N")} ${PROMPT_PALETTE.cyan("›")} `;
8959
+ const gap = Math.max(1, PROMPT_WIDTH - visibleWidth(left) - visibleWidth(right));
8960
+ return `${left}${" ".repeat(gap)}${right}`;
8961
+ }
8860
8962
  /**
8861
8963
  * Resolve the `Bun` runtime global, or `undefined` when not running under Bun.
8862
8964
  *
@@ -8914,7 +9016,7 @@ function defaultConfirm(question) {
8914
9016
  input: process.stdin,
8915
9017
  output: process.stdout
8916
9018
  });
8917
- readline.question(`${question} [y/N] `, (answer) => {
9019
+ readline.question(confirmPrompt(question), (answer) => {
8918
9020
  readline.close();
8919
9021
  resolve(YES_PATTERN.test(answer.trim()));
8920
9022
  });
@@ -8937,8 +9039,8 @@ function defaultSelect(question, choices) {
8937
9039
  input: process.stdin,
8938
9040
  output: process.stdout
8939
9041
  });
8940
- for (const [index, choice] of choices.entries()) console.log(` ${index + 1}) ${choice}`);
8941
- readline.question(`${question} [1-${choices.length}] `, (answer) => {
9042
+ console.log(selectChoicesBlock(question, choices));
9043
+ readline.question(selectPrompt(question, choices.length), (answer) => {
8942
9044
  readline.close();
8943
9045
  const picked = Number.parseInt(answer.trim(), 10);
8944
9046
  resolve(Number.isInteger(picked) && picked >= 1 && picked <= choices.length ? picked - 1 : 0);
@@ -8946,6 +9048,36 @@ function defaultSelect(question, choices) {
8946
9048
  });
8947
9049
  }
8948
9050
  /**
9051
+ * Render the select block: a brand `◆` marker + the question, then each choice as an
9052
+ * indented dim number + label. Off a color TTY, falls back to the plain ` N) label`
9053
+ * list (the question rides the prompt instead).
9054
+ *
9055
+ * @param question - The prompt shown above the choices (styled mode only).
9056
+ * @param choices - The selectable option labels.
9057
+ * @returns The multi-line choices block.
9058
+ * @example
9059
+ * selectChoicesBlock("Set up a workflow?", ["Auto", "Manual", "Skip"]);
9060
+ */
9061
+ function selectChoicesBlock(question, choices) {
9062
+ if (!PROMPT_COLOR) return choices.map((choice, index) => ` ${index + 1}) ${choice}`).join("\n");
9063
+ return [` ${PROMPT_PALETTE.pink("◆")} ${question}`, ...choices.map((choice, index) => ` ${PROMPT_PALETTE.dim(String(index + 1))} ${choice}`)].join("\n");
9064
+ }
9065
+ /**
9066
+ * Build the select input prompt: a dim `pick 1–N` hint + cyan `›` caret in styled mode,
9067
+ * or the plain `question [1-N] ` form off a color TTY (where the question is not printed
9068
+ * separately).
9069
+ *
9070
+ * @param question - The prompt (used only by the plain fallback).
9071
+ * @param count - The number of choices.
9072
+ * @returns The readline prompt string.
9073
+ * @example
9074
+ * selectPrompt("Set up a workflow?", 3);
9075
+ */
9076
+ function selectPrompt(question, count) {
9077
+ if (!PROMPT_COLOR) return `${question} [1-${count}] `;
9078
+ return ` ${PROMPT_PALETTE.dim(`pick 1–${count}`)} ${PROMPT_PALETTE.cyan("›")} `;
9079
+ }
9080
+ /**
8949
9081
  * Default recursive directory watcher — wraps `node:fs.watch` with `{ recursive: true }`
8950
9082
  * and adapts its handle to {@link WatchHandle}. Tests inject a fake emitter so no real
8951
9083
  * FS watch is registered.
package/package.json CHANGED
@@ -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.1"
116
+ "version": "1.4.1"
117
117
  }