@vertesia/build-tools 1.5.0-dev.20260722.120446Z → 1.5.0-dev.20260804.124748Z

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.
Files changed (59) hide show
  1. package/README.md +297 -266
  2. package/lib/bin/build.js +26 -1
  3. package/lib/bin/build.js.map +1 -1
  4. package/lib/bin/config.d.ts +12 -0
  5. package/lib/bin/config.d.ts.map +1 -1
  6. package/lib/bin/config.js +25 -0
  7. package/lib/bin/config.js.map +1 -1
  8. package/lib/core/skill-markdown/load-catalog.d.ts +30 -0
  9. package/lib/core/skill-markdown/load-catalog.d.ts.map +1 -0
  10. package/lib/core/skill-markdown/load-catalog.js +69 -0
  11. package/lib/core/skill-markdown/load-catalog.js.map +1 -0
  12. package/lib/core/skill-markdown/preprocess.d.ts +127 -0
  13. package/lib/core/skill-markdown/preprocess.d.ts.map +1 -0
  14. package/lib/core/skill-markdown/preprocess.js +455 -0
  15. package/lib/core/skill-markdown/preprocess.js.map +1 -0
  16. package/lib/core/skill-markdown/schema-validator.d.ts +109 -0
  17. package/lib/core/skill-markdown/schema-validator.d.ts.map +1 -0
  18. package/lib/core/skill-markdown/schema-validator.js +420 -0
  19. package/lib/core/skill-markdown/schema-validator.js.map +1 -0
  20. package/lib/core/transformers/index.d.ts +1 -1
  21. package/lib/core/transformers/index.d.ts.map +1 -1
  22. package/lib/core/transformers/index.js +1 -1
  23. package/lib/core/transformers/index.js.map +1 -1
  24. package/lib/core/transformers/skill.d.ts +15 -1
  25. package/lib/core/transformers/skill.d.ts.map +1 -1
  26. package/lib/core/transformers/skill.js +82 -60
  27. package/lib/core/transformers/skill.js.map +1 -1
  28. package/lib/import-transform/detector.d.ts +19 -6
  29. package/lib/import-transform/detector.d.ts.map +1 -1
  30. package/lib/import-transform/detector.js +73 -24
  31. package/lib/import-transform/detector.js.map +1 -1
  32. package/lib/import-transform/index.d.ts.map +1 -1
  33. package/lib/import-transform/index.js +10 -1
  34. package/lib/import-transform/index.js.map +1 -1
  35. package/lib/import-transform/patterns.d.ts +7 -8
  36. package/lib/import-transform/patterns.d.ts.map +1 -1
  37. package/lib/import-transform/patterns.js +7 -8
  38. package/lib/import-transform/patterns.js.map +1 -1
  39. package/lib/index.d.ts +7 -3
  40. package/lib/index.d.ts.map +1 -1
  41. package/lib/index.js +9 -3
  42. package/lib/index.js.map +1 -1
  43. package/lib/vite/dev-server.d.ts +10 -0
  44. package/lib/vite/dev-server.d.ts.map +1 -1
  45. package/lib/vite/dev-server.js +36 -4
  46. package/lib/vite/dev-server.js.map +1 -1
  47. package/package.json +9 -12
  48. package/src/bin/build.ts +31 -2
  49. package/src/bin/config.ts +27 -0
  50. package/src/core/skill-markdown/load-catalog.ts +100 -0
  51. package/src/core/skill-markdown/preprocess.ts +632 -0
  52. package/src/core/skill-markdown/schema-validator.ts +517 -0
  53. package/src/core/transformers/index.ts +3 -0
  54. package/src/core/transformers/skill.ts +116 -53
  55. package/src/import-transform/detector.ts +78 -26
  56. package/src/import-transform/index.ts +12 -2
  57. package/src/import-transform/patterns.ts +7 -12
  58. package/src/index.ts +35 -2
  59. package/src/vite/dev-server.ts +52 -4
@@ -6,7 +6,12 @@ import { existsSync } from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { z } from 'zod';
8
8
  import { parseFrontmatter } from '../parsers/frontmatter.js';
9
- import type { TransformerPreset } from '../types.js';
9
+ import {
10
+ assertSkillMarkdown,
11
+ type PreprocessSkillMarkdownOptions,
12
+ preprocessSkillMarkdown,
13
+ } from '../skill-markdown/preprocess.js';
14
+ import type { TransformerPreset, TransformerRule, TransformResult } from '../types.js';
10
15
  import { discoverSkillAssets } from '../utils/asset-discovery.js';
11
16
 
12
17
  /**
@@ -78,6 +83,7 @@ const SkillFrontmatterSchema = z
78
83
  // Flat structure fields (legacy)
79
84
  keywords: z.array(z.string()).optional(),
80
85
  tools: z.array(z.string()).optional(),
86
+ supporting_tools: z.array(z.string()).optional(),
81
87
  data_patterns: z.array(z.string()).optional(),
82
88
  language: z.string().optional(),
83
89
  packages: z.array(z.string()).optional(),
@@ -234,9 +240,12 @@ function buildSkillDefinition(
234
240
  }
235
241
  }
236
242
 
237
- // Tools unlocked by this skill (from frontmatter `tools:` key)
238
- if (frontmatter.tools) {
239
- skill.tools = frontmatter.tools;
243
+ // Tools unlocked by this skill. `supporting_tools` unlock identically — the split exists only
244
+ // so validation knows which grants the author owes the reader an explanation for. Merged here so
245
+ // no runtime path has to know about the distinction.
246
+ const unlocked = [...(frontmatter.tools ?? []), ...(frontmatter.supporting_tools ?? [])];
247
+ if (unlocked.length > 0) {
248
+ skill.tools = unlocked;
240
249
  }
241
250
 
242
251
  // Input schema from frontmatter
@@ -267,51 +276,106 @@ function buildSkillDefinition(
267
276
  * // Both are SkillDefinition objects
268
277
  * ```
269
278
  */
270
- export const skillTransformer: TransformerPreset = {
271
- pattern: /(\.md\?skill$|\/SKILL\.md$)/,
272
- schema: SkillDefinitionSchema,
273
- transform: (content: string, filePath: string) => {
274
- const { frontmatter, content: markdown } = parseFrontmatter(content);
275
-
276
- // Validate frontmatter first to catch unknown properties
277
- const frontmatterValidation = SkillFrontmatterSchema.safeParse(frontmatter);
278
- if (!frontmatterValidation.success) {
279
- const errors = frontmatterValidation.error.issues
280
- .map((err) => {
281
- const pathStr = err.path.length > 0 ? err.path.join('.') : 'frontmatter';
282
- return ` - ${pathStr}: ${err.message}`;
283
- })
284
- .join('\n');
285
- throw new Error(`Invalid frontmatter in ${filePath}:\n${errors}`);
286
- }
279
+ export interface SkillTransformerConfig {
280
+ /**
281
+ * Catalog used to resolve `{@tool …}` / `{@skill …}` and validate `tool=`-tagged examples.
282
+ *
283
+ * Supplied by the consuming build, never discovered here: a transformer that went looking
284
+ * for a registry would couple build-tools to whichever package happens to own one.
285
+ */
286
+ markdown?: PreprocessSkillMarkdownOptions;
287
+ }
287
288
 
288
- const validatedFrontmatter = frontmatterValidation.data;
289
+ /**
290
+ * Render the skill body, failing closed when the semantic syntax is used without a catalog.
291
+ *
292
+ * Without this guard a package that writes `{@tool x}` but forgets to configure `skillCatalog`
293
+ * would ship the raw construct straight to the model — silently, and looking fine in review.
294
+ */
295
+ function renderSkillMarkdown(markdown: string, filePath: string, options?: PreprocessSkillMarkdownOptions): string {
296
+ if (options) {
297
+ return assertSkillMarkdown(markdown, options, filePath).markdown;
298
+ }
299
+ const probe = preprocessSkillMarkdown(markdown, { tools: new Set(), skills: new Set() });
300
+ if (probe.references.length > 0 || probe.examples.length > 0 || probe.errors.length > 0) {
301
+ throw new Error(
302
+ `${filePath} uses {@tool …}, {@skill …} or a tool= example tag, but this build supplies no ` +
303
+ 'skill catalog. Set "vertesia-build.skillCatalog" in package.json to a module exporting ' +
304
+ '{ tools, skills } (and optionally validateExample).',
305
+ );
306
+ }
307
+ return markdown;
308
+ }
289
309
 
290
- // Determine content type from frontmatter or file extension
291
- const content_type: SkillContentType = validatedFrontmatter.content_type || 'md';
310
+ /**
311
+ * Marks a rule as *this* preset rather than any rule that happens to share its pattern.
312
+ *
313
+ * The CLI swaps in a catalog-bound skill transformer; matching on `pattern.source` would also
314
+ * replace an unrelated custom transformer registered for the same files.
315
+ */
316
+ const SKILL_TRANSFORMER = Symbol.for('@vertesia/build-tools:skillTransformer');
292
317
 
293
- // Discover assets (scripts and widgets) in the skill directory
294
- const assets = discoverSkillAssets(filePath);
318
+ /** True when `rule` is a skill transformer produced by `createSkillTransformer`. */
319
+ export function isSkillTransformer(rule: TransformerRule): boolean {
320
+ return (rule as unknown as Record<PropertyKey, unknown>)[SKILL_TRANSFORMER] === true;
321
+ }
295
322
 
296
- // Build skill definition using the same logic as parseSkillFile in tools-sdk
297
- const skillData = buildSkillDefinition(
298
- validatedFrontmatter,
299
- markdown,
300
- content_type,
301
- assets.widgets,
302
- assets.scripts,
303
- );
323
+ /** Build a skill transformer bound to a specific catalog. `skillTransformer` is the unbound one. */
324
+ export function createSkillTransformer(config: SkillTransformerConfig = {}): TransformerPreset {
325
+ return {
326
+ [SKILL_TRANSFORMER]: true,
327
+ pattern: /(\.md\?skill$|\/SKILL\.md$)/,
328
+ schema: SkillDefinitionSchema,
329
+ transform: (content: string, filePath: string) => transformSkill(content, filePath, config),
330
+ } as TransformerPreset;
331
+ }
304
332
 
305
- // Check if properties.ts exists in the skill directory
306
- const skillDir = path.dirname(filePath);
307
- const propertiesPath = path.join(skillDir, 'properties.ts');
308
- const hasProperties = existsSync(propertiesPath);
333
+ export const skillTransformer: TransformerPreset = createSkillTransformer();
309
334
 
310
- // If properties.ts exists, generate custom code with import and merge
311
- // Rollup will handle transpiling properties.ts to properties.js
312
- if (hasProperties) {
313
- const skillDataJson = JSON.stringify(skillData, null, 2);
314
- const code = `import properties from './properties.js';
335
+ function transformSkill(content: string, filePath: string, config: SkillTransformerConfig): TransformResult {
336
+ const { frontmatter, content: rawMarkdown } = parseFrontmatter(content);
337
+ const markdown = renderSkillMarkdown(rawMarkdown, filePath, config.markdown);
338
+
339
+ // Validate frontmatter first to catch unknown properties
340
+ const frontmatterValidation = SkillFrontmatterSchema.safeParse(frontmatter);
341
+ if (!frontmatterValidation.success) {
342
+ const errors = frontmatterValidation.error.issues
343
+ .map((err) => {
344
+ const pathStr = err.path.length > 0 ? err.path.join('.') : 'frontmatter';
345
+ return ` - ${pathStr}: ${err.message}`;
346
+ })
347
+ .join('\n');
348
+ throw new Error(`Invalid frontmatter in ${filePath}:\n${errors}`);
349
+ }
350
+
351
+ const validatedFrontmatter = frontmatterValidation.data;
352
+
353
+ // Determine content type from frontmatter or file extension
354
+ const content_type: SkillContentType = validatedFrontmatter.content_type || 'md';
355
+
356
+ // Discover assets (scripts and widgets) in the skill directory
357
+ const assets = discoverSkillAssets(filePath);
358
+
359
+ // Build skill definition using the same logic as parseSkillFile in tools-sdk
360
+ const skillData = buildSkillDefinition(
361
+ validatedFrontmatter,
362
+ markdown,
363
+ content_type,
364
+ assets.widgets,
365
+ assets.scripts,
366
+ );
367
+
368
+ // Check if properties.ts exists in the skill directory
369
+ const skillDir = path.dirname(filePath);
370
+ const propertiesPath = path.join(skillDir, 'properties.ts');
371
+ const hasProperties = existsSync(propertiesPath);
372
+
373
+ // If properties.ts exists, generate custom code with import and merge.
374
+ // `tsc` has already emitted properties.js beside this chunk — the pipeline runs after it and
375
+ // never compiles TypeScript itself — so the generated module can import './properties.js'.
376
+ if (hasProperties) {
377
+ const skillDataJson = JSON.stringify(skillData, null, 2);
378
+ const code = `import properties from './properties.js';
315
379
 
316
380
  // Runtime validation for function properties
317
381
  if ('isEnabled' in properties && typeof properties.isEnabled !== 'function') {
@@ -322,18 +386,17 @@ const skill = ${skillDataJson};
322
386
 
323
387
  export default { ...skill, ...properties };
324
388
  `;
325
- return {
326
- data: skillData,
327
- assets: assets.assetFiles,
328
- widgets: assets.widgetMetadata,
329
- code,
330
- };
331
- }
332
-
333
389
  return {
334
390
  data: skillData,
335
391
  assets: assets.assetFiles,
336
392
  widgets: assets.widgetMetadata,
393
+ code,
337
394
  };
338
- },
339
- };
395
+ }
396
+
397
+ return {
398
+ data: skillData,
399
+ assets: assets.assetFiles,
400
+ widgets: assets.widgetMetadata,
401
+ };
402
+ }
@@ -1,15 +1,20 @@
1
1
  /**
2
- * Finds Vertesia query-style import specifiers inside a JavaScript source
3
- * file and maps each one to the transformer that should handle it.
2
+ * Finds Vertesia query-style import specifiers in a JavaScript source file and maps each one to
3
+ * the transformer that should handle it.
4
4
  *
5
- * The detector is regex-based and works on `tsc` output where import
6
- * specifiers are preserved literally. It identifies any string literal that
7
- * contains a known marker (`?skill`, `?raw`, …, or `/SKILL.md`) and then
8
- * matches the specifier against the configured transformer patterns.
5
+ * Detection uses a real ESM lexer (`es-module-lexer`) rather than a regex over string literals,
6
+ * because a regex models the wrong thing: it matches anything that *looks* like a specifier. A doc
7
+ * comment mentioning `` `?skill` ``, or an ordinary constant such as
8
+ * `const example = './example/SKILL.md'`, were both detected as imports — the former resolved to a
9
+ * directory and killed the build with `EISDIR`.
10
+ *
11
+ * Lexing the module handles comments, template literals, regex literals, static imports,
12
+ * re-exports (`export … from`) and dynamic `import()` in one pass, and still yields the source
13
+ * offsets the rewriter needs.
9
14
  */
10
15
 
16
+ import { initSync, parse } from 'es-module-lexer';
11
17
  import type { TransformerRule } from '../core/types.js';
12
- import { QUERY_STRING_LITERAL } from './patterns.js';
13
18
 
14
19
  export interface ImportOccurrence {
15
20
  /** The transformer whose pattern matched the specifier. */
@@ -28,28 +33,75 @@ export interface ImportOccurrence {
28
33
  quote: "'" | '"' | '`';
29
34
  }
30
35
 
36
+ /**
37
+ * `es-module-lexer` is WASM-backed and must be initialised before the first `parse`.
38
+ *
39
+ * Initialised synchronously and once, so `detectQueryImports` stays a plain function — making it
40
+ * async would push a promise through the scanner and every caller for no benefit.
41
+ */
42
+ let initialised = false;
43
+ function ensureInitialised(): void {
44
+ if (!initialised) {
45
+ initSync();
46
+ initialised = true;
47
+ }
48
+ }
49
+
50
+ const QUOTES = new Set(["'", '"', '`']);
51
+
52
+ /**
53
+ * Locate the quotes around a specifier.
54
+ *
55
+ * The lexer reports the specifier *without* surrounding quotes for static imports but *with* them
56
+ * for dynamic ones, and the rewriter splices over the quoted range — so normalise here rather than
57
+ * making every caller know the difference.
58
+ */
59
+ function quoteBounds(source: string, start: number, end: number): { quoteStart: number; quoteEnd: number } | undefined {
60
+ if (QUOTES.has(source[start - 1]) && QUOTES.has(source[end])) {
61
+ return { quoteStart: start - 1, quoteEnd: end + 1 };
62
+ }
63
+ if (QUOTES.has(source[start]) && QUOTES.has(source[end - 1])) {
64
+ return { quoteStart: start, quoteEnd: end };
65
+ }
66
+ // A dynamic import whose specifier is a computed expression: nothing to rewrite.
67
+ return undefined;
68
+ }
69
+
70
+ /**
71
+ * Throws if `content` cannot be lexed.
72
+ *
73
+ * Deliberately not caught: the scanner only reaches this for files that already contain a
74
+ * transformation marker, so a swallowed lexer failure would mean a real `?skill` import is left
75
+ * untransformed and the raw specifier ships to the runtime. A build that cannot read a file must
76
+ * stop, not quietly emit less. `transformImports` adds the file path to the message.
77
+ */
31
78
  export function detectQueryImports(content: string, transformers: TransformerRule[]): ImportOccurrence[] {
79
+ ensureInitialised();
80
+
81
+ const [imports] = parse(content);
82
+
32
83
  const occurrences: ImportOccurrence[] = [];
33
- QUERY_STRING_LITERAL.lastIndex = 0;
34
-
35
- let match: RegExpExecArray | null;
36
- // biome-ignore lint/suspicious/noAssignInExpressions: regex .exec() loop
37
- while ((match = QUERY_STRING_LITERAL.exec(content)) !== null) {
38
- const quote = match[1] as "'" | '"' | '`';
39
- const specifier = match[2];
40
-
41
- for (const transformer of transformers) {
42
- if (transformer.pattern.test(specifier)) {
43
- occurrences.push({
44
- transformer,
45
- specifier,
46
- quoteStart: match.index,
47
- quoteEnd: match.index + match[0].length,
48
- quote,
49
- });
50
- break;
51
- }
84
+ for (const entry of imports) {
85
+ const specifier = entry.n;
86
+ if (specifier === undefined) {
87
+ // `import.meta`, or a dynamic import with a non-literal specifier.
88
+ continue;
89
+ }
90
+ const bounds = quoteBounds(content, entry.s, entry.e);
91
+ if (!bounds) {
92
+ continue;
93
+ }
94
+ const transformer = transformers.find((rule) => rule.pattern.test(specifier));
95
+ if (!transformer) {
96
+ continue;
52
97
  }
98
+ occurrences.push({
99
+ transformer,
100
+ specifier,
101
+ quoteStart: bounds.quoteStart,
102
+ quoteEnd: bounds.quoteEnd,
103
+ quote: content[bounds.quoteStart] as "'" | '"' | '`',
104
+ });
53
105
  }
54
106
 
55
107
  return occurrences;
@@ -23,7 +23,7 @@ import type { AssetFile, TransformerRule } from '../core/types.js';
23
23
  import { copyAssets } from '../core/utils/asset-copy.js';
24
24
  import type { WidgetMetadata } from '../core/utils/asset-discovery.js';
25
25
  import { emitChunk } from './chunk-emitter.js';
26
- import { detectQueryImports } from './detector.js';
26
+ import { detectQueryImports, type ImportOccurrence } from './detector.js';
27
27
  import { SNIFF_PATTERN } from './patterns.js';
28
28
  import { resolveImport } from './resolver.js';
29
29
  import { type ImportReplacement, writeRewrittenFile } from './rewriter.js';
@@ -94,7 +94,17 @@ export async function transformImports(opts: TransformImportsOptions): Promise<T
94
94
 
95
95
  while (workQueue.length > 0) {
96
96
  const file = workQueue.shift() as WorkItem;
97
- const occurrences = detectQueryImports(file.content, transformers);
97
+
98
+ let occurrences: ImportOccurrence[];
99
+ try {
100
+ occurrences = detectQueryImports(file.content, transformers);
101
+ } catch (error) {
102
+ // Fail closed. This file reached the queue because it contains a transformation
103
+ // marker, so a lexer failure means a real import may go untransformed — the raw
104
+ // specifier would then ship to the runtime.
105
+ throw new Error(`Failed to parse imports in ${file.path}: ${(error as Error).message}`);
106
+ }
107
+
98
108
  if (occurrences.length === 0) {
99
109
  continue;
100
110
  }
@@ -1,7 +1,11 @@
1
1
  /**
2
- * Shared regex fragments used by the scanner, detector, and orchestrator to
3
- * identify Vertesia query-style imports. Keep these in sync with the
4
- * `pattern` field of each transformer in `core/transformers/`.
2
+ * Shared regex fragments used by the scanner and orchestrator to cheaply pre-filter files that
3
+ * could contain Vertesia query-style imports. Keep these in sync with the `pattern` field of each
4
+ * transformer in `core/transformers/`.
5
+ *
6
+ * These are a *sniff* only. Deciding what is actually an import is the detector's job, and it
7
+ * lexes the module rather than matching string literals — a literal-matching regex used to live
8
+ * here too, and it could not tell an import from a comment or a constant.
5
9
  */
6
10
 
7
11
  /**
@@ -23,12 +27,3 @@ const BARE_GROUP = BARE_FILENAMES.map((name) => `\\/${name.replace('.', '\\.')}`
23
27
  * Cheap regex check before invoking the more expensive detector.
24
28
  */
25
29
  export const SNIFF_PATTERN = new RegExp(`\\?(?:${QUERY_GROUP})\\b|(?:${BARE_GROUP})\\b`);
26
-
27
- /**
28
- * Matches a quoted string literal whose contents end with a query marker.
29
- * Capture group 1 is the quote character; capture group 2 is the specifier.
30
- */
31
- export const QUERY_STRING_LITERAL = new RegExp(
32
- `(['"\`])([^'"\`]*?(?:\\?(?:${QUERY_GROUP})|${BARE_GROUP})[^'"\`]*)\\1`,
33
- 'g',
34
- );
package/src/index.ts CHANGED
@@ -20,12 +20,13 @@
20
20
  *
21
21
  * @example
22
22
  * ```typescript
23
- * import { transformImports } from '@vertesia/build-tools';
23
+ * import { resolveTransformerNames, transformImports } from '@vertesia/build-tools';
24
24
  *
25
25
  * await transformImports({
26
26
  * libDir: './lib',
27
27
  * srcDir: './src',
28
- * transformers: ['skill', 'raw'],
28
+ * // Resolved rules, not names — use resolveTransformerNames to go from one to the other.
29
+ * transformers: resolveTransformerNames(['skill', 'raw']),
29
30
  * assetsDir: './dist',
30
31
  * });
31
32
  * ```
@@ -40,8 +41,39 @@ export {
40
41
  } from './core/compilers/widget.js';
41
42
  // Parsers
42
43
  export { type FrontmatterResult, parseFrontmatter } from './core/parsers/frontmatter.js';
44
+ export {
45
+ loadSkillCatalog,
46
+ readPackageJson,
47
+ } from './core/skill-markdown/load-catalog.js';
48
+ // Skill markdown preprocessor: `{@tool …}` / `{@skill …}` resolution and tagged-example
49
+ // validation. Pure — the consuming build supplies the catalog and the schema validator.
50
+ export {
51
+ assertSkillMarkdown,
52
+ DEFAULT_SKILL_TOOL_PREFIX,
53
+ type PreprocessSkillMarkdownOptions,
54
+ type PreprocessSkillMarkdownResult,
55
+ preprocessSkillMarkdown,
56
+ type SkillExample,
57
+ type SkillReference,
58
+ type SkillReferenceKind,
59
+ } from './core/skill-markdown/preprocess.js';
60
+ export {
61
+ checkDispatchDescriptor,
62
+ createSchemaExampleValidator,
63
+ createSchemaFieldValidator,
64
+ type DispatchResolution,
65
+ nodesAtPath,
66
+ pairDispatchedInputs,
67
+ resolveDispatchedNames,
68
+ schemaNodeAtPath,
69
+ type ToolDispatchDescriptor,
70
+ type ToolSchemaEntry,
71
+ toolNamesAtPath,
72
+ } from './core/skill-markdown/schema-validator.js';
43
73
  // Transformers (the pure transformation functions)
44
74
  export {
75
+ createSkillTransformer,
76
+ isSkillTransformer,
45
77
  type PromptContentType,
46
78
  type PromptDefinition,
47
79
  PromptDefinitionSchema,
@@ -54,6 +86,7 @@ export {
54
86
  type SkillDefinition,
55
87
  SkillDefinitionSchema,
56
88
  SkillPropertiesSchema,
89
+ type SkillTransformerConfig,
57
90
  skillCollectionTransformer,
58
91
  skillTransformer,
59
92
  TemplateType,
@@ -33,6 +33,9 @@
33
33
  import { readFileSync } from 'node:fs';
34
34
  import path from 'node:path';
35
35
  import type { Plugin } from 'vite';
36
+ import { loadSkillCatalog, readPackageJson, resolveSkillCatalogPath } from '../core/skill-markdown/load-catalog.js';
37
+ import type { PreprocessSkillMarkdownOptions } from '../core/skill-markdown/preprocess.js';
38
+ import { createSkillTransformer, isSkillTransformer } from '../core/transformers/skill.js';
36
39
  import type { TransformerRule } from '../core/types.js';
37
40
  import { BUILTIN_TRANSFORMER_NAMES, resolveTransformerNames } from '../import-transform/builtins.js';
38
41
 
@@ -43,16 +46,57 @@ export interface VertesiaDevServerPluginOptions {
43
46
  * Pass a subset to disable transformers you don't use.
44
47
  */
45
48
  transformers?: readonly string[];
49
+
50
+ /**
51
+ * Catalog used to resolve `{@tool …}` / `{@skill …}` and validate `tool=`-tagged examples.
52
+ *
53
+ * Defaults to the module named by `vertesia-build.skillCatalog` in the package.json of the
54
+ * Vite root, which is the same key the CLI reads — so dev, test and build agree without the
55
+ * setting being written twice. Pass a path to override it, or an object to supply a catalog
56
+ * directly (useful in tests).
57
+ */
58
+ skillCatalog?: string | PreprocessSkillMarkdownOptions | false;
46
59
  }
47
60
 
48
61
  export function vertesiaDevServerPlugin(options: VertesiaDevServerPluginOptions = {}): Plugin {
49
62
  const names = options.transformers ?? BUILTIN_TRANSFORMER_NAMES;
50
63
  const transformers = resolveTransformerNames([...names]);
51
64
 
65
+ // Resolved once, lazily: the catalog module is compiled output, so it may not exist when the
66
+ // config is first evaluated.
67
+ let root = process.cwd();
68
+
69
+ let boundPromise: Promise<TransformerRule | null> | undefined;
70
+
71
+ /** The catalog-bound skill transformer, or null when this package configures no catalog. */
72
+ const boundSkillTransformer = (): Promise<TransformerRule | null> => {
73
+ if (!boundPromise) {
74
+ boundPromise = (async () => {
75
+ if (options.skillCatalog === false) return null;
76
+ const markdown =
77
+ typeof options.skillCatalog === 'object'
78
+ ? options.skillCatalog
79
+ : await (async () => {
80
+ const modulePath =
81
+ typeof options.skillCatalog === 'string'
82
+ ? path.resolve(root, options.skillCatalog)
83
+ : resolveSkillCatalogPath(readPackageJson(root) ?? {}, root);
84
+ return modulePath ? await loadSkillCatalog(modulePath) : null;
85
+ })();
86
+ return markdown ? createSkillTransformer({ markdown }) : null;
87
+ })();
88
+ }
89
+ return boundPromise;
90
+ };
91
+
52
92
  return {
53
93
  name: 'vertesia-dev-server',
54
94
  enforce: 'pre',
55
95
 
96
+ configResolved(config: { root?: string }) {
97
+ if (config.root) root = config.root;
98
+ },
99
+
56
100
  /**
57
101
  * Map relative query-style imports to absolute paths so Vite's loader
58
102
  * sees a stable id keyed by the original file location. The query
@@ -87,13 +131,17 @@ export function vertesiaDevServerPlugin(options: VertesiaDevServerPluginOptions
87
131
  const matched = findMatchingTransformer(id, transformers);
88
132
  if (!matched) return null;
89
133
 
134
+ // Bind the skill transformer to the catalog on first use. Matched by identity rather
135
+ // than by pattern so a consumer's own transformer for the same files is left alone.
136
+ const active = isSkillTransformer(matched) ? ((await boundSkillTransformer()) ?? matched) : matched;
137
+
90
138
  const cleanId = stripQuery(id);
91
139
  try {
92
- const content = matched.virtual ? '' : readFileSync(cleanId, 'utf-8');
93
- const result = await matched.transform(content, cleanId);
140
+ const content = active.virtual ? '' : readFileSync(cleanId, 'utf-8');
141
+ const result = await active.transform(content, cleanId);
94
142
 
95
- if (matched.schema) {
96
- const validation = matched.schema.safeParse(result.data);
143
+ if (active.schema) {
144
+ const validation = active.schema.safeParse(result.data);
97
145
  if (!validation.success) {
98
146
  const errors = validation.error.issues
99
147
  .map((err) => ` - ${err.path.join('.')}: ${err.message}`)