@ultimat3/core 23.0.0 → 25.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 +29 -26
- package/README.md +98 -30
- package/package.json +4 -7
- package/src/actor.ts +9 -0
- package/src/address-class.ts +40 -4
- package/src/assert.ts +9 -5
- package/src/audit.ts +144 -0
- package/src/aws-sigv4.ts +275 -0
- package/src/backoff.ts +16 -0
- package/src/bunfs.ts +17 -0
- package/src/client-dispatch.ts +24 -3
- package/src/client-flight.ts +68 -13
- package/src/client-problem.ts +62 -6
- package/src/client-retry-after.ts +47 -0
- package/src/client-transport.ts +3 -1
- package/src/client-wire.ts +27 -3
- package/src/config-ai.ts +32 -0
- package/src/config-defaults.ts +53 -0
- package/src/config-fixes.ts +0 -10
- package/src/config-health.ts +9 -2
- package/src/config-jobs.ts +51 -0
- package/src/config-keys.ts +170 -0
- package/src/config-mail.ts +73 -0
- package/src/config-merge.ts +9 -1
- package/src/config-navigation.ts +1 -25
- package/src/config-pwa.ts +42 -5
- package/src/config-removed.ts +131 -0
- package/src/config-shape.ts +79 -0
- package/src/config-site.ts +14 -3
- package/src/config.ts +141 -173
- package/src/context.ts +29 -13
- package/src/cookie.ts +299 -0
- package/src/core-error-codes.ts +2 -0
- package/src/cursor-page.ts +41 -0
- package/src/cursor.ts +26 -5
- package/src/decimal-order.ts +5 -4
- package/src/deprecation.ts +77 -0
- package/src/dev-secrets.ts +18 -7
- package/src/drain-deadline.ts +43 -0
- package/src/env-example.ts +9 -29
- package/src/error-reporter-sentry.ts +7 -3
- package/src/errors.ts +18 -9
- package/src/exports/error-contract.ts +0 -1
- package/src/exports/observability.ts +1 -1
- package/src/exports/secrets.ts +3 -0
- package/src/finite-option.ts +1 -1
- package/src/flight-gate.ts +43 -16
- package/src/fnv1a.ts +19 -0
- package/src/generation-fence.ts +1 -1
- package/src/health-disclosure.ts +43 -0
- package/src/host-rules.ts +28 -1
- package/src/html-escape.ts +24 -0
- package/src/ids.ts +7 -7
- package/src/image/canvas.ts +76 -5
- package/src/image/errors.ts +3 -1
- package/src/image/pipeline.ts +17 -5
- package/src/image/png-pixels.ts +29 -6
- package/src/image/probe.ts +7 -2
- package/src/image/raster.ts +27 -2
- package/src/index.ts +99 -33
- package/src/iso-date.ts +1 -1
- package/src/lifecycle-errors.ts +1 -1
- package/src/lifecycle-readiness.ts +60 -2
- package/src/lifecycle-signals.ts +27 -2
- package/src/lifecycle-types.ts +96 -0
- package/src/lifecycle.ts +44 -133
- package/src/locale-direction.ts +1 -1
- package/src/logger.ts +92 -16
- package/src/mcp-exposure.ts +70 -8
- package/src/measurement-actor.ts +16 -1
- package/src/metric-errors.ts +32 -0
- package/src/metric-registry.ts +151 -0
- package/src/metric-series.ts +94 -0
- package/src/metrics.ts +9 -255
- package/src/nearest-name.ts +11 -2
- package/src/otlp-metric-exporter.ts +1 -1
- package/src/otlp-span-exporter.ts +1 -1
- package/src/otlp.ts +44 -13
- package/src/page.ts +4 -2
- package/src/pg-executor.ts +15 -0
- package/src/public-cause.ts +37 -0
- package/src/registrar.ts +22 -4
- package/src/retry.ts +40 -7
- package/src/route-rank.ts +36 -0
- package/src/same-origin.ts +1 -1
- package/src/sampler.ts +6 -2
- package/src/secrets-errors.ts +14 -3
- package/src/secrets-key-file.ts +139 -0
- package/src/secrets-store.ts +32 -16
- package/src/service.ts +5 -5
- package/src/single-flight.ts +1 -1
- package/src/source-mask.ts +14 -8
- package/src/store-mode.ts +23 -0
- package/src/telemetry.ts +1 -1
- package/src/theme-storage.ts +12 -0
- package/src/type-pins.ts +51 -1
- package/src/image/fixtures.ts +0 -263
- package/src/time-zone-name.ts +0 -14
package/src/config-merge.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// per section and key by key. Carries no config KEY on purpose: `config-readers` counts a property
|
|
3
3
|
// access outside `config.ts` as a reader, so this file only ever sees sections as opaque records.
|
|
4
4
|
|
|
5
|
+
import { isJsonObject } from './json-object';
|
|
6
|
+
|
|
5
7
|
/** A section's patch: every key optional, and an explicit `undefined` meaning "not said". */
|
|
6
8
|
export type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
|
|
7
9
|
|
|
@@ -11,6 +13,12 @@ export type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
|
|
|
11
13
|
*/
|
|
12
14
|
export function section<T extends object>(base: T, patch: Input<T> | undefined): T {
|
|
13
15
|
if (patch === undefined) return base;
|
|
16
|
+
// A layer that wrote something other than an object (`null`, a string, a list) has no key to
|
|
17
|
+
// merge, and `Object.entries(null)` below was a native `TypeError` out of the validator's own
|
|
18
|
+
// caller. It is carried through AS WRITTEN — and stays, whatever later layers say — so the shape
|
|
19
|
+
// screen refuses it by name; dropped here, the app would run on defaults it believed it replaced.
|
|
20
|
+
if (!isJsonObject(base)) return base;
|
|
21
|
+
if (!isJsonObject(patch)) return patch as T;
|
|
14
22
|
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
15
23
|
for (const [key, value] of Object.entries(patch)) {
|
|
16
24
|
if (value !== undefined) out[key] = value;
|
|
@@ -28,7 +36,7 @@ export function layered<T extends object>(base: T, patches: readonly (Input<T> |
|
|
|
28
36
|
return patches.reduce<T>((out, patch) => section(out, patch), base);
|
|
29
37
|
}
|
|
30
38
|
|
|
31
|
-
/** A whole-value key (`
|
|
39
|
+
/** A whole-value key (`roles`, `jobs.queues`): the last layer that said something wins. */
|
|
32
40
|
export function lastSaid<T>(base: T, values: readonly (T | undefined)[]): T {
|
|
33
41
|
let out = base;
|
|
34
42
|
for (const value of values) if (value !== undefined) out = value;
|
package/src/config-navigation.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
// were: shape, merge and screen are one subject.
|
|
7
7
|
|
|
8
8
|
import { describeValue } from './error-render';
|
|
9
|
-
import { ConfigInvalidError } from './errors';
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
11
|
* The surfaces that render documents a browser navigates between. `api` answers JSON and `shared`
|
|
@@ -73,7 +72,7 @@ export interface NavigationSectionInput {
|
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
/**
|
|
76
|
-
* Whole-value keys: the last layer that listed surfaces wins, as `
|
|
75
|
+
* Whole-value keys: the last layer that listed surfaces wins, as `roles` does — and so does the
|
|
77
76
|
* last one that set `speculation.prefetch` or listed `speculation.exclude`, each on its own.
|
|
78
77
|
*/
|
|
79
78
|
export function mergeNavigation(layers: readonly NavigationSectionInput[]): NavigationSection {
|
|
@@ -138,29 +137,6 @@ function speculationIssues(speculation: unknown, issues: string[]): void {
|
|
|
138
137
|
}
|
|
139
138
|
}
|
|
140
139
|
|
|
141
|
-
/**
|
|
142
|
-
* `navigation.speculation` as some reader OUTSIDE `defineConfig` found it (`@ultimat3/cli` imports
|
|
143
|
-
* the app's config module structurally): the defaults for what it does not say, and the SAME
|
|
144
|
-
* refusal `defineConfig` gives for what it says wrongly. One validator — a second reader that
|
|
145
|
-
* coerced `'eager'` to `'moderate'` or dropped a bad pattern would serve rules the app never wrote.
|
|
146
|
-
*/
|
|
147
|
-
export function resolveSpeculation(said: unknown): SpeculationConfig {
|
|
148
|
-
if (said === undefined) return DEFAULT_SPECULATION;
|
|
149
|
-
const { speculation } = mergeNavigation([
|
|
150
|
-
{ navigation: { speculation: said as SpeculationInput } },
|
|
151
|
-
]).navigation;
|
|
152
|
-
const issues: string[] = [];
|
|
153
|
-
speculationIssues(speculation, issues);
|
|
154
|
-
if (issues.length > 0) {
|
|
155
|
-
throw new ConfigInvalidError({
|
|
156
|
-
cause: issues.join('; '),
|
|
157
|
-
fix: 'Correct navigation.speculation in app.config.ts: prefetch is "moderate", "conservative" or false, and exclude is a list of path patterns starting with "/"',
|
|
158
|
-
meta: { issues },
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
return speculation;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
140
|
/** Appends every refusal the section earns to `issues`, `config.ts`' one list. */
|
|
165
141
|
export function navigationIssues(config: NavigationSection, issues: string[]): void {
|
|
166
142
|
speculationIssues(config.navigation.speculation, issues);
|
package/src/config-pwa.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { describeValue } from './error-render';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* `installPrompt` was removed 2026-08, same rule: `@ultimat3/pwa`'s `
|
|
16
|
+
* `installPrompt` was removed 2026-08, same rule: `@ultimat3/pwa`'s `installController` is
|
|
17
17
|
* real and complete, nothing ever threaded the flag into it, and both tracked apps plus every
|
|
18
18
|
* scaffolded app set a switch with no wire. Call the controller from your own affordance instead.
|
|
19
19
|
*/
|
|
@@ -115,8 +115,8 @@ export interface PwaScreenshot {
|
|
|
115
115
|
/**
|
|
116
116
|
* The install chrome's two colours for one scheme, as CSS colour strings.
|
|
117
117
|
*
|
|
118
|
-
* ONE
|
|
119
|
-
*
|
|
118
|
+
* THE ONE PLACE IN `app.config.ts` A RAW COLOUR IS LEGAL (`theme.tokens`, the other, was deleted in
|
|
119
|
+
* 25.0.0 — the theme is `defineTheme` in `@ultimat3/ui`), because a browser paints the install splash and the address bar
|
|
120
120
|
* from these before a single stylesheet has loaded, so there is no token to resolve them against
|
|
121
121
|
* and no component anywhere in the loop.
|
|
122
122
|
*/
|
|
@@ -173,10 +173,20 @@ export function pwaIssues(pwa: PwaConfig, issues: string[]): boolean {
|
|
|
173
173
|
// `pwa.enabled` means an installable app, and an installable app that shows the browser's error
|
|
174
174
|
// page offline is the failure the whole block exists to prevent, so this is required rather
|
|
175
175
|
// than optional: the alternative is two meanings for one switch (axiom 1).
|
|
176
|
+
// Same origin, too: `//host/offline` and `/\host/offline` start with `/` and are still another
|
|
177
|
+
// host, which the worker would precache and serve as this app's offline page.
|
|
176
178
|
const fallback: unknown = pwa.offline?.fallback;
|
|
177
|
-
if (
|
|
179
|
+
if (!isSameOriginPath(fallback)) {
|
|
178
180
|
issues.push(
|
|
179
|
-
`pwa.offline.fallback is required when pwa.enabled is true and must be an absolute route path like "/offline", and is ${describeValue(fallback)}`,
|
|
181
|
+
`pwa.offline.fallback is required when pwa.enabled is true and must be an absolute route path on this origin like "/offline", and is ${describeValue(fallback)}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
for (const key of ['image', 'font'] as const) {
|
|
185
|
+
const placeholder: unknown = pwa.offline?.[key];
|
|
186
|
+
if (placeholder === undefined || placeholder === null || isSameOriginPath(placeholder))
|
|
187
|
+
continue;
|
|
188
|
+
issues.push(
|
|
189
|
+
`pwa.offline.${key} must be a path on this origin like "/offline.svg", and is ${describeValue(placeholder)}`,
|
|
180
190
|
);
|
|
181
191
|
}
|
|
182
192
|
const personal: unknown = pwa.offline?.personalPages;
|
|
@@ -233,3 +243,30 @@ function manifestIssues(pwa: PwaConfig, issues: string[]): void {
|
|
|
233
243
|
}
|
|
234
244
|
}
|
|
235
245
|
}
|
|
246
|
+
|
|
247
|
+
/** An origin no relative path can reach: a value that resolves anywhere else left this origin. */
|
|
248
|
+
const PROBE_ORIGIN = 'http://x.invalid';
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* A path on THIS origin, as a browser will resolve it — the one predicate for every URL the worker
|
|
252
|
+
* precaches and serves as an offline answer (`@ultimat3/pwa`'s build asks the same question).
|
|
253
|
+
* Judged by RESOLUTION, never by the raw prefix: the URL parser strips a tab, CR or LF anywhere and
|
|
254
|
+
* reads `\` as `/`, so `/\t/evil.test/x` is `//evil.test/x` once parsed. Every C0 control and DEL
|
|
255
|
+
* is refused before the parse, as `@ultimat3/http`'s sign-in redirect does; a dot segment that
|
|
256
|
+
* leaves a `//` pathname (`/.//evil.test`) is refused after it, since any reader without the base
|
|
257
|
+
* takes that pathname as a host.
|
|
258
|
+
*/
|
|
259
|
+
export function isSameOriginPath(value: unknown): value is string {
|
|
260
|
+
if (typeof value !== 'string' || !value.startsWith('/')) return false;
|
|
261
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
262
|
+
const code = value.charCodeAt(index);
|
|
263
|
+
if (code < 0x20 || code === 0x7f) return false;
|
|
264
|
+
}
|
|
265
|
+
let resolved: URL;
|
|
266
|
+
try {
|
|
267
|
+
resolved = new URL(value, PROBE_ORIGIN);
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
return resolved.origin === PROBE_ORIGIN && !resolved.pathname.startsWith('//');
|
|
272
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Single responsibility: the `app.config.ts` keys a major DELETED, and the refusal an app still
|
|
2
|
+
// writing one gets. A deleted key the validator ignores is a switch with no wire — an operator sets
|
|
3
|
+
// it, redeploys and nothing changes — so each is refused by name, with the line that replaces it.
|
|
4
|
+
|
|
5
|
+
import { isJsonObject } from './json-object';
|
|
6
|
+
|
|
7
|
+
export interface RemovedConfigKey {
|
|
8
|
+
/** The major that deleted it. */
|
|
9
|
+
readonly removedIn: string;
|
|
10
|
+
/** What an app writes instead — the second half of the `fix:`. */
|
|
11
|
+
readonly instead: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Dotted paths, one row per deleted leaf. Every row older than 25.0.0 was silently carried through
|
|
16
|
+
* `section()` until 25.0.0 refused it — this table by name, then `config-keys.ts` closing the
|
|
17
|
+
* shape for any other key; a row is what turns "not a key" into the line that replaces it. A row
|
|
18
|
+
* is never removed: a config written against an older major must keep getting the instruction,
|
|
19
|
+
* not silence. `Object.freeze`d, read through `Object.hasOwn`, so `__proto__` in a layer names no
|
|
20
|
+
* row.
|
|
21
|
+
*/
|
|
22
|
+
export const REMOVED_CONFIG_KEYS: Readonly<Record<string, RemovedConfigKey>> = Object.freeze({
|
|
23
|
+
locales: {
|
|
24
|
+
removedIn: '25.0.0',
|
|
25
|
+
instead:
|
|
26
|
+
"the app's locales are the keys of its catalogs: defineCatalogs({ default: 'en', locales: { en, es } }) from @ultimat3/i18n, in packages/i18n/src/index.ts",
|
|
27
|
+
},
|
|
28
|
+
defaultLocale: {
|
|
29
|
+
removedIn: '25.0.0',
|
|
30
|
+
instead:
|
|
31
|
+
"the fallback locale is defineCatalogs({ default: 'en', locales: { en } })'s default, from @ultimat3/i18n",
|
|
32
|
+
},
|
|
33
|
+
defaultTimeZone: {
|
|
34
|
+
removedIn: '25.0.0',
|
|
35
|
+
instead:
|
|
36
|
+
"nothing read it; there is no ambient zone — pass one at every call: formatDate(at, { locale, zone: 'Europe/Paris' }), task({ tz }), userActor({ tz })",
|
|
37
|
+
},
|
|
38
|
+
defaultCurrency: {
|
|
39
|
+
removedIn: '25.0.0',
|
|
40
|
+
instead:
|
|
41
|
+
"nothing read it; every Money carries its own currency ({ minor, currency: 'USD' }) — an app that wants a default declares its own constant in an app module",
|
|
42
|
+
},
|
|
43
|
+
'jobs.driver': {
|
|
44
|
+
removedIn: '5.0.0',
|
|
45
|
+
instead:
|
|
46
|
+
'nothing read it and boot always built Postgres; the driver is code — setJobDriver(postgresJobDriver({ executor })) from @ultimat3/jobs, or setJobDriver(memoryJobDriver()) in a test',
|
|
47
|
+
},
|
|
48
|
+
'realtime.heartbeatMs': {
|
|
49
|
+
removedIn: '4.0.0',
|
|
50
|
+
instead:
|
|
51
|
+
"nothing read it; the page socket's beat is the sync node's, named in its hello reply, and the presence beat is a third of the presence ttl",
|
|
52
|
+
},
|
|
53
|
+
'database.urlEnv': {
|
|
54
|
+
removedIn: '4.0.0',
|
|
55
|
+
instead:
|
|
56
|
+
'nothing read it; the connection string is the DATABASE_URL environment variable, read by @ultimat3/db',
|
|
57
|
+
},
|
|
58
|
+
'database.poolSize': {
|
|
59
|
+
removedIn: '4.0.0',
|
|
60
|
+
instead:
|
|
61
|
+
'nothing read it; the pool is sized by the DATABASE_POOL_MAX environment variable, per process',
|
|
62
|
+
},
|
|
63
|
+
'database.schema': {
|
|
64
|
+
removedIn: '4.0.0',
|
|
65
|
+
instead:
|
|
66
|
+
'nothing read it and nothing emits SET search_path; entity() tables live in public, and there is no replacement',
|
|
67
|
+
},
|
|
68
|
+
'pwa.installPrompt': {
|
|
69
|
+
removedIn: '8.0.0',
|
|
70
|
+
instead:
|
|
71
|
+
'nothing read it; call installController() from @ultimat3/pwa in your own install affordance',
|
|
72
|
+
},
|
|
73
|
+
'auth.afterSignInPath': {
|
|
74
|
+
removedIn: '8.0.0',
|
|
75
|
+
instead:
|
|
76
|
+
'nothing read it; send the visitor where you mean from your sign-in route, the only code that can honour it',
|
|
77
|
+
},
|
|
78
|
+
'ai.modelEnv': {
|
|
79
|
+
removedIn: '8.0.0',
|
|
80
|
+
instead:
|
|
81
|
+
"nothing read it; the model is the prompt's own, or llm({ model }) from @ultimat3/ai — read your own env key and pass it there",
|
|
82
|
+
},
|
|
83
|
+
'cache.driver': {
|
|
84
|
+
removedIn: '9.0.0',
|
|
85
|
+
instead:
|
|
86
|
+
"cache.tiers is the one selector — name the rung: cache: { tiers: ['request-memo', 'lru', 'redis'] }",
|
|
87
|
+
},
|
|
88
|
+
'cache.urlEnv': {
|
|
89
|
+
removedIn: '9.0.0',
|
|
90
|
+
instead:
|
|
91
|
+
"nothing read it; the redis tier reads the REDIS_URL environment variable, so name 'redis' in cache.tiers and set REDIS_URL",
|
|
92
|
+
},
|
|
93
|
+
'realtime.tier': {
|
|
94
|
+
removedIn: '10.0.0',
|
|
95
|
+
instead:
|
|
96
|
+
"nothing read it; an app's realtime tier is what it declares — a channel() topic, a live: true query, persist: true on an entity",
|
|
97
|
+
},
|
|
98
|
+
'theme.tokens': {
|
|
99
|
+
removedIn: '25.0.0',
|
|
100
|
+
instead:
|
|
101
|
+
"nothing read it; the app's theme is declared once with export const brand = defineTheme({ … }) from @ultimat3/ui, in apps/web/shared/theme.ts",
|
|
102
|
+
},
|
|
103
|
+
'ai.mcp.path': {
|
|
104
|
+
removedIn: '25.0.0',
|
|
105
|
+
instead:
|
|
106
|
+
"an MCP endpoint's path is its own: defineAppMcp({ path: '/mcp' }) from @ultimat3/mcp, in apps/<app>/mcp.ts — the default is /mcp, and every endpoint mounts where its own metadata says",
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const said = (layer: unknown, path: string): boolean => {
|
|
111
|
+
let at: unknown = layer;
|
|
112
|
+
for (const segment of path.split('.')) {
|
|
113
|
+
if (!isJsonObject(at) || !Object.hasOwn(at, segment)) return false;
|
|
114
|
+
at = at[segment];
|
|
115
|
+
}
|
|
116
|
+
// `undefined` is a layer not saying, the rule every other key follows.
|
|
117
|
+
return at !== undefined;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** The removed keys one layer still writes, in table order. */
|
|
121
|
+
export function removedKeysIn(layer: unknown): readonly string[] {
|
|
122
|
+
return Object.keys(REMOVED_CONFIG_KEYS).filter((path) => said(layer, path));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One cause line per removed key, naming the major. */
|
|
126
|
+
export const removedKeyIssue = (path: string): string =>
|
|
127
|
+
`${path} was removed in ${REMOVED_CONFIG_KEYS[path]?.removedIn ?? 'a major'} and is no longer read`;
|
|
128
|
+
|
|
129
|
+
/** One instruction per removed key: delete the line, and what replaces it. */
|
|
130
|
+
export const removedKeyFix = (path: string): string =>
|
|
131
|
+
`delete ${path} from app.config.ts — ${REMOVED_CONFIG_KEYS[path]?.instead ?? ''}`;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Single responsibility: the SHAPE half of `app.config.ts` validation — is this a section, a list,
|
|
2
|
+
// a boolean, one of a closed set — asked before any rule reads the value. Takes every key as a
|
|
3
|
+
// string and every value as `unknown`, and names no config key of its own: `config-readers` counts
|
|
4
|
+
// a property access outside the declaring files as a reader, so this file must not make one.
|
|
5
|
+
|
|
6
|
+
import { describeValue } from './error-render';
|
|
7
|
+
import { isJsonObject } from './json-object';
|
|
8
|
+
|
|
9
|
+
/** A string is worth echoing — it is the typo; anything else is described by shape. */
|
|
10
|
+
const said = (value: unknown): string =>
|
|
11
|
+
typeof value === 'string' ? `"${value}"` : describeValue(value);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every section and list one LAYER wrote, compared with the same position in `reference` — the
|
|
15
|
+
* defaults merged with no layer, so the screen is derived and never a hand list of key names.
|
|
16
|
+
* Structure only: where the reference holds a section the layer may hold a section, where it holds
|
|
17
|
+
* a list, a list. `undefined` is a layer not saying; scalars are the per-key rules' business; and
|
|
18
|
+
* a position the reference leaves `null` or `undefined` (an optional block) is not judged here.
|
|
19
|
+
*
|
|
20
|
+
* It runs BEFORE the merge and its issues end the validation, because the merge and every rule
|
|
21
|
+
* after it read through the structure: `Object.entries(null)` and `null.length` are the native
|
|
22
|
+
* `TypeError`s the validator exists to replace with an instruction.
|
|
23
|
+
*/
|
|
24
|
+
export function shapeIssues(reference: unknown, layer: unknown, issues: string[], path = ''): void {
|
|
25
|
+
if (!isJsonObject(reference) || !isJsonObject(layer)) return;
|
|
26
|
+
for (const [key, expected] of Object.entries(reference)) {
|
|
27
|
+
const at = path === '' ? key : `${path}.${key}`;
|
|
28
|
+
const value: unknown = layer[key];
|
|
29
|
+
if (value === undefined) continue;
|
|
30
|
+
if (Array.isArray(expected)) {
|
|
31
|
+
if (!Array.isArray(value)) issues.push(`${at} must be a list, not ${describeValue(value)}`);
|
|
32
|
+
} else if (isJsonObject(expected)) {
|
|
33
|
+
if (isJsonObject(value)) shapeIssues(expected, value, issues, at);
|
|
34
|
+
else issues.push(`${at} must be an object, not ${describeValue(value)}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Why `value` is not one of `allowed`, or `undefined` when it is. */
|
|
40
|
+
export function oneOfIssue(
|
|
41
|
+
key: string,
|
|
42
|
+
value: unknown,
|
|
43
|
+
allowed: readonly string[],
|
|
44
|
+
): string | undefined {
|
|
45
|
+
if (allowed.some((known) => known === value)) return undefined;
|
|
46
|
+
return `${key} ${said(value)} is not one of ${allowed.join(', ')}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `typeof`, never truthiness: an untyped config writing `'false'` — a string out of an environment
|
|
51
|
+
* variable — is truthy, so the switch it meant to turn off stayed on and nothing said so.
|
|
52
|
+
*/
|
|
53
|
+
export function booleanIssue(key: string, value: unknown): string | undefined {
|
|
54
|
+
return typeof value === 'boolean'
|
|
55
|
+
? undefined
|
|
56
|
+
: `${key} must be true or false, not ${said(value)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A route path the framework mounts or redirects to: absolute, or the browser resolves it. */
|
|
60
|
+
export function routePathIssue(key: string, value: unknown): string | undefined {
|
|
61
|
+
return typeof value === 'string' && value.startsWith('/')
|
|
62
|
+
? undefined
|
|
63
|
+
: `${key} must be a path starting with /, not ${said(value)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A list that names things: at least one entry, each a non-empty string, `what` each. */
|
|
67
|
+
export function nameListIssues(
|
|
68
|
+
key: string,
|
|
69
|
+
list: readonly unknown[],
|
|
70
|
+
what: string,
|
|
71
|
+
issues: string[],
|
|
72
|
+
): void {
|
|
73
|
+
if (list.length === 0) issues.push(`${key} must list at least one ${what}`);
|
|
74
|
+
for (const entry of list) {
|
|
75
|
+
if (typeof entry !== 'string' || entry.trim() === '') {
|
|
76
|
+
issues.push(`${key} contains ${said(entry)}, not a ${what} name`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/config-site.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Split from `config.ts` for `config-pwa.ts`' reason: that file sits at its 500-line ceiling.
|
|
4
4
|
|
|
5
5
|
import { type Input, layered } from './config-merge';
|
|
6
|
+
import { describeValue } from './error-render';
|
|
6
7
|
|
|
7
8
|
export interface SiteConfig {
|
|
8
9
|
/**
|
|
@@ -108,10 +109,20 @@ export function siteIssues(config: SiteSections, issues: string[]): void {
|
|
|
108
109
|
const issue = originIssue(origin);
|
|
109
110
|
if (issue !== undefined) issues.push(issue);
|
|
110
111
|
}
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
// `unknown` entries: an untyped config reaches here with whatever it listed, and `5.startsWith`
|
|
113
|
+
// was a native `TypeError` thrown by the validator itself.
|
|
114
|
+
for (const path of config.seo.robots.disallow as readonly unknown[]) {
|
|
115
|
+
if (typeof path !== 'string') {
|
|
116
|
+
issues.push(`seo.robots.disallow entry must be a path string, not ${describeValue(path)}`);
|
|
117
|
+
} else if (!path.startsWith('/')) {
|
|
118
|
+
issues.push(`seo.robots.disallow entry "${path}" must start with /`);
|
|
119
|
+
}
|
|
113
120
|
}
|
|
114
|
-
for (const path of config.seo.sitemap.extra) {
|
|
121
|
+
for (const path of config.seo.sitemap.extra as readonly unknown[]) {
|
|
122
|
+
if (typeof path !== 'string') {
|
|
123
|
+
issues.push(`seo.sitemap.extra entry must be a path string, not ${describeValue(path)}`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
115
126
|
// A PATH, never a URL: every `<loc>` is built against the one declared origin, and a query or
|
|
116
127
|
// a fragment names a variant of a page, which a sitemap lists by its canonical URL alone.
|
|
117
128
|
if (!path.startsWith('/') || path.startsWith('//') || /[?#]/.test(path)) {
|