@ultimat3/cli 5.0.0 → 6.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.
@@ -0,0 +1,229 @@
1
+ // From a generator's file list to the disk: one entry per path, catalogs merged rather than
2
+ // clobbered, and nothing written at all if any file would conflict. Split from `cmd-generate.ts`,
3
+ // which decides WHICH files a generator emits — this file decides what happens to them, and `x new`
4
+ // and the scaffold fixture assemble their own lists and land them through exactly these rules.
5
+
6
+ // `resolve`/`sep` and not `join`: only resolving the assembled path can prove it stayed inside the
7
+ // app root, and `node:path` is the only API that resolves one. `node:fs` for the exists check.
8
+ import { existsSync } from 'node:fs';
9
+ import { resolve, sep } from 'node:path';
10
+ import { GenerateJsonInvalidError, ScaffoldPathEscapeError } from './errors';
11
+ import { mergeJsonDeep } from './json-merge';
12
+ import type { Finding } from './output';
13
+ import type { GeneratedFile } from './templates';
14
+
15
+ /** `undefined` when `text` does not parse as a JSON object — the one shape every catalog, whether
16
+ * generated or hand-edited on disk, must hold. */
17
+ function parseJsonObject(text: string): Record<string, unknown> | undefined {
18
+ let parsed: unknown;
19
+ try {
20
+ parsed = JSON.parse(text);
21
+ } catch {
22
+ return undefined;
23
+ }
24
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
25
+ ? (parsed as Record<string, unknown>)
26
+ : undefined;
27
+ }
28
+
29
+ /** Deterministic catalog bytes: sorted keys, 2-space indent, trailing newline — a diff shows only
30
+ * the keys a run actually changed, never a reordering. */
31
+ function prettyJson(value: Record<string, unknown>): string {
32
+ const sorted = Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
33
+ return `${JSON.stringify(sorted, null, 2)}\n`;
34
+ }
35
+
36
+ /**
37
+ * Two generators can legitimately produce the same shared file. A plain file (errors.ts) keeps
38
+ * first-write-wins; a `merge: 'json'` catalog instead merges every contributor's keys into one
39
+ * file — `resourceFiles` and `routeFiles` both target the same locale's catalog, and a plain
40
+ * overwrite would drop whichever generator ran first. Later entries fill keys the earlier one
41
+ * lacks; the first occurrence wins a clash, the same rule `writeFiles` applies against the copy
42
+ * already on disk. Exported so `x new` (`cmd-new.ts`) and the scaffold fixture resolve a shared
43
+ * catalog the identical way — one merge rule, not three hand-copied ones.
44
+ *
45
+ * A `merge: 'json'` file's `contents` are the generator's own output, not user data — one that
46
+ * fails to parse as a JSON object is a bug in the template that produced it, so it throws here
47
+ * rather than being silently treated as `{}` and merged into (or written as) a catalog with
48
+ * attribution to nobody. `writeFiles`/`mergeJsonFile` never see a malformed *generated* payload in
49
+ * practice: every production caller (`generate()` below, `cmd-new.ts`'s `planNewApp()`, the
50
+ * scaffold fixture) runs its file list through this function first.
51
+ */
52
+ export function dedupe(files: readonly GeneratedFile[]): readonly GeneratedFile[] {
53
+ const seen = new Map<string, GeneratedFile>();
54
+ for (const file of files) {
55
+ if (file.merge === 'json' && parseJsonObject(file.contents) === undefined) {
56
+ throw new GenerateJsonInvalidError({ path: file.path });
57
+ }
58
+ const prior = seen.get(file.path);
59
+ if (prior === undefined) {
60
+ seen.set(file.path, file);
61
+ } else if (prior.merge === 'json' && file.merge === 'json') {
62
+ // Both sides already proved parseable above — the fallback only guards a future change to
63
+ // that invariant, it never fires today. Deep: two generators contributing to one nested
64
+ // catalog share top-level keys (`app`, `admin`), and a shallow spread drops one of them.
65
+ const later = parseJsonObject(file.contents) ?? {};
66
+ const earlier = parseJsonObject(prior.contents) ?? {};
67
+ const { merged } = mergeJsonDeep(earlier, later);
68
+ seen.set(file.path, { ...prior, contents: prettyJson(merged) });
69
+ }
70
+ // else: not mergeable — first write wins, exactly as it always has.
71
+ }
72
+ return [...seen.values()];
73
+ }
74
+
75
+ export interface WriteReport {
76
+ readonly written: readonly string[];
77
+ readonly conflicts: readonly Finding[];
78
+ }
79
+
80
+ /**
81
+ * `GeneratedFile.path` is documented as relative-POSIX, not enforced as it: `join` would happily
82
+ * walk out of the app on a `..` segment or ignore the root entirely on an absolute path. Proven
83
+ * before the write, once per file, because after the write there is nothing left to prove.
84
+ */
85
+ export function containedPath(root: string, path: string): string {
86
+ const base = resolve(root);
87
+ const target = resolve(base, path);
88
+ if (target !== base && !target.startsWith(`${base}${sep}`))
89
+ // The default `fix` names the scaffold gate's own test, which repairs nothing for someone
90
+ // running `x g`: the fix here is the generate command, re-run as a dry run.
91
+ throw new ScaffoldPathEscapeError({
92
+ path,
93
+ dir: base,
94
+ // Command first, the caveat behind a `#`: the line runs verbatim and the shell drops the
95
+ // rest. `x g <kind> <name>` pasted into bash is a redirect, not a command.
96
+ fix: `x g resource posts --dry-run # name every file relative to the app root, no ".." segment`,
97
+ });
98
+ return target;
99
+ }
100
+
101
+ /**
102
+ * A `merge: 'json'` catalog is never a conflict on existence and never subject to `--force`: an
103
+ * existing key on disk always wins, because it may hold a human translation, and only genuinely
104
+ * new keys are added — so a second, third… generator run keeps growing the same file instead of
105
+ * fighting over it. A file that exists but does not parse as a JSON object cannot be merged into
106
+ * without risking silent data loss, so that alone is reported rather than clobbered or thrown past.
107
+ *
108
+ * Typed to the `merge: 'json'` variant alone, not the general `GeneratedFile` union: a
109
+ * byte-carrying file has no `contents: string` to merge, and this is what stops one from ever
110
+ * reaching `parseJsonObject` even if a future caller forgets the `file.merge === 'json'` guard
111
+ * its one call site already applies.
112
+ */
113
+ async function planJsonMerge(
114
+ file: Extract<GeneratedFile, { merge: 'json' }>,
115
+ absolute: string,
116
+ ): Promise<WritePlan> {
117
+ const generated = parseJsonObject(file.contents) ?? {};
118
+ if (!existsSync(absolute))
119
+ return { kind: 'write', file, absolute, contents: prettyJson(generated) };
120
+ const existing = parseJsonObject(await Bun.file(absolute).text());
121
+ if (existing === undefined) {
122
+ return {
123
+ kind: 'conflict',
124
+ finding: {
125
+ code: 'X_GENERATE_CONFLICT',
126
+ cause: `${file.path} exists but is not a JSON object, so its keys cannot be merged`,
127
+ fix: `edit ${file.path} by hand until it parses as a JSON object, or delete it and re-run x g`,
128
+ docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
129
+ at: file.path,
130
+ },
131
+ };
132
+ }
133
+ // An existing key wins because it may hold a human translation; only the new keys are added.
134
+ // Deep, so a nested catalog gains `site.blog.title` without losing the rest of `site`.
135
+ const { merged, gained } = mergeJsonDeep(existing, generated);
136
+ // Every key the generator wants is already there — leave the file untouched and unclaimed.
137
+ if (!gained) return { kind: 'skip' };
138
+ return { kind: 'write', file, absolute, contents: prettyJson(merged) };
139
+ }
140
+
141
+ /**
142
+ * What one generated file would do, decided without doing it. The merge case computes its own
143
+ * bytes here rather than at the write, so the two passes below cannot disagree about a file.
144
+ */
145
+ type WritePlan =
146
+ | {
147
+ readonly kind: 'write';
148
+ readonly file: GeneratedFile;
149
+ readonly absolute: string;
150
+ readonly contents: string | Uint8Array;
151
+ }
152
+ | { readonly kind: 'skip' }
153
+ | { readonly kind: 'conflict'; readonly finding: Finding };
154
+
155
+ function planFile(
156
+ file: GeneratedFile,
157
+ absolute: string,
158
+ force: boolean,
159
+ invocation: string,
160
+ ): WritePlan {
161
+ // A foundation file belongs to the slice, not to the generator that needs it: several generators
162
+ // emit the same `repo.ts`, so an existing one is the author's — never a conflict, and never
163
+ // overwritten, `--force` included. `--force` is about the primitive the author named; clobbering
164
+ // `policy.ts` to regenerate one action would delete every rule they wrote. Regenerating a slice
165
+ // module is `x g entity|policy`.
166
+ if (file.merge === 'if-absent') {
167
+ return existsSync(absolute)
168
+ ? { kind: 'skip' }
169
+ : { kind: 'write', file, absolute, contents: file.contents };
170
+ }
171
+ if (!force && existsSync(absolute)) {
172
+ return {
173
+ kind: 'conflict',
174
+ finding: {
175
+ code: 'X_GENERATE_CONFLICT',
176
+ cause: `${file.path} already exists`,
177
+ // The caller's own invocation, not `x g <kind>`: `x g --force` is X_CLI_UNKNOWN_COMMAND
178
+ // when run, and a `fix:` is copied and pasted verbatim. Same construction as
179
+ // `generate-kinds.ts`'s `assertSurfaceSupported`.
180
+ fix: `${invocation} --force # overwrites ${file.path}, or pass a different name`,
181
+ docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
182
+ at: file.path,
183
+ },
184
+ };
185
+ }
186
+ return { kind: 'write', file, absolute, contents: file.contents };
187
+ }
188
+
189
+ /**
190
+ * Never clobbers, and never half-writes. A generator that overwrites is a generator nobody runs
191
+ * twice; a generator that lands four of seven files and then reports a conflict is worse, because
192
+ * the next run conflicts on the files the failed one wrote.
193
+ *
194
+ * Two passes, and the split is the point: the first decides — containment, existence, whether a
195
+ * catalog can be merged into — and touches nothing, the second writes only when the first found
196
+ * no conflict at all. Containment was already proven up front and the rest was not.
197
+ */
198
+ export async function writeFiles(
199
+ root: string,
200
+ files: readonly GeneratedFile[],
201
+ force: boolean,
202
+ /**
203
+ * The command line that produced these files, so a conflict's `fix:` can hand it back with
204
+ * `--force` on the end. Optional for a caller assembling files itself; the fallback is the
205
+ * shape, not a runnable line, and every generator path supplies the real one.
206
+ */
207
+ invocation = 'x g <kind> <name>',
208
+ ): Promise<WriteReport> {
209
+ const plans: WritePlan[] = [];
210
+ for (const file of files) {
211
+ const absolute = containedPath(root, file.path);
212
+ plans.push(
213
+ file.merge === 'json'
214
+ ? await planJsonMerge(file, absolute)
215
+ : planFile(file, absolute, force, invocation),
216
+ );
217
+ }
218
+ const conflicts = plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
219
+ if (conflicts.length > 0) return { written: [], conflicts };
220
+
221
+ const written: string[] = [];
222
+ for (const plan of plans) {
223
+ if (plan.kind !== 'write') continue;
224
+ // Bun.write creates missing parent directories, so a generator never needs an mkdir step.
225
+ await Bun.write(plan.absolute, plan.contents);
226
+ written.push(plan.file.path);
227
+ }
228
+ return { written, conflicts: [] };
229
+ }
package/src/i18n-audit.ts CHANGED
@@ -79,6 +79,11 @@ export async function loadCatalogs(root: string): Promise<Readonly<Record<Locale
79
79
  export interface AuditFacts {
80
80
  readonly report: ExtractReport;
81
81
  readonly catalogs: Readonly<Record<Locale, Catalog>>;
82
+ /** The raw scan, carried so the runtime half can re-audit against the REGISTRY rather than
83
+ * against the files — same keys, same plural rules, a different question. */
84
+ readonly extraction: Extraction;
85
+ /** The `prefix*` patterns derived from dynamic calls, carried for the same reason. */
86
+ readonly ignoreUnused: readonly string[];
82
87
  }
83
88
 
84
89
  /** The static head of a template literal, up to its first interpolation. */
@@ -105,7 +110,12 @@ export function runtimeKeyPatterns(extraction: Extraction): readonly string[] {
105
110
  export async function auditApp(root: string): Promise<AuditFacts> {
106
111
  const [extraction, catalogs] = await Promise.all([scanSource(root), loadCatalogs(root)]);
107
112
  const ignoreUnused = runtimeKeyPatterns(extraction);
108
- return { report: auditCatalogs({ extraction, catalogs, ignoreUnused }), catalogs };
113
+ return {
114
+ report: auditCatalogs({ extraction, catalogs, ignoreUnused }),
115
+ catalogs,
116
+ extraction,
117
+ ignoreUnused,
118
+ };
109
119
  }
110
120
 
111
121
  /**
@@ -130,6 +140,34 @@ export function resolveDefaultLocale(
130
140
  return locales.length === 1 ? locales[0] : undefined;
131
141
  }
132
142
 
143
+ /** Where an app's catalog module declares its own package name. */
144
+ const I18N_PACKAGE_JSON = 'packages/i18n/package.json';
145
+
146
+ /**
147
+ * The specifier a generated page imports `useT()` from — `@<app>/i18n`, read off the app's own
148
+ * manifest rather than derived from a directory name, because the package name is the only thing
149
+ * a TypeScript import can actually resolve.
150
+ *
151
+ * `undefined` when the app ships no catalog package, and every generator falls back to `t` from
152
+ * `@ultimat3/i18n` there: emitting an import that cannot resolve trades a wrong idiom for a file
153
+ * that does not compile. A manifest that will not parse, or names nothing, answers the same way —
154
+ * this is a generator convenience, and refusing to scaffold over it would be the wrong verdict.
155
+ */
156
+ export async function resolveCatalogModule(root: string): Promise<string | undefined> {
157
+ const path = join(root, I18N_PACKAGE_JSON);
158
+ if (!existsSync(path)) return undefined;
159
+ try {
160
+ const parsed: unknown = await Bun.file(path).json();
161
+ if (typeof parsed !== 'object' || parsed === null) return undefined;
162
+ const name = (parsed as { name?: unknown }).name;
163
+ return typeof name === 'string' && name.length > 0 ? name : undefined;
164
+ } catch {
165
+ // Deliberately silent: `x g route` writing a page is not the command that should refuse an
166
+ // app over a malformed manifest, and `bun install` already does.
167
+ return undefined;
168
+ }
169
+ }
170
+
133
171
  /**
134
172
  * A sorted copy of `catalog`. Every write below goes through this, so a later `sync` (or a second
135
173
  * `add`) diffs only the keys that actually changed, never a reshuffle.
@@ -0,0 +1,130 @@
1
+ // Every finding about an app's strings, and the one composition `x i18n check` and `x verify`'s
2
+ // `i18n` step both report — two callers, one answer, so the command and the gate can never
3
+ // disagree. The runtime half is here because nothing else could ask it: `i18n-audit.ts` compares
4
+ // source against files on disk and was green for every string of a shipped app whose catalog
5
+ // module nothing imported (issue #249).
6
+
7
+ import type { Catalog, Extraction, ExtractReport, Locale } from '@ultimat3/i18n';
8
+ import {
9
+ auditCatalogs,
10
+ catalogFor,
11
+ catalogMissingKeys,
12
+ catalogRegistrationGaps,
13
+ catalogsNeverRegistered,
14
+ catalogUnregistered,
15
+ registeredLocales,
16
+ } from '@ultimat3/i18n';
17
+ import { loadApp } from './app-load';
18
+ import { auditApp } from './i18n-audit';
19
+ import type { Finding } from './output';
20
+ import { findingFrom } from './output';
21
+ import { catalogPath } from './templates/locales';
22
+
23
+ /**
24
+ * What this check needs of a boot. The seam is injected so a fixture can be exactly "the app
25
+ * loaded and registered nothing" — the shipped shape of the bug — without a temp directory that
26
+ * can resolve `@ultimat3/*`. `loadApp` is the production value, and it is the same call
27
+ * `serveApp` makes: asking a different loader than the server uses would prove nothing.
28
+ */
29
+ export type AppLoader = (root: string) => Promise<{
30
+ readonly findings: readonly Finding[];
31
+ readonly defaultLocale: string;
32
+ }>;
33
+
34
+ export interface RegistrationInput {
35
+ readonly root: string;
36
+ /** The catalogs on disk, parsed — `packages/i18n/catalogs/*.json`. */
37
+ readonly catalogs: Readonly<Record<Locale, Catalog>>;
38
+ readonly extraction: Extraction;
39
+ readonly ignoreUnused: readonly string[];
40
+ readonly load?: AppLoader;
41
+ }
42
+
43
+ export interface RegistrationReport {
44
+ readonly ok: boolean;
45
+ readonly findings: readonly Finding[];
46
+ /** Keys that would render a loud miss because registration never happened. */
47
+ readonly unregistered: number;
48
+ /** How many locales are affected — one row of the summary's "across N locale(s)". */
49
+ readonly locales: number;
50
+ /** Which shipped locales the registry cannot fully answer — the `registered` column's `no`. */
51
+ readonly unregisteredLocales: readonly Locale[];
52
+ /** Every locale the registry holds after the load, sorted. Empty is not possible in a real
53
+ * boot: the framework's own catalog is the base layer, so `['en']` is the floor. */
54
+ readonly registered: readonly Locale[];
55
+ }
56
+
57
+ /**
58
+ * The app ships nothing on disk, so there is no file to diff — the only evidence left is whether
59
+ * the keys source actually uses resolve. Audited through `auditCatalogs` rather than a fresh
60
+ * `hasOwn` loop, because a plural family is defined as `n_one`/`n_other` and a bare lookup of the
61
+ * stem `n` would report every plural in the app as unresolved.
62
+ */
63
+ function unresolvedUsedKeys(input: RegistrationInput, locale: Locale): readonly string[] {
64
+ const report = auditCatalogs({
65
+ extraction: input.extraction,
66
+ catalogs: { [locale]: catalogFor(locale) },
67
+ ignoreUnused: input.ignoreUnused,
68
+ });
69
+ return report.locales[0]?.missing ?? [];
70
+ }
71
+
72
+ export async function checkRegistration(input: RegistrationInput): Promise<RegistrationReport> {
73
+ // Importing the app's modules IS the registration, in this process exactly as in the server's.
74
+ const app = await (input.load ?? loadApp)(input.root);
75
+
76
+ const gaps = catalogRegistrationGaps(input.catalogs);
77
+ const findings: Finding[] = gaps.map((gap) => ({
78
+ ...findingFrom(catalogUnregistered(gap)),
79
+ at: catalogPath(gap.locale),
80
+ }));
81
+ let unregistered = gaps.reduce((sum, gap) => sum + gap.missing.length, 0);
82
+ let locales = gaps.length;
83
+
84
+ if (Object.keys(input.catalogs).length === 0) {
85
+ const unresolved = unresolvedUsedKeys(input, app.defaultLocale);
86
+ if (unresolved.length > 0) {
87
+ findings.push(findingFrom(catalogsNeverRegistered(app.defaultLocale, unresolved)));
88
+ unregistered += unresolved.length;
89
+ locales += 1;
90
+ }
91
+ }
92
+
93
+ return {
94
+ ok: findings.length === 0,
95
+ // The load's own findings ride along ONLY when something is unregistered, and that condition is
96
+ // the whole value: a module that would not import registers nothing, so "packages/i18n/src/
97
+ // index.ts: SyntaxError" is the evidence for the gap above it. With every catalog registered, a
98
+ // broken route file is not this command's business and reporting it would be noise on a pass.
99
+ findings: findings.length === 0 ? findings : [...findings, ...app.findings],
100
+ unregistered,
101
+ locales,
102
+ unregisteredLocales: gaps.map((gap) => gap.locale),
103
+ registered: registeredLocales(),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * The file half: a key source uses that a locale's catalog does not define. Built here rather than
109
+ * in `cmd-i18n.ts` because `x verify`'s `i18n` step reports the same finding, and a second
110
+ * construction of it is two renderers of one fact waiting to drift.
111
+ */
112
+ export function missingKeyFindings(report: ExtractReport): readonly Finding[] {
113
+ return report.locales
114
+ .filter((audit) => audit.missing.length > 0)
115
+ .map((audit) => ({
116
+ ...findingFrom(catalogMissingKeys(audit.locale, audit.missing)),
117
+ at: catalogPath(audit.locale),
118
+ }));
119
+ }
120
+
121
+ /**
122
+ * Both halves of one question — does every string this app renders resolve? — for a caller that
123
+ * wants the verdict and not the table. `x verify`'s `i18n` step is that caller; `cmd-i18n.ts`
124
+ * composes the same two pieces itself because it also renders per-locale rows.
125
+ */
126
+ export async function catalogFindings(root: string): Promise<readonly Finding[]> {
127
+ const { report, catalogs, extraction, ignoreUnused } = await auditApp(root);
128
+ const registration = await checkRegistration({ root, catalogs, extraction, ignoreUnused });
129
+ return [...missingKeyFindings(report), ...registration.findings];
130
+ }
@@ -13,6 +13,7 @@ import {
13
13
  islandModuleId,
14
14
  } from '@ultimat3/render';
15
15
  import { IslandBuildFailedError } from './errors';
16
+ import { solidJsxPlugin } from './solid-loader';
16
17
 
17
18
  /**
18
19
  * Where a chunk is served from, in `x dev`, in the container and in a static export — one base
@@ -78,6 +79,12 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
78
79
  format: 'esm',
79
80
  splitting: false,
80
81
  minify: true,
82
+ // A build with no `plugins` is a build with no JSX transform: `Bun.plugin` installs into the
83
+ // RUNTIME's loader and `Bun.build` walks its own graph, so render's `.tsx` loader never sees
84
+ // an island. The app's tsconfig says `jsx: "preserve"`, which makes the bundler fall back to
85
+ // classic `React.createElement` — emitted into a browser chunk that imports no React, with
86
+ // `success: true` and no log. Every island shipped that way through five majors.
87
+ plugins: [solidJsxPlugin],
81
88
  });
82
89
  } catch (error) {
83
90
  throw new IslandBuildFailedError({ file, logs: describeBuildError(error) });
package/src/mcp-errors.ts CHANGED
@@ -91,6 +91,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
91
91
  X_RUNTIME_DRIVER_SPLIT: 'x dev --json # the boot names the driver the app installed twice',
92
92
  X_GENERATE_CONFLICT: 'x g route posts --force --json',
93
93
  X_PORT_IN_USE: 'x dev --port 3001 --json',
94
+ X_DEV_ALREADY_RUNNING:
95
+ 'x dev --json # after stopping the x dev that already owns this checkout',
94
96
  // Not `x db status`: there is no such subcommand (`x db` is gen, migrate, reset, studio, branch),
95
97
  // so the fix answered a failed step with X_CLI_UNKNOWN_COMMAND. `x doctor` is what reports
96
98
  // reachability and drift, and is already this table's answer for X_DB_STUDIO_FAILED.
package/src/messages.ts CHANGED
@@ -72,6 +72,9 @@ const CATALOG = {
72
72
  'cli.dev.mail.external': 'mail=external({driver} via {detail})',
73
73
  'cli.dev.mail.refused': 'mail=refused({detail})',
74
74
  'cli.dev.hmr': 'reloaded {file} in {ms}ms',
75
+ // A hard kill leaves the lock behind and that is normal, not a fault — worth one line so a
76
+ // reader knows why the boot paused, and never a finding.
77
+ 'cli.dev.staleLock': 'cleared a stale dev.lock — the previous x dev did not shut down cleanly',
75
78
  'cli.dev.roles': ' roles {roles}',
76
79
  'cli.dev.panels': ' panels {panels}',
77
80
  'cli.dev.introspect': ' introspect {url}',
@@ -0,0 +1,127 @@
1
+ // The JSX transform every island chunk is built with: `.tsx` → Solid's COMPILED DOM output, run by
2
+ // `babel-preset-solid` inside the island `Bun.build`'s own plugin. Solid's reactivity is a
3
+ // COMPILE-time contract, which is why no runtime factory can stand in for the compiler here.
4
+
5
+ /// <reference path="../types/babel-modules.d.ts" />
6
+ // The reference is load-bearing, not decorative: @ultimat3/cli ships SOURCE, so an APP's
7
+ // `tsc` compiles this file inside ITS program, where a `.d.ts` sitting in this directory is
8
+ // not included and its `declare module` never applies. `tsc -b` proves it in this repo.
9
+ import { transformAsync } from '@babel/core';
10
+ import { contentHash } from '@ultimat3/render';
11
+ import solidPreset from 'babel-preset-solid';
12
+ import type { BunPlugin } from 'bun';
13
+ import { IslandBuildFailedError } from './errors';
14
+
15
+ /**
16
+ * `generate: 'dom'` because an island runs in a browser and nowhere else; `hydratable: false`
17
+ * because an island MOUNTS over server markup through its own `mount(el, props)` rather than
18
+ * resuming a Solid hydration tree — there is no `renderToString` pass on the other side of it, so
19
+ * hydration markers would be per-node bytes with no reader.
20
+ */
21
+ const PRESET_OPTIONS = { generate: 'dom', hydratable: false } as const;
22
+
23
+ /**
24
+ * The parser plugins, given DIRECTLY rather than through `@babel/plugin-syntax-typescript`. That
25
+ * plugin does nothing but push this same array, and Babel 8 deleted its `isTSX` option — so the
26
+ * documented spelling silently stops parsing JSX and every `<` becomes a type parameter. Given
27
+ * here the option surface is Babel's parser, which has never moved.
28
+ *
29
+ * Order is inert here and is NOT inert via `plugins:` — as plugin entries, the JSX one must precede
30
+ * the TypeScript one or `<button` parses as a type-parameter list. One more reason to say it once,
31
+ * here.
32
+ */
33
+ const PARSER_PLUGINS = ['typescript', 'jsx'] as const;
34
+
35
+ interface CacheEntry {
36
+ /** `contentHash` of the source the code was compiled from. */
37
+ readonly hash: string;
38
+ readonly code: string;
39
+ }
40
+
41
+ /**
42
+ * Keyed by PATH, not by content hash: `x dev` re-runs `buildIslands` on every change, and a map
43
+ * keyed by content would grow one entry per keystroke for the life of the process. One entry per
44
+ * island file is bounded by the island count, which is the only quantity that should bound it.
45
+ *
46
+ * It never hits inside a single `x build` — `discoverIslands` yields unique paths and `buildOne`
47
+ * runs once each. The dev loop is the whole reason it exists: Babel is ~8.7ms a file against
48
+ * `Bun.Transpiler`'s 0.07ms, so an app with twenty islands re-compiles nineteen unchanged files on
49
+ * every rebuild without this.
50
+ */
51
+ const cache = new Map<string, CacheEntry>();
52
+
53
+ /** Test seam: the cache is process-global because the dev server it serves is too. */
54
+ export function clearIslandTransformCache(): void {
55
+ cache.clear();
56
+ }
57
+
58
+ /**
59
+ * `.tsx` source → Solid's compiled DOM expressions. Exported so the transform is testable as a
60
+ * function of its two inputs: the plugin below is the four lines of glue that hand it a file.
61
+ *
62
+ * The output is still TypeScript — Babel PARSES the annotations here and does not strip them,
63
+ * which is why the plugin declares `loader: 'ts'` and lets Bun remove them.
64
+ */
65
+ export async function transformIslandTsx(source: string, path: string): Promise<string> {
66
+ const hash = contentHash(source);
67
+ const hit = cache.get(path);
68
+ if (hit !== undefined && hit.hash === hash) return hit.code;
69
+
70
+ // Babel installs its own `prepareStackTrace` on the first TRANSFORM — not on import, which is
71
+ // where this guard was first put — and leaving it installed makes `Error.captureStackTrace`
72
+ // strict for every unrelated module loaded later in the same process.
73
+ const saved = Error.prepareStackTrace;
74
+ let code: string | null | undefined;
75
+ try {
76
+ // `transformAsync` and never `transformFileAsync`: the latter is gated behind `@babel/core`'s
77
+ // `browser` export condition and throws "Transforming files is not supported in browsers"
78
+ // under `bun --conditions=browser`, which is the condition Solid work runs in.
79
+ const result = await transformAsync(source, {
80
+ filename: path,
81
+ // The app's own Babel config is not this transform's business, and an app that happens to
82
+ // have one must not change what its islands compile to.
83
+ babelrc: false,
84
+ configFile: false,
85
+ parserOpts: { plugins: [...PARSER_PLUGINS] },
86
+ presets: [[solidPreset, PRESET_OPTIONS]],
87
+ });
88
+ code = result?.code;
89
+ } finally {
90
+ Error.prepareStackTrace = saved;
91
+ }
92
+ // A parse error is NOT caught here: Babel's own message already names the file, the line and the
93
+ // column ("a.island.tsx: Unexpected token (2:23)"), and `buildOne` wraps whatever escapes in
94
+ // `X_BUILD_FAILED` naming the island. Re-wrapping it here would only bury that.
95
+ if (code == null) {
96
+ throw new IslandBuildFailedError({
97
+ file: path,
98
+ logs: 'the Solid JSX transform emitted no code',
99
+ });
100
+ }
101
+ cache.set(path, { hash, code });
102
+ return code;
103
+ }
104
+
105
+ /**
106
+ * The plugin `island-bundle.ts` hands `Bun.build`. It carries no state of its own, so one frozen
107
+ * descriptor serves every concurrent island build — `Bun.build` calls `setup` once per build with
108
+ * that build's own builder.
109
+ *
110
+ * `.tsx`, NOT `.island.tsx`. The narrow filter looks like axiom 6 and is the opposite of it: an
111
+ * island that imports a plain `.tsx` component — the most ordinary thing an author does, and what
112
+ * `x g resource` generates — would have that component compiled by nobody, and the app's
113
+ * `jsx: "preserve"` tsconfig turns it straight back into `React.createElement("span", …)` and
114
+ * `React is not defined`. Axiom 6 is already satisfied by GRAPH SEPARATION: this plugin runs
115
+ * inside the island build, whose graph only ever holds islands and what they import, and a page
116
+ * names its island by SPECIFIER and never imports one. So `.tsx` here already means exactly the
117
+ * set that ships to a browser — the filter never had to do that work.
118
+ */
119
+ export const solidJsxPlugin: BunPlugin = {
120
+ name: 'ultimate-island-solid',
121
+ setup(build): void {
122
+ build.onLoad({ filter: /\.tsx$/ }, async ({ path }) => ({
123
+ contents: await transformIslandTsx(await Bun.file(path).text(), path),
124
+ loader: 'ts',
125
+ }));
126
+ },
127
+ };
@@ -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),