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
@@ -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
  });
@@ -892,6 +864,57 @@ function ensureStaticHeader(contents, route, name, value) {
892
864
  return `${contents.slice(0, blockStart)}${prefix} ${name}: ${value}${eol}${contents.slice(blockStart)}`;
893
865
  }
894
866
 
867
+ const RE_MD_EXT = /\.md$/;
868
+ function isStaticMarkdownSourceRoute(route) {
869
+ if (route.includes("*") || route.includes(":"))
870
+ return false;
871
+ const path = route.split("?")[0] || route;
872
+ if (isReservedPath(path))
873
+ return false;
874
+ const lastSegment = path.split("/").pop() || "";
875
+ return !lastSegment.includes(".");
876
+ }
877
+ function staticDescribedbyEntry(baseURL) {
878
+ return `<${encodeURI(toDeployedRoute("/llms.txt", baseURL))}>; rel="describedby"`;
879
+ }
880
+ function buildStaticMarkdownLinkHeader(route, baseURL, describedby) {
881
+ const htmlRoute = encodeURI(toDeployedRoute(normalizePagePath(route), baseURL));
882
+ const parts = [
883
+ `<${htmlRoute}>; rel="alternate"; type="text/html"`,
884
+ `<${htmlRoute}>; rel="canonical"`
885
+ ];
886
+ if (describedby)
887
+ parts.push(staticDescribedbyEntry(baseURL));
888
+ return parts.join(", ");
889
+ }
890
+ function pageRouteFromMarkdownTwin(fileName) {
891
+ if (!fileName?.endsWith(".md") || fileName === SITEMAP_MD_ROUTE)
892
+ return null;
893
+ const pageRoute = normalizePagePath(fileName.replace(RE_MD_EXT, ""));
894
+ if (pageRoute === "/index")
895
+ return "/";
896
+ return isStaticMarkdownSourceRoute(pageRoute) ? pageRoute : null;
897
+ }
898
+ function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby) {
899
+ const rules = /* @__PURE__ */ new Map();
900
+ for (const entry of prerenderedRoutes) {
901
+ const pageRoute = pageRouteFromMarkdownTwin(entry.fileName);
902
+ if (pageRoute === null)
903
+ continue;
904
+ const mdRoute = toMarkdownPath(pageRoute);
905
+ if (!rules.has(mdRoute)) {
906
+ rules.set(mdRoute, {
907
+ route: mdRoute,
908
+ headers: {
909
+ "Content-Type": "text/markdown; charset=utf-8",
910
+ "Link": buildStaticMarkdownLinkHeader(pageRoute, baseURL, describedby)
911
+ }
912
+ });
913
+ }
914
+ }
915
+ return [...rules.values()];
916
+ }
917
+
895
918
  const DEFAULT_MAX_OUTPUT_CHARS = 1500;
896
919
  const DEFAULT_LIST_LIMIT = 20;
897
920
  const DEFAULT_SEARCH_LIMIT = 10;
@@ -1216,7 +1239,12 @@ ${details}`);
1216
1239
  userAgent: "*",
1217
1240
  // Preserve nuxt-robots' default wildcard rule so the injected group remains valid.
1218
1241
  disallow: [""],
1219
- contentSignal: [`ai-train=${config.contentSignal.aiTrain ? "yes" : "no"}`, `search=${config.contentSignal.search ? "yes" : "no"}`, `ai-input=${config.contentSignal.aiInput ? "yes" : "no"}`]
1242
+ // Object form renders one comma-separated line; an array renders one line per entry, which validators reject.
1243
+ contentSignal: {
1244
+ "ai-train": config.contentSignal.aiTrain ? "yes" : "no",
1245
+ "search": config.contentSignal.search ? "yes" : "no",
1246
+ "ai-input": config.contentSignal.aiInput ? "yes" : "no"
1247
+ }
1220
1248
  };
1221
1249
  if (config.contentSignal.contentUsage !== false)
1222
1250
  group.contentUsage = [`train-ai=${config.contentSignal.aiTrain ? "y" : "n"}`];
@@ -1600,6 +1628,8 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1600
1628
  debugCron: config.debugCron || false,
1601
1629
  contentNegotiation: config.contentNegotiation === void 0 ? "auto" : config.contentNegotiation ? "enabled" : "disabled",
1602
1630
  mdreamOptions: config.mdreamOptions || {},
1631
+ sitemapMd: config.sitemapMd !== false,
1632
+ describedby: config.describedby !== false,
1603
1633
  markdownCacheHeaders: defu(config.markdownCacheHeaders, {
1604
1634
  maxAge: 3600,
1605
1635
  swr: true
@@ -1643,6 +1673,9 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1643
1673
  });
1644
1674
  addServerHandler({ route: "/llms.txt", handler: resolve("./runtime/server/routes/llms.txt.get"), lazy: true });
1645
1675
  addServerHandler({ route: "/llms-full.txt", handler: resolve("./runtime/server/routes/llms-full.txt.get"), lazy: true });
1676
+ if (config.sitemapMd !== false) {
1677
+ addServerHandler({ route: SITEMAP_MD_ROUTE, handler: resolve("./runtime/server/routes/sitemap.md.get"), lazy: true });
1678
+ }
1646
1679
  if (agentSkillsResult._tag === "Enabled") {
1647
1680
  addServerHandler({ route: AGENT_SKILLS_INDEX_ROUTE, handler: resolve("./runtime/server/routes/agent-skills-index"), lazy: true });
1648
1681
  for (const route of Object.keys(agentSkillsResult.localArtifacts)) {
@@ -1689,7 +1722,9 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1689
1722
  addServerHandler({ route: "/__ai-ready/pages", handler: resolve("./runtime/server/routes/__ai-ready/pages.get"), lazy: true });
1690
1723
  }
1691
1724
  }
1692
- addServerHandler({ route: "/__ai-ready__/debug.json", handler: resolve("./runtime/server/routes/__ai-ready/devtools.get"), lazy: true });
1725
+ if (nuxt.options.dev || config.debug) {
1726
+ addServerHandler({ route: "/__ai-ready__/debug.json", handler: resolve("./runtime/server/routes/__ai-ready/devtools.get"), lazy: true });
1727
+ }
1693
1728
  if (config.debug) {
1694
1729
  addServerHandler({ route: "/__ai-ready-debug", handler: resolve("./runtime/server/routes/__ai-ready-debug.get"), lazy: true });
1695
1730
  }
@@ -1698,6 +1733,7 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1698
1733
  addServerHandler({ route: "/__ai-ready/poll", method: "post", handler: resolve("./runtime/server/routes/__ai-ready/poll.post"), lazy: true });
1699
1734
  addServerHandler({ route: "/__ai-ready/prune", method: "post", handler: resolve("./runtime/server/routes/__ai-ready/prune.post"), lazy: true });
1700
1735
  addServerHandler({ route: "/__ai-ready/restore", method: "post", handler: resolve("./runtime/server/routes/__ai-ready/restore.post"), lazy: true });
1736
+ addServerHandler({ route: "/__ai-ready/reindex", method: "post", handler: resolve("./runtime/server/routes/__ai-ready/reindex.post"), lazy: true });
1701
1737
  addServerPlugin(resolve("./runtime/server/plugins/sitemap-seeder"));
1702
1738
  }
1703
1739
  if (config.cron && !nuxt.options.dev) {
@@ -1735,19 +1771,50 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1735
1771
  for (const route of ["/llms.txt", "/llms-full.txt"]) {
1736
1772
  extendRouteRules(route, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
1737
1773
  }
1774
+ if (config.sitemapMd !== false) {
1775
+ extendRouteRules(SITEMAP_MD_ROUTE, { headers: { "Content-Type": "text/markdown; charset=utf-8" } });
1776
+ }
1777
+ const staticBaseURL = nuxt.options.app.baseURL || "/";
1778
+ for (const route of nuxt.options.nitro.prerender?.routes || []) {
1779
+ if (!isStaticMarkdownSourceRoute(route))
1780
+ continue;
1781
+ extendRouteRules(toMarkdownPath(route), {
1782
+ headers: {
1783
+ "Content-Type": "text/markdown; charset=utf-8",
1784
+ "Link": buildStaticMarkdownLinkHeader(route, staticBaseURL, config.describedby !== false)
1785
+ }
1786
+ });
1787
+ }
1738
1788
  nuxt.hooks.hook("nitro:build:before", (nitro) => {
1789
+ nitro.hooks.hook("prerender:done", () => {
1790
+ for (const { route, headers } of prerenderedMarkdownHeaderRules(
1791
+ nitro._prerenderedRoutes || [],
1792
+ staticBaseURL,
1793
+ config.describedby !== false
1794
+ )) {
1795
+ nitro.options.routeRules[route] = defu({ headers }, nitro.options.routeRules[route]);
1796
+ }
1797
+ });
1739
1798
  nitro.hooks.hook("compiled", async () => {
1740
1799
  const headersPath = join(nitro.options.output.publicDir, "_headers");
1741
1800
  logger.debug(`Checking for _headers file: ${headersPath}`);
1742
1801
  const exists = await access(headersPath).then(() => true).catch(() => false);
1743
1802
  if (exists) {
1744
1803
  const headers = await readFile(headersPath, "utf8");
1745
- const mergedHeaders = ensureStaticHeader(
1804
+ let mergedHeaders = ensureStaticHeader(
1746
1805
  headers,
1747
1806
  "/*.md",
1748
1807
  "Content-Type",
1749
1808
  "text/markdown; charset=utf-8"
1750
1809
  );
1810
+ if (config.describedby !== false) {
1811
+ mergedHeaders = ensureStaticHeader(
1812
+ mergedHeaders,
1813
+ "/*.md",
1814
+ "Link",
1815
+ staticDescribedbyEntry(nitro.options.baseURL || "/")
1816
+ );
1817
+ }
1751
1818
  if (mergedHeaders !== headers) {
1752
1819
  await writeFile(headersPath, mergedHeaders);
1753
1820
  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.0",
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": {