@ultimat3/cli 20.1.6 → 20.2.1
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 +1 -0
- 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 +49 -9
- 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/island-bundle.ts +6 -1
- package/src/island-solid-dedupe.ts +108 -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/shot-theme.ts +52 -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 +77 -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/mcp-ui.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
// The dev MCP server's
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The dev MCP server's eyes and hand: `ui.shot` (a route), `ui.island` (a component's states),
|
|
2
|
+
// `ui.inspect` (DOM facts for a set of selectors, in `mcp-ui-inspect.ts`), `ui.interact` (steps
|
|
3
|
+
// first, then the picture, in `mcp-ui-interact.ts`) and `ui.diff` (two captures compared, in
|
|
4
|
+
// `mcp-ui-diff.ts`), as the `DevCapabilities` half `packages/mcp` declares and cannot satisfy — a
|
|
5
|
+
// browser is the CLI's to launch, and the files are the CLI's to read. The four that look
|
|
6
|
+
// are `x shot` under another name: the same server lookup (a running `x dev` is
|
|
4
7
|
// reused through its lock, otherwise a scratch one boots), the same driver, the same verdict.
|
|
5
8
|
// Nothing here is a new capability; it is the existing one made reachable from inside the loop
|
|
6
9
|
// an agent already works in, so "does it look right" stops needing a hand-written script.
|
|
@@ -8,7 +11,18 @@
|
|
|
8
11
|
// why: Bun exposes no path-join primitive, and the picture's directory is a path an agent opens.
|
|
9
12
|
import { join } from 'node:path';
|
|
10
13
|
import { UltimateError } from '@ultimat3/core';
|
|
11
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
UiDiffInput,
|
|
16
|
+
UiDiffResult,
|
|
17
|
+
UiInspectInput,
|
|
18
|
+
UiInspectResult,
|
|
19
|
+
UiInteractInput,
|
|
20
|
+
UiInteractResult,
|
|
21
|
+
UiIslandInput,
|
|
22
|
+
UiIslandResult,
|
|
23
|
+
UiShotInput,
|
|
24
|
+
UiShotResult,
|
|
25
|
+
} from '@ultimat3/mcp';
|
|
12
26
|
import { describeRoutes } from '@ultimat3/render';
|
|
13
27
|
import type { ScrapeDriver } from '@ultimat3/scraping';
|
|
14
28
|
import { DEFAULT_PAGE_TIMEOUT_MS } from '@ultimat3/scraping';
|
|
@@ -17,6 +31,9 @@ import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
|
|
|
17
31
|
import { islandShot } from './cmd-shot-island';
|
|
18
32
|
import type { Env } from './dev-services';
|
|
19
33
|
import { islandVerdictJson } from './island-verdict';
|
|
34
|
+
import { diffShots } from './mcp-ui-diff';
|
|
35
|
+
import { inspectRoute } from './mcp-ui-inspect';
|
|
36
|
+
import { interactRoute } from './mcp-ui-interact';
|
|
20
37
|
import { retryMemo } from './retry-memo';
|
|
21
38
|
import { shotBrowserChoice } from './shot-browser';
|
|
22
39
|
import { devServerFor, type ShotServer } from './shot-server';
|
|
@@ -44,7 +61,7 @@ export function assertBudgetedRoute(route: string, declared: readonly DeclaredRo
|
|
|
44
61
|
throw new UltimateError({
|
|
45
62
|
code: 'X_UI_SHOT_ROUTE_UNKNOWN',
|
|
46
63
|
cause: `no route in this app answers ${route}`,
|
|
47
|
-
fix: 'x routes --json # then ui
|
|
64
|
+
fix: 'x routes --json # then call the ui.* tool with one of its `path` values',
|
|
48
65
|
});
|
|
49
66
|
}
|
|
50
67
|
if (hit.budgetJs === null) {
|
|
@@ -78,6 +95,10 @@ export interface UiHostInput {
|
|
|
78
95
|
export interface UiCapabilities {
|
|
79
96
|
shotRoute(shot: UiShotInput): Promise<UiShotResult>;
|
|
80
97
|
shotIsland(island: UiIslandInput): Promise<UiIslandResult>;
|
|
98
|
+
inspectRoute(inspect: UiInspectInput): Promise<UiInspectResult>;
|
|
99
|
+
interactRoute(interact: UiInteractInput): Promise<UiInteractResult>;
|
|
100
|
+
/** Two captures under `.x/shot/` compared without a browser; never boots the scratch server. */
|
|
101
|
+
diffShots(diff: UiDiffInput): Promise<UiDiffResult>;
|
|
81
102
|
/** Stops the scratch server, if one was booted. Never boots one in order to stop it. */
|
|
82
103
|
close(): Promise<void>;
|
|
83
104
|
}
|
|
@@ -100,6 +121,17 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
100
121
|
// provider's CDP URL, or the launcher's own discovery.
|
|
101
122
|
const browser = () => shotBrowserChoice({ cdpFlag: undefined, browserFlag: undefined, env });
|
|
102
123
|
const routes = input.routes ?? describeRoutes;
|
|
124
|
+
// One browser per call, sized to the call: the injected driver in a test, `appBrowser` otherwise.
|
|
125
|
+
const driverFor = async (viewport: UiShotInput['viewport']): Promise<ScrapeDriver> => {
|
|
126
|
+
if (input.driver !== undefined) return input.driver(viewport);
|
|
127
|
+
const { cdpUrl, executablePath } = browser();
|
|
128
|
+
return appBrowser({
|
|
129
|
+
root,
|
|
130
|
+
viewport,
|
|
131
|
+
...(executablePath === undefined ? {} : { executablePath }),
|
|
132
|
+
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
133
|
+
});
|
|
134
|
+
};
|
|
103
135
|
let closed = false;
|
|
104
136
|
|
|
105
137
|
return {
|
|
@@ -113,16 +145,7 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
113
145
|
|
|
114
146
|
async shotRoute(shot) {
|
|
115
147
|
assertBudgetedRoute(shot.route, routes());
|
|
116
|
-
const
|
|
117
|
-
const driver =
|
|
118
|
-
input.driver === undefined
|
|
119
|
-
? await appBrowser({
|
|
120
|
-
root,
|
|
121
|
-
viewport: shot.viewport,
|
|
122
|
-
...(executablePath === undefined ? {} : { executablePath }),
|
|
123
|
-
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
124
|
-
})
|
|
125
|
-
: await input.driver(shot.viewport);
|
|
148
|
+
const driver = await driverFor(shot.viewport);
|
|
126
149
|
// One directory per (route, viewport, scheme), so two pictures of one route at two widths
|
|
127
150
|
// never overwrite each other and an agent can hold both.
|
|
128
151
|
const outDir = join(
|
|
@@ -149,6 +172,21 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
149
172
|
};
|
|
150
173
|
},
|
|
151
174
|
|
|
175
|
+
async inspectRoute(inspect) {
|
|
176
|
+
// The same gate as `ui.shot`, for the same reason: facts about a draft are facts about the
|
|
177
|
+
// wrong thing.
|
|
178
|
+
assertBudgetedRoute(inspect.route, routes());
|
|
179
|
+
return inspectRoute({ root, boot, driver: driverFor }, inspect);
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async interactRoute(interact) {
|
|
183
|
+
assertBudgetedRoute(interact.route, routes());
|
|
184
|
+
return interactRoute({ root, boot, driver: driverFor }, interact);
|
|
185
|
+
},
|
|
186
|
+
// No route gate and no boot: the captures were gated when they were taken, and a diff of two
|
|
187
|
+
// files needs neither a server nor a browser.
|
|
188
|
+
diffShots: (diff) => diffShots({ root }, diff),
|
|
189
|
+
|
|
152
190
|
async shotIsland(island) {
|
|
153
191
|
const { cdpUrl, executablePath } = browser();
|
|
154
192
|
const artifacts = await islandShot({
|
package/src/output.ts
CHANGED
|
@@ -64,7 +64,7 @@ export interface CommandResult {
|
|
|
64
64
|
* Which fd this result is written to. `stdout` for every command, absent included — and
|
|
65
65
|
* `stderr` for the one case where fd 1 is not the command's to write on: `x mcp serve
|
|
66
66
|
* --transport stdio`, whose stdout carries JSON-RPC frames, and where the `✓ mcp stdio serving
|
|
67
|
-
*
|
|
67
|
+
* 18 tools` line printed after the loop exits is a malformed frame to whatever is reading.
|
|
68
68
|
*
|
|
69
69
|
* Behaviour, not a fact, exactly like `hold` above — so NEITHER renderer carries it. It says
|
|
70
70
|
* where a rendered line goes, and a payload that also claimed it would be a second answer to a
|
package/src/prerender.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
serviceWorkerHead,
|
|
31
31
|
serviceWorkerRegistration,
|
|
32
32
|
} from './sw-artifacts';
|
|
33
|
+
import { loadThemeMode, themeBoot } from './theme-boot';
|
|
33
34
|
|
|
34
35
|
// Re-exported, never re-declared: `static-report.ts` owns the shape because the report on disk
|
|
35
36
|
// carries it, and this file already imports that module.
|
|
@@ -186,6 +187,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
186
187
|
// wiring exists to close. `undefined` when the app is not installable, and then no document
|
|
187
188
|
// names it either.
|
|
188
189
|
const pwa = await loadPwaArtifacts(options.root);
|
|
190
|
+
const theme = themeBoot(await loadThemeMode(options.root));
|
|
189
191
|
// The registration TAG now, the worker itself after the render loop — the two halves are wanted
|
|
190
192
|
// at different moments and used to be taken at the same one. Every document below has to name
|
|
191
193
|
// `/x-sw-register.js`, and the worker's precache manifest is built from the content hash of
|
|
@@ -238,6 +240,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
238
240
|
runWithContext(as, () =>
|
|
239
241
|
routeDocument(entry, data, {
|
|
240
242
|
resolveIsland: (file: string) => islands.resolverFor(file),
|
|
243
|
+
themeHead: theme.head,
|
|
241
244
|
...(pwa === undefined ? {} : { pwaHead: pwa.head + (swHead ?? '') }),
|
|
242
245
|
}),
|
|
243
246
|
);
|
|
@@ -146,6 +146,11 @@ const overlay = (root: string, app: string): string =>
|
|
|
146
146
|
compilerOptions: {
|
|
147
147
|
noEmit: true,
|
|
148
148
|
paths: {
|
|
149
|
+
// The two subpath exports a generated app reaches for, spelled the way the packages'
|
|
150
|
+
// own `exports` maps spell them — the wildcard below would read `ui/icons/zap` as a
|
|
151
|
+
// package name. Longest prefix wins, so these are consulted first.
|
|
152
|
+
'@ultimat3/ui/icons/*': [`${root}/packages/ui/src/icons/glyphs/*`],
|
|
153
|
+
'@ultimat3/render/server': [`${root}/packages/render/src/server`],
|
|
149
154
|
'@ultimat3/*': [`${root}/packages/*/src`],
|
|
150
155
|
[`@${app}/web/*`]: ['./apps/web/*'],
|
|
151
156
|
[`@${app}/admin/*`]: ['./apps/admin/*'],
|
package/src/script-csp.ts
CHANGED
|
@@ -11,7 +11,10 @@ import { HYDRATE_RUNTIME_BODIES } from '@ultimat3/render';
|
|
|
11
11
|
* Hashes, never a nonce: a `render: 'static'` page is a file on disk, so no per-response value can
|
|
12
12
|
* reach it. Read from `@ultimat3/render`'s own enumeration rather than restated here — the body
|
|
13
13
|
* the document carries and the body the policy hashes have to be one string.
|
|
14
|
+
*
|
|
15
|
+
* `extra` is for sources the caller already hashed from a body it emits — the theme boot's,
|
|
16
|
+
* which `theme-boot.ts` derives from the same string it inlines.
|
|
14
17
|
*/
|
|
15
|
-
export function inlineScriptSources(): readonly string[] {
|
|
16
|
-
return [...new Set(HYDRATE_RUNTIME_BODIES.map(cspHashSource))].sort();
|
|
18
|
+
export function inlineScriptSources(extra: readonly string[] = []): readonly string[] {
|
|
19
|
+
return [...new Set([...HYDRATE_RUNTIME_BODIES.map(cspHashSource), ...extra])].sort();
|
|
17
20
|
}
|
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,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The script a capture runs ahead of the document when a theme was ASKED for. Since 20.2.0 the
|
|
2
|
+
// boot inlines a theme script (`@ultimat3/render`'s `themeScriptBody`) whose fallback is the app's
|
|
3
|
+
// `theme.defaultMode`, and only a stored choice under `THEME_STORAGE_KEY` beats it — so emulating
|
|
4
|
+
// `prefers-color-scheme` alone photographs a `defaultMode: 'dark'` app dark whatever was requested
|
|
5
|
+
// (issue #489). A requested theme is therefore stored as the visitor's CHOICE, on the page's origin,
|
|
6
|
+
// before the boot reads it: the picture is then what a visitor who chose that theme sees.
|
|
7
|
+
|
|
8
|
+
import { THEME_STORAGE_KEY } from '@ultimat3/render';
|
|
9
|
+
import type { ColorScheme } from '@ultimat3/scraping';
|
|
10
|
+
import { BadFlagError } from './errors';
|
|
11
|
+
|
|
12
|
+
/** What `x shot --theme` accepts: the two the boot honours from storage, and nothing else. */
|
|
13
|
+
const SHOT_THEMES = ['light', 'dark'] as const;
|
|
14
|
+
export type ShotTheme = (typeof SHOT_THEMES)[number];
|
|
15
|
+
|
|
16
|
+
const isShotTheme = (value: string): value is ShotTheme =>
|
|
17
|
+
(SHOT_THEMES as readonly string[]).includes(value);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `--theme light|dark` on a ROUTE shot, or nothing — the box's own preference and the app's own
|
|
21
|
+
* default, which is what `x shot` has always photographed. Refused by name for any other value:
|
|
22
|
+
* `no-preference` is the scraping vocabulary's clear, not a theme a reader can ask for.
|
|
23
|
+
*/
|
|
24
|
+
export function readThemeFlag(value: string | undefined): ShotTheme | undefined {
|
|
25
|
+
if (value === undefined) return undefined;
|
|
26
|
+
if (isShotTheme(value)) return value;
|
|
27
|
+
throw new BadFlagError({
|
|
28
|
+
flag: 'theme',
|
|
29
|
+
command: 'shot',
|
|
30
|
+
reason: `"${value}" is not a theme; it is light or dark`,
|
|
31
|
+
fix: 'x shot / --theme light --json',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The seeding expression, DETERMINISTIC per scheme: the offline drivers key recordings on the exact
|
|
37
|
+
* string, and a test asserts on it by value. `'no-preference'` answers `undefined` — the boot
|
|
38
|
+
* honours only `"light"` and `"dark"` from storage, so there is no choice to store, and storing
|
|
39
|
+
* anything else would be a value the tokens have no block for.
|
|
40
|
+
*
|
|
41
|
+
* `JSON.stringify` on both halves, never a value pasted between quotes: the key and the scheme land
|
|
42
|
+
* inside a JS string, where one `"` ends it. The `try` is for the origin the page starts on:
|
|
43
|
+
* `about:blank` is opaque and its `localStorage` throws, and a throw from a new-document script
|
|
44
|
+
* would surface as a page error on a capture that has not navigated yet.
|
|
45
|
+
*/
|
|
46
|
+
export function themeChoiceExpression(scheme: ColorScheme): string | undefined {
|
|
47
|
+
if (scheme === 'no-preference') return undefined;
|
|
48
|
+
return (
|
|
49
|
+
`try{localStorage.setItem(${JSON.stringify(THEME_STORAGE_KEY)},${JSON.stringify(scheme)})}` +
|
|
50
|
+
'catch(e){}'
|
|
51
|
+
);
|
|
52
|
+
}
|
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.
|