nuxt-ai-ready 2.0.1 → 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 (57) 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 +3 -1
  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/context.d.ts +2 -0
  12. package/dist/runtime/server/db/context.js +2 -0
  13. package/dist/runtime/server/db/drizzle/client.d.ts +6 -1
  14. package/dist/runtime/server/db/drizzle/client.js +66 -13
  15. package/dist/runtime/server/db/drizzle/index.d.ts +2 -2
  16. package/dist/runtime/server/db/drizzle/index.js +1 -2
  17. package/dist/runtime/server/db/drizzle/providers/dbPath.js +1 -1
  18. package/dist/runtime/server/db/drizzle/providers/postgres.d.ts +8 -0
  19. package/dist/runtime/server/db/drizzle/providers/postgres.js +17 -0
  20. package/dist/runtime/server/db/drizzle/queries.d.ts +1 -5
  21. package/dist/runtime/server/db/drizzle/queries.js +79 -38
  22. package/dist/runtime/server/db/drizzle/raw.d.ts +2 -2
  23. package/dist/runtime/server/db/drizzle/raw.js +32 -13
  24. package/dist/runtime/server/db/queries.d.ts +30 -27
  25. package/dist/runtime/server/db/queries.js +142 -91
  26. package/dist/runtime/server/db/schema/postgres.d.ts +20 -20
  27. package/dist/runtime/server/db/schema/postgres.js +6 -6
  28. package/dist/runtime/server/db/shared.d.ts +16 -0
  29. package/dist/runtime/server/db/shared.js +12 -0
  30. package/dist/runtime/server/middleware/markdown.js +10 -5
  31. package/dist/runtime/server/middleware/markdown.prerender.js +4 -1
  32. package/dist/runtime/server/plugins/db-lifecycle.js +5 -4
  33. package/dist/runtime/server/plugins/sitemap-seeder.js +9 -4
  34. package/dist/runtime/server/routes/__ai-ready/cron.get.js +10 -2
  35. package/dist/runtime/server/routes/__ai-ready/poll.post.js +30 -14
  36. package/dist/runtime/server/routes/__ai-ready/reindex.post.d.ts +2 -0
  37. package/dist/runtime/server/routes/__ai-ready/reindex.post.js +23 -0
  38. package/dist/runtime/server/routes/api-catalog.js +11 -2
  39. package/dist/runtime/server/routes/llms.txt.get.js +17 -12
  40. package/dist/runtime/server/routes/sitemap.md.get.d.ts +2 -0
  41. package/dist/runtime/server/routes/sitemap.md.get.js +51 -0
  42. package/dist/runtime/server/tasks/ai-ready-cron.js +3 -0
  43. package/dist/runtime/server/utils/i18n.d.ts +4 -0
  44. package/dist/runtime/server/utils/i18n.js +13 -0
  45. package/dist/runtime/server/utils/indexPage.d.ts +0 -1
  46. package/dist/runtime/server/utils/indexPage.js +10 -4
  47. package/dist/runtime/server/utils/link-header.d.ts +2 -0
  48. package/dist/runtime/server/utils/link-header.js +6 -0
  49. package/dist/runtime/server/utils/markdown-request.js +3 -1
  50. package/dist/runtime/server/utils/negotiation-decision.d.ts +1 -1
  51. package/dist/runtime/server/utils/runCron.d.ts +24 -0
  52. package/dist/runtime/server/utils/runCron.js +21 -4
  53. package/dist/runtime/server/utils/sitemap-md.d.ts +13 -0
  54. package/dist/runtime/server/utils/sitemap-md.js +65 -0
  55. package/dist/runtime/types.d.ts +21 -7
  56. package/dist/shared/{nuxt-ai-ready.D8qbnNrv.mjs → nuxt-ai-ready.D5URd0TD.mjs} +107 -39
  57. package/package.json +31 -26
@@ -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);
@@ -1,10 +1,11 @@
1
- import { DB_CONTEXT_KEY } from "../db/context.js";
1
+ import { DB_CONTEXT_KEY, DB_PROMISE_CONTEXT_KEY, DB_WORK_CONTEXT_KEY } from "../db/context.js";
2
2
  export default function dbLifecyclePlugin(nitroApp) {
3
3
  nitroApp.hooks.hook("afterResponse", async (event) => {
4
- if (!event.context?.[DB_CONTEXT_KEY])
4
+ if (!event.context?.[DB_CONTEXT_KEY] && !event.context?.[DB_PROMISE_CONTEXT_KEY] && !event.context?.[DB_WORK_CONTEXT_KEY]) {
5
5
  return;
6
- const { closeDrizzle } = await import("../db/index.js");
7
- await closeDrizzle(event);
6
+ }
7
+ const { finishDrizzleResponse } = await import("../db/index.js");
8
+ await finishDrizzleResponse(event);
8
9
  });
9
10
  nitroApp.hooks.hook("close", async () => {
10
11
  const { closeDrizzle } = await import("../db/index.js");
@@ -1,4 +1,5 @@
1
1
  import { useRuntimeConfig } from "#nuxtseo/nitro";
2
+ import { trackDrizzleWork } from "../db/drizzle/client.js";
2
3
  import { getPageLastmods, getSitemapLastCrawledAt, markSitemapSeeded, seedRoutes } from "../db/queries.js";
3
4
  import { logger } from "../logger.js";
4
5
  import { mapSitemapRoutes } from "../utils/sitemap-routes.js";
@@ -12,6 +13,7 @@ function recordDiagnostic(event, message) {
12
13
  list.push(message);
13
14
  }
14
15
  const SEED_INTERVAL_MS = 5 * 60 * 1e3;
16
+ const lastSeedAt = /* @__PURE__ */ new Map();
15
17
  const READ_TIMEOUT_MS = 3e3;
16
18
  const SLOW_READ_WARN_MS = 1e3;
17
19
  const SLOW_SEED_WARN_MS = 1e4;
@@ -86,9 +88,11 @@ export default function sitemapSeederPlugin(nitroApp) {
86
88
  logger.warn(`[sitemap-seeder] ${message} for ${sitemapName}`);
87
89
  record?.(message);
88
90
  }
89
- 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)
90
93
  return;
91
- const routes = [...routeToUrl.keys()];
94
+ lastSeedAt.set(sitemapName, Date.now());
95
+ const routes = [...routeToUrl.entries()].map(([route, url]) => ({ route, url: url.loc }));
92
96
  const urlCount = urls.length;
93
97
  const seed = async () => {
94
98
  const seedStart = Date.now();
@@ -106,9 +110,10 @@ export default function sitemapSeederPlugin(nitroApp) {
106
110
  logger.debug(`[sitemap-seeder] Seeded ${seeded} routes from ${sitemapName} in ${seedMs}ms`);
107
111
  };
108
112
  if (event.waitUntil) {
109
- event.waitUntil(seed().catch(
113
+ const backgroundSeed = trackDrizzleWork(event, seed()).catch(
110
114
  (err) => logger.error(`[sitemap-seeder] Background seed failed: ${err.message}`)
111
- ));
115
+ );
116
+ event.waitUntil(backgroundSeed);
112
117
  } else {
113
118
  await seed();
114
119
  }
@@ -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;
@@ -7,3 +7,16 @@ export {
7
7
  export function getRuntimeI18n(aiReadyConfig) {
8
8
  return aiReadyConfig.i18n || null;
9
9
  }
10
+ export function normalizeHost(value) {
11
+ return value.trim().toLowerCase().replace(/^[a-z][a-z\d+.-]*:\/\//, "").split("/")[0];
12
+ }
13
+ export function hostMatchesLocaleDomain(host, i18n) {
14
+ if (!host)
15
+ return false;
16
+ const normalized = normalizeHost(host);
17
+ if (!normalized)
18
+ return false;
19
+ return i18n.locales.some(
20
+ (locale) => [...locale.domains ?? [], locale.domain, ...locale.defaultForDomains ?? []].some((domain) => domain && normalizeHost(domain) === normalized)
21
+ );
22
+ }
@@ -1,5 +1,4 @@
1
1
  import type { H3Event } from '#nuxtseo/h3';
2
- export declare const INDEXING_HEADER = "x-ai-ready-indexing";
3
2
  export interface IndexPageOptions {
4
3
  /** Skip if page was indexed within TTL (uses config ttl if not specified) */
5
4
  ttl?: number;
@@ -5,7 +5,7 @@ import { logger } from "../logger.js";
5
5
  import { convertHtmlToMarkdown } from "../utils.js";
6
6
  import { createUniversalContext } from "./context.js";
7
7
  import { extractKeywords } from "./keywords.js";
8
- export const INDEXING_HEADER = "x-ai-ready-indexing";
8
+ import { INTERNAL_HEADER } from "./negotiation-decision.js";
9
9
  export async function indexPage(route, html, options = {}, event) {
10
10
  const config = useRuntimeConfig()["nuxt-ai-ready"];
11
11
  const ttl = options.ttl ?? config.runtimeSync.ttl;
@@ -70,10 +70,10 @@ export async function indexPage(route, html, options = {}, event) {
70
70
  export async function indexPageByRoute(route, event, options = {}) {
71
71
  logger.debug(`[indexPageByRoute] Fetching HTML for ${route} (timeout: 10000ms)`);
72
72
  const html = await (event ? fetchWithEvent(event, route, {
73
- headers: { [INDEXING_HEADER]: "1" },
73
+ headers: { accept: "text/html", [INTERNAL_HEADER]: "1" },
74
74
  timeout: 1e4
75
75
  }) : globalThis.$fetch(route, {
76
- headers: { [INDEXING_HEADER]: "1" },
76
+ headers: { accept: "text/html", [INTERNAL_HEADER]: "1" },
77
77
  timeout: 1e4
78
78
  // 10s timeout per page (must fit within CF worker limit)
79
79
  })).catch((err) => {
@@ -98,5 +98,11 @@ export async function indexPageByRoute(route, event, options = {}) {
98
98
  }
99
99
  return { success: false, error: `Failed to fetch HTML for ${route}` };
100
100
  }
101
- return indexPage(route, html, options, event);
101
+ try {
102
+ return await indexPage(route, html, options, event);
103
+ } catch (err) {
104
+ const message = err instanceof Error ? err.message : String(err);
105
+ logger.warn(`[indexPageByRoute] Failed to index ${route}:`, message);
106
+ return { success: false, error: message || `Failed to index ${route}` };
107
+ }
102
108
  }
@@ -8,10 +8,12 @@ import type { RuntimeI18nConfig, RuntimeRouteContext } from './i18n.js';
8
8
  */
9
9
  export declare function encodePathForHeader(path: string): string;
10
10
  type LinkUrlResolver = (path: string) => string;
11
+ export declare const LLMS_TXT_PATH = "/llms.txt";
11
12
  interface LinkHeaderConfig {
12
13
  apiCatalog?: {
13
14
  href: string;
14
15
  };
16
+ describedby?: boolean;
15
17
  i18n?: RuntimeI18nConfig | null;
16
18
  }
17
19
  /**
@@ -4,6 +4,7 @@ import { computeLocaleAlternates } from "./i18n.js";
4
4
  export function encodePathForHeader(path) {
5
5
  return encodeURI(path);
6
6
  }
7
+ export const LLMS_TXT_PATH = "/llms.txt";
7
8
  function resolveHeaderUrl(path, resolveUrl) {
8
9
  if (!resolveUrl)
9
10
  return path;
@@ -21,6 +22,11 @@ export function buildLinkHeader(path, variant, config, resolveUrl, routeContext
21
22
  } else {
22
23
  const href = resolveHeaderUrl(path, resolveUrl);
23
24
  parts.push(`<${encodePathForHeader(href)}>; rel="alternate"; type="text/html"`);
25
+ parts.push(`<${encodePathForHeader(href)}>; rel="canonical"`);
26
+ }
27
+ if (config.describedby !== false) {
28
+ const href = resolveHeaderUrl(LLMS_TXT_PATH, resolveUrl);
29
+ parts.push(`<${encodePathForHeader(href)}>; rel="describedby"`);
24
30
  }
25
31
  if (config.i18n) {
26
32
  const alternates = computeLocaleAlternates(path, config.i18n, routeContext);
@@ -1,6 +1,8 @@
1
1
  import { negotiateContent } from "@mdream/js/negotiate";
2
2
  import { getBotInfo } from "@nuxtjs/robots/util";
3
3
  import { getHeaders } from "#nuxtseo/h3";
4
+ import { isReservedPath } from "../../markdown-path.js";
5
+ const RE_MD_EXT = /\.md$/;
4
6
  export function toMarkdownRequest(event, isPrerender = !!import.meta.prerender) {
5
7
  return { path: event.path, headers: getHeaders(event), isPrerender };
6
8
  }
@@ -25,7 +27,7 @@ export function getRequestRenderInfo(request, mode = { _tag: "runtime", contentN
25
27
  const queryIndex = request.path.indexOf("?");
26
28
  const originalPath = queryIndex === -1 ? request.path : request.path.slice(0, queryIndex);
27
29
  const isPrerender = mode._tag === "prerender";
28
- if (originalPath.startsWith("/api") || originalPath.startsWith("/_") || originalPath.startsWith("/@"))
30
+ if (isReservedPath(originalPath.replace(RE_MD_EXT, "")))
29
31
  return null;
30
32
  const accept = request.headers.accept || "";
31
33
  if (!originalPath.endsWith(".md") && accept && /\b(?:application\/json|text\/event-stream)\b/i.test(accept) && !/text\/(?:html|markdown|plain)\b|\*\/\*/i.test(accept)) {
@@ -1,7 +1,7 @@
1
1
  import type { ContentNegotiationPolicy } from '../../types.js';
2
2
  import type { ContentNegotiationResolution, NegotiationRouteRule } from './content-negotiation.js';
3
3
  import type { MarkdownRequest } from './markdown-request.js';
4
- /** Marks the internal HTML fetch that the explicit `.md` handler makes. */
4
+ /** Marks an internal HTML fetch that must bypass content negotiation. */
5
5
  export declare const INTERNAL_HEADER = "x-ai-ready-internal";
6
6
  /**
7
7
  * Where the decision runs. The early stage sits in front of the Nitro static
@@ -1,6 +1,25 @@
1
1
  import type { H3Event } from '#nuxtseo/h3';
2
2
  import type { StaleCheckResult } from './checkStale.js';
3
+ /**
4
+ * Why a run could not finish.
5
+ *
6
+ * `lock` means the run never started, so nothing was changed. `run` means it
7
+ * started and stopped part way, so some work may already be committed.
8
+ */
9
+ export interface CronFailure {
10
+ stage: 'lock' | 'run';
11
+ message: string;
12
+ }
3
13
  export interface CronResult {
14
+ /**
15
+ * Set when the run failed.
16
+ *
17
+ * A scheduled task that throws reaches Cloudflare as `scriptThrewException`,
18
+ * which carries no stage, no message, and nothing a host can report. The
19
+ * failure travels back as a value instead, so the caller decides what to do
20
+ * with it.
21
+ */
22
+ failed?: CronFailure;
4
23
  runId?: number | null;
5
24
  stale?: StaleCheckResult;
6
25
  sitemap?: {
@@ -19,6 +38,11 @@ export interface CronResult {
19
38
  }
20
39
  /**
21
40
  * Run cron job logic - shared between scheduled task and HTTP endpoint
41
+ *
42
+ * This is the error boundary for the whole run. Nothing throws past it: the
43
+ * scheduled task has no request context and no handler above it, so a throw
44
+ * here becomes an opaque platform exception rather than something the host can
45
+ * report. Every failure comes back as `CronResult.failed`.
22
46
  */
23
47
  export declare function runCron(event: H3Event | undefined, options?: {
24
48
  batchSize?: number;
@@ -6,17 +6,34 @@ import { checkAndHandleStale, STALE_CHECK_INTERVAL_MS } from "./checkStale.js";
6
6
  import { resolveCronPlan, resolveSitemapIntervalMinutes } from "./cron-plan.js";
7
7
  import { crawlSitemapByRoute, getSitemapsFromConfig } from "./sitemap.js";
8
8
  import { mapSitemapRoutes } from "./sitemap-routes.js";
9
+ function describeFailure(error) {
10
+ if (error instanceof Error)
11
+ return error.message;
12
+ return String(error);
13
+ }
9
14
  export async function runCron(event, options) {
10
15
  if (import.meta.dev)
11
16
  return {};
17
+ return executeCron(event, options).catch((error) => {
18
+ const message = describeFailure(error);
19
+ logger.error(`[cron] Run failed: ${message}`);
20
+ return { failed: { stage: "run", message } };
21
+ });
22
+ }
23
+ async function executeCron(event, options) {
12
24
  const config = useRuntimeConfig()["nuxt-ai-ready"];
13
25
  const debug = config.debug;
14
26
  const startTime = Date.now();
15
27
  const results = {};
16
28
  const allErrors = [];
17
29
  const sitemapIntervalMinutes = resolveSitemapIntervalMinutes(config.runtimeSync.ttl);
18
- const acquired = await tryAcquireCronLock(event);
19
- if (!acquired) {
30
+ const lock = await tryAcquireCronLock(event).catch((error) => {
31
+ logger.error(`[cron] Could not acquire the lock: ${describeFailure(error)}`);
32
+ return null;
33
+ });
34
+ if (lock === null)
35
+ return { failed: { stage: "lock", message: "Could not acquire the cron lock" } };
36
+ if (lock._tag === "held") {
20
37
  if (debug) {
21
38
  logger.info(`[cron] Skipping - another cron run is in progress`);
22
39
  }
@@ -122,7 +139,7 @@ export async function runCron(event, options) {
122
139
  }
123
140
  return results;
124
141
  } finally {
125
- await releaseCronLock(event).catch((err) => {
142
+ await releaseCronLock(event, lock.token).catch((err) => {
126
143
  logger.warn(`[cron] Failed to release lock: ${err?.message || err}`);
127
144
  });
128
145
  }
@@ -152,7 +169,7 @@ async function pingSitemap(event, config, sitemapIntervalMinutes, debug) {
152
169
  route: nextSitemap.route,
153
170
  state: nextSitemap.crawlState
154
171
  });
155
- const routes = [...mapSitemapRoutes(result.urls).keys()];
172
+ const routes = [...mapSitemapRoutes(result.urls).entries()].map(([route, url]) => ({ route, url: url.loc }));
156
173
  if (routes.length > 0)
157
174
  await seedRoutes(event, routes);
158
175
  if (result._tag === "failed") {
@@ -0,0 +1,13 @@
1
+ export declare const SITEMAP_MD_ROUTE = "/sitemap.md";
2
+ export interface SitemapMdEntry {
3
+ route: string;
4
+ title?: string;
5
+ updatedAt?: string;
6
+ }
7
+ export interface SitemapMdOptions {
8
+ siteName?: string;
9
+ resolveHref?: (route: string) => string;
10
+ }
11
+ export declare function buildSitemapMd(entries: SitemapMdEntry[], options?: SitemapMdOptions): string;
12
+ export declare function appendSitemapSection(markdown: string, sitemapHref?: string): string;
13
+ export declare function isSitemapMdRequest(path: string, baseURL: string, enabled: boolean): boolean;