@iterant/site-runtime 3.12.0 → 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 +100 -11
- package/package.json +1 -1
- package/scripts/verify.mjs +25 -1
- package/src/components/seo.tsx +49 -18
- package/src/config/preset.ts +8 -1
- package/src/config/site-module.ts +17 -8
- package/src/config/virtual-modules.d.ts +6 -0
- package/src/index.ts +3 -2
- package/src/layouts/LayoutCore.astro +44 -20
- package/src/lib/canonical-scope.ts +108 -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 +57 -12
- package/src/routes/index.ts +6 -1
- package/src/routes/llms-txt.ts +98 -39
- package/src/routes/robots-txt.ts +23 -7
package/src/routes/llms-txt.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getCollection } from "astro:content";
|
|
2
|
-
import { FOLDER } from "virtual:iterant/site";
|
|
2
|
+
import { CANONICAL_SCOPE, FOLDER, PLATFORM_SITE } from "virtual:iterant/site";
|
|
3
3
|
import { isAdvertised } from "../lib/advertised";
|
|
4
|
+
import { inCanonicalScope, type CanonicalScope } from "../lib/canonical-scope";
|
|
4
5
|
import { folderPath } from "../lib/folder";
|
|
5
6
|
import type { APIRoute } from "astro";
|
|
6
7
|
|
|
@@ -23,53 +24,111 @@ export interface LlmsTxtOptions {
|
|
|
23
24
|
siteConfig: { name: string; description: string };
|
|
24
25
|
}
|
|
25
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
|
+
|
|
26
34
|
const normalizeText = (value: string) => value.trim().replace(/\s+/g, " ");
|
|
27
35
|
|
|
28
36
|
const escapeLinkText = (value: string) =>
|
|
29
37
|
normalizeText(value).replaceAll("[", "\\[").replaceAll("]", "\\]");
|
|
30
38
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
+
});
|
|
40
70
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
:
|
|
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
|
+
];
|
|
49
87
|
|
|
50
|
-
|
|
51
|
-
|
|
88
|
+
const section = (heading: string, links: string[]) =>
|
|
89
|
+
links.length > 0 ? [heading, "", ...links, ""] : [];
|
|
52
90
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
+
});
|
|
71
130
|
|
|
72
|
-
return new Response(
|
|
131
|
+
return new Response(body, {
|
|
73
132
|
headers: {
|
|
74
133
|
"content-type": "text/plain; charset=utf-8",
|
|
75
134
|
},
|
package/src/routes/robots-txt.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
-
import { FOLDER } from "virtual:iterant/site";
|
|
2
|
+
import { CANONICAL_SCOPE, FOLDER, PLATFORM_SITE } from "virtual:iterant/site";
|
|
3
3
|
|
|
4
|
+
import {
|
|
5
|
+
HOST_SCOPE,
|
|
6
|
+
inCanonicalScope,
|
|
7
|
+
type CanonicalScope,
|
|
8
|
+
} from "../lib/canonical-scope";
|
|
4
9
|
import { folderPath } from "../lib/folder";
|
|
5
10
|
import { normalizeSourceUrls } from "../lib/sitemap/shared";
|
|
6
11
|
|
|
@@ -116,18 +121,24 @@ export function renderRobotsTxt(
|
|
|
116
121
|
// folder is what the customer forwards from their domain's root, while a site
|
|
117
122
|
// URL that carries a path keeps resolving relative to it without one.
|
|
118
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,
|
|
119
129
|
): string {
|
|
120
130
|
const policy = resolveAiPolicy(config.aiPolicy);
|
|
121
131
|
const indexPath = folder
|
|
122
132
|
? folderPath(folder, "sitemap-index.xml")
|
|
123
133
|
: "sitemap-index.xml";
|
|
134
|
+
const mirrored =
|
|
135
|
+
normalizeSourceUrls(config.sourceSitemapUrl ?? "").length > 0 &&
|
|
136
|
+
inCanonicalScope("/sitemap.xml", scope);
|
|
124
137
|
const sitemap = site
|
|
125
138
|
? [
|
|
126
139
|
`Sitemap: ${new URL(indexPath, site).href}`,
|
|
127
140
|
// Mirrored sitemap, only when sourceSitemapUrl is configured.
|
|
128
|
-
...(
|
|
129
|
-
? [`Sitemap: ${new URL("sitemap.xml", site).href}`]
|
|
130
|
-
: []),
|
|
141
|
+
...(mirrored ? [`Sitemap: ${new URL("sitemap.xml", site).href}`] : []),
|
|
131
142
|
].join("\n") + "\n"
|
|
132
143
|
: "";
|
|
133
144
|
const wildcard = [
|
|
@@ -152,7 +163,12 @@ export function createRobotsTxtRoute(options: RobotsTxtOptions): APIRoute {
|
|
|
152
163
|
const { siteConfig, ...flat } = options;
|
|
153
164
|
const config: RobotsSiteConfig = { ...flat, ...(siteConfig ?? {}) };
|
|
154
165
|
return ({ site }) =>
|
|
155
|
-
new Response(
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
);
|
|
158
174
|
}
|