nuxt-ai-ready 2.1.0 → 2.2.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.
- package/dist/chunks/agent-skills.mjs +4 -1
- package/dist/chunks/prerender.mjs +10 -3
- package/dist/cli.mjs +67 -11
- package/dist/module.d.mts +2 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +4 -1
- package/dist/runtime/app/plugins/md-alternate.server.js +6 -2
- package/dist/runtime/llms-txt-utils.js +2 -2
- package/dist/runtime/markdown-path.d.ts +2 -0
- package/dist/runtime/markdown-path.js +4 -0
- package/dist/runtime/server/db/drizzle/index.d.ts +1 -1
- package/dist/runtime/server/db/drizzle/index.js +0 -1
- package/dist/runtime/server/db/drizzle/queries.d.ts +0 -4
- package/dist/runtime/server/db/drizzle/queries.js +22 -16
- package/dist/runtime/server/db/drizzle/raw.js +1 -4
- package/dist/runtime/server/db/queries.d.ts +22 -21
- package/dist/runtime/server/db/queries.js +89 -66
- package/dist/runtime/server/db/shared.d.ts +16 -0
- package/dist/runtime/server/db/shared.js +12 -0
- package/dist/runtime/server/middleware/markdown.js +10 -5
- package/dist/runtime/server/middleware/markdown.prerender.js +4 -1
- package/dist/runtime/server/plugins/sitemap-seeder.js +5 -2
- package/dist/runtime/server/routes/__ai-ready/cron.get.js +10 -2
- package/dist/runtime/server/routes/__ai-ready/poll.post.js +30 -14
- package/dist/runtime/server/routes/__ai-ready/reindex.post.d.ts +2 -0
- package/dist/runtime/server/routes/__ai-ready/reindex.post.js +23 -0
- package/dist/runtime/server/routes/api-catalog.js +11 -2
- package/dist/runtime/server/routes/llms.txt.get.js +17 -12
- package/dist/runtime/server/routes/sitemap.md.get.d.ts +2 -0
- package/dist/runtime/server/routes/sitemap.md.get.js +51 -0
- package/dist/runtime/server/tasks/ai-ready-cron.js +3 -0
- package/dist/runtime/server/utils/i18n.d.ts +4 -0
- package/dist/runtime/server/utils/i18n.js +13 -0
- package/dist/runtime/server/utils/indexPage.js +7 -1
- package/dist/runtime/server/utils/link-header.d.ts +2 -0
- package/dist/runtime/server/utils/link-header.js +6 -0
- package/dist/runtime/server/utils/markdown-request.js +3 -1
- package/dist/runtime/server/utils/runCron.d.ts +24 -0
- package/dist/runtime/server/utils/runCron.js +21 -4
- package/dist/runtime/server/utils/sitemap-md.d.ts +13 -0
- package/dist/runtime/server/utils/sitemap-md.js +65 -0
- package/dist/runtime/types.d.ts +16 -3
- package/dist/shared/{nuxt-ai-ready.BTQAkSYt.mjs → nuxt-ai-ready.D5URd0TD.mjs} +103 -36
- package/package.json +17 -17
|
@@ -1,18 +1,41 @@
|
|
|
1
|
+
import { randomUUID } from "uncrypto";
|
|
1
2
|
import { getRequestURL } from "#nuxtseo/h3";
|
|
2
3
|
import { useEvent, useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
|
-
import {
|
|
4
|
+
import { createUniversalContext } from "../utils/context.js";
|
|
5
|
+
import { hostMatchesLocaleDomain, resolveLocaleFromRoute } from "../utils/i18n.js";
|
|
4
6
|
import { parseSitemapCrawlState, serializeSitemapCrawlState } from "../utils/sitemap-crawl-state.js";
|
|
5
7
|
import { initSchema } from "./drizzle/queries.js";
|
|
6
8
|
import { useRawDb } from "./drizzle/raw.js";
|
|
7
|
-
import { normalizeRoute, normalizeRouteKey } from "./shared.js";
|
|
8
|
-
function
|
|
9
|
+
import { LIKE_ESCAPE, likeSubstring, maxRowsPerInsert, normalizeRoute, normalizeRouteKey } from "./shared.js";
|
|
10
|
+
function hostFromUrl(url) {
|
|
11
|
+
if (!url)
|
|
12
|
+
return void 0;
|
|
13
|
+
try {
|
|
14
|
+
return new URL(url).host || void 0;
|
|
15
|
+
} catch {
|
|
16
|
+
return void 0;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function deriveLocale(event, route, explicit, pageUrl) {
|
|
9
20
|
if (explicit !== void 0)
|
|
10
21
|
return explicit;
|
|
11
22
|
const cfg = useRuntimeConfig(event);
|
|
12
23
|
const i18n = cfg["nuxt-ai-ready"]?.i18n;
|
|
13
24
|
if (!i18n)
|
|
14
25
|
return "";
|
|
15
|
-
|
|
26
|
+
const entryHost = hostFromUrl(pageUrl);
|
|
27
|
+
if (entryHost)
|
|
28
|
+
return resolveLocaleFromRoute(route, i18n, { host: entryHost }).locale;
|
|
29
|
+
let requestHost;
|
|
30
|
+
if (event) {
|
|
31
|
+
try {
|
|
32
|
+
requestHost = getRequestURL(event).host;
|
|
33
|
+
} catch {
|
|
34
|
+
requestHost = void 0;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const host = requestHost && hostMatchesLocaleDomain(requestHost, i18n) ? requestHost : hostFromUrl(createUniversalContext(event).siteUrl);
|
|
38
|
+
return resolveLocaleFromRoute(route, i18n, host ? { host } : void 0).locale;
|
|
16
39
|
}
|
|
17
40
|
function getEventFromContext(providedEvent) {
|
|
18
41
|
if (providedEvent)
|
|
@@ -208,7 +231,7 @@ export async function queryPages(event, options = {}) {
|
|
|
208
231
|
return includeMarkdown ? rowToData(row) : rowToEntry(row);
|
|
209
232
|
}
|
|
210
233
|
const { sql: whereClause, params } = buildWhereClause(where);
|
|
211
|
-
let sql = `SELECT ${cols} FROM ai_ready_pages ${whereClause}`;
|
|
234
|
+
let sql = `SELECT ${cols} FROM ai_ready_pages ${whereClause} ORDER BY route`;
|
|
212
235
|
if (limit) {
|
|
213
236
|
sql += ` LIMIT ?`;
|
|
214
237
|
params.push(limit);
|
|
@@ -264,15 +287,15 @@ export async function searchPages(event, query, options = {}) {
|
|
|
264
287
|
if (!sanitized)
|
|
265
288
|
return [];
|
|
266
289
|
if (db.dialect === "postgres") {
|
|
267
|
-
const searchTerm =
|
|
290
|
+
const searchTerm = likeSubstring(sanitized);
|
|
268
291
|
return db.all(`
|
|
269
292
|
SELECT route, title, description, 0 AS score
|
|
270
293
|
FROM ai_ready_pages
|
|
271
294
|
WHERE is_error = 0
|
|
272
|
-
AND (title ILIKE ? OR description ILIKE ? OR markdown ILIKE ? OR headings ILIKE ?)
|
|
295
|
+
AND (title ILIKE ? ESCAPE ? OR description ILIKE ? ESCAPE ? OR markdown ILIKE ? ESCAPE ? OR headings ILIKE ? ESCAPE ?)
|
|
273
296
|
ORDER BY route
|
|
274
297
|
LIMIT ?
|
|
275
|
-
`, [searchTerm, searchTerm, searchTerm, searchTerm, limit]);
|
|
298
|
+
`, [searchTerm, LIKE_ESCAPE, searchTerm, LIKE_ESCAPE, searchTerm, LIKE_ESCAPE, searchTerm, LIKE_ESCAPE, limit]);
|
|
276
299
|
}
|
|
277
300
|
const terms = sanitized.split(RE_WHITESPACE).map((t) => `${t}*`).join(" ");
|
|
278
301
|
return db.all(`
|
|
@@ -295,9 +318,10 @@ export async function upsertPage(event, page) {
|
|
|
295
318
|
const source = page.source || "runtime";
|
|
296
319
|
const lastSeenAt = source === "runtime" ? indexedAt : null;
|
|
297
320
|
const locale = deriveLocale(event, route, page.locale);
|
|
321
|
+
const localeTrusted = page.locale !== void 0;
|
|
298
322
|
await db.exec(`
|
|
299
323
|
INSERT INTO ai_ready_pages (route, route_key, title, description, markdown, headings, keywords, content_hash, updated_at, indexed_at, is_error, indexed, source, last_seen_at, locale)
|
|
300
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
324
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
301
325
|
ON CONFLICT(route) DO UPDATE SET
|
|
302
326
|
title = excluded.title,
|
|
303
327
|
description = excluded.description,
|
|
@@ -308,11 +332,11 @@ export async function upsertPage(event, page) {
|
|
|
308
332
|
updated_at = excluded.updated_at,
|
|
309
333
|
indexed_at = excluded.indexed_at,
|
|
310
334
|
is_error = excluded.is_error,
|
|
311
|
-
indexed =
|
|
335
|
+
indexed = excluded.indexed,
|
|
312
336
|
source = excluded.source,
|
|
313
|
-
last_seen_at = excluded.last_seen_at
|
|
314
|
-
locale = excluded.locale
|
|
315
|
-
`, [route, routeKey, page.title, page.description, page.markdown, page.headings, keywordsJson, page.contentHash || null, page.updatedAt, indexedAt, page.isError ? 1 : 0, source, lastSeenAt, locale]);
|
|
337
|
+
last_seen_at = excluded.last_seen_at${localeTrusted ? `,
|
|
338
|
+
locale = excluded.locale` : ""}
|
|
339
|
+
`, [route, routeKey, page.title, page.description, page.markdown, page.headings, keywordsJson, page.contentHash || null, page.updatedAt, indexedAt, page.isError ? 1 : 0, page.isError ? 0 : 1, source, lastSeenAt, locale]);
|
|
316
340
|
}
|
|
317
341
|
export async function isPageFresh(event, route, ttlSeconds) {
|
|
318
342
|
if (ttlSeconds <= 0)
|
|
@@ -359,22 +383,27 @@ export async function seedRoutes(event, routes) {
|
|
|
359
383
|
for (const entry of routes) {
|
|
360
384
|
const route = normalizeRoute(typeof entry === "string" ? entry : entry.route);
|
|
361
385
|
const explicitLocale = typeof entry === "string" ? void 0 : entry.locale;
|
|
386
|
+
const entryUrl = typeof entry === "string" ? void 0 : entry.url;
|
|
362
387
|
byRoute.set(route, {
|
|
363
388
|
route,
|
|
364
389
|
routeKey: normalizeRouteKey(route),
|
|
365
|
-
locale: deriveLocale(event, route, explicitLocale)
|
|
390
|
+
locale: deriveLocale(event, route, explicitLocale, entryUrl)
|
|
366
391
|
});
|
|
367
392
|
}
|
|
368
|
-
const
|
|
393
|
+
const rowsPerInsert = maxRowsPerInsert(5);
|
|
369
394
|
const stmts = [];
|
|
370
|
-
for (const batch of chunk([...byRoute.values()],
|
|
395
|
+
for (const batch of chunk([...byRoute.values()], rowsPerInsert)) {
|
|
371
396
|
const valuesSql = batch.map(() => `(?, ?, '', '', '', '[]', '[]', ?, 0, 0, 0, 'runtime', ?, ?)`).join(", ");
|
|
372
397
|
const params = batch.flatMap((r) => [r.route, r.routeKey, now, nowMs, r.locale]);
|
|
373
398
|
stmts.push({
|
|
374
399
|
sql: `
|
|
375
400
|
INSERT INTO ai_ready_pages (route, route_key, title, description, markdown, headings, keywords, updated_at, indexed_at, is_error, indexed, source, last_seen_at, locale)
|
|
376
401
|
VALUES ${valuesSql}
|
|
377
|
-
ON CONFLICT(route) DO UPDATE SET
|
|
402
|
+
ON CONFLICT(route) DO UPDATE SET
|
|
403
|
+
last_seen_at = excluded.last_seen_at,
|
|
404
|
+
locale = excluded.locale,
|
|
405
|
+
is_error = 0,
|
|
406
|
+
indexed = CASE WHEN ai_ready_pages.is_error = 1 THEN 0 ELSE ai_ready_pages.indexed END
|
|
378
407
|
`,
|
|
379
408
|
params
|
|
380
409
|
});
|
|
@@ -382,22 +411,6 @@ export async function seedRoutes(event, routes) {
|
|
|
382
411
|
await db.batch(stmts);
|
|
383
412
|
return byRoute.size;
|
|
384
413
|
}
|
|
385
|
-
export async function getSitemapSeededAt(event) {
|
|
386
|
-
const db = await getDb(event);
|
|
387
|
-
if (!db)
|
|
388
|
-
return void 0;
|
|
389
|
-
const row = await db.first("SELECT value FROM _ai_ready_info WHERE id = ?", ["sitemap_seeded_at"]);
|
|
390
|
-
return row ? Number.parseInt(row.value, 10) : void 0;
|
|
391
|
-
}
|
|
392
|
-
export async function setSitemapSeededAt(event, timestamp) {
|
|
393
|
-
const db = await getDb(event);
|
|
394
|
-
if (!db)
|
|
395
|
-
return;
|
|
396
|
-
await db.exec(`
|
|
397
|
-
INSERT INTO _ai_ready_info (id, value) VALUES (?, ?)
|
|
398
|
-
ON CONFLICT(id) DO UPDATE SET value = excluded.value
|
|
399
|
-
`, ["sitemap_seeded_at", String(timestamp)]);
|
|
400
|
-
}
|
|
401
414
|
export async function pruneStaleRoutes(event, staleThresholdSeconds, protectedSince) {
|
|
402
415
|
const db = await getDb(event);
|
|
403
416
|
if (!db)
|
|
@@ -477,22 +490,6 @@ export async function getRecentCronRuns(event, limit = 10) {
|
|
|
477
490
|
);
|
|
478
491
|
return rows.map(rowToCronRun);
|
|
479
492
|
}
|
|
480
|
-
export async function cleanupOldCronRuns(event, keepCount = 50) {
|
|
481
|
-
const db = await getDb(event);
|
|
482
|
-
if (!db)
|
|
483
|
-
return 0;
|
|
484
|
-
const countRow = await db.first("SELECT COUNT(*) as count FROM ai_ready_cron_runs");
|
|
485
|
-
const total = toNumber(countRow?.count);
|
|
486
|
-
if (total <= keepCount)
|
|
487
|
-
return 0;
|
|
488
|
-
const deleteCount = total - keepCount;
|
|
489
|
-
await db.exec(`
|
|
490
|
-
DELETE FROM ai_ready_cron_runs WHERE id IN (
|
|
491
|
-
SELECT id FROM ai_ready_cron_runs ORDER BY started_at ASC LIMIT ?
|
|
492
|
-
)
|
|
493
|
-
`, [deleteCount]);
|
|
494
|
-
return deleteCount;
|
|
495
|
-
}
|
|
496
493
|
export async function pruneCronRunsByAge(event, maxAgeMs = 24 * 60 * 60 * 1e3) {
|
|
497
494
|
const db = await getDb(event);
|
|
498
495
|
if (!db)
|
|
@@ -532,25 +529,50 @@ export async function getCronFastPathStatus(event, sitemapIntervalMinutes = 5) {
|
|
|
532
529
|
};
|
|
533
530
|
}
|
|
534
531
|
const CRON_LOCK_TTL_MS = 3e5;
|
|
532
|
+
function cronLockField(db, column, key) {
|
|
533
|
+
if (db.dialect !== "postgres")
|
|
534
|
+
return `json_extract(${column}, '$.${key}')`;
|
|
535
|
+
const jsonb = `${column}::jsonb`;
|
|
536
|
+
return `CASE WHEN jsonb_typeof(${jsonb}) = 'object' THEN (${jsonb} ->> '${key}') END`;
|
|
537
|
+
}
|
|
535
538
|
export async function tryAcquireCronLock(event) {
|
|
539
|
+
const token = randomUUID();
|
|
536
540
|
const db = await getDb(event);
|
|
537
541
|
if (!db)
|
|
538
|
-
return
|
|
542
|
+
return { _tag: "acquired", token };
|
|
539
543
|
const now = Date.now();
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
|
|
544
|
+
const value = JSON.stringify({ t: token, a: now, e: now + CRON_LOCK_TTL_MS });
|
|
545
|
+
try {
|
|
546
|
+
await db.exec(`
|
|
547
|
+
INSERT INTO _ai_ready_info (id, value) VALUES ('cron_lock', ?)
|
|
548
|
+
ON CONFLICT(id) DO UPDATE SET value = excluded.value
|
|
549
|
+
WHERE CAST(coalesce(${cronLockField(db, "_ai_ready_info.value", "e")}, '0') AS BIGINT) < ?
|
|
550
|
+
`, [value, now]);
|
|
551
|
+
const row = await db.first(
|
|
552
|
+
`SELECT value FROM _ai_ready_info WHERE id = 'cron_lock' AND ${cronLockField(db, "value", "t")} = ?`,
|
|
553
|
+
[token]
|
|
554
|
+
);
|
|
555
|
+
return row ? { _tag: "acquired", token } : { _tag: "held" };
|
|
556
|
+
} catch (error) {
|
|
557
|
+
await releaseCronLock(event, token).catch(() => {
|
|
558
|
+
});
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
export async function releaseCronLock(event, token) {
|
|
550
563
|
const db = await getDb(event);
|
|
551
564
|
if (!db)
|
|
552
565
|
return;
|
|
553
|
-
await db.exec(
|
|
566
|
+
await db.exec(`DELETE FROM _ai_ready_info WHERE id = 'cron_lock' AND ${cronLockField(db, "value", "t")} = ?`, [token]);
|
|
567
|
+
}
|
|
568
|
+
const RE_CRON_LOCK_NUMERIC = /^\d+$/;
|
|
569
|
+
function parseCronLockValue(value) {
|
|
570
|
+
if (RE_CRON_LOCK_NUMERIC.test(value))
|
|
571
|
+
return { acquiredAt: Number(value), expiresAt: Number(value) + CRON_LOCK_TTL_MS };
|
|
572
|
+
const parsed = safeJsonParse(value, null);
|
|
573
|
+
if (parsed && typeof parsed === "object" && typeof parsed.a === "number" && typeof parsed.e === "number")
|
|
574
|
+
return { acquiredAt: parsed.a, expiresAt: parsed.e };
|
|
575
|
+
return null;
|
|
554
576
|
}
|
|
555
577
|
export async function getCronLockStatus(event) {
|
|
556
578
|
const db = await getDb(event);
|
|
@@ -562,14 +584,15 @@ export async function getCronLockStatus(event) {
|
|
|
562
584
|
);
|
|
563
585
|
if (!row)
|
|
564
586
|
return { held: false, since: null, elapsedMs: null, stale: false };
|
|
565
|
-
const
|
|
587
|
+
const record = parseCronLockValue(row.value);
|
|
588
|
+
if (!record)
|
|
589
|
+
return { held: false, since: null, elapsedMs: null, stale: false };
|
|
566
590
|
const now = Date.now();
|
|
567
|
-
const
|
|
568
|
-
const stale = elapsed >= CRON_LOCK_TTL_MS;
|
|
591
|
+
const stale = now >= record.expiresAt;
|
|
569
592
|
return {
|
|
570
593
|
held: !stale,
|
|
571
|
-
since:
|
|
572
|
-
elapsedMs:
|
|
594
|
+
since: record.acquiredAt,
|
|
595
|
+
elapsedMs: now - record.acquiredAt,
|
|
573
596
|
stale
|
|
574
597
|
};
|
|
575
598
|
}
|
|
@@ -44,6 +44,22 @@ export declare function initSchema(db: DatabaseAdapter, options?: InitSchemaOpti
|
|
|
44
44
|
* one row. Widening it would let both spellings persist and race for the key.
|
|
45
45
|
*/
|
|
46
46
|
export declare function normalizeRoute(route: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* Escape LIKE/ILIKE wildcard characters in a search term so the term matches
|
|
49
|
+
* literal text. Pair with an `ESCAPE` clause (see `LIKE_ESCAPE`): SQLite has no
|
|
50
|
+
* default escape character, so without the clause the escaping is inert.
|
|
51
|
+
*/
|
|
52
|
+
export declare function escapeLikeTerm(term: string): string;
|
|
53
|
+
/** Substring LIKE/ILIKE pattern for a term, with wildcards escaped. */
|
|
54
|
+
export declare function likeSubstring(term: string): string;
|
|
55
|
+
/** Escape character to bind next to `likeSubstring` patterns. */
|
|
56
|
+
export declare const LIKE_ESCAPE = "\\";
|
|
57
|
+
/**
|
|
58
|
+
* Multi-row INSERT chunk size that keeps one statement within the 100-bind
|
|
59
|
+
* cap shared by SQLite and D1, derived from the row's bind count so an added
|
|
60
|
+
* column resizes the chunk instead of breaking the insert.
|
|
61
|
+
*/
|
|
62
|
+
export declare function maxRowsPerInsert(paramsPerRow: number): number;
|
|
47
63
|
/**
|
|
48
64
|
* Normalize route to storage key format
|
|
49
65
|
* e.g., '/about/team' -> 'about:team', '/' -> 'index'
|
|
@@ -57,6 +57,18 @@ export function normalizeRoute(route) {
|
|
|
57
57
|
return "/";
|
|
58
58
|
return route.startsWith("/") ? route : `/${route}`;
|
|
59
59
|
}
|
|
60
|
+
const RE_LIKE_META = /[\\%_]/g;
|
|
61
|
+
export function escapeLikeTerm(term) {
|
|
62
|
+
return term.replace(RE_LIKE_META, (ch) => `\\${ch}`);
|
|
63
|
+
}
|
|
64
|
+
export function likeSubstring(term) {
|
|
65
|
+
return `%${escapeLikeTerm(term)}%`;
|
|
66
|
+
}
|
|
67
|
+
export const LIKE_ESCAPE = "\\";
|
|
68
|
+
const MAX_BIND_PARAMS_PER_STATEMENT = 100;
|
|
69
|
+
export function maxRowsPerInsert(paramsPerRow) {
|
|
70
|
+
return Math.max(1, Math.floor(MAX_BIND_PARAMS_PER_STATEMENT / paramsPerRow));
|
|
71
|
+
}
|
|
60
72
|
export function normalizeRouteKey(route) {
|
|
61
73
|
return normalizeRoute(route).replace(RE_LEADING_SLASH, "").replace(RE_SLASH, ":") || "index";
|
|
62
74
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createError, defineEventHandler, setHeader, setResponseStatus } from "#nuxtseo/h3";
|
|
2
|
-
import { useNitroApp } from "#nuxtseo/nitro";
|
|
2
|
+
import { useNitroApp, useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
3
|
import { resolveLocaleAlternateUrl } from "../../i18n-url.js";
|
|
4
4
|
import { logger } from "../logger.js";
|
|
5
5
|
import { computeLocaleAlternates, resolveLocaleFromRoute } from "../utils/i18n.js";
|
|
6
6
|
import { INTERNAL_HEADER } from "../utils/negotiation-decision.js";
|
|
7
7
|
import { applyNegotiation, buildNegotiationContext, decideNegotiation, ensureSiteConfig, setMarkdownHeaders } from "../utils/negotiation-response.js";
|
|
8
|
+
import { appendSitemapSection, isSitemapMdRequest, SITEMAP_MD_ROUTE } from "../utils/sitemap-md.js";
|
|
8
9
|
function notFoundMarkdown(ctx, canonicalUrl, build) {
|
|
9
10
|
const { path, config, resolveUrl, routeContext } = ctx;
|
|
10
11
|
const body = [
|
|
@@ -37,6 +38,9 @@ function notFoundMarkdown(ctx, canonicalUrl, build) {
|
|
|
37
38
|
${body}`;
|
|
38
39
|
}
|
|
39
40
|
export default defineEventHandler(async (event) => {
|
|
41
|
+
const runtimeConfig = useRuntimeConfig(event);
|
|
42
|
+
if (isSitemapMdRequest(event.path, runtimeConfig.app.baseURL, runtimeConfig["nuxt-ai-ready"]?.sitemapMd !== false))
|
|
43
|
+
return;
|
|
40
44
|
const decision = decideNegotiation(event, "middleware");
|
|
41
45
|
const negotiationResponse = await applyNegotiation(event, decision);
|
|
42
46
|
if (decision._tag !== "render")
|
|
@@ -45,6 +49,7 @@ export default defineEventHandler(async (event) => {
|
|
|
45
49
|
const ctx = buildNegotiationContext(event, decision.path);
|
|
46
50
|
const { path, config, resolvePath, resolveUrl, routeContext } = ctx;
|
|
47
51
|
const canonicalUrl = resolveUrl(path);
|
|
52
|
+
const finalizeMarkdown = (markdown) => config.sitemapMd === false ? markdown : appendSitemapSection(markdown, resolvePath(SITEMAP_MD_ROUTE));
|
|
48
53
|
const [
|
|
49
54
|
{ tryGetContentMarkdown },
|
|
50
55
|
{ fetchRawWithEvent },
|
|
@@ -65,7 +70,7 @@ export default defineEventHandler(async (event) => {
|
|
|
65
70
|
last_updated: updatedAt || (/* @__PURE__ */ new Date()).toISOString()
|
|
66
71
|
}, markdown);
|
|
67
72
|
setMarkdownHeaders(event, ctx);
|
|
68
|
-
return responseMarkdown;
|
|
73
|
+
return finalizeMarkdown(responseMarkdown);
|
|
69
74
|
}
|
|
70
75
|
const contentPage = await tryGetContentMarkdown(event, path).catch((e) => {
|
|
71
76
|
logger.debug(`[markdown] Content lookup failed for ${path}`, e);
|
|
@@ -80,8 +85,8 @@ export default defineEventHandler(async (event) => {
|
|
|
80
85
|
last_updated: contentPage.updatedAt || (/* @__PURE__ */ new Date()).toISOString()
|
|
81
86
|
});
|
|
82
87
|
setMarkdownHeaders(event, ctx);
|
|
83
|
-
return `${frontmatter}
|
|
84
|
-
${contentPage.markdown}
|
|
88
|
+
return finalizeMarkdown(`${frontmatter}
|
|
89
|
+
${contentPage.markdown}`);
|
|
85
90
|
}
|
|
86
91
|
logger.debug(`[markdown] Fetching HTML for ${path}`);
|
|
87
92
|
const response = await fetchRawWithEvent(event, resolvePath(path), {
|
|
@@ -152,5 +157,5 @@ ${contentPage.markdown}`;
|
|
|
152
157
|
}
|
|
153
158
|
);
|
|
154
159
|
setMarkdownHeaders(event, ctx);
|
|
155
|
-
return result.markdown;
|
|
160
|
+
return finalizeMarkdown(result.markdown);
|
|
156
161
|
});
|
|
@@ -10,6 +10,7 @@ import { buildFrontmatter } from "../utils/frontmatter.js";
|
|
|
10
10
|
import { extractKeywords } from "../utils/keywords.js";
|
|
11
11
|
import { getMarkdownRenderInfo } from "../utils/markdown-request.js";
|
|
12
12
|
import { consumePrerenderedHtml } from "../utils/prerender-html.js";
|
|
13
|
+
import { isSitemapMdRequest } from "../utils/sitemap-md.js";
|
|
13
14
|
function extractHeadingsFromMarkdown(markdown) {
|
|
14
15
|
const headings = [];
|
|
15
16
|
for (const m of markdown.matchAll(/^(#{1,6}) ([^\n]+)$/gm)) {
|
|
@@ -27,11 +28,13 @@ export default defineEventHandler(async (event) => {
|
|
|
27
28
|
if (!import.meta.prerender) {
|
|
28
29
|
return;
|
|
29
30
|
}
|
|
31
|
+
const fullRuntimeConfig = useRuntimeConfig(event);
|
|
32
|
+
if (isSitemapMdRequest(event.path, fullRuntimeConfig.app.baseURL, fullRuntimeConfig["nuxt-ai-ready"]?.sitemapMd !== false))
|
|
33
|
+
return;
|
|
30
34
|
const renderInfo = getMarkdownRenderInfo(event, { _tag: "prerender" });
|
|
31
35
|
if (!renderInfo || "notAcceptable" in renderInfo)
|
|
32
36
|
return;
|
|
33
37
|
const { path } = renderInfo;
|
|
34
|
-
const fullRuntimeConfig = useRuntimeConfig(event);
|
|
35
38
|
const runtimeConfig = fullRuntimeConfig["nuxt-ai-ready"];
|
|
36
39
|
const deployedPath = toDeployedRoute(path, fullRuntimeConfig.app.baseURL);
|
|
37
40
|
const canonicalUrl = withSiteUrl(event, deployedPath);
|
|
@@ -13,6 +13,7 @@ function recordDiagnostic(event, message) {
|
|
|
13
13
|
list.push(message);
|
|
14
14
|
}
|
|
15
15
|
const SEED_INTERVAL_MS = 5 * 60 * 1e3;
|
|
16
|
+
const lastSeedAt = /* @__PURE__ */ new Map();
|
|
16
17
|
const READ_TIMEOUT_MS = 3e3;
|
|
17
18
|
const SLOW_READ_WARN_MS = 1e3;
|
|
18
19
|
const SLOW_SEED_WARN_MS = 1e4;
|
|
@@ -87,9 +88,11 @@ export default function sitemapSeederPlugin(nitroApp) {
|
|
|
87
88
|
logger.warn(`[sitemap-seeder] ${message} for ${sitemapName}`);
|
|
88
89
|
record?.(message);
|
|
89
90
|
}
|
|
90
|
-
|
|
91
|
+
const lastSeed = Math.max(lastCrawled ?? 0, lastSeedAt.get(sitemapName) ?? 0);
|
|
92
|
+
if (Date.now() - lastSeed < SEED_INTERVAL_MS)
|
|
91
93
|
return;
|
|
92
|
-
|
|
94
|
+
lastSeedAt.set(sitemapName, Date.now());
|
|
95
|
+
const routes = [...routeToUrl.entries()].map(([route, url]) => ({ route, url: url.loc }));
|
|
93
96
|
const urlCount = urls.length;
|
|
94
97
|
const seed = async () => {
|
|
95
98
|
const seedStart = Date.now();
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import { eventHandler } from "#nuxtseo/h3";
|
|
1
|
+
import { createError, eventHandler } from "#nuxtseo/h3";
|
|
2
2
|
import { runCron } from "../../utils/runCron.js";
|
|
3
3
|
export default eventHandler(async (event) => {
|
|
4
4
|
const { requireAuth } = await import("../../utils/auth.js");
|
|
5
5
|
requireAuth(event);
|
|
6
|
-
|
|
6
|
+
const result = await runCron(event);
|
|
7
|
+
if (result.failed) {
|
|
8
|
+
throw createError({
|
|
9
|
+
statusCode: 500,
|
|
10
|
+
message: `Cron run failed at the ${result.failed.stage} stage: ${result.failed.message}`,
|
|
11
|
+
data: result.failed
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
7
15
|
});
|
|
@@ -1,21 +1,37 @@
|
|
|
1
|
-
import { eventHandler, getQuery } from "#nuxtseo/h3";
|
|
1
|
+
import { eventHandler, getQuery, setResponseStatus } from "#nuxtseo/h3";
|
|
2
|
+
import { useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
|
+
import { releaseCronLock, tryAcquireCronLock } from "../../db/queries.js";
|
|
4
|
+
import { logger } from "../../logger.js";
|
|
2
5
|
import { batchIndexPages } from "../../utils/batchIndex.js";
|
|
3
6
|
export default eventHandler(async (event) => {
|
|
4
7
|
const { requireAuth } = await import("../../utils/auth.js");
|
|
5
8
|
requireAuth(event);
|
|
9
|
+
const lock = await tryAcquireCronLock(event);
|
|
10
|
+
if (lock._tag === "held") {
|
|
11
|
+
setResponseStatus(event, 409);
|
|
12
|
+
return { locked: true };
|
|
13
|
+
}
|
|
6
14
|
const query = getQuery(event);
|
|
7
|
-
const
|
|
15
|
+
const config = useRuntimeConfig(event)["nuxt-ai-ready"];
|
|
16
|
+
const defaultLimit = Math.max(1, Math.trunc(Math.min(config.runtimeSync?.batchSize ?? 10, 50))) || 10;
|
|
17
|
+
const limit = query.limit ? Math.max(1, Math.min(50, Math.trunc(Number(query.limit)) || defaultLimit)) : defaultLimit;
|
|
8
18
|
const timeout = query.timeout ? Math.max(1e3, Math.trunc(Number(query.timeout)) || 3e4) : void 0;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
try {
|
|
20
|
+
const result = await batchIndexPages(event, {
|
|
21
|
+
limit,
|
|
22
|
+
all: query.all === "true" || query.all === "1",
|
|
23
|
+
timeout
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
indexed: result.indexed,
|
|
27
|
+
remaining: result.remaining,
|
|
28
|
+
errors: result.errors.length > 0 ? result.errors : void 0,
|
|
29
|
+
duration: result.duration,
|
|
30
|
+
complete: result.complete
|
|
31
|
+
};
|
|
32
|
+
} finally {
|
|
33
|
+
await releaseCronLock(event, lock.token).catch((err) => {
|
|
34
|
+
logger.warn(`[poll] Failed to release lock: ${err?.message || err}`);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
21
37
|
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createError, eventHandler, getQuery, setResponseStatus } from "#nuxtseo/h3";
|
|
2
|
+
import { indexPageByRoute } from "../../utils/indexPage.js";
|
|
3
|
+
export default eventHandler(async (event) => {
|
|
4
|
+
const { requireAuth } = await import("../../utils/auth.js");
|
|
5
|
+
requireAuth(event);
|
|
6
|
+
const query = getQuery(event);
|
|
7
|
+
const route = typeof query.route === "string" ? query.route.trim() : "";
|
|
8
|
+
if (!route.startsWith("/")) {
|
|
9
|
+
throw createError({ statusCode: 400, message: 'Invalid route. It must be an absolute path starting with "/", for example "/about".' });
|
|
10
|
+
}
|
|
11
|
+
const force = query.force !== "false" && query.force !== "0";
|
|
12
|
+
const result = await indexPageByRoute(route, event, { force });
|
|
13
|
+
if (!result.success) {
|
|
14
|
+
setResponseStatus(event, 502);
|
|
15
|
+
return { route, indexed: false, error: result.error ?? `Failed to index ${route}` };
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
route,
|
|
19
|
+
indexed: !result.skipped,
|
|
20
|
+
skipped: result.skipped || void 0,
|
|
21
|
+
contentChanged: result.contentChanged
|
|
22
|
+
};
|
|
23
|
+
});
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import { assertMethod, defineEventHandler, setHeader } from "#nuxtseo/h3";
|
|
1
|
+
import { assertMethod, createError, defineEventHandler, setHeader, setHeaders, setResponseStatus } from "#nuxtseo/h3";
|
|
2
2
|
import { useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
3
|
export default defineEventHandler((event) => {
|
|
4
|
+
setHeader(event, "Access-Control-Allow-Origin", "*");
|
|
5
|
+
if (event.method === "OPTIONS") {
|
|
6
|
+
setHeaders(event, {
|
|
7
|
+
"Access-Control-Allow-Headers": "Content-Type, If-None-Match",
|
|
8
|
+
"Access-Control-Allow-Methods": "GET, HEAD"
|
|
9
|
+
});
|
|
10
|
+
setResponseStatus(event, 204);
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
4
13
|
assertMethod(event, ["GET", "HEAD"]);
|
|
5
14
|
const config = useRuntimeConfig(event)["nuxt-ai-ready"].apiCatalog;
|
|
6
15
|
if (!config)
|
|
7
|
-
|
|
16
|
+
throw createError({ statusCode: 404, message: "API catalog is not configured" });
|
|
8
17
|
setHeader(event, "content-type", config.mediaType);
|
|
9
18
|
setHeader(event, "link", `<${config.href}>; rel="api-catalog"`);
|
|
10
19
|
if (event.method === "HEAD")
|
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
import { eventHandler, setHeader } from "#nuxtseo/h3";
|
|
2
2
|
import { defineCachedFunction, useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
3
|
import { buildLlmsTxt } from "../../llms-txt-utils.js";
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
const cachedBuilders = /* @__PURE__ */ new Map();
|
|
5
|
+
function getBuildLlmsTxtCached(maxAge) {
|
|
6
|
+
let cached = cachedBuilders.get(maxAge);
|
|
7
|
+
if (!cached) {
|
|
8
|
+
cached = defineCachedFunction(buildLlmsTxt, {
|
|
9
|
+
name: "llms-txt",
|
|
10
|
+
group: "ai-ready",
|
|
11
|
+
maxAge,
|
|
12
|
+
swr: true
|
|
13
|
+
});
|
|
14
|
+
cachedBuilders.set(maxAge, cached);
|
|
12
15
|
}
|
|
13
|
-
|
|
16
|
+
return cached;
|
|
17
|
+
}
|
|
14
18
|
export default eventHandler(async (event) => {
|
|
15
19
|
const runtimeConfig = useRuntimeConfig(event)["nuxt-ai-ready"];
|
|
16
|
-
const
|
|
17
|
-
const
|
|
20
|
+
const cacheSeconds = runtimeConfig.llmsTxtCacheSeconds;
|
|
21
|
+
const cacheEnabled = !import.meta.dev && cacheSeconds > 0;
|
|
22
|
+
const content = cacheEnabled ? await getBuildLlmsTxtCached(cacheSeconds)(event) : await buildLlmsTxt(event);
|
|
18
23
|
setHeader(event, "Content-Type", "text/plain; charset=utf-8");
|
|
19
24
|
if (cacheEnabled) {
|
|
20
|
-
setHeader(event, "Cache-Control", `public, max-age=${
|
|
25
|
+
setHeader(event, "Cache-Control", `public, max-age=${cacheSeconds}, s-maxage=${cacheSeconds}, stale-while-revalidate=3600`);
|
|
21
26
|
}
|
|
22
27
|
return content;
|
|
23
28
|
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { eventHandler, setHeader } from "#nuxtseo/h3";
|
|
2
|
+
import { defineCachedFunction, useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
|
+
import { getSiteConfig } from "#site-config/server/composables";
|
|
4
|
+
import { toMarkdownPath } from "../../markdown-path.js";
|
|
5
|
+
import { toDeployedRoute } from "../../route-path.js";
|
|
6
|
+
import { queryPages } from "../db/queries.js";
|
|
7
|
+
import { logger } from "../logger.js";
|
|
8
|
+
import { buildSitemapMd } from "../utils/sitemap-md.js";
|
|
9
|
+
async function buildSitemapMarkdown(event) {
|
|
10
|
+
let pages = [];
|
|
11
|
+
try {
|
|
12
|
+
pages = await queryPages(event);
|
|
13
|
+
} catch (err) {
|
|
14
|
+
logger.warn(
|
|
15
|
+
`[ai-ready] Database unavailable for sitemap.md, serving an empty sitemap: ${err instanceof Error ? err.message : String(err)}`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
const baseURL = useRuntimeConfig(event).app.baseURL;
|
|
19
|
+
return buildSitemapMd(
|
|
20
|
+
pages.map((page) => ({ route: page.route, title: page.title, updatedAt: page.updatedAt })),
|
|
21
|
+
{
|
|
22
|
+
siteName: getSiteConfig(event).name,
|
|
23
|
+
resolveHref: (route) => toDeployedRoute(toMarkdownPath(route), baseURL)
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const cachedBuilders = /* @__PURE__ */ new Map();
|
|
28
|
+
function getBuildSitemapMdCached(maxAge) {
|
|
29
|
+
let cached = cachedBuilders.get(maxAge);
|
|
30
|
+
if (!cached) {
|
|
31
|
+
cached = defineCachedFunction(buildSitemapMarkdown, {
|
|
32
|
+
name: "sitemap-md",
|
|
33
|
+
group: "ai-ready",
|
|
34
|
+
maxAge,
|
|
35
|
+
swr: true
|
|
36
|
+
});
|
|
37
|
+
cachedBuilders.set(maxAge, cached);
|
|
38
|
+
}
|
|
39
|
+
return cached;
|
|
40
|
+
}
|
|
41
|
+
export default eventHandler(async (event) => {
|
|
42
|
+
const fullRuntimeConfig = useRuntimeConfig(event);
|
|
43
|
+
const runtimeConfig = fullRuntimeConfig["nuxt-ai-ready"];
|
|
44
|
+
const cacheSeconds = runtimeConfig.llmsTxtCacheSeconds ?? 600;
|
|
45
|
+
const cacheEnabled = !import.meta.dev && cacheSeconds > 0;
|
|
46
|
+
setHeader(event, "Content-Type", "text/markdown; charset=utf-8");
|
|
47
|
+
if (cacheEnabled) {
|
|
48
|
+
setHeader(event, "Cache-Control", `public, max-age=${cacheSeconds}, s-maxage=${cacheSeconds}, stale-while-revalidate=3600`);
|
|
49
|
+
}
|
|
50
|
+
return cacheEnabled ? await getBuildSitemapMdCached(cacheSeconds)(event) : await buildSitemapMarkdown(event);
|
|
51
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineTask } from "#nuxtseo/nitro";
|
|
2
|
+
import { logger } from "../logger.js";
|
|
2
3
|
import { runCron } from "../utils/runCron.js";
|
|
3
4
|
export default defineTask({
|
|
4
5
|
meta: {
|
|
@@ -11,6 +12,8 @@ export default defineTask({
|
|
|
11
12
|
const result = await runCron(void 0, {
|
|
12
13
|
batchSize: payload?.limit
|
|
13
14
|
});
|
|
15
|
+
if (result.failed)
|
|
16
|
+
logger.error(`[ai-ready:cron] Run failed at the ${result.failed.stage} stage: ${result.failed.message}`);
|
|
14
17
|
return { result };
|
|
15
18
|
}
|
|
16
19
|
});
|
|
@@ -5,3 +5,7 @@ export type { LocaleAlternate, LocaleAlternateResolution, LocalePages, RouteLoca
|
|
|
5
5
|
export declare function getRuntimeI18n(aiReadyConfig: {
|
|
6
6
|
i18n?: RuntimeI18nConfig | null;
|
|
7
7
|
}): RuntimeI18nConfig | null;
|
|
8
|
+
/** Normalize a host or URL into a comparable hostname (lowercase, no scheme, no path). */
|
|
9
|
+
export declare function normalizeHost(value: string): string;
|
|
10
|
+
/** True when the host equals a domain configured on any i18n locale. */
|
|
11
|
+
export declare function hostMatchesLocaleDomain(host: string | undefined, i18n: RuntimeI18nConfig): boolean;
|