@ultimat3/cli 27.8.0 → 27.9.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/index.ts +2 -1
- package/src/runtime-address.ts +16 -0
- package/src/runtime-isr-outcome.ts +30 -13
- package/src/runtime-render.ts +5 -2
- package/src/seo-meta.ts +14 -4
- package/src/site-config.ts +2 -2
- package/src/site-seo-write.ts +16 -0
- package/src/site-seo.ts +89 -31
- package/src/static-document.ts +8 -4
- package/src/templates/scaffold-entries.ts +14 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.9.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -39,33 +39,33 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@babel/core": "^8.0.1",
|
|
42
|
-
"@ultimat3/action": "27.
|
|
43
|
-
"@ultimat3/admin": "27.
|
|
44
|
-
"@ultimat3/ai": "27.
|
|
45
|
-
"@ultimat3/auth": "27.
|
|
46
|
-
"@ultimat3/cache": "27.
|
|
47
|
-
"@ultimat3/core": "27.
|
|
48
|
-
"@ultimat3/db": "27.
|
|
49
|
-
"@ultimat3/entity": "27.
|
|
50
|
-
"@ultimat3/flags": "27.
|
|
51
|
-
"@ultimat3/http": "27.
|
|
52
|
-
"@ultimat3/i18n": "27.
|
|
53
|
-
"@ultimat3/jobs": "27.
|
|
54
|
-
"@ultimat3/mail": "27.
|
|
55
|
-
"@ultimat3/manifest": "27.
|
|
56
|
-
"@ultimat3/mcp": "27.
|
|
57
|
-
"@ultimat3/money": "27.
|
|
58
|
-
"@ultimat3/notify": "27.
|
|
59
|
-
"@ultimat3/policy": "27.
|
|
60
|
-
"@ultimat3/pwa": "27.
|
|
61
|
-
"@ultimat3/query": "27.
|
|
62
|
-
"@ultimat3/realtime": "27.
|
|
63
|
-
"@ultimat3/render": "27.
|
|
64
|
-
"@ultimat3/schema": "27.
|
|
65
|
-
"@ultimat3/seo": "27.
|
|
66
|
-
"@ultimat3/storage": "27.
|
|
67
|
-
"@ultimat3/testing": "27.
|
|
68
|
-
"@ultimat3/time": "27.
|
|
42
|
+
"@ultimat3/action": "27.9.0",
|
|
43
|
+
"@ultimat3/admin": "27.9.0",
|
|
44
|
+
"@ultimat3/ai": "27.9.0",
|
|
45
|
+
"@ultimat3/auth": "27.9.0",
|
|
46
|
+
"@ultimat3/cache": "27.9.0",
|
|
47
|
+
"@ultimat3/core": "27.9.0",
|
|
48
|
+
"@ultimat3/db": "27.9.0",
|
|
49
|
+
"@ultimat3/entity": "27.9.0",
|
|
50
|
+
"@ultimat3/flags": "27.9.0",
|
|
51
|
+
"@ultimat3/http": "27.9.0",
|
|
52
|
+
"@ultimat3/i18n": "27.9.0",
|
|
53
|
+
"@ultimat3/jobs": "27.9.0",
|
|
54
|
+
"@ultimat3/mail": "27.9.0",
|
|
55
|
+
"@ultimat3/manifest": "27.9.0",
|
|
56
|
+
"@ultimat3/mcp": "27.9.0",
|
|
57
|
+
"@ultimat3/money": "27.9.0",
|
|
58
|
+
"@ultimat3/notify": "27.9.0",
|
|
59
|
+
"@ultimat3/policy": "27.9.0",
|
|
60
|
+
"@ultimat3/pwa": "27.9.0",
|
|
61
|
+
"@ultimat3/query": "27.9.0",
|
|
62
|
+
"@ultimat3/realtime": "27.9.0",
|
|
63
|
+
"@ultimat3/render": "27.9.0",
|
|
64
|
+
"@ultimat3/schema": "27.9.0",
|
|
65
|
+
"@ultimat3/seo": "27.9.0",
|
|
66
|
+
"@ultimat3/storage": "27.9.0",
|
|
67
|
+
"@ultimat3/testing": "27.9.0",
|
|
68
|
+
"@ultimat3/time": "27.9.0",
|
|
69
69
|
"babel-preset-solid": "^1.9.15"
|
|
70
70
|
}
|
|
71
71
|
}
|
package/src/index.ts
CHANGED
|
@@ -157,8 +157,9 @@ export {
|
|
|
157
157
|
export { quoteArg } from './shell-quote';
|
|
158
158
|
export type { SiteSettings } from './site-config';
|
|
159
159
|
export { loadSiteSettings, publicOrigin } from './site-config';
|
|
160
|
-
export type { SiteSeo, SiteSeoOptions } from './site-seo';
|
|
160
|
+
export type { SiteSeo, SiteSeoOptions, SiteSeoSitemap } from './site-seo';
|
|
161
161
|
export { ROBOTS_PATH, SITEMAP_PATH, siteSeo } from './site-seo';
|
|
162
|
+
export { writeSiteSeo } from './site-seo-write';
|
|
162
163
|
export { eachSourceFile, isGenerated, isTest, SOURCE_GLOBS } from './source-files';
|
|
163
164
|
export type { SkippedRoute, StaticReport } from './static-report';
|
|
164
165
|
export { parseStaticReport } from './static-report';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The URL a SHARED document (`static`, `isr`) is rendered at and kept under: the request's own, at
|
|
2
|
+
// the one spelling of the address the router matched. The router answers `/about/`, `//about` and
|
|
3
|
+
// `/%61bout` with one page; a document one visitor's spelling rendered and the next visitor is
|
|
4
|
+
// answered must not carry the first one's spelling in its `canonical`, nor be kept once per spelling.
|
|
5
|
+
|
|
6
|
+
import type { RequestContext } from '@ultimat3/http';
|
|
7
|
+
import { requestAddress } from '@ultimat3/http';
|
|
8
|
+
|
|
9
|
+
/** `url` with its pathname respelled (`requestAddress`); unchanged before a route matched. */
|
|
10
|
+
export function addressedUrl(url: URL, ctx: RequestContext): URL {
|
|
11
|
+
const address = requestAddress(ctx);
|
|
12
|
+
if (address === undefined || address.pathname === url.pathname) return url;
|
|
13
|
+
const at = new URL(url);
|
|
14
|
+
at.pathname = address.pathname;
|
|
15
|
+
return at;
|
|
16
|
+
}
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
import { logger } from '@ultimat3/core';
|
|
6
6
|
import type { RedirectIntent, RequestContext, RouteParams } from '@ultimat3/http';
|
|
7
|
-
import { asCtx, takeRedirect } from '@ultimat3/http';
|
|
7
|
+
import { asCtx, requestAddress, takeRedirect } from '@ultimat3/http';
|
|
8
8
|
import type { RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
|
|
9
|
-
import { routeDataFor, routeNoStoreOf, routeStatusOf } from '@ultimat3/render';
|
|
9
|
+
import { describePages, routeDataFor, routeNoStoreOf, routeStatusOf } from '@ultimat3/render';
|
|
10
10
|
import type { IsrController, IsrEntry } from '@ultimat3/render/server';
|
|
11
|
-
import { isrKey, isrRequestUrl, undeclaredQuery } from '@ultimat3/render/server';
|
|
11
|
+
import { isrKey, isrRequestUrl, isrRouteFor, undeclaredQuery } from '@ultimat3/render/server';
|
|
12
12
|
|
|
13
13
|
export type IsrOutcome =
|
|
14
14
|
| { readonly kind: 'page'; readonly result: RenderResult }
|
|
@@ -38,15 +38,17 @@ const NAMED_PARAMS = 5;
|
|
|
38
38
|
const NAMED_PARAM_CHARS = 40;
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* The request narrowed to what the route declared it varies on (`revalidate.query`)
|
|
42
|
-
* feeds the store key, `load`, `meta` and the
|
|
43
|
-
* the
|
|
41
|
+
* The request narrowed to what the route declared it varies on (`revalidate.query`), AT the one
|
|
42
|
+
* spelling of its address (`pathname`). ONE narrowing feeds the store key, `load`, `meta` and the
|
|
43
|
+
* page, so neither a parameter outside the key nor the way a visitor happened to spell the path
|
|
44
|
+
* (`/blog//a/`, `/blog/%61`) can reach the document stored under it.
|
|
44
45
|
*
|
|
45
46
|
* A route that declared NOTHING still keys on the whole query, and is told so once, the first time
|
|
46
47
|
* a visitor uses that: every distinct query string is then a render and a stored page of its own.
|
|
47
48
|
*/
|
|
48
|
-
function keyedData(entry: RouteEntry, data: IsrData): IsrData {
|
|
49
|
+
function keyedData(entry: RouteEntry, data: IsrData, pathname: string | undefined): IsrData {
|
|
49
50
|
const url = new URL(data.url);
|
|
51
|
+
if (pathname !== undefined) url.pathname = pathname;
|
|
50
52
|
const declared = entry.config.revalidate?.query ?? null;
|
|
51
53
|
if (declared === null && !toldUndeclared.has(entry.file)) {
|
|
52
54
|
const minted = undeclaredQuery(url, declared);
|
|
@@ -96,12 +98,27 @@ function dropPage(isr: IsrController, key: string): void {
|
|
|
96
98
|
* redirected decides for itself, exactly as the static single-flight in `appRoutes` does.
|
|
97
99
|
*/
|
|
98
100
|
export async function isrOutcome(arrived: IsrRequest): Promise<IsrOutcome> {
|
|
99
|
-
|
|
101
|
+
// Where the router MATCHED this request, never where the visitor wrote it: the router answers
|
|
102
|
+
// many spellings with one page, and a key built from the raw pathname stored each of them.
|
|
103
|
+
const address = requestAddress(arrived.ctx);
|
|
104
|
+
const request: IsrRequest = {
|
|
105
|
+
...arrived,
|
|
106
|
+
data: keyedData(arrived.entry, arrived.data, address?.pathname),
|
|
107
|
+
};
|
|
100
108
|
const { entry, data, ctx, isr } = request;
|
|
101
109
|
// `isrKey(url, locale)`, never `url.pathname`: the DECLARED query is part of what was rendered —
|
|
102
110
|
// `meta` reads `data.url` — so two URLs differing in it are two documents (#171). The locale is
|
|
103
|
-
// `ctx.locale`, the one the `locale` stage negotiated for THIS request.
|
|
104
|
-
|
|
111
|
+
// `ctx.locale`, the one the `locale` stage negotiated for THIS request. The key's path carries
|
|
112
|
+
// NO base path: the route table has never heard of the mount, so a key that kept it matched no
|
|
113
|
+
// route — and every `isr` page of an app under a `basePath` was stored with no ttl and no tags.
|
|
114
|
+
const keyed = new URL(data.url);
|
|
115
|
+
if (address !== undefined) keyed.pathname = address.path;
|
|
116
|
+
const key = isrKey(keyed, asCtx(ctx).locale);
|
|
117
|
+
// The key has to read back as THIS route, or its ttl, its tags and what a bust does to it are
|
|
118
|
+
// another route's or nobody's. It always does for an address the framework's URL scheme can
|
|
119
|
+
// spell; what is left is a path the router matched that another reading claims (`//en/x` is the
|
|
120
|
+
// `/:lang/x` route with no locale, and `/en/x` is the `/x` route in English). Answered, uncached.
|
|
121
|
+
if (isrRouteFor(describePages(), key)?.file !== entry.file) return ownOutcome(request);
|
|
105
122
|
let decided: RedirectIntent | undefined;
|
|
106
123
|
// `{ html, status }`, never the bare string: the entry stores the status beside the HTML and
|
|
107
124
|
// serves it on every hit, so a 404 under `isr` is a 404 for its whole TTL.
|
|
@@ -127,9 +144,9 @@ export async function isrOutcome(arrived: IsrRequest): Promise<IsrOutcome> {
|
|
|
127
144
|
}
|
|
128
145
|
|
|
129
146
|
/**
|
|
130
|
-
* A joiner whose leader's load redirected
|
|
131
|
-
* here is NOT stored — the store was just told the
|
|
132
|
-
* the
|
|
147
|
+
* A request answered for itself alone: a joiner whose leader's load redirected, or one whose key
|
|
148
|
+
* is not its own route's. Its own `load`; a page here is NOT stored — the store was just told the
|
|
149
|
+
* key has none, or the key is another page's — so it is `private, no-store`.
|
|
133
150
|
*/
|
|
134
151
|
async function ownOutcome(request: IsrRequest): Promise<IsrOutcome> {
|
|
135
152
|
const loaded = await routeDataFor(request.entry.config, request.data);
|
package/src/runtime-render.ts
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
principalRelocation,
|
|
54
54
|
} from './page-navigation';
|
|
55
55
|
import { bootScript, collectorFor } from './route-islands';
|
|
56
|
+
import { addressedUrl } from './runtime-address';
|
|
56
57
|
import { attachedIsr } from './runtime-isr';
|
|
57
58
|
import { isrOutcome } from './runtime-isr-outcome';
|
|
58
59
|
import type { StaticResult } from './static-document';
|
|
@@ -425,7 +426,7 @@ export function appRoutes(options: DevRenderOptions): readonly Route[] {
|
|
|
425
426
|
// A static page already rendered by this process is answered without `load` or a render.
|
|
426
427
|
const key =
|
|
427
428
|
options.memoStatic === true
|
|
428
|
-
? staticMemoKey(entry, request.url, asCtx(ctx).locale)
|
|
429
|
+
? staticMemoKey(entry, addressedUrl(request.url, ctx), asCtx(ctx).locale)
|
|
429
430
|
: undefined;
|
|
430
431
|
const kept = key === undefined ? undefined : memo.get(key);
|
|
431
432
|
if (kept !== undefined) return staticResponse(request, kept);
|
|
@@ -461,7 +462,9 @@ async function renderEntry(
|
|
|
461
462
|
options: DevRenderOptions,
|
|
462
463
|
isr: IsrController,
|
|
463
464
|
): Promise<Rendered> {
|
|
464
|
-
|
|
465
|
+
// A `static` document is one file in the export, so it is rendered AT its one address here too.
|
|
466
|
+
const at = entry.config.render === 'static' ? addressedUrl(request.url, ctx) : request.url;
|
|
467
|
+
const data: DevRouteData = { url: at.href, params: ctx.params };
|
|
465
468
|
if (entry.config.render === 'isr') return renderIsr(entry, data, ctx, options, isr);
|
|
466
469
|
// ONCE per request, before the mode is chosen: every branch of `resultFor` reads this same
|
|
467
470
|
// object, so a route's `load` runs exactly once however its mode splits head from body.
|
package/src/seo-meta.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// this is the only tier that can see both: `@ultimat3/seo` is tier 1 and may not import the route
|
|
6
6
|
// registry, which is tier 4.
|
|
7
7
|
|
|
8
|
+
import { ctxOf, runWithContext } from '@ultimat3/core';
|
|
9
|
+
import { localizedPath } from '@ultimat3/i18n';
|
|
8
10
|
import { metaContextFor, type RouteEntry, routeDataFor, routeEntries } from '@ultimat3/render';
|
|
9
11
|
import type { RouteRecord } from '@ultimat3/seo';
|
|
10
12
|
import { loadApp } from './app-load';
|
|
@@ -66,11 +68,19 @@ export async function scanSiteMeta(root: string): Promise<SiteMetaScan> {
|
|
|
66
68
|
/**
|
|
67
69
|
* The same scan over the registry as it stands, without loading anything.
|
|
68
70
|
*
|
|
71
|
+
* `locale` runs every `meta` inside that locale's context; omitted, in none (the gate's read).
|
|
72
|
+
*
|
|
69
73
|
* Split from `scanSiteMeta` so the resolution rules are testable against routes registered by hand:
|
|
70
74
|
* the half worth pinning is which routes are reachable and what happens when one throws, and
|
|
71
75
|
* neither of those is a fact about globbing a directory.
|
|
72
76
|
*/
|
|
73
|
-
export async function readSiteMeta(): Promise<SiteMetaScan> {
|
|
77
|
+
export async function readSiteMeta(locale?: string): Promise<SiteMetaScan> {
|
|
78
|
+
// In a locale: each `meta` runs as that locale's document would — `ctx.locale`, `t` and the URL
|
|
79
|
+
// are its own — which is what lets a page that is `noindex` in one language say so.
|
|
80
|
+
const scoped = <T>(run: () => Promise<T>): Promise<T> =>
|
|
81
|
+
locale === undefined ? run() : runWithContext(ctxOf({ role: 'web', locale }), run);
|
|
82
|
+
const pathOf = (path: string): string =>
|
|
83
|
+
locale === undefined ? path : localizedPath(path, locale);
|
|
74
84
|
const records: RouteRecord[] = [];
|
|
75
85
|
const unresolved: UnresolvedRoute[] = [];
|
|
76
86
|
const findings: Finding[] = [];
|
|
@@ -82,10 +92,10 @@ export async function readSiteMeta(): Promise<SiteMetaScan> {
|
|
|
82
92
|
unresolved.push({ path: entry.path, file: entry.file, reason });
|
|
83
93
|
continue;
|
|
84
94
|
}
|
|
85
|
-
const ctx = { url: `${PROBE_ORIGIN}${entry.path}`, params: {} };
|
|
95
|
+
const ctx = { url: `${PROBE_ORIGIN}${pathOf(entry.path)}`, params: {} };
|
|
86
96
|
try {
|
|
87
|
-
const meta = await
|
|
88
|
-
metaContextFor(ctx, await routeDataFor(entry.config, ctx)),
|
|
97
|
+
const meta = await scoped(async () =>
|
|
98
|
+
entry.config.meta(metaContextFor(ctx, await routeDataFor(entry.config, ctx))),
|
|
89
99
|
);
|
|
90
100
|
records.push({
|
|
91
101
|
path: entry.path,
|
package/src/site-config.ts
CHANGED
|
@@ -11,14 +11,14 @@ export interface SiteSettings {
|
|
|
11
11
|
readonly origin: string | null;
|
|
12
12
|
/** `seo.robots.disallow` — the paths a production `robots.txt` keeps crawlers out of. */
|
|
13
13
|
readonly disallow: readonly string[];
|
|
14
|
-
/** `seo.sitemap` — public `app/` pages to list,
|
|
14
|
+
/** `seo.sitemap` — public `app/` pages to list, where `<lastmod>` comes from, sections, page size. */
|
|
15
15
|
readonly sitemap: SeoSitemapConfig;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export const NO_SITE_SETTINGS: SiteSettings = {
|
|
19
19
|
origin: null,
|
|
20
20
|
disallow: [],
|
|
21
|
-
sitemap: { extra: [], lastmod: 'none' },
|
|
21
|
+
sitemap: { extra: [], lastmod: 'none', sections: [], pageSize: 50_000 },
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
const trimSlash = (origin: string): string => origin.replace(/\/+$/, '');
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Single responsibility: `siteSeo()`'s answer as files in a static export — `/sitemap.xml`, every
|
|
2
|
+
// part an index names, and `robots.txt` — so a CDN serves the bytes a running web role answers.
|
|
3
|
+
|
|
4
|
+
// why: Bun exposes no path-join primitive, and every file path is relative to the output root.
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { ROBOTS_PATH, type SiteSeo } from './site-seo';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Writes every file of `seo` under `out` and answers their site-relative paths, the sitemap files
|
|
10
|
+
* first. `Bun.write` creates `sitemaps/` on the way, so an index and its parts land together.
|
|
11
|
+
*/
|
|
12
|
+
export async function writeSiteSeo(out: string, seo: SiteSeo): Promise<readonly string[]> {
|
|
13
|
+
for (const file of seo.sitemaps) await Bun.write(join(out, file.path), file.xml);
|
|
14
|
+
await Bun.write(join(out, ROBOTS_PATH), seo.robots);
|
|
15
|
+
return [...seo.sitemaps.map((file) => file.path), ROBOTS_PATH];
|
|
16
|
+
}
|
package/src/site-seo.ts
CHANGED
|
@@ -8,8 +8,8 @@ import { localizedPath, unlocalizedPath } from '@ultimat3/i18n';
|
|
|
8
8
|
import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
9
9
|
import type { RouteEntry } from '@ultimat3/render';
|
|
10
10
|
import { routeEntries } from '@ultimat3/render';
|
|
11
|
-
import {
|
|
12
|
-
import type { RouteRecord, SitemapFile } from '@ultimat3/seo';
|
|
11
|
+
import { enumeratePrerenderEntries, fillPath } from '@ultimat3/render/server';
|
|
12
|
+
import type { RouteRecord, SitemapFile, SitemapPage } from '@ultimat3/seo';
|
|
13
13
|
import { buildRobots, buildSitemap, isDynamic } from '@ultimat3/seo';
|
|
14
14
|
import { readSiteMeta } from './seo-meta';
|
|
15
15
|
import { lastmodOf } from './sitemap-lastmod';
|
|
@@ -25,7 +25,8 @@ export interface SiteSeoOptions {
|
|
|
25
25
|
/**
|
|
26
26
|
* The concrete pages a build EMITTED for a dynamic route. The static export passes its report's,
|
|
27
27
|
* so the sitemap cannot name a page the artifact lacks; absent, `prerender()` is asked — which is
|
|
28
|
-
* the same list, enumerated by the same function the prerenderer calls.
|
|
28
|
+
* the same list, enumerated by the same function the prerenderer calls. Given, `prerender()` is
|
|
29
|
+
* still asked what each page says of ITSELF (`lastmod`, `locales`); the list stays this one.
|
|
29
30
|
*/
|
|
30
31
|
readonly pagesFor?: ((routePath: string) => readonly string[]) | undefined;
|
|
31
32
|
/**
|
|
@@ -35,14 +36,22 @@ export interface SiteSeoOptions {
|
|
|
35
36
|
readonly disallow?: readonly string[] | undefined;
|
|
36
37
|
/**
|
|
37
38
|
* `seo.sitemap` from `app.config.ts` (`loadSiteSettings`): the public `app/` pages to list beside
|
|
38
|
-
* the `site/` ones,
|
|
39
|
+
* the `site/` ones, where each `<lastmod>` comes from, and the sections and page size of an
|
|
40
|
+
* index. Omitted: `site/` pages, no `<lastmod>`, one urlset.
|
|
39
41
|
*/
|
|
40
|
-
readonly sitemap?:
|
|
42
|
+
readonly sitemap?: SiteSeoSitemap | undefined;
|
|
41
43
|
/** The app root the route files are relative to — read for `lastmod: 'git' | 'mtime'`. */
|
|
42
44
|
readonly root?: string | undefined;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
/**
|
|
48
|
+
* `seo.sitemap` as `siteSeo` takes it: `sections` and `pageSize` optional, so a caller that wrote
|
|
49
|
+
* `{ extra, lastmod }` before either existed still compiles and still gets the one urlset.
|
|
50
|
+
*/
|
|
51
|
+
export type SiteSeoSitemap = Pick<SeoSitemapConfig, 'extra' | 'lastmod'> &
|
|
52
|
+
Partial<Pick<SeoSitemapConfig, 'sections' | 'pageSize'>>;
|
|
53
|
+
|
|
54
|
+
const NO_SITEMAP_SETTINGS: SiteSeoSitemap = { extra: [], lastmod: 'none' };
|
|
46
55
|
|
|
47
56
|
/** A `seo.sitemap.extra` path that names no public page outside `site/`. */
|
|
48
57
|
export class SitemapExtraInvalidError extends UltimateError {
|
|
@@ -67,42 +76,87 @@ const isPublicSite = (entry: RouteEntry): boolean =>
|
|
|
67
76
|
entry.surface === 'site' && entry.config.policy === undefined;
|
|
68
77
|
|
|
69
78
|
/**
|
|
70
|
-
* A
|
|
71
|
-
* copy (`/blog/a` and `/en/blog/a`), and the sitemap
|
|
72
|
-
* prefixed copies back as pages of their own listed
|
|
79
|
+
* A route's pages, UNPREFIXED and once each, with what `prerender()` said of each one. A
|
|
80
|
+
* static export's report lists every locale's copy (`/blog/a` and `/en/blog/a`), and the sitemap
|
|
81
|
+
* localizes each page itself — reading the prefixed copies back as pages of their own listed
|
|
82
|
+
* `/en/en/blog/a`.
|
|
73
83
|
*/
|
|
74
|
-
const pagesOf = async (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
84
|
+
const pagesOf = async (
|
|
85
|
+
entry: RouteEntry,
|
|
86
|
+
options: SiteSeoOptions,
|
|
87
|
+
): Promise<readonly SitemapPage[]> => {
|
|
88
|
+
const said = (await enumeratePrerenderEntries(entry)).map(
|
|
89
|
+
(page): SitemapPage => ({
|
|
90
|
+
path: fillPath(entry.pattern.source, page.params),
|
|
91
|
+
...(page.lastmod === undefined ? {} : { lastmod: page.lastmod }),
|
|
92
|
+
...(page.locales === undefined ? {} : { locales: page.locales }),
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
// Only a DYNAMIC route's list is the report's: a page with no params is listed whether or not
|
|
96
|
+
// this build emitted it (an `isr` one is rendered by the server), exactly as before.
|
|
97
|
+
if (options.pagesFor === undefined || !isDynamic(entry.path)) return said;
|
|
98
|
+
const byPath = new Map(said.map((page) => [page.path, page]));
|
|
99
|
+
const emitted = new Set(options.pagesFor(entry.path).map((path) => unlocalizedPath(path)));
|
|
100
|
+
return [...emitted].map((path) => byPath.get(path) ?? { path });
|
|
80
101
|
};
|
|
81
102
|
|
|
103
|
+
/**
|
|
104
|
+
* What a page's own `meta` says about being indexed, per locale: the record's `meta` when every
|
|
105
|
+
* locale agrees, its `locales` when only some may be indexed. `robots: { index: false }` in ONE
|
|
106
|
+
* locale used to leave that copy listed and every other copy's hreflang pointing at it — the read
|
|
107
|
+
* was made once, in no locale at all.
|
|
108
|
+
*/
|
|
109
|
+
async function indexability(
|
|
110
|
+
locales: readonly string[],
|
|
111
|
+
): Promise<(path: string) => Pick<RouteRecord, 'meta' | 'locales'>> {
|
|
112
|
+
if (locales.length < 2) {
|
|
113
|
+
const metaByPath = new Map((await readSiteMeta()).records.map((r) => [r.path, r.meta]));
|
|
114
|
+
return (path) => {
|
|
115
|
+
const meta = metaByPath.get(path);
|
|
116
|
+
return meta === undefined ? {} : { meta };
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const scans = new Map<string, Map<string, RouteRecord['meta']>>();
|
|
120
|
+
// One locale at a time: each scan runs every route's `meta` inside that locale's context.
|
|
121
|
+
for (const locale of locales) {
|
|
122
|
+
scans.set(locale, new Map((await readSiteMeta(locale)).records.map((r) => [r.path, r.meta])));
|
|
123
|
+
}
|
|
124
|
+
return (path) => {
|
|
125
|
+
const listed = locales.filter(
|
|
126
|
+
(locale) => scans.get(locale)?.get(path)?.robots?.index !== false,
|
|
127
|
+
);
|
|
128
|
+
if (listed.length === locales.length) return {};
|
|
129
|
+
// No locale may be indexed: the `noindex` itself is what keeps the route out.
|
|
130
|
+
if (listed.length === 0) return { meta: { robots: { index: false } } };
|
|
131
|
+
return { locales: listed };
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
82
135
|
/**
|
|
83
136
|
* The public `site/` routes as `@ultimat3/seo` reads them. `meta` is carried where it resolves
|
|
84
137
|
* without a request (`readSiteMeta`), which is what lets a page's own `robots: { index: false }`
|
|
85
138
|
* keep it out of the sitemap — a page that asks crawlers to stay away must not be listed for them.
|
|
139
|
+
* A route with params or a `load` resolves no `meta` here: its pages say it per entry (`locales`).
|
|
86
140
|
*/
|
|
87
141
|
async function publicSiteRoutes(
|
|
88
142
|
options: SiteSeoOptions,
|
|
89
143
|
stamp: Stamp,
|
|
144
|
+
locales: readonly string[],
|
|
90
145
|
): Promise<readonly RouteRecord[]> {
|
|
91
|
-
const
|
|
146
|
+
const indexed = await indexability(locales);
|
|
92
147
|
return routeEntries()
|
|
93
148
|
.filter(isPublicSite)
|
|
94
|
-
.map((entry) => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
});
|
|
149
|
+
.map((entry) => ({
|
|
150
|
+
path: entry.path,
|
|
151
|
+
file: entry.file,
|
|
152
|
+
surface: 'site' as const,
|
|
153
|
+
render: entry.config.render,
|
|
154
|
+
...indexed(entry.path),
|
|
155
|
+
...(isDynamic(entry.path) || entry.config.prerender !== undefined
|
|
156
|
+
? { prerender: () => pagesOf(entry, options) }
|
|
157
|
+
: {}),
|
|
158
|
+
...stamp(entry.file),
|
|
159
|
+
}));
|
|
106
160
|
}
|
|
107
161
|
|
|
108
162
|
type Stamp = (file: string) => { readonly lastmod?: string };
|
|
@@ -158,11 +212,15 @@ export async function siteSeo(options: SiteSeoOptions): Promise<SiteSeo> {
|
|
|
158
212
|
return lastmod === undefined ? {} : { lastmod };
|
|
159
213
|
};
|
|
160
214
|
const routes = [
|
|
161
|
-
...(await publicSiteRoutes(options, stamp)),
|
|
215
|
+
...(await publicSiteRoutes(options, stamp, locales)),
|
|
162
216
|
...extraRoutes(settings.extra, stamp),
|
|
163
217
|
];
|
|
164
218
|
const sitemap = await buildSitemap(routes, {
|
|
165
219
|
baseUrl: options.baseUrl,
|
|
220
|
+
// A URL `robots.txt` keeps crawlers out of is not handed to them here: one config, two files.
|
|
221
|
+
...(options.disallow === undefined ? {} : { disallow: options.disallow }),
|
|
222
|
+
...(settings.sections === undefined ? {} : { sections: settings.sections }),
|
|
223
|
+
...(settings.pageSize === undefined ? {} : { maxUrls: settings.pageSize }),
|
|
166
224
|
...(locales.length < 2
|
|
167
225
|
? {}
|
|
168
226
|
: {
|
|
@@ -172,8 +230,8 @@ export async function siteSeo(options: SiteSeoOptions): Promise<SiteSeo> {
|
|
|
172
230
|
localizedPath(path, locale, defaultLocale),
|
|
173
231
|
}),
|
|
174
232
|
});
|
|
175
|
-
//
|
|
176
|
-
// `files` is that one file and there is no index.
|
|
233
|
+
// With sections, or past a protocol cap, `files` are `/sitemaps/*.xml` and the index is
|
|
234
|
+
// `/sitemap.xml`; otherwise `files` is that one file and there is no index.
|
|
177
235
|
const sitemaps = sitemap.index === undefined ? sitemap.files : [sitemap.index, ...sitemap.files];
|
|
178
236
|
const robots = buildRobots({
|
|
179
237
|
baseUrl: options.baseUrl,
|
package/src/static-document.ts
CHANGED
|
@@ -12,9 +12,11 @@ import { etagMatches } from './runtime-storage';
|
|
|
12
12
|
export type StaticResult = RenderResult & { readonly body: string };
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* The entries one process keeps. A static route has no params here and no query,
|
|
16
|
-
* is
|
|
17
|
-
*
|
|
15
|
+
* The entries one process keeps. A static route has no params here and no query, and the key's
|
|
16
|
+
* path is the request's ADDRESS (`runtime-address.ts`), never its raw pathname — `/about/`,
|
|
17
|
+
* `//about` and `/%61bout` are one entry — so the real count is routes × locales; the cap is what a
|
|
18
|
+
* forged `Host` (part of the key when the app declares no origin) can cost — past it a page is
|
|
19
|
+
* rendered per request, as before, never refused.
|
|
18
20
|
*/
|
|
19
21
|
export const STATIC_MEMO_LIMIT = 1024;
|
|
20
22
|
|
|
@@ -77,7 +79,9 @@ export function createStaticMemo(
|
|
|
77
79
|
* The key a request's document is kept under, or `undefined` when it must be rendered: a query
|
|
78
80
|
* string is part of what `meta` reads (`data.url`), and a dynamic segment is an unbounded set an
|
|
79
81
|
* anonymous visitor chooses. The origin is in the key because canonical and `og:url` are absolute
|
|
80
|
-
* against the request's own when the app declares none.
|
|
82
|
+
* against the request's own when the app declares none. `url` is `addressedUrl`'s: keyed on the
|
|
83
|
+
* pathname as written, any visitor filled the memo with spellings of one page (`/about//`, …) and
|
|
84
|
+
* every page that arrived after the cap was rendered per request for the life of the process.
|
|
81
85
|
*/
|
|
82
86
|
export function staticMemoKey(
|
|
83
87
|
entry: Pick<RouteEntry, 'config' | 'pattern'>,
|
|
@@ -66,7 +66,13 @@ const prerender =
|
|
|
66
66
|
// landed on disk — which is what \`x build --target static --json\` reads back.
|
|
67
67
|
|
|
68
68
|
import { join } from 'node:path';
|
|
69
|
-
import {
|
|
69
|
+
import {
|
|
70
|
+
loadSiteSettings,
|
|
71
|
+
type PrerenderReport,
|
|
72
|
+
prerenderSite,
|
|
73
|
+
siteSeo,
|
|
74
|
+
writeSiteSeo,
|
|
75
|
+
} from '@ultimat3/cli';
|
|
70
76
|
|
|
71
77
|
const root = join(import.meta.dir, '..', '..');
|
|
72
78
|
const flag = Bun.argv.indexOf('--out');
|
|
@@ -84,9 +90,12 @@ const origin = Bun.env.SITE_ORIGIN;
|
|
|
84
90
|
* page whose \`meta\` says \`robots: { index: false }\` left out — so a crawler reads one sitemap
|
|
85
91
|
* from the CDN and from the container.
|
|
86
92
|
*
|
|
87
|
-
* A DYNAMIC route contributes exactly the URLs this build emitted for it, read back off the report
|
|
88
|
-
*
|
|
89
|
-
*
|
|
93
|
+
* A DYNAMIC route contributes exactly the URLs this build emitted for it, read back off the report:
|
|
94
|
+
* the sitemap then cannot name a page the artifact does not contain. \`prerender()\` is asked again
|
|
95
|
+
* only for what each page says of itself — its \`lastmod\`, the \`locales\` it is listed in.
|
|
96
|
+
*
|
|
97
|
+
* With \`seo.sitemap.sections\` the answer is an index and its parts: \`/sitemap.xml\` plus
|
|
98
|
+
* \`/sitemaps/<name>-<n>.xml\`, every one written here.
|
|
90
99
|
*
|
|
91
100
|
* \`robots.txt\` fails closed — anything that is not \`ULTIMATE_ENV=production\` emits
|
|
92
101
|
* \`Disallow: /\` and advertises no sitemap — so a preview build cannot outrank the real site.
|
|
@@ -104,9 +113,7 @@ async function writeSeoFiles(report: PrerenderReport): Promise<readonly string[]
|
|
|
104
113
|
pagesFor: (route) =>
|
|
105
114
|
report.pages.filter((page) => page.route === route).map((page) => page.path),
|
|
106
115
|
});
|
|
107
|
-
|
|
108
|
-
await Bun.write(join(out, 'robots.txt'), seo.robots);
|
|
109
|
-
return [...seo.sitemaps.map((file) => file.path), '/robots.txt'];
|
|
116
|
+
return await writeSiteSeo(out, seo);
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
if (import.meta.main) {
|