nuxt-ai-ready 3.2.0 → 3.3.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,23 +1,18 @@
1
1
  import { decodePath } from "ufo";
2
- import { getRequestHost } from "#nuxtseo/h3";
2
+ import { createError, getRequestHost, getRequestURL } from "#nuxtseo/h3";
3
3
  import { useRuntimeConfig } from "#nuxtseo/nitro";
4
4
  import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
5
5
  import { withSiteTrailingSlash, withSiteUrl } from "#site-config/server/composables/utils";
6
- import { formatLlmsTxtPageLink, normalizeLlmsTxtConfig } from "./llms-txt-format.js";
7
- import { absoluteSiteUrl } from "./markdown-links.js";
6
+ import { createLlmsTxtFiles } from "./llms-txt-files.js";
8
7
  import { hasMarkdownTwin, toMarkdownPath } from "./markdown-path.js";
9
8
  import { normalizePersistedRoute, toDeployedRoute, toLogicalRoute } from "./route-path.js";
10
- import { queryPages } from "./server/db/queries.js";
9
+ import { queryPageMetadata, queryPages } from "./server/db/queries.js";
11
10
  import { logger } from "./server/logger.js";
12
- import { fetchSitemapUrls } from "./server/utils/sitemap.js";
13
- export { normalizeLlmsTxtConfig };
11
+ import { fetchSitemapUrlGroups, getSitemapsFromConfig } from "./server/utils/sitemap.js";
12
+ export { normalizeLlmsTxtConfig } from "./llms-txt-format.js";
14
13
  async function findMarkdownLinkAvailability() {
15
- if (!import.meta.prerender) {
16
- return {
17
- runtimeMarkdownAvailable: true,
18
- paths: /* @__PURE__ */ new Set()
19
- };
20
- }
14
+ if (!import.meta.prerender)
15
+ return { runtimeMarkdownAvailable: true, paths: /* @__PURE__ */ new Set() };
21
16
  const { readMarkdownLinkAvailabilityFromFilesystem } = await import("#ai-ready-virtual/read-page-data.mjs");
22
17
  const availability = await readMarkdownLinkAvailabilityFromFilesystem();
23
18
  return {
@@ -25,231 +20,97 @@ async function findMarkdownLinkAvailability() {
25
20
  paths: new Set(availability.paths.map(decodePath))
26
21
  };
27
22
  }
28
- function getGroupPrefix(url, depth) {
29
- const segments = url.split("/").filter(Boolean);
30
- if (segments.length === 0)
31
- return "/";
32
- if (depth === 1 || segments.length === 1)
33
- return `/${segments[0]}`;
34
- return `/${segments[0]}/${segments[1]}`;
35
- }
36
- function getPathSegments(pathname) {
37
- return pathname.split("/").filter((s) => Boolean(s));
38
- }
39
- function analyzePageGroups(pages) {
40
- const twoSegmentCount = /* @__PURE__ */ new Map();
41
- const segmentHasNested = /* @__PURE__ */ new Map();
42
- for (const page of pages) {
43
- const prefix = getGroupPrefix(page.pathname, 2);
44
- twoSegmentCount.set(prefix, (twoSegmentCount.get(prefix) || 0) + 1);
45
- const segments = getPathSegments(page.pathname);
46
- const firstSegment = segments[0] || "";
47
- if (!segmentHasNested.has(firstSegment))
48
- segmentHasNested.set(firstSegment, false);
49
- if (segments.length > 1)
50
- segmentHasNested.set(firstSegment, true);
51
- }
52
- return { twoSegmentCount, segmentHasNested };
53
- }
54
- function getPageGroupKey(pathname, { twoSegmentCount, segmentHasNested }) {
55
- const segments = getPathSegments(pathname);
56
- const firstSegment = segments[0] || "";
57
- const twoSegPrefix = getGroupPrefix(pathname, 2);
58
- const twoSegCount = twoSegmentCount.get(twoSegPrefix) || 0;
59
- let groupKey = twoSegCount > 1 ? twoSegPrefix : `/${firstSegment}`;
60
- if (segments.length <= 1 && !segmentHasNested.get(firstSegment))
61
- groupKey = "";
62
- return groupKey;
63
- }
64
- function sortPagesByPath(pages) {
65
- const analysis = analyzePageGroups(pages);
66
- return pages.sort((a, b) => {
67
- const segmentsA = getPathSegments(a.pathname);
68
- const segmentsB = getPathSegments(b.pathname);
69
- const groupKeyA = getPageGroupKey(a.pathname, analysis);
70
- const groupKeyB = getPageGroupKey(b.pathname, analysis);
71
- if (groupKeyA === "" && groupKeyB !== "")
72
- return -1;
73
- if (groupKeyA !== "" && groupKeyB === "")
74
- return 1;
75
- if (groupKeyA !== groupKeyB)
76
- return groupKeyA.localeCompare(groupKeyB);
77
- if (segmentsA.length === 0)
78
- return -1;
79
- if (segmentsB.length === 0)
80
- return 1;
81
- const minLen = Math.min(segmentsA.length, segmentsB.length);
82
- for (let i = 0; i < minLen; i++) {
83
- const cmp = segmentsA[i].localeCompare(segmentsB[i]);
84
- if (cmp !== 0)
85
- return cmp;
86
- }
87
- return segmentsA.length - segmentsB.length;
88
- });
89
- }
90
- function formatPagesWithGroups(pages) {
91
- if (pages.length === 0)
92
- return [];
93
- const analysis = analyzePageGroups(pages);
94
- const lines = [];
95
- let currentGroup = "";
96
- let segmentGroupIndex = 0;
97
- let urlsInCurrentGroup = 0;
98
- for (const page of pages) {
99
- const groupKey = getPageGroupKey(page.pathname, analysis);
100
- if (groupKey !== currentGroup) {
101
- if (urlsInCurrentGroup > 0) {
102
- const shouldAddBlankLine = segmentGroupIndex === 0 || segmentGroupIndex >= 1 && segmentGroupIndex <= 2 && urlsInCurrentGroup > 1;
103
- if (shouldAddBlankLine)
104
- lines.push("");
105
- }
106
- currentGroup = groupKey;
107
- segmentGroupIndex++;
108
- urlsInCurrentGroup = 0;
109
- }
110
- urlsInCurrentGroup++;
111
- lines.push(formatLlmsTxtPageLink(page));
112
- }
113
- return lines;
114
- }
115
- export async function buildLlmsTxt(event) {
23
+ export async function buildLlmsTxtFiles(event) {
116
24
  const runtimeConfig = useRuntimeConfig(event);
117
25
  const aiReadyConfig = runtimeConfig["nuxt-ai-ready"];
118
- const sitemapConfig = runtimeConfig.sitemap;
119
26
  const siteConfig = getSiteConfig(event);
120
- const llmsTxtConfig = aiReadyConfig.llmsTxt;
121
- const i18n = aiReadyConfig.i18n;
122
- const i18nRuntime = i18n ? { _tag: "enabled", config: i18n, ...await import("./llms-txt-i18n.js") } : { _tag: "disabled" };
123
- const i18nContext = { host: getRequestHost(event, { xForwardedHost: true }) };
27
+ const config = aiReadyConfig.llmsTxt;
124
28
  const baseURL = runtimeConfig.app.baseURL;
125
- const resolvePath = (path) => withSiteTrailingSlash(event, toDeployedRoute(path, baseURL));
126
29
  const resolveUrl = (path) => withSiteUrl(event, toDeployedRoute(path, baseURL), { withBase: true });
30
+ const resolvePageUrl = (path) => resolveUrl(withSiteTrailingSlash(event, path));
127
31
  const canonicalSiteUrl = siteConfig.url ? resolveUrl("/") : void 0;
128
- const parts = [];
129
- parts.push(`# ${siteConfig.name || canonicalSiteUrl}`);
130
- if (siteConfig.description) {
131
- parts.push(`
132
- > ${siteConfig.description}`);
133
- }
134
- if (canonicalSiteUrl) {
135
- parts.push(`
136
- Canonical Origin: ${canonicalSiteUrl}`);
137
- }
138
- parts.push("");
139
- const sections = llmsTxtConfig.sections?.map((section) => ({
140
- ...section,
141
- links: section.links ? [...section.links] : void 0
142
- })) ?? [];
143
- if (sections[0]?.links) {
144
- const sitemapRoutes = sitemapConfig?.sitemaps ? Object.values(sitemapConfig.sitemaps).map((s) => s.sitemapName) : ["sitemap.xml"];
145
- for (const name of sitemapRoutes) {
146
- sections[0].links.push({
147
- title: name,
148
- href: resolveUrl(`/${name}`),
149
- description: "XML sitemap for search engines and crawlers."
150
- });
32
+ const i18n = aiReadyConfig.i18n;
33
+ const i18nRuntime = i18n ? { _tag: "enabled", config: i18n, ...await import("./llms-txt-i18n.js") } : { _tag: "disabled" };
34
+ const i18nContext = { host: getRequestHost(event, { xForwardedHost: true }) };
35
+ const parts = [`# ${(siteConfig.name || canonicalSiteUrl || "Site").replace(/\s+/g, " ").trim()}`];
36
+ if (siteConfig.description)
37
+ parts.push("", `> ${siteConfig.description.replace(/\s+/g, " ").trim()}`);
38
+ if (canonicalSiteUrl)
39
+ parts.push("", `Canonical Origin: ${canonicalSiteUrl}`);
40
+ const sections = config.sections?.map((section) => ({ ...section, links: section.links ? [...section.links] : void 0 })) ?? [];
41
+ const resources = sections.find((section) => section.title === "LLM Resources" && !section.sitemap);
42
+ if (resources) {
43
+ resources.links ||= [];
44
+ for (const sitemap of getSitemapsFromConfig(event).filter((sitemap2) => config.sitemaps === void 0 || config.sitemaps.includes(sitemap2.name))) {
45
+ resources.links.push({ title: sitemap.route.split("/").pop() || `${sitemap.name}.xml`, href: resolveUrl(toLogicalRoute(sitemap.route, baseURL)), description: "XML sitemap for search engines and crawlers." });
151
46
  }
152
- sections[0].links.push({
153
- title: "robots.txt",
154
- href: resolveUrl("/robots.txt"),
155
- description: "Crawler rules and permissions."
156
- });
47
+ if (!getSitemapsFromConfig(event).length && (config.sitemaps === void 0 || config.sitemaps.includes("sitemap")))
48
+ resources.links.push({ title: "sitemap.xml", href: resolveUrl("/sitemap.xml"), description: "XML sitemap for search engines and crawlers." });
49
+ resources.links.push({ title: "robots.txt", href: resolveUrl("/robots.txt"), description: "Crawler rules and permissions." });
157
50
  }
158
- const normalizedContent = normalizeLlmsTxtConfig({ ...llmsTxtConfig, sections });
159
- if (normalizedContent) {
160
- parts.push(normalizedContent);
161
- parts.push("");
51
+ if (config.pages === false && !sections.some((section) => section.sitemap))
52
+ return Object.fromEntries(createLlmsTxtFiles({ header: parts.join("\n"), config, sections, pages: [], sitemapPages: /* @__PURE__ */ new Map(), resolveUrl }));
53
+ const groups = await fetchSitemapUrlGroups(event, config.sitemaps);
54
+ const allowedOrigin = canonicalSiteUrl ? new URL(canonicalSiteUrl).origin : void 0;
55
+ const requestOrigin = getRequestURL(event, { xForwardedHost: true, xForwardedProto: true }).origin;
56
+ const sitemapPaths = /* @__PURE__ */ new Map();
57
+ for (const [name, urls] of groups) {
58
+ const paths = /* @__PURE__ */ new Set();
59
+ for (const entry of urls) {
60
+ const url = URL.canParse(entry.loc, canonicalSiteUrl || "http://localhost") ? new URL(entry.loc, canonicalSiteUrl || "http://localhost") : void 0;
61
+ if (!url || url.search || url.hash || allowedOrigin && url.origin !== allowedOrigin && url.origin !== requestOrigin)
62
+ continue;
63
+ const path = toLogicalRoute(url.pathname, baseURL);
64
+ paths.add(path);
65
+ }
66
+ sitemapPaths.set(name, paths);
162
67
  }
163
- const urlsPromise = fetchSitemapUrls(event);
164
- let pages = [];
165
- let errorPages = [];
166
- try {
167
- ;
168
- [pages, errorPages] = await Promise.all([
169
- queryPages(event),
170
- queryPages(event, { where: { hasError: true } })
171
- ]);
172
- } catch (err) {
173
- logger.warn(
174
- `[ai-ready] Database unavailable for llms.txt, falling back to sitemap-only pages: ${err instanceof Error ? err.message : String(err)}`
175
- );
68
+ const admitted = new Set([...sitemapPaths.values()].flatMap((paths) => [...paths]));
69
+ let stored = [];
70
+ if (aiReadyConfig.database?._tag !== "Disabled") {
71
+ const read = config.pages === "all" ? Promise.all([queryPages(event), queryPages(event, { where: { hasError: true } })]).then((results) => results.flat()) : queryPageMetadata(event, [...admitted].flatMap((path) => [path, toDeployedRoute(path, baseURL)]));
72
+ stored = await read.catch((error) => {
73
+ logger.warn(`[ai-ready] Database unavailable for llms.txt. Using sitemap metadata: ${error instanceof Error ? error.message : String(error)}`);
74
+ return [];
75
+ });
176
76
  }
177
- const urls = await urlsPromise;
178
- const sitemapPaths = urls.map((url) => toLogicalRoute(url.loc, baseURL));
179
- const sitemapPathSet = new Set(sitemapPaths);
180
- const seenPaths = /* @__PURE__ */ new Set();
181
- const errorSet = new Set(errorPages.map((p) => normalizePersistedRoute(p.route, sitemapPathSet, baseURL)));
182
- const prerendered = [];
183
- for (const page of pages) {
184
- const pathname = normalizePersistedRoute(page.route, sitemapPathSet, baseURL);
185
- if (seenPaths.has(pathname))
77
+ const errorPaths = new Set(stored.filter((page) => page.isError).map((page) => normalizePersistedRoute(page.route, admitted, baseURL)));
78
+ const metadata = new Map(stored.map((page) => [normalizePersistedRoute(page.route, admitted, baseURL), page]));
79
+ const availability = config.markdownLinks ? await findMarkdownLinkAvailability() : void 0;
80
+ const href = (path) => {
81
+ const markdownPath = toMarkdownPath(path);
82
+ if (availability && (availability.paths.has(decodePath(toDeployedRoute(markdownPath, baseURL))) || availability.runtimeMarkdownAvailable && hasMarkdownTwin(path)))
83
+ return resolveUrl(markdownPath);
84
+ return resolvePageUrl(path);
85
+ };
86
+ const pageMap = /* @__PURE__ */ new Map();
87
+ const candidates = config.pages === "all" ? /* @__PURE__ */ new Set([...admitted, ...metadata.keys()]) : admitted;
88
+ for (const pathname of [...candidates].sort()) {
89
+ if (errorPaths.has(pathname))
186
90
  continue;
187
- seenPaths.add(pathname);
188
- prerendered.push({ pathname, title: page.title, description: page.description, locale: page.locale });
91
+ const page = metadata.get(pathname);
92
+ const locale = page?.locale || (i18nRuntime._tag === "enabled" ? i18nRuntime.resolveLocaleFromRoute(pathname, i18nRuntime.config, i18nContext).locale : void 0);
93
+ pageMap.set(pathname, { pathname, href: href(pathname), title: page?.title, description: page?.description, locale });
189
94
  }
190
- const devModeHint = import.meta.dev && prerendered.length === 0 ? " (dev mode - run `nuxi generate` for page titles)" : "";
191
- const other = [];
192
- for (const pathname of sitemapPaths) {
193
- if (!seenPaths.has(pathname) && !errorSet.has(pathname)) {
194
- const locale = i18nRuntime._tag === "enabled" ? i18nRuntime.resolveLocaleFromRoute(pathname, i18nRuntime.config, i18nContext).locale : void 0;
195
- other.push({ pathname, locale });
196
- seenPaths.add(pathname);
197
- }
198
- }
199
- let resolvePageHref = resolvePath;
200
- if (llmsTxtConfig.markdownLinks) {
201
- const markdownLinkAvailability = await findMarkdownLinkAvailability();
202
- resolvePageHref = (pathname) => {
203
- const deployedPathname = resolvePath(pathname);
204
- const markdownPath = resolvePath(toMarkdownPath(pathname));
205
- if (markdownLinkAvailability.paths.has(decodePath(markdownPath)) || markdownLinkAvailability.runtimeMarkdownAvailable && hasMarkdownTwin(pathname)) {
206
- return markdownPath;
207
- }
208
- return deployedPathname;
209
- };
210
- }
211
- const resolvePagePath = resolvePageHref;
212
- resolvePageHref = (pathname) => absoluteSiteUrl(resolvePagePath(pathname), canonicalSiteUrl);
213
- for (const page of [...prerendered, ...other])
214
- page.href = resolvePageHref(page.pathname);
95
+ let rootContent;
215
96
  if (i18nRuntime._tag === "enabled") {
216
- const pageCounts = /* @__PURE__ */ new Map();
217
- for (const locale of i18nRuntime.config.locales) pageCounts.set(locale.code, 0);
218
- for (const p of [...prerendered, ...other]) {
219
- const code = p.locale || i18nRuntime.resolveLocaleFromRoute(p.pathname, i18nRuntime.config, i18nContext).locale;
220
- pageCounts.set(code, (pageCounts.get(code) ?? 0) + 1);
97
+ const counts = /* @__PURE__ */ new Map();
98
+ for (const page of pageMap.values())
99
+ counts.set(page.locale, (counts.get(page.locale) || 0) + 1);
100
+ rootContent = i18nRuntime.formatAvailableLanguagesSection(i18nRuntime.config, counts, href, i18nContext).join("\n");
101
+ for (const [path, page] of pageMap) {
102
+ if (page.locale !== i18nRuntime.config.defaultLocale)
103
+ pageMap.delete(path);
221
104
  }
222
- parts.push(...i18nRuntime.formatAvailableLanguagesSection(i18nRuntime.config, pageCounts, resolvePageHref, i18nContext));
223
- parts.push("");
224
105
  }
225
- const isDefaultLocale = (item) => {
226
- if (i18nRuntime._tag === "disabled")
227
- return true;
228
- const code = item.locale || i18nRuntime.resolveLocaleFromRoute(item.pathname, i18nRuntime.config, i18nContext).locale;
229
- return code === i18nRuntime.config.defaultLocale;
230
- };
231
- const filteredPrerendered = i18nRuntime._tag === "enabled" ? prerendered.filter(isDefaultLocale) : prerendered;
232
- const filteredOther = i18nRuntime._tag === "enabled" ? other.filter(isDefaultLocale) : other;
233
- const sortedPrerendered = sortPagesByPath(filteredPrerendered);
234
- const sortedOther = sortPagesByPath(filteredOther);
235
- if (sortedPrerendered.length > 0 && sortedOther.length > 0) {
236
- parts.push(`## Prerendered Pages${devModeHint}
237
- `);
238
- parts.push(...formatPagesWithGroups(sortedPrerendered));
239
- parts.push("");
240
- parts.push("## Other Pages\n");
241
- parts.push(...formatPagesWithGroups(sortedOther));
242
- parts.push("");
243
- } else if (sortedPrerendered.length > 0) {
244
- parts.push(`## Pages${devModeHint}
245
- `);
246
- parts.push(...formatPagesWithGroups(sortedPrerendered));
247
- parts.push("");
248
- } else if (sortedOther.length > 0) {
249
- parts.push(`## Pages${devModeHint}
250
- `);
251
- parts.push(...formatPagesWithGroups(sortedOther));
252
- parts.push("");
253
- }
254
- return parts.join("\n");
106
+ const sitemapPages = new Map([...sitemapPaths].map(([name, paths]) => [name, [...paths].flatMap((path) => pageMap.has(path) ? [pageMap.get(path)] : [])]));
107
+ return Object.fromEntries(createLlmsTxtFiles({ header: parts.join("\n"), config, sections, rootContent, pages: config.pages === false ? [] : [...pageMap.values()], sitemapPages, resolveUrl }));
108
+ }
109
+ export async function buildLlmsTxt(event) {
110
+ const files = await buildLlmsTxtFiles(event);
111
+ const path = toLogicalRoute(event.path.split("?")[0], useRuntimeConfig(event).app.baseURL);
112
+ const content = files[path];
113
+ if (content === void 0)
114
+ throw createError({ statusCode: 404, statusMessage: "llms.txt file not found" });
115
+ return content;
255
116
  }
@@ -34,6 +34,9 @@ export interface PageEntry {
34
34
  export interface PageData extends PageEntry {
35
35
  markdown: string;
36
36
  }
37
+ export type PageMetadata = Pick<PageEntry, 'route' | 'title' | 'description' | 'isError' | 'locale'>;
38
+ /** Read only admitted page metadata. Each query fits D1's 100 parameter limit. */
39
+ export declare function queryPageMetadata(event: AiReadyDatabaseEvent | undefined, routes: readonly string[]): Promise<PageMetadata[]>;
37
40
  export interface SearchResult {
38
41
  route: string;
39
42
  title: string;
@@ -80,6 +80,7 @@ async function getPrerenderDb() {
80
80
  const isErrorQuery = sql.includes("is_error = 1") || params.includes(1) && sql.includes("is_error");
81
81
  const excludeErrors = sql.includes("is_error = 0");
82
82
  const includeMarkdown = wantsMarkdown(sql);
83
+ const admittedRoutes = sql.includes("WHERE route IN (") ? new Set(params) : void 0;
83
84
  if (isErrorQuery) {
84
85
  return [...errorRoutes].map((route) => ({
85
86
  route,
@@ -94,8 +95,8 @@ async function getPrerenderDb() {
94
95
  locale: ""
95
96
  }));
96
97
  }
97
- const filtered = excludeErrors ? pages.filter((p) => !errorRoutes.has(p.route)) : pages;
98
- return filtered.map((p) => ({
98
+ const filtered = pages.filter((p) => (!excludeErrors || !errorRoutes.has(p.route)) && (!admittedRoutes || admittedRoutes.has(p.route)));
99
+ const rows = filtered.map((p) => ({
99
100
  route: p.route,
100
101
  title: p.title,
101
102
  description: p.description,
@@ -107,6 +108,13 @@ async function getPrerenderDb() {
107
108
  indexed: 1,
108
109
  locale: p.locale || ""
109
110
  }));
111
+ if (admittedRoutes && !excludeErrors) {
112
+ for (const route of errorRoutes) {
113
+ if (admittedRoutes.has(route))
114
+ rows.push({ route, title: "", description: "", headings: [], keywords: "[]", updated_at: "", is_error: 1, indexed: 0, locale: "" });
115
+ }
116
+ }
117
+ return rows;
110
118
  },
111
119
  first: async (sql, params = []) => {
112
120
  if (sql.includes("WHERE route = ?")) {
@@ -136,6 +144,24 @@ async function getPrerenderDb() {
136
144
  }
137
145
  };
138
146
  }
147
+ export async function queryPageMetadata(event, routes) {
148
+ if (!routes.length)
149
+ return [];
150
+ const db = await getDb(event);
151
+ if (!db)
152
+ return [];
153
+ const unique = [...new Set(routes)];
154
+ const pages = [];
155
+ for (let i = 0; i < unique.length; i += 100) {
156
+ const batch = unique.slice(i, i + 100);
157
+ const rows = await db.all(
158
+ `SELECT route, title, description, is_error, locale FROM ai_ready_pages WHERE route IN (${batch.map(() => "?").join(", ")})`,
159
+ batch
160
+ );
161
+ pages.push(...rows.map((row) => ({ route: row.route, title: row.title, description: row.description, isError: row.is_error === 1, locale: row.locale || "" })));
162
+ }
163
+ return pages;
164
+ }
139
165
  function buildWhereClause(where) {
140
166
  const conditions = [];
141
167
  const params = [];
@@ -20,7 +20,7 @@ function notFoundMarkdown(ctx, canonicalUrl, build) {
20
20
  ``,
21
21
  `- [Sitemap](${resolveUrl("/sitemap.xml")})`,
22
22
  `- [llms.txt](${resolveUrl("/llms.txt")})`,
23
- `- [llms-full.txt](${resolveUrl("/llms-full.txt")})`,
23
+ ...config.llmsFullTxt === false ? [] : [`- [llms-full.txt](${resolveUrl("/llms-full.txt")})`],
24
24
  ``
25
25
  ].join("\n");
26
26
  const i18n = config.i18n;
@@ -1,4 +1,4 @@
1
- import { eventHandler, sendIterable, setHeader, setResponseHeader } from "#nuxtseo/h3";
1
+ import { assertMethod, eventHandler, sendIterable, setHeader, setResponseHeader } from "#nuxtseo/h3";
2
2
  import { useRuntimeConfig } from "#nuxtseo/nitro";
3
3
  import { getSiteConfig } from "#site-config/server/composables";
4
4
  import { withSiteUrl } from "#site-config/server/composables/utils";
@@ -7,6 +7,7 @@ import { toDeployedRoute } from "../../route-path.js";
7
7
  import { countPages, streamPages } from "../db/queries.js";
8
8
  import { buildLlmsFullTxtHeader, formatPageForLlmsFullTxt } from "../utils/llms-full.js";
9
9
  export default eventHandler(async (event) => {
10
+ assertMethod(event, "GET", true);
10
11
  if (import.meta.prerender) {
11
12
  setHeader(event, "Content-Type", "text/plain; charset=utf-8");
12
13
  return "# llms-full.txt\n\nThis file is generated during prerender.";
@@ -1,29 +1,37 @@
1
- import { eventHandler, setHeader } from "#nuxtseo/h3";
1
+ import { assertMethod, createError, eventHandler, getRequestURL, setHeader } from "#nuxtseo/h3";
2
2
  import { defineCachedFunction, useRuntimeConfig } from "#nuxtseo/nitro";
3
3
  import { publicCacheControl } from "../../cache-control.js";
4
- import { buildLlmsTxt } from "../../llms-txt-utils.js";
4
+ import { buildLlmsTxtFiles } from "../../llms-txt-utils.js";
5
+ import { toLogicalRoute } from "../../route-path.js";
5
6
  const cachedBuilders = /* @__PURE__ */ new Map();
6
7
  function getBuildLlmsTxtCached(maxAge) {
7
8
  let cached = cachedBuilders.get(maxAge);
8
9
  if (!cached) {
9
- cached = defineCachedFunction(buildLlmsTxt, {
10
- name: "llms-txt",
10
+ cached = defineCachedFunction(buildLlmsTxtFiles, {
11
+ name: "llms-txt-files",
11
12
  group: "ai-ready",
12
13
  maxAge,
13
- swr: true
14
+ swr: true,
15
+ // Share one discovery snapshot between root and child files, separated by origin.
16
+ getKey: (event) => getRequestURL(event, { xForwardedHost: true, xForwardedProto: true }).origin
14
17
  });
15
18
  cachedBuilders.set(maxAge, cached);
16
19
  }
17
20
  return cached;
18
21
  }
19
22
  export default eventHandler(async (event) => {
20
- const runtimeConfig = useRuntimeConfig(event)["nuxt-ai-ready"];
21
- const cacheSeconds = runtimeConfig.llmsTxtCacheSeconds;
22
- const cacheEnabled = !import.meta.dev && cacheSeconds > 0;
23
- const content = cacheEnabled ? await getBuildLlmsTxtCached(cacheSeconds)(event) : await buildLlmsTxt(event);
23
+ assertMethod(event, "GET", true);
24
+ const runtimeConfig = useRuntimeConfig(event);
25
+ const config = runtimeConfig["nuxt-ai-ready"];
26
+ const cacheSeconds = config.llmsTxtCacheSeconds;
27
+ const cacheEnabled = !import.meta.dev && !import.meta.prerender && cacheSeconds > 0;
28
+ const files = cacheEnabled ? await getBuildLlmsTxtCached(cacheSeconds)(event) : await buildLlmsTxtFiles(event);
29
+ const path = toLogicalRoute(event.path.split("?")[0], runtimeConfig.app.baseURL);
30
+ const content = files[path];
31
+ if (content === void 0)
32
+ throw createError({ statusCode: 404, statusMessage: "llms.txt file not found" });
24
33
  setHeader(event, "Content-Type", "text/plain; charset=utf-8");
25
- if (cacheEnabled) {
34
+ if (cacheEnabled)
26
35
  setHeader(event, "Cache-Control", publicCacheControl(cacheSeconds, 3600));
27
- }
28
36
  return content;
29
37
  });
@@ -1,4 +1,4 @@
1
- import { eventHandler, setHeader } from "#nuxtseo/h3";
1
+ import { assertMethod, eventHandler, setHeader } from "#nuxtseo/h3";
2
2
  import { defineCachedFunction, useRuntimeConfig } from "#nuxtseo/nitro";
3
3
  import { getSiteConfig } from "#site-config/server/composables";
4
4
  import { publicCacheControl } from "../../cache-control.js";
@@ -42,6 +42,7 @@ function getBuildSitemapMdCached(maxAge) {
42
42
  return cached;
43
43
  }
44
44
  export default eventHandler(async (event) => {
45
+ assertMethod(event, "GET", true);
45
46
  const fullRuntimeConfig = useRuntimeConfig(event);
46
47
  const runtimeConfig = fullRuntimeConfig["nuxt-ai-ready"];
47
48
  const cacheSeconds = runtimeConfig.llmsTxtCacheSeconds ?? 600;
@@ -1,7 +1,7 @@
1
1
  import { negotiateContent, parseAcceptHeader } from "@mdream/js/negotiate";
2
2
  import { getBotInfo } from "@nuxtjs/robots/util";
3
3
  import { getHeaders } from "#nuxtseo/h3";
4
- import { isReservedPath } from "../../markdown-path.js";
4
+ import { hasMarkdownTwin, isReservedPath } from "../../markdown-path.js";
5
5
  const RE_MD_EXT = /\.md$/;
6
6
  export function toMarkdownRequest(event, isPrerender = !!import.meta.prerender) {
7
7
  return { path: event.path, headers: getHeaders(event), isPrerender };
@@ -38,10 +38,7 @@ export function getRequestRenderInfo(request, mode = { _tag: "runtime", contentN
38
38
  const isExplicit = originalPath.endsWith(".md");
39
39
  if (isPrerender && !isExplicit)
40
40
  return null;
41
- const lastSegment = originalPath.split("/").pop() || "";
42
- const hasExtension = lastSegment.includes(".");
43
- const extension = hasExtension ? lastSegment.substring(lastSegment.lastIndexOf(".")) : "";
44
- if (hasExtension && extension !== ".md")
41
+ if (!isExplicit && !hasMarkdownTwin(originalPath))
45
42
  return null;
46
43
  const negotiation = isPrerender ? "markdown" : mode.contentNegotiation ? negotiateRequestRepresentation(request, mode.botNegotiation) : "html";
47
44
  if (isExplicit) {
@@ -53,5 +53,6 @@ export declare function fetchSitemapByRoute(event: H3Event | undefined, route: s
53
53
  * Fetch all URLs from all sitemaps (or single sitemap if not multi-sitemap)
54
54
  * Used for backwards compatibility and llms.txt generation
55
55
  */
56
+ export declare function fetchSitemapUrlGroups(event: H3Event, names?: readonly string[]): Promise<Map<string, SitemapUrl[]>>;
56
57
  export declare function fetchSitemapUrls(event: H3Event): Promise<SitemapUrl[]>;
57
58
  export {};
@@ -1,5 +1,7 @@
1
1
  import { createSitemapReader } from "sitemapd";
2
2
  import { parseSitemap } from "sitemapd/parse";
3
+ import staticSitemapConfig from "#ai-ready-virtual/sitemap-config.mjs";
4
+ import { getRequestURL } from "#nuxtseo/h3";
3
5
  import { useRuntimeConfig } from "#nuxtseo/nitro";
4
6
  import { logger } from "../logger.js";
5
7
  import { getCfEnv, hasAssets } from "./cloudflare.js";
@@ -19,17 +21,18 @@ const SITEMAP_READER_LIMITS = {
19
21
  const MAX_SITEMAP_CRAWL_ROUNDS = 10;
20
22
  export function getSitemapsFromConfig(event) {
21
23
  const runtimeConfig = useRuntimeConfig(event);
22
- const sitemapConfig = runtimeConfig.sitemap;
24
+ const sitemapConfig = { ...staticSitemapConfig, ...runtimeConfig.sitemap };
23
25
  if (!sitemapConfig?.sitemaps)
24
26
  return [];
25
27
  const sitemaps = [];
26
28
  for (const [key, sitemap] of Object.entries(sitemapConfig.sitemaps)) {
27
29
  if (key === "index")
28
30
  continue;
29
- if (sitemap._route) {
31
+ const route = sitemap._route || sitemap.route || (key === "chunks" ? sitemapConfig.sitemaps.index?._route || sitemapConfig.sitemaps.index?.route : void 0);
32
+ if (route) {
30
33
  sitemaps.push({
31
- name: sitemap.sitemapName || key,
32
- route: sitemap._route
34
+ name: (sitemap.sitemapName || key).replace(/\.xml$/, ""),
35
+ route: route.startsWith("/") ? route : `/${route}`
33
36
  });
34
37
  }
35
38
  }
@@ -305,6 +308,7 @@ export async function crawlSitemapByRoute(event, input) {
305
308
  const usePublicAsset = config.sitemapPrerendered && hasAssets(event);
306
309
  const configuredSiteUrl = createUniversalContext(event).siteUrl ?? SITEMAP_FALLBACK_ORIGIN;
307
310
  const siteUrl = parseAbsoluteUrl(configuredSiteUrl);
311
+ const requestOrigin = import.meta.dev && event ? getRequestURL(event, { xForwardedHost: true, xForwardedProto: true }).origin : void 0;
308
312
  if (siteUrl._tag === "error") {
309
313
  return {
310
314
  _tag: "failed",
@@ -346,7 +350,7 @@ export async function crawlSitemapByRoute(event, input) {
346
350
  const target = parseAbsoluteUrl(request.url);
347
351
  if (target._tag === "error")
348
352
  return { _tag: "deny", reason: `Invalid sitemap target: ${request.url}` };
349
- if (target.value.origin !== siteUrl.value.origin) {
353
+ if (target.value.origin !== siteUrl.value.origin && target.value.origin !== requestOrigin) {
350
354
  return {
351
355
  _tag: "deny",
352
356
  reason: `Sitemap target origin ${target.value.origin} differs from ${siteUrl.value.origin}`
@@ -433,17 +437,21 @@ export async function fetchSitemapByRoute(event, route) {
433
437
  const result = await crawlSitemapByRoute(event, { route });
434
438
  return result._tag === "complete" ? { urls: result.urls } : { urls: result.urls, error: result.error };
435
439
  }
436
- export async function fetchSitemapUrls(event) {
440
+ export async function fetchSitemapUrlGroups(event, names) {
437
441
  const sitemaps = getSitemapsFromConfig(event);
442
+ const groups = /* @__PURE__ */ new Map();
438
443
  if (sitemaps.length > 0) {
439
444
  logger.debug(`[sitemap] Multi-sitemap mode: ${sitemaps.length} sitemaps`);
440
- const allUrls = [];
441
445
  for (const sitemap of sitemaps) {
446
+ if (names !== void 0 && !names.includes(sitemap.name))
447
+ continue;
442
448
  const { urls: urls2 } = await fetchSitemapByRoute(event, sitemap.route);
443
- allUrls.push(...urls2);
449
+ groups.set(sitemap.name, [...groups.get(sitemap.name) || [], ...urls2]);
444
450
  }
445
- return allUrls;
451
+ return groups;
446
452
  }
453
+ if (names !== void 0 && !names.includes("sitemap"))
454
+ return groups;
447
455
  logger.debug(`[sitemap] Single sitemap mode: /sitemap.xml`);
448
456
  const { urls, error } = await fetchSitemapByRoute(event, "/sitemap.xml");
449
457
  if (error) {
@@ -451,5 +459,9 @@ export async function fetchSitemapUrls(event) {
451
459
  } else if (urls.length === 0) {
452
460
  logger.warn("Sitemap is empty - add routes to sitemap or configure sitemap.sources in nuxt.config");
453
461
  }
454
- return urls;
462
+ groups.set("sitemap", urls);
463
+ return groups;
464
+ }
465
+ export async function fetchSitemapUrls(event) {
466
+ return [...(await fetchSitemapUrlGroups(event)).values()].flat();
455
467
  }
@@ -185,7 +185,9 @@ export interface ModuleOptions {
185
185
  /**
186
186
  * Structured llms.txt configuration
187
187
  */
188
- llmsTxt?: LlmsTxtConfig;
188
+ llmsTxt?: LlmsTxtConfig | false;
189
+ /** Generate and serve llms-full.txt. Set false to omit the export and its discovery link. */
190
+ llmsFullTxt?: boolean;
189
191
  /**
190
192
  * Generate a Markdown sitemap at `/sitemap.md` from indexed pages and end
191
193
  * every Markdown page with a `## Sitemap` section linking to it.
@@ -512,6 +514,10 @@ export interface LlmsTxtLink {
512
514
  * Section in llms.txt
513
515
  */
514
516
  export interface LlmsTxtSection {
517
+ /** Generate a separate file from these sitemap names. Chunked sitemaps retain their configured name. */
518
+ sitemap?: string | string[];
519
+ /** Generated file path. Defaults to /llms/<sitemap>.txt. */
520
+ path?: string;
515
521
  /** The title of the section */
516
522
  title: string;
517
523
  /** Description rendered in the heading-free preamble, or in each optional link note (can be array for multiple paragraphs) */
@@ -525,6 +531,12 @@ export interface LlmsTxtSection {
525
531
  * Structured llms.txt configuration
526
532
  */
527
533
  export interface LlmsTxtConfig {
534
+ /** Page admission. Sitemap URLs are authoritative by default. Use all to include stored pages outside the sitemap. */
535
+ pages?: 'sitemap' | 'all' | false;
536
+ /** Only read these sitemap names. An empty array disables sitemap page discovery. */
537
+ sitemaps?: string[];
538
+ /** Maximum characters per generated file. Large lists become linked files. Minimum 1024. Default 80000. */
539
+ maxLength?: number;
528
540
  /**
529
541
  * Rewrite automatically generated page links to their Markdown representation
530
542
  * when the final static output contains the file or the deployment retains