nuxt-ai-ready 1.5.4 → 1.5.6

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.4",
7
+ "version": "1.5.6",
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';
@@ -9,8 +9,9 @@ import { resolveNuxtContentVersion } from 'nuxtseo-shared/kit';
9
9
  import { readPackageJSON, resolvePackageJSON } from 'pkg-types';
10
10
  import { parseSitemapXml } from '@nuxtjs/sitemap/utils';
11
11
  import { colorize } from 'consola/utils';
12
- import { withBase } from 'ufo';
13
- import { toMarkdownPath } from '../dist/runtime/markdown-path.js';
12
+ import { withLeadingSlash, withBase } from 'ufo';
13
+ import { normalizePagePath, toMarkdownPath } from '../dist/runtime/markdown-path.js';
14
+ import { toLogicalRoute, toDeployedRoute } from '../dist/runtime/route-path.js';
14
15
  import { initSchema, computeContentHash, insertPage, queryAllPages, exportDbDump } from '../dist/runtime/server/db/shared.js';
15
16
  import { comparePageHashes, submitToIndexNowShared } from '../dist/runtime/server/utils/indexnow-shared.js';
16
17
  import { buildLlmsFullTxtHeader, formatPageForLlmsFullTxt } from '../dist/runtime/server/utils/llms-full.js';
@@ -25,7 +26,7 @@ const RE_HTML_MD_EXT = /\.(html|md)$/;
25
26
  const RE_INDEX_SUFFIX = /\/index$/;
26
27
  const RE_MD_EXT = /\.md$/;
27
28
  async function fetchPreviousMeta(siteUrl, indexNow) {
28
- const metaUrl = `${siteUrl}/__ai-ready/pages.meta.json`;
29
+ const metaUrl = withBase("/__ai-ready/pages.meta.json", siteUrl);
29
30
  logger.info(`Fetching previous build meta from ${metaUrl}`);
30
31
  const controller = new AbortController();
31
32
  const timeoutId = setTimeout(() => controller.abort(), BUILD_FETCH_TIMEOUT);
@@ -41,7 +42,7 @@ async function fetchPreviousMeta(siteUrl, indexNow) {
41
42
  }
42
43
  logger.info(`Previous build: ${prevMeta.pageCount} pages (buildId: ${prevMeta.buildId})`);
43
44
  if (indexNow) {
44
- const keyUrl = `${siteUrl}/${indexNow}.txt`;
45
+ const keyUrl = withBase(`/${indexNow}.txt`, siteUrl);
45
46
  const keyLive = await fetch(keyUrl, { signal: AbortSignal.timeout(5e3) }).then((r) => r.ok).catch(() => false);
46
47
  if (!keyLive) {
47
48
  logger.info("IndexNow key file not live yet - IndexNow submission will be skipped");
@@ -125,14 +126,18 @@ function resolveRouteLocale(route, i18n) {
125
126
  return matched ? matched.code : i18n.defaultLocale;
126
127
  }
127
128
  async function processMarkdownRoute(state, nuxt, route, parsed, lastmod, options) {
128
- const { markdown, title, description, headings, keywords, updatedAt: metaUpdatedAt } = parsed;
129
+ route = normalizePagePath(route);
130
+ const { title, description, headings, keywords, updatedAt: metaUpdatedAt } = parsed;
129
131
  let updatedAt = (lastmod instanceof Date ? lastmod.toISOString() : lastmod) || (/* @__PURE__ */ new Date()).toISOString();
130
132
  if (metaUpdatedAt) {
131
133
  const parsedDate = new Date(metaUpdatedAt);
132
134
  if (!Number.isNaN(parsedDate.getTime()))
133
135
  updatedAt = parsedDate.toISOString();
134
136
  }
135
- await nuxt.hooks.callHook("ai-ready:page:markdown", { route, markdown, title, description, headings });
137
+ const hookContext = { ...parsed, route };
138
+ await nuxt.hooks.callHook("ai-ready:page:markdown", hookContext);
139
+ const { markdown } = hookContext;
140
+ parsed.markdown = markdown;
136
141
  if (state.db) {
137
142
  const contentHash = await computeContentHash(markdown);
138
143
  await insertPage(state.db, {
@@ -147,7 +152,7 @@ async function processMarkdownRoute(state, nuxt, route, parsed, lastmod, options
147
152
  locale: resolveRouteLocale(route, state.i18n)
148
153
  });
149
154
  }
150
- if (state.llmsFullTxtPath && !options?.skipLlmsFullTxt) {
155
+ if (state.llmsFullTxtPath && !options?.skipLlmsFullTxt && markdown.trim()) {
151
156
  const pageContent = formatPageForLlmsFullTxt(route, title, description, markdown, state.siteInfo?.url);
152
157
  logger.debug(`Appending to llms-full.txt: ${route} (${(pageContent.length / 1024).toFixed(1)}kb)`);
153
158
  await appendFile(state.llmsFullTxtPath, pageContent, "utf-8");
@@ -157,7 +162,7 @@ async function processMarkdownRoute(state, nuxt, route, parsed, lastmod, options
157
162
  async function processSitemapEntry(state, nuxt, nitro, entry) {
158
163
  const loc = typeof entry === "string" ? entry : entry.loc;
159
164
  const lastmod = typeof entry === "string" ? void 0 : entry.lastmod;
160
- const route = loc.startsWith("http") ? new URL(loc).pathname : loc;
165
+ const route = toLogicalRoute(loc, nitro.options.baseURL);
161
166
  if (route.split("/").some((segment) => segment.startsWith("_"))) {
162
167
  return { crawled: false, skipped: true };
163
168
  }
@@ -165,7 +170,7 @@ async function processSitemapEntry(state, nuxt, nitro, entry) {
165
170
  return { crawled: false, skipped: true };
166
171
  }
167
172
  const mdRoute = toMarkdownPath(route);
168
- const mdUrl = withBase(mdRoute, nitro.options.baseURL);
173
+ const mdUrl = toDeployedRoute(mdRoute, nitro.options.baseURL);
169
174
  logger.debug(`Fetching markdown for ${route} \u2192 ${mdUrl}`);
170
175
  const res = await globalThis.$fetch(mdUrl, {
171
176
  headers: { "x-nitro-prerender": mdRoute },
@@ -288,7 +293,8 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, indexNo
288
293
  let initPromise = null;
289
294
  nitro.hooks.hook("prerender:generate", async (route) => {
290
295
  if (route.error) {
291
- const pageRoute2 = route.route.replace(RE_HTML_MD_EXT, "").replace(RE_INDEX_SUFFIX, "") || "/";
296
+ const routePath = route.fileName || route.route;
297
+ const pageRoute2 = withLeadingSlash(routePath).replace(RE_HTML_MD_EXT, "").replace(RE_INDEX_SUFFIX, "").replace(RE_HTML_MD_EXT, "") || "/";
292
298
  state.errorRoutes.add(pageRoute2);
293
299
  logger.debug(`Detected error page: ${pageRoute2}`);
294
300
  return;
@@ -566,6 +572,37 @@ async function detectI18n(opts = {}) {
566
572
  return toRuntimeI18nConfig(auto);
567
573
  }
568
574
 
575
+ function escapeRegExp(value) {
576
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
577
+ }
578
+ function ensureStaticHeader(contents, route, name, value) {
579
+ const eol = contents.includes("\r\n") ? "\r\n" : "\n";
580
+ const routePattern = new RegExp(`^${escapeRegExp(route)}[\\t ]*\\r?$`, "gm");
581
+ const routeMatches = [...contents.matchAll(routePattern)];
582
+ const routeMatch = routeMatches.at(-1);
583
+ if (!routeMatch || routeMatch.index === void 0) {
584
+ const separator = contents.length === 0 ? "" : contents.endsWith(`${eol}${eol}`) ? "" : contents.endsWith(eol) ? eol : `${eol}${eol}`;
585
+ return `${contents}${separator}${route}${eol} ${name}: ${value}${eol}`;
586
+ }
587
+ const routeLineEnd = routeMatch.index + routeMatch[0].length;
588
+ const blockStart = contents[routeLineEnd] === "\n" ? routeLineEnd + 1 : routeLineEnd;
589
+ const nextBlockPattern = /^(?![\t ]|\r?$).+/gm;
590
+ nextBlockPattern.lastIndex = blockStart;
591
+ const nextBlock = nextBlockPattern.exec(contents);
592
+ const blockEnd = nextBlock?.index ?? contents.length;
593
+ const block = contents.slice(blockStart, blockEnd);
594
+ const escapedName = escapeRegExp(name);
595
+ const existingHeaderPattern = new RegExp(
596
+ `^[\\t ]+(?:${escapedName}[\\t ]*:|![\\t ]*${escapedName}[\\t ]*\\r?$)`,
597
+ "im"
598
+ );
599
+ if (existingHeaderPattern.test(block)) {
600
+ return contents;
601
+ }
602
+ const prefix = blockStart === routeLineEnd ? eol : "";
603
+ return `${contents.slice(0, blockStart)}${prefix} ${name}: ${value}${eol}${contents.slice(blockStart)}`;
604
+ }
605
+
569
606
  const module$1 = defineNuxtModule({
570
607
  meta: {
571
608
  name: "nuxt-ai-ready",
@@ -682,6 +719,8 @@ const module$1 = defineNuxtModule({
682
719
  robotsOpts.groups = groups;
683
720
  const group = {
684
721
  userAgent: "*",
722
+ // Preserve nuxt-robots' default wildcard rule so the injected group remains valid.
723
+ disallow: [""],
685
724
  contentSignal: [`ai-train=${config.contentSignal.aiTrain ? "yes" : "no"}`, `search=${config.contentSignal.search ? "yes" : "no"}`, `ai-input=${config.contentSignal.aiInput ? "yes" : "no"}`]
686
725
  };
687
726
  if (config.contentSignal.contentUsage !== false)
@@ -690,7 +729,7 @@ const module$1 = defineNuxtModule({
690
729
  }
691
730
  registerTypeTemplates();
692
731
  const defaultLlmsTxtSections = [];
693
- const llmsFullRoute = withSiteUrl("llms-full.txt");
732
+ const llmsFullRoute = withSiteUrl("llms-full.txt", { withBase: true });
694
733
  defaultLlmsTxtSections.push({
695
734
  title: "LLM Resources",
696
735
  links: [
@@ -713,7 +752,7 @@ const module$1 = defineNuxtModule({
713
752
  });
714
753
  const mcpLink = {
715
754
  title: "MCP",
716
- href: withSiteUrl(nuxt.options.mcp !== false && nuxt.options.mcp?.route || "/mcp"),
755
+ href: withSiteUrl(nuxt.options.mcp !== false && nuxt.options.mcp?.route || "/mcp", { withBase: true }),
717
756
  description: "Model Context Protocol server endpoint for AI agent integration."
718
757
  };
719
758
  if (defaultLlmsTxtSections[0]) {
@@ -944,6 +983,7 @@ export async function lookupContentPage(event, path) {
944
983
  i18n: i18nConfig,
945
984
  ftsTokenizer
946
985
  };
986
+ addServerPlugin(resolve("./runtime/server/plugins/html-capture.prerender"));
947
987
  addServerHandler({
948
988
  middleware: true,
949
989
  handler: resolve("./runtime/server/middleware/markdown.prerender")
@@ -1000,7 +1040,7 @@ export async function lookupContentPage(event, path) {
1000
1040
  const siteConfig = useSiteConfig();
1001
1041
  setupPrerenderHandler(config, buildDbPath, {
1002
1042
  name: siteConfig.name,
1003
- url: siteConfig.url,
1043
+ url: siteConfig.url ? withSiteUrl("/", { withBase: true }) : void 0,
1004
1044
  description: siteConfig.description
1005
1045
  }, mergedLlmsTxt, indexNow, { ftsTokenizer, i18n: i18nConfig });
1006
1046
  }
@@ -1012,20 +1052,29 @@ export async function lookupContentPage(event, path) {
1012
1052
  icon: "carbon:ai-label"
1013
1053
  }, resolve, nuxt);
1014
1054
  nuxt.options.nitro.routeRules = nuxt.options.nitro.routeRules || {};
1015
- nuxt.options.nitro.routeRules["/llms.txt"] = { headers: { "Content-Type": "text/plain; charset=utf-8" } };
1016
- nuxt.options.nitro.routeRules["/llms-full.txt"] = { headers: { "Content-Type": "text/plain; charset=utf-8" } };
1055
+ for (const route of ["/llms.txt", "/llms-full.txt"]) {
1056
+ nuxt.options.nitro.routeRules[route] = defu(
1057
+ nuxt.options.nitro.routeRules[route],
1058
+ { headers: { "Content-Type": "text/plain; charset=utf-8" } }
1059
+ );
1060
+ }
1017
1061
  nuxt.hooks.hook("nitro:build:before", (nitro) => {
1018
1062
  nitro.hooks.hook("compiled", async () => {
1019
1063
  const headersPath = join(nitro.options.output.publicDir, "_headers");
1020
1064
  logger.debug(`Checking for _headers file: ${headersPath}`);
1021
1065
  const exists = await access(headersPath).then(() => true).catch(() => false);
1022
1066
  if (exists) {
1023
- logger.debug(`Appending .md charset header to _headers`);
1024
- await appendFile(headersPath, `
1025
- /*.md
1026
- Content-Type: text/markdown; charset=utf-8
1027
- `);
1028
- logger.debug("Appended .md charset header to _headers");
1067
+ const headers = await readFile(headersPath, "utf8");
1068
+ const mergedHeaders = ensureStaticHeader(
1069
+ headers,
1070
+ "/*.md",
1071
+ "Content-Type",
1072
+ "text/markdown; charset=utf-8"
1073
+ );
1074
+ if (mergedHeaders !== headers) {
1075
+ await writeFile(headersPath, mergedHeaders);
1076
+ logger.debug("Merged .md charset header into _headers");
1077
+ }
1029
1078
  }
1030
1079
  });
1031
1080
  });
@@ -1,16 +1,18 @@
1
1
  import { useRuntimeConfig } from "nitropack/runtime";
2
2
  import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
3
+ import { withSiteTrailingSlash, withSiteUrl } from "#site-config/server/composables/utils";
3
4
  import { normalizeLlmsTxtConfig } from "./llms-txt-format.js";
5
+ import { normalizePersistedRoute, toDeployedRoute, toLogicalRoute } from "./route-path.js";
4
6
  import { queryPages } from "./server/db/queries.js";
5
7
  import { logger } from "./server/logger.js";
6
8
  import { localePath, resolveLocaleFromRoute } from "./server/utils/i18n.js";
7
9
  import { fetchSitemapUrls } from "./server/utils/sitemap.js";
8
10
  export { normalizeLlmsTxtConfig };
9
- function formatAvailableLanguagesSection(i18n, pageCounts) {
11
+ function formatAvailableLanguagesSection(i18n, pageCounts, resolvePath) {
10
12
  const lines = ["## Available Languages on Website", ""];
11
13
  for (const locale of i18n.locales) {
12
14
  const isDefault = locale.code === i18n.defaultLocale;
13
- const prefix = localePath("/", locale.code, i18n);
15
+ const prefix = resolvePath(localePath("/", locale.code, i18n));
14
16
  const count = pageCounts.get(locale.code) ?? 0;
15
17
  const display = locale.nativeName ? `${locale.nativeName} (${locale.code})` : locale.name ? `${locale.name} (${locale.code})` : locale.code;
16
18
  const suffix = isDefault ? "Content included below" : "Visit website for content";
@@ -102,10 +104,11 @@ function formatPagesWithGroups(pages) {
102
104
  }
103
105
  urlsInCurrentGroup++;
104
106
  const descText = page.description ? `: ${page.description.substring(0, 160)}${page.description.length > 160 ? "..." : ""}` : "";
107
+ const href = page.href ?? page.pathname;
105
108
  if (page.title && page.title !== page.pathname)
106
- lines.push(`- [${page.title}](${page.pathname})${descText}`);
109
+ lines.push(`- [${page.title}](${href})${descText}`);
107
110
  else
108
- lines.push(`- ${page.pathname}${descText}`);
111
+ lines.push(`- ${href}${descText}`);
109
112
  }
110
113
  return lines;
111
114
  }
@@ -116,26 +119,39 @@ export async function buildLlmsTxt(event) {
116
119
  const siteConfig = getSiteConfig(event);
117
120
  const llmsTxtConfig = aiReadyConfig.llmsTxt;
118
121
  const i18n = aiReadyConfig.i18n;
122
+ const baseURL = runtimeConfig.app.baseURL;
123
+ const resolvePath = (path) => withSiteTrailingSlash(event, toDeployedRoute(path, baseURL));
124
+ const resolveUrl = (path) => withSiteUrl(event, toDeployedRoute(path, baseURL));
125
+ const canonicalSiteUrl = siteConfig.url ? resolveUrl("/") : void 0;
119
126
  const parts = [];
120
- parts.push(`# ${siteConfig.name || siteConfig.url}`);
127
+ parts.push(`# ${siteConfig.name || canonicalSiteUrl}`);
121
128
  if (siteConfig.description) {
122
129
  parts.push(`
123
130
  > ${siteConfig.description}`);
124
131
  }
125
- if (siteConfig.url) {
132
+ if (canonicalSiteUrl) {
126
133
  parts.push(`
127
- Canonical Origin: ${siteConfig.url}`);
134
+ Canonical Origin: ${canonicalSiteUrl}`);
128
135
  }
129
136
  parts.push("");
130
- const sections = llmsTxtConfig.sections ? [...llmsTxtConfig.sections] : [];
137
+ const sections = llmsTxtConfig.sections?.map((section) => ({
138
+ ...section,
139
+ links: section.links ? [...section.links] : void 0
140
+ })) ?? [];
131
141
  if (sections[0]?.links) {
132
- if (sitemapConfig?.sitemaps) {
133
- const sitemapRoutes = Object.values(sitemapConfig.sitemaps).map((s) => s.sitemapName);
134
- for (const name of sitemapRoutes) {
135
- sections[0].links.push({ title: name, href: `/${name}`, description: "XML sitemap for search engines and crawlers." });
136
- }
142
+ const sitemapRoutes = sitemapConfig?.sitemaps ? Object.values(sitemapConfig.sitemaps).map((s) => s.sitemapName) : ["sitemap.xml"];
143
+ for (const name of sitemapRoutes) {
144
+ sections[0].links.push({
145
+ title: name,
146
+ href: resolveUrl(`/${name}`),
147
+ description: "XML sitemap for search engines and crawlers."
148
+ });
137
149
  }
138
- sections[0].links.push({ title: "robots.txt", href: "/robots.txt", description: "Crawler rules and permissions." });
150
+ sections[0].links.push({
151
+ title: "robots.txt",
152
+ href: resolveUrl("/robots.txt"),
153
+ description: "Crawler rules and permissions."
154
+ });
139
155
  }
140
156
  const normalizedContent = normalizeLlmsTxtConfig({ ...llmsTxtConfig, sections });
141
157
  if (normalizedContent) {
@@ -157,23 +173,29 @@ Canonical Origin: ${siteConfig.url}`);
157
173
  );
158
174
  }
159
175
  const urls = await urlsPromise;
176
+ const sitemapPaths = urls.map((url) => toLogicalRoute(url.loc, baseURL));
177
+ const sitemapPathSet = new Set(sitemapPaths);
160
178
  const seenPaths = /* @__PURE__ */ new Set();
161
- const errorSet = new Set(errorPages.map((p) => p.route));
179
+ const errorSet = new Set(errorPages.map((p) => normalizePersistedRoute(p.route, sitemapPathSet, baseURL)));
162
180
  const prerendered = [];
163
181
  for (const page of pages) {
164
- seenPaths.add(page.route);
165
- prerendered.push({ pathname: page.route, title: page.title, description: page.description, locale: page.locale });
182
+ const pathname = normalizePersistedRoute(page.route, sitemapPathSet, baseURL);
183
+ if (seenPaths.has(pathname))
184
+ continue;
185
+ seenPaths.add(pathname);
186
+ prerendered.push({ pathname, title: page.title, description: page.description, locale: page.locale });
166
187
  }
167
188
  const devModeHint = import.meta.dev && prerendered.length === 0 ? " (dev mode - run `nuxi generate` for page titles)" : "";
168
189
  const other = [];
169
- for (const url of urls) {
170
- const pathname = url.loc.startsWith("/") ? url.loc : new URL(url.loc).pathname;
190
+ for (const pathname of sitemapPaths) {
171
191
  if (!seenPaths.has(pathname) && !errorSet.has(pathname)) {
172
192
  const locale = i18n ? resolveLocaleFromRoute(pathname, i18n).locale : void 0;
173
193
  other.push({ pathname, locale });
174
194
  seenPaths.add(pathname);
175
195
  }
176
196
  }
197
+ for (const page of [...prerendered, ...other])
198
+ page.href = resolvePath(page.pathname);
177
199
  if (i18n) {
178
200
  const pageCounts = /* @__PURE__ */ new Map();
179
201
  for (const locale of i18n.locales) pageCounts.set(locale.code, 0);
@@ -181,7 +203,7 @@ Canonical Origin: ${siteConfig.url}`);
181
203
  const code = p.locale || resolveLocaleFromRoute(p.pathname, i18n).locale;
182
204
  pageCounts.set(code, (pageCounts.get(code) ?? 0) + 1);
183
205
  }
184
- parts.push(...formatAvailableLanguagesSection(i18n, pageCounts));
206
+ parts.push(...formatAvailableLanguagesSection(i18n, pageCounts, resolvePath));
185
207
  parts.push("");
186
208
  }
187
209
  const isDefaultLocale = (item) => {
@@ -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
  }
@@ -0,0 +1,10 @@
1
+ /** Convert a sitemap URL (which includes app.baseURL) to a logical route. */
2
+ export declare function toLogicalRoute(pathOrUrl: string, baseURL: string): string;
3
+ /** Apply app.baseURL to a logical route exactly once. */
4
+ export declare function toDeployedRoute(route: string, baseURL: string): string;
5
+ /**
6
+ * Normalize rows written by older releases, which stored deployed paths.
7
+ * A sitemap match disambiguates those rows from real logical routes whose
8
+ * first segment happens to match app.baseURL.
9
+ */
10
+ export declare function normalizePersistedRoute(route: string, sitemapRoutes: ReadonlySet<string>, baseURL: string): string;
@@ -0,0 +1,16 @@
1
+ import { joinURL, parseURL, withLeadingSlash, withoutBase } from "ufo";
2
+ import { normalizePagePath } from "./markdown-path.js";
3
+ export function toLogicalRoute(pathOrUrl, baseURL) {
4
+ const pathname = withLeadingSlash(parseURL(pathOrUrl).pathname);
5
+ return normalizePagePath(withoutBase(pathname, baseURL));
6
+ }
7
+ export function toDeployedRoute(route, baseURL) {
8
+ return joinURL(baseURL, withLeadingSlash(route));
9
+ }
10
+ export function normalizePersistedRoute(route, sitemapRoutes, baseURL) {
11
+ const normalizedRoute = normalizePagePath(withLeadingSlash(parseURL(route).pathname));
12
+ if (sitemapRoutes.has(normalizedRoute))
13
+ return normalizedRoute;
14
+ const logicalRoute = toLogicalRoute(normalizedRoute, baseURL);
15
+ return sitemapRoutes.has(logicalRoute) ? logicalRoute : normalizedRoute;
16
+ }
@@ -57,17 +57,17 @@ async function getPrerenderDb() {
57
57
  const excludeErrors = sql.includes("is_error = 0");
58
58
  const includeMarkdown = wantsMarkdown(sql);
59
59
  if (isErrorQuery) {
60
- return pages.filter((p) => errorRoutes.has(p.route)).map((p) => ({
61
- route: p.route,
62
- title: p.title,
63
- description: p.description,
64
- ...includeMarkdown ? { markdown: p.markdown } : {},
65
- headings: p.headings,
66
- keywords: JSON.stringify(p.keywords),
67
- updated_at: p.updatedAt,
60
+ return [...errorRoutes].map((route) => ({
61
+ route,
62
+ title: "",
63
+ description: "",
64
+ ...includeMarkdown ? { markdown: "" } : {},
65
+ headings: "[]",
66
+ keywords: "[]",
67
+ updated_at: "",
68
68
  is_error: 1,
69
69
  indexed: 0,
70
- locale: p.locale || ""
70
+ locale: ""
71
71
  }));
72
72
  }
73
73
  const filtered = excludeErrors ? pages.filter((p) => !errorRoutes.has(p.route)) : pages;
@@ -1,6 +1,7 @@
1
1
  import { createError, defineEventHandler, getHeader, sendRedirect, setHeader } from "h3";
2
2
  import { useRuntimeConfig } from "nitropack/runtime";
3
3
  import { withSiteUrl } from "#site-config/server/composables/utils";
4
+ import { toDeployedRoute } from "../../route-path.js";
4
5
  import { queryPages } from "../db/queries.js";
5
6
  import { logger } from "../logger.js";
6
7
  import { convertHtmlToMarkdown, extractLastUpdated, getMarkdownRenderInfo, toMarkdownPath } from "../utils.js";
@@ -23,7 +24,7 @@ function setMarkdownHeaders(event, path, config, resolveUrl) {
23
24
  setHeader(event, "cache-control", cacheControl);
24
25
  }
25
26
  }
26
- function notFoundMarkdown(canonicalUrl, path, config) {
27
+ function notFoundMarkdown(canonicalUrl, path, config, resolveUrl) {
27
28
  const body = [
28
29
  `# Page not found`,
29
30
  ``,
@@ -31,14 +32,14 @@ function notFoundMarkdown(canonicalUrl, path, config) {
31
32
  ``,
32
33
  `Try one of these resources:`,
33
34
  ``,
34
- `- [Sitemap](/sitemap.xml)`,
35
- `- [llms.txt](/llms.txt)`,
36
- `- [llms-full.txt](/llms-full.txt)`,
35
+ `- [Sitemap](${resolveUrl("/sitemap.xml")})`,
36
+ `- [llms.txt](${resolveUrl("/llms.txt")})`,
37
+ `- [llms-full.txt](${resolveUrl("/llms-full.txt")})`,
37
38
  ``
38
39
  ].join("\n");
39
40
  const i18n = config.i18n;
40
41
  const locale = i18n ? resolveLocaleFromRoute(path, i18n).locale : void 0;
41
- const alternates = i18n ? computeLocaleAlternates(path, i18n).map((a) => ({ hreflang: a.hreflang, href: a.path })) : void 0;
42
+ const alternates = i18n ? computeLocaleAlternates(path, i18n).map((a) => ({ hreflang: a.hreflang, href: resolveUrl(a.path) })) : void 0;
42
43
  const frontmatter = buildFrontmatter({
43
44
  title: "Page not found",
44
45
  description: `No content is available at ${path}.`,
@@ -64,15 +65,18 @@ export default defineEventHandler(async (event) => {
64
65
  });
65
66
  }
66
67
  const { path, isExplicit, negotiation } = renderInfo;
67
- const config = useRuntimeConfig(event)["nuxt-ai-ready"];
68
- const resolveUrl = (path2) => withSiteUrl(event, path2);
68
+ const runtimeConfig = useRuntimeConfig(event);
69
+ const config = runtimeConfig["nuxt-ai-ready"];
70
+ const baseURL = runtimeConfig.app.baseURL;
71
+ const resolvePath = (path2) => toDeployedRoute(path2, baseURL);
72
+ const resolveUrl = (path2) => withSiteUrl(event, resolvePath(path2));
69
73
  const canonicalUrl = resolveUrl(path);
70
74
  if (negotiation === "html") {
71
75
  setNegotiationHeaders(event, path, config, resolveUrl);
72
76
  return;
73
77
  }
74
78
  if (!isExplicit) {
75
- return sendRedirect(event, toMarkdownPath(path), 307);
79
+ return sendRedirect(event, resolvePath(toMarkdownPath(path)), 307);
76
80
  }
77
81
  const contentPage = await tryGetContentMarkdown(event, path).catch((e) => {
78
82
  logger.debug(`[markdown] Content lookup failed for ${path}`, e);
@@ -91,7 +95,7 @@ export default defineEventHandler(async (event) => {
91
95
  ${contentPage.markdown}`;
92
96
  }
93
97
  logger.debug(`[markdown] Fetching HTML for ${path}`);
94
- const response = await event.fetch(path, {
98
+ const response = await event.fetch(resolvePath(path), {
95
99
  headers: { [INTERNAL_HEADER]: "1" },
96
100
  redirect: "manual"
97
101
  }).catch((e) => {
@@ -100,7 +104,7 @@ ${contentPage.markdown}`;
100
104
  });
101
105
  if (!response) {
102
106
  setMarkdownHeaders(event, path, config, resolveUrl);
103
- return notFoundMarkdown(canonicalUrl, path, config);
107
+ return notFoundMarkdown(canonicalUrl, path, config, resolveUrl);
104
108
  }
105
109
  if (response.status >= 300 && response.status < 400) {
106
110
  const location = response.headers.get("location");
@@ -115,12 +119,12 @@ ${contentPage.markdown}`;
115
119
  }
116
120
  if (!response.ok) {
117
121
  setMarkdownHeaders(event, path, config, resolveUrl);
118
- return notFoundMarkdown(canonicalUrl, path, config);
122
+ return notFoundMarkdown(canonicalUrl, path, config, resolveUrl);
119
123
  }
120
124
  const contentType = response.headers.get("content-type") || "";
121
125
  if (!contentType.includes("text/html")) {
122
126
  setMarkdownHeaders(event, path, config, resolveUrl);
123
- return notFoundMarkdown(canonicalUrl, path, config);
127
+ return notFoundMarkdown(canonicalUrl, path, config, resolveUrl);
124
128
  }
125
129
  const html = await response.text();
126
130
  logger.debug(`[markdown] Fetched HTML for ${path} (${html.length} bytes)`);
@@ -1,11 +1,13 @@
1
1
  import { createError, defineEventHandler } from "h3";
2
2
  import { useRuntimeConfig } from "nitropack/runtime";
3
3
  import { withSiteUrl } from "#site-config/server/composables/utils";
4
+ import { toDeployedRoute } from "../../route-path.js";
4
5
  import { logger } from "../logger.js";
5
6
  import { convertHtmlToMarkdown, extractLastUpdated, getMarkdownRenderInfo } from "../utils.js";
6
7
  import { tryGetContentMarkdown } from "../utils/content.js";
7
8
  import { buildFrontmatter } from "../utils/frontmatter.js";
8
9
  import { extractKeywords } from "../utils/keywords.js";
10
+ import { consumePrerenderedHtml } from "../utils/prerender-html.js";
9
11
  function extractHeadingsFromMarkdown(markdown) {
10
12
  const headings = [];
11
13
  for (const m of markdown.matchAll(/^(#{1,6}) ([^\n]+)$/gm)) {
@@ -25,8 +27,10 @@ export default defineEventHandler(async (event) => {
25
27
  if (!renderInfo || "notAcceptable" in renderInfo)
26
28
  return;
27
29
  const { path } = renderInfo;
28
- const runtimeConfig = useRuntimeConfig(event)["nuxt-ai-ready"];
29
- const canonicalUrl = withSiteUrl(event, path);
30
+ const fullRuntimeConfig = useRuntimeConfig(event);
31
+ const runtimeConfig = fullRuntimeConfig["nuxt-ai-ready"];
32
+ const deployedPath = toDeployedRoute(path, fullRuntimeConfig.app.baseURL);
33
+ const canonicalUrl = withSiteUrl(event, deployedPath);
30
34
  const contentPage = await tryGetContentMarkdown(event, path).catch(() => null);
31
35
  if (contentPage) {
32
36
  logger.debug(`[markdown.prerender] Using content source for ${path} (${contentPage.markdown.length} bytes)`);
@@ -50,26 +54,32 @@ ${contentPage.markdown}`;
50
54
  updatedAt: lastUpdated2
51
55
  });
52
56
  }
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}`
57
+ let html = consumePrerenderedHtml(path);
58
+ if (html) {
59
+ logger.debug(`[markdown.prerender] Reusing prerendered HTML for ${path} (${html.length} bytes)`);
60
+ } else {
61
+ logger.debug(`[markdown.prerender] Fetching HTML for ${path}`);
62
+ const response = await event.fetch(deployedPath, { signal: AbortSignal.timeout(3e4) }).catch((err) => {
63
+ if (err?.name === "TimeoutError" || err?.name === "AbortError") {
64
+ throw createError({
65
+ statusCode: 504,
66
+ statusMessage: "Gateway Timeout",
67
+ message: `Timed out fetching HTML for ${path}`
68
+ });
69
+ }
70
+ throw err;
71
+ });
72
+ if (!response.ok) {
73
+ return createError({
74
+ statusCode: response.status,
75
+ statusMessage: response.statusText,
76
+ message: `Failed to fetch HTML for ${path}`
60
77
  });
61
78
  }
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
- });
79
+ html = await response.text();
80
+ logger.debug(`[markdown.prerender] Fetched HTML for ${path} (${html.length} bytes)`);
81
+ consumePrerenderedHtml(path);
70
82
  }
71
- const html = await response.text();
72
- logger.debug(`[markdown.prerender] Fetched HTML for ${path} (${html.length} bytes)`);
73
83
  if (html.includes("__NUXT_ERROR__") || html.includes("nuxt-error-page")) {
74
84
  return createError({
75
85
  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
+ }
@@ -1,6 +1,8 @@
1
1
  import { eventHandler, sendIterable, setHeader, setResponseHeader } from "h3";
2
2
  import { useRuntimeConfig } from "nitropack/runtime";
3
3
  import { getSiteConfig } from "#site-config/server/composables";
4
+ import { withSiteUrl } from "#site-config/server/composables/utils";
5
+ import { toDeployedRoute } from "../../route-path.js";
4
6
  import { countPages, streamPages } from "../db/queries.js";
5
7
  import { buildLlmsFullTxtHeader, formatPageForLlmsFullTxt } from "../utils/llms-full.js";
6
8
  export default eventHandler(async (event) => {
@@ -8,12 +10,15 @@ export default eventHandler(async (event) => {
8
10
  setHeader(event, "Content-Type", "text/plain; charset=utf-8");
9
11
  return "# llms-full.txt\n\nThis file is generated during prerender.";
10
12
  }
11
- const config = useRuntimeConfig()["nuxt-ai-ready"];
13
+ const runtimeConfig = useRuntimeConfig(event);
14
+ const config = runtimeConfig["nuxt-ai-ready"];
12
15
  const siteConfig = getSiteConfig(event);
16
+ const baseURL = runtimeConfig.app.baseURL;
17
+ const canonicalSiteUrl = siteConfig.url ? withSiteUrl(event, toDeployedRoute("/", baseURL)) : void 0;
13
18
  const header = buildLlmsFullTxtHeader(
14
19
  {
15
20
  name: siteConfig.name,
16
- url: siteConfig.url,
21
+ url: canonicalSiteUrl,
17
22
  description: siteConfig.description
18
23
  },
19
24
  config.llmsTxt
@@ -1,3 +1,4 @@
1
+ import { joinURL } from "ufo";
1
2
  export const INDEXNOW_HOSTS = ["api.indexnow.org", "www.bing.com"];
2
3
  export function getIndexNowEndpoints() {
3
4
  const testEndpoint = process.env.INDEXNOW_TEST_ENDPOINT;
@@ -8,12 +9,12 @@ export function getIndexNowEndpoints() {
8
9
  }
9
10
  export function buildIndexNowBody(routes, key, siteUrl) {
10
11
  const urlList = routes.map(
11
- (route) => route.startsWith("http") ? route : `${siteUrl}${route}`
12
+ (route) => route.startsWith("http") ? route : joinURL(siteUrl, route)
12
13
  );
13
14
  return {
14
15
  host: new URL(siteUrl).host,
15
16
  key,
16
- keyLocation: `${siteUrl}/${key}.txt`,
17
+ keyLocation: joinURL(siteUrl, `${key}.txt`),
17
18
  urlList
18
19
  };
19
20
  }
@@ -15,9 +15,11 @@ function resolveHeaderUrl(path, resolveUrl) {
15
15
  export function buildLinkHeader(path, variant, config, resolveUrl) {
16
16
  const parts = [];
17
17
  if (variant === "html") {
18
- parts.push(`<${encodePathForHeader(toMarkdownPath(path))}>; rel="alternate"; type="text/markdown"`);
18
+ const href = resolveHeaderUrl(toMarkdownPath(path), resolveUrl);
19
+ parts.push(`<${encodePathForHeader(href)}>; rel="alternate"; type="text/markdown"`);
19
20
  } else {
20
- parts.push(`<${encodePathForHeader(path)}>; rel="alternate"; type="text/html"`);
21
+ const href = resolveHeaderUrl(path, resolveUrl);
22
+ parts.push(`<${encodePathForHeader(href)}>; rel="alternate"; type="text/html"`);
21
23
  }
22
24
  if (config.i18n) {
23
25
  const alternates = computeLocaleAlternates(path, config.i18n);
@@ -1,9 +1,9 @@
1
+ import { joinURL } from "ufo";
1
2
  import { normalizeLlmsTxtConfig } from "../../llms-txt-format.js";
2
- const RE_TRAILING_SLASH = /\/$/;
3
3
  const RE_FRONTMATTER = /^---\n[\s\S]*?\n---\n*/;
4
4
  const RE_HEADING = /^(#{1,6}) ([^\n]+)$/gm;
5
5
  export function formatPageForLlmsFullTxt(route, title, description, markdown, siteUrl) {
6
- const canonicalUrl = siteUrl ? `${siteUrl.replace(RE_TRAILING_SLASH, "")}${route}` : route;
6
+ const canonicalUrl = siteUrl ? joinURL(siteUrl, route) : route;
7
7
  const heading = title && title !== route ? `### ${title}` : `### ${route}`;
8
8
  const content = markdown.replace(RE_FRONTMATTER, "").replace(RE_HEADING, (_, hashes, text) => `h${hashes.length}. ${text}`);
9
9
  const parts = [heading, ""];
@@ -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.4",
4
+ "version": "1.5.6",
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.3",
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.3",
67
67
  "nuxt-site-config": "^4.1.1",
68
- "nuxtseo-shared": "^5.3.2",
68
+ "nuxtseo-shared": "^5.3.3",
69
69
  "pathe": "^2.0.3",
70
70
  "pkg-types": "^2.3.1",
71
71
  "site-config-stack": "^4.1.1",
@@ -74,14 +74,14 @@
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
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",
84
- "@nuxtjs/robots": "^6.1.2",
83
+ "@nuxtjs/mcp-toolkit": "^0.18.0",
84
+ "@nuxtjs/robots": "^6.1.3",
85
85
  "@nuxtjs/sitemap": "^8.2.2",
86
86
  "@types/better-sqlite3": "^7.6.13",
87
87
  "@vitest/coverage-v8": "^4.1.10",
@@ -89,24 +89,24 @@
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",
95
- "happy-dom": "^20.10.6",
94
+ "execa": "^10.0.0",
95
+ "happy-dom": "^20.11.0",
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.2",
99
+ "nuxtseo-layer-devtools": "^5.3.3",
100
100
  "playwright": "^1.61.1",
101
101
  "playwright-core": "^1.61.1",
102
102
  "tinyglobby": "^0.2.17",
103
- "typescript": "^6.0.3",
103
+ "typescript": "6.0.3",
104
104
  "unbuild": "^3.6.1",
105
105
  "vitest": "^4.1.10",
106
- "vue": "^3.5.39",
107
- "vue-router": "^5.1.0",
108
- "vue-tsc": "^3.3.6",
109
- "wrangler": "^4.107.0",
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": {