@ultimat3/cli 22.9.0 → 22.10.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/dev-route-table.ts +1 -1
- package/src/error-codes.ts +2 -0
- package/src/mcp-errors.ts +2 -0
- package/src/seo-routes.ts +5 -1
- package/src/serve-boot.ts +1 -1
- package/src/site-config.ts +29 -2
- package/src/site-seo.ts +82 -3
- package/src/sitemap-lastmod.ts +73 -0
- package/src/templates/scaffold-entries.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.10.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,33 +38,33 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@babel/core": "^8.0.1",
|
|
41
|
-
"@ultimat3/action": "22.
|
|
42
|
-
"@ultimat3/admin": "22.
|
|
43
|
-
"@ultimat3/ai": "22.
|
|
44
|
-
"@ultimat3/auth": "22.
|
|
45
|
-
"@ultimat3/cache": "22.
|
|
46
|
-
"@ultimat3/core": "22.
|
|
47
|
-
"@ultimat3/db": "22.
|
|
48
|
-
"@ultimat3/entity": "22.
|
|
49
|
-
"@ultimat3/flags": "22.
|
|
50
|
-
"@ultimat3/http": "22.
|
|
51
|
-
"@ultimat3/i18n": "22.
|
|
52
|
-
"@ultimat3/jobs": "22.
|
|
53
|
-
"@ultimat3/mail": "22.
|
|
54
|
-
"@ultimat3/manifest": "22.
|
|
55
|
-
"@ultimat3/mcp": "22.
|
|
56
|
-
"@ultimat3/money": "22.
|
|
57
|
-
"@ultimat3/notify": "22.
|
|
58
|
-
"@ultimat3/policy": "22.
|
|
59
|
-
"@ultimat3/pwa": "22.
|
|
60
|
-
"@ultimat3/query": "22.
|
|
61
|
-
"@ultimat3/realtime": "22.
|
|
62
|
-
"@ultimat3/render": "22.
|
|
63
|
-
"@ultimat3/schema": "22.
|
|
64
|
-
"@ultimat3/seo": "22.
|
|
65
|
-
"@ultimat3/storage": "22.
|
|
66
|
-
"@ultimat3/testing": "22.
|
|
67
|
-
"@ultimat3/time": "22.
|
|
41
|
+
"@ultimat3/action": "22.10.0",
|
|
42
|
+
"@ultimat3/admin": "22.10.0",
|
|
43
|
+
"@ultimat3/ai": "22.10.0",
|
|
44
|
+
"@ultimat3/auth": "22.10.0",
|
|
45
|
+
"@ultimat3/cache": "22.10.0",
|
|
46
|
+
"@ultimat3/core": "22.10.0",
|
|
47
|
+
"@ultimat3/db": "22.10.0",
|
|
48
|
+
"@ultimat3/entity": "22.10.0",
|
|
49
|
+
"@ultimat3/flags": "22.10.0",
|
|
50
|
+
"@ultimat3/http": "22.10.0",
|
|
51
|
+
"@ultimat3/i18n": "22.10.0",
|
|
52
|
+
"@ultimat3/jobs": "22.10.0",
|
|
53
|
+
"@ultimat3/mail": "22.10.0",
|
|
54
|
+
"@ultimat3/manifest": "22.10.0",
|
|
55
|
+
"@ultimat3/mcp": "22.10.0",
|
|
56
|
+
"@ultimat3/money": "22.10.0",
|
|
57
|
+
"@ultimat3/notify": "22.10.0",
|
|
58
|
+
"@ultimat3/policy": "22.10.0",
|
|
59
|
+
"@ultimat3/pwa": "22.10.0",
|
|
60
|
+
"@ultimat3/query": "22.10.0",
|
|
61
|
+
"@ultimat3/realtime": "22.10.0",
|
|
62
|
+
"@ultimat3/render": "22.10.0",
|
|
63
|
+
"@ultimat3/schema": "22.10.0",
|
|
64
|
+
"@ultimat3/seo": "22.10.0",
|
|
65
|
+
"@ultimat3/storage": "22.10.0",
|
|
66
|
+
"@ultimat3/testing": "22.10.0",
|
|
67
|
+
"@ultimat3/time": "22.10.0",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/dev-route-table.ts
CHANGED
|
@@ -127,7 +127,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
127
127
|
// captured at boot would answer 404 for the href the document now carries.
|
|
128
128
|
...styleRoutes(() => styleBundle()),
|
|
129
129
|
// `robots.txt` and `sitemap.xml`, the same two files the static export writes (`site-seo.ts`).
|
|
130
|
-
...seoRoutes({ env: input.env, site }),
|
|
130
|
+
...seoRoutes({ env: input.env, site, root: input.root }),
|
|
131
131
|
// `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
|
|
132
132
|
// lives here rather than in a second server because everything it needs is in THIS process:
|
|
133
133
|
// the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
|
package/src/error-codes.ts
CHANGED
|
@@ -202,6 +202,7 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
202
202
|
'X_VERIFY_SHARD_INVALID',
|
|
203
203
|
'X_VERIFY_MERGE_INCOMPLETE',
|
|
204
204
|
'X_VERIFY_MERGE_INPUT',
|
|
205
|
+
'X_SITEMAP_EXTRA_INVALID',
|
|
205
206
|
] as const;
|
|
206
207
|
|
|
207
208
|
/**
|
|
@@ -353,6 +354,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
353
354
|
X_VERIFY_SHARD_INVALID: 'x verify --shard was given a spec or a step list it cannot split',
|
|
354
355
|
X_VERIFY_MERGE_INCOMPLETE: 'x verify merge was handed parts that do not add up to the whole gate',
|
|
355
356
|
X_VERIFY_MERGE_INPUT: 'a part handed to x verify merge is not an x verify --json document',
|
|
357
|
+
X_SITEMAP_EXTRA_INVALID: 'a seo.sitemap.extra path names no public page',
|
|
356
358
|
};
|
|
357
359
|
|
|
358
360
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
package/src/mcp-errors.ts
CHANGED
|
@@ -251,6 +251,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
251
251
|
'x build --target static --json # after adding prerender() with one real path to the route',
|
|
252
252
|
X_ROUTE_ASYNC_PAGE:
|
|
253
253
|
'x verify --only budgets --json # after moving each await in Page into export const load',
|
|
254
|
+
X_SITEMAP_EXTRA_INVALID:
|
|
255
|
+
'x routes --json # then list in seo.sitemap.extra only a path a registered app/ route without a policy answers',
|
|
254
256
|
};
|
|
255
257
|
|
|
256
258
|
const isCliCode = (code: string): code is CliErrorCode =>
|
package/src/seo-routes.ts
CHANGED
|
@@ -10,8 +10,10 @@ import { ROBOTS_PATH, SITEMAP_PATH, siteSeo } from './site-seo';
|
|
|
10
10
|
|
|
11
11
|
export interface SeoRoutesOptions {
|
|
12
12
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
13
|
-
/** `site.origin
|
|
13
|
+
/** `site.origin`, `seo.robots.disallow` and `seo.sitemap` from `app.config.ts` (`loadSiteSettings`). */
|
|
14
14
|
readonly site?: SiteSettings;
|
|
15
|
+
/** The app root route files are relative to — `seo.sitemap.lastmod: 'git' | 'mtime'` reads them. */
|
|
16
|
+
readonly root?: string;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -37,6 +39,8 @@ export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
|
|
|
37
39
|
baseUrl: originOf(options, request),
|
|
38
40
|
environment,
|
|
39
41
|
disallow: options.site?.disallow ?? [],
|
|
42
|
+
...(options.site === undefined ? {} : { sitemap: options.site.sitemap }),
|
|
43
|
+
...(options.root === undefined ? {} : { root: options.root }),
|
|
40
44
|
});
|
|
41
45
|
|
|
42
46
|
return [
|
package/src/serve-boot.ts
CHANGED
|
@@ -198,7 +198,7 @@ async function webSurface(
|
|
|
198
198
|
// filled, so this process serves exactly the CSS it renders against.
|
|
199
199
|
...styleRoutes(() => styleBundle()),
|
|
200
200
|
// `robots.txt` and `sitemap.xml`, the same two files the static export writes (`site-seo.ts`).
|
|
201
|
-
...seoRoutes({ env: options.env, site }),
|
|
201
|
+
...seoRoutes({ env: options.env, site, root: options.root }),
|
|
202
202
|
// The page's one socket: its worker script, served beside the islands for their reason.
|
|
203
203
|
...sync.routes,
|
|
204
204
|
...navigation.routes,
|
package/src/site-config.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
// why: Bun exposes no path-join primitive, and the config path is app-root-relative.
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
-
import type { AppConfig, Environment } from '@ultimat3/core';
|
|
8
|
+
import type { AppConfig, Environment, SeoSitemapConfig, SitemapLastmod } from '@ultimat3/core';
|
|
9
|
+
import { SITEMAP_LASTMOD_SOURCES } from '@ultimat3/core';
|
|
9
10
|
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
10
11
|
import { APP_CONFIG_FILE } from './app-root';
|
|
11
12
|
|
|
@@ -14,9 +15,15 @@ export interface SiteSettings {
|
|
|
14
15
|
readonly origin: string | null;
|
|
15
16
|
/** `seo.robots.disallow` — the paths a production `robots.txt` keeps crawlers out of. */
|
|
16
17
|
readonly disallow: readonly string[];
|
|
18
|
+
/** `seo.sitemap` — public `app/` pages to list, and where `<lastmod>` comes from. */
|
|
19
|
+
readonly sitemap: SeoSitemapConfig;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
export const NO_SITE_SETTINGS: SiteSettings = {
|
|
22
|
+
export const NO_SITE_SETTINGS: SiteSettings = {
|
|
23
|
+
origin: null,
|
|
24
|
+
disallow: [],
|
|
25
|
+
sitemap: { extra: [], lastmod: 'none' },
|
|
26
|
+
};
|
|
20
27
|
|
|
21
28
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
22
29
|
typeof value === 'object' && value !== null;
|
|
@@ -43,6 +50,26 @@ export async function loadSiteSettings(root: string): Promise<SiteSettings> {
|
|
|
43
50
|
return {
|
|
44
51
|
origin: typeof origin === 'string' && origin !== '' ? trimSlash(origin) : null,
|
|
45
52
|
disallow: [...config.seo.robots.disallow],
|
|
53
|
+
sitemap: sitemapOf(config.seo),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Structural for `hasSiteSections`' reason: a config resolved through a core older than
|
|
59
|
+
* `seo.sitemap` has no such key, and that is the default, not a crash.
|
|
60
|
+
*/
|
|
61
|
+
function sitemapOf(seo: unknown): SeoSitemapConfig {
|
|
62
|
+
const declared = isRecord(seo) ? seo['sitemap'] : undefined;
|
|
63
|
+
if (!isRecord(declared)) return NO_SITE_SETTINGS.sitemap;
|
|
64
|
+
const extra = declared['extra'];
|
|
65
|
+
const lastmod = declared['lastmod'];
|
|
66
|
+
return {
|
|
67
|
+
extra: Array.isArray(extra)
|
|
68
|
+
? extra.filter((path): path is string => typeof path === 'string')
|
|
69
|
+
: [],
|
|
70
|
+
lastmod: SITEMAP_LASTMOD_SOURCES.includes(lastmod as SitemapLastmod)
|
|
71
|
+
? (lastmod as SitemapLastmod)
|
|
72
|
+
: 'none',
|
|
46
73
|
};
|
|
47
74
|
}
|
|
48
75
|
|
package/src/site-seo.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// (`apps/web/prerender.ts` writes it into the artifact) and the running web role (`seo-routes.ts`
|
|
3
3
|
// serves it), so a crawler reads the same two files from a CDN and from a container.
|
|
4
4
|
|
|
5
|
-
import type { Environment } from '@ultimat3/core';
|
|
5
|
+
import type { Environment, SeoSitemapConfig } from '@ultimat3/core';
|
|
6
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
7
|
import { localeConfig, localizedPath, routedLocales, unlocalizedPath } from '@ultimat3/i18n';
|
|
7
8
|
import type { RouteEntry } from '@ultimat3/render';
|
|
8
9
|
import { routeEntries } from '@ultimat3/render';
|
|
@@ -10,6 +11,7 @@ import { enumeratePrerender, fillPath } from '@ultimat3/render/server';
|
|
|
10
11
|
import type { RouteRecord, SitemapFile } from '@ultimat3/seo';
|
|
11
12
|
import { buildRobots, buildSitemap, isDynamic } from '@ultimat3/seo';
|
|
12
13
|
import { readSiteMeta } from './seo-meta';
|
|
14
|
+
import { lastmodOf } from './sitemap-lastmod';
|
|
13
15
|
|
|
14
16
|
export const ROBOTS_PATH = '/robots.txt';
|
|
15
17
|
export const SITEMAP_PATH = '/sitemap.xml';
|
|
@@ -30,6 +32,27 @@ export interface SiteSeoOptions {
|
|
|
30
32
|
* `User-agent: *` group; a non-production `robots.txt` still disallows everything.
|
|
31
33
|
*/
|
|
32
34
|
readonly disallow?: readonly string[] | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* `seo.sitemap` from `app.config.ts` (`loadSiteSettings`): the public `app/` pages to list beside
|
|
37
|
+
* the `site/` ones, and where each `<lastmod>` comes from. Omitted: `site/` pages, no `<lastmod>`.
|
|
38
|
+
*/
|
|
39
|
+
readonly sitemap?: SeoSitemapConfig | undefined;
|
|
40
|
+
/** The app root the route files are relative to — read for `lastmod: 'git' | 'mtime'`. */
|
|
41
|
+
readonly root?: string | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const NO_SITEMAP_SETTINGS: SeoSitemapConfig = { extra: [], lastmod: 'none' };
|
|
45
|
+
|
|
46
|
+
/** A `seo.sitemap.extra` path that names no public page outside `site/`. */
|
|
47
|
+
export class SitemapExtraInvalidError extends UltimateError {
|
|
48
|
+
constructor(path: string, reason: string) {
|
|
49
|
+
super({
|
|
50
|
+
code: 'X_SITEMAP_EXTRA_INVALID',
|
|
51
|
+
cause: `seo.sitemap.extra lists "${path}", which ${reason}`,
|
|
52
|
+
fix: 'x routes --json # then list in seo.sitemap.extra only a path a registered app/ route without a policy answers',
|
|
53
|
+
meta: { path },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
33
56
|
}
|
|
34
57
|
|
|
35
58
|
export interface SiteSeo {
|
|
@@ -60,7 +83,10 @@ const pagesOf = async (entry: RouteEntry, options: SiteSeoOptions): Promise<read
|
|
|
60
83
|
* without a request (`readSiteMeta`), which is what lets a page's own `robots: { index: false }`
|
|
61
84
|
* keep it out of the sitemap — a page that asks crawlers to stay away must not be listed for them.
|
|
62
85
|
*/
|
|
63
|
-
async function publicSiteRoutes(
|
|
86
|
+
async function publicSiteRoutes(
|
|
87
|
+
options: SiteSeoOptions,
|
|
88
|
+
stamp: Stamp,
|
|
89
|
+
): Promise<readonly RouteRecord[]> {
|
|
64
90
|
const metaByPath = new Map((await readSiteMeta()).records.map((r) => [r.path, r.meta]));
|
|
65
91
|
return routeEntries()
|
|
66
92
|
.filter(isPublicSite)
|
|
@@ -73,17 +99,70 @@ async function publicSiteRoutes(options: SiteSeoOptions): Promise<readonly Route
|
|
|
73
99
|
render: entry.config.render,
|
|
74
100
|
...(meta === undefined ? {} : { meta }),
|
|
75
101
|
...(isDynamic(entry.path) ? { prerender: () => pagesOf(entry, options) } : {}),
|
|
102
|
+
...stamp(entry.file),
|
|
76
103
|
};
|
|
77
104
|
});
|
|
78
105
|
}
|
|
79
106
|
|
|
107
|
+
type Stamp = (file: string) => { readonly lastmod?: string };
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The app's `seo.sitemap.extra` paths as records, each resolved to the route that answers it — the
|
|
111
|
+
* most specific match, the router's own tie-break. REFUSED, never skipped, when it names no route,
|
|
112
|
+
* an `api/` route, a gated one or a `site/` page (listed already): a sitemap missing a page its
|
|
113
|
+
* author listed is the silent drop, and a gated page in it is a URL a crawler cannot open.
|
|
114
|
+
*/
|
|
115
|
+
function extraRoutes(extra: readonly string[], stamp: Stamp): readonly RouteRecord[] {
|
|
116
|
+
const entries = routeEntries();
|
|
117
|
+
return [...new Set(extra)].map((path) => {
|
|
118
|
+
const entry = entries
|
|
119
|
+
.filter((candidate) => candidate.pattern.regex.test(path))
|
|
120
|
+
.sort((a, b) => b.pattern.specificity - a.pattern.specificity)[0];
|
|
121
|
+
if (entry === undefined)
|
|
122
|
+
throw new SitemapExtraInvalidError(path, 'no registered route answers');
|
|
123
|
+
if (entry.surface === 'api') {
|
|
124
|
+
throw new SitemapExtraInvalidError(path, `is the api/ route ${entry.file}, not a page`);
|
|
125
|
+
}
|
|
126
|
+
if (entry.surface === 'site') {
|
|
127
|
+
throw new SitemapExtraInvalidError(
|
|
128
|
+
path,
|
|
129
|
+
`is the site/ page ${entry.file} — every public site/ page is listed already`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (entry.config.policy !== undefined) {
|
|
133
|
+
throw new SitemapExtraInvalidError(
|
|
134
|
+
path,
|
|
135
|
+
`is answered by ${entry.file}, which declares a policy — a crawler cannot open it`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
path,
|
|
140
|
+
file: entry.file,
|
|
141
|
+
surface: 'app' as const,
|
|
142
|
+
render: entry.config.render,
|
|
143
|
+
sitemap: true,
|
|
144
|
+
...stamp(entry.file),
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
80
149
|
export async function siteSeo(options: SiteSeoOptions): Promise<SiteSeo> {
|
|
81
150
|
// Every routed locale, with `xhtml:link` alternates: one `<url>` per page per locale, each
|
|
82
151
|
// naming the whole cluster and `x-default`. A single-locale app passes none and gets the plain
|
|
83
152
|
// urlset it always had — an alternates cluster of one is noise.
|
|
84
153
|
const locales = routedLocales();
|
|
85
154
|
const defaultLocale = localeConfig().fallback;
|
|
86
|
-
const
|
|
155
|
+
const settings = options.sitemap ?? NO_SITEMAP_SETTINGS;
|
|
156
|
+
const root = options.root ?? process.cwd();
|
|
157
|
+
const stamp: Stamp = (file) => {
|
|
158
|
+
const lastmod = lastmodOf(file, settings.lastmod, root);
|
|
159
|
+
return lastmod === undefined ? {} : { lastmod };
|
|
160
|
+
};
|
|
161
|
+
const routes = [
|
|
162
|
+
...(await publicSiteRoutes(options, stamp)),
|
|
163
|
+
...extraRoutes(settings.extra, stamp),
|
|
164
|
+
];
|
|
165
|
+
const sitemap = await buildSitemap(routes, {
|
|
87
166
|
baseUrl: options.baseUrl,
|
|
88
167
|
...(locales.length < 2
|
|
89
168
|
? {}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// A sitemap `<lastmod>` per route source file — `seo.sitemap.lastmod` in `app.config.ts`. Read once
|
|
2
|
+
// per file per process: the running web role answers `/sitemap.xml` per request, and a `git log`
|
|
3
|
+
// per route per request is a subprocess per crawler hit for a date that changes on deploy.
|
|
4
|
+
|
|
5
|
+
// why: Bun has no synchronous stat, and `Bun.file(missing).lastModified` answers a garbage date
|
|
6
|
+
// (measured: year 144683) instead of refusing — the sitemap is built synchronously per route.
|
|
7
|
+
import { statSync } from 'node:fs';
|
|
8
|
+
// why: Bun exposes no path-join primitive, and the route file is app-root-relative.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import type { SitemapLastmod } from '@ultimat3/core';
|
|
11
|
+
|
|
12
|
+
/** The moment this process first built a sitemap — `'build'`'s one timestamp for every URL. */
|
|
13
|
+
let builtAt: string | undefined;
|
|
14
|
+
|
|
15
|
+
const cache = new Map<string, string | undefined>();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The `<lastmod>` for one route file, W3C Datetime (ISO 8601, UTC), or `undefined` when the source
|
|
19
|
+
* yields none — a sitemap entry without `<lastmod>` is valid, a wrong one misleads a crawler.
|
|
20
|
+
*
|
|
21
|
+
* `'git'` falls back to the file's mtime where there is no work tree (a container image): the
|
|
22
|
+
* image's copy of the file was written by the build, which is the honest "last changed" there.
|
|
23
|
+
*/
|
|
24
|
+
export function lastmodOf(file: string, mode: SitemapLastmod, root: string): string | undefined {
|
|
25
|
+
if (mode === 'none') return undefined;
|
|
26
|
+
if (mode === 'build') {
|
|
27
|
+
builtAt ??= new Date().toISOString();
|
|
28
|
+
return builtAt;
|
|
29
|
+
}
|
|
30
|
+
const key = `${mode}\u0000${root}\u0000${file}`;
|
|
31
|
+
if (cache.has(key)) return cache.get(key);
|
|
32
|
+
const value = (mode === 'git' ? gitDate(file, root) : undefined) ?? mtimeOf(join(root, file));
|
|
33
|
+
cache.set(key, value);
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Test seam: forget every date read, so a test can move a file's clock. */
|
|
38
|
+
export function resetLastmodCache(): void {
|
|
39
|
+
cache.clear();
|
|
40
|
+
builtAt = undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function gitDate(file: string, root: string): string | undefined {
|
|
44
|
+
try {
|
|
45
|
+
const run = Bun.spawnSync(['git', 'log', '-1', '--format=%cI', '--', file], {
|
|
46
|
+
cwd: root,
|
|
47
|
+
stdout: 'pipe',
|
|
48
|
+
stderr: 'ignore',
|
|
49
|
+
});
|
|
50
|
+
if (run.exitCode !== 0) return undefined;
|
|
51
|
+
return isoOf(run.stdout.toString().trim());
|
|
52
|
+
} catch {
|
|
53
|
+
// No `git` binary on PATH: the fallback, never a sitemap that cannot be served.
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mtimeOf(path: string): string | undefined {
|
|
59
|
+
try {
|
|
60
|
+
const modified = statSync(path, { throwIfNoEntry: false })?.mtimeMs;
|
|
61
|
+
return modified !== undefined && Number.isFinite(modified) && modified > 0
|
|
62
|
+
? new Date(modified).toISOString()
|
|
63
|
+
: undefined;
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isoOf(text: string): string | undefined {
|
|
70
|
+
if (text === '') return undefined;
|
|
71
|
+
const time = Date.parse(text);
|
|
72
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : undefined;
|
|
73
|
+
}
|
|
@@ -91,10 +91,15 @@ const origin = Bun.env.SITE_ORIGIN;
|
|
|
91
91
|
* \`Disallow: /\` and advertises no sitemap — so a preview build cannot outrank the real site.
|
|
92
92
|
*/
|
|
93
93
|
async function writeSeoFiles(report: PrerenderReport): Promise<readonly string[]> {
|
|
94
|
+
// The same disallow list and sitemap settings the running server answers with
|
|
95
|
+
// (app.config.ts seo.robots.disallow and seo.sitemap).
|
|
96
|
+
const settings = await loadSiteSettings(root);
|
|
94
97
|
const seo = await siteSeo({
|
|
95
98
|
// The origin the pages were built against, so the sitemap and every canonical agree.
|
|
96
99
|
baseUrl: report.origin,
|
|
97
|
-
disallow:
|
|
100
|
+
disallow: settings.disallow,
|
|
101
|
+
sitemap: settings.sitemap,
|
|
102
|
+
root,
|
|
98
103
|
pagesFor: (route) =>
|
|
99
104
|
report.pages.filter((page) => page.route === route).map((page) => page.path),
|
|
100
105
|
});
|