@ultimat3/core 22.14.0 → 22.15.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/package.json +2 -2
- package/src/config-navigation.ts +122 -3
- package/src/index.ts +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.15.0",
|
|
4
4
|
"description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -40,6 +40,6 @@
|
|
|
40
40
|
"test": "bun test"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@ultimat3/schema": "22.
|
|
43
|
+
"@ultimat3/schema": "22.15.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/config-navigation.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// were: shape, merge and screen are one subject.
|
|
7
7
|
|
|
8
8
|
import { describeValue } from './error-render';
|
|
9
|
+
import { ConfigInvalidError } from './errors';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* The surfaces that render documents a browser navigates between. `api` answers JSON and `shared`
|
|
@@ -23,28 +24,146 @@ export type NavigationSurface = (typeof NAVIGATION_SURFACES)[number];
|
|
|
23
24
|
*/
|
|
24
25
|
export interface NavigationConfig {
|
|
25
26
|
readonly client: readonly NavigationSurface[];
|
|
27
|
+
readonly speculation: SpeculationConfig;
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How eagerly a browser may fetch a link's document before the click, on a page that carries no
|
|
32
|
+
* client router. `'moderate'`: on pointer rest or pointer down. `'conservative'`: on pointer down
|
|
33
|
+
* only. `'eager'` and `'immediate'` are not offered — they fetch links nobody pointed at.
|
|
34
|
+
*/
|
|
35
|
+
export const SPECULATION_EAGERNESS = ['moderate', 'conservative'] as const;
|
|
36
|
+
export type SpeculationEagerness = (typeof SPECULATION_EAGERNESS)[number];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Speculation Rules (`<script type="speculationrules">`) for the documents a browser navigates
|
|
40
|
+
* between WITHOUT the client router — the 0kb answer to a slow full-page load. PREFETCH only, never
|
|
41
|
+
* prerender: a prefetch runs no script of the next page, so no analytics hit and no island boots
|
|
42
|
+
* for a page nobody opened. ON BY DEFAULT at `'moderate'`; `prefetch: false` emits nothing.
|
|
43
|
+
*
|
|
44
|
+
* Only pages the route table knows to be a pure read are candidates (`@ultimat3/cli`'s
|
|
45
|
+
* `page-speculation.ts`); `exclude` removes more, as URL patterns (`/blog/*`, `/legal/:doc`).
|
|
46
|
+
*/
|
|
47
|
+
export interface SpeculationConfig {
|
|
48
|
+
readonly prefetch: SpeculationEagerness | false;
|
|
49
|
+
readonly exclude: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const DEFAULT_SPECULATION: SpeculationConfig = Object.freeze({
|
|
53
|
+
prefetch: 'moderate',
|
|
54
|
+
exclude: Object.freeze([]) as readonly string[],
|
|
55
|
+
});
|
|
56
|
+
|
|
28
57
|
export interface NavigationSection {
|
|
29
58
|
readonly navigation: NavigationConfig;
|
|
30
59
|
}
|
|
31
60
|
|
|
32
61
|
export interface NavigationSectionInput {
|
|
33
|
-
readonly navigation?:
|
|
62
|
+
readonly navigation?:
|
|
63
|
+
| {
|
|
64
|
+
readonly client?: readonly NavigationSurface[] | undefined;
|
|
65
|
+
readonly speculation?:
|
|
66
|
+
| {
|
|
67
|
+
readonly prefetch?: SpeculationEagerness | false | undefined;
|
|
68
|
+
readonly exclude?: readonly string[] | undefined;
|
|
69
|
+
}
|
|
70
|
+
| undefined;
|
|
71
|
+
}
|
|
72
|
+
| undefined;
|
|
34
73
|
}
|
|
35
74
|
|
|
36
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Whole-value keys: the last layer that listed surfaces wins, as `locales` does — and so does the
|
|
77
|
+
* last one that set `speculation.prefetch` or listed `speculation.exclude`, each on its own.
|
|
78
|
+
*/
|
|
37
79
|
export function mergeNavigation(layers: readonly NavigationSectionInput[]): NavigationSection {
|
|
38
80
|
let client: readonly NavigationSurface[] = [];
|
|
81
|
+
let prefetch: SpeculationEagerness | false = DEFAULT_SPECULATION.prefetch;
|
|
82
|
+
let exclude: readonly string[] = DEFAULT_SPECULATION.exclude;
|
|
83
|
+
// A layer that wrote something other than an object (`speculation: 'off'`, `null`, a list) has
|
|
84
|
+
// no key to merge. It is carried through AS WRITTEN so `navigationIssues` refuses it — dropped
|
|
85
|
+
// here, the app would run at the default it believed it had turned off.
|
|
86
|
+
let unmergeable: { readonly said: unknown } | undefined;
|
|
39
87
|
for (const layer of layers) {
|
|
40
88
|
const said = layer.navigation?.client;
|
|
41
89
|
if (said !== undefined) client = said;
|
|
90
|
+
const speculation: unknown = layer.navigation?.speculation;
|
|
91
|
+
if (speculation === undefined) continue;
|
|
92
|
+
if (!isSpeculationObject(speculation)) {
|
|
93
|
+
unmergeable ??= { said: speculation };
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (speculation.prefetch !== undefined) prefetch = speculation.prefetch;
|
|
97
|
+
if (speculation.exclude !== undefined) exclude = speculation.exclude;
|
|
98
|
+
}
|
|
99
|
+
const speculation =
|
|
100
|
+
unmergeable === undefined ? { prefetch, exclude } : (unmergeable.said as SpeculationConfig);
|
|
101
|
+
return { navigation: { client, speculation } };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type SpeculationInput = NonNullable<
|
|
105
|
+
NonNullable<NavigationSectionInput['navigation']>['speculation']
|
|
106
|
+
>;
|
|
107
|
+
|
|
108
|
+
const isSpeculationObject = (value: unknown): value is SpeculationInput =>
|
|
109
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A pattern is emitted inside a JSON string the browser parses as a URL pattern: it must be a
|
|
113
|
+
* same-origin PATH, so anything not starting with `/` (a host, a scheme, `*`) is refused.
|
|
114
|
+
*/
|
|
115
|
+
function speculationIssues(speculation: unknown, issues: string[]): void {
|
|
116
|
+
if (!isSpeculationObject(speculation)) {
|
|
117
|
+
issues.push(`navigation.speculation must be an object, not ${describeValue(speculation)}`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const { prefetch, exclude } = speculation as { prefetch?: unknown; exclude?: unknown };
|
|
121
|
+
if (prefetch !== false && !SPECULATION_EAGERNESS.some((known) => known === prefetch)) {
|
|
122
|
+
issues.push(
|
|
123
|
+
`navigation.speculation.prefetch must be ${SPECULATION_EAGERNESS.join(', ')} or false, not ${describeValue(prefetch)}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(exclude)) {
|
|
127
|
+
issues.push(
|
|
128
|
+
`navigation.speculation.exclude must be a list of URL patterns, not ${describeValue(exclude)}`,
|
|
129
|
+
);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
for (const pattern of exclude as readonly unknown[]) {
|
|
133
|
+
if (typeof pattern !== 'string' || !pattern.startsWith('/')) {
|
|
134
|
+
issues.push(
|
|
135
|
+
`navigation.speculation.exclude contains ${describeValue(pattern)}, not a path pattern starting with "/"`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
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
|
+
});
|
|
42
160
|
}
|
|
43
|
-
return
|
|
161
|
+
return speculation;
|
|
44
162
|
}
|
|
45
163
|
|
|
46
164
|
/** Appends every refusal the section earns to `issues`, `config.ts`' one list. */
|
|
47
165
|
export function navigationIssues(config: NavigationSection, issues: string[]): void {
|
|
166
|
+
speculationIssues(config.navigation.speculation, issues);
|
|
48
167
|
// `unknown`: an untyped config file reaches this validator with whatever it wrote.
|
|
49
168
|
const client: unknown = config.navigation.client;
|
|
50
169
|
if (!Array.isArray(client)) {
|
package/src/index.ts
CHANGED
|
@@ -119,8 +119,15 @@ export type {
|
|
|
119
119
|
NavigationSection,
|
|
120
120
|
NavigationSectionInput,
|
|
121
121
|
NavigationSurface,
|
|
122
|
+
SpeculationConfig,
|
|
123
|
+
SpeculationEagerness,
|
|
124
|
+
} from './config-navigation';
|
|
125
|
+
export {
|
|
126
|
+
DEFAULT_SPECULATION,
|
|
127
|
+
NAVIGATION_SURFACES,
|
|
128
|
+
resolveSpeculation,
|
|
129
|
+
SPECULATION_EAGERNESS,
|
|
122
130
|
} from './config-navigation';
|
|
123
|
-
export { NAVIGATION_SURFACES } from './config-navigation';
|
|
124
131
|
export type {
|
|
125
132
|
PwaColors,
|
|
126
133
|
PwaConfig,
|