@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "22.14.0",
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.14.0"
43
+ "@ultimat3/schema": "22.15.0"
44
44
  }
45
45
  }
@@ -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?: { readonly client?: readonly NavigationSurface[] | undefined } | undefined;
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
- /** A whole-value key: the last layer that listed surfaces wins, as `locales` does. */
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 { navigation: { client } };
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,