@fragments-sdk/cli 0.7.4 → 0.7.5

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 (58) hide show
  1. package/LICENSE +1 -4
  2. package/dist/bin.js +33 -14
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-NEJ2FBTN.js → chunk-CR3XHBGM.js} +2 -2
  5. package/dist/{chunk-S56I5FST.js → chunk-EFQ7SIBX.js} +582 -107
  6. package/dist/chunk-EFQ7SIBX.js.map +1 -0
  7. package/dist/{chunk-UXLGIGSX.js → chunk-GIC3I2KZ.js} +2 -2
  8. package/dist/{chunk-R6IZZSE7.js → chunk-JZNATKQA.js} +9 -3
  9. package/dist/chunk-JZNATKQA.js.map +1 -0
  10. package/dist/{chunk-P33AKQJW.js → chunk-SFWZ4K7C.js} +8 -2
  11. package/dist/{chunk-P33AKQJW.js.map → chunk-SFWZ4K7C.js.map} +1 -1
  12. package/dist/{core-3NMNCLFW.js → core-T7BDYEGO.js} +3 -3
  13. package/dist/{generate-23VLX7QN.js → generate-C2DKFCFJ.js} +4 -4
  14. package/dist/index.d.ts +28 -2
  15. package/dist/index.js +8 -6
  16. package/dist/index.js.map +1 -1
  17. package/dist/{init-VYVYMVHH.js → init-O3FCHEPN.js} +22 -6
  18. package/dist/init-O3FCHEPN.js.map +1 -0
  19. package/dist/mcp-bin.js +3 -3
  20. package/dist/{scan-FZR6YVI5.js → scan-IYTZDUKG.js} +5 -5
  21. package/dist/{service-CFFBHW4X.js → service-VA6XKADO.js} +3 -3
  22. package/dist/{static-viewer-VA2JXSCX.js → static-viewer-5N42MBDR.js} +3 -3
  23. package/dist/{test-VTD7R6G2.js → test-OMMDWL2W.js} +3 -3
  24. package/dist/{tokens-7JA5CPDL.js → tokens-6VJAHFIG.js} +4 -4
  25. package/dist/{viewer-WXTDDQGK.js → viewer-IVP5XC7U.js} +22 -14
  26. package/dist/viewer-IVP5XC7U.js.map +1 -0
  27. package/package.json +4 -2
  28. package/src/bin.ts +4 -0
  29. package/src/commands/add.ts +6 -0
  30. package/src/commands/init.ts +18 -2
  31. package/src/commands/validate.ts +24 -2
  32. package/src/core/config.ts +6 -0
  33. package/src/core/index.ts +1 -0
  34. package/src/core/schema.ts +6 -0
  35. package/src/core/types.ts +21 -0
  36. package/src/index.ts +2 -1
  37. package/src/service/snippet-validation.test.ts +209 -0
  38. package/src/service/snippet-validation.ts +635 -0
  39. package/src/validators.ts +53 -5
  40. package/src/viewer/__tests__/viewer-integration.test.ts +8 -0
  41. package/src/viewer/components/CodePanel.naming.test.tsx +60 -0
  42. package/src/viewer/components/CodePanel.tsx +76 -468
  43. package/src/viewer/components/Layout.tsx +1 -1
  44. package/src/viewer/utils/a11y-fixes.ts +24 -9
  45. package/src/viewer/vite-plugin.ts +9 -1
  46. package/dist/chunk-R6IZZSE7.js.map +0 -1
  47. package/dist/chunk-S56I5FST.js.map +0 -1
  48. package/dist/init-VYVYMVHH.js.map +0 -1
  49. package/dist/viewer-WXTDDQGK.js.map +0 -1
  50. /package/dist/{chunk-NEJ2FBTN.js.map → chunk-CR3XHBGM.js.map} +0 -0
  51. /package/dist/{chunk-UXLGIGSX.js.map → chunk-GIC3I2KZ.js.map} +0 -0
  52. /package/dist/{core-3NMNCLFW.js.map → core-T7BDYEGO.js.map} +0 -0
  53. /package/dist/{generate-23VLX7QN.js.map → generate-C2DKFCFJ.js.map} +0 -0
  54. /package/dist/{scan-FZR6YVI5.js.map → scan-IYTZDUKG.js.map} +0 -0
  55. /package/dist/{service-CFFBHW4X.js.map → service-VA6XKADO.js.map} +0 -0
  56. /package/dist/{static-viewer-VA2JXSCX.js.map → static-viewer-5N42MBDR.js.map} +0 -0
  57. /package/dist/{test-VTD7R6G2.js.map → test-OMMDWL2W.js.map} +0 -0
  58. /package/dist/{tokens-7JA5CPDL.js.map → tokens-6VJAHFIG.js.map} +0 -0
@@ -0,0 +1,635 @@
1
+ import ts from 'typescript';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import type { FragmentsConfig, SnippetPolicyConfig } from '../core/types.js';
6
+ import { BRAND } from '../core/constants.js';
7
+ import {
8
+ discoverBlockFiles,
9
+ discoverFragmentFiles,
10
+ extractComponentName,
11
+ type DiscoveredFile,
12
+ } from '../core/node.js';
13
+
14
+ export interface SnippetValidationOptions {
15
+ mode?: 'warn' | 'error';
16
+ scope?: 'snippet' | 'snippet+render';
17
+ requireFullSnippet?: boolean;
18
+ allowedExternalModules?: string[];
19
+ componentStart?: string;
20
+ componentLimit?: number;
21
+ }
22
+
23
+ export interface SnippetValidationResult {
24
+ errors: Array<{ file: string; message: string }>;
25
+ warnings: Array<{ file: string; message: string }>;
26
+ }
27
+
28
+ interface EffectiveSnippetPolicy {
29
+ mode: 'warn' | 'error';
30
+ scope: 'snippet' | 'snippet+render';
31
+ requireFullSnippet: boolean;
32
+ allowedExternalModules: Set<string>;
33
+ componentStart?: string;
34
+ componentLimit?: number;
35
+ }
36
+
37
+ interface SourceContext {
38
+ imports: Map<string, string>;
39
+ localComponents: Set<string>;
40
+ }
41
+
42
+ interface FileIssue {
43
+ file: string;
44
+ message: string;
45
+ }
46
+
47
+ const INTRINSIC_TAGS = new Set([
48
+ 'div',
49
+ 'span',
50
+ 'p',
51
+ 'h1',
52
+ 'h2',
53
+ 'h3',
54
+ 'h4',
55
+ 'h5',
56
+ 'h6',
57
+ 'main',
58
+ 'section',
59
+ 'article',
60
+ 'aside',
61
+ 'nav',
62
+ 'header',
63
+ 'footer',
64
+ 'ul',
65
+ 'ol',
66
+ 'li',
67
+ 'button',
68
+ 'input',
69
+ 'textarea',
70
+ 'label',
71
+ 'svg',
72
+ 'path',
73
+ ]);
74
+
75
+ const JSX_TAG_PATTERN = /<\s*([A-Za-z][A-Za-z0-9.]*)\b/g;
76
+ const STYLE_PATTERN = /\bstyle\s*=\s*\{/;
77
+ const TRANSPILED_PATTERN = /jsxDEV|_jsx|@__PURE__|\bfileName\s*:|\blineNumber\s*:|\bcolumnNumber\s*:/;
78
+ const ALIAS_DRIFT_PATTERN = /<\s*[A-Z][A-Za-z0-9]*(?:Root|2)\b/;
79
+ const HAS_IMPORT_PATTERN = /\bimport\s+[^;]+\s+from\s+['"][^'"]+['"]/;
80
+ const HAS_JSX_PATTERN = /<\s*[A-Za-z][A-Za-z0-9.]*\b/;
81
+
82
+ const DEFAULT_POLICY: EffectiveSnippetPolicy = {
83
+ mode: 'warn',
84
+ scope: 'snippet+render',
85
+ requireFullSnippet: true,
86
+ allowedExternalModules: new Set([
87
+ '@phosphor-icons/react',
88
+ 'recharts',
89
+ 'react-day-picker',
90
+ ]),
91
+ };
92
+
93
+ function normalizePolicy(
94
+ configured: SnippetPolicyConfig | undefined,
95
+ overrides: SnippetValidationOptions
96
+ ): EffectiveSnippetPolicy {
97
+ const fromConfig: EffectiveSnippetPolicy = {
98
+ mode: configured?.mode ?? DEFAULT_POLICY.mode,
99
+ scope: configured?.scope ?? DEFAULT_POLICY.scope,
100
+ requireFullSnippet: configured?.requireFullSnippet ?? DEFAULT_POLICY.requireFullSnippet,
101
+ allowedExternalModules: new Set(configured?.allowedExternalModules ?? [...DEFAULT_POLICY.allowedExternalModules]),
102
+ componentStart: overrides.componentStart,
103
+ componentLimit: overrides.componentLimit,
104
+ };
105
+
106
+ if (overrides.mode) fromConfig.mode = overrides.mode;
107
+ if (overrides.scope) fromConfig.scope = overrides.scope;
108
+ if (typeof overrides.requireFullSnippet === 'boolean') {
109
+ fromConfig.requireFullSnippet = overrides.requireFullSnippet;
110
+ }
111
+ if (overrides.allowedExternalModules && overrides.allowedExternalModules.length > 0) {
112
+ fromConfig.allowedExternalModules = new Set(overrides.allowedExternalModules);
113
+ }
114
+
115
+ return fromConfig;
116
+ }
117
+
118
+ function isFragmentsModule(modulePath: string): boolean {
119
+ return (
120
+ modulePath === '@fragments-sdk/ui'
121
+ || modulePath === '@fragments/ui'
122
+ || modulePath === '.'
123
+ || modulePath === '..'
124
+ || modulePath.startsWith('@/components/')
125
+ || modulePath.startsWith('@components/')
126
+ || modulePath.startsWith('./')
127
+ || modulePath.startsWith('../')
128
+ );
129
+ }
130
+
131
+ function collectSourceContext(sourceFile: ts.SourceFile): SourceContext {
132
+ const imports = new Map<string, string>();
133
+ const localComponents = new Set<string>();
134
+
135
+ function markLocal(name: string | undefined): void {
136
+ if (!name) return;
137
+ if (/^[A-Z]/.test(name)) {
138
+ localComponents.add(name);
139
+ }
140
+ }
141
+
142
+ function visit(node: ts.Node): void {
143
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
144
+ const modulePath = node.moduleSpecifier.text;
145
+ const clause = node.importClause;
146
+
147
+ if (clause?.name) {
148
+ imports.set(clause.name.text, modulePath);
149
+ }
150
+
151
+ if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
152
+ for (const item of clause.namedBindings.elements) {
153
+ imports.set(item.name.text, modulePath);
154
+ }
155
+ }
156
+ }
157
+
158
+ if (ts.isFunctionDeclaration(node)) {
159
+ markLocal(node.name?.text);
160
+ }
161
+
162
+ if (ts.isClassDeclaration(node)) {
163
+ markLocal(node.name?.text);
164
+ }
165
+
166
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
167
+ markLocal(node.name.text);
168
+ }
169
+
170
+ ts.forEachChild(node, visit);
171
+ }
172
+
173
+ visit(sourceFile);
174
+ return { imports, localComponents };
175
+ }
176
+
177
+ function getJsxTags(code: string): string[] {
178
+ const tags: string[] = [];
179
+ JSX_TAG_PATTERN.lastIndex = 0;
180
+
181
+ let match: RegExpExecArray | null;
182
+ while ((match = JSX_TAG_PATTERN.exec(code)) !== null) {
183
+ tags.push(match[1]);
184
+ }
185
+
186
+ return tags;
187
+ }
188
+
189
+ function rootTagName(tag: string): string {
190
+ return tag.split('.')[0];
191
+ }
192
+
193
+ function parseSnippetImports(snippet: string): Map<string, string> {
194
+ const sourceFile = ts.createSourceFile(
195
+ 'snippet.tsx',
196
+ snippet,
197
+ ts.ScriptTarget.Latest,
198
+ true,
199
+ ts.ScriptKind.TSX,
200
+ );
201
+ return collectSourceContext(sourceFile).imports;
202
+ }
203
+
204
+ function findDefineCall(sourceFile: ts.SourceFile, name: 'defineFragment' | 'defineBlock'): ts.CallExpression | null {
205
+ let result: ts.CallExpression | null = null;
206
+
207
+ function visit(node: ts.Node): void {
208
+ if (result) return;
209
+
210
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === name) {
211
+ result = node;
212
+ return;
213
+ }
214
+
215
+ ts.forEachChild(node, visit);
216
+ }
217
+
218
+ visit(sourceFile);
219
+ return result;
220
+ }
221
+
222
+ function findProperty(obj: ts.ObjectLiteralExpression, propertyName: string): ts.Expression | null {
223
+ for (const prop of obj.properties) {
224
+ if (!ts.isPropertyAssignment(prop)) continue;
225
+ if (!ts.isIdentifier(prop.name)) continue;
226
+ if (prop.name.text === propertyName) {
227
+ return prop.initializer;
228
+ }
229
+ }
230
+
231
+ return null;
232
+ }
233
+
234
+ function readStaticString(expr: ts.Expression | null): string | null {
235
+ if (!expr) return null;
236
+ if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
237
+ return expr.text;
238
+ }
239
+ return null;
240
+ }
241
+
242
+ function readRenderBody(renderExpr: ts.Expression, sourceFile: ts.SourceFile): string | null {
243
+ if (!ts.isArrowFunction(renderExpr) && !ts.isFunctionExpression(renderExpr)) {
244
+ return null;
245
+ }
246
+
247
+ const body = renderExpr.body;
248
+ const start = body.getStart(sourceFile);
249
+ const end = body.getEnd();
250
+ return sourceFile.text.slice(start, end).trim();
251
+ }
252
+
253
+ function report(issues: FileIssue[], file: string, message: string): void {
254
+ issues.push({ file, message });
255
+ }
256
+
257
+ function validateRawRules(
258
+ issues: FileIssue[],
259
+ file: string,
260
+ label: string,
261
+ code: string,
262
+ ): void {
263
+ if (STYLE_PATTERN.test(code)) {
264
+ report(issues, file, `${label}: inline style usage is not allowed; use Box/Stack/Text props.`);
265
+ }
266
+
267
+ if (TRANSPILED_PATTERN.test(code)) {
268
+ report(issues, file, `${label}: transpiler output detected (jsxDEV/_jsx/@__PURE__). Use authored snippet source.`);
269
+ }
270
+
271
+ if (ALIAS_DRIFT_PATTERN.test(code)) {
272
+ report(issues, file, `${label}: alias drift tag detected (*Root/*2). Use canonical component names.`);
273
+ }
274
+
275
+ const tags = getJsxTags(code);
276
+ const intrinsic = tags
277
+ .map((tag) => rootTagName(tag))
278
+ .filter((tag) => /^[a-z]/.test(tag))
279
+ .map((tag) => tag.toLowerCase())
280
+ .filter((tag) => INTRINSIC_TAGS.has(tag));
281
+
282
+ if (intrinsic.length > 0) {
283
+ const names = [...new Set(intrinsic)].sort().join(', ');
284
+ report(issues, file, `${label}: raw HTML tags are not allowed (${names}). Use Fragments primitives.`);
285
+ }
286
+ }
287
+
288
+ function validateComponentAllowlist(
289
+ issues: FileIssue[],
290
+ file: string,
291
+ label: string,
292
+ code: string,
293
+ imports: Map<string, string>,
294
+ localComponents: Set<string>,
295
+ policy: EffectiveSnippetPolicy,
296
+ ): void {
297
+ const tags = getJsxTags(code);
298
+ const seen = new Set<string>();
299
+
300
+ for (const tag of tags) {
301
+ const root = rootTagName(tag);
302
+
303
+ if (seen.has(root)) continue;
304
+ seen.add(root);
305
+
306
+ if (!/^[A-Z]/.test(root)) {
307
+ continue;
308
+ }
309
+
310
+ const modulePath = imports.get(root);
311
+
312
+ if (modulePath) {
313
+ if (isFragmentsModule(modulePath)) {
314
+ continue;
315
+ }
316
+ if (policy.allowedExternalModules.has(modulePath)) {
317
+ continue;
318
+ }
319
+
320
+ report(
321
+ issues,
322
+ file,
323
+ `${label}: component "${root}" comes from "${modulePath}" and is not in snippets.allowedExternalModules.`,
324
+ );
325
+ continue;
326
+ }
327
+
328
+ if (localComponents.has(root)) {
329
+ report(
330
+ issues,
331
+ file,
332
+ `${label}: locally defined JSX component "${root}" is not allowed in snippets/renders. Import approved components instead.`,
333
+ );
334
+ continue;
335
+ }
336
+
337
+ report(
338
+ issues,
339
+ file,
340
+ `${label}: component "${root}" is used without an import and is not allowed.`,
341
+ );
342
+ }
343
+ }
344
+
345
+ function validateSnippetString(
346
+ issues: FileIssue[],
347
+ file: string,
348
+ label: string,
349
+ snippet: string,
350
+ policy: EffectiveSnippetPolicy,
351
+ ): void {
352
+ validateRawRules(issues, file, label, snippet);
353
+
354
+ if (policy.requireFullSnippet) {
355
+ if (!HAS_IMPORT_PATTERN.test(snippet)) {
356
+ report(issues, file, `${label}: full snippet required (missing import statement).`);
357
+ }
358
+
359
+ if (!HAS_JSX_PATTERN.test(snippet)) {
360
+ report(issues, file, `${label}: full snippet required (missing JSX usage).`);
361
+ }
362
+ }
363
+
364
+ const imports = parseSnippetImports(snippet);
365
+ validateComponentAllowlist(issues, file, label, snippet, imports, new Set(), policy);
366
+ }
367
+
368
+ function validateFragmentSource(
369
+ sourceFile: ts.SourceFile,
370
+ file: string,
371
+ policy: EffectiveSnippetPolicy,
372
+ issues: FileIssue[],
373
+ ): void {
374
+ const context = collectSourceContext(sourceFile);
375
+
376
+ const defineCall = findDefineCall(sourceFile, 'defineFragment');
377
+ if (!defineCall) {
378
+ return;
379
+ }
380
+
381
+ const arg = defineCall.arguments[0];
382
+ if (!arg || !ts.isObjectLiteralExpression(arg)) {
383
+ return;
384
+ }
385
+
386
+ const variantsExpr = findProperty(arg, 'variants');
387
+ if (!variantsExpr || !ts.isArrayLiteralExpression(variantsExpr)) {
388
+ return;
389
+ }
390
+
391
+ for (const variantExpr of variantsExpr.elements) {
392
+ if (!ts.isObjectLiteralExpression(variantExpr)) continue;
393
+
394
+ const name = readStaticString(findProperty(variantExpr, 'name')) ?? 'Unknown';
395
+ const labelPrefix = `variant "${name}"`;
396
+
397
+ const codeExpr = findProperty(variantExpr, 'code');
398
+ const snippet = readStaticString(codeExpr);
399
+ if (snippet) {
400
+ validateSnippetString(issues, file, `${labelPrefix} snippet`, snippet, policy);
401
+ } else {
402
+ report(issues, file, `${labelPrefix}: missing explicit code snippet (variant.code).`);
403
+ }
404
+
405
+ if (policy.scope === 'snippet+render') {
406
+ const renderExpr = findProperty(variantExpr, 'render');
407
+ if (renderExpr) {
408
+ const renderBody = readRenderBody(renderExpr, sourceFile);
409
+ if (!renderBody) {
410
+ report(issues, file, `${labelPrefix} render: expected a static render function.`);
411
+ continue;
412
+ }
413
+
414
+ validateRawRules(issues, file, `${labelPrefix} render`, renderBody);
415
+ validateComponentAllowlist(
416
+ issues,
417
+ file,
418
+ `${labelPrefix} render`,
419
+ renderBody,
420
+ context.imports,
421
+ context.localComponents,
422
+ policy,
423
+ );
424
+ }
425
+ }
426
+ }
427
+ }
428
+
429
+ function validateBlockSource(
430
+ sourceFile: ts.SourceFile,
431
+ file: string,
432
+ policy: EffectiveSnippetPolicy,
433
+ issues: FileIssue[],
434
+ ): void {
435
+ const defineCall = findDefineCall(sourceFile, 'defineBlock');
436
+ if (!defineCall) {
437
+ return;
438
+ }
439
+
440
+ const arg = defineCall.arguments[0];
441
+ if (!arg || !ts.isObjectLiteralExpression(arg)) {
442
+ return;
443
+ }
444
+
445
+ const codeExpr = findProperty(arg, 'code');
446
+ const snippet = readStaticString(codeExpr);
447
+ if (!snippet) {
448
+ report(issues, file, 'block snippet: missing static code string.');
449
+ return;
450
+ }
451
+
452
+ validateSnippetString(issues, file, 'block snippet', snippet, policy);
453
+ }
454
+
455
+ function validateBlockPreviewExamples(
456
+ sourceFile: ts.SourceFile,
457
+ file: string,
458
+ policy: EffectiveSnippetPolicy,
459
+ issues: FileIssue[],
460
+ ): void {
461
+ if (policy.scope !== 'snippet+render') {
462
+ return;
463
+ }
464
+
465
+ const code = sourceFile.text;
466
+ const context = collectSourceContext(sourceFile);
467
+
468
+ validateRawRules(issues, file, 'block preview render', code);
469
+ validateComponentAllowlist(
470
+ issues,
471
+ file,
472
+ 'block preview render',
473
+ code,
474
+ context.imports,
475
+ context.localComponents,
476
+ policy,
477
+ );
478
+ }
479
+
480
+ function sourceFileFromText(filePath: string, content: string): ts.SourceFile {
481
+ return ts.createSourceFile(
482
+ filePath,
483
+ content,
484
+ ts.ScriptTarget.Latest,
485
+ true,
486
+ ts.ScriptKind.TSX,
487
+ );
488
+ }
489
+
490
+ function sortAndFilterBatch(
491
+ files: DiscoveredFile[],
492
+ componentStart?: string,
493
+ componentLimit?: number,
494
+ ): { selected: DiscoveredFile[]; warning?: string } {
495
+ const getComponentName = (relativePath: string): string => {
496
+ const normalized = relativePath.replace(/\\/g, '/');
497
+ const fileName = normalized.split('/').pop() ?? normalized;
498
+ if (fileName.endsWith(BRAND.fileExtension)) {
499
+ return fileName.slice(0, -BRAND.fileExtension.length);
500
+ }
501
+ return extractComponentName(relativePath);
502
+ };
503
+
504
+ const sorted = [...files].sort((a, b) => {
505
+ const nameA = getComponentName(a.relativePath).toLowerCase();
506
+ const nameB = getComponentName(b.relativePath).toLowerCase();
507
+ return nameA.localeCompare(nameB);
508
+ });
509
+
510
+ if (!componentStart && !componentLimit) {
511
+ return { selected: sorted };
512
+ }
513
+
514
+ const startName = componentStart?.toLowerCase();
515
+ let startIndex = 0;
516
+
517
+ if (startName) {
518
+ const foundIndex = sorted.findIndex((file) => getComponentName(file.relativePath).toLowerCase() === startName);
519
+ if (foundIndex === -1) {
520
+ return {
521
+ selected: [],
522
+ warning: `Component start "${componentStart}" not found for snippet validation batch.`,
523
+ };
524
+ }
525
+ startIndex = foundIndex;
526
+ }
527
+
528
+ const limit = componentLimit && componentLimit > 0 ? componentLimit : sorted.length;
529
+ return {
530
+ selected: sorted.slice(startIndex, startIndex + limit),
531
+ };
532
+ }
533
+
534
+ async function findBlockPreviewExamplesFile(configDir: string): Promise<string | null> {
535
+ const candidates = [
536
+ join(configDir, 'apps/docs/src/app/(docs)/blocks/examples/index.tsx'),
537
+ join(configDir, '../apps/docs/src/app/(docs)/blocks/examples/index.tsx'),
538
+ join(configDir, '../../apps/docs/src/app/(docs)/blocks/examples/index.tsx'),
539
+ ];
540
+
541
+ for (const candidate of candidates) {
542
+ if (existsSync(candidate)) {
543
+ return candidate;
544
+ }
545
+ }
546
+
547
+ return null;
548
+ }
549
+
550
+ function toValidationResult(policy: EffectiveSnippetPolicy, issues: FileIssue[]): SnippetValidationResult {
551
+ if (policy.mode === 'error') {
552
+ return {
553
+ errors: issues,
554
+ warnings: [],
555
+ };
556
+ }
557
+
558
+ return {
559
+ errors: [],
560
+ warnings: issues,
561
+ };
562
+ }
563
+
564
+ /**
565
+ * Validate snippet and render policy for fragments, blocks, and docs block previews.
566
+ */
567
+ export async function validateSnippetPolicy(
568
+ config: FragmentsConfig,
569
+ configDir: string,
570
+ options: SnippetValidationOptions = {},
571
+ ): Promise<SnippetValidationResult> {
572
+ const policy = normalizePolicy(config.snippets, options);
573
+ const issues: FileIssue[] = [];
574
+
575
+ const discovered = await discoverFragmentFiles(config, configDir);
576
+ const fragmentFiles = discovered.filter((file) => file.relativePath.endsWith(BRAND.fileExtension));
577
+
578
+ const batchResult = sortAndFilterBatch(fragmentFiles, policy.componentStart, policy.componentLimit);
579
+ if (batchResult.warning) {
580
+ issues.push({ file: 'snippets', message: batchResult.warning });
581
+ }
582
+
583
+ for (const file of batchResult.selected) {
584
+ try {
585
+ const content = await readFile(file.absolutePath, 'utf-8');
586
+ const sourceFile = sourceFileFromText(file.relativePath, content);
587
+ validateFragmentSource(sourceFile, file.relativePath, policy, issues);
588
+ } catch (error) {
589
+ issues.push({
590
+ file: file.relativePath,
591
+ message: `Failed to validate fragment snippets: ${error instanceof Error ? error.message : String(error)}`,
592
+ });
593
+ }
594
+ }
595
+
596
+ const isBatchOnly = Boolean(policy.componentStart || policy.componentLimit);
597
+ if (!isBatchOnly) {
598
+ try {
599
+ const blockFiles = await discoverBlockFiles(configDir, config.exclude);
600
+ for (const file of blockFiles) {
601
+ try {
602
+ const content = await readFile(file.absolutePath, 'utf-8');
603
+ const sourceFile = sourceFileFromText(file.relativePath, content);
604
+ validateBlockSource(sourceFile, file.relativePath, policy, issues);
605
+ } catch (error) {
606
+ issues.push({
607
+ file: file.relativePath,
608
+ message: `Failed to validate block snippets: ${error instanceof Error ? error.message : String(error)}`,
609
+ });
610
+ }
611
+ }
612
+ } catch (error) {
613
+ issues.push({
614
+ file: 'blocks',
615
+ message: `Failed to discover block files: ${error instanceof Error ? error.message : String(error)}`,
616
+ });
617
+ }
618
+
619
+ const blockPreviewFile = await findBlockPreviewExamplesFile(configDir);
620
+ if (blockPreviewFile) {
621
+ try {
622
+ const content = await readFile(blockPreviewFile, 'utf-8');
623
+ const sourceFile = sourceFileFromText(blockPreviewFile, content);
624
+ validateBlockPreviewExamples(sourceFile, blockPreviewFile, policy, issues);
625
+ } catch (error) {
626
+ issues.push({
627
+ file: blockPreviewFile,
628
+ message: `Failed to validate block preview examples: ${error instanceof Error ? error.message : String(error)}`,
629
+ });
630
+ }
631
+ }
632
+ }
633
+
634
+ return toValidationResult(policy, issues);
635
+ }
package/src/validators.ts CHANGED
@@ -4,8 +4,8 @@ import {
4
4
  discoverComponentFiles,
5
5
  extractComponentName,
6
6
  loadFragmentFile,
7
- type DiscoveredFile,
8
7
  } from './core/node.js';
8
+ import { validateSnippetPolicy, type SnippetValidationOptions } from './service/snippet-validation.js';
9
9
 
10
10
  export interface ValidationResult {
11
11
  valid: boolean;
@@ -24,6 +24,13 @@ export interface ValidationWarning {
24
24
  message: string;
25
25
  }
26
26
 
27
+ export interface ValidationRunOptions {
28
+ snippets?: boolean;
29
+ snippetMode?: 'warn' | 'error';
30
+ componentStart?: string;
31
+ componentLimit?: number;
32
+ }
33
+
27
34
  /**
28
35
  * Validate fragment file schema
29
36
  */
@@ -137,16 +144,57 @@ export async function validateCoverage(
137
144
  */
138
145
  export async function validateAll(
139
146
  config: FragmentsConfig,
140
- configDir: string
147
+ configDir: string,
148
+ options: ValidationRunOptions = {}
141
149
  ): Promise<ValidationResult> {
142
150
  const [schemaResult, coverageResult] = await Promise.all([
143
151
  validateSchema(config, configDir),
144
152
  validateCoverage(config, configDir),
145
153
  ]);
146
154
 
155
+ if (options.snippets === false) {
156
+ return {
157
+ valid: schemaResult.valid && coverageResult.valid,
158
+ errors: [...schemaResult.errors, ...coverageResult.errors],
159
+ warnings: [...schemaResult.warnings, ...coverageResult.warnings],
160
+ };
161
+ }
162
+
163
+ const snippetOptions: SnippetValidationOptions = {
164
+ ...(options.snippetMode && { mode: options.snippetMode }),
165
+ ...(options.componentStart && { componentStart: options.componentStart }),
166
+ ...(typeof options.componentLimit === 'number'
167
+ ? { componentLimit: options.componentLimit }
168
+ : {}),
169
+ };
170
+ const snippetResult = await validateSnippetPolicy(config, configDir, snippetOptions);
171
+
172
+ return {
173
+ valid: schemaResult.valid && coverageResult.valid && snippetResult.errors.length === 0,
174
+ errors: [...schemaResult.errors, ...coverageResult.errors, ...snippetResult.errors],
175
+ warnings: [...schemaResult.warnings, ...coverageResult.warnings, ...snippetResult.warnings],
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Validate snippet/render policy only.
181
+ */
182
+ export async function validateSnippets(
183
+ config: FragmentsConfig,
184
+ configDir: string,
185
+ options: ValidationRunOptions = {}
186
+ ): Promise<ValidationResult> {
187
+ const snippetResult = await validateSnippetPolicy(config, configDir, {
188
+ ...(options.snippetMode && { mode: options.snippetMode }),
189
+ ...(options.componentStart && { componentStart: options.componentStart }),
190
+ ...(typeof options.componentLimit === 'number'
191
+ ? { componentLimit: options.componentLimit }
192
+ : {}),
193
+ });
194
+
147
195
  return {
148
- valid: schemaResult.valid && coverageResult.valid,
149
- errors: [...schemaResult.errors, ...coverageResult.errors],
150
- warnings: [...schemaResult.warnings, ...coverageResult.warnings],
196
+ valid: snippetResult.errors.length === 0,
197
+ errors: snippetResult.errors,
198
+ warnings: snippetResult.warnings,
151
199
  };
152
200
  }