@iterant/site-runtime 3.11.2 → 3.13.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/bin/site-runtime.mjs +1 -0
- package/docs/runtime-contract.md +147 -5
- package/package.json +1 -1
- package/scripts/verify.mjs +25 -1
- package/src/components/seo.tsx +49 -18
- package/src/config/preset.ts +43 -1
- package/src/config/site-module.ts +106 -0
- package/src/config/virtual-modules.d.ts +22 -0
- package/src/index.ts +3 -2
- package/src/layouts/LayoutCore.astro +50 -20
- package/src/lib/canonical-scope.ts +108 -0
- package/src/lib/folder.ts +33 -0
- package/src/lib/hreflang-derive.ts +118 -0
- package/src/lib/hreflang.ts +24 -101
- package/src/lib/locales.ts +16 -9
- package/src/lib/sitemap/index.ts +1 -0
- package/src/lib/sitemap/sitemap-with-custom-pages-plugin.ts +58 -36
- package/src/routes/index.ts +14 -4
- package/src/routes/llms-txt-injected.ts +11 -0
- package/src/routes/llms-txt.ts +103 -38
- package/src/routes/robots-txt.ts +35 -8
|
@@ -2,6 +2,11 @@ import { fileURLToPath } from "node:url";
|
|
|
2
2
|
import sitemap, { type SitemapOptions } from "@astrojs/sitemap";
|
|
3
3
|
import type { AstroIntegration } from "astro";
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
HOST_SCOPE,
|
|
7
|
+
inCanonicalScope,
|
|
8
|
+
type CanonicalScope,
|
|
9
|
+
} from "../canonical-scope";
|
|
5
10
|
import { getSitemapPaths, type SitemapPathsOptions } from "./get-sitemap-paths";
|
|
6
11
|
|
|
7
12
|
// The platform patches the `site:` literal at deploy time, so we emit URLs against a
|
|
@@ -14,51 +19,69 @@ const PLACEHOLDER = "https://starter.invalid";
|
|
|
14
19
|
// names them here.
|
|
15
20
|
const PLATFORM_DENY = new Set(["/under-construction"]);
|
|
16
21
|
|
|
17
|
-
/** Whether a sitemap URL may be listed:
|
|
18
|
-
* entry route (a draft or a noindex page),
|
|
19
|
-
* found it.
|
|
22
|
+
/** Whether a sitemap URL may be listed: in the canonical scope, and neither a
|
|
23
|
+
* platform route nor a denied entry route (a draft or a noindex page),
|
|
24
|
+
* whichever way the integration found it. An out-of-scope path is not served
|
|
25
|
+
* on the origin the `<loc>` would name, so listing it points a crawler at the
|
|
26
|
+
* customer's own site. */
|
|
20
27
|
export function sitemapPageAllowed(
|
|
21
28
|
url: string,
|
|
22
29
|
deny: ReadonlySet<string> = new Set(),
|
|
30
|
+
scope: CanonicalScope = HOST_SCOPE,
|
|
23
31
|
): boolean {
|
|
32
|
+
const pathname = sitemapPath(url);
|
|
33
|
+
if (pathname === undefined) return true;
|
|
34
|
+
return (
|
|
35
|
+
!PLATFORM_DENY.has(pathname) &&
|
|
36
|
+
!deny.has(pathname) &&
|
|
37
|
+
inCanonicalScope(pathname, scope)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A candidate's path in entry-route form, or undefined when it is not a URL
|
|
42
|
+
* the integration can read. */
|
|
43
|
+
function sitemapPath(url: string): string | undefined {
|
|
24
44
|
try {
|
|
25
|
-
|
|
26
|
-
return !PLATFORM_DENY.has(pathname) && !deny.has(pathname);
|
|
45
|
+
return new URL(url).pathname.replace(/\/+$/, "") || "/";
|
|
27
46
|
} catch {
|
|
28
|
-
return
|
|
47
|
+
return undefined;
|
|
29
48
|
}
|
|
30
49
|
}
|
|
31
50
|
|
|
32
|
-
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
/**
|
|
52
|
+
* The integration's page filter: the rule above, plus first-one-wins on a
|
|
53
|
+
* repeated path.
|
|
54
|
+
*
|
|
55
|
+
* @astrojs/sitemap discovers the prerendered pages itself AND is handed the
|
|
56
|
+
* entry routes as customPages, so a bespoke page that also has a page entry
|
|
57
|
+
* arrives twice and is listed twice. One pass over the candidates, so the state
|
|
58
|
+
* is the filter's own and a new build gets a new one.
|
|
59
|
+
*/
|
|
60
|
+
export function createSitemapFilter(
|
|
61
|
+
deny: ReadonlySet<string> = new Set(),
|
|
62
|
+
scope: CanonicalScope = HOST_SCOPE,
|
|
63
|
+
): (url: string) => boolean {
|
|
64
|
+
const listed = new Set<string>();
|
|
65
|
+
return (url) => {
|
|
66
|
+
if (!sitemapPageAllowed(url, deny, scope)) return false;
|
|
67
|
+
const pathname = sitemapPath(url) ?? url;
|
|
68
|
+
if (listed.has(pathname)) return false;
|
|
69
|
+
listed.add(pathname);
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
50
72
|
}
|
|
51
73
|
|
|
52
74
|
export type SitemapWithCustomPagesOptions = SitemapOptions &
|
|
53
|
-
SitemapPathsOptions
|
|
75
|
+
SitemapPathsOptions & {
|
|
76
|
+
/** Which paths the site origin serves. Defaults to the whole host. */
|
|
77
|
+
scope?: CanonicalScope;
|
|
78
|
+
};
|
|
54
79
|
|
|
55
80
|
export function sitemapWithCustomPages(
|
|
56
81
|
options: SitemapWithCustomPagesOptions = {},
|
|
57
82
|
): AstroIntegration[] {
|
|
58
83
|
let resolvedSite = "";
|
|
59
|
-
|
|
60
|
-
const emitted = new Set<string>();
|
|
61
|
-
const { pagesDir, root, ...sitemapOptions } = options;
|
|
84
|
+
const { pagesDir, root, scope = HOST_SCOPE, ...sitemapOptions } = options;
|
|
62
85
|
const userSerialize = sitemapOptions.serialize;
|
|
63
86
|
const userFilter = sitemapOptions.filter;
|
|
64
87
|
|
|
@@ -69,8 +92,10 @@ export function sitemapWithCustomPages(
|
|
|
69
92
|
// populated before anything reads it.
|
|
70
93
|
const customPages: string[] = [...(sitemapOptions.customPages ?? [])];
|
|
71
94
|
// Filled beside customPages: the entry routes the integration must not list
|
|
72
|
-
// from its own crawl of prerendered pages.
|
|
95
|
+
// from its own crawl of prerendered pages. The filter reads it by reference,
|
|
96
|
+
// so it sees whatever astro:config:setup put there.
|
|
73
97
|
const denied = new Set<string>();
|
|
98
|
+
const allowed = createSitemapFilter(denied, scope);
|
|
74
99
|
|
|
75
100
|
return [
|
|
76
101
|
{
|
|
@@ -109,7 +134,6 @@ export function sitemapWithCustomPages(
|
|
|
109
134
|
}
|
|
110
135
|
},
|
|
111
136
|
"astro:config:done": ({ config }) => {
|
|
112
|
-
emitted.clear();
|
|
113
137
|
if (config.site) {
|
|
114
138
|
resolvedSite = String(config.site).replace(/\/$/, "");
|
|
115
139
|
}
|
|
@@ -119,13 +143,11 @@ export function sitemapWithCustomPages(
|
|
|
119
143
|
sitemap({
|
|
120
144
|
...sitemapOptions,
|
|
121
145
|
customPages,
|
|
122
|
-
filter: (page) =>
|
|
123
|
-
sitemapPageAllowed(page, denied) &&
|
|
124
|
-
(userFilter ? userFilter(page) : true),
|
|
146
|
+
filter: (page) => (userFilter ? userFilter(page) : true) && allowed(page),
|
|
125
147
|
serialize(item) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
148
|
+
if (resolvedSite && item.url.startsWith(PLACEHOLDER)) {
|
|
149
|
+
item.url = resolvedSite + item.url.slice(PLACEHOLDER.length);
|
|
150
|
+
}
|
|
129
151
|
return userSerialize ? userSerialize(item) : item;
|
|
130
152
|
},
|
|
131
153
|
}),
|
package/src/routes/index.ts
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
// Platform route handlers. Every one of these stays behind a VISIBLE file in the
|
|
2
|
-
// repo's src/pages
|
|
3
|
-
// package bump.
|
|
4
|
-
//
|
|
5
|
-
|
|
2
|
+
// repo's src/pages/: existence and addressing are repo-owned, behavior rides a
|
|
3
|
+
// package bump. A route that exists in no repo file breaks the src/pages/ mental
|
|
4
|
+
// model and the debuggability contract.
|
|
5
|
+
//
|
|
6
|
+
// One route is addressed by the platform, llms.txt under a brand folder
|
|
7
|
+
// (site-runtime 3.12.0): the folder is per-brand data, so no file name the
|
|
8
|
+
// template ships can carry it, and the preset injects the same handler at
|
|
9
|
+
// /<folder>/llms.txt (../config/site-module.ts).
|
|
10
|
+
export {
|
|
11
|
+
createLlmsTxtRoute,
|
|
12
|
+
renderLlmsTxt,
|
|
13
|
+
type LlmsTxtOptions,
|
|
14
|
+
type LlmsTxtPage,
|
|
15
|
+
} from "./llms-txt";
|
|
6
16
|
export {
|
|
7
17
|
AI_ANSWER_CRAWLERS,
|
|
8
18
|
AI_POLICY_PRESETS,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SITE_CONFIG } from "virtual:iterant/site";
|
|
2
|
+
|
|
3
|
+
import { createLlmsTxtRoute } from "./llms-txt";
|
|
4
|
+
|
|
5
|
+
// The body of /<folder>/llms.txt, as the preset injects it (see
|
|
6
|
+
// ../config/site-module.ts). The brand's name and description come from the
|
|
7
|
+
// repo's site config through the virtual module, since an injected route has no
|
|
8
|
+
// repo file to import them from; the folder the handler writes into its links
|
|
9
|
+
// comes from the same module.
|
|
10
|
+
export const prerender = true;
|
|
11
|
+
export const GET = createLlmsTxtRoute({ siteConfig: SITE_CONFIG });
|
package/src/routes/llms-txt.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { getCollection } from "astro:content";
|
|
2
|
+
import { CANONICAL_SCOPE, FOLDER, PLATFORM_SITE } from "virtual:iterant/site";
|
|
2
3
|
import { isAdvertised } from "../lib/advertised";
|
|
4
|
+
import { inCanonicalScope, type CanonicalScope } from "../lib/canonical-scope";
|
|
5
|
+
import { folderPath } from "../lib/folder";
|
|
3
6
|
import type { APIRoute } from "astro";
|
|
4
7
|
|
|
5
8
|
// /llms.txt, generated at dev/build time with no crawler or AI step: a curated
|
|
@@ -11,59 +14,121 @@ import type { APIRoute } from "astro";
|
|
|
11
14
|
// ---
|
|
12
15
|
// export const prerender = true;
|
|
13
16
|
// export const GET = createLlmsTxtRoute({ siteConfig: SITE_CONFIG });
|
|
17
|
+
//
|
|
18
|
+
// Under a brand folder the same handler is addressed by the preset instead, at
|
|
19
|
+
// /<folder>/llms.txt, because no file name the template ships can carry a
|
|
20
|
+
// per-brand segment (../config/site-module.ts).
|
|
14
21
|
|
|
15
22
|
export interface LlmsTxtOptions {
|
|
16
23
|
/** The brand's SITE_CONFIG; name and description head the file when set. */
|
|
17
24
|
siteConfig: { name: string; description: string };
|
|
18
25
|
}
|
|
19
26
|
|
|
27
|
+
/** One published page, as the file lists it. */
|
|
28
|
+
export interface LlmsTxtPage {
|
|
29
|
+
route: string;
|
|
30
|
+
title: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
const normalizeText = (value: string) => value.trim().replace(/\s+/g, " ");
|
|
21
35
|
|
|
22
36
|
const escapeLinkText = (value: string) =>
|
|
23
37
|
normalizeText(value).replaceAll("[", "\\[").replaceAll("]", "\\]");
|
|
24
38
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The file's body. EVERY link in it is a path this origin serves: the file is
|
|
41
|
+
* read on whichever host it was fetched from, so a link to a path the customer
|
|
42
|
+
* forwards to their own site sends a reader there, and an absolute link to our
|
|
43
|
+
* host advertises it on their domain. Under folder scope that leaves out the
|
|
44
|
+
* pages outside the folder, the brand home and the root robots policy; the
|
|
45
|
+
* generated sitemap index is inside the folder and stays. A section whose every
|
|
46
|
+
* line went is dropped with its heading.
|
|
47
|
+
*/
|
|
48
|
+
export function renderLlmsTxt(params: {
|
|
49
|
+
siteConfig: { name: string; description: string };
|
|
50
|
+
pages: LlmsTxtPage[];
|
|
51
|
+
origin: string;
|
|
52
|
+
folder: string;
|
|
53
|
+
scope: CanonicalScope;
|
|
54
|
+
}): string {
|
|
55
|
+
const { siteConfig, pages, origin, folder, scope } = params;
|
|
56
|
+
const served = (path: string) => inCanonicalScope(path, scope);
|
|
57
|
+
const absolute = (path: string) => new URL(path, origin).href;
|
|
58
|
+
|
|
59
|
+
const contentLinks = pages
|
|
60
|
+
.filter((page) => served(page.route))
|
|
61
|
+
.toSorted((a, b) => a.title.localeCompare(b.title))
|
|
62
|
+
.map((page) => {
|
|
63
|
+
const title = escapeLinkText(page.title);
|
|
64
|
+
const description = page.description
|
|
65
|
+
? `: ${normalizeText(page.description)}`
|
|
66
|
+
: "";
|
|
67
|
+
|
|
68
|
+
return `- [${title}](${absolute(page.route)})${description}`;
|
|
69
|
+
});
|
|
34
70
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
:
|
|
71
|
+
const coreLinks = served("/")
|
|
72
|
+
? [`- [Home](${absolute("/")}): Primary overview of the site.`]
|
|
73
|
+
: [];
|
|
74
|
+
const sitemapPath = folderPath(folder, "sitemap-index.xml");
|
|
75
|
+
const discoveryLinks = [
|
|
76
|
+
...(served(sitemapPath)
|
|
77
|
+
? [
|
|
78
|
+
`- [Sitemap](${absolute(sitemapPath)}): Complete search-engine sitemap, if available.`,
|
|
79
|
+
]
|
|
80
|
+
: []),
|
|
81
|
+
...(served("/robots.txt")
|
|
82
|
+
? [
|
|
83
|
+
`- [Robots policy](${absolute("/robots.txt")}): Crawler permissions for automated agents.`,
|
|
84
|
+
]
|
|
85
|
+
: []),
|
|
86
|
+
];
|
|
43
87
|
|
|
44
|
-
|
|
45
|
-
|
|
88
|
+
const section = (heading: string, links: string[]) =>
|
|
89
|
+
links.length > 0 ? [heading, "", ...links, ""] : [];
|
|
46
90
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
91
|
+
const lines = [
|
|
92
|
+
...(siteConfig.name ? [`# ${siteConfig.name}`, ""] : []),
|
|
93
|
+
...(siteConfig.description ? [`> ${siteConfig.description}`, ""] : []),
|
|
94
|
+
"This file is generated from the site's source content. It is a curated index for AI assistants, not a crawler permissions file. For crawler permissions, see `/robots.txt`.",
|
|
95
|
+
"",
|
|
96
|
+
...section("## Core Pages", coreLinks),
|
|
97
|
+
...section("## Public Content", contentLinks),
|
|
98
|
+
...section("## Discovery", discoveryLinks),
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createLlmsTxtRoute({ siteConfig }: LlmsTxtOptions): APIRoute {
|
|
105
|
+
return async ({ site }) => {
|
|
106
|
+
const pages = (await getCollection("pages"))
|
|
107
|
+
.filter((entry) =>
|
|
108
|
+
isAdvertised(
|
|
109
|
+
{ draft: entry.data.draft, noindex: entry.data.noindex },
|
|
110
|
+
{ includeDrafts: !import.meta.env.PROD },
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
.map((entry) => ({
|
|
114
|
+
route: entry.data.route,
|
|
115
|
+
title: entry.data.meta.title,
|
|
116
|
+
description: entry.data.meta.description,
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
const body = renderLlmsTxt({
|
|
120
|
+
siteConfig,
|
|
121
|
+
pages,
|
|
122
|
+
origin: site?.origin ?? "https://example.com",
|
|
123
|
+
folder: FOLDER,
|
|
124
|
+
scope: {
|
|
125
|
+
name: CANONICAL_SCOPE,
|
|
126
|
+
folder: FOLDER,
|
|
127
|
+
platformSite: PLATFORM_SITE,
|
|
128
|
+
},
|
|
129
|
+
});
|
|
65
130
|
|
|
66
|
-
return new Response(
|
|
131
|
+
return new Response(body, {
|
|
67
132
|
headers: {
|
|
68
133
|
"content-type": "text/plain; charset=utf-8",
|
|
69
134
|
},
|
package/src/routes/robots-txt.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
-
|
|
2
|
+
import { CANONICAL_SCOPE, FOLDER, PLATFORM_SITE } from "virtual:iterant/site";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
HOST_SCOPE,
|
|
6
|
+
inCanonicalScope,
|
|
7
|
+
type CanonicalScope,
|
|
8
|
+
} from "../lib/canonical-scope";
|
|
9
|
+
import { folderPath } from "../lib/folder";
|
|
3
10
|
import { normalizeSourceUrls } from "../lib/sitemap/shared";
|
|
4
11
|
|
|
5
12
|
// /robots.txt: crawler permissions, the site's AI policy and the sitemap
|
|
@@ -108,15 +115,30 @@ function legacyDisallows(rules: string[] | undefined): string[] {
|
|
|
108
115
|
export function renderRobotsTxt(
|
|
109
116
|
config: RobotsSiteConfig,
|
|
110
117
|
site: URL | undefined,
|
|
118
|
+
// The brand's folder (site-runtime 3.12.0). robots.txt itself stays at the
|
|
119
|
+
// root, where a crawler looks for it; its index line points into the folder,
|
|
120
|
+
// where the build writes the sitemap. Resolved against the ORIGIN, because the
|
|
121
|
+
// folder is what the customer forwards from their domain's root, while a site
|
|
122
|
+
// URL that carries a path keeps resolving relative to it without one.
|
|
123
|
+
folder: string = "",
|
|
124
|
+
// Which paths that origin serves (site-runtime 3.13.0). The generated index
|
|
125
|
+
// is inside the folder and is always advertised; the MIRRORED sitemap is at
|
|
126
|
+
// the root, which a customer forwarding the folder alone answers with their
|
|
127
|
+
// own site, so under folder scope it is not advertised at all.
|
|
128
|
+
scope: CanonicalScope = HOST_SCOPE,
|
|
111
129
|
): string {
|
|
112
130
|
const policy = resolveAiPolicy(config.aiPolicy);
|
|
131
|
+
const indexPath = folder
|
|
132
|
+
? folderPath(folder, "sitemap-index.xml")
|
|
133
|
+
: "sitemap-index.xml";
|
|
134
|
+
const mirrored =
|
|
135
|
+
normalizeSourceUrls(config.sourceSitemapUrl ?? "").length > 0 &&
|
|
136
|
+
inCanonicalScope("/sitemap.xml", scope);
|
|
113
137
|
const sitemap = site
|
|
114
138
|
? [
|
|
115
|
-
`Sitemap: ${new URL(
|
|
139
|
+
`Sitemap: ${new URL(indexPath, site).href}`,
|
|
116
140
|
// Mirrored sitemap, only when sourceSitemapUrl is configured.
|
|
117
|
-
...(
|
|
118
|
-
? [`Sitemap: ${new URL("sitemap.xml", site).href}`]
|
|
119
|
-
: []),
|
|
141
|
+
...(mirrored ? [`Sitemap: ${new URL("sitemap.xml", site).href}`] : []),
|
|
120
142
|
].join("\n") + "\n"
|
|
121
143
|
: "";
|
|
122
144
|
const wildcard = [
|
|
@@ -141,7 +163,12 @@ export function createRobotsTxtRoute(options: RobotsTxtOptions): APIRoute {
|
|
|
141
163
|
const { siteConfig, ...flat } = options;
|
|
142
164
|
const config: RobotsSiteConfig = { ...flat, ...(siteConfig ?? {}) };
|
|
143
165
|
return ({ site }) =>
|
|
144
|
-
new Response(
|
|
145
|
-
|
|
146
|
-
|
|
166
|
+
new Response(
|
|
167
|
+
renderRobotsTxt(config, site, FOLDER, {
|
|
168
|
+
name: CANONICAL_SCOPE,
|
|
169
|
+
folder: FOLDER,
|
|
170
|
+
platformSite: PLATFORM_SITE,
|
|
171
|
+
}),
|
|
172
|
+
{ headers: { "Content-Type": "text/plain" } },
|
|
173
|
+
);
|
|
147
174
|
}
|