nuxt-ai-ready 3.0.4 → 3.2.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.
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { realpath, readFile, readdir } from 'node:fs/promises';
3
3
  import { join, resolve, isAbsolute, relative, sep } from 'node:path';
4
4
  import { parseDocument } from 'yaml';
5
- import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.iq1L3kll.mjs';
5
+ import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.DH9mSayy.mjs';
6
6
  import 'node:module';
7
7
  import 'node:url';
8
8
  import '@nuxt/kit';
@@ -6,7 +6,9 @@ import { resolveLocaleFromRoute } from 'nuxtseo-shared/i18n-runtime';
6
6
  import { createPrerenderFetch } from 'nuxtseo-shared/prerender';
7
7
  import { collectSitemap } from 'sitemapd/parse';
8
8
  import { withLeadingSlash, joinURL, withBase } from 'ufo';
9
- import { l as logger, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.iq1L3kll.mjs';
9
+ import { l as logger, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.DH9mSayy.mjs';
10
+ import { insertLlmsTxtDirective } from '../../dist/runtime/llms-txt-directive.js';
11
+ import { absoluteSiteUrl } from '../../dist/runtime/markdown-links.js';
10
12
  import { normalizePagePath, toMarkdownPath } from '../../dist/runtime/markdown-path.js';
11
13
  import { toDeployedRoute, toLogicalRoute } from '../../dist/runtime/route-path.js';
12
14
  import { initSchema, computeContentHash, insertPage, queryAllPages, exportDbDump } from '../../dist/runtime/server/db/shared.js';
@@ -347,7 +349,8 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
347
349
  await initPromise;
348
350
  const parsed = JSON.parse(route.contents || "{}");
349
351
  await processMarkdownRoute(state, nuxt, pageRoute, parsed);
350
- route.contents = options.sitemapMd === false ? parsed.markdown : appendSitemapSection(parsed.markdown, toDeployedRoute(SITEMAP_MD_ROUTE, nitro.options.baseURL));
352
+ const withSitemap = options.sitemapMd === false ? parsed.markdown : appendSitemapSection(parsed.markdown, absoluteSiteUrl(toDeployedRoute(SITEMAP_MD_ROUTE, nitro.options.baseURL), siteInfo?.url));
353
+ route.contents = llmsTxtConfig?.directive?.markdown === false ? withSitemap : insertLlmsTxtDirective(withSitemap, absoluteSiteUrl(toDeployedRoute("/llms.txt", nitro.options.baseURL), siteInfo?.url));
351
354
  route.contentType = "text/markdown; charset=utf-8";
352
355
  state.totalProcessingTime += Date.now() - pageStartTime;
353
356
  });
@@ -409,10 +412,12 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras)
409
412
  }
410
413
  if (state.dbPath && state.llmsTxtConfig?.markdownLinks) {
411
414
  const availabilityFile = join(dirname(state.dbPath), MARKDOWN_LINK_AVAILABILITY_FILE);
415
+ const options2 = nitro.options;
416
+ const ignoreBaseDir = options2.srcDir || (typeof options2.serverDir === "string" ? options2.serverDir : void 0) || options2.rootDir;
412
417
  const paths = await findOutputMarkdownPaths(
413
418
  nitro.options.output.publicDir,
414
419
  nitro.options.baseURL,
415
- nitro.options.srcDir,
420
+ ignoreBaseDir,
416
421
  nitro.options.ignore,
417
422
  nitro.options.publicAssets
418
423
  );
@@ -4,7 +4,7 @@ export interface DevtoolsConfig {
4
4
  sitemapPrerendered: boolean
5
5
  markdownCacheHeaders: { maxAge: number, swr: boolean }
6
6
  llmsTxtCacheSeconds: number
7
- contentSignal: false | { aiTrain: boolean, search: boolean, aiInput: boolean }
7
+ contentSignal: false | { aiTrain?: boolean, search?: boolean, aiInput?: boolean }
8
8
  mcp: { enabled: boolean, tools: boolean, resources: boolean }
9
9
  cron: boolean
10
10
  }
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": "^4.6.0 || ^5.0.0-0"
5
5
  },
6
6
  "configKey": "aiReady",
7
- "version": "3.0.4",
7
+ "version": "3.2.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -7,7 +7,7 @@ import '@nuxt/kit';
7
7
  import 'defu';
8
8
  import 'nuxt-site-config/kit';
9
9
  import 'nuxtseo-shared/kit';
10
- export { m as default } from './shared/nuxt-ai-ready.iq1L3kll.mjs';
10
+ export { m as default } from './shared/nuxt-ai-ready.DH9mSayy.mjs';
11
11
  import './shared/nuxt-ai-ready.B3hAuOLQ.mjs';
12
12
  import '../dist/runtime/route-path.js';
13
13
  import '../dist/runtime/server/utils/sitemap-md.js';
@@ -0,0 +1,15 @@
1
+ /** The line that points a reader of one page at the whole site's index. */
2
+ export declare function llmsTxtDirective(llmsTxtUrl: string): string;
3
+ /**
4
+ * Add the llms.txt directive near the top of a page's Markdown, after any
5
+ * frontmatter so YAML parsers still find it first. An agent that lands on one
6
+ * page then knows where the full index is.
7
+ */
8
+ export declare function insertLlmsTxtDirective(markdown: string, llmsTxtUrl: string): string;
9
+ /**
10
+ * The HTML equivalent: a link at the top of the body, hidden visually but
11
+ * kept for screen readers and agents that read the raw HTML.
12
+ */
13
+ export declare function llmsTxtDirectiveHtml(llmsTxtPath: string): string;
14
+ /** Remove the injected HTML link, so a page's Markdown carries the directive once. */
15
+ export declare function stripLlmsTxtDirectiveHtml(html: string): string;
@@ -0,0 +1,23 @@
1
+ const RE_FRONTMATTER = /^\uFEFF?---[\t ]*\r?\n(?:[\s\S]*?\r?\n)?---[\t ]*(?:\r?\n|$)/;
2
+ export function llmsTxtDirective(llmsTxtUrl) {
3
+ return `> Index of every page on this site: [llms.txt](${llmsTxtUrl})`;
4
+ }
5
+ export function insertLlmsTxtDirective(markdown, llmsTxtUrl) {
6
+ const directive = llmsTxtDirective(llmsTxtUrl);
7
+ const frontmatter = markdown.match(RE_FRONTMATTER)?.[0] ?? "";
8
+ const body = markdown.slice(frontmatter.length).replace(/^(?:\r?\n)+/, "");
9
+ if (body === directive || body.startsWith(`${directive}
10
+ `) || body.startsWith(`${directive}\r
11
+ `))
12
+ return markdown;
13
+ return `${frontmatter}${frontmatter ? "\n" : ""}${directive}
14
+
15
+ ${body}`;
16
+ }
17
+ const RE_DIRECTIVE_HTML = /<a [^>]*data-llms-txt-directive[^>]*>[^<]*<\/a>/g;
18
+ export function llmsTxtDirectiveHtml(llmsTxtPath) {
19
+ return `<a href="${llmsTxtPath}" tabindex="-1" style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0" data-llms-txt-directive>Index of every page on this site: llms.txt</a>`;
20
+ }
21
+ export function stripLlmsTxtDirectiveHtml(html) {
22
+ return html.replace(RE_DIRECTIVE_HTML, "");
23
+ }
@@ -4,6 +4,7 @@ import { useRuntimeConfig } from "#nuxtseo/nitro";
4
4
  import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
5
5
  import { withSiteTrailingSlash, withSiteUrl } from "#site-config/server/composables/utils";
6
6
  import { formatLlmsTxtPageLink, normalizeLlmsTxtConfig } from "./llms-txt-format.js";
7
+ import { absoluteSiteUrl } from "./markdown-links.js";
7
8
  import { hasMarkdownTwin, toMarkdownPath } from "./markdown-path.js";
8
9
  import { normalizePersistedRoute, toDeployedRoute, toLogicalRoute } from "./route-path.js";
9
10
  import { queryPages } from "./server/db/queries.js";
@@ -207,6 +208,8 @@ Canonical Origin: ${canonicalSiteUrl}`);
207
208
  return deployedPathname;
208
209
  };
209
210
  }
211
+ const resolvePagePath = resolvePageHref;
212
+ resolvePageHref = (pathname) => absoluteSiteUrl(resolvePagePath(pathname), canonicalSiteUrl);
210
213
  for (const page of [...prerendered, ...other])
211
214
  page.href = resolvePageHref(page.pathname);
212
215
  if (i18nRuntime._tag === "enabled") {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Resolve root-relative link and image destinations against a site origin.
3
+ *
4
+ * A Markdown page read on its own, by an agent or from `llms-full.txt`, has
5
+ * no page URL to resolve `/docs/api` against, so such a link loses its
6
+ * target. Fenced code and inline code spans keep their text.
7
+ */
8
+ export declare function absoluteMarkdownLinks(markdown: string, siteUrl: string): string;
9
+ /** A deployed path on the site's origin, or the path itself when no site URL is known. */
10
+ export declare function absoluteSiteUrl(path: string, siteUrl: string | undefined): string;
@@ -0,0 +1,52 @@
1
+ const RE_FENCE = /^ {0,3}(`{3,}|~{3,})/;
2
+ const RE_INLINE_ROOT_LINK = /\]\((<?)\/(?!\/)/g;
3
+ const RE_REFERENCE_ROOT_LINK = /^( {0,3}\[[^\]]+\]:[\t ]*<?)\/(?!\/)/;
4
+ function closesFence(line, fence) {
5
+ const trimmed = line.trim();
6
+ return trimmed.length >= fence.length && trimmed[0] === fence[0] && [...trimmed].every((char) => char === fence[0]);
7
+ }
8
+ function backtickRun(line, start) {
9
+ let end = start;
10
+ while (line[end] === "`")
11
+ end++;
12
+ return end - start;
13
+ }
14
+ function rewriteOutsideCodeSpans(line, rewrite) {
15
+ let output = "";
16
+ let last = 0;
17
+ let index = line.indexOf("`");
18
+ while (index !== -1) {
19
+ const length = backtickRun(line, index);
20
+ let close = line.indexOf("`", index + length);
21
+ while (close !== -1 && backtickRun(line, close) !== length)
22
+ close = line.indexOf("`", close + backtickRun(line, close));
23
+ if (close === -1) {
24
+ index = line.indexOf("`", index + length);
25
+ continue;
26
+ }
27
+ output += rewrite(line.slice(last, index)) + line.slice(index, close + length);
28
+ last = close + length;
29
+ index = line.indexOf("`", last);
30
+ }
31
+ return output + rewrite(line.slice(last));
32
+ }
33
+ export function absoluteMarkdownLinks(markdown, siteUrl) {
34
+ const origin = new URL(siteUrl).origin;
35
+ let fence;
36
+ return markdown.split(/(?<=\n)/).map((line) => {
37
+ if (fence) {
38
+ if (closesFence(line, fence))
39
+ fence = void 0;
40
+ return line;
41
+ }
42
+ const opening = line.match(RE_FENCE);
43
+ if (opening) {
44
+ fence = opening[1];
45
+ return line;
46
+ }
47
+ return rewriteOutsideCodeSpans(line, (text) => text.replace(RE_REFERENCE_ROOT_LINK, `$1${origin}/`).replace(RE_INLINE_ROOT_LINK, `]($1${origin}/`));
48
+ }).join("");
49
+ }
50
+ export function absoluteSiteUrl(path, siteUrl) {
51
+ return siteUrl ? new URL(path, new URL(siteUrl).origin).href : path;
52
+ }
@@ -1,6 +1,8 @@
1
1
  import { createError, defineEventHandler, getRequestURL, setHeader, setResponseStatus } from "#nuxtseo/h3";
2
2
  import { useNitroApp, useRuntimeConfig } from "#nuxtseo/nitro";
3
3
  import { resolveLocaleAlternateUrl } from "../../i18n-url.js";
4
+ import { insertLlmsTxtDirective } from "../../llms-txt-directive.js";
5
+ import { absoluteMarkdownLinks } from "../../markdown-links.js";
4
6
  import { logger } from "../logger.js";
5
7
  import { computeLocaleAlternates, resolveLocaleFromRoute } from "../utils/i18n.js";
6
8
  import { resolveMarkdownRedirect } from "../utils/markdown-redirect.js";
@@ -49,7 +51,12 @@ export default defineEventHandler(async (event) => {
49
51
  const ctx = buildNegotiationContext(event, decision.path);
50
52
  const { path, config, resolvePath, resolveUrl, routeContext } = ctx;
51
53
  const canonicalUrl = resolveUrl(path);
52
- const finalizeMarkdown = (markdown) => config.sitemapMd === false ? markdown : appendSitemapSection(markdown, resolvePath(SITEMAP_MD_ROUTE));
54
+ const finalizeMarkdown = (markdown) => {
55
+ const withSitemap = config.sitemapMd === false ? markdown : appendSitemapSection(markdown, resolvePath(SITEMAP_MD_ROUTE));
56
+ const llmsTxt = config.llmsTxt;
57
+ const withDirective = llmsTxt?.directive?.markdown === false ? withSitemap : insertLlmsTxtDirective(withSitemap, resolveUrl("/llms.txt"));
58
+ return absoluteMarkdownLinks(withDirective, canonicalUrl);
59
+ };
53
60
  const [
54
61
  { tryGetContentMarkdown },
55
62
  { fetchRawWithEvent },
@@ -1,6 +1,7 @@
1
1
  import { createError, defineEventHandler } from "#nuxtseo/h3";
2
2
  import { useRuntimeConfig } from "#nuxtseo/nitro";
3
3
  import { withSiteUrl } from "#site-config/server/composables/utils";
4
+ import { absoluteMarkdownLinks } from "../../markdown-links.js";
4
5
  import { toDeployedRoute } from "../../route-path.js";
5
6
  import { logger } from "../logger.js";
6
7
  import { convertHtmlToMarkdown, extractLastUpdated } from "../utils.js";
@@ -52,7 +53,7 @@ export default defineEventHandler(async (event) => {
52
53
  last_updated: contentPage.updatedAt
53
54
  });
54
55
  const markdown = `${frontmatter}
55
- ${contentPage.markdown}`;
56
+ ${absoluteMarkdownLinks(contentPage.markdown, canonicalUrl)}`;
56
57
  const headings = extractHeadingsFromMarkdown(contentPage.markdown);
57
58
  const keywords2 = extractKeywords(contentPage.markdown, "");
58
59
  return JSON.stringify({
@@ -0,0 +1,3 @@
1
+ /** Registered only with `llmsTxt.directive.html: true`. */
2
+ declare const _default: any;
3
+ export default _default;
@@ -0,0 +1,8 @@
1
+ import { defineNitroPlugin, useRuntimeConfig } from "#nuxtseo/nitro";
2
+ import { llmsTxtDirectiveHtml } from "../../llms-txt-directive.js";
3
+ import { toDeployedRoute } from "../../route-path.js";
4
+ export default defineNitroPlugin((nitroApp) => {
5
+ nitroApp.hooks.hook("render:html", (html, { event }) => {
6
+ html.bodyPrepend.unshift(llmsTxtDirectiveHtml(toDeployedRoute("/llms.txt", useRuntimeConfig(event).app.baseURL)));
7
+ });
8
+ });
@@ -2,6 +2,7 @@ import { eventHandler, setHeader } from "#nuxtseo/h3";
2
2
  import { defineCachedFunction, useRuntimeConfig } from "#nuxtseo/nitro";
3
3
  import { getSiteConfig } from "#site-config/server/composables";
4
4
  import { publicCacheControl } from "../../cache-control.js";
5
+ import { absoluteSiteUrl } from "../../markdown-links.js";
5
6
  import { toMarkdownPath } from "../../markdown-path.js";
6
7
  import { toDeployedRoute } from "../../route-path.js";
7
8
  import { queryPages } from "../db/queries.js";
@@ -17,11 +18,12 @@ async function buildSitemapMarkdown(event) {
17
18
  );
18
19
  }
19
20
  const baseURL = useRuntimeConfig(event).app.baseURL;
21
+ const siteConfig = getSiteConfig(event);
20
22
  return buildSitemapMd(
21
23
  pages.map((page) => ({ route: page.route, title: page.title, updatedAt: page.updatedAt })),
22
24
  {
23
- siteName: getSiteConfig(event).name,
24
- resolveHref: (route) => toDeployedRoute(toMarkdownPath(route), baseURL)
25
+ siteName: siteConfig.name,
26
+ resolveHref: (route) => absoluteSiteUrl(toDeployedRoute(toMarkdownPath(route), baseURL), siteConfig.url)
25
27
  }
26
28
  );
27
29
  }
@@ -1,5 +1,6 @@
1
1
  import { htmlToMarkdown } from "mdream";
2
2
  import { useNitroApp } from "#nuxtseo/nitro";
3
+ import { stripLlmsTxtDirectiveHtml } from "../llms-txt-directive.js";
3
4
  import { hoistMetaDescription } from "./utils/frontmatter.js";
4
5
  const RE_NBSP = /\u00A0/g;
5
6
  function normalizeWhitespace(text) {
@@ -75,7 +76,7 @@ export async function convertHtmlToMarkdown(html, url, mdreamOptions, opts) {
75
76
  await nitroApp.hooks.callHook("ai-ready:mdreamConfig", options);
76
77
  const context = {
77
78
  html,
78
- markdown: hoistMetaDescription(htmlToMarkdown(html, options)),
79
+ markdown: hoistMetaDescription(htmlToMarkdown(stripLlmsTxtDirectiveHtml(html), options)),
79
80
  route: opts.hooks.route,
80
81
  title: meta.title,
81
82
  description: meta.description,
@@ -200,7 +200,12 @@ export interface ModuleOptions {
200
200
  */
201
201
  describedby?: boolean;
202
202
  /**
203
- * Content Signal Directives
203
+ * Content Signal directives for robots.txt.
204
+ *
205
+ * Unset, robots.txt gets `Content-Signal: search=yes, ai-input=yes` and no
206
+ * `ai-train` preference, unless a robots group already sets one. An object
207
+ * states the whole policy: each permission left out is sent as `no`.
208
+ * `false` sends no Content-Signal line.
204
209
  */
205
210
  contentSignal?: false | {
206
211
  /**
@@ -524,9 +529,25 @@ export interface LlmsTxtConfig {
524
529
  * Rewrite automatically generated page links to their Markdown representation
525
530
  * when the final static output contains the file or the deployment retains
526
531
  * the runtime Markdown handler.
527
- * @default false
532
+ * @default true
528
533
  */
529
534
  markdownLinks?: boolean;
535
+ /**
536
+ * Point each page at llms.txt, so an agent that lands on one page finds the
537
+ * whole site's index.
538
+ */
539
+ directive?: {
540
+ /**
541
+ * Add a blockquote line after the frontmatter of every page's `.md` response.
542
+ * @default true
543
+ */
544
+ markdown?: boolean;
545
+ /**
546
+ * Add a visually hidden link to `/llms.txt` at the top of every HTML page's body.
547
+ * @default false
548
+ */
549
+ html?: boolean;
550
+ };
530
551
  /** The sections of the documentation */
531
552
  sections?: LlmsTxtSection[];
532
553
  /** Additional heading-free preamble notes rendered before file-list sections */
@@ -666,18 +666,42 @@ function cloudflareStaticFallbackPaths(existing, pages, environments = []) {
666
666
  const patterns = [...existing || [], ...environments.flatMap((routing) => Array.isArray(routing) ? routing : [])];
667
667
  return pagePaths(pages).filter((path) => !patterns.some((pattern) => matchesPattern(path, pattern.startsWith("!") ? pattern.slice(1) : pattern)));
668
668
  }
669
- function cloudflareNegotiationPatterns(existing, pages) {
670
- if (existing === true || existing === false || pages.length === 0)
671
- return existing;
672
- const patterns = [...existing || []];
673
- for (const path of cloudflareStaticFallbackPaths(existing, pages)) {
674
- if (!patterns.includes(path))
675
- patterns.push(path);
669
+ function parentDirectories(path) {
670
+ const segments = path.split("/").filter(Boolean);
671
+ return segments.slice(0, -1).map((_, index) => `/${segments.slice(0, index + 1).join("/")}/`);
672
+ }
673
+ function fitWorkerFirstBudget(paths, budget) {
674
+ let remaining = [...paths];
675
+ const globs = [];
676
+ while (remaining.length + globs.length > budget) {
677
+ const counts = /* @__PURE__ */ new Map();
678
+ for (const path of remaining) {
679
+ for (const directory2 of parentDirectories(path))
680
+ counts.set(directory2, (counts.get(directory2) ?? 0) + 1);
681
+ }
682
+ let best;
683
+ for (const [directory2, count] of counts) {
684
+ if (!best || count > best[1] || count === best[1] && directory2.length > best[0].length)
685
+ best = [directory2, count];
686
+ }
687
+ if (!best || best[1] < 2)
688
+ return void 0;
689
+ const [directory] = best;
690
+ globs.push(`${directory}*`);
691
+ remaining = remaining.filter((path) => !path.startsWith(directory) || path === directory);
676
692
  }
677
- if (patterns.length > MAX_WORKER_FIRST_PATTERNS) {
678
- throw new Error("Cloudflare Markdown negotiation exceeds 100 worker-first patterns. Configure broader assets.run_worker_first patterns, or disable aiReady.contentNegotiation.");
693
+ return [...remaining, ...globs];
694
+ }
695
+ function cloudflareWorkerFirstAdditions(existing, pages) {
696
+ if (existing === true || existing === false || pages.length === 0)
697
+ return [];
698
+ const patterns = existing || [];
699
+ const paths = cloudflareStaticFallbackPaths(existing, pages).filter((path) => !patterns.includes(path));
700
+ const additions = fitWorkerFirstBudget(paths, MAX_WORKER_FIRST_PATTERNS - patterns.length);
701
+ if (!additions) {
702
+ throw new Error("Cloudflare Markdown negotiation exceeds 100 worker-first patterns, even with directory globs. Configure broader assets.run_worker_first patterns, or disable aiReady.contentNegotiation.");
679
703
  }
680
- return patterns;
704
+ return additions;
681
705
  }
682
706
 
683
707
  const CACHE_GUIDE = "https://nuxtseo.com/docs/ai-ready/guides/markdown#cache-safety";
@@ -748,6 +772,58 @@ function resolveConfigurationWarnings(input) {
748
772
  return warnings;
749
773
  }
750
774
 
775
+ const DEFAULT_CONTENT_SIGNAL = "search=yes, ai-input=yes";
776
+ function resolveContentSignalPolicy(option) {
777
+ if (option === false)
778
+ return { _tag: "Disabled" };
779
+ if (option === void 0)
780
+ return { _tag: "Default" };
781
+ const value = (allowed) => allowed ? "yes" : "no";
782
+ return {
783
+ _tag: "Configured",
784
+ // One comma-separated line: validators reject one line per signal.
785
+ contentSignal: `ai-train=${value(option.aiTrain)}, search=${value(option.search)}, ai-input=${value(option.aiInput)}`,
786
+ ...option.contentUsage !== false && { contentUsage: `train-ai=${option.aiTrain ? "y" : "n"}` }
787
+ };
788
+ }
789
+ function hasContentSignal(value) {
790
+ if (Array.isArray(value))
791
+ return value.some(Boolean);
792
+ if (value && typeof value === "object")
793
+ return Object.keys(value).length > 0;
794
+ return Boolean(value);
795
+ }
796
+ function isWildcardGroup(group) {
797
+ const userAgents = Array.isArray(group.userAgent) ? group.userAgent : [group.userAgent ?? "*"];
798
+ return userAgents.length === 1 && userAgents[0] === "*";
799
+ }
800
+ function wildcardGroup(groups) {
801
+ const existing = groups.find(isWildcardGroup);
802
+ if (existing)
803
+ return existing;
804
+ const group = { userAgent: ["*"], disallow: [""] };
805
+ groups.push(group);
806
+ return group;
807
+ }
808
+ function applyContentSignalPolicy(groups, policy) {
809
+ switch (policy._tag) {
810
+ case "Disabled":
811
+ return;
812
+ case "Default": {
813
+ if (groups.some((group) => hasContentSignal(group.contentSignal)))
814
+ return;
815
+ wildcardGroup(groups).contentSignal = [DEFAULT_CONTENT_SIGNAL];
816
+ return;
817
+ }
818
+ case "Configured": {
819
+ const group = wildcardGroup(groups);
820
+ group.contentSignal = [policy.contentSignal];
821
+ if (policy.contentUsage)
822
+ group.contentUsage = [policy.contentUsage];
823
+ }
824
+ }
825
+ }
826
+
751
827
  const DATABASE_TYPE_NONE = "none";
752
828
  const DATABASE_DEPENDENT_OPTIONS = ["runtimeSync", "cron"];
753
829
  const DATABASE_CONSUMER_LABELS = {
@@ -1071,6 +1147,7 @@ function parseMcpServerCardConfig(input) {
1071
1147
  }
1072
1148
  };
1073
1149
  }
1150
+ const MCP_SERVER_CARD_WELL_KNOWN_PATHS = ["/.well-known/mcp/server-card.json", "/.well-known/mcp.json"];
1074
1151
  function resolveMcpServerCardRoute(mcpRoute) {
1075
1152
  return `${mcpRoute.replace(/\/+$/, "") || "/mcp"}/server-card`;
1076
1153
  }
@@ -1166,6 +1243,14 @@ function createMcpServerCardEtag(card) {
1166
1243
  return `"${createHash("sha256").update(JSON.stringify(card)).digest("hex")}"`;
1167
1244
  }
1168
1245
 
1246
+ function opaqueJsonModule(exports) {
1247
+ const encode = (value) => JSON.stringify(Buffer.from(JSON.stringify(value), "utf8").toString("base64"));
1248
+ return [
1249
+ "const decode = value => JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(value), char => char.charCodeAt(0))))",
1250
+ ...Object.entries(exports).map(([name, value]) => `export const ${name} = decode(${encode(value)})`)
1251
+ ].join("\n");
1252
+ }
1253
+
1169
1254
  function escapeRegExp(value) {
1170
1255
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1171
1256
  }
@@ -1744,25 +1829,11 @@ ${details}`);
1744
1829
  (paths.resources ||= []).push(`${mcpRuntimeDir}/resources`);
1745
1830
  });
1746
1831
  const contentSource = await resolveContentSource(config.contentSource !== false);
1747
- if (typeof config.contentSignal === "object") {
1748
- const robotsOpts = nuxt.options.robots || {};
1749
- nuxt.options.robots = robotsOpts;
1750
- const groups = robotsOpts.groups || [];
1751
- robotsOpts.groups = groups;
1752
- const group = {
1753
- userAgent: "*",
1754
- // Preserve nuxt-robots' default wildcard rule so the injected group remains valid.
1755
- disallow: [""],
1756
- // Object form renders one comma-separated line; an array renders one line per entry, which validators reject.
1757
- contentSignal: {
1758
- "ai-train": config.contentSignal.aiTrain ? "yes" : "no",
1759
- "search": config.contentSignal.search ? "yes" : "no",
1760
- "ai-input": config.contentSignal.aiInput ? "yes" : "no"
1761
- }
1762
- };
1763
- if (config.contentSignal.contentUsage !== false)
1764
- group.contentUsage = [`train-ai=${config.contentSignal.aiTrain ? "y" : "n"}`];
1765
- groups.push(group);
1832
+ const contentSignalPolicy = resolveContentSignalPolicy(config.contentSignal);
1833
+ if (contentSignalPolicy._tag !== "Disabled") {
1834
+ nuxt.hook("robots:config", (robotsConfig) => {
1835
+ applyContentSignalPolicy(robotsConfig.groups, contentSignalPolicy);
1836
+ });
1766
1837
  }
1767
1838
  registerTypeTemplates({ config, nitroCompatibility });
1768
1839
  const defaultLlmsTxtSections = [];
@@ -1789,14 +1860,19 @@ ${details}`);
1789
1860
  }))
1790
1861
  });
1791
1862
  }
1863
+ const llmsTxtDirective = {
1864
+ markdown: config.llmsTxt?.directive?.markdown !== false,
1865
+ html: config.llmsTxt?.directive?.html === true
1866
+ };
1792
1867
  const mergedLlmsTxt = config.llmsTxt ? {
1793
- markdownLinks: config.llmsTxt.markdownLinks ?? false,
1868
+ markdownLinks: config.llmsTxt.markdownLinks ?? true,
1869
+ directive: llmsTxtDirective,
1794
1870
  sections: [
1795
1871
  ...defaultLlmsTxtSections,
1796
1872
  ...config.llmsTxt.sections || []
1797
1873
  ],
1798
1874
  notes: config.llmsTxt.notes
1799
- } : { markdownLinks: false, sections: defaultLlmsTxtSections };
1875
+ } : { markdownLinks: true, directive: llmsTxtDirective, sections: defaultLlmsTxtSections };
1800
1876
  const llmsTxtPayload = {
1801
1877
  sections: mergedLlmsTxt.sections || [],
1802
1878
  notes: typeof mergedLlmsTxt.notes === "string" ? [mergedLlmsTxt.notes] : mergedLlmsTxt.notes || []
@@ -1876,18 +1952,21 @@ ${details}`);
1876
1952
  updateSeoProFeatures(seoProModules);
1877
1953
  return;
1878
1954
  }
1879
- const mcpLink = {
1880
- title: "MCP",
1881
- href: withSiteUrl(finalMcpToolkitState.route, { withBase: true }),
1882
- description: config.mcp?.tools !== false && hasMcpSiteTools ? "Use this MCP endpoint when you need information from this site's indexed pages." : "Model Context Protocol server endpoint for AI agent integration."
1883
- };
1884
- const firstSection = mergedLlmsTxt.sections?.[0];
1885
- if (firstSection) {
1886
- firstSection.links ||= [];
1887
- if (!firstSection.links.some((link) => link.title === mcpLink.title))
1888
- firstSection.links.push(mcpLink);
1889
- } else {
1890
- mergedLlmsTxt.sections = [{ title: "LLM Tools", links: [mcpLink] }];
1955
+ if (mcpServerCardResult._tag === "Enabled") {
1956
+ const endpoint = withSiteUrl(finalMcpToolkitState.route, { withBase: true });
1957
+ const mcpLink = {
1958
+ title: "MCP server card",
1959
+ href: withSiteUrl(MCP_SERVER_CARD_WELL_KNOWN_PATHS[0].slice(1), { withBase: true }),
1960
+ description: config.mcp?.tools !== false && hasMcpSiteTools ? `Connect an MCP client to ${endpoint} when you need information from this site's indexed pages.` : `Connect an MCP client to ${endpoint} to use this site's Model Context Protocol server.`
1961
+ };
1962
+ const firstSection = mergedLlmsTxt.sections?.[0];
1963
+ if (firstSection) {
1964
+ firstSection.links ||= [];
1965
+ if (!firstSection.links.some((link) => link.title === mcpLink.title))
1966
+ firstSection.links.push(mcpLink);
1967
+ } else {
1968
+ mergedLlmsTxt.sections = [{ title: "LLM Tools", links: [mcpLink] }];
1969
+ }
1891
1970
  }
1892
1971
  const mcpServerCardRoute = resolveMcpServerCardRoute(finalMcpToolkitState.route);
1893
1972
  if (siteConfig.url && config.apiCatalog !== false) {
@@ -1964,21 +2043,23 @@ ${details}`);
1964
2043
  etag
1965
2044
  };
1966
2045
  const handler = resolve$1("./runtime/server/routes/mcp-server-card");
1967
- addServerHandler({ route: mcpServerCardRoute, method: "get", handler, lazy: true });
1968
- addServerHandler({ route: mcpServerCardRoute, method: "head", handler, lazy: true });
1969
- addServerHandler({ route: mcpServerCardRoute, method: "options", handler, lazy: true });
1970
- extendRouteRules(mcpServerCardRoute, {
1971
- sitemap: false,
1972
- headers: {
1973
- "Access-Control-Allow-Headers": "Content-Type, If-None-Match",
1974
- "Access-Control-Allow-Methods": "GET, HEAD",
1975
- "Access-Control-Allow-Origin": "*",
1976
- "Access-Control-Expose-Headers": "ETag",
1977
- "Cache-Control": `public, max-age=${mcpServerCardResult.config.cacheMaxAge}`,
1978
- "Content-Type": MCP_SERVER_CARD_MEDIA_TYPE,
1979
- "ETag": etag
1980
- }
1981
- });
2046
+ for (const route of [mcpServerCardRoute, ...MCP_SERVER_CARD_WELL_KNOWN_PATHS]) {
2047
+ addServerHandler({ route, method: "get", handler, lazy: true });
2048
+ addServerHandler({ route, method: "head", handler, lazy: true });
2049
+ addServerHandler({ route, method: "options", handler, lazy: true });
2050
+ extendRouteRules(route, {
2051
+ sitemap: false,
2052
+ headers: {
2053
+ "Access-Control-Allow-Headers": "Content-Type, If-None-Match",
2054
+ "Access-Control-Allow-Methods": "GET, HEAD",
2055
+ "Access-Control-Allow-Origin": "*",
2056
+ "Access-Control-Expose-Headers": "ETag",
2057
+ "Cache-Control": `public, max-age=${mcpServerCardResult.config.cacheMaxAge}`,
2058
+ "Content-Type": MCP_SERVER_CARD_MEDIA_TYPE,
2059
+ "ETag": etag
2060
+ }
2061
+ });
2062
+ }
1982
2063
  if (siteConfig.url) {
1983
2064
  const document = resolveAiCatalog({
1984
2065
  siteUrl: siteConfig.url,
@@ -2116,8 +2197,7 @@ export { unavailable as computeLocaleAlternates, unavailable as localePath, unav
2116
2197
  `;
2117
2198
  };
2118
2199
  nitroConfig.virtual["#ai-ready-virtual/site-tools.mjs"] = `export default ${JSON.stringify(siteToolsConfig)}`;
2119
- nitroConfig.virtual["#ai-ready-virtual/agent-skills.mjs"] = agentSkillsResult._tag === "Enabled" ? `export const agentSkillsIndex = ${JSON.stringify(agentSkillsResult.index)}
2120
- export const localAgentSkillArtifacts = ${JSON.stringify(agentSkillsResult.localArtifacts)}` : "export const agentSkillsIndex = null\nexport const localAgentSkillArtifacts = {}";
2200
+ nitroConfig.virtual["#ai-ready-virtual/agent-skills.mjs"] = agentSkillsResult._tag === "Enabled" ? opaqueJsonModule({ agentSkillsIndex: agentSkillsResult.index, localAgentSkillArtifacts: agentSkillsResult.localArtifacts }) : "export const agentSkillsIndex = null\nexport const localAgentSkillArtifacts = {}";
2121
2201
  const markdownLinkAvailabilityPath = join(dirname(buildDbPath), MARKDOWN_LINK_AVAILABILITY_FILE);
2122
2202
  nitroConfig.virtual["#ai-ready-virtual/read-page-data.mjs"] = createBuildPageDataVirtual({
2123
2203
  buildDbPath,
@@ -2188,7 +2268,7 @@ export function trackDrizzleWork(event, work) { return work }
2188
2268
  const schemaPath = database._tag === "Enabled" && (database.type === "neon" || database.type === "postgres") ? "#ai-ready/server/db/schema/postgres" : "#ai-ready/server/db/schema/sqlite";
2189
2269
  nitroConfig.virtual["#ai-ready-virtual/db-schema.mjs"] = database._tag === "Enabled" ? `export * from '${schemaPath}'` : "export {}";
2190
2270
  nitroConfig.virtual["#ai-ready-virtual/devtools-meta.mjs"] = `export const devtoolsMeta = ${JSON.stringify({
2191
- contentSignal: config.contentSignal || false,
2271
+ contentSignal: config.contentSignal ?? { search: true, aiInput: true },
2192
2272
  mcp: { enabled: mcpAvailable, tools: mcpAvailable && config.mcp?.tools !== false && hasMcpSiteTools, resources: mcpAvailable && config.mcp?.resources !== false },
2193
2273
  cron: !!config.cron
2194
2274
  })}`;
@@ -2249,6 +2329,8 @@ export function trackDrizzleWork(event, work) { return work }
2249
2329
  }
2250
2330
  addNitroPlugin(resolve$1(getNitroVersion(nuxt) === 3 ? "./runtime/server/plugins/link-header-nitro3" : "./runtime/server/plugins/link-header"));
2251
2331
  addNitroPlugin(resolve$1("./runtime/server/plugins/markdown-negotiation"));
2332
+ if (llmsTxtDirective.html)
2333
+ addNitroPlugin(resolve$1("./runtime/server/plugins/llms-txt-directive-html"));
2252
2334
  addPlugin({
2253
2335
  mode: "server",
2254
2336
  src: resolve$1("./runtime/app/plugins/md-alternate.server")
@@ -2402,13 +2484,12 @@ export function trackDrizzleWork(event, work) { return work }
2402
2484
  const assets = wrangler.assets ||= {};
2403
2485
  const fileRouting = await readCloudflareAssetRouting(nitro.options.rootDir);
2404
2486
  const routing = defu$1({ value: assets.run_worker_first }, { value: fileRouting.root }).value;
2405
- cloudflareNegotiationPatterns(routing, pages);
2487
+ const additions = cloudflareWorkerFirstAdditions(routing, pages);
2406
2488
  const environmentConfig = wrangler;
2407
2489
  const configuredEnvironments = Object.values(environmentConfig.env || {}).flatMap((env) => env.assets?.run_worker_first === void 0 ? [] : [env.assets.run_worker_first]);
2408
2490
  const fallbackPaths = cloudflareStaticFallbackPaths(routing, pages, [...fileRouting.environments, ...configuredEnvironments]);
2409
- const addedPaths = cloudflareStaticFallbackPaths(routing, pages);
2410
- if (addedPaths.length > 0)
2411
- assets.run_worker_first = [...Array.isArray(assets.run_worker_first) ? assets.run_worker_first : [], ...addedPaths];
2491
+ if (additions.length > 0)
2492
+ assets.run_worker_first = [...Array.isArray(assets.run_worker_first) ? assets.run_worker_first : [], ...additions];
2412
2493
  nitro.options.runtimeConfig["nuxt-ai-ready"].cloudflarePrerenderedPages = fallbackPaths;
2413
2494
  }
2414
2495
  const plan = planStaticMarkdownHeaderRules(
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-ai-ready",
3
3
  "type": "module",
4
- "version": "3.0.4",
4
+ "version": "3.2.0",
5
5
  "description": "Best practice AI & LLM discoverability for Nuxt sites.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -39,8 +39,9 @@ With no `aiReady` config, the module adds:
39
39
  - `/sitemap.md`, and a `## Sitemap` footer on every generated `.md` page. Set `sitemapMd: false` to remove both.
40
40
  - Nuxt Content v3 `page` collections: the `.md` route serves the source Markdown, not converted HTML. Set `contentSource: false` to convert HTML.
41
41
  - Agent skills: `public/skills/<name>/SKILL.md` in the project or a local layer is published at `/.well-known/agent-skills/`, `/skills/<name>/SKILL.md`, and in `llms.txt`. Custom public directories are supported. One local skill is also served at `/SKILL.md`. Set `agentSkills: false` to publish nothing.
42
+ - `Content-Signal: search=yes, ai-input=yes` in `robots.txt`. Set `contentSignal: false` to remove it.
42
43
 
43
- Off by default: Content Signals, MCP, WebMCP, runtime sync, cron, and the database.
44
+ Off by default: MCP, WebMCP, runtime sync, cron, and the database.
44
45
 
45
46
  ## Common tasks
46
47
 
@@ -76,14 +77,14 @@ export default defineNitroPlugin((nitroApp) => {
76
77
  })
77
78
  ```
78
79
 
79
- Allow Content Signals. An omitted permission renders as `no`:
80
+ Content Signals. Without config, robots.txt gets `Content-Signal: search=yes, ai-input=yes` and no `ai-train`. A robots group or merged robots.txt that already sets `Content-Signal` replaces the default. `contentSignal: false` removes the line. An object states the whole policy, so an omitted permission renders as `no`:
80
81
 
81
82
  ```ts
82
- export default defineNuxtConfig({ aiReady: { contentSignal: { search: true } } })
83
- // robots.txt: Content-Signal: ai-train=no, search=yes, ai-input=no
83
+ export default defineNuxtConfig({ aiReady: { contentSignal: { search: true, aiInput: true, aiTrain: false } } })
84
+ // robots.txt: Content-Signal: ai-train=no, search=yes, ai-input=yes
84
85
  ```
85
86
 
86
- MCP: install `@nuxtjs/mcp-toolkit` and add it to `modules`. The module then registers `list_pages`, `search_pages`, and `get_page_markdown` at `/mcp`. These tools need the database, so also run `npx nypm add drizzle-orm@1.0.0-rc.4`. MCP needs a running server; `nuxt generate` output cannot host it.
87
+ MCP: install `@nuxtjs/mcp-toolkit` and add it to `modules`. The module then registers `list_pages`, `search_pages`, and `get_page_markdown` at `/mcp`. These tools need the database, so also run `npx nypm add drizzle-orm@1.0.0-rc.4`. MCP needs a running server; `nuxt generate` output cannot host it. The server card is served at `/mcp/server-card`, `/.well-known/mcp/server-card.json`, and `/.well-known/mcp.json`. llms.txt links the card, never the endpoint, because the endpoint answers GET with 405 or 406. `mcpServerCard: false` removes the card routes and the llms.txt entry.
87
88
 
88
89
  WebMCP: set `webmcp: true`. This registers the same three tools in the browser and auto-imports `useWebMcpTool()` and `useWebMcpSupported()`. The tools need the database and `drizzle-orm`. Without `webmcp`, those composables are not defined.
89
90
 
@@ -94,6 +95,7 @@ Runtime indexing, database drivers, Cloudflare D1, and the server query helpers:
94
95
  - **The same edit in both hooks applies twice.** During prerendering the Nitro `ai-ready:page:markdown` hook runs, then the Nuxt hook of the same name. Put an edit in one of them, not both.
95
96
  - **Content source pages skip conversion hooks.** A Nuxt Content page ignores `mdreamOptions` and both Nitro hooks.
96
97
  - **Dev has no page data.** Query helpers, MCP tools, and search return empty results in `nuxt dev`. Test with `nuxt build` and `node .output/server/index.mjs`.
98
+ - **Cloudflare static pages negotiate only with `contentNegotiation: true`.** The default leaves prerendered HTML on the asset service, so `Accept: text/markdown` gets HTML there. With `true`, prerendered pages go into `assets.run_worker_first`: exact paths up to Cloudflare's 100 entries, directory globs such as `/docs/*` past that. More than 100 top-level pages fail the build; set broader patterns yourself.
97
99
  - **Response caching turns negotiation off.** An ISR, `swr`, or `cache` route rule without `varies: ['accept']` serves HTML to Markdown clients. With `botNegotiation: true`, the rule needs `varies: ['accept', 'sec-fetch-dest', 'user-agent']`. The `.md` URL still works. For a CDN that caches by URL only, set `contentNegotiation: false`.
98
100
  - **Emptying `ctx.markdown` does not hide a page.** It drops the body from `llms-full.txt`. The URL stays in `llms.txt` and in the index. Use `sitemap.exclude` for discovery, and access control for private pages.
99
101
  - **Root `skills/` folders require explicit publication.** Automatic discovery scans public directories only. Set `agentSkills.dir: 'skills'` to publish root skills intentionally.
@@ -124,7 +126,8 @@ Runtime indexing, database drivers, Cloudflare D1, and the server query helpers:
124
126
 
125
127
  ## Config
126
128
 
127
- - `llmsTxt.markdownLinks` (`false`): link page entries in `llms.txt` to their `.md` twins.
129
+ - `llmsTxt.directive` (`{ markdown: true, html: false }`): a `> Index of every page on this site: [llms.txt](...)` line after the frontmatter of each page's `.md`; `html: true` adds a visually hidden `/llms.txt` link at the top of each HTML body.
130
+ - `llmsTxt.markdownLinks` (`true`): link page entries in `llms.txt` to their `.md` twins. `false` links canonical URLs. With `site.url` set, page links in `llms.txt`, `sitemap.md`, and `.md` bodies are absolute.
128
131
  - `describedby` (`true`): the `describedby` link tag and header.
129
132
  - `tools.<listPages|searchPages|getPageMarkdown>.<mcp|webmcp>.enabled`: attach a built in tool to one transport only.
130
133
  - `autoI18n` (`true`): with `@nuxtjs/i18n`, adds hreflang `Link` headers and a languages section in `llms.txt`.