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.
- package/dist/chunks/agent-skills.mjs +2 -1
- package/dist/chunks/prerender.mjs +197 -28
- package/dist/cli.mjs +259 -0
- package/dist/module.d.mts +1 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +2 -1
- package/dist/runtime/llms-txt-files.d.ts +30 -0
- package/dist/runtime/llms-txt-files.js +196 -0
- package/dist/runtime/llms-txt-format.js +5 -4
- package/dist/runtime/llms-txt-utils.d.ts +3 -2
- package/dist/runtime/llms-txt-utils.js +85 -224
- 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 +5 -6
- package/dist/runtime/server/db/queries.js +40 -9
- 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/middleware/markdown.js +1 -1
- package/dist/runtime/server/plugins/sitemap-seeder.js +9 -1
- package/dist/runtime/server/routes/llms-full.txt.get.js +2 -1
- package/dist/runtime/server/routes/llms.txt.get.js +21 -11
- package/dist/runtime/server/routes/sitemap.md.get.js +2 -1
- package/dist/runtime/server/utils/llms-full.js +31 -5
- package/dist/runtime/server/utils/markdown-request.js +2 -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.d.ts +1 -0
- package/dist/runtime/server/utils/sitemap.js +26 -12
- package/dist/runtime/server/utils.js +50 -2
- package/dist/runtime/types.d.ts +13 -1
- package/dist/shared/{nuxt-ai-ready.DH9mSayy.mjs → nuxt-ai-ready.CKXM1jnE.mjs} +40 -12
- package/package.json +3 -3
- 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.
|
|
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 {
|
|
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.
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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;
|