@ultimat3/cli 22.2.1 → 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/api-registration.ts +23 -11
- 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-dev.ts +1 -0
- 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 +13 -2
- package/src/error-contract.ts +5 -0
- package/src/index.ts +4 -0
- package/src/messages.ts +1 -0
- package/src/page-sync.ts +12 -1
- 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 +69 -0
- package/src/serve-boot.ts +10 -2
- 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 +107 -0
- package/src/templates/scaffold-entries.ts +22 -36
- 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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// `GET /robots.txt` and `GET /sitemap.xml` from a running web role — the files the static export
|
|
2
|
+
// writes, answered by the process for a deploy that serves its pages from a container. Mounted by
|
|
3
|
+
// `x dev` and `runRole` alike, before the app's pages, for `style-routes.ts`' reason.
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_ENVIRONMENT, type Environment, tryResolveEnvironment } from '@ultimat3/core';
|
|
6
|
+
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
7
|
+
import { applyCacheHeaders } from '@ultimat3/http';
|
|
8
|
+
import { NO_SITE_SETTINGS, publicOrigin, type SiteSettings } from './site-config';
|
|
9
|
+
import { ROBOTS_PATH, SITEMAP_PATH, siteSeo } from './site-seo';
|
|
10
|
+
|
|
11
|
+
export interface SeoRoutesOptions {
|
|
12
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
13
|
+
/** `site.origin` and `seo.robots.disallow` from `app.config.ts` (`loadSiteSettings`). */
|
|
14
|
+
readonly site?: SiteSettings;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The public origin — `publicOrigin()`: `APP_URL`, `SITE_ORIGIN`, then `site.origin` — else the
|
|
19
|
+
* request's own. A container behind an ingress sees its pod address as the request's host, which
|
|
20
|
+
* is why the declared origin comes first — a sitemap of `http://10.0.0.7:3000/…` indexes nothing.
|
|
21
|
+
*/
|
|
22
|
+
function originOf(options: SeoRoutesOptions, request: UltimateRequest): string {
|
|
23
|
+
return publicOrigin(options.env, options.site ?? NO_SITE_SETTINGS) ?? new URL(request.url).origin;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Per request, never cached across one: `prerender()` may enumerate rows that change, and a
|
|
28
|
+
* crawler asks for these a handful of times a day. An hour of shared cache is what a CDN keeps.
|
|
29
|
+
*/
|
|
30
|
+
const SEO_CACHE = { mode: 'public', maxAgeSeconds: 3600 } as const;
|
|
31
|
+
|
|
32
|
+
export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
|
|
33
|
+
const environment: Environment =
|
|
34
|
+
tryResolveEnvironment({ env: options.env }) ?? DEFAULT_ENVIRONMENT;
|
|
35
|
+
const answer = async (request: UltimateRequest) =>
|
|
36
|
+
await siteSeo({
|
|
37
|
+
baseUrl: originOf(options, request),
|
|
38
|
+
environment,
|
|
39
|
+
disallow: options.site?.disallow ?? [],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
method: 'GET',
|
|
45
|
+
path: ROBOTS_PATH,
|
|
46
|
+
meta: { name: 'seo.robots', auth: 'public', tags: ['seo'] },
|
|
47
|
+
handler: async (request: UltimateRequest): Promise<Response> =>
|
|
48
|
+
applyCacheHeaders(
|
|
49
|
+
new Response((await answer(request)).robots, {
|
|
50
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
51
|
+
}),
|
|
52
|
+
SEO_CACHE,
|
|
53
|
+
),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
method: 'GET',
|
|
57
|
+
path: SITEMAP_PATH,
|
|
58
|
+
meta: { name: 'seo.sitemap', auth: 'public', tags: ['seo'] },
|
|
59
|
+
// The first file is `/sitemap.xml` in both shapes: the whole urlset, or the index of parts.
|
|
60
|
+
handler: async (request: UltimateRequest): Promise<Response> =>
|
|
61
|
+
applyCacheHeaders(
|
|
62
|
+
new Response((await answer(request)).sitemaps[0]?.xml ?? '', {
|
|
63
|
+
headers: { 'content-type': 'application/xml; charset=utf-8' },
|
|
64
|
+
}),
|
|
65
|
+
SEO_CACHE,
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
}
|
package/src/serve-boot.ts
CHANGED
|
@@ -25,9 +25,11 @@ import { appRoutes } from './runtime-render';
|
|
|
25
25
|
import { replicaOverrides } from './runtime-replica';
|
|
26
26
|
import type { RunningServices } from './runtime-services';
|
|
27
27
|
import { servedStorage, storageRoutes } from './runtime-storage';
|
|
28
|
+
import { seoRoutes } from './seo-routes';
|
|
28
29
|
import { loadDrainConfig } from './serve-drain';
|
|
29
30
|
import { configureReporting, containerBinding, metricsPortFor, portFromEnv } from './serve-env';
|
|
30
31
|
import type { ServedApp, ServeOptions } from './serve-types';
|
|
32
|
+
import { loadSiteSettings, publicOrigin } from './site-config';
|
|
31
33
|
import { styleBundle } from './style-bundle';
|
|
32
34
|
import { styleRoutes } from './style-routes';
|
|
33
35
|
import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
@@ -137,9 +139,12 @@ async function webSurface(
|
|
|
137
139
|
// prevent, and it is the one an operator cannot see without installing the app.
|
|
138
140
|
const pwa = await loadPwaArtifacts(options.root);
|
|
139
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);
|
|
140
145
|
// The page's sync target and its scripts — the same call `x dev` makes, so the two cannot differ.
|
|
141
146
|
// Before the service worker, which precaches those scripts.
|
|
142
|
-
const sync = await pageSync(options.root, options.env, buildId);
|
|
147
|
+
const sync = await pageSync(options.root, options.env, buildId, runtime.realtime);
|
|
143
148
|
// The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
|
|
144
149
|
// the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
|
|
145
150
|
// route added here cannot be missing from the precache manifest.
|
|
@@ -173,14 +178,17 @@ async function webSurface(
|
|
|
173
178
|
// The surface stylesheets the documents link. Built from the registry the `loadApp` above
|
|
174
179
|
// filled, so this process serves exactly the CSS it renders against.
|
|
175
180
|
...styleRoutes(() => styleBundle()),
|
|
181
|
+
// `robots.txt` and `sitemap.xml`, the same two files the static export writes (`site-seo.ts`).
|
|
182
|
+
...seoRoutes({ env: options.env, site }),
|
|
176
183
|
// The page's one socket: its worker script, served beside the islands for their reason.
|
|
177
184
|
...sync.routes,
|
|
178
185
|
...appRoutes({
|
|
179
186
|
buildId,
|
|
180
187
|
resolveIsland: (file) => islands.resolverFor(file),
|
|
181
|
-
sync: sync.head,
|
|
188
|
+
...(sync.head === undefined ? {} : { sync: sync.head }),
|
|
182
189
|
persisted: sync.persisted,
|
|
183
190
|
themeHead: theme.head,
|
|
191
|
+
...(origin === undefined ? {} : { origin }),
|
|
184
192
|
...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
|
|
185
193
|
// Only when a store was supplied. `createIsrController` defaults to a per-process memory
|
|
186
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
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Serving `apps/web/site/assets/**` at the hashed URLs `asset()` mints. Mounted by `assetRoutes`,
|
|
2
|
+
// which both `x dev` and the container compose, so a picture that paints on a laptop paints in the
|
|
3
|
+
// image. Byte ranges are answered because Safari will not play an `<video>` from a server that
|
|
4
|
+
// ignores `Range`, and a film that plays everywhere but an iPhone is not a film that ships.
|
|
5
|
+
|
|
6
|
+
import { isUltimateError } from '@ultimat3/core';
|
|
7
|
+
import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
|
|
8
|
+
import { applyCacheHeaders, json } from '@ultimat3/http';
|
|
9
|
+
import type { SiteAsset, SiteAssetTable } from './site-assets';
|
|
10
|
+
import { parseHashedAssetUrl, SITE_ASSET_BASE_PATH } from './site-assets';
|
|
11
|
+
|
|
12
|
+
/** The URL is the content, so the answer is `public, max-age=31536000, immutable`. */
|
|
13
|
+
export const SITE_ASSET_CACHE: CacheHint = { mode: 'immutable' };
|
|
14
|
+
|
|
15
|
+
const notFound = (code: string, cause: string, fix: string): Response =>
|
|
16
|
+
json({ ok: false, error: { code, cause, fix } }, { status: 404 });
|
|
17
|
+
|
|
18
|
+
/** One `bytes=` range, resolved against the file's size; `null` when it cannot be satisfied. */
|
|
19
|
+
export function byteRange(
|
|
20
|
+
header: string | null,
|
|
21
|
+
size: number,
|
|
22
|
+
): { readonly start: number; readonly end: number } | null | undefined {
|
|
23
|
+
if (header === null) return undefined;
|
|
24
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
25
|
+
// A multi-range or a unit we do not speak: RFC 9110 lets a server ignore Range and send it all.
|
|
26
|
+
if (match === null) return undefined;
|
|
27
|
+
const [, from = '', to = ''] = match;
|
|
28
|
+
if (from === '' && to === '') return null;
|
|
29
|
+
if (from === '') {
|
|
30
|
+
const suffix = Number(to);
|
|
31
|
+
if (suffix === 0) return null;
|
|
32
|
+
return { start: Math.max(0, size - suffix), end: size - 1 };
|
|
33
|
+
}
|
|
34
|
+
const start = Number(from);
|
|
35
|
+
const end = to === '' ? size - 1 : Math.min(Number(to), size - 1);
|
|
36
|
+
if (start >= size || end < start) return null;
|
|
37
|
+
return { start, end };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assetResponse(asset: SiteAsset, range: string | null): Response {
|
|
41
|
+
const file = Bun.file(asset.file);
|
|
42
|
+
const headers: Record<string, string> = {
|
|
43
|
+
'content-type': asset.contentType,
|
|
44
|
+
'accept-ranges': 'bytes',
|
|
45
|
+
};
|
|
46
|
+
const wanted = byteRange(range, asset.bytes);
|
|
47
|
+
if (wanted === null) {
|
|
48
|
+
return new Response(null, {
|
|
49
|
+
status: 416,
|
|
50
|
+
headers: { ...headers, 'content-range': `bytes */${String(asset.bytes)}` },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (wanted === undefined) {
|
|
54
|
+
return applyCacheHeaders(new Response(file, { headers }), SITE_ASSET_CACHE);
|
|
55
|
+
}
|
|
56
|
+
return applyCacheHeaders(
|
|
57
|
+
new Response(file.slice(wanted.start, wanted.end + 1), {
|
|
58
|
+
status: 206,
|
|
59
|
+
headers: {
|
|
60
|
+
...headers,
|
|
61
|
+
'content-range': `bytes ${String(wanted.start)}-${String(wanted.end)}/${String(asset.bytes)}`,
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
SITE_ASSET_CACHE,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Only the HASHED name is served, and only while the bytes still hash to it: an immutable answer
|
|
70
|
+
* under a URL whose bytes changed would be cached for a year by every CDN that saw it. A stale
|
|
71
|
+
* hash is a document from an earlier build, and says so.
|
|
72
|
+
*/
|
|
73
|
+
export function siteAssetRoutes(table: SiteAssetTable): readonly Route[] {
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
method: 'GET',
|
|
77
|
+
path: `${SITE_ASSET_BASE_PATH}/*file`,
|
|
78
|
+
meta: { name: 'assets.site', auth: 'public', tags: ['assets'] },
|
|
79
|
+
handler: (request: UltimateRequest): Response => {
|
|
80
|
+
const named = parseHashedAssetUrl(request.pathname);
|
|
81
|
+
if (named === undefined) {
|
|
82
|
+
return notFound(
|
|
83
|
+
'X_ROUTE_NOT_FOUND',
|
|
84
|
+
`${request.pathname} is not a hashed site asset URL`,
|
|
85
|
+
"name the file with asset('assets/…') in the page, which returns the URL this route serves",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
let asset: SiteAsset;
|
|
89
|
+
try {
|
|
90
|
+
asset = table.resolve(named.path);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (!isUltimateError(error)) throw error;
|
|
93
|
+
return notFound(error.code, error.cause, error.fix);
|
|
94
|
+
}
|
|
95
|
+
if (asset.hash !== named.hash) {
|
|
96
|
+
return notFound(
|
|
97
|
+
'X_ROUTE_NOT_FOUND',
|
|
98
|
+
`${named.path} is at ${asset.url} now — the document asking for ${request.pathname} was rendered against earlier bytes`,
|
|
99
|
+
'reload the page; it names the current URL',
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return assetResponse(asset, request.headers.get('range'));
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// The site asset table: every file under `apps/web/site/assets/`, named by a content-hashed URL.
|
|
2
|
+
// One table per app root answers all three questions — the URL `asset()` returns while a page
|
|
3
|
+
// renders, the file `/assets/*` serves, and the copy `x build --target static` writes — so the
|
|
4
|
+
// page, the container and the CDN can never name one file three ways.
|
|
5
|
+
|
|
6
|
+
// why: `asset()` is called from a synchronous render, so the table answers synchronously — and Bun
|
|
7
|
+
// ships no sync stat or sync read; `join`/`relative` because Bun ships no path API either.
|
|
8
|
+
import { copyFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path'; // why: Bun ships no path API.
|
|
10
|
+
import type { AssetExtension, AssetPath } from '@ultimat3/render';
|
|
11
|
+
import { ASSET_DIR, AssetMissingError, assetPathProblem } from '@ultimat3/render';
|
|
12
|
+
|
|
13
|
+
/** App-root-relative, beside `favicon.ico`: `apps/web/site/` is where an app's public files live. */
|
|
14
|
+
export const SITE_ASSETS_SOURCE = `apps/web/site/${ASSET_DIR}`;
|
|
15
|
+
|
|
16
|
+
/** The URL prefix every hashed asset is served under, and the export directory it is copied to. */
|
|
17
|
+
export const SITE_ASSET_BASE_PATH = `/${ASSET_DIR}`;
|
|
18
|
+
|
|
19
|
+
/** What each served extension IS. `vtt` carries a charset: captions are text a player decodes. */
|
|
20
|
+
export const ASSET_CONTENT_TYPES: Readonly<Record<AssetExtension, string>> = {
|
|
21
|
+
avif: 'image/avif',
|
|
22
|
+
webp: 'image/webp',
|
|
23
|
+
png: 'image/png',
|
|
24
|
+
jpg: 'image/jpeg',
|
|
25
|
+
jpeg: 'image/jpeg',
|
|
26
|
+
gif: 'image/gif',
|
|
27
|
+
svg: 'image/svg+xml',
|
|
28
|
+
ico: 'image/x-icon',
|
|
29
|
+
woff2: 'font/woff2',
|
|
30
|
+
mp4: 'video/mp4',
|
|
31
|
+
webm: 'video/webm',
|
|
32
|
+
vtt: 'text/vtt; charset=utf-8',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface SiteAsset {
|
|
36
|
+
/** As `asset()` names it: `assets/brand/logo.svg`. */
|
|
37
|
+
readonly path: AssetPath;
|
|
38
|
+
/** Absolute path on disk. */
|
|
39
|
+
readonly file: string;
|
|
40
|
+
/** xxHash32 of the bytes, 8 hex characters — the algorithm `contentHash` uses for CSS. */
|
|
41
|
+
readonly hash: string;
|
|
42
|
+
/** `/assets/brand/logo.<hash>.svg`. */
|
|
43
|
+
readonly url: string;
|
|
44
|
+
readonly contentType: string;
|
|
45
|
+
readonly bytes: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const splitExtension = (path: string): { readonly stem: string; readonly extension: string } => {
|
|
49
|
+
const dot = path.lastIndexOf('.');
|
|
50
|
+
return { stem: path.slice(0, dot), extension: path.slice(dot + 1) };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The hash goes before the extension, so a static host still infers the type from the name. */
|
|
54
|
+
export function hashedAssetUrl(path: AssetPath, hash: string): string {
|
|
55
|
+
const { stem, extension } = splitExtension(path);
|
|
56
|
+
return `/${stem}.${hash}.${extension}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const HASHED = /^\/(assets\/.+)\.([0-9a-f]{8})\.([a-z0-9]+)$/i;
|
|
60
|
+
|
|
61
|
+
/** The inverse of `hashedAssetUrl`: which asset a request names, and at which content. */
|
|
62
|
+
export function parseHashedAssetUrl(
|
|
63
|
+
pathname: string,
|
|
64
|
+
): { readonly path: AssetPath; readonly hash: string } | undefined {
|
|
65
|
+
const match = HASHED.exec(pathname);
|
|
66
|
+
if (match === null) return undefined;
|
|
67
|
+
const path = `${match[1] ?? ''}.${match[3] ?? ''}`;
|
|
68
|
+
if (assetPathProblem(path) !== undefined) return undefined;
|
|
69
|
+
return { path: path as AssetPath, hash: match[2] ?? '' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const hashBytes = (bytes: Uint8Array): string =>
|
|
73
|
+
Bun.hash.xxHash32(bytes).toString(16).padStart(8, '0');
|
|
74
|
+
|
|
75
|
+
export interface SiteAssetTable {
|
|
76
|
+
/** The asset `asset()` named, hashed as the bytes are NOW. Throws `X_ASSET_MISSING`. */
|
|
77
|
+
resolve(path: AssetPath): SiteAsset;
|
|
78
|
+
/** Every servable file under the source directory, sorted by path. */
|
|
79
|
+
all(): readonly SiteAsset[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const missing = (path: string, cause: string): AssetMissingError =>
|
|
83
|
+
new AssetMissingError(
|
|
84
|
+
`asset(${JSON.stringify(path)}): ${cause}`,
|
|
85
|
+
`add the file at ${SITE_ASSETS_SOURCE}/${path.slice(ASSET_DIR.length + 1)}, or correct the path passed to asset(), then x build --target static --json`,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A stat per call, a read only when the file changed: `x dev` sees an edited image under a new URL
|
|
90
|
+
* on the next render with no watcher, and the container — whose files never change — pays one
|
|
91
|
+
* `stat` per `asset()` call and nothing else. One behaviour in both, never a dev-only rescan.
|
|
92
|
+
*/
|
|
93
|
+
export function createSiteAssetTable(root: string): SiteAssetTable {
|
|
94
|
+
const source = join(root, SITE_ASSETS_SOURCE);
|
|
95
|
+
const cache = new Map<string, { readonly stamp: string; readonly asset: SiteAsset }>();
|
|
96
|
+
|
|
97
|
+
const resolve = (path: AssetPath): SiteAsset => {
|
|
98
|
+
const problem = assetPathProblem(path);
|
|
99
|
+
if (problem !== undefined) throw missing(path, problem);
|
|
100
|
+
const file = join(source, path.slice(ASSET_DIR.length + 1));
|
|
101
|
+
let stamp: string;
|
|
102
|
+
try {
|
|
103
|
+
const stat = statSync(file);
|
|
104
|
+
if (!stat.isFile()) throw missing(path, `${file} is not a file`);
|
|
105
|
+
stamp = `${String(stat.mtimeMs)}:${String(stat.size)}`;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof AssetMissingError) throw error;
|
|
108
|
+
throw missing(path, `no file at ${SITE_ASSETS_SOURCE}/${path.slice(ASSET_DIR.length + 1)}`);
|
|
109
|
+
}
|
|
110
|
+
const cached = cache.get(path);
|
|
111
|
+
if (cached !== undefined && cached.stamp === stamp) return cached.asset;
|
|
112
|
+
const bytes = readFileSync(file);
|
|
113
|
+
const hash = hashBytes(bytes);
|
|
114
|
+
// `assetPathProblem` already refused an unknown extension; the guard keeps `constructor` from
|
|
115
|
+
// ever answering an `Object` member if that check is loosened.
|
|
116
|
+
const extension = splitExtension(path).extension.toLowerCase();
|
|
117
|
+
const asset: SiteAsset = {
|
|
118
|
+
path,
|
|
119
|
+
file,
|
|
120
|
+
hash,
|
|
121
|
+
url: hashedAssetUrl(path, hash),
|
|
122
|
+
contentType: Object.hasOwn(ASSET_CONTENT_TYPES, extension)
|
|
123
|
+
? ASSET_CONTENT_TYPES[extension as AssetExtension]
|
|
124
|
+
: 'application/octet-stream',
|
|
125
|
+
bytes: bytes.byteLength,
|
|
126
|
+
};
|
|
127
|
+
cache.set(path, { stamp, asset });
|
|
128
|
+
return asset;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const all = (): readonly SiteAsset[] => {
|
|
132
|
+
const found: SiteAsset[] = [];
|
|
133
|
+
let files: string[];
|
|
134
|
+
try {
|
|
135
|
+
files = [...new Bun.Glob('**/*').scanSync({ cwd: source, onlyFiles: true })];
|
|
136
|
+
} catch {
|
|
137
|
+
// No `assets/` directory is an app with no assets, not an error.
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
for (const relativePath of files.sort()) {
|
|
141
|
+
const path = `${ASSET_DIR}/${relativePath.split('\\').join('/')}`;
|
|
142
|
+
// A source file beside its renditions (a `.psd`, a `README.md`) is not a served asset.
|
|
143
|
+
if (assetPathProblem(path) !== undefined) continue;
|
|
144
|
+
found.push(resolve(path as AssetPath));
|
|
145
|
+
}
|
|
146
|
+
return found;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return { resolve, all };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** One table per root, so the renderer and the route read the same cache. */
|
|
153
|
+
const tables = new Map<string, SiteAssetTable>();
|
|
154
|
+
|
|
155
|
+
export function siteAssetTable(root: string): SiteAssetTable {
|
|
156
|
+
let table = tables.get(root);
|
|
157
|
+
if (table === undefined) {
|
|
158
|
+
table = createSiteAssetTable(root);
|
|
159
|
+
tables.set(root, table);
|
|
160
|
+
}
|
|
161
|
+
return table;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Every asset, copied into the static export under its hashed URL — a static host serves with no
|
|
166
|
+
* process behind it, so the artifact carries every byte a document names. Returns the URLs written.
|
|
167
|
+
*/
|
|
168
|
+
export function writeSiteAssets(root: string, out: string): readonly string[] {
|
|
169
|
+
const written: string[] = [];
|
|
170
|
+
for (const asset of siteAssetTable(root).all()) {
|
|
171
|
+
const target = join(out, asset.url.slice(1));
|
|
172
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
173
|
+
copyFileSync(asset.file, target);
|
|
174
|
+
written.push(asset.url);
|
|
175
|
+
}
|
|
176
|
+
return written;
|
|
177
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The public origin and the crawler rules out of `app.config.ts` — `site.origin` and
|
|
2
|
+
// `seo.robots.disallow` — and the one resolution of "which origin is this site served on" every
|
|
3
|
+
// absolute URL a document, a sitemap or a robots file carries is built against. Sibling of
|
|
4
|
+
// `app-auth.ts`'s `loadSignInPath`, and it imports the config for the same reason.
|
|
5
|
+
|
|
6
|
+
// why: Bun exposes no path-join primitive, and the config path is app-root-relative.
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import type { AppConfig, Environment } from '@ultimat3/core';
|
|
9
|
+
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
10
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
11
|
+
|
|
12
|
+
export interface SiteSettings {
|
|
13
|
+
/** `site.origin`, trailing slash removed; `null` when the app declares none. */
|
|
14
|
+
readonly origin: string | null;
|
|
15
|
+
/** `seo.robots.disallow` — the paths a production `robots.txt` keeps crawlers out of. */
|
|
16
|
+
readonly disallow: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const NO_SITE_SETTINGS: SiteSettings = { origin: null, disallow: [] };
|
|
20
|
+
|
|
21
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
22
|
+
typeof value === 'object' && value !== null;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Structural, never `instanceof`, for `loadSignInPath`'s reason: a config that resolved through an
|
|
26
|
+
* older core simply has no `site` section, and that is the "not declared" answer, not a crash.
|
|
27
|
+
*/
|
|
28
|
+
const hasSiteSections = (value: unknown): value is Pick<AppConfig, 'site' | 'seo'> =>
|
|
29
|
+
isRecord(value) &&
|
|
30
|
+
isRecord(value['site']) &&
|
|
31
|
+
isRecord(value['seo']) &&
|
|
32
|
+
isRecord(value['seo']['robots']);
|
|
33
|
+
|
|
34
|
+
const trimSlash = (origin: string): string => origin.replace(/\/+$/, '');
|
|
35
|
+
|
|
36
|
+
export async function loadSiteSettings(root: string): Promise<SiteSettings> {
|
|
37
|
+
const configPath = join(root, APP_CONFIG_FILE);
|
|
38
|
+
if (!(await Bun.file(configPath).exists())) return NO_SITE_SETTINGS;
|
|
39
|
+
const module = (await import(configPath)) as Record<string, unknown>;
|
|
40
|
+
const config = module[APP_CONFIG_EXPORT];
|
|
41
|
+
if (!hasSiteSections(config)) return NO_SITE_SETTINGS;
|
|
42
|
+
const origin = config.site.origin;
|
|
43
|
+
return {
|
|
44
|
+
origin: typeof origin === 'string' && origin !== '' ? trimSlash(origin) : null,
|
|
45
|
+
disallow: [...config.seo.robots.disallow],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The declared public origin: `APP_URL` (what the runtime already names it — OAuth's redirect, the
|
|
51
|
+
* sync node's admitted origin), then `SITE_ORIGIN` (the static build's), then `site.origin`.
|
|
52
|
+
* The environment first because one image is deployed to staging and production under two
|
|
53
|
+
* origins; the config line is what a deploy that sets neither falls back to. `undefined` when
|
|
54
|
+
* nothing names one — the caller decides whether the request's own origin will do.
|
|
55
|
+
*/
|
|
56
|
+
export function publicOrigin(
|
|
57
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
58
|
+
site: SiteSettings,
|
|
59
|
+
): string | undefined {
|
|
60
|
+
for (const key of ['APP_URL', 'SITE_ORIGIN']) {
|
|
61
|
+
const declared = env[key]?.trim() ?? '';
|
|
62
|
+
if (declared !== '') return trimSlash(declared);
|
|
63
|
+
}
|
|
64
|
+
return site.origin ?? undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The sentence a production build prints when no origin was declared. Every canonical, `og:url`,
|
|
69
|
+
* hreflang and sitemap `<loc>` is then built against a placeholder, and a search engine indexes a
|
|
70
|
+
* page whose canonical names a host the site is not on. A warning, not a refusal: a production
|
|
71
|
+
* build on a laptop is how a deploy is rehearsed.
|
|
72
|
+
*/
|
|
73
|
+
export function originWarning(environment: Environment, declared: string | undefined): string[] {
|
|
74
|
+
if (environment !== 'production' || declared !== undefined) return [];
|
|
75
|
+
return [
|
|
76
|
+
'no public origin: set APP_URL (or SITE_ORIGIN) or `site: { origin }` in app.config.ts — ' +
|
|
77
|
+
'canonical, og:url, hreflang and the sitemap are absolute against a placeholder',
|
|
78
|
+
];
|
|
79
|
+
}
|
package/src/site-seo.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// `robots.txt` and `sitemap.xml`, off the route table: ONE answer for the static export
|
|
2
|
+
// (`apps/web/prerender.ts` writes it into the artifact) and the running web role (`seo-routes.ts`
|
|
3
|
+
// serves it), so a crawler reads the same two files from a CDN and from a container.
|
|
4
|
+
|
|
5
|
+
import type { Environment } from '@ultimat3/core';
|
|
6
|
+
import { localeConfig, localizedPath, routedLocales, unlocalizedPath } from '@ultimat3/i18n';
|
|
7
|
+
import type { RouteEntry } from '@ultimat3/render';
|
|
8
|
+
import { routeEntries } from '@ultimat3/render';
|
|
9
|
+
import { enumeratePrerender, fillPath } from '@ultimat3/render/server';
|
|
10
|
+
import type { RouteRecord, SitemapFile } from '@ultimat3/seo';
|
|
11
|
+
import { buildRobots, buildSitemap, isDynamic } from '@ultimat3/seo';
|
|
12
|
+
import { readSiteMeta } from './seo-meta';
|
|
13
|
+
|
|
14
|
+
export const ROBOTS_PATH = '/robots.txt';
|
|
15
|
+
export const SITEMAP_PATH = '/sitemap.xml';
|
|
16
|
+
|
|
17
|
+
export interface SiteSeoOptions {
|
|
18
|
+
/** The public origin every `<loc>` and the `Sitemap:` line are absolute against. */
|
|
19
|
+
readonly baseUrl: string;
|
|
20
|
+
/** Omitted: `ULTIMATE_ENV`, read by `@ultimat3/seo` — anything but `production` disallows. */
|
|
21
|
+
readonly environment?: Environment | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* The concrete pages a build EMITTED for a dynamic route. The static export passes its report's,
|
|
24
|
+
* so the sitemap cannot name a page the artifact lacks; absent, `prerender()` is asked — which is
|
|
25
|
+
* the same list, enumerated by the same function the prerenderer calls.
|
|
26
|
+
*/
|
|
27
|
+
readonly pagesFor?: ((routePath: string) => readonly string[]) | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* `seo.robots.disallow` from `app.config.ts` (`loadSiteSettings`). Added to the production
|
|
30
|
+
* `User-agent: *` group; a non-production `robots.txt` still disallows everything.
|
|
31
|
+
*/
|
|
32
|
+
readonly disallow?: readonly string[] | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SiteSeo {
|
|
36
|
+
readonly robots: string;
|
|
37
|
+
/** Every sitemap file: `/sitemap.xml` alone, or the index at that path first and its parts after. */
|
|
38
|
+
readonly sitemaps: readonly SitemapFile[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A `site/` page anyone may fetch. A policy on a `site/` route makes it not public, whatever the surface. */
|
|
42
|
+
const isPublicSite = (entry: RouteEntry): boolean =>
|
|
43
|
+
entry.surface === 'site' && entry.config.policy === undefined;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A dynamic route's pages, UNPREFIXED and once each. A static export's report lists every locale's
|
|
47
|
+
* copy (`/blog/a` and `/en/blog/a`), and the sitemap localizes each page itself — reading the
|
|
48
|
+
* prefixed copies back as pages of their own listed `/en/en/blog/a`.
|
|
49
|
+
*/
|
|
50
|
+
const pagesOf = async (entry: RouteEntry, options: SiteSeoOptions): Promise<readonly string[]> => {
|
|
51
|
+
if (options.pagesFor !== undefined) {
|
|
52
|
+
return [...new Set(options.pagesFor(entry.path).map((path) => unlocalizedPath(path)))];
|
|
53
|
+
}
|
|
54
|
+
const params = await enumeratePrerender(entry);
|
|
55
|
+
return params.map((set) => fillPath(entry.pattern.source, set));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The public `site/` routes as `@ultimat3/seo` reads them. `meta` is carried where it resolves
|
|
60
|
+
* without a request (`readSiteMeta`), which is what lets a page's own `robots: { index: false }`
|
|
61
|
+
* keep it out of the sitemap — a page that asks crawlers to stay away must not be listed for them.
|
|
62
|
+
*/
|
|
63
|
+
async function publicSiteRoutes(options: SiteSeoOptions): Promise<readonly RouteRecord[]> {
|
|
64
|
+
const metaByPath = new Map((await readSiteMeta()).records.map((r) => [r.path, r.meta]));
|
|
65
|
+
return routeEntries()
|
|
66
|
+
.filter(isPublicSite)
|
|
67
|
+
.map((entry) => {
|
|
68
|
+
const meta = metaByPath.get(entry.path);
|
|
69
|
+
return {
|
|
70
|
+
path: entry.path,
|
|
71
|
+
file: entry.file,
|
|
72
|
+
surface: 'site' as const,
|
|
73
|
+
render: entry.config.render,
|
|
74
|
+
...(meta === undefined ? {} : { meta }),
|
|
75
|
+
...(isDynamic(entry.path) ? { prerender: () => pagesOf(entry, options) } : {}),
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function siteSeo(options: SiteSeoOptions): Promise<SiteSeo> {
|
|
81
|
+
// Every routed locale, with `xhtml:link` alternates: one `<url>` per page per locale, each
|
|
82
|
+
// naming the whole cluster and `x-default`. A single-locale app passes none and gets the plain
|
|
83
|
+
// urlset it always had — an alternates cluster of one is noise.
|
|
84
|
+
const locales = routedLocales();
|
|
85
|
+
const defaultLocale = localeConfig().fallback;
|
|
86
|
+
const sitemap = await buildSitemap(await publicSiteRoutes(options), {
|
|
87
|
+
baseUrl: options.baseUrl,
|
|
88
|
+
...(locales.length < 2
|
|
89
|
+
? {}
|
|
90
|
+
: {
|
|
91
|
+
locales,
|
|
92
|
+
defaultLocale,
|
|
93
|
+
localizePath: (path: string, locale: string) =>
|
|
94
|
+
localizedPath(path, locale, defaultLocale),
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
// Past 50,000 URLs `files` are `/sitemap-N.xml` and the index is `/sitemap.xml`; below it,
|
|
98
|
+
// `files` is that one file and there is no index.
|
|
99
|
+
const sitemaps = sitemap.index === undefined ? sitemap.files : [sitemap.index, ...sitemap.files];
|
|
100
|
+
const robots = buildRobots({
|
|
101
|
+
baseUrl: options.baseUrl,
|
|
102
|
+
sitemaps: [SITEMAP_PATH],
|
|
103
|
+
...(options.disallow === undefined ? {} : { disallow: options.disallow }),
|
|
104
|
+
...(options.environment === undefined ? {} : { environment: options.environment }),
|
|
105
|
+
});
|
|
106
|
+
return { robots, sitemaps };
|
|
107
|
+
}
|