@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.
@@ -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
- export function createLlmsTxtRoute({ siteConfig }: LlmsTxtOptions): APIRoute {
32
- return async ({ site }) => {
33
- const origin = site?.origin ?? "https://example.com";
34
- const pages = (await getCollection("pages")).filter((entry) =>
35
- isAdvertised(
36
- { draft: entry.data.draft, noindex: entry.data.noindex },
37
- { includeDrafts: !import.meta.env.PROD },
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
- const contentLinks = pages
42
- .toSorted((a, b) => a.data.meta.title.localeCompare(b.data.meta.title))
43
- .map((entry) => {
44
- const title = escapeLinkText(entry.data.meta.title);
45
- const url = new URL(entry.data.route, origin).href;
46
- const description = entry.data.meta.description
47
- ? `: ${normalizeText(entry.data.meta.description)}`
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
- return `- [${title}](${url})${description}`;
51
- });
88
+ const section = (heading: string, links: string[]) =>
89
+ links.length > 0 ? [heading, "", ...links, ""] : [];
52
90
 
53
- const lines = [
54
- ...(siteConfig.name ? [`# ${siteConfig.name}`, ""] : []),
55
- ...(siteConfig.description ? [`> ${siteConfig.description}`, ""] : []),
56
- "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`.",
57
- "",
58
- "## Core Pages",
59
- "",
60
- `- [Home](${new URL("/", origin).href}): Primary overview of the site.`,
61
- "",
62
- ...(contentLinks.length > 0
63
- ? ["## Public Content", "", ...contentLinks, ""]
64
- : []),
65
- "## Discovery",
66
- "",
67
- `- [Sitemap](${new URL(folderPath(FOLDER, "sitemap-index.xml"), origin).href}): Complete search-engine sitemap, if available.`,
68
- `- [Robots policy](${new URL("/robots.txt", origin).href}): Crawler permissions for automated agents.`,
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(lines.join("\n"), {
131
+ return new Response(body, {
73
132
  headers: {
74
133
  "content-type": "text/plain; charset=utf-8",
75
134
  },
@@ -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
- ...(normalizeSourceUrls(config.sourceSitemapUrl ?? "").length > 0
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(renderRobotsTxt(config, site, FOLDER), {
156
- headers: { "Content-Type": "text/plain" },
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
  }