@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/package.json +28 -28
- package/src/app-load.ts +12 -1
- package/src/browser-launcher-port.ts +7 -0
- package/src/cdp-shot-driver.ts +8 -2
- package/src/cmd-shot-matrix.ts +201 -0
- package/src/cmd-shot-spec.ts +15 -1
- package/src/cmd-shot.ts +66 -8
- package/src/cmd-test-spec.ts +13 -3
- package/src/cmd-test.ts +31 -3
- package/src/cmd-verify-spec.ts +4 -4
- package/src/cmd-verify.ts +31 -16
- package/src/dev-route-table.ts +6 -1
- package/src/error-contract.ts +5 -0
- package/src/index.ts +2 -0
- package/src/messages.ts +1 -0
- package/src/prerender-locales.ts +34 -0
- package/src/prerender.ts +67 -11
- package/src/runtime-assets.ts +10 -2
- package/src/runtime-render.ts +41 -19
- package/src/seo-routes.ts +11 -9
- package/src/serve-boot.ts +6 -1
- package/src/shot-locale.ts +67 -0
- package/src/site-asset-routes.ts +106 -0
- package/src/site-assets.ts +177 -0
- package/src/site-config.ts +79 -0
- package/src/site-seo.ts +31 -2
- package/src/templates/scaffold-entries.ts +6 -4
- package/src/test-select.ts +30 -3
- package/src/test-workers.ts +52 -12
- package/src/verify-run.ts +13 -6
- package/src/verify-step.ts +10 -7
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
|
|
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 =
|
|
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
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
55
|
-
* an invented lead, which is the rule `parse.ts` already follows
|
|
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
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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:
|
|
68
|
-
fix
|
|
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
|
package/src/dev-route-table.ts
CHANGED
|
@@ -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
|
];
|
package/src/error-contract.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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 = (
|
|
250
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
}
|
package/src/runtime-assets.ts
CHANGED
|
@@ -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/`,
|
|
35
|
-
*
|
|
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));
|
package/src/runtime-render.ts
CHANGED
|
@@ -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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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(
|
|
21
|
-
|
|
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({
|
|
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
|
+
});
|