nuxt-ai-ready 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.BVWXwmUS.mjs} +140 -36
  44. package/package.json +17 -17
@@ -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
+ }
@@ -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,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;
@@ -0,0 +1,65 @@
1
+ import { toMarkdownPath } from "../../markdown-path.js";
2
+ import { toDeployedRoute } from "../../route-path.js";
3
+ export const SITEMAP_MD_ROUTE = "/sitemap.md";
4
+ function topSegment(route) {
5
+ return route.split("/").filter(Boolean)[0] || "";
6
+ }
7
+ function escapeTitle(title) {
8
+ return title.replaceAll("[", "\\[").replaceAll("]", "\\]");
9
+ }
10
+ function formatEntry(entry, resolveHref) {
11
+ const title = escapeTitle(entry.title?.trim() || entry.route);
12
+ const href = resolveHref(entry.route);
13
+ if (entry.updatedAt) {
14
+ const parsed = new Date(entry.updatedAt);
15
+ if (!Number.isNaN(parsed.getTime()))
16
+ return `- [${title}](${href} "${parsed.toISOString()}")`;
17
+ }
18
+ return `- [${title}](${href})`;
19
+ }
20
+ export function buildSitemapMd(entries, options = {}) {
21
+ const resolveHref = options.resolveHref || toMarkdownPath;
22
+ const groups = /* @__PURE__ */ new Map();
23
+ for (const entry of [...entries].sort((a, b) => a.route.localeCompare(b.route))) {
24
+ const key = topSegment(entry.route);
25
+ const group = groups.get(key);
26
+ if (group)
27
+ group.push(entry);
28
+ else
29
+ groups.set(key, [entry]);
30
+ }
31
+ const keys = [...groups.keys()].sort((a, b) => {
32
+ if (a === "")
33
+ return -1;
34
+ if (b === "")
35
+ return 1;
36
+ return a.localeCompare(b);
37
+ });
38
+ const parts = [`# ${options.siteName || "Site"} Sitemap`, "", "All pages in Markdown format."];
39
+ for (const key of keys) {
40
+ parts.push("", `## ${key || "Root"}`);
41
+ for (const entry of groups.get(key))
42
+ parts.push(formatEntry(entry, resolveHref));
43
+ }
44
+ return `${parts.join("\n")}
45
+ `;
46
+ }
47
+ export function appendSitemapSection(markdown, sitemapHref = SITEMAP_MD_ROUTE) {
48
+ const section = `## Sitemap
49
+
50
+ See the full [sitemap](${sitemapHref}) for all pages.`;
51
+ const trimmed = markdown.trimEnd();
52
+ if (trimmed.endsWith(section))
53
+ return `${trimmed}
54
+ `;
55
+ return `${trimmed}
56
+
57
+ ${section}
58
+ `;
59
+ }
60
+ export function isSitemapMdRequest(path, baseURL, enabled) {
61
+ if (!enabled)
62
+ return false;
63
+ const withoutQuery = path.split("?")[0] || path;
64
+ return withoutQuery === SITEMAP_MD_ROUTE || withoutQuery === toDeployedRoute(SITEMAP_MD_ROUTE, baseURL);
65
+ }
@@ -149,6 +149,19 @@ export interface ModuleOptions {
149
149
  * Structured llms.txt configuration
150
150
  */
151
151
  llmsTxt?: LlmsTxtConfig;
152
+ /**
153
+ * Generate a Markdown sitemap at `/sitemap.md` from indexed pages and end
154
+ * every Markdown page with a `## Sitemap` section linking to it.
155
+ * @default true
156
+ */
157
+ sitemapMd?: boolean;
158
+ /**
159
+ * Advertise llms.txt as `rel="describedby"` from HTML and Markdown
160
+ * responses, as the llms.txt v2 spec requires. Emits an HTML `<link>` tag
161
+ * and a Link header entry pointing at the llms.txt route.
162
+ * @default true
163
+ */
164
+ describedby?: boolean;
152
165
  /**
153
166
  * Content Signal Directives
154
167
  */
@@ -258,7 +271,7 @@ export interface ModuleOptions {
258
271
  filename?: string;
259
272
  /**
260
273
  * D1 binding name for Cloudflare Workers/Pages
261
- * @default 'AI_READY_DB'
274
+ * @default 'DB'
262
275
  */
263
276
  bindingName?: string;
264
277
  /**
@@ -272,7 +285,7 @@ export interface ModuleOptions {
272
285
  authToken?: string;
273
286
  };
274
287
  /**
275
- * Enable scheduled cron task (runs every minute)
288
+ * Enable scheduled cron task (runs every 5 minutes)
276
289
  * When true, automatically enables runtimeSync for background indexing
277
290
  */
278
291
  cron?: boolean;
@@ -322,7 +335,7 @@ export interface ModuleOptions {
322
335
  ttl?: number;
323
336
  /**
324
337
  * Pages to index per batch
325
- * @default 20
338
+ * @default 50
326
339
  */
327
340
  batchSize?: number;
328
341
  /**
@@ -8,9 +8,12 @@ import { installNuxtSiteConfig, useSiteConfig, withSiteUrl } from 'nuxt-site-con
8
8
  import { resolveNuxtContentVersion, renderNitroTypeAugmentations, setupNitroRuntimeCompatibility } from 'nuxtseo-shared/kit';
9
9
  import { readPackageJSON, resolvePackageJSON } from 'pkg-types';
10
10
  import { fileURLToPath, pathToFileURL } from 'node:url';
11
+ import { isReservedPath, normalizePagePath, toMarkdownPath } from '../../dist/runtime/markdown-path.js';
12
+ import { SITEMAP_MD_ROUTE } from '../../dist/runtime/server/utils/sitemap-md.js';
11
13
  import { MCP_SERVER_CARD_MEDIA_TYPE, AI_CATALOG_MEDIA_TYPE } from '../../dist/runtime/server/utils/discovery-response.js';
12
14
  import { isAbsolute, join as join$1 } from 'pathe';
13
15
  import { resolveI18nConfig, toRuntimeI18nConfig as toRuntimeI18nConfig$1 } from 'nuxtseo-shared/i18n';
16
+ import { toDeployedRoute } from '../../dist/runtime/route-path.js';
14
17
 
15
18
  const SELECT_PAGE_DATA = "SELECT route, title, description, markdown, headings, keywords, updated_at, is_error, locale FROM ai_ready_pages";
16
19
  function createBuildPageDataVirtual(options) {
@@ -175,7 +178,7 @@ function resolveModuleEntryUrl(url) {
175
178
 
176
179
  const MARKDOWN_LINK_AVAILABILITY_FILE = "markdown-link-availability.json";
177
180
 
178
- const declarativeWebMcpTypes = `
181
+ const webmcpTypes = `
179
182
  import type { WebMcpToolsContext } from 'nuxt-ai-ready/webmcp'
180
183
 
181
184
  declare module '#app' {
@@ -184,37 +187,6 @@ declare module '#app' {
184
187
  'ai-ready:webmcp:tools': (context: WebMcpToolsContext) => void | Promise<void>
185
188
  }
186
189
  }
187
-
188
- declare module '@vue/runtime-dom' {
189
- interface HTMLAttributes {
190
- /** Names the tool this form exposes to agents. */
191
- toolname?: string
192
- /** Describes what submitting this form does. */
193
- tooldescription?: string
194
- /** Describes this field as a tool parameter. */
195
- toolparamdescription?: string
196
- /** Submit the form as soon as an agent fills it in. */
197
- toolautosubmit?: boolean | ''
198
- }
199
- }
200
-
201
- declare global {
202
- interface SubmitEvent {
203
- /** Whether an agent triggered this submit through a tool call. */
204
- readonly agentInvoked?: boolean
205
- /** Return a result to the agent. Call preventDefault() first. */
206
- respondWith?: (result: Promise<unknown>) => void
207
- }
208
-
209
- interface WebMcpToolEvent extends Event {
210
- readonly toolName: string
211
- }
212
-
213
- interface WindowEventMap {
214
- toolactivated: WebMcpToolEvent
215
- toolcancel: WebMcpToolEvent
216
- }
217
- }
218
190
  `;
219
191
  function registerTypeTemplates(ctx) {
220
192
  const nitroTypes = renderNitroTypeAugmentations(ctx.nitroCompatibility, {
@@ -231,7 +203,7 @@ import type { MarkdownContext, MarkdownSourceContext, PageIndexedContext } from
231
203
  import type { MdreamOptions } from 'mdream'
232
204
 
233
205
  ${nitroTypes}
234
- ${ctx.config.webmcp ? declarativeWebMcpTypes : ""}
206
+ ${ctx.config.webmcp ? webmcpTypes : ""}
235
207
  export {}
236
208
  `
237
209
  });
@@ -891,6 +863,85 @@ function ensureStaticHeader(contents, route, name, value) {
891
863
  const prefix = blockStart === routeLineEnd ? eol : "";
892
864
  return `${contents.slice(0, blockStart)}${prefix} ${name}: ${value}${eol}${contents.slice(blockStart)}`;
893
865
  }
866
+ const CLOUDFLARE_STATIC_HEADER_RULE_LIMIT = 100;
867
+ const RE_EXACT_MARKDOWN_ROUTE = /^\/[^*\s]*\.md[\t ]*\r?$/;
868
+ function splitHeaderBlocks(contents) {
869
+ const blocks = [];
870
+ let current = { route: null, text: "" };
871
+ for (const line of contents.split(/(?<=\n)/)) {
872
+ const isRoute = /^[^\s#]/.test(line);
873
+ if (isRoute) {
874
+ blocks.push(current);
875
+ current = { route: line.trimEnd(), text: "" };
876
+ }
877
+ current.text += line;
878
+ }
879
+ blocks.push(current);
880
+ return blocks;
881
+ }
882
+ function enforceStaticHeaderBudget(contents, limit) {
883
+ const blocks = splitHeaderBlocks(contents);
884
+ const ruleCount = blocks.filter((block) => block.route !== null).length;
885
+ if (ruleCount <= limit)
886
+ return { contents, total: ruleCount, dropped: 0 };
887
+ const kept = blocks.filter((block) => block.route === null || !RE_EXACT_MARKDOWN_ROUTE.test(block.route));
888
+ return {
889
+ contents: kept.map((block) => block.text).join(""),
890
+ total: kept.filter((block) => block.route !== null).length,
891
+ dropped: ruleCount - kept.filter((block) => block.route !== null).length
892
+ };
893
+ }
894
+
895
+ const RE_MD_EXT = /\.md$/;
896
+ function isStaticMarkdownSourceRoute(route) {
897
+ if (route.includes("*") || route.includes(":"))
898
+ return false;
899
+ const path = route.split("?")[0] || route;
900
+ if (isReservedPath(path))
901
+ return false;
902
+ const lastSegment = path.split("/").pop() || "";
903
+ return !lastSegment.includes(".");
904
+ }
905
+ function staticDescribedbyEntry(baseURL) {
906
+ return `<${encodeURI(toDeployedRoute("/llms.txt", baseURL))}>; rel="describedby"`;
907
+ }
908
+ function buildStaticMarkdownLinkHeader(route, baseURL, describedby) {
909
+ const htmlRoute = encodeURI(toDeployedRoute(normalizePagePath(route), baseURL));
910
+ const parts = [
911
+ `<${htmlRoute}>; rel="alternate"; type="text/html"`,
912
+ `<${htmlRoute}>; rel="canonical"`
913
+ ];
914
+ if (describedby)
915
+ parts.push(staticDescribedbyEntry(baseURL));
916
+ return parts.join(", ");
917
+ }
918
+ function pageRouteFromMarkdownTwin(fileName) {
919
+ if (!fileName?.endsWith(".md") || fileName === SITEMAP_MD_ROUTE)
920
+ return null;
921
+ const pageRoute = normalizePagePath(fileName.replace(RE_MD_EXT, ""));
922
+ if (pageRoute === "/index")
923
+ return "/";
924
+ return isStaticMarkdownSourceRoute(pageRoute) ? pageRoute : null;
925
+ }
926
+ function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby) {
927
+ const rules = /* @__PURE__ */ new Map();
928
+ for (const entry of prerenderedRoutes) {
929
+ const pageRoute = pageRouteFromMarkdownTwin(entry.fileName);
930
+ if (pageRoute === null)
931
+ continue;
932
+ const mdRoute = toMarkdownPath(pageRoute);
933
+ if (!rules.has(mdRoute)) {
934
+ rules.set(mdRoute, {
935
+ route: mdRoute,
936
+ headers: {
937
+ "Content-Type": "text/markdown; charset=utf-8",
938
+ "Link": buildStaticMarkdownLinkHeader(pageRoute, baseURL, describedby)
939
+ }
940
+ });
941
+ }
942
+ }
943
+ return [...rules.values()];
944
+ }
894
945
 
895
946
  const DEFAULT_MAX_OUTPUT_CHARS = 1500;
896
947
  const DEFAULT_LIST_LIMIT = 20;
@@ -1216,7 +1267,12 @@ ${details}`);
1216
1267
  userAgent: "*",
1217
1268
  // Preserve nuxt-robots' default wildcard rule so the injected group remains valid.
1218
1269
  disallow: [""],
1219
- contentSignal: [`ai-train=${config.contentSignal.aiTrain ? "yes" : "no"}`, `search=${config.contentSignal.search ? "yes" : "no"}`, `ai-input=${config.contentSignal.aiInput ? "yes" : "no"}`]
1270
+ // Object form renders one comma-separated line; an array renders one line per entry, which validators reject.
1271
+ contentSignal: {
1272
+ "ai-train": config.contentSignal.aiTrain ? "yes" : "no",
1273
+ "search": config.contentSignal.search ? "yes" : "no",
1274
+ "ai-input": config.contentSignal.aiInput ? "yes" : "no"
1275
+ }
1220
1276
  };
1221
1277
  if (config.contentSignal.contentUsage !== false)
1222
1278
  group.contentUsage = [`train-ai=${config.contentSignal.aiTrain ? "y" : "n"}`];
@@ -1600,6 +1656,8 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1600
1656
  debugCron: config.debugCron || false,
1601
1657
  contentNegotiation: config.contentNegotiation === void 0 ? "auto" : config.contentNegotiation ? "enabled" : "disabled",
1602
1658
  mdreamOptions: config.mdreamOptions || {},
1659
+ sitemapMd: config.sitemapMd !== false,
1660
+ describedby: config.describedby !== false,
1603
1661
  markdownCacheHeaders: defu(config.markdownCacheHeaders, {
1604
1662
  maxAge: 3600,
1605
1663
  swr: true
@@ -1643,6 +1701,9 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1643
1701
  });
1644
1702
  addServerHandler({ route: "/llms.txt", handler: resolve("./runtime/server/routes/llms.txt.get"), lazy: true });
1645
1703
  addServerHandler({ route: "/llms-full.txt", handler: resolve("./runtime/server/routes/llms-full.txt.get"), lazy: true });
1704
+ if (config.sitemapMd !== false) {
1705
+ addServerHandler({ route: SITEMAP_MD_ROUTE, handler: resolve("./runtime/server/routes/sitemap.md.get"), lazy: true });
1706
+ }
1646
1707
  if (agentSkillsResult._tag === "Enabled") {
1647
1708
  addServerHandler({ route: AGENT_SKILLS_INDEX_ROUTE, handler: resolve("./runtime/server/routes/agent-skills-index"), lazy: true });
1648
1709
  for (const route of Object.keys(agentSkillsResult.localArtifacts)) {
@@ -1689,7 +1750,9 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1689
1750
  addServerHandler({ route: "/__ai-ready/pages", handler: resolve("./runtime/server/routes/__ai-ready/pages.get"), lazy: true });
1690
1751
  }
1691
1752
  }
1692
- addServerHandler({ route: "/__ai-ready__/debug.json", handler: resolve("./runtime/server/routes/__ai-ready/devtools.get"), lazy: true });
1753
+ if (nuxt.options.dev || config.debug) {
1754
+ addServerHandler({ route: "/__ai-ready__/debug.json", handler: resolve("./runtime/server/routes/__ai-ready/devtools.get"), lazy: true });
1755
+ }
1693
1756
  if (config.debug) {
1694
1757
  addServerHandler({ route: "/__ai-ready-debug", handler: resolve("./runtime/server/routes/__ai-ready-debug.get"), lazy: true });
1695
1758
  }
@@ -1698,6 +1761,7 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1698
1761
  addServerHandler({ route: "/__ai-ready/poll", method: "post", handler: resolve("./runtime/server/routes/__ai-ready/poll.post"), lazy: true });
1699
1762
  addServerHandler({ route: "/__ai-ready/prune", method: "post", handler: resolve("./runtime/server/routes/__ai-ready/prune.post"), lazy: true });
1700
1763
  addServerHandler({ route: "/__ai-ready/restore", method: "post", handler: resolve("./runtime/server/routes/__ai-ready/restore.post"), lazy: true });
1764
+ addServerHandler({ route: "/__ai-ready/reindex", method: "post", handler: resolve("./runtime/server/routes/__ai-ready/reindex.post"), lazy: true });
1701
1765
  addServerPlugin(resolve("./runtime/server/plugins/sitemap-seeder"));
1702
1766
  }
1703
1767
  if (config.cron && !nuxt.options.dev) {
@@ -1735,19 +1799,59 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1735
1799
  for (const route of ["/llms.txt", "/llms-full.txt"]) {
1736
1800
  extendRouteRules(route, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
1737
1801
  }
1802
+ if (config.sitemapMd !== false) {
1803
+ extendRouteRules(SITEMAP_MD_ROUTE, { headers: { "Content-Type": "text/markdown; charset=utf-8" } });
1804
+ }
1805
+ const staticBaseURL = nuxt.options.app.baseURL || "/";
1806
+ for (const route of nuxt.options.nitro.prerender?.routes || []) {
1807
+ if (!isStaticMarkdownSourceRoute(route))
1808
+ continue;
1809
+ extendRouteRules(toMarkdownPath(route), {
1810
+ headers: {
1811
+ "Content-Type": "text/markdown; charset=utf-8",
1812
+ "Link": buildStaticMarkdownLinkHeader(route, staticBaseURL, config.describedby !== false)
1813
+ }
1814
+ });
1815
+ }
1738
1816
  nuxt.hooks.hook("nitro:build:before", (nitro) => {
1817
+ nitro.hooks.hook("prerender:done", () => {
1818
+ for (const { route, headers } of prerenderedMarkdownHeaderRules(
1819
+ nitro._prerenderedRoutes || [],
1820
+ staticBaseURL,
1821
+ config.describedby !== false
1822
+ )) {
1823
+ nitro.options.routeRules[route] = defu({ headers }, nitro.options.routeRules[route]);
1824
+ }
1825
+ });
1739
1826
  nitro.hooks.hook("compiled", async () => {
1740
1827
  const headersPath = join(nitro.options.output.publicDir, "_headers");
1741
1828
  logger.debug(`Checking for _headers file: ${headersPath}`);
1742
1829
  const exists = await access(headersPath).then(() => true).catch(() => false);
1743
1830
  if (exists) {
1744
1831
  const headers = await readFile(headersPath, "utf8");
1745
- const mergedHeaders = ensureStaticHeader(
1832
+ let mergedHeaders = ensureStaticHeader(
1746
1833
  headers,
1747
1834
  "/*.md",
1748
1835
  "Content-Type",
1749
1836
  "text/markdown; charset=utf-8"
1750
1837
  );
1838
+ if (config.describedby !== false) {
1839
+ mergedHeaders = ensureStaticHeader(
1840
+ mergedHeaders,
1841
+ "/*.md",
1842
+ "Link",
1843
+ staticDescribedbyEntry(nitro.options.baseURL || "/")
1844
+ );
1845
+ }
1846
+ if (String(nitro.options.preset || "").startsWith("cloudflare")) {
1847
+ const budget = enforceStaticHeaderBudget(mergedHeaders, CLOUDFLARE_STATIC_HEADER_RULE_LIMIT);
1848
+ if (budget.dropped > 0) {
1849
+ mergedHeaders = budget.contents;
1850
+ logger.warn(`_headers had ${budget.total + budget.dropped} rules and Cloudflare allows ${CLOUDFLARE_STATIC_HEADER_RULE_LIMIT}. Dropped the ${budget.dropped} per-page .md rules; the /*.md glob still sets the charset and describedby entries, but static markdown no longer sends a per-page canonical Link.`);
1851
+ }
1852
+ if (budget.total > CLOUDFLARE_STATIC_HEADER_RULE_LIMIT)
1853
+ logger.warn(`_headers still has ${budget.total} rules after dropping the .md rules. Cloudflare will reject the deploy until the other rules fit under ${CLOUDFLARE_STATIC_HEADER_RULE_LIMIT}.`);
1854
+ }
1751
1855
  if (mergedHeaders !== headers) {
1752
1856
  await writeFile(headersPath, mergedHeaders);
1753
1857
  logger.debug("Merged .md charset header into _headers");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-ai-ready",
3
3
  "type": "module",
4
- "version": "2.1.0",
4
+ "version": "2.2.1",
5
5
  "description": "Best practice AI & LLM discoverability for Nuxt sites.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -66,17 +66,17 @@
66
66
  }
67
67
  },
68
68
  "dependencies": {
69
- "@mdream/js": "^1.7.0",
69
+ "@mdream/js": "^1.7.1",
70
70
  "@nuxt/kit": "^4.5.2",
71
71
  "citty": "^0.2.2",
72
72
  "consola": "^3.4.2",
73
73
  "defu": "^6.1.7",
74
- "drizzle-orm": "^1.0.0-rc.4",
75
- "mdream": "^1.7.0",
74
+ "drizzle-orm": "1.0.0-rc.4",
75
+ "mdream": "^1.7.1",
76
76
  "nuxt-site-config": "^4.2.3",
77
77
  "nuxtseo-shared": "^5.3.14",
78
78
  "pathe": "^2.0.3",
79
- "pkg-types": "^2.3.1",
79
+ "pkg-types": "^2.3.2",
80
80
  "site-config-stack": "^4.2.3",
81
81
  "sitemapd": "^0.2.2",
82
82
  "ufo": "^1.6.4",
@@ -84,10 +84,10 @@
84
84
  "yaml": "^2.9.0"
85
85
  },
86
86
  "devDependencies": {
87
- "@antfu/eslint-config": "^9.3.0",
87
+ "@antfu/eslint-config": "^9.5.1",
88
88
  "@arethetypeswrong/cli": "^0.18.5",
89
89
  "@harlan-zw/comark-content": "^0.1.5",
90
- "@libsql/client": "^0.17.4",
90
+ "@libsql/client": "^0.18.0",
91
91
  "@nuxt/content": "^3.16.0",
92
92
  "@nuxt/module-builder": "^1.0.3",
93
93
  "@nuxt/test-utils": "^4.2.0",
@@ -96,31 +96,31 @@
96
96
  "@nuxtjs/robots": "^6.2.0",
97
97
  "@nuxtjs/sitemap": "^8.5.0",
98
98
  "@types/better-sqlite3": "^9.6.0",
99
- "@vitest/coverage-v8": "^4.1.11",
99
+ "@vitest/coverage-v8": "^5.0.0",
100
100
  "@vue/test-utils": "^2.5.0",
101
101
  "@vueuse/nuxt": "^14.4.0",
102
102
  "better-sqlite3": "^13.0.3",
103
- "bumpp": "^12.2.2",
104
- "eslint": "^10.9.1",
105
- "eslint-plugin-harlanzw": "^0.21.0",
103
+ "bumpp": "^12.3.0",
104
+ "eslint": "^10.10.0",
105
+ "eslint-plugin-harlanzw": "^0.21.1",
106
106
  "execa": "^10.0.1",
107
107
  "h3": "^1.15.11",
108
- "happy-dom": "^20.12.0",
108
+ "happy-dom": "^20.14.0",
109
109
  "nitropack": "^2.13.4",
110
110
  "nuxt": "^4.5.2",
111
111
  "nuxt-site-config": "^4.2.3",
112
112
  "nuxtseo-layer-devtools": "^5.3.14",
113
- "playwright": "^1.62.1",
114
- "playwright-core": "^1.62.1",
113
+ "playwright": "^1.63.0",
114
+ "playwright-core": "^1.63.0",
115
115
  "postgres": "^3.4.9",
116
116
  "tinyglobby": "^0.2.17",
117
117
  "typescript": "6.0.3",
118
118
  "unbuild": "^3.6.1",
119
- "vitest": "^4.1.11",
119
+ "vitest": "^5.0.0",
120
120
  "vue": "^3.5.42",
121
- "vue-router": "^5.3.0",
121
+ "vue-router": "^5.3.1",
122
122
  "vue-tsc": "^3.3.11",
123
- "wrangler": "^4.127.1",
123
+ "wrangler": "^4.129.0",
124
124
  "zod": "^4.5.4"
125
125
  },
126
126
  "scripts": {