@ultimat3/cli 22.2.2 → 22.3.1

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.
@@ -0,0 +1,67 @@
1
+ // Which locale a picture is of. `x shot` pins `Accept-Language` on every capture — to `--locale`
2
+ // when one is asked for and to the app's DEFAULT locale otherwise — so a screenshot never depends
3
+ // on the language of the machine's Chrome. A non-default locale is also a URL prefix (`/en/…`),
4
+ // because on a `site/` route the unprefixed path is always the default locale whatever the header.
5
+
6
+ import { join } from 'node:path'; // why: Bun ships no path join.
7
+ import { APP_CONFIG_EXPORT } from './app-auth';
8
+ import { APP_CONFIG_FILE } from './app-root';
9
+ import { BadFlagError } from './errors';
10
+
11
+ export interface ShotLocales {
12
+ readonly locales: readonly string[];
13
+ readonly defaultLocale: string;
14
+ }
15
+
16
+ /** An app that declares nothing is `en` only — the framework's own default. */
17
+ export const FALLBACK_SHOT_LOCALES: ShotLocales = { locales: ['en'], defaultLocale: 'en' };
18
+
19
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
20
+ typeof value === 'object' && value !== null;
21
+
22
+ /** `app.config.ts`'s `locales` and `defaultLocale`, read the way `loadThemeMode` reads `theme`. */
23
+ export async function loadShotLocales(root: string): Promise<ShotLocales> {
24
+ const configPath = join(root, APP_CONFIG_FILE);
25
+ if (!(await Bun.file(configPath).exists())) return FALLBACK_SHOT_LOCALES;
26
+ const module = (await import(configPath)) as Record<string, unknown>;
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
+ };
40
+ }
41
+
42
+ /** `--locale <l>`, refused by name when the app does not declare it — a typo costs no browser. */
43
+ export function readLocaleFlag(value: string | undefined, app: ShotLocales): string | undefined {
44
+ if (value === undefined) return undefined;
45
+ if (app.locales.includes(value)) return value;
46
+ throw new BadFlagError({
47
+ flag: 'locale',
48
+ command: 'shot',
49
+ reason: `"${value}" is not one of the app's locales (${app.locales.join(', ')})`,
50
+ fix: `x shot / --locale ${app.defaultLocale} --json`,
51
+ });
52
+ }
53
+
54
+ /**
55
+ * The path a visitor in `locale` opens: unchanged for the default locale, `/<locale>/…` for any
56
+ * other. The root keeps its trailing slash (`/en/`), which is the directory a static export writes
57
+ * the prefixed home page to.
58
+ */
59
+ export function localizedShotPath(route: string, locale: string, defaultLocale: string): string {
60
+ if (locale === defaultLocale) return route;
61
+ return route === '/' ? `/${locale}/` : `/${locale}${route}`;
62
+ }
63
+
64
+ /** The header every capture sends. One key, lower-case, as CDP forwards it verbatim. */
65
+ export const acceptLanguageHeaders = (locale: string): Readonly<Record<string, string>> => ({
66
+ 'accept-language': locale,
67
+ });
@@ -25,6 +25,9 @@ export const SHOT_MESSAGE_KEYS = [
25
25
  'cli.shot.ok',
26
26
  'cli.shot.errors',
27
27
  'cli.shot.redirected',
28
+ 'cli.shot.status',
29
+ 'cli.shot.document',
30
+ 'cli.shot.documentUnknown',
28
31
  'cli.shot.picture',
29
32
  'cli.shot.verdict',
30
33
  'cli.shot.server.booted',
@@ -146,6 +149,13 @@ export interface ShotInput {
146
149
  readonly networkDropped: number;
147
150
  /** `null` when the probe could not run or did not parse. Never a zero standing in for unknown. */
148
151
  readonly islands: IslandCount | null;
152
+ /**
153
+ * The document's status as read right after navigation. Only a fallback: the network ring is
154
+ * bounded, so on a page of a few hundred requests the document entry may be gone by capture time.
155
+ */
156
+ readonly landedStatus?: number | null | undefined;
157
+ /** `--expect-status`: the one status this shot is ok with. Absent means any 2xx. */
158
+ readonly expectStatus?: number | undefined;
149
159
  }
150
160
 
151
161
  export interface ShotVerdict extends ShotInput {
@@ -161,10 +171,44 @@ export interface ShotVerdict extends ShotInput {
161
171
  * gating, like a console warning: a missing favicon would otherwise fail every shot of every app.
162
172
  */
163
173
  readonly failed: number;
174
+ /**
175
+ * The HTTP status of the document photographed. `null` when no driver response carried one —
176
+ * "not seen" never gates, like an uncounted island, and is never reported as a 200.
177
+ */
178
+ readonly status: number | null;
179
+ /** The status asked for with `--expect-status`, or `null` for "any 2xx". */
180
+ readonly expectedStatus: number | null;
164
181
  /** What this verdict cannot see, stated every time — a `0` whose blind spots are named. */
165
182
  readonly blind: readonly string[];
166
183
  }
167
184
 
185
+ const withoutFragment = (url: string): string => {
186
+ const hash = url.indexOf('#');
187
+ return hash === -1 ? url : url.slice(0, hash);
188
+ };
189
+
190
+ /**
191
+ * The status of the LAST document response at `url` — the last, because a reload or a redirect
192
+ * back re-requests it and the picture is of the latest. The fragment is dropped: `location.href`
193
+ * keeps it and no request ever carries one.
194
+ */
195
+ export function documentStatus(network: readonly NetworkEntry[], url: string): number | null {
196
+ const target = withoutFragment(url);
197
+ for (let index = network.length - 1; index >= 0; index -= 1) {
198
+ const entry = network[index];
199
+ if (entry === undefined || entry.resourceType !== 'document') continue;
200
+ if (withoutFragment(entry.url) !== target) continue;
201
+ if (entry.status !== undefined) return entry.status;
202
+ }
203
+ return null;
204
+ }
205
+
206
+ /** ok for a status: the one expected, or any 2xx when none was. Unseen is not a failure. */
207
+ const statusOk = (status: number | null, expected: number | null): boolean => {
208
+ if (expected !== null) return status === expected;
209
+ return status === null || (status >= 200 && status < 300);
210
+ };
211
+
168
212
  /**
169
213
  * What a shot is blind to, each naming the mechanism rather than apologising. Constant because
170
214
  * these are properties of the browser port, not of a run — and in the artifact because `errors: 0`
@@ -183,7 +227,7 @@ const levelCount = (lines: readonly ConsoleLine[], level: ConsoleLine['level']):
183
227
  lines.filter((line) => line.level === level).length;
184
228
 
185
229
  /**
186
- * `ok` is four conditions, and every one is something a picture cannot show: nothing on the page
230
+ * `ok` is five conditions, and every one is something a picture cannot show: nothing on the page
187
231
  * logged an error, nothing THREW, no island's `mount()` REJECTED, and the document photographed is
188
232
  * the route that was asked for.
189
233
  *
@@ -195,16 +239,23 @@ const levelCount = (lines: readonly ConsoleLine[], level: ConsoleLine['level']):
195
239
  * an uncounted probe (`null`) out of the verdict — "not counted" is not "none failed".
196
240
  * A redirect is a failure of the CAPTURE rather than of the app: an agent that
197
241
  * photographs the sign-in page and files "the island did not mount" is the outcome this prevents.
242
+ * A fifth (`As of 2026-09-26`): the document answered 2xx, or exactly `--expect-status`. A 404
243
+ * page is clean by every other clause, and `x shot /typo` reported it ok.
198
244
  */
199
245
  export function buildVerdict(input: ShotInput): ShotVerdict {
200
246
  const errors = levelCount(input.console, 'error');
247
+ const status = documentStatus(input.network, input.finalUrl) ?? input.landedStatus ?? null;
248
+ const expectedStatus = input.expectStatus ?? null;
201
249
  return {
202
250
  ...input,
203
251
  ok:
204
252
  errors === 0 &&
205
253
  input.pageErrors.length === 0 &&
206
254
  (input.islands?.failed ?? 0) === 0 &&
207
- input.requestedUrl === input.finalUrl,
255
+ input.requestedUrl === input.finalUrl &&
256
+ statusOk(status, expectedStatus),
257
+ status,
258
+ expectedStatus,
208
259
  redirected: input.requestedUrl !== input.finalUrl,
209
260
  errors,
210
261
  warnings: levelCount(input.console, 'warn'),
@@ -241,6 +292,8 @@ export function verdictJson(verdict: ShotVerdict): JsonValue {
241
292
  requestedUrl: verdict.requestedUrl,
242
293
  finalUrl: verdict.finalUrl,
243
294
  redirected: verdict.redirected,
295
+ status: verdict.status,
296
+ expectedStatus: verdict.expectedStatus,
244
297
  server: verdict.server,
245
298
  capturedAt: verdict.capturedAt,
246
299
  screenshot: verdict.screenshot,
@@ -301,6 +354,9 @@ export function shotLines(artifacts: ShotArtifacts): readonly string[] {
301
354
  const canvas = verdict.canvas;
302
355
  return [
303
356
  msg(`cli.shot.server.${verdict.server}`, { url: verdict.finalUrl }),
357
+ verdict.status === null
358
+ ? msg('cli.shot.documentUnknown')
359
+ : msg('cli.shot.document', { status: verdict.status }),
304
360
  canvas === null
305
361
  ? msg('cli.shot.canvasUnreadable', { bytes: verdict.bytes.byteLength })
306
362
  : msg('cli.shot.canvas', { width: canvas.width, height: canvas.height }),
@@ -338,6 +394,13 @@ export const shotSummary = (verdict: ShotVerdict): string => {
338
394
  if (verdict.redirected) {
339
395
  return msg('cli.shot.redirected', { route: verdict.route, url: verdict.finalUrl });
340
396
  }
397
+ if (!statusOk(verdict.status, verdict.expectedStatus)) {
398
+ return msg('cli.shot.status', {
399
+ route: verdict.route,
400
+ status: verdict.status ?? 'unseen',
401
+ expected: verdict.expectedStatus ?? '2xx',
402
+ });
403
+ }
341
404
  // Ahead of the console count, because a throw is the more severe fact AND the quieter one: an
342
405
  // island that died can log nothing at all, so `errors` would report a clean page.
343
406
  if (verdict.pageErrors.length > 0) {
@@ -0,0 +1,106 @@
1
+ // Serving `apps/web/site/assets/**` at the hashed URLs `asset()` mints. Mounted by `assetRoutes`,
2
+ // which both `x dev` and the container compose, so a picture that paints on a laptop paints in the
3
+ // image. Byte ranges are answered because Safari will not play an `<video>` from a server that
4
+ // ignores `Range`, and a film that plays everywhere but an iPhone is not a film that ships.
5
+
6
+ import { isUltimateError } from '@ultimat3/core';
7
+ import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
8
+ import { applyCacheHeaders, json } from '@ultimat3/http';
9
+ import type { SiteAsset, SiteAssetTable } from './site-assets';
10
+ import { parseHashedAssetUrl, SITE_ASSET_BASE_PATH } from './site-assets';
11
+
12
+ /** The URL is the content, so the answer is `public, max-age=31536000, immutable`. */
13
+ export const SITE_ASSET_CACHE: CacheHint = { mode: 'immutable' };
14
+
15
+ const notFound = (code: string, cause: string, fix: string): Response =>
16
+ json({ ok: false, error: { code, cause, fix } }, { status: 404 });
17
+
18
+ /** One `bytes=` range, resolved against the file's size; `null` when it cannot be satisfied. */
19
+ export function byteRange(
20
+ header: string | null,
21
+ size: number,
22
+ ): { readonly start: number; readonly end: number } | null | undefined {
23
+ if (header === null) return undefined;
24
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
25
+ // A multi-range or a unit we do not speak: RFC 9110 lets a server ignore Range and send it all.
26
+ if (match === null) return undefined;
27
+ const [, from = '', to = ''] = match;
28
+ if (from === '' && to === '') return null;
29
+ if (from === '') {
30
+ const suffix = Number(to);
31
+ if (suffix === 0) return null;
32
+ return { start: Math.max(0, size - suffix), end: size - 1 };
33
+ }
34
+ const start = Number(from);
35
+ const end = to === '' ? size - 1 : Math.min(Number(to), size - 1);
36
+ if (start >= size || end < start) return null;
37
+ return { start, end };
38
+ }
39
+
40
+ function assetResponse(asset: SiteAsset, range: string | null): Response {
41
+ const file = Bun.file(asset.file);
42
+ const headers: Record<string, string> = {
43
+ 'content-type': asset.contentType,
44
+ 'accept-ranges': 'bytes',
45
+ };
46
+ const wanted = byteRange(range, asset.bytes);
47
+ if (wanted === null) {
48
+ return new Response(null, {
49
+ status: 416,
50
+ headers: { ...headers, 'content-range': `bytes */${String(asset.bytes)}` },
51
+ });
52
+ }
53
+ if (wanted === undefined) {
54
+ return applyCacheHeaders(new Response(file, { headers }), SITE_ASSET_CACHE);
55
+ }
56
+ return applyCacheHeaders(
57
+ new Response(file.slice(wanted.start, wanted.end + 1), {
58
+ status: 206,
59
+ headers: {
60
+ ...headers,
61
+ 'content-range': `bytes ${String(wanted.start)}-${String(wanted.end)}/${String(asset.bytes)}`,
62
+ },
63
+ }),
64
+ SITE_ASSET_CACHE,
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Only the HASHED name is served, and only while the bytes still hash to it: an immutable answer
70
+ * under a URL whose bytes changed would be cached for a year by every CDN that saw it. A stale
71
+ * hash is a document from an earlier build, and says so.
72
+ */
73
+ export function siteAssetRoutes(table: SiteAssetTable): readonly Route[] {
74
+ return [
75
+ {
76
+ method: 'GET',
77
+ path: `${SITE_ASSET_BASE_PATH}/*file`,
78
+ meta: { name: 'assets.site', auth: 'public', tags: ['assets'] },
79
+ handler: (request: UltimateRequest): Response => {
80
+ const named = parseHashedAssetUrl(request.pathname);
81
+ if (named === undefined) {
82
+ return notFound(
83
+ 'X_ROUTE_NOT_FOUND',
84
+ `${request.pathname} is not a hashed site asset URL`,
85
+ "name the file with asset('assets/…') in the page, which returns the URL this route serves",
86
+ );
87
+ }
88
+ let asset: SiteAsset;
89
+ try {
90
+ asset = table.resolve(named.path);
91
+ } catch (error) {
92
+ if (!isUltimateError(error)) throw error;
93
+ return notFound(error.code, error.cause, error.fix);
94
+ }
95
+ if (asset.hash !== named.hash) {
96
+ return notFound(
97
+ 'X_ROUTE_NOT_FOUND',
98
+ `${named.path} is at ${asset.url} now — the document asking for ${request.pathname} was rendered against earlier bytes`,
99
+ 'reload the page; it names the current URL',
100
+ );
101
+ }
102
+ return assetResponse(asset, request.headers.get('range'));
103
+ },
104
+ },
105
+ ];
106
+ }
@@ -0,0 +1,177 @@
1
+ // The site asset table: every file under `apps/web/site/assets/`, named by a content-hashed URL.
2
+ // One table per app root answers all three questions — the URL `asset()` returns while a page
3
+ // renders, the file `/assets/*` serves, and the copy `x build --target static` writes — so the
4
+ // page, the container and the CDN can never name one file three ways.
5
+
6
+ // why: `asset()` is called from a synchronous render, so the table answers synchronously — and Bun
7
+ // ships no sync stat or sync read; `join`/`relative` because Bun ships no path API either.
8
+ import { copyFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path'; // why: Bun ships no path API.
10
+ import type { AssetExtension, AssetPath } from '@ultimat3/render';
11
+ import { ASSET_DIR, AssetMissingError, assetPathProblem } from '@ultimat3/render';
12
+
13
+ /** App-root-relative, beside `favicon.ico`: `apps/web/site/` is where an app's public files live. */
14
+ export const SITE_ASSETS_SOURCE = `apps/web/site/${ASSET_DIR}`;
15
+
16
+ /** The URL prefix every hashed asset is served under, and the export directory it is copied to. */
17
+ export const SITE_ASSET_BASE_PATH = `/${ASSET_DIR}`;
18
+
19
+ /** What each served extension IS. `vtt` carries a charset: captions are text a player decodes. */
20
+ export const ASSET_CONTENT_TYPES: Readonly<Record<AssetExtension, string>> = {
21
+ avif: 'image/avif',
22
+ webp: 'image/webp',
23
+ png: 'image/png',
24
+ jpg: 'image/jpeg',
25
+ jpeg: 'image/jpeg',
26
+ gif: 'image/gif',
27
+ svg: 'image/svg+xml',
28
+ ico: 'image/x-icon',
29
+ woff2: 'font/woff2',
30
+ mp4: 'video/mp4',
31
+ webm: 'video/webm',
32
+ vtt: 'text/vtt; charset=utf-8',
33
+ };
34
+
35
+ export interface SiteAsset {
36
+ /** As `asset()` names it: `assets/brand/logo.svg`. */
37
+ readonly path: AssetPath;
38
+ /** Absolute path on disk. */
39
+ readonly file: string;
40
+ /** xxHash32 of the bytes, 8 hex characters — the algorithm `contentHash` uses for CSS. */
41
+ readonly hash: string;
42
+ /** `/assets/brand/logo.<hash>.svg`. */
43
+ readonly url: string;
44
+ readonly contentType: string;
45
+ readonly bytes: number;
46
+ }
47
+
48
+ const splitExtension = (path: string): { readonly stem: string; readonly extension: string } => {
49
+ const dot = path.lastIndexOf('.');
50
+ return { stem: path.slice(0, dot), extension: path.slice(dot + 1) };
51
+ };
52
+
53
+ /** The hash goes before the extension, so a static host still infers the type from the name. */
54
+ export function hashedAssetUrl(path: AssetPath, hash: string): string {
55
+ const { stem, extension } = splitExtension(path);
56
+ return `/${stem}.${hash}.${extension}`;
57
+ }
58
+
59
+ const HASHED = /^\/(assets\/.+)\.([0-9a-f]{8})\.([a-z0-9]+)$/i;
60
+
61
+ /** The inverse of `hashedAssetUrl`: which asset a request names, and at which content. */
62
+ export function parseHashedAssetUrl(
63
+ pathname: string,
64
+ ): { readonly path: AssetPath; readonly hash: string } | undefined {
65
+ const match = HASHED.exec(pathname);
66
+ if (match === null) return undefined;
67
+ const path = `${match[1] ?? ''}.${match[3] ?? ''}`;
68
+ if (assetPathProblem(path) !== undefined) return undefined;
69
+ return { path: path as AssetPath, hash: match[2] ?? '' };
70
+ }
71
+
72
+ const hashBytes = (bytes: Uint8Array): string =>
73
+ Bun.hash.xxHash32(bytes).toString(16).padStart(8, '0');
74
+
75
+ export interface SiteAssetTable {
76
+ /** The asset `asset()` named, hashed as the bytes are NOW. Throws `X_ASSET_MISSING`. */
77
+ resolve(path: AssetPath): SiteAsset;
78
+ /** Every servable file under the source directory, sorted by path. */
79
+ all(): readonly SiteAsset[];
80
+ }
81
+
82
+ const missing = (path: string, cause: string): AssetMissingError =>
83
+ new AssetMissingError(
84
+ `asset(${JSON.stringify(path)}): ${cause}`,
85
+ `add the file at ${SITE_ASSETS_SOURCE}/${path.slice(ASSET_DIR.length + 1)}, or correct the path passed to asset(), then x build --target static --json`,
86
+ );
87
+
88
+ /**
89
+ * A stat per call, a read only when the file changed: `x dev` sees an edited image under a new URL
90
+ * on the next render with no watcher, and the container — whose files never change — pays one
91
+ * `stat` per `asset()` call and nothing else. One behaviour in both, never a dev-only rescan.
92
+ */
93
+ export function createSiteAssetTable(root: string): SiteAssetTable {
94
+ const source = join(root, SITE_ASSETS_SOURCE);
95
+ const cache = new Map<string, { readonly stamp: string; readonly asset: SiteAsset }>();
96
+
97
+ const resolve = (path: AssetPath): SiteAsset => {
98
+ const problem = assetPathProblem(path);
99
+ if (problem !== undefined) throw missing(path, problem);
100
+ const file = join(source, path.slice(ASSET_DIR.length + 1));
101
+ let stamp: string;
102
+ try {
103
+ const stat = statSync(file);
104
+ if (!stat.isFile()) throw missing(path, `${file} is not a file`);
105
+ stamp = `${String(stat.mtimeMs)}:${String(stat.size)}`;
106
+ } catch (error) {
107
+ if (error instanceof AssetMissingError) throw error;
108
+ throw missing(path, `no file at ${SITE_ASSETS_SOURCE}/${path.slice(ASSET_DIR.length + 1)}`);
109
+ }
110
+ const cached = cache.get(path);
111
+ if (cached !== undefined && cached.stamp === stamp) return cached.asset;
112
+ const bytes = readFileSync(file);
113
+ const hash = hashBytes(bytes);
114
+ // `assetPathProblem` already refused an unknown extension; the guard keeps `constructor` from
115
+ // ever answering an `Object` member if that check is loosened.
116
+ const extension = splitExtension(path).extension.toLowerCase();
117
+ const asset: SiteAsset = {
118
+ path,
119
+ file,
120
+ hash,
121
+ url: hashedAssetUrl(path, hash),
122
+ contentType: Object.hasOwn(ASSET_CONTENT_TYPES, extension)
123
+ ? ASSET_CONTENT_TYPES[extension as AssetExtension]
124
+ : 'application/octet-stream',
125
+ bytes: bytes.byteLength,
126
+ };
127
+ cache.set(path, { stamp, asset });
128
+ return asset;
129
+ };
130
+
131
+ const all = (): readonly SiteAsset[] => {
132
+ const found: SiteAsset[] = [];
133
+ let files: string[];
134
+ try {
135
+ files = [...new Bun.Glob('**/*').scanSync({ cwd: source, onlyFiles: true })];
136
+ } catch {
137
+ // No `assets/` directory is an app with no assets, not an error.
138
+ return [];
139
+ }
140
+ for (const relativePath of files.sort()) {
141
+ const path = `${ASSET_DIR}/${relativePath.split('\\').join('/')}`;
142
+ // A source file beside its renditions (a `.psd`, a `README.md`) is not a served asset.
143
+ if (assetPathProblem(path) !== undefined) continue;
144
+ found.push(resolve(path as AssetPath));
145
+ }
146
+ return found;
147
+ };
148
+
149
+ return { resolve, all };
150
+ }
151
+
152
+ /** One table per root, so the renderer and the route read the same cache. */
153
+ const tables = new Map<string, SiteAssetTable>();
154
+
155
+ export function siteAssetTable(root: string): SiteAssetTable {
156
+ let table = tables.get(root);
157
+ if (table === undefined) {
158
+ table = createSiteAssetTable(root);
159
+ tables.set(root, table);
160
+ }
161
+ return table;
162
+ }
163
+
164
+ /**
165
+ * Every asset, copied into the static export under its hashed URL — a static host serves with no
166
+ * process behind it, so the artifact carries every byte a document names. Returns the URLs written.
167
+ */
168
+ export function writeSiteAssets(root: string, out: string): readonly string[] {
169
+ const written: string[] = [];
170
+ for (const asset of siteAssetTable(root).all()) {
171
+ const target = join(out, asset.url.slice(1));
172
+ mkdirSync(dirname(target), { recursive: true });
173
+ copyFileSync(asset.file, target);
174
+ written.push(asset.url);
175
+ }
176
+ return written;
177
+ }
@@ -0,0 +1,79 @@
1
+ // The public origin and the crawler rules out of `app.config.ts` — `site.origin` and
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. Sibling of
4
+ // `app-auth.ts`'s `loadSignInPath`, and it imports the config for the same reason.
5
+
6
+ // why: Bun exposes no path-join primitive, and the config path is app-root-relative.
7
+ import { join } from 'node:path';
8
+ import type { AppConfig, Environment } from '@ultimat3/core';
9
+ import { APP_CONFIG_EXPORT } from './app-auth';
10
+ import { APP_CONFIG_FILE } from './app-root';
11
+
12
+ export interface SiteSettings {
13
+ /** `site.origin`, trailing slash removed; `null` when the app declares none. */
14
+ readonly origin: string | null;
15
+ /** `seo.robots.disallow` — the paths a production `robots.txt` keeps crawlers out of. */
16
+ readonly disallow: readonly string[];
17
+ }
18
+
19
+ export const NO_SITE_SETTINGS: SiteSettings = { origin: null, disallow: [] };
20
+
21
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
22
+ typeof value === 'object' && value !== null;
23
+
24
+ /**
25
+ * Structural, never `instanceof`, for `loadSignInPath`'s reason: a config that resolved through an
26
+ * older core simply has no `site` section, and that is the "not declared" answer, not a crash.
27
+ */
28
+ const hasSiteSections = (value: unknown): value is Pick<AppConfig, 'site' | 'seo'> =>
29
+ isRecord(value) &&
30
+ isRecord(value['site']) &&
31
+ isRecord(value['seo']) &&
32
+ isRecord(value['seo']['robots']);
33
+
34
+ const trimSlash = (origin: string): string => origin.replace(/\/+$/, '');
35
+
36
+ export async function loadSiteSettings(root: string): Promise<SiteSettings> {
37
+ const configPath = join(root, APP_CONFIG_FILE);
38
+ if (!(await Bun.file(configPath).exists())) return NO_SITE_SETTINGS;
39
+ const module = (await import(configPath)) as Record<string, unknown>;
40
+ const config = module[APP_CONFIG_EXPORT];
41
+ if (!hasSiteSections(config)) return NO_SITE_SETTINGS;
42
+ const origin = config.site.origin;
43
+ return {
44
+ origin: typeof origin === 'string' && origin !== '' ? trimSlash(origin) : null,
45
+ disallow: [...config.seo.robots.disallow],
46
+ };
47
+ }
48
+
49
+ /**
50
+ * The declared public origin: `APP_URL` (what the runtime already names it — OAuth's redirect, the
51
+ * sync node's admitted origin), then `SITE_ORIGIN` (the static build's), then `site.origin`.
52
+ * The environment first because one image is deployed to staging and production under two
53
+ * origins; the config line is what a deploy that sets neither falls back to. `undefined` when
54
+ * nothing names one — the caller decides whether the request's own origin will do.
55
+ */
56
+ export function publicOrigin(
57
+ env: Readonly<Record<string, string | undefined>>,
58
+ site: SiteSettings,
59
+ ): string | undefined {
60
+ for (const key of ['APP_URL', 'SITE_ORIGIN']) {
61
+ const declared = env[key]?.trim() ?? '';
62
+ if (declared !== '') return trimSlash(declared);
63
+ }
64
+ return site.origin ?? undefined;
65
+ }
66
+
67
+ /**
68
+ * The sentence a production build prints when no origin was declared. Every canonical, `og:url`,
69
+ * hreflang and sitemap `<loc>` is then built against a placeholder, and a search engine indexes a
70
+ * page whose canonical names a host the site is not on. A warning, not a refusal: a production
71
+ * build on a laptop is how a deploy is rehearsed.
72
+ */
73
+ export function originWarning(environment: Environment, declared: string | undefined): string[] {
74
+ if (environment !== 'production' || declared !== undefined) return [];
75
+ return [
76
+ 'no public origin: set APP_URL (or SITE_ORIGIN) or `site: { origin }` in app.config.ts — ' +
77
+ 'canonical, og:url, hreflang and the sitemap are absolute against a placeholder',
78
+ ];
79
+ }
package/src/site-seo.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // serves it), so a crawler reads the same two files from a CDN and from a container.
4
4
 
5
5
  import type { Environment } from '@ultimat3/core';
6
+ import { localeConfig, localizedPath, routedLocales, unlocalizedPath } from '@ultimat3/i18n';
6
7
  import type { RouteEntry } from '@ultimat3/render';
7
8
  import { routeEntries } from '@ultimat3/render';
8
9
  import { enumeratePrerender, fillPath } from '@ultimat3/render/server';
@@ -24,6 +25,11 @@ export interface SiteSeoOptions {
24
25
  * the same list, enumerated by the same function the prerenderer calls.
25
26
  */
26
27
  readonly pagesFor?: ((routePath: string) => readonly string[]) | undefined;
28
+ /**
29
+ * `seo.robots.disallow` from `app.config.ts` (`loadSiteSettings`). Added to the production
30
+ * `User-agent: *` group; a non-production `robots.txt` still disallows everything.
31
+ */
32
+ readonly disallow?: readonly string[] | undefined;
27
33
  }
28
34
 
29
35
  export interface SiteSeo {
@@ -36,8 +42,15 @@ export interface SiteSeo {
36
42
  const isPublicSite = (entry: RouteEntry): boolean =>
37
43
  entry.surface === 'site' && entry.config.policy === undefined;
38
44
 
45
+ /**
46
+ * A dynamic route's pages, UNPREFIXED and once each. A static export's report lists every locale's
47
+ * copy (`/blog/a` and `/en/blog/a`), and the sitemap localizes each page itself — reading the
48
+ * prefixed copies back as pages of their own listed `/en/en/blog/a`.
49
+ */
39
50
  const pagesOf = async (entry: RouteEntry, options: SiteSeoOptions): Promise<readonly string[]> => {
40
- if (options.pagesFor !== undefined) return options.pagesFor(entry.path);
51
+ if (options.pagesFor !== undefined) {
52
+ return [...new Set(options.pagesFor(entry.path).map((path) => unlocalizedPath(path)))];
53
+ }
41
54
  const params = await enumeratePrerender(entry);
42
55
  return params.map((set) => fillPath(entry.pattern.source, set));
43
56
  };
@@ -65,13 +78,29 @@ async function publicSiteRoutes(options: SiteSeoOptions): Promise<readonly Route
65
78
  }
66
79
 
67
80
  export async function siteSeo(options: SiteSeoOptions): Promise<SiteSeo> {
68
- const sitemap = await buildSitemap(await publicSiteRoutes(options), { baseUrl: options.baseUrl });
81
+ // Every routed locale, with `xhtml:link` alternates: one `<url>` per page per locale, each
82
+ // naming the whole cluster and `x-default`. A single-locale app passes none and gets the plain
83
+ // urlset it always had — an alternates cluster of one is noise.
84
+ const locales = routedLocales();
85
+ const defaultLocale = localeConfig().fallback;
86
+ const sitemap = await buildSitemap(await publicSiteRoutes(options), {
87
+ baseUrl: options.baseUrl,
88
+ ...(locales.length < 2
89
+ ? {}
90
+ : {
91
+ locales,
92
+ defaultLocale,
93
+ localizePath: (path: string, locale: string) =>
94
+ localizedPath(path, locale, defaultLocale),
95
+ }),
96
+ });
69
97
  // Past 50,000 URLs `files` are `/sitemap-N.xml` and the index is `/sitemap.xml`; below it,
70
98
  // `files` is that one file and there is no index.
71
99
  const sitemaps = sitemap.index === undefined ? sitemap.files : [sitemap.index, ...sitemap.files];
72
100
  const robots = buildRobots({
73
101
  baseUrl: options.baseUrl,
74
102
  sitemaps: [SITEMAP_PATH],
103
+ ...(options.disallow === undefined ? {} : { disallow: options.disallow }),
75
104
  ...(options.environment === undefined ? {} : { environment: options.environment }),
76
105
  });
77
106
  return { robots, sitemaps };
@@ -65,7 +65,7 @@ const prerender =
65
65
  // landed on disk — which is what \`x build --target static --json\` reads back.
66
66
 
67
67
  import { join } from 'node:path';
68
- import { DEFAULT_ORIGIN, type PrerenderReport, prerenderSite, siteSeo } from '@ultimat3/cli';
68
+ import { loadSiteSettings, type PrerenderReport, prerenderSite, siteSeo } from '@ultimat3/cli';
69
69
 
70
70
  const root = join(import.meta.dir, '..', '..');
71
71
  const flag = Bun.argv.indexOf('--out');
@@ -90,9 +90,11 @@ const origin = Bun.env.SITE_ORIGIN;
90
90
  * \`robots.txt\` fails closed — anything that is not \`ULTIMATE_ENV=production\` emits
91
91
  * \`Disallow: /\` and advertises no sitemap — so a preview build cannot outrank the real site.
92
92
  */
93
- async function writeSeoFiles(report: PrerenderReport, baseUrl: string): Promise<readonly string[]> {
93
+ async function writeSeoFiles(report: PrerenderReport): Promise<readonly string[]> {
94
94
  const seo = await siteSeo({
95
- baseUrl,
95
+ // The origin the pages were built against, so the sitemap and every canonical agree.
96
+ baseUrl: report.origin,
97
+ disallow: (await loadSiteSettings(root)).disallow,
96
98
  pagesFor: (route) =>
97
99
  report.pages.filter((page) => page.route === route).map((page) => page.path),
98
100
  });
@@ -103,7 +105,7 @@ async function writeSeoFiles(report: PrerenderReport, baseUrl: string): Promise<
103
105
 
104
106
  if (import.meta.main) {
105
107
  const report = await prerenderSite({ root, out, ...(origin === undefined ? {} : { origin }) });
106
- const seo = await writeSeoFiles(report, origin ?? DEFAULT_ORIGIN);
108
+ const seo = await writeSeoFiles(report);
107
109
  await Bun.stdout.write(
108
110
  \`\${JSON.stringify({ ok: true, out: report.out, emitted: report.pages, skipped: report.skipped, unmeasured: report.unmeasured, report: report.report, seo })}\\n\`,
109
111
  );