@ultimat3/cli 6.0.0 → 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 +50 -4
- package/package.json +25 -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 +29 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-pr.ts +308 -0
- package/src/cmd-shot.ts +320 -0
- package/src/cmd-test.ts +96 -7
- package/src/error-codes.ts +16 -0
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/index.ts +37 -0
- package/src/island-bundle.ts +62 -3
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/mcp-errors.ts +9 -0
- package/src/messages.ts +64 -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/static-report.ts +219 -0
- 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 +20 -41
- package/src/templates/route.ts +14 -1
- package/src/templates/scaffold-app.ts +17 -3
- package/src/templates/scaffold-db-package.ts +32 -1
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +11 -1
- package/src/workspace-graph.ts +241 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// The inventory of one `x build --target static`: which declared route reached the artifact, which
|
|
2
|
+
// did not, and WHY for each. Written by the prerenderer, read back by `x build`, so both renderers
|
|
3
|
+
// state the same thing. A skipped route with no reason is what turned "the island did not mount"
|
|
4
|
+
// into a bug report about a page that had never been in `.x/static/` at all (#242).
|
|
5
|
+
|
|
6
|
+
// `node:` twice, and only where Bun answers nothing: Bun ships no path API, and `rm(…, { force })`
|
|
7
|
+
// deletes a report that may not be there without a branch. `existsSync` was a third and is gone —
|
|
8
|
+
// `Bun.file(path).json()` already rejects on a missing file, into the catch that answers `undefined`.
|
|
9
|
+
import { rm } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import type { RenderMode } from '@ultimat3/core';
|
|
12
|
+
import { RENDER_MODES } from '@ultimat3/core';
|
|
13
|
+
import type { Surface } from '@ultimat3/render';
|
|
14
|
+
import { SURFACE_SPECS, SURFACES, surfaceAllows } from '@ultimat3/render';
|
|
15
|
+
import type { JsonValue } from './output';
|
|
16
|
+
|
|
17
|
+
/** Beside `.x/build-stats.json`, and written by the same call — see `readStaticReport` below. */
|
|
18
|
+
export const STATIC_REPORT_FILE = join('.x', 'static-report.json');
|
|
19
|
+
|
|
20
|
+
export const SKIP_REASONS = [
|
|
21
|
+
'surface-forbids-static',
|
|
22
|
+
'mode-revalidates',
|
|
23
|
+
'mode-per-request',
|
|
24
|
+
'no-prerender-paths',
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export type SkipReason = (typeof SKIP_REASONS)[number];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Type ALIASES, never interfaces — every shape below is JSON on both sides of a file, and only an
|
|
31
|
+
* alias carries the implicit index signature that makes it assignable to `JsonValue`. As
|
|
32
|
+
* interfaces, `data: { emitted, skipped }` in `cmd-build.ts` was TS2322 (`not assignable to type
|
|
33
|
+
* 'JsonValue'`) with nothing wrong in either file, and the obvious fixes — a cast, a widened
|
|
34
|
+
* annotation — are the two this repo refuses. Same rule `RouteContext` in `@ultimat3/render` is an
|
|
35
|
+
* alias for.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** The two declarations a skip decision reads. Narrower than `RouteEntry` so a test can state one. */
|
|
39
|
+
export type RouteFacts = {
|
|
40
|
+
readonly surface: Surface;
|
|
41
|
+
readonly render: RenderMode;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type SkippedRoute = RouteFacts & {
|
|
45
|
+
/** The DECLARED path, `/blog/:slug` — never a filled one, so the author can grep for it. */
|
|
46
|
+
readonly route: string;
|
|
47
|
+
readonly reason: SkipReason;
|
|
48
|
+
readonly why: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** One HTML file in the artifact, and the declared route that produced it. */
|
|
52
|
+
export type EmittedPage = {
|
|
53
|
+
readonly route: string;
|
|
54
|
+
readonly path: string;
|
|
55
|
+
/** Relative to the build output root, POSIX — the file a screenshot tool actually opens. */
|
|
56
|
+
readonly file: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type StaticReport = {
|
|
60
|
+
readonly target: 'static';
|
|
61
|
+
readonly out: string;
|
|
62
|
+
readonly buildId: string;
|
|
63
|
+
readonly emitted: readonly EmittedPage[];
|
|
64
|
+
readonly skipped: readonly SkippedRoute[];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The ONE decision about what lands on a CDN — `isPrerenderable` is derived from it, so the answer
|
|
69
|
+
* and the reason can never disagree.
|
|
70
|
+
*
|
|
71
|
+
* Surface is asked FIRST, and that ordering is the whole finding. `app/` allows `stream | ssr` and
|
|
72
|
+
* nothing else, so no `render:` edit can put an app/ route into the artifact: the surface is the
|
|
73
|
+
* cause, and naming the mode would send an author to change the one thing that cannot help. On a
|
|
74
|
+
* surface that DOES allow `static`, the mode is the cause and the edit is real — which is why the
|
|
75
|
+
* two produce different sentences below rather than one flat "not prerendered".
|
|
76
|
+
*/
|
|
77
|
+
export function skipReasonFor(route: RouteFacts): SkipReason | null {
|
|
78
|
+
if (!surfaceAllows(route.surface, 'static')) return 'surface-forbids-static';
|
|
79
|
+
if (route.render === 'static') return null;
|
|
80
|
+
return route.render === 'isr' ? 'mode-revalidates' : 'mode-per-request';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `Object.freeze<Record<…>>`, never `const X: Readonly<Record<…>> = Object.freeze({…})`: the second
|
|
85
|
+
* form infers from the literal and accepts an EXTRA key in silence (`bun run frozen-records`).
|
|
86
|
+
*
|
|
87
|
+
* English rather than `t()`, for the reason every `cause:`/`fix:` in this repo is English: this is
|
|
88
|
+
* a diagnostic an agent pastes into a report, and `wiki/Error-Codes.md` is the same surface.
|
|
89
|
+
*/
|
|
90
|
+
const WHY = Object.freeze<Record<SkipReason, (route: RouteFacts) => string>>({
|
|
91
|
+
'surface-forbids-static': (route) =>
|
|
92
|
+
`${route.surface}/ surface — server-rendered, not prerendered; ` +
|
|
93
|
+
`${route.surface}/ allows ${SURFACE_SPECS[route.surface].allowedModes.join(' | ') || 'no render mode'}, ` +
|
|
94
|
+
'so no route on it can ever reach the artifact',
|
|
95
|
+
'mode-revalidates': (route) =>
|
|
96
|
+
`render: '${route.render}' regenerates on a tag or ttl and a published file cannot, ` +
|
|
97
|
+
"so it is served by the app — change it to render: 'static' to emit it",
|
|
98
|
+
'mode-per-request': (route) =>
|
|
99
|
+
`render: '${route.render}' is rendered per request, so it is served by the app — ` +
|
|
100
|
+
"change it to render: 'static' to emit it",
|
|
101
|
+
'no-prerender-paths': (route) =>
|
|
102
|
+
`render: '${route.render}' with dynamic params and no prerender() paths, so the build ` +
|
|
103
|
+
'enumerated nothing to write — add prerender() to the route',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/** The reason as a row: the code a machine keys off, and the sentence a human reads. */
|
|
107
|
+
export function skippedRoute(
|
|
108
|
+
input: RouteFacts & { readonly route: string },
|
|
109
|
+
reason: SkipReason,
|
|
110
|
+
): SkippedRoute {
|
|
111
|
+
return {
|
|
112
|
+
route: input.route,
|
|
113
|
+
surface: input.surface,
|
|
114
|
+
render: input.render,
|
|
115
|
+
reason,
|
|
116
|
+
why: WHY[reason](input),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
121
|
+
typeof value === 'object' && value !== null;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A closed vocabulary, checked against the vocabulary itself. `typeof value === 'string'` is what
|
|
125
|
+
* `surface` and `render` were held to, and a string is not a `Surface`: the parse then handed back
|
|
126
|
+
* a `SkippedRoute` whose declared type says `'site' | 'app' | 'api' | 'shared'` while it holds
|
|
127
|
+
* whatever the file said, so `SURFACE_SPECS[route.surface]` on the read side is `undefined` at the
|
|
128
|
+
* first reader that indexes it. `SKIP_REASONS` was already checked this way; these two are the same
|
|
129
|
+
* kind of field and get the same rule.
|
|
130
|
+
*/
|
|
131
|
+
const inDomain = (domain: readonly string[], value: unknown): boolean =>
|
|
132
|
+
typeof value === 'string' && domain.includes(value);
|
|
133
|
+
|
|
134
|
+
const isSkipped = (value: unknown): value is SkippedRoute =>
|
|
135
|
+
isRecord(value) &&
|
|
136
|
+
typeof value['route'] === 'string' &&
|
|
137
|
+
inDomain(SURFACES, value['surface']) &&
|
|
138
|
+
inDomain(RENDER_MODES, value['render']) &&
|
|
139
|
+
typeof value['why'] === 'string' &&
|
|
140
|
+
inDomain(SKIP_REASONS, value['reason']);
|
|
141
|
+
|
|
142
|
+
const isEmitted = (value: unknown): value is EmittedPage =>
|
|
143
|
+
isRecord(value) &&
|
|
144
|
+
typeof value['route'] === 'string' &&
|
|
145
|
+
typeof value['path'] === 'string' &&
|
|
146
|
+
typeof value['file'] === 'string';
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Parsed, never cast. The file is this build's own output, but `x build` reads it back off disk
|
|
150
|
+
* after a subprocess — a truncated write or a hand-edit must read as "no report", which the caller
|
|
151
|
+
* already handles, rather than as a report with a `skipped` nobody can render.
|
|
152
|
+
*/
|
|
153
|
+
export function parseStaticReport(value: unknown): StaticReport | undefined {
|
|
154
|
+
if (!isRecord(value)) return undefined;
|
|
155
|
+
const { target, out, buildId, emitted, skipped } = value;
|
|
156
|
+
if (target !== 'static' || typeof out !== 'string' || typeof buildId !== 'string') {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
if (!Array.isArray(emitted) || !emitted.every(isEmitted)) return undefined;
|
|
160
|
+
if (!Array.isArray(skipped) || !skipped.every(isSkipped)) return undefined;
|
|
161
|
+
return { target, out, buildId, emitted, skipped };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function writeStaticReport(root: string, report: StaticReport): Promise<string> {
|
|
165
|
+
const path = join(root, STATIC_REPORT_FILE);
|
|
166
|
+
await Bun.write(path, `${JSON.stringify(report, null, 2)}\n`);
|
|
167
|
+
return path;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* `undefined` means no report — an app whose `apps/web/prerender.ts` does not call `prerenderSite`.
|
|
172
|
+
* That app also writes no `.x/build-stats.json` (one call writes both), so `x verify`'s `budgets`
|
|
173
|
+
* step already reds it with `X_BUDGET_UNMEASURED`; this side stays quiet rather than adding a
|
|
174
|
+
* second code for one cause.
|
|
175
|
+
*/
|
|
176
|
+
export async function readStaticReport(root: string): Promise<StaticReport | undefined> {
|
|
177
|
+
try {
|
|
178
|
+
return parseStaticReport(await Bun.file(join(root, STATIC_REPORT_FILE)).json());
|
|
179
|
+
} catch {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Deleted before the builder is spawned, so a failed build can never report the last one's list. */
|
|
185
|
+
export async function removeStaticReport(root: string): Promise<void> {
|
|
186
|
+
await rm(join(root, STATIC_REPORT_FILE), { force: true });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The `--json` half, beside `renderStaticReport`'s human one — and a `Record<string, JsonValue>`
|
|
191
|
+
* rather than a `JsonValue`, because `x build` SPREADS it into `data` beside `target` and
|
|
192
|
+
* `artifact`: a bare `JsonValue` admits a string, so the spread is TS2698 (`spread types may only
|
|
193
|
+
* be created from object types`) and no annotation at the call site can rescue it.
|
|
194
|
+
*
|
|
195
|
+
* A field the compiler cannot prove is JSON reds HERE, at the projection, rather than at whichever
|
|
196
|
+
* caller happens to hand the report to a renderer.
|
|
197
|
+
*/
|
|
198
|
+
export function staticReportData(report: StaticReport | undefined): Record<string, JsonValue> {
|
|
199
|
+
// Never `{ ...report }`: `out` is an absolute build path and `buildId` is this run's, neither of
|
|
200
|
+
// which the inventory is about — `data` already carries `artifact` and the build's own id.
|
|
201
|
+
return report === undefined ? {} : { emitted: report.emitted, skipped: report.skipped };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The human half. Fixed-width columns headed by the JSON's own field names, exactly as
|
|
206
|
+
* `renderRouteTable` is — the renderer invents no prose, so `--json` and the terminal cannot drift.
|
|
207
|
+
*/
|
|
208
|
+
export function renderStaticReport(report: StaticReport): readonly string[] {
|
|
209
|
+
const rows: readonly (readonly string[])[] = [
|
|
210
|
+
...report.emitted.map((page) => ['emitted', page.route, page.file]),
|
|
211
|
+
...report.skipped.map((route) => ['skipped', route.route, route.why]),
|
|
212
|
+
];
|
|
213
|
+
const widths = [0, 1].map((index) =>
|
|
214
|
+
Math.max(...rows.map((row) => (row[index] ?? '').length), 0),
|
|
215
|
+
);
|
|
216
|
+
return rows.map((row) =>
|
|
217
|
+
` ${row.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(' ')}`.trimEnd(),
|
|
218
|
+
);
|
|
219
|
+
}
|
package/src/templates/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ export type { QueryOptions } from './query';
|
|
|
30
30
|
export { queryFiles } from './query';
|
|
31
31
|
export type { ResourceOptions } from './resource';
|
|
32
32
|
export { resourceFiles } from './resource';
|
|
33
|
+
export { formIslandFiles } from './resource-form-island';
|
|
33
34
|
export type { RouteOptions, Surface } from './route';
|
|
34
35
|
export { routeFiles } from './route';
|
|
35
36
|
export { appFiles } from './scaffold-app';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// TEST-ONLY. An app root on disk holding emitted files, with the packages an island imports
|
|
2
|
+
// resolvable BY SPECIFIER the way a real app resolves them — so a template that emits
|
|
3
|
+
// `import { Button } from '@ultimat3/ui'` is proven by a build that actually resolves it, and not
|
|
4
|
+
// by a string assertion that the import is present.
|
|
5
|
+
|
|
6
|
+
// `node:` by necessity, and SYNC by necessity: `[Symbol.dispose]` cannot await, so the teardown
|
|
7
|
+
// half has to be synchronous — and Bun ships neither a path API nor a `symlink`.
|
|
8
|
+
import { mkdirSync, rmSync, symlinkSync } from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import type { GeneratedFile } from './naming';
|
|
11
|
+
|
|
12
|
+
/** `packages/cli/src/templates` → the repo root, four hops up. */
|
|
13
|
+
const REPO_ROOT = join(import.meta.dir, '..', '..', '..', '..');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* INSIDE the checkout, and measured rather than chosen: the identical fixture under `os.tmpdir()`
|
|
17
|
+
* fails every `@ultimat3/*` and every relative import inside it with `Could not resolve`, because
|
|
18
|
+
* `Bun.build`'s resolver is scoped to the project `bun test` was started in and an app root outside
|
|
19
|
+
* it cannot reach its own `node_modules`. `.prerender-fixture` is the same shape for the same
|
|
20
|
+
* reason. The leading dot keeps it out of every `tsc` wildcard include.
|
|
21
|
+
*/
|
|
22
|
+
const FIXTURE_ROOT = join(REPO_ROOT, 'packages', 'cli', '.island-fixture');
|
|
23
|
+
|
|
24
|
+
/** The package the fixture lives inside. Linking it would aim a symlink at its own ancestor. */
|
|
25
|
+
const SELF = 'cli';
|
|
26
|
+
|
|
27
|
+
export interface FixtureApp extends Disposable {
|
|
28
|
+
/** Absolute path of the app root — what `buildIslands` globs from. */
|
|
29
|
+
readonly path: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Symlinks rather than a `bun install`: the emitted island must resolve THIS working copy of
|
|
34
|
+
* `@ultimat3/ui`, and an install in a fixture directory would fetch the registry's last release
|
|
35
|
+
* and quietly prove nothing about the change under test. Every workspace is linked, not a chosen
|
|
36
|
+
* few — a template that grows an import should build, not fail on a list nobody updated.
|
|
37
|
+
*/
|
|
38
|
+
function linkDependencies(root: string): void {
|
|
39
|
+
const scope = join(root, 'node_modules', '@ultimat3');
|
|
40
|
+
mkdirSync(scope, { recursive: true });
|
|
41
|
+
const packages = join(REPO_ROOT, 'packages');
|
|
42
|
+
for (const entry of new Bun.Glob('*/package.json').scanSync({ cwd: packages })) {
|
|
43
|
+
const name = entry.slice(0, entry.indexOf('/'));
|
|
44
|
+
if (name === SELF) continue;
|
|
45
|
+
symlinkSync(join(packages, name), join(scope, name), 'dir');
|
|
46
|
+
}
|
|
47
|
+
// Resolved, never spelled as a path: the installer's layout is its own business and a hardcoded
|
|
48
|
+
// `node_modules/solid-js` is a fixture that breaks on a linker change rather than on a real one.
|
|
49
|
+
symlinkSync(
|
|
50
|
+
dirname(Bun.resolveSync('solid-js/package.json', REPO_ROOT)),
|
|
51
|
+
join(root, 'node_modules', 'solid-js'),
|
|
52
|
+
'dir',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `Disposable`, so the idiom is `using root = await fixtureAppRoot(label, files)`. `label` is the
|
|
58
|
+
* caller's, and is what keeps two test FILES off one directory: the path is fixed rather than
|
|
59
|
+
* random, because a random one cannot be named in `.gitignore` and a crashed run leaves it behind.
|
|
60
|
+
*/
|
|
61
|
+
export async function fixtureAppRoot(
|
|
62
|
+
label: string,
|
|
63
|
+
files: readonly GeneratedFile[],
|
|
64
|
+
): Promise<FixtureApp> {
|
|
65
|
+
const path = join(FIXTURE_ROOT, label);
|
|
66
|
+
rmSync(path, { recursive: true, force: true });
|
|
67
|
+
mkdirSync(path, { recursive: true });
|
|
68
|
+
linkDependencies(path);
|
|
69
|
+
for (const file of files) await Bun.write(join(path, file.path), String(file.contents));
|
|
70
|
+
return {
|
|
71
|
+
path,
|
|
72
|
+
[Symbol.dispose]: (): void => {
|
|
73
|
+
rmSync(path, { recursive: true, force: true });
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
package/src/templates/island.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// `x g island <name>` — the one file on a route that ships JavaScript. Not a ninth primitive and
|
|
2
2
|
// not a component generator: an island is a client ENTRY POINT, so what the scaffold has to get
|
|
3
|
-
// right is the filename (the bundler discovers by it)
|
|
4
|
-
//
|
|
3
|
+
// right is the filename (the bundler discovers by it), the `mount` export (the hydration runtime
|
|
4
|
+
// calls it by name) and what `mount` DOES — Solid's `render`, the one client shape the island build
|
|
5
|
+
// compiles. All three are pinned by the emitted test, which builds the chunk and mounts it.
|
|
5
6
|
|
|
6
7
|
import type { GeneratedFile } from './naming';
|
|
7
8
|
import { kebab, pascal } from './naming';
|
|
@@ -11,6 +12,18 @@ export interface IslandOptions {
|
|
|
11
12
|
readonly dir: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* `join(import.meta.dir, '..', …)` back to the app root, one hop per directory segment. The
|
|
17
|
+
* emitted test names the island app-root-relative because that is how `discoverIslands` reports it,
|
|
18
|
+
* so the two spellings have to agree or `mountIsland` reports a file it did not build.
|
|
19
|
+
*/
|
|
20
|
+
export const upToAppRoot = (dir: string): string =>
|
|
21
|
+
dir
|
|
22
|
+
.split('/')
|
|
23
|
+
.filter((part) => part.length > 0)
|
|
24
|
+
.map(() => "'..'")
|
|
25
|
+
.join(', ');
|
|
26
|
+
|
|
14
27
|
const islandSource = (name: string): string => {
|
|
15
28
|
const Name = pascal(name);
|
|
16
29
|
return `// ${Name}: the interactive half of an otherwise static page, and the only module on this
|
|
@@ -21,39 +34,136 @@ const islandSource = (name: string): string => {
|
|
|
21
34
|
// A string has no import edge, so nothing follows one into this file and the page's bundle graph
|
|
22
35
|
// stays the page's (axiom 6). WHEN it wakes is the route's \`hydrate\`, never a declaration here.
|
|
23
36
|
|
|
37
|
+
import type { JSX } from 'solid-js';
|
|
38
|
+
import { createSignal } from 'solid-js';
|
|
39
|
+
import { render } from 'solid-js/web';
|
|
40
|
+
import styles from './${name}.module.scss';
|
|
41
|
+
|
|
24
42
|
/** What the server sends. Declared here AND in the page's \`island({ props })\` — both, or neither. */
|
|
25
43
|
export interface ${Name}Props {
|
|
26
44
|
/** Already translated: this runs in the browser, where \`t()\`'s catalog is not. */
|
|
27
45
|
readonly label: string;
|
|
28
46
|
}
|
|
29
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Solid, and not hand-written DOM: reactivity is a COMPILE-time contract that \`babel-preset-solid\`
|
|
50
|
+
* fulfils inside the island build, which is what makes \`count()\` read below update that one text
|
|
51
|
+
* node and nothing around it. A component written against an eager JSX factory reads every signal
|
|
52
|
+
* once and never again — it renders, and then it is a photograph.
|
|
53
|
+
*/
|
|
54
|
+
function ${Name}(props: ${Name}Props): JSX.Element {
|
|
55
|
+
const [count, setCount] = createSignal(0);
|
|
56
|
+
return (
|
|
57
|
+
<p class={styles.panel}>
|
|
58
|
+
<button type="button" class={styles.trigger} onClick={() => setCount(count() + 1)}>
|
|
59
|
+
{props.label}
|
|
60
|
+
</button>
|
|
61
|
+
<output data-role="count">{count()}</output>
|
|
62
|
+
</p>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
30
66
|
/**
|
|
31
67
|
* The one export the hydration runtime calls — \`import(entry).then((m) => m.mount(el, props))\`.
|
|
32
|
-
* \`el\` is the wrapper the page rendered, with the server's own markup already inside it
|
|
33
|
-
*
|
|
68
|
+
* \`el\` is the wrapper the page rendered, with the server's own markup already inside it.
|
|
69
|
+
*
|
|
70
|
+
* The shell is cleared first, and that line is load-bearing: Solid's \`render\` APPENDS when the
|
|
71
|
+
* container already has children, so without it the server's markup stays on screen above a
|
|
72
|
+
* second, live copy of the same thing.
|
|
34
73
|
*/
|
|
35
74
|
export function mount(el: HTMLElement, props: ${Name}Props): void {
|
|
36
|
-
el.textContent =
|
|
37
|
-
|
|
38
|
-
// \`dataset.open\`, not \`dataset['open']\`: the bracket form is lint/complexity/useLiteralKeys,
|
|
39
|
-
// which the app's own \`biome check\` fails on — twice, in the one file every island copies.
|
|
40
|
-
el.dataset.open = el.dataset.open === 'true' ? 'false' : 'true';
|
|
41
|
-
});
|
|
75
|
+
el.textContent = '';
|
|
76
|
+
render(() => <${Name} {...props} />, el);
|
|
42
77
|
}
|
|
43
78
|
`;
|
|
44
79
|
};
|
|
45
80
|
|
|
81
|
+
const islandStyle = (): string => `// Semantic tokens only — a raw hex here is a dark-theme bug and
|
|
82
|
+
// a lint failure. Scoped by the island build, with the class names the server hashed.
|
|
83
|
+
@use '@ultimat3/ui/tokens' as tokens;
|
|
84
|
+
|
|
85
|
+
.panel {
|
|
86
|
+
display: flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
gap: tokens.space(2);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.trigger {
|
|
92
|
+
padding: tokens.space(2);
|
|
93
|
+
border-radius: tokens.radius('sm');
|
|
94
|
+
background: tokens.role('surface-raised');
|
|
95
|
+
color: tokens.role('fg');
|
|
96
|
+
}
|
|
97
|
+
`;
|
|
98
|
+
|
|
46
99
|
const islandTest = (
|
|
47
100
|
name: string,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
//
|
|
101
|
+
dir: string,
|
|
102
|
+
): string => `// The island the browser actually runs. \`mountIsland\` builds this entry with the same
|
|
103
|
+
// \`buildIslands\` that \`x build\` and \`x dev\` use, imports the emitted chunk the way the hydration
|
|
104
|
+
// runtime does, and drives \`mount\` against a DOM small enough to read.
|
|
105
|
+
//
|
|
106
|
+
// Importing the module and asserting \`typeof mount === 'function'\` proves the file exists, and a
|
|
107
|
+
// file that exists is exactly what ships dead: a renamed export, a dropped handler and a signal
|
|
108
|
+
// that never reaches the DOM all pass that test and none of them survive this one.
|
|
109
|
+
|
|
110
|
+
import { join } from 'node:path';
|
|
111
|
+
import { buildIslands } from '@ultimat3/cli';
|
|
112
|
+
import {
|
|
113
|
+
afterAll,
|
|
114
|
+
beforeAll,
|
|
115
|
+
describe,
|
|
116
|
+
expect,
|
|
117
|
+
type MountedIsland,
|
|
118
|
+
mountIsland,
|
|
119
|
+
test,
|
|
120
|
+
} from '@ultimat3/testing';
|
|
51
121
|
|
|
52
|
-
|
|
53
|
-
|
|
122
|
+
const APP_ROOT = join(import.meta.dir, ${upToAppRoot(dir)});
|
|
123
|
+
const ISLAND = '${dir}/${name}.island.tsx';
|
|
124
|
+
|
|
125
|
+
let mounted: MountedIsland;
|
|
126
|
+
|
|
127
|
+
// The build is a Babel pass plus a browser bundle — seconds, not milliseconds. It lives in
|
|
128
|
+
// \`beforeAll\` with its own timeout because \`test\` takes no third argument: fixtures are resolved
|
|
129
|
+
// per case, so the slow work goes where it can be given one and every case shares the result.
|
|
130
|
+
beforeAll(async () => {
|
|
131
|
+
mounted = await mountIsland({
|
|
132
|
+
build: buildIslands,
|
|
133
|
+
root: APP_ROOT,
|
|
134
|
+
file: ISLAND,
|
|
135
|
+
props: { label: 'Open' },
|
|
136
|
+
// What the server rendered inside the island's wrapper. \`mount\` replaces it.
|
|
137
|
+
shell: '<span>Open</span>',
|
|
138
|
+
});
|
|
139
|
+
}, 60_000);
|
|
54
140
|
|
|
55
|
-
|
|
56
|
-
|
|
141
|
+
// The fake \`document\` is process-global: left installed it reaches every LATER FILE in the run.
|
|
142
|
+
//
|
|
143
|
+
// \`?.\` on a binding the type says is always set: TypeScript's definite-assignment analysis does not
|
|
144
|
+
// cross the \`beforeAll\` closure, so a setup that REJECTED leaves this undefined at run time — and
|
|
145
|
+
// bun runs \`afterAll\` regardless. Unguarded, the build failure is followed by a \`TypeError:
|
|
146
|
+
// undefined is not an object\` that says nothing, and that second line is the one a tail reads.
|
|
147
|
+
// Nothing is skipped by the guard: \`mountIsland\` restores the process itself when a mount throws.
|
|
148
|
+
afterAll(() => {
|
|
149
|
+
mounted?.[Symbol.dispose]();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('the ${name} island', () => {
|
|
153
|
+
test('mount replaces the server shell', () => {
|
|
154
|
+
expect(mounted.find('span')).toBeNull();
|
|
155
|
+
// Solid compiles to real DOM calls; a chunk that fell back to the classic React factory names
|
|
156
|
+
// a global that is not in it, and \`Bun.build\` answers \`success: true\` over that all the same.
|
|
157
|
+
expect(mounted.code).not.toMatch(/\\bReact\\b/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('a click reaches the DOM through the signal', () => {
|
|
161
|
+
expect(mounted.text('[data-role="count"]')).toBe('0');
|
|
162
|
+
// \`false\` means no handler ran — an island whose onClick never reached the DOM looks identical
|
|
163
|
+
// to a selector typo otherwise.
|
|
164
|
+
expect(mounted.fire('button', 'click')).toBe(true);
|
|
165
|
+
expect(mounted.text('[data-role="count"]')).toBe('1');
|
|
166
|
+
});
|
|
57
167
|
});
|
|
58
168
|
`;
|
|
59
169
|
|
|
@@ -62,6 +172,7 @@ export function islandFiles(rawName: string, options: IslandOptions): readonly G
|
|
|
62
172
|
const dir = options.dir.replace(/\/+$/, '');
|
|
63
173
|
return [
|
|
64
174
|
{ path: `${dir}/${name}.island.tsx`, contents: islandSource(name) },
|
|
65
|
-
{ path: `${dir}/${name}.
|
|
175
|
+
{ path: `${dir}/${name}.module.scss`, contents: islandStyle() },
|
|
176
|
+
{ path: `${dir}/${name}.island.test.ts`, contents: islandTest(name, dir) },
|
|
66
177
|
];
|
|
67
178
|
}
|