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
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { realpath, readFile, readdir } from 'node:fs/promises';
3
3
  import { join, resolve, isAbsolute, relative, sep } from 'node:path';
4
4
  import { parseDocument } from 'yaml';
5
- import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.Dmvy7CIJ.mjs';
5
+ import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.BwnSMqRn.mjs';
6
6
  import 'node:module';
7
7
  import 'node:url';
8
8
  import '@nuxt/kit';
@@ -11,6 +11,7 @@ import 'nuxt-site-config/kit';
11
11
  import 'nuxtseo-shared/kit';
12
12
  import '../shared/nuxt-ai-ready.B3hAuOLQ.mjs';
13
13
  import 'node:fs';
14
+ import '../../dist/runtime/llms-txt-files.js';
14
15
  import '../../dist/runtime/route-path.js';
15
16
  import '../../dist/runtime/server/utils/sitemap-md.js';
16
17
  import '../../dist/runtime/cache-control.js';
@@ -1,14 +1,16 @@
1
- import { mkdir, writeFile, appendFile, stat, readdir } from 'node:fs/promises';
2
- import { join, dirname, relative, resolve } from 'node:path';
1
+ import { mkdir, writeFile, appendFile, readFile, stat, readdir } from 'node:fs/promises';
2
+ import { resolve, relative, join, dirname } from 'node:path';
3
3
  import { useNuxt, getNitroVersion, hasNuxtModule, resolveFiles } from '@nuxt/kit';
4
4
  import { colorize } from 'consola/utils';
5
5
  import { resolveLocaleFromRoute } from 'nuxtseo-shared/i18n-runtime';
6
6
  import { createPrerenderFetch } from 'nuxtseo-shared/prerender';
7
7
  import { collectSitemap } from 'sitemapd/parse';
8
- import { withLeadingSlash, joinURL, withBase } from 'ufo';
9
- import { l as logger, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.Dmvy7CIJ.mjs';
10
- import { normalizePagePath, toMarkdownPath } from '../../dist/runtime/markdown-path.js';
11
- import { toDeployedRoute, toLogicalRoute } from '../../dist/runtime/route-path.js';
8
+ import { parseURL, withLeadingSlash, joinURL, withBase } from 'ufo';
9
+ import { l as logger, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.BwnSMqRn.mjs';
10
+ import { toLogicalRoute, toDeployedRoute } from '../../dist/runtime/route-path.js';
11
+ import { insertLlmsTxtDirective } from '../../dist/runtime/llms-txt-directive.js';
12
+ import { absoluteSiteUrl } from '../../dist/runtime/markdown-links.js';
13
+ import { normalizePagePath, hasMarkdownTwin, toMarkdownPath } from '../../dist/runtime/markdown-path.js';
12
14
  import { initSchema, computeContentHash, insertPage, queryAllPages, exportDbDump } from '../../dist/runtime/server/db/shared.js';
13
15
  import { buildLlmsFullTxtHeader, formatPageForLlmsFullTxt } from '../../dist/runtime/server/utils/llms-full.js';
14
16
  import { resolvePageUpdatedAt } from '../../dist/runtime/server/utils/page-updated-at.js';
@@ -21,6 +23,7 @@ import 'nuxt-site-config/kit';
21
23
  import 'nuxtseo-shared/kit';
22
24
  import '../shared/nuxt-ai-ready.B3hAuOLQ.mjs';
23
25
  import 'node:fs';
26
+ import '../../dist/runtime/llms-txt-files.js';
24
27
  import '../../dist/runtime/cache-control.js';
25
28
  import '../../dist/runtime/server/utils/discovery-response.js';
26
29
  import 'confbox';
@@ -29,6 +32,33 @@ import '../../dist/runtime/server/utils/content-negotiation.js';
29
32
  import 'pathe';
30
33
  import 'nuxtseo-shared/i18n';
31
34
 
35
+ function parsePrerenderRoute(input, baseURL) {
36
+ let pathname = parseURL(input).pathname;
37
+ try {
38
+ pathname = decodeURIComponent(pathname);
39
+ } catch {
40
+ return { _tag: "Invalid" };
41
+ }
42
+ while (true) {
43
+ if (pathname.includes("\\") || [...pathname].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127) || pathname.split("/").some((segment) => segment === "." || segment === ".."))
44
+ return { _tag: "Invalid" };
45
+ const decoded = pathname.replace(/%([\da-f]{2})/gi, (_, byte) => String.fromCharCode(Number.parseInt(byte, 16)));
46
+ if (decoded === pathname)
47
+ break;
48
+ pathname = decoded;
49
+ }
50
+ return { _tag: "Valid", route: toLogicalRoute(input, baseURL) };
51
+ }
52
+ function resolvePrerenderFile(root, route) {
53
+ if (parsePrerenderRoute(route, "/")._tag === "Invalid")
54
+ throw new Error(`Unsafe prerender file path: ${route}`);
55
+ const destination = resolve(root, `.${route.startsWith("/") ? route : `/${route}`}`);
56
+ const child = relative(resolve(root), destination);
57
+ if (child === ".." || child.startsWith("../") || child.startsWith("..\\"))
58
+ throw new Error(`Prerender file path escapes its root: ${route}`);
59
+ return destination;
60
+ }
61
+
32
62
  const PRERENDER_PAGE_TIMEOUT = 3e4;
33
63
  const RE_HTML_MD_EXT = /\.(html|md)$/;
34
64
  const RE_INDEX_SUFFIX = /\/index$/;
@@ -184,7 +214,32 @@ async function processMarkdownRoute(state, nuxt, route, parsed, lastmod, options
184
214
  async function processSitemapEntry(state, nuxt, nitro, entry) {
185
215
  const loc = typeof entry === "string" ? entry : entry.loc;
186
216
  const lastmod = typeof entry === "string" ? void 0 : entry.lastmod;
187
- const route = toLogicalRoute(loc, nitro.options.baseURL);
217
+ const parsedRoute = parsePrerenderRoute(loc, nitro.options.baseURL);
218
+ if (parsedRoute._tag === "Invalid") {
219
+ logger.warn(`Skipping unsafe sitemap URL: ${loc}`);
220
+ return { crawled: false, skipped: true };
221
+ }
222
+ const route = parsedRoute.route;
223
+ if (!hasMarkdownTwin(route))
224
+ return { crawled: false, skipped: true };
225
+ if (nitro.options.static) {
226
+ for (const asset of nitro.options.publicAssets) {
227
+ const assetBase = asset.baseURL || "/";
228
+ if (!route.startsWith(assetBase))
229
+ continue;
230
+ const assetRoute = route.slice(assetBase.length);
231
+ for (const htmlRoute of [`${assetRoute}.html`, `${assetRoute}/index.html`]) {
232
+ const htmlPath = resolvePrerenderFile(asset.dir, htmlRoute);
233
+ const existing = await stat(htmlPath).catch((error) => {
234
+ if (error.code === "ENOENT")
235
+ return void 0;
236
+ throw error;
237
+ });
238
+ if (existing?.isFile())
239
+ return { crawled: false, skipped: true };
240
+ }
241
+ }
242
+ }
188
243
  if (route.split("/").some((segment) => segment.startsWith("_"))) {
189
244
  return { crawled: false, skipped: true };
190
245
  }
@@ -193,12 +248,24 @@ async function processSitemapEntry(state, nuxt, nitro, entry) {
193
248
  }
194
249
  const mdRoute = toMarkdownPath(route);
195
250
  const mdUrl = toDeployedRoute(mdRoute, nitro.options.baseURL);
251
+ const candidates = [route, toDeployedRoute(route, nitro.options.baseURL), mdRoute, mdUrl];
252
+ const ignored = nitro.options.prerender.ignore.some((pattern) => candidates.some((path) => {
253
+ if (typeof pattern === "string")
254
+ return path.startsWith(pattern);
255
+ if (typeof pattern === "function")
256
+ return pattern(path) === true;
257
+ return pattern.test(path);
258
+ }));
259
+ if (ignored)
260
+ return { crawled: false, skipped: true };
196
261
  logger.debug(`Fetching markdown for ${route} \u2192 ${mdUrl}`);
197
262
  const res = await crawlerFetch(state)(mdUrl, {
198
263
  headers: { "x-nitro-prerender": mdRoute },
199
264
  retry: 0,
200
265
  signal: AbortSignal.timeout(PRERENDER_PAGE_TIMEOUT)
201
266
  }).catch((err) => {
267
+ if (err?.response?.status === 404 || err?.response?.status === 410)
268
+ state.errorRoutes.add(route);
202
269
  if (err?.name === "TimeoutError" || err?.name === "AbortError")
203
270
  logger.warn(`Timeout (${PRERENDER_PAGE_TIMEOUT}ms) fetching markdown for ${route}`);
204
271
  else
@@ -215,6 +282,29 @@ async function processSitemapEntry(state, nuxt, nitro, entry) {
215
282
  return { crawled: false, skipped: false };
216
283
  }
217
284
  await processMarkdownRoute(state, nuxt, route, parsed, lastmod, { skipLlmsFullTxt: true });
285
+ if (nitro.options.static) {
286
+ for (const asset of nitro.options.publicAssets) {
287
+ const assetBase = asset.baseURL || "/";
288
+ if (!mdRoute.startsWith(assetBase))
289
+ continue;
290
+ const source = resolvePrerenderFile(asset.dir, mdRoute.slice(assetBase.length));
291
+ const existing = await stat(source).catch((error) => {
292
+ if (error.code === "ENOENT")
293
+ return void 0;
294
+ throw error;
295
+ });
296
+ if (existing?.isFile())
297
+ return { crawled: true, skipped: false };
298
+ }
299
+ const generated = { route: mdRoute, fileName: mdRoute, contents: JSON.stringify(parsed) };
300
+ await nitro.hooks.callHook("prerender:generate", generated, nitro);
301
+ if (generated.skip || generated.error)
302
+ return { crawled: true, skipped: false };
303
+ const filePath = resolvePrerenderFile(nitro.options.output.publicDir, mdRoute);
304
+ await mkdir(dirname(filePath), { recursive: true });
305
+ await writeFile(filePath, generated.contents || "", "utf8");
306
+ nitro._prerenderedRoutes.push(generated);
307
+ }
218
308
  return { crawled: true, skipped: false };
219
309
  }
220
310
  async function crawlSitemapEntries(state, nuxt, nitro, entries) {
@@ -235,18 +325,72 @@ async function crawlSitemapEntries(state, nuxt, nitro, entries) {
235
325
  logger.debug(`Sitemap crawl complete: ${crawled} crawled, ${skipped} skipped`);
236
326
  return crawled;
237
327
  }
238
- async function crawlSitemapContent(state, nuxt, nitro, sitemapContent) {
328
+ async function crawlSitemapContent(state, nuxt, nitro, sitemapContent, seen = /* @__PURE__ */ new Set(), depth = 0, documents = /* @__PURE__ */ new Map()) {
239
329
  logger.debug(`Parsing sitemap XML (${sitemapContent.length} bytes)`);
240
330
  const result = await collectSitemap(sitemapContent);
241
- if (result._tag !== "document" || result.document._tag !== "urlset") {
331
+ if (result._tag !== "document") {
242
332
  const issues = result.issues.map((issue) => issue.message).join("; ");
243
333
  logger.debug(`Skipping sitemap: ${issues || "document is not a URL set"}`);
244
334
  return 0;
245
335
  }
336
+ if (result.document._tag === "index") {
337
+ if (depth >= 3 || seen.size >= 100) {
338
+ logger.warn("Sitemap crawl reached its document limit.");
339
+ return 0;
340
+ }
341
+ let crawled = 0;
342
+ const origin = new URL(state.siteInfo?.url || "http://localhost").origin;
343
+ for (const entry of result.document.entries) {
344
+ if (parsePrerenderRoute(entry.loc, nitro.options.baseURL)._tag === "Invalid")
345
+ continue;
346
+ const url = new URL(entry.loc, origin);
347
+ if (url.origin !== origin || seen.has(url.href))
348
+ continue;
349
+ if (seen.size >= 100) {
350
+ logger.warn("Sitemap crawl reached its document limit.");
351
+ break;
352
+ }
353
+ seen.add(url.href);
354
+ const content = documents.get(toLogicalRoute(url.pathname, nitro.options.baseURL))?.() ?? await crawlerFetch(state)(`${url.pathname}${url.search}`, {
355
+ headers: { "x-nitro-prerender": url.pathname },
356
+ retry: 0,
357
+ signal: AbortSignal.timeout(PRERENDER_PAGE_TIMEOUT)
358
+ }).catch((error) => {
359
+ logger.warn(`Sitemap crawl failed for ${url.pathname}: ${String(error)}`);
360
+ return null;
361
+ });
362
+ if (content)
363
+ crawled += await crawlSitemapContent(state, nuxt, nitro, content, seen, depth + 1, documents);
364
+ }
365
+ return crawled;
366
+ }
246
367
  const urls = result.document.entries;
247
368
  logger.debug(`Found ${urls.length} URLs in sitemap`);
248
369
  return crawlSitemapEntries(state, nuxt, nitro, urls);
249
370
  }
371
+ async function crawlConfiguredSitemaps(state, nuxt, nitro, sitemap, documents = /* @__PURE__ */ new Map()) {
372
+ const names = state.llmsTxtConfig?.sitemaps;
373
+ const singleName = (sitemap.sitemapName || "sitemap.xml").replace(/\.xml$/, "");
374
+ const roots = sitemap.sitemaps && typeof sitemap.sitemaps === "object" ? Object.entries(sitemap.sitemaps).flatMap(([key, definition]) => {
375
+ const name = (definition.sitemapName || key).replace(/\.xml$/, "");
376
+ if (key === "index" || names !== void 0 && !names.includes(name))
377
+ return [];
378
+ const route = definition._route || definition.route;
379
+ return [route ? toLogicalRoute(route, nitro.options.baseURL) : joinURL(sitemap.sitemapsPathPrefix ?? "/__sitemap__/", `${name}.xml`)];
380
+ }) : names !== void 0 && !names.includes(sitemap.sitemaps ? "chunks" : singleName) ? [] : [sitemap.sitemaps ? "/sitemap_index.xml" : `/${sitemap.sitemapName || "sitemap.xml"}`];
381
+ for (const root of roots) {
382
+ const content = documents.get(root)?.() ?? await crawlerFetch(state)(toDeployedRoute(root, nitro.options.baseURL), {
383
+ headers: { "x-nitro-prerender": root },
384
+ retry: 0,
385
+ signal: AbortSignal.timeout(PRERENDER_PAGE_TIMEOUT)
386
+ }).catch((error) => {
387
+ logger.warn(`Sitemap crawl failed for ${root}: ${String(error)}`);
388
+ return null;
389
+ });
390
+ if (content)
391
+ await crawlSitemapContent(state, nuxt, nitro, content, /* @__PURE__ */ new Set(), 0, documents);
392
+ }
393
+ }
250
394
  function isNuxtGenerate() {
251
395
  return process.argv.includes("generate") || process.env.NUXT_GENERATE === "true" || process.env.prerender === "true";
252
396
  }
@@ -272,6 +416,7 @@ function detectSitemapPrerender(sitemapName = "sitemap.xml") {
272
416
  };
273
417
  }
274
418
  async function prerenderRoute(nitro, route, fetch) {
419
+ const filePath = resolvePrerenderFile(nitro.options.output.publicDir, route);
275
420
  const start = Date.now();
276
421
  const encodedRoute = encodeURI(route);
277
422
  const fetchUrl = withBase(encodedRoute, nitro.options.baseURL);
@@ -280,7 +425,6 @@ async function prerenderRoute(nitro, route, fetch) {
280
425
  retry: nitro.options.prerender.retry,
281
426
  retryDelay: nitro.options.prerender.retryDelay
282
427
  });
283
- const filePath = join(nitro.options.output.publicDir, route);
284
428
  logger.debug(`Creating directory for prerender: ${dirname(filePath)}`);
285
429
  await mkdir(dirname(filePath), { recursive: true });
286
430
  const data = res._data;
@@ -302,7 +446,7 @@ async function prerenderRoute(nitro, route, fetch) {
302
446
  function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras) {
303
447
  const nuxt = useNuxt();
304
448
  nuxt.hooks.hook("nitro:init", async (nitro) => {
305
- const llmsFullTxtPath = join(nitro.options.output.publicDir, "llms-full.txt");
449
+ const llmsFullTxtPath = options.llmsFullTxt === false ? void 0 : join(nitro.options.output.publicDir, "llms-full.txt");
306
450
  const state = createCrawlerState(
307
451
  dbPath,
308
452
  llmsFullTxtPath,
@@ -346,8 +490,10 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
346
490
  initPromise = initCrawler(state);
347
491
  await initPromise;
348
492
  const parsed = JSON.parse(route.contents || "{}");
349
- await processMarkdownRoute(state, nuxt, pageRoute, parsed);
350
- route.contents = options.sitemapMd === false ? parsed.markdown : appendSitemapSection(parsed.markdown, toDeployedRoute(SITEMAP_MD_ROUTE, nitro.options.baseURL));
493
+ if (!state.prerenderedRoutes.has(pageRoute))
494
+ await processMarkdownRoute(state, nuxt, pageRoute, parsed);
495
+ const withSitemap = options.sitemapMd === false ? parsed.markdown : appendSitemapSection(parsed.markdown, absoluteSiteUrl(toDeployedRoute(SITEMAP_MD_ROUTE, nitro.options.baseURL), siteInfo?.url));
496
+ route.contents = llmsTxtConfig?.directive?.markdown === false ? withSitemap : insertLlmsTxtDirective(withSitemap, absoluteSiteUrl(toDeployedRoute("/llms.txt", nitro.options.baseURL), siteInfo?.url));
351
497
  route.contentType = "text/markdown; charset=utf-8";
352
498
  state.totalProcessingTime += Date.now() - pageStartTime;
353
499
  });
@@ -409,10 +555,12 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
409
555
  }
410
556
  if (state.dbPath && state.llmsTxtConfig?.markdownLinks) {
411
557
  const availabilityFile = join(dirname(state.dbPath), MARKDOWN_LINK_AVAILABILITY_FILE);
558
+ const options2 = nitro.options;
559
+ const ignoreBaseDir = options2.srcDir || (typeof options2.serverDir === "string" ? options2.serverDir : void 0) || options2.rootDir;
412
560
  const paths = await findOutputMarkdownPaths(
413
561
  nitro.options.output.publicDir,
414
562
  nitro.options.baseURL,
415
- nitro.options.srcDir,
563
+ ignoreBaseDir,
416
564
  nitro.options.ignore,
417
565
  nitro.options.publicAssets
418
566
  );
@@ -421,20 +569,46 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
421
569
  paths
422
570
  }), "utf-8");
423
571
  }
424
- const llmsStats = await prerenderRoute(nitro, "/llms.txt", crawlerFetch(state));
425
- const llmsFullStats = await stat(state.llmsFullTxtPath);
426
- nitro._prerenderedRoutes.push({ route: "/llms-full.txt", fileName: "/llms-full.txt" });
572
+ let llmsSize = 0;
573
+ if (options.llmsTxt !== false) {
574
+ const seen = /* @__PURE__ */ new Set();
575
+ const pending = ["/llms.txt"];
576
+ while (pending.length) {
577
+ const route = pending.shift();
578
+ if (seen.has(route))
579
+ continue;
580
+ seen.add(route);
581
+ const stats = await prerenderRoute(nitro, route, crawlerFetch(state));
582
+ llmsSize += stats.size;
583
+ const body = await readFile(resolvePrerenderFile(nitro.options.output.publicDir, route), "utf-8");
584
+ for (const match of body.matchAll(/\]\(([^)]+)\)/g)) {
585
+ const origin = new URL(siteInfo?.url || "http://localhost").origin;
586
+ const parsedTarget = parsePrerenderRoute(match[1], nitro.options.baseURL);
587
+ if (parsedTarget._tag === "Invalid")
588
+ continue;
589
+ const target = new URL(match[1], origin);
590
+ if (target.origin !== origin)
591
+ continue;
592
+ const path = parsedTarget.route;
593
+ if (path.startsWith("/llms/") && path.endsWith(".txt"))
594
+ pending.push(path);
595
+ }
596
+ }
597
+ }
598
+ const llmsFullStats = state.llmsFullTxtPath ? await stat(state.llmsFullTxtPath) : { size: 0 };
599
+ if (state.llmsFullTxtPath)
600
+ nitro._prerenderedRoutes.push({ route: "/llms-full.txt", fileName: "/llms-full.txt" });
427
601
  if (options.sitemapMd !== false) {
428
602
  const sitemapMdStats = await prerenderRoute(nitro, SITEMAP_MD_ROUTE, crawlerFetch(state));
429
603
  logger.debug(`Wrote sitemap.md (${(sitemapMdStats.size / 1024).toFixed(1)}kb)`);
430
604
  }
431
605
  const kb = (b) => (b / 1024).toFixed(1);
432
- const totalKb = kb(llmsStats.size + llmsFullStats.size);
606
+ const totalKb = kb(llmsSize + llmsFullStats.size);
433
607
  const dim = (s) => colorize("dim", s);
434
608
  const cyan = (s) => colorize("cyan", s);
435
609
  const timeStr = state.totalProcessingTime >= 100 ? ` in ${cyan(`${(state.totalProcessingTime / 1e3).toFixed(1)}s`)}` : "";
436
610
  logger.info(`Indexed ${cyan(String(state.prerenderedRoutes.size))} pages for llms.txt${timeStr} \u2192 ${cyan(`${totalKb}kb`)}`);
437
- logger.info(dim(` llms.txt: ${kb(llmsStats.size)}kb, llms-full.txt: ${kb(llmsFullStats.size)}kb`));
611
+ logger.info(dim(` llms.txt files: ${kb(llmsSize)}kb, llms-full.txt: ${kb(llmsFullStats.size)}kb`));
438
612
  }
439
613
  const { useSitemapHook, usePrerenderHook } = detectSitemapPrerender();
440
614
  logger.debug(`Prerender hooks: sitemap=${useSitemapHook}, prerender=${usePrerenderHook}`);
@@ -442,8 +616,8 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
442
616
  nuxt.hooks.hook("sitemap:prerender:done", async (ctx) => (async () => {
443
617
  if (!state.initialized)
444
618
  return;
445
- for (const sitemap of ctx.sitemaps)
446
- await crawlSitemapContent(state, nuxt, nitro, sitemap.content);
619
+ const documents = new Map(ctx.sitemaps.map((sitemap) => [toLogicalRoute(sitemap.name, nitro.options.baseURL), () => sitemap.content]));
620
+ await crawlConfiguredSitemaps(state, nuxt, nitro, ctx.options, documents);
447
621
  await writeLlmsFiles();
448
622
  state.prerenderedRoutes.clear();
449
623
  if (state.db)
@@ -453,14 +627,7 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
453
627
  nitro.hooks.hook("prerender:done", async () => (async () => {
454
628
  if (!state.initialized)
455
629
  return;
456
- const sitemapContent = await crawlerFetch(state)(toDeployedRoute("/sitemap.xml", nitro.options.baseURL), {
457
- headers: { "x-nitro-prerender": "/sitemap.xml" },
458
- signal: AbortSignal.timeout(PRERENDER_PAGE_TIMEOUT)
459
- }).catch(() => {
460
- return null;
461
- });
462
- if (sitemapContent)
463
- await crawlSitemapContent(state, nuxt, nitro, sitemapContent);
630
+ await crawlConfiguredSitemaps(state, nuxt, nitro, nuxt.options.sitemap || {});
464
631
  await writeLlmsFiles();
465
632
  state.prerenderedRoutes.clear();
466
633
  if (state.db)
package/dist/module.d.mts CHANGED
@@ -101,6 +101,7 @@ interface ModulePublicRuntimeConfig {
101
101
  version: string;
102
102
  sitemapMd: boolean;
103
103
  describedby: boolean;
104
+ llmsFullTxt?: boolean;
104
105
  mdreamOptions: ModuleOptions['mdreamOptions'];
105
106
  markdownCacheHeaders: Required<NonNullable<ModuleOptions['markdownCacheHeaders']>>;
106
107
  database: ResolvedDatabase;
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": "^4.6.0 || ^5.0.0-0"
5
5
  },
6
6
  "configKey": "aiReady",
7
- "version": "3.1.0",
7
+ "version": "3.3.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -7,8 +7,9 @@ import '@nuxt/kit';
7
7
  import 'defu';
8
8
  import 'nuxt-site-config/kit';
9
9
  import 'nuxtseo-shared/kit';
10
- export { m as default } from './shared/nuxt-ai-ready.Dmvy7CIJ.mjs';
10
+ export { m as default } from './shared/nuxt-ai-ready.BwnSMqRn.mjs';
11
11
  import './shared/nuxt-ai-ready.B3hAuOLQ.mjs';
12
+ import '../dist/runtime/llms-txt-files.js';
12
13
  import '../dist/runtime/route-path.js';
13
14
  import '../dist/runtime/server/utils/sitemap-md.js';
14
15
  import '../dist/runtime/server/utils/discovery-response.js';
@@ -0,0 +1,15 @@
1
+ /** The line that points a reader of one page at the whole site's index. */
2
+ export declare function llmsTxtDirective(llmsTxtUrl: string): string;
3
+ /**
4
+ * Add the llms.txt directive near the top of a page's Markdown, after any
5
+ * frontmatter so YAML parsers still find it first. An agent that lands on one
6
+ * page then knows where the full index is.
7
+ */
8
+ export declare function insertLlmsTxtDirective(markdown: string, llmsTxtUrl: string): string;
9
+ /**
10
+ * The HTML equivalent: a link at the top of the body, hidden visually but
11
+ * kept for screen readers and agents that read the raw HTML.
12
+ */
13
+ export declare function llmsTxtDirectiveHtml(llmsTxtPath: string): string;
14
+ /** Remove the injected HTML link, so a page's Markdown carries the directive once. */
15
+ export declare function stripLlmsTxtDirectiveHtml(html: string): string;
@@ -0,0 +1,23 @@
1
+ const RE_FRONTMATTER = /^\uFEFF?---[\t ]*\r?\n(?:[\s\S]*?\r?\n)?---[\t ]*(?:\r?\n|$)/;
2
+ export function llmsTxtDirective(llmsTxtUrl) {
3
+ return `> Index of every page on this site: [llms.txt](${llmsTxtUrl})`;
4
+ }
5
+ export function insertLlmsTxtDirective(markdown, llmsTxtUrl) {
6
+ const directive = llmsTxtDirective(llmsTxtUrl);
7
+ const frontmatter = markdown.match(RE_FRONTMATTER)?.[0] ?? "";
8
+ const body = markdown.slice(frontmatter.length).replace(/^(?:\r?\n)+/, "");
9
+ if (body === directive || body.startsWith(`${directive}
10
+ `) || body.startsWith(`${directive}\r
11
+ `))
12
+ return markdown;
13
+ return `${frontmatter}${frontmatter ? "\n" : ""}${directive}
14
+
15
+ ${body}`;
16
+ }
17
+ const RE_DIRECTIVE_HTML = /<a [^>]*data-llms-txt-directive[^>]*>[^<]*<\/a>/g;
18
+ export function llmsTxtDirectiveHtml(llmsTxtPath) {
19
+ return `<a href="${llmsTxtPath}" tabindex="-1" style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0" data-llms-txt-directive>Index of every page on this site: llms.txt</a>`;
20
+ }
21
+ export function stripLlmsTxtDirectiveHtml(html) {
22
+ return html.replace(RE_DIRECTIVE_HTML, "");
23
+ }
@@ -0,0 +1,30 @@
1
+ import type { LlmsTxtConfig, LlmsTxtSection } from './types.js';
2
+ export interface LlmsTxtPage {
3
+ pathname: string;
4
+ href?: string;
5
+ title?: string;
6
+ description?: string;
7
+ locale?: string;
8
+ }
9
+ export declare function llmsTxtSectionPath(section: LlmsTxtSection): string;
10
+ export type ParsedLlmsTxtConfig = {
11
+ _tag: 'Valid';
12
+ config: LlmsTxtConfig;
13
+ } | {
14
+ _tag: 'Invalid';
15
+ message: string;
16
+ };
17
+ /** Parse the public configuration once, before Nitro registers routes. */
18
+ export declare function parseLlmsTxtConfig(input: unknown): ParsedLlmsTxtConfig;
19
+ interface LlmsTxtFilesInput {
20
+ rootContent?: string;
21
+ header: string;
22
+ config: LlmsTxtConfig;
23
+ sections: LlmsTxtSection[];
24
+ pages: LlmsTxtPage[];
25
+ sitemapPages: ReadonlyMap<string, LlmsTxtPage[]>;
26
+ resolveUrl: (path: string) => string;
27
+ }
28
+ /** A bounded tree of file lists. Every page appears once; every file is reachable from the root. */
29
+ export declare function createLlmsTxtFiles(input: LlmsTxtFilesInput): Map<string, string>;
30
+ export {};
@@ -0,0 +1,141 @@
1
+ import { formatLlmsTxtPageLink, normalizeLlmsTxtConfig } from "./llms-txt-format.js";
2
+ const FILE_PATH = /^\/llms\/[a-z0-9][\w-]*\.txt$/i;
3
+ const SITEMAP_NAME = /^[a-z0-9][\w-]*$/i;
4
+ export function llmsTxtSectionPath(section) {
5
+ const name = Array.isArray(section.sitemap) ? section.sitemap[0] : section.sitemap;
6
+ return section.path || `/llms/${name}.txt`;
7
+ }
8
+ export function parseLlmsTxtConfig(input) {
9
+ const invalid = (message) => ({ _tag: "Invalid", message });
10
+ if (!input || typeof input !== "object" || Array.isArray(input))
11
+ return invalid("llmsTxt must be an object or false.");
12
+ const config = input;
13
+ if (config.pages !== void 0 && config.pages !== false && config.pages !== "sitemap" && config.pages !== "all")
14
+ return invalid("llmsTxt.pages must be sitemap, all, or false.");
15
+ if (config.maxLength !== void 0 && (!Number.isSafeInteger(config.maxLength) || config.maxLength < 1024))
16
+ return invalid("llmsTxt.maxLength must be an integer of at least 1024.");
17
+ if (config.sitemaps !== void 0 && (!Array.isArray(config.sitemaps) || config.sitemaps.some((name) => typeof name !== "string" || !SITEMAP_NAME.test(name))))
18
+ return invalid("llmsTxt.sitemaps must contain sitemap names.");
19
+ if (config.sections !== void 0 && !Array.isArray(config.sections))
20
+ return invalid("llmsTxt.sections must be an array.");
21
+ const paths = /* @__PURE__ */ new Set();
22
+ for (const section of config.sections || []) {
23
+ if (!section || typeof section !== "object" || typeof section.title !== "string")
24
+ return invalid("Each llmsTxt section needs a title.");
25
+ if (section.sitemap === void 0) {
26
+ if (section.path !== void 0)
27
+ return invalid("A section path requires a sitemap.");
28
+ continue;
29
+ }
30
+ const names = Array.isArray(section.sitemap) ? section.sitemap : [section.sitemap];
31
+ if (!names.length || names.some((name) => typeof name !== "string" || !SITEMAP_NAME.test(name)))
32
+ return invalid("Each section sitemap must contain a sitemap name.");
33
+ const path = llmsTxtSectionPath(section);
34
+ if (!FILE_PATH.test(path))
35
+ return invalid("A section path must use /llms/<name>.txt.");
36
+ if (paths.has(path))
37
+ return invalid(`Duplicate llmsTxt section path: ${path}.`);
38
+ paths.add(path);
39
+ }
40
+ return { _tag: "Valid", config };
41
+ }
42
+ export function createLlmsTxtFiles(input) {
43
+ const files = /* @__PURE__ */ new Map();
44
+ const maxLength = input.config.maxLength ?? 8e4;
45
+ const link = (title, path, description) => formatLlmsTxtPageLink({ pathname: path, href: input.resolveUrl(path), title, description });
46
+ const render = (header, title, lines) => `${header.trim()}
47
+
48
+ ## ${title.replace(/\s+/g, " ").trim()}
49
+
50
+ ${lines.join("\n")}
51
+ `;
52
+ function tree(path, prefix, header, title, lines, level = 0) {
53
+ const body = render(header, title, lines);
54
+ if (body.length <= maxLength) {
55
+ files.set(path, body);
56
+ return;
57
+ }
58
+ const overhead = render(header, title, []).length;
59
+ const chunks = [];
60
+ let chunk = [];
61
+ let length = overhead;
62
+ for (const line of lines) {
63
+ if (overhead + line.length + 1 > maxLength)
64
+ throw new Error("An llms.txt entry exceeds llmsTxt.maxLength. Shorten the entry or increase the limit.");
65
+ if (length + line.length + 1 > maxLength) {
66
+ chunks.push(chunk);
67
+ chunk = [];
68
+ length = overhead;
69
+ }
70
+ chunk.push(line);
71
+ length += line.length + 1;
72
+ }
73
+ if (chunk.length)
74
+ chunks.push(chunk);
75
+ if (chunks.length >= lines.length)
76
+ throw new Error("llmsTxt.maxLength cannot fit an index. Increase the limit.");
77
+ const links = chunks.map((entries, index) => {
78
+ const child = `/llms/_${prefix}-${level}-${index + 1}.txt`;
79
+ files.set(child, render(header, title, entries));
80
+ return link(`${title} ${index + 1}`, child);
81
+ });
82
+ tree(path, prefix, header, title, links, level + 1);
83
+ }
84
+ const sections = [];
85
+ const assigned = /* @__PURE__ */ new Set();
86
+ for (const [index, section] of input.sections.entries()) {
87
+ if (!section.sitemap) {
88
+ sections.push(section);
89
+ continue;
90
+ }
91
+ const names = Array.isArray(section.sitemap) ? section.sitemap : [section.sitemap];
92
+ const pages2 = /* @__PURE__ */ new Map();
93
+ for (const name of names) {
94
+ for (const page of input.sitemapPages.get(name) || []) {
95
+ if (!assigned.has(page.pathname))
96
+ pages2.set(page.pathname, page);
97
+ }
98
+ }
99
+ const entries = [...(section.links || []).map((entry) => formatLlmsTxtPageLink({ pathname: entry.href, ...entry })), ...[...pages2.values()].map(formatLlmsTxtPageLink)];
100
+ if (!entries.length)
101
+ continue;
102
+ for (const pathname of pages2.keys())
103
+ assigned.add(pathname);
104
+ const path = llmsTxtSectionPath(section);
105
+ const description = Array.isArray(section.description) ? section.description.join(" ") : section.description;
106
+ const preamble = normalizeLlmsTxtConfig({ notes: description });
107
+ tree(path, `section-${index + 1}`, `${input.header.trim()}
108
+
109
+ ${preamble}
110
+
111
+ ${link("llms.txt", "/llms.txt")}`, section.title, entries);
112
+ sections.push({ ...section, sitemap: void 0, path: void 0, links: [{ title: section.title, href: input.resolveUrl(path), description }] });
113
+ }
114
+ const rootHeader = `${input.header.trim()}
115
+
116
+ ${normalizeLlmsTxtConfig({ ...input.config, sections })}${input.rootContent ? `
117
+
118
+ ${input.rootContent}` : ""}`;
119
+ const pages = input.pages.filter((page) => !assigned.has(page.pathname)).map(formatLlmsTxtPageLink);
120
+ if (!pages.length && rootHeader.length + 1 <= maxLength) {
121
+ files.set("/llms.txt", `${rootHeader.trim()}
122
+ `);
123
+ } else {
124
+ const inline = render(rootHeader, "Pages", pages);
125
+ if (inline.length <= maxLength) {
126
+ files.set("/llms.txt", inline);
127
+ } else {
128
+ tree("/llms/_pages.txt", "pages", input.header, "Pages", pages);
129
+ const root = `${rootHeader.trim()}
130
+
131
+ ## Pages
132
+
133
+ ${link("Pages", "/llms/_pages.txt")}
134
+ `;
135
+ if (root.length > maxLength)
136
+ throw new Error("llms.txt sections exceed llmsTxt.maxLength. Shorten the sections or increase the limit.");
137
+ files.set("/llms.txt", root);
138
+ }
139
+ }
140
+ return files;
141
+ }
@@ -1,4 +1,5 @@
1
1
  import type { H3Event } from '#nuxtseo/h3';
2
- import { normalizeLlmsTxtConfig } from './llms-txt-format.js';
3
- export { normalizeLlmsTxtConfig };
2
+ export { normalizeLlmsTxtConfig } from './llms-txt-format.js';
3
+ /** One discovery snapshot supplies the root and every bounded child file. */
4
+ export declare function buildLlmsTxtFiles(event: H3Event): Promise<Record<string, string>>;
4
5
  export declare function buildLlmsTxt(event: H3Event): Promise<string>;