nuxt-ai-ready 3.2.0 → 3.3.1

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.
Files changed (46) hide show
  1. package/dist/chunks/agent-skills.mjs +2 -1
  2. package/dist/chunks/prerender.mjs +197 -28
  3. package/dist/cli.mjs +259 -0
  4. package/dist/module.d.mts +1 -0
  5. package/dist/module.json +1 -1
  6. package/dist/module.mjs +2 -1
  7. package/dist/runtime/llms-txt-files.d.ts +30 -0
  8. package/dist/runtime/llms-txt-files.js +196 -0
  9. package/dist/runtime/llms-txt-format.js +5 -4
  10. package/dist/runtime/llms-txt-utils.d.ts +3 -2
  11. package/dist/runtime/llms-txt-utils.js +85 -224
  12. package/dist/runtime/markdown-text.d.ts +2 -0
  13. package/dist/runtime/markdown-text.js +3 -0
  14. package/dist/runtime/server/db/drizzle/queries.d.ts +2 -6
  15. package/dist/runtime/server/db/drizzle/queries.js +30 -10
  16. package/dist/runtime/server/db/queries.d.ts +5 -6
  17. package/dist/runtime/server/db/queries.js +40 -9
  18. package/dist/runtime/server/db/schema/postgres.d.ts +30 -0
  19. package/dist/runtime/server/db/schema/postgres.js +1 -0
  20. package/dist/runtime/server/db/schema/sqlite.d.ts +32 -0
  21. package/dist/runtime/server/db/schema/sqlite.js +1 -0
  22. package/dist/runtime/server/db/schema-sql.d.ts +1 -1
  23. package/dist/runtime/server/db/schema-sql.js +2 -1
  24. package/dist/runtime/server/db/seed.d.ts +8 -0
  25. package/dist/runtime/server/db/seed.js +16 -0
  26. package/dist/runtime/server/db/shared.d.ts +3 -3
  27. package/dist/runtime/server/db/shared.js +19 -1
  28. package/dist/runtime/server/middleware/markdown.js +1 -1
  29. package/dist/runtime/server/plugins/sitemap-seeder.js +9 -1
  30. package/dist/runtime/server/routes/llms-full.txt.get.js +2 -1
  31. package/dist/runtime/server/routes/llms.txt.get.js +21 -11
  32. package/dist/runtime/server/routes/sitemap.md.get.js +2 -1
  33. package/dist/runtime/server/utils/llms-full.js +31 -5
  34. package/dist/runtime/server/utils/markdown-request.js +2 -5
  35. package/dist/runtime/server/utils/negotiation-response.js +3 -0
  36. package/dist/runtime/server/utils/runCron.js +1 -1
  37. package/dist/runtime/server/utils/sitemap-lastmod.d.ts +4 -0
  38. package/dist/runtime/server/utils/sitemap-lastmod.js +19 -0
  39. package/dist/runtime/server/utils/sitemap-md.js +3 -2
  40. package/dist/runtime/server/utils/sitemap.d.ts +1 -0
  41. package/dist/runtime/server/utils/sitemap.js +26 -12
  42. package/dist/runtime/server/utils.js +50 -2
  43. package/dist/runtime/types.d.ts +13 -1
  44. package/dist/shared/{nuxt-ai-ready.DH9mSayy.mjs → nuxt-ai-ready.CKXM1jnE.mjs} +40 -12
  45. package/package.json +3 -3
  46. package/skills/nuxt-ai-ready/SKILL.md +34 -1
@@ -1,15 +1,41 @@
1
1
  import { joinURL } from "ufo";
2
+ import { isMap, isScalar, parseDocument, stringify } from "yaml";
2
3
  import { normalizeLlmsTxtConfig } from "../../llms-txt-format.js";
3
- const RE_FRONTMATTER = /^---\r?\n[\s\S]*?\r?\n---\r?\n*/;
4
+ import { escapeMarkdownHtml } from "../../markdown-text.js";
5
+ const RE_FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n*/;
4
6
  const RE_INLINE_WHITESPACE = /\s+/g;
7
+ function imageMetadataFence(frontmatter) {
8
+ const document = parseDocument(frontmatter);
9
+ const meta = document.get("meta", true);
10
+ if (document.errors.length || !isMap(meta))
11
+ return;
12
+ const images = {};
13
+ for (const { key, value } of meta.items) {
14
+ if (!isScalar(key) || typeof key.value !== "string" || !isScalar(value))
15
+ continue;
16
+ if (key.value !== "og:image" && !key.value.startsWith("og:image:") && key.value !== "twitter:image" && !key.value.startsWith("twitter:image:"))
17
+ continue;
18
+ if (typeof value.value === "string" || typeof value.value === "number" && Number.isFinite(value.value))
19
+ images[key.value] = value.value;
20
+ }
21
+ if (!Object.keys(images).length)
22
+ return;
23
+ return `\`\`\`yaml
24
+ ${stringify({ meta: images }, { lineWidth: 0 }).trimEnd()}
25
+ \`\`\``;
26
+ }
5
27
  export function formatPageForLlmsFullTxt(route, title, description, markdown, siteUrl) {
6
28
  const canonicalUrl = siteUrl ? joinURL(siteUrl, route) : route;
7
- const pageTitle = (title && title !== route ? title : route).trim().replace(RE_INLINE_WHITESPACE, " ");
8
- const pageDescription = description.trim().replace(RE_INLINE_WHITESPACE, " ");
29
+ const pageTitle = escapeMarkdownHtml((title && title !== route ? title : route).trim().replace(RE_INLINE_WHITESPACE, " "));
30
+ const pageDescription = escapeMarkdownHtml(description.trim().replace(RE_INLINE_WHITESPACE, " "));
9
31
  const content = markdown.replace(RE_FRONTMATTER, "").trim();
10
32
  const parts = ["---", "", `- **Page:** ${pageTitle}`, `- **Source:** ${canonicalUrl}`];
11
33
  if (pageDescription)
12
34
  parts.push(`- **Description:** ${pageDescription}`);
35
+ const frontmatter = markdown.match(RE_FRONTMATTER)?.[1];
36
+ const images = frontmatter && imageMetadataFence(frontmatter);
37
+ if (images)
38
+ parts.push("", images);
13
39
  parts.push("");
14
40
  if (content) {
15
41
  parts.push(content);
@@ -19,9 +45,9 @@ export function formatPageForLlmsFullTxt(route, title, description, markdown, si
19
45
  `;
20
46
  }
21
47
  export function buildLlmsFullTxtHeader(siteInfo, llmsTxtConfig) {
22
- const parts = [`# ${siteInfo?.name || siteInfo?.url || "Site"}`];
48
+ const parts = [`# ${escapeMarkdownHtml(siteInfo?.name || siteInfo?.url || "Site")}`];
23
49
  if (siteInfo?.description)
24
- parts.push("", `> ${siteInfo.description}`);
50
+ parts.push("", `> ${escapeMarkdownHtml(siteInfo.description)}`);
25
51
  if (siteInfo?.url)
26
52
  parts.push("", `Canonical Origin: ${siteInfo.url}`);
27
53
  if (llmsTxtConfig) {
@@ -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) {
@@ -52,6 +52,9 @@ export function setUncacheableHeaders(event) {
52
52
  }
53
53
  export function setMarkdownHeaders(event, ctx, sourceHeaders) {
54
54
  setHeader(event, "content-type", "text/markdown; charset=utf-8");
55
+ const sourcePolicy = sourceHeaders?.get("content-signal");
56
+ if (sourcePolicy !== null && sourcePolicy !== void 0 && getResponseHeader(event, "content-signal") === void 0)
57
+ setHeader(event, "content-signal", sourcePolicy);
55
58
  const vary = [getResponseHeader(event, "vary"), sourceHeaders?.get("vary")].filter(Boolean).join(", ");
56
59
  setHeader(event, "vary", mergeVaryHeader(vary, "Accept"));
57
60
  setLinkHeader(event, ctx, "markdown");
@@ -170,7 +170,7 @@ async function pingSitemap(event, config, sitemapIntervalMinutes, debug) {
170
170
  route: nextSitemap.route,
171
171
  state: nextSitemap.crawlState
172
172
  });
173
- const routes = [...mapSitemapRoutes(result.urls).entries()].map(([route, url]) => ({ route, url: url.loc }));
173
+ const routes = [...mapSitemapRoutes(result.urls).entries()].map(([route, url]) => ({ route, url: url.loc, lastmod: url.lastmod }));
174
174
  if (routes.length > 0)
175
175
  await seedRoutes(event, routes, { refreshWindowMs });
176
176
  if (result._tag === "failed") {
@@ -0,0 +1,4 @@
1
+ /** Mark dates added by this module without changing the public sitemap date. */
2
+ export declare function markIndexedSitemapLastmods(xml: string, enrichedUrls: ReadonlySet<string>): string;
3
+ /** The source crawl must never treat our indexed date as a CMS change. */
4
+ export declare function stripIndexedSitemapLastmods(body: Uint8Array): Uint8Array;
@@ -0,0 +1,19 @@
1
+ const INDEXED_LASTMOD_MARKER = "<!-- nuxt-ai-ready:indexed-lastmod -->";
2
+ export function markIndexedSitemapLastmods(xml, enrichedUrls) {
3
+ const escapedUrls = new Set([...enrichedUrls].map((url) => url.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;")));
4
+ return xml.replace(/<url>([\s\S]*?)<\/url>/g, (entry, body) => {
5
+ const loc = body.match(/<loc>([^<]*)<\/loc>/)?.[1];
6
+ return loc && escapedUrls.has(loc) ? entry.replace("<lastmod>", `${INDEXED_LASTMOD_MARKER}<lastmod>`) : entry;
7
+ });
8
+ }
9
+ export function stripIndexedSitemapLastmods(body) {
10
+ let xml;
11
+ try {
12
+ xml = new TextDecoder("utf-8", { fatal: true }).decode(body);
13
+ } catch {
14
+ return body;
15
+ }
16
+ if (!xml.includes(INDEXED_LASTMOD_MARKER))
17
+ return body;
18
+ return new TextEncoder().encode(xml.replace(/<!-- nuxt-ai-ready:indexed-lastmod -->\s*<lastmod>[^<]*<\/lastmod>/g, ""));
19
+ }
@@ -1,11 +1,12 @@
1
1
  import { toMarkdownPath } from "../../markdown-path.js";
2
+ import { escapeMarkdownHtml } from "../../markdown-text.js";
2
3
  import { toDeployedRoute } from "../../route-path.js";
3
4
  export const SITEMAP_MD_ROUTE = "/sitemap.md";
4
5
  function topSegment(route) {
5
6
  return route.split("/").filter(Boolean)[0] || "";
6
7
  }
7
8
  function escapeTitle(title) {
8
- return title.replaceAll("[", "\\[").replaceAll("]", "\\]");
9
+ return escapeMarkdownHtml(title).replaceAll("[", "\\[").replaceAll("]", "\\]");
9
10
  }
10
11
  function formatEntry(entry, resolveHref) {
11
12
  const title = escapeTitle(entry.title?.trim() || entry.route);
@@ -35,7 +36,7 @@ export function buildSitemapMd(entries, options = {}) {
35
36
  return 1;
36
37
  return a.localeCompare(b);
37
38
  });
38
- const parts = [`# ${options.siteName || "Site"} Sitemap`, "", "All pages in Markdown format."];
39
+ const parts = [`# ${escapeMarkdownHtml(options.siteName || "Site")} Sitemap`, "", "All pages in Markdown format."];
39
40
  for (const key of keys) {
40
41
  parts.push("", `## ${key || "Root"}`);
41
42
  for (const entry of groups.get(key))
@@ -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,10 +1,13 @@
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";
6
8
  import { createUniversalContext } from "./context.js";
7
9
  import { fetchRawWithEvent } from "./fetch.js";
10
+ import { stripIndexedSitemapLastmods } from "./sitemap-lastmod.js";
8
11
  const FETCH_TIMEOUT = 15e3;
9
12
  const SITEMAP_FALLBACK_ORIGIN = "http://localhost";
10
13
  const SITEMAP_READER_LIMITS = {
@@ -19,17 +22,18 @@ const SITEMAP_READER_LIMITS = {
19
22
  const MAX_SITEMAP_CRAWL_ROUNDS = 10;
20
23
  export function getSitemapsFromConfig(event) {
21
24
  const runtimeConfig = useRuntimeConfig(event);
22
- const sitemapConfig = runtimeConfig.sitemap;
25
+ const sitemapConfig = { ...staticSitemapConfig, ...runtimeConfig.sitemap };
23
26
  if (!sitemapConfig?.sitemaps)
24
27
  return [];
25
28
  const sitemaps = [];
26
29
  for (const [key, sitemap] of Object.entries(sitemapConfig.sitemaps)) {
27
30
  if (key === "index")
28
31
  continue;
29
- if (sitemap._route) {
32
+ const route = sitemap._route || sitemap.route || (key === "chunks" ? sitemapConfig.sitemaps.index?._route || sitemapConfig.sitemaps.index?.route : void 0);
33
+ if (route) {
30
34
  sitemaps.push({
31
- name: sitemap.sitemapName || key,
32
- route: sitemap._route
35
+ name: (sitemap.sitemapName || key).replace(/\.xml$/, ""),
36
+ route: route.startsWith("/") ? route : `/${route}`
33
37
  });
34
38
  }
35
39
  }
@@ -218,11 +222,12 @@ function createLocalSitemapLoader(event, usePublicAsset, loadedBodies) {
218
222
  bytesRead: read.result.bytesRead
219
223
  };
220
224
  }
221
- loadedBodies.set(request.url, read.result.body);
225
+ const sourceBody = stripIndexedSitemapLastmods(read.result.body);
226
+ loadedBodies.set(request.url, sourceBody);
222
227
  return {
223
228
  _tag: "body",
224
229
  url: request.url,
225
- body: read.result.body
230
+ body: sourceBody
226
231
  };
227
232
  };
228
233
  }
@@ -305,6 +310,7 @@ export async function crawlSitemapByRoute(event, input) {
305
310
  const usePublicAsset = config.sitemapPrerendered && hasAssets(event);
306
311
  const configuredSiteUrl = createUniversalContext(event).siteUrl ?? SITEMAP_FALLBACK_ORIGIN;
307
312
  const siteUrl = parseAbsoluteUrl(configuredSiteUrl);
313
+ const requestOrigin = import.meta.dev && event ? getRequestURL(event, { xForwardedHost: true, xForwardedProto: true }).origin : void 0;
308
314
  if (siteUrl._tag === "error") {
309
315
  return {
310
316
  _tag: "failed",
@@ -346,7 +352,7 @@ export async function crawlSitemapByRoute(event, input) {
346
352
  const target = parseAbsoluteUrl(request.url);
347
353
  if (target._tag === "error")
348
354
  return { _tag: "deny", reason: `Invalid sitemap target: ${request.url}` };
349
- if (target.value.origin !== siteUrl.value.origin) {
355
+ if (target.value.origin !== siteUrl.value.origin && target.value.origin !== requestOrigin) {
350
356
  return {
351
357
  _tag: "deny",
352
358
  reason: `Sitemap target origin ${target.value.origin} differs from ${siteUrl.value.origin}`
@@ -433,17 +439,21 @@ export async function fetchSitemapByRoute(event, route) {
433
439
  const result = await crawlSitemapByRoute(event, { route });
434
440
  return result._tag === "complete" ? { urls: result.urls } : { urls: result.urls, error: result.error };
435
441
  }
436
- export async function fetchSitemapUrls(event) {
442
+ export async function fetchSitemapUrlGroups(event, names) {
437
443
  const sitemaps = getSitemapsFromConfig(event);
444
+ const groups = /* @__PURE__ */ new Map();
438
445
  if (sitemaps.length > 0) {
439
446
  logger.debug(`[sitemap] Multi-sitemap mode: ${sitemaps.length} sitemaps`);
440
- const allUrls = [];
441
447
  for (const sitemap of sitemaps) {
448
+ if (names !== void 0 && !names.includes(sitemap.name))
449
+ continue;
442
450
  const { urls: urls2 } = await fetchSitemapByRoute(event, sitemap.route);
443
- allUrls.push(...urls2);
451
+ groups.set(sitemap.name, [...groups.get(sitemap.name) || [], ...urls2]);
444
452
  }
445
- return allUrls;
453
+ return groups;
446
454
  }
455
+ if (names !== void 0 && !names.includes("sitemap"))
456
+ return groups;
447
457
  logger.debug(`[sitemap] Single sitemap mode: /sitemap.xml`);
448
458
  const { urls, error } = await fetchSitemapByRoute(event, "/sitemap.xml");
449
459
  if (error) {
@@ -451,5 +461,9 @@ export async function fetchSitemapUrls(event) {
451
461
  } else if (urls.length === 0) {
452
462
  logger.warn("Sitemap is empty - add routes to sitemap or configure sitemap.sources in nuxt.config");
453
463
  }
454
- return urls;
464
+ groups.set("sitemap", urls);
465
+ return groups;
466
+ }
467
+ export async function fetchSitemapUrls(event) {
468
+ return [...(await fetchSitemapUrlGroups(event)).values()].flat();
455
469
  }
@@ -3,11 +3,57 @@ import { useNitroApp } from "#nuxtseo/nitro";
3
3
  import { stripLlmsTxtDirectiveHtml } from "../llms-txt-directive.js";
4
4
  import { hoistMetaDescription } from "./utils/frontmatter.js";
5
5
  const RE_NBSP = /\u00A0/g;
6
+ const JSON_LD_MAX_BYTES = 65536;
7
+ const JSON_LD_MAX_BLOCKS = 16;
8
+ const IMAGE_META_FIELDS = [
9
+ "og:image",
10
+ "og:image:url",
11
+ "og:image:secure_url",
12
+ "og:image:type",
13
+ "og:image:width",
14
+ "og:image:height",
15
+ "og:image:alt",
16
+ "twitter:image",
17
+ "twitter:image:src",
18
+ "twitter:image:alt"
19
+ ];
20
+ function parseJsonLdBlock(text, remainingBytes) {
21
+ if (text.length > remainingBytes)
22
+ return { _tag: "Skipped" };
23
+ const content = text.trim();
24
+ if (!content)
25
+ return { _tag: "Skipped" };
26
+ let value;
27
+ try {
28
+ value = JSON.parse(text);
29
+ } catch {
30
+ return { _tag: "Skipped" };
31
+ }
32
+ const isObject = (item) => typeof item === "object" && item !== null && !Array.isArray(item);
33
+ if (!isObject(value) && !(Array.isArray(value) && value.every(isObject)))
34
+ return { _tag: "Skipped" };
35
+ const markdown = `\`\`\`json
36
+ ${content.replace(RE_NBSP, "\\u00a0")}
37
+ \`\`\``;
38
+ const bytes = new TextEncoder().encode(markdown).byteLength + 2;
39
+ if (bytes > remainingBytes)
40
+ return { _tag: "Skipped" };
41
+ return { _tag: "Preserved", markdown, bytes };
42
+ }
6
43
  function normalizeWhitespace(text) {
7
44
  return text.replace(RE_NBSP, " ");
8
45
  }
9
46
  function buildMdreamOptions(url, mdreamOptions, meta, extractUpdatedAt, additionalFields) {
10
47
  const extraction = {
48
+ "script[type]": (el) => {
49
+ if (el.attributes.type?.trim().toLowerCase() !== "application/ld+json" || meta.jsonLd.blocks.length >= JSON_LD_MAX_BLOCKS)
50
+ return;
51
+ const block = parseJsonLdBlock(el.textContent, JSON_LD_MAX_BYTES - meta.jsonLd.bytes);
52
+ if (block._tag === "Preserved") {
53
+ meta.jsonLd.blocks.push(block.markdown);
54
+ meta.jsonLd.bytes += block.bytes;
55
+ }
56
+ },
11
57
  'meta[name="keywords"]': (el) => {
12
58
  meta.metaKeywords = el.attributes.content || "";
13
59
  },
@@ -30,6 +76,7 @@ function buildMdreamOptions(url, mdreamOptions, meta, extractUpdatedAt, addition
30
76
  };
31
77
  const frontmatter = {
32
78
  additionalFields,
79
+ metaFields: [...IMAGE_META_FIELDS],
33
80
  onExtract: (fm) => {
34
81
  if (fm.title)
35
82
  meta.title = fm.title;
@@ -70,13 +117,14 @@ export function extractLastUpdated(html) {
70
117
  return void 0;
71
118
  }
72
119
  export async function convertHtmlToMarkdown(html, url, mdreamOptions, opts) {
73
- const meta = { title: "", description: "", metaKeywords: "", headings: [], textContent: [] };
120
+ const meta = { title: "", description: "", metaKeywords: "", headings: [], textContent: [], jsonLd: { blocks: [], bytes: 0 } };
74
121
  const options = buildMdreamOptions(url, mdreamOptions, meta, opts.extractUpdatedAt ?? false, opts.additionalFrontmatter ?? {});
75
122
  const nitroApp = useNitroApp();
76
123
  await nitroApp.hooks.callHook("ai-ready:mdreamConfig", options);
124
+ const convertedMarkdown = hoistMetaDescription(htmlToMarkdown(stripLlmsTxtDirectiveHtml(html), options));
77
125
  const context = {
78
126
  html,
79
- markdown: hoistMetaDescription(htmlToMarkdown(stripLlmsTxtDirectiveHtml(html), options)),
127
+ markdown: [convertedMarkdown, ...meta.jsonLd.blocks].join("\n\n"),
80
128
  route: opts.hooks.route,
81
129
  title: meta.title,
82
130
  description: meta.description,
@@ -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
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
- import { readFile, mkdir, writeFile, access } from 'node:fs/promises';
2
+ import { readFile, access, mkdir, writeFile } from 'node:fs/promises';
3
3
  import { createRequire } from 'node:module';
4
4
  import { dirname, basename, join, resolve, relative } from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -8,6 +8,7 @@ import defu$1, { defu } from 'defu';
8
8
  import { useSiteConfig, withSiteUrl } from 'nuxt-site-config/kit';
9
9
  import { resolveNuxtContentVersion, renderNitroTypeAugmentations, setupRuntimeAliases, setupNitroRuntimeCompatibility } from 'nuxtseo-shared/kit';
10
10
  import { r as readPackageMetadata, a as resolvePackageMetadataPath } from './nuxt-ai-ready.B3hAuOLQ.mjs';
11
+ import { parseLlmsTxtConfig } from '../../dist/runtime/llms-txt-files.js';
11
12
  import { toDeployedRoute } from '../../dist/runtime/route-path.js';
12
13
  import { SITEMAP_MD_ROUTE } from '../../dist/runtime/server/utils/sitemap-md.js';
13
14
  import { publicCacheControl, mergeVaryHeader } from '../../dist/runtime/cache-control.js';
@@ -210,7 +211,11 @@ export {}
210
211
  });
211
212
  addTemplate({
212
213
  filename: "types/ai-ready-virtual.d.ts",
213
- getContents: () => `declare module '#ai-ready-virtual/read-page-data.mjs' {
214
+ getContents: () => `declare module '#ai-ready-virtual/sitemap-config.mjs' {
215
+ const config: { sitemaps?: Record<string, { sitemapName?: string, route?: string, _route?: string }> }
216
+ export default config
217
+ }
218
+ declare module '#ai-ready-virtual/read-page-data.mjs' {
214
219
  export function readMarkdownLinkAvailabilityFromFilesystem(): Promise<{
215
220
  runtimeMarkdownAvailable: boolean
216
221
  paths: string[]
@@ -1840,7 +1845,7 @@ ${details}`);
1840
1845
  const llmsFullRoute = withSiteUrl("llms-full.txt", { withBase: true });
1841
1846
  defaultLlmsTxtSections.push({
1842
1847
  title: "LLM Resources",
1843
- links: [
1848
+ links: config.llmsFullTxt === false ? [] : [
1844
1849
  {
1845
1850
  title: "Full Content",
1846
1851
  href: llmsFullRoute,
@@ -1853,18 +1858,31 @@ ${details}`);
1853
1858
  defaultLlmsTxtSections.push({
1854
1859
  title: "Agent Skills",
1855
1860
  description: `Skills an agent can install from this site. The index at ${indexUrl} carries a sha256 digest for each one.`,
1856
- links: agentSkillsResult.links.map((link) => ({
1861
+ links: [{ title: "Agent Skills index", href: indexUrl, description: "Every published skill and its sha256 digest." }, ...agentSkillsResult.links.map((link) => ({
1857
1862
  title: link.name,
1858
1863
  href: link.source === "local" ? withSiteUrl(link.href.slice(1), { withBase: true }) : resolveExternalSkillUrl(link.href, indexUrl),
1859
1864
  description: link.description
1860
- }))
1865
+ }))]
1861
1866
  });
1862
1867
  }
1868
+ const directiveConfig = config.llmsTxt === false ? void 0 : config.llmsTxt?.directive;
1863
1869
  const llmsTxtDirective = {
1864
- markdown: config.llmsTxt?.directive?.markdown !== false,
1865
- html: config.llmsTxt?.directive?.html === true
1870
+ markdown: directiveConfig?.markdown !== false,
1871
+ html: directiveConfig?.html === true
1866
1872
  };
1873
+ const parsedLlmsTxt = parseLlmsTxtConfig(config.llmsTxt || {});
1874
+ if (parsedLlmsTxt._tag === "Invalid")
1875
+ throw new Error(`[nuxt-ai-ready] ${parsedLlmsTxt.message}`);
1876
+ const publicLlmsTxt = await Promise.all(getLayerDirectories(nuxt).map(
1877
+ (layer) => access(join(layer.public, "llms.txt")).then(() => true).catch((error) => {
1878
+ if (error.code === "ENOENT")
1879
+ return false;
1880
+ throw error;
1881
+ })
1882
+ ));
1883
+ const generateLlmsTxt = config.llmsTxt !== false && !publicLlmsTxt.some(Boolean);
1867
1884
  const mergedLlmsTxt = config.llmsTxt ? {
1885
+ ...parsedLlmsTxt.config,
1868
1886
  markdownLinks: config.llmsTxt.markdownLinks ?? true,
1869
1887
  directive: llmsTxtDirective,
1870
1888
  sections: [
@@ -1880,6 +1898,9 @@ ${details}`);
1880
1898
  await nuxt.callHook("ai-ready:llms-txt", llmsTxtPayload);
1881
1899
  mergedLlmsTxt.sections = llmsTxtPayload.sections;
1882
1900
  mergedLlmsTxt.notes = llmsTxtPayload.notes.length > 0 ? llmsTxtPayload.notes : void 0;
1901
+ const parsedHookLlmsTxt = parseLlmsTxtConfig(mergedLlmsTxt);
1902
+ if (parsedHookLlmsTxt._tag === "Invalid")
1903
+ throw new Error(`[nuxt-ai-ready] ${parsedHookLlmsTxt.message}`);
1883
1904
  const prerenderCacheDir = join(nuxt.options.rootDir, "node_modules/.cache/nuxt-seo/ai-ready/routes");
1884
1905
  const buildDbPath = join(nuxt.options.buildDir, ".data/ai-ready/build.db");
1885
1906
  const runtimeSyncConfig = typeof config.runtimeSync === "object" ? config.runtimeSync : {};
@@ -1896,6 +1917,8 @@ ${details}`);
1896
1917
  if (apiCatalogRegistered)
1897
1918
  return;
1898
1919
  apiCatalogRegistered = true;
1920
+ const resources = mergedLlmsTxt.sections?.find((section) => section.title === "LLM Resources");
1921
+ resources?.links?.push({ title: "API catalog", href: catalog.href, description: "The public API and service descriptions." });
1899
1922
  addServerHandler({ route: API_CATALOG_PATH, handler: resolve$1("./runtime/server/routes/api-catalog"), lazy: true });
1900
1923
  const apiCatalogLink = `<${catalog.href}>; rel="api-catalog"`;
1901
1924
  extendRouteRules("/**", {
@@ -2273,6 +2296,7 @@ export function trackDrizzleWork(event, work) { return work }
2273
2296
  cron: !!config.cron
2274
2297
  })}`;
2275
2298
  nitroConfig.virtual["#ai-ready-virtual/content-lookup.mjs"] = contentLookupModule(contentSource);
2299
+ nitroConfig.virtual["#ai-ready-virtual/sitemap-config.mjs"] = `export { default } from '#sitemap-virtual/static-config.mjs'`;
2276
2300
  });
2277
2301
  nuxt.options.runtimeConfig["nuxt-ai-ready"] = {
2278
2302
  version: version || "0.0.0",
@@ -2288,6 +2312,7 @@ export function trackDrizzleWork(event, work) { return work }
2288
2312
  swr: true
2289
2313
  }),
2290
2314
  llmsTxt: mergedLlmsTxt,
2315
+ llmsFullTxt: config.llmsFullTxt !== false,
2291
2316
  llmsTxtCacheSeconds: config.llmsTxtCacheSeconds ?? 600,
2292
2317
  prerenderCacheDir,
2293
2318
  database,
@@ -2335,10 +2360,13 @@ export function trackDrizzleWork(event, work) { return work }
2335
2360
  mode: "server",
2336
2361
  src: resolve$1("./runtime/app/plugins/md-alternate.server")
2337
2362
  });
2338
- addServerHandler({ route: "/llms.txt", handler: resolve$1("./runtime/server/routes/llms.txt.get"), lazy: true });
2339
- addServerHandler({ route: "/llms-full.txt", handler: resolve$1("./runtime/server/routes/llms-full.txt.get"), lazy: true });
2363
+ if (generateLlmsTxt) {
2364
+ addServerHandler({ middleware: true, method: void 0, handler: resolve$1("./runtime/server/routes/llms.txt.get"), lazy: true });
2365
+ }
2366
+ if (config.llmsFullTxt !== false)
2367
+ addServerHandler({ route: "/llms-full.txt", method: void 0, handler: resolve$1("./runtime/server/routes/llms-full.txt.get"), lazy: true });
2340
2368
  if (config.sitemapMd !== false) {
2341
- addServerHandler({ route: SITEMAP_MD_ROUTE, handler: resolve$1("./runtime/server/routes/sitemap.md.get"), lazy: true });
2369
+ addServerHandler({ route: SITEMAP_MD_ROUTE, method: void 0, handler: resolve$1("./runtime/server/routes/sitemap.md.get"), lazy: true });
2342
2370
  }
2343
2371
  if (agentSkillsResult._tag === "Enabled") {
2344
2372
  addServerHandler({ route: AGENT_SKILLS_INDEX_ROUTE, handler: resolve$1("./runtime/server/routes/agent-skills-index"), lazy: true });
@@ -2414,7 +2442,7 @@ export function trackDrizzleWork(event, work) { return work }
2414
2442
  }
2415
2443
  if (prerenderEnabled) {
2416
2444
  const { setupPrerenderHandler } = await import('../chunks/prerender.mjs');
2417
- setupPrerenderHandler(config, buildDbPath, {
2445
+ setupPrerenderHandler({ ...config, llmsTxt: generateLlmsTxt ? config.llmsTxt : false }, buildDbPath, {
2418
2446
  name: siteConfig.name,
2419
2447
  url: siteConfig.url ? withSiteUrl("/", { withBase: true }) : void 0,
2420
2448
  description: siteConfig.description
@@ -2437,7 +2465,7 @@ export function trackDrizzleWork(event, work) { return work }
2437
2465
  icon: "carbon:ai-label"
2438
2466
  }, resolve$1, nuxt);
2439
2467
  }
2440
- for (const route of ["/llms.txt", "/llms-full.txt"]) {
2468
+ for (const route of [generateLlmsTxt && "/llms.txt", generateLlmsTxt && "/llms/**", config.llmsFullTxt !== false && "/llms-full.txt"].filter((route2) => Boolean(route2))) {
2441
2469
  extendRouteRules(route, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
2442
2470
  }
2443
2471
  if (config.sitemapMd !== false) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-ai-ready",
3
3
  "type": "module",
4
- "version": "3.2.0",
4
+ "version": "3.3.1",
5
5
  "description": "Best practice AI & LLM discoverability for Nuxt sites.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -71,7 +71,7 @@
71
71
  }
72
72
  },
73
73
  "dependencies": {
74
- "@mdream/js": "^2.0.1",
74
+ "@mdream/js": "^2.0.3",
75
75
  "@nuxt/kit": "^4.6.0",
76
76
  "@nuxtjs/robots": "^7.0.0",
77
77
  "@nuxtjs/sitemap": "^9.0.0",
@@ -79,7 +79,7 @@
79
79
  "confbox": "^0.3.1",
80
80
  "consola": "^3.4.2",
81
81
  "defu": "^6.1.7",
82
- "mdream": "^2.0.1",
82
+ "mdream": "^2.0.3",
83
83
  "nuxt-site-config": "^5.0.0",
84
84
  "nuxtseo-shared": "^6.0.0",
85
85
  "ohash": "^2.0.12",
@@ -31,7 +31,7 @@ A page visit in dev or production does not index anything.
31
31
 
32
32
  With no `aiReady` config, the module adds:
33
33
 
34
- - `/llms.txt`: site name, description, resource links, and a `## Pages` list. In dev it lists sitemap routes without titles.
34
+ - `/llms.txt`: site name, description, resource links, and current sitemap pages. Stored pages supply metadata. Large lists split under `/llms/`.
35
35
  - `/llms-full.txt`: the full Markdown of each prerendered page. Only prerendered pages go in the static file.
36
36
  - A `.md` twin per page. `/about` becomes `/about.md`, and `/` becomes `/index.md`. Paths under `/api` and `/_` get none.
37
37
  - Accept negotiation: a request that prefers `text/markdown` gets a `307` to the `.md` URL. Responses send `Vary: Accept`. AI bot user agents get Markdown only with `botNegotiation: true`.
@@ -58,6 +58,32 @@ export default defineNuxtConfig({
58
58
  })
59
59
  ```
60
60
 
61
+ Publish separate discovery files from named sitemaps. Use module configuration before writing sitemap and database middleware:
62
+
63
+ ```ts
64
+ export default defineNuxtConfig({
65
+ aiReady: {
66
+ llmsFullTxt: false,
67
+ llmsTxt: {
68
+ markdownLinks: true,
69
+ sitemaps: ['pages', 'skills', 'demos'],
70
+ sections: [
71
+ { title: 'Skills', sitemap: 'skills', path: '/llms/skills.txt' },
72
+ { title: 'Demos', sitemap: 'demos', path: '/llms/demos.txt' },
73
+ ],
74
+ },
75
+ },
76
+ })
77
+ ```
78
+
79
+ The root links each section file. Chunked sitemaps keep their configured name.
80
+ Every file stays within `maxLength`, default 80,000 characters. Static builds emit every linked child.
81
+ GET and HEAD work on every generated discovery file. Empty sections produce no link or file.
82
+
83
+ For manual discovery, place `public/llms.txt`. The module preserves it and keeps Markdown conversion enabled.
84
+ If a server handler owns discovery, set `llmsTxt: false`.
85
+ Use `llmsFullTxt: false` instead of redirecting the full export to the index.
86
+
61
87
  Remove an element from the Markdown on every path, build and runtime. Put it in `mdreamOptions`; the defaults `minimal` and `clean` stay on:
62
88
 
63
89
  ```ts
@@ -92,6 +118,8 @@ Runtime indexing, database drivers, Cloudflare D1, and the server query helpers:
92
118
 
93
119
  ## Traps
94
120
 
121
+ - **Discovery is not authorization.** Selected sitemaps exclude stale rows from `llms.txt`; they do not delete stored content or protect other APIs.
122
+ - **Custom route policies must allow Markdown twins.** If middleware rejects `/skills/planning.md`, allow it whenever `/skills/planning` is allowed.
95
123
  - **The same edit in both hooks applies twice.** During prerendering the Nitro `ai-ready:page:markdown` hook runs, then the Nuxt hook of the same name. Put an edit in one of them, not both.
96
124
  - **Content source pages skip conversion hooks.** A Nuxt Content page ignores `mdreamOptions` and both Nitro hooks.
97
125
  - **Dev has no page data.** Query helpers, MCP tools, and search return empty results in `nuxt dev`. Test with `nuxt build` and `node .output/server/index.mjs`.
@@ -126,6 +154,11 @@ Runtime indexing, database drivers, Cloudflare D1, and the server query helpers:
126
154
 
127
155
  ## Config
128
156
 
157
+ - `llmsTxt` (`object`): generated discovery. Set `false` for application-owned server routes. A public `llms.txt` disables generation automatically.
158
+ - `llmsTxt.pages` (`'sitemap'`): sitemap admission. Use `'all'` for stored pages outside the sitemap, or `false` for configured sections only.
159
+ - `llmsTxt.sitemaps`: selected sitemap names. An empty array stops sitemap discovery.
160
+ - `llmsTxt.maxLength` (`80000`): maximum characters per file. Minimum `1024`. Larger lists split without dropping links.
161
+ - `llmsFullTxt` (`true`): generate the full export and advertise its link. Set `false` to omit both.
129
162
  - `llmsTxt.directive` (`{ markdown: true, html: false }`): a `> Index of every page on this site: [llms.txt](...)` line after the frontmatter of each page's `.md`; `html: true` adds a visually hidden `/llms.txt` link at the top of each HTML body.
130
163
  - `llmsTxt.markdownLinks` (`true`): link page entries in `llms.txt` to their `.md` twins. `false` links canonical URLs. With `site.url` set, page links in `llms.txt`, `sitemap.md`, and `.md` bodies are absolute.
131
164
  - `describedby` (`true`): the `describedby` link tag and header.