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.
Files changed (44) hide show
  1. package/dist/chunks/agent-skills.mjs +4 -1
  2. package/dist/chunks/prerender.mjs +10 -3
  3. package/dist/cli.mjs +67 -11
  4. package/dist/module.d.mts +2 -0
  5. package/dist/module.json +1 -1
  6. package/dist/module.mjs +4 -1
  7. package/dist/runtime/app/plugins/md-alternate.server.js +6 -2
  8. package/dist/runtime/llms-txt-utils.js +2 -2
  9. package/dist/runtime/markdown-path.d.ts +2 -0
  10. package/dist/runtime/markdown-path.js +4 -0
  11. package/dist/runtime/server/db/drizzle/index.d.ts +1 -1
  12. package/dist/runtime/server/db/drizzle/index.js +0 -1
  13. package/dist/runtime/server/db/drizzle/queries.d.ts +0 -4
  14. package/dist/runtime/server/db/drizzle/queries.js +22 -16
  15. package/dist/runtime/server/db/drizzle/raw.js +1 -4
  16. package/dist/runtime/server/db/queries.d.ts +22 -21
  17. package/dist/runtime/server/db/queries.js +89 -66
  18. package/dist/runtime/server/db/shared.d.ts +16 -0
  19. package/dist/runtime/server/db/shared.js +12 -0
  20. package/dist/runtime/server/middleware/markdown.js +10 -5
  21. package/dist/runtime/server/middleware/markdown.prerender.js +4 -1
  22. package/dist/runtime/server/plugins/sitemap-seeder.js +5 -2
  23. package/dist/runtime/server/routes/__ai-ready/cron.get.js +10 -2
  24. package/dist/runtime/server/routes/__ai-ready/poll.post.js +30 -14
  25. package/dist/runtime/server/routes/__ai-ready/reindex.post.d.ts +2 -0
  26. package/dist/runtime/server/routes/__ai-ready/reindex.post.js +23 -0
  27. package/dist/runtime/server/routes/api-catalog.js +11 -2
  28. package/dist/runtime/server/routes/llms.txt.get.js +17 -12
  29. package/dist/runtime/server/routes/sitemap.md.get.d.ts +2 -0
  30. package/dist/runtime/server/routes/sitemap.md.get.js +51 -0
  31. package/dist/runtime/server/tasks/ai-ready-cron.js +3 -0
  32. package/dist/runtime/server/utils/i18n.d.ts +4 -0
  33. package/dist/runtime/server/utils/i18n.js +13 -0
  34. package/dist/runtime/server/utils/indexPage.js +7 -1
  35. package/dist/runtime/server/utils/link-header.d.ts +2 -0
  36. package/dist/runtime/server/utils/link-header.js +6 -0
  37. package/dist/runtime/server/utils/markdown-request.js +3 -1
  38. package/dist/runtime/server/utils/runCron.d.ts +24 -0
  39. package/dist/runtime/server/utils/runCron.js +21 -4
  40. package/dist/runtime/server/utils/sitemap-md.d.ts +13 -0
  41. package/dist/runtime/server/utils/sitemap-md.js +65 -0
  42. package/dist/runtime/types.d.ts +16 -3
  43. package/dist/shared/{nuxt-ai-ready.BTQAkSYt.mjs → nuxt-ai-ready.D5URd0TD.mjs} +103 -36
  44. 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 { resolveLocaleFromRoute } from "../utils/i18n.js";
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 deriveLocale(event, route, explicit) {
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
- return resolveLocaleFromRoute(route, i18n, event ? { host: getRequestURL(event).host } : void 0).locale;
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 = `%${sanitized}%`;
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
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 = 1,
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 ROWS_PER_INSERT = 20;
393
+ const rowsPerInsert = maxRowsPerInsert(5);
369
394
  const stmts = [];
370
- for (const batch of chunk([...byRoute.values()], ROWS_PER_INSERT)) {
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 last_seen_at = excluded.last_seen_at, locale = excluded.locale
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 true;
542
+ return { _tag: "acquired", token };
539
543
  const now = Date.now();
540
- const staleThreshold = now - CRON_LOCK_TTL_MS;
541
- await db.exec(`
542
- INSERT INTO _ai_ready_info (id, value) VALUES ('cron_lock', ?)
543
- ON CONFLICT(id) DO UPDATE SET value = ?
544
- WHERE CAST(_ai_ready_info.value AS BIGINT) < ?
545
- `, [String(now), String(now), staleThreshold]);
546
- const row = await db.first("SELECT value FROM _ai_ready_info WHERE id = ?", ["cron_lock"]);
547
- return row?.value === String(now);
548
- }
549
- export async function releaseCronLock(event) {
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("DELETE FROM _ai_ready_info WHERE id = ?", ["cron_lock"]);
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 lockTime = Number.parseInt(row.value, 10);
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 elapsed = now - lockTime;
568
- const stale = elapsed >= CRON_LOCK_TTL_MS;
591
+ const stale = now >= record.expiresAt;
569
592
  return {
570
593
  held: !stale,
571
- since: lockTime,
572
- elapsedMs: elapsed,
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
- if (lastCrawled && Date.now() - lastCrawled < SEED_INTERVAL_MS)
91
+ const lastSeed = Math.max(lastCrawled ?? 0, lastSeedAt.get(sitemapName) ?? 0);
92
+ if (Date.now() - lastSeed < SEED_INTERVAL_MS)
91
93
  return;
92
- const routes = [...routeToUrl.keys()];
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
- return runCron(event);
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 limit = query.limit ? Math.max(1, Math.min(50, Math.trunc(Number(query.limit)) || 10)) : void 0;
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
- const result = await batchIndexPages(event, {
10
- limit,
11
- all: query.all === "true" || query.all === "1",
12
- timeout
13
- });
14
- return {
15
- indexed: result.indexed,
16
- remaining: result.remaining,
17
- errors: result.errors.length > 0 ? result.errors : void 0,
18
- duration: result.duration,
19
- complete: result.complete
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,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -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
- return;
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 buildLlmsTxtCached = defineCachedFunction(
5
- buildLlmsTxt,
6
- {
7
- name: "llms-txt",
8
- group: "ai-ready",
9
- maxAge: 60 * 10,
10
- // 10 minutes
11
- swr: true
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 cacheEnabled = !import.meta.dev && runtimeConfig.llmsTxtCacheSeconds > 0;
17
- const content = cacheEnabled ? await buildLlmsTxtCached(event) : await buildLlmsTxt(event);
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=${runtimeConfig.llmsTxtCacheSeconds}, s-maxage=${runtimeConfig.llmsTxtCacheSeconds}, stale-while-revalidate=3600`);
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,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -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;