@ultimat3/cli 5.0.1 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CLAUDE.md +75 -6
  2. package/README.md +2 -2
  3. package/package.json +28 -24
  4. package/src/affected.ts +320 -0
  5. package/src/browser-launcher.ts +109 -0
  6. package/src/ci-log.ts +0 -0
  7. package/src/ci-runs.ts +179 -0
  8. package/src/cmd-affected.ts +109 -0
  9. package/src/cmd-build.ts +36 -3
  10. package/src/cmd-ci.ts +273 -0
  11. package/src/cmd-dev.ts +35 -2
  12. package/src/cmd-generate.ts +16 -348
  13. package/src/cmd-i18n.ts +32 -16
  14. package/src/cmd-pr.ts +308 -0
  15. package/src/cmd-shot.ts +320 -0
  16. package/src/cmd-test.ts +96 -7
  17. package/src/cmd-verify.ts +10 -427
  18. package/src/compile-externals.ts +34 -0
  19. package/src/dev-lock.ts +275 -0
  20. package/src/dev-render.ts +7 -17
  21. package/src/error-codes.ts +18 -0
  22. package/src/generate-files.ts +127 -0
  23. package/src/generate-write.ts +229 -0
  24. package/src/gh-target.ts +118 -0
  25. package/src/gh.ts +204 -0
  26. package/src/i18n-audit.ts +39 -1
  27. package/src/i18n-registration.ts +130 -0
  28. package/src/index.ts +37 -0
  29. package/src/island-bundle.ts +68 -2
  30. package/src/island-solid-production.ts +129 -0
  31. package/src/island-styles.ts +41 -0
  32. package/src/mcp-errors.ts +11 -0
  33. package/src/messages.ts +67 -0
  34. package/src/pr-threads.ts +291 -0
  35. package/src/prerender.ts +52 -10
  36. package/src/registry.ts +8 -0
  37. package/src/shot-verdict.ts +337 -0
  38. package/src/solid-loader.ts +127 -0
  39. package/src/static-report.ts +219 -0
  40. package/src/templates/admin-page.ts +46 -5
  41. package/src/templates/index.ts +1 -0
  42. package/src/templates/island-fixture.ts +76 -0
  43. package/src/templates/island.ts +129 -18
  44. package/src/templates/resource-form-island.ts +279 -0
  45. package/src/templates/resource.ts +52 -43
  46. package/src/templates/route.ts +45 -6
  47. package/src/templates/scaffold-app.ts +70 -19
  48. package/src/templates/scaffold-container.ts +2 -2
  49. package/src/templates/scaffold-db-package.ts +88 -39
  50. package/src/templates/scaffold-docs.ts +18 -1
  51. package/src/templates/scaffold-i18n.ts +9 -2
  52. package/src/templates/scaffold-mcp-package.ts +35 -2
  53. package/src/templates/scaffold-package-shape.ts +7 -2
  54. package/src/templates/scaffold-repo.ts +2 -2
  55. package/src/test-shards.ts +19 -3
  56. package/src/verify-checks.ts +349 -0
  57. package/src/verify-run.ts +122 -0
  58. package/src/verify-step.ts +7 -0
  59. package/src/workspace-graph.ts +241 -0
  60. package/types/babel-modules.d.ts +31 -0
@@ -23,11 +23,50 @@ export interface AdminPageOptions {
23
23
  */
24
24
  readonly dir?: string;
25
25
  readonly locales?: readonly string[];
26
+ /**
27
+ * The app's own catalog module — `@<app>/i18n`, read off `packages/i18n/package.json` by
28
+ * `resolveCatalogModule`. Absent only for an app that ships no such package.
29
+ */
30
+ readonly catalogModule?: string;
26
31
  }
27
32
 
28
33
  const titleKeyFor = (name: string): string => `admin.${name}.title`;
29
34
 
30
- const pageSource = (name: string, permission: string, dir: string): string => {
35
+ /**
36
+ * The generated page reaches strings through the APP's catalog module — the one that calls
37
+ * `defineCatalogs()` — so a page that renders a string depends on the module that registers them.
38
+ * `t` from `@ultimat3/i18n` renders while depending on nothing, which is how a shipped app served
39
+ * every string as a loud miss with a green gate (issue #249). An app with no catalog module keeps
40
+ * the framework import: emitting one that cannot resolve is worse than the wrong idiom.
41
+ */
42
+ const catalogImport = (module: string | undefined): string =>
43
+ module === undefined
44
+ ? "import { t } from '@ultimat3/i18n';"
45
+ : `import { useT } from '${module}';`;
46
+
47
+ /**
48
+ * The two imports, in the order biome's organize-imports wants — which DEPENDS on the app's scope
49
+ * and cannot be hardcoded either way. An app catalog (`@myapp/i18n`) sorts BEFORE
50
+ * `@ultimat3/admin`; the fallback `@ultimat3/i18n` sorts AFTER it. Emitting one fixed order makes
51
+ * every generated admin page a lint error in exactly one of the two cases, and each case is
52
+ * covered by a different job — the fallback by `templates`' own linter test, the app-scoped one
53
+ * only by `scaffold-smoke`, which runs the generators against a real scaffold.
54
+ */
55
+ const pageImports = (module: string | undefined): string =>
56
+ [`import type { AdminCustomPage, AdminPageProps } from '@ultimat3/admin';`, catalogImport(module)]
57
+ .sort((a, b) => (a.slice(a.indexOf("'")) < b.slice(b.indexOf("'")) ? -1 : 1))
58
+ .join('\n');
59
+
60
+ /** `useT()` is per render, so the component binds it in its own body. */
61
+ const translatorBinding = (module: string | undefined): string =>
62
+ module === undefined ? '' : '\n const t = useT();\n';
63
+
64
+ const pageSource = (
65
+ name: string,
66
+ permission: string,
67
+ dir: string,
68
+ module: string | undefined,
69
+ ): string => {
31
70
  const Name = pascal(name);
32
71
  const declaration = camel(name);
33
72
  return `// Admin page: /${name}. An ORDINARY component — there is no \`defineRoute\` here, deliberately:
@@ -39,10 +78,9 @@ const pageSource = (name: string, permission: string, dir: string): string => {
39
78
  // import { ${declaration}Page } from './${name}';
40
79
  // defineAdmin({ …, pages: […, ${declaration}Page] })
41
80
 
42
- import type { AdminCustomPage, AdminPageProps } from '@ultimat3/admin';
43
- import { t } from '@ultimat3/i18n';
81
+ ${pageImports(module)}
44
82
 
45
- export function ${Name}Page(props: AdminPageProps) {
83
+ export function ${Name}Page(props: AdminPageProps) {${translatorBinding(module)}
46
84
  return (
47
85
  <section>
48
86
  <h1>{t('${titleKeyFor(name)}')}</h1>
@@ -92,7 +130,10 @@ export function adminPageFiles(
92
130
  // Trailing slashes trimmed exactly as `islandFiles` does — one `--at`, one normalization.
93
131
  const dir = (options.dir ?? DEFAULT_ADMIN_PAGE_DIR).replace(/\/+$/, '');
94
132
  return [
95
- { path: `${dir}/${name}.tsx`, contents: pageSource(name, options.permission, dir) },
133
+ {
134
+ path: `${dir}/${name}.tsx`,
135
+ contents: pageSource(name, options.permission, dir, options.catalogModule),
136
+ },
96
137
  { path: `${dir}/${name}.test.ts`, contents: pageTest(name, options.permission) },
97
138
  ...resolveLocales(options.locales).map((locale) => ({
98
139
  path: catalogPath(locale),
@@ -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
+ }
@@ -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) and the `mount` export (the hydration
4
- // runtime calls it by name). Both are pinned by the emitted test.
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, so a
33
- * mount that replaces the markup instead of taking it over is a visible flash on every load.
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 = props.label;
37
- el.addEventListener('click', () => {
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
- ): string => `// The runtime boots an island by calling \`mount\` on whatever the module exports. A renamed or
49
- // deleted export is a page that renders, serves, passes every other gate and does nothing when
50
- // clicked — which is exactly the failure nothing else in the build can see.
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
- import { expect, unitTest } from '@ultimat3/testing';
53
- import * as entry from './${name}.island';
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
- unitTest('${name}.island exports the mount the hydration runtime calls', () => {
56
- expect(typeof entry.mount).toBe('function');
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}.island.test.ts`, contents: islandTest(name) },
175
+ { path: `${dir}/${name}.module.scss`, contents: islandStyle() },
176
+ { path: `${dir}/${name}.island.test.ts`, contents: islandTest(name, dir) },
66
177
  ];
67
178
  }
@@ -0,0 +1,279 @@
1
+ // The slice's client entry: `x g resource <name>` emits its form as an ISLAND, because that is the
2
+ // one shape the framework compiles for a browser. A plain `.tsx` with a signal and an `onSubmit`
3
+ // is not a smaller version of this — the island glob never discovers it, and a server render drops
4
+ // every `on*` prop (`packages/render/src/html.ts`) and reads each signal exactly once.
5
+
6
+ // Bun ships no path API, and the one arithmetic this file does is a specifier: the island's path
7
+ // seen from the page's directory, which `island-bundle.ts` resolves the same way at build time.
8
+ import { posix } from 'node:path';
9
+ import { upToAppRoot } from './island';
10
+ import type { GeneratedFile, NameSet } from './naming';
11
+
12
+ /**
13
+ * What the PAGE has to write, which is not what the island's own directory would suggest.
14
+ *
15
+ * `island({ src })` resolves against the route file, and `x g resource widget` writes its page at
16
+ * `apps/web/app/widgets/` while the island lands in `apps/web/app/widget/` — so the `'./…'` this
17
+ * file used to print resolved to `apps/web/app/widgets/widget-form.island.tsx`, a path the build
18
+ * never bundles, and an author following the comment got `X_ISLAND_INVALID`. Derived from the two
19
+ * directories rather than written down, so it cannot disagree with where the files actually go.
20
+ */
21
+ export const formIslandSpecifier = (feature: NameSet, dir: string, pageDir: string): string => {
22
+ const relative = posix.join(posix.relative(pageDir, dir), `${feature.kebab}-form.island.tsx`);
23
+ return relative.startsWith('.') ? relative : `./${relative}`;
24
+ };
25
+
26
+ const formIslandSource = (
27
+ feature: NameSet,
28
+ specifier: string,
29
+ ): string => `// ${feature.pascal}Form: the only module of the ${feature.kebab} slice a browser downloads.
30
+ //
31
+ // The page names this file by SPECIFIER, never by import — and the specifier is resolved against
32
+ // the PAGE, which is one directory across from this one:
33
+ // const ${feature.pascal}Form = island({
34
+ // src: '${specifier}',
35
+ // props: ['endpoint', 'locale', 'labels'],
36
+ // });
37
+ // <${feature.pascal}Form endpoint={derivePath('create${feature.pascal}').path} locale={locale} labels={labels} />
38
+ // A string has no import edge, so the page's bundle graph stays the page's (axiom 6).
39
+
40
+ import { Button, Form, Input, setSolidRuntime, UiProvider } from '@ultimat3/ui';
41
+ import type { JSX } from 'solid-js';
42
+ import * as solidRuntime from 'solid-js';
43
+ import { createSignal } from 'solid-js';
44
+ import { render } from 'solid-js/web';
45
+ import styles from './ui.module.scss';
46
+
47
+ /** Every string this module renders, and the path it posts to. An island's props cross the seam as
48
+ * JSON inside the document, so \`t()\`'s catalog cannot travel — the server translates. */
49
+ export interface ${feature.pascal}FormProps {
50
+ /** \`derivePath('create${feature.pascal}').path\`, minted on the server: one namer for the route. */
51
+ readonly endpoint: string;
52
+ /** The request's own locale. A browser has no ambient one the server ever agreed to. */
53
+ readonly locale: string;
54
+ readonly labels: {
55
+ readonly title: string;
56
+ readonly submit: string;
57
+ readonly saved: string;
58
+ readonly retry: string;
59
+ };
60
+ }
61
+
62
+ type SaveState = 'idle' | 'saved' | 'failed';
63
+
64
+ /**
65
+ * Presentation only: the action this submits to owns validation server-side, so the form never
66
+ * re-implements the invariant — a blank title fails at the boundary, not in the DOM.
67
+ *
68
+ * A plain \`fetch\` to the path the server minted, not the typed client: \`rpc()\` pulls
69
+ * \`@ultimat3/action\` into the chunk, which is larger than everything else here put together. The
70
+ * naming rule is still the framework's; only the transport is second.
71
+ */
72
+ function ${feature.pascal}FormBody(props: ${feature.pascal}FormProps): JSX.Element {
73
+ const [title, setTitle] = createSignal('');
74
+ const [state, setState] = createSignal<SaveState>('idle');
75
+
76
+ // A rejected \`fetch\` — offline, DNS, an aborted request — is the same OUTCOME as a refused one,
77
+ // and it is the one \`retry\` exists for. Without the catch, \`setState\` is never reached: the
78
+ // status line stays empty, and the rejection escapes the \`void send()\` below as an unhandled one.
79
+ const send = async (): Promise<void> => {
80
+ try {
81
+ const response = await fetch(props.endpoint, {
82
+ method: 'POST',
83
+ headers: { 'content-type': 'application/json' },
84
+ body: JSON.stringify({ title: title() }),
85
+ });
86
+ setState(response.ok ? 'saved' : 'failed');
87
+ } catch {
88
+ setState('failed');
89
+ }
90
+ };
91
+
92
+ const status = (): string => {
93
+ if (state() === 'saved') return props.labels.saved;
94
+ return state() === 'failed' ? props.labels.retry : '';
95
+ };
96
+
97
+ return (
98
+ <Form
99
+ class={styles.item}
100
+ onSubmit={(event) => {
101
+ event.preventDefault();
102
+ void send();
103
+ }}
104
+ >
105
+ <Input
106
+ aria-label={props.labels.title}
107
+ value={title()}
108
+ onInput={(event) => setTitle(event.currentTarget.value)}
109
+ />
110
+ <Button type="submit">{props.labels.submit}</Button>
111
+ <p data-role="status" role="status" aria-live="polite">
112
+ {status()}
113
+ </p>
114
+ </Form>
115
+ );
116
+ }
117
+
118
+ /**
119
+ * The one export the hydration runtime calls — \`import(entry).then((m) => m.mount(el, props))\`.
120
+ *
121
+ * \`setSolidRuntime\` comes FIRST and it is not optional: \`@ultimat3/ui\` imports *types* from
122
+ * solid-js and never a runtime, so the reactive graph a component reaches is the one an entry
123
+ * registers. Delete the line and the first \`<UiProvider>\` render throws X_UI_RUNTIME_MISSING —
124
+ * loud on purpose, because a DOM render that lost its runtime is a theme toggle that does nothing.
125
+ *
126
+ * The shell is cleared first: Solid's \`render\` APPENDS when the container already has children,
127
+ * so without it the server's markup stays on screen above a second, live copy of the same thing.
128
+ */
129
+ export function mount(el: HTMLElement, props: ${feature.pascal}FormProps): void {
130
+ setSolidRuntime(solidRuntime);
131
+ el.textContent = '';
132
+ render(
133
+ () => (
134
+ <UiProvider locale={props.locale}>
135
+ <${feature.pascal}FormBody {...props} />
136
+ </UiProvider>
137
+ ),
138
+ el,
139
+ );
140
+ }
141
+ `;
142
+
143
+ const formIslandTest = (
144
+ feature: NameSet,
145
+ dir: string,
146
+ ): string => `// The form the browser actually runs. \`mountIsland\` builds this entry with the same
147
+ // \`buildIslands\` that \`x build\` and \`x dev\` use, imports the emitted chunk the way the hydration
148
+ // runtime does, and drives \`mount\` against a DOM small enough to read.
149
+ //
150
+ // It is the test that keeps this file a CLIENT entry. A generated form that only typechecks is
151
+ // what shipped before: server-rendered, every \`on*\` prop dropped, every signal read once.
152
+
153
+ import { join } from 'node:path';
154
+ import { buildIslands } from '@ultimat3/cli';
155
+ import {
156
+ afterAll,
157
+ beforeAll,
158
+ describe,
159
+ expect,
160
+ type FakeElement,
161
+ type MountedIsland,
162
+ mountIsland,
163
+ test,
164
+ } from '@ultimat3/testing';
165
+
166
+ const APP_ROOT = join(import.meta.dir, ${upToAppRoot(dir)});
167
+ const ISLAND = '${dir}/${feature.kebab}-form.island.tsx';
168
+ const ENDPOINT = '/api/${feature.kebab}/create-${feature.kebab}';
169
+
170
+ const LABELS = { title: 'Title', submit: 'Save', saved: 'Saved', retry: 'Try again' };
171
+
172
+ const calls: { url: string; body: Record<string, unknown> }[] = [];
173
+
174
+ /** One stub, both outcomes: the mount costs seconds, so the network's answer is a switch. */
175
+ let networkFails = false;
176
+
177
+ let mounted: MountedIsland;
178
+
179
+ // The build is a Babel pass plus a browser bundle — seconds, not milliseconds. It lives in
180
+ // \`beforeAll\` with its own timeout because \`test\` takes no third argument: fixtures are resolved
181
+ // per case, so the slow work goes where it can be given one and every case shares the result.
182
+ beforeAll(async () => {
183
+ mounted = await mountIsland({
184
+ build: buildIslands,
185
+ root: APP_ROOT,
186
+ file: ISLAND,
187
+ props: { endpoint: ENDPOINT, locale: 'en', labels: LABELS },
188
+ // What the server rendered inside the island's wrapper. \`mount\` replaces it.
189
+ shell: '<p>Loading</p>',
190
+ globals: {
191
+ fetch: (url: string, init: { body: string }): Promise<{ ok: boolean }> => {
192
+ calls.push({ url, body: JSON.parse(init.body) as Record<string, unknown> });
193
+ // What a browser rejects with when there is no network. Not a response: \`response.ok\`
194
+ // is never read on this path, which is exactly why the form has to catch it.
195
+ return networkFails
196
+ ? Promise.reject(new TypeError('Failed to fetch'))
197
+ : Promise.resolve({ ok: true });
198
+ },
199
+ },
200
+ });
201
+ }, 60_000);
202
+
203
+ // The fake \`document\` is process-global: left installed it reaches every LATER FILE in the run.
204
+ //
205
+ // \`?.\` on a binding the type says is always set: TypeScript's definite-assignment analysis does not
206
+ // cross the \`beforeAll\` closure, so a setup that REJECTED leaves this undefined at run time — and
207
+ // bun runs \`afterAll\` regardless. Unguarded, the build failure is followed by a \`TypeError:
208
+ // undefined is not an object\` that says nothing, and that second line is the one a tail reads.
209
+ // Nothing is skipped by the guard: \`mountIsland\` restores the process itself when a mount throws.
210
+ afterAll(() => {
211
+ mounted?.[Symbol.dispose]();
212
+ });
213
+
214
+ /**
215
+ * One mount, driven as a session: the cases below run in order against the same island, because
216
+ * building the real chunk costs seconds and repeating it per case would pay them for state each
217
+ * case sets up anyway. What each one asserts is independent.
218
+ */
219
+ describe('the ${feature.kebab} form island', () => {
220
+ test('mount replaces the server shell with the editor', () => {
221
+ expect(mounted.find('p')?.getAttribute('data-role')).toBe('status');
222
+ // Solid compiles to real DOM calls; a chunk that fell back to the classic React factory names
223
+ // a global that is not in it, and \`Bun.build\` answers \`success: true\` over that all the same.
224
+ expect(mounted.code).not.toMatch(/\\bReact\\b/);
225
+ });
226
+
227
+ test('the field tracks, and submit posts what was typed', async () => {
228
+ const field: FakeElement | null = mounted.find('input');
229
+ expect(field).not.toBeNull();
230
+ if (field !== null) field.value = 'First ${feature.camel}';
231
+ // \`false\` means no handler ran — an island whose onInput never reached the DOM looks
232
+ // identical to a selector typo otherwise.
233
+ expect(mounted.fire(field, 'input')).toBe(true);
234
+ expect(mounted.fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
235
+ await Promise.resolve();
236
+
237
+ expect(calls).toEqual([{ url: ENDPOINT, body: { title: 'First ${feature.camel}' } }]);
238
+ });
239
+
240
+ test('the status line answers the response', () => {
241
+ // The signal reached the DOM: an eager JSX factory renders '' here and never runs again.
242
+ expect(mounted.text('[data-role="status"]')).toBe(LABELS.saved);
243
+ });
244
+
245
+ test('a request that never got a response still reaches retry', async () => {
246
+ // The outcome \`retry\` is FOR. A \`fetch\` that rejects reaches no \`response.ok\`, so without
247
+ // the catch in \`send\` the status line stays on its last value and the rejection escapes.
248
+ networkFails = true;
249
+ expect(mounted.fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
250
+ await Promise.resolve();
251
+ await Promise.resolve();
252
+
253
+ expect(mounted.text('[data-role="status"]')).toBe(LABELS.retry);
254
+ });
255
+ });
256
+ `;
257
+
258
+ /**
259
+ * The slice's form, as the one client shape: `<dir>/<feature>-form.island.tsx` plus its test.
260
+ *
261
+ * `pageDir` is the directory of the page that declares it — the caller's, because only the caller
262
+ * runs both generators and knows where the other one put its file.
263
+ */
264
+ export function formIslandFiles(
265
+ feature: NameSet,
266
+ dir: string,
267
+ pageDir: string,
268
+ ): readonly GeneratedFile[] {
269
+ return [
270
+ {
271
+ path: `${dir}/${feature.kebab}-form.island.tsx`,
272
+ contents: formIslandSource(feature, formIslandSpecifier(feature, dir, pageDir)),
273
+ },
274
+ {
275
+ path: `${dir}/${feature.kebab}-form.island.test.ts`,
276
+ contents: formIslandTest(feature, dir),
277
+ },
278
+ ];
279
+ }