nuxt-ai-ready 1.5.6 → 1.5.7

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.6",
7
+ "version": "1.5.7",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -616,7 +616,7 @@ const module$1 = defineNuxtModule({
616
616
  version: ">=6.0.0"
617
617
  },
618
618
  "@nuxtjs/sitemap": {
619
- version: ">=8.0.0"
619
+ version: ">=8.3.0"
620
620
  },
621
621
  "nuxt-site-config": {
622
622
  version: ">=3.2"
@@ -1,8 +1,19 @@
1
1
  /**
2
2
  * Pure formatting functions for llms.txt - no runtime dependencies
3
3
  */
4
+ import type { RuntimeI18nConfig } from './server/utils/i18n.js';
4
5
  import type { LlmsTxtConfig } from './types.js';
6
+ export declare function formatAvailableLanguagesSection(i18n: RuntimeI18nConfig, pageCounts: Map<string, number>, resolveHref?: (pathname: string) => string): string[];
7
+ interface LlmsTxtPageLink {
8
+ pathname: string;
9
+ href?: string;
10
+ title?: string;
11
+ description?: string;
12
+ }
13
+ export declare function formatLlmsTxtPageLink(page: LlmsTxtPageLink): string;
5
14
  /**
6
- * Normalize llms.txt structured configuration to markdown string
15
+ * Normalize llms.txt structured configuration to the llmstxt.org Markdown format.
16
+ * Preamble prose must appear before the first H2; every H2 body is a file list.
7
17
  */
8
18
  export declare function normalizeLlmsTxtConfig(config: LlmsTxtConfig): string;
19
+ export {};
@@ -1,39 +1,100 @@
1
- function normalizeLink(link) {
2
- const parts = [];
3
- parts.push(`- [${link.title}](${link.href})`);
4
- if (link.description)
5
- parts.push(` ${link.description}`);
6
- return parts.join("\n");
1
+ import { localePath } from "./server/utils/i18n.js";
2
+ const RE_INLINE_WHITESPACE = /\s+/g;
3
+ const RE_LINK_TITLE_BRACKET = /[[\]]/g;
4
+ const RE_LINK_HREF_UNSAFE = /[\s()]/g;
5
+ const RE_PREAMBLE_ATX_HEADING = /^( {0,3})(#{1,6})(?=\s)/gm;
6
+ function normalizeInlineText(value) {
7
+ return value.trim().replace(RE_INLINE_WHITESPACE, " ");
7
8
  }
8
- function normalizeSection(section, headingLevel = 2) {
9
- const prefix = "#".repeat(headingLevel);
10
- const parts = [];
11
- parts.push(`${prefix} ${section.title}`);
12
- parts.push("");
13
- if (section.description) {
14
- const descriptions = Array.isArray(section.description) ? section.description : [section.description];
15
- parts.push(...descriptions);
16
- parts.push("");
17
- }
9
+ function normalizeLinkTitle(value) {
10
+ return normalizeInlineText(value).replace(RE_LINK_TITLE_BRACKET, (bracket) => bracket === "[" ? "&#91;" : "&#93;");
11
+ }
12
+ function normalizeLinkHref(value) {
13
+ return value.trim().replace(RE_LINK_HREF_UNSAFE, (character) => {
14
+ if (character === "(")
15
+ return "%28";
16
+ if (character === ")")
17
+ return "%29";
18
+ return encodeURIComponent(character);
19
+ });
20
+ }
21
+ function normalizeDescriptions(value) {
22
+ return (Array.isArray(value) ? value : value ? [value] : []).filter((description) => description.trim()).map(normalizeInlineText);
23
+ }
24
+ function normalizePreambleBlocks(value) {
25
+ return (Array.isArray(value) ? value : value ? [value] : []).filter((block) => block.trim()).map((block) => block.trim().replace(RE_PREAMBLE_ATX_HEADING, "$1\\$2"));
26
+ }
27
+ function normalizeLink(link, descriptionPrefixes = []) {
28
+ const descriptions = [...descriptionPrefixes, link.description].filter((value) => Boolean(value?.trim())).map(normalizeInlineText);
29
+ const description = descriptions.join("; ");
30
+ return `- [${normalizeLinkTitle(link.title)}](${normalizeLinkHref(link.href)})${description ? `: ${description}` : ""}`;
31
+ }
32
+ function normalizeSection(section) {
33
+ const parts = [`## ${normalizeInlineText(section.title)}`];
18
34
  if (section.links?.length)
19
- parts.push(...section.links.map(normalizeLink));
35
+ parts.push("", ...section.links.map((link) => normalizeLink(link)));
20
36
  return parts.join("\n");
21
37
  }
22
- export function normalizeLlmsTxtConfig(config) {
38
+ function normalizePreamble(config) {
23
39
  const parts = [];
24
- const required = config.sections?.filter((s) => !s.optional) ?? [];
25
- const optional = config.sections?.filter((s) => s.optional) ?? [];
26
- if (required.length)
27
- parts.push(...required.map((s) => normalizeSection(s)));
28
- if (optional.length) {
29
- parts.push("## Optional");
30
- parts.push(...optional.map((s) => normalizeSection(s, 3)));
31
- }
32
40
  if (config.notes) {
33
- parts.push("## Notes");
34
- parts.push("");
35
- const notes = Array.isArray(config.notes) ? config.notes : [config.notes];
36
- parts.push(...notes);
41
+ const notes = normalizePreambleBlocks(config.notes);
42
+ if (notes.length)
43
+ parts.push(["**Notes:**", ...notes].join("\n\n"));
44
+ }
45
+ for (const section of config.sections?.filter((section2) => !section2.optional) ?? []) {
46
+ if (!section.description)
47
+ continue;
48
+ const descriptions = normalizePreambleBlocks(section.description);
49
+ if (descriptions.length)
50
+ parts.push([`**${normalizeInlineText(section.title)}:**`, ...descriptions].join("\n\n"));
37
51
  }
52
+ return parts;
53
+ }
54
+ function normalizeOptionalSections(sections) {
55
+ const links = sections.flatMap(
56
+ (section) => section.links?.map((link) => normalizeLink(link, [section.title, ...normalizeDescriptions(section.description)])) ?? []
57
+ );
58
+ if (!links.length)
59
+ return void 0;
60
+ return ["## Optional", "", ...links].join("\n");
61
+ }
62
+ function normalizeRequiredSections(sections) {
63
+ return sections.filter((section) => section.links?.length).map(normalizeSection);
64
+ }
65
+ export function formatAvailableLanguagesSection(i18n, pageCounts, resolveHref = (pathname) => pathname) {
66
+ const lines = ["## Available Languages on Website", ""];
67
+ for (const locale of i18n.locales) {
68
+ const isDefault = locale.code === i18n.defaultLocale;
69
+ const prefix = localePath("/", locale.code, i18n);
70
+ const count = pageCounts.get(locale.code) ?? 0;
71
+ const display = locale.nativeName ? `${locale.nativeName} (${locale.code})` : locale.name ? `${locale.name} (${locale.code})` : locale.code;
72
+ const suffix = isDefault ? "content included below" : "visit this language for content";
73
+ lines.push(normalizeLink({
74
+ title: display,
75
+ href: resolveHref(prefix),
76
+ description: `${count} pages; ${suffix}.`
77
+ }));
78
+ }
79
+ return lines;
80
+ }
81
+ export function formatLlmsTxtPageLink(page) {
82
+ const description = page.description?.trim().replace(RE_INLINE_WHITESPACE, " ");
83
+ const normalizedTitle = page.title ? normalizeInlineText(page.title) : "";
84
+ const title = normalizedTitle && normalizedTitle !== page.pathname ? normalizedTitle : page.pathname;
85
+ const href = (page.href || page.pathname).trim();
86
+ const truncatedDescription = description ? `${description.substring(0, 160)}${description.length > 160 ? "..." : ""}` : void 0;
87
+ return normalizeLink({ title, href, description: truncatedDescription });
88
+ }
89
+ export function normalizeLlmsTxtConfig(config) {
90
+ const required = config.sections?.filter((section) => !section.optional) ?? [];
91
+ const optional = config.sections?.filter((section) => section.optional) ?? [];
92
+ const parts = [
93
+ ...normalizePreamble(config),
94
+ ...normalizeRequiredSections(required)
95
+ ];
96
+ const optionalSection = normalizeOptionalSections(optional);
97
+ if (optionalSection)
98
+ parts.push(optionalSection);
38
99
  return parts.join("\n\n");
39
100
  }
@@ -1,25 +1,13 @@
1
1
  import { useRuntimeConfig } from "nitropack/runtime";
2
2
  import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
3
3
  import { withSiteTrailingSlash, withSiteUrl } from "#site-config/server/composables/utils";
4
- import { normalizeLlmsTxtConfig } from "./llms-txt-format.js";
4
+ import { formatAvailableLanguagesSection, formatLlmsTxtPageLink, normalizeLlmsTxtConfig } from "./llms-txt-format.js";
5
5
  import { normalizePersistedRoute, toDeployedRoute, toLogicalRoute } from "./route-path.js";
6
6
  import { queryPages } from "./server/db/queries.js";
7
7
  import { logger } from "./server/logger.js";
8
- import { localePath, resolveLocaleFromRoute } from "./server/utils/i18n.js";
8
+ import { resolveLocaleFromRoute } from "./server/utils/i18n.js";
9
9
  import { fetchSitemapUrls } from "./server/utils/sitemap.js";
10
10
  export { normalizeLlmsTxtConfig };
11
- function formatAvailableLanguagesSection(i18n, pageCounts, resolvePath) {
12
- const lines = ["## Available Languages on Website", ""];
13
- for (const locale of i18n.locales) {
14
- const isDefault = locale.code === i18n.defaultLocale;
15
- const prefix = resolvePath(localePath("/", locale.code, i18n));
16
- const count = pageCounts.get(locale.code) ?? 0;
17
- const display = locale.nativeName ? `${locale.nativeName} (${locale.code})` : locale.name ? `${locale.name} (${locale.code})` : locale.code;
18
- const suffix = isDefault ? "Content included below" : "Visit website for content";
19
- lines.push(`- ${display} - ${count} pages - ${prefix} - ${suffix}`);
20
- }
21
- return lines;
22
- }
23
11
  function getGroupPrefix(url, depth) {
24
12
  const segments = url.split("/").filter(Boolean);
25
13
  if (segments.length === 0)
@@ -103,12 +91,7 @@ function formatPagesWithGroups(pages) {
103
91
  urlsInCurrentGroup = 0;
104
92
  }
105
93
  urlsInCurrentGroup++;
106
- const descText = page.description ? `: ${page.description.substring(0, 160)}${page.description.length > 160 ? "..." : ""}` : "";
107
- const href = page.href ?? page.pathname;
108
- if (page.title && page.title !== page.pathname)
109
- lines.push(`- [${page.title}](${href})${descText}`);
110
- else
111
- lines.push(`- ${href}${descText}`);
94
+ lines.push(formatLlmsTxtPageLink(page));
112
95
  }
113
96
  return lines;
114
97
  }
@@ -17,5 +17,5 @@ export declare function hasAssets(event?: H3Event): boolean;
17
17
  * Falls back to $fetch with timeout to avoid self-fetch hangs on CF Workers.
18
18
  */
19
19
  export declare function fetchPublicAsset<T = unknown>(event: H3Event | undefined, path: string, options?: {
20
- responseType?: 'json' | 'text' | 'arrayBuffer';
20
+ responseType?: 'json' | 'text' | 'arrayBuffer' | 'stream';
21
21
  }): Promise<T | null>;
@@ -19,6 +19,8 @@ export async function fetchPublicAsset(event, path, options) {
19
19
  return response.text().catch(() => null);
20
20
  if (responseType === "arrayBuffer")
21
21
  return response.arrayBuffer().catch(() => null);
22
+ if (responseType === "stream")
23
+ return response.body;
22
24
  }
23
25
  return null;
24
26
  }
@@ -27,6 +29,6 @@ export async function fetchPublicAsset(event, path, options) {
27
29
  return globalThis.$fetch(path, {
28
30
  baseURL: "/",
29
31
  signal: controller.signal,
30
- responseType: responseType === "arrayBuffer" ? "arrayBuffer" : void 0
32
+ responseType: responseType === "arrayBuffer" || responseType === "stream" ? responseType : void 0
31
33
  }).catch(() => null).finally(() => clearTimeout(timeout));
32
34
  }
@@ -1,4 +1,4 @@
1
- import { isSitemapIndex, parseSitemapIndex, parseSitemapXml } from "@nuxtjs/sitemap/utils";
1
+ import { parseSitemapStream } from "@nuxtjs/sitemap/utils";
2
2
  import { useRuntimeConfig } from "nitropack/runtime";
3
3
  import { withLeadingSlash } from "ufo";
4
4
  import { logger } from "../logger.js";
@@ -26,26 +26,26 @@ export function hasMultipleSitemaps(event) {
26
26
  const sitemaps = getSitemapsFromConfig(event);
27
27
  return sitemaps.length > 1;
28
28
  }
29
- function normalizeUrls(urls) {
30
- return urls.map((entry) => {
31
- if (typeof entry === "string")
32
- return { loc: entry };
33
- const e = entry;
34
- return {
35
- loc: e.loc,
36
- lastmod: e.lastmod instanceof Date ? e.lastmod.toISOString() : e.lastmod
37
- };
38
- });
29
+ function normalizeUrl(entry) {
30
+ if (typeof entry === "string")
31
+ return { loc: entry };
32
+ return {
33
+ loc: entry.loc,
34
+ lastmod: entry.lastmod instanceof Date ? entry.lastmod.toISOString() : entry.lastmod
35
+ };
36
+ }
37
+ function isSitemapXmlInput(value) {
38
+ return typeof value === "string" || value instanceof Uint8Array || typeof value === "object" && value !== null && "getReader" in value;
39
39
  }
40
40
  export async function fetchSitemapByRoute(event, route, depth = 0) {
41
41
  const config = useRuntimeConfig(event)["nuxt-ai-ready"];
42
42
  const fetchRoute = withLeadingSlash(route);
43
43
  const usePublicAsset = config.sitemapPrerendered && hasAssets(event);
44
44
  logger.debug(`[sitemap] Fetching ${fetchRoute} via ${usePublicAsset ? "ASSETS.fetch" : event ? "event.$fetch" : "globalThis.$fetch"}`);
45
- let sitemapXml = null;
45
+ let sitemapInput = null;
46
46
  if (usePublicAsset) {
47
- sitemapXml = await fetchPublicAsset(event, fetchRoute, { responseType: "text" });
48
- if (!sitemapXml) {
47
+ sitemapInput = await fetchPublicAsset(event, fetchRoute, { responseType: "stream" });
48
+ if (!sitemapInput) {
49
49
  logger.warn(`[sitemap] Not found in ASSETS: ${fetchRoute}`);
50
50
  return { urls: [], error: "Not found in ASSETS" };
51
51
  }
@@ -53,38 +53,53 @@ export async function fetchSitemapByRoute(event, route, depth = 0) {
53
53
  try {
54
54
  const $fetch = event?.$fetch ?? globalThis.$fetch;
55
55
  const res = await $fetch(fetchRoute, {
56
- responseType: "text",
56
+ responseType: "stream",
57
57
  timeout: FETCH_TIMEOUT
58
58
  });
59
- if (!res || typeof res !== "string") {
59
+ if (!res) {
60
60
  logger.warn(`[sitemap] Empty response from ${fetchRoute}`);
61
61
  return { urls: [], error: "Empty response" };
62
62
  }
63
- sitemapXml = res;
63
+ if (!isSitemapXmlInput(res)) {
64
+ logger.warn(`[sitemap] Invalid response body from ${fetchRoute}`);
65
+ return { urls: [], error: "Invalid response body" };
66
+ }
67
+ sitemapInput = res;
64
68
  } catch (e) {
65
69
  const msg = e instanceof Error ? e.message : String(e);
66
70
  logger.warn(`[sitemap] Failed to fetch ${fetchRoute}: ${msg}`);
67
71
  return { urls: [], error: msg };
68
72
  }
69
73
  }
70
- logger.debug(`[sitemap] Parsing sitemap XML (${sitemapXml.length} bytes)`);
71
- if (isSitemapIndex(sitemapXml)) {
74
+ logger.debug(`[sitemap] Parsing sitemap XML stream`);
75
+ const urls = [];
76
+ const indexEntries = [];
77
+ let kind;
78
+ try {
79
+ for await (const parsed of parseSitemapStream(sitemapInput)) {
80
+ if (parsed._tag === "kind")
81
+ kind = parsed.kind;
82
+ else if (parsed._tag === "url")
83
+ urls.push(normalizeUrl(parsed.url));
84
+ else if (parsed._tag === "sitemap")
85
+ indexEntries.push(parsed.sitemap);
86
+ else
87
+ logger.warn(`[sitemap] ${fetchRoute}: ${parsed.warning.message}`);
88
+ }
89
+ } catch (e) {
90
+ const msg = e instanceof Error ? e.message : String(e);
91
+ logger.warn(`[sitemap] Failed to parse ${fetchRoute}: ${msg}`);
92
+ return { urls: [], error: msg };
93
+ }
94
+ if (kind === "index") {
72
95
  if (depth >= 3) {
73
96
  logger.warn(`[sitemap] Sitemap index nesting too deep at ${fetchRoute}, stopping`);
74
97
  return { urls: [] };
75
98
  }
76
- let index;
77
- try {
78
- index = await parseSitemapIndex(sitemapXml);
79
- } catch (e) {
80
- const msg = e instanceof Error ? e.message : String(e);
81
- logger.warn(`[sitemap] Failed to parse sitemap index ${fetchRoute}: ${msg}`);
82
- return { urls: [], error: msg };
83
- }
84
- logger.debug(`[sitemap] ${fetchRoute} is a sitemap index with ${index.entries.length} children`);
99
+ logger.debug(`[sitemap] ${fetchRoute} is a sitemap index with ${indexEntries.length} children`);
85
100
  const allUrls = [];
86
101
  const childErrors = [];
87
- for (const entry of index.entries) {
102
+ for (const entry of indexEntries) {
88
103
  const childRoute = entry.loc.startsWith("http") ? new URL(entry.loc).pathname : entry.loc;
89
104
  if (withLeadingSlash(childRoute) === fetchRoute)
90
105
  continue;
@@ -94,21 +109,12 @@ export async function fetchSitemapByRoute(event, route, depth = 0) {
94
109
  childErrors.push(`${withLeadingSlash(childRoute)}: ${error}`);
95
110
  }
96
111
  if (childErrors.length > 0) {
97
- const msg = `${childErrors.length}/${index.entries.length} child sitemaps failed (${childErrors.join("; ")})`;
112
+ const msg = `${childErrors.length}/${indexEntries.length} child sitemaps failed (${childErrors.join("; ")})`;
98
113
  logger.warn(`[sitemap] Sitemap index ${fetchRoute}: ${msg}`);
99
114
  return { urls: allUrls, error: msg };
100
115
  }
101
116
  return { urls: allUrls };
102
117
  }
103
- let result;
104
- try {
105
- result = await parseSitemapXml(sitemapXml);
106
- } catch (e) {
107
- const msg = e instanceof Error ? e.message : String(e);
108
- logger.warn(`[sitemap] Failed to parse ${fetchRoute}: ${msg}`);
109
- return { urls: [], error: msg };
110
- }
111
- const urls = normalizeUrls(result?.urls || []);
112
118
  logger.debug(`[sitemap] Found ${urls.length} URLs in ${fetchRoute}`);
113
119
  return { urls };
114
120
  }
@@ -260,11 +260,11 @@ export interface LlmsTxtLink {
260
260
  export interface LlmsTxtSection {
261
261
  /** The title of the section */
262
262
  title: string;
263
- /** The description of the section (can be array for multiple paragraphs) */
263
+ /** Description rendered in the heading-free preamble, or in each optional link note (can be array for multiple paragraphs) */
264
264
  description?: string | string[];
265
265
  /** The links of the section */
266
266
  links?: LlmsTxtLink[];
267
- /** Mark section as optional per llms.txt spec. Optional sections appear under `## Optional` and LLMs may skip them with shorter context windows. */
267
+ /** Mark links as optional per llms.txt spec. Links from all optional sections are flattened under `## Optional`. */
268
268
  optional?: boolean;
269
269
  }
270
270
  /**
@@ -273,7 +273,7 @@ export interface LlmsTxtSection {
273
273
  export interface LlmsTxtConfig {
274
274
  /** The sections of the documentation */
275
275
  sections?: LlmsTxtSection[];
276
- /** Notes section (always appears at the end) */
276
+ /** Additional heading-free preamble notes rendered before file-list sections */
277
277
  notes?: string | string[];
278
278
  }
279
279
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-ai-ready",
3
3
  "type": "module",
4
- "version": "1.5.6",
4
+ "version": "1.5.7",
5
5
  "description": "Best practice AI & LLM discoverability for Nuxt sites.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -42,7 +42,7 @@
42
42
  "peerDependencies": {
43
43
  "@libsql/client": "^0.14.0",
44
44
  "@neondatabase/serverless": "^1.0.0",
45
- "@nuxtjs/sitemap": ">=7.0.0",
45
+ "@nuxtjs/sitemap": ">=8.3.0",
46
46
  "better-sqlite3": "^11.0.0 || ^12.0.0"
47
47
  },
48
48
  "peerDependenciesMeta": {
@@ -64,11 +64,11 @@
64
64
  "defu": "^6.1.7",
65
65
  "drizzle-orm": "^0.45.2",
66
66
  "mdream": "^1.5.3",
67
- "nuxt-site-config": "^4.1.1",
67
+ "nuxt-site-config": "^4.1.2",
68
68
  "nuxtseo-shared": "^5.3.3",
69
69
  "pathe": "^2.0.3",
70
70
  "pkg-types": "^2.3.1",
71
- "site-config-stack": "^4.1.1",
71
+ "site-config-stack": "^4.1.2",
72
72
  "ufo": "^1.6.4",
73
73
  "uncrypto": "^0.1.3"
74
74
  },
@@ -82,7 +82,7 @@
82
82
  "@nuxtjs/eslint-config-typescript": "^12.1.0",
83
83
  "@nuxtjs/mcp-toolkit": "^0.18.0",
84
84
  "@nuxtjs/robots": "^6.1.3",
85
- "@nuxtjs/sitemap": "^8.2.2",
85
+ "@nuxtjs/sitemap": "^8.3.0",
86
86
  "@types/better-sqlite3": "^7.6.13",
87
87
  "@vitest/coverage-v8": "^4.1.10",
88
88
  "@vue/test-utils": "^2.4.11",
@@ -95,7 +95,7 @@
95
95
  "happy-dom": "^20.11.0",
96
96
  "nitropack": "^2.13.4",
97
97
  "nuxt": "^4.5.0",
98
- "nuxt-site-config": "^4.1.1",
98
+ "nuxt-site-config": "^4.1.2",
99
99
  "nuxtseo-layer-devtools": "^5.3.3",
100
100
  "playwright": "^1.61.1",
101
101
  "playwright-core": "^1.61.1",