nuxt-ai-ready 3.3.0 → 3.4.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 (43) hide show
  1. package/dist/chunks/agent-skills.mjs +1 -1
  2. package/dist/chunks/prerender.mjs +35 -17
  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 +1 -1
  7. package/dist/runtime/llms-txt-files.js +58 -3
  8. package/dist/runtime/llms-txt-format.js +5 -4
  9. package/dist/runtime/markdown-text.d.ts +2 -0
  10. package/dist/runtime/markdown-text.js +3 -0
  11. package/dist/runtime/server/db/drizzle/queries.d.ts +2 -6
  12. package/dist/runtime/server/db/drizzle/queries.js +30 -10
  13. package/dist/runtime/server/db/queries.d.ts +2 -6
  14. package/dist/runtime/server/db/queries.js +12 -7
  15. package/dist/runtime/server/db/schema/postgres.d.ts +30 -0
  16. package/dist/runtime/server/db/schema/postgres.js +1 -0
  17. package/dist/runtime/server/db/schema/sqlite.d.ts +32 -0
  18. package/dist/runtime/server/db/schema/sqlite.js +1 -0
  19. package/dist/runtime/server/db/schema-sql.d.ts +1 -1
  20. package/dist/runtime/server/db/schema-sql.js +2 -1
  21. package/dist/runtime/server/db/seed.d.ts +8 -0
  22. package/dist/runtime/server/db/seed.js +16 -0
  23. package/dist/runtime/server/db/shared.d.ts +3 -3
  24. package/dist/runtime/server/db/shared.js +19 -1
  25. package/dist/runtime/server/middleware/markdown.prerender.js +9 -1
  26. package/dist/runtime/server/plugins/content-signal-capture-nitro3.prerender.d.ts +2 -0
  27. package/dist/runtime/server/plugins/content-signal-capture-nitro3.prerender.js +13 -0
  28. package/dist/runtime/server/plugins/content-signal-capture.prerender.d.ts +2 -0
  29. package/dist/runtime/server/plugins/content-signal-capture.prerender.js +13 -0
  30. package/dist/runtime/server/plugins/sitemap-seeder.js +9 -1
  31. package/dist/runtime/server/routes/llms.txt.get.js +4 -2
  32. package/dist/runtime/server/utils/llms-full.js +31 -5
  33. package/dist/runtime/server/utils/negotiation-response.js +3 -0
  34. package/dist/runtime/server/utils/prerender-html.d.ts +3 -0
  35. package/dist/runtime/server/utils/prerender-html.js +12 -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.js +4 -2
  41. package/dist/runtime/server/utils.js +50 -2
  42. package/dist/shared/{nuxt-ai-ready.BwnSMqRn.mjs → nuxt-ai-ready.O6_9Egm8.mjs} +367 -246
  43. package/package.json +3 -3
@@ -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.BwnSMqRn.mjs';
5
+ import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.O6_9Egm8.mjs';
6
6
  import 'node:module';
7
7
  import 'node:url';
8
8
  import '@nuxt/kit';
@@ -6,7 +6,7 @@ import { resolveLocaleFromRoute } from 'nuxtseo-shared/i18n-runtime';
6
6
  import { createPrerenderFetch } from 'nuxtseo-shared/prerender';
7
7
  import { collectSitemap } from 'sitemapd/parse';
8
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';
9
+ import { l as logger, m as matchingStaticContentSignals, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.O6_9Egm8.mjs';
10
10
  import { toLogicalRoute, toDeployedRoute } from '../../dist/runtime/route-path.js';
11
11
  import { insertLlmsTxtDirective } from '../../dist/runtime/llms-txt-directive.js';
12
12
  import { absoluteSiteUrl } from '../../dist/runtime/markdown-links.js';
@@ -325,7 +325,7 @@ async function crawlSitemapEntries(state, nuxt, nitro, entries) {
325
325
  logger.debug(`Sitemap crawl complete: ${crawled} crawled, ${skipped} skipped`);
326
326
  return crawled;
327
327
  }
328
- async function crawlSitemapContent(state, nuxt, nitro, sitemapContent, seen = /* @__PURE__ */ new Set(), depth = 0, documents = /* @__PURE__ */ new Map()) {
328
+ async function crawlSitemapContent(state, nuxt, nitro, sitemapContent, seen = /* @__PURE__ */ new Set(), depth = 0, documents = /* @__PURE__ */ new Map(), budget = { fetched: 0, warned: false }) {
329
329
  logger.debug(`Parsing sitemap XML (${sitemapContent.length} bytes)`);
330
330
  const result = await collectSitemap(sitemapContent);
331
331
  if (result._tag !== "document") {
@@ -334,24 +334,30 @@ async function crawlSitemapContent(state, nuxt, nitro, sitemapContent, seen = /*
334
334
  return 0;
335
335
  }
336
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
337
  let crawled = 0;
342
338
  const origin = new URL(state.siteInfo?.url || "http://localhost").origin;
343
339
  for (const entry of result.document.entries) {
344
340
  if (parsePrerenderRoute(entry.loc, nitro.options.baseURL)._tag === "Invalid")
345
341
  continue;
346
342
  const url = new URL(entry.loc, origin);
347
- if (url.origin !== origin || seen.has(url.href))
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
+ }
348
355
  continue;
349
- if (seen.size >= 100) {
350
- logger.warn("Sitemap crawl reached its document limit.");
351
- break;
352
356
  }
353
- seen.add(url.href);
354
- const content = documents.get(toLogicalRoute(url.pathname, nitro.options.baseURL))?.() ?? await crawlerFetch(state)(`${url.pathname}${url.search}`, {
357
+ seen.add(identity);
358
+ if (!localDocument)
359
+ budget.fetched++;
360
+ const content = localDocument?.() ?? await crawlerFetch(state)(`${url.pathname}${url.search}`, {
355
361
  headers: { "x-nitro-prerender": url.pathname },
356
362
  retry: 0,
357
363
  signal: AbortSignal.timeout(PRERENDER_PAGE_TIMEOUT)
@@ -360,7 +366,7 @@ async function crawlSitemapContent(state, nuxt, nitro, sitemapContent, seen = /*
360
366
  return null;
361
367
  });
362
368
  if (content)
363
- crawled += await crawlSitemapContent(state, nuxt, nitro, content, seen, depth + 1, documents);
369
+ crawled += await crawlSitemapContent(state, nuxt, nitro, content, seen, depth + Number(!localDocument), documents, budget);
364
370
  }
365
371
  return crawled;
366
372
  }
@@ -371,11 +377,12 @@ async function crawlSitemapContent(state, nuxt, nitro, sitemapContent, seen = /*
371
377
  async function crawlConfiguredSitemaps(state, nuxt, nitro, sitemap, documents = /* @__PURE__ */ new Map()) {
372
378
  const names = state.llmsTxtConfig?.sitemaps;
373
379
  const singleName = (sitemap.sitemapName || "sitemap.xml").replace(/\.xml$/, "");
374
- const roots = sitemap.sitemaps && typeof sitemap.sitemaps === "object" ? Object.entries(sitemap.sitemaps).flatMap(([key, definition]) => {
380
+ const configured = sitemap.sitemaps && typeof sitemap.sitemaps === "object" ? sitemap.sitemaps : void 0;
381
+ const roots = configured ? Object.entries(configured).flatMap(([key, definition]) => {
375
382
  const name = (definition.sitemapName || key).replace(/\.xml$/, "");
376
383
  if (key === "index" || names !== void 0 && !names.includes(name))
377
384
  return [];
378
- const route = definition._route || definition.route;
385
+ const route = definition._route || definition.route || (key === "chunks" ? configured.index?._route || configured.index?.route : void 0);
379
386
  return [route ? toLogicalRoute(route, nitro.options.baseURL) : joinURL(sitemap.sitemapsPathPrefix ?? "/__sitemap__/", `${name}.xml`)];
380
387
  }) : names !== void 0 && !names.includes(sitemap.sitemaps ? "chunks" : singleName) ? [] : [sitemap.sitemaps ? "/sitemap_index.xml" : `/${sitemap.sitemapName || "sitemap.xml"}`];
381
388
  for (const root of roots) {
@@ -490,6 +497,17 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
490
497
  initPromise = initCrawler(state);
491
498
  await initPromise;
492
499
  const parsed = JSON.parse(route.contents || "{}");
500
+ if (parsed.contentSignal !== void 0) {
501
+ route.contentSignal = parsed.contentSignal;
502
+ for (const sourceRoute of pageRoute === "/" ? ["/"] : [pageRoute, `${pageRoute}/`]) {
503
+ if (matchingStaticContentSignals(nitro.options.routeRules, sourceRoute).includes(parsed.contentSignal))
504
+ continue;
505
+ const source = nitro.options.routeRules[sourceRoute] ||= {};
506
+ source.headers ||= {};
507
+ if (!Object.keys(source.headers).some((name) => name.toLowerCase() === "content-signal"))
508
+ source.headers["Content-Signal"] = parsed.contentSignal;
509
+ }
510
+ }
493
511
  if (!state.prerenderedRoutes.has(pageRoute))
494
512
  await processMarkdownRoute(state, nuxt, pageRoute, parsed);
495
513
  const withSitemap = options.sitemapMd === false ? parsed.markdown : appendSitemapSection(parsed.markdown, absoluteSiteUrl(toDeployedRoute(SITEMAP_MD_ROUTE, nitro.options.baseURL), siteInfo?.url));
@@ -590,7 +608,7 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
590
608
  if (target.origin !== origin)
591
609
  continue;
592
610
  const path = parsedTarget.route;
593
- if (path.startsWith("/llms/") && path.endsWith(".txt"))
611
+ if (/^\/llms\/[^/]+\.txt$/i.test(path))
594
612
  pending.push(path);
595
613
  }
596
614
  }
@@ -637,4 +655,4 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
637
655
  });
638
656
  }
639
657
 
640
- export { createPrerenderDatabase, detectSitemapPrerender, setupPrerenderHandler };
658
+ 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
@@ -29,6 +29,7 @@ interface ParsedMarkdownResult {
29
29
  headings: Array<Record<string, string>>;
30
30
  keywords?: string[];
31
31
  updatedAt?: string;
32
+ contentSignal?: string;
32
33
  }
33
34
 
34
35
  interface ResolvedApiCatalogLinkTarget {
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.3.0",
7
+ "version": "3.4.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -7,7 +7,7 @@ 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.BwnSMqRn.mjs';
10
+ export { a as default } from './shared/nuxt-ai-ready.O6_9Egm8.mjs';
11
11
  import './shared/nuxt-ai-ready.B3hAuOLQ.mjs';
12
12
  import '../dist/runtime/llms-txt-files.js';
13
13
  import '../dist/runtime/route-path.js';
@@ -72,13 +72,13 @@ ${lines.join("\n")}
72
72
  }
73
73
  if (chunk.length)
74
74
  chunks.push(chunk);
75
- if (chunks.length >= lines.length)
76
- throw new Error("llmsTxt.maxLength cannot fit an index. Increase the limit.");
77
75
  const links = chunks.map((entries, index) => {
78
76
  const child = `/llms/_${prefix}-${level}-${index + 1}.txt`;
79
77
  files.set(child, render(header, title, entries));
80
78
  return link(`${title} ${index + 1}`, child);
81
79
  });
80
+ if (chunks.length >= lines.length && render(header, title, links).length >= body.length)
81
+ throw new Error("llmsTxt.maxLength cannot fit an index. Increase the limit.");
82
82
  tree(path, prefix, header, title, links, level + 1);
83
83
  }
84
84
  const sections = [];
@@ -111,11 +111,66 @@ ${preamble}
111
111
  ${link("llms.txt", "/llms.txt")}`, section.title, entries);
112
112
  sections.push({ ...section, sitemap: void 0, path: void 0, links: [{ title: section.title, href: input.resolveUrl(path), description }] });
113
113
  }
114
- const rootHeader = `${input.header.trim()}
114
+ const rootPreamble = `${input.header.trim()}
115
+
116
+ ${normalizeLlmsTxtConfig({ notes: input.config.notes })}${input.rootContent ? `
117
+
118
+ ${input.rootContent}` : ""}`;
119
+ const pageIndexSpace = input.pages.some((page) => !assigned.has(page.pathname)) ? render("", "Pages", [link("Pages", "/llms/_pages.txt")]).length : 1;
120
+ let rootHeader = `${input.header.trim()}
115
121
 
116
122
  ${normalizeLlmsTxtConfig({ ...input.config, sections })}${input.rootContent ? `
117
123
 
118
124
  ${input.rootContent}` : ""}`;
125
+ if (rootHeader.length + pageIndexSpace > maxLength) {
126
+ const references = sections.flatMap((section, index) => {
127
+ const path = `/llms/_configured-${index + 1}.txt`;
128
+ if (!section.links?.length) {
129
+ const context = normalizeLlmsTxtConfig({ sections: [section] });
130
+ if (!context)
131
+ return [];
132
+ const body = `${input.header.trim()}
133
+
134
+ ${context}
135
+
136
+ ${link("llms.txt", "/llms.txt")}
137
+ `;
138
+ if (body.length > maxLength)
139
+ throw new Error("An llms.txt entry exceeds llmsTxt.maxLength. Shorten the entry or increase the limit.");
140
+ files.set(path, body);
141
+ return [{ title: section.title, links: [{ title: section.title, href: input.resolveUrl(path) }] }];
142
+ }
143
+ const text = normalizeLlmsTxtConfig({ sections: [{ ...section, description: section.optional ? section.description : void 0 }] });
144
+ const lines = text.split("\n").slice(2);
145
+ const description = Array.isArray(section.description) ? section.description.join(" ") : section.description;
146
+ const preamble = section.optional ? "" : normalizeLlmsTxtConfig({ notes: description });
147
+ tree(path, `configured-${index + 1}`, `${input.header.trim()}
148
+
149
+ ${preamble}
150
+
151
+ ${link("llms.txt", "/llms.txt")}`, section.optional ? "Optional" : section.title, lines);
152
+ return [{ title: section.title, optional: section.optional, links: [{ title: section.title, href: input.resolveUrl(path), description }] }];
153
+ });
154
+ rootHeader = `${rootPreamble}
155
+
156
+ ${normalizeLlmsTxtConfig({ sections: references })}`;
157
+ if (rootHeader.length + pageIndexSpace > maxLength) {
158
+ const indexes = [];
159
+ for (const optional of [false, true]) {
160
+ const selected = references.filter((section) => Boolean(section.optional) === optional);
161
+ if (!selected.length)
162
+ continue;
163
+ const title = optional ? "Optional" : "Sections";
164
+ const path = optional ? "/llms/_optional.txt" : "/llms/_sections.txt";
165
+ const text = normalizeLlmsTxtConfig({ sections: selected.map((section) => ({ ...section, optional: true })) });
166
+ tree(path, optional ? "optional" : "sections", input.header, title, text.split("\n").slice(2));
167
+ indexes.push({ title, optional, links: [{ title, href: input.resolveUrl(path) }] });
168
+ }
169
+ rootHeader = `${rootPreamble}
170
+
171
+ ${normalizeLlmsTxtConfig({ sections: indexes })}`;
172
+ }
173
+ }
119
174
  const pages = input.pages.filter((page) => !assigned.has(page.pathname)).map(formatLlmsTxtPageLink);
120
175
  if (!pages.length && rootHeader.length + 1 <= maxLength) {
121
176
  files.set("/llms.txt", `${rootHeader.trim()}
@@ -1,3 +1,4 @@
1
+ import { escapeMarkdownHtml } from "./markdown-text.js";
1
2
  const RE_INLINE_WHITESPACE = /\s+/g;
2
3
  const RE_LINK_TITLE_BRACKET = /[[\]]/g;
3
4
  const RE_LINK_HREF_UNSAFE = /[\s()]/g;
@@ -6,7 +7,7 @@ function normalizeInlineText(value) {
6
7
  return value.trim().replace(RE_INLINE_WHITESPACE, " ");
7
8
  }
8
9
  function normalizeLinkTitle(value) {
9
- return normalizeInlineText(value).replace(RE_LINK_TITLE_BRACKET, (bracket) => bracket === "[" ? "&#91;" : "&#93;");
10
+ return escapeMarkdownHtml(normalizeInlineText(value)).replace(RE_LINK_TITLE_BRACKET, (bracket) => bracket === "[" ? "&#91;" : "&#93;");
10
11
  }
11
12
  function normalizeLinkHref(value) {
12
13
  return value.trim().replace(RE_LINK_HREF_UNSAFE, (character) => {
@@ -29,7 +30,7 @@ function normalizeLink(link, descriptionPrefixes = []) {
29
30
  return `- [${normalizeLinkTitle(link.title)}](${normalizeLinkHref(link.href)})${description ? `: ${description}` : ""}`;
30
31
  }
31
32
  function normalizeSection(section) {
32
- const parts = [`## ${normalizeInlineText(section.title)}`];
33
+ const parts = [`## ${escapeMarkdownHtml(normalizeInlineText(section.title))}`];
33
34
  if (section.links?.length)
34
35
  parts.push("", ...section.links.map((link) => normalizeLink(link)));
35
36
  return parts.join("\n");
@@ -46,7 +47,7 @@ function normalizePreamble(config) {
46
47
  continue;
47
48
  const descriptions = normalizePreambleBlocks(section.description);
48
49
  if (descriptions.length)
49
- parts.push([`**${normalizeInlineText(section.title)}:**`, ...descriptions].join("\n\n"));
50
+ parts.push([`**${escapeMarkdownHtml(normalizeInlineText(section.title))}:**`, ...descriptions].join("\n\n"));
50
51
  }
51
52
  return parts;
52
53
  }
@@ -67,7 +68,7 @@ export function formatLlmsTxtPageLink(page) {
67
68
  const title = normalizedTitle && normalizedTitle !== page.pathname ? normalizedTitle : page.pathname;
68
69
  const href = (page.href || page.pathname).trim();
69
70
  const truncatedDescription = description ? `${description.substring(0, 160)}${description.length > 160 ? "..." : ""}` : void 0;
70
- return normalizeLink({ title, href, description: truncatedDescription });
71
+ return normalizeLink({ title, href, description: truncatedDescription ? escapeMarkdownHtml(truncatedDescription) : void 0 });
71
72
  }
72
73
  export function normalizeLlmsTxtConfig(config) {
73
74
  const required = config.sections?.filter((section) => !section.optional) ?? [];
@@ -0,0 +1,2 @@
1
+ /** Keep plain metadata literal when Markdown allows inline HTML. */
2
+ export declare function escapeMarkdownHtml(value: string): string;
@@ -0,0 +1,3 @@
1
+ export function escapeMarkdownHtml(value) {
2
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
3
+ }
@@ -1,6 +1,6 @@
1
1
  import type { SitemapCrawlState } from '../../utils/sitemap-crawl-state.js';
2
2
  import type { AiReadyDatabaseEvent } from '../context.js';
3
- import type { SeedRoutesOptions } from '../seed.js';
3
+ import type { SeedRoute, SeedRoutesOptions } from '../seed.js';
4
4
  export interface PageInput {
5
5
  route: string;
6
6
  title: string;
@@ -182,9 +182,5 @@ export declare function resetSitemapErrors(event: AiReadyDatabaseEvent | undefin
182
182
  * guard each run bumped `last_seen_at` on every row, which cost millions of
183
183
  * D1 row writes a month. Keep the two layers in sync.
184
184
  */
185
- export declare function seedRoutes(event: AiReadyDatabaseEvent | undefined, routes: Array<string | {
186
- route: string;
187
- locale?: string;
188
- url?: string;
189
- }>, options?: SeedRoutesOptions): Promise<number>;
185
+ export declare function seedRoutes(event: AiReadyDatabaseEvent | undefined, routes: SeedRoute[], options?: SeedRoutesOptions): Promise<number>;
190
186
  export {};