nuxt-ai-ready 2.2.1 → 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.
- package/dist/chunks/agent-skills.mjs +148 -39
- package/dist/chunks/prerender.mjs +1 -1
- package/dist/module.d.mts +11 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +1 -1
- package/dist/runtime/server/middleware/markdown.prerender.js +2 -1
- package/dist/runtime/server/utils/negotiation-decision.d.ts +6 -1
- package/dist/runtime/server/utils/negotiation-decision.js +2 -0
- package/dist/runtime/server/utils/negotiation-response.d.ts +2 -0
- package/dist/runtime/server/utils/negotiation-response.js +8 -1
- package/dist/runtime/types.d.ts +35 -4
- package/dist/shared/{nuxt-ai-ready.BVWXwmUS.mjs → nuxt-ai-ready.B8hPhZL9.mjs} +94 -16
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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
|
|
219
|
+
issues: [{ field: "agentSkills", message: "skills must be an array when set" }]
|
|
208
220
|
};
|
|
209
221
|
}
|
|
210
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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 ("
|
|
234
|
-
|
|
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.
|
|
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
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.
|
|
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) {
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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';
|
|
@@ -864,7 +864,7 @@ function ensureStaticHeader(contents, route, name, value) {
|
|
|
864
864
|
return `${contents.slice(0, blockStart)}${prefix} ${name}: ${value}${eol}${contents.slice(blockStart)}`;
|
|
865
865
|
}
|
|
866
866
|
const CLOUDFLARE_STATIC_HEADER_RULE_LIMIT = 100;
|
|
867
|
-
const RE_EXACT_MARKDOWN_ROUTE = /^\/[^*\s]*\.md[\t ]*\r?$/;
|
|
867
|
+
const RE_EXACT_MARKDOWN_ROUTE$1 = /^\/[^*\s]*\.md[\t ]*\r?$/;
|
|
868
868
|
function splitHeaderBlocks(contents) {
|
|
869
869
|
const blocks = [];
|
|
870
870
|
let current = { route: null, text: "" };
|
|
@@ -884,7 +884,7 @@ function enforceStaticHeaderBudget(contents, limit) {
|
|
|
884
884
|
const ruleCount = blocks.filter((block) => block.route !== null).length;
|
|
885
885
|
if (ruleCount <= limit)
|
|
886
886
|
return { contents, total: ruleCount, dropped: 0 };
|
|
887
|
-
const kept = blocks.filter((block) => block.route === null || !RE_EXACT_MARKDOWN_ROUTE.test(block.route));
|
|
887
|
+
const kept = blocks.filter((block) => block.route === null || !RE_EXACT_MARKDOWN_ROUTE$1.test(block.route));
|
|
888
888
|
return {
|
|
889
889
|
contents: kept.map((block) => block.text).join(""),
|
|
890
890
|
total: kept.filter((block) => block.route !== null).length,
|
|
@@ -923,9 +923,13 @@ function pageRouteFromMarkdownTwin(fileName) {
|
|
|
923
923
|
return "/";
|
|
924
924
|
return isStaticMarkdownSourceRoute(pageRoute) ? pageRoute : null;
|
|
925
925
|
}
|
|
926
|
-
function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby) {
|
|
926
|
+
function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby, artifactRoutes) {
|
|
927
|
+
const artifacts = new Set(artifactRoutes);
|
|
927
928
|
const rules = /* @__PURE__ */ new Map();
|
|
928
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
|
+
}
|
|
929
933
|
const pageRoute = pageRouteFromMarkdownTwin(entry.fileName);
|
|
930
934
|
if (pageRoute === null)
|
|
931
935
|
continue;
|
|
@@ -942,6 +946,34 @@ function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby)
|
|
|
942
946
|
}
|
|
943
947
|
return [...rules.values()];
|
|
944
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
|
+
}
|
|
945
977
|
|
|
946
978
|
const DEFAULT_MAX_OUTPUT_CHARS = 1500;
|
|
947
979
|
const DEFAULT_LIST_LIMIT = 20;
|
|
@@ -1111,11 +1143,35 @@ const module$1 = defineNuxtModule({
|
|
|
1111
1143
|
logger.debug("Module is disabled, skipping setup.");
|
|
1112
1144
|
return;
|
|
1113
1145
|
}
|
|
1114
|
-
const
|
|
1115
|
-
|
|
1116
|
-
const details =
|
|
1117
|
-
|
|
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:
|
|
1118
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(", ")}`);
|
|
1119
1175
|
}
|
|
1120
1176
|
const mcpServerCardResult = parseMcpServerCardConfig(config.mcpServerCard);
|
|
1121
1177
|
if (mcpServerCardResult._tag === "Invalid")
|
|
@@ -1291,6 +1347,18 @@ ${details}`);
|
|
|
1291
1347
|
}
|
|
1292
1348
|
]
|
|
1293
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
|
+
}
|
|
1294
1362
|
const mergedLlmsTxt = config.llmsTxt ? {
|
|
1295
1363
|
markdownLinks: config.llmsTxt.markdownLinks ?? false,
|
|
1296
1364
|
sections: [
|
|
@@ -1658,7 +1726,7 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
|
|
|
1658
1726
|
mdreamOptions: config.mdreamOptions || {},
|
|
1659
1727
|
sitemapMd: config.sitemapMd !== false,
|
|
1660
1728
|
describedby: config.describedby !== false,
|
|
1661
|
-
markdownCacheHeaders: defu(config.markdownCacheHeaders, {
|
|
1729
|
+
markdownCacheHeaders: defu$1(config.markdownCacheHeaders, {
|
|
1662
1730
|
maxAge: 3600,
|
|
1663
1731
|
swr: true
|
|
1664
1732
|
}),
|
|
@@ -1814,14 +1882,24 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
|
|
|
1814
1882
|
});
|
|
1815
1883
|
}
|
|
1816
1884
|
nuxt.hooks.hook("nitro:build:before", (nitro) => {
|
|
1885
|
+
const isCloudflarePreset = String(nitro.options.preset || "").startsWith("cloudflare");
|
|
1817
1886
|
nitro.hooks.hook("prerender:done", () => {
|
|
1818
|
-
|
|
1819
|
-
nitro.
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
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;
|
|
1824
1901
|
}
|
|
1902
|
+
applyStaticMarkdownHeaderPlan(nitro.options.routeRules, plan);
|
|
1825
1903
|
});
|
|
1826
1904
|
nitro.hooks.hook("compiled", async () => {
|
|
1827
1905
|
const headersPath = join(nitro.options.output.publicDir, "_headers");
|
|
@@ -1843,7 +1921,7 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
|
|
|
1843
1921
|
staticDescribedbyEntry(nitro.options.baseURL || "/")
|
|
1844
1922
|
);
|
|
1845
1923
|
}
|
|
1846
|
-
if (
|
|
1924
|
+
if (isCloudflarePreset) {
|
|
1847
1925
|
const budget = enforceStaticHeaderBudget(mergedHeaders, CLOUDFLARE_STATIC_HEADER_RULE_LIMIT);
|
|
1848
1926
|
if (budget.dropped > 0) {
|
|
1849
1927
|
mergedHeaders = budget.contents;
|