@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/package.json +29 -29
- package/src/budgets.ts +17 -2
- package/src/cmd-dev.ts +14 -15
- package/src/cmd-mcp.ts +2 -2
- package/src/cmd-shot.ts +19 -5
- package/src/dev-render.ts +9 -1
- package/src/dev-roles.ts +6 -6
- package/src/error-codes.ts +20 -2
- package/src/error-page-csp.ts +50 -0
- package/src/mcp-errors.ts +20 -1
- package/src/mcp-ui-diff.ts +141 -0
- package/src/mcp-ui-inspect.ts +183 -0
- package/src/mcp-ui-interact.ts +304 -0
- package/src/mcp-ui.ts +53 -15
- package/src/output.ts +1 -1
- package/src/prerender.ts +3 -0
- package/src/scaffold-typecheck.ts +5 -0
- package/src/script-csp.ts +5 -2
- package/src/serve.ts +8 -0
- package/src/templates/index.ts +5 -1
- package/src/templates/scaffold-app.ts +14 -155
- package/src/templates/scaffold-dashboard-bare.ts +182 -0
- package/src/templates/scaffold-dashboard-example.ts +326 -0
- package/src/templates/scaffold-dashboard-shared.ts +74 -0
- package/src/templates/scaffold-dashboard.ts +27 -0
- package/src/templates/scaffold-errors.ts +128 -0
- package/src/templates/scaffold-i18n.ts +46 -0
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +461 -0
- package/src/templates/scaffold-site.ts +287 -0
- package/src/theme-boot.ts +59 -0
- package/src/ui-diff.ts +90 -0
- package/src/ui-inspect-probe.ts +131 -0
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,
|
package/src/templates/index.ts
CHANGED
|
@@ -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
|
-
|
|
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`
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
+
];
|