@ultimat3/cli 5.0.1 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CLAUDE.md +75 -6
  2. package/README.md +2 -2
  3. package/package.json +28 -24
  4. package/src/affected.ts +320 -0
  5. package/src/browser-launcher.ts +109 -0
  6. package/src/ci-log.ts +0 -0
  7. package/src/ci-runs.ts +179 -0
  8. package/src/cmd-affected.ts +109 -0
  9. package/src/cmd-build.ts +36 -3
  10. package/src/cmd-ci.ts +273 -0
  11. package/src/cmd-dev.ts +35 -2
  12. package/src/cmd-generate.ts +16 -348
  13. package/src/cmd-i18n.ts +32 -16
  14. package/src/cmd-pr.ts +308 -0
  15. package/src/cmd-shot.ts +320 -0
  16. package/src/cmd-test.ts +96 -7
  17. package/src/cmd-verify.ts +10 -427
  18. package/src/compile-externals.ts +34 -0
  19. package/src/dev-lock.ts +275 -0
  20. package/src/dev-render.ts +7 -17
  21. package/src/error-codes.ts +18 -0
  22. package/src/generate-files.ts +127 -0
  23. package/src/generate-write.ts +229 -0
  24. package/src/gh-target.ts +118 -0
  25. package/src/gh.ts +204 -0
  26. package/src/i18n-audit.ts +39 -1
  27. package/src/i18n-registration.ts +130 -0
  28. package/src/index.ts +37 -0
  29. package/src/island-bundle.ts +68 -2
  30. package/src/island-solid-production.ts +129 -0
  31. package/src/island-styles.ts +41 -0
  32. package/src/mcp-errors.ts +11 -0
  33. package/src/messages.ts +67 -0
  34. package/src/pr-threads.ts +291 -0
  35. package/src/prerender.ts +52 -10
  36. package/src/registry.ts +8 -0
  37. package/src/shot-verdict.ts +337 -0
  38. package/src/solid-loader.ts +127 -0
  39. package/src/static-report.ts +219 -0
  40. package/src/templates/admin-page.ts +46 -5
  41. package/src/templates/index.ts +1 -0
  42. package/src/templates/island-fixture.ts +76 -0
  43. package/src/templates/island.ts +129 -18
  44. package/src/templates/resource-form-island.ts +279 -0
  45. package/src/templates/resource.ts +52 -43
  46. package/src/templates/route.ts +45 -6
  47. package/src/templates/scaffold-app.ts +70 -19
  48. package/src/templates/scaffold-container.ts +2 -2
  49. package/src/templates/scaffold-db-package.ts +88 -39
  50. package/src/templates/scaffold-docs.ts +18 -1
  51. package/src/templates/scaffold-i18n.ts +9 -2
  52. package/src/templates/scaffold-mcp-package.ts +35 -2
  53. package/src/templates/scaffold-package-shape.ts +7 -2
  54. package/src/templates/scaffold-repo.ts +2 -2
  55. package/src/test-shards.ts +19 -3
  56. package/src/verify-checks.ts +349 -0
  57. package/src/verify-run.ts +122 -0
  58. package/src/verify-step.ts +7 -0
  59. package/src/workspace-graph.ts +241 -0
  60. package/types/babel-modules.d.ts +31 -0
@@ -13,7 +13,8 @@ import type { GeneratedFile, NameSet } from './naming';
13
13
  import { names, pascal } from './naming';
14
14
  import { policyFiles } from './policy';
15
15
  import { queryFiles } from './query';
16
- import { routeFiles } from './route';
16
+ import { formIslandFiles } from './resource-form-island';
17
+ import { routeDir, routeFiles } from './route';
17
18
 
18
19
  const serviceSource = (
19
20
  feature: NameSet,
@@ -58,12 +59,29 @@ unitTest('${feature.pascal}NotFoundError carries a code, a cause and a fix', ()
58
59
  });
59
60
  `;
60
61
 
62
+ /**
63
+ * The generated component reaches strings through the APP's catalog module — the one that calls
64
+ * `defineCatalogs()` — so a component that renders a string depends on the module that registers
65
+ * them. `t` from `@ultimat3/i18n` renders while depending on nothing, which is how a shipped app
66
+ * served every string as a loud miss with a green gate (issue #249). An app with no catalog module
67
+ * keeps the framework import: emitting one that cannot resolve is worse than the wrong idiom.
68
+ */
69
+ const catalogImport = (module: string | undefined): string =>
70
+ module === undefined
71
+ ? "import { t } from '@ultimat3/i18n';"
72
+ : `import { useT } from '${module}';`;
73
+
74
+ /** `useT()` is per render, so each component binds it in its own body. */
75
+ const translatorBinding = (module: string | undefined): string =>
76
+ module === undefined ? '' : '\n const t = useT();\n';
77
+
61
78
  const uiSource = (
62
79
  feature: NameSet,
80
+ module: string | undefined,
63
81
  ): string => `// Presentation only. No fetching, no business logic: the list arrives as a prop from the route,
64
82
  // which got it from the live query.
65
83
 
66
- import { t } from '@ultimat3/i18n';
84
+ ${catalogImport(module)}
67
85
  import { For } from 'solid-js';
68
86
  import type { ${feature.pascal} } from './entity';
69
87
  import styles from './ui.module.scss';
@@ -72,7 +90,7 @@ export interface ${feature.pascal}ListProps {
72
90
  readonly rows: readonly ${feature.pascal}[];
73
91
  }
74
92
 
75
- export function ${feature.pascal}List(props: ${feature.pascal}ListProps) {
93
+ export function ${feature.pascal}List(props: ${feature.pascal}ListProps) {${translatorBinding(module)}
76
94
  return (
77
95
  <ul class={styles.list}>
78
96
  <For each={props.rows} fallback={<li>{t('app.${feature.kebab}.empty')}</li>}>
@@ -103,10 +121,11 @@ const uiStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
103
121
 
104
122
  const cardSource = (
105
123
  feature: NameSet,
124
+ module: string | undefined,
106
125
  ): string => `// One ${feature.camel} rendered on its own — the list's \`item\` shown outside a list, so a
107
126
  // detail route and a search result render the identical markup.
108
127
 
109
- import { t } from '@ultimat3/i18n';
128
+ ${catalogImport(module)}
110
129
  import type { ${feature.pascal} } from '../entity';
111
130
  import styles from '../ui.module.scss';
112
131
 
@@ -114,7 +133,7 @@ export interface ${feature.pascal}CardProps {
114
133
  readonly row: ${feature.pascal};
115
134
  }
116
135
 
117
- export function ${feature.pascal}Card(props: ${feature.pascal}CardProps) {
136
+ export function ${feature.pascal}Card(props: ${feature.pascal}CardProps) {${translatorBinding(module)}
118
137
  return (
119
138
  <article class={styles.item}>
120
139
  <h3>{props.row.title}</h3>
@@ -124,39 +143,6 @@ export function ${feature.pascal}Card(props: ${feature.pascal}CardProps) {
124
143
  }
125
144
  `;
126
145
 
127
- const formSource = (
128
- feature: NameSet,
129
- ): string => `// Presentation only: the mutator this submits to owns validation server-side, so this form
130
- // never re-implements the invariant — a blank title fails at the boundary, not in the DOM.
131
-
132
- import { t } from '@ultimat3/i18n';
133
- import { createSignal } from 'solid-js';
134
- import styles from '../ui.module.scss';
135
-
136
- export interface ${feature.pascal}FormProps {
137
- readonly onSubmit: (title: string) => void;
138
- }
139
-
140
- export function ${feature.pascal}Form(props: ${feature.pascal}FormProps) {
141
- const [title, setTitle] = createSignal('');
142
- return (
143
- <form
144
- class={styles.item}
145
- onSubmit={(event) => {
146
- event.preventDefault();
147
- props.onSubmit(title());
148
- }}
149
- >
150
- <label>
151
- {t('app.${feature.kebab}.titleLabel')}
152
- <input value={title()} onInput={(event) => setTitle(event.currentTarget.value)} />
153
- </label>
154
- <button type="submit">{t('app.${feature.kebab}.submit')}</button>
155
- </form>
156
- );
157
- }
158
- `;
159
-
160
146
  // `admin.<feature>.title` is always here, `--admin` or not: `defineAdmin()` resolves that key the
161
147
  // moment anyone writes the override, and a missing key renders ⟦key⟧ and fails the i18n gate,
162
148
  // while an unused key is only ever reported (`auditCatalogs` fails on `missing`, never `unused`).
@@ -166,6 +152,8 @@ const catalogSource = (feature: NameSet): string =>
166
152
  [`app.${feature.kebab}.updated`]: 'Last updated',
167
153
  [`app.${feature.kebab}.titleLabel`]: 'Title',
168
154
  [`app.${feature.kebab}.submit`]: 'Save',
155
+ [`app.${feature.kebab}.saved`]: 'Saved',
156
+ [`app.${feature.kebab}.retry`]: 'Try again',
169
157
  [`admin.${feature.kebab}.title`]: pascal(feature.plural),
170
158
  });
171
159
 
@@ -174,12 +162,21 @@ export interface ResourceOptions extends FeatureTarget {
174
162
  readonly admin?: boolean;
175
163
  /** Every locale the feature's catalog ships for. Defaults to `['en']`. */
176
164
  readonly locales?: readonly string[];
165
+ /**
166
+ * The app's own catalog module — `@<app>/i18n`, read off `packages/i18n/package.json` by
167
+ * `resolveCatalogModule`. Absent only for an app that ships no such package.
168
+ */
169
+ readonly catalogModule?: string;
177
170
  }
178
171
 
179
172
  export function resourceFiles(rawName: string, target: ResourceOptions): readonly GeneratedFile[] {
180
173
  const feature = names(rawName);
181
174
  const slice: FeatureTarget = { surfaceDir: target.surfaceDir, feature: feature.kebab };
182
175
  const dir = `${slice.surfaceDir}/${slice.feature}`;
176
+ // The page this same call writes, from the function that decides where a route goes — the island
177
+ // specifier is resolved against it, so re-deriving the path here would be two answers to one
178
+ // question and only one of them reaches `routeFiles`.
179
+ const pageDir = routeDir('app', feature.pluralKebab);
183
180
  const locales = resolveLocales(target.locales);
184
181
  return [
185
182
  ...entityFiles(rawName, slice),
@@ -190,18 +187,30 @@ export function resourceFiles(rawName: string, target: ResourceOptions): readonl
190
187
  ...jobFiles(`reindex-${feature.kebab}`, slice),
191
188
  { path: `${dir}/service.ts`, contents: serviceSource(feature) },
192
189
  { path: `${dir}/service.test.ts`, contents: serviceTest(feature) },
193
- { path: `${dir}/ui.tsx`, contents: uiSource(feature) },
190
+ { path: `${dir}/ui.tsx`, contents: uiSource(feature, target.catalogModule) },
194
191
  { path: `${dir}/ui.module.scss`, contents: uiStyle() },
195
- { path: `${dir}/ui/${feature.kebab}-card.tsx`, contents: cardSource(feature) },
196
- { path: `${dir}/ui/${feature.kebab}-form.tsx`, contents: formSource(feature) },
192
+ {
193
+ path: `${dir}/ui/${feature.kebab}-card.tsx`,
194
+ contents: cardSource(feature, target.catalogModule),
195
+ },
196
+ ...formIslandFiles(feature, dir, pageDir),
197
197
  ...locales.map((locale) => ({
198
198
  path: catalogPath(locale),
199
199
  contents: catalogSource(feature),
200
200
  merge: 'json' as const,
201
201
  })),
202
- // Always an app route: a slice ships a live query, a form and actions, and `generate()`
202
+ // Always an app route: a slice ships a live query, a form island and actions, and `generate()`
203
203
  // refuses `--surface site` for a resource rather than emit them behind a 0kb budget.
204
- ...routeFiles(feature.pluralKebab, { surface: 'app', locales }),
204
+ //
205
+ // `catalogModule` travels with it. It did not, so the page a RESOURCE writes imported `t` from
206
+ // `@ultimat3/i18n` while the components beside it imported `useT` from the app's own catalog
207
+ // module — one command, two idioms, and the framework-import one is issue #249's: it renders
208
+ // strings while depending on nothing that registers them.
209
+ ...routeFiles(feature.pluralKebab, {
210
+ surface: 'app',
211
+ locales,
212
+ ...(target.catalogModule === undefined ? {} : { catalogModule: target.catalogModule }),
213
+ }),
205
214
  ...(target.admin === true ? adminFiles(rawName, slice) : []),
206
215
  ];
207
216
  }
@@ -19,6 +19,14 @@ export type Surface = 'site' | 'app';
19
19
  * that was absent from `x routes`, from the manifest and from `budgets`. Ship the mode that works.
20
20
  */
21
21
  const RENDER: Record<Surface, string> = { site: 'isr', app: 'ssr' };
22
+ /**
23
+ * `app` is `'visible'` on a page the generator writes with no island, and that is not an oversight.
24
+ * A declared strategy costs nothing when there is nothing to hydrate — `hydrateRuntime` is emitted
25
+ * per island DIRECTIVE and answers `''` for a page with none (`packages/render/src/hydrate.ts`), so
26
+ * `x routes` reads `visible` while the document ships 0 bytes. What it buys is that adding the
27
+ * first island is one edit: a declared `'never'` beside an island is `X_ISLAND_NOT_HYDRATED`, which
28
+ * `defineRoute` refuses on purpose, so the scaffold would break at the moment it is first used.
29
+ */
22
30
  const HYDRATE: Record<Surface, string> = { site: 'never', app: 'visible' };
23
31
  const OFFLINE: Record<Surface, string> = { site: 'precache', app: 'runtime' };
24
32
  /**
@@ -66,10 +74,35 @@ export const routeParams = (path: string): readonly string[] =>
66
74
  return name === undefined ? [] : [name];
67
75
  });
68
76
 
69
- const routeDir = (surface: Surface, path: string): string =>
77
+ /**
78
+ * Where the page lands. Exported because an island's `src` is resolved relative to the PAGE, and
79
+ * only this function knows where `x g resource`'s page went — a second copy of the formula in the
80
+ * resource generator is how the specifier it prints stops matching the file it writes.
81
+ */
82
+ export const routeDir = (surface: Surface, path: string): string =>
70
83
  `apps/web/${surface}/${segmentsOf(path).join('/')}`;
71
84
 
72
- const pageSource = (surface: Surface, path: string): string => {
85
+ /**
86
+ * How the generated page reaches a string, and it is the whole reason this generator takes an app
87
+ * module at all. `useT()` comes from the app's own catalog module — the one that calls
88
+ * `defineCatalogs()` — so a page that renders a string DEPENDS on the module that registers them.
89
+ * Reaching straight for `t` in `@ultimat3/i18n` renders strings while depending on nothing, which
90
+ * is how a shipped app served `⟦app.play.title⟧` on every page with a green gate (issue #249), and
91
+ * this generator is where that idiom came from.
92
+ *
93
+ * An app with no catalog module keeps the framework import: emitting one that cannot resolve would
94
+ * trade a wrong idiom for a file that does not compile.
95
+ */
96
+ const catalogImport = (module: string | undefined): string =>
97
+ module === undefined
98
+ ? "import { t } from '@ultimat3/i18n';"
99
+ : `import { useT } from '${module}';`;
100
+
101
+ /** `useT()` is per render, so the body binds it; `meta` takes the router's own `t`. */
102
+ const translatorBinding = (module: string | undefined): string =>
103
+ module === undefined ? '' : '\n const t = useT();\n';
104
+
105
+ const pageSource = (surface: Surface, path: string, module: string | undefined): string => {
73
106
  const name = pascal(
74
107
  path
75
108
  .split('/')
@@ -79,7 +112,7 @@ const pageSource = (surface: Surface, path: string): string => {
79
112
  return `// Route: /${path} on the ${surface} surface. Config first: render mode, offline
80
113
  // strategy and budget are declarations, not runtime choices.
81
114
 
82
- import { t } from '@ultimat3/i18n';
115
+ ${catalogImport(module)}
83
116
  import { defineRoute } from '@ultimat3/render';
84
117
  import styles from './page.module.scss';
85
118
 
@@ -88,13 +121,13 @@ export const config = defineRoute({
88
121
  hydrate: '${HYDRATE[surface]}',
89
122
  offline: '${OFFLINE[surface]}',
90
123
  budget: ${budgetLiteral(surface)},
91
- meta: () => ({
124
+ meta: ({ t }) => ({
92
125
  title: t('${titleKey(path)}'),
93
126
  description: t('${titleKey(path).replace('.title', '.description')}'),
94
127
  }),
95
128
  });
96
129
 
97
- export function ${name}Page() {
130
+ export function ${name}Page() {${translatorBinding(module)}
98
131
  return (
99
132
  <main class={styles.page}>
100
133
  <h1>{t('${titleKey(path)}')}</h1>
@@ -202,6 +235,12 @@ export interface RouteOptions {
202
235
  readonly surface: Surface;
203
236
  /** Every locale the catalog entry ships for. Defaults to `['en']` — an app narrows or grows it. */
204
237
  readonly locales?: readonly string[];
238
+ /**
239
+ * The app's own catalog module — `@<app>/i18n`, read off `packages/i18n/package.json` by
240
+ * `resolveCatalogModule`. Absent for an app that ships no such package, and only then does the
241
+ * generated page fall back to importing `t` from `@ultimat3/i18n`.
242
+ */
243
+ readonly catalogModule?: string;
205
244
  }
206
245
 
207
246
  export function routeFiles(rawPath: string, options: RouteOptions): readonly GeneratedFile[] {
@@ -209,7 +248,7 @@ export function routeFiles(rawPath: string, options: RouteOptions): readonly Gen
209
248
  const dir = routeDir(options.surface, path);
210
249
  const locales = resolveLocales(options.locales);
211
250
  return [
212
- { path: `${dir}/page.tsx`, contents: pageSource(options.surface, path) },
251
+ { path: `${dir}/page.tsx`, contents: pageSource(options.surface, path, options.catalogModule) },
213
252
  { path: `${dir}/page.module.scss`, contents: styleSource() },
214
253
  { path: `${dir}/page.test.ts`, contents: routeTest(options.surface, path) },
215
254
  { path: `${dir}/page.e2e.test.ts`, contents: routeE2eTest(path) },
@@ -7,6 +7,10 @@ import { apiFiles } from './scaffold-api';
7
7
  import { icon } from './scaffold-icon';
8
8
  import { rolesFiles } from './scaffold-roles';
9
9
 
10
+ // The one dependency this manifest names, and it is not decoration: every page below reads its
11
+ // strings through `@<app>/i18n`'s `useT()`, so the surface that renders a string DEPENDS on the
12
+ // module that registers the catalogs. An undeclared workspace dependency resolves through the root
13
+ // symlink and then breaks the day the app is built anywhere else.
10
14
  const webPackage = (app: NameSet): string => `{
11
15
  "name": "@${app.kebab}/web",
12
16
  "version": "0.0.0",
@@ -18,6 +22,9 @@ const webPackage = (app: NameSet): string => `{
18
22
  },
19
23
  "scripts": {
20
24
  "typecheck": "tsc --noEmit -p tsconfig.json"
25
+ },
26
+ "dependencies": {
27
+ "@${app.kebab}/i18n": "0.0.0"
21
28
  }
22
29
  }
23
30
  `;
@@ -34,7 +41,12 @@ const tsconfig = (): string => `{
34
41
  const sitePage = (
35
42
  app: NameSet,
36
43
  ): string => `// The landing page. site/ is 0kb JS: static render, hydrate never, no framework script tag.
37
- import { t } from '@ultimat3/i18n';
44
+ //
45
+ // Strings come from \`useT()\` — this app's own catalog module — and never from
46
+ // \`t\` in @ultimat3/i18n. That import is what puts the module holding \`defineCatalogs()\` in
47
+ // this page's graph, so rendering a string is what registers the catalogs. A page that reached
48
+ // past it shipped every string as \`\u27e6key\u27e7\` with \`x verify\` green (issue #249).
49
+ import { useT } from '@${app.kebab}/i18n';
38
50
  import { defineRoute } from '@ultimat3/render';
39
51
  import styles from './page.module.scss';
40
52
 
@@ -43,13 +55,17 @@ export const config = defineRoute({
43
55
  hydrate: 'never',
44
56
  offline: 'precache',
45
57
  budget: { js: '0kb' },
46
- meta: () => ({
58
+ // \`t\` is handed to \`meta\` by the router — one translator per render, resolved against the
59
+ // request's locale before the head is built.
60
+ meta: ({ t }) => ({
47
61
  title: t('site.home.title'),
48
62
  description: t('site.home.description'),
49
63
  }),
50
64
  });
51
65
 
52
66
  export function HomePage() {
67
+ const t = useT();
68
+
53
69
  return (
54
70
  <main class={styles.hero}>
55
71
  <h1>{t('site.home.title')}</h1>
@@ -103,11 +119,13 @@ unitTest('the landing page ships zero JS and declares metadata', async () => {
103
119
  });
104
120
  `;
105
121
 
106
- const dashboardPage =
107
- (): string => `// The authed dashboard. app/ streams: a static shell is flushed instantly and the holes arrive
122
+ const dashboardPage = (
123
+ app: NameSet,
124
+ ): string => `// The authed dashboard. app/ streams: a static shell is flushed instantly and the holes arrive
108
125
  // as their data resolves.
109
126
 
110
- import { t } from '@ultimat3/i18n';
127
+ // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
128
+ import { useT } from '@${app.kebab}/i18n';
111
129
  import { defineRoute } from '@ultimat3/render';
112
130
  import styles from './page.module.scss';
113
131
 
@@ -118,15 +136,22 @@ export const config = defineRoute({
118
136
  // therefore failed x routes with X_ROUTE_MODE_INVALID on the first run, printing a fix nobody
119
137
  // could follow. Ship the mode that works. Async data needs no boundary: await it in the page.
120
138
  render: 'ssr',
139
+ // Stated with no island on the page, deliberately and for free — \`apps/admin/app/admin/page.tsx\`
140
+ // carries the reason.
121
141
  hydrate: 'visible',
122
142
  offline: 'runtime',
123
143
  // Auth is a policy, never a route-local flag: one authz system, evaluated everywhere.
124
144
  policy: { permission: 'dashboard:read' },
125
145
  budget: { js: '60kb' },
126
- meta: () => ({ title: t('app.dashboard.title'), description: t('app.dashboard.description') }),
146
+ meta: ({ t }) => ({
147
+ title: t('app.dashboard.title'),
148
+ description: t('app.dashboard.description'),
149
+ }),
127
150
  });
128
151
 
129
152
  export function DashboardPage() {
153
+ const t = useT();
154
+
130
155
  return (
131
156
  <section class={styles.panel}>
132
157
  <h1>{t('app.dashboard.title')}</h1>
@@ -157,14 +182,18 @@ unitTest('the dashboard renders on the server, is gated, and has an offline stra
157
182
  });
158
183
  `;
159
184
 
160
- const offlineFallback =
161
- (): string => `// The offline fallback. Every app/ route with offline: 'runtime' falls back here, so a train
185
+ const offlineFallback = (
186
+ app: NameSet,
187
+ ): string => `// The offline fallback. Every app/ route with offline: 'runtime' falls back here, so a train
162
188
  // tunnel shows the product's own shell instead of the browser's error page.
163
189
 
164
- import { t } from '@ultimat3/i18n';
190
+ // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
191
+ import { useT } from '@${app.kebab}/i18n';
165
192
  import styles from './offline.module.scss';
166
193
 
167
194
  export function OfflineFallback() {
195
+ const t = useT();
196
+
168
197
  return (
169
198
  <main class={styles.offline}>
170
199
  <h1>{t('app.offline.title')}</h1>
@@ -267,6 +296,8 @@ unitTest('holds answers from the role map, and an anonymous actor holds nothing'
267
296
  });
268
297
  `;
269
298
 
299
+ // Same one dependency as `apps/web`, and for the same reason: `app/admin/page.tsx` reads its
300
+ // strings through `@<app>/i18n`'s `useT()`.
270
301
  const adminPackage = (app: NameSet): string => `{
271
302
  "name": "@${app.kebab}/admin",
272
303
  "version": "0.0.0",
@@ -278,28 +309,42 @@ const adminPackage = (app: NameSet): string => `{
278
309
  },
279
310
  "scripts": {
280
311
  "typecheck": "tsc --noEmit -p tsconfig.json"
312
+ },
313
+ "dependencies": {
314
+ "@${app.kebab}/i18n": "0.0.0"
281
315
  }
282
316
  }
283
317
  `;
284
318
 
285
- const adminPage =
286
- (): string => `// The generated admin dashboard. It ships an MCP surface over the app's own actions, so the
319
+ const adminPage = (
320
+ app: NameSet,
321
+ ): string => `// The generated admin dashboard. It ships an MCP surface over the app's own actions, so the
287
322
  // user's agents can drive the user's product with the user's permissions.
288
323
 
289
- import { t } from '@ultimat3/i18n';
324
+ // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
325
+ import { useT } from '@${app.kebab}/i18n';
290
326
  import { defineRoute } from '@ultimat3/render';
291
327
 
292
328
  export const config = defineRoute({
293
- render: 'spa',
329
+ render: 'ssr',
330
+ // Stated on a page whose body is one \`<h1>\`, and it costs nothing: the hydration runtime is
331
+ // emitted per island DIRECTIVE, so \`hydrateRuntime([])\` is \`''\` and this document ships 0 bytes
332
+ // (\`packages/render/src/hydrate.ts\`). It buys the first island being ONE file's edit —
333
+ // \`hydrate: 'never'\` beside an island is \`X_ISLAND_NOT_HYDRATED\`, which defineRoute refuses.
294
334
  hydrate: 'idle',
295
335
  offline: 'network-only',
296
- // A spa renders no data, so the shell itself must be gated — @ultimat3/render requires it.
336
+ // Behind auth, so the mode has to render per request: \`ssr\` and \`stream\` both do, and both take
337
+ // a \`policy\`. \`static\` and \`isr\` refuse one outright — a file on disk has no actor to decide
338
+ // against, and an ISR document is cached per URL, so the first actor's HTML would be served to
339
+ // every later one who passes the same policy.
297
340
  policy: { permission: 'admin:read' },
298
341
  budget: { js: '120kb' },
299
- meta: () => ({ title: t('admin.home.title'), description: t('admin.home.description') }),
342
+ meta: ({ t }) => ({ title: t('admin.home.title'), description: t('admin.home.description') }),
300
343
  });
301
344
 
302
345
  export function AdminHome() {
346
+ const t = useT();
347
+
303
348
  return <h1>{t('admin.home.title')}</h1>;
304
349
  }
305
350
  `;
@@ -357,6 +402,12 @@ const prerender =
357
402
  // whatever its mode, so \`x verify\`'s \`budgets\` step has a number for it. \`unmeasured\` is the list
358
403
  // this build could not render — each one is an X_BUDGET_UNMEASURED at the gate, and this is where
359
404
  // the reason is.
405
+ //
406
+ // It writes the whole of \`pages\` and \`skipped\`, never a COUNT of either. A count is what let a
407
+ // partial artifact read as a complete one: someone pointed a screenshot tool at \`.x/static\` and
408
+ // filed "the island did not mount" against a route that had never been emitted (issue #242). Each
409
+ // skipped route carries its own \`reason\` and \`why\`, and \`report\` is where the same inventory
410
+ // landed on disk — which is what \`x build --target static --json\` reads back.
360
411
 
361
412
  import { join } from 'node:path';
362
413
  import { prerenderSite } from '@ultimat3/cli';
@@ -373,7 +424,7 @@ const origin = Bun.env.SITE_ORIGIN;
373
424
  if (import.meta.main) {
374
425
  const report = await prerenderSite({ root, out, ...(origin === undefined ? {} : { origin }) });
375
426
  await Bun.stdout.write(
376
- \`\${JSON.stringify({ ok: true, out: report.out, pages: report.pages.length, skipped: report.skipped, unmeasured: report.unmeasured })}\\n\`,
427
+ \`\${JSON.stringify({ ok: true, out: report.out, emitted: report.pages, skipped: report.skipped, unmeasured: report.unmeasured, report: report.report })}\\n\`,
377
428
  );
378
429
  }
379
430
  `;
@@ -402,10 +453,10 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
402
453
  { path: 'apps/web/site/page.tsx', contents: sitePage(app) },
403
454
  { path: 'apps/web/site/page.module.scss', contents: siteStyle() },
404
455
  { path: 'apps/web/site/page.test.ts', contents: sitePageTest() },
405
- { path: 'apps/web/app/dashboard/page.tsx', contents: dashboardPage() },
456
+ { path: 'apps/web/app/dashboard/page.tsx', contents: dashboardPage(app) },
406
457
  { path: 'apps/web/app/dashboard/page.module.scss', contents: dashboardStyle() },
407
458
  { path: 'apps/web/app/dashboard/page.test.ts', contents: dashboardTest() },
408
- { path: 'apps/web/app/offline.tsx', contents: offlineFallback() },
459
+ { path: 'apps/web/app/offline.tsx', contents: offlineFallback(app) },
409
460
  { path: 'apps/web/app/offline.module.scss', contents: offlineStyle() },
410
461
  // The third surface, and the one call that registers what the app declares — `scaffold-api.ts`.
411
462
  ...apiFiles(example),
@@ -425,7 +476,7 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
425
476
  // `apps/web/site/page.tsx` — `x dev` loads both surfaces into one route table and the
426
477
  // scaffolded app failed its own `x routes` with X_ROUTE_DUPLICATE. `/admin` also matches
427
478
  // @ultimat3/admin's own `basePath` default, so the two agree instead of merely not clashing.
428
- { path: 'apps/admin/app/admin/page.tsx', contents: adminPage() },
479
+ { path: 'apps/admin/app/admin/page.tsx', contents: adminPage(app) },
429
480
  { path: 'apps/mobile/README.md', contents: placeholder('mobile', app) },
430
481
  { path: 'apps/desktop/README.md', contents: placeholder('desktop', app) },
431
482
  ];
@@ -21,7 +21,7 @@ const dockerfile = (
21
21
  # syntax=docker/dockerfile:1
22
22
 
23
23
  # ---------- deps: runtime dependencies only, cached on the workspace manifests ----------
24
- FROM oven/bun:1.3-alpine AS deps
24
+ FROM oven/bun:1.4-alpine AS deps
25
25
  WORKDIR /app
26
26
  COPY package.json bun.lock ./
27
27
  # The workspace members' manifests are what \`bun install\` resolves against; their sources are not.
@@ -34,7 +34,7 @@ RUN bun install --frozen-lockfile --production
34
34
  # before it ever calls \`docker build\`. Re-running typecheck and lint here would need the
35
35
  # devDependencies the \`--production\` install above deliberately leaves out — which is exactly how
36
36
  # a build stage came to run \`tsc\` and \`biome\` against a tree that had neither.
37
- FROM oven/bun:1.3-alpine AS runtime
37
+ FROM oven/bun:1.4-alpine AS runtime
38
38
  WORKDIR /app
39
39
  COPY --from=deps /app/node_modules ./node_modules
40
40
  COPY . .