@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.
- package/CLAUDE.md +25 -2
- package/README.md +2 -2
- package/package.json +27 -24
- package/src/cmd-build.ts +7 -0
- package/src/cmd-dev.ts +35 -2
- package/src/cmd-generate.ts +16 -348
- package/src/cmd-i18n.ts +32 -16
- package/src/cmd-verify.ts +10 -427
- package/src/compile-externals.ts +34 -0
- package/src/dev-lock.ts +275 -0
- package/src/dev-render.ts +7 -17
- package/src/error-codes.ts +2 -0
- package/src/generate-files.ts +127 -0
- package/src/generate-write.ts +229 -0
- package/src/i18n-audit.ts +39 -1
- package/src/i18n-registration.ts +130 -0
- package/src/island-bundle.ts +7 -0
- package/src/mcp-errors.ts +2 -0
- package/src/messages.ts +3 -0
- package/src/solid-loader.ts +127 -0
- package/src/templates/admin-page.ts +46 -5
- package/src/templates/resource.ts +39 -9
- package/src/templates/route.ts +31 -5
- package/src/templates/scaffold-app.ts +55 -18
- package/src/templates/scaffold-container.ts +2 -2
- package/src/templates/scaffold-db-package.ts +56 -38
- package/src/templates/scaffold-docs.ts +18 -1
- package/src/templates/scaffold-i18n.ts +9 -2
- package/src/templates/scaffold-repo.ts +2 -2
- package/src/verify-checks.ts +339 -0
- package/src/verify-run.ts +122 -0
- package/src/verify-step.ts +7 -0
- package/types/babel-modules.d.ts +31 -0
|
@@ -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 {
|
|
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
|
+
}
|
package/src/island-bundle.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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),
|