radiant-docs-validator 0.1.5 → 0.1.6

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/index.d.ts CHANGED
@@ -196,4 +196,17 @@ declare function resolveDocsHref(href: string): DocsHrefResolution;
196
196
  declare const resolveDocsPageHref: typeof resolveDocsHref;
197
197
  declare function validateMdxContent(): Promise<void>;
198
198
 
199
- export { type AssistantButtonConfig, type AssistantButtonSize, type AssistantConfig, type AssistantIcon, type AssistantNavbarButtonConfig, BASE_COLOR_OPTIONS, type BaseColorByMode, type BaseColorOption, type CardButtonTheme, type CardCoverTheme, type CardTheme, type CodeSyntaxThemeConfig, type CodeTheme, DEFAULT_THEME_COLOR_DARK, DEFAULT_THEME_COLOR_LIGHT, type DocsConfig, type DocsHrefResolution, type DocsTheme, type DocsValidatorOptions, type Footer, type FooterLink, type HiddenPageRoute, type Logo, type LogoVariant, type NavGroup, type NavMenu, type NavMenuItem, type NavOpenApi, type NavOpenApiPage, type NavOpenApiPageRef, type NavPage, type NavTag, type NavbarItem, type NavigationItem, PUBLISHABLE_STATIC_ASSET_EXTENSIONS, type SocialPlatform, type TagTheme, type ThemeColorByMode, configureDocsValidator, getConfig, isPublishableStaticAssetPath, loadOpenApiSpec, resolveDocsHref, resolveDocsPageHref, validateMdxContent };
199
+ type MdxJsxElementNode = {
200
+ name?: string | null;
201
+ attributes?: unknown[];
202
+ children?: unknown[];
203
+ };
204
+ type RadiantComponentValidationOptions = {
205
+ sourceFile: string;
206
+ validateLinkHref?: (href: string) => void;
207
+ validateAssetHref?: (href: string) => void;
208
+ };
209
+ declare function validateRadiantComponentProps(componentName: string, props: Record<string, unknown>, options: RadiantComponentValidationOptions): void;
210
+ declare function validateRadiantComponentNode(node: MdxJsxElementNode, options: RadiantComponentValidationOptions): void;
211
+
212
+ export { type AssistantButtonConfig, type AssistantButtonSize, type AssistantConfig, type AssistantIcon, type AssistantNavbarButtonConfig, BASE_COLOR_OPTIONS, type BaseColorByMode, type BaseColorOption, type CardButtonTheme, type CardCoverTheme, type CardTheme, type CodeSyntaxThemeConfig, type CodeTheme, DEFAULT_THEME_COLOR_DARK, DEFAULT_THEME_COLOR_LIGHT, type DocsConfig, type DocsHrefResolution, type DocsTheme, type DocsValidatorOptions, type Footer, type FooterLink, type HiddenPageRoute, type Logo, type LogoVariant, type NavGroup, type NavMenu, type NavMenuItem, type NavOpenApi, type NavOpenApiPage, type NavOpenApiPageRef, type NavPage, type NavTag, type NavbarItem, type NavigationItem, PUBLISHABLE_STATIC_ASSET_EXTENSIONS, type RadiantComponentValidationOptions, type SocialPlatform, type TagTheme, type ThemeColorByMode, configureDocsValidator, getConfig, isPublishableStaticAssetPath, loadOpenApiSpec, resolveDocsHref, resolveDocsPageHref, validateMdxContent, validateRadiantComponentNode, validateRadiantComponentProps };
package/dist/index.js CHANGED
@@ -16,6 +16,626 @@ import pkg from "@stoplight/spectral-core";
16
16
  import { oas } from "@stoplight/spectral-rulesets";
17
17
  import { compile } from "@mdx-js/mdx";
18
18
  import yaml from "yaml";
19
+
20
+ // src/component-validation.ts
21
+ import { compileSync } from "@mdx-js/mdx";
22
+ function componentError(componentName, message, sourceFile) {
23
+ throw new Error(`<${componentName}>: ${message} (in ${sourceFile})`);
24
+ }
25
+ function readStaticEstreeValue(node, componentName, propName, sourceFile) {
26
+ if (!node) {
27
+ componentError(
28
+ componentName,
29
+ `Invalid prop "${propName}": expected a static literal value`,
30
+ sourceFile
31
+ );
32
+ }
33
+ if (node.type === "Literal") {
34
+ return node.value;
35
+ }
36
+ if (node.type === "TemplateLiteral") {
37
+ const expressions = node.expressions;
38
+ const quasis = node.quasis;
39
+ if (Array.isArray(expressions) && expressions.length === 0 && Array.isArray(quasis) && quasis.length === 1) {
40
+ const value = quasis[0];
41
+ if (typeof value.value?.cooked === "string") {
42
+ return value.value.cooked;
43
+ }
44
+ }
45
+ }
46
+ if (node.type === "UnaryExpression") {
47
+ const operator = node.operator;
48
+ const argument = readStaticEstreeValue(
49
+ node.argument,
50
+ componentName,
51
+ propName,
52
+ sourceFile
53
+ );
54
+ if ((operator === "+" || operator === "-") && typeof argument === "number") {
55
+ return operator === "-" ? -argument : argument;
56
+ }
57
+ }
58
+ if (node.type === "ArrayExpression") {
59
+ const elements = node.elements;
60
+ if (Array.isArray(elements)) {
61
+ return elements.map((element) => {
62
+ if (!element || element.type === "SpreadElement") {
63
+ componentError(
64
+ componentName,
65
+ `Invalid prop "${propName}": arrays cannot contain empty items or spreads`,
66
+ sourceFile
67
+ );
68
+ }
69
+ return readStaticEstreeValue(
70
+ element,
71
+ componentName,
72
+ propName,
73
+ sourceFile
74
+ );
75
+ });
76
+ }
77
+ }
78
+ if (node.type === "ObjectExpression") {
79
+ const properties = node.properties;
80
+ if (Array.isArray(properties)) {
81
+ const value = {};
82
+ for (const propertyValue of properties) {
83
+ const property = propertyValue;
84
+ if (property.type !== "Property" || property.kind !== "init" || property.computed === true || property.method === true || property.shorthand === true) {
85
+ componentError(
86
+ componentName,
87
+ `Invalid prop "${propName}": objects cannot contain spreads, methods, computed keys, or shorthand values`,
88
+ sourceFile
89
+ );
90
+ }
91
+ const keyNode = property.key;
92
+ const key = keyNode.type === "Identifier" ? keyNode.name : keyNode.type === "Literal" ? keyNode.value : void 0;
93
+ if (typeof key !== "string") {
94
+ componentError(
95
+ componentName,
96
+ `Invalid prop "${propName}": object keys must be static strings`,
97
+ sourceFile
98
+ );
99
+ }
100
+ value[key] = readStaticEstreeValue(
101
+ property.value,
102
+ componentName,
103
+ propName,
104
+ sourceFile
105
+ );
106
+ }
107
+ return value;
108
+ }
109
+ }
110
+ componentError(
111
+ componentName,
112
+ `Invalid prop "${propName}": expected a static literal value`,
113
+ sourceFile
114
+ );
115
+ }
116
+ function readStaticExpression(value, componentName, propName, sourceFile) {
117
+ const program = value?.data?.estree;
118
+ const body = program?.body;
119
+ if (!Array.isArray(body) || body.length !== 1) {
120
+ componentError(
121
+ componentName,
122
+ `Invalid prop "${propName}": expected a static literal value`,
123
+ sourceFile
124
+ );
125
+ }
126
+ const statement = body[0];
127
+ if (statement.type !== "ExpressionStatement") {
128
+ componentError(
129
+ componentName,
130
+ `Invalid prop "${propName}": expected a static literal value`,
131
+ sourceFile
132
+ );
133
+ }
134
+ return readStaticEstreeValue(
135
+ statement.expression,
136
+ componentName,
137
+ propName,
138
+ sourceFile
139
+ );
140
+ }
141
+ function readStaticProps(node, componentName, sourceFile) {
142
+ const props = {};
143
+ for (const rawAttribute of node.attributes ?? []) {
144
+ const attribute = rawAttribute;
145
+ if (attribute.type !== "mdxJsxAttribute" || typeof attribute.name !== "string") {
146
+ componentError(
147
+ componentName,
148
+ "Component prop spreads are not supported. Use explicit static props.",
149
+ sourceFile
150
+ );
151
+ }
152
+ if (attribute.value === null) {
153
+ props[attribute.name] = true;
154
+ } else if (typeof attribute.value === "string") {
155
+ props[attribute.name] = attribute.value;
156
+ } else {
157
+ props[attribute.name] = readStaticExpression(
158
+ attribute.value,
159
+ componentName,
160
+ attribute.name,
161
+ sourceFile
162
+ );
163
+ }
164
+ }
165
+ return props;
166
+ }
167
+ function assertNoUnknownProps(componentName, props, allowedProps, sourceFile) {
168
+ const allowed = new Set(allowedProps);
169
+ const unknownProps = Object.keys(props).filter((key) => !allowed.has(key));
170
+ if (unknownProps.length === 0) return;
171
+ const propLabel = unknownProps.length === 1 ? "prop" : "props";
172
+ const unknownLabel = unknownProps.map((name) => `"${name}"`).join(", ");
173
+ const allowedLabel = allowedProps.map((name) => `"${name}"`).join(", ");
174
+ componentError(
175
+ componentName,
176
+ `Unsupported ${propLabel}: ${unknownLabel}. Allowed props: ${allowedLabel}`,
177
+ sourceFile
178
+ );
179
+ }
180
+ function assertRequired(componentName, propName, value, sourceFile) {
181
+ if (value === void 0 || value === null || value === "") {
182
+ componentError(
183
+ componentName,
184
+ `Missing required prop "${propName}"`,
185
+ sourceFile
186
+ );
187
+ }
188
+ }
189
+ function getValueType(value) {
190
+ if (value === null) return "null";
191
+ if (value === void 0) return "undefined";
192
+ if (Array.isArray(value)) return "array";
193
+ return typeof value;
194
+ }
195
+ function assertType(componentName, propName, value, expectedTypes, sourceFile) {
196
+ if (value === void 0) return;
197
+ const actualType = getValueType(value);
198
+ if (!expectedTypes.includes(actualType)) {
199
+ componentError(
200
+ componentName,
201
+ `Invalid prop "${propName}": expected ${expectedTypes.join(" or ")}, got ${actualType}`,
202
+ sourceFile
203
+ );
204
+ }
205
+ }
206
+ function assertEnum(componentName, propName, value, allowedValues, sourceFile) {
207
+ if (value === void 0) return;
208
+ if (typeof value !== "string" || !allowedValues.includes(value)) {
209
+ componentError(
210
+ componentName,
211
+ `Invalid prop "${propName}"="${String(value)}". Expected one of: ${allowedValues.join(", ")}`,
212
+ sourceFile
213
+ );
214
+ }
215
+ }
216
+ function assertPlainObject(componentName, propName, value, sourceFile) {
217
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
218
+ componentError(
219
+ componentName,
220
+ `Invalid prop "${propName}": expected object`,
221
+ sourceFile
222
+ );
223
+ }
224
+ }
225
+ function assertHexColor(componentName, propName, value, sourceFile) {
226
+ if (typeof value !== "string") {
227
+ componentError(
228
+ componentName,
229
+ `Invalid prop "${propName}": expected string`,
230
+ sourceFile
231
+ );
232
+ }
233
+ const normalized = value.trim().startsWith("#") ? value.trim() : `#${value.trim()}`;
234
+ if (!/^#(?:[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/.test(normalized)) {
235
+ componentError(
236
+ componentName,
237
+ `Invalid prop "${propName}": expected a hex color like "#3b82f6"`,
238
+ sourceFile
239
+ );
240
+ }
241
+ }
242
+ function validateCard(props, options) {
243
+ const componentName = "Card";
244
+ assertNoUnknownProps(
245
+ componentName,
246
+ props,
247
+ ["title", "href", "icon", "cover", "button"],
248
+ options.sourceFile
249
+ );
250
+ assertRequired(componentName, "title", props.title, options.sourceFile);
251
+ assertType(componentName, "title", props.title, ["string"], options.sourceFile);
252
+ assertType(componentName, "href", props.href, ["string"], options.sourceFile);
253
+ assertType(componentName, "icon", props.icon, ["string"], options.sourceFile);
254
+ if (typeof props.href === "string") {
255
+ options.validateLinkHref?.(props.href);
256
+ }
257
+ if (props.cover !== void 0) {
258
+ assertPlainObject(componentName, "cover", props.cover, options.sourceFile);
259
+ assertNoUnknownProps(
260
+ "Card.cover",
261
+ props.cover,
262
+ ["icon", "text", "glass", "colors", "patternSeed", "colorSeed"],
263
+ options.sourceFile
264
+ );
265
+ assertType("Card.cover", "icon", props.cover.icon, ["string"], options.sourceFile);
266
+ assertType("Card.cover", "text", props.cover.text, ["string"], options.sourceFile);
267
+ assertType("Card.cover", "glass", props.cover.glass, ["boolean"], options.sourceFile);
268
+ assertType("Card.cover", "colors", props.cover.colors, ["array"], options.sourceFile);
269
+ assertType("Card.cover", "patternSeed", props.cover.patternSeed, ["string"], options.sourceFile);
270
+ assertType("Card.cover", "colorSeed", props.cover.colorSeed, ["string"], options.sourceFile);
271
+ if (Array.isArray(props.cover.colors)) {
272
+ if (props.cover.colors.length < 1 || props.cover.colors.length > 4) {
273
+ componentError(
274
+ componentName,
275
+ 'Invalid prop "cover.colors": expected 1 to 4 colors',
276
+ options.sourceFile
277
+ );
278
+ }
279
+ props.cover.colors.forEach((color, index) => {
280
+ assertHexColor(componentName, `cover.colors.${index}`, color, options.sourceFile);
281
+ });
282
+ }
283
+ for (const seed of ["patternSeed", "colorSeed"]) {
284
+ const value = props.cover[seed];
285
+ if (typeof value === "string" && value.trim().length === 0) {
286
+ componentError(
287
+ componentName,
288
+ `Invalid prop "cover.${seed}": expected a non-empty string`,
289
+ options.sourceFile
290
+ );
291
+ }
292
+ }
293
+ }
294
+ if (props.button !== void 0) {
295
+ assertPlainObject(componentName, "button", props.button, options.sourceFile);
296
+ assertNoUnknownProps(
297
+ "Card.button",
298
+ props.button,
299
+ ["text", "href", "color"],
300
+ options.sourceFile
301
+ );
302
+ assertRequired("Card.button", "text", props.button.text, options.sourceFile);
303
+ assertRequired("Card.button", "href", props.button.href, options.sourceFile);
304
+ assertType("Card.button", "text", props.button.text, ["string"], options.sourceFile);
305
+ assertType("Card.button", "href", props.button.href, ["string"], options.sourceFile);
306
+ for (const propName of ["text", "href"]) {
307
+ const value = props.button[propName];
308
+ if (typeof value === "string" && value.trim().length === 0) {
309
+ componentError(
310
+ componentName,
311
+ `Invalid prop "button.${propName}": expected a non-empty string`,
312
+ options.sourceFile
313
+ );
314
+ }
315
+ }
316
+ if (typeof props.button.href === "string") {
317
+ options.validateLinkHref?.(props.button.href);
318
+ }
319
+ const color = props.button.color;
320
+ if (color !== void 0) {
321
+ if (typeof color === "string") {
322
+ assertHexColor(componentName, "button.color", color, options.sourceFile);
323
+ } else {
324
+ assertPlainObject(componentName, "button.color", color, options.sourceFile);
325
+ assertNoUnknownProps(
326
+ "Card.button.color",
327
+ color,
328
+ ["light", "dark"],
329
+ options.sourceFile
330
+ );
331
+ if (color.light === void 0 && color.dark === void 0) {
332
+ componentError(
333
+ componentName,
334
+ 'Invalid prop "button.color": expected at least one of "light" or "dark"',
335
+ options.sourceFile
336
+ );
337
+ }
338
+ if (color.light !== void 0) {
339
+ assertHexColor(componentName, "button.color.light", color.light, options.sourceFile);
340
+ }
341
+ if (color.dark !== void 0) {
342
+ assertHexColor(componentName, "button.color.dark", color.dark, options.sourceFile);
343
+ }
344
+ }
345
+ }
346
+ }
347
+ }
348
+ function validateImage(props, options) {
349
+ assertNoUnknownProps(
350
+ "Image",
351
+ props,
352
+ ["src", "alt", "title", "width", "zoom"],
353
+ options.sourceFile
354
+ );
355
+ assertRequired("Image", "src", props.src, options.sourceFile);
356
+ assertType("Image", "src", props.src, ["string", "object"], options.sourceFile);
357
+ assertType("Image", "alt", props.alt, ["string"], options.sourceFile);
358
+ assertType("Image", "title", props.title, ["string"], options.sourceFile);
359
+ assertType("Image", "width", props.width, ["number", "string"], options.sourceFile);
360
+ assertType("Image", "zoom", props.zoom, ["boolean"], options.sourceFile);
361
+ if (typeof props.src === "string") {
362
+ options.validateAssetHref?.(props.src);
363
+ } else if (typeof props.src === "object") {
364
+ assertPlainObject("Image", "src", props.src, options.sourceFile);
365
+ const light = props.src.light;
366
+ const dark = props.src.dark;
367
+ if (typeof light !== "string" || light.trim().length === 0) {
368
+ componentError(
369
+ "Image",
370
+ 'Invalid prop "src": object form requires a non-empty "light" string',
371
+ options.sourceFile
372
+ );
373
+ }
374
+ if (dark !== void 0 && typeof dark !== "string") {
375
+ componentError(
376
+ "Image",
377
+ 'Invalid prop "src.dark": expected string',
378
+ options.sourceFile
379
+ );
380
+ }
381
+ options.validateAssetHref?.(light);
382
+ if (typeof dark === "string" && dark.trim().length > 0) {
383
+ options.validateAssetHref?.(dark);
384
+ }
385
+ }
386
+ }
387
+ function validateRadiantComponentProps(componentName, props, options) {
388
+ if (![
389
+ "Step",
390
+ "Tab",
391
+ "Accordion",
392
+ "Callout",
393
+ "Card",
394
+ "Image",
395
+ "Columns",
396
+ "Column",
397
+ "CodeGroup",
398
+ "ComponentPreview"
399
+ ].includes(componentName)) {
400
+ return;
401
+ }
402
+ if (componentName === "Step") {
403
+ assertRequired("Step", "title", props.title, options.sourceFile);
404
+ assertType("Step", "title", props.title, ["string"], options.sourceFile);
405
+ return;
406
+ }
407
+ if (componentName === "Tab") {
408
+ assertRequired("Tab", "label", props.label, options.sourceFile);
409
+ assertType("Tab", "label", props.label, ["string"], options.sourceFile);
410
+ assertType("Tab", "icon", props.icon, ["string"], options.sourceFile);
411
+ return;
412
+ }
413
+ if (componentName === "Accordion") {
414
+ assertRequired("Accordion", "title", props.title, options.sourceFile);
415
+ assertType("Accordion", "title", props.title, ["string"], options.sourceFile);
416
+ assertType("Accordion", "icon", props.icon, ["string"], options.sourceFile);
417
+ assertType("Accordion", "defaultOpen", props.defaultOpen, ["boolean"], options.sourceFile);
418
+ assertEnum(
419
+ "Accordion",
420
+ "titleSize",
421
+ props.titleSize,
422
+ ["xs", "sm", "md", "lg", "xl", "2xl"],
423
+ options.sourceFile
424
+ );
425
+ return;
426
+ }
427
+ if (componentName === "Callout") {
428
+ assertEnum(
429
+ "Callout",
430
+ "type",
431
+ props.type,
432
+ ["warning", "info", "tip", "danger", "success"],
433
+ options.sourceFile
434
+ );
435
+ assertType("Callout", "title", props.title, ["string", "boolean"], options.sourceFile);
436
+ assertType("Callout", "icon", props.icon, ["string", "boolean"], options.sourceFile);
437
+ assertType("Callout", "accent", props.accent, ["boolean"], options.sourceFile);
438
+ assertType("Callout", "color", props.color, ["string"], options.sourceFile);
439
+ if (props.title === true) {
440
+ componentError("Callout", 'Invalid prop "title": expected string or false, got true', options.sourceFile);
441
+ }
442
+ if (props.icon === true) {
443
+ componentError("Callout", 'Invalid prop "icon": expected string or false, got true', options.sourceFile);
444
+ }
445
+ return;
446
+ }
447
+ if (componentName === "Card") {
448
+ validateCard(props, options);
449
+ return;
450
+ }
451
+ if (componentName === "Image") {
452
+ validateImage(props, options);
453
+ return;
454
+ }
455
+ if (componentName === "Columns") {
456
+ assertNoUnknownProps("Columns", props, ["columns"], options.sourceFile);
457
+ assertType("Columns", "columns", props.columns, ["number", "string"], options.sourceFile);
458
+ if (props.columns !== void 0 && Number(
459
+ typeof props.columns === "string" ? props.columns.trim() : props.columns
460
+ ) !== 2 && Number(
461
+ typeof props.columns === "string" ? props.columns.trim() : props.columns
462
+ ) !== 3) {
463
+ componentError("Columns", 'Invalid prop "columns": expected 2 or 3', options.sourceFile);
464
+ }
465
+ return;
466
+ }
467
+ if (componentName === "Column") {
468
+ assertNoUnknownProps("Column", props, [], options.sourceFile);
469
+ return;
470
+ }
471
+ if (componentName === "CodeGroup") {
472
+ assertNoUnknownProps("CodeGroup", props, [], options.sourceFile);
473
+ return;
474
+ }
475
+ assertNoUnknownProps(
476
+ "ComponentPreview",
477
+ props,
478
+ ["showAllCode"],
479
+ options.sourceFile
480
+ );
481
+ assertType(
482
+ "ComponentPreview",
483
+ "showAllCode",
484
+ props.showAllCode,
485
+ ["boolean"],
486
+ options.sourceFile
487
+ );
488
+ }
489
+ function validateRadiantComponentNode(node, options) {
490
+ if (typeof node.name !== "string") return;
491
+ const props = readStaticProps(node, node.name, options.sourceFile);
492
+ validateRadiantComponentProps(node.name, props, options);
493
+ validateRadiantComponentChildStructure(node, props, options.sourceFile);
494
+ }
495
+ function isIgnorableWhitespaceNode(node) {
496
+ if (!node || typeof node !== "object") return false;
497
+ const typedNode = node;
498
+ return typedNode.type === "text" && typeof typedNode.value === "string" ? typedNode.value.trim().length === 0 : false;
499
+ }
500
+ function isNamedMdxJsxElement(node, name) {
501
+ if (!node || typeof node !== "object") return false;
502
+ const typedNode = node;
503
+ return (typedNode.type === "mdxJsxFlowElement" || typedNode.type === "mdxJsxTextElement") && typedNode.name === name;
504
+ }
505
+ function validateRadiantComponentChildStructure(node, props, sourceFile) {
506
+ const children = (node.children ?? []).filter(
507
+ (child) => !isIgnorableWhitespaceNode(child)
508
+ );
509
+ if (node.name === "Columns") {
510
+ const expectedColumns = props.columns === void 0 ? 2 : Number(props.columns);
511
+ const hasOnlyColumns = children.every(
512
+ (child) => isNamedMdxJsxElement(child, "Column")
513
+ );
514
+ if (!hasOnlyColumns) {
515
+ componentError(
516
+ "Columns",
517
+ "Only direct <Column> children are allowed. Wrap each column's content in <Column>.",
518
+ sourceFile
519
+ );
520
+ }
521
+ if (children.length !== expectedColumns) {
522
+ componentError(
523
+ "Columns",
524
+ `Expected exactly ${expectedColumns} <Column> children for columns={${expectedColumns}}, but found ${children.length}. Render another <Columns> block for a new row.`,
525
+ sourceFile
526
+ );
527
+ }
528
+ }
529
+ if (node.name === "Tabs") {
530
+ const hasOnlyTabs = children.every(
531
+ (child) => isNamedMdxJsxElement(child, "Tab")
532
+ );
533
+ if (!hasOnlyTabs) {
534
+ componentError(
535
+ "Tabs",
536
+ "Only direct <Tab> children are allowed. Wrap each tab's content in <Tab>.",
537
+ sourceFile
538
+ );
539
+ }
540
+ if (children.length < 2) {
541
+ componentError(
542
+ "Tabs",
543
+ `Must contain at least two direct <Tab> children, but found ${children.length}.`,
544
+ sourceFile
545
+ );
546
+ }
547
+ }
548
+ if (node.name === "Steps") {
549
+ const hasOnlySteps = children.every(
550
+ (child) => isNamedMdxJsxElement(child, "Step")
551
+ );
552
+ if (!hasOnlySteps) {
553
+ componentError(
554
+ "Steps",
555
+ "Only direct <Step> children are allowed. Wrap each procedure step in <Step>.",
556
+ sourceFile
557
+ );
558
+ }
559
+ if (children.length === 0) {
560
+ componentError(
561
+ "Steps",
562
+ "Must contain at least one direct <Step> child.",
563
+ sourceFile
564
+ );
565
+ }
566
+ }
567
+ if (node.name === "AccordionGroup") {
568
+ const hasOnlyAccordions = children.every(
569
+ (child) => isNamedMdxJsxElement(child, "Accordion")
570
+ );
571
+ if (!hasOnlyAccordions) {
572
+ componentError(
573
+ "AccordionGroup",
574
+ "Only direct <Accordion> children are allowed. Wrap each section in <Accordion>.",
575
+ sourceFile
576
+ );
577
+ }
578
+ if (children.length === 0) {
579
+ componentError(
580
+ "AccordionGroup",
581
+ "Must contain at least one direct <Accordion> child.",
582
+ sourceFile
583
+ );
584
+ }
585
+ }
586
+ if (node.name === "CodeGroup") {
587
+ const hasOnlyCodeBlocks = children.every(
588
+ (child) => child?.type === "code"
589
+ );
590
+ if (!hasOnlyCodeBlocks) {
591
+ componentError(
592
+ "CodeGroup",
593
+ "Only direct fenced code blocks are allowed.",
594
+ sourceFile
595
+ );
596
+ }
597
+ if (children.length < 2) {
598
+ componentError(
599
+ "CodeGroup",
600
+ `Must contain at least two direct fenced code blocks, but found ${children.length}.`,
601
+ sourceFile
602
+ );
603
+ }
604
+ }
605
+ if (node.name === "ComponentPreview") {
606
+ const hasExactlyOneCodeBlock = children.length === 1 && children[0]?.type === "code";
607
+ if (!hasExactlyOneCodeBlock) {
608
+ componentError(
609
+ "ComponentPreview",
610
+ "Must contain exactly one direct fenced code block.",
611
+ sourceFile
612
+ );
613
+ }
614
+ const codeNode = children[0];
615
+ const language = typeof codeNode.lang === "string" && codeNode.lang.trim().length > 0 ? codeNode.lang.trim().toLowerCase() : "plaintext";
616
+ if (!["jsx", "tsx", "mdx"].includes(language)) {
617
+ componentError(
618
+ "ComponentPreview",
619
+ `Fenced code block must use jsx, tsx, or mdx language (received "${language}")`,
620
+ sourceFile
621
+ );
622
+ }
623
+ try {
624
+ compileSync(typeof codeNode.value === "string" ? codeNode.value : "", {
625
+ jsx: true
626
+ });
627
+ } catch (error) {
628
+ const reason = error instanceof Error && error.message.trim().length > 0 ? ` -> ${error.message}` : "";
629
+ componentError(
630
+ "ComponentPreview",
631
+ `Failed to parse fenced code block as MDX content${reason}`,
632
+ sourceFile
633
+ );
634
+ }
635
+ }
636
+ }
637
+
638
+ // src/validator.ts
19
639
  var { Spectral } = pkg;
20
640
  var DOCS_DIR = "";
21
641
  var CONFIG_PATH = "";
@@ -2244,6 +2864,11 @@ function createInternalLinkValidationPlugin(args) {
2244
2864
  if (hrefAttribute.name !== "href" && hrefAttribute.name !== "src") {
2245
2865
  continue;
2246
2866
  }
2867
+ if (hrefAttribute.name === "href" && typeof hrefAttribute.value !== "string") {
2868
+ throw new Error(
2869
+ `Invalid JSX href in ${args.sourceFile}. Direct JSX href props must use quoted strings, such as href="/guides/quickstart", not JSX expressions.`
2870
+ );
2871
+ }
2247
2872
  if (typeof hrefAttribute.value !== "string") continue;
2248
2873
  validateDocsRootAbsoluteHref({
2249
2874
  href: hrefAttribute.value,
@@ -2255,6 +2880,34 @@ function createInternalLinkValidationPlugin(args) {
2255
2880
  });
2256
2881
  };
2257
2882
  }
2883
+ function createComponentValidationPlugin(args) {
2884
+ return () => (tree) => {
2885
+ visitMdxTree(tree, (node) => {
2886
+ if (node.type !== "mdxJsxFlowElement" && node.type !== "mdxJsxTextElement") {
2887
+ return;
2888
+ }
2889
+ validateRadiantComponentNode(node, {
2890
+ sourceFile: args.sourceFile,
2891
+ validateLinkHref: (href) => {
2892
+ validateDocsRootAbsoluteHref({
2893
+ href,
2894
+ sourceFile: args.sourceFile,
2895
+ linkIndex: args.linkIndex,
2896
+ expectedTarget: "link"
2897
+ });
2898
+ },
2899
+ validateAssetHref: (href) => {
2900
+ validateDocsRootAbsoluteHref({
2901
+ href,
2902
+ sourceFile: args.sourceFile,
2903
+ linkIndex: args.linkIndex,
2904
+ expectedTarget: "asset"
2905
+ });
2906
+ }
2907
+ });
2908
+ });
2909
+ };
2910
+ }
2258
2911
  function getMdxFiles(dir) {
2259
2912
  let results = [];
2260
2913
  const list = fs.readdirSync(dir);
@@ -2297,6 +2950,10 @@ async function validateMdxContent() {
2297
2950
  createInternalLinkValidationPlugin({
2298
2951
  sourceFile: relativePath,
2299
2952
  linkIndex
2953
+ }),
2954
+ createComponentValidationPlugin({
2955
+ sourceFile: relativePath,
2956
+ linkIndex
2300
2957
  })
2301
2958
  ]
2302
2959
  });
@@ -2325,5 +2982,7 @@ export {
2325
2982
  loadOpenApiSpec,
2326
2983
  resolveDocsHref,
2327
2984
  resolveDocsPageHref,
2328
- validateMdxContent
2985
+ validateMdxContent,
2986
+ validateRadiantComponentNode,
2987
+ validateRadiantComponentProps
2329
2988
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "radiant-docs-validator",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Shared validation for Radiant documentation repositories",
5
5
  "type": "module",
6
6
  "scripts": {