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 +14 -1
- package/dist/index.js +660 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
};
|