@iterant/site-runtime 3.10.0 → 3.11.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.
@@ -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.10.0._
53
+ _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.11.0._
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.10.0",
3
+ "version": "3.11.0",
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`;
@@ -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 must be an absolute URL (crawlers 403 on
163
- // signed S3 URLs publish CDN URLs only). assetId links back to the
164
- // brand asset; alt is the accessible/og:image:alt text.
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.string().url(),
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 = false,
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={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
+ }
@@ -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 (unpublished
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
- if (entry.draft) continue; // never advertise a draft sibling/base
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. Drafts (siblings inherit the base's `draft`) are skipped,
10
- // so an unpublished translation never appears.
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,8 +32,8 @@ function resolvePagesDir({ pagesDir, root }: SitemapPathsOptions): string {
31
32
  }
32
33
 
33
34
  /**
34
- * Every non-draft page entry's route, home excluded (@astrojs/sitemap already
35
- * emits the site root). Returns `{ paths, pagesDir }` so a caller can report
35
+ * Every advertised page entry's route (neither draft nor noindex), home
36
+ * excluded (@astrojs/sitemap already emits the site root). Returns `{ paths, pagesDir }` so a caller can report
36
37
  * WHERE it looked when the answer is empty.
37
38
  */
38
39
  export function getSitemapPaths(options: SitemapPathsOptions = {}): {
@@ -54,7 +55,8 @@ export function getSitemapPaths(options: SitemapPathsOptions = {}): {
54
55
  .map((rel) => {
55
56
  try {
56
57
  const data = JSON.parse(readFileSync(join(pagesDir, rel), "utf8"));
57
- if (data.draft || typeof data.route !== "string") return null;
58
+ if (typeof data.route !== "string") return null;
59
+ if (!isAdvertised(data, { includeDrafts: false })) return null;
58
60
  return data.route as string;
59
61
  } catch {
60
62
  return null; // malformed entry; the build will surface the schema error
@@ -8,6 +8,22 @@ 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: everything but the platform's own routes. */
18
+ export function sitemapPageAllowed(url: string): boolean {
19
+ try {
20
+ const pathname = new URL(url).pathname.replace(/\/+$/, "") || "/";
21
+ return !PLATFORM_DENY.has(pathname);
22
+ } catch {
23
+ return true;
24
+ }
25
+ }
26
+
11
27
  export type SitemapWithCustomPagesOptions = SitemapOptions &
12
28
  SitemapPathsOptions;
13
29
 
@@ -17,6 +33,7 @@ export function sitemapWithCustomPages(
17
33
  let resolvedSite = "";
18
34
  const { pagesDir, root, ...sitemapOptions } = options;
19
35
  const userSerialize = sitemapOptions.serialize;
36
+ const userFilter = sitemapOptions.filter;
20
37
 
21
38
  // Filled in at astro:config:setup and read by @astrojs/sitemap at
22
39
  // astro:build:done, which is what lets the entry routes be discovered against
@@ -67,6 +84,8 @@ export function sitemapWithCustomPages(
67
84
  sitemap({
68
85
  ...sitemapOptions,
69
86
  customPages,
87
+ filter: (page) =>
88
+ sitemapPageAllowed(page) && (userFilter ? userFilter(page) : true),
70
89
  serialize(item) {
71
90
  if (resolvedSite && item.url.startsWith(PLACEHOLDER)) {
72
91
  item.url = resolvedSite + item.url.slice(PLACEHOLDER.length);
@@ -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 { createRobotsTxtRoute, type RobotsTxtOptions } from "./robots-txt";
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,
@@ -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
- (entry) => !entry.data.draft || !import.meta.env.PROD,
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
@@ -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 plus the sitemap pointers. The repo keeps the
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
- // sourceSitemapUrl: SITE_CONFIG.sourceSitemapUrl,
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
- export interface RobotsTxtOptions {
16
- /** The brand's `SITE_CONFIG.sourceSitemapUrl`; a mirrored sitemap is only
17
- * advertised when one is configured. */
18
- sourceSitemapUrl: string | string[];
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 function createRobotsTxtRoute({
22
- sourceSitemapUrl,
23
- }: RobotsTxtOptions): APIRoute {
24
- return ({ site }) => {
25
- const sitemap = site
26
- ? [
27
- `Sitemap: ${new URL("sitemap-index.xml", site).href}`,
28
- // Mirrored sitemap, only when sourceSitemapUrl is configured.
29
- ...(normalizeSourceUrls(sourceSitemapUrl).length > 0
30
- ? [`Sitemap: ${new URL("sitemap.xml", site).href}`]
31
- : []),
32
- ].join("\n") + "\n"
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
- const body = `User-agent: Googlebot
36
- Allow: /
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
- User-agent: Bingbot
39
- Allow: /
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
- User-agent: Twitterbot
42
- Allow: /
94
+ const grant = (allowed: boolean) => (allowed ? "Allow: /" : "Disallow: /");
95
+ const yesNo = (value: boolean) => (value ? "yes" : "no");
43
96
 
44
- User-agent: facebookexternalhit
45
- Allow: /
97
+ function agentBlocks(agents: readonly string[], allowed: boolean): string[] {
98
+ return agents.map((agent) => `User-agent: ${agent}\n${grant(allowed)}`);
99
+ }
46
100
 
47
- User-agent: *
48
- Allow: /
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
- ${sitemap}`;
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
- return new Response(body, {
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
  }