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
@@ -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.DH9mSayy.mjs';
5
+ import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.CKXM1jnE.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,16 +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.DH9mSayy.mjs';
8
+ import { parseURL, withLeadingSlash, joinURL, withBase } from 'ufo';
9
+ import { l as logger, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.CKXM1jnE.mjs';
10
+ import { toLogicalRoute, toDeployedRoute } from '../../dist/runtime/route-path.js';
10
11
  import { insertLlmsTxtDirective } from '../../dist/runtime/llms-txt-directive.js';
11
12
  import { absoluteSiteUrl } from '../../dist/runtime/markdown-links.js';
12
- import { normalizePagePath, toMarkdownPath } from '../../dist/runtime/markdown-path.js';
13
- import { toDeployedRoute, toLogicalRoute } from '../../dist/runtime/route-path.js';
13
+ import { normalizePagePath, hasMarkdownTwin, toMarkdownPath } from '../../dist/runtime/markdown-path.js';
14
14
  import { initSchema, computeContentHash, insertPage, queryAllPages, exportDbDump } from '../../dist/runtime/server/db/shared.js';
15
15
  import { buildLlmsFullTxtHeader, formatPageForLlmsFullTxt } from '../../dist/runtime/server/utils/llms-full.js';
16
16
  import { resolvePageUpdatedAt } from '../../dist/runtime/server/utils/page-updated-at.js';
@@ -23,6 +23,7 @@ import 'nuxt-site-config/kit';
23
23
  import 'nuxtseo-shared/kit';
24
24
  import '../shared/nuxt-ai-ready.B3hAuOLQ.mjs';
25
25
  import 'node:fs';
26
+ import '../../dist/runtime/llms-txt-files.js';
26
27
  import '../../dist/runtime/cache-control.js';
27
28
  import '../../dist/runtime/server/utils/discovery-response.js';
28
29
  import 'confbox';
@@ -31,6 +32,33 @@ import '../../dist/runtime/server/utils/content-negotiation.js';
31
32
  import 'pathe';
32
33
  import 'nuxtseo-shared/i18n';
33
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
+
34
62
  const PRERENDER_PAGE_TIMEOUT = 3e4;
35
63
  const RE_HTML_MD_EXT = /\.(html|md)$/;
36
64
  const RE_INDEX_SUFFIX = /\/index$/;
@@ -186,7 +214,32 @@ async function processMarkdownRoute(state, nuxt, route, parsed, lastmod, options
186
214
  async function processSitemapEntry(state, nuxt, nitro, entry) {
187
215
  const loc = typeof entry === "string" ? entry : entry.loc;
188
216
  const lastmod = typeof entry === "string" ? void 0 : entry.lastmod;
189
- 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
+ }
190
243
  if (route.split("/").some((segment) => segment.startsWith("_"))) {
191
244
  return { crawled: false, skipped: true };
192
245
  }
@@ -195,12 +248,24 @@ async function processSitemapEntry(state, nuxt, nitro, entry) {
195
248
  }
196
249
  const mdRoute = toMarkdownPath(route);
197
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 };
198
261
  logger.debug(`Fetching markdown for ${route} \u2192 ${mdUrl}`);
199
262
  const res = await crawlerFetch(state)(mdUrl, {
200
263
  headers: { "x-nitro-prerender": mdRoute },
201
264
  retry: 0,
202
265
  signal: AbortSignal.timeout(PRERENDER_PAGE_TIMEOUT)
203
266
  }).catch((err) => {
267
+ if (err?.response?.status === 404 || err?.response?.status === 410)
268
+ state.errorRoutes.add(route);
204
269
  if (err?.name === "TimeoutError" || err?.name === "AbortError")
205
270
  logger.warn(`Timeout (${PRERENDER_PAGE_TIMEOUT}ms) fetching markdown for ${route}`);
206
271
  else
@@ -217,6 +282,29 @@ async function processSitemapEntry(state, nuxt, nitro, entry) {
217
282
  return { crawled: false, skipped: false };
218
283
  }
219
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
+ }
220
308
  return { crawled: true, skipped: false };
221
309
  }
222
310
  async function crawlSitemapEntries(state, nuxt, nitro, entries) {
@@ -237,18 +325,79 @@ async function crawlSitemapEntries(state, nuxt, nitro, entries) {
237
325
  logger.debug(`Sitemap crawl complete: ${crawled} crawled, ${skipped} skipped`);
238
326
  return crawled;
239
327
  }
240
- 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(), budget = { fetched: 0, warned: false }) {
241
329
  logger.debug(`Parsing sitemap XML (${sitemapContent.length} bytes)`);
242
330
  const result = await collectSitemap(sitemapContent);
243
- if (result._tag !== "document" || result.document._tag !== "urlset") {
331
+ if (result._tag !== "document") {
244
332
  const issues = result.issues.map((issue) => issue.message).join("; ");
245
333
  logger.debug(`Skipping sitemap: ${issues || "document is not a URL set"}`);
246
334
  return 0;
247
335
  }
336
+ if (result.document._tag === "index") {
337
+ let crawled = 0;
338
+ const origin = new URL(state.siteInfo?.url || "http://localhost").origin;
339
+ for (const entry of result.document.entries) {
340
+ if (parsePrerenderRoute(entry.loc, nitro.options.baseURL)._tag === "Invalid")
341
+ continue;
342
+ const url = new URL(entry.loc, origin);
343
+ if (url.origin !== origin)
344
+ continue;
345
+ const documentKey = toLogicalRoute(url.pathname, nitro.options.baseURL);
346
+ const localDocument = documents.get(documentKey);
347
+ const identity = localDocument ? `local:${documentKey}` : url.href;
348
+ if (seen.has(identity))
349
+ continue;
350
+ if (!localDocument && (depth >= 3 || budget.fetched >= 100)) {
351
+ if (!budget.warned) {
352
+ logger.warn("Sitemap crawl reached its document limit.");
353
+ budget.warned = true;
354
+ }
355
+ continue;
356
+ }
357
+ seen.add(identity);
358
+ if (!localDocument)
359
+ budget.fetched++;
360
+ const content = localDocument?.() ?? await crawlerFetch(state)(`${url.pathname}${url.search}`, {
361
+ headers: { "x-nitro-prerender": url.pathname },
362
+ retry: 0,
363
+ signal: AbortSignal.timeout(PRERENDER_PAGE_TIMEOUT)
364
+ }).catch((error) => {
365
+ logger.warn(`Sitemap crawl failed for ${url.pathname}: ${String(error)}`);
366
+ return null;
367
+ });
368
+ if (content)
369
+ crawled += await crawlSitemapContent(state, nuxt, nitro, content, seen, depth + Number(!localDocument), documents, budget);
370
+ }
371
+ return crawled;
372
+ }
248
373
  const urls = result.document.entries;
249
374
  logger.debug(`Found ${urls.length} URLs in sitemap`);
250
375
  return crawlSitemapEntries(state, nuxt, nitro, urls);
251
376
  }
377
+ async function crawlConfiguredSitemaps(state, nuxt, nitro, sitemap, documents = /* @__PURE__ */ new Map()) {
378
+ const names = state.llmsTxtConfig?.sitemaps;
379
+ const singleName = (sitemap.sitemapName || "sitemap.xml").replace(/\.xml$/, "");
380
+ const configured = sitemap.sitemaps && typeof sitemap.sitemaps === "object" ? sitemap.sitemaps : void 0;
381
+ const roots = configured ? Object.entries(configured).flatMap(([key, definition]) => {
382
+ const name = (definition.sitemapName || key).replace(/\.xml$/, "");
383
+ if (key === "index" || names !== void 0 && !names.includes(name))
384
+ return [];
385
+ const route = definition._route || definition.route || (key === "chunks" ? configured.index?._route || configured.index?.route : void 0);
386
+ return [route ? toLogicalRoute(route, nitro.options.baseURL) : joinURL(sitemap.sitemapsPathPrefix ?? "/__sitemap__/", `${name}.xml`)];
387
+ }) : names !== void 0 && !names.includes(sitemap.sitemaps ? "chunks" : singleName) ? [] : [sitemap.sitemaps ? "/sitemap_index.xml" : `/${sitemap.sitemapName || "sitemap.xml"}`];
388
+ for (const root of roots) {
389
+ const content = documents.get(root)?.() ?? await crawlerFetch(state)(toDeployedRoute(root, nitro.options.baseURL), {
390
+ headers: { "x-nitro-prerender": root },
391
+ retry: 0,
392
+ signal: AbortSignal.timeout(PRERENDER_PAGE_TIMEOUT)
393
+ }).catch((error) => {
394
+ logger.warn(`Sitemap crawl failed for ${root}: ${String(error)}`);
395
+ return null;
396
+ });
397
+ if (content)
398
+ await crawlSitemapContent(state, nuxt, nitro, content, /* @__PURE__ */ new Set(), 0, documents);
399
+ }
400
+ }
252
401
  function isNuxtGenerate() {
253
402
  return process.argv.includes("generate") || process.env.NUXT_GENERATE === "true" || process.env.prerender === "true";
254
403
  }
@@ -274,6 +423,7 @@ function detectSitemapPrerender(sitemapName = "sitemap.xml") {
274
423
  };
275
424
  }
276
425
  async function prerenderRoute(nitro, route, fetch) {
426
+ const filePath = resolvePrerenderFile(nitro.options.output.publicDir, route);
277
427
  const start = Date.now();
278
428
  const encodedRoute = encodeURI(route);
279
429
  const fetchUrl = withBase(encodedRoute, nitro.options.baseURL);
@@ -282,7 +432,6 @@ async function prerenderRoute(nitro, route, fetch) {
282
432
  retry: nitro.options.prerender.retry,
283
433
  retryDelay: nitro.options.prerender.retryDelay
284
434
  });
285
- const filePath = join(nitro.options.output.publicDir, route);
286
435
  logger.debug(`Creating directory for prerender: ${dirname(filePath)}`);
287
436
  await mkdir(dirname(filePath), { recursive: true });
288
437
  const data = res._data;
@@ -304,7 +453,7 @@ async function prerenderRoute(nitro, route, fetch) {
304
453
  function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras) {
305
454
  const nuxt = useNuxt();
306
455
  nuxt.hooks.hook("nitro:init", async (nitro) => {
307
- const llmsFullTxtPath = join(nitro.options.output.publicDir, "llms-full.txt");
456
+ const llmsFullTxtPath = options.llmsFullTxt === false ? void 0 : join(nitro.options.output.publicDir, "llms-full.txt");
308
457
  const state = createCrawlerState(
309
458
  dbPath,
310
459
  llmsFullTxtPath,
@@ -348,7 +497,8 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
348
497
  initPromise = initCrawler(state);
349
498
  await initPromise;
350
499
  const parsed = JSON.parse(route.contents || "{}");
351
- await processMarkdownRoute(state, nuxt, pageRoute, parsed);
500
+ if (!state.prerenderedRoutes.has(pageRoute))
501
+ await processMarkdownRoute(state, nuxt, pageRoute, parsed);
352
502
  const withSitemap = options.sitemapMd === false ? parsed.markdown : appendSitemapSection(parsed.markdown, absoluteSiteUrl(toDeployedRoute(SITEMAP_MD_ROUTE, nitro.options.baseURL), siteInfo?.url));
353
503
  route.contents = llmsTxtConfig?.directive?.markdown === false ? withSitemap : insertLlmsTxtDirective(withSitemap, absoluteSiteUrl(toDeployedRoute("/llms.txt", nitro.options.baseURL), siteInfo?.url));
354
504
  route.contentType = "text/markdown; charset=utf-8";
@@ -426,20 +576,46 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
426
576
  paths
427
577
  }), "utf-8");
428
578
  }
429
- const llmsStats = await prerenderRoute(nitro, "/llms.txt", crawlerFetch(state));
430
- const llmsFullStats = await stat(state.llmsFullTxtPath);
431
- nitro._prerenderedRoutes.push({ route: "/llms-full.txt", fileName: "/llms-full.txt" });
579
+ let llmsSize = 0;
580
+ if (options.llmsTxt !== false) {
581
+ const seen = /* @__PURE__ */ new Set();
582
+ const pending = ["/llms.txt"];
583
+ while (pending.length) {
584
+ const route = pending.shift();
585
+ if (seen.has(route))
586
+ continue;
587
+ seen.add(route);
588
+ const stats = await prerenderRoute(nitro, route, crawlerFetch(state));
589
+ llmsSize += stats.size;
590
+ const body = await readFile(resolvePrerenderFile(nitro.options.output.publicDir, route), "utf-8");
591
+ for (const match of body.matchAll(/\]\(([^)]+)\)/g)) {
592
+ const origin = new URL(siteInfo?.url || "http://localhost").origin;
593
+ const parsedTarget = parsePrerenderRoute(match[1], nitro.options.baseURL);
594
+ if (parsedTarget._tag === "Invalid")
595
+ continue;
596
+ const target = new URL(match[1], origin);
597
+ if (target.origin !== origin)
598
+ continue;
599
+ const path = parsedTarget.route;
600
+ if (/^\/llms\/[^/]+\.txt$/i.test(path))
601
+ pending.push(path);
602
+ }
603
+ }
604
+ }
605
+ const llmsFullStats = state.llmsFullTxtPath ? await stat(state.llmsFullTxtPath) : { size: 0 };
606
+ if (state.llmsFullTxtPath)
607
+ nitro._prerenderedRoutes.push({ route: "/llms-full.txt", fileName: "/llms-full.txt" });
432
608
  if (options.sitemapMd !== false) {
433
609
  const sitemapMdStats = await prerenderRoute(nitro, SITEMAP_MD_ROUTE, crawlerFetch(state));
434
610
  logger.debug(`Wrote sitemap.md (${(sitemapMdStats.size / 1024).toFixed(1)}kb)`);
435
611
  }
436
612
  const kb = (b) => (b / 1024).toFixed(1);
437
- const totalKb = kb(llmsStats.size + llmsFullStats.size);
613
+ const totalKb = kb(llmsSize + llmsFullStats.size);
438
614
  const dim = (s) => colorize("dim", s);
439
615
  const cyan = (s) => colorize("cyan", s);
440
616
  const timeStr = state.totalProcessingTime >= 100 ? ` in ${cyan(`${(state.totalProcessingTime / 1e3).toFixed(1)}s`)}` : "";
441
617
  logger.info(`Indexed ${cyan(String(state.prerenderedRoutes.size))} pages for llms.txt${timeStr} \u2192 ${cyan(`${totalKb}kb`)}`);
442
- logger.info(dim(` llms.txt: ${kb(llmsStats.size)}kb, llms-full.txt: ${kb(llmsFullStats.size)}kb`));
618
+ logger.info(dim(` llms.txt files: ${kb(llmsSize)}kb, llms-full.txt: ${kb(llmsFullStats.size)}kb`));
443
619
  }
444
620
  const { useSitemapHook, usePrerenderHook } = detectSitemapPrerender();
445
621
  logger.debug(`Prerender hooks: sitemap=${useSitemapHook}, prerender=${usePrerenderHook}`);
@@ -447,8 +623,8 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
447
623
  nuxt.hooks.hook("sitemap:prerender:done", async (ctx) => (async () => {
448
624
  if (!state.initialized)
449
625
  return;
450
- for (const sitemap of ctx.sitemaps)
451
- await crawlSitemapContent(state, nuxt, nitro, sitemap.content);
626
+ const documents = new Map(ctx.sitemaps.map((sitemap) => [toLogicalRoute(sitemap.name, nitro.options.baseURL), () => sitemap.content]));
627
+ await crawlConfiguredSitemaps(state, nuxt, nitro, ctx.options, documents);
452
628
  await writeLlmsFiles();
453
629
  state.prerenderedRoutes.clear();
454
630
  if (state.db)
@@ -458,14 +634,7 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
458
634
  nitro.hooks.hook("prerender:done", async () => (async () => {
459
635
  if (!state.initialized)
460
636
  return;
461
- const sitemapContent = await crawlerFetch(state)(toDeployedRoute("/sitemap.xml", nitro.options.baseURL), {
462
- headers: { "x-nitro-prerender": "/sitemap.xml" },
463
- signal: AbortSignal.timeout(PRERENDER_PAGE_TIMEOUT)
464
- }).catch(() => {
465
- return null;
466
- });
467
- if (sitemapContent)
468
- await crawlSitemapContent(state, nuxt, nitro, sitemapContent);
637
+ await crawlConfiguredSitemaps(state, nuxt, nitro, nuxt.options.sitemap || {});
469
638
  await writeLlmsFiles();
470
639
  state.prerenderedRoutes.clear();
471
640
  if (state.db)
@@ -475,4 +644,4 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
475
644
  });
476
645
  }
477
646
 
478
- export { createPrerenderDatabase, detectSitemapPrerender, setupPrerenderHandler };
647
+ export { crawlConfiguredSitemaps, crawlSitemapContent, createPrerenderDatabase, detectSitemapPrerender, setupPrerenderHandler };
package/dist/cli.mjs CHANGED
@@ -10,6 +10,240 @@ import 'node:module';
10
10
  import 'node:path';
11
11
  import 'node:url';
12
12
 
13
+ const capabilities = ["llms", "api-catalog", "ai-catalog", "mcp", "skills"];
14
+ function publicUrl(input, base) {
15
+ if (!URL.canParse(input, base))
16
+ return { _tag: "Err", message: "Use an absolute HTTP or HTTPS URL." };
17
+ const url = new URL(input, base);
18
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
19
+ return { _tag: "Err", message: "Use an HTTP or HTTPS URL without credentials." };
20
+ url.hash = "";
21
+ return { _tag: "Ok", value: url };
22
+ }
23
+ function parseAuditOptions(input) {
24
+ const url = publicUrl(input.url);
25
+ if (url._tag === "Err")
26
+ return url;
27
+ const base = input.base || "/";
28
+ if (!base.startsWith("/") || base.startsWith("//") || /[?#\\]/.test(base))
29
+ return { _tag: "Err", message: "Use an application base path such as /docs/." };
30
+ const selected = [...new Set((input.capabilities || "").split(",").map((value) => value.trim()).filter(Boolean))];
31
+ const unknown = selected.filter((value) => !capabilities.includes(value));
32
+ if (unknown.length)
33
+ return { _tag: "Err", message: `Unknown capabilities: ${unknown.join(", ")}. Use ${capabilities.join(", ")}.` };
34
+ return { _tag: "Ok", value: { url: url.value.href, base: `${new URL(base, url.value).pathname.replace(/\/+$/, "")}/`, capabilities: selected } };
35
+ }
36
+ async function readDocument(url, accept, dependencies) {
37
+ const maxBytes = dependencies.maxBytes ?? 2 * 1024 * 1024;
38
+ const signal = AbortSignal.timeout(dependencies.timeoutMs ?? 1e4);
39
+ return Promise.resolve().then(async () => {
40
+ let current = url;
41
+ for (let redirects = 0; redirects <= 5; redirects++) {
42
+ const response = await dependencies.fetch(current, { headers: { accept }, redirect: "manual", signal });
43
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
44
+ await response.body?.cancel();
45
+ const location = response.headers.get("location");
46
+ const next = location ? publicUrl(location, current) : { _tag: "Err", message: "Redirect has no Location header." };
47
+ if (next._tag === "Err")
48
+ return next;
49
+ current = next.value.href;
50
+ continue;
51
+ }
52
+ if (Number(response.headers.get("content-length")) > maxBytes) {
53
+ await response.body?.cancel();
54
+ return { _tag: "Err", message: `Response exceeds the ${maxBytes} byte limit.` };
55
+ }
56
+ const reader = response.body?.getReader();
57
+ const decoder = new TextDecoder();
58
+ let body = "";
59
+ let bytes = 0;
60
+ if (reader) {
61
+ while (true) {
62
+ const chunk = await reader.read();
63
+ if (chunk.done)
64
+ break;
65
+ bytes += chunk.value.byteLength;
66
+ if (bytes > maxBytes) {
67
+ await reader.cancel();
68
+ return { _tag: "Err", message: `Response exceeds the ${maxBytes} byte limit.` };
69
+ }
70
+ body += decoder.decode(chunk.value, { stream: true });
71
+ }
72
+ body += decoder.decode();
73
+ }
74
+ return { _tag: "Ok", value: { url: current, status: response.status, headers: response.headers, body } };
75
+ }
76
+ return { _tag: "Err", message: "Response exceeds the five redirect limit." };
77
+ }).catch((error) => ({ _tag: "Err", message: `Request failed: ${error instanceof Error ? error.message : String(error)}` }));
78
+ }
79
+ function responseFailure(document) {
80
+ if (document._tag === "Err")
81
+ return document.message;
82
+ if (document.value.headers.get("cf-mitigated") === "challenge")
83
+ return "Cloudflare returned a challenge. Check crawler access rules.";
84
+ if (document.value.status < 200 || document.value.status >= 300)
85
+ return `HTTP ${document.value.status}. Check this public URL.`;
86
+ }
87
+ function isHtml(body) {
88
+ return /^\s*(?:<!doctype html|<html|<head|<body)/i.test(body);
89
+ }
90
+ function contentCheck(id, url, document, accepts, success, failure) {
91
+ const error = responseFailure(document);
92
+ if (error)
93
+ return { id, url, status: "fail", detail: error };
94
+ return document._tag === "Ok" && accepts(document.value) ? { id, url: document.value.url, status: "pass", detail: success } : { id, url, status: "fail", detail: failure };
95
+ }
96
+ function markdown(document) {
97
+ return /^text\/markdown(?:;|$)/i.test(document.headers.get("content-type") || "") && !!document.body.trim() && !isHtml(document.body);
98
+ }
99
+ function links(header) {
100
+ const source = header || "";
101
+ const entries = [];
102
+ let quoted = false;
103
+ let target = false;
104
+ let start = 0;
105
+ for (let i = 0; i < source.length; i++) {
106
+ const character = source[i];
107
+ if (character === '"' && source[i - 1] !== "\\")
108
+ quoted = !quoted;
109
+ if (!quoted) {
110
+ if (character === "<")
111
+ target = true;
112
+ if (character === ">")
113
+ target = false;
114
+ if (character === "," && !target) {
115
+ entries.push(source.slice(start, i));
116
+ start = i + 1;
117
+ }
118
+ }
119
+ }
120
+ entries.push(source.slice(start));
121
+ return entries.flatMap((entry) => {
122
+ const match = entry.match(/^\s*<([^>]+)>([\s\S]*)$/);
123
+ if (!match)
124
+ return [];
125
+ const parameter = (name) => match[2]?.match(new RegExp(`;\\s*${name}\\s*=\\s*(?:"([^"]*)"|([^;\\s]+))`, "i"));
126
+ const rel = parameter("rel");
127
+ const type = parameter("type");
128
+ return [{ href: match[1], rel: (rel?.[1] || rel?.[2] || "").split(/\s+/), type: type?.[1] || type?.[2] || "" }];
129
+ });
130
+ }
131
+ function policy(header) {
132
+ const signals = {};
133
+ for (const part of header.split(",")) {
134
+ const match = part.trim().match(/^(search|ai-input|ai-train)\s*=\s*(yes|no)$/);
135
+ if (!match)
136
+ return { _tag: "Err", message: "Content-Signal has an invalid name or value." };
137
+ const name = match[1];
138
+ const value = match[2];
139
+ if (signals[name] && signals[name] !== value)
140
+ return { _tag: "Err", message: `Content-Signal contains conflicting ${name} values.` };
141
+ signals[name] = value;
142
+ }
143
+ return { _tag: "Ok", value: signals };
144
+ }
145
+ function policyCheck(html, md, id = "content-signal") {
146
+ if (html._tag === "Err" || md._tag === "Err" || responseFailure(html) || responseFailure(md))
147
+ return { id, status: "skip", detail: "Policy comparison requires both public page responses." };
148
+ const source = html.value.headers.get("content-signal");
149
+ const converted = md.value.headers.get("content-signal");
150
+ if (!source && !converted)
151
+ return { id, status: "warn", detail: "No HTTP Content-Signal. Check whether this matches your content policy." };
152
+ const parsedSource = source ? policy(source) : void 0;
153
+ const parsedConverted = converted ? policy(converted) : void 0;
154
+ for (const parsed of [parsedSource, parsedConverted]) {
155
+ if (parsed?._tag === "Err")
156
+ return { id, status: "fail", detail: parsed.message };
157
+ }
158
+ if (parsedSource?._tag === "Ok" && (!parsedConverted || parsedConverted._tag !== "Ok" || Object.keys(parsedSource.value).length !== Object.keys(parsedConverted.value).length || Object.entries(parsedSource.value).some(([key, value]) => parsedConverted.value[key] !== value))) {
159
+ return { id, status: "fail", detail: "Markdown does not preserve the HTML Content-Signal policy." };
160
+ }
161
+ return { id, status: "pass", detail: `HTTP policy: ${converted || source}` };
162
+ }
163
+ function isRecord(value) {
164
+ return !!value && typeof value === "object" && !Array.isArray(value);
165
+ }
166
+ function parseJson(body) {
167
+ try {
168
+ return { _tag: "Ok", value: JSON.parse(body) };
169
+ } catch {
170
+ return { _tag: "Err", message: "Discovery response contains invalid JSON." };
171
+ }
172
+ }
173
+ function discoveryDocument(capability, document) {
174
+ if (capability === "llms")
175
+ return !isHtml(document.body) && /^#\s+\S/m.test(document.body);
176
+ if (!/^application\/(?:[\w.+-]+\+)?json(?:;|$)/i.test(document.headers.get("content-type") || ""))
177
+ return false;
178
+ const parsed = parseJson(document.body);
179
+ if (parsed._tag === "Err")
180
+ return false;
181
+ const value = parsed.value;
182
+ if (!isRecord(value))
183
+ return false;
184
+ switch (capability) {
185
+ case "api-catalog":
186
+ return Array.isArray(value.linkset) && value.linkset.length > 0 && value.linkset.every((entry) => isRecord(entry) && typeof entry.anchor === "string");
187
+ case "skills":
188
+ return Array.isArray(value.skills);
189
+ case "ai-catalog":
190
+ return value.specVersion === "1.0" && Array.isArray(value.entries) && value.entries.length > 0 && value.entries.every((entry) => isRecord(entry) && typeof entry.url === "string");
191
+ case "mcp":
192
+ return typeof value.name === "string" && typeof value.version === "string" && Array.isArray(value.remotes) && value.remotes.some((remote) => isRecord(remote) && remote.type === "streamable-http" && typeof remote.url === "string");
193
+ }
194
+ }
195
+ async function auditSite(options, dependencies) {
196
+ const [html, negotiated] = await Promise.all([
197
+ readDocument(options.url, "text/html", dependencies),
198
+ readDocument(options.url, "text/markdown", dependencies)
199
+ ]);
200
+ const pageUrl = html._tag === "Ok" && !responseFailure(html) ? html.value.url : options.url;
201
+ const page = new URL(pageUrl);
202
+ const site = new URL(options.base, page);
203
+ const advertised = html._tag === "Ok" ? links(html.value.headers.get("link")) : [];
204
+ const alternate = advertised.find((link) => link.rel.includes("alternate") && link.type.toLowerCase() === "text/markdown");
205
+ const linkedUrl = alternate ? publicUrl(alternate.href, pageUrl) : void 0;
206
+ const direct = new URL(pageUrl);
207
+ direct.pathname = `${direct.pathname.replace(/\/$/, "") || "/index"}.md`;
208
+ const robotsUrl = new URL("/robots.txt", page).href;
209
+ const [directMd, robots] = await Promise.all([
210
+ readDocument(direct.href, "text/markdown", dependencies),
211
+ readDocument(robotsUrl, "text/plain", dependencies)
212
+ ]);
213
+ const robotsSitemap = robots._tag === "Ok" ? robots.value.body.match(/^sitemap:\s*(\S+)/im)?.[1] : void 0;
214
+ const sitemapUrl = robotsSitemap ? publicUrl(robotsSitemap, robotsUrl) : publicUrl("sitemap.xml", site.href);
215
+ const sitemap = sitemapUrl._tag === "Ok" ? await readDocument(sitemapUrl.value.href, "application/xml, text/xml", dependencies) : sitemapUrl;
216
+ const missingUrl = new URL(".well-known/nuxt-ai-ready-audit-missing", site).href;
217
+ const missing = await readDocument(missingUrl, "text/markdown", dependencies);
218
+ const checks = [
219
+ contentCheck("html", options.url, html, (doc) => /^text\/html(?:;|$)/i.test(doc.headers.get("content-type") || "") && !!doc.body.trim(), "Public HTML is available.", "Expected a non-empty HTML page."),
220
+ contentCheck("markdown-negotiation", options.url, negotiated, markdown, "Accept: text/markdown returns Markdown.", "Expected a non-empty text/markdown response."),
221
+ contentCheck("markdown-url", direct.href, directMd, markdown, "The direct Markdown URL is available.", "Expected a non-empty text/markdown response."),
222
+ { id: "markdown-link", status: linkedUrl?._tag === "Ok" && linkedUrl.value.href === direct.href ? "pass" : "warn", detail: linkedUrl?._tag === "Ok" && linkedUrl.value.href === direct.href ? "The Link header advertises the direct Markdown URL." : alternate ? "Inspect the alternate Markdown Link target." : "No alternate Markdown Link header." },
223
+ contentCheck("robots", robotsUrl, robots, (doc) => !isHtml(doc.body) && /^user-agent:\s*\S/im.test(doc.body), "robots.txt is available. Inspect its crawler-specific rules.", "Expected robots.txt with a User-agent group."),
224
+ contentCheck("sitemap", sitemapUrl._tag === "Ok" ? sitemapUrl.value.href : site.href, sitemap, (doc) => /<(?:urlset|sitemapindex)(?:\s|>)/i.test(doc.body) && /<loc>\s*https?:\/\/[^<]+<\/loc>/i.test(doc.body), "A sitemap document is available.", "Expected a sitemap with absolute URL entries."),
225
+ policyCheck(html, negotiated),
226
+ policyCheck(html, directMd, "content-signal-markdown-url"),
227
+ { id: "unknown-route", url: missingUrl, status: missing._tag === "Ok" && [404, 410].includes(missing.value.status) ? "pass" : "fail", detail: missing._tag === "Err" ? missing.message : [404, 410].includes(missing.value.status) ? "Unknown routes return 404 or 410." : `Unknown route returned HTTP ${missing.value.status}. Check fallback routing.` }
228
+ ];
229
+ if (negotiated._tag === "Ok" && !responseFailure(negotiated)) {
230
+ const vary = (negotiated.value.headers.get("vary") || "").toLowerCase().split(",").map((value) => value.trim());
231
+ checks.push({ id: "vary-accept", status: vary.includes("accept") || vary.includes("*") ? "pass" : "fail", detail: vary.includes("accept") || vary.includes("*") ? "Vary separates HTML and Markdown cache entries." : "Set Vary: Accept on negotiated responses." });
232
+ }
233
+ const paths = { "llms": "llms.txt", "api-catalog": ".well-known/api-catalog", "ai-catalog": ".well-known/ai-catalog.json", "mcp": ".well-known/mcp/server-card.json", "skills": ".well-known/agent-skills/index.json" };
234
+ const discovery = await Promise.all(capabilities.map(async (capability) => {
235
+ const declaredLink = capability === "api-catalog" ? advertised.find((link) => link.rel.includes("api-catalog")) : void 0;
236
+ if (!options.capabilities.includes(capability) && !declaredLink)
237
+ return { id: capability, status: "skip", detail: "Not declared. Use --capabilities to check this feature." };
238
+ const url = publicUrl(declaredLink?.href || paths[capability], declaredLink ? pageUrl : site.href);
239
+ if (url._tag === "Err")
240
+ return { id: capability, status: "fail", detail: url.message };
241
+ const document = await readDocument(url.value.href, capability === "llms" ? "text/plain" : "application/json", dependencies);
242
+ return contentCheck(capability, url.value.href, document, (doc) => discoveryDocument(capability, doc), "The discovery document is available.", "The discovery document has an unexpected format.");
243
+ }));
244
+ return { schemaVersion: 1, url: options.url, checks: [...checks, ...discovery] };
245
+ }
246
+
13
247
  async function getSecret(cwd) {
14
248
  const secretPath = join(cwd, "node_modules/.cache/nuxt/ai-ready/secret");
15
249
  if (!existsSync(secretPath)) {
@@ -53,6 +287,31 @@ const main = defineCommand({
53
287
  version: await readPackageMetadata(import.meta.url).then((p) => p.version || "0.0.0")
54
288
  },
55
289
  subCommands: {
290
+ audit: () => defineCommand({
291
+ meta: { name: "audit", description: "Check a public page and its agent discovery documents" },
292
+ args: {
293
+ url: { type: "positional", required: true, description: "Public page URL" },
294
+ base: { type: "string", default: "/", description: "Application base path, such as /docs/" },
295
+ capabilities: { type: "string", description: "Check declared features: llms,api-catalog,ai-catalog,mcp,skills" },
296
+ json: { type: "boolean", description: "Print structured JSON results" }
297
+ },
298
+ async run({ args }) {
299
+ const parsed = parseAuditOptions(args);
300
+ if (parsed._tag === "Err")
301
+ throw new Error(parsed.message);
302
+ const report = await auditSite(parsed.value, { fetch });
303
+ if (args.json) {
304
+ process.stdout.write(`${JSON.stringify(report, null, 2)}
305
+ `);
306
+ } else {
307
+ consola.info(`Readiness audit: ${report.url}`);
308
+ for (const check of report.checks)
309
+ consola.log(`${check.status.toUpperCase()} ${check.id}: ${check.detail}`);
310
+ }
311
+ if (report.checks.some((check) => check.status === "fail"))
312
+ process.exitCode = 1;
313
+ }
314
+ }),
56
315
  status: () => defineCommand({
57
316
  meta: {
58
317
  name: "status",
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.2.0",
7
+ "version": "3.3.1",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"