@ultimat3/cli 28.0.0 → 29.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 +1 -1
- package/package.json +31 -31
- package/src/app-root.ts +1 -1
- package/src/cmd-deploy-compose.ts +10 -1
- package/src/cmd-deploy-start-first.ts +63 -19
- package/src/cmd-deploy.ts +10 -1
- package/src/cmd-i18n.ts +28 -6
- package/src/dev-route-table.ts +6 -0
- package/src/document-options.ts +10 -0
- package/src/error-codes.ts +6 -0
- package/src/i18n-locale-parity.ts +134 -0
- package/src/i18n-registration.ts +6 -0
- package/src/mcp-errors.ts +6 -0
- package/src/messages.ts +4 -0
- package/src/prerender.ts +5 -0
- package/src/runtime-jobs.ts +83 -7
- package/src/runtime-render.ts +57 -55
- package/src/serve-web.ts +5 -0
- package/src/site-config.ts +14 -4
- package/src/templates/scaffold-container.ts +3 -3
- package/src/templates/scaffold-repo.ts +2 -2
- package/src/workspace-graph.ts +56 -9
package/CLAUDE.md
CHANGED
|
@@ -98,7 +98,7 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
|
|
|
98
98
|
| `app-config-load.ts` | the ONE reader of an app's `app.config.ts`: `loadAppConfig(root)` — the `config` export through core's `defineConfig`, so validated and default-merged; `undefined` with no file; no `config` object is `X_CONFIG_INVALID`. Every boot fact (`app-auth.ts`, `theme-boot.ts`, `site-config.ts`, `pwa-artifacts.ts`, …) reads a section off it, and `startServices` loads it once. An `import(` of the config module anywhere else is `X_CONFIG_IMPORT_OUTSIDE_LOADER` (`scripts/lib/config-import.ts`) |
|
|
99
99
|
| `theme-brand.ts` | the ONE reader of an app's theme: `export const brand` in `apps/web/shared/theme.ts` (ui's `defineTheme`), inlined after the surface stylesheet with its hash in `style-src`; none → nothing emitted |
|
|
100
100
|
| `runtime-jobs.ts` / `runtime-realtime.ts` / `runtime-notify-retention.ts` / `runtime-cache.ts` / `runtime-purge.ts` / `runtime-replica.ts` | `app.config.ts` sections the boot obeys (`*Of(config)` over the one loaded config), and what each wires |
|
|
101
|
-
| `runtime-jobs.ts` (queues) |
|
|
101
|
+
| `runtime-jobs.ts` (queues) | `jobs.queues` ∪ registered queues; `WORKER_QUEUES` = the EXACT set, each entry in that union or `X_WORKER_QUEUE_UNKNOWN`; `queue-unserved` warns the rest |
|
|
102
102
|
| `serve-retire.ts` / `dev-restart-retire.ts` | the worker retire — `stop()`: claim nothing, abort nothing — on SIGUSR2 under `runRole` (armed before boot, then drain + exit 0; none on win32; the chart's `roles.worker.retireSeconds` preStop sends it), and before a supervised `x dev` restart's drain (#677) |
|
|
103
103
|
| `role-start.ts` / `role-start-types.ts` / `role-realtime.ts` | `--role` selection and start/stop for `web`, `sync`, `worker`, `scheduler`; `realtime.enabled: false` drops `sync` and `replicator` |
|
|
104
104
|
| `role-wake.ts` | the worker's LISTEN session (`@ultimat3/jobs`' `startQueueWake`) — worker role only, only over a client that `canListen` — and `queue_wake_live` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "29.0.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"LICENSE"
|
|
31
31
|
],
|
|
32
32
|
"engines": {
|
|
33
|
-
"bun": ">=1.4.
|
|
33
|
+
"bun": ">=1.4.4"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
36
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
@@ -38,34 +38,34 @@
|
|
|
38
38
|
"dev": "bun run src/bin.ts dev"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@babel/core": "^8.0.
|
|
42
|
-
"@ultimat3/action": "
|
|
43
|
-
"@ultimat3/admin": "
|
|
44
|
-
"@ultimat3/ai": "
|
|
45
|
-
"@ultimat3/auth": "
|
|
46
|
-
"@ultimat3/cache": "
|
|
47
|
-
"@ultimat3/core": "
|
|
48
|
-
"@ultimat3/db": "
|
|
49
|
-
"@ultimat3/entity": "
|
|
50
|
-
"@ultimat3/flags": "
|
|
51
|
-
"@ultimat3/http": "
|
|
52
|
-
"@ultimat3/i18n": "
|
|
53
|
-
"@ultimat3/jobs": "
|
|
54
|
-
"@ultimat3/mail": "
|
|
55
|
-
"@ultimat3/manifest": "
|
|
56
|
-
"@ultimat3/mcp": "
|
|
57
|
-
"@ultimat3/money": "
|
|
58
|
-
"@ultimat3/notify": "
|
|
59
|
-
"@ultimat3/policy": "
|
|
60
|
-
"@ultimat3/pwa": "
|
|
61
|
-
"@ultimat3/query": "
|
|
62
|
-
"@ultimat3/realtime": "
|
|
63
|
-
"@ultimat3/render": "
|
|
64
|
-
"@ultimat3/schema": "
|
|
65
|
-
"@ultimat3/seo": "
|
|
66
|
-
"@ultimat3/storage": "
|
|
67
|
-
"@ultimat3/testing": "
|
|
68
|
-
"@ultimat3/time": "
|
|
69
|
-
"babel-preset-solid": "^1.9.
|
|
41
|
+
"@babel/core": "^8.0.7",
|
|
42
|
+
"@ultimat3/action": "29.0.0",
|
|
43
|
+
"@ultimat3/admin": "29.0.0",
|
|
44
|
+
"@ultimat3/ai": "29.0.0",
|
|
45
|
+
"@ultimat3/auth": "29.0.0",
|
|
46
|
+
"@ultimat3/cache": "29.0.0",
|
|
47
|
+
"@ultimat3/core": "29.0.0",
|
|
48
|
+
"@ultimat3/db": "29.0.0",
|
|
49
|
+
"@ultimat3/entity": "29.0.0",
|
|
50
|
+
"@ultimat3/flags": "29.0.0",
|
|
51
|
+
"@ultimat3/http": "29.0.0",
|
|
52
|
+
"@ultimat3/i18n": "29.0.0",
|
|
53
|
+
"@ultimat3/jobs": "29.0.0",
|
|
54
|
+
"@ultimat3/mail": "29.0.0",
|
|
55
|
+
"@ultimat3/manifest": "29.0.0",
|
|
56
|
+
"@ultimat3/mcp": "29.0.0",
|
|
57
|
+
"@ultimat3/money": "29.0.0",
|
|
58
|
+
"@ultimat3/notify": "29.0.0",
|
|
59
|
+
"@ultimat3/policy": "29.0.0",
|
|
60
|
+
"@ultimat3/pwa": "29.0.0",
|
|
61
|
+
"@ultimat3/query": "29.0.0",
|
|
62
|
+
"@ultimat3/realtime": "29.0.0",
|
|
63
|
+
"@ultimat3/render": "29.0.0",
|
|
64
|
+
"@ultimat3/schema": "29.0.0",
|
|
65
|
+
"@ultimat3/seo": "29.0.0",
|
|
66
|
+
"@ultimat3/storage": "29.0.0",
|
|
67
|
+
"@ultimat3/testing": "29.0.0",
|
|
68
|
+
"@ultimat3/time": "29.0.0",
|
|
69
|
+
"babel-preset-solid": "^1.9.16"
|
|
70
70
|
}
|
|
71
71
|
}
|
package/src/app-root.ts
CHANGED
|
@@ -36,7 +36,7 @@ export const MANIFEST_FILE = 'x.manifest.json';
|
|
|
36
36
|
* declared supported would hang on graceful shutdown the moment its database went away. The full
|
|
37
37
|
* measurement is in `.github/actions/setup/action.yml`; read it before lowering this.
|
|
38
38
|
*/
|
|
39
|
-
export const REQUIRED_BUN = '1.4.
|
|
39
|
+
export const REQUIRED_BUN = '1.4.4';
|
|
40
40
|
|
|
41
41
|
export interface AppRoot {
|
|
42
42
|
readonly dir: string;
|
|
@@ -84,7 +84,16 @@ export function composeStrategies(text: string | undefined): ReadonlyMap<string,
|
|
|
84
84
|
}
|
|
85
85
|
const services = isRecord(parsed) ? parsed['services'] : undefined;
|
|
86
86
|
if (!isRecord(services)) return new Map();
|
|
87
|
-
|
|
87
|
+
const strategies = new Map<string, ComposeStrategy>(
|
|
88
|
+
SERVING_ROLES.map((role) => [role, strategyOf(services[role])]),
|
|
89
|
+
);
|
|
90
|
+
if (Object.hasOwn(services, 'replicator')) {
|
|
91
|
+
strategies.set('replicator', {
|
|
92
|
+
kind: 'stop-first',
|
|
93
|
+
why: 'the replicator is a singleton: the old process must stop before its replacement starts',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return strategies;
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
/** The compose file at `path`, or `undefined` when there is none to read. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// One compose role rolled start-first: the new containers come up beside the old ones, are waited
|
|
2
|
-
// on until healthy, and only then are the old ones stopped and removed
|
|
3
|
-
// replica count
|
|
2
|
+
// on until healthy, and only then are the old ones stopped and removed, converging on the declared
|
|
3
|
+
// replica count. A replica is the requested version only when compose's config hash AND the image
|
|
4
|
+
// it runs BOTH match: a tag republished under the same name leaves that hash untouched.
|
|
4
5
|
|
|
5
6
|
import type { ExecOptions, Runner } from './exec';
|
|
6
7
|
|
|
@@ -13,7 +14,13 @@ export const COMPOSE_WAIT_TIMEOUT_SECONDS = 300;
|
|
|
13
14
|
|
|
14
15
|
/** The label compose stamps on each container: the hash of the service definition it was made from. */
|
|
15
16
|
const CONFIG_HASH = 'com.docker.compose.config-hash';
|
|
16
|
-
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One `docker inspect` reads BOTH facts a replica carries: the definition it was made from (the
|
|
20
|
+
* config hash) and the build it runs (the image id). One call, one format string — two calls could
|
|
21
|
+
* describe two different moments, and the pair would be a fiction.
|
|
22
|
+
*/
|
|
23
|
+
const INSPECT_FORMAT = `{{.Id}} {{.Created}} {{index .Config.Labels "${CONFIG_HASH}"}} {{.Image}}`;
|
|
17
24
|
|
|
18
25
|
/** The `up` that starts the new replicas beside the running ones; `--no-recreate` keeps the old. */
|
|
19
26
|
export function startFirstUp(
|
|
@@ -63,42 +70,68 @@ const linesOf = (stdout: string): readonly string[] =>
|
|
|
63
70
|
const hashOf = (stdout: string): string | undefined => linesOf(stdout)[0]?.split(/\s+/)[1];
|
|
64
71
|
|
|
65
72
|
/**
|
|
66
|
-
* The running containers split by the
|
|
67
|
-
*
|
|
68
|
-
*
|
|
73
|
+
* The running containers split by the TWO facts a replica carries: `current` already runs the one
|
|
74
|
+
* being deployed (newest first), `stale` anything else. A container `inspect` did not describe is
|
|
75
|
+
* stale — stopping one too many is a restart, keeping one too many is the old build serving.
|
|
76
|
+
*
|
|
77
|
+
* BOTH facts must match: compose's config hash (the definition) AND the image id (the build). The
|
|
78
|
+
* hash alone is not enough — a tag republished under the same name leaves the compose file, and
|
|
79
|
+
* with it that hash, untouched, so the previous build would hash-match and the deploy would report
|
|
80
|
+
* a rollout it never did.
|
|
81
|
+
*
|
|
82
|
+
* A requested image whose id could not be resolved is UNKNOWN, and unknown is never read as
|
|
83
|
+
* "unchanged": every replica is then stale — the safe direction, the role is rolled and compose
|
|
84
|
+
* fetches the image during the `up`.
|
|
69
85
|
*/
|
|
70
86
|
function partition(
|
|
71
87
|
running: readonly string[],
|
|
72
88
|
inspected: string,
|
|
73
89
|
want: string,
|
|
90
|
+
wantImage: string | undefined,
|
|
74
91
|
): { readonly current: readonly string[]; readonly stale: readonly string[] } {
|
|
75
92
|
const described = new Map(
|
|
76
93
|
linesOf(inspected).map((line) => {
|
|
77
|
-
const [id = '', created = '', hash = ''] = line.split(/\s+/);
|
|
78
|
-
return [id, { created, hash }] as const;
|
|
94
|
+
const [id = '', created = '', hash = '', image = ''] = line.split(/\s+/);
|
|
95
|
+
return [id, { created, hash, image }] as const;
|
|
79
96
|
}),
|
|
80
97
|
);
|
|
81
98
|
const facts = running.map((id) => {
|
|
82
99
|
const match = [...described].find(([full]) => full === id || full.startsWith(id));
|
|
83
|
-
return {
|
|
100
|
+
return {
|
|
101
|
+
id,
|
|
102
|
+
created: match?.[1].created ?? '',
|
|
103
|
+
hash: match?.[1].hash,
|
|
104
|
+
image: match?.[1].image,
|
|
105
|
+
};
|
|
84
106
|
});
|
|
85
107
|
return {
|
|
86
108
|
current: facts
|
|
87
|
-
.filter((fact) => fact.hash === want)
|
|
109
|
+
.filter((fact) => wantImage !== undefined && fact.hash === want && fact.image === wantImage)
|
|
88
110
|
.sort((a, b) => (a.created < b.created ? 1 : -1))
|
|
89
111
|
.map((fact) => fact.id),
|
|
90
|
-
stale: facts
|
|
112
|
+
stale: facts
|
|
113
|
+
.filter((fact) => wantImage === undefined || fact.hash !== want || fact.image !== wantImage)
|
|
114
|
+
.map((fact) => fact.id),
|
|
91
115
|
};
|
|
92
116
|
}
|
|
93
117
|
|
|
94
118
|
/**
|
|
95
|
-
* Start-first for one role. The containers running now are listed and split
|
|
96
|
-
*
|
|
97
|
-
* newest first), and only the shortfall is created BESIDE the rest (`--scale
|
|
98
|
-
* --no-recreate`, so compose builds the extra containers from the new
|
|
99
|
-
* others alone). Everything is waited on until its healthcheck — `/readyz`
|
|
100
|
-
* scrape port for the others — answers. Only then are the stale containers
|
|
101
|
-
* (SIGTERM, then the `stop_grace_period` compose stamped on each as its
|
|
119
|
+
* Start-first for one role. The containers running now are listed and split against the version
|
|
120
|
+
* being deployed: those already on it — compose's config hash AND the image id they run — are kept
|
|
121
|
+
* (up to `replicas`, newest first), and only the shortfall is created BESIDE the rest (`--scale
|
|
122
|
+
* running + shortfall --no-recreate`, so compose builds the extra containers from the new
|
|
123
|
+
* definition and leaves the others alone). Everything is waited on until its healthcheck — `/readyz`
|
|
124
|
+
* for web and sync, the scrape port for the others — answers. Only then are the stale containers
|
|
125
|
+
* and any surplus stopped (SIGTERM, then the `stop_grace_period` compose stamped on each as its
|
|
126
|
+
* stop timeout) and removed.
|
|
127
|
+
*
|
|
128
|
+
* The image id is half of what "the version being deployed" means, and the hash cannot carry it:
|
|
129
|
+
* a tag republished under the same name leaves the compose file, and with it the config hash,
|
|
130
|
+
* untouched — the hash alone would call the previous build the requested one, the shortfall would
|
|
131
|
+
* be zero, and the deploy would report a rollout it never did. The requested id is the one the
|
|
132
|
+
* deploy was ASKED for (the `--image` reference) resolves to on the daemon; a reference that
|
|
133
|
+
* resolves to nothing is UNKNOWN, and unknown is never read as "unchanged": every container is
|
|
134
|
+
* then stale and the role is rolled — compose fetches the image during the `up`.
|
|
102
135
|
*
|
|
103
136
|
* Counting the shortfall rather than `running + replicas` is what makes a rerun safe: after a
|
|
104
137
|
* deploy that brought the new containers up and then failed to stop the old, running it again
|
|
@@ -113,10 +146,12 @@ export async function runStartFirst(step: {
|
|
|
113
146
|
readonly role: string;
|
|
114
147
|
readonly base: readonly string[];
|
|
115
148
|
readonly replicas: number;
|
|
149
|
+
/** The reference the deploy was asked for — the image the plan reports and compose runs. */
|
|
150
|
+
readonly image: string;
|
|
116
151
|
readonly runner: Runner;
|
|
117
152
|
readonly options: ExecOptions;
|
|
118
153
|
}): Promise<StartFirstOutcome> {
|
|
119
|
-
const { role, base, replicas, runner, options } = step;
|
|
154
|
+
const { role, base, replicas, image, runner, options } = step;
|
|
120
155
|
const run = async (command: readonly string[]) => {
|
|
121
156
|
const result = await runner(command, options);
|
|
122
157
|
return { result, failed: { ok: false as const, code: result.code, command } };
|
|
@@ -132,10 +167,19 @@ export async function runStartFirst(step: {
|
|
|
132
167
|
if (!config.result.ok) return config.failed;
|
|
133
168
|
const inspect = await run(['docker', 'inspect', '--format', INSPECT_FORMAT, ...running]);
|
|
134
169
|
if (!inspect.result.ok) return inspect.failed;
|
|
170
|
+
// The id the REQUESTED reference resolves to on the daemon. Unresolved is UNKNOWN and not a
|
|
171
|
+
// failure: compose fetches the image during the `up`, so a tag that is not on the daemon yet is
|
|
172
|
+
// the ordinary case — and unknown must never read as "unchanged" (see `partition`).
|
|
173
|
+
const resolved = await runner(
|
|
174
|
+
['docker', 'image', 'inspect', '--format', '{{.Id}}', image],
|
|
175
|
+
options,
|
|
176
|
+
);
|
|
177
|
+
const wantImage = resolved.ok ? linesOf(resolved.stdout)[0] : undefined;
|
|
135
178
|
const { current, stale } = partition(
|
|
136
179
|
running,
|
|
137
180
|
inspect.result.stdout,
|
|
138
181
|
hashOf(config.result.stdout) ?? '',
|
|
182
|
+
wantImage,
|
|
139
183
|
);
|
|
140
184
|
const shortfall = Math.max(0, replicas - current.length);
|
|
141
185
|
const up = await run(startFirstUp(base, role, running.length + shortfall));
|
package/src/cmd-deploy.ts
CHANGED
|
@@ -236,7 +236,12 @@ export function planDeploy(
|
|
|
236
236
|
// header documents `IMAGE=… docker compose …` as the way to run it by hand; this is that line,
|
|
237
237
|
// performed.
|
|
238
238
|
env: { IMAGE: image },
|
|
239
|
-
steps: DEPLOY_ROLES.
|
|
239
|
+
steps: DEPLOY_ROLES.flatMap((role) => {
|
|
240
|
+
const step = composeStep(composeBase(root), role, strategies.get(role));
|
|
241
|
+
return role === 'backfill' && strategies.has('replicator')
|
|
242
|
+
? [composeStep(composeBase(root), 'replicator', strategies.get('replicator')), step]
|
|
243
|
+
: [step];
|
|
244
|
+
}),
|
|
240
245
|
};
|
|
241
246
|
}
|
|
242
247
|
|
|
@@ -413,6 +418,10 @@ export const deployCommand: CliCommand = {
|
|
|
413
418
|
role: step.role,
|
|
414
419
|
base: composeBase(root),
|
|
415
420
|
replicas: step.strategy.replicas,
|
|
421
|
+
// The reference the deploy was asked for, named here rather than read off
|
|
422
|
+
// `options.env`: the image the plan reports and the image compose runs must be one
|
|
423
|
+
// fact, and the roll decides on the id this reference resolves to.
|
|
424
|
+
image: plan.image,
|
|
416
425
|
runner: ctx.runner,
|
|
417
426
|
options,
|
|
418
427
|
})
|
package/src/cmd-i18n.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// would, and `node:path` because Bun exposes no path API to build what either of them takes.
|
|
9
9
|
import { type FileHandle, mkdir, open } from 'node:fs/promises';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
|
-
import { stringField } from '@ultimat3/core';
|
|
11
|
+
import { DEFAULT_LOCALE, stringField } from '@ultimat3/core';
|
|
12
12
|
import type { Catalog } from '@ultimat3/i18n';
|
|
13
13
|
import { auditCatalogs, catalogKeys } from '@ultimat3/i18n';
|
|
14
14
|
import { loadApp } from './app-load';
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
syncCatalog,
|
|
27
27
|
} from './i18n-audit';
|
|
28
28
|
import { syncI18nIndex } from './i18n-index';
|
|
29
|
+
import { localeParityFindings } from './i18n-locale-parity';
|
|
29
30
|
import {
|
|
30
31
|
checkRegistration,
|
|
31
32
|
loudMiss,
|
|
@@ -126,8 +127,13 @@ async function runCheck(root: string): Promise<CommandResult> {
|
|
|
126
127
|
// The runtime question, asked after the file question and never instead of it: a catalog can be
|
|
127
128
|
// complete on disk, used everywhere in source, and reach no registry at all (issue #249).
|
|
128
129
|
const registration = await checkRegistration({ root, catalogs, extraction, ignoreUnused });
|
|
130
|
+
// The two rules only a comparison with the reference locale can see: a slot one side does not
|
|
131
|
+
// have, and a string nobody translated. The same call `x verify`'s `i18n` step makes, so the
|
|
132
|
+
// command and the gate answer about an app with one voice.
|
|
133
|
+
const parity = localeParityFindings(catalogs);
|
|
134
|
+
const parityLocales = new Set(parity.map((finding) => finding.meta?.['locale'])).size;
|
|
129
135
|
|
|
130
|
-
const findings: Finding[] = [...missingKeyFindings(report), ...registration.findings];
|
|
136
|
+
const findings: Finding[] = [...missingKeyFindings(report), ...registration.findings, ...parity];
|
|
131
137
|
|
|
132
138
|
// Whether the LOCALE has a catalog is the wrong question — the framework's own `en` is always
|
|
133
139
|
// registered, so a locale-presence column reads `yes` for an app whose every key is a loud miss.
|
|
@@ -165,10 +171,26 @@ async function runCheck(root: string): Promise<CommandResult> {
|
|
|
165
171
|
const missingTotal =
|
|
166
172
|
report.locales.reduce((sum, audit) => sum + audit.missing.length, 0) +
|
|
167
173
|
registration.unregistered;
|
|
168
|
-
const ok = report.ok && registration.ok;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
174
|
+
const ok = report.ok && registration.ok && parity.length === 0;
|
|
175
|
+
// The summary names the parity findings when there are any: `cli.i18n.gaps` counts MISSING KEYS,
|
|
176
|
+
// so a locale whose slots drifted and whose string was never translated answered "0 missing
|
|
177
|
+
// key(s) across 0 locale(s)" — a green-looking line over a red command. The missing count keeps
|
|
178
|
+
// a line of its own, so neither fact is lost.
|
|
179
|
+
let summary: string;
|
|
180
|
+
if (parity.length > 0) {
|
|
181
|
+
summary = msg('cli.i18n.parity', {
|
|
182
|
+
count: parity.length,
|
|
183
|
+
locales: parityLocales,
|
|
184
|
+
reference: DEFAULT_LOCALE,
|
|
185
|
+
});
|
|
186
|
+
if (missingTotal > 0) {
|
|
187
|
+
lines.push(` ${msg('cli.i18n.gaps', { missing: missingTotal, locales: gapLocales })}`);
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
summary = ok
|
|
191
|
+
? msg('cli.i18n.ok', { locales: report.locales.length, keys: report.used.length })
|
|
192
|
+
: msg('cli.i18n.gaps', { missing: missingTotal, locales: gapLocales });
|
|
193
|
+
}
|
|
172
194
|
|
|
173
195
|
return {
|
|
174
196
|
ok,
|
package/src/dev-route-table.ts
CHANGED
|
@@ -92,6 +92,10 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
92
92
|
// `site.origin` and `seo.robots.disallow`: the absolute URLs every document and the sitemap carry.
|
|
93
93
|
const site = await loadSiteSettings(input.root);
|
|
94
94
|
const origin = publicOrigin(input.env, site);
|
|
95
|
+
// The app's own favicon is NOT resolved here: the document resolves it, per render, from the
|
|
96
|
+
// app root below. The route table is built once and the watcher rebuilds only the manifest and
|
|
97
|
+
// the islands, so a head minted at boot would name the icon's bytes as they were and go stale
|
|
98
|
+
// on the first save.
|
|
95
99
|
// The same call `serve.ts` makes, so the two boots cannot serve different sync targets.
|
|
96
100
|
const sync = await pageSync(input.root, input.env, input.buildId, input.realtime);
|
|
97
101
|
// The client router, when a surface opted in (`navigation.client`) — the same call in both boots.
|
|
@@ -186,6 +190,8 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
186
190
|
}),
|
|
187
191
|
...appRoutes({
|
|
188
192
|
buildId: input.buildId,
|
|
193
|
+
// The app root: the document resolves the app's own favicon from it, per render.
|
|
194
|
+
root: input.root,
|
|
189
195
|
...(input.isr === undefined ? {} : { isr: input.isr }),
|
|
190
196
|
resolveIsland: (file) => input.islands().resolverFor(file),
|
|
191
197
|
realtimeIslands: () => input.islands().realtime,
|
package/src/document-options.ts
CHANGED
|
@@ -75,4 +75,14 @@ export interface DocumentOptions {
|
|
|
75
75
|
* carries none, because the router prefetches by its own rules.
|
|
76
76
|
*/
|
|
77
77
|
readonly speculationHead?: string;
|
|
78
|
+
/**
|
|
79
|
+
* `<link rel="icon" type="image/svg+xml" href="…">` — the app's own favicon, resolved once at
|
|
80
|
+
* boot by `runtime-render.ts`'s `appFaviconHead` into the content-hashed site-asset URL
|
|
81
|
+
* `/assets/*` serves.
|
|
82
|
+
*
|
|
83
|
+
* Document-level for `pwaHead`'s reason: it is the same one link on every page of the app, and
|
|
84
|
+
* it is decided by `app.config.ts`, which the boot read and the renderer cannot. Absent when the
|
|
85
|
+
* app declares no favicon — the browser's own `/favicon.ico` request is then the only one.
|
|
86
|
+
*/
|
|
87
|
+
readonly faviconHead?: string;
|
|
78
88
|
}
|
package/src/error-codes.ts
CHANGED
|
@@ -234,6 +234,9 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
234
234
|
'X_BACKFILL_SLICE_ENTITY',
|
|
235
235
|
'X_LAYOUT_SIBLING_MODULE',
|
|
236
236
|
'X_SLICE_POLICY_INCOMPLETE',
|
|
237
|
+
'X_CATALOG_SLOT_MISMATCH',
|
|
238
|
+
'X_CATALOG_UNTRANSLATED',
|
|
239
|
+
'X_WORKER_QUEUE_UNKNOWN',
|
|
237
240
|
] as const;
|
|
238
241
|
|
|
239
242
|
/**
|
|
@@ -424,6 +427,9 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
424
427
|
X_BACKFILL_SLICE_ENTITY: 'the slice holds no entity a generated backfill can sweep',
|
|
425
428
|
X_LAYOUT_SIBLING_MODULE: 'a primitive module beside its directory',
|
|
426
429
|
X_SLICE_POLICY_INCOMPLETE: 'a generator needs an export the slice policy.ts does not declare',
|
|
430
|
+
X_CATALOG_SLOT_MISMATCH: 'two locales define one key with different interpolation slots',
|
|
431
|
+
X_CATALOG_UNTRANSLATED: "a locale still carries the reference locale's value",
|
|
432
|
+
X_WORKER_QUEUE_UNKNOWN: 'WORKER_QUEUES names a queue no job and no jobs.queues entry declares',
|
|
427
433
|
};
|
|
428
434
|
|
|
429
435
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The two rules a locale's catalog must satisfy against the reference locale's: the same
|
|
2
|
+
// interpolation slots, and no untranslated copy of the reference's string. Both already hold at the
|
|
3
|
+
// APP level (examples/dummy's catalog test asserts them over its en/es catalogs); this module is
|
|
4
|
+
// the same rule as a finding, so a broken locale fails the gate instead of shipping.
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_LOCALE, ERROR_DOCS_URL } from '@ultimat3/core';
|
|
7
|
+
import type { Catalog, Locale } from '@ultimat3/i18n';
|
|
8
|
+
import { isMiss, placeholdersOf } from '@ultimat3/i18n';
|
|
9
|
+
import type { Finding } from './output';
|
|
10
|
+
import { catalogPath } from './templates/locales';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The length above which two identical values are a copy rather than a name. `Postly`, `Free` and
|
|
14
|
+
* `Blog` read the same in every language; a sentence of 25 characters does not, and shipping it
|
|
15
|
+
* untranslated is the defect this rule exists to name.
|
|
16
|
+
*/
|
|
17
|
+
const COPY_LENGTH = 24;
|
|
18
|
+
|
|
19
|
+
/** The slots, as `{name}` — the way a cause and a fix name them. */
|
|
20
|
+
const shownSlots = (slots: readonly string[]): string =>
|
|
21
|
+
slots.map((slot) => `{${slot}}`).join(', ');
|
|
22
|
+
|
|
23
|
+
/** The slots as the loud miss `interpolate` renders for each of them. */
|
|
24
|
+
const shownMisses = (slots: readonly string[]): string =>
|
|
25
|
+
slots.map((slot) => `⟦${slot}⟧`).join(', ');
|
|
26
|
+
|
|
27
|
+
/** A value, capped: a cause is one line, and a 300-character string is not a cause. */
|
|
28
|
+
const shownValue = (value: string): string =>
|
|
29
|
+
value.length > 60 ? `${value.slice(0, 60)}…` : value;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Every defect one locale's catalog holds against the reference's, as findings.
|
|
33
|
+
*
|
|
34
|
+
* Pure: catalogs in, findings out. A slot is read with `placeholdersOf` — @ultimat3/i18n's one
|
|
35
|
+
* definition, the same function `interpolate` substitutes by — because a second regex here could
|
|
36
|
+
* only ever disagree with the runtime about what a slot is.
|
|
37
|
+
*
|
|
38
|
+
* The reference is `en` — core's `DEFAULT_LOCALE`, never a second `'en'` spelled here. An app
|
|
39
|
+
* that ships no `en` catalog is not compared at all: there is no reference to compare against, and
|
|
40
|
+
* a rule with nothing to compare is noise on a healthy app.
|
|
41
|
+
*/
|
|
42
|
+
export function localeParityFindings(
|
|
43
|
+
catalogs: Readonly<Record<Locale, Catalog>>,
|
|
44
|
+
): readonly Finding[] {
|
|
45
|
+
const reference = catalogs[DEFAULT_LOCALE];
|
|
46
|
+
if (reference === undefined) return [];
|
|
47
|
+
const findings: Finding[] = [];
|
|
48
|
+
for (const locale of Object.keys(catalogs).sort()) {
|
|
49
|
+
if (locale === DEFAULT_LOCALE) continue;
|
|
50
|
+
const catalog = catalogs[locale];
|
|
51
|
+
if (catalog === undefined) continue;
|
|
52
|
+
for (const key of Object.keys(reference).sort()) {
|
|
53
|
+
const value = reference[key];
|
|
54
|
+
const translated = catalog[key];
|
|
55
|
+
// Only a key BOTH define: a key one catalog holds alone is a MISSING key, and that is
|
|
56
|
+
// `X_CATALOG_MISSING_KEYS`'s finding. Reporting it here too would be one defect under two
|
|
57
|
+
// codes, and the second code's repair is not the first one's.
|
|
58
|
+
if (value === undefined || translated === undefined) continue;
|
|
59
|
+
findings.push(...keyFindings(locale, key, value, translated));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return findings;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The findings for one key, in one order: the slots the locale adds, the slots it drops, and the
|
|
67
|
+
* copy. A slot mismatch is TWO findings, because the two repairs differ — one is a rename, the
|
|
68
|
+
* other an addition — and a single cause could only name one of them.
|
|
69
|
+
*
|
|
70
|
+
* Every `fix:` opens with the command and names the edit behind a `#`: a fix is pasted from its
|
|
71
|
+
* first character, so a sentence the reader has to translate first is what the `fix-prose`
|
|
72
|
+
* ratchet refuses.
|
|
73
|
+
*/
|
|
74
|
+
function keyFindings(
|
|
75
|
+
locale: Locale,
|
|
76
|
+
key: string,
|
|
77
|
+
reference: string,
|
|
78
|
+
translated: string,
|
|
79
|
+
): readonly Finding[] {
|
|
80
|
+
const path = catalogPath(locale);
|
|
81
|
+
// A loud miss (`⟦key⟧`) is a placeholder, not a translation, and it carries no slot of its own:
|
|
82
|
+
// a slot comparison across it blames the marker — `x i18n sync` seeds one into every key
|
|
83
|
+
// nobody has translated — and prints a fix ("add {name}") that edits the placeholder. The hole
|
|
84
|
+
// is `X_CATALOG_MISSING_KEYS`'s finding, already reported: a second code for one defect, with a
|
|
85
|
+
// repair that would destroy the marker. So the comparison waits for a real string on both sides.
|
|
86
|
+
const comparable = !isMiss(reference) && !isMiss(translated);
|
|
87
|
+
const referenceSlots = placeholdersOf(reference);
|
|
88
|
+
const translatedSlots = placeholdersOf(translated);
|
|
89
|
+
const extra = translatedSlots.filter((slot) => !referenceSlots.includes(slot));
|
|
90
|
+
const missing = referenceSlots.filter((slot) => !translatedSlots.includes(slot));
|
|
91
|
+
const findings: Finding[] = [];
|
|
92
|
+
|
|
93
|
+
if (comparable && extra.length > 0) {
|
|
94
|
+
findings.push({
|
|
95
|
+
code: 'X_CATALOG_SLOT_MISMATCH',
|
|
96
|
+
cause: `${path} defines "${key}" with ${shownSlots(extra)}, which ${catalogPath(DEFAULT_LOCALE)} does not — interpolate substitutes by name, so ${locale} renders ${shownMisses(extra)}`,
|
|
97
|
+
fix:
|
|
98
|
+
referenceSlots.length === 0
|
|
99
|
+
? `x i18n check --json # remove ${shownSlots(extra)} from "${key}" in ${path}`
|
|
100
|
+
: `x i18n check --json # use ${shownSlots(referenceSlots)} instead of ${shownSlots(extra)} in "${key}" in ${path}`,
|
|
101
|
+
docs: ERROR_DOCS_URL,
|
|
102
|
+
at: path,
|
|
103
|
+
meta: { locale, key },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (comparable && missing.length > 0) {
|
|
108
|
+
findings.push({
|
|
109
|
+
code: 'X_CATALOG_SLOT_MISMATCH',
|
|
110
|
+
cause: `${catalogPath(DEFAULT_LOCALE)} defines "${key}" with ${shownSlots(missing)}, which ${path} does not — the value it carries never renders in ${locale}`,
|
|
111
|
+
fix: `x i18n check --json # add ${shownSlots(missing)} to "${key}" in ${path}`,
|
|
112
|
+
docs: ERROR_DOCS_URL,
|
|
113
|
+
at: path,
|
|
114
|
+
meta: { locale, key },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A value longer than the bar that is byte-identical to the reference's is a copy nobody
|
|
119
|
+
// translated. A marker is NOT a copy: `⟦key⟧` is the loud miss `X_CATALOG_MISSING_KEYS` already
|
|
120
|
+
// reports, and a placeholder seeded into two locales would otherwise be one hole under two
|
|
121
|
+
// codes. `isMiss` is the framework's own predicate for that marker.
|
|
122
|
+
if (translated === reference && reference.length > COPY_LENGTH && !isMiss(reference)) {
|
|
123
|
+
findings.push({
|
|
124
|
+
code: 'X_CATALOG_UNTRANSLATED',
|
|
125
|
+
cause: `${path} defines "${key}" exactly as ${catalogPath(DEFAULT_LOCALE)} does ("${shownValue(reference)}") — ${reference.length} characters, so ${locale} reads the reference's sentence instead of its own`,
|
|
126
|
+
fix: `x i18n check --json # translate "${key}" in ${path}`,
|
|
127
|
+
docs: ERROR_DOCS_URL,
|
|
128
|
+
at: path,
|
|
129
|
+
meta: { locale, key },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return findings;
|
|
134
|
+
}
|
package/src/i18n-registration.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { loadApp } from './app-load';
|
|
|
25
25
|
import type { DuplicateProbe } from './duplicate-packages';
|
|
26
26
|
import { duplicateCause, duplicateFinding, findDuplicateInstalls } from './duplicate-packages';
|
|
27
27
|
import { auditApp } from './i18n-audit';
|
|
28
|
+
import { localeParityFindings } from './i18n-locale-parity';
|
|
28
29
|
import type { Finding } from './output';
|
|
29
30
|
import { findingFrom } from './output';
|
|
30
31
|
import { CATALOG_ROOT, catalogPath } from './templates/locales';
|
|
@@ -287,6 +288,10 @@ export function missingKeyFindings(report: ExtractReport): readonly Finding[] {
|
|
|
287
288
|
* Both halves of one question — does every string this app renders resolve? — for a caller that
|
|
288
289
|
* wants the verdict and not the table. `x verify`'s `i18n` step is that caller; `cmd-i18n.ts`
|
|
289
290
|
* composes the same two pieces itself because it also renders per-locale rows.
|
|
291
|
+
*
|
|
292
|
+
* The locale parity joins them: a catalog can hold every key source uses and still be wrong in
|
|
293
|
+
* the two ways only a comparison with the reference locale can see — a slot one side does not
|
|
294
|
+
* have, and a string nobody translated.
|
|
290
295
|
*/
|
|
291
296
|
export async function catalogFindings(root: string): Promise<readonly Finding[]> {
|
|
292
297
|
const { report, catalogs, extraction, ignoreUnused } = await auditApp(root);
|
|
@@ -294,5 +299,6 @@ export async function catalogFindings(root: string): Promise<readonly Finding[]>
|
|
|
294
299
|
return [
|
|
295
300
|
...missingKeyFindings(withPlaceholdersMissing(report, catalogs)),
|
|
296
301
|
...registration.findings,
|
|
302
|
+
...localeParityFindings(catalogs),
|
|
297
303
|
];
|
|
298
304
|
}
|
package/src/mcp-errors.ts
CHANGED
|
@@ -306,6 +306,12 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
306
306
|
'x verify --only boundaries --json # then move each declaration in X.ts to X/<name>.ts, one primitive per file',
|
|
307
307
|
X_SLICE_POLICY_INCOMPLETE:
|
|
308
308
|
'x policy list --json # names the slice policy module; add the export lines the thrown fix spells out to it, then run the generator again',
|
|
309
|
+
X_CATALOG_SLOT_MISMATCH:
|
|
310
|
+
'x i18n check --json # the finding names the locale, the key and the slots that differ',
|
|
311
|
+
X_CATALOG_UNTRANSLATED:
|
|
312
|
+
'x i18n check --json # the finding names the locale and the key still carrying the reference value',
|
|
313
|
+
X_WORKER_QUEUE_UNKNOWN:
|
|
314
|
+
'x jobs list --json # set WORKER_QUEUES to queues it lists, or add the queue to jobs.queues in app.config.ts',
|
|
309
315
|
};
|
|
310
316
|
|
|
311
317
|
const isCliCode = (code: string): code is CliErrorCode =>
|
package/src/messages.ts
CHANGED
|
@@ -139,6 +139,10 @@ const CATALOG = {
|
|
|
139
139
|
'cli.i18n.dynamic': '{count} dynamic t() call(s) the extractor cannot verify:',
|
|
140
140
|
'cli.i18n.gaps': '{missing} missing key(s) across {locales} locale(s)',
|
|
141
141
|
'cli.i18n.ok': '{locales} locale(s), {keys} key(s) used — no gaps',
|
|
142
|
+
// The summary a locale-parity finding needs: `cli.i18n.gaps` counts MISSING KEYS, so it cannot
|
|
143
|
+
// say "this locale's slots drifted and this string was never translated".
|
|
144
|
+
'cli.i18n.parity':
|
|
145
|
+
'{count} locale-parity finding(s) across {locales} locale(s) — a slot or an untranslated copy against {reference}',
|
|
142
146
|
'cli.i18n.synced': 'synced {locale} from {from} — {added} key(s) added, {total} total',
|
|
143
147
|
'cli.i18n.unused': '{count} key(s) defined in {locale} and never used:',
|
|
144
148
|
'cli.jobs.backfillNoCursor': 'no cursor yet',
|
package/src/prerender.ts
CHANGED
|
@@ -195,6 +195,8 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
195
195
|
const theme = themeBoot(await loadThemeMode(options.root));
|
|
196
196
|
// The brand the served process carries, so an exported page and a served one paint alike.
|
|
197
197
|
const brand = await loadThemeBrand(options.root);
|
|
198
|
+
// The app's own favicon is resolved by the DOCUMENT, per render, from the app root below — the
|
|
199
|
+
// link the served surfaces emit, so an exported document and a served one carry the same icon.
|
|
198
200
|
// The client router, for the surfaces that opted in: written BEFORE any document names it, for
|
|
199
201
|
// the register's reason below — `measureDocumentJs` weighs it off disk, and it is charged to
|
|
200
202
|
// the route (the interactivity the app asked for), so it must be there to be weighed.
|
|
@@ -266,6 +268,9 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
266
268
|
// Started on the first route that needs weighing, and released however the loop ends.
|
|
267
269
|
const database = measureDatabase(options.root);
|
|
268
270
|
const documentOptions = {
|
|
271
|
+
// The app root: the document resolves the app's own favicon from it, per render. `writeSiteAssets`
|
|
272
|
+
// above copied the icon into the artifact under the same hashed URL this resolves.
|
|
273
|
+
root: options.root,
|
|
269
274
|
resolveIsland: (file: string) => islands.resolverFor(file),
|
|
270
275
|
realtimeIslands: () => islands.realtime,
|
|
271
276
|
themeHead: theme.head,
|