nuxt-ai-ready 1.5.3 → 1.5.5

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/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": ">=4.0.0"
5
5
  },
6
6
  "configKey": "aiReady",
7
- "version": "1.5.3",
7
+ "version": "1.5.5",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
- import { mkdir, writeFile, appendFile, stat, access } from 'node:fs/promises';
2
+ import { mkdir, writeFile, appendFile, stat, access, readFile } from 'node:fs/promises';
3
3
  import { join, dirname } from 'node:path';
4
- import { useLogger, useNuxt, addTypeTemplate, addTemplate, defineNuxtModule, createResolver, hasNuxtModule, addServerHandler, addPlugin, addServerPlugin } from '@nuxt/kit';
4
+ import { useLogger, useNuxt, addTypeTemplate, addTemplate, defineNuxtModule, createResolver, hasNuxtModule, addServerPlugin, addServerHandler, addPlugin } from '@nuxt/kit';
5
5
  import defu from 'defu';
6
6
  import { installNuxtSiteConfig, useSiteConfig, withSiteUrl } from 'nuxt-site-config/kit';
7
7
  import { setupDevToolsUI } from 'nuxtseo-shared/devtools';
@@ -10,7 +10,7 @@ import { readPackageJSON, resolvePackageJSON } from 'pkg-types';
10
10
  import { parseSitemapXml } from '@nuxtjs/sitemap/utils';
11
11
  import { colorize } from 'consola/utils';
12
12
  import { withBase } from 'ufo';
13
- import { toMarkdownPath } from '../dist/runtime/markdown-path.js';
13
+ import { normalizePagePath, toMarkdownPath } from '../dist/runtime/markdown-path.js';
14
14
  import { initSchema, computeContentHash, insertPage, queryAllPages, exportDbDump } from '../dist/runtime/server/db/shared.js';
15
15
  import { comparePageHashes, submitToIndexNowShared } from '../dist/runtime/server/utils/indexnow-shared.js';
16
16
  import { buildLlmsFullTxtHeader, formatPageForLlmsFullTxt } from '../dist/runtime/server/utils/llms-full.js';
@@ -125,14 +125,18 @@ function resolveRouteLocale(route, i18n) {
125
125
  return matched ? matched.code : i18n.defaultLocale;
126
126
  }
127
127
  async function processMarkdownRoute(state, nuxt, route, parsed, lastmod, options) {
128
- const { markdown, title, description, headings, keywords, updatedAt: metaUpdatedAt } = parsed;
128
+ route = normalizePagePath(route);
129
+ const { title, description, headings, keywords, updatedAt: metaUpdatedAt } = parsed;
129
130
  let updatedAt = (lastmod instanceof Date ? lastmod.toISOString() : lastmod) || (/* @__PURE__ */ new Date()).toISOString();
130
131
  if (metaUpdatedAt) {
131
132
  const parsedDate = new Date(metaUpdatedAt);
132
133
  if (!Number.isNaN(parsedDate.getTime()))
133
134
  updatedAt = parsedDate.toISOString();
134
135
  }
135
- await nuxt.hooks.callHook("ai-ready:page:markdown", { route, markdown, title, description, headings });
136
+ const hookContext = { ...parsed, route };
137
+ await nuxt.hooks.callHook("ai-ready:page:markdown", hookContext);
138
+ const { markdown } = hookContext;
139
+ parsed.markdown = markdown;
136
140
  if (state.db) {
137
141
  const contentHash = await computeContentHash(markdown);
138
142
  await insertPage(state.db, {
@@ -147,7 +151,7 @@ async function processMarkdownRoute(state, nuxt, route, parsed, lastmod, options
147
151
  locale: resolveRouteLocale(route, state.i18n)
148
152
  });
149
153
  }
150
- if (state.llmsFullTxtPath && !options?.skipLlmsFullTxt) {
154
+ if (state.llmsFullTxtPath && !options?.skipLlmsFullTxt && markdown.trim()) {
151
155
  const pageContent = formatPageForLlmsFullTxt(route, title, description, markdown, state.siteInfo?.url);
152
156
  logger.debug(`Appending to llms-full.txt: ${route} (${(pageContent.length / 1024).toFixed(1)}kb)`);
153
157
  await appendFile(state.llmsFullTxtPath, pageContent, "utf-8");
@@ -157,7 +161,7 @@ async function processMarkdownRoute(state, nuxt, route, parsed, lastmod, options
157
161
  async function processSitemapEntry(state, nuxt, nitro, entry) {
158
162
  const loc = typeof entry === "string" ? entry : entry.loc;
159
163
  const lastmod = typeof entry === "string" ? void 0 : entry.lastmod;
160
- const route = loc.startsWith("http") ? new URL(loc).pathname : loc;
164
+ const route = normalizePagePath(loc.startsWith("http") ? new URL(loc).pathname : loc);
161
165
  if (route.split("/").some((segment) => segment.startsWith("_"))) {
162
166
  return { crawled: false, skipped: true };
163
167
  }
@@ -262,7 +266,10 @@ async function prerenderRoute(nitro, route) {
262
266
  await writeFile(filePath, data, "utf8");
263
267
  const _route = {
264
268
  route,
265
- fileName: filePath,
269
+ // fileName is relative to the output public dir (nitro core convention);
270
+ // presets key overrides/route exclusions off it, so an absolute path here
271
+ // breaks e.g. the Vercel config.json overrides map.
272
+ fileName: route,
266
273
  generateTimeMS: Date.now() - start
267
274
  };
268
275
  nitro._prerenderedRoutes.push(_route);
@@ -392,6 +399,7 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, indexNo
392
399
  }
393
400
  const llmsStats = await prerenderRoute(nitro, "/llms.txt");
394
401
  const llmsFullStats = await stat(state.llmsFullTxtPath);
402
+ nitro._prerenderedRoutes.push({ route: "/llms-full.txt", fileName: "/llms-full.txt" });
395
403
  const kb = (b) => (b / 1024).toFixed(1);
396
404
  const totalKb = kb(llmsStats.size + llmsFullStats.size);
397
405
  const dim = (s) => colorize("dim", s);
@@ -562,6 +570,37 @@ async function detectI18n(opts = {}) {
562
570
  return toRuntimeI18nConfig(auto);
563
571
  }
564
572
 
573
+ function escapeRegExp(value) {
574
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
575
+ }
576
+ function ensureStaticHeader(contents, route, name, value) {
577
+ const eol = contents.includes("\r\n") ? "\r\n" : "\n";
578
+ const routePattern = new RegExp(`^${escapeRegExp(route)}[\\t ]*\\r?$`, "gm");
579
+ const routeMatches = [...contents.matchAll(routePattern)];
580
+ const routeMatch = routeMatches.at(-1);
581
+ if (!routeMatch || routeMatch.index === void 0) {
582
+ const separator = contents.length === 0 ? "" : contents.endsWith(`${eol}${eol}`) ? "" : contents.endsWith(eol) ? eol : `${eol}${eol}`;
583
+ return `${contents}${separator}${route}${eol} ${name}: ${value}${eol}`;
584
+ }
585
+ const routeLineEnd = routeMatch.index + routeMatch[0].length;
586
+ const blockStart = contents[routeLineEnd] === "\n" ? routeLineEnd + 1 : routeLineEnd;
587
+ const nextBlockPattern = /^(?![\t ]|\r?$).+/gm;
588
+ nextBlockPattern.lastIndex = blockStart;
589
+ const nextBlock = nextBlockPattern.exec(contents);
590
+ const blockEnd = nextBlock?.index ?? contents.length;
591
+ const block = contents.slice(blockStart, blockEnd);
592
+ const escapedName = escapeRegExp(name);
593
+ const existingHeaderPattern = new RegExp(
594
+ `^[\\t ]+(?:${escapedName}[\\t ]*:|![\\t ]*${escapedName}[\\t ]*\\r?$)`,
595
+ "im"
596
+ );
597
+ if (existingHeaderPattern.test(block)) {
598
+ return contents;
599
+ }
600
+ const prefix = blockStart === routeLineEnd ? eol : "";
601
+ return `${contents.slice(0, blockStart)}${prefix} ${name}: ${value}${eol}${contents.slice(blockStart)}`;
602
+ }
603
+
565
604
  const module$1 = defineNuxtModule({
566
605
  meta: {
567
606
  name: "nuxt-ai-ready",
@@ -678,6 +717,8 @@ const module$1 = defineNuxtModule({
678
717
  robotsOpts.groups = groups;
679
718
  const group = {
680
719
  userAgent: "*",
720
+ // Preserve nuxt-robots' default wildcard rule so the injected group remains valid.
721
+ disallow: [""],
681
722
  contentSignal: [`ai-train=${config.contentSignal.aiTrain ? "yes" : "no"}`, `search=${config.contentSignal.search ? "yes" : "no"}`, `ai-input=${config.contentSignal.aiInput ? "yes" : "no"}`]
682
723
  };
683
724
  if (config.contentSignal.contentUsage !== false)
@@ -940,6 +981,7 @@ export async function lookupContentPage(event, path) {
940
981
  i18n: i18nConfig,
941
982
  ftsTokenizer
942
983
  };
984
+ addServerPlugin(resolve("./runtime/server/plugins/html-capture.prerender"));
943
985
  addServerHandler({
944
986
  middleware: true,
945
987
  handler: resolve("./runtime/server/middleware/markdown.prerender")
@@ -1008,20 +1050,29 @@ export async function lookupContentPage(event, path) {
1008
1050
  icon: "carbon:ai-label"
1009
1051
  }, resolve, nuxt);
1010
1052
  nuxt.options.nitro.routeRules = nuxt.options.nitro.routeRules || {};
1011
- nuxt.options.nitro.routeRules["/llms.txt"] = { headers: { "Content-Type": "text/plain; charset=utf-8" } };
1012
- nuxt.options.nitro.routeRules["/llms-full.txt"] = { headers: { "Content-Type": "text/plain; charset=utf-8" } };
1053
+ for (const route of ["/llms.txt", "/llms-full.txt"]) {
1054
+ nuxt.options.nitro.routeRules[route] = defu(
1055
+ nuxt.options.nitro.routeRules[route],
1056
+ { headers: { "Content-Type": "text/plain; charset=utf-8" } }
1057
+ );
1058
+ }
1013
1059
  nuxt.hooks.hook("nitro:build:before", (nitro) => {
1014
1060
  nitro.hooks.hook("compiled", async () => {
1015
1061
  const headersPath = join(nitro.options.output.publicDir, "_headers");
1016
1062
  logger.debug(`Checking for _headers file: ${headersPath}`);
1017
1063
  const exists = await access(headersPath).then(() => true).catch(() => false);
1018
1064
  if (exists) {
1019
- logger.debug(`Appending .md charset header to _headers`);
1020
- await appendFile(headersPath, `
1021
- /*.md
1022
- Content-Type: text/markdown; charset=utf-8
1023
- `);
1024
- logger.debug("Appended .md charset header to _headers");
1065
+ const headers = await readFile(headersPath, "utf8");
1066
+ const mergedHeaders = ensureStaticHeader(
1067
+ headers,
1068
+ "/*.md",
1069
+ "Content-Type",
1070
+ "text/markdown; charset=utf-8"
1071
+ );
1072
+ if (mergedHeaders !== headers) {
1073
+ await writeFile(headersPath, mergedHeaders);
1074
+ logger.debug("Merged .md charset header into _headers");
1075
+ }
1025
1076
  }
1026
1077
  });
1027
1078
  });
@@ -1 +1,2 @@
1
+ export declare function normalizePagePath(path: string): string;
1
2
  export declare function toMarkdownPath(path: string): string;
@@ -1,6 +1,10 @@
1
1
  const RE_TRAILING_SLASHES = /\/+$/;
2
+ export function normalizePagePath(path) {
3
+ return path.replace(RE_TRAILING_SLASHES, "") || "/";
4
+ }
2
5
  export function toMarkdownPath(path) {
3
- if (path === "/")
6
+ const normalizedPath = normalizePagePath(path);
7
+ if (normalizedPath === "/")
4
8
  return "/index.md";
5
- return `${path.replace(RE_TRAILING_SLASHES, "")}.md`;
9
+ return `${normalizedPath}.md`;
6
10
  }
@@ -15,8 +15,8 @@ async function ensureWritableDir(dir) {
15
15
  await rm(probe, { force: true }).catch(() => null);
16
16
  return null;
17
17
  }
18
- function isReadOnly(err) {
19
- return err.code === "EROFS" || err.code === "EACCES";
18
+ function isRecoverable(err) {
19
+ return err.code === "EROFS" || err.code === "EACCES" || err.code === "ENOENT" || err.code === "ENOTDIR";
20
20
  }
21
21
  export async function resolveWritableDbPath(dbPath) {
22
22
  const cached = resolved.get(dbPath);
@@ -28,19 +28,19 @@ export async function resolveWritableDbPath(dbPath) {
28
28
  resolved.set(dbPath, dbPath);
29
29
  return dbPath;
30
30
  }
31
- if (!isReadOnly(err))
31
+ if (!isRecoverable(err))
32
32
  throw err;
33
33
  const key = createHash("sha256").update(resolve(dbPath)).digest("hex").slice(0, 16);
34
34
  const fallbackDir = join(tmpdir(), `ai-ready-${key}`);
35
35
  const fallbackErr = await ensureWritableDir(fallbackDir);
36
36
  if (fallbackErr) {
37
37
  throw new Error(
38
- `[ai-ready] Database directory '${dir}' is read-only and the temp dir fallback ('${fallbackDir}') also failed: ${fallbackErr.message}. Set database.filename to a writable path, or use a serverless driver: database.type 'd1' (Cloudflare), 'neon' (Vercel/Postgres), or 'libsql' (Turso).`
38
+ `[ai-ready] Database directory '${dir}' is not writable (${err.code}) and the temp dir fallback ('${fallbackDir}') also failed: ${fallbackErr.message}. Set database.filename to a writable path, or use a serverless driver: database.type 'd1' (Cloudflare), 'neon' (Vercel/Postgres), or 'libsql' (Turso).`
39
39
  );
40
40
  }
41
41
  const fallback = join(fallbackDir, "pages.db");
42
42
  logger.warn(
43
- `[ai-ready] Database directory '${dir}' is read-only; falling back to '${fallback}'. This database is ephemeral and reseeded on cold start. Set database.filename to a writable persistent path (or a volume) to silence this warning.`
43
+ `[ai-ready] Database directory '${dir}' is not writable (${err.code}); falling back to '${fallback}'. This database is ephemeral and reseeded on cold start. Set database.filename to a writable persistent path (or a volume) to silence this warning.`
44
44
  );
45
45
  resolved.set(dbPath, fallback);
46
46
  return fallback;
@@ -6,6 +6,7 @@ import { convertHtmlToMarkdown, extractLastUpdated, getMarkdownRenderInfo } from
6
6
  import { tryGetContentMarkdown } from "../utils/content.js";
7
7
  import { buildFrontmatter } from "../utils/frontmatter.js";
8
8
  import { extractKeywords } from "../utils/keywords.js";
9
+ import { consumePrerenderedHtml } from "../utils/prerender-html.js";
9
10
  function extractHeadingsFromMarkdown(markdown) {
10
11
  const headings = [];
11
12
  for (const m of markdown.matchAll(/^(#{1,6}) ([^\n]+)$/gm)) {
@@ -50,26 +51,32 @@ ${contentPage.markdown}`;
50
51
  updatedAt: lastUpdated2
51
52
  });
52
53
  }
53
- logger.debug(`[markdown.prerender] Fetching HTML for ${path}`);
54
- const response = await event.fetch(path, { signal: AbortSignal.timeout(3e4) }).catch((err) => {
55
- if (err?.name === "TimeoutError" || err?.name === "AbortError") {
56
- throw createError({
57
- statusCode: 504,
58
- statusMessage: "Gateway Timeout",
59
- message: `Timed out fetching HTML for ${path}`
54
+ let html = consumePrerenderedHtml(path);
55
+ if (html) {
56
+ logger.debug(`[markdown.prerender] Reusing prerendered HTML for ${path} (${html.length} bytes)`);
57
+ } else {
58
+ logger.debug(`[markdown.prerender] Fetching HTML for ${path}`);
59
+ const response = await event.fetch(path, { signal: AbortSignal.timeout(3e4) }).catch((err) => {
60
+ if (err?.name === "TimeoutError" || err?.name === "AbortError") {
61
+ throw createError({
62
+ statusCode: 504,
63
+ statusMessage: "Gateway Timeout",
64
+ message: `Timed out fetching HTML for ${path}`
65
+ });
66
+ }
67
+ throw err;
68
+ });
69
+ if (!response.ok) {
70
+ return createError({
71
+ statusCode: response.status,
72
+ statusMessage: response.statusText,
73
+ message: `Failed to fetch HTML for ${path}`
60
74
  });
61
75
  }
62
- throw err;
63
- });
64
- if (!response.ok) {
65
- return createError({
66
- statusCode: response.status,
67
- statusMessage: response.statusText,
68
- message: `Failed to fetch HTML for ${path}`
69
- });
76
+ html = await response.text();
77
+ logger.debug(`[markdown.prerender] Fetched HTML for ${path} (${html.length} bytes)`);
78
+ consumePrerenderedHtml(path);
70
79
  }
71
- const html = await response.text();
72
- logger.debug(`[markdown.prerender] Fetched HTML for ${path} (${html.length} bytes)`);
73
80
  if (html.includes("__NUXT_ERROR__") || html.includes("nuxt-error-page")) {
74
81
  return createError({
75
82
  statusCode: 404,
@@ -0,0 +1,2 @@
1
+ import type { NitroApp } from 'nitropack/types';
2
+ export default function htmlCapturePlugin(nitroApp: NitroApp): void;
@@ -0,0 +1,10 @@
1
+ import { storePrerenderedHtml } from "../utils/prerender-html.js";
2
+ export default function htmlCapturePlugin(nitroApp) {
3
+ if (!import.meta.prerender)
4
+ return;
5
+ nitroApp.hooks.hook("render:response", (response, { event }) => {
6
+ if (typeof response.body !== "string" || !String(response.headers?.["content-type"] || "").includes("text/html"))
7
+ return;
8
+ storePrerenderedHtml(event.path, response.body);
9
+ });
10
+ }
@@ -0,0 +1,2 @@
1
+ export declare function storePrerenderedHtml(path: string, html: string): void;
2
+ export declare function consumePrerenderedHtml(path: string): string | undefined;
@@ -0,0 +1,13 @@
1
+ const htmlByPath = /* @__PURE__ */ new Map();
2
+ function normalizeHtmlCachePath(path) {
3
+ return path.replace(/\/+$/, "") || "/";
4
+ }
5
+ export function storePrerenderedHtml(path, html) {
6
+ htmlByPath.set(normalizeHtmlCachePath(path), html);
7
+ }
8
+ export function consumePrerenderedHtml(path) {
9
+ const key = normalizeHtmlCachePath(path);
10
+ const html = htmlByPath.get(key);
11
+ htmlByPath.delete(key);
12
+ return html;
13
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-ai-ready",
3
3
  "type": "module",
4
- "version": "1.5.3",
4
+ "version": "1.5.5",
5
5
  "description": "Best practice AI & LLM discoverability for Nuxt sites.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -57,15 +57,15 @@
57
57
  }
58
58
  },
59
59
  "dependencies": {
60
- "@mdream/js": "^1.4.1",
61
- "@nuxt/kit": "^4.4.8",
60
+ "@mdream/js": "^1.5.2",
61
+ "@nuxt/kit": "^4.5.0",
62
62
  "citty": "^0.2.2",
63
63
  "consola": "^3.4.2",
64
64
  "defu": "^6.1.7",
65
65
  "drizzle-orm": "^0.45.2",
66
- "mdream": "^1.4.1",
66
+ "mdream": "^1.5.2",
67
67
  "nuxt-site-config": "^4.1.1",
68
- "nuxtseo-shared": "^5.3.1",
68
+ "nuxtseo-shared": "^5.3.2",
69
69
  "pathe": "^2.0.3",
70
70
  "pkg-types": "^2.3.1",
71
71
  "site-config-stack": "^4.1.1",
@@ -74,39 +74,39 @@
74
74
  },
75
75
  "devDependencies": {
76
76
  "@antfu/eslint-config": "^9.1.0",
77
- "@arethetypeswrong/cli": "^0.18.4",
77
+ "@arethetypeswrong/cli": "^0.18.5",
78
78
  "@libsql/client": "^0.17.4",
79
- "@nuxt/content": "^3.14.0",
79
+ "@nuxt/content": "^3.15.0",
80
80
  "@nuxt/module-builder": "^1.0.2",
81
81
  "@nuxt/test-utils": "^4.0.3",
82
82
  "@nuxtjs/eslint-config-typescript": "^12.1.0",
83
- "@nuxtjs/mcp-toolkit": "^0.17.2",
83
+ "@nuxtjs/mcp-toolkit": "^0.18.0",
84
84
  "@nuxtjs/robots": "^6.1.2",
85
85
  "@nuxtjs/sitemap": "^8.2.2",
86
86
  "@types/better-sqlite3": "^7.6.13",
87
- "@vitest/coverage-v8": "^4.1.9",
87
+ "@vitest/coverage-v8": "^4.1.10",
88
88
  "@vue/test-utils": "^2.4.11",
89
89
  "@vueuse/nuxt": "^14.3.0",
90
90
  "better-sqlite3": "^12.11.1",
91
91
  "bumpp": "^11.1.0",
92
- "eslint": "^10.6.0",
92
+ "eslint": "^10.7.0",
93
93
  "eslint-plugin-harlanzw": "^0.17.0",
94
- "execa": "^9.6.1",
94
+ "execa": "^10.0.0",
95
95
  "happy-dom": "^20.10.6",
96
96
  "nitropack": "^2.13.4",
97
- "nuxt": "^4.4.8",
97
+ "nuxt": "^4.5.0",
98
98
  "nuxt-site-config": "^4.1.1",
99
- "nuxtseo-layer-devtools": "^5.3.1",
99
+ "nuxtseo-layer-devtools": "^5.3.2",
100
100
  "playwright": "^1.61.1",
101
101
  "playwright-core": "^1.61.1",
102
102
  "tinyglobby": "^0.2.17",
103
103
  "typescript": "^6.0.3",
104
104
  "unbuild": "^3.6.1",
105
- "vitest": "^4.1.9",
106
- "vue": "^3.5.39",
107
- "vue-router": "^5.1.0",
108
- "vue-tsc": "^3.3.5",
109
- "wrangler": "^4.105.0",
105
+ "vitest": "^4.1.10",
106
+ "vue": "^3.5.40",
107
+ "vue-router": "^5.2.0",
108
+ "vue-tsc": "^3.3.7",
109
+ "wrangler": "^4.112.0",
110
110
  "zod": "^4.4.3"
111
111
  },
112
112
  "scripts": {