nuxt-ai-ready 2.2.0 → 2.3.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.
@@ -1,8 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { realpath, readFile } from 'node:fs/promises';
3
- import { resolve, isAbsolute, relative, sep } from 'node:path';
2
+ import { realpath, readFile, readdir } from 'node:fs/promises';
3
+ import { resolve, isAbsolute, join, relative, sep } from 'node:path';
4
4
  import { parseDocument } from 'yaml';
5
- import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.D5URd0TD.mjs';
5
+ import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.B8hPhZL9.mjs';
6
6
  import 'node:module';
7
7
  import '@nuxt/kit';
8
8
  import 'defu';
@@ -23,9 +23,28 @@ const urlBase = "https://example.com/.well-known/agent-skills/index.json";
23
23
  function skillRoute(name) {
24
24
  return `/.well-known/agent-skills/${name}/SKILL.md`;
25
25
  }
26
+ const ROOT_SKILL_ALIAS = "/SKILL.md";
27
+ function aliasesOf(skill) {
28
+ return skill.alias === void 0 ? [] : Array.isArray(skill.alias) ? skill.alias : [skill.alias];
29
+ }
26
30
  function skillUrl(name) {
27
31
  return `${name}/SKILL.md`;
28
32
  }
33
+ function validateAlias(alias, index, sitemapMd) {
34
+ if (alias === void 0)
35
+ return [];
36
+ if (Array.isArray(alias))
37
+ return alias.flatMap((entry) => validateAlias(entry, index, sitemapMd));
38
+ if (typeof alias !== "string" || !/^\/(?:[^/?#\s]+\/)*[^/?#\s]+\.md$/.test(alias) || alias.split("/").includes(".."))
39
+ return [{ index, field: "alias", message: 'must be a path-absolute route ending in .md, such as "/SKILL.md"' }];
40
+ if (alias.startsWith("/.well-known/"))
41
+ return [{ index, field: "alias", message: "must not use the /.well-known/ prefix reserved for discovery routes" }];
42
+ if (alias === "/index.md")
43
+ return [{ index, field: "alias", message: 'must not use the module-owned markdown route "/index.md"' }];
44
+ if (sitemapMd && alias === "/sitemap.md")
45
+ return [{ index, field: "alias", message: 'must not use the module-owned markdown route "/sitemap.md"' }];
46
+ return [];
47
+ }
29
48
  function isRecord(value) {
30
49
  return typeof value === "object" && value !== null && !Array.isArray(value);
31
50
  }
@@ -51,7 +70,7 @@ function validateCommonFields(skill, index) {
51
70
  }
52
71
  return issues;
53
72
  }
54
- function validateSkill(skill, index) {
73
+ function validateSkill(skill, index, sitemapMd) {
55
74
  if (!isRecord(skill)) {
56
75
  return [{ index, field: "source", message: "must be a local or external skill entry" }];
57
76
  }
@@ -59,6 +78,7 @@ function validateSkill(skill, index) {
59
78
  if (skill.source === "local") {
60
79
  if (typeof skill.file !== "string" || skill.file.trim().length === 0)
61
80
  issues.push({ index, field: "file", message: "must be a non-empty path relative to the Nuxt root directory" });
81
+ issues.push(...validateAlias(skill.alias, index, sitemapMd));
62
82
  return issues;
63
83
  }
64
84
  if (skill.source === "external") {
@@ -83,33 +103,25 @@ function resolveExternalEntry(skill) {
83
103
  digest: skill.digest
84
104
  };
85
105
  }
86
- function parseLocalSkillMetadata(content, skill, index) {
106
+ function readSkillFrontmatter(content) {
87
107
  const match = content.match(/^\uFEFF?---[\t ]*\r?\n([\s\S]*?)\r?\n---[\t ]*(?:\r?\n|$)/);
88
- if (!match?.[1]) {
89
- return [{
90
- index,
91
- field: "file",
92
- message: "must contain YAML frontmatter with name and description fields"
93
- }];
94
- }
108
+ if (!match?.[1])
109
+ return { _tag: "Err", message: "must contain YAML frontmatter with name and description fields" };
95
110
  const document = parseDocument(match[1], { prettyErrors: false });
96
- if (document.errors.length > 0) {
97
- return [{
98
- index,
99
- field: "file",
100
- message: `contains invalid YAML frontmatter: ${document.errors[0]?.message || "unknown YAML error"}`
101
- }];
102
- }
111
+ if (document.errors.length > 0)
112
+ return { _tag: "Err", message: `contains invalid YAML frontmatter: ${document.errors[0]?.message || "unknown YAML error"}` };
103
113
  const metadata = document.toJS();
104
- if (!isRecord(metadata)) {
105
- return [{
106
- index,
107
- field: "file",
108
- message: "frontmatter must be a YAML mapping with name and description fields"
109
- }];
110
- }
114
+ if (!isRecord(metadata))
115
+ return { _tag: "Err", message: "frontmatter must be a YAML mapping with name and description fields" };
116
+ return { _tag: "Ok", metadata, hasBody: content.slice(match[0].length).trim().length > 0 };
117
+ }
118
+ function parseLocalSkillMetadata(content, skill, index) {
119
+ const frontmatter = readSkillFrontmatter(content);
120
+ if (frontmatter._tag === "Err")
121
+ return [{ index, field: "file", message: frontmatter.message }];
122
+ const { metadata } = frontmatter;
111
123
  const issues = [];
112
- if (!content.slice(match[0].length).trim()) {
124
+ if (!frontmatter.hasBody) {
113
125
  issues.push({
114
126
  index,
115
127
  field: "file",
@@ -174,7 +186,7 @@ async function resolveLocalEntry(skill, index, rootDir) {
174
186
  issues: metadataIssues
175
187
  };
176
188
  }
177
- const route = skillRoute(skill.name);
189
+ const routes = [skillRoute(skill.name), ...aliasesOf(skill)];
178
190
  const digest = `sha256:${createHash("sha256").update(content).digest("hex")}`;
179
191
  return {
180
192
  _tag: "Resolved",
@@ -185,7 +197,7 @@ async function resolveLocalEntry(skill, index, rootDir) {
185
197
  url: skillUrl(skill.name),
186
198
  digest
187
199
  },
188
- route,
200
+ routes,
189
201
  content: text
190
202
  };
191
203
  });
@@ -198,40 +210,57 @@ async function resolveLocalEntry(skill, index, rootDir) {
198
210
  }]
199
211
  }));
200
212
  }
201
- async function resolveAgentSkillsConfig(config, rootDir) {
213
+ async function resolveAgentSkillsConfig(config, rootDir, options = {}) {
202
214
  if (config === false || config === void 0)
203
215
  return { _tag: "Disabled" };
204
- if (!isRecord(config) || !Array.isArray(config.skills)) {
216
+ if (!isRecord(config) || config.skills !== void 0 && !Array.isArray(config.skills)) {
205
217
  return {
206
218
  _tag: "Invalid",
207
- issues: [{ field: "agentSkills", message: "must contain a skills array" }]
219
+ issues: [{ field: "agentSkills", message: "skills must be an array when set" }]
208
220
  };
209
221
  }
210
- const issues = config.skills.flatMap((skill, index) => validateSkill(skill, index));
222
+ const configuredSkills = config.skills ?? [];
223
+ if (configuredSkills.length === 0)
224
+ return { _tag: "Disabled" };
225
+ const sitemapMd = options.sitemapMd !== false;
226
+ const issues = configuredSkills.flatMap((skill, index) => validateSkill(skill, index, sitemapMd));
211
227
  const seenNames = /* @__PURE__ */ new Set();
212
- for (const [index, skill] of config.skills.entries()) {
228
+ const seenAliases = /* @__PURE__ */ new Set();
229
+ for (const [index, skill] of configuredSkills.entries()) {
213
230
  if (!isRecord(skill) || typeof skill.name !== "string" || seenNames.has(skill.name)) {
214
231
  if (isRecord(skill) && typeof skill.name === "string" && seenNames.has(skill.name))
215
232
  issues.push({ index, field: "name", message: `duplicates the skill name "${skill.name}"` });
216
233
  continue;
217
234
  }
218
235
  seenNames.add(skill.name);
236
+ for (const alias of aliasesOf(skill)) {
237
+ if (seenAliases.has(alias))
238
+ issues.push({ index, field: "alias", message: `duplicates the alias "${alias}"` });
239
+ seenAliases.add(alias);
240
+ }
219
241
  }
220
242
  if (issues.length > 0)
221
243
  return { _tag: "Invalid", issues };
222
- const skills = config.skills;
244
+ const skills = configuredSkills;
223
245
  const resolved = await Promise.all(skills.map((skill, index) => skill.source === "local" ? resolveLocalEntry(skill, index, rootDir) : Promise.resolve({ _tag: "Resolved", entry: resolveExternalEntry(skill) })));
224
246
  const fileIssues = resolved.flatMap((result) => result._tag === "Invalid" ? result.issues : []);
225
247
  if (fileIssues.length > 0)
226
248
  return { _tag: "Invalid", issues: fileIssues };
227
249
  const entries = [];
228
250
  const localArtifacts = {};
251
+ const links = [];
229
252
  for (const result of resolved) {
230
253
  if (result._tag !== "Resolved")
231
254
  continue;
232
255
  entries.push(result.entry);
233
- if ("route" in result)
234
- localArtifacts[result.route] = result.content;
256
+ if ("routes" in result) {
257
+ for (const route of result.routes)
258
+ localArtifacts[route] = result.content;
259
+ const [alias] = result.routes.slice(1).sort((a, b) => a.length - b.length);
260
+ links.push({ name: result.entry.name, description: result.entry.description, href: alias ?? result.routes[0] });
261
+ } else {
262
+ links.push({ name: result.entry.name, description: result.entry.description, href: result.entry.url });
263
+ }
235
264
  }
236
265
  return {
237
266
  _tag: "Enabled",
@@ -239,8 +268,88 @@ async function resolveAgentSkillsConfig(config, rootDir) {
239
268
  $schema: AGENT_SKILLS_SCHEMA,
240
269
  skills: entries
241
270
  },
242
- localArtifacts
271
+ localArtifacts,
272
+ links
273
+ };
274
+ }
275
+ function toPosix(path) {
276
+ return path.split(sep).join("/");
277
+ }
278
+ async function discoverAgentSkills(options) {
279
+ const dir = options.dir.replace(/^\.?\/+/, "").replace(/\/+$/, "");
280
+ const skills = [];
281
+ const issues = [];
282
+ const seen = /* @__PURE__ */ new Set();
283
+ for (const scanDir of options.scanDirs) {
284
+ const base = resolve(scanDir, dir);
285
+ if (!isWithinDirectory(options.rootDir, base) && base !== options.rootDir)
286
+ continue;
287
+ const entries = await readdir(base, { withFileTypes: true }).catch(() => {
288
+ return [];
289
+ });
290
+ for (const entry of entries.filter((entry2) => entry2.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
291
+ if (seen.has(entry.name))
292
+ continue;
293
+ const file = join(base, entry.name, "SKILL.md");
294
+ const content = await readFile(file, "utf8").catch(() => {
295
+ return null;
296
+ });
297
+ if (content === null)
298
+ continue;
299
+ const relativeFile = toPosix(relative(options.rootDir, file));
300
+ const frontmatter = readSkillFrontmatter(content);
301
+ if (frontmatter._tag === "Err") {
302
+ issues.push({ field: "file", message: `${relativeFile} ${frontmatter.message}` });
303
+ continue;
304
+ }
305
+ const { name, description } = frontmatter.metadata;
306
+ if (name !== entry.name) {
307
+ issues.push({ field: "name", message: `${relativeFile} frontmatter name "${String(name)}" must equal its directory name "${entry.name}"` });
308
+ continue;
309
+ }
310
+ if (typeof description !== "string" || description.length === 0) {
311
+ issues.push({ field: "description", message: `${relativeFile} frontmatter must contain a description` });
312
+ continue;
313
+ }
314
+ seen.add(entry.name);
315
+ skills.push({
316
+ source: "local",
317
+ name: entry.name,
318
+ description,
319
+ file: relativeFile,
320
+ alias: `/${dir}/${entry.name}/SKILL.md`
321
+ });
322
+ }
323
+ }
324
+ return { skills, issues };
325
+ }
326
+ function mergeAgentSkills(discovered, configured) {
327
+ const overridden = new Set(configured.map((skill) => skill.name));
328
+ return [...discovered.filter((skill) => !overridden.has(skill.name)), ...configured];
329
+ }
330
+ function applyRootAlias(skills, root) {
331
+ if (root === false)
332
+ return { _tag: "Ok", skills: [...skills] };
333
+ const locals = skills.filter((skill) => skill.source === "local");
334
+ const target = root === void 0 ? locals.length === 1 ? locals[0] : void 0 : locals.find((skill) => skill.name === root);
335
+ if (root !== void 0 && !target)
336
+ return { _tag: "Invalid", issues: [{ field: "root", message: `names no local skill: "${root}"` }] };
337
+ if (!target || aliasesOf(target).includes(ROOT_SKILL_ALIAS))
338
+ return { _tag: "Ok", skills: [...skills] };
339
+ return {
340
+ _tag: "Ok",
341
+ skills: skills.map((skill) => skill === target ? { ...skill, alias: [...aliasesOf(skill), ROOT_SKILL_ALIAS] } : skill)
243
342
  };
244
343
  }
344
+ async function prepareAgentSkills(config, options) {
345
+ if (config.dir !== void 0 && config.dir !== false && typeof config.dir !== "string")
346
+ return { _tag: "Invalid", issues: [{ field: "dir", message: "must be a string or false when set" }] };
347
+ if (config.skills !== void 0 && !Array.isArray(config.skills))
348
+ return { _tag: "Invalid", issues: [{ field: "agentSkills", message: "skills must be an array when set" }] };
349
+ const discovered = config.dir === false ? { skills: [], issues: [] } : await discoverAgentSkills({ rootDir: options.rootDir, scanDirs: options.scanDirs, dir: config.dir ?? "skills" });
350
+ if (discovered.issues.length > 0)
351
+ return { _tag: "Invalid", issues: discovered.issues };
352
+ return { _tag: "Ok", skills: mergeAgentSkills(discovered.skills, config.skills ?? []) };
353
+ }
245
354
 
246
- export { AGENT_SKILLS_SCHEMA, resolveAgentSkillsConfig };
355
+ export { AGENT_SKILLS_SCHEMA, ROOT_SKILL_ALIAS, applyRootAlias, discoverAgentSkills, mergeAgentSkills, prepareAgentSkills, readSkillFrontmatter, resolveAgentSkillsConfig };
@@ -5,7 +5,7 @@ import { colorize } from 'consola/utils';
5
5
  import { resolveLocaleFromRoute } from 'nuxtseo-shared/i18n-runtime';
6
6
  import { collectSitemap } from 'sitemapd/parse';
7
7
  import { withLeadingSlash, joinURL, withBase } from 'ufo';
8
- import { l as logger, s as supportsNativeNodeSqlite, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.D5URd0TD.mjs';
8
+ import { l as logger, s as supportsNativeNodeSqlite, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.B8hPhZL9.mjs';
9
9
  import { normalizePagePath, toMarkdownPath } from '../../dist/runtime/markdown-path.js';
10
10
  import { toDeployedRoute, toLogicalRoute } from '../../dist/runtime/route-path.js';
11
11
  import { initSchema, computeContentHash, insertPage, queryAllPages, exportDbDump } from '../../dist/runtime/server/db/shared.js';
package/dist/module.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _nuxt_schema from '@nuxt/schema';
2
- import { LlmsTxtConfig, ContentNegotiationPolicy, ModuleOptions } from '../dist/runtime/types.js';
2
+ import { LlmsTxtConfig, AgentSkillConfig, ContentNegotiationPolicy, ModuleOptions } from '../dist/runtime/types.js';
3
3
  export { AgentSkillConfig, AgentSkillsConfig, AgentSkillsIndex, AgentSkillsIndexEntry, ApiCatalogConfig, ApiCatalogEntry, ApiCatalogLinkTarget, ApiCatalogLinks, ExternalAgentSkillConfig, LocalAgentSkillConfig, McpServerCardConfig, ModuleOptions } from '../dist/runtime/types.js';
4
4
  import { RuntimeI18nConfig } from 'nuxtseo-shared/i18n-runtime';
5
5
  import { ResolvedWebMcpToolsConfig } from '../dist/runtime/site-tool-config.js';
@@ -73,11 +73,21 @@ interface ModuleHooks {
73
73
  sections: LlmsTxtConfig['sections'];
74
74
  notes: string[];
75
75
  }) => void | Promise<void>;
76
+ /**
77
+ * Hook called with every agent skill about to be published: the ones
78
+ * discovered under `agentSkills.dir` merged with `agentSkills.skills`.
79
+ * Mutate the array to add external entries, drop a skill, or change its
80
+ * aliases. The root alias is applied after this hook.
81
+ */
82
+ 'ai-ready:agent-skills': (payload: {
83
+ skills: AgentSkillConfig[];
84
+ }) => void | Promise<void>;
76
85
  }
77
86
  declare module '@nuxt/schema' {
78
87
  interface NuxtHooks {
79
88
  'ai-ready:page:markdown': ModuleHooks['ai-ready:page:markdown'];
80
89
  'ai-ready:llms-txt': ModuleHooks['ai-ready:llms-txt'];
90
+ 'ai-ready:agent-skills': ModuleHooks['ai-ready:agent-skills'];
81
91
  }
82
92
  }
83
93
  interface ModulePublicRuntimeConfig {
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": ">=4.0.0"
5
5
  },
6
6
  "configKey": "aiReady",
7
- "version": "2.2.0",
7
+ "version": "2.3.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 'defu';
7
7
  import 'nuxt-site-config/kit';
8
8
  import 'nuxtseo-shared/kit';
9
9
  import 'pkg-types';
10
- export { m as default } from './shared/nuxt-ai-ready.D5URd0TD.mjs';
10
+ export { m as default } from './shared/nuxt-ai-ready.B8hPhZL9.mjs';
11
11
  import '../dist/runtime/markdown-path.js';
12
12
  import '../dist/runtime/server/utils/sitemap-md.js';
13
13
  import '../dist/runtime/server/utils/discovery-response.js';
@@ -9,6 +9,7 @@ import { fetchRawWithEvent } from "../utils/fetch.js";
9
9
  import { buildFrontmatter } from "../utils/frontmatter.js";
10
10
  import { extractKeywords } from "../utils/keywords.js";
11
11
  import { getMarkdownRenderInfo } from "../utils/markdown-request.js";
12
+ import { agentSkillArtifactPaths } from "../utils/negotiation-response.js";
12
13
  import { consumePrerenderedHtml } from "../utils/prerender-html.js";
13
14
  import { isSitemapMdRequest } from "../utils/sitemap-md.js";
14
15
  function extractHeadingsFromMarkdown(markdown) {
@@ -23,7 +24,7 @@ function extractHeadingsFromMarkdown(markdown) {
23
24
  return headings;
24
25
  }
25
26
  export default defineEventHandler(async (event) => {
26
- if (event.path.startsWith("/.well-known/"))
27
+ if (event.path.startsWith("/.well-known/") || agentSkillArtifactPaths().has(event.path))
27
28
  return;
28
29
  if (!import.meta.prerender) {
29
30
  return;
@@ -13,10 +13,15 @@ export interface NegotiationInput {
13
13
  request: MarkdownRequest;
14
14
  routeRule: NegotiationRouteRule;
15
15
  policy: ContentNegotiationPolicy;
16
+ /**
17
+ * Routes that already serve a Markdown file verbatim, such as an agent skill
18
+ * alias. They are answered by their own handler, never rendered.
19
+ */
20
+ artifactPaths?: ReadonlySet<string>;
16
21
  }
17
22
  export type NegotiationDecision = {
18
23
  _tag: 'skip';
19
- reason: 'well-known' | 'internal' | 'not-a-page' | 'deferred';
24
+ reason: 'well-known' | 'internal' | 'artifact' | 'not-a-page' | 'deferred';
20
25
  } | {
21
26
  _tag: 'not-acceptable';
22
27
  } | {
@@ -6,6 +6,8 @@ export function resolveNegotiationDecision(input) {
6
6
  const path = withoutQuery(request.path);
7
7
  if (path.startsWith("/.well-known/"))
8
8
  return { _tag: "skip", reason: "well-known" };
9
+ if (input.artifactPaths?.has(path))
10
+ return { _tag: "skip", reason: "artifact" };
9
11
  if (request.headers[INTERNAL_HEADER])
10
12
  return { _tag: "skip", reason: "internal" };
11
13
  const negotiation = resolveContentNegotiation({ policy: input.policy, routeRule: input.routeRule });
@@ -3,6 +3,8 @@ import type { ModulePublicRuntimeConfig } from '../../../module.js';
3
3
  import type { RuntimeRouteContext } from './i18n.js';
4
4
  import type { NegotiationDecision, NegotiationStage } from './negotiation-decision.js';
5
5
  import { sendRedirect } from '#nuxtseo/h3';
6
+ /** Routes an agent skill answers verbatim; negotiation must not render them. */
7
+ export declare function agentSkillArtifactPaths(): ReadonlySet<string>;
6
8
  export type LinkUrlResolver = (path: string) => string;
7
9
  export interface NegotiationContext {
8
10
  config: ModulePublicRuntimeConfig;
@@ -1,4 +1,5 @@
1
1
  import { createNitroRouteRuleMatcher } from "nuxtseo-shared/server";
2
+ import { localAgentSkillArtifacts } from "#ai-ready-virtual/agent-skills.mjs";
2
3
  import { appendHeader, createError, getRequestURL, getResponseHeader, sendRedirect, setHeader } from "#nuxtseo/h3";
3
4
  import { useRuntimeConfig } from "#nuxtseo/nitro";
4
5
  import { withSiteUrl } from "#site-config/server/composables/utils";
@@ -11,6 +12,11 @@ import { buildLinkHeader } from "./link-header.js";
11
12
  import { toMarkdownRequest } from "./markdown-request.js";
12
13
  import { resolveNegotiationDecision } from "./negotiation-decision.js";
13
14
  const APPLIED_KEY = "nuxt-ai-ready:negotiation-applied";
15
+ let artifactPaths;
16
+ export function agentSkillArtifactPaths() {
17
+ artifactPaths ??= new Set(Object.keys(localAgentSkillArtifacts));
18
+ return artifactPaths;
19
+ }
14
20
  export function setLinkHeader(event, ctx, variant) {
15
21
  setHeader(event, "link", buildLinkHeader(ctx.path, variant, ctx.config, ctx.resolveUrl, ctx.routeContext));
16
22
  }
@@ -74,7 +80,8 @@ export function decideNegotiation(event, stage) {
74
80
  stage,
75
81
  request: toMarkdownRequest(event),
76
82
  routeRule: getRouteRuleMatcher(runtimeConfig)(event.path),
77
- policy: config.contentNegotiation
83
+ policy: config.contentNegotiation,
84
+ artifactPaths: agentSkillArtifactPaths()
78
85
  });
79
86
  }
80
87
  export async function applyNegotiation(event, decision) {
@@ -46,6 +46,13 @@ export interface LocalAgentSkillConfig {
46
46
  description: string;
47
47
  /** SKILL.md path, resolved relative to the Nuxt root directory. */
48
48
  file: string;
49
+ /**
50
+ * Also serve the same bytes at these path-absolute `.md` routes, such as
51
+ * `/SKILL.md`. Content negotiation leaves the routes alone, so the response
52
+ * is the file itself with no generated frontmatter. The discovery index
53
+ * keeps advertising the `.well-known` artifact URL.
54
+ */
55
+ alias?: string | string[];
49
56
  }
50
57
  export interface ExternalAgentSkillConfig {
51
58
  /** Advertise an artifact already hosted at a URL. */
@@ -63,7 +70,29 @@ export interface ExternalAgentSkillConfig {
63
70
  }
64
71
  export type AgentSkillConfig = LocalAgentSkillConfig | ExternalAgentSkillConfig;
65
72
  export interface AgentSkillsConfig {
66
- skills: AgentSkillConfig[];
73
+ /**
74
+ * Explicit entries. Discovered skills are merged in first; an explicit entry
75
+ * with the same name replaces the discovered one.
76
+ */
77
+ skills?: AgentSkillConfig[];
78
+ /**
79
+ * Directory scanned for `<name>/SKILL.md` in the project root and in every
80
+ * layer inside it. Each match is published as a local skill and also served
81
+ * at `/<dir>/<name>/SKILL.md`, mirroring the repository path. `false` turns
82
+ * discovery off.
83
+ * @default 'skills'
84
+ */
85
+ dir?: string | false;
86
+ /**
87
+ * Name of the local skill also served at `/SKILL.md`. Defaults to the only
88
+ * local skill when exactly one exists. `false` publishes no root alias.
89
+ */
90
+ root?: string | false;
91
+ /**
92
+ * Add an "Agent Skills" section to llms.txt listing every published skill.
93
+ * @default true
94
+ */
95
+ llmsTxt?: boolean;
67
96
  }
68
97
  export interface AgentSkillsIndexEntry {
69
98
  name: string;
@@ -231,9 +260,11 @@ export interface ModuleOptions {
231
260
  exposedTo?: string[];
232
261
  };
233
262
  /**
234
- * Publish an Agent Skills Discovery v0.2.0 index and optionally host local
235
- * SKILL.md artifacts under `/.well-known/agent-skills/`.
236
- * @default false
263
+ * Publish an Agent Skills Discovery v0.2.0 index and host local SKILL.md
264
+ * artifacts under `/.well-known/agent-skills/`. Skills in `skills/<name>/SKILL.md`
265
+ * are discovered and published without configuration; nothing is published
266
+ * when none exist. Set `false` to turn the feature off.
267
+ * @default {}
237
268
  */
238
269
  agentSkills?: false | AgentSkillsConfig;
239
270
  /**
@@ -3,7 +3,7 @@ import { readFile, mkdir, writeFile, access } from 'node:fs/promises';
3
3
  import { createRequire } from 'node:module';
4
4
  import { dirname, basename, join } from 'node:path';
5
5
  import { hasNuxtModule, useNuxt, useLogger, addTypeTemplate, addTemplate, defineNuxtModule, createResolver, addServerImports, addServerPlugin, addServerHandler, extendRouteRules, addPlugin, addImports } from '@nuxt/kit';
6
- import defu from 'defu';
6
+ import defu$1, { defu } from 'defu';
7
7
  import { installNuxtSiteConfig, useSiteConfig, withSiteUrl } from 'nuxt-site-config/kit';
8
8
  import { resolveNuxtContentVersion, renderNitroTypeAugmentations, setupNitroRuntimeCompatibility } from 'nuxtseo-shared/kit';
9
9
  import { readPackageJSON, resolvePackageJSON } from 'pkg-types';
@@ -863,6 +863,34 @@ function ensureStaticHeader(contents, route, name, value) {
863
863
  const prefix = blockStart === routeLineEnd ? eol : "";
864
864
  return `${contents.slice(0, blockStart)}${prefix} ${name}: ${value}${eol}${contents.slice(blockStart)}`;
865
865
  }
866
+ const CLOUDFLARE_STATIC_HEADER_RULE_LIMIT = 100;
867
+ const RE_EXACT_MARKDOWN_ROUTE$1 = /^\/[^*\s]*\.md[\t ]*\r?$/;
868
+ function splitHeaderBlocks(contents) {
869
+ const blocks = [];
870
+ let current = { route: null, text: "" };
871
+ for (const line of contents.split(/(?<=\n)/)) {
872
+ const isRoute = /^[^\s#]/.test(line);
873
+ if (isRoute) {
874
+ blocks.push(current);
875
+ current = { route: line.trimEnd(), text: "" };
876
+ }
877
+ current.text += line;
878
+ }
879
+ blocks.push(current);
880
+ return blocks;
881
+ }
882
+ function enforceStaticHeaderBudget(contents, limit) {
883
+ const blocks = splitHeaderBlocks(contents);
884
+ const ruleCount = blocks.filter((block) => block.route !== null).length;
885
+ if (ruleCount <= limit)
886
+ return { contents, total: ruleCount, dropped: 0 };
887
+ const kept = blocks.filter((block) => block.route === null || !RE_EXACT_MARKDOWN_ROUTE$1.test(block.route));
888
+ return {
889
+ contents: kept.map((block) => block.text).join(""),
890
+ total: kept.filter((block) => block.route !== null).length,
891
+ dropped: ruleCount - kept.filter((block) => block.route !== null).length
892
+ };
893
+ }
866
894
 
867
895
  const RE_MD_EXT = /\.md$/;
868
896
  function isStaticMarkdownSourceRoute(route) {
@@ -895,9 +923,13 @@ function pageRouteFromMarkdownTwin(fileName) {
895
923
  return "/";
896
924
  return isStaticMarkdownSourceRoute(pageRoute) ? pageRoute : null;
897
925
  }
898
- function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby) {
926
+ function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby, artifactRoutes) {
927
+ const artifacts = new Set(artifactRoutes);
899
928
  const rules = /* @__PURE__ */ new Map();
900
929
  for (const entry of prerenderedRoutes) {
930
+ if (entry.route !== void 0 && artifacts.has(entry.route) || entry.fileName !== void 0 && artifacts.has(entry.fileName)) {
931
+ continue;
932
+ }
901
933
  const pageRoute = pageRouteFromMarkdownTwin(entry.fileName);
902
934
  if (pageRoute === null)
903
935
  continue;
@@ -914,6 +946,34 @@ function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby)
914
946
  }
915
947
  return [...rules.values()];
916
948
  }
949
+ const RE_EXACT_MARKDOWN_ROUTE = /^\/[^*:\s]*\.md$/;
950
+ function isStaticHeaderRouteRule([, rule]) {
951
+ return Boolean(rule && "headers" in rule && rule.headers);
952
+ }
953
+ function planStaticMarkdownHeaderRules(routeRules, rules, limit) {
954
+ if (limit === null)
955
+ return { _tag: "apply", rules };
956
+ const entries = Object.entries(routeRules).filter(isStaticHeaderRouteRule);
957
+ const registeredMarkdown = entries.map(([route]) => route).filter((route) => RE_EXACT_MARKDOWN_ROUTE.test(route));
958
+ const other = entries.length - registeredMarkdown.length;
959
+ const markdown = /* @__PURE__ */ new Set([...registeredMarkdown, ...rules.map((rule) => rule.route)]);
960
+ const total = other + markdown.size;
961
+ if (total <= limit)
962
+ return { _tag: "apply", rules };
963
+ return { _tag: "skip", drop: registeredMarkdown, total, dropped: markdown.size };
964
+ }
965
+ function applyStaticMarkdownHeaderPlan(routeRules, plan) {
966
+ if (plan._tag === "skip") {
967
+ for (const route of plan.drop) {
968
+ const rule = routeRules[route];
969
+ if (rule)
970
+ rule.headers = void 0;
971
+ }
972
+ return;
973
+ }
974
+ for (const { route, headers } of plan.rules)
975
+ routeRules[route] = defu({ headers }, routeRules[route]);
976
+ }
917
977
 
918
978
  const DEFAULT_MAX_OUTPUT_CHARS = 1500;
919
979
  const DEFAULT_LIST_LIMIT = 20;
@@ -1083,11 +1143,35 @@ const module$1 = defineNuxtModule({
1083
1143
  logger.debug("Module is disabled, skipping setup.");
1084
1144
  return;
1085
1145
  }
1086
- const agentSkillsResult = config.agentSkills === false || config.agentSkills === void 0 ? { _tag: "Disabled" } : await import('../chunks/agent-skills.mjs').then(({ resolveAgentSkillsConfig }) => resolveAgentSkillsConfig(config.agentSkills, nuxt.options.rootDir));
1087
- if (agentSkillsResult._tag === "Invalid") {
1088
- const details = agentSkillsResult.issues.map((issue) => `${issue.index === void 0 ? "agentSkills" : `agentSkills.skills[${issue.index}]`}.${issue.field}: ${issue.message}`).join("\n");
1089
- throw new Error(`[nuxt-ai-ready] Invalid Agent Skills configuration:
1146
+ const agentSkillsConfig = config.agentSkills === false ? false : config.agentSkills ?? {};
1147
+ const agentSkillsError = (issues) => {
1148
+ const details = issues.map((issue) => `${issue.index === void 0 ? "agentSkills" : `agentSkills.skills[${issue.index}]`}.${issue.field}: ${issue.message}`).join("\n");
1149
+ return new Error(`[nuxt-ai-ready] Invalid Agent Skills configuration:
1090
1150
  ${details}`);
1151
+ };
1152
+ let agentSkillsResult = { _tag: "Disabled" };
1153
+ if (agentSkillsConfig !== false) {
1154
+ const { applyRootAlias, prepareAgentSkills, resolveAgentSkillsConfig } = await import('../chunks/agent-skills.mjs');
1155
+ const prepared = await prepareAgentSkills(agentSkillsConfig, {
1156
+ rootDir: nuxt.options.rootDir,
1157
+ scanDirs: nuxt.options._layers.map((layer) => layer.config.rootDir ?? layer.cwd)
1158
+ });
1159
+ if (prepared._tag === "Invalid")
1160
+ throw agentSkillsError(prepared.issues);
1161
+ const payload = { skills: prepared.skills };
1162
+ await nuxt.callHook("ai-ready:agent-skills", payload);
1163
+ const rooted = applyRootAlias(payload.skills, agentSkillsConfig.root);
1164
+ if (rooted._tag === "Invalid")
1165
+ throw agentSkillsError(rooted.issues);
1166
+ agentSkillsResult = await resolveAgentSkillsConfig(
1167
+ { ...agentSkillsConfig, skills: rooted.skills },
1168
+ nuxt.options.rootDir,
1169
+ { sitemapMd: config.sitemapMd !== false }
1170
+ );
1171
+ if (agentSkillsResult._tag === "Invalid")
1172
+ throw agentSkillsError(agentSkillsResult.issues);
1173
+ if (agentSkillsResult._tag === "Enabled")
1174
+ logger.debug(`Publishing ${agentSkillsResult.index.skills.length} agent skill(s): ${agentSkillsResult.index.skills.map((skill) => skill.name).join(", ")}`);
1091
1175
  }
1092
1176
  const mcpServerCardResult = parseMcpServerCardConfig(config.mcpServerCard);
1093
1177
  if (mcpServerCardResult._tag === "Invalid")
@@ -1263,6 +1347,18 @@ ${details}`);
1263
1347
  }
1264
1348
  ]
1265
1349
  });
1350
+ if (agentSkillsResult._tag === "Enabled" && agentSkillsConfig !== false && agentSkillsConfig.llmsTxt !== false) {
1351
+ const indexUrl = withSiteUrl(AGENT_SKILLS_INDEX_ROUTE.slice(1), { withBase: true });
1352
+ defaultLlmsTxtSections.push({
1353
+ title: "Agent Skills",
1354
+ description: `Skills an agent can install from this site. The index at ${indexUrl} carries a sha256 digest for each one.`,
1355
+ links: agentSkillsResult.links.map((link) => ({
1356
+ title: link.name,
1357
+ href: link.href.startsWith("/") ? withSiteUrl(link.href.slice(1), { withBase: true }) : new URL(link.href, indexUrl).href,
1358
+ description: link.description
1359
+ }))
1360
+ });
1361
+ }
1266
1362
  const mergedLlmsTxt = config.llmsTxt ? {
1267
1363
  markdownLinks: config.llmsTxt.markdownLinks ?? false,
1268
1364
  sections: [
@@ -1630,7 +1726,7 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1630
1726
  mdreamOptions: config.mdreamOptions || {},
1631
1727
  sitemapMd: config.sitemapMd !== false,
1632
1728
  describedby: config.describedby !== false,
1633
- markdownCacheHeaders: defu(config.markdownCacheHeaders, {
1729
+ markdownCacheHeaders: defu$1(config.markdownCacheHeaders, {
1634
1730
  maxAge: 3600,
1635
1731
  swr: true
1636
1732
  }),
@@ -1786,14 +1882,24 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1786
1882
  });
1787
1883
  }
1788
1884
  nuxt.hooks.hook("nitro:build:before", (nitro) => {
1885
+ const isCloudflarePreset = String(nitro.options.preset || "").startsWith("cloudflare");
1789
1886
  nitro.hooks.hook("prerender:done", () => {
1790
- for (const { route, headers } of prerenderedMarkdownHeaderRules(
1791
- nitro._prerenderedRoutes || [],
1792
- staticBaseURL,
1793
- config.describedby !== false
1794
- )) {
1795
- nitro.options.routeRules[route] = defu({ headers }, nitro.options.routeRules[route]);
1887
+ const plan = planStaticMarkdownHeaderRules(
1888
+ nitro.options.routeRules,
1889
+ prerenderedMarkdownHeaderRules(
1890
+ nitro._prerenderedRoutes || [],
1891
+ staticBaseURL,
1892
+ config.describedby !== false,
1893
+ agentSkillsResult._tag === "Enabled" ? Object.keys(agentSkillsResult.localArtifacts) : []
1894
+ ),
1895
+ isCloudflarePreset ? CLOUDFLARE_STATIC_HEADER_RULE_LIMIT : null
1896
+ );
1897
+ if (plan._tag === "skip") {
1898
+ applyStaticMarkdownHeaderPlan(nitro.options.routeRules, plan);
1899
+ logger.warn(`_headers would hold ${plan.total} rules and Cloudflare allows ${CLOUDFLARE_STATIC_HEADER_RULE_LIMIT}. Skipped the ${plan.dropped} per-page .md rules; the /*.md glob still sets the charset and describedby entries, but static markdown does not send a per-page canonical Link.`);
1900
+ return;
1796
1901
  }
1902
+ applyStaticMarkdownHeaderPlan(nitro.options.routeRules, plan);
1797
1903
  });
1798
1904
  nitro.hooks.hook("compiled", async () => {
1799
1905
  const headersPath = join(nitro.options.output.publicDir, "_headers");
@@ -1815,6 +1921,15 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
1815
1921
  staticDescribedbyEntry(nitro.options.baseURL || "/")
1816
1922
  );
1817
1923
  }
1924
+ if (isCloudflarePreset) {
1925
+ const budget = enforceStaticHeaderBudget(mergedHeaders, CLOUDFLARE_STATIC_HEADER_RULE_LIMIT);
1926
+ if (budget.dropped > 0) {
1927
+ mergedHeaders = budget.contents;
1928
+ logger.warn(`_headers had ${budget.total + budget.dropped} rules and Cloudflare allows ${CLOUDFLARE_STATIC_HEADER_RULE_LIMIT}. Dropped the ${budget.dropped} per-page .md rules; the /*.md glob still sets the charset and describedby entries, but static markdown no longer sends a per-page canonical Link.`);
1929
+ }
1930
+ if (budget.total > CLOUDFLARE_STATIC_HEADER_RULE_LIMIT)
1931
+ logger.warn(`_headers still has ${budget.total} rules after dropping the .md rules. Cloudflare will reject the deploy until the other rules fit under ${CLOUDFLARE_STATIC_HEADER_RULE_LIMIT}.`);
1932
+ }
1818
1933
  if (mergedHeaders !== headers) {
1819
1934
  await writeFile(headersPath, mergedHeaders);
1820
1935
  logger.debug("Merged .md charset header into _headers");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-ai-ready",
3
3
  "type": "module",
4
- "version": "2.2.0",
4
+ "version": "2.3.0",
5
5
  "description": "Best practice AI & LLM discoverability for Nuxt sites.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",