@iterant/site-runtime 3.10.0 → 3.11.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/docs/runtime-contract.md +1 -1
- package/package.json +1 -1
- package/scripts/scan-bespoke-siblings.mjs +1 -0
- package/src/content/schema.ts +17 -4
- package/src/layouts/LayoutCore.astro +7 -2
- package/src/lib/advertised.ts +17 -0
- package/src/lib/hreflang.ts +9 -4
- package/src/lib/sitemap/get-sitemap-paths.ts +25 -17
- package/src/lib/sitemap/sitemap-with-custom-pages-plugin.ts +32 -0
- package/src/routes/index.ts +12 -1
- package/src/routes/llms-txt.ts +6 -2
- package/src/routes/robots-txt.ts +126 -35
package/docs/runtime-contract.md
CHANGED
|
@@ -50,7 +50,7 @@ runtime and says so.
|
|
|
50
50
|
|
|
51
51
|
<!-- generated: available libraries -->
|
|
52
52
|
|
|
53
|
-
_Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.
|
|
53
|
+
_Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.11.1._
|
|
54
54
|
|
|
55
55
|
**Toolchain** (this package owns the version; do NOT declare these):
|
|
56
56
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iterant/site-runtime",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The platform layer every Iterant brand site runs on: content grammar, collection schemas, SEO head and JSON-LD, layout core, Astro config preset, dev integrations and the verify gates.",
|
|
6
6
|
"scripts": {
|
|
@@ -113,6 +113,7 @@ async function bespokeInventory() {
|
|
|
113
113
|
* `/es` (and `/es/<base>`) to `src/pages/[locale]/index.astro` (respectively
|
|
114
114
|
* `[locale]/<base>.astro`) before any rest-param catch-all, so when that file
|
|
115
115
|
* exists the sibling never renders through the catch-all this gate asserts.
|
|
116
|
+
* @param {string} base
|
|
116
117
|
*/
|
|
117
118
|
async function siblingRouteShadowed(base) {
|
|
118
119
|
const file = base === "home" ? "index.astro" : `${base}.astro`;
|
package/src/content/schema.ts
CHANGED
|
@@ -135,6 +135,12 @@ export function createContentSchemas({
|
|
|
135
135
|
// design ships its own nav/footer (a full-design import) so the layout
|
|
136
136
|
// mounts no chrome. Additive — existing entries omit it and keep chrome.
|
|
137
137
|
chrome: z.boolean().default(true),
|
|
138
|
+
// Kept out of search (site-runtime 3.11.0): the ONE owner of noindex.
|
|
139
|
+
// The layout renders noindex,nofollow from it, and the sitemap, llms.txt
|
|
140
|
+
// and a sibling's hreflang leave the page out. Each locale sibling
|
|
141
|
+
// carries its own value, as draft does. Additive: existing entries omit
|
|
142
|
+
// it and stay indexed.
|
|
143
|
+
noindex: z.boolean().default(false),
|
|
138
144
|
// Which SHELL this page renders in (site-runtime 3.2.0). A replicated
|
|
139
145
|
// site can carry more than one frame: a page whose chrome differed from
|
|
140
146
|
// the site's kept its own under a scope suffix, and the shell registry
|
|
@@ -159,16 +165,23 @@ export function createContentSchemas({
|
|
|
159
165
|
.object({
|
|
160
166
|
title: z.string(),
|
|
161
167
|
description: z.string().optional(),
|
|
162
|
-
// Social/share image. src
|
|
163
|
-
//
|
|
164
|
-
//
|
|
168
|
+
// Social/share image. src is a root-relative path, which the layout
|
|
169
|
+
// resolves against the site origin at render time, or an https URL
|
|
170
|
+
// (a published CDN URL; crawlers 403 on signed S3 URLs, and a
|
|
171
|
+
// scheme-relative or http src never reaches a share card). assetId
|
|
172
|
+
// links back to the brand asset; alt is the og:image:alt text.
|
|
165
173
|
ogImage: z
|
|
166
174
|
.object({
|
|
167
175
|
// Every other image leaf in an entry is tagged type: "image",
|
|
168
176
|
// and the model reaches for the same tag here; the tag is
|
|
169
177
|
// accepted so a share image never refuses a whole page write.
|
|
170
178
|
type: z.literal("image").optional(),
|
|
171
|
-
src: z
|
|
179
|
+
src: z
|
|
180
|
+
.string()
|
|
181
|
+
.regex(
|
|
182
|
+
/^(?:\/(?!\/)\S*|https:\/\/\S+)$/,
|
|
183
|
+
"ogImage.src must be a root-relative path or an https URL",
|
|
184
|
+
),
|
|
172
185
|
assetId: z.string().optional(),
|
|
173
186
|
alt: z.string().optional(),
|
|
174
187
|
})
|
|
@@ -86,7 +86,7 @@ const {
|
|
|
86
86
|
canonical,
|
|
87
87
|
image,
|
|
88
88
|
imageAlt,
|
|
89
|
-
noindex
|
|
89
|
+
noindex,
|
|
90
90
|
type = "website",
|
|
91
91
|
siteName = siteConfig.name,
|
|
92
92
|
jsonLd,
|
|
@@ -144,6 +144,11 @@ const structuredData = resolveStructuredData(
|
|
|
144
144
|
// prop, which is what makes the forward safe to ship ahead of any emit.
|
|
145
145
|
const shellId = shell ?? pageEntry?.data.shell;
|
|
146
146
|
|
|
147
|
+
// Kept out of search, from the same entry, so the page's own noindex reaches
|
|
148
|
+
// <meta robots> through a catch-all that threads nothing. A shell that passes
|
|
149
|
+
// the prop wins, like shell does; no entry and no prop means indexed.
|
|
150
|
+
const noindexResolved = noindex ?? pageEntry?.data.noindex ?? false;
|
|
151
|
+
|
|
147
152
|
// Machine-fed related links (ILV-6): the machine-owned src/content/links.json
|
|
148
153
|
// manifest, resolved by this page's own route key. A locale sibling looks up
|
|
149
154
|
// its full prefixed route; there is no fallback to the base route, because the
|
|
@@ -225,7 +230,7 @@ const fontPreload = fonts.named ? [{ style: "normal", subset: "latin" }] : false
|
|
|
225
230
|
canonicalUrl={canonicalUrl}
|
|
226
231
|
imageUrl={imageUrl}
|
|
227
232
|
imageAlt={imageAlt}
|
|
228
|
-
noindex={
|
|
233
|
+
noindex={noindexResolved}
|
|
229
234
|
type={type}
|
|
230
235
|
pageType={structuredData.pageType}
|
|
231
236
|
datePublished={structuredData.datePublished}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Whether a page entry is advertised to crawlers and agents: listed in the
|
|
2
|
+
// sitemap and llms.txt, and named by its siblings' hreflang. One flag with one
|
|
3
|
+
// meaning: `noindex` keeps a page out everywhere, and a draft is out except
|
|
4
|
+
// where drafts are shown (the dev server). Absent flags read as live and
|
|
5
|
+
// indexed, which is what every entry written before the flags carries.
|
|
6
|
+
export interface AdvertisedFlags {
|
|
7
|
+
draft?: boolean;
|
|
8
|
+
noindex?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isAdvertised(
|
|
12
|
+
entry: AdvertisedFlags,
|
|
13
|
+
options: { includeDrafts: boolean },
|
|
14
|
+
): boolean {
|
|
15
|
+
if (entry.noindex) return false;
|
|
16
|
+
return !entry.draft || options.includeDrafts;
|
|
17
|
+
}
|
package/src/lib/hreflang.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isAdvertised } from "./advertised";
|
|
1
2
|
import { DEFAULT_LOCALE, normalizeBcp47, parseEntryId } from "./locales";
|
|
2
3
|
|
|
3
4
|
// hreflang alternates for locale sibling pages (starter 2.9.0). A page with
|
|
@@ -21,12 +22,14 @@ export interface HreflangEntry {
|
|
|
21
22
|
/** The entry's `route` field (`/example`, `/es/example`). */
|
|
22
23
|
route: string;
|
|
23
24
|
draft: boolean;
|
|
25
|
+
/** Kept out of search; never advertised as an alternate. */
|
|
26
|
+
noindex?: boolean;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
/** A `pages` collection entry, as `pageLocaleHead` consumes it. */
|
|
27
30
|
export interface PagesCollectionEntry {
|
|
28
31
|
id: string;
|
|
29
|
-
data: { route: string; draft: boolean };
|
|
32
|
+
data: { route: string; draft: boolean; noindex?: boolean };
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
/**
|
|
@@ -58,6 +61,7 @@ export function pageLocaleHead(params: {
|
|
|
58
61
|
id: entry.id,
|
|
59
62
|
route: entry.data.route,
|
|
60
63
|
draft: entry.data.draft,
|
|
64
|
+
noindex: entry.data.noindex,
|
|
61
65
|
})),
|
|
62
66
|
baseName: parseEntryId(params.entryId).base,
|
|
63
67
|
site: params.site,
|
|
@@ -74,8 +78,8 @@ function absolute(route: string, site: URL | string | undefined): string {
|
|
|
74
78
|
* The reciprocal hreflang set for the group `baseName` belongs to, derived from
|
|
75
79
|
* ALL page entries (the caller passes the whole collection). Every non-draft
|
|
76
80
|
* entry in the group contributes one alternate; the base entry also seeds the
|
|
77
|
-
* single `x-default`. Draft siblings are never advertised
|
|
78
|
-
* translations must not leak to crawlers). Deduped on the normalized locale key
|
|
81
|
+
* single `x-default`. Draft and noindex siblings are never advertised
|
|
82
|
+
* (unpublished translations and private pages must not leak to crawlers). Deduped on the normalized locale key
|
|
79
83
|
* (first entry wins); hrefs absolute via `site` (`Astro.site`). Returns `[]`
|
|
80
84
|
* when the group has no non-draft siblings — a lone page emits no hreflang.
|
|
81
85
|
*/
|
|
@@ -92,7 +96,8 @@ export function deriveHreflangAlternates(params: {
|
|
|
92
96
|
>();
|
|
93
97
|
let base: { route: string } | undefined;
|
|
94
98
|
for (const entry of entries) {
|
|
95
|
-
|
|
99
|
+
// Never advertise a draft or a noindex sibling or base.
|
|
100
|
+
if (!isAdvertised(entry, { includeDrafts: false })) continue;
|
|
96
101
|
const { base: entryBase, locale } = parseEntryId(entry.id);
|
|
97
102
|
if (entryBase !== baseName) continue;
|
|
98
103
|
const isBase = !locale;
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Locale siblings (`<page>.<locale>.json`) are ordinary entries here: each
|
|
8
8
|
// contributes its own prefixed route (`/es/example`) so every published locale
|
|
9
|
-
// is in the sitemap.
|
|
10
|
-
//
|
|
9
|
+
// is in the sitemap. Each file's own `draft` and `noindex` are read, so an
|
|
10
|
+
// unpublished translation or a private page never appears.
|
|
11
11
|
//
|
|
12
12
|
// `root` is the CONSUMING repo's project root, which the caller reads off
|
|
13
13
|
// Astro's resolved config rather than guessing from cwd: the package sits in
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { readdirSync, readFileSync } from "node:fs";
|
|
17
17
|
import { isAbsolute, join } from "node:path";
|
|
18
18
|
|
|
19
|
+
import { isAdvertised } from "../advertised";
|
|
19
20
|
import { DEFAULT_PAGES_DIR } from "../content-paths";
|
|
20
21
|
|
|
21
22
|
export interface SitemapPathsOptions {
|
|
@@ -31,12 +32,16 @@ function resolvePagesDir({ pagesDir, root }: SitemapPathsOptions): string {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
|
-
* Every
|
|
35
|
-
* emits the site root)
|
|
35
|
+
* Every advertised page entry's route (neither draft nor noindex), home
|
|
36
|
+
* excluded (@astrojs/sitemap already emits the site root), beside `deny`: the
|
|
37
|
+
* routes of the entries that are NOT advertised. The integration crawls the
|
|
38
|
+
* prerendered pages itself and would list those a second way, so the caller
|
|
39
|
+
* drops them through its filter. Returns `pagesDir` so a caller can report
|
|
36
40
|
* WHERE it looked when the answer is empty.
|
|
37
41
|
*/
|
|
38
42
|
export function getSitemapPaths(options: SitemapPathsOptions = {}): {
|
|
39
43
|
paths: string[];
|
|
44
|
+
deny: string[];
|
|
40
45
|
entryCount: number;
|
|
41
46
|
pagesDir: string;
|
|
42
47
|
} {
|
|
@@ -45,21 +50,24 @@ export function getSitemapPaths(options: SitemapPathsOptions = {}): {
|
|
|
45
50
|
try {
|
|
46
51
|
entries = readdirSync(pagesDir, { recursive: true }) as string[];
|
|
47
52
|
} catch {
|
|
48
|
-
return { paths: [], pagesDir, entryCount: 0 }; // no page entries yet
|
|
53
|
+
return { paths: [], deny: [], pagesDir, entryCount: 0 }; // no page entries yet
|
|
49
54
|
}
|
|
50
55
|
const jsonEntries = entries.filter((entry) => entry.endsWith(".json"));
|
|
51
56
|
const entryCount = jsonEntries.length;
|
|
52
|
-
const paths =
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
const paths: string[] = [];
|
|
58
|
+
const deny: string[] = [];
|
|
59
|
+
for (const rel of jsonEntries) {
|
|
60
|
+
try {
|
|
61
|
+
const data = JSON.parse(readFileSync(join(pagesDir, rel), "utf8"));
|
|
62
|
+
if (typeof data.route !== "string") continue;
|
|
63
|
+
if (!isAdvertised(data, { includeDrafts: false })) {
|
|
64
|
+
deny.push(data.route as string);
|
|
65
|
+
} else if (data.route !== "/") {
|
|
66
|
+
paths.push(data.route as string);
|
|
61
67
|
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
} catch {
|
|
69
|
+
// malformed entry; the build will surface the schema error
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { paths, deny, pagesDir, entryCount };
|
|
65
73
|
}
|
|
@@ -8,6 +8,27 @@ import { getSitemapPaths, type SitemapPathsOptions } from "./get-sitemap-paths";
|
|
|
8
8
|
// placeholder and swap it for the resolved `config.site` at sitemap emit time.
|
|
9
9
|
const PLACEHOLDER = "https://starter.invalid";
|
|
10
10
|
|
|
11
|
+
// Routes the platform serves that are never a page of the site: the holding
|
|
12
|
+
// page a brand shows before its first publish. @astrojs/sitemap lists every
|
|
13
|
+
// prerendered route, and these have no entry to carry noindex, so the filter
|
|
14
|
+
// names them here.
|
|
15
|
+
const PLATFORM_DENY = new Set(["/under-construction"]);
|
|
16
|
+
|
|
17
|
+
/** Whether a sitemap URL may be listed: neither a platform route nor a denied
|
|
18
|
+
* entry route (a draft or a noindex page), whichever way the integration
|
|
19
|
+
* found it. */
|
|
20
|
+
export function sitemapPageAllowed(
|
|
21
|
+
url: string,
|
|
22
|
+
deny: ReadonlySet<string> = new Set(),
|
|
23
|
+
): boolean {
|
|
24
|
+
try {
|
|
25
|
+
const pathname = new URL(url).pathname.replace(/\/+$/, "") || "/";
|
|
26
|
+
return !PLATFORM_DENY.has(pathname) && !deny.has(pathname);
|
|
27
|
+
} catch {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
11
32
|
export type SitemapWithCustomPagesOptions = SitemapOptions &
|
|
12
33
|
SitemapPathsOptions;
|
|
13
34
|
|
|
@@ -17,6 +38,7 @@ export function sitemapWithCustomPages(
|
|
|
17
38
|
let resolvedSite = "";
|
|
18
39
|
const { pagesDir, root, ...sitemapOptions } = options;
|
|
19
40
|
const userSerialize = sitemapOptions.serialize;
|
|
41
|
+
const userFilter = sitemapOptions.filter;
|
|
20
42
|
|
|
21
43
|
// Filled in at astro:config:setup and read by @astrojs/sitemap at
|
|
22
44
|
// astro:build:done, which is what lets the entry routes be discovered against
|
|
@@ -24,6 +46,9 @@ export function sitemapWithCustomPages(
|
|
|
24
46
|
// This integration is ordered ahead of @astrojs/sitemap, so the array is
|
|
25
47
|
// populated before anything reads it.
|
|
26
48
|
const customPages: string[] = [...(sitemapOptions.customPages ?? [])];
|
|
49
|
+
// Filled beside customPages: the entry routes the integration must not list
|
|
50
|
+
// from its own crawl of prerendered pages.
|
|
51
|
+
const denied = new Set<string>();
|
|
27
52
|
|
|
28
53
|
return [
|
|
29
54
|
{
|
|
@@ -32,6 +57,7 @@ export function sitemapWithCustomPages(
|
|
|
32
57
|
"astro:config:setup": ({ config, logger }) => {
|
|
33
58
|
const {
|
|
34
59
|
paths,
|
|
60
|
+
deny,
|
|
35
61
|
pagesDir: searched,
|
|
36
62
|
entryCount,
|
|
37
63
|
} = getSitemapPaths({
|
|
@@ -51,6 +77,9 @@ export function sitemapWithCustomPages(
|
|
|
51
77
|
`page entries in ${searched} are all drafts or the site root; the sitemap lists no SSR routes yet`,
|
|
52
78
|
);
|
|
53
79
|
}
|
|
80
|
+
for (const path of deny) {
|
|
81
|
+
denied.add(path.replace(/\/+$/, "") || "/");
|
|
82
|
+
}
|
|
54
83
|
for (const path of paths) {
|
|
55
84
|
customPages.push(
|
|
56
85
|
`${PLACEHOLDER}${path.startsWith("/") ? path : `/${path}`}`,
|
|
@@ -67,6 +96,9 @@ export function sitemapWithCustomPages(
|
|
|
67
96
|
sitemap({
|
|
68
97
|
...sitemapOptions,
|
|
69
98
|
customPages,
|
|
99
|
+
filter: (page) =>
|
|
100
|
+
sitemapPageAllowed(page, denied) &&
|
|
101
|
+
(userFilter ? userFilter(page) : true),
|
|
70
102
|
serialize(item) {
|
|
71
103
|
if (resolvedSite && item.url.startsWith(PLACEHOLDER)) {
|
|
72
104
|
item.url = resolvedSite + item.url.slice(PLACEHOLDER.length);
|
package/src/routes/index.ts
CHANGED
|
@@ -3,7 +3,18 @@
|
|
|
3
3
|
// package bump. No route is injected: a route that exists in no repo file breaks
|
|
4
4
|
// the src/pages/ mental model and the debuggability contract.
|
|
5
5
|
export { createLlmsTxtRoute, type LlmsTxtOptions } from "./llms-txt";
|
|
6
|
-
export {
|
|
6
|
+
export {
|
|
7
|
+
AI_ANSWER_CRAWLERS,
|
|
8
|
+
AI_POLICY_PRESETS,
|
|
9
|
+
AI_TRAINING_CRAWLERS,
|
|
10
|
+
createRobotsTxtRoute,
|
|
11
|
+
renderRobotsTxt,
|
|
12
|
+
resolveAiPolicy,
|
|
13
|
+
type AiPolicy,
|
|
14
|
+
type AiPolicyPreset,
|
|
15
|
+
type RobotsSiteConfig,
|
|
16
|
+
type RobotsTxtOptions,
|
|
17
|
+
} from "./robots-txt";
|
|
7
18
|
export {
|
|
8
19
|
createProxiedSitemapRoute,
|
|
9
20
|
createSitemapRoute,
|
package/src/routes/llms-txt.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getCollection } from "astro:content";
|
|
2
|
+
import { isAdvertised } from "../lib/advertised";
|
|
2
3
|
import type { APIRoute } from "astro";
|
|
3
4
|
|
|
4
5
|
// /llms.txt, generated at dev/build time with no crawler or AI step: a curated
|
|
@@ -24,8 +25,11 @@ const escapeLinkText = (value: string) =>
|
|
|
24
25
|
export function createLlmsTxtRoute({ siteConfig }: LlmsTxtOptions): APIRoute {
|
|
25
26
|
return async ({ site }) => {
|
|
26
27
|
const origin = site?.origin ?? "https://example.com";
|
|
27
|
-
const pages = (await getCollection("pages")).filter(
|
|
28
|
-
(
|
|
28
|
+
const pages = (await getCollection("pages")).filter((entry) =>
|
|
29
|
+
isAdvertised(
|
|
30
|
+
{ draft: entry.data.draft, noindex: entry.data.noindex },
|
|
31
|
+
{ includeDrafts: !import.meta.env.PROD },
|
|
32
|
+
),
|
|
29
33
|
);
|
|
30
34
|
|
|
31
35
|
const contentLinks = pages
|
package/src/routes/robots-txt.ts
CHANGED
|
@@ -2,55 +2,146 @@ import type { APIRoute } from "astro";
|
|
|
2
2
|
|
|
3
3
|
import { normalizeSourceUrls } from "../lib/sitemap/shared";
|
|
4
4
|
|
|
5
|
-
// /robots.txt: crawler permissions
|
|
6
|
-
// route file and this supplies the body:
|
|
5
|
+
// /robots.txt: crawler permissions, the site's AI policy and the sitemap
|
|
6
|
+
// pointers. The repo keeps the route file and this supplies the body:
|
|
7
7
|
//
|
|
8
8
|
// src/pages/robots.txt.ts
|
|
9
9
|
// ---
|
|
10
10
|
// export const prerender = true;
|
|
11
|
-
// export const GET = createRobotsTxtRoute({
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// export const GET = createRobotsTxtRoute({ siteConfig: SITE_CONFIG });
|
|
12
|
+
//
|
|
13
|
+
// A shim written before 3.11.0 passes `{ sourceSitemapUrl }` alone and keeps
|
|
14
|
+
// working: the policy then renders its recommended default.
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
/** What the site lets AI systems do with its content. The Content-Signal
|
|
17
|
+
* line states it and the per-crawler blocks enforce it. */
|
|
18
|
+
export interface AiPolicy {
|
|
19
|
+
/** Search engines may index and show the pages. */
|
|
20
|
+
search: boolean;
|
|
21
|
+
/** AI answers and assistants may read the pages to answer a person. */
|
|
22
|
+
aiInput: boolean;
|
|
23
|
+
/** AI models may train on the pages. */
|
|
24
|
+
aiTrain: boolean;
|
|
19
25
|
}
|
|
20
26
|
|
|
21
|
-
export
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
export type AiPolicyPreset = "recommended" | "open" | "closed";
|
|
28
|
+
|
|
29
|
+
export const AI_POLICY_PRESETS: Record<AiPolicyPreset, AiPolicy> = {
|
|
30
|
+
// Findable, quotable, not trained on: what the readiness playbook recommends.
|
|
31
|
+
recommended: { search: true, aiInput: true, aiTrain: false },
|
|
32
|
+
open: { search: true, aiInput: true, aiTrain: true },
|
|
33
|
+
closed: { search: true, aiInput: false, aiTrain: false },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// The AI crawlers the public readiness scanner names, by the purpose each one
|
|
37
|
+
// declares. One list per language: the platform's robots parser carries the
|
|
38
|
+
// same fifteen names, and the test here pins them.
|
|
39
|
+
export const AI_TRAINING_CRAWLERS = [
|
|
40
|
+
"GPTBot",
|
|
41
|
+
"ClaudeBot",
|
|
42
|
+
"Claude-Web",
|
|
43
|
+
"anthropic-ai",
|
|
44
|
+
"CCBot",
|
|
45
|
+
"Bytespider",
|
|
46
|
+
"cohere-ai",
|
|
47
|
+
"Google-Extended",
|
|
48
|
+
"GoogleOther",
|
|
49
|
+
"Applebot-Extended",
|
|
50
|
+
"FacebookBot",
|
|
51
|
+
] as const;
|
|
52
|
+
|
|
53
|
+
export const AI_ANSWER_CRAWLERS = [
|
|
54
|
+
"OAI-SearchBot",
|
|
55
|
+
"ChatGPT-User",
|
|
56
|
+
"PerplexityBot",
|
|
57
|
+
"Amazonbot",
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
const SEARCH_CRAWLERS = [
|
|
61
|
+
"Googlebot",
|
|
62
|
+
"Bingbot",
|
|
63
|
+
"Twitterbot",
|
|
64
|
+
"facebookexternalhit",
|
|
65
|
+
] as const;
|
|
66
|
+
|
|
67
|
+
/** The fields of `SITE_CONFIG` this route reads. */
|
|
68
|
+
export interface RobotsSiteConfig {
|
|
69
|
+
/** A mirrored legacy sitemap, advertised only when configured. */
|
|
70
|
+
sourceSitemapUrl?: string | string[];
|
|
71
|
+
/** The AI policy, explicit or by preset. Absent: `recommended`. */
|
|
72
|
+
aiPolicy?: AiPolicy | AiPolicyPreset;
|
|
73
|
+
/** Disallow paths carried over from the site's previous robots.txt, rendered
|
|
74
|
+
* under the wildcard agent so what the old site kept out stays out. */
|
|
75
|
+
legacyRules?: string[];
|
|
76
|
+
}
|
|
34
77
|
|
|
35
|
-
|
|
36
|
-
|
|
78
|
+
export interface RobotsTxtOptions extends RobotsSiteConfig {
|
|
79
|
+
/** The brand's `SITE_CONFIG`, whole, so a field added later needs no shim
|
|
80
|
+
* change. Its fields win over the flat ones. */
|
|
81
|
+
siteConfig?: RobotsSiteConfig;
|
|
82
|
+
}
|
|
37
83
|
|
|
38
|
-
|
|
39
|
-
|
|
84
|
+
export function resolveAiPolicy(
|
|
85
|
+
policy: AiPolicy | AiPolicyPreset | undefined,
|
|
86
|
+
): AiPolicy {
|
|
87
|
+
if (policy === undefined) return AI_POLICY_PRESETS.recommended;
|
|
88
|
+
if (typeof policy === "string") {
|
|
89
|
+
return AI_POLICY_PRESETS[policy] ?? AI_POLICY_PRESETS.recommended;
|
|
90
|
+
}
|
|
91
|
+
return policy;
|
|
92
|
+
}
|
|
40
93
|
|
|
41
|
-
|
|
42
|
-
|
|
94
|
+
const grant = (allowed: boolean) => (allowed ? "Allow: /" : "Disallow: /");
|
|
95
|
+
const yesNo = (value: boolean) => (value ? "yes" : "no");
|
|
43
96
|
|
|
44
|
-
|
|
45
|
-
|
|
97
|
+
function agentBlocks(agents: readonly string[], allowed: boolean): string[] {
|
|
98
|
+
return agents.map((agent) => `User-agent: ${agent}\n${grant(allowed)}`);
|
|
99
|
+
}
|
|
46
100
|
|
|
47
|
-
|
|
48
|
-
|
|
101
|
+
function legacyDisallows(rules: string[] | undefined): string[] {
|
|
102
|
+
return (rules ?? [])
|
|
103
|
+
.map((rule) => rule.replace(/^\s*disallow\s*:/i, "").trim())
|
|
104
|
+
.filter((path) => path.length > 0)
|
|
105
|
+
.map((path) => `Disallow: ${path}`);
|
|
106
|
+
}
|
|
49
107
|
|
|
50
|
-
|
|
108
|
+
export function renderRobotsTxt(
|
|
109
|
+
config: RobotsSiteConfig,
|
|
110
|
+
site: URL | undefined,
|
|
111
|
+
): string {
|
|
112
|
+
const policy = resolveAiPolicy(config.aiPolicy);
|
|
113
|
+
const sitemap = site
|
|
114
|
+
? [
|
|
115
|
+
`Sitemap: ${new URL("sitemap-index.xml", site).href}`,
|
|
116
|
+
// Mirrored sitemap, only when sourceSitemapUrl is configured.
|
|
117
|
+
...(normalizeSourceUrls(config.sourceSitemapUrl ?? "").length > 0
|
|
118
|
+
? [`Sitemap: ${new URL("sitemap.xml", site).href}`]
|
|
119
|
+
: []),
|
|
120
|
+
].join("\n") + "\n"
|
|
121
|
+
: "";
|
|
122
|
+
const wildcard = [
|
|
123
|
+
"User-agent: *",
|
|
124
|
+
"Allow: /",
|
|
125
|
+
...legacyDisallows(config.legacyRules),
|
|
126
|
+
`Content-Signal: search=${yesNo(policy.search)}, ai-input=${yesNo(policy.aiInput)}, ai-train=${yesNo(policy.aiTrain)}`,
|
|
127
|
+
].join("\n");
|
|
128
|
+
return (
|
|
129
|
+
[
|
|
130
|
+
...agentBlocks(SEARCH_CRAWLERS, policy.search),
|
|
131
|
+
...agentBlocks(AI_ANSWER_CRAWLERS, policy.aiInput),
|
|
132
|
+
...agentBlocks(AI_TRAINING_CRAWLERS, policy.aiTrain),
|
|
133
|
+
wildcard,
|
|
134
|
+
].join("\n\n") +
|
|
135
|
+
"\n\n" +
|
|
136
|
+
sitemap
|
|
137
|
+
);
|
|
138
|
+
}
|
|
51
139
|
|
|
52
|
-
|
|
140
|
+
export function createRobotsTxtRoute(options: RobotsTxtOptions): APIRoute {
|
|
141
|
+
const { siteConfig, ...flat } = options;
|
|
142
|
+
const config: RobotsSiteConfig = { ...flat, ...(siteConfig ?? {}) };
|
|
143
|
+
return ({ site }) =>
|
|
144
|
+
new Response(renderRobotsTxt(config, site), {
|
|
53
145
|
headers: { "Content-Type": "text/plain" },
|
|
54
146
|
});
|
|
55
|
-
};
|
|
56
147
|
}
|