@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.
- package/package.json +28 -28
- package/src/app-load.ts +12 -1
- package/src/browser-launcher-fake.ts +4 -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 +21 -1
- package/src/cmd-shot.ts +83 -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 +7 -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/shot-verdict.ts +65 -2
- 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 +65 -12
- package/src/verify-run.ts +20 -7
- package/src/verify-step.ts +10 -7
package/src/cmd-test.ts
CHANGED
|
@@ -17,7 +17,14 @@ import type { CommandResult, JsonValue } from './output';
|
|
|
17
17
|
import type { ParsedArgs } from './parse';
|
|
18
18
|
import { flagBool, flagString } from './parse';
|
|
19
19
|
import { quoteArg } from './shell-quote';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
discoverTests,
|
|
22
|
+
missingSelection,
|
|
23
|
+
readFilters,
|
|
24
|
+
readSample,
|
|
25
|
+
readType,
|
|
26
|
+
sampleFiles,
|
|
27
|
+
} from './test-select';
|
|
21
28
|
import { runShards } from './test-shards';
|
|
22
29
|
import { defaultWorkers, SERIAL_TYPES, WORKER_CEILING } from './test-workers';
|
|
23
30
|
import type { TestType } from './verify-tests';
|
|
@@ -100,16 +107,37 @@ const withScope = (result: CommandResult, scope: AffectedScope): CommandResult =
|
|
|
100
107
|
data: { ...asObject(result.data), affected: affectedScopeJson(scope) },
|
|
101
108
|
});
|
|
102
109
|
|
|
110
|
+
/** The selection that matched nothing, as one phrase for `cli.test.empty`. */
|
|
111
|
+
const describeSelection = (missing: { type?: string; filter?: string }): string =>
|
|
112
|
+
[
|
|
113
|
+
missing.type === undefined ? undefined : `type ${missing.type}`,
|
|
114
|
+
missing.filter === undefined ? undefined : `"${missing.filter}"`,
|
|
115
|
+
]
|
|
116
|
+
.filter((part) => part !== undefined)
|
|
117
|
+
.join(' and ') || 'the selection';
|
|
118
|
+
|
|
103
119
|
export const testCommand: CliCommand = {
|
|
104
120
|
spec: testSpec,
|
|
105
121
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
106
122
|
const type = readOnlyType(ctx.args.positionals);
|
|
107
123
|
const filter = flagString(ctx.args, 'filter');
|
|
124
|
+
const filters = readFilters(filter);
|
|
108
125
|
const sample = readSample(ctx.args);
|
|
109
126
|
const scope = await readAffectedScope(ctx);
|
|
110
|
-
const discovered = await discoverTests(ctx.cwd,
|
|
127
|
+
const discovered = await discoverTests(ctx.cwd, filters, type);
|
|
111
128
|
if (discovered.length === 0) {
|
|
112
|
-
|
|
129
|
+
const missing = missingSelection(type, filter);
|
|
130
|
+
// `--allow-empty` is for a caller that COMPUTED the selection — a scoped runner handing over
|
|
131
|
+
// the paths a diff touched, some of which hold no test yet. Green, spawning nothing, and
|
|
132
|
+
// never read as "the suite passed": the line names the selection that matched nothing and
|
|
133
|
+
// `data.files` is 0. Without the flag an empty selection stays X_TEST_NO_FILES, because a
|
|
134
|
+
// typo'd `--filter` typed by hand is exactly the run that must not pass.
|
|
135
|
+
if (flagBool(ctx.args, 'allow-empty')) {
|
|
136
|
+
return ok('test', msg('cli.test.empty', { selection: describeSelection(missing) }), {
|
|
137
|
+
data: { ...missing, files: 0, empty: true },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
throw new NoTestFilesError({ root: ctx.cwd, ...missing });
|
|
113
141
|
}
|
|
114
142
|
const selected =
|
|
115
143
|
scope === undefined
|
package/src/cmd-verify-spec.ts
CHANGED
|
@@ -7,22 +7,22 @@ import { WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-worke
|
|
|
7
7
|
export const verifySpec: CommandSpec = {
|
|
8
8
|
name: 'verify',
|
|
9
9
|
summary: 'the gate: typecheck, lint, boundaries, all tests, drift, contract, budgets',
|
|
10
|
-
usage: 'x verify [--only <step>] [--workers N] [--json]',
|
|
10
|
+
usage: 'x verify [--only <step>[,<step>…]] [--workers N] [--json]',
|
|
11
11
|
requiresApp: true,
|
|
12
12
|
// Two flags, and only one of them narrows. `--workers` changes how wide the test steps
|
|
13
|
-
// spread, never which steps run. `--only` runs
|
|
13
|
+
// spread, never which steps run. `--only` runs the steps it names and says so in both renderers —
|
|
14
14
|
// never silently, which is the whole of what makes it safe to have.
|
|
15
15
|
flags: [
|
|
16
16
|
{
|
|
17
17
|
name: 'workers',
|
|
18
18
|
type: 'string',
|
|
19
|
-
summary: `test processes per parallel step (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
|
|
19
|
+
summary: `test processes per parallel step (default: ${WORKER_OVERSUBSCRIBE}x CPUs rounded up, held to free memory; min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
22
|
name: 'only',
|
|
23
23
|
type: 'string',
|
|
24
24
|
summary:
|
|
25
|
-
'run
|
|
25
|
+
'run the named step(s), comma-separated, in one process — an iteration loop, NOT A GATE RUN; the gate is this command with no flag',
|
|
26
26
|
},
|
|
27
27
|
],
|
|
28
28
|
};
|
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
|
@@ -189,6 +189,12 @@ const CATALOG = {
|
|
|
189
189
|
'cli.shot.ok': '{route} clean — {islands} island(s) mounted, nothing logged and nothing threw',
|
|
190
190
|
'cli.shot.errors': '{route}: {errors} console error(s) — verdict.json names each one',
|
|
191
191
|
'cli.shot.redirected': '{route} redirected to {url} — the picture is not the route asked for',
|
|
192
|
+
// After the redirect and before everything else: a 404 page can be spotless, and a picture of
|
|
193
|
+
// "Not found" read as the route is the capture lying about its subject.
|
|
194
|
+
'cli.shot.status':
|
|
195
|
+
'{route} answered HTTP {status}, expected {expected} — the picture is that response, not the page',
|
|
196
|
+
'cli.shot.document': ' document HTTP {status}',
|
|
197
|
+
'cli.shot.documentUnknown': ' document status not seen — the driver recorded no response for it',
|
|
192
198
|
'cli.shot.server.booted': ' server booted for this shot on {url}',
|
|
193
199
|
'cli.shot.server.reused': ' server the x dev already running on {url}',
|
|
194
200
|
'cli.shot.canvas': ' canvas {width}x{height}',
|
|
@@ -257,6 +263,7 @@ const CATALOG = {
|
|
|
257
263
|
' the diff has moved under this thread — {line} is where the comment was written',
|
|
258
264
|
'cli.test.fail': '{failed} of {workers} shard(s) failed',
|
|
259
265
|
'cli.test.affected.none': 'nothing is affected by {base}...HEAD — 0 test file(s) ran',
|
|
266
|
+
'cli.test.empty': 'no test file matches {selection} — 0 test file(s) ran (--allow-empty)',
|
|
260
267
|
'cli.test.pass': '{files} test file(s) on {workers} worker(s) passed in {ms}ms',
|
|
261
268
|
'cli.test.sampled': 'sampled {kept} of {total} {type} file(s)',
|
|
262
269
|
'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
|