nuxt-ai-ready 0.2.3 → 0.3.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.
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.3",
7
+ "version": "0.3.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { dirname, join } from 'node:path';
1
2
  import { useLogger, useNuxt, defineNuxtModule, createResolver, addTypeTemplate, hasNuxtModule, addServerHandler, addPlugin } from '@nuxt/kit';
2
3
  import defu from 'defu';
3
4
  import { useSiteConfig, installNuxtSiteConfig, withSiteUrl } from 'nuxt-site-config/kit';
@@ -5,13 +6,113 @@ 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");
@@ -227,6 +369,10 @@ const module$1 = defineNuxtModule({
227
369
  maxAge: 3600,
228
370
  // 1 hour
229
371
  swr: true
372
+ },
373
+ timestamps: {
374
+ enabled: false,
375
+ manifestPath: "node_modules/.cache/nuxt-seo/ai-index/content-hashes.json"
230
376
  }
231
377
  };
232
378
  },
@@ -350,6 +496,7 @@ export {}
350
496
  await nuxt.callHook("ai-ready:llms-txt", llmsTxtPayload);
351
497
  mergedLlmsTxt.sections = llmsTxtPayload.sections;
352
498
  mergedLlmsTxt.notes = llmsTxtPayload.notes.length > 0 ? llmsTxtPayload.notes : void 0;
499
+ 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
500
  nuxt.options.runtimeConfig["nuxt-ai-ready"] = {
354
501
  version: version || "0.0.0",
355
502
  debug: config.debug || false,
@@ -358,8 +505,15 @@ export {}
358
505
  maxAge: 3600,
359
506
  swr: true
360
507
  }),
361
- llmsTxt: mergedLlmsTxt
508
+ llmsTxt: mergedLlmsTxt,
509
+ timestampsManifestPath
362
510
  };
511
+ if (config.timestamps?.enabled && hasNuxtModule("@nuxtjs/sitemap")) {
512
+ nuxt.hook("nitro:config", (nitroConfig) => {
513
+ nitroConfig.plugins = nitroConfig.plugins || [];
514
+ nitroConfig.plugins.push(resolve("./runtime/server/plugins/sitemap-lastmod"));
515
+ });
516
+ }
363
517
  addServerHandler({
364
518
  middleware: true,
365
519
  handler: resolve("./runtime/server/middleware/mdream")
@@ -371,7 +525,7 @@ export {}
371
525
  addServerHandler({ route: "/llms-full.txt", handler: resolve("./runtime/server/routes/llms.txt.get") });
372
526
  const isStatic = nuxt.options.nitro.static || nuxt.options._generate || false;
373
527
  if (isStatic || nuxt.options.nitro.prerender?.routes?.length) {
374
- setupPrerenderHandler(mergedLlmsTxt);
528
+ setupPrerenderHandler(mergedLlmsTxt, config.timestamps);
375
529
  }
376
530
  nuxt.options.nitro.routeRules = nuxt.options.nitro.routeRules || {};
377
531
  nuxt.options.nitro.routeRules["/llms.toon"] = { headers: { "Content-Type": "text/toon; charset=utf-8" } };
@@ -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
  }
@@ -82,6 +82,21 @@ export interface ModuleOptions {
82
82
  pagesChunks?: boolean;
83
83
  };
84
84
  };
85
+ /**
86
+ * Content timestamp tracking configuration
87
+ */
88
+ timestamps?: {
89
+ /**
90
+ * Enable timestamp tracking
91
+ * @default false
92
+ */
93
+ enabled?: boolean;
94
+ /**
95
+ * Path to store content hash manifest
96
+ * @default 'node_modules/.cache/nuxt-seo/ai-index/content-hashes.json'
97
+ */
98
+ manifestPath?: string;
99
+ };
85
100
  }
86
101
  /**
87
102
  * Individual chunk entry in llms-full.toon (one per chunk)
@@ -112,6 +127,8 @@ export interface BulkDocument {
112
127
  headings: Array<Record<string, string>>;
113
128
  /** All chunk IDs for this page (first ID can be used as document ID) */
114
129
  chunkIds: string[];
130
+ /** ISO 8601 timestamp of last content update */
131
+ updatedAt?: string;
115
132
  }
116
133
  /**
117
134
  * 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.3",
4
+ "version": "0.3.0",
5
5
  "description": "Best practice AI & LLM discoverability for Nuxt sites.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -33,20 +33,21 @@
33
33
  ],
34
34
  "dependencies": {
35
35
  "@nuxt/kit": "4.2.1",
36
- "@toon-format/toon": "^2.0.1",
36
+ "@toon-format/toon": "^2.1.0",
37
37
  "consola": "^3.4.2",
38
38
  "defu": "^6.1.4",
39
- "mdream": "^0.15.1",
39
+ "mdream": "^0.15.2",
40
40
  "minimatch": "^10.1.1",
41
41
  "nuxt-site-config": "^3.2.11",
42
42
  "pathe": "^2.0.3",
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",
@@ -57,7 +58,7 @@
57
58
  "@nuxtjs/eslint-config-typescript": "^12.1.0",
58
59
  "@nuxtjs/i18n": "^10.2.1",
59
60
  "@nuxtjs/mcp-toolkit": "^0.5.1",
60
- "@nuxtjs/robots": "^5.6.0",
61
+ "@nuxtjs/robots": "^5.6.1",
61
62
  "@nuxtjs/sitemap": "^7.4.8",
62
63
  "@vitest/coverage-v8": "^4.0.15",
63
64
  "@vueuse/nuxt": "^14.1.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": {