nuxt-ai-ready 3.0.3 → 3.1.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.CsGXtlrU.mjs';
5
+ import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.Dmvy7CIJ.mjs';
6
6
  import 'node:module';
7
7
  import 'node:url';
8
8
  import '@nuxt/kit';
@@ -11,12 +11,12 @@ import 'nuxt-site-config/kit';
11
11
  import 'nuxtseo-shared/kit';
12
12
  import '../shared/nuxt-ai-ready.B3hAuOLQ.mjs';
13
13
  import 'node:fs';
14
- import '../../dist/runtime/markdown-path.js';
15
14
  import '../../dist/runtime/route-path.js';
16
15
  import '../../dist/runtime/server/utils/sitemap-md.js';
17
16
  import '../../dist/runtime/cache-control.js';
18
17
  import '../../dist/runtime/server/utils/discovery-response.js';
19
18
  import 'confbox';
19
+ import '../../dist/runtime/markdown-path.js';
20
20
  import 'nuxtseo-shared/server';
21
21
  import '../../dist/runtime/server/utils/content-negotiation.js';
22
22
  import 'pathe';
@@ -6,7 +6,7 @@ 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.CsGXtlrU.mjs';
9
+ import { l as logger, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.Dmvy7CIJ.mjs';
10
10
  import { normalizePagePath, toMarkdownPath } from '../../dist/runtime/markdown-path.js';
11
11
  import { toDeployedRoute, toLogicalRoute } from '../../dist/runtime/route-path.js';
12
12
  import { initSchema, computeContentHash, insertPage, queryAllPages, exportDbDump } from '../../dist/runtime/server/db/shared.js';
@@ -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.3",
7
+ "version": "3.1.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -7,14 +7,14 @@ 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.CsGXtlrU.mjs';
10
+ export { m as default } from './shared/nuxt-ai-ready.Dmvy7CIJ.mjs';
11
11
  import './shared/nuxt-ai-ready.B3hAuOLQ.mjs';
12
- import '../dist/runtime/markdown-path.js';
13
12
  import '../dist/runtime/route-path.js';
14
13
  import '../dist/runtime/server/utils/sitemap-md.js';
15
14
  import '../dist/runtime/server/utils/discovery-response.js';
16
15
  import '../dist/runtime/cache-control.js';
17
16
  import 'confbox';
17
+ import '../dist/runtime/markdown-path.js';
18
18
  import 'nuxtseo-shared/server';
19
19
  import '../dist/runtime/server/utils/content-negotiation.js';
20
20
  import 'pathe';
@@ -87,6 +87,12 @@ ${contentPage.markdown}`;
87
87
  message: `Failed to fetch HTML for ${path}`
88
88
  });
89
89
  }
90
+ if (!response.headers.get("content-type")?.includes("text/html")) {
91
+ return createError({
92
+ statusCode: 404,
93
+ message: `Not an HTML page: ${path}`
94
+ });
95
+ }
90
96
  html = await response.text();
91
97
  logger.debug(`[markdown.prerender] Fetched HTML for ${path} (${html.length} bytes)`);
92
98
  consumePrerenderedHtml(path);
@@ -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
  /**
@@ -8,12 +8,12 @@ import defu$1, { defu } from 'defu';
8
8
  import { useSiteConfig, withSiteUrl } from 'nuxt-site-config/kit';
9
9
  import { resolveNuxtContentVersion, renderNitroTypeAugmentations, setupRuntimeAliases, setupNitroRuntimeCompatibility } from 'nuxtseo-shared/kit';
10
10
  import { r as readPackageMetadata, a as resolvePackageMetadataPath } from './nuxt-ai-ready.B3hAuOLQ.mjs';
11
- import { hasMarkdownTwin, normalizePagePath, toMarkdownPath } from '../../dist/runtime/markdown-path.js';
12
11
  import { toDeployedRoute } from '../../dist/runtime/route-path.js';
13
12
  import { SITEMAP_MD_ROUTE } from '../../dist/runtime/server/utils/sitemap-md.js';
14
13
  import { publicCacheControl, mergeVaryHeader } from '../../dist/runtime/cache-control.js';
15
14
  import { MCP_SERVER_CARD_MEDIA_TYPE, AI_CATALOG_MEDIA_TYPE } from '../../dist/runtime/server/utils/discovery-response.js';
16
15
  import { parseTOML, parseJSONC } from 'confbox';
16
+ import { toMarkdownPath, hasMarkdownTwin, normalizePagePath } from '../../dist/runtime/markdown-path.js';
17
17
  import { createNitroRouteRuleMatcher } from 'nuxtseo-shared/server';
18
18
  import { getContentNegotiationVary, resolveContentNegotiation } from '../../dist/runtime/server/utils/content-negotiation.js';
19
19
  import { isAbsolute, join as join$1 } from 'pathe';
@@ -748,6 +748,58 @@ function resolveConfigurationWarnings(input) {
748
748
  return warnings;
749
749
  }
750
750
 
751
+ const DEFAULT_CONTENT_SIGNAL = "search=yes, ai-input=yes";
752
+ function resolveContentSignalPolicy(option) {
753
+ if (option === false)
754
+ return { _tag: "Disabled" };
755
+ if (option === void 0)
756
+ return { _tag: "Default" };
757
+ const value = (allowed) => allowed ? "yes" : "no";
758
+ return {
759
+ _tag: "Configured",
760
+ // One comma-separated line: validators reject one line per signal.
761
+ contentSignal: `ai-train=${value(option.aiTrain)}, search=${value(option.search)}, ai-input=${value(option.aiInput)}`,
762
+ ...option.contentUsage !== false && { contentUsage: `train-ai=${option.aiTrain ? "y" : "n"}` }
763
+ };
764
+ }
765
+ function hasContentSignal(value) {
766
+ if (Array.isArray(value))
767
+ return value.some(Boolean);
768
+ if (value && typeof value === "object")
769
+ return Object.keys(value).length > 0;
770
+ return Boolean(value);
771
+ }
772
+ function isWildcardGroup(group) {
773
+ const userAgents = Array.isArray(group.userAgent) ? group.userAgent : [group.userAgent ?? "*"];
774
+ return userAgents.length === 1 && userAgents[0] === "*";
775
+ }
776
+ function wildcardGroup(groups) {
777
+ const existing = groups.find(isWildcardGroup);
778
+ if (existing)
779
+ return existing;
780
+ const group = { userAgent: ["*"], disallow: [""] };
781
+ groups.push(group);
782
+ return group;
783
+ }
784
+ function applyContentSignalPolicy(groups, policy) {
785
+ switch (policy._tag) {
786
+ case "Disabled":
787
+ return;
788
+ case "Default": {
789
+ if (groups.some((group) => hasContentSignal(group.contentSignal)))
790
+ return;
791
+ wildcardGroup(groups).contentSignal = [DEFAULT_CONTENT_SIGNAL];
792
+ return;
793
+ }
794
+ case "Configured": {
795
+ const group = wildcardGroup(groups);
796
+ group.contentSignal = [policy.contentSignal];
797
+ if (policy.contentUsage)
798
+ group.contentUsage = [policy.contentUsage];
799
+ }
800
+ }
801
+ }
802
+
751
803
  const DATABASE_TYPE_NONE = "none";
752
804
  const DATABASE_DEPENDENT_OPTIONS = ["runtimeSync", "cron"];
753
805
  const DATABASE_CONSUMER_LABELS = {
@@ -1071,6 +1123,7 @@ function parseMcpServerCardConfig(input) {
1071
1123
  }
1072
1124
  };
1073
1125
  }
1126
+ const MCP_SERVER_CARD_WELL_KNOWN_PATHS = ["/.well-known/mcp/server-card.json", "/.well-known/mcp.json"];
1074
1127
  function resolveMcpServerCardRoute(mcpRoute) {
1075
1128
  return `${mcpRoute.replace(/\/+$/, "") || "/mcp"}/server-card`;
1076
1129
  }
@@ -1516,6 +1569,8 @@ function resolveWebMcpConfig(input, toolsConfig) {
1516
1569
  };
1517
1570
  }
1518
1571
 
1572
+ const MODULE_PACKAGE_NAME = "nuxt-ai-ready";
1573
+ const RE_SITEMAPD_PACKAGE = /[/\\]node_modules[/\\]sitemapd[/\\]/;
1519
1574
  const module$1 = defineNuxtModule({
1520
1575
  meta: {
1521
1576
  name: "nuxt-ai-ready",
@@ -1742,25 +1797,11 @@ ${details}`);
1742
1797
  (paths.resources ||= []).push(`${mcpRuntimeDir}/resources`);
1743
1798
  });
1744
1799
  const contentSource = await resolveContentSource(config.contentSource !== false);
1745
- if (typeof config.contentSignal === "object") {
1746
- const robotsOpts = nuxt.options.robots || {};
1747
- nuxt.options.robots = robotsOpts;
1748
- const groups = robotsOpts.groups || [];
1749
- robotsOpts.groups = groups;
1750
- const group = {
1751
- userAgent: "*",
1752
- // Preserve nuxt-robots' default wildcard rule so the injected group remains valid.
1753
- disallow: [""],
1754
- // Object form renders one comma-separated line; an array renders one line per entry, which validators reject.
1755
- contentSignal: {
1756
- "ai-train": config.contentSignal.aiTrain ? "yes" : "no",
1757
- "search": config.contentSignal.search ? "yes" : "no",
1758
- "ai-input": config.contentSignal.aiInput ? "yes" : "no"
1759
- }
1760
- };
1761
- if (config.contentSignal.contentUsage !== false)
1762
- group.contentUsage = [`train-ai=${config.contentSignal.aiTrain ? "y" : "n"}`];
1763
- groups.push(group);
1800
+ const contentSignalPolicy = resolveContentSignalPolicy(config.contentSignal);
1801
+ if (contentSignalPolicy._tag !== "Disabled") {
1802
+ nuxt.hook("robots:config", (robotsConfig) => {
1803
+ applyContentSignalPolicy(robotsConfig.groups, contentSignalPolicy);
1804
+ });
1764
1805
  }
1765
1806
  registerTypeTemplates({ config, nitroCompatibility });
1766
1807
  const defaultLlmsTxtSections = [];
@@ -1874,18 +1915,21 @@ ${details}`);
1874
1915
  updateSeoProFeatures(seoProModules);
1875
1916
  return;
1876
1917
  }
1877
- const mcpLink = {
1878
- title: "MCP",
1879
- href: withSiteUrl(finalMcpToolkitState.route, { withBase: true }),
1880
- 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."
1881
- };
1882
- const firstSection = mergedLlmsTxt.sections?.[0];
1883
- if (firstSection) {
1884
- firstSection.links ||= [];
1885
- if (!firstSection.links.some((link) => link.title === mcpLink.title))
1886
- firstSection.links.push(mcpLink);
1887
- } else {
1888
- mergedLlmsTxt.sections = [{ title: "LLM Tools", links: [mcpLink] }];
1918
+ if (mcpServerCardResult._tag === "Enabled") {
1919
+ const endpoint = withSiteUrl(finalMcpToolkitState.route, { withBase: true });
1920
+ const mcpLink = {
1921
+ title: "MCP server card",
1922
+ href: withSiteUrl(MCP_SERVER_CARD_WELL_KNOWN_PATHS[0].slice(1), { withBase: true }),
1923
+ 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.`
1924
+ };
1925
+ const firstSection = mergedLlmsTxt.sections?.[0];
1926
+ if (firstSection) {
1927
+ firstSection.links ||= [];
1928
+ if (!firstSection.links.some((link) => link.title === mcpLink.title))
1929
+ firstSection.links.push(mcpLink);
1930
+ } else {
1931
+ mergedLlmsTxt.sections = [{ title: "LLM Tools", links: [mcpLink] }];
1932
+ }
1889
1933
  }
1890
1934
  const mcpServerCardRoute = resolveMcpServerCardRoute(finalMcpToolkitState.route);
1891
1935
  if (siteConfig.url && config.apiCatalog !== false) {
@@ -1962,21 +2006,23 @@ ${details}`);
1962
2006
  etag
1963
2007
  };
1964
2008
  const handler = resolve$1("./runtime/server/routes/mcp-server-card");
1965
- addServerHandler({ route: mcpServerCardRoute, method: "get", handler, lazy: true });
1966
- addServerHandler({ route: mcpServerCardRoute, method: "head", handler, lazy: true });
1967
- addServerHandler({ route: mcpServerCardRoute, method: "options", handler, lazy: true });
1968
- extendRouteRules(mcpServerCardRoute, {
1969
- sitemap: false,
1970
- headers: {
1971
- "Access-Control-Allow-Headers": "Content-Type, If-None-Match",
1972
- "Access-Control-Allow-Methods": "GET, HEAD",
1973
- "Access-Control-Allow-Origin": "*",
1974
- "Access-Control-Expose-Headers": "ETag",
1975
- "Cache-Control": `public, max-age=${mcpServerCardResult.config.cacheMaxAge}`,
1976
- "Content-Type": MCP_SERVER_CARD_MEDIA_TYPE,
1977
- "ETag": etag
1978
- }
1979
- });
2009
+ for (const route of [mcpServerCardRoute, ...MCP_SERVER_CARD_WELL_KNOWN_PATHS]) {
2010
+ addServerHandler({ route, method: "get", handler, lazy: true });
2011
+ addServerHandler({ route, method: "head", handler, lazy: true });
2012
+ addServerHandler({ route, method: "options", handler, lazy: true });
2013
+ extendRouteRules(route, {
2014
+ sitemap: false,
2015
+ headers: {
2016
+ "Access-Control-Allow-Headers": "Content-Type, If-None-Match",
2017
+ "Access-Control-Allow-Methods": "GET, HEAD",
2018
+ "Access-Control-Allow-Origin": "*",
2019
+ "Access-Control-Expose-Headers": "ETag",
2020
+ "Cache-Control": `public, max-age=${mcpServerCardResult.config.cacheMaxAge}`,
2021
+ "Content-Type": MCP_SERVER_CARD_MEDIA_TYPE,
2022
+ "ETag": etag
2023
+ }
2024
+ });
2025
+ }
1980
2026
  if (siteConfig.url) {
1981
2027
  const document = resolveAiCatalog({
1982
2028
  siteUrl: siteConfig.url,
@@ -2031,8 +2077,12 @@ ${details}`);
2031
2077
  nuxt.hooks.hook("nitro:config", (nitroConfig) => {
2032
2078
  if (getNitroVersion(nuxt) === 3) {
2033
2079
  const nativeConfig = nitroConfig;
2034
- if (nativeConfig.noExternals !== true)
2035
- nativeConfig.noExternals = [...nativeConfig.noExternals || [], "nuxt-ai-ready"];
2080
+ if (nativeConfig.noExternals !== true) {
2081
+ nativeConfig.noExternals = [
2082
+ ...(nativeConfig.noExternals || []).filter((entry) => entry !== MODULE_PACKAGE_NAME),
2083
+ resolve$1("./runtime")
2084
+ ];
2085
+ }
2036
2086
  } else {
2037
2087
  const externals = nitroConfig.externals ||= {};
2038
2088
  externals.inline ||= [];
@@ -2047,7 +2097,7 @@ ${details}`);
2047
2097
  if (nitroCompatibility._tag === "nitro-v3") {
2048
2098
  const nitro3Config = nitroConfig;
2049
2099
  if (nitro3Config.noExternals !== true)
2050
- nitro3Config.noExternals = [...nitro3Config.noExternals || [], "sitemapd"];
2100
+ nitro3Config.noExternals = [...nitro3Config.noExternals || [], RE_SITEMAPD_PACKAGE];
2051
2101
  if (!isEdgePreset) {
2052
2102
  nitro3Config.traceDeps ||= [];
2053
2103
  nitro3Config.traceDeps.push("mdream*");
@@ -2182,7 +2232,7 @@ export function trackDrizzleWork(event, work) { return work }
2182
2232
  const schemaPath = database._tag === "Enabled" && (database.type === "neon" || database.type === "postgres") ? "#ai-ready/server/db/schema/postgres" : "#ai-ready/server/db/schema/sqlite";
2183
2233
  nitroConfig.virtual["#ai-ready-virtual/db-schema.mjs"] = database._tag === "Enabled" ? `export * from '${schemaPath}'` : "export {}";
2184
2234
  nitroConfig.virtual["#ai-ready-virtual/devtools-meta.mjs"] = `export const devtoolsMeta = ${JSON.stringify({
2185
- contentSignal: config.contentSignal || false,
2235
+ contentSignal: config.contentSignal ?? { search: true, aiInput: true },
2186
2236
  mcp: { enabled: mcpAvailable, tools: mcpAvailable && config.mcp?.tools !== false && hasMcpSiteTools, resources: mcpAvailable && config.mcp?.resources !== false },
2187
2237
  cron: !!config.cron
2188
2238
  })}`;
@@ -2379,17 +2429,6 @@ export function trackDrizzleWork(event, work) { return work }
2379
2429
  nitro.hooks.hook("rollup:reload", () => warnConfiguration(nitro));
2380
2430
  });
2381
2431
  nuxt.hook("nitro:build:before", warnConfiguration);
2382
- for (const route of nuxt.options.nitro.prerender?.routes || []) {
2383
- if (!isStaticMarkdownSourceRoute(route))
2384
- continue;
2385
- extendRouteRules(toMarkdownPath(route), {
2386
- headers: {
2387
- "Content-Type": "text/markdown; charset=utf-8",
2388
- "Link": buildStaticMarkdownLinkHeader(route, staticBaseURL, config.describedby !== false),
2389
- "Vary": "Accept"
2390
- }
2391
- });
2392
- }
2393
2432
  let sharedMarkdownRuleNoted = false;
2394
2433
  const noteSharedMarkdownRule = (total, dropped) => {
2395
2434
  if (sharedMarkdownRuleNoted)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-ai-ready",
3
3
  "type": "module",
4
- "version": "3.0.3",
4
+ "version": "3.1.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