nuxt-ai-ready 3.1.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.
Files changed (30) hide show
  1. package/dist/chunks/agent-skills.mjs +2 -1
  2. package/dist/chunks/prerender.mjs +196 -29
  3. package/dist/module.d.mts +1 -0
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +2 -1
  6. package/dist/runtime/llms-txt-directive.d.ts +15 -0
  7. package/dist/runtime/llms-txt-directive.js +23 -0
  8. package/dist/runtime/llms-txt-files.d.ts +30 -0
  9. package/dist/runtime/llms-txt-files.js +141 -0
  10. package/dist/runtime/llms-txt-utils.d.ts +3 -2
  11. package/dist/runtime/llms-txt-utils.js +85 -221
  12. package/dist/runtime/markdown-links.d.ts +10 -0
  13. package/dist/runtime/markdown-links.js +52 -0
  14. package/dist/runtime/server/db/queries.d.ts +3 -0
  15. package/dist/runtime/server/db/queries.js +28 -2
  16. package/dist/runtime/server/middleware/markdown.js +9 -2
  17. package/dist/runtime/server/middleware/markdown.prerender.js +2 -1
  18. package/dist/runtime/server/plugins/llms-txt-directive-html.d.ts +3 -0
  19. package/dist/runtime/server/plugins/llms-txt-directive-html.js +8 -0
  20. package/dist/runtime/server/routes/llms-full.txt.get.js +2 -1
  21. package/dist/runtime/server/routes/llms.txt.get.js +19 -11
  22. package/dist/runtime/server/routes/sitemap.md.get.js +6 -3
  23. package/dist/runtime/server/utils/markdown-request.js +2 -5
  24. package/dist/runtime/server/utils/sitemap.d.ts +1 -0
  25. package/dist/runtime/server/utils/sitemap.js +22 -10
  26. package/dist/runtime/server/utils.js +2 -1
  27. package/dist/runtime/types.d.ts +30 -2
  28. package/dist/shared/{nuxt-ai-ready.Dmvy7CIJ.mjs → nuxt-ai-ready.BwnSMqRn.mjs} +94 -28
  29. package/package.json +1 -1
  30. package/skills/nuxt-ai-ready/SKILL.md +37 -2
@@ -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
  }
@@ -1,5 +1,6 @@
1
1
  import { htmlToMarkdown } from "mdream";
2
2
  import { useNitroApp } from "#nuxtseo/nitro";
3
+ import { stripLlmsTxtDirectiveHtml } from "../llms-txt-directive.js";
3
4
  import { hoistMetaDescription } from "./utils/frontmatter.js";
4
5
  const RE_NBSP = /\u00A0/g;
5
6
  function normalizeWhitespace(text) {
@@ -75,7 +76,7 @@ export async function convertHtmlToMarkdown(html, url, mdreamOptions, opts) {
75
76
  await nitroApp.hooks.callHook("ai-ready:mdreamConfig", options);
76
77
  const context = {
77
78
  html,
78
- markdown: hoistMetaDescription(htmlToMarkdown(html, options)),
79
+ markdown: hoistMetaDescription(htmlToMarkdown(stripLlmsTxtDirectiveHtml(html), options)),
79
80
  route: opts.hooks.route,
80
81
  title: meta.title,
81
82
  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,13 +531,35 @@ 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
531
543
  * the runtime Markdown handler.
532
- * @default false
544
+ * @default true
533
545
  */
534
546
  markdownLinks?: boolean;
547
+ /**
548
+ * Point each page at llms.txt, so an agent that lands on one page finds the
549
+ * whole site's index.
550
+ */
551
+ directive?: {
552
+ /**
553
+ * Add a blockquote line after the frontmatter of every page's `.md` response.
554
+ * @default true
555
+ */
556
+ markdown?: boolean;
557
+ /**
558
+ * Add a visually hidden link to `/llms.txt` at the top of every HTML page's body.
559
+ * @default false
560
+ */
561
+ html?: boolean;
562
+ };
535
563
  /** The sections of the documentation */
536
564
  sections?: LlmsTxtSection[];
537
565
  /** Additional heading-free preamble notes rendered before file-list sections */
@@ -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[]
@@ -666,18 +671,42 @@ function cloudflareStaticFallbackPaths(existing, pages, environments = []) {
666
671
  const patterns = [...existing || [], ...environments.flatMap((routing) => Array.isArray(routing) ? routing : [])];
667
672
  return pagePaths(pages).filter((path) => !patterns.some((pattern) => matchesPattern(path, pattern.startsWith("!") ? pattern.slice(1) : pattern)));
668
673
  }
669
- function cloudflareNegotiationPatterns(existing, pages) {
670
- if (existing === true || existing === false || pages.length === 0)
671
- return existing;
672
- const patterns = [...existing || []];
673
- for (const path of cloudflareStaticFallbackPaths(existing, pages)) {
674
- if (!patterns.includes(path))
675
- patterns.push(path);
674
+ function parentDirectories(path) {
675
+ const segments = path.split("/").filter(Boolean);
676
+ return segments.slice(0, -1).map((_, index) => `/${segments.slice(0, index + 1).join("/")}/`);
677
+ }
678
+ function fitWorkerFirstBudget(paths, budget) {
679
+ let remaining = [...paths];
680
+ const globs = [];
681
+ while (remaining.length + globs.length > budget) {
682
+ const counts = /* @__PURE__ */ new Map();
683
+ for (const path of remaining) {
684
+ for (const directory2 of parentDirectories(path))
685
+ counts.set(directory2, (counts.get(directory2) ?? 0) + 1);
686
+ }
687
+ let best;
688
+ for (const [directory2, count] of counts) {
689
+ if (!best || count > best[1] || count === best[1] && directory2.length > best[0].length)
690
+ best = [directory2, count];
691
+ }
692
+ if (!best || best[1] < 2)
693
+ return void 0;
694
+ const [directory] = best;
695
+ globs.push(`${directory}*`);
696
+ remaining = remaining.filter((path) => !path.startsWith(directory) || path === directory);
676
697
  }
677
- if (patterns.length > MAX_WORKER_FIRST_PATTERNS) {
678
- throw new Error("Cloudflare Markdown negotiation exceeds 100 worker-first patterns. Configure broader assets.run_worker_first patterns, or disable aiReady.contentNegotiation.");
698
+ return [...remaining, ...globs];
699
+ }
700
+ function cloudflareWorkerFirstAdditions(existing, pages) {
701
+ if (existing === true || existing === false || pages.length === 0)
702
+ return [];
703
+ const patterns = existing || [];
704
+ const paths = cloudflareStaticFallbackPaths(existing, pages).filter((path) => !patterns.includes(path));
705
+ const additions = fitWorkerFirstBudget(paths, MAX_WORKER_FIRST_PATTERNS - patterns.length);
706
+ if (!additions) {
707
+ throw new Error("Cloudflare Markdown negotiation exceeds 100 worker-first patterns, even with directory globs. Configure broader assets.run_worker_first patterns, or disable aiReady.contentNegotiation.");
679
708
  }
680
- return patterns;
709
+ return additions;
681
710
  }
682
711
 
683
712
  const CACHE_GUIDE = "https://nuxtseo.com/docs/ai-ready/guides/markdown#cache-safety";
@@ -1219,6 +1248,14 @@ function createMcpServerCardEtag(card) {
1219
1248
  return `"${createHash("sha256").update(JSON.stringify(card)).digest("hex")}"`;
1220
1249
  }
1221
1250
 
1251
+ function opaqueJsonModule(exports) {
1252
+ const encode = (value) => JSON.stringify(Buffer.from(JSON.stringify(value), "utf8").toString("base64"));
1253
+ return [
1254
+ "const decode = value => JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(value), char => char.charCodeAt(0))))",
1255
+ ...Object.entries(exports).map(([name, value]) => `export const ${name} = decode(${encode(value)})`)
1256
+ ].join("\n");
1257
+ }
1258
+
1222
1259
  function escapeRegExp(value) {
1223
1260
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1224
1261
  }
@@ -1808,7 +1845,7 @@ ${details}`);
1808
1845
  const llmsFullRoute = withSiteUrl("llms-full.txt", { withBase: true });
1809
1846
  defaultLlmsTxtSections.push({
1810
1847
  title: "LLM Resources",
1811
- links: [
1848
+ links: config.llmsFullTxt === false ? [] : [
1812
1849
  {
1813
1850
  title: "Full Content",
1814
1851
  href: llmsFullRoute,
@@ -1821,21 +1858,39 @@ ${details}`);
1821
1858
  defaultLlmsTxtSections.push({
1822
1859
  title: "Agent Skills",
1823
1860
  description: `Skills an agent can install from this site. The index at ${indexUrl} carries a sha256 digest for each one.`,
1824
- 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) => ({
1825
1862
  title: link.name,
1826
1863
  href: link.source === "local" ? withSiteUrl(link.href.slice(1), { withBase: true }) : resolveExternalSkillUrl(link.href, indexUrl),
1827
1864
  description: link.description
1828
- }))
1865
+ }))]
1829
1866
  });
1830
1867
  }
1868
+ const directiveConfig = config.llmsTxt === false ? void 0 : config.llmsTxt?.directive;
1869
+ const llmsTxtDirective = {
1870
+ markdown: directiveConfig?.markdown !== false,
1871
+ html: directiveConfig?.html === true
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);
1831
1884
  const mergedLlmsTxt = config.llmsTxt ? {
1832
- markdownLinks: config.llmsTxt.markdownLinks ?? false,
1885
+ ...parsedLlmsTxt.config,
1886
+ markdownLinks: config.llmsTxt.markdownLinks ?? true,
1887
+ directive: llmsTxtDirective,
1833
1888
  sections: [
1834
1889
  ...defaultLlmsTxtSections,
1835
1890
  ...config.llmsTxt.sections || []
1836
1891
  ],
1837
1892
  notes: config.llmsTxt.notes
1838
- } : { markdownLinks: false, sections: defaultLlmsTxtSections };
1893
+ } : { markdownLinks: true, directive: llmsTxtDirective, sections: defaultLlmsTxtSections };
1839
1894
  const llmsTxtPayload = {
1840
1895
  sections: mergedLlmsTxt.sections || [],
1841
1896
  notes: typeof mergedLlmsTxt.notes === "string" ? [mergedLlmsTxt.notes] : mergedLlmsTxt.notes || []
@@ -1843,6 +1898,9 @@ ${details}`);
1843
1898
  await nuxt.callHook("ai-ready:llms-txt", llmsTxtPayload);
1844
1899
  mergedLlmsTxt.sections = llmsTxtPayload.sections;
1845
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}`);
1846
1904
  const prerenderCacheDir = join(nuxt.options.rootDir, "node_modules/.cache/nuxt-seo/ai-ready/routes");
1847
1905
  const buildDbPath = join(nuxt.options.buildDir, ".data/ai-ready/build.db");
1848
1906
  const runtimeSyncConfig = typeof config.runtimeSync === "object" ? config.runtimeSync : {};
@@ -1859,6 +1917,8 @@ ${details}`);
1859
1917
  if (apiCatalogRegistered)
1860
1918
  return;
1861
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." });
1862
1922
  addServerHandler({ route: API_CATALOG_PATH, handler: resolve$1("./runtime/server/routes/api-catalog"), lazy: true });
1863
1923
  const apiCatalogLink = `<${catalog.href}>; rel="api-catalog"`;
1864
1924
  extendRouteRules("/**", {
@@ -2160,8 +2220,7 @@ export { unavailable as computeLocaleAlternates, unavailable as localePath, unav
2160
2220
  `;
2161
2221
  };
2162
2222
  nitroConfig.virtual["#ai-ready-virtual/site-tools.mjs"] = `export default ${JSON.stringify(siteToolsConfig)}`;
2163
- nitroConfig.virtual["#ai-ready-virtual/agent-skills.mjs"] = agentSkillsResult._tag === "Enabled" ? `export const agentSkillsIndex = ${JSON.stringify(agentSkillsResult.index)}
2164
- export const localAgentSkillArtifacts = ${JSON.stringify(agentSkillsResult.localArtifacts)}` : "export const agentSkillsIndex = null\nexport const localAgentSkillArtifacts = {}";
2223
+ nitroConfig.virtual["#ai-ready-virtual/agent-skills.mjs"] = agentSkillsResult._tag === "Enabled" ? opaqueJsonModule({ agentSkillsIndex: agentSkillsResult.index, localAgentSkillArtifacts: agentSkillsResult.localArtifacts }) : "export const agentSkillsIndex = null\nexport const localAgentSkillArtifacts = {}";
2165
2224
  const markdownLinkAvailabilityPath = join(dirname(buildDbPath), MARKDOWN_LINK_AVAILABILITY_FILE);
2166
2225
  nitroConfig.virtual["#ai-ready-virtual/read-page-data.mjs"] = createBuildPageDataVirtual({
2167
2226
  buildDbPath,
@@ -2237,6 +2296,7 @@ export function trackDrizzleWork(event, work) { return work }
2237
2296
  cron: !!config.cron
2238
2297
  })}`;
2239
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'`;
2240
2300
  });
2241
2301
  nuxt.options.runtimeConfig["nuxt-ai-ready"] = {
2242
2302
  version: version || "0.0.0",
@@ -2252,6 +2312,7 @@ export function trackDrizzleWork(event, work) { return work }
2252
2312
  swr: true
2253
2313
  }),
2254
2314
  llmsTxt: mergedLlmsTxt,
2315
+ llmsFullTxt: config.llmsFullTxt !== false,
2255
2316
  llmsTxtCacheSeconds: config.llmsTxtCacheSeconds ?? 600,
2256
2317
  prerenderCacheDir,
2257
2318
  database,
@@ -2293,14 +2354,20 @@ export function trackDrizzleWork(event, work) { return work }
2293
2354
  }
2294
2355
  addNitroPlugin(resolve$1(getNitroVersion(nuxt) === 3 ? "./runtime/server/plugins/link-header-nitro3" : "./runtime/server/plugins/link-header"));
2295
2356
  addNitroPlugin(resolve$1("./runtime/server/plugins/markdown-negotiation"));
2357
+ if (llmsTxtDirective.html)
2358
+ addNitroPlugin(resolve$1("./runtime/server/plugins/llms-txt-directive-html"));
2296
2359
  addPlugin({
2297
2360
  mode: "server",
2298
2361
  src: resolve$1("./runtime/app/plugins/md-alternate.server")
2299
2362
  });
2300
- addServerHandler({ route: "/llms.txt", handler: resolve$1("./runtime/server/routes/llms.txt.get"), lazy: true });
2301
- addServerHandler({ route: "/llms-full.txt", handler: resolve$1("./runtime/server/routes/llms-full.txt.get"), lazy: true });
2363
+ if (generateLlmsTxt) {
2364
+ for (const route of ["/llms.txt", "/llms/**"])
2365
+ addServerHandler({ route, method: void 0, handler: resolve$1("./runtime/server/routes/llms.txt.get"), lazy: true });
2366
+ }
2367
+ if (config.llmsFullTxt !== false)
2368
+ addServerHandler({ route: "/llms-full.txt", method: void 0, handler: resolve$1("./runtime/server/routes/llms-full.txt.get"), lazy: true });
2302
2369
  if (config.sitemapMd !== false) {
2303
- addServerHandler({ route: SITEMAP_MD_ROUTE, handler: resolve$1("./runtime/server/routes/sitemap.md.get"), lazy: true });
2370
+ addServerHandler({ route: SITEMAP_MD_ROUTE, method: void 0, handler: resolve$1("./runtime/server/routes/sitemap.md.get"), lazy: true });
2304
2371
  }
2305
2372
  if (agentSkillsResult._tag === "Enabled") {
2306
2373
  addServerHandler({ route: AGENT_SKILLS_INDEX_ROUTE, handler: resolve$1("./runtime/server/routes/agent-skills-index"), lazy: true });
@@ -2376,7 +2443,7 @@ export function trackDrizzleWork(event, work) { return work }
2376
2443
  }
2377
2444
  if (prerenderEnabled) {
2378
2445
  const { setupPrerenderHandler } = await import('../chunks/prerender.mjs');
2379
- setupPrerenderHandler(config, buildDbPath, {
2446
+ setupPrerenderHandler({ ...config, llmsTxt: generateLlmsTxt ? config.llmsTxt : false }, buildDbPath, {
2380
2447
  name: siteConfig.name,
2381
2448
  url: siteConfig.url ? withSiteUrl("/", { withBase: true }) : void 0,
2382
2449
  description: siteConfig.description
@@ -2399,7 +2466,7 @@ export function trackDrizzleWork(event, work) { return work }
2399
2466
  icon: "carbon:ai-label"
2400
2467
  }, resolve$1, nuxt);
2401
2468
  }
2402
- for (const route of ["/llms.txt", "/llms-full.txt"]) {
2469
+ for (const route of [generateLlmsTxt && "/llms.txt", generateLlmsTxt && "/llms/**", config.llmsFullTxt !== false && "/llms-full.txt"].filter((route2) => Boolean(route2))) {
2403
2470
  extendRouteRules(route, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
2404
2471
  }
2405
2472
  if (config.sitemapMd !== false) {
@@ -2446,13 +2513,12 @@ export function trackDrizzleWork(event, work) { return work }
2446
2513
  const assets = wrangler.assets ||= {};
2447
2514
  const fileRouting = await readCloudflareAssetRouting(nitro.options.rootDir);
2448
2515
  const routing = defu$1({ value: assets.run_worker_first }, { value: fileRouting.root }).value;
2449
- cloudflareNegotiationPatterns(routing, pages);
2516
+ const additions = cloudflareWorkerFirstAdditions(routing, pages);
2450
2517
  const environmentConfig = wrangler;
2451
2518
  const configuredEnvironments = Object.values(environmentConfig.env || {}).flatMap((env) => env.assets?.run_worker_first === void 0 ? [] : [env.assets.run_worker_first]);
2452
2519
  const fallbackPaths = cloudflareStaticFallbackPaths(routing, pages, [...fileRouting.environments, ...configuredEnvironments]);
2453
- const addedPaths = cloudflareStaticFallbackPaths(routing, pages);
2454
- if (addedPaths.length > 0)
2455
- assets.run_worker_first = [...Array.isArray(assets.run_worker_first) ? assets.run_worker_first : [], ...addedPaths];
2520
+ if (additions.length > 0)
2521
+ assets.run_worker_first = [...Array.isArray(assets.run_worker_first) ? assets.run_worker_first : [], ...additions];
2456
2522
  nitro.options.runtimeConfig["nuxt-ai-ready"].cloudflarePrerenderedPages = fallbackPaths;
2457
2523
  }
2458
2524
  const plan = planStaticMarkdownHeaderRules(
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-ai-ready",
3
3
  "type": "module",
4
- "version": "3.1.0",
4
+ "version": "3.3.0",
5
5
  "description": "Best practice AI & LLM discoverability for Nuxt sites.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -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,9 +118,12 @@ 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
+ - **Cloudflare static pages negotiate only with `contentNegotiation: true`.** The default leaves prerendered HTML on the asset service, so `Accept: text/markdown` gets HTML there. With `true`, prerendered pages go into `assets.run_worker_first`: exact paths up to Cloudflare's 100 entries, directory globs such as `/docs/*` past that. More than 100 top-level pages fail the build; set broader patterns yourself.
98
127
  - **Response caching turns negotiation off.** An ISR, `swr`, or `cache` route rule without `varies: ['accept']` serves HTML to Markdown clients. With `botNegotiation: true`, the rule needs `varies: ['accept', 'sec-fetch-dest', 'user-agent']`. The `.md` URL still works. For a CDN that caches by URL only, set `contentNegotiation: false`.
99
128
  - **Emptying `ctx.markdown` does not hide a page.** It drops the body from `llms-full.txt`. The URL stays in `llms.txt` and in the index. Use `sitemap.exclude` for discovery, and access control for private pages.
100
129
  - **Root `skills/` folders require explicit publication.** Automatic discovery scans public directories only. Set `agentSkills.dir: 'skills'` to publish root skills intentionally.
@@ -125,7 +154,13 @@ Runtime indexing, database drivers, Cloudflare D1, and the server query helpers:
125
154
 
126
155
  ## Config
127
156
 
128
- - `llmsTxt.markdownLinks` (`false`): link page entries in `llms.txt` to their `.md` twins.
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.
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.
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.
129
164
  - `describedby` (`true`): the `describedby` link tag and header.
130
165
  - `tools.<listPages|searchPages|getPageMarkdown>.<mcp|webmcp>.enabled`: attach a built in tool to one transport only.
131
166
  - `autoI18n` (`true`): with `@nuxtjs/i18n`, adds hreflang `Link` headers and a languages section in `llms.txt`.