@ultimat3/cli 20.1.6 → 20.2.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/src/serve.ts CHANGED
@@ -40,6 +40,7 @@ import { startServices } from './dev-runtime';
40
40
  import type { Env } from './dev-services';
41
41
  import { resolveServices } from './dev-services';
42
42
  import { storageRoutes } from './dev-storage';
43
+ import { errorPageStyleSources } from './error-page-csp';
43
44
  import { PortInvalidError, RoleUnknownError } from './errors';
44
45
  import { holdUntilShutdown } from './hold';
45
46
  import { buildIslands } from './island-bundle';
@@ -53,6 +54,7 @@ import { styleBundle } from './style-bundle';
53
54
  import { styleRoutes } from './style-routes';
54
55
  import { serviceWorkerArtifacts } from './sw-artifacts';
55
56
  import { serviceWorkerRoutes } from './sw-routes';
57
+ import { loadThemeMode, themeBoot } from './theme-boot';
56
58
 
57
59
  export const DEFAULT_PORT = 3000;
58
60
 
@@ -360,6 +362,7 @@ async function bootRoles(boot: {
360
362
  // on a laptop and absent in the image is exactly the dev/prod difference this file exists to
361
363
  // prevent, and it is the one an operator cannot see without installing the app.
362
364
  const pwa = await loadPwaArtifacts(options.root);
365
+ const theme = themeBoot(await loadThemeMode(options.root));
363
366
  // The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
364
367
  // the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
365
368
  // route added here cannot be missing from the precache manifest.
@@ -393,6 +396,7 @@ async function bootRoles(boot: {
393
396
  ...appRoutes({
394
397
  buildId,
395
398
  resolveIsland: (file) => islands.resolverFor(file),
399
+ themeHead: theme.head,
396
400
  ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
397
401
  // Only when a store was supplied. `createIsrController` defaults to a per-process memory
398
402
  // store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
@@ -419,6 +423,10 @@ async function bootRoles(boot: {
419
423
  // Same declaration `x dev` reads. Without it a container answers a browser that opened a
420
424
  // guarded page with the problem document, rendered as raw JSON in the viewport.
421
425
  signInPath: await loadSignInPath(options.root),
426
+ // The enforced policy this process sends must admit the app's own error pages' `<style>` and
427
+ // the theme boot the documents carry; `x dev` is report-only, so only here was it a blank page.
428
+ inlineStyles: await errorPageStyleSources(options.root),
429
+ inlineScripts: [theme.cspSource],
422
430
  // The app's own `apps/web/site/errors/<status>.html`, resolved inside `startWeb` so this
423
431
  // process and `x dev` cannot answer a browser differently.
424
432
  root: options.root,
@@ -39,11 +39,15 @@ export { claudeFiles } from './scaffold-claude';
39
39
  export { claudeAgentFiles } from './scaffold-claude-agents';
40
40
  export { claudeCommandFiles } from './scaffold-claude-commands';
41
41
  export { containerFiles } from './scaffold-container';
42
+ export { dashboardFiles } from './scaffold-dashboard';
42
43
  export { docsFiles, EXECUTABLE_FILES } from './scaffold-docs';
43
44
  export { entryFiles } from './scaffold-entries';
44
- // The four guards `x new` ships, distinct from `guardFiles` above, which is `x g guard <name>`.
45
+ export { errorPageFiles, PWA_COLORS } from './scaffold-errors';
46
+ // The nine guards `x new` ships, distinct from `guardFiles` above, which is `x g guard <name>`.
45
47
  export { scaffoldGuardFiles } from './scaffold-guards';
46
48
  export { i18nIndex } from './scaffold-i18n';
47
49
  export { repoFiles } from './scaffold-repo';
50
+ export { shellFiles } from './scaffold-shell';
51
+ export { siteFiles } from './scaffold-site';
48
52
  export type { SliceModule } from './slice-foundation';
49
53
  export { sliceFoundation } from './slice-foundation';
@@ -6,10 +6,14 @@ import { sortedImports } from './imports';
6
6
  import type { GeneratedFile, NameSet } from './naming';
7
7
  import { apiFiles } from './scaffold-api';
8
8
  import { authFiles } from './scaffold-auth';
9
+ import { dashboardFiles } from './scaffold-dashboard';
9
10
  import { entryFiles } from './scaffold-entries';
11
+ import { errorPageFiles } from './scaffold-errors';
10
12
  import { httpFiles } from './scaffold-http';
11
13
  import { icon } from './scaffold-icon';
12
14
  import { rolesFiles } from './scaffold-roles';
15
+ import { shellFiles } from './scaffold-shell';
16
+ import { siteFiles } from './scaffold-site';
13
17
 
14
18
  // The one dependency this manifest names, and it is not decoration: every page below reads its
15
19
  // strings through `@<app>/i18n`'s `useT()`, so the surface that renders a string DEPENDS on the
@@ -42,154 +46,6 @@ const tsconfig = (): string => `{
42
46
  }
43
47
  `;
44
48
 
45
- const sitePage = (
46
- app: NameSet,
47
- ): string => `// The landing page. site/ is 0kb JS: static render, hydrate never, no framework script tag.
48
- //
49
- // Strings come from \`useT()\` — this app's own catalog module — and never from
50
- // \`t\` in @ultimat3/i18n. That import is what puts the module holding \`defineCatalogs()\` in
51
- // this page's graph, so rendering a string is what registers the catalogs. A page that reached
52
- // past it shipped every string as \`\u27e6key\u27e7\` with \`x verify\` green (issue #249).
53
- ${sortedImports([
54
- `import { useT } from '@${app.kebab}/i18n';`,
55
- `import { defineRoute } from '@ultimat3/render';`,
56
- ])}
57
- import styles from './page.module.scss';
58
-
59
- export const config = defineRoute({
60
- render: 'static',
61
- hydrate: 'never',
62
- offline: 'precache',
63
- budget: { js: '0kb' },
64
- // \`t\` is handed to \`meta\` by the router — one translator per render, resolved against the
65
- // request's locale before the head is built.
66
- meta: ({ t }) => ({
67
- title: t('site.home.title'),
68
- description: t('site.home.description'),
69
- }),
70
- });
71
-
72
- export function HomePage() {
73
- const t = useT();
74
-
75
- return (
76
- <main class={styles.hero}>
77
- <h1>{t('site.home.title')}</h1>
78
- <p>{t('site.home.description')}</p>
79
- <a class={styles.cta} href="/dashboard">
80
- {t('site.home.cta')}
81
- </a>
82
- </main>
83
- );
84
- }
85
-
86
- export const appName = '${app.kebab}';
87
- `;
88
-
89
- const siteStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
90
-
91
- .hero {
92
- display: grid;
93
- gap: tokens.space(4);
94
- padding: tokens.space(8);
95
- background: tokens.role('bg');
96
- color: tokens.role('fg');
97
- }
98
-
99
- .cta {
100
- justify-self: start;
101
- padding: tokens.space(2) tokens.space(4);
102
- border-radius: tokens.radius('md');
103
- background: tokens.role('accent');
104
- color: tokens.role('accent-fg');
105
- }
106
- `;
107
-
108
- const sitePageTest =
109
- (): string => `// The landing page ships zero JS and declares its metadata. Both are promises the file makes in
110
- // its config, and both are the kind that rot silently when someone adds one import.
111
- import { metaContextFor, routeDataFor } from '@ultimat3/render';
112
- import { expect, unitTest } from '@ultimat3/testing';
113
- import { config } from './page';
114
-
115
- // The same two objects a render builds: \`routeDataFor\` resolves the route's data once, and
116
- // \`metaContextFor\` wraps it the way every render mode wraps it before calling \`meta\`.
117
- const ctx = { params: {}, url: 'https://example.test/' };
118
-
119
- unitTest('the landing page ships zero JS and declares metadata', async () => {
120
- expect(config.render).toBe('static');
121
- expect(config.hydrate).toBe('never');
122
- expect(config.budget.js).toBe('0kb');
123
- const meta = await config.meta(metaContextFor(ctx, await routeDataFor(config, ctx)));
124
- expect(meta.title ?? '').not.toBe('');
125
- });
126
- `;
127
-
128
- const dashboardPage = (
129
- app: NameSet,
130
- ): string => `// The authed dashboard. app/ streams: a static shell is flushed instantly and the holes arrive
131
- // as their data resolves.
132
-
133
- // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
134
- ${sortedImports([
135
- `import { useT } from '@${app.kebab}/i18n';`,
136
- `import { defineRoute } from '@ultimat3/render';`,
137
- ])}
138
- import styles from './page.module.scss';
139
-
140
- export const config = defineRoute({
141
- // 'ssr', not 'stream', and this is not a downgrade: 'stream' needs a boundary to stream into,
142
- // and the framework has no hole marker yet. Solid's <Suspense> is not it — it throws outside a
143
- // Solid renderer, and the server JSX factory is inert on purpose. A scaffolded 'stream' route
144
- // therefore failed x routes with X_ROUTE_MODE_INVALID on the first run, printing a fix nobody
145
- // could follow. Ship the mode that works. Async data needs no boundary: await it in the page.
146
- render: 'ssr',
147
- // Stated with no island on the page, deliberately and for free — \`apps/admin/app/admin/page.tsx\`
148
- // carries the reason.
149
- hydrate: 'visible',
150
- offline: 'runtime',
151
- // Auth is a policy, never a route-local flag: one authz system, evaluated everywhere.
152
- policy: { permission: 'dashboard:read' },
153
- budget: { js: '60kb' },
154
- meta: ({ t }) => ({
155
- title: t('app.dashboard.title'),
156
- description: t('app.dashboard.description'),
157
- }),
158
- });
159
-
160
- export function DashboardPage() {
161
- const t = useT();
162
-
163
- return (
164
- <section class={styles.panel}>
165
- <h1>{t('app.dashboard.title')}</h1>
166
- </section>
167
- );
168
- }
169
- `;
170
-
171
- const dashboardStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
172
-
173
- .panel {
174
- padding: tokens.space(6);
175
- background: tokens.role('surface-raised');
176
- color: tokens.role('fg');
177
- }
178
- `;
179
-
180
- const dashboardTest =
181
- (): string => `// The dashboard renders per request, is gated by a policy, and has an offline strategy. Losing
182
- // the policy is the interesting regression: the page still renders, to anyone.
183
- import { expect, unitTest } from '@ultimat3/testing';
184
- import { config } from './page';
185
-
186
- unitTest('the dashboard renders on the server, is gated, and has an offline strategy', () => {
187
- expect(config.render).toBe('ssr');
188
- expect(config.policy?.permission).toBe('dashboard:read');
189
- expect(config.offline).toBe('runtime');
190
- });
191
- `;
192
-
193
49
  const offlineTest =
194
50
  (): string => `// The offline fallback has to render with nothing: no network, no session, no database, and no
195
51
  // JavaScript. Every one of those is a config field here, and every one of them rots the moment
@@ -419,7 +275,8 @@ restructure.
419
275
  | Start | \`x new ${app.kebab}-${surface}\` inside this directory, or wire it by hand |
420
276
  `;
421
277
 
422
- /** `example` reaches only `apps/web/api/index.ts`: the slice it registers is written elsewhere. */
278
+ /** `example` decides the API registration, the shell's nav and which dashboard is written; the
279
+ * slice itself is written elsewhere. */
423
280
  export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile[] {
424
281
  return [
425
282
  { path: 'apps/web/package.json', contents: webPackage(app) },
@@ -427,12 +284,14 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
427
284
  // The process a container starts and the artifact a CDN is handed — `scaffold-entries.ts`.
428
285
  ...entryFiles(),
429
286
  { path: 'apps/web/site/icon.png', contents: icon() },
430
- { path: 'apps/web/site/page.tsx', contents: sitePage(app) },
431
- { path: 'apps/web/site/page.module.scss', contents: siteStyle() },
432
- { path: 'apps/web/site/page.test.ts', contents: sitePageTest() },
433
- { path: 'apps/web/app/dashboard/page.tsx', contents: dashboardPage(app) },
434
- { path: 'apps/web/app/dashboard/page.module.scss', contents: dashboardStyle() },
435
- { path: 'apps/web/app/dashboard/page.test.ts', contents: dashboardTest() },
287
+ // The landing page: hero, two calls to action, three feature cards — `scaffold-site.ts`.
288
+ ...siteFiles(app),
289
+ // The signed-in product: its frame and the one island it ships (`scaffold-shell.ts`), then the
290
+ // dashboard in the shape the invocation earns (`scaffold-dashboard.ts`).
291
+ ...shellFiles(app, example),
292
+ ...dashboardFiles(app, example),
293
+ // Served verbatim for those statuses and carried into the static export — `scaffold-errors.ts`.
294
+ ...errorPageFiles(app),
436
295
  // The third piece of the authz story the scaffold already tells twice: the routes declare a
437
296
  // policy and `shared/roles.ts` declares the grants, and until this file existed nothing
438
297
  // answered "who is this?" — so every one of those routes refused every request.
@@ -0,0 +1,182 @@
1
+ // The `--no-example` dashboard: framework facts and the route table, and no chart — there is no
2
+ // series to draw until the first `x g resource`, and a chart of invented numbers would be the first
3
+ // lie in the app. The route declaration and the island come from `scaffold-dashboard-shared.ts`.
4
+
5
+ import { sortedImports } from './imports';
6
+ import type { GeneratedFile, NameSet } from './naming';
7
+ import { DASHBOARD_DIR, routeConfig, themeActions, themeIsland } from './scaffold-dashboard-shared';
8
+
9
+ // Plain strings for the framework lines, never template literals: the workspace-dependency scanner
10
+ // blanks a string's contents but not a nested template's, so a template here would bill the CLI
11
+ // for the imports of the app it writes.
12
+ const barePage = (
13
+ app: NameSet,
14
+ ): string => `// The authed dashboard of an app with no entity yet. Nothing here is invented: the tiles are the
15
+ // framework's own registries — routes, locales, roles, version — and the table is the route table
16
+ // \`x routes\` prints. NO CHART, deliberately: there is no series to draw until the first
17
+ // \`x g resource\`, and a chart of made-up numbers would be the first lie in the app.
18
+ //
19
+ // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
20
+ ${sortedImports([
21
+ `import { catalogs, useT } from '@${app.kebab}/i18n';`,
22
+ "import { frameworkVersion } from '@ultimat3/core';",
23
+ "import { currentLocale } from '@ultimat3/i18n';",
24
+ "import { defineRoute, island, routeEntries } from '@ultimat3/render';",
25
+ "import { DataTable, Grid, PageHeader, Section, StatTile, ThemeToggle } from '@ultimat3/ui';",
26
+ ])}
27
+ import { roles } from '../../shared/roles';
28
+ import { Shell } from '../../shared/shell';
29
+ import { factsOf, formatCount, type RouteRow, routeRows } from './dashboard-view';
30
+ import styles from './page.module.scss';
31
+
32
+ ${themeIsland}
33
+
34
+ ${routeConfig('')}
35
+
36
+ export function DashboardPage() {
37
+ const t = useT();
38
+ const locale = currentLocale();
39
+ // Read at render, not at import: the registries are filled by the boot scan, after this module.
40
+ const entries = routeEntries();
41
+ const facts = factsOf({
42
+ routes: entries.length,
43
+ locales: catalogs.locales.length,
44
+ roles: Object.keys(roles).length,
45
+ version: frameworkVersion(),
46
+ });
47
+ const rows = routeRows(entries);
48
+
49
+ return (
50
+ <Shell
51
+ nav="dashboard"
52
+ actions={
53
+ ${themeActions}
54
+ }
55
+ >
56
+ <div class={styles.page}>
57
+ <PageHeader title={t('app.dashboard.title')} description={t('app.dashboard.subtitle')} />
58
+ {/* 12rem, not the catalog's 16rem default: four tiles pair up two-by-two on a tablet's main
59
+ column and sit in one row on a desktop, never three and an orphan. */}
60
+ <Grid minColumn="12rem">
61
+ <StatTile
62
+ stat="routes"
63
+ label={t('app.dashboard.statRoutes')}
64
+ value={formatCount(facts.routes, locale)}
65
+ hint={t('app.dashboard.hintRoutes')}
66
+ />
67
+ <StatTile
68
+ stat="locales"
69
+ label={t('app.dashboard.statLocales')}
70
+ value={formatCount(facts.locales, locale)}
71
+ hint={t('app.dashboard.hintLocales')}
72
+ />
73
+ <StatTile
74
+ stat="roles"
75
+ label={t('app.dashboard.statRoles')}
76
+ value={formatCount(facts.roles, locale)}
77
+ hint={t('app.dashboard.hintRoles')}
78
+ />
79
+ <StatTile
80
+ stat="version"
81
+ label={t('app.dashboard.statVersion')}
82
+ value={facts.version}
83
+ hint={t('app.dashboard.hintVersion')}
84
+ />
85
+ </Grid>
86
+ <Section title={t('app.dashboard.routesTitle')}>
87
+ <DataTable
88
+ caption={t('app.dashboard.routesCaption')}
89
+ rowKey={(row: RouteRow) => row.path}
90
+ rows={rows}
91
+ columns={[
92
+ { key: 'path', header: t('app.dashboard.columnPath'), cell: (row) => row.path },
93
+ {
94
+ key: 'surface',
95
+ header: t('app.dashboard.columnSurface'),
96
+ cell: (row) => row.surface,
97
+ },
98
+ { key: 'render', header: t('app.dashboard.columnRender'), cell: (row) => row.render },
99
+ ]}
100
+ />
101
+ </Section>
102
+ </div>
103
+ </Shell>
104
+ );
105
+ }
106
+ `;
107
+
108
+ const bareView =
109
+ (): string => `// The dashboard's facts, apart from its markup: pure projections of what the framework registered,
110
+ // so the page and a test read the same shape. No I/O, no \`t()\`, no JSX.
111
+
112
+ /** The three columns of the route table, as \`x routes\` prints them. */
113
+ export interface RouteRow {
114
+ readonly path: string;
115
+ readonly surface: string;
116
+ readonly render: string;
117
+ }
118
+
119
+ /** What \`routeEntries()\` hands over, narrowed to the fields the table reads. */
120
+ export interface RouteLike {
121
+ readonly path: string;
122
+ readonly surface: string;
123
+ readonly config: { readonly render: string };
124
+ }
125
+
126
+ /** Sorted by path in code-unit order — the order \`x routes\` and the manifest both use. */
127
+ export const routeRows = (entries: readonly RouteLike[]): readonly RouteRow[] =>
128
+ entries
129
+ .map((entry) => ({ path: entry.path, surface: entry.surface, render: entry.config.render }))
130
+ .toSorted((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
131
+
132
+ export interface FrameworkFacts {
133
+ readonly routes: number;
134
+ readonly locales: number;
135
+ readonly roles: number;
136
+ readonly version: string;
137
+ }
138
+
139
+ /** Counts are never negative, and a blank version is reported as unknown rather than as nothing. */
140
+ export const factsOf = (input: FrameworkFacts): FrameworkFacts => ({
141
+ routes: Math.max(0, input.routes),
142
+ locales: Math.max(0, input.locales),
143
+ roles: Math.max(0, input.roles),
144
+ version: input.version === '' ? 'unknown' : input.version,
145
+ });
146
+
147
+ /** A figure for a tile, in the page's locale. \`Intl.NumberFormat\` needs no time zone. */
148
+ export const formatCount = (value: number, locale: string): string =>
149
+ new Intl.NumberFormat(locale).format(value);
150
+ `;
151
+
152
+ const bareViewTest = (): string => `// The route table and the fact tiles, pinned on fixed input.
153
+ import { expect, unitTest } from '@ultimat3/testing';
154
+ import { factsOf, formatCount, routeRows } from './dashboard-view';
155
+
156
+ unitTest('routeRows projects the three columns and sorts by path', () => {
157
+ const rows = routeRows([
158
+ { path: '/dashboard', surface: 'app', config: { render: 'ssr' } },
159
+ { path: '/', surface: 'site', config: { render: 'static' } },
160
+ ]);
161
+ expect(rows.map((row) => row.path)).toEqual(['/', '/dashboard']);
162
+ expect(rows[1]).toEqual({ path: '/dashboard', surface: 'app', render: 'ssr' });
163
+ });
164
+
165
+ unitTest('factsOf clamps counts and names an unknown version', () => {
166
+ const facts = factsOf({ routes: -1, locales: 1, roles: 2, version: '' });
167
+ expect(facts).toEqual({ routes: 0, locales: 1, roles: 2, version: 'unknown' });
168
+ expect(factsOf({ routes: 3, locales: 1, roles: 2, version: '1.2.3' }).version).toBe('1.2.3');
169
+ });
170
+
171
+ unitTest('formatCount follows the locale', () => {
172
+ expect(formatCount(1234, 'en')).toBe('1,234');
173
+ expect(formatCount(1234, 'de')).toBe('1.234');
174
+ });
175
+ `;
176
+
177
+ /** The bare dashboard's page, view module and view test. */
178
+ export const bareDashboardFiles = (app: NameSet): readonly GeneratedFile[] => [
179
+ { path: `${DASHBOARD_DIR}/page.tsx`, contents: barePage(app) },
180
+ { path: `${DASHBOARD_DIR}/dashboard-view.ts`, contents: bareView() },
181
+ { path: `${DASHBOARD_DIR}/dashboard-view.test.ts`, contents: bareViewTest() },
182
+ ];