@moku-labs/web 1.13.0 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.mjs CHANGED
@@ -471,6 +471,38 @@ function dynamicSegmentCount(pattern) {
471
471
  return count;
472
472
  }
473
473
  /**
474
+ * Whether a route is rendered ENTIRELY on the client in `spa` mode: a dynamic route
475
+ * (≥1 non-lang param) that declares no build-time `.generate()` enumerator, so its
476
+ * concrete param paths are unknown until runtime.
477
+ *
478
+ * The build SKIPS such a route — emitting a static page for it would write one
479
+ * param-less shell whose path (`/b/{id}`) matches no file (a 404) and carries no
480
+ * param for the islands to read. Instead the SPA client-renders it from the URL on
481
+ * boot and on navigation. Build and client share this ONE predicate (the same way
482
+ * `dynamicSegmentCount`/`bySpecificity` are shared) so the two sides can never
483
+ * disagree about which routes are pre-rendered vs. client-only.
484
+ *
485
+ * Static routes (`/`) and dynamic routes WITH `.generate()` are pre-rendered as
486
+ * usual and so are NOT client-only. In `ssg`/`hybrid` mode nothing is client-only
487
+ * (the build pre-renders every route), so this is always `false` outside `spa`.
488
+ *
489
+ * @param mode - The global render mode (`router.mode()`).
490
+ * @param route - The route to test (only its `pattern` + `.generate()` presence are read).
491
+ * @param route.pattern - The route's URL pattern string.
492
+ * @param route._handlers - The route's handler bag.
493
+ * @param route._handlers.generate - The build-only static-paths enumerator, if any (presence only).
494
+ * @returns `true` when the route is client-only (spa mode, dynamic, no `.generate()`).
495
+ * @example
496
+ * ```ts
497
+ * isClientOnlyRoute("spa", { pattern: "/b/{id}", _handlers: {} }); // true
498
+ * isClientOnlyRoute("spa", { pattern: "/", _handlers: {} }); // false (static)
499
+ * isClientOnlyRoute("hybrid", { pattern: "/b/{id}", _handlers: {} }); // false (not spa)
500
+ * ```
501
+ */
502
+ function isClientOnlyRoute(mode, route) {
503
+ return mode === "spa" && route._handlers.generate === void 0 && dynamicSegmentCount(route.pattern) > 0;
504
+ }
505
+ /**
474
506
  * Comparator that orders two routes most-specific-first (fewest dynamic segments
475
507
  * first). Equal specificity yields `0` so a stable sort preserves declaration
476
508
  * order — the exact ordering the compiled matcher table uses, guaranteeing
@@ -3374,12 +3406,16 @@ function createSpaKernel(state, config, emit, deps) {
3374
3406
  * const resolved = await resolveDataRender("/en/world/");
3375
3407
  */
3376
3408
  const resolveDataRender = async (pathname) => {
3377
- if (!deps.dataAt) return false;
3378
3409
  const matchPath = pathname.split("?")[0] ?? pathname;
3379
3410
  const hit = deps.router.match(matchPath);
3380
3411
  if (!hit?.route._handlers.render) return false;
3381
- const data = await deps.dataAt(pathname);
3382
- if (data === null) return false;
3412
+ let data = {};
3413
+ if (!isClientOnlyRoute(deps.router.mode(), hit.route)) {
3414
+ if (!deps.dataAt) return false;
3415
+ const persisted = await deps.dataAt(pathname);
3416
+ if (persisted === null) return false;
3417
+ data = persisted;
3418
+ }
3383
3419
  const locale = hit.params.lang ?? document.documentElement.lang ?? "";
3384
3420
  const routeContext = {
3385
3421
  params: hit.params,
@@ -3461,6 +3497,29 @@ function createSpaKernel(state, config, emit, deps) {
3461
3497
  }
3462
3498
  };
3463
3499
  /**
3500
+ * Initial-load render for a spa client-only route (dynamic, no `.generate()`): the build emitted
3501
+ * no static HTML for it, so the host served a fallback shell. Client-render the matched route into
3502
+ * the swap region from the URL, then mount its islands — the deep-link / refresh paint. Unlike a
3503
+ * navigation there is nothing to unmount and no `spa:navigated` to emit. If the route cannot be
3504
+ * resolved (defensive — a matched client-only route always resolves), fall back to mounting the
3505
+ * served body so boot still wires up whatever islands the shell does carry.
3506
+ *
3507
+ * @param pathname - The current document path (pathname + search).
3508
+ * @example
3509
+ * await bootRender("/b/abc123");
3510
+ */
3511
+ const bootRender = async (pathname) => {
3512
+ const resolvedRender = await resolveDataRender(pathname);
3513
+ if (resolvedRender === false) {
3514
+ scanAndMount(state, emit, resolved.swapSelector);
3515
+ return;
3516
+ }
3517
+ const { vnode, region } = resolvedRender;
3518
+ const { renderVNode } = await import("./render-BNe0s7fr.mjs");
3519
+ renderVNode(vnode, region);
3520
+ scanAndMount(state, emit, resolved.swapSelector);
3521
+ };
3522
+ /**
3464
3523
  * Unified navigation: try the client DATA path first (only when the `data`
3465
3524
  * plugin is composed), then fall back to HTML-over-fetch (which itself falls
3466
3525
  * back to a full `location.href` reload). Injected into the router so every
@@ -3505,7 +3564,10 @@ function createSpaKernel(state, config, emit, deps) {
3505
3564
  progress = createProgressBar(resolved.progressBar);
3506
3565
  state.currentUrl = currentLocationUrl();
3507
3566
  state.destroyRouter = attachRouter(handlers, navigate);
3508
- scanAndMount(state, emit, resolved.swapSelector);
3567
+ const matchPath = state.currentUrl.split("?")[0] ?? state.currentUrl;
3568
+ const hit = deps.router.match(matchPath);
3569
+ if (hit?.route._handlers.render && isClientOnlyRoute(deps.router.mode(), hit.route)) bootRender(state.currentUrl);
3570
+ else scanAndMount(state, emit, resolved.swapSelector);
3509
3571
  state.started = true;
3510
3572
  },
3511
3573
  /**
package/dist/index.cjs CHANGED
@@ -1084,6 +1084,38 @@ function dynamicSegmentCount(pattern) {
1084
1084
  return count;
1085
1085
  }
1086
1086
  /**
1087
+ * Whether a route is rendered ENTIRELY on the client in `spa` mode: a dynamic route
1088
+ * (≥1 non-lang param) that declares no build-time `.generate()` enumerator, so its
1089
+ * concrete param paths are unknown until runtime.
1090
+ *
1091
+ * The build SKIPS such a route — emitting a static page for it would write one
1092
+ * param-less shell whose path (`/b/{id}`) matches no file (a 404) and carries no
1093
+ * param for the islands to read. Instead the SPA client-renders it from the URL on
1094
+ * boot and on navigation. Build and client share this ONE predicate (the same way
1095
+ * `dynamicSegmentCount`/`bySpecificity` are shared) so the two sides can never
1096
+ * disagree about which routes are pre-rendered vs. client-only.
1097
+ *
1098
+ * Static routes (`/`) and dynamic routes WITH `.generate()` are pre-rendered as
1099
+ * usual and so are NOT client-only. In `ssg`/`hybrid` mode nothing is client-only
1100
+ * (the build pre-renders every route), so this is always `false` outside `spa`.
1101
+ *
1102
+ * @param mode - The global render mode (`router.mode()`).
1103
+ * @param route - The route to test (only its `pattern` + `.generate()` presence are read).
1104
+ * @param route.pattern - The route's URL pattern string.
1105
+ * @param route._handlers - The route's handler bag.
1106
+ * @param route._handlers.generate - The build-only static-paths enumerator, if any (presence only).
1107
+ * @returns `true` when the route is client-only (spa mode, dynamic, no `.generate()`).
1108
+ * @example
1109
+ * ```ts
1110
+ * isClientOnlyRoute("spa", { pattern: "/b/{id}", _handlers: {} }); // true
1111
+ * isClientOnlyRoute("spa", { pattern: "/", _handlers: {} }); // false (static)
1112
+ * isClientOnlyRoute("hybrid", { pattern: "/b/{id}", _handlers: {} }); // false (not spa)
1113
+ * ```
1114
+ */
1115
+ function isClientOnlyRoute(mode, route) {
1116
+ return mode === "spa" && route._handlers.generate === void 0 && dynamicSegmentCount(route.pattern) > 0;
1117
+ }
1118
+ /**
1087
1119
  * Comparator that orders two routes most-specific-first (fewest dynamic segments
1088
1120
  * first). Equal specificity yields `0` so a stable sort preserves declaration
1089
1121
  * order — the exact ordering the compiled matcher table uses, guaranteeing
@@ -4528,16 +4560,23 @@ function resolveEntry(byPattern, definition) {
4528
4560
  * generate context is the spec `{ locale, require, has }`, so a `.generate()` handler
4529
4561
  * pulls sibling APIs the spec way.
4530
4562
  *
4563
+ * In `spa` mode a client-only route (dynamic, no `.generate()`) is SKIPPED entirely
4564
+ * (`[]`) — it is rendered on the client from the URL, so emitting a static param-less
4565
+ * shell here would only write a file at the wrong path (a 404 for any real param path)
4566
+ * carrying no param. See {@link isClientOnlyRoute}.
4567
+ *
4531
4568
  * @param definition - The route definition from the manifest.
4532
4569
  * @param locale - The active locale to generate param sets for.
4570
+ * @param mode - The global render mode (`router.mode()`); gates the spa client-only skip.
4533
4571
  * @param ctx - Plugin context (provides `require`/`has` for the generate context).
4534
- * @returns The param sets for this route+locale (`[{}]` when there is no `.generate()`).
4572
+ * @returns The param sets for this route+locale (`[{}]` when there is no `.generate()`; `[]` when client-only).
4535
4573
  * @example
4536
4574
  * ```ts
4537
- * const paramSets = await generateParamSets(def, "en", ctx);
4575
+ * const paramSets = await generateParamSets(def, "en", "hybrid", ctx);
4538
4576
  * ```
4539
4577
  */
4540
- async function generateParameterSets(definition, locale, ctx) {
4578
+ async function generateParameterSets(definition, locale, mode, ctx) {
4579
+ if (isClientOnlyRoute(mode, definition)) return [];
4541
4580
  const generateContext = {
4542
4581
  locale,
4543
4582
  require: ctx.require,
@@ -4561,21 +4600,22 @@ async function generateParameterSets(definition, locale, ctx) {
4561
4600
  * @param locales - Active locale codes from i18n.
4562
4601
  * @param defaultLocale - The i18n default locale (kept when locales collapse to one file).
4563
4602
  * @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
4603
+ * @param mode - The global render mode (`router.mode()`); gates the spa client-only skip.
4564
4604
  * @param ctx - Plugin context (provides `require`/`has` for the generate context).
4565
4605
  * @returns The flattened, file-deduplicated list of page instances for this route.
4566
4606
  * @example
4567
4607
  * ```ts
4568
- * await expandRoute(def, ["en"], "en", byPattern, ctx);
4608
+ * await expandRoute(def, ["en"], "en", byPattern, "hybrid", ctx);
4569
4609
  * ```
4570
4610
  */
4571
- async function expandRoute(definition, locales, defaultLocale, byPattern, ctx) {
4611
+ async function expandRoute(definition, locales, defaultLocale, byPattern, mode, ctx) {
4572
4612
  const entry = resolveEntry(byPattern, definition);
4573
4613
  const { name } = entry;
4574
4614
  const orderedLocales = [defaultLocale, ...locales.filter((locale) => locale !== defaultLocale)];
4575
4615
  const instances = [];
4576
4616
  const claimedFiles = /* @__PURE__ */ new Set();
4577
4617
  for (const locale of orderedLocales) {
4578
- const parameterSets = await generateParameterSets(definition, locale, ctx);
4618
+ const parameterSets = await generateParameterSets(definition, locale, mode, ctx);
4579
4619
  for (const raw of parameterSets) {
4580
4620
  const params = raw ?? {};
4581
4621
  const file = entry.toFile(params);
@@ -4909,15 +4949,16 @@ async function prepareShell(ctx) {
4909
4949
  * @param locales - Active locale codes from i18n.
4910
4950
  * @param defaultLocale - The i18n default locale (kept when a route's locales collapse).
4911
4951
  * @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
4952
+ * @param mode - The global render mode (`router.mode()`); gates the spa client-only skip.
4912
4953
  * @param ctx - Plugin context (provides `require`/`has` for generate contexts).
4913
4954
  * @returns The flattened list of page instances to render.
4914
4955
  * @example
4915
4956
  * ```ts
4916
- * const instances = await expandAllInstances(manifest, ["en"], "en", byPattern, ctx);
4957
+ * const instances = await expandAllInstances(manifest, ["en"], "en", byPattern, "hybrid", ctx);
4917
4958
  * ```
4918
4959
  */
4919
- async function expandAllInstances(manifest, locales, defaultLocale, byPattern, ctx) {
4920
- return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, defaultLocale, byPattern, ctx)))).flat();
4960
+ async function expandAllInstances(manifest, locales, defaultLocale, byPattern, mode, ctx) {
4961
+ return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, defaultLocale, byPattern, mode, ctx)))).flat();
4921
4962
  }
4922
4963
  /**
4923
4964
  * Persist per-page client-data sidecars when the app opts into client navigation
@@ -5027,14 +5068,15 @@ async function renderInBatches(items, batchSize, worker) {
5027
5068
  async function renderPages(ctx, options) {
5028
5069
  const reuse = options?.reuse === true;
5029
5070
  const router = ctx.require(routerPlugin);
5071
+ const mode = router.mode();
5030
5072
  const manifest = router.manifest();
5031
5073
  ctx.state.manifest = [...manifest];
5032
5074
  const locales = ctx.require(i18nPlugin).locales();
5033
5075
  const byPattern = makeEntryMap(router);
5034
5076
  if (!reuse) ctx.state.renderCache.clear();
5035
5077
  const shell = await prepareShell(ctx);
5036
- const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, shell.defaultLocale, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
5037
- await writeDataSidecars(ctx, rendered, router.mode());
5078
+ const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, shell.defaultLocale, byPattern, mode, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
5079
+ await writeDataSidecars(ctx, rendered, mode);
5038
5080
  ctx.log.debug("build:pages", { count: rendered.length });
5039
5081
  return {
5040
5082
  pageCount: rendered.length,
@@ -6108,7 +6150,7 @@ async function readWranglerConfig(cwd) {
6108
6150
  /** Relative path of the generated wrangler config. */
6109
6151
  const WRANGLER_PATH = "wrangler.jsonc";
6110
6152
  /** Relative path of the generated GitHub Actions workflow. */
6111
- const WORKFLOW_PATH = ".github/workflows/deploy.yml";
6153
+ const WORKFLOW_PATH$1 = ".github/workflows/deploy.yml";
6112
6154
  /** Wrangler `compatibility_date` used when the deploy config does not pin one. */
6113
6155
  const DEFAULT_COMPATIBILITY_DATE = "2024-01-01";
6114
6156
  /**
@@ -6166,12 +6208,12 @@ async function writeScaffolding(input) {
6166
6208
  result
6167
6209
  });
6168
6210
  if (ci) await reconcile({
6169
- relativePath: WORKFLOW_PATH,
6211
+ relativePath: WORKFLOW_PATH$1,
6170
6212
  expected: generateGithubWorkflow({
6171
6213
  slug,
6172
6214
  ...options.workflowTrigger ? { trigger: options.workflowTrigger } : {}
6173
6215
  }),
6174
- existing: await readMaybe(cwd, WORKFLOW_PATH),
6216
+ existing: await readMaybe(cwd, WORKFLOW_PATH$1),
6175
6217
  cwd,
6176
6218
  check,
6177
6219
  result
@@ -6869,30 +6911,39 @@ async function resolveTrigger(ctx, choice) {
6869
6911
  if (choice === 0) return "auto";
6870
6912
  return await ctx.state.select("How should the versioned deploy be triggered?", ["On a version tag push (v*) + the manual Run-workflow button", "Manual Run-workflow button only (workflow_dispatch)"]) === 0 ? "versioned-tag" : "dispatch";
6871
6913
  }
6914
+ /** Relative path of the GitHub Actions workflow the deploy plugin scaffolds. */
6915
+ const WORKFLOW_PATH = ".github/workflows/deploy.yml";
6872
6916
  /**
6873
6917
  * Offer to scaffold a GitHub Actions deploy workflow, letting the user choose how it is
6874
- * triggered, then remind them which repo secrets to add. A no-op past a "skip" choice.
6918
+ * triggered, then remind them which repo secrets to add. Short-circuits WITHOUT prompting
6919
+ * when {@link WORKFLOW_PATH} already exists — CI is already wired and the scaffold is
6920
+ * idempotent (a second setup would only no-op), so there is nothing to ask; it just
6921
+ * confirms the file and re-shows the secrets reminder. A no-op past a "skip" choice.
6875
6922
  *
6876
6923
  * @param ctx - The cli plugin context.
6924
+ * @param cwd - The project root (where `.github/workflows/deploy.yml` lives).
6877
6925
  * @returns Resolves once any chosen workflow has been scaffolded.
6878
6926
  * @example
6879
- * await offerWorkflowSetup(ctx);
6927
+ * await offerWorkflowSetup(ctx, process.cwd());
6880
6928
  */
6881
- async function offerWorkflowSetup(ctx) {
6929
+ async function offerWorkflowSetup(ctx, cwd) {
6882
6930
  ctx.state.render.heading("Automate future deploys (GitHub Actions)");
6931
+ if ((0, node_fs.existsSync)(node_path$1.default.join(cwd, WORKFLOW_PATH))) {
6932
+ ctx.state.render.check(true, `${WORKFLOW_PATH} already exists (left unchanged)`);
6933
+ ctx.state.render.info(SECRETS_HELP);
6934
+ return;
6935
+ }
6883
6936
  const trigger = await resolveTrigger(ctx, await ctx.state.select("Set up a deploy workflow?", [
6884
6937
  "Auto-deploy on every push to main",
6885
6938
  "Manual / versioned deploy (choose trigger)",
6886
6939
  "Skip for now"
6887
6940
  ]));
6888
6941
  if (trigger === null) return;
6889
- const result = await ctx.require(deployPlugin).init({
6942
+ const wrote = (await ctx.require(deployPlugin).init({
6890
6943
  ci: true,
6891
6944
  workflowTrigger: trigger
6892
- });
6893
- const workflowPath = ".github/workflows/deploy.yml";
6894
- const wrote = result.written.includes(workflowPath);
6895
- ctx.state.render.check(true, wrote ? `wrote ${workflowPath}` : `${workflowPath} already exists (left unchanged)`);
6945
+ })).written.includes(WORKFLOW_PATH);
6946
+ ctx.state.render.check(true, wrote ? `wrote ${WORKFLOW_PATH}` : `${WORKFLOW_PATH} already exists (left unchanged)`);
6896
6947
  ctx.state.render.info(SECRETS_HELP);
6897
6948
  }
6898
6949
  /**
@@ -7088,7 +7139,7 @@ async function runDeployWizard(ctx, options) {
7088
7139
  ctx.state.render.check(notFoundOk, `${ctx.config.notFoundFile} present`, notFoundOk ? void 0 : "Set build.notFound so the SSG emits it (CF Pages else flips to SPA mode).");
7089
7140
  ctx.state.render.info("Tip: run `bun run preview` to eyeball the built site before deploying.");
7090
7141
  const outcome = await runDeployStep(ctx, options);
7091
- if (!(outcome.deployed === false && outcome.reason === "failed")) await offerWorkflowSetup(ctx);
7142
+ if (!(outcome.deployed === false && outcome.reason === "failed")) await offerWorkflowSetup(ctx, cwd);
7092
7143
  return outcome;
7093
7144
  }
7094
7145
  //#endregion
@@ -9980,12 +10031,16 @@ function createSpaKernel(state, config, emit, deps) {
9980
10031
  * const resolved = await resolveDataRender("/en/world/");
9981
10032
  */
9982
10033
  const resolveDataRender = async (pathname) => {
9983
- if (!deps.dataAt) return false;
9984
10034
  const matchPath = pathname.split("?")[0] ?? pathname;
9985
10035
  const hit = deps.router.match(matchPath);
9986
10036
  if (!hit?.route._handlers.render) return false;
9987
- const data = await deps.dataAt(pathname);
9988
- if (data === null) return false;
10037
+ let data = {};
10038
+ if (!isClientOnlyRoute(deps.router.mode(), hit.route)) {
10039
+ if (!deps.dataAt) return false;
10040
+ const persisted = await deps.dataAt(pathname);
10041
+ if (persisted === null) return false;
10042
+ data = persisted;
10043
+ }
9989
10044
  const locale = hit.params.lang ?? document.documentElement.lang ?? "";
9990
10045
  const routeContext = {
9991
10046
  params: hit.params,
@@ -10067,6 +10122,29 @@ function createSpaKernel(state, config, emit, deps) {
10067
10122
  }
10068
10123
  };
10069
10124
  /**
10125
+ * Initial-load render for a spa client-only route (dynamic, no `.generate()`): the build emitted
10126
+ * no static HTML for it, so the host served a fallback shell. Client-render the matched route into
10127
+ * the swap region from the URL, then mount its islands — the deep-link / refresh paint. Unlike a
10128
+ * navigation there is nothing to unmount and no `spa:navigated` to emit. If the route cannot be
10129
+ * resolved (defensive — a matched client-only route always resolves), fall back to mounting the
10130
+ * served body so boot still wires up whatever islands the shell does carry.
10131
+ *
10132
+ * @param pathname - The current document path (pathname + search).
10133
+ * @example
10134
+ * await bootRender("/b/abc123");
10135
+ */
10136
+ const bootRender = async (pathname) => {
10137
+ const resolvedRender = await resolveDataRender(pathname);
10138
+ if (resolvedRender === false) {
10139
+ scanAndMount(state, emit, resolved.swapSelector);
10140
+ return;
10141
+ }
10142
+ const { vnode, region } = resolvedRender;
10143
+ const { renderVNode } = await Promise.resolve().then(() => require("./render-DLZEOe4M.cjs"));
10144
+ renderVNode(vnode, region);
10145
+ scanAndMount(state, emit, resolved.swapSelector);
10146
+ };
10147
+ /**
10070
10148
  * Unified navigation: try the client DATA path first (only when the `data`
10071
10149
  * plugin is composed), then fall back to HTML-over-fetch (which itself falls
10072
10150
  * back to a full `location.href` reload). Injected into the router so every
@@ -10111,7 +10189,10 @@ function createSpaKernel(state, config, emit, deps) {
10111
10189
  progress = createProgressBar(resolved.progressBar);
10112
10190
  state.currentUrl = currentLocationUrl();
10113
10191
  state.destroyRouter = attachRouter(handlers, navigate);
10114
- scanAndMount(state, emit, resolved.swapSelector);
10192
+ const matchPath = state.currentUrl.split("?")[0] ?? state.currentUrl;
10193
+ const hit = deps.router.match(matchPath);
10194
+ if (hit?.route._handlers.render && isClientOnlyRoute(deps.router.mode(), hit.route)) bootRender(state.currentUrl);
10195
+ else scanAndMount(state, emit, resolved.swapSelector);
10115
10196
  state.started = true;
10116
10197
  },
10117
10198
  /**
package/dist/index.mjs CHANGED
@@ -1071,6 +1071,38 @@ function dynamicSegmentCount(pattern) {
1071
1071
  return count;
1072
1072
  }
1073
1073
  /**
1074
+ * Whether a route is rendered ENTIRELY on the client in `spa` mode: a dynamic route
1075
+ * (≥1 non-lang param) that declares no build-time `.generate()` enumerator, so its
1076
+ * concrete param paths are unknown until runtime.
1077
+ *
1078
+ * The build SKIPS such a route — emitting a static page for it would write one
1079
+ * param-less shell whose path (`/b/{id}`) matches no file (a 404) and carries no
1080
+ * param for the islands to read. Instead the SPA client-renders it from the URL on
1081
+ * boot and on navigation. Build and client share this ONE predicate (the same way
1082
+ * `dynamicSegmentCount`/`bySpecificity` are shared) so the two sides can never
1083
+ * disagree about which routes are pre-rendered vs. client-only.
1084
+ *
1085
+ * Static routes (`/`) and dynamic routes WITH `.generate()` are pre-rendered as
1086
+ * usual and so are NOT client-only. In `ssg`/`hybrid` mode nothing is client-only
1087
+ * (the build pre-renders every route), so this is always `false` outside `spa`.
1088
+ *
1089
+ * @param mode - The global render mode (`router.mode()`).
1090
+ * @param route - The route to test (only its `pattern` + `.generate()` presence are read).
1091
+ * @param route.pattern - The route's URL pattern string.
1092
+ * @param route._handlers - The route's handler bag.
1093
+ * @param route._handlers.generate - The build-only static-paths enumerator, if any (presence only).
1094
+ * @returns `true` when the route is client-only (spa mode, dynamic, no `.generate()`).
1095
+ * @example
1096
+ * ```ts
1097
+ * isClientOnlyRoute("spa", { pattern: "/b/{id}", _handlers: {} }); // true
1098
+ * isClientOnlyRoute("spa", { pattern: "/", _handlers: {} }); // false (static)
1099
+ * isClientOnlyRoute("hybrid", { pattern: "/b/{id}", _handlers: {} }); // false (not spa)
1100
+ * ```
1101
+ */
1102
+ function isClientOnlyRoute(mode, route) {
1103
+ return mode === "spa" && route._handlers.generate === void 0 && dynamicSegmentCount(route.pattern) > 0;
1104
+ }
1105
+ /**
1074
1106
  * Comparator that orders two routes most-specific-first (fewest dynamic segments
1075
1107
  * first). Equal specificity yields `0` so a stable sort preserves declaration
1076
1108
  * order — the exact ordering the compiled matcher table uses, guaranteeing
@@ -4515,16 +4547,23 @@ function resolveEntry(byPattern, definition) {
4515
4547
  * generate context is the spec `{ locale, require, has }`, so a `.generate()` handler
4516
4548
  * pulls sibling APIs the spec way.
4517
4549
  *
4550
+ * In `spa` mode a client-only route (dynamic, no `.generate()`) is SKIPPED entirely
4551
+ * (`[]`) — it is rendered on the client from the URL, so emitting a static param-less
4552
+ * shell here would only write a file at the wrong path (a 404 for any real param path)
4553
+ * carrying no param. See {@link isClientOnlyRoute}.
4554
+ *
4518
4555
  * @param definition - The route definition from the manifest.
4519
4556
  * @param locale - The active locale to generate param sets for.
4557
+ * @param mode - The global render mode (`router.mode()`); gates the spa client-only skip.
4520
4558
  * @param ctx - Plugin context (provides `require`/`has` for the generate context).
4521
- * @returns The param sets for this route+locale (`[{}]` when there is no `.generate()`).
4559
+ * @returns The param sets for this route+locale (`[{}]` when there is no `.generate()`; `[]` when client-only).
4522
4560
  * @example
4523
4561
  * ```ts
4524
- * const paramSets = await generateParamSets(def, "en", ctx);
4562
+ * const paramSets = await generateParamSets(def, "en", "hybrid", ctx);
4525
4563
  * ```
4526
4564
  */
4527
- async function generateParameterSets(definition, locale, ctx) {
4565
+ async function generateParameterSets(definition, locale, mode, ctx) {
4566
+ if (isClientOnlyRoute(mode, definition)) return [];
4528
4567
  const generateContext = {
4529
4568
  locale,
4530
4569
  require: ctx.require,
@@ -4548,21 +4587,22 @@ async function generateParameterSets(definition, locale, ctx) {
4548
4587
  * @param locales - Active locale codes from i18n.
4549
4588
  * @param defaultLocale - The i18n default locale (kept when locales collapse to one file).
4550
4589
  * @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
4590
+ * @param mode - The global render mode (`router.mode()`); gates the spa client-only skip.
4551
4591
  * @param ctx - Plugin context (provides `require`/`has` for the generate context).
4552
4592
  * @returns The flattened, file-deduplicated list of page instances for this route.
4553
4593
  * @example
4554
4594
  * ```ts
4555
- * await expandRoute(def, ["en"], "en", byPattern, ctx);
4595
+ * await expandRoute(def, ["en"], "en", byPattern, "hybrid", ctx);
4556
4596
  * ```
4557
4597
  */
4558
- async function expandRoute(definition, locales, defaultLocale, byPattern, ctx) {
4598
+ async function expandRoute(definition, locales, defaultLocale, byPattern, mode, ctx) {
4559
4599
  const entry = resolveEntry(byPattern, definition);
4560
4600
  const { name } = entry;
4561
4601
  const orderedLocales = [defaultLocale, ...locales.filter((locale) => locale !== defaultLocale)];
4562
4602
  const instances = [];
4563
4603
  const claimedFiles = /* @__PURE__ */ new Set();
4564
4604
  for (const locale of orderedLocales) {
4565
- const parameterSets = await generateParameterSets(definition, locale, ctx);
4605
+ const parameterSets = await generateParameterSets(definition, locale, mode, ctx);
4566
4606
  for (const raw of parameterSets) {
4567
4607
  const params = raw ?? {};
4568
4608
  const file = entry.toFile(params);
@@ -4896,15 +4936,16 @@ async function prepareShell(ctx) {
4896
4936
  * @param locales - Active locale codes from i18n.
4897
4937
  * @param defaultLocale - The i18n default locale (kept when a route's locales collapse).
4898
4938
  * @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
4939
+ * @param mode - The global render mode (`router.mode()`); gates the spa client-only skip.
4899
4940
  * @param ctx - Plugin context (provides `require`/`has` for generate contexts).
4900
4941
  * @returns The flattened list of page instances to render.
4901
4942
  * @example
4902
4943
  * ```ts
4903
- * const instances = await expandAllInstances(manifest, ["en"], "en", byPattern, ctx);
4944
+ * const instances = await expandAllInstances(manifest, ["en"], "en", byPattern, "hybrid", ctx);
4904
4945
  * ```
4905
4946
  */
4906
- async function expandAllInstances(manifest, locales, defaultLocale, byPattern, ctx) {
4907
- return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, defaultLocale, byPattern, ctx)))).flat();
4947
+ async function expandAllInstances(manifest, locales, defaultLocale, byPattern, mode, ctx) {
4948
+ return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, defaultLocale, byPattern, mode, ctx)))).flat();
4908
4949
  }
4909
4950
  /**
4910
4951
  * Persist per-page client-data sidecars when the app opts into client navigation
@@ -5014,14 +5055,15 @@ async function renderInBatches(items, batchSize, worker) {
5014
5055
  async function renderPages(ctx, options) {
5015
5056
  const reuse = options?.reuse === true;
5016
5057
  const router = ctx.require(routerPlugin);
5058
+ const mode = router.mode();
5017
5059
  const manifest = router.manifest();
5018
5060
  ctx.state.manifest = [...manifest];
5019
5061
  const locales = ctx.require(i18nPlugin).locales();
5020
5062
  const byPattern = makeEntryMap(router);
5021
5063
  if (!reuse) ctx.state.renderCache.clear();
5022
5064
  const shell = await prepareShell(ctx);
5023
- const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, shell.defaultLocale, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
5024
- await writeDataSidecars(ctx, rendered, router.mode());
5065
+ const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, shell.defaultLocale, byPattern, mode, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
5066
+ await writeDataSidecars(ctx, rendered, mode);
5025
5067
  ctx.log.debug("build:pages", { count: rendered.length });
5026
5068
  return {
5027
5069
  pageCount: rendered.length,
@@ -6095,7 +6137,7 @@ async function readWranglerConfig(cwd) {
6095
6137
  /** Relative path of the generated wrangler config. */
6096
6138
  const WRANGLER_PATH = "wrangler.jsonc";
6097
6139
  /** Relative path of the generated GitHub Actions workflow. */
6098
- const WORKFLOW_PATH = ".github/workflows/deploy.yml";
6140
+ const WORKFLOW_PATH$1 = ".github/workflows/deploy.yml";
6099
6141
  /** Wrangler `compatibility_date` used when the deploy config does not pin one. */
6100
6142
  const DEFAULT_COMPATIBILITY_DATE = "2024-01-01";
6101
6143
  /**
@@ -6153,12 +6195,12 @@ async function writeScaffolding(input) {
6153
6195
  result
6154
6196
  });
6155
6197
  if (ci) await reconcile({
6156
- relativePath: WORKFLOW_PATH,
6198
+ relativePath: WORKFLOW_PATH$1,
6157
6199
  expected: generateGithubWorkflow({
6158
6200
  slug,
6159
6201
  ...options.workflowTrigger ? { trigger: options.workflowTrigger } : {}
6160
6202
  }),
6161
- existing: await readMaybe(cwd, WORKFLOW_PATH),
6203
+ existing: await readMaybe(cwd, WORKFLOW_PATH$1),
6162
6204
  cwd,
6163
6205
  check,
6164
6206
  result
@@ -6856,30 +6898,39 @@ async function resolveTrigger(ctx, choice) {
6856
6898
  if (choice === 0) return "auto";
6857
6899
  return await ctx.state.select("How should the versioned deploy be triggered?", ["On a version tag push (v*) + the manual Run-workflow button", "Manual Run-workflow button only (workflow_dispatch)"]) === 0 ? "versioned-tag" : "dispatch";
6858
6900
  }
6901
+ /** Relative path of the GitHub Actions workflow the deploy plugin scaffolds. */
6902
+ const WORKFLOW_PATH = ".github/workflows/deploy.yml";
6859
6903
  /**
6860
6904
  * Offer to scaffold a GitHub Actions deploy workflow, letting the user choose how it is
6861
- * triggered, then remind them which repo secrets to add. A no-op past a "skip" choice.
6905
+ * triggered, then remind them which repo secrets to add. Short-circuits WITHOUT prompting
6906
+ * when {@link WORKFLOW_PATH} already exists — CI is already wired and the scaffold is
6907
+ * idempotent (a second setup would only no-op), so there is nothing to ask; it just
6908
+ * confirms the file and re-shows the secrets reminder. A no-op past a "skip" choice.
6862
6909
  *
6863
6910
  * @param ctx - The cli plugin context.
6911
+ * @param cwd - The project root (where `.github/workflows/deploy.yml` lives).
6864
6912
  * @returns Resolves once any chosen workflow has been scaffolded.
6865
6913
  * @example
6866
- * await offerWorkflowSetup(ctx);
6914
+ * await offerWorkflowSetup(ctx, process.cwd());
6867
6915
  */
6868
- async function offerWorkflowSetup(ctx) {
6916
+ async function offerWorkflowSetup(ctx, cwd) {
6869
6917
  ctx.state.render.heading("Automate future deploys (GitHub Actions)");
6918
+ if (existsSync(path.join(cwd, WORKFLOW_PATH))) {
6919
+ ctx.state.render.check(true, `${WORKFLOW_PATH} already exists (left unchanged)`);
6920
+ ctx.state.render.info(SECRETS_HELP);
6921
+ return;
6922
+ }
6870
6923
  const trigger = await resolveTrigger(ctx, await ctx.state.select("Set up a deploy workflow?", [
6871
6924
  "Auto-deploy on every push to main",
6872
6925
  "Manual / versioned deploy (choose trigger)",
6873
6926
  "Skip for now"
6874
6927
  ]));
6875
6928
  if (trigger === null) return;
6876
- const result = await ctx.require(deployPlugin).init({
6929
+ const wrote = (await ctx.require(deployPlugin).init({
6877
6930
  ci: true,
6878
6931
  workflowTrigger: trigger
6879
- });
6880
- const workflowPath = ".github/workflows/deploy.yml";
6881
- const wrote = result.written.includes(workflowPath);
6882
- ctx.state.render.check(true, wrote ? `wrote ${workflowPath}` : `${workflowPath} already exists (left unchanged)`);
6932
+ })).written.includes(WORKFLOW_PATH);
6933
+ ctx.state.render.check(true, wrote ? `wrote ${WORKFLOW_PATH}` : `${WORKFLOW_PATH} already exists (left unchanged)`);
6883
6934
  ctx.state.render.info(SECRETS_HELP);
6884
6935
  }
6885
6936
  /**
@@ -7075,7 +7126,7 @@ async function runDeployWizard(ctx, options) {
7075
7126
  ctx.state.render.check(notFoundOk, `${ctx.config.notFoundFile} present`, notFoundOk ? void 0 : "Set build.notFound so the SSG emits it (CF Pages else flips to SPA mode).");
7076
7127
  ctx.state.render.info("Tip: run `bun run preview` to eyeball the built site before deploying.");
7077
7128
  const outcome = await runDeployStep(ctx, options);
7078
- if (!(outcome.deployed === false && outcome.reason === "failed")) await offerWorkflowSetup(ctx);
7129
+ if (!(outcome.deployed === false && outcome.reason === "failed")) await offerWorkflowSetup(ctx, cwd);
7079
7130
  return outcome;
7080
7131
  }
7081
7132
  //#endregion
@@ -9967,12 +10018,16 @@ function createSpaKernel(state, config, emit, deps) {
9967
10018
  * const resolved = await resolveDataRender("/en/world/");
9968
10019
  */
9969
10020
  const resolveDataRender = async (pathname) => {
9970
- if (!deps.dataAt) return false;
9971
10021
  const matchPath = pathname.split("?")[0] ?? pathname;
9972
10022
  const hit = deps.router.match(matchPath);
9973
10023
  if (!hit?.route._handlers.render) return false;
9974
- const data = await deps.dataAt(pathname);
9975
- if (data === null) return false;
10024
+ let data = {};
10025
+ if (!isClientOnlyRoute(deps.router.mode(), hit.route)) {
10026
+ if (!deps.dataAt) return false;
10027
+ const persisted = await deps.dataAt(pathname);
10028
+ if (persisted === null) return false;
10029
+ data = persisted;
10030
+ }
9976
10031
  const locale = hit.params.lang ?? document.documentElement.lang ?? "";
9977
10032
  const routeContext = {
9978
10033
  params: hit.params,
@@ -10054,6 +10109,29 @@ function createSpaKernel(state, config, emit, deps) {
10054
10109
  }
10055
10110
  };
10056
10111
  /**
10112
+ * Initial-load render for a spa client-only route (dynamic, no `.generate()`): the build emitted
10113
+ * no static HTML for it, so the host served a fallback shell. Client-render the matched route into
10114
+ * the swap region from the URL, then mount its islands — the deep-link / refresh paint. Unlike a
10115
+ * navigation there is nothing to unmount and no `spa:navigated` to emit. If the route cannot be
10116
+ * resolved (defensive — a matched client-only route always resolves), fall back to mounting the
10117
+ * served body so boot still wires up whatever islands the shell does carry.
10118
+ *
10119
+ * @param pathname - The current document path (pathname + search).
10120
+ * @example
10121
+ * await bootRender("/b/abc123");
10122
+ */
10123
+ const bootRender = async (pathname) => {
10124
+ const resolvedRender = await resolveDataRender(pathname);
10125
+ if (resolvedRender === false) {
10126
+ scanAndMount(state, emit, resolved.swapSelector);
10127
+ return;
10128
+ }
10129
+ const { vnode, region } = resolvedRender;
10130
+ const { renderVNode } = await import("./render-BNe0s7fr.mjs");
10131
+ renderVNode(vnode, region);
10132
+ scanAndMount(state, emit, resolved.swapSelector);
10133
+ };
10134
+ /**
10057
10135
  * Unified navigation: try the client DATA path first (only when the `data`
10058
10136
  * plugin is composed), then fall back to HTML-over-fetch (which itself falls
10059
10137
  * back to a full `location.href` reload). Injected into the router so every
@@ -10098,7 +10176,10 @@ function createSpaKernel(state, config, emit, deps) {
10098
10176
  progress = createProgressBar(resolved.progressBar);
10099
10177
  state.currentUrl = currentLocationUrl();
10100
10178
  state.destroyRouter = attachRouter(handlers, navigate);
10101
- scanAndMount(state, emit, resolved.swapSelector);
10179
+ const matchPath = state.currentUrl.split("?")[0] ?? state.currentUrl;
10180
+ const hit = deps.router.match(matchPath);
10181
+ if (hit?.route._handlers.render && isClientOnlyRoute(deps.router.mode(), hit.route)) bootRender(state.currentUrl);
10182
+ else scanAndMount(state, emit, resolved.swapSelector);
10102
10183
  state.started = true;
10103
10184
  },
10104
10185
  /**
package/package.json CHANGED
@@ -126,5 +126,5 @@
126
126
  "test:build-e2e": "bun test src/plugins/build/__tests__/e2e/",
127
127
  "test:coverage": "vitest run --project unit --project integration --coverage"
128
128
  },
129
- "version": "1.13.0"
129
+ "version": "1.13.2"
130
130
  }