@ultimat3/cli 23.0.0 → 24.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 +15 -7
- package/README.md +3 -3
- package/package.json +28 -28
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-manifest.ts +2 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +7 -17
- package/src/budgets.ts +4 -35
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build.ts +64 -15
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +6 -5
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +41 -14
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +79 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs.ts +18 -6
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +12 -2
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +4 -4
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +23 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +13 -2
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +33 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/i18n-audit.ts +4 -1
- package/src/index.ts +1 -3
- package/src/island-bundle.ts +3 -15
- package/src/jobs-drain.ts +82 -15
- package/src/jobs-report.ts +4 -4
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +12 -3
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +2 -0
- package/src/metrics-endpoint.ts +25 -1
- package/src/output.ts +6 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +9 -4
- package/src/role-sync.ts +55 -20
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +31 -10
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +62 -35
- package/src/runtime-services.ts +20 -17
- package/src/runtime-storage.ts +10 -5
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +34 -6
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-web.ts +12 -7
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +3 -0
- package/src/templates/naming.ts +28 -0
- package/src/templates/route.ts +3 -6
- package/src/templates/scaffold-app.ts +6 -2
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +4 -2
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-repo.ts +9 -1
- package/src/templates/scaffold-shell.ts +39 -36
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/trusted-hops-error.ts +17 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +17 -46
- package/src/verify-deadline.ts +130 -33
- package/src/verify-merge.ts +60 -27
- package/src/verify-part-step.ts +90 -0
- package/src/verify-run.ts +29 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-tests.ts +5 -2
- package/src/build-errors.ts +0 -54
- package/src/verify-errors.ts +0 -58
package/src/serve-boot.ts
CHANGED
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
// (`serve-web.ts`) and the manifest projection are behind `await import()` in turn, so a worker's
|
|
6
6
|
// module graph holds neither.
|
|
7
7
|
|
|
8
|
-
import type { Role } from '@ultimat3/core';
|
|
8
|
+
import type { DrainConfig, LifecycleOptions, Role } from '@ultimat3/core';
|
|
9
9
|
import { configureLifecycle, logger } from '@ultimat3/core';
|
|
10
10
|
import type { MetricsEndpoint } from './metrics-endpoint';
|
|
11
11
|
import { startOtlpExport } from './otlp-export';
|
|
12
12
|
import { loadAppForRole, roleLoadFor } from './role-load';
|
|
13
13
|
import { startRoles } from './role-start';
|
|
14
14
|
import { resolveServices } from './runtime-bindings';
|
|
15
|
+
import { warnIfIdempotencyProcessScoped } from './runtime-idempotency-scope';
|
|
16
|
+
import { warnUnsealedMfaSecrets } from './runtime-mfa-warning';
|
|
15
17
|
import { replicaOverrides } from './runtime-replica';
|
|
16
18
|
import type { RunningServices } from './runtime-services';
|
|
17
19
|
import { startServices } from './runtime-services';
|
|
@@ -42,6 +44,23 @@ async function buildIdFor(options: ServeOptions, role: Role): Promise<string> {
|
|
|
42
44
|
return (await appManifest(options.root)).manifest.buildId;
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
/** The roles that open an HTTP listener something routes to — the only ones a readiness grace serves. */
|
|
48
|
+
const LISTENING_ROLES: readonly Role[] = ['web', 'sync'];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `app.config.ts`'s `drain`, applied to the WHOLE process rather than only to the web server that
|
|
52
|
+
* `createServer` hands it to. A role nothing routes to gets no readiness grace at all: the grace
|
|
53
|
+
* holds `/readyz` at 503 with the listener open so the endpoints catch up, and a worker has neither
|
|
54
|
+
* — it only went on claiming jobs for those seconds, then aborted them at the drain (s1-con #7).
|
|
55
|
+
*/
|
|
56
|
+
export function lifecycleForRole(
|
|
57
|
+
role: Role,
|
|
58
|
+
drain: Partial<DrainConfig> | undefined,
|
|
59
|
+
): LifecycleOptions {
|
|
60
|
+
if (!LISTENING_ROLES.includes(role)) return { readinessGraceMs: 0 };
|
|
61
|
+
return drain?.readinessGraceMs === undefined ? {} : { readinessGraceMs: drain.readinessGraceMs };
|
|
62
|
+
}
|
|
63
|
+
|
|
45
64
|
/**
|
|
46
65
|
* Everything `serveApp` does after the scrape listener is open: the services, then the roles over
|
|
47
66
|
* them. One export, so `serve.ts` reaches the whole serving graph through ONE `await import()`.
|
|
@@ -53,11 +72,12 @@ export async function bootServing(boot: {
|
|
|
53
72
|
readonly metrics: MetricsEndpoint;
|
|
54
73
|
}): Promise<ServedApp> {
|
|
55
74
|
const { options } = boot;
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
75
|
+
const services = resolveServices(options.root, options.env);
|
|
76
|
+
// A serving role on a real database runs no DDL: `ROLE=migrate` applied this build's framework
|
|
77
|
+
// tables first, and this boot only verifies the stamp it left (`framework-schema-apply.ts`).
|
|
78
|
+
// The embedded database has no migrate step to have run, so it applies its own.
|
|
79
|
+
const schema = services.db.mode === 'external' ? 'verify' : 'apply';
|
|
80
|
+
const runtime = await startServices(services, options.env, options.runtime, schema);
|
|
61
81
|
boot.acquired.push(() => runtime.stop());
|
|
62
82
|
return bootRoles({ ...boot, runtime });
|
|
63
83
|
}
|
|
@@ -84,6 +104,8 @@ async function bootRoles(boot: {
|
|
|
84
104
|
for (const finding of loaded.findings) {
|
|
85
105
|
logger.error('ultimate app module failed to load', { role, ...finding });
|
|
86
106
|
}
|
|
107
|
+
// After the app's modules: an app swaps the store at import time, after `startServices`.
|
|
108
|
+
warnIfIdempotencyProcessScoped();
|
|
87
109
|
const buildId = await buildIdFor(options, role);
|
|
88
110
|
// Before the first socket opens: everything above this line fails loudly into the container's
|
|
89
111
|
// own logs, everything below it is a served request, a claimed job or a routed frame.
|
|
@@ -102,6 +124,9 @@ async function bootRoles(boot: {
|
|
|
102
124
|
role === 'web'
|
|
103
125
|
? await (await import('./serve-web')).webSurface(options, runtime, buildId)
|
|
104
126
|
: undefined;
|
|
127
|
+
if (web !== undefined) acquired.push(web.release);
|
|
128
|
+
// The web role alone — it is where a second factor is asked for — and once per pod boot.
|
|
129
|
+
if (role === 'web') await warnUnsealedMfaSecrets(runtime.db);
|
|
105
130
|
// Islands and stylesheets are the image build's to make (`x build --target prebuilt`). A role
|
|
106
131
|
// that made either here served correctly and paid for it — seconds of CPU and a compiler's
|
|
107
132
|
// memory, on every start of every replica — so it says so once, with the Dockerfile line.
|
|
@@ -112,6 +137,7 @@ async function bootRoles(boot: {
|
|
|
112
137
|
// still wins — that is the deploy talking.
|
|
113
138
|
const metricsPort = metricsPortFor(options.env, port, options.metricsPort);
|
|
114
139
|
const drain = await loadDrainConfig(options.root);
|
|
140
|
+
configureLifecycle(lifecycleForRole(role, drain));
|
|
115
141
|
// Process-wide, so every role's `/readyz` — web, sync, worker — answers in the declared mode.
|
|
116
142
|
const health = await loadHealthConfig(options.root);
|
|
117
143
|
if (health?.readiness !== undefined) configureLifecycle({ readiness: health.readiness });
|
|
@@ -156,6 +182,8 @@ async function bootRoles(boot: {
|
|
|
156
182
|
runtime,
|
|
157
183
|
async stop() {
|
|
158
184
|
await running.stop();
|
|
185
|
+
// After the server drained: a bust landing mid-drain still marks the pages it is serving.
|
|
186
|
+
web?.release();
|
|
159
187
|
await runtime.stop();
|
|
160
188
|
// Last: the exporters outlive the roles they were recording, so the drain's own spans and
|
|
161
189
|
// the final counter snapshot still have somewhere to go.
|
package/src/serve-drain.ts
CHANGED
|
@@ -1,41 +1,17 @@
|
|
|
1
|
-
// `app.config.ts`'s `drain` and `health` sections
|
|
2
|
-
//
|
|
3
|
-
// module. Both shape what `/readyz` tells the load balancer.
|
|
1
|
+
// `app.config.ts`'s `drain` and `health` sections for the production boot, off the one loader
|
|
2
|
+
// (`app-config-load.ts`). Both shape what `/readyz` tells the load balancer.
|
|
4
3
|
|
|
5
4
|
import type { DrainConfig, HealthConfig } from '@ultimat3/core';
|
|
6
|
-
import {
|
|
7
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
5
|
+
import { loadAppConfig } from './app-config-load';
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
typeof value === 'object' && value !== null;
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* The declared section, or `undefined` when the app has none (a config not built by
|
|
14
|
-
* `defineConfig`, or no config file): `@ultimat3/http` then keeps core's own default. A value that
|
|
15
|
-
* is not a number is left for core to refuse, where the rule lives, rather than dropped here.
|
|
16
|
-
*/
|
|
7
|
+
/** The declared section, or `undefined` for a root with no config file — core keeps its default. */
|
|
17
8
|
export async function loadDrainConfig(root: string): Promise<Partial<DrainConfig> | undefined> {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const module = (await import(path)) as Record<string, unknown>;
|
|
21
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
22
|
-
const drain = isRecord(config) ? config['drain'] : undefined;
|
|
23
|
-
if (!isRecord(drain) || !Object.hasOwn(drain, 'readinessGraceMs')) return undefined;
|
|
24
|
-
return { readinessGraceMs: drain['readinessGraceMs'] as number };
|
|
9
|
+
const drain = (await loadAppConfig(root))?.drain;
|
|
10
|
+
return drain === undefined ? undefined : { readinessGraceMs: drain.readinessGraceMs };
|
|
25
11
|
}
|
|
26
12
|
|
|
27
|
-
/**
|
|
28
|
-
* `health.readiness`, or `undefined` when the app declares none (core keeps `'dependencies'`). The
|
|
29
|
-
* value is handed to `configureLifecycle` as written, so a bad one is refused by core's own rule.
|
|
30
|
-
*/
|
|
13
|
+
/** `health.readiness`, or `undefined` for a root with no config file (core keeps its default). */
|
|
31
14
|
export async function loadHealthConfig(root: string): Promise<Partial<HealthConfig> | undefined> {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
const module = (await import(path)) as Record<string, unknown>;
|
|
35
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
36
|
-
const health = isRecord(config)
|
|
37
|
-
? (config['health'] as Partial<HealthConfig> | undefined)
|
|
38
|
-
: undefined;
|
|
39
|
-
if (!isRecord(health) || health.readiness === undefined) return undefined;
|
|
40
|
-
return { readiness: health.readiness };
|
|
15
|
+
const health = (await loadAppConfig(root))?.health;
|
|
16
|
+
return health === undefined ? undefined : { readiness: health.readiness };
|
|
41
17
|
}
|
package/src/serve-env.ts
CHANGED
|
@@ -42,22 +42,25 @@ export const containerBinding = (env: Env, hostname?: string): WebBinding => ({
|
|
|
42
42
|
/**
|
|
43
43
|
* `ROLE` is the one knob one image exposes. Validated rather than defaulted: a typo that fell back
|
|
44
44
|
* to `web` would start a process that serves nothing the operator asked for and reports healthy.
|
|
45
|
+
* Empty or blank is unset, read the way `PORT` and `HOST` are — not a role name nobody typed.
|
|
45
46
|
*/
|
|
46
47
|
export function roleFromEnv(env: Env): Role {
|
|
47
|
-
const raw = env['ROLE']
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
const raw = env['ROLE']?.trim();
|
|
49
|
+
const role = raw === undefined || raw.length === 0 ? 'web' : raw;
|
|
50
|
+
if (!isRole(role)) throw new RoleUnknownError({ role, known: ROLES });
|
|
51
|
+
return role;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
/**
|
|
53
|
-
* `Number.parseInt` would read `80abc` as 80,
|
|
54
|
-
*
|
|
55
|
+
* `Number.parseInt` would read `80abc` as 80, and `Number` reads `0x1F90` as 8080 and `8e3` as
|
|
56
|
+
* 8000, so the whole string has to be decimal digits — a port spelled any other way is a deploy
|
|
57
|
+
* that binds somewhere nobody wrote down.
|
|
55
58
|
*/
|
|
56
59
|
function portValue(env: Env, name: string, fallback: number): number {
|
|
57
60
|
const raw = env[name];
|
|
58
61
|
if (raw === undefined || raw.trim().length === 0) return fallback;
|
|
59
62
|
const port = Number(raw.trim());
|
|
60
|
-
if (!Number.isInteger(port) || port < 0 || port > 65_535)
|
|
63
|
+
if (!/^\d+$/.test(raw.trim()) || !Number.isInteger(port) || port < 0 || port > 65_535)
|
|
61
64
|
throw new PortInvalidError({ value: raw, name });
|
|
62
65
|
return port;
|
|
63
66
|
}
|
package/src/serve-web.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
|
|
6
6
|
import type { Route } from '@ultimat3/http';
|
|
7
7
|
import { describeRoutes } from '@ultimat3/render';
|
|
8
|
-
import { createIsrController } from '@ultimat3/render/server';
|
|
9
8
|
import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
|
|
10
9
|
import { loadSignInPath } from './app-auth';
|
|
11
10
|
import { mountAppMcp } from './app-mcp';
|
|
@@ -19,6 +18,7 @@ import { pageSync } from './page-sync';
|
|
|
19
18
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
20
19
|
import { adminMountRoutes } from './runtime-admin';
|
|
21
20
|
import { assetRoutes } from './runtime-assets';
|
|
21
|
+
import { attachedIsr } from './runtime-isr';
|
|
22
22
|
import { appRoutes } from './runtime-render';
|
|
23
23
|
import type { RunningServices } from './runtime-services';
|
|
24
24
|
import { servedStorage, storageRoutes } from './runtime-storage';
|
|
@@ -42,6 +42,8 @@ export interface WebSurface {
|
|
|
42
42
|
readonly signInPath: string | null;
|
|
43
43
|
/** Present when this boot built the islands itself: how many chunks, and why not the store. */
|
|
44
44
|
readonly islandsBuilt?: LoadedIslands['built'];
|
|
45
|
+
/** Detaches the ISR controller from `invalidateTags` — on the boot's stop list. */
|
|
46
|
+
readonly release: () => void;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export async function webSurface(
|
|
@@ -93,6 +95,13 @@ export async function webSurface(
|
|
|
93
95
|
const mcpMount = await mountAppMcp(options.root);
|
|
94
96
|
// The app's own disks when it declared any (`defineStorage` in an app module), else this boot's.
|
|
95
97
|
const served = servedStorage(runtime.storage);
|
|
98
|
+
// Attached, so a tag bust marks this process's ISR pages stale. The store is the deployment's
|
|
99
|
+
// when it supplied one: the default is per process, so twelve replicas hold twelve of them and a
|
|
100
|
+
// purge regenerates one twelfth of the fleet while the other eleven keep the invalidated page.
|
|
101
|
+
const isr = attachedIsr({
|
|
102
|
+
buildId,
|
|
103
|
+
...(options.runtime?.isrStore === undefined ? {} : { store: options.runtime.isrStore }),
|
|
104
|
+
});
|
|
96
105
|
const routes: readonly Route[] = [
|
|
97
106
|
...apiRoutes(),
|
|
98
107
|
// The bearer doors onto a cut of the same routes (`defineApi({ http: { mounts } })`), counted
|
|
@@ -142,12 +151,7 @@ export async function webSurface(
|
|
|
142
151
|
...(pwa === undefined
|
|
143
152
|
? {}
|
|
144
153
|
: { pwaHead: (locale: string) => pwa.headFor(locale) + (serviceWorker?.head ?? '') }),
|
|
145
|
-
|
|
146
|
-
// store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
|
|
147
|
-
// the fleet while the other eleven keep serving the page it just invalidated.
|
|
148
|
-
...(options.runtime?.isrStore === undefined
|
|
149
|
-
? {}
|
|
150
|
-
: { isr: createIsrController({ buildId, store: options.runtime.isrStore }) }),
|
|
154
|
+
isr: isr.isr,
|
|
151
155
|
}),
|
|
152
156
|
];
|
|
153
157
|
return {
|
|
@@ -160,5 +164,6 @@ export async function webSurface(
|
|
|
160
164
|
// guarded page with the problem document, rendered as raw JSON in the viewport.
|
|
161
165
|
signInPath: await loadSignInPath(options.root),
|
|
162
166
|
...(islandsBuilt === undefined ? {} : { islandsBuilt }),
|
|
167
|
+
release: isr.release,
|
|
163
168
|
};
|
|
164
169
|
}
|
package/src/shot-locale.ts
CHANGED
|
@@ -3,9 +3,7 @@
|
|
|
3
3
|
// on the language of the machine's Chrome. A non-default locale is also a URL prefix (`/en/…`),
|
|
4
4
|
// because on a `site/` route the unprefixed path is always the default locale whatever the header.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
8
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
6
|
+
import { loadAppConfig } from './app-config-load';
|
|
9
7
|
import { BadFlagError } from './errors';
|
|
10
8
|
|
|
11
9
|
export interface ShotLocales {
|
|
@@ -16,27 +14,11 @@ export interface ShotLocales {
|
|
|
16
14
|
/** An app that declares nothing is `en` only — the framework's own default. */
|
|
17
15
|
export const FALLBACK_SHOT_LOCALES: ShotLocales = { locales: ['en'], defaultLocale: 'en' };
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
typeof value === 'object' && value !== null;
|
|
21
|
-
|
|
22
|
-
/** `app.config.ts`'s `locales` and `defaultLocale`, read the way `loadThemeMode` reads `theme`. */
|
|
17
|
+
/** `app.config.ts`'s `locales` and `defaultLocale`, off the one loader (`app-config-load.ts`). */
|
|
23
18
|
export async function loadShotLocales(root: string): Promise<ShotLocales> {
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
28
|
-
if (!isRecord(config)) return FALLBACK_SHOT_LOCALES;
|
|
29
|
-
const declared = config['locales'];
|
|
30
|
-
const locales = Array.isArray(declared)
|
|
31
|
-
? declared.filter((locale): locale is string => typeof locale === 'string')
|
|
32
|
-
: [];
|
|
33
|
-
const fallback = config['defaultLocale'];
|
|
34
|
-
const defaultLocale =
|
|
35
|
-
typeof fallback === 'string' && fallback !== '' ? fallback : (locales[0] ?? 'en');
|
|
36
|
-
return {
|
|
37
|
-
locales: locales.length === 0 ? [defaultLocale] : locales,
|
|
38
|
-
defaultLocale,
|
|
39
|
-
};
|
|
19
|
+
const config = await loadAppConfig(root);
|
|
20
|
+
if (config === undefined) return FALLBACK_SHOT_LOCALES;
|
|
21
|
+
return { locales: config.locales, defaultLocale: config.defaultLocale };
|
|
40
22
|
}
|
|
41
23
|
|
|
42
24
|
/** `--locale <l>`, refused by name when the app does not declare it — a typo costs no browser. */
|
package/src/site-assets.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { copyFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
|
9
9
|
import { dirname, join } from 'node:path'; // why: Bun ships no path API.
|
|
10
10
|
import type { AssetExtension, AssetPath } from '@ultimat3/render';
|
|
11
11
|
import { ASSET_DIR, AssetMissingError, assetPathProblem } from '@ultimat3/render';
|
|
12
|
+
import { contentHash } from '@ultimat3/render/server';
|
|
12
13
|
|
|
13
14
|
/** App-root-relative, beside `favicon.ico`: `apps/web/site/` is where an app's public files live. */
|
|
14
15
|
export const SITE_ASSETS_SOURCE = `apps/web/site/${ASSET_DIR}`;
|
|
@@ -37,7 +38,7 @@ export interface SiteAsset {
|
|
|
37
38
|
readonly path: AssetPath;
|
|
38
39
|
/** Absolute path on disk. */
|
|
39
40
|
readonly file: string;
|
|
40
|
-
/**
|
|
41
|
+
/** `@ultimat3/render`'s `contentHash` of the bytes, 8 hex characters — the one CSS and HTML get. */
|
|
41
42
|
readonly hash: string;
|
|
42
43
|
/** `/assets/brand/logo.<hash>.svg`. */
|
|
43
44
|
readonly url: string;
|
|
@@ -69,9 +70,6 @@ export function parseHashedAssetUrl(
|
|
|
69
70
|
return { path: path as AssetPath, hash: match[2] ?? '' };
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
const hashBytes = (bytes: Uint8Array): string =>
|
|
73
|
-
Bun.hash.xxHash32(bytes).toString(16).padStart(8, '0');
|
|
74
|
-
|
|
75
73
|
export interface SiteAssetTable {
|
|
76
74
|
/** The asset `asset()` named, hashed as the bytes are NOW. Throws `X_ASSET_MISSING`. */
|
|
77
75
|
resolve(path: AssetPath): SiteAsset;
|
|
@@ -110,7 +108,7 @@ export function createSiteAssetTable(root: string): SiteAssetTable {
|
|
|
110
108
|
const cached = cache.get(path);
|
|
111
109
|
if (cached !== undefined && cached.stamp === stamp) return cached.asset;
|
|
112
110
|
const bytes = readFileSync(file);
|
|
113
|
-
const hash =
|
|
111
|
+
const hash = contentHash(bytes);
|
|
114
112
|
// `assetPathProblem` already refused an unknown extension; the guard keeps `constructor` from
|
|
115
113
|
// ever answering an `Object` member if that check is loosened.
|
|
116
114
|
const extension = splitExtension(path).extension.toLowerCase();
|
package/src/site-config.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
// The public origin and the crawler rules out of `app.config.ts` — `site.origin` and
|
|
2
2
|
// `seo.robots.disallow` — and the one resolution of "which origin is this site served on" every
|
|
3
|
-
// absolute URL a document, a sitemap or a robots file carries is built against.
|
|
4
|
-
// `app-
|
|
3
|
+
// absolute URL a document, a sitemap or a robots file carries is built against. Read off the one
|
|
4
|
+
// loader (`app-config-load.ts`).
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
import type { AppConfig, Environment, SeoSitemapConfig, SitemapLastmod } from '@ultimat3/core';
|
|
9
|
-
import { SITEMAP_LASTMOD_SOURCES } from '@ultimat3/core';
|
|
10
|
-
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
11
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
6
|
+
import type { Environment, SeoSitemapConfig } from '@ultimat3/core';
|
|
7
|
+
import { loadAppConfig } from './app-config-load';
|
|
12
8
|
|
|
13
9
|
export interface SiteSettings {
|
|
14
10
|
/** `site.origin`, trailing slash removed; `null` when the app declares none. */
|
|
@@ -25,51 +21,16 @@ export const NO_SITE_SETTINGS: SiteSettings = {
|
|
|
25
21
|
sitemap: { extra: [], lastmod: 'none' },
|
|
26
22
|
};
|
|
27
23
|
|
|
28
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
29
|
-
typeof value === 'object' && value !== null;
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Structural, never `instanceof`, for `loadSignInPath`'s reason: a config that resolved through an
|
|
33
|
-
* older core simply has no `site` section, and that is the "not declared" answer, not a crash.
|
|
34
|
-
*/
|
|
35
|
-
const hasSiteSections = (value: unknown): value is Pick<AppConfig, 'site' | 'seo'> =>
|
|
36
|
-
isRecord(value) &&
|
|
37
|
-
isRecord(value['site']) &&
|
|
38
|
-
isRecord(value['seo']) &&
|
|
39
|
-
isRecord(value['seo']['robots']);
|
|
40
|
-
|
|
41
24
|
const trimSlash = (origin: string): string => origin.replace(/\/+$/, '');
|
|
42
25
|
|
|
43
26
|
export async function loadSiteSettings(root: string): Promise<SiteSettings> {
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
const
|
|
47
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
48
|
-
if (!hasSiteSections(config)) return NO_SITE_SETTINGS;
|
|
49
|
-
const origin = config.site.origin;
|
|
27
|
+
const config = await loadAppConfig(root);
|
|
28
|
+
if (config === undefined) return NO_SITE_SETTINGS;
|
|
29
|
+
const { origin } = config.site;
|
|
50
30
|
return {
|
|
51
|
-
origin:
|
|
31
|
+
origin: origin === null ? null : trimSlash(origin),
|
|
52
32
|
disallow: [...config.seo.robots.disallow],
|
|
53
|
-
sitemap:
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Structural for `hasSiteSections`' reason: a config resolved through a core older than
|
|
59
|
-
* `seo.sitemap` has no such key, and that is the default, not a crash.
|
|
60
|
-
*/
|
|
61
|
-
function sitemapOf(seo: unknown): SeoSitemapConfig {
|
|
62
|
-
const declared = isRecord(seo) ? seo['sitemap'] : undefined;
|
|
63
|
-
if (!isRecord(declared)) return NO_SITE_SETTINGS.sitemap;
|
|
64
|
-
const extra = declared['extra'];
|
|
65
|
-
const lastmod = declared['lastmod'];
|
|
66
|
-
return {
|
|
67
|
-
extra: Array.isArray(extra)
|
|
68
|
-
? extra.filter((path): path is string => typeof path === 'string')
|
|
69
|
-
: [],
|
|
70
|
-
lastmod: SITEMAP_LASTMOD_SOURCES.includes(lastmod as SitemapLastmod)
|
|
71
|
-
? (lastmod as SitemapLastmod)
|
|
72
|
-
: 'none',
|
|
33
|
+
sitemap: config.seo.sitemap,
|
|
73
34
|
};
|
|
74
35
|
}
|
|
75
36
|
|
package/src/site-seo.ts
CHANGED
|
@@ -173,7 +173,7 @@ export async function siteSeo(options: SiteSeoOptions): Promise<SiteSeo> {
|
|
|
173
173
|
localizedPath(path, locale, defaultLocale),
|
|
174
174
|
}),
|
|
175
175
|
});
|
|
176
|
-
// Past 50,000 URLs `files` are `/
|
|
176
|
+
// Past 50,000 URLs `files` are `/sitemaps/<n>.xml` and the index is `/sitemap.xml`; below it,
|
|
177
177
|
// `files` is that one file and there is no index.
|
|
178
178
|
const sitemaps = sitemap.index === undefined ? sitemap.files : [sitemap.index, ...sitemap.files];
|
|
179
179
|
const robots = buildRobots({
|
package/src/sitemap-lastmod.ts
CHANGED
|
@@ -14,6 +14,9 @@ let builtAt: string | undefined;
|
|
|
14
14
|
|
|
15
15
|
const cache = new Map<string, string | undefined>();
|
|
16
16
|
|
|
17
|
+
/** One `git log -1` over one path; a synchronous wait past this blocks the request serving it. */
|
|
18
|
+
const GIT_DATE_TIMEOUT_MS = 5_000;
|
|
19
|
+
|
|
17
20
|
/**
|
|
18
21
|
* The `<lastmod>` for one route file, W3C Datetime (ISO 8601, UTC), or `undefined` when the source
|
|
19
22
|
* yields none — a sitemap entry without `<lastmod>` is valid, a wrong one misleads a crawler.
|
|
@@ -46,6 +49,8 @@ function gitDate(file: string, root: string): string | undefined {
|
|
|
46
49
|
cwd: root,
|
|
47
50
|
stdout: 'pipe',
|
|
48
51
|
stderr: 'ignore',
|
|
52
|
+
// A wedged `git` (a lock, a credential prompt) answers no date: the mtime fallback below.
|
|
53
|
+
timeout: GIT_DATE_TIMEOUT_MS,
|
|
49
54
|
});
|
|
50
55
|
if (run.exitCode !== 0) return undefined;
|
|
51
56
|
return isoOf(run.stdout.toString().trim());
|
package/src/solid-loader.ts
CHANGED
|
@@ -73,6 +73,10 @@ interface CacheEntry {
|
|
|
73
73
|
*/
|
|
74
74
|
const cache = new Map<string, CacheEntry>();
|
|
75
75
|
|
|
76
|
+
/** Transforms running now, and the `prepareStackTrace` the first of them found (see below). */
|
|
77
|
+
let inFlight = 0;
|
|
78
|
+
let foundStackTrace: typeof Error.prepareStackTrace;
|
|
79
|
+
|
|
76
80
|
/** Test seam: the cache is process-global because the dev server it serves is too. */
|
|
77
81
|
export function clearIslandTransformCache(): void {
|
|
78
82
|
cache.clear();
|
|
@@ -92,8 +96,12 @@ export async function transformIslandTsx(source: string, path: string): Promise<
|
|
|
92
96
|
|
|
93
97
|
// Babel installs its own `prepareStackTrace` on the first TRANSFORM — not on import, which is
|
|
94
98
|
// where this guard was first put — and leaving it installed makes `Error.captureStackTrace`
|
|
95
|
-
// strict for every unrelated module loaded later in the same process
|
|
96
|
-
|
|
99
|
+
// strict for every unrelated module loaded later in the same process, and names every error in
|
|
100
|
+
// it `Error` (a `TypeError`'s stack then opens `Error: …`). Saved by the FIRST transform in
|
|
101
|
+
// flight and restored by the LAST: `Bun.build` loads a second `.tsx` while the first compiles,
|
|
102
|
+
// and a per-call save taken inside that window saved Babel's own rewriter and restored it last.
|
|
103
|
+
if (inFlight === 0) foundStackTrace = Error.prepareStackTrace;
|
|
104
|
+
inFlight += 1;
|
|
97
105
|
let code: string | null | undefined;
|
|
98
106
|
try {
|
|
99
107
|
// `transformAsync` and never `transformFileAsync`: the latter is gated behind `@babel/core`'s
|
|
@@ -111,7 +119,8 @@ export async function transformIslandTsx(source: string, path: string): Promise<
|
|
|
111
119
|
});
|
|
112
120
|
code = result?.code;
|
|
113
121
|
} finally {
|
|
114
|
-
|
|
122
|
+
inFlight -= 1;
|
|
123
|
+
if (inFlight === 0) Error.prepareStackTrace = foundStackTrace;
|
|
115
124
|
}
|
|
116
125
|
// A parse error is NOT caught here: Babel's own message already names the file, the line and the
|
|
117
126
|
// column ("a.island.tsx: Unexpected token (2:23)"), and `buildOne` wraps whatever escapes in
|
package/src/source-files.ts
CHANGED
|
@@ -16,6 +16,12 @@ export const SOURCE_GLOBS = [
|
|
|
16
16
|
'api/**/*.{ts,tsx}',
|
|
17
17
|
'shared/**/*.{ts,tsx}',
|
|
18
18
|
'apps/*/{app,site,api,shared}/**/*.{ts,tsx}',
|
|
19
|
+
// An app's own entry files: authored, shipped, and outside every surface glob above — a 900-line
|
|
20
|
+
// `server.ts` or an unrunnable `fix:` in it was invisible to `filesize` and `errors`.
|
|
21
|
+
// And its guards: run on the gate, held to the error contract, and invisible to `filesize`.
|
|
22
|
+
'guards/*.ts',
|
|
23
|
+
'apps/*/server.ts',
|
|
24
|
+
'apps/*/prerender.ts',
|
|
19
25
|
] as const;
|
|
20
26
|
|
|
21
27
|
/**
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// How a stored object may be presented when the app's own origin serves it (plan 101, s1-sec L6):
|
|
2
|
+
// inline only for a type a browser renders without running anything, a sandboxed download otherwise. Read by
|
|
3
|
+
// both surfaces that serve stored bytes — `/_storage` (`runtime-storage.ts`) and `/media`.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Raster images, audio and video: what an `<img>`, `<audio>` or `<video>` presents and nothing
|
|
7
|
+
* executes. NOT `image/svg+xml` (it carries `<script>`), `text/html`, `application/xml` or PDF —
|
|
8
|
+
* any of them opened from the app's origin runs with the app's cookies and its `'self'` sources.
|
|
9
|
+
*/
|
|
10
|
+
const INLINE_TYPES: ReadonlySet<string> = new Set([
|
|
11
|
+
'image/png',
|
|
12
|
+
'image/jpeg',
|
|
13
|
+
'image/gif',
|
|
14
|
+
'image/webp',
|
|
15
|
+
'image/avif',
|
|
16
|
+
'audio/mpeg',
|
|
17
|
+
'audio/ogg',
|
|
18
|
+
'audio/wav',
|
|
19
|
+
'audio/webm',
|
|
20
|
+
'video/mp4',
|
|
21
|
+
'video/webm',
|
|
22
|
+
'video/ogg',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/** `image/PNG; charset=…` is `image/png`: parameters and case never widen the list. */
|
|
26
|
+
const essenceOf = (contentType: string): string =>
|
|
27
|
+
(contentType.split(';')[0] ?? '').trim().toLowerCase();
|
|
28
|
+
|
|
29
|
+
export const isInlineSafe = (contentType: string): boolean =>
|
|
30
|
+
INLINE_TYPES.has(essenceOf(contentType));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The headers a stored object is served with beyond its type. `attachment` makes a navigation to an
|
|
34
|
+
* uploaded `text/html` a download instead of a page on the app's origin; an embedding element
|
|
35
|
+
* (`<img>`, `<video>`) ignores it, so nothing that displays an object inline stops working. The
|
|
36
|
+
* `sandbox` policy is for the browser that renders it anyway: an opaque origin, no script, no
|
|
37
|
+
* cookies. `@ultimat3/http`'s security stage keeps it and adds the app's policy beside it.
|
|
38
|
+
*/
|
|
39
|
+
export function storedObjectHeaders(contentType: string): Readonly<Record<string, string>> {
|
|
40
|
+
return isInlineSafe(contentType)
|
|
41
|
+
? {}
|
|
42
|
+
: { 'content-disposition': 'attachment', 'content-security-policy': 'sandbox' };
|
|
43
|
+
}
|
package/src/sync-url.ts
CHANGED
|
@@ -43,8 +43,8 @@ export function syncConnectSources(env: Readonly<Record<string, string | undefin
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* The page origins a sync node admits besides its own
|
|
47
|
-
*
|
|
46
|
+
* The page origins a sync node admits besides its own origin: `APP_URL`'s, when set. A deployment
|
|
47
|
+
* that serves the page on another port or host than the node sees needs it; an unparsable value is
|
|
48
48
|
* refused rather than read as "no page origin", which would refuse every socket in silence.
|
|
49
49
|
*/
|
|
50
50
|
export function syncOriginsFrom(env: Readonly<Record<string, string | undefined>>): string[] {
|
package/src/tasks-facts.ts
CHANGED
|
@@ -5,38 +5,12 @@
|
|
|
5
5
|
import type { TaskDescriptor, TaskHandle } from '@ultimat3/jobs';
|
|
6
6
|
import { getTask, registeredTasks } from '@ultimat3/jobs';
|
|
7
7
|
import type { CronPhrases } from '@ultimat3/time';
|
|
8
|
-
import {
|
|
9
|
-
describeCron,
|
|
10
|
-
fromEpochMs,
|
|
11
|
-
nextCronOccurrenceMs,
|
|
12
|
-
offsetLabel,
|
|
13
|
-
toZoned,
|
|
14
|
-
} from '@ultimat3/time';
|
|
8
|
+
import { describeCron, fromEpochMs, isoInZone, nextCronOccurrenceMs } from '@ultimat3/time';
|
|
15
9
|
import { BadFlagError } from './errors';
|
|
16
10
|
|
|
17
11
|
const DEFAULT_COUNT = 5;
|
|
18
12
|
const MAX_COUNT = 50;
|
|
19
13
|
|
|
20
|
-
const pad2 = (value: number): string => String(value).padStart(2, '0');
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* ISO-8601 rendered with the ZONE's OWN offset (`2026-03-08T03:00:00-04:00`), never collapsed to
|
|
24
|
-
* UTC `Z` — an ambient zone is exactly the bug a task's `tz` exists to prevent. No single
|
|
25
|
-
* "instant in a zone, as ISO" export exists in `@ultimat3/time` to call instead: `toIso` is
|
|
26
|
-
* UTC-`Z` only, `isoDateInZone` is date-only, and `formatWithOffset` renders locale prose, not
|
|
27
|
-
* ISO. So this composes the package's own instant→zoned-wall-clock conversion (`toZoned`) and
|
|
28
|
-
* zone-label helper (`offsetLabel`) rather than a fresh `Intl.DateTimeFormat` call here.
|
|
29
|
-
*
|
|
30
|
-
* Exported for `cmd-tasks.ts`'s `last` column: the occurrence a task last fired and the one it
|
|
31
|
-
* fires next are one clock, so they are one function.
|
|
32
|
-
*/
|
|
33
|
-
export function isoInZone(ms: number, zone: string): string {
|
|
34
|
-
const zoned = toZoned(fromEpochMs(ms), zone);
|
|
35
|
-
const date = `${String(zoned.year).padStart(4, '0')}-${pad2(zoned.month)}-${pad2(zoned.day)}`;
|
|
36
|
-
const time = `${pad2(zoned.hour)}:${pad2(zoned.minute)}:${pad2(zoned.second)}`;
|
|
37
|
-
return `${date}T${time}${offsetLabel(zoned.offsetMinutes)}`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
14
|
/** `x tasks list` row: the descriptor plus the next occurrence, ms and rendered alike. */
|
|
41
15
|
export interface TaskFact extends TaskDescriptor {
|
|
42
16
|
readonly nextMs: number;
|
|
@@ -46,7 +20,7 @@ export interface TaskFact extends TaskDescriptor {
|
|
|
46
20
|
function toFact(handle: TaskHandle, nowMs: number): TaskFact {
|
|
47
21
|
const descriptor = handle.describe();
|
|
48
22
|
const nextMs = nextCronOccurrenceMs(descriptor.cron, descriptor.tz, nowMs);
|
|
49
|
-
return { ...descriptor, nextMs, next: isoInZone(nextMs, descriptor.tz) };
|
|
23
|
+
return { ...descriptor, nextMs, next: isoInZone(fromEpochMs(nextMs), descriptor.tz) };
|
|
50
24
|
}
|
|
51
25
|
|
|
52
26
|
export function listTaskFacts(nowMs: number): readonly TaskFact[] {
|
|
@@ -110,7 +84,7 @@ export function taskShowFacts(
|
|
|
110
84
|
let cursor = nowMs;
|
|
111
85
|
for (let i = 0; i < count; i += 1) {
|
|
112
86
|
cursor = nextCronOccurrenceMs(descriptor.cron, descriptor.tz, cursor);
|
|
113
|
-
upcoming.push({ ms: cursor, at: isoInZone(cursor, descriptor.tz) });
|
|
87
|
+
upcoming.push({ ms: cursor, at: isoInZone(fromEpochMs(cursor), descriptor.tz) });
|
|
114
88
|
}
|
|
115
89
|
return { descriptor, describe: describeCron(descriptor.cron, 'en-US', phrases), upcoming };
|
|
116
90
|
}
|
package/src/templates/action.ts
CHANGED
|
@@ -82,6 +82,9 @@ export const ${name.camel} = mutator({
|
|
|
82
82
|
input: t.object({ id: t.uuid, orgId: t.uuid, title: t.string }),
|
|
83
83
|
output: t.object({ id: t.uuid, title: t.string }),
|
|
84
84
|
policy: can${feature.pascal}Write,
|
|
85
|
+
// Required on a mutator (X_MUTATOR_NOT_IDEMPOTENT): the client replays this write under one
|
|
86
|
+
// Idempotency-Key after a dropped response, and the replay answers the first result.
|
|
87
|
+
idempotent: true,
|
|
85
88
|
// No \`mcp\` until a real description is written: see \`x g action\`.
|
|
86
89
|
// tx.table(name) rather than tx.${feature.plural}: the typed accessor exists only once the app
|
|
87
90
|
// augments LocalTables, and generated code cannot assume that has happened yet. The name is the
|
package/src/templates/naming.ts
CHANGED
|
@@ -77,6 +77,34 @@ export const plural = (input: string): string => {
|
|
|
77
77
|
return `${input}s`;
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
+
/** Singular words `plural()` already handles, though they end in a lone `s`. */
|
|
81
|
+
const SINGULAR_IN_S: ReadonlySet<string> = new Set([
|
|
82
|
+
'alias',
|
|
83
|
+
'atlas',
|
|
84
|
+
'bias',
|
|
85
|
+
'canvas',
|
|
86
|
+
'gas',
|
|
87
|
+
'lens',
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The singular a plural name was made from, kebab-case — `undefined` when the name is not one
|
|
92
|
+
* `plural()` would have produced. Read off the last word only, `plural()`'s inverse: a name that
|
|
93
|
+
* is already plural is pluralised AGAIN by every generator that names a table (`posts` →
|
|
94
|
+
* `postses`), so it is refused with this spelling. `ss`, `us`, `is` endings are singular.
|
|
95
|
+
*/
|
|
96
|
+
export const singularOf = (input: string): string | undefined => {
|
|
97
|
+
const parts = words(input);
|
|
98
|
+
const last = parts.at(-1);
|
|
99
|
+
if (last === undefined || !last.endsWith('s') || /(ss|us|is)$/.test(last)) return undefined;
|
|
100
|
+
if (SINGULAR_IN_S.has(last)) return undefined;
|
|
101
|
+
const candidates = last.endsWith('ies')
|
|
102
|
+
? [`${last.slice(0, -3)}y`]
|
|
103
|
+
: [last.slice(0, -2), last.slice(0, -1)];
|
|
104
|
+
const found = candidates.find((candidate) => candidate.length > 0 && plural(candidate) === last);
|
|
105
|
+
return found === undefined ? undefined : [...parts.slice(0, -1), found].join('-');
|
|
106
|
+
};
|
|
107
|
+
|
|
80
108
|
export const titleKey = (input: string): string => `app.${kebab(input)}.title`;
|
|
81
109
|
|
|
82
110
|
/**
|