@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.
@@ -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: neither a platform route nor a denied
18
- * entry route (a draft or a noindex page), whichever way the integration
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
- const pathname = new URL(url).pathname.replace(/\/+$/, "") || "/";
26
- return !PLATFORM_DENY.has(pathname) && !deny.has(pathname);
45
+ return new URL(url).pathname.replace(/\/+$/, "") || "/";
27
46
  } catch {
28
- return true;
47
+ return undefined;
29
48
  }
30
49
  }
31
50
 
32
- /** The serialize step's URL work, extracted so it can be tested: resolve the
33
- * placeholder origin against the real site, then drop a URL this build has
34
- * already emitted. The integration de-duplicates by exact string before
35
- * serialize runs, so a route that is both a page entry and a prerendered
36
- * shell arrives twice, under the crawl's real origin and under ours, and
37
- * becomes one URL only here. Returning nothing drops the item. */
38
- export function resolveSitemapUrl(
39
- url: string,
40
- site: string,
41
- emitted: Set<string>,
42
- ): string | undefined {
43
- const resolved =
44
- site && url.startsWith(PLACEHOLDER)
45
- ? site + url.slice(PLACEHOLDER.length)
46
- : url;
47
- if (emitted.has(resolved)) return undefined;
48
- emitted.add(resolved);
49
- return resolved;
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
- // Emptied per build in astro:config:done.
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
- const url = resolveSitemapUrl(item.url, resolvedSite, emitted);
127
- if (!url) return undefined;
128
- item.url = url;
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
  }),
@@ -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/ existence and addressing are repo-owned, behavior rides a
3
- // package bump. No route is injected: a route that exists in no repo file breaks
4
- // the src/pages/ mental model and the debuggability contract.
5
- export { createLlmsTxtRoute, type LlmsTxtOptions } from "./llms-txt";
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 });
@@ -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
- export function createLlmsTxtRoute({ siteConfig }: LlmsTxtOptions): APIRoute {
26
- return async ({ site }) => {
27
- const origin = site?.origin ?? "https://example.com";
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
- ),
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
- const contentLinks = pages
36
- .toSorted((a, b) => a.data.meta.title.localeCompare(b.data.meta.title))
37
- .map((entry) => {
38
- const title = escapeLinkText(entry.data.meta.title);
39
- const url = new URL(entry.data.route, origin).href;
40
- const description = entry.data.meta.description
41
- ? `: ${normalizeText(entry.data.meta.description)}`
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
- return `- [${title}](${url})${description}`;
45
- });
88
+ const section = (heading: string, links: string[]) =>
89
+ links.length > 0 ? [heading, "", ...links, ""] : [];
46
90
 
47
- const lines = [
48
- ...(siteConfig.name ? [`# ${siteConfig.name}`, ""] : []),
49
- ...(siteConfig.description ? [`> ${siteConfig.description}`, ""] : []),
50
- "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`.",
51
- "",
52
- "## Core Pages",
53
- "",
54
- `- [Home](${new URL("/", origin).href}): Primary overview of the site.`,
55
- "",
56
- ...(contentLinks.length > 0
57
- ? ["## Public Content", "", ...contentLinks, ""]
58
- : []),
59
- "## Discovery",
60
- "",
61
- `- [Sitemap](${new URL("/sitemap-index.xml", origin).href}): Complete search-engine sitemap, if available.`,
62
- `- [Robots policy](${new URL("/robots.txt", origin).href}): Crawler permissions for automated agents.`,
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(lines.join("\n"), {
131
+ return new Response(body, {
67
132
  headers: {
68
133
  "content-type": "text/plain; charset=utf-8",
69
134
  },
@@ -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("sitemap-index.xml", site).href}`,
139
+ `Sitemap: ${new URL(indexPath, site).href}`,
116
140
  // Mirrored sitemap, only when sourceSitemapUrl is configured.
117
- ...(normalizeSourceUrls(config.sourceSitemapUrl ?? "").length > 0
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(renderRobotsTxt(config, site), {
145
- headers: { "Content-Type": "text/plain" },
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
  }