@ultimat3/cli 22.9.1 → 22.11.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 +28 -28
- package/src/browser-launcher-port.ts +12 -0
- package/src/cdp-shot-driver.ts +14 -1
- package/src/cdp-shot-page.ts +12 -3
- package/src/cmd-shot-island.ts +5 -0
- package/src/cmd-shot-spec.ts +7 -1
- package/src/cmd-shot.ts +12 -0
- package/src/dev-route-table.ts +1 -1
- package/src/error-codes.ts +2 -0
- package/src/island-capture.ts +15 -5
- package/src/island-harness-script.ts +18 -0
- package/src/island-verdict.ts +6 -0
- package/src/mcp-errors.ts +2 -0
- package/src/seo-routes.ts +5 -1
- package/src/serve-boot.ts +1 -1
- package/src/shot-cookie.ts +45 -0
- package/src/site-config.ts +29 -2
- package/src/site-seo.ts +82 -3
- package/src/sitemap-lastmod.ts +73 -0
- package/src/templates/scaffold-entries.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.11.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,33 +38,33 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@babel/core": "^8.0.1",
|
|
41
|
-
"@ultimat3/action": "22.
|
|
42
|
-
"@ultimat3/admin": "22.
|
|
43
|
-
"@ultimat3/ai": "22.
|
|
44
|
-
"@ultimat3/auth": "22.
|
|
45
|
-
"@ultimat3/cache": "22.
|
|
46
|
-
"@ultimat3/core": "22.
|
|
47
|
-
"@ultimat3/db": "22.
|
|
48
|
-
"@ultimat3/entity": "22.
|
|
49
|
-
"@ultimat3/flags": "22.
|
|
50
|
-
"@ultimat3/http": "22.
|
|
51
|
-
"@ultimat3/i18n": "22.
|
|
52
|
-
"@ultimat3/jobs": "22.
|
|
53
|
-
"@ultimat3/mail": "22.
|
|
54
|
-
"@ultimat3/manifest": "22.
|
|
55
|
-
"@ultimat3/mcp": "22.
|
|
56
|
-
"@ultimat3/money": "22.
|
|
57
|
-
"@ultimat3/notify": "22.
|
|
58
|
-
"@ultimat3/policy": "22.
|
|
59
|
-
"@ultimat3/pwa": "22.
|
|
60
|
-
"@ultimat3/query": "22.
|
|
61
|
-
"@ultimat3/realtime": "22.
|
|
62
|
-
"@ultimat3/render": "22.
|
|
63
|
-
"@ultimat3/schema": "22.
|
|
64
|
-
"@ultimat3/seo": "22.
|
|
65
|
-
"@ultimat3/storage": "22.
|
|
66
|
-
"@ultimat3/testing": "22.
|
|
67
|
-
"@ultimat3/time": "22.
|
|
41
|
+
"@ultimat3/action": "22.11.0",
|
|
42
|
+
"@ultimat3/admin": "22.11.0",
|
|
43
|
+
"@ultimat3/ai": "22.11.0",
|
|
44
|
+
"@ultimat3/auth": "22.11.0",
|
|
45
|
+
"@ultimat3/cache": "22.11.0",
|
|
46
|
+
"@ultimat3/core": "22.11.0",
|
|
47
|
+
"@ultimat3/db": "22.11.0",
|
|
48
|
+
"@ultimat3/entity": "22.11.0",
|
|
49
|
+
"@ultimat3/flags": "22.11.0",
|
|
50
|
+
"@ultimat3/http": "22.11.0",
|
|
51
|
+
"@ultimat3/i18n": "22.11.0",
|
|
52
|
+
"@ultimat3/jobs": "22.11.0",
|
|
53
|
+
"@ultimat3/mail": "22.11.0",
|
|
54
|
+
"@ultimat3/manifest": "22.11.0",
|
|
55
|
+
"@ultimat3/mcp": "22.11.0",
|
|
56
|
+
"@ultimat3/money": "22.11.0",
|
|
57
|
+
"@ultimat3/notify": "22.11.0",
|
|
58
|
+
"@ultimat3/policy": "22.11.0",
|
|
59
|
+
"@ultimat3/pwa": "22.11.0",
|
|
60
|
+
"@ultimat3/query": "22.11.0",
|
|
61
|
+
"@ultimat3/realtime": "22.11.0",
|
|
62
|
+
"@ultimat3/render": "22.11.0",
|
|
63
|
+
"@ultimat3/schema": "22.11.0",
|
|
64
|
+
"@ultimat3/seo": "22.11.0",
|
|
65
|
+
"@ultimat3/storage": "22.11.0",
|
|
66
|
+
"@ultimat3/testing": "22.11.0",
|
|
67
|
+
"@ultimat3/time": "22.11.0",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
|
@@ -152,6 +152,18 @@ export interface ShotSessionInit {
|
|
|
152
152
|
* the locale asked for, never of whatever language the machine's Chrome happens to speak.
|
|
153
153
|
*/
|
|
154
154
|
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
155
|
+
/**
|
|
156
|
+
* Set in the browser before the first navigation, each for `url`'s origin — `x shot --cookie`, so
|
|
157
|
+
* a page is photographed with its consent (or any other cookie-held choice) already decided.
|
|
158
|
+
*/
|
|
159
|
+
readonly cookies?: readonly ShotCookie[] | undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** One cookie for a shot session: `url` scopes it to the app under test, never another host. */
|
|
163
|
+
export interface ShotCookie {
|
|
164
|
+
readonly name: string;
|
|
165
|
+
readonly value: string;
|
|
166
|
+
readonly url: string;
|
|
155
167
|
}
|
|
156
168
|
|
|
157
169
|
export interface ShotSession {
|
package/src/cdp-shot-driver.ts
CHANGED
|
@@ -139,6 +139,15 @@ export function cdpShotDriver(options: CdpShotDriverOptions): ShotDriver {
|
|
|
139
139
|
if (init.headers !== undefined && Object.keys(init.headers).length > 0) {
|
|
140
140
|
await on('Network.setExtraHTTPHeaders', { headers: init.headers });
|
|
141
141
|
}
|
|
142
|
+
// `Network.setCookie` needs the Network domain on and precedes `goto` for the header's
|
|
143
|
+
// reason: a consent banner decided after the first paint is a picture of the banner.
|
|
144
|
+
for (const cookie of init.cookies ?? []) {
|
|
145
|
+
await on('Network.setCookie', {
|
|
146
|
+
name: cookie.name,
|
|
147
|
+
value: cookie.value,
|
|
148
|
+
url: cookie.url,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
142
151
|
const size = init.viewport ?? viewport;
|
|
143
152
|
await on('Emulation.setDeviceMetricsOverride', {
|
|
144
153
|
width: size.width,
|
|
@@ -146,7 +155,11 @@ export function cdpShotDriver(options: CdpShotDriverOptions): ShotDriver {
|
|
|
146
155
|
deviceScaleFactor: 1,
|
|
147
156
|
mobile: false,
|
|
148
157
|
});
|
|
149
|
-
|
|
158
|
+
// A classic scrollbar takes ~15px of the layout, so a page "at 1440" was laid out at 1425 and
|
|
159
|
+
// a full-page capture came back 1425 wide. Hidden, the page is laid out at the width asked
|
|
160
|
+
// for, which is what a phone's overlay scrollbar and the reviewer's expectation both are.
|
|
161
|
+
await on('Emulation.setScrollbarsHidden', { hidden: true });
|
|
162
|
+
return { page: cdpShotPage({ connection, sessionId, init, watch, viewport: size }), close };
|
|
150
163
|
} catch (error) {
|
|
151
164
|
await close();
|
|
152
165
|
throw error;
|
package/src/cdp-shot-page.ts
CHANGED
|
@@ -42,6 +42,8 @@ export interface CdpShotPageInput {
|
|
|
42
42
|
readonly sessionId: string;
|
|
43
43
|
readonly init: ShotSessionInit;
|
|
44
44
|
readonly watch: PageWatch;
|
|
45
|
+
/** What the page is laid out in: a full-page capture is never narrower than it. */
|
|
46
|
+
readonly viewport?: { readonly width: number; readonly height: number } | undefined;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export function cdpShotPage(input: CdpShotPageInput): ShotPage {
|
|
@@ -164,7 +166,7 @@ export function cdpShotPage(input: CdpShotPageInput): ShotPage {
|
|
|
164
166
|
),
|
|
165
167
|
query,
|
|
166
168
|
evaluate,
|
|
167
|
-
screenshot: (options?: ShotCapture) => capture(send, options ?? {}),
|
|
169
|
+
screenshot: (options?: ShotCapture) => capture(send, options ?? {}, input.viewport?.width ?? 0),
|
|
168
170
|
async colorScheme(scheme: ShotColorScheme) {
|
|
169
171
|
// An EMPTY list is CDP's reset; an explicit `no-preference` would be an override.
|
|
170
172
|
const features =
|
|
@@ -191,7 +193,7 @@ type Send = (
|
|
|
191
193
|
* PNG bytes of the viewport, the whole document, or one clip — never both of the last two, which
|
|
192
194
|
* CDP would resolve silently in favour of one. A clip is CSS pixels in page coordinates.
|
|
193
195
|
*/
|
|
194
|
-
async function capture(send: Send, framing: ShotCapture): Promise<Uint8Array> {
|
|
196
|
+
async function capture(send: Send, framing: ShotCapture, minWidth: number): Promise<Uint8Array> {
|
|
195
197
|
const clip = framing.clip;
|
|
196
198
|
assert(
|
|
197
199
|
!(framing.fullPage === true && clip !== undefined),
|
|
@@ -212,7 +214,14 @@ async function capture(send: Send, framing: ShotCapture): Promise<Uint8Array> {
|
|
|
212
214
|
const width = metrics?.['width'];
|
|
213
215
|
const height = metrics?.['height'];
|
|
214
216
|
if (typeof width === 'number' && typeof height === 'number') {
|
|
215
|
-
|
|
217
|
+
// Floored at the viewport: the content size is the LAYOUT width, which a scrollbar (or a
|
|
218
|
+
// document narrower than the window) makes smaller than the picture that was asked for.
|
|
219
|
+
region = {
|
|
220
|
+
x: 0,
|
|
221
|
+
y: 0,
|
|
222
|
+
width: Math.max(minWidth, Math.ceil(width)),
|
|
223
|
+
height: Math.ceil(height),
|
|
224
|
+
};
|
|
216
225
|
}
|
|
217
226
|
}
|
|
218
227
|
const answer = await send('Page.captureScreenshot', {
|
package/src/cmd-shot-island.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
islandVerdictJson,
|
|
27
27
|
} from './island-verdict';
|
|
28
28
|
import type { CommandResult } from './output';
|
|
29
|
+
import type { ShotCookiePair } from './shot-cookie';
|
|
29
30
|
import type { ShotServer } from './shot-server';
|
|
30
31
|
import { SHOT_DIR } from './shot-server';
|
|
31
32
|
|
|
@@ -152,6 +153,8 @@ export interface IslandShotInput {
|
|
|
152
153
|
readonly settleMs: number;
|
|
153
154
|
readonly timeoutMs: number;
|
|
154
155
|
readonly extraHosts?: string | undefined;
|
|
156
|
+
/** `--cookie`, set in every state's session for the harness's own origin. */
|
|
157
|
+
readonly cookies?: readonly ShotCookiePair[] | undefined;
|
|
155
158
|
readonly executablePath?: string | undefined;
|
|
156
159
|
/** A provider's session, or a sidecar. One attach per viewport, memoised like a launch. */
|
|
157
160
|
readonly cdpUrl?: string | undefined;
|
|
@@ -195,6 +198,7 @@ export async function islandShot(input: IslandShotInput): Promise<IslandArtifact
|
|
|
195
198
|
settleMs: input.settleMs,
|
|
196
199
|
timeoutMs: input.timeoutMs,
|
|
197
200
|
...(input.extraHosts === undefined ? {} : { extraHosts: input.extraHosts }),
|
|
201
|
+
...(input.cookies === undefined ? {} : { cookies: input.cookies }),
|
|
198
202
|
...(input.minBytes === undefined ? {} : { minBytes: input.minBytes }),
|
|
199
203
|
});
|
|
200
204
|
}
|
|
@@ -225,6 +229,7 @@ export async function islandSweep(input: IslandSweepInput): Promise<IslandSweepA
|
|
|
225
229
|
settleMs: input.settleMs,
|
|
226
230
|
timeoutMs: input.timeoutMs,
|
|
227
231
|
...(input.extraHosts === undefined ? {} : { extraHosts: input.extraHosts }),
|
|
232
|
+
...(input.cookies === undefined ? {} : { cookies: input.cookies }),
|
|
228
233
|
...(input.minBytes === undefined ? {} : { minBytes: input.minBytes }),
|
|
229
234
|
});
|
|
230
235
|
}
|
package/src/cmd-shot-spec.ts
CHANGED
|
@@ -7,7 +7,7 @@ export const shotSpec: CommandSpec = {
|
|
|
7
7
|
name: 'shot',
|
|
8
8
|
summary: 'photograph one route, one island in a state it declares, or every island in the app',
|
|
9
9
|
usage:
|
|
10
|
-
'x shot <route> [--locale <l>] [--theme light|dark] | --matrix [<route>] | --island <name> [--state <id>] | --all-islands [--expect-status 404] [--port 0] [--out <dir>] [--settle 2000] [--json]',
|
|
10
|
+
'x shot <route> [--locale <l>] [--theme light|dark] | --matrix [<route>] | --island <name> [--state <id>] | --all-islands [--cookie <name=value>] [--expect-status 404] [--port 0] [--out <dir>] [--settle 2000] [--json]',
|
|
11
11
|
requiresApp: true,
|
|
12
12
|
flags: [
|
|
13
13
|
{ name: 'port', type: 'string', summary: 'dev port (0 lets the kernel pick a free one)' },
|
|
@@ -28,6 +28,12 @@ export const shotSpec: CommandSpec = {
|
|
|
28
28
|
summary: 'attach to a browser somebody else is running (a provider session, a sidecar)',
|
|
29
29
|
},
|
|
30
30
|
{ name: 'allow-hosts', type: 'string', summary: 'extra hosts the page may request' },
|
|
31
|
+
{
|
|
32
|
+
name: 'cookie',
|
|
33
|
+
type: 'string',
|
|
34
|
+
summary:
|
|
35
|
+
'name=value[,name=value] set for the app before the first navigation (e.g. consent=granted)',
|
|
36
|
+
},
|
|
31
37
|
{
|
|
32
38
|
name: 'theme',
|
|
33
39
|
type: 'string',
|
package/src/cmd-shot.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type { CommandResult } from './output';
|
|
|
32
32
|
import type { ParsedArgs } from './parse';
|
|
33
33
|
import { flagBool, flagString } from './parse';
|
|
34
34
|
import { shotBrowserChoice } from './shot-browser';
|
|
35
|
+
import { readCookieFlag, type ShotCookiePair, shotCookies } from './shot-cookie';
|
|
35
36
|
import {
|
|
36
37
|
acceptLanguageHeaders,
|
|
37
38
|
loadShotLocales,
|
|
@@ -218,6 +219,11 @@ export interface ShotRun {
|
|
|
218
219
|
* app's default locale — so a picture never depends on the language of the machine's Chrome.
|
|
219
220
|
*/
|
|
220
221
|
readonly acceptLanguage?: string | undefined;
|
|
222
|
+
/**
|
|
223
|
+
* `--cookie`: set before the first navigation, each for the server's own URL once it is up —
|
|
224
|
+
* so a consent banner (or any cookie-held choice) is decided in the picture.
|
|
225
|
+
*/
|
|
226
|
+
readonly cookies?: readonly ShotCookiePair[] | undefined;
|
|
221
227
|
readonly now?: (() => Date) | undefined;
|
|
222
228
|
/**
|
|
223
229
|
* `--expect-status`: the document status this shot is ok with — `404` to photograph the not-found
|
|
@@ -263,6 +269,9 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
|
|
|
263
269
|
...(options.acceptLanguage === undefined
|
|
264
270
|
? {}
|
|
265
271
|
: { headers: acceptLanguageHeaders(options.acceptLanguage) }),
|
|
272
|
+
...(options.cookies === undefined || options.cookies.length === 0
|
|
273
|
+
? {}
|
|
274
|
+
: { cookies: shotCookies(options.cookies, new URL(server.url).toString()) }),
|
|
266
275
|
});
|
|
267
276
|
const page = session.page;
|
|
268
277
|
if (options.colorScheme !== undefined) {
|
|
@@ -354,6 +363,7 @@ export const shotCommand: CliCommand = {
|
|
|
354
363
|
const positional = ctx.args.positionals[0];
|
|
355
364
|
const sweep = flagBool(ctx.args, 'all-islands');
|
|
356
365
|
const matrix = flagBool(ctx.args, 'matrix');
|
|
366
|
+
const cookies = readCookieFlag(flagString(ctx.args, 'cookie'));
|
|
357
367
|
const app = await loadShotLocales(root);
|
|
358
368
|
const locale = readLocaleFlag(flagString(ctx.args, 'locale'), app);
|
|
359
369
|
// Every ambiguous pair refused BY NAME, before a value is read: a reader who typed two
|
|
@@ -424,6 +434,7 @@ export const shotCommand: CliCommand = {
|
|
|
424
434
|
...(flagString(ctx.args, 'allow-hosts') === undefined
|
|
425
435
|
? {}
|
|
426
436
|
: { extraHosts: flagString(ctx.args, 'allow-hosts') }),
|
|
437
|
+
...(cookies.length === 0 ? {} : { cookies }),
|
|
427
438
|
boot,
|
|
428
439
|
};
|
|
429
440
|
if (sweep) return islandSweepResult(await islandSweep(shared));
|
|
@@ -444,6 +455,7 @@ export const shotCommand: CliCommand = {
|
|
|
444
455
|
fullPage: flagBool(ctx.args, 'full'),
|
|
445
456
|
extraHosts: flagString(ctx.args, 'allow-hosts'),
|
|
446
457
|
...(expectStatus === undefined ? {} : { expectStatus }),
|
|
458
|
+
...(cookies.length === 0 ? {} : { cookies }),
|
|
447
459
|
};
|
|
448
460
|
if (matrix) {
|
|
449
461
|
// `--locale` and `--theme` narrow the matrix to one value of their axis.
|
package/src/dev-route-table.ts
CHANGED
|
@@ -127,7 +127,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
127
127
|
// captured at boot would answer 404 for the href the document now carries.
|
|
128
128
|
...styleRoutes(() => styleBundle()),
|
|
129
129
|
// `robots.txt` and `sitemap.xml`, the same two files the static export writes (`site-seo.ts`).
|
|
130
|
-
...seoRoutes({ env: input.env, site }),
|
|
130
|
+
...seoRoutes({ env: input.env, site, root: input.root }),
|
|
131
131
|
// `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
|
|
132
132
|
// lives here rather than in a second server because everything it needs is in THIS process:
|
|
133
133
|
// the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
|
package/src/error-codes.ts
CHANGED
|
@@ -202,6 +202,7 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
202
202
|
'X_VERIFY_SHARD_INVALID',
|
|
203
203
|
'X_VERIFY_MERGE_INCOMPLETE',
|
|
204
204
|
'X_VERIFY_MERGE_INPUT',
|
|
205
|
+
'X_SITEMAP_EXTRA_INVALID',
|
|
205
206
|
] as const;
|
|
206
207
|
|
|
207
208
|
/**
|
|
@@ -353,6 +354,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
353
354
|
X_VERIFY_SHARD_INVALID: 'x verify --shard was given a spec or a step list it cannot split',
|
|
354
355
|
X_VERIFY_MERGE_INCOMPLETE: 'x verify merge was handed parts that do not add up to the whole gate',
|
|
355
356
|
X_VERIFY_MERGE_INPUT: 'a part handed to x verify merge is not an x verify --json document',
|
|
357
|
+
X_SITEMAP_EXTRA_INVALID: 'a seo.sitemap.extra path names no public page',
|
|
356
358
|
};
|
|
357
359
|
|
|
358
360
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
package/src/island-capture.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { readinessProbe } from './island-harness-script';
|
|
|
14
14
|
import { IslandRequestUnstubbedError, IslandUnphotographableError } from './island-shot-errors';
|
|
15
15
|
import type { IslandReadiness, IslandStateShot } from './island-verdict';
|
|
16
16
|
import { parseReadiness } from './island-verdict';
|
|
17
|
+
import { type ShotCookiePair, shotCookies } from './shot-cookie';
|
|
17
18
|
import type { ShotServer } from './shot-server';
|
|
18
19
|
import { allowHostsFrom } from './shot-server';
|
|
19
20
|
import { SETTLE_POLL_MS, settleReadiness } from './shot-settle';
|
|
@@ -120,7 +121,8 @@ export function photographFault(
|
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
/**
|
|
123
|
-
* The capture rectangle for a readiness answer, in PAGE coordinates: the crop target's
|
|
124
|
+
* The capture rectangle for a readiness answer, in PAGE coordinates: the crop target's frame (its
|
|
125
|
+
* own box grown to every open popup),
|
|
124
126
|
* translated out of the viewport coordinates `getBoundingClientRect()` answers in, then grown by
|
|
125
127
|
* `margin` on every side and CLAMPED to the document.
|
|
126
128
|
*
|
|
@@ -136,10 +138,13 @@ export function photographFault(
|
|
|
136
138
|
* the parser's floor and not a second opinion.
|
|
137
139
|
*/
|
|
138
140
|
export function clipFor(seen: IslandReadiness | null, margin: number): CaptureClip {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
const
|
|
141
|
+
// The frame, not the box: an open listbox, menu or popover lies outside the island's own box,
|
|
142
|
+
// and cropping to the box photographed an `open` state closed. The frame always contains the box.
|
|
143
|
+
const framed = seen?.frame ?? seen?.box;
|
|
144
|
+
const x = (framed?.x ?? 0) + (seen?.scroll.x ?? 0);
|
|
145
|
+
const y = (framed?.y ?? 0) + (seen?.scroll.y ?? 0);
|
|
146
|
+
const width = framed?.width ?? 0;
|
|
147
|
+
const height = framed?.height ?? 0;
|
|
143
148
|
const left = Math.max(0, x - margin);
|
|
144
149
|
const top = Math.max(0, y - margin);
|
|
145
150
|
const right = Math.min(seen?.page.width ?? x + width, x + width + margin);
|
|
@@ -159,6 +164,8 @@ export interface IslandCaptureRun {
|
|
|
159
164
|
readonly timeoutMs: number;
|
|
160
165
|
readonly extraHosts?: string | undefined;
|
|
161
166
|
readonly cropMarginPx?: number | undefined;
|
|
167
|
+
/** `--cookie`, scoped to the harness's server once it is up. */
|
|
168
|
+
readonly cookies?: readonly ShotCookiePair[] | undefined;
|
|
162
169
|
}
|
|
163
170
|
|
|
164
171
|
const quietly = async (stop: () => Promise<void>): Promise<void> => {
|
|
@@ -189,6 +196,9 @@ export async function captureIslandState(
|
|
|
189
196
|
rules: { allowHosts: allowHostsFrom(server.url, options.extraHosts) },
|
|
190
197
|
clock: systemShotClock,
|
|
191
198
|
timeoutMs: options.timeoutMs,
|
|
199
|
+
...(options.cookies === undefined || options.cookies.length === 0
|
|
200
|
+
? {}
|
|
201
|
+
: { cookies: shotCookies(options.cookies, new URL(server.url).toString()) }),
|
|
192
202
|
});
|
|
193
203
|
const page = session.page;
|
|
194
204
|
// BEFORE the navigation, so the first paint already has it: `prefers-color-scheme` is a live
|
|
@@ -179,11 +179,28 @@ export function harnessScript(options: HarnessScriptOptions): string {
|
|
|
179
179
|
*
|
|
180
180
|
* `selector` is the crop target the manifest declared; the island's own host element when absent.
|
|
181
181
|
*/
|
|
182
|
+
/**
|
|
183
|
+
* What an island shows OUTSIDE its own box while a state is open: a combobox's listbox, a menu, a
|
|
184
|
+
* popover or a dialog — often portalled to `<body>`, so no descendant walk would find it. The crop
|
|
185
|
+
* is the union of the target's box and every visible match, so an `open` state photographs open.
|
|
186
|
+
* `:popover-open` is last and dropped on an engine that cannot parse it.
|
|
187
|
+
*/
|
|
188
|
+
export const POPUP_SELECTOR =
|
|
189
|
+
'[role=listbox],[role=menu],[role=dialog],[role=alertdialog],[role=tooltip],dialog[open],:popover-open';
|
|
190
|
+
|
|
182
191
|
export const readinessProbe = (selector: string): string =>
|
|
183
192
|
`(function(){var W=window.${HARNESS_GLOBAL}||{};` +
|
|
184
193
|
'var host=document.querySelector("[data-x-island]");' +
|
|
185
194
|
`var box=document.querySelector(${JSON.stringify(selector)})||host;` +
|
|
186
195
|
'var r=box?box.getBoundingClientRect():{width:0,height:0,x:0,y:0};' +
|
|
196
|
+
// The frame: the box grown to every open popup the page shows, in the same viewport coordinates.
|
|
197
|
+
'var fl=r.x,ft=r.y,fr=r.x+r.width,fb=r.y+r.height,ps=[];' +
|
|
198
|
+
`try{ps=document.querySelectorAll(${JSON.stringify(POPUP_SELECTOR)})}` +
|
|
199
|
+
`catch(e){ps=document.querySelectorAll(${JSON.stringify(POPUP_SELECTOR.replace(',:popover-open', ''))})}` +
|
|
200
|
+
'for(var i=0;box&&i<ps.length;i++){var s=getComputedStyle(ps[i]);' +
|
|
201
|
+
'if(s.display==="none"||s.visibility==="hidden")continue;var q=ps[i].getBoundingClientRect();' +
|
|
202
|
+
'if(q.width===0||q.height===0)continue;fl=Math.min(fl,q.x);ft=Math.min(ft,q.y);' +
|
|
203
|
+
'fr=Math.max(fr,q.x+q.width);fb=Math.max(fb,q.y+q.height)}' +
|
|
187
204
|
'return{harness:W.harness===true,ready:W.ready===true,' +
|
|
188
205
|
'unstubbed:(W.unstubbed||[]).slice(),' +
|
|
189
206
|
// Recorded beside `unstubbed`, never merged into it: a socket a component opened and never
|
|
@@ -198,6 +215,7 @@ export const readinessProbe = (selector: string): string =>
|
|
|
198
215
|
// and rendered nothing is the silence a non-zero box would otherwise read as success.
|
|
199
216
|
'filled:box?(box.children.length>0||(box.textContent||"").trim().length>0):false,' +
|
|
200
217
|
'box:{x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)},' +
|
|
218
|
+
'frame:{x:Math.round(fl),y:Math.round(ft),width:Math.round(fr-fl),height:Math.round(fb-ft)},' +
|
|
201
219
|
// The box is VIEWPORT coordinates — what `getBoundingClientRect()` answers — and a capture clip
|
|
202
220
|
// is PAGE coordinates. They agree only while the page is at the origin, which is the one case a
|
|
203
221
|
// harness happens to be in and is not a rule anything enforces: a state whose component sits
|
package/src/island-verdict.ts
CHANGED
|
@@ -67,6 +67,11 @@ export interface IslandReadiness {
|
|
|
67
67
|
readonly filled: boolean;
|
|
68
68
|
/** The crop target's rectangle in VIEWPORT coordinates, which is what the DOM answers. */
|
|
69
69
|
readonly box: IslandBox;
|
|
70
|
+
/**
|
|
71
|
+
* `box` grown to every open popup the page shows (`POPUP_SELECTOR`), same coordinates: what the
|
|
72
|
+
* picture is cropped to, so a listbox or a portalled menu is in it. Absent: the box alone.
|
|
73
|
+
*/
|
|
74
|
+
readonly frame?: IslandBox | undefined;
|
|
70
75
|
/**
|
|
71
76
|
* The page's scroll offset at the moment the box was measured. A capture clip is in PAGE
|
|
72
77
|
* coordinates, so this is what turns one into the other — and it is a separate field rather than
|
|
@@ -99,6 +104,7 @@ const readinessSchema: StandardSchemaV1<unknown, IslandReadiness> = t.object({
|
|
|
99
104
|
failed: t.nullable(t.string),
|
|
100
105
|
filled: t.boolean,
|
|
101
106
|
box: t.object({ x: t.number, y: t.number, width: t.number, height: t.number }),
|
|
107
|
+
frame: t.optional(t.object({ x: t.number, y: t.number, width: t.number, height: t.number })),
|
|
102
108
|
scroll: t.object({ x: t.number, y: t.number }),
|
|
103
109
|
overflow: t.object({ x: t.boolean, y: t.boolean }),
|
|
104
110
|
page: t.object({ width: t.number, height: t.number }),
|
package/src/mcp-errors.ts
CHANGED
|
@@ -251,6 +251,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
251
251
|
'x build --target static --json # after adding prerender() with one real path to the route',
|
|
252
252
|
X_ROUTE_ASYNC_PAGE:
|
|
253
253
|
'x verify --only budgets --json # after moving each await in Page into export const load',
|
|
254
|
+
X_SITEMAP_EXTRA_INVALID:
|
|
255
|
+
'x routes --json # then list in seo.sitemap.extra only a path a registered app/ route without a policy answers',
|
|
254
256
|
};
|
|
255
257
|
|
|
256
258
|
const isCliCode = (code: string): code is CliErrorCode =>
|
package/src/seo-routes.ts
CHANGED
|
@@ -10,8 +10,10 @@ import { ROBOTS_PATH, SITEMAP_PATH, siteSeo } from './site-seo';
|
|
|
10
10
|
|
|
11
11
|
export interface SeoRoutesOptions {
|
|
12
12
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
13
|
-
/** `site.origin
|
|
13
|
+
/** `site.origin`, `seo.robots.disallow` and `seo.sitemap` from `app.config.ts` (`loadSiteSettings`). */
|
|
14
14
|
readonly site?: SiteSettings;
|
|
15
|
+
/** The app root route files are relative to — `seo.sitemap.lastmod: 'git' | 'mtime'` reads them. */
|
|
16
|
+
readonly root?: string;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -37,6 +39,8 @@ export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
|
|
|
37
39
|
baseUrl: originOf(options, request),
|
|
38
40
|
environment,
|
|
39
41
|
disallow: options.site?.disallow ?? [],
|
|
42
|
+
...(options.site === undefined ? {} : { sitemap: options.site.sitemap }),
|
|
43
|
+
...(options.root === undefined ? {} : { root: options.root }),
|
|
40
44
|
});
|
|
41
45
|
|
|
42
46
|
return [
|
package/src/serve-boot.ts
CHANGED
|
@@ -198,7 +198,7 @@ async function webSurface(
|
|
|
198
198
|
// filled, so this process serves exactly the CSS it renders against.
|
|
199
199
|
...styleRoutes(() => styleBundle()),
|
|
200
200
|
// `robots.txt` and `sitemap.xml`, the same two files the static export writes (`site-seo.ts`).
|
|
201
|
-
...seoRoutes({ env: options.env, site }),
|
|
201
|
+
...seoRoutes({ env: options.env, site, root: options.root }),
|
|
202
202
|
// The page's one socket: its worker script, served beside the islands for their reason.
|
|
203
203
|
...sync.routes,
|
|
204
204
|
...navigation.routes,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// `x shot --cookie name=value[,name=value]` — cookies set in the browser before the first
|
|
2
|
+
// navigation, so a page is photographed with a cookie-held choice (consent, a dismissed banner)
|
|
3
|
+
// already made. Read here, before anything boots; scoped to the app's own origin once it exists.
|
|
4
|
+
|
|
5
|
+
import type { ShotCookie } from './browser-launcher-port';
|
|
6
|
+
import { BadFlagError } from './errors';
|
|
7
|
+
|
|
8
|
+
/** RFC 6265 `token` for a name; a value may not carry what ends or splits a Cookie header. */
|
|
9
|
+
const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
10
|
+
const COOKIE_VALUE = /^[^\s;,"\\]*$/;
|
|
11
|
+
|
|
12
|
+
const FIX = 'x shot / --cookie consent=granted --json';
|
|
13
|
+
|
|
14
|
+
export interface ShotCookiePair {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly value: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const refuse = (reason: string): never => {
|
|
20
|
+
throw new BadFlagError({ flag: 'cookie', command: 'shot', reason, fix: FIX });
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The pairs `--cookie` names, in order; absent or empty is none. */
|
|
24
|
+
export function readCookieFlag(raw: string | undefined): readonly ShotCookiePair[] {
|
|
25
|
+
if (raw === undefined || raw.trim() === '') return [];
|
|
26
|
+
return raw
|
|
27
|
+
.split(',')
|
|
28
|
+
.map((part) => part.trim())
|
|
29
|
+
.filter((part) => part !== '')
|
|
30
|
+
.map((pair) => {
|
|
31
|
+
const at = pair.indexOf('=');
|
|
32
|
+
if (at < 0) return refuse(`"${pair}" has no "=" — a cookie is name=value`);
|
|
33
|
+
const name = pair.slice(0, at).trim();
|
|
34
|
+
const value = pair.slice(at + 1).trim();
|
|
35
|
+
if (!COOKIE_NAME.test(name)) return refuse(`"${name}" is not a cookie name`);
|
|
36
|
+
if (!COOKIE_VALUE.test(value)) {
|
|
37
|
+
return refuse(`the value of "${name}" holds a space, ";", ",", a quote or a backslash`);
|
|
38
|
+
}
|
|
39
|
+
return { name, value };
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Each pair for the app under test: the server's own URL, so no cookie reaches another host. */
|
|
44
|
+
export const shotCookies = (pairs: readonly ShotCookiePair[], url: string): readonly ShotCookie[] =>
|
|
45
|
+
pairs.map((pair) => ({ ...pair, url }));
|
package/src/site-config.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
// why: Bun exposes no path-join primitive, and the config path is app-root-relative.
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
-
import type { AppConfig, Environment } from '@ultimat3/core';
|
|
8
|
+
import type { AppConfig, Environment, SeoSitemapConfig, SitemapLastmod } from '@ultimat3/core';
|
|
9
|
+
import { SITEMAP_LASTMOD_SOURCES } from '@ultimat3/core';
|
|
9
10
|
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
10
11
|
import { APP_CONFIG_FILE } from './app-root';
|
|
11
12
|
|
|
@@ -14,9 +15,15 @@ export interface SiteSettings {
|
|
|
14
15
|
readonly origin: string | null;
|
|
15
16
|
/** `seo.robots.disallow` — the paths a production `robots.txt` keeps crawlers out of. */
|
|
16
17
|
readonly disallow: readonly string[];
|
|
18
|
+
/** `seo.sitemap` — public `app/` pages to list, and where `<lastmod>` comes from. */
|
|
19
|
+
readonly sitemap: SeoSitemapConfig;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
export const NO_SITE_SETTINGS: SiteSettings = {
|
|
22
|
+
export const NO_SITE_SETTINGS: SiteSettings = {
|
|
23
|
+
origin: null,
|
|
24
|
+
disallow: [],
|
|
25
|
+
sitemap: { extra: [], lastmod: 'none' },
|
|
26
|
+
};
|
|
20
27
|
|
|
21
28
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
22
29
|
typeof value === 'object' && value !== null;
|
|
@@ -43,6 +50,26 @@ export async function loadSiteSettings(root: string): Promise<SiteSettings> {
|
|
|
43
50
|
return {
|
|
44
51
|
origin: typeof origin === 'string' && origin !== '' ? trimSlash(origin) : null,
|
|
45
52
|
disallow: [...config.seo.robots.disallow],
|
|
53
|
+
sitemap: sitemapOf(config.seo),
|
|
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',
|
|
46
73
|
};
|
|
47
74
|
}
|
|
48
75
|
|
package/src/site-seo.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// (`apps/web/prerender.ts` writes it into the artifact) and the running web role (`seo-routes.ts`
|
|
3
3
|
// serves it), so a crawler reads the same two files from a CDN and from a container.
|
|
4
4
|
|
|
5
|
-
import type { Environment } from '@ultimat3/core';
|
|
5
|
+
import type { Environment, SeoSitemapConfig } from '@ultimat3/core';
|
|
6
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
7
|
import { localeConfig, localizedPath, routedLocales, unlocalizedPath } from '@ultimat3/i18n';
|
|
7
8
|
import type { RouteEntry } from '@ultimat3/render';
|
|
8
9
|
import { routeEntries } from '@ultimat3/render';
|
|
@@ -10,6 +11,7 @@ import { enumeratePrerender, fillPath } from '@ultimat3/render/server';
|
|
|
10
11
|
import type { RouteRecord, SitemapFile } from '@ultimat3/seo';
|
|
11
12
|
import { buildRobots, buildSitemap, isDynamic } from '@ultimat3/seo';
|
|
12
13
|
import { readSiteMeta } from './seo-meta';
|
|
14
|
+
import { lastmodOf } from './sitemap-lastmod';
|
|
13
15
|
|
|
14
16
|
export const ROBOTS_PATH = '/robots.txt';
|
|
15
17
|
export const SITEMAP_PATH = '/sitemap.xml';
|
|
@@ -30,6 +32,27 @@ export interface SiteSeoOptions {
|
|
|
30
32
|
* `User-agent: *` group; a non-production `robots.txt` still disallows everything.
|
|
31
33
|
*/
|
|
32
34
|
readonly disallow?: readonly string[] | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* `seo.sitemap` from `app.config.ts` (`loadSiteSettings`): the public `app/` pages to list beside
|
|
37
|
+
* the `site/` ones, and where each `<lastmod>` comes from. Omitted: `site/` pages, no `<lastmod>`.
|
|
38
|
+
*/
|
|
39
|
+
readonly sitemap?: SeoSitemapConfig | undefined;
|
|
40
|
+
/** The app root the route files are relative to — read for `lastmod: 'git' | 'mtime'`. */
|
|
41
|
+
readonly root?: string | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const NO_SITEMAP_SETTINGS: SeoSitemapConfig = { extra: [], lastmod: 'none' };
|
|
45
|
+
|
|
46
|
+
/** A `seo.sitemap.extra` path that names no public page outside `site/`. */
|
|
47
|
+
export class SitemapExtraInvalidError extends UltimateError {
|
|
48
|
+
constructor(path: string, reason: string) {
|
|
49
|
+
super({
|
|
50
|
+
code: 'X_SITEMAP_EXTRA_INVALID',
|
|
51
|
+
cause: `seo.sitemap.extra lists "${path}", which ${reason}`,
|
|
52
|
+
fix: 'x routes --json # then list in seo.sitemap.extra only a path a registered app/ route without a policy answers',
|
|
53
|
+
meta: { path },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
33
56
|
}
|
|
34
57
|
|
|
35
58
|
export interface SiteSeo {
|
|
@@ -60,7 +83,10 @@ const pagesOf = async (entry: RouteEntry, options: SiteSeoOptions): Promise<read
|
|
|
60
83
|
* without a request (`readSiteMeta`), which is what lets a page's own `robots: { index: false }`
|
|
61
84
|
* keep it out of the sitemap — a page that asks crawlers to stay away must not be listed for them.
|
|
62
85
|
*/
|
|
63
|
-
async function publicSiteRoutes(
|
|
86
|
+
async function publicSiteRoutes(
|
|
87
|
+
options: SiteSeoOptions,
|
|
88
|
+
stamp: Stamp,
|
|
89
|
+
): Promise<readonly RouteRecord[]> {
|
|
64
90
|
const metaByPath = new Map((await readSiteMeta()).records.map((r) => [r.path, r.meta]));
|
|
65
91
|
return routeEntries()
|
|
66
92
|
.filter(isPublicSite)
|
|
@@ -73,17 +99,70 @@ async function publicSiteRoutes(options: SiteSeoOptions): Promise<readonly Route
|
|
|
73
99
|
render: entry.config.render,
|
|
74
100
|
...(meta === undefined ? {} : { meta }),
|
|
75
101
|
...(isDynamic(entry.path) ? { prerender: () => pagesOf(entry, options) } : {}),
|
|
102
|
+
...stamp(entry.file),
|
|
76
103
|
};
|
|
77
104
|
});
|
|
78
105
|
}
|
|
79
106
|
|
|
107
|
+
type Stamp = (file: string) => { readonly lastmod?: string };
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The app's `seo.sitemap.extra` paths as records, each resolved to the route that answers it — the
|
|
111
|
+
* most specific match, the router's own tie-break. REFUSED, never skipped, when it names no route,
|
|
112
|
+
* an `api/` route, a gated one or a `site/` page (listed already): a sitemap missing a page its
|
|
113
|
+
* author listed is the silent drop, and a gated page in it is a URL a crawler cannot open.
|
|
114
|
+
*/
|
|
115
|
+
function extraRoutes(extra: readonly string[], stamp: Stamp): readonly RouteRecord[] {
|
|
116
|
+
const entries = routeEntries();
|
|
117
|
+
return [...new Set(extra)].map((path) => {
|
|
118
|
+
const entry = entries
|
|
119
|
+
.filter((candidate) => candidate.pattern.regex.test(path))
|
|
120
|
+
.sort((a, b) => b.pattern.specificity - a.pattern.specificity)[0];
|
|
121
|
+
if (entry === undefined)
|
|
122
|
+
throw new SitemapExtraInvalidError(path, 'no registered route answers');
|
|
123
|
+
if (entry.surface === 'api') {
|
|
124
|
+
throw new SitemapExtraInvalidError(path, `is the api/ route ${entry.file}, not a page`);
|
|
125
|
+
}
|
|
126
|
+
if (entry.surface === 'site') {
|
|
127
|
+
throw new SitemapExtraInvalidError(
|
|
128
|
+
path,
|
|
129
|
+
`is the site/ page ${entry.file} — every public site/ page is listed already`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (entry.config.policy !== undefined) {
|
|
133
|
+
throw new SitemapExtraInvalidError(
|
|
134
|
+
path,
|
|
135
|
+
`is answered by ${entry.file}, which declares a policy — a crawler cannot open it`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
path,
|
|
140
|
+
file: entry.file,
|
|
141
|
+
surface: 'app' as const,
|
|
142
|
+
render: entry.config.render,
|
|
143
|
+
sitemap: true,
|
|
144
|
+
...stamp(entry.file),
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
80
149
|
export async function siteSeo(options: SiteSeoOptions): Promise<SiteSeo> {
|
|
81
150
|
// Every routed locale, with `xhtml:link` alternates: one `<url>` per page per locale, each
|
|
82
151
|
// naming the whole cluster and `x-default`. A single-locale app passes none and gets the plain
|
|
83
152
|
// urlset it always had — an alternates cluster of one is noise.
|
|
84
153
|
const locales = routedLocales();
|
|
85
154
|
const defaultLocale = localeConfig().fallback;
|
|
86
|
-
const
|
|
155
|
+
const settings = options.sitemap ?? NO_SITEMAP_SETTINGS;
|
|
156
|
+
const root = options.root ?? process.cwd();
|
|
157
|
+
const stamp: Stamp = (file) => {
|
|
158
|
+
const lastmod = lastmodOf(file, settings.lastmod, root);
|
|
159
|
+
return lastmod === undefined ? {} : { lastmod };
|
|
160
|
+
};
|
|
161
|
+
const routes = [
|
|
162
|
+
...(await publicSiteRoutes(options, stamp)),
|
|
163
|
+
...extraRoutes(settings.extra, stamp),
|
|
164
|
+
];
|
|
165
|
+
const sitemap = await buildSitemap(routes, {
|
|
87
166
|
baseUrl: options.baseUrl,
|
|
88
167
|
...(locales.length < 2
|
|
89
168
|
? {}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// A sitemap `<lastmod>` per route source file — `seo.sitemap.lastmod` in `app.config.ts`. Read once
|
|
2
|
+
// per file per process: the running web role answers `/sitemap.xml` per request, and a `git log`
|
|
3
|
+
// per route per request is a subprocess per crawler hit for a date that changes on deploy.
|
|
4
|
+
|
|
5
|
+
// why: Bun has no synchronous stat, and `Bun.file(missing).lastModified` answers a garbage date
|
|
6
|
+
// (measured: year 144683) instead of refusing — the sitemap is built synchronously per route.
|
|
7
|
+
import { statSync } from 'node:fs';
|
|
8
|
+
// why: Bun exposes no path-join primitive, and the route file is app-root-relative.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import type { SitemapLastmod } from '@ultimat3/core';
|
|
11
|
+
|
|
12
|
+
/** The moment this process first built a sitemap — `'build'`'s one timestamp for every URL. */
|
|
13
|
+
let builtAt: string | undefined;
|
|
14
|
+
|
|
15
|
+
const cache = new Map<string, string | undefined>();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The `<lastmod>` for one route file, W3C Datetime (ISO 8601, UTC), or `undefined` when the source
|
|
19
|
+
* yields none — a sitemap entry without `<lastmod>` is valid, a wrong one misleads a crawler.
|
|
20
|
+
*
|
|
21
|
+
* `'git'` falls back to the file's mtime where there is no work tree (a container image): the
|
|
22
|
+
* image's copy of the file was written by the build, which is the honest "last changed" there.
|
|
23
|
+
*/
|
|
24
|
+
export function lastmodOf(file: string, mode: SitemapLastmod, root: string): string | undefined {
|
|
25
|
+
if (mode === 'none') return undefined;
|
|
26
|
+
if (mode === 'build') {
|
|
27
|
+
builtAt ??= new Date().toISOString();
|
|
28
|
+
return builtAt;
|
|
29
|
+
}
|
|
30
|
+
const key = `${mode}\u0000${root}\u0000${file}`;
|
|
31
|
+
if (cache.has(key)) return cache.get(key);
|
|
32
|
+
const value = (mode === 'git' ? gitDate(file, root) : undefined) ?? mtimeOf(join(root, file));
|
|
33
|
+
cache.set(key, value);
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Test seam: forget every date read, so a test can move a file's clock. */
|
|
38
|
+
export function resetLastmodCache(): void {
|
|
39
|
+
cache.clear();
|
|
40
|
+
builtAt = undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function gitDate(file: string, root: string): string | undefined {
|
|
44
|
+
try {
|
|
45
|
+
const run = Bun.spawnSync(['git', 'log', '-1', '--format=%cI', '--', file], {
|
|
46
|
+
cwd: root,
|
|
47
|
+
stdout: 'pipe',
|
|
48
|
+
stderr: 'ignore',
|
|
49
|
+
});
|
|
50
|
+
if (run.exitCode !== 0) return undefined;
|
|
51
|
+
return isoOf(run.stdout.toString().trim());
|
|
52
|
+
} catch {
|
|
53
|
+
// No `git` binary on PATH: the fallback, never a sitemap that cannot be served.
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mtimeOf(path: string): string | undefined {
|
|
59
|
+
try {
|
|
60
|
+
const modified = statSync(path, { throwIfNoEntry: false })?.mtimeMs;
|
|
61
|
+
return modified !== undefined && Number.isFinite(modified) && modified > 0
|
|
62
|
+
? new Date(modified).toISOString()
|
|
63
|
+
: undefined;
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isoOf(text: string): string | undefined {
|
|
70
|
+
if (text === '') return undefined;
|
|
71
|
+
const time = Date.parse(text);
|
|
72
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : undefined;
|
|
73
|
+
}
|
|
@@ -91,10 +91,15 @@ const origin = Bun.env.SITE_ORIGIN;
|
|
|
91
91
|
* \`Disallow: /\` and advertises no sitemap — so a preview build cannot outrank the real site.
|
|
92
92
|
*/
|
|
93
93
|
async function writeSeoFiles(report: PrerenderReport): Promise<readonly string[]> {
|
|
94
|
+
// The same disallow list and sitemap settings the running server answers with
|
|
95
|
+
// (app.config.ts seo.robots.disallow and seo.sitemap).
|
|
96
|
+
const settings = await loadSiteSettings(root);
|
|
94
97
|
const seo = await siteSeo({
|
|
95
98
|
// The origin the pages were built against, so the sitemap and every canonical agree.
|
|
96
99
|
baseUrl: report.origin,
|
|
97
|
-
disallow:
|
|
100
|
+
disallow: settings.disallow,
|
|
101
|
+
sitemap: settings.sitemap,
|
|
102
|
+
root,
|
|
98
103
|
pagesFor: (route) =>
|
|
99
104
|
report.pages.filter((page) => page.route === route).map((page) => page.path),
|
|
100
105
|
});
|