nuxt-ai-ready 0.2.4 → 0.3.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.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![npm downloads][npm-downloads-src]][npm-downloads-href]
5
5
  [![Nuxt][nuxt-src]][nuxt-href]
6
6
 
7
- > Best practice AI & LLM discoverability for Nuxt sites
7
+ > Best practice AI & LLM discoverability for Nuxt sites
8
8
 
9
9
  ## Why Nuxt AI Ready?
10
10
 
@@ -27,6 +27,7 @@ Nuxt AI Ready converts your indexable pages into clean markdown that AI systems
27
27
  - 🌐 **Indexable Pages**: Integrating with [Nuxt Sitemap](https://nuxtseo.com/sitemap) to index only AI-allowed pages
28
28
  - 📦 **Bulk Chunk Export**: Exported token optimized chunks ready for RAG and semantic search
29
29
  - ⚡ **MCP Integration**: Let AI agents query your site directly
30
+ - ⏱️ **Automatic Last Updated**: Content freshness automatically tracked and added to your [sitemap.xml](https://nuxtseo.com/docs/ai-ready/guides/automatic-updated-at)
30
31
 
31
32
  ## Installation
32
33
 
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": ">=4.0.0"
5
5
  },
6
6
  "configKey": "aiReady",
7
- "version": "0.2.4",
7
+ "version": "0.3.1",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,17 +1,118 @@
1
- import { useLogger, useNuxt, defineNuxtModule, createResolver, addTypeTemplate, hasNuxtModule, addServerHandler, addPlugin } from '@nuxt/kit';
1
+ import { dirname, join } from 'node:path';
2
+ import { useLogger, useNuxt, defineNuxtModule, createResolver, addTypeTemplate, hasNuxtModule, addServerHandler, addPlugin, extendPages } from '@nuxt/kit';
2
3
  import defu from 'defu';
3
4
  import { useSiteConfig, installNuxtSiteConfig, withSiteUrl } from 'nuxt-site-config/kit';
4
5
  import { relative } from 'pathe';
5
6
  import { readPackageJSON } from 'pkg-types';
6
7
  import { createHash } from 'node:crypto';
7
8
  import { mkdirSync, createWriteStream } from 'node:fs';
8
- import { stat, open } from 'node:fs/promises';
9
- import { join, dirname } from 'node:path';
9
+ import { mkdir, stat, open } from 'node:fs/promises';
10
10
  import { encodeLines } from '@toon-format/toon';
11
11
  import { createLlmsTxtStream } from 'mdream/llms-txt';
12
+ import { createStorage } from 'unstorage';
13
+ import fsDriver from 'unstorage/drivers/fs';
12
14
 
13
15
  const logger = useLogger("nuxt-ai-ready");
14
16
 
17
+ function createContentHashManager(options) {
18
+ const { storagePath, debug = false } = options;
19
+ let storage;
20
+ let manifest = {
21
+ pages: {},
22
+ version: "1"
23
+ };
24
+ async function initStorage() {
25
+ await mkdir(dirname(storagePath), { recursive: true });
26
+ storage = createStorage({
27
+ driver: fsDriver({ base: dirname(storagePath) })
28
+ });
29
+ }
30
+ function hashContent(markdown) {
31
+ return createHash("sha256").update(markdown).digest("hex");
32
+ }
33
+ async function getManifest() {
34
+ if (!storage) {
35
+ await initStorage();
36
+ }
37
+ const stored = await storage.getItem("content-hashes.json");
38
+ if (stored) {
39
+ manifest = stored;
40
+ if (debug) {
41
+ logger.debug(`Loaded manifest with ${Object.keys(manifest.pages).length} pages`);
42
+ }
43
+ } else {
44
+ if (debug) {
45
+ logger.debug("No existing manifest found, starting fresh");
46
+ }
47
+ }
48
+ return manifest;
49
+ }
50
+ async function saveManifest() {
51
+ if (!storage) {
52
+ await initStorage();
53
+ }
54
+ await storage.setItem("content-hashes.json", manifest);
55
+ if (debug) {
56
+ logger.debug(`Saved manifest with ${Object.keys(manifest.pages).length} pages`);
57
+ }
58
+ }
59
+ function updatePageHash(route, markdown, previousManifest) {
60
+ const contentHash = hashContent(markdown);
61
+ const now = (/* @__PURE__ */ new Date()).toISOString();
62
+ const existing = previousManifest.pages[route];
63
+ let result;
64
+ if (!existing) {
65
+ result = {
66
+ contentHash,
67
+ updatedAt: now,
68
+ firstSeenAt: now
69
+ };
70
+ if (debug) {
71
+ logger.debug(`New page detected: ${route}`);
72
+ }
73
+ } else if (existing.contentHash !== contentHash) {
74
+ result = {
75
+ contentHash,
76
+ updatedAt: now,
77
+ firstSeenAt: existing.firstSeenAt
78
+ };
79
+ if (debug) {
80
+ logger.debug(`Content changed: ${route}`);
81
+ }
82
+ } else {
83
+ result = {
84
+ contentHash: existing.contentHash,
85
+ updatedAt: existing.updatedAt,
86
+ firstSeenAt: existing.firstSeenAt
87
+ };
88
+ if (debug) {
89
+ logger.debug(`Content unchanged: ${route}`);
90
+ }
91
+ }
92
+ manifest.pages[route] = result;
93
+ return result;
94
+ }
95
+ function setPageTimestamp(route, markdown, timestamp, previousManifest) {
96
+ const contentHash = hashContent(markdown);
97
+ const existing = previousManifest.pages[route];
98
+ manifest.pages[route] = {
99
+ contentHash,
100
+ updatedAt: timestamp,
101
+ firstSeenAt: existing?.firstSeenAt || timestamp
102
+ };
103
+ if (debug) {
104
+ logger.debug(`Manual timestamp set for ${route}: ${timestamp}`);
105
+ }
106
+ }
107
+ return {
108
+ getManifest,
109
+ saveManifest,
110
+ hashContent,
111
+ updatePageHash,
112
+ setPageTimestamp
113
+ };
114
+ }
115
+
15
116
  function generateVectorId(route, chunkIdx) {
16
117
  const hash = createHash("sha256").update(route).digest("hex").substring(0, 8);
17
118
  return `${hash}-${chunkIdx}`;
@@ -30,7 +131,7 @@ async function updateFirstLine(filePath, newFirstLine) {
30
131
  await fh.close();
31
132
  }
32
133
  }
33
- function setupPrerenderHandler(llmsTxtConfig) {
134
+ function setupPrerenderHandler(llmsTxtConfig, timestampsConfig) {
34
135
  const nuxt = useNuxt();
35
136
  nuxt.hooks.hook("nitro:init", async (nitro) => {
36
137
  let writer = null;
@@ -41,6 +142,18 @@ function setupPrerenderHandler(llmsTxtConfig) {
41
142
  const startTime = Date.now();
42
143
  const pagesChunksPath = join(nitro.options.output.publicDir, "llms-full.toon");
43
144
  const pagesPath = join(nitro.options.output.publicDir, "llms.toon");
145
+ let contentHashManager = null;
146
+ let previousManifest = null;
147
+ if (timestampsConfig?.enabled) {
148
+ const manifestPath = join(
149
+ nuxt.options.rootDir,
150
+ timestampsConfig.manifestPath || "node_modules/.cache/nuxt-seo/ai-index/content-hashes.json"
151
+ );
152
+ contentHashManager = createContentHashManager({
153
+ storagePath: manifestPath,
154
+ debug: !!nuxt.options.debug
155
+ });
156
+ }
44
157
  nitro.hooks.hook("prerender:generate", async (route) => {
45
158
  if (!route.fileName?.endsWith(".md")) {
46
159
  return;
@@ -61,15 +174,39 @@ function setupPrerenderHandler(llmsTxtConfig) {
61
174
  notes: llmsTxtConfig.notes
62
175
  });
63
176
  writer = stream.getWriter();
177
+ if (contentHashManager && !previousManifest) {
178
+ previousManifest = await contentHashManager.getManifest();
179
+ }
64
180
  mkdirSync(dirname(pagesChunksPath), { recursive: true });
65
181
  mkdirSync(dirname(pagesPath), { recursive: true });
66
182
  chunksStream = createWriteStream(pagesChunksPath, { encoding: "utf-8" });
67
183
  chunksStream.write("pageChunks[999999]{id,route,content}:\n");
68
184
  pagesStream = createWriteStream(pagesPath, { encoding: "utf-8" });
69
- pagesStream.write("pages[999999]{route,title,description,headings,chunkIds}:\n");
185
+ pagesStream.write("pages[999999]{route,title,description,headings,chunkIds,updatedAt}:\n");
70
186
  }
71
- const { chunks, title, description, headings } = JSON.parse(route.contents || "{}");
187
+ const { chunks, title, description, headings, updatedAt: metaUpdatedAt } = JSON.parse(route.contents || "{}");
72
188
  const markdown = chunks.map((c) => c.content).join("\n\n");
189
+ let pageTimestamp = {
190
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
191
+ };
192
+ let usedMetaTimestamp = false;
193
+ if (metaUpdatedAt) {
194
+ const parsedDate = new Date(metaUpdatedAt);
195
+ if (!Number.isNaN(parsedDate.getTime())) {
196
+ pageTimestamp.updatedAt = parsedDate.toISOString();
197
+ usedMetaTimestamp = true;
198
+ if (contentHashManager && previousManifest) {
199
+ contentHashManager.setPageTimestamp(pageRoute, markdown, pageTimestamp.updatedAt, previousManifest);
200
+ }
201
+ }
202
+ }
203
+ if (!usedMetaTimestamp && contentHashManager && previousManifest) {
204
+ pageTimestamp = contentHashManager.updatePageHash(
205
+ pageRoute,
206
+ markdown,
207
+ previousManifest
208
+ );
209
+ }
73
210
  await writer.write({
74
211
  url: pageRoute,
75
212
  title,
@@ -121,7 +258,8 @@ function setupPrerenderHandler(llmsTxtConfig) {
121
258
  ([tag, texts]) => texts.map((text) => `${tag}:${text}`)
122
259
  ).join("|") : "",
123
260
  // Join chunkIds array to comma-separated string
124
- chunkIds: chunkIds.join(",")
261
+ chunkIds: chunkIds.join(","),
262
+ updatedAt: pageTimestamp.updatedAt
125
263
  };
126
264
  if (pagesStream) {
127
265
  const lines = Array.from(encodeLines({ pages: [pageDoc] }));
@@ -152,7 +290,11 @@ function setupPrerenderHandler(llmsTxtConfig) {
152
290
  });
153
291
  }
154
292
  await updateFirstLine(pagesChunksPath, `pageChunks[${chunksProcessed}]{id,route,content}:`);
155
- await updateFirstLine(pagesPath, `pages[${pageCount}]{route,title,description,headings,chunkIds}:`);
293
+ await updateFirstLine(pagesPath, `pages[${pageCount}]{route,title,description,headings,chunkIds,updatedAt}:`);
294
+ if (contentHashManager) {
295
+ await contentHashManager.saveManifest();
296
+ logger.debug("Saved content hash manifest");
297
+ }
156
298
  logger.info(`Wrote llms-full.toon with ${chunksProcessed} chunks`);
157
299
  logger.info(`Wrote llms.toon with ${pageCount} pages`);
158
300
  const llmsTxtPath = join(nitro.options.output.publicDir, "llms.txt");
@@ -196,6 +338,23 @@ function setupPrerenderHandler(llmsTxtConfig) {
196
338
  });
197
339
  }
198
340
 
341
+ function createPagesPromise(nuxt = useNuxt()) {
342
+ return new Promise((resolve) => {
343
+ nuxt.hooks.hook("modules:done", () => {
344
+ if (typeof nuxt.options.pages === "boolean" && !nuxt.options.pages || typeof nuxt.options.pages === "object" && !nuxt.options.pages.enabled) {
345
+ return resolve([]);
346
+ }
347
+ extendPages(resolve);
348
+ });
349
+ });
350
+ }
351
+ function flattenPages(pages, parent = "") {
352
+ return pages.flatMap((page) => {
353
+ const path = parent + page.path;
354
+ const current = { path, name: page.name, meta: page.meta };
355
+ return page.children?.length ? [current, ...flattenPages(page.children, path)] : [current];
356
+ });
357
+ }
199
358
  const module$1 = defineNuxtModule({
200
359
  meta: {
201
360
  name: "nuxt-ai-ready",
@@ -227,6 +386,10 @@ const module$1 = defineNuxtModule({
227
386
  maxAge: 3600,
228
387
  // 1 hour
229
388
  swr: true
389
+ },
390
+ timestamps: {
391
+ enabled: false,
392
+ manifestPath: "node_modules/.cache/nuxt-seo/ai-index/content-hashes.json"
230
393
  }
231
394
  };
232
395
  },
@@ -247,9 +410,17 @@ const module$1 = defineNuxtModule({
247
410
  }
248
411
  nuxt.options.nitro.scanDirs = nuxt.options.nitro.scanDirs || [];
249
412
  nuxt.options.nitro.scanDirs.push(
250
- resolve("./runtime/server/utils"),
251
- resolve("./runtime/server/mcp")
413
+ resolve("./runtime/server/utils")
252
414
  );
415
+ const pagesPromise = createPagesPromise(nuxt);
416
+ nuxt.hooks.hook("nitro:config", (nitroConfig) => {
417
+ nitroConfig.virtual = nitroConfig.virtual || {};
418
+ nitroConfig.virtual["#ai-ready/routes.mjs"] = async () => {
419
+ const pages = await pagesPromise;
420
+ const routes = flattenPages(pages);
421
+ return `export default ${JSON.stringify(routes)}`;
422
+ };
423
+ });
253
424
  if (typeof config.contentSignal === "object") {
254
425
  nuxt.options.robots.groups.push({
255
426
  userAgent: "*",
@@ -305,22 +476,12 @@ export {}
305
476
  const hasMCP = hasNuxtModule("@nuxtjs/mcp-toolkit");
306
477
  if (hasMCP) {
307
478
  nuxt.hook("mcp:definitions:paths", (paths) => {
308
- const mcpRuntimeDir = resolve("./runtime/server/mcp");
309
- paths.tools = paths.tools || [];
310
- paths.resources = paths.resources || [];
311
- paths.prompts = paths.prompts || [];
479
+ const mcpRuntimeDir = resolve(`./runtime/server/mcp/${nuxt.options.dev ? "dev" : "prod"}`);
312
480
  const mcpConfig = config.mcp || {};
313
- const toolsConfig = mcpConfig.tools ?? {};
314
- const resourcesConfig = mcpConfig.resources ?? {};
315
- if (toolsConfig.listPages !== false) {
316
- paths.tools.push(`${mcpRuntimeDir}/tools/list-pages.ts`);
317
- }
318
- if (resourcesConfig.pages !== false) {
319
- paths.resources.push(`${mcpRuntimeDir}/resources/pages.ts`);
320
- }
321
- if (resourcesConfig.pagesChunks !== false) {
322
- paths.resources.push(`${mcpRuntimeDir}/resources/pages-chunks.ts`);
323
- }
481
+ if (mcpConfig.tools !== false)
482
+ (paths.tools ||= []).push(`${mcpRuntimeDir}/tools`);
483
+ if (mcpConfig.resources !== false)
484
+ (paths.resources ||= []).push(`${mcpRuntimeDir}/resources`);
324
485
  });
325
486
  const mcpLink = {
326
487
  title: "MCP",
@@ -350,16 +511,25 @@ export {}
350
511
  await nuxt.callHook("ai-ready:llms-txt", llmsTxtPayload);
351
512
  mergedLlmsTxt.sections = llmsTxtPayload.sections;
352
513
  mergedLlmsTxt.notes = llmsTxtPayload.notes.length > 0 ? llmsTxtPayload.notes : void 0;
514
+ const timestampsManifestPath = config.timestamps?.enabled ? join(nuxt.options.rootDir, config.timestamps.manifestPath || "node_modules/.cache/nuxt-seo/ai-index/content-hashes.json") : void 0;
353
515
  nuxt.options.runtimeConfig["nuxt-ai-ready"] = {
354
516
  version: version || "0.0.0",
355
517
  debug: config.debug || false,
518
+ hasSitemap: hasNuxtModule("@nuxtjs/sitemap"),
356
519
  mdreamOptions: config.mdreamOptions || {},
357
520
  markdownCacheHeaders: defu(config.markdownCacheHeaders, {
358
521
  maxAge: 3600,
359
522
  swr: true
360
523
  }),
361
- llmsTxt: mergedLlmsTxt
524
+ llmsTxt: mergedLlmsTxt,
525
+ timestampsManifestPath
362
526
  };
527
+ if (config.timestamps?.enabled && hasNuxtModule("@nuxtjs/sitemap")) {
528
+ nuxt.hook("nitro:config", (nitroConfig) => {
529
+ nitroConfig.plugins = nitroConfig.plugins || [];
530
+ nitroConfig.plugins.push(resolve("./runtime/server/plugins/sitemap-lastmod"));
531
+ });
532
+ }
363
533
  addServerHandler({
364
534
  middleware: true,
365
535
  handler: resolve("./runtime/server/middleware/mdream")
@@ -371,7 +541,7 @@ export {}
371
541
  addServerHandler({ route: "/llms-full.txt", handler: resolve("./runtime/server/routes/llms.txt.get") });
372
542
  const isStatic = nuxt.options.nitro.static || nuxt.options._generate || false;
373
543
  if (isStatic || nuxt.options.nitro.prerender?.routes?.length) {
374
- setupPrerenderHandler(mergedLlmsTxt);
544
+ setupPrerenderHandler(mergedLlmsTxt, config.timestamps);
375
545
  }
376
546
  nuxt.options.nitro.routeRules = nuxt.options.nitro.routeRules || {};
377
547
  nuxt.options.nitro.routeRules["/llms.toon"] = { headers: { "Content-Type": "text/toon; charset=utf-8" } };
@@ -1,2 +1,2 @@
1
- declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
1
+ declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
2
2
  export default _default;
@@ -0,0 +1,19 @@
1
+ import { getDevPages } from "../utils.js";
2
+ export default {
3
+ uri: "resource://nuxt-ai-ready/pages",
4
+ name: "All Pages",
5
+ description: "Page routes from sitemap/routes. In dev mode, returns JSON (TOON format unavailable until build).",
6
+ metadata: {
7
+ mimeType: "application/json"
8
+ },
9
+ async handler(uri) {
10
+ const pages = await getDevPages();
11
+ return {
12
+ contents: [{
13
+ uri: uri.toString(),
14
+ mimeType: "application/json",
15
+ text: JSON.stringify(pages, null, 2)
16
+ }]
17
+ };
18
+ }
19
+ };
@@ -0,0 +1,10 @@
1
+ import { getDevPages, jsonResult } from "../utils.js";
2
+ export default {
3
+ name: "list_pages",
4
+ description: "Lists all available pages with their routes. In dev mode, returns JSON from sitemap/routes (TOON format unavailable until build).",
5
+ inputSchema: {},
6
+ async handler() {
7
+ const pages = await getDevPages();
8
+ return jsonResult(pages);
9
+ }
10
+ };
@@ -0,0 +1,22 @@
1
+ import routes from "#ai-ready/routes.mjs";
2
+ import { useRuntimeConfig } from "nitropack/runtime";
3
+ export { jsonResult } from "../utils.js";
4
+ export async function getDevPages() {
5
+ const config = useRuntimeConfig()["nuxt-ai-ready"];
6
+ if (!config.hasSitemap)
7
+ return routes.map((r) => ({ route: r.path, name: r.name, meta: r.meta }));
8
+ const { parseSitemapXml } = await import("@nuxtjs/sitemap/utils");
9
+ const sitemapRes = await fetch("/sitemap.xml");
10
+ if (!sitemapRes.ok)
11
+ return routes.map((r) => ({ route: r.path, name: r.name, meta: r.meta }));
12
+ const xml = await sitemapRes.text();
13
+ const { urls } = await parseSitemapXml(xml);
14
+ return urls.map((entry) => {
15
+ if (typeof entry === "string")
16
+ return { route: new URL(entry).pathname };
17
+ return {
18
+ route: new URL(entry.loc).pathname,
19
+ lastmod: entry.lastmod
20
+ };
21
+ });
22
+ }
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { toonResult } from "../utils.js";
2
+ import { toonResult } from "../../utils.js";
3
3
  const schema = {
4
4
  mode: z.enum(["chunks", "minimal"]).default("minimal").describe("Return individual content chunks (chunks) or page-level metadata (minimal)")
5
5
  };
@@ -0,0 +1,2 @@
1
+ declare const _default: import("nitropack/types").NitroAppPlugin;
2
+ export default _default;
@@ -0,0 +1,22 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { defineNitroPlugin, useRuntimeConfig } from "nitropack/runtime";
3
+ export default defineNitroPlugin((nitroApp) => {
4
+ const config = useRuntimeConfig();
5
+ const manifestPath = config["nuxt-ai-ready"]?.timestampsManifestPath;
6
+ if (!manifestPath) {
7
+ return;
8
+ }
9
+ nitroApp.hooks.hook("sitemap:resolved", async (ctx) => {
10
+ const manifest = await readFile(manifestPath, "utf-8").then((data) => JSON.parse(data)).catch(() => null);
11
+ if (!manifest) {
12
+ return;
13
+ }
14
+ for (const url of ctx.urls) {
15
+ const route = url.loc.replace(/^https?:\/\/[^/]+/, "").replace(/\/$/, "") || "/";
16
+ const pageData = manifest.pages[route];
17
+ if (pageData?.updatedAt) {
18
+ url.lastmod = pageData.updatedAt;
19
+ }
20
+ }
21
+ });
22
+ });
@@ -1,7 +1,8 @@
1
1
  import type { ModulePublicRuntimeConfig } from '../../module.js';
2
2
  export declare function convertHtmlToMarkdownChunks(html: string, url: string, mdreamOptions: ModulePublicRuntimeConfig['mdreamOptions']): Promise<{
3
+ headings: Record<string, string[]>;
4
+ updatedAt?: string | undefined;
3
5
  chunks: import("mdream").MarkdownChunk[];
4
6
  title: string;
5
7
  description: string;
6
- headings: Record<string, string[]>;
7
8
  }>;
@@ -6,12 +6,19 @@ import { estimateTokenCount } from "tokenx";
6
6
  export async function convertHtmlToMarkdownChunks(html, url, mdreamOptions) {
7
7
  let title = "";
8
8
  let description = "";
9
+ let updatedAt;
9
10
  const extractPlugin = extractionPlugin({
10
11
  title(el) {
11
12
  title = el.textContent;
12
13
  },
13
14
  'meta[name="description"]': (el) => {
14
15
  description = el.attributes.content || "";
16
+ },
17
+ // Extract timestamp from various meta tag formats
18
+ 'meta[property="article:modified_time"], meta[name="last-modified"], meta[name="updated"], meta[property="og:updated_time"], meta[name="lastmod"]': (el) => {
19
+ if (!updatedAt && el.attributes.content) {
20
+ updatedAt = el.attributes.content;
21
+ }
15
22
  }
16
23
  });
17
24
  let options = {
@@ -38,13 +45,19 @@ export async function convertHtmlToMarkdownChunks(html, url, mdreamOptions) {
38
45
  for await (const chunk of chunksStream) {
39
46
  chunks.push(chunk);
40
47
  }
41
- return { chunks, title, description, headings: chunks.reduce((set, m) => {
42
- Object.entries(m.metadata?.headers || {}).forEach(([k, v]) => {
43
- if (!set[k])
44
- set[k] = [];
45
- if (v && !set[k].includes(v))
46
- set[k].push(v);
47
- });
48
- return set;
49
- }, {}) };
48
+ return {
49
+ chunks,
50
+ title,
51
+ description,
52
+ ...updatedAt && { updatedAt },
53
+ headings: chunks.reduce((set, m) => {
54
+ Object.entries(m.metadata?.headers || {}).forEach(([k, v]) => {
55
+ if (!set[k])
56
+ set[k] = [];
57
+ if (v && !set[k].includes(v))
58
+ set[k].push(v);
59
+ });
60
+ return set;
61
+ }, {})
62
+ };
50
63
  }
@@ -63,24 +63,25 @@ export interface ModuleOptions {
63
63
  * @default All enabled when @nuxtjs/mcp-toolkit is installed
64
64
  */
65
65
  mcp?: {
66
+ /** Enable MCP tools (list-pages) @default true */
67
+ tools?: boolean;
68
+ /** Enable MCP resources (pages, pages-chunks) @default true */
69
+ resources?: boolean;
70
+ };
71
+ /**
72
+ * Content timestamp tracking configuration
73
+ */
74
+ timestamps?: {
66
75
  /**
67
- * Enable/disable specific MCP tools
68
- * @default All tools enabled
76
+ * Enable timestamp tracking
77
+ * @default false
69
78
  */
70
- tools?: {
71
- /** Get page by route - fetches markdown content for specific page */
72
- listPages?: boolean;
73
- };
79
+ enabled?: boolean;
74
80
  /**
75
- * Enable/disable specific MCP resources
76
- * @default All resources enabled
81
+ * Path to store content hash manifest
82
+ * @default 'node_modules/.cache/nuxt-seo/ai-index/content-hashes.json'
77
83
  */
78
- resources?: {
79
- /** pages://list - all pages without markdown content */
80
- pages?: boolean;
81
- /** pages://chunks - individual content chunks from all pages */
82
- pagesChunks?: boolean;
83
- };
84
+ manifestPath?: string;
84
85
  };
85
86
  }
86
87
  /**
@@ -112,6 +113,8 @@ export interface BulkDocument {
112
113
  headings: Array<Record<string, string>>;
113
114
  /** All chunk IDs for this page (first ID can be used as document ID) */
114
115
  chunkIds: string[];
116
+ /** ISO 8601 timestamp of last content update */
117
+ updatedAt?: string;
115
118
  }
116
119
  /**
117
120
  * Hook context for markdown processing (Nitro runtime hook)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-ai-ready",
3
3
  "type": "module",
4
- "version": "0.2.4",
4
+ "version": "0.3.1",
5
5
  "description": "Best practice AI & LLM discoverability for Nuxt sites.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -43,10 +43,11 @@
43
43
  "pkg-types": "^2.3.0",
44
44
  "std-env": "^3.10.0",
45
45
  "tokenx": "^1.2.1",
46
- "ufo": "^1.6.1"
46
+ "ufo": "^1.6.1",
47
+ "unstorage": "^1.17.3"
47
48
  },
48
49
  "devDependencies": {
49
- "@antfu/eslint-config": "^6.4.1",
50
+ "@antfu/eslint-config": "^6.4.2",
50
51
  "@arethetypeswrong/cli": "^0.18.2",
51
52
  "@headlessui/vue": "^1.7.23",
52
53
  "@nuxt/content": "^3.9.0",
@@ -75,7 +76,7 @@
75
76
  "vitest": "^4.0.15",
76
77
  "vue": "^3.5.25",
77
78
  "vue-router": "^4.6.3",
78
- "vue-tsc": "^3.1.5",
79
+ "vue-tsc": "^3.1.6",
79
80
  "zod": "^4.1.13"
80
81
  },
81
82
  "resolutions": {
@@ -1,17 +0,0 @@
1
- declare const _default: {
2
- uri: string;
3
- name: string;
4
- description: string;
5
- metadata: {
6
- mimeType: string;
7
- };
8
- cache: "1h";
9
- handler(uri: URL): Promise<{
10
- contents: {
11
- uri: string;
12
- mimeType: string;
13
- text: string;
14
- }[];
15
- }>;
16
- };
17
- export default _default;
@@ -1,17 +0,0 @@
1
- declare const _default: {
2
- uri: string;
3
- name: string;
4
- description: string;
5
- metadata: {
6
- mimeType: string;
7
- };
8
- cache: "1h";
9
- handler(uri: URL): Promise<{
10
- contents: {
11
- uri: string;
12
- mimeType: string;
13
- text: string;
14
- }[];
15
- }>;
16
- };
17
- export default _default;
@@ -1,86 +0,0 @@
1
- import { z } from 'zod';
2
- /**
3
- * Lists all pages by fetching and returning TOON-encoded data
4
- * TOON (Token-Oriented Object Notation) is a compact encoding that minimizes tokens for LLM input
5
- * See https://toonformat.dev
6
- */
7
- declare const _default: {
8
- name: string;
9
- description: string;
10
- inputSchema: {
11
- mode: z.ZodDefault<z.ZodEnum<{
12
- minimal: "minimal";
13
- chunks: "chunks";
14
- }>>;
15
- };
16
- cache: "1h";
17
- handler({ mode }: import("@modelcontextprotocol/sdk/server/zod-compat.js").ShapeOutput<Readonly<{
18
- [k: string]: z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
19
- }>>): Promise<{
20
- [x: string]: unknown;
21
- content: ({
22
- type: "text";
23
- text: string;
24
- _meta?: {
25
- [x: string]: unknown;
26
- } | undefined;
27
- } | {
28
- type: "image";
29
- data: string;
30
- mimeType: string;
31
- _meta?: {
32
- [x: string]: unknown;
33
- } | undefined;
34
- } | {
35
- type: "audio";
36
- data: string;
37
- mimeType: string;
38
- _meta?: {
39
- [x: string]: unknown;
40
- } | undefined;
41
- } | {
42
- uri: string;
43
- name: string;
44
- type: "resource_link";
45
- description?: string | undefined;
46
- mimeType?: string | undefined;
47
- _meta?: {
48
- [x: string]: unknown;
49
- } | undefined;
50
- icons?: {
51
- src: string;
52
- mimeType?: string | undefined;
53
- sizes?: string[] | undefined;
54
- }[] | undefined;
55
- title?: string | undefined;
56
- } | {
57
- type: "resource";
58
- resource: {
59
- uri: string;
60
- text: string;
61
- mimeType?: string | undefined;
62
- _meta?: {
63
- [x: string]: unknown;
64
- } | undefined;
65
- } | {
66
- uri: string;
67
- blob: string;
68
- mimeType?: string | undefined;
69
- _meta?: {
70
- [x: string]: unknown;
71
- } | undefined;
72
- };
73
- _meta?: {
74
- [x: string]: unknown;
75
- } | undefined;
76
- })[];
77
- _meta?: {
78
- [x: string]: unknown;
79
- } | undefined;
80
- structuredContent?: {
81
- [x: string]: unknown;
82
- } | undefined;
83
- isError?: boolean | undefined;
84
- }>;
85
- };
86
- export default _default;