@ultimat3/cli 22.2.2 → 22.3.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/src/cmd-verify.ts CHANGED
@@ -2,12 +2,12 @@
2
2
  // same list in the terminal and in --json, and a non-zero exit if any step fails. Green means
3
3
  // shippable (axiom 5): one step list, no second checklist, no CI-only step.
4
4
  //
5
- // `--only <step>` is the ONE narrowing, decided as D6, and it does not weaken that: the GATE is
6
- // the no-flag run, and a narrowed run says `NOT A GATE RUN` in the summary and in `--json` so no
5
+ // `--only <step>[,<step>…]` is the ONE narrowing, decided as D6, and it does not weaken that: the
6
+ // GATE is the no-flag run, and a narrowed run says `NOT A GATE RUN` in the summary and in `--json` so no
7
7
  // reader of either can take it for one. `--skip` stays refused — it would let a caller drop the
8
8
  // step that was going to fail and still read the output as a whole-tree verdict.
9
9
 
10
- import { nearestName } from '@ultimat3/core';
10
+ import { nearestName, renderFixShellArg } from '@ultimat3/core';
11
11
  import { requireAppRoot } from './app-root';
12
12
  import { verifySpec } from './cmd-verify-spec';
13
13
  import type { CliCommand, CommandContext } from './command';
@@ -35,7 +35,7 @@ export const verifyCommand: CliCommand = {
35
35
  // Both readers before the run: an unrunnable flag must be refused in milliseconds, not after
36
36
  // `tsc -b` has spent fourteen seconds on a run the caller cannot use.
37
37
  const workers = readWorkers(ctx.args);
38
- const only = readOnlyStep(ctx.args);
38
+ const only = readOnlySteps(ctx.args);
39
39
  return runVerify(VERIFY_STEPS, {
40
40
  root,
41
41
  runner: ctx.runner,
@@ -47,28 +47,43 @@ export const verifyCommand: CliCommand = {
47
47
  };
48
48
 
49
49
  /**
50
- * The step `--only` names, or nothing. Refused against `VERIFY_STEP_NAMES` — the same constant the
51
- * runner's list is built from — so a typo can never be read as "narrow to no steps at all", which
52
- * is a run that passes by checking nothing.
50
+ * The steps `--only` names, or nothing: one step (`--only lint`) or a comma-separated list
51
+ * (`--only typecheck,lint,boundaries`) run in ONE process, in the gate's declared order whatever
52
+ * order they were typed in. Refused against `VERIFY_STEP_NAMES` — the same constant the runner's
53
+ * list is built from — so a typo can never be read as "narrow to no steps at all", which is a run
54
+ * that passes by checking nothing. An empty item (`lint,,drift`, a trailing comma) is refused for
55
+ * the same reason.
53
56
  *
54
- * A near miss leads with the step it is near; a word near NOTHING gets the gate itself rather than
55
- * an invented lead, which is the rule `parse.ts` already follows for a command that resembles
56
- * none. Both arms are commands that run.
57
+ * A near miss leads with the list as it would be with each typo corrected; an item near NOTHING
58
+ * gets the gate itself rather than an invented lead, which is the rule `parse.ts` already follows
59
+ * for a command that resembles none. Both arms are commands that run.
57
60
  */
58
- export const readOnlyStep = (args: ParsedArgs): VerifyStepName | undefined => {
61
+ export const readOnlySteps = (args: ParsedArgs): readonly VerifyStepName[] | undefined => {
59
62
  const raw = flagString(args, 'only');
60
63
  if (raw === undefined) return undefined;
61
- const found = VERIFY_STEP_NAMES.find((name) => name === raw);
62
- if (found !== undefined) return found;
63
- const suggestion = nearestName(raw, VERIFY_STEP_NAMES);
64
+ const items = raw.split(',').map((item) => item.trim());
65
+ const unknown = items.filter((item) => !isStepName(item));
66
+ if (unknown.length === 0) {
67
+ return VERIFY_STEP_NAMES.filter((name) => items.includes(name));
68
+ }
69
+ const corrected = items.map((item) =>
70
+ isStepName(item) ? item : nearestName(item, VERIFY_STEP_NAMES),
71
+ );
72
+ const fix = corrected.every((item) => item !== undefined)
73
+ ? `x verify --only ${renderFixShellArg([...new Set(corrected)].join(','), '<step,step>')} --json`
74
+ : 'x verify --json';
75
+ const named = unknown.map((item) => (item === '' ? '(empty)' : `"${item}"`)).join(', ');
64
76
  throw new BadFlagError({
65
77
  flag: 'only',
66
78
  command: 'verify',
67
- reason: `"${raw}" is not a gate step (${VERIFY_STEP_NAMES.join(', ')})`,
68
- fix: suggestion === undefined ? 'x verify --json' : `x verify --only ${suggestion} --json`,
79
+ reason: `${named} ${unknown.length === 1 ? 'is not a gate step' : 'are not gate steps'} (${VERIFY_STEP_NAMES.join(', ')})`,
80
+ fix,
69
81
  });
70
82
  };
71
83
 
84
+ const isStepName = (raw: string): raw is VerifyStepName =>
85
+ (VERIFY_STEP_NAMES as readonly string[]).includes(raw);
86
+
72
87
  /**
73
88
  * Both bounds are the constants the flag summary already names, so `x help verify` and the reader
74
89
  * cannot disagree. Exported for the test that pins them: the command's `run` reaches this only
@@ -21,6 +21,7 @@ import { assetRoutes } from './runtime-assets';
21
21
  import { appRoutes } from './runtime-render';
22
22
  import { servedStorage, storageRoutes } from './runtime-storage';
23
23
  import { seoRoutes } from './seo-routes';
24
+ import { loadSiteSettings, publicOrigin } from './site-config';
24
25
  import { styleBundle } from './style-bundle';
25
26
  import { styleRoutes } from './style-routes';
26
27
  import { serviceWorkerArtifacts } from './sw-artifacts';
@@ -55,6 +56,9 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
55
56
  // is mounted, and the 0kb baseline is not spent on a `<link>` to a file that does not exist.
56
57
  const pwa = await loadPwaArtifacts(input.root);
57
58
  const theme = themeBoot(await loadThemeMode(input.root));
59
+ // `site.origin` and `seo.robots.disallow`: the absolute URLs every document and the sitemap carry.
60
+ const site = await loadSiteSettings(input.root);
61
+ const origin = publicOrigin(input.env, site);
58
62
  // The same call `serve.ts` makes, so the two boots cannot serve different sync targets.
59
63
  const sync = await pageSync(input.root, input.env, input.buildId, input.realtime);
60
64
  const errorStyles = await errorPageStyleSources(input.root);
@@ -101,7 +105,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
101
105
  // captured at boot would answer 404 for the href the document now carries.
102
106
  ...styleRoutes(() => styleBundle()),
103
107
  // `robots.txt` and `sitemap.xml`, the same two files the static export writes (`site-seo.ts`).
104
- ...seoRoutes({ env: input.env }),
108
+ ...seoRoutes({ env: input.env, site }),
105
109
  // `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
106
110
  // lives here rather than in a second server because everything it needs is in THIS process:
107
111
  // the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
@@ -119,6 +123,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
119
123
  ...(sync.head === undefined ? {} : { sync: sync.head }),
120
124
  persisted: sync.persisted,
121
125
  themeHead: theme.head,
126
+ ...(origin === undefined ? {} : { origin }),
122
127
  ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
123
128
  }),
124
129
  ];
@@ -42,6 +42,11 @@ export const COMMAND_TOKENS: readonly RegExp[] = [
42
42
  // Built from `fix-path.ts`'s extension list, because the token that makes a fix count as an
43
43
  // instruction is exactly the token `citedPathProblem` then has to resolve.
44
44
  new RegExp(FILE_TOKEN_PATTERN),
45
+ // A repo script, run as written: `bin/check --full`, `./bin/probe`. An app's gate is `bin/check`,
46
+ // so without this the one command its fixes most often name read as the banned word "check".
47
+ // Anchored like `x` above — `/usr/bin/env` and `robin/check` are not the repo's scripts.
48
+ // (`scripts/<name>.ts` is already a file token.)
49
+ /(?:^|[\s;|&("'`])(?:\.\/)?bin\/[\w.-]+/,
45
50
  /\b(?:app\.config\.ts|package\.json|tsconfig\.json|bunfig\.toml|\.env(?:\.[\w.-]+)?)\b/,
46
51
  ];
47
52
 
package/src/index.ts CHANGED
@@ -145,6 +145,8 @@ export {
145
145
  serveApp,
146
146
  } from './serve';
147
147
  export { quoteArg } from './shell-quote';
148
+ export type { SiteSettings } from './site-config';
149
+ export { loadSiteSettings, publicOrigin } from './site-config';
148
150
  export type { SiteSeo, SiteSeoOptions } from './site-seo';
149
151
  export { ROBOTS_PATH, SITEMAP_PATH, siteSeo } from './site-seo';
150
152
  export { eachSourceFile, isGenerated, isTest, SOURCE_GLOBS } from './source-files';
package/src/messages.ts CHANGED
@@ -257,6 +257,7 @@ const CATALOG = {
257
257
  ' the diff has moved under this thread — {line} is where the comment was written',
258
258
  'cli.test.fail': '{failed} of {workers} shard(s) failed',
259
259
  'cli.test.affected.none': 'nothing is affected by {base}...HEAD — 0 test file(s) ran',
260
+ 'cli.test.empty': 'no test file matches {selection} — 0 test file(s) ran (--allow-empty)',
260
261
  'cli.test.pass': '{files} test file(s) on {workers} worker(s) passed in {ms}ms',
261
262
  'cli.test.sampled': 'sampled {kept} of {total} {type} file(s)',
262
263
  'cli.test.type.fail': '{type} — {failed} of {workers} shard(s) failed',
@@ -0,0 +1,34 @@
1
+ // One `site/` route's static artifacts in every routed locale. The default locale's pages keep the
2
+ // paths and files `renderStatic` computed; every other locale's are served at `/<locale>/<path>`
3
+ // and written to `<locale>/<file>` — the layout every static host resolves with no rewrite rule.
4
+
5
+ import { localeSegment, localizedPath } from '@ultimat3/i18n';
6
+ import type { StaticArtifact } from '@ultimat3/render/server';
7
+
8
+ /** A `StaticArtifact` placed for one locale: its served path and its file under `<locale>/`. */
9
+ export type LocalizedArtifact = StaticArtifact & { readonly locale: string };
10
+
11
+ /** The default's artifacts first, so a report lists the unprefixed page before its translations. */
12
+ export async function localizedArtifacts(
13
+ locales: readonly string[],
14
+ defaultLocale: string,
15
+ renderIn: (locale: string) => Promise<readonly StaticArtifact[]>,
16
+ ): Promise<readonly LocalizedArtifact[]> {
17
+ const out: LocalizedArtifact[] = [];
18
+ for (const locale of locales) {
19
+ const isDefault = locale === defaultLocale;
20
+ for (const artifact of await renderIn(locale)) {
21
+ out.push({
22
+ ...artifact,
23
+ locale,
24
+ path: isDefault ? artifact.path : localizedPath(artifact.path, locale, defaultLocale),
25
+ outputPath: isDefault
26
+ ? artifact.outputPath
27
+ : `${localeSegment(locale)}/${artifact.outputPath}`,
28
+ });
29
+ }
30
+ // A route with no pages writes none in any locale; asking again per locale is the same answer.
31
+ if (out.length === 0) break;
32
+ }
33
+ return out;
34
+ }
package/src/prerender.ts CHANGED
@@ -4,7 +4,15 @@
4
4
  // only which routes qualify and where the bytes land.
5
5
 
6
6
  import { join } from 'node:path';
7
- import { createContext, isUltimateError, renderThrowable, runWithContext } from '@ultimat3/core';
7
+ import {
8
+ createContext,
9
+ DEFAULT_ENVIRONMENT,
10
+ isUltimateError,
11
+ renderThrowable,
12
+ runWithContext,
13
+ tryResolveEnvironment,
14
+ } from '@ultimat3/core';
15
+ import { localeConfig, localizedPath, routedLocales } from '@ultimat3/i18n';
8
16
  import type { RouteEntry } from '@ultimat3/render';
9
17
  import { describeRoutes, routeEntries } from '@ultimat3/render';
10
18
  import { renderStatic } from '@ultimat3/render/server';
@@ -19,9 +27,12 @@ import { buildIslands, writeIslands } from './island-bundle';
19
27
  import { measureDatabase } from './measure-database';
20
28
  import { measurePaths } from './measure-paths';
21
29
  import { measureScope, withAppUrl } from './measure-scope';
30
+ import { localizedArtifacts } from './prerender-locales';
22
31
  import { clearPrerenderOut } from './prerender-out';
23
32
  import { loadPwaArtifacts, WEB_MANIFEST_PATH, writePwaIcons } from './pwa-artifacts';
24
33
  import { routeDocument } from './runtime-render';
34
+ import { writeSiteAssets } from './site-assets';
35
+ import { loadSiteSettings, originWarning, publicOrigin } from './site-config';
25
36
  import type { SkippedRoute, UnmeasuredRoute } from './static-report';
26
37
  import { skippedRoute, skipReasonFor, writeStaticReport } from './static-report';
27
38
  import { styleBundle, writeStyles } from './style-bundle';
@@ -53,15 +64,22 @@ export const isPrerenderable = (entry: RouteEntry): boolean =>
53
64
  export interface PrerenderOptions {
54
65
  readonly root: string;
55
66
  readonly out: string;
56
- /** Origin the rendered `<head>` builds canonical and og:url from. */
67
+ /**
68
+ * Origin the rendered `<head>` builds canonical, og:url and hreflang from. Absent: `APP_URL`,
69
+ * `SITE_ORIGIN`, then `site.origin` from `app.config.ts`, then `DEFAULT_ORIGIN` — with a warning
70
+ * on a production build, because a placeholder canonical is one a search engine indexes.
71
+ */
57
72
  readonly origin?: string;
58
73
  }
59
74
 
60
75
  export interface PrerenderedPage {
61
76
  /** The DECLARED route, `/blog/:slug` — one route can write many pages, and the report groups them. */
62
77
  readonly route: string;
78
+ /** The URL the page is served at — `/en/pricing` for a non-default locale. */
63
79
  readonly path: string;
64
- /** Relative to `out`, POSIX, as `renderStatic` computed it. */
80
+ /** The locale the page was rendered in. */
81
+ readonly locale: string;
82
+ /** Relative to `out`, POSIX — under `<locale>/` for a non-default locale. */
65
83
  readonly file: string;
66
84
  readonly hash: string;
67
85
  readonly bytes: number;
@@ -101,6 +119,10 @@ export interface PrerenderReport {
101
119
  * has to reach the build's own report. Empty for an app with no service worker.
102
120
  */
103
121
  readonly serviceWorkerWarnings: readonly string[];
122
+ /** What this build could not get right on its own — today, a production build with no origin. */
123
+ readonly warnings: readonly string[];
124
+ /** The origin every absolute URL in the export was built against — hand it to `siteSeo`. */
125
+ readonly origin: string;
104
126
  }
105
127
 
106
128
  /**
@@ -149,7 +171,17 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
149
171
  await clearPrerenderOut(options.out, options.root);
150
172
  await loadApp(options.root);
151
173
  const buildId = (await appManifest(options.root)).manifest.buildId;
152
- const origin = options.origin ?? DEFAULT_ORIGIN;
174
+ const declaredOrigin =
175
+ options.origin ?? publicOrigin(process.env, await loadSiteSettings(options.root));
176
+ const origin = declaredOrigin ?? DEFAULT_ORIGIN;
177
+ // Written to stderr as well as returned: an app's `prerender.ts` prints its report as ONE JSON
178
+ // line on stdout, and a build that parses it must not meet a warning inside it.
179
+ const warnings = originWarning(tryResolveEnvironment() ?? DEFAULT_ENVIRONMENT, declaredOrigin);
180
+ for (const warning of warnings) process.stderr.write(`warning: ${warning}\n`);
181
+ // After `loadApp`: `defineCatalogs()` configures the locales on the app's own import. The
182
+ // default first, so every other pass writes beside a tree that already holds the unprefixed one.
183
+ const locales = routedLocales();
184
+ const defaultLocale = localeConfig().fallback;
153
185
  const pages: PrerenderedPage[] = [];
154
186
  const skipped: SkippedRoute[] = [];
155
187
  const routes: RouteStats[] = [];
@@ -171,6 +203,9 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
171
203
  // pages whose `<link>` names a file the artifact does not have.
172
204
  const styles = styleBundle();
173
205
  await writeStyles(styles, options.out);
206
+ // The hashed `site/assets/**` copies every document's `asset()` URL names — once, never per
207
+ // locale: an asset is the same bytes in every language, so `/en/` pages link the root's copy.
208
+ writeSiteAssets(options.root, options.out);
174
209
  // Same rule, one asset further: a browser asks for `/favicon.ico` on the first page it loads,
175
210
  // and a static export has no route to answer it — so the bytes the served surfaces would have
176
211
  // returned go into the artifact instead of leaving a 404 in every visitor's console.
@@ -230,7 +265,12 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
230
265
  // build. One context for the build, `role: 'web'` because that is the role serving these
231
266
  // documents, and this build's own id so a component reading `ctx.buildId` stamps the artifact
232
267
  // with the id the report and the stats carry.
233
- const ctx = createContext({ role: 'web', buildId });
268
+ // ONE PER LOCALE, and the locale is the context's: `createContext` defaults it to core's `en`, so a
269
+ // Spanish-default site was prerendered in English — `<html lang="en">` over English copy — while
270
+ // the served process answered Spanish. Each `site/` page is rendered once per routed locale.
271
+ const contexts = new Map(
272
+ locales.map((locale) => [locale, createContext({ role: 'web', buildId, locale })]),
273
+ );
234
274
  // A SECOND scope, for the branch below that renders only to weigh (`measure-scope.ts`): a
235
275
  // request context holding the app's measurement actor, with the app's own API answered in
236
276
  // process, because an `app/` page's `load` calls policy-guarded queries over its typed client
@@ -244,10 +284,17 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
244
284
  routeDocument(entry, data, {
245
285
  resolveIsland: (file: string) => islands.resolverFor(file),
246
286
  themeHead: theme.head,
287
+ origin,
247
288
  ...(pwa === undefined ? {} : { pwaHead: pwa.head + (swHead ?? '') }),
248
289
  });
249
- const document = (entry: RouteEntry, data: { url: string; params: Record<string, string> }) =>
250
- runWithContext(ctx, () => render(entry, data));
290
+ const document = (
291
+ locale: string,
292
+ entry: RouteEntry,
293
+ data: { url: string; params: Record<string, string> },
294
+ ) =>
295
+ runWithContext(contexts.get(locale) ?? createContext({ role: 'web', buildId, locale }), () =>
296
+ render(entry, data),
297
+ );
251
298
 
252
299
  try {
253
300
  for (const entry of routeEntries()) {
@@ -303,10 +350,16 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
303
350
  }
304
351
  continue;
305
352
  }
306
- const artifacts = await renderStatic(
307
- entry,
308
- ({ path, params }) => document(entry, { url: new URL(path, origin).href, params }),
309
- { buildId },
353
+ const artifacts = await localizedArtifacts(locales, defaultLocale, (locale) =>
354
+ renderStatic(
355
+ entry,
356
+ ({ path, params }) =>
357
+ document(locale, entry, {
358
+ url: new URL(localizedPath(path, locale, defaultLocale), origin).href,
359
+ params,
360
+ }),
361
+ { buildId },
362
+ ),
310
363
  );
311
364
  // `enumeratePrerender` answers `[]` for a dynamic route with no `prerender()`, so a
312
365
  // `render: 'static'` route with a param writes nothing and used to be reported NOWHERE — past
@@ -329,6 +382,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
329
382
  pages.push({
330
383
  route: entry.path,
331
384
  path: artifact.path,
385
+ locale: artifact.locale,
332
386
  file: artifact.outputPath,
333
387
  hash: artifact.hash,
334
388
  bytes,
@@ -403,5 +457,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
403
457
  islands: islands.chunks.map((chunk) => chunk.file),
404
458
  styles: styles.chunks.map((chunk) => chunk.url),
405
459
  serviceWorkerWarnings: serviceWorker?.warnings ?? [],
460
+ warnings,
461
+ origin,
406
462
  };
407
463
  }
@@ -26,13 +26,17 @@ import {
26
26
  authorizeStorageRead,
27
27
  STORAGE_READ_PERMISSION,
28
28
  } from './runtime-storage';
29
+ import { siteAssetRoutes } from './site-asset-routes';
30
+ import { siteAssetTable } from './site-assets';
29
31
 
30
32
  /**
31
33
  * Storage-backed images. `responsiveImage({ src: '/media/<key>' })` mints its variants under it.
32
34
  * Guarded exactly as `/_storage` is — an object reachable through two URLs must not be reachable
33
35
  * on two different terms — so a `src` under this path needs a signed-in reader holding
34
- * `storage:read`. A genuinely public image belongs in `apps/web/site/`, which is served as a
35
- * static asset and never touches a disk holding another tenant's uploads.
36
+ * `storage:read`. A genuinely public image belongs in `apps/web/site/assets/`, named with
37
+ * `asset('assets/…')`: served below at a content-hashed `/assets/*` URL, copied into the static
38
+ * export under the same name (`site-assets.ts`), and never touching a disk that holds another
39
+ * tenant's uploads.
36
40
  */
37
41
  export const MEDIA_BASE_PATH = '/media';
38
42
 
@@ -222,6 +226,10 @@ export function assetRoutes(options: AssetRoutesOptions): readonly Route[] {
222
226
  // container is the dev/prod difference this package's own rule forbids. `favicon.ts` owns what
223
227
  // the answer IS — this file only says the app's asset surface is where it hangs.
224
228
  routes.push(faviconRoute(options.root));
229
+ // And every other public file of the site: `apps/web/site/assets/**` at the hashed URLs
230
+ // `asset()` mints, immutable. The same table `loadApp` installed for the renderer, so the URL a
231
+ // document names and the URL this answers are one computation.
232
+ routes.push(...siteAssetRoutes(siteAssetTable(options.root)));
225
233
  // Same rule one asset further along: the icons above are the ones this manifest NAMES, so the
226
234
  // two belong to one surface and cannot be mounted from two places without drifting apart.
227
235
  if (options.pwa !== undefined) routes.push(pwaManifestRoute(options.pwa));
@@ -26,7 +26,7 @@ import type {
26
26
  RouteParams,
27
27
  } from '@ultimat3/http';
28
28
  import { asCtx, html, NO_STORE, redirect, stream, takeRedirect } from '@ultimat3/http';
29
- import { currentLocale } from '@ultimat3/i18n';
29
+ import { currentLocale, localeConfig } from '@ultimat3/i18n';
30
30
  import type {
31
31
  ClientSyncHead,
32
32
  IslandCollector,
@@ -107,6 +107,12 @@ export interface DocumentOptions {
107
107
  * with no scope carries none.
108
108
  */
109
109
  readonly persisted?: () => readonly string[];
110
+ /**
111
+ * The public origin canonical, `og:url` and hreflang are absolute against — `publicOrigin()`
112
+ * from `site-config.ts`. Absent, the request's own origin: a relative canonical is one a crawler
113
+ * resolves against whatever host it happened to fetch from, a CDN's or a preview's.
114
+ */
115
+ readonly origin?: string;
110
116
  }
111
117
 
112
118
  export interface DevRenderOptions extends DocumentOptions {
@@ -142,24 +148,37 @@ const headFor = async (
142
148
  data: RouteData,
143
149
  options: DocumentOptions,
144
150
  scope?: string,
145
- ): Promise<string> =>
146
- renderHead(
147
- headFromMeta(
148
- await entry.config.meta(metaContextFor(ctx, data)),
149
- seoRenderers({ path: new URL(ctx.url).pathname }),
150
- [
151
- ...(options.sync === undefined ? [] : clientSyncTags(options.sync)),
152
- // The page boot rides the scope tag: its whole job — restoring a principal's persisted
153
- // records and replaying its queued writes — is per principal, and a shareable document
154
- // (no scope tag) has neither. Cheaper than walking the page's islands, and exact.
155
- ...(scope === undefined
156
- ? []
157
- : [clientScopeTag(scope), ...clientPersistTags(options.persisted?.() ?? [])]),
158
- ],
159
- ),
160
- ) +
161
- (options.themeHead ?? '') +
162
- (options.pwaHead ?? '');
151
+ ): Promise<string> => {
152
+ const meta = metaContextFor(ctx, data);
153
+ const url = new URL(ctx.url);
154
+ return (
155
+ renderHead(
156
+ headFromMeta(
157
+ await entry.config.meta(meta),
158
+ seoRenderers({
159
+ path: url.pathname,
160
+ baseUrl: options.origin ?? url.origin,
161
+ localization: {
162
+ locale: meta.locale,
163
+ defaultLocale: localeConfig().fallback,
164
+ alternates: meta.alternates,
165
+ },
166
+ }),
167
+ [
168
+ ...(options.sync === undefined ? [] : clientSyncTags(options.sync)),
169
+ // The page boot rides the scope tag: its whole job — restoring a principal's persisted
170
+ // records and replaying its queued writes — is per principal, and a shareable document
171
+ // (no scope tag) has neither. Cheaper than walking the page's islands, and exact.
172
+ ...(scope === undefined
173
+ ? []
174
+ : [clientScopeTag(scope), ...clientPersistTags(options.persisted?.() ?? [])]),
175
+ ],
176
+ ),
177
+ ) +
178
+ (options.themeHead ?? '') +
179
+ (options.pwaHead ?? '')
180
+ );
181
+ };
163
182
 
164
183
  /**
165
184
  * `<link rel="stylesheet">` for the surface's own stylesheets, or nothing at all when the surface
@@ -409,6 +428,9 @@ const metaOf = (entry: RouteEntry): HttpRouteMeta => ({
409
428
  auth: entry.config.policy === undefined ? 'public' : 'required',
410
429
  render: entry.config.render,
411
430
  tags: [entry.surface],
431
+ // A `site/` page is prerendered once per locale and served as files, so its locale is its URL's:
432
+ // the unprefixed path is the default, and the served process must answer what the file says.
433
+ localeSource: entry.surface === 'site' ? 'path' : 'request',
412
434
  ...(entry.config.policy === undefined ? {} : { policy: entry.config.policy.permission }),
413
435
  // Projected so the router screens its ages at mount (`assertRouteCache`) and the `cache-headers`
414
436
  // stage applies it to a response that wrote none — a loader's redirect. The rendered document
package/src/seo-routes.ts CHANGED
@@ -5,24 +5,22 @@
5
5
  import { DEFAULT_ENVIRONMENT, type Environment, tryResolveEnvironment } from '@ultimat3/core';
6
6
  import type { Route, UltimateRequest } from '@ultimat3/http';
7
7
  import { applyCacheHeaders } from '@ultimat3/http';
8
+ import { NO_SITE_SETTINGS, publicOrigin, type SiteSettings } from './site-config';
8
9
  import { ROBOTS_PATH, SITEMAP_PATH, siteSeo } from './site-seo';
9
10
 
10
11
  export interface SeoRoutesOptions {
11
12
  readonly env: Readonly<Record<string, string | undefined>>;
13
+ /** `site.origin` and `seo.robots.disallow` from `app.config.ts` (`loadSiteSettings`). */
14
+ readonly site?: SiteSettings;
12
15
  }
13
16
 
14
17
  /**
15
- * The public origin: `APP_URL`, the one the framework's runtime already names for it (OAuth's
16
- * redirect, the sync node's admitted origin); else `SITE_ORIGIN`, the static build's; else the
18
+ * The public origin — `publicOrigin()`: `APP_URL`, `SITE_ORIGIN`, then `site.origin` — else the
17
19
  * request's own. A container behind an ingress sees its pod address as the request's host, which
18
20
  * is why the declared origin comes first — a sitemap of `http://10.0.0.7:3000/…` indexes nothing.
19
21
  */
20
- function originOf(env: SeoRoutesOptions['env'], request: UltimateRequest): string {
21
- for (const key of ['APP_URL', 'SITE_ORIGIN']) {
22
- const declared = env[key]?.trim() ?? '';
23
- if (declared !== '') return declared.replace(/\/+$/, '');
24
- }
25
- return new URL(request.url).origin;
22
+ function originOf(options: SeoRoutesOptions, request: UltimateRequest): string {
23
+ return publicOrigin(options.env, options.site ?? NO_SITE_SETTINGS) ?? new URL(request.url).origin;
26
24
  }
27
25
 
28
26
  /**
@@ -35,7 +33,11 @@ export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
35
33
  const environment: Environment =
36
34
  tryResolveEnvironment({ env: options.env }) ?? DEFAULT_ENVIRONMENT;
37
35
  const answer = async (request: UltimateRequest) =>
38
- await siteSeo({ baseUrl: originOf(options.env, request), environment });
36
+ await siteSeo({
37
+ baseUrl: originOf(options, request),
38
+ environment,
39
+ disallow: options.site?.disallow ?? [],
40
+ });
39
41
 
40
42
  return [
41
43
  {
package/src/serve-boot.ts CHANGED
@@ -29,6 +29,7 @@ import { seoRoutes } from './seo-routes';
29
29
  import { loadDrainConfig } from './serve-drain';
30
30
  import { configureReporting, containerBinding, metricsPortFor, portFromEnv } from './serve-env';
31
31
  import type { ServedApp, ServeOptions } from './serve-types';
32
+ import { loadSiteSettings, publicOrigin } from './site-config';
32
33
  import { styleBundle } from './style-bundle';
33
34
  import { styleRoutes } from './style-routes';
34
35
  import { serviceWorkerArtifacts } from './sw-artifacts';
@@ -138,6 +139,9 @@ async function webSurface(
138
139
  // prevent, and it is the one an operator cannot see without installing the app.
139
140
  const pwa = await loadPwaArtifacts(options.root);
140
141
  const theme = themeBoot(await loadThemeMode(options.root));
142
+ // `site.origin` and `seo.robots.disallow`: the absolute URLs every document and the sitemap carry.
143
+ const site = await loadSiteSettings(options.root);
144
+ const origin = publicOrigin(options.env, site);
141
145
  // The page's sync target and its scripts — the same call `x dev` makes, so the two cannot differ.
142
146
  // Before the service worker, which precaches those scripts.
143
147
  const sync = await pageSync(options.root, options.env, buildId, runtime.realtime);
@@ -175,7 +179,7 @@ async function webSurface(
175
179
  // filled, so this process serves exactly the CSS it renders against.
176
180
  ...styleRoutes(() => styleBundle()),
177
181
  // `robots.txt` and `sitemap.xml`, the same two files the static export writes (`site-seo.ts`).
178
- ...seoRoutes({ env: options.env }),
182
+ ...seoRoutes({ env: options.env, site }),
179
183
  // The page's one socket: its worker script, served beside the islands for their reason.
180
184
  ...sync.routes,
181
185
  ...appRoutes({
@@ -184,6 +188,7 @@ async function webSurface(
184
188
  ...(sync.head === undefined ? {} : { sync: sync.head }),
185
189
  persisted: sync.persisted,
186
190
  themeHead: theme.head,
191
+ ...(origin === undefined ? {} : { origin }),
187
192
  ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
188
193
  // Only when a store was supplied. `createIsrController` defaults to a per-process memory
189
194
  // store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
@@ -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
+ });