@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.
@@ -0,0 +1,108 @@
1
+ // Which paths the origin in `astro.config.mjs` actually serves.
2
+ //
3
+ // The production deploy bakes the CUSTOMER's origin into `site`, and in path
4
+ // routing the customer forwards one prefix and nothing else: `/<folder>/*`.
5
+ // Translations live under that same prefix (`/<folder>/<locale>/x`), so the one
6
+ // rule covers them too.
7
+ // Every other route of the build (the brand home at `/`, the holding page,
8
+ // anything outside the folder) lives on the platform's own host alone. A
9
+ // canonical, an og:url or a sitemap `<loc>` that names the customer's origin
10
+ // for one of those points at a URL their edge answers with their own site,
11
+ // which is how a link preview sends a reader to the wrong page.
12
+ //
13
+ // `host` is every build that came before this one: the origin serves the whole
14
+ // site and nothing here changes a byte of what it emits.
15
+
16
+ export type CanonicalScopeName = "host" | "folder";
17
+
18
+ export interface CanonicalScope {
19
+ name: CanonicalScopeName;
20
+ /** The one segment the customer forwards. Empty under `host`. */
21
+ folder: string;
22
+ /** The origin an out-of-scope path resolves against. Null under `host`. */
23
+ platformSite: string | null;
24
+ }
25
+
26
+ /** The default, and what every build declared before the scope existed. */
27
+ export const HOST_SCOPE: CanonicalScope = {
28
+ name: "host",
29
+ folder: "",
30
+ platformSite: null,
31
+ };
32
+
33
+ /** The build's environment, where the deploy states the scope. The index
34
+ * signature is what lets `process.env` be passed straight in. */
35
+ export interface CanonicalScopeEnv {
36
+ ITERANT_CANONICAL_SCOPE?: string;
37
+ ITERANT_PLATFORM_SITE?: string;
38
+ [key: string]: string | undefined;
39
+ }
40
+
41
+ /**
42
+ * The scope this build runs under, from the deploy's environment and the
43
+ * folder the preset was given.
44
+ *
45
+ * Folder scope with no folder, or with no platform origin to send the rest of
46
+ * the site to, is a deploy that would emit customer URLs for pages the customer
47
+ * never serves. There is no safe reading of it, so the build stops here rather
48
+ * than at the first crawler.
49
+ */
50
+ export function readCanonicalScope(
51
+ env: CanonicalScopeEnv,
52
+ folder: string,
53
+ ): CanonicalScope {
54
+ const name = env.ITERANT_CANONICAL_SCOPE?.trim() || "host";
55
+ if (name !== "host" && name !== "folder") {
56
+ throw new Error(
57
+ `ITERANT_CANONICAL_SCOPE must be "host" or "folder" (got ${JSON.stringify(name)})`,
58
+ );
59
+ }
60
+ if (name === "host") return HOST_SCOPE;
61
+ const platformSite = env.ITERANT_PLATFORM_SITE?.trim() ?? "";
62
+ if (!folder) {
63
+ throw new Error(
64
+ "ITERANT_CANONICAL_SCOPE=folder needs a folder: pass the brand's folder to iterantStarter({ folder })",
65
+ );
66
+ }
67
+ if (!platformSite) {
68
+ throw new Error(
69
+ "ITERANT_CANONICAL_SCOPE=folder needs ITERANT_PLATFORM_SITE, the origin that serves the routes the customer's domain does not",
70
+ );
71
+ }
72
+ if (!URL.canParse(platformSite)) {
73
+ throw new Error(
74
+ `ITERANT_PLATFORM_SITE must be an absolute origin (got ${JSON.stringify(platformSite)})`,
75
+ );
76
+ }
77
+ return { name, folder, platformSite: new URL(platformSite).origin };
78
+ }
79
+
80
+ /**
81
+ * Whether the build's `site` origin serves this path. Always true under `host`.
82
+ *
83
+ * The folder is matched SEGMENT-wise, never as a string prefix, so `/feedback`
84
+ * is out of scope for the folder `feed`. The locale needs no test of its own:
85
+ * a translation lives under the folder like every other page.
86
+ */
87
+ export function inCanonicalScope(
88
+ pathname: string,
89
+ scope: CanonicalScope,
90
+ ): boolean {
91
+ if (scope.name === "host") return true;
92
+ const segments = pathname.split("/").filter(Boolean);
93
+ return segments[0] === scope.folder;
94
+ }
95
+
96
+ /**
97
+ * The origin a path's absolute URL resolves against: the build's `site` when
98
+ * the path is in scope, the platform origin when it is not. Under `host` it is
99
+ * always `site`, so every emitter that calls this keeps emitting what it did.
100
+ */
101
+ export function originForPath<T extends URL | string | undefined>(
102
+ pathname: string,
103
+ scope: CanonicalScope,
104
+ site: T,
105
+ ): T | string {
106
+ if (!scope.platformSite || inCanonicalScope(pathname, scope)) return site;
107
+ return scope.platformSite;
108
+ }
@@ -0,0 +1,118 @@
1
+ import { isAdvertised } from "./advertised";
2
+ import { originForPath, type CanonicalScope } from "./canonical-scope";
3
+ import { DEFAULT_LOCALE, normalizeBcp47, parseEntryId } from "./locales";
4
+
5
+ // hreflang alternates for locale sibling pages (starter 2.9.0). A page with
6
+ // locale siblings advertises one `<link rel="alternate" hreflang>` per
7
+ // NON-DRAFT sibling in the group, plus exactly one `x-default` pointing at the
8
+ // unsuffixed base entry. A page with no siblings emits nothing. Ported from the
9
+ // legacy `derive_hreflang_set` (backend translation_group.py): published/
10
+ // indexable rows only (starter: non-draft), one x-default = the default-locale
11
+ // row, deduped on a normalized locale key, hreflang values in canonical BCP-47.
12
+ //
13
+ // The DERIVATION alone lives here, so it imports no virtual module and the
14
+ // package's pure entry can re-export it. The scope is an argument rather than a
15
+ // default: a caller that does not know which origin serves which path cannot
16
+ // spell these hrefs, and a default would let it emit customer URLs for pages
17
+ // the customer never serves. ./hreflang.ts is where that caller reads it.
18
+
19
+ /** One reciprocal hreflang alternate. `hreflang` is BCP-47 or "x-default". */
20
+ export interface HreflangAlternate {
21
+ hreflang: string;
22
+ href: string;
23
+ }
24
+
25
+ /** The minimal page-entry shape the derivation reads. */
26
+ export interface HreflangEntry {
27
+ /** Collection entry id (`example`, `example.es`). */
28
+ id: string;
29
+ /** The entry's `route` field (`/example`, `/es/example`). */
30
+ route: string;
31
+ draft: boolean;
32
+ /** Kept out of search; never advertised as an alternate. */
33
+ noindex?: boolean;
34
+ }
35
+
36
+ /** A `pages` collection entry, as `pageLocaleHead` consumes it. */
37
+ export interface PagesCollectionEntry {
38
+ id: string;
39
+ data: { route: string; draft: boolean; noindex?: boolean };
40
+ }
41
+
42
+ /**
43
+ * The `<html lang>` value for a page, from its entry id: base entries are the
44
+ * brand default (`en`); siblings carry their locale in canonical BCP-47
45
+ * casing (`example.pt-br` → `pt-BR`).
46
+ */
47
+ export function pageLang(entryId: string): string {
48
+ const { locale } = parseEntryId(entryId);
49
+ return locale ? normalizeBcp47(locale) : DEFAULT_LOCALE;
50
+ }
51
+
52
+ function absolute(
53
+ route: string,
54
+ site: URL | string | undefined,
55
+ scope: CanonicalScope,
56
+ ): string {
57
+ if (!site) return route; // dev without a configured `site`, relative href
58
+ return new URL(
59
+ route,
60
+ originForPath(route, scope, site).toString(),
61
+ ).toString();
62
+ }
63
+
64
+ /**
65
+ * The reciprocal hreflang set for the group `baseName` belongs to, derived from
66
+ * ALL page entries (the caller passes the whole collection). Every non-draft
67
+ * entry in the group contributes one alternate; the base entry also seeds the
68
+ * single `x-default`. Draft and noindex siblings are never advertised
69
+ * (unpublished translations and private pages must not leak to crawlers). Deduped on the normalized locale key
70
+ * (first entry wins); hrefs absolute via `site` (`Astro.site`), or via the
71
+ * platform origin for a route that site does not serve. Returns `[]`
72
+ * when the group has no non-draft siblings — a lone page emits no hreflang.
73
+ */
74
+ export function deriveHreflangAlternates(params: {
75
+ entries: HreflangEntry[];
76
+ baseName: string;
77
+ site: URL | string | undefined;
78
+ /** Which paths the site origin serves. */
79
+ scope: CanonicalScope;
80
+ }): HreflangAlternate[] {
81
+ const { entries, baseName, site, scope } = params;
82
+
83
+ const byKey = new Map<
84
+ string,
85
+ { tag: string; route: string; isBase: boolean }
86
+ >();
87
+ let base: { route: string } | undefined;
88
+ for (const entry of entries) {
89
+ // Never advertise a draft or a noindex sibling or base.
90
+ if (!isAdvertised(entry, { includeDrafts: false })) continue;
91
+ const { base: entryBase, locale } = parseEntryId(entry.id);
92
+ if (entryBase !== baseName) continue;
93
+ const isBase = !locale;
94
+ const tag = isBase ? DEFAULT_LOCALE : normalizeBcp47(locale);
95
+ const key = tag.toLowerCase();
96
+ if (isBase) base = { route: entry.route };
97
+ if (!byKey.has(key)) byKey.set(key, { tag, route: entry.route, isBase });
98
+ }
99
+
100
+ const hasSibling = [...byKey.values()].some((alt) => !alt.isBase);
101
+ if (!hasSibling) return [];
102
+
103
+ const ordered = [...byKey.values()].sort((a, b) => {
104
+ if (a.isBase !== b.isBase) return a.isBase ? -1 : 1; // base first
105
+ return a.tag.localeCompare(b.tag);
106
+ });
107
+ const alternates: HreflangAlternate[] = ordered.map((alt) => ({
108
+ hreflang: alt.tag,
109
+ href: absolute(alt.route, site, scope),
110
+ }));
111
+ if (base) {
112
+ alternates.push({
113
+ hreflang: "x-default",
114
+ href: absolute(base.route, site, scope),
115
+ });
116
+ }
117
+ return alternates;
118
+ }
@@ -1,46 +1,22 @@
1
- import { isAdvertised } from "./advertised";
2
- import { DEFAULT_LOCALE, normalizeBcp47, parseEntryId } from "./locales";
3
-
4
- // hreflang alternates for locale sibling pages (starter 2.9.0). A page with
5
- // locale siblings advertises one `<link rel="alternate" hreflang>` per
6
- // NON-DRAFT sibling in the group, plus exactly one `x-default` pointing at the
7
- // unsuffixed base entry. A page with no siblings emits nothing. Ported from the
8
- // legacy `derive_hreflang_set` (backend translation_group.py): published/
9
- // indexable rows only (starter: non-draft), one x-default = the default-locale
10
- // row, deduped on a normalized locale key, hreflang values in canonical BCP-47.
11
-
12
- /** One reciprocal hreflang alternate. `hreflang` is BCP-47 or "x-default". */
13
- export interface HreflangAlternate {
14
- hreflang: string;
15
- href: string;
16
- }
17
-
18
- /** The minimal page-entry shape the derivation reads. */
19
- export interface HreflangEntry {
20
- /** Collection entry id (`example`, `example.es`). */
21
- id: string;
22
- /** The entry's `route` field (`/example`, `/es/example`). */
23
- route: string;
24
- draft: boolean;
25
- /** Kept out of search; never advertised as an alternate. */
26
- noindex?: boolean;
27
- }
28
-
29
- /** A `pages` collection entry, as `pageLocaleHead` consumes it. */
30
- export interface PagesCollectionEntry {
31
- id: string;
32
- data: { route: string; draft: boolean; noindex?: boolean };
33
- }
34
-
35
- /**
36
- * The `<html lang>` value for a page, from its entry id: base entries are the
37
- * brand default (`en`); siblings carry their locale in canonical BCP-47
38
- * casing (`example.pt-br` → `pt-BR`).
39
- */
40
- export function pageLang(entryId: string): string {
41
- const { locale } = parseEntryId(entryId);
42
- return locale ? normalizeBcp47(locale) : DEFAULT_LOCALE;
43
- }
1
+ import { CANONICAL_SCOPE, FOLDER, PLATFORM_SITE } from "virtual:iterant/site";
2
+
3
+ import {
4
+ deriveHreflangAlternates,
5
+ pageLang,
6
+ type HreflangAlternate,
7
+ type PagesCollectionEntry,
8
+ } from "./hreflang-derive";
9
+ import { parseEntryId } from "./locales";
10
+
11
+ // The locale head a page renders, as a repo calls it
12
+ // (`@iterant/site-runtime/hreflang`). It is the derivation in
13
+ // ./hreflang-derive.ts plus the one fact a repo cannot supply: which paths the
14
+ // baked `site` origin actually serves. That comes from the preset, through the
15
+ // virtual module the layout and the platform routes already read, so a page
16
+ // under a customer's forwarded folder advertises its siblings on their domain
17
+ // and a page outside it advertises them on ours. No repo has to know, and none
18
+ // can get it wrong.
19
+ export * from "./hreflang-derive";
44
20
 
45
21
  /**
46
22
  * The locale head pair every page emits (starter 2.10.0): `<html lang>` plus
@@ -65,64 +41,11 @@ export function pageLocaleHead(params: {
65
41
  })),
66
42
  baseName: parseEntryId(params.entryId).base,
67
43
  site: params.site,
44
+ scope: {
45
+ name: CANONICAL_SCOPE,
46
+ folder: FOLDER,
47
+ platformSite: PLATFORM_SITE,
48
+ },
68
49
  }),
69
50
  };
70
51
  }
71
-
72
- function absolute(route: string, site: URL | string | undefined): string {
73
- if (!site) return route; // dev without a configured `site` — relative href
74
- return new URL(route, site.toString()).toString();
75
- }
76
-
77
- /**
78
- * The reciprocal hreflang set for the group `baseName` belongs to, derived from
79
- * ALL page entries (the caller passes the whole collection). Every non-draft
80
- * entry in the group contributes one alternate; the base entry also seeds the
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
83
- * (first entry wins); hrefs absolute via `site` (`Astro.site`). Returns `[]`
84
- * when the group has no non-draft siblings — a lone page emits no hreflang.
85
- */
86
- export function deriveHreflangAlternates(params: {
87
- entries: HreflangEntry[];
88
- baseName: string;
89
- site: URL | string | undefined;
90
- }): HreflangAlternate[] {
91
- const { entries, baseName, site } = params;
92
-
93
- const byKey = new Map<
94
- string,
95
- { tag: string; route: string; isBase: boolean }
96
- >();
97
- let base: { route: string } | undefined;
98
- for (const entry of entries) {
99
- // Never advertise a draft or a noindex sibling or base.
100
- if (!isAdvertised(entry, { includeDrafts: false })) continue;
101
- const { base: entryBase, locale } = parseEntryId(entry.id);
102
- if (entryBase !== baseName) continue;
103
- const isBase = !locale;
104
- const tag = isBase ? DEFAULT_LOCALE : normalizeBcp47(locale);
105
- const key = tag.toLowerCase();
106
- if (isBase) base = { route: entry.route };
107
- if (!byKey.has(key)) byKey.set(key, { tag, route: entry.route, isBase });
108
- }
109
-
110
- const hasSibling = [...byKey.values()].some((alt) => !alt.isBase);
111
- if (!hasSibling) return [];
112
-
113
- const ordered = [...byKey.values()].sort((a, b) => {
114
- if (a.isBase !== b.isBase) return a.isBase ? -1 : 1; // base first
115
- return a.tag.localeCompare(b.tag);
116
- });
117
- const alternates: HreflangAlternate[] = ordered.map((alt) => ({
118
- hreflang: alt.tag,
119
- href: absolute(alt.route, site),
120
- }));
121
- if (base) {
122
- alternates.push({
123
- hreflang: "x-default",
124
- href: absolute(base.route, site),
125
- });
126
- }
127
- return alternates;
128
- }
@@ -2,8 +2,9 @@ import { z } from "astro/zod";
2
2
 
3
3
  // Locale sibling entries (starter 2.8.0): a translated page is a first-class
4
4
  // content entry next to its base — `src/content/pages/<page>.<locale>.json`
5
- // with the lowercased locale as both the filename suffix and the root-level
6
- // route prefix (`/es/pricing`); the unsuffixed base stays the x-default.
5
+ // with the lowercased locale as both the filename suffix and the route segment
6
+ // the translation lives under (`/es/pricing`, or `/feed/es/pricing` under a
7
+ // brand folder); the unsuffixed base stays the x-default.
7
8
  // Site chrome follows the same model (`src/content/chrome.<locale>.json`),
8
9
  // resolved per request path with fallback to the base chrome. `<html lang>`
9
10
  // and hreflang alternates are derived from the entry locale (see
@@ -62,14 +63,20 @@ export function normalizeBcp47(locale: string): string {
62
63
  }
63
64
 
64
65
  /**
65
- * The locale segment a request path is prefixed with, if any: `/es/pricing`
66
- * → `es`, `/pt-br` → `pt-br`, `/pricing` → undefined. Grammar-only the
67
- * caller decides whether a matching sibling (chrome.<locale>.json) actually
68
- * exists and falls back to the base otherwise, so a page that merely LOOKS
69
- * locale-shaped costs nothing.
66
+ * The locale segment a request path carries, if any: `/es/pricing` → `es`,
67
+ * `/pt-br` → `pt-br`, `/pricing` → undefined. Under a brand folder the locale
68
+ * sits inside it, so `localeFromPath("/feed/es/pricing", "feed")` is `es`:
69
+ * that is what lets the customer forward `/feed/*` alone and still reach every
70
+ * translation. Grammar-only the caller decides whether a matching sibling
71
+ * (chrome.<locale>.json) actually exists and falls back to the base otherwise,
72
+ * so a page that merely LOOKS locale-shaped costs nothing.
70
73
  */
71
- export function localeFromPath(pathname: string): string | undefined {
72
- const [first] = pathname.split("/").filter(Boolean);
74
+ export function localeFromPath(
75
+ pathname: string,
76
+ folder?: string,
77
+ ): string | undefined {
78
+ const segments = pathname.split("/").filter(Boolean);
79
+ const first = segments[folder && segments[0] === folder ? 1 : 0];
73
80
  return first && LOCALE_SEGMENT_RE.test(first) ? first : undefined;
74
81
  }
75
82
 
@@ -5,6 +5,7 @@
5
5
  export { DEFAULT_PAGES_DIR } from "../content-paths";
6
6
  export { getSitemapPaths, type SitemapPathsOptions } from "./get-sitemap-paths";
7
7
  export {
8
+ createSitemapFilter,
8
9
  sitemapWithCustomPages,
9
10
  type SitemapWithCustomPagesOptions,
10
11
  } from "./sitemap-with-custom-pages-plugin";
@@ -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,29 +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
 
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
+ };
72
+ }
73
+
32
74
  export type SitemapWithCustomPagesOptions = SitemapOptions &
33
- SitemapPathsOptions;
75
+ SitemapPathsOptions & {
76
+ /** Which paths the site origin serves. Defaults to the whole host. */
77
+ scope?: CanonicalScope;
78
+ };
34
79
 
35
80
  export function sitemapWithCustomPages(
36
81
  options: SitemapWithCustomPagesOptions = {},
37
82
  ): AstroIntegration[] {
38
83
  let resolvedSite = "";
39
- const { pagesDir, root, ...sitemapOptions } = options;
84
+ const { pagesDir, root, scope = HOST_SCOPE, ...sitemapOptions } = options;
40
85
  const userSerialize = sitemapOptions.serialize;
41
86
  const userFilter = sitemapOptions.filter;
42
87
 
@@ -47,8 +92,10 @@ export function sitemapWithCustomPages(
47
92
  // populated before anything reads it.
48
93
  const customPages: string[] = [...(sitemapOptions.customPages ?? [])];
49
94
  // Filled beside customPages: the entry routes the integration must not list
50
- // 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.
51
97
  const denied = new Set<string>();
98
+ const allowed = createSitemapFilter(denied, scope);
52
99
 
53
100
  return [
54
101
  {
@@ -96,9 +143,7 @@ export function sitemapWithCustomPages(
96
143
  sitemap({
97
144
  ...sitemapOptions,
98
145
  customPages,
99
- filter: (page) =>
100
- sitemapPageAllowed(page, denied) &&
101
- (userFilter ? userFilter(page) : true),
146
+ filter: (page) => (userFilter ? userFilter(page) : true) && allowed(page),
102
147
  serialize(item) {
103
148
  if (resolvedSite && item.url.startsWith(PLACEHOLDER)) {
104
149
  item.url = resolvedSite + item.url.slice(PLACEHOLDER.length);
@@ -7,7 +7,12 @@
7
7
  // (site-runtime 3.12.0): the folder is per-brand data, so no file name the
8
8
  // template ships can carry it, and the preset injects the same handler at
9
9
  // /<folder>/llms.txt (../config/site-module.ts).
10
- export { createLlmsTxtRoute, type LlmsTxtOptions } from "./llms-txt";
10
+ export {
11
+ createLlmsTxtRoute,
12
+ renderLlmsTxt,
13
+ type LlmsTxtOptions,
14
+ type LlmsTxtPage,
15
+ } from "./llms-txt";
11
16
  export {
12
17
  AI_ANSWER_CRAWLERS,
13
18
  AI_POLICY_PRESETS,