@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.
- package/CLAUDE.md +75 -6
- package/README.md +2 -2
- package/package.json +28 -24
- package/src/affected.ts +320 -0
- package/src/browser-launcher.ts +109 -0
- package/src/ci-log.ts +0 -0
- package/src/ci-runs.ts +179 -0
- package/src/cmd-affected.ts +109 -0
- package/src/cmd-build.ts +36 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-dev.ts +35 -2
- package/src/cmd-generate.ts +16 -348
- package/src/cmd-i18n.ts +32 -16
- package/src/cmd-pr.ts +308 -0
- package/src/cmd-shot.ts +320 -0
- package/src/cmd-test.ts +96 -7
- package/src/cmd-verify.ts +10 -427
- package/src/compile-externals.ts +34 -0
- package/src/dev-lock.ts +275 -0
- package/src/dev-render.ts +7 -17
- package/src/error-codes.ts +18 -0
- package/src/generate-files.ts +127 -0
- package/src/generate-write.ts +229 -0
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/i18n-audit.ts +39 -1
- package/src/i18n-registration.ts +130 -0
- package/src/index.ts +37 -0
- package/src/island-bundle.ts +68 -2
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/mcp-errors.ts +11 -0
- package/src/messages.ts +67 -0
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/registry.ts +8 -0
- package/src/shot-verdict.ts +337 -0
- package/src/solid-loader.ts +127 -0
- package/src/static-report.ts +219 -0
- package/src/templates/admin-page.ts +46 -5
- package/src/templates/index.ts +1 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +129 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +52 -43
- package/src/templates/route.ts +45 -6
- package/src/templates/scaffold-app.ts +70 -19
- package/src/templates/scaffold-container.ts +2 -2
- package/src/templates/scaffold-db-package.ts +88 -39
- package/src/templates/scaffold-docs.ts +18 -1
- package/src/templates/scaffold-i18n.ts +9 -2
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/templates/scaffold-repo.ts +2 -2
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +349 -0
- package/src/verify-run.ts +122 -0
- package/src/verify-step.ts +7 -0
- package/src/workspace-graph.ts +241 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
196
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/templates/route.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
//
|
|
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,
|
|
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.
|
|
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.
|
|
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 . .
|