nuxt-ai-ready 3.3.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.
- package/dist/chunks/agent-skills.mjs +1 -1
- package/dist/chunks/prerender.mjs +24 -17
- package/dist/cli.mjs +259 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +1 -1
- package/dist/runtime/llms-txt-files.js +58 -3
- package/dist/runtime/llms-txt-format.js +5 -4
- package/dist/runtime/markdown-text.d.ts +2 -0
- package/dist/runtime/markdown-text.js +3 -0
- package/dist/runtime/server/db/drizzle/queries.d.ts +2 -6
- package/dist/runtime/server/db/drizzle/queries.js +30 -10
- package/dist/runtime/server/db/queries.d.ts +2 -6
- package/dist/runtime/server/db/queries.js +12 -7
- package/dist/runtime/server/db/schema/postgres.d.ts +30 -0
- package/dist/runtime/server/db/schema/postgres.js +1 -0
- package/dist/runtime/server/db/schema/sqlite.d.ts +32 -0
- package/dist/runtime/server/db/schema/sqlite.js +1 -0
- package/dist/runtime/server/db/schema-sql.d.ts +1 -1
- package/dist/runtime/server/db/schema-sql.js +2 -1
- package/dist/runtime/server/db/seed.d.ts +8 -0
- package/dist/runtime/server/db/seed.js +16 -0
- package/dist/runtime/server/db/shared.d.ts +3 -3
- package/dist/runtime/server/db/shared.js +19 -1
- package/dist/runtime/server/plugins/sitemap-seeder.js +9 -1
- package/dist/runtime/server/routes/llms.txt.get.js +4 -2
- package/dist/runtime/server/utils/llms-full.js +31 -5
- package/dist/runtime/server/utils/negotiation-response.js +3 -0
- package/dist/runtime/server/utils/runCron.js +1 -1
- package/dist/runtime/server/utils/sitemap-lastmod.d.ts +4 -0
- package/dist/runtime/server/utils/sitemap-lastmod.js +19 -0
- package/dist/runtime/server/utils/sitemap-md.js +3 -2
- package/dist/runtime/server/utils/sitemap.js +4 -2
- package/dist/runtime/server/utils.js +50 -2
- package/dist/shared/{nuxt-ai-ready.BwnSMqRn.mjs → nuxt-ai-ready.CKXM1jnE.mjs} +1 -2
- 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.
|
|
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';
|
|
@@ -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.
|
|
9
|
+
import { l as logger, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.CKXM1jnE.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
|
|
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(
|
|
354
|
-
|
|
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 +
|
|
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
|
|
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) {
|
|
@@ -590,7 +597,7 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
|
|
|
590
597
|
if (target.origin !== origin)
|
|
591
598
|
continue;
|
|
592
599
|
const path = parsedTarget.route;
|
|
593
|
-
if (
|
|
600
|
+
if (/^\/llms\/[^/]+\.txt$/i.test(path))
|
|
594
601
|
pending.push(path);
|
|
595
602
|
}
|
|
596
603
|
}
|
|
@@ -637,4 +644,4 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
|
|
|
637
644
|
});
|
|
638
645
|
}
|
|
639
646
|
|
|
640
|
-
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.json
CHANGED
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.
|
|
10
|
+
export { m as default } from './shared/nuxt-ai-ready.CKXM1jnE.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
|
|
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 === "[" ? "[" : "]");
|
|
10
|
+
return escapeMarkdownHtml(normalizeInlineText(value)).replace(RE_LINK_TITLE_BRACKET, (bracket) => bracket === "[" ? "[" : "]");
|
|
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) ?? [];
|
|
@@ -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:
|
|
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 {};
|