radiant-docs-validator 0.1.15 → 0.1.16

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 (2) hide show
  1. package/dist/index.js +309 -43
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -161,6 +161,13 @@ function readStaticProps(node, componentName, sourceFile) {
161
161
  sourceFile
162
162
  );
163
163
  }
164
+ if (Object.prototype.hasOwnProperty.call(props, attribute.name)) {
165
+ componentError(
166
+ componentName,
167
+ `Duplicate prop "${attribute.name}" is not supported`,
168
+ sourceFile
169
+ );
170
+ }
164
171
  if (attribute.value === null) {
165
172
  props[attribute.name] = true;
166
173
  } else if (typeof attribute.value === "string") {
@@ -390,6 +397,12 @@ function validateImage(props, options) {
390
397
  options.validateAssetHref?.(props.src);
391
398
  } else if (typeof props.src === "object") {
392
399
  assertPlainObject("Image", "src", props.src, options.sourceFile);
400
+ assertNoUnknownProps(
401
+ "Image.src",
402
+ props.src,
403
+ ["light", "dark"],
404
+ options.sourceFile
405
+ );
393
406
  const light = props.src.light;
394
407
  const dark = props.src.dark;
395
408
  if (typeof light !== "string" || light.trim().length === 0) {
@@ -406,8 +419,15 @@ function validateImage(props, options) {
406
419
  options.sourceFile
407
420
  );
408
421
  }
422
+ if (typeof dark === "string" && dark.trim().length === 0) {
423
+ componentError(
424
+ "Image",
425
+ 'Invalid prop "src.dark": expected a non-empty string',
426
+ options.sourceFile
427
+ );
428
+ }
409
429
  options.validateAssetHref?.(light);
410
- if (typeof dark === "string" && dark.trim().length > 0) {
430
+ if (typeof dark === "string") {
411
431
  options.validateAssetHref?.(dark);
412
432
  }
413
433
  }
@@ -422,17 +442,22 @@ function validateRadiantComponentProps(componentName, props, options) {
422
442
  "Image",
423
443
  "Columns",
424
444
  "Column",
445
+ "Tabs",
446
+ "Steps",
447
+ "AccordionGroup",
425
448
  "CodeGroup",
426
449
  "ComponentPreview"
427
450
  ].includes(componentName)) {
428
451
  return;
429
452
  }
430
453
  if (componentName === "Step") {
454
+ assertNoUnknownProps("Step", props, ["title"], options.sourceFile);
431
455
  assertRequired("Step", "title", props.title, options.sourceFile);
432
456
  assertType("Step", "title", props.title, ["string"], options.sourceFile);
433
457
  return;
434
458
  }
435
459
  if (componentName === "Tab") {
460
+ assertNoUnknownProps("Tab", props, ["label", "icon"], options.sourceFile);
436
461
  assertRequired("Tab", "label", props.label, options.sourceFile);
437
462
  assertType("Tab", "label", props.label, ["string"], options.sourceFile);
438
463
  assertType("Tab", "icon", props.icon, ["string"], options.sourceFile);
@@ -440,6 +465,12 @@ function validateRadiantComponentProps(componentName, props, options) {
440
465
  return;
441
466
  }
442
467
  if (componentName === "Accordion") {
468
+ assertNoUnknownProps(
469
+ "Accordion",
470
+ props,
471
+ ["title", "icon", "defaultOpen", "titleSize"],
472
+ options.sourceFile
473
+ );
443
474
  assertRequired("Accordion", "title", props.title, options.sourceFile);
444
475
  assertType("Accordion", "title", props.title, ["string"], options.sourceFile);
445
476
  assertType("Accordion", "icon", props.icon, ["string"], options.sourceFile);
@@ -455,6 +486,12 @@ function validateRadiantComponentProps(componentName, props, options) {
455
486
  return;
456
487
  }
457
488
  if (componentName === "Callout") {
489
+ assertNoUnknownProps(
490
+ "Callout",
491
+ props,
492
+ ["type", "title", "icon", "accent", "color"],
493
+ options.sourceFile
494
+ );
458
495
  assertEnum(
459
496
  "Callout",
460
497
  "type",
@@ -486,6 +523,18 @@ function validateRadiantComponentProps(componentName, props, options) {
486
523
  validateImage(props, options);
487
524
  return;
488
525
  }
526
+ if (componentName === "Tabs") {
527
+ assertNoUnknownProps("Tabs", props, [], options.sourceFile);
528
+ return;
529
+ }
530
+ if (componentName === "Steps") {
531
+ assertNoUnknownProps("Steps", props, [], options.sourceFile);
532
+ return;
533
+ }
534
+ if (componentName === "AccordionGroup") {
535
+ assertNoUnknownProps("AccordionGroup", props, [], options.sourceFile);
536
+ return;
537
+ }
489
538
  if (componentName === "Columns") {
490
539
  assertNoUnknownProps("Columns", props, ["columns"], options.sourceFile);
491
540
  assertType("Columns", "columns", props.columns, ["number", "string"], options.sourceFile);
@@ -696,6 +745,16 @@ function configureDocsValidator({
696
745
  lastMtime = 0;
697
746
  }
698
747
  var iconSets = /* @__PURE__ */ new Map();
748
+ var LOCAL_IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
749
+ ".avif",
750
+ ".gif",
751
+ ".ico",
752
+ ".jpeg",
753
+ ".jpg",
754
+ ".png",
755
+ ".svg",
756
+ ".webp"
757
+ ]);
699
758
  function isUrl(str) {
700
759
  try {
701
760
  const url = new URL(str);
@@ -750,14 +809,19 @@ function validateIcon(icon, currentPath) {
750
809
  }
751
810
  return;
752
811
  }
753
- const localRelativePath = icon.startsWith("/") ? icon.slice(1) : icon;
754
- const localPath = path.join(DOCS_DIR, localRelativePath);
755
- if (!fs.existsSync(localPath)) {
812
+ const localRelativePath = validateLocalDocsFile(
813
+ icon,
814
+ currentPath,
815
+ "Icon"
816
+ );
817
+ const extension = path.extname(localRelativePath).toLowerCase();
818
+ if (!LOCAL_IMAGE_EXTENSIONS.has(extension)) {
756
819
  throwConfigError(
757
- `Icon not found: "${icon}". Local icons must exist in your repository. Did you mean to use an library icon like "lucide:home"?`,
820
+ `Icon must be a valid image file (.svg, .png, .jpg, .jpeg, .webp, .gif, .ico, .avif). Found: ${icon}`,
758
821
  currentPath
759
822
  );
760
823
  }
824
+ return;
761
825
  }
762
826
  function validateComponentIcon(icon, currentPath) {
763
827
  const trimmedIcon = icon.trim();
@@ -809,13 +873,25 @@ var throwConfigError = (message, currentPath) => {
809
873
  throw new Error(`${message}${location}
810
874
  `);
811
875
  };
876
+ function assertNoUnknownKeys(value, allowedKeys, currentPath, label) {
877
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
878
+ const allowed = new Set(allowedKeys);
879
+ for (const key of Object.keys(value)) {
880
+ if (!allowed.has(key)) {
881
+ throwConfigError(
882
+ `${label} does not support '${key}'. Supported keys: ${allowedKeys.join(", ")}.`,
883
+ [...currentPath, key]
884
+ );
885
+ }
886
+ }
887
+ }
812
888
  function checkType(value, type, currentPath, label) {
813
- if (value === void 0 || value === null) return;
889
+ if (value === void 0) return;
814
890
  if (type === "array") {
815
891
  if (!Array.isArray(value))
816
892
  throwConfigError(`${label} must be an array.`, currentPath);
817
893
  } else if (type === "object") {
818
- if (typeof value !== "object" || value === null)
894
+ if (typeof value !== "object" || value === null || Array.isArray(value))
819
895
  throwConfigError(`${label} must be an object.`, currentPath);
820
896
  } else {
821
897
  if (typeof value !== type)
@@ -949,8 +1025,13 @@ function normalizeSeedValue(value, currentPath, label) {
949
1025
  return trimmedValue;
950
1026
  }
951
1027
  function validateFileExistence(filePath, currentPath) {
952
- const fullPath = path.join(DOCS_DIR, `${filePath}.mdx`);
953
- if (!fs.existsSync(fullPath)) {
1028
+ const normalizedPath = normalizeLocalDocsPath(
1029
+ `${filePath}.mdx`,
1030
+ currentPath,
1031
+ "Page path"
1032
+ );
1033
+ const fullPath = path.join(DOCS_DIR, normalizedPath);
1034
+ if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
954
1035
  throwConfigError(
955
1036
  `Referenced file not found. Expected: ${filePath}`,
956
1037
  currentPath
@@ -982,12 +1063,90 @@ function normalizeDocsPagePath(value, currentPath, label = "Page path") {
982
1063
  currentPath
983
1064
  );
984
1065
  }
985
- const normalizedPath = trimmedPath.replace(/^\/+/, "").replace(/\/+$/, "");
1066
+ const slashPath = trimmedPath.replace(/\\/g, "/");
1067
+ if (containsPathTraversal(slashPath)) {
1068
+ throwConfigError(
1069
+ `${label} cannot contain '..' path traversal segments`,
1070
+ currentPath
1071
+ );
1072
+ }
1073
+ const normalizedPath = slashPath.replace(/^\/+/, "").replace(/\/+$/, "");
986
1074
  if (normalizedPath === "") {
987
1075
  throwConfigError(`${label} cannot be '/'`, currentPath);
988
1076
  }
989
1077
  return normalizedPath;
990
1078
  }
1079
+ function normalizeLocalDocsPath(value, currentPath, label) {
1080
+ const trimmedPath = value.trim();
1081
+ if (trimmedPath.length === 0) {
1082
+ throwConfigError(`${label} cannot be empty.`, currentPath);
1083
+ }
1084
+ if (trimmedPath.startsWith("#") || trimmedPath.startsWith("?") || trimmedPath.startsWith("//")) {
1085
+ throwConfigError(`${label} must be a local file path in the docs root.`, currentPath);
1086
+ }
1087
+ const { pathname } = splitHrefPathAndSuffix(trimmedPath);
1088
+ const slashPath = pathname.replace(/\\/g, "/");
1089
+ if (containsPathTraversal(slashPath)) {
1090
+ throwConfigError(
1091
+ `${label} cannot contain '..' path traversal segments.`,
1092
+ currentPath
1093
+ );
1094
+ }
1095
+ const withoutLeadingSlash = slashPath.replace(/^\/+/, "");
1096
+ const normalizedPath = path.posix.normalize(`/${withoutLeadingSlash}`).replace(/^\/+/, "");
1097
+ if (!normalizedPath || normalizedPath === ".") {
1098
+ throwConfigError(`${label} cannot be '/'.`, currentPath);
1099
+ }
1100
+ const fullPath = path.resolve(DOCS_DIR, normalizedPath);
1101
+ const relativePath = path.relative(DOCS_DIR, fullPath);
1102
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
1103
+ throwConfigError(`${label} must stay inside the docs root.`, currentPath);
1104
+ }
1105
+ return normalizedPath;
1106
+ }
1107
+ function getSvgValidationError(fullPath, label) {
1108
+ let content;
1109
+ try {
1110
+ content = fs.readFileSync(fullPath, "utf-8");
1111
+ } catch (error) {
1112
+ return `${label} could not be read as an SVG file.`;
1113
+ }
1114
+ if (!/<svg(?:\s|>)/i.test(content)) {
1115
+ return `${label} must contain an <svg> element.`;
1116
+ }
1117
+ if (/<script(?:\s|>)/i.test(content)) {
1118
+ return `${label} cannot contain <script> tags.`;
1119
+ }
1120
+ return null;
1121
+ }
1122
+ function validateLocalFileContents(fullPath, relativePath, currentPath, label) {
1123
+ const stats = fs.statSync(fullPath);
1124
+ if (stats.size === 0) {
1125
+ throwConfigError(
1126
+ `${label} file is empty. Expected a non-empty file: ${relativePath}`,
1127
+ currentPath
1128
+ );
1129
+ }
1130
+ if (path.extname(relativePath).toLowerCase() === ".svg") {
1131
+ const svgError = getSvgValidationError(fullPath, label);
1132
+ if (svgError) {
1133
+ throwConfigError(svgError, currentPath);
1134
+ }
1135
+ }
1136
+ }
1137
+ function validateLocalDocsFile(value, currentPath, label) {
1138
+ const normalizedPath = normalizeLocalDocsPath(value, currentPath, label);
1139
+ const fullPath = path.join(DOCS_DIR, normalizedPath);
1140
+ if (!fs.existsSync(fullPath)) {
1141
+ throwConfigError(`${label} file not found. Expected: ${normalizedPath}`, currentPath);
1142
+ }
1143
+ const stats = fs.statSync(fullPath);
1144
+ if (!stats.isFile()) {
1145
+ throwConfigError(`${label} file not found. Expected: ${normalizedPath}`, currentPath);
1146
+ }
1147
+ validateLocalFileContents(fullPath, normalizedPath, currentPath, label);
1148
+ return normalizedPath;
1149
+ }
991
1150
  function splitHrefPathAndSuffix(href) {
992
1151
  const match = href.match(/^([^?#]*)(.*)$/);
993
1152
  return {
@@ -1049,7 +1208,12 @@ async function loadOpenApiSpec(filePathOrUrl) {
1049
1208
  );
1050
1209
  }
1051
1210
  } else {
1052
- const fullPath = path.join(DOCS_DIR, filePathOrUrl);
1211
+ const normalizedPath = normalizeLocalDocsPath(
1212
+ filePathOrUrl,
1213
+ [],
1214
+ "OpenAPI file"
1215
+ );
1216
+ const fullPath = path.join(DOCS_DIR, normalizedPath);
1053
1217
  fileContent = fs.readFileSync(fullPath, "utf-8");
1054
1218
  }
1055
1219
  const trimmedContent = fileContent.trim();
@@ -1090,13 +1254,7 @@ async function validateOpenApiFile(filePathOrUrl, currentPath) {
1090
1254
  currentPath
1091
1255
  );
1092
1256
  }
1093
- const fullPath = path.join(DOCS_DIR, filePathOrUrl);
1094
- if (!fs.existsSync(fullPath)) {
1095
- throwConfigError(
1096
- `Referenced OpenAPI file not found. Expected: ${filePathOrUrl}`,
1097
- currentPath
1098
- );
1099
- }
1257
+ validateLocalDocsFile(filePathOrUrl, currentPath, "OpenAPI file");
1100
1258
  } else {
1101
1259
  try {
1102
1260
  const url = new URL(filePathOrUrl);
@@ -1224,6 +1382,12 @@ function parseEndpointString(endpointStr) {
1224
1382
  }
1225
1383
  async function validateNavOpenApiPage(navOpenApiPage, currentPath) {
1226
1384
  checkType(navOpenApiPage, "object", currentPath, "Open API page");
1385
+ assertNoUnknownKeys(
1386
+ navOpenApiPage,
1387
+ ["source", "endpoint"],
1388
+ currentPath,
1389
+ "Open API page"
1390
+ );
1227
1391
  if (typeof navOpenApiPage.source !== "string") {
1228
1392
  throwConfigError(
1229
1393
  "Open API page must include a 'source' property that is a string.",
@@ -1273,6 +1437,12 @@ async function validateNavigationNode(item, currentPath, groupDepth = 0) {
1273
1437
  }
1274
1438
  if (isGroup) {
1275
1439
  const path2 = [...currentPath];
1440
+ assertNoUnknownKeys(
1441
+ item,
1442
+ ["group", "pages", "icon", "expanded", "tag"],
1443
+ path2,
1444
+ "Navigation group"
1445
+ );
1276
1446
  if (groupDepth >= 2) {
1277
1447
  throwConfigError("Groups can only be nested up to 2 levels deep.", path2);
1278
1448
  }
@@ -1301,6 +1471,12 @@ async function validateNavigationNode(item, currentPath, groupDepth = 0) {
1301
1471
  }
1302
1472
  if (isPage) {
1303
1473
  const path2 = [...currentPath];
1474
+ assertNoUnknownKeys(
1475
+ item,
1476
+ ["page", "icon", "tag", "title"],
1477
+ path2,
1478
+ "Navigation page"
1479
+ );
1304
1480
  const normalizedPagePath = normalizeDocsPagePath(item.page, [
1305
1481
  ...path2,
1306
1482
  "page"
@@ -1321,6 +1497,12 @@ async function validateNavigationNode(item, currentPath, groupDepth = 0) {
1321
1497
  }
1322
1498
  if (isOpenApiPage) {
1323
1499
  const path2 = [...currentPath];
1500
+ assertNoUnknownKeys(
1501
+ item,
1502
+ ["openapi", "title", "tag"],
1503
+ path2,
1504
+ "Open API navigation page"
1505
+ );
1324
1506
  if ("icon" in item) {
1325
1507
  throwConfigError(
1326
1508
  "Open API page items cannot have an 'icon'. Method badges are displayed automatically.",
@@ -1414,6 +1596,12 @@ function getFirstRouteFromNavigation(navigation) {
1414
1596
  }
1415
1597
  async function validateNavOpenApi(navOpenApi, currentPath) {
1416
1598
  checkType(navOpenApi, "object", currentPath, "Open API object");
1599
+ assertNoUnknownKeys(
1600
+ navOpenApi,
1601
+ ["source", "include", "exclude"],
1602
+ currentPath,
1603
+ "Open API object"
1604
+ );
1417
1605
  if (typeof navOpenApi.source !== "string") {
1418
1606
  throwConfigError(
1419
1607
  "Open API object must have an 'source' property that is a string.",
@@ -1509,6 +1697,12 @@ async function validateNavOpenApi(navOpenApi, currentPath) {
1509
1697
  }
1510
1698
  async function validateNavMenuItem(item, currentPath) {
1511
1699
  checkType(item, "object", currentPath, "Menu item");
1700
+ assertNoUnknownKeys(
1701
+ item,
1702
+ ["label", "submenu", "icon"],
1703
+ currentPath,
1704
+ "Menu item"
1705
+ );
1512
1706
  validateIcon(item.icon, [...currentPath, "icon"]);
1513
1707
  if (!item.label) {
1514
1708
  throwConfigError("Menu item must have a 'label' property.", [
@@ -1603,6 +1797,7 @@ async function validateNavMenuItem(item, currentPath) {
1603
1797
  }
1604
1798
  async function validateNavMenu(menu, currentPath) {
1605
1799
  checkType(menu, "object", currentPath, "Menu");
1800
+ assertNoUnknownKeys(menu, ["type", "label", "items"], currentPath, "Menu");
1606
1801
  if (menu.type !== void 0) {
1607
1802
  if (menu.type !== "dropdown" && menu.type !== "segmented") {
1608
1803
  throwConfigError(
@@ -1626,6 +1821,12 @@ async function validateNavMenu(menu, currentPath) {
1626
1821
  function validateNavbarItem(item, currentPath) {
1627
1822
  if (item === void 0) return null;
1628
1823
  checkType(item, "object", currentPath, "Navbar item");
1824
+ assertNoUnknownKeys(
1825
+ item,
1826
+ ["text", "href", "icon", "color"],
1827
+ currentPath,
1828
+ "Navbar item"
1829
+ );
1629
1830
  if (typeof item.text !== "string") {
1630
1831
  throwConfigError("Navbar item must have a 'text' property.", [
1631
1832
  ...currentPath,
@@ -1677,24 +1878,16 @@ function validateLogoPaddingValue(value, currentPath, label) {
1677
1878
  }
1678
1879
  }
1679
1880
  function validateLogoImagePath(imagePath, currentPath, label) {
1680
- const validExtensions = [".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif"];
1681
- const hasValidExtension = validExtensions.some(
1682
- (ext) => imagePath.toLowerCase().endsWith(ext)
1683
- );
1881
+ const normalizedPath = normalizeLocalDocsPath(imagePath, currentPath, label);
1882
+ const extension = path.extname(normalizedPath).toLowerCase();
1883
+ const hasValidExtension = LOCAL_IMAGE_EXTENSIONS.has(extension);
1684
1884
  if (!hasValidExtension) {
1685
1885
  throwConfigError(
1686
- `${label} must be a valid image file (.svg, .png, .jpg, .jpeg, .webp, .gif)`,
1687
- currentPath
1688
- );
1689
- }
1690
- const normalizedPath = imagePath.startsWith("/") ? imagePath.slice(1) : imagePath;
1691
- const fullPath = path.join(DOCS_DIR, normalizedPath);
1692
- if (!fs.existsSync(fullPath)) {
1693
- throwConfigError(
1694
- `${label} file not found. Expected: ${normalizedPath}`,
1886
+ `${label} must be a valid image file (.svg, .png, .jpg, .jpeg, .webp, .gif, .ico, .avif)`,
1695
1887
  currentPath
1696
1888
  );
1697
1889
  }
1890
+ validateLocalDocsFile(imagePath, currentPath, label);
1698
1891
  }
1699
1892
  function validateAssistantIconSource(iconSource, currentPath) {
1700
1893
  checkType(iconSource, "string", currentPath, "Assistant icon source");
@@ -1743,14 +1936,7 @@ function validateAssistantIconSource(iconSource, currentPath) {
1743
1936
  currentPath
1744
1937
  );
1745
1938
  }
1746
- const normalizedPath = parsed.pathname.replace(/^\/+/, "");
1747
- const fullPath = path.join(DOCS_DIR, normalizedPath);
1748
- if (!fs.existsSync(fullPath)) {
1749
- throwConfigError(
1750
- `Assistant icon source file not found. Expected: ${normalizedPath}`,
1751
- currentPath
1752
- );
1753
- }
1939
+ validateLocalDocsFile(parsed.pathname, currentPath, "Assistant icon source");
1754
1940
  return trimmedSource;
1755
1941
  }
1756
1942
  function validateLogoVariant(variant, currentPath, mode) {
@@ -1815,6 +2001,12 @@ function validateLogoVariant(variant, currentPath, mode) {
1815
2001
  function validateLogo(logo) {
1816
2002
  if (logo === void 0) return;
1817
2003
  checkType(logo, "object", ["logo"], "Logo configuration");
2004
+ assertNoUnknownKeys(
2005
+ logo,
2006
+ ["light", "dark", "href", "pill"],
2007
+ ["logo"],
2008
+ "Logo configuration"
2009
+ );
1818
2010
  validateLogoVariant(logo.light, ["logo", "light"], "light");
1819
2011
  validateLogoVariant(logo.dark, ["logo", "dark"], "dark");
1820
2012
  if (logo.href !== void 0) {
@@ -2429,6 +2621,12 @@ function validateNavbar(navbar) {
2429
2621
  const hiddenPageRoutes = [];
2430
2622
  if (navbar === void 0) return hiddenPageRoutes;
2431
2623
  checkType(navbar, "object", ["navbar"], "Navbar configuration");
2624
+ assertNoUnknownKeys(
2625
+ navbar,
2626
+ ["blur", "primary", "secondary", "links"],
2627
+ ["navbar"],
2628
+ "Navbar configuration"
2629
+ );
2432
2630
  checkType(navbar.blur, "boolean", ["navbar", "blur"], "Navbar blur setting");
2433
2631
  const primaryPageRoute = validateNavbarItem(navbar.primary, [
2434
2632
  "navbar",
@@ -2459,6 +2657,12 @@ function validateFooter(footer) {
2459
2657
  const hiddenPageRoutes = [];
2460
2658
  if (footer === void 0) return hiddenPageRoutes;
2461
2659
  checkType(footer, "object", ["footer"], "Footer configuration");
2660
+ assertNoUnknownKeys(
2661
+ footer,
2662
+ ["socials", "links"],
2663
+ ["footer"],
2664
+ "Footer configuration"
2665
+ );
2462
2666
  if (footer.socials !== void 0) {
2463
2667
  checkType(
2464
2668
  footer.socials,
@@ -2512,6 +2716,12 @@ function validateFooter(footer) {
2512
2716
  checkType(footer.links, "array", ["footer", "links"], "Footer links");
2513
2717
  footer.links.forEach((link, i) => {
2514
2718
  checkType(link, "object", ["footer", "links", i], "Footer link");
2719
+ assertNoUnknownKeys(
2720
+ link,
2721
+ ["text", "href"],
2722
+ ["footer", "links", i],
2723
+ "Footer link"
2724
+ );
2515
2725
  if (typeof link.text !== "string") {
2516
2726
  throwConfigError("Footer link must have a 'text' property.", [
2517
2727
  "footer",
@@ -2543,6 +2753,12 @@ function validateFooter(footer) {
2543
2753
  }
2544
2754
  async function validateNavigation(navigation) {
2545
2755
  checkType(navigation, "object", ["navigation"], "Navigation");
2756
+ assertNoUnknownKeys(
2757
+ navigation,
2758
+ ["pages", "menu", "openapi"],
2759
+ ["navigation"],
2760
+ "Navigation"
2761
+ );
2546
2762
  const keys = Object.keys(navigation);
2547
2763
  const validKeys = ["pages", "menu", "openapi"];
2548
2764
  const navKeys = keys.filter((key) => validKeys.includes(key));
@@ -2587,6 +2803,25 @@ async function validateNavigation(navigation) {
2587
2803
  }
2588
2804
  }
2589
2805
  async function validateConfig(config) {
2806
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
2807
+ throwConfigError("docs.json must be an object.", []);
2808
+ }
2809
+ assertNoUnknownKeys(
2810
+ config,
2811
+ [
2812
+ "title",
2813
+ "logo",
2814
+ "theme",
2815
+ "assistant",
2816
+ "home",
2817
+ "navigation",
2818
+ "navbar",
2819
+ "playground",
2820
+ "footer"
2821
+ ],
2822
+ [],
2823
+ "docs.json"
2824
+ );
2590
2825
  validateTitle(config.title);
2591
2826
  validateLogo(config.logo);
2592
2827
  validateTheme(config.theme);
@@ -2615,6 +2850,12 @@ async function validateConfig(config) {
2615
2850
  config.hiddenPageRoutes = Array.from(dedupedHiddenPageRoutes.values());
2616
2851
  if (config.playground !== void 0) {
2617
2852
  checkType(config.playground, "object", ["playground"], "Playground");
2853
+ assertNoUnknownKeys(
2854
+ config.playground,
2855
+ ["proxy"],
2856
+ ["playground"],
2857
+ "Playground"
2858
+ );
2618
2859
  if (config.playground.proxy !== void 0) {
2619
2860
  checkType(
2620
2861
  config.playground.proxy,
@@ -2874,16 +3115,38 @@ function validateDocsRootAbsoluteHref(args) {
2874
3115
  }
2875
3116
  if (resolvedHref.kind === "local-asset") {
2876
3117
  const fullAssetPath = path.join(DOCS_DIR, resolvedHref.filePath);
2877
- if (!fs.existsSync(fullAssetPath) || !fs.statSync(fullAssetPath).isFile()) {
3118
+ if (!fs.existsSync(fullAssetPath)) {
3119
+ throw new Error(
3120
+ `Invalid local asset "${args.href}" in ${args.sourceFile}. No matching file was found. Expected "${resolvedHref.filePath}" under the docs root.`
3121
+ );
3122
+ }
3123
+ const assetStats = fs.statSync(fullAssetPath);
3124
+ if (!assetStats.isFile()) {
2878
3125
  throw new Error(
2879
3126
  `Invalid local asset "${args.href}" in ${args.sourceFile}. No matching file was found. Expected "${resolvedHref.filePath}" under the docs root.`
2880
3127
  );
2881
3128
  }
3129
+ if (assetStats.size === 0) {
3130
+ throw new Error(
3131
+ `Invalid local asset "${args.href}" in ${args.sourceFile}. The referenced file is empty.`
3132
+ );
3133
+ }
2882
3134
  if (!resolvedHref.publishable) {
2883
3135
  throw new Error(
2884
3136
  `Invalid local asset "${args.href}" in ${args.sourceFile}. Files with extension "${resolvedHref.extension}" are not published as docs assets.`
2885
3137
  );
2886
3138
  }
3139
+ if (resolvedHref.extension === ".svg") {
3140
+ const svgError = getSvgValidationError(
3141
+ fullAssetPath,
3142
+ `SVG asset "${args.href}"`
3143
+ );
3144
+ if (svgError) {
3145
+ throw new Error(
3146
+ `Invalid local asset "${args.href}" in ${args.sourceFile}. ${svgError}`
3147
+ );
3148
+ }
3149
+ }
2887
3150
  return;
2888
3151
  }
2889
3152
  if (args.expectedTarget === "asset") {
@@ -2942,12 +3205,15 @@ function createInternalLinkValidationPlugin(args) {
2942
3205
  if (hrefAttribute.name !== "href" && hrefAttribute.name !== "src") {
2943
3206
  continue;
2944
3207
  }
2945
- if (hrefAttribute.name === "href" && typeof hrefAttribute.value !== "string") {
3208
+ if (typeof hrefAttribute.value !== "string") {
3209
+ if (hrefAttribute.name === "src" && element.name === "Image") {
3210
+ continue;
3211
+ }
3212
+ const example = hrefAttribute.name === "src" ? 'src="/images/example.png"' : 'href="/guides/quickstart"';
2946
3213
  throw new Error(
2947
- `Invalid JSX href in ${args.sourceFile}. Direct JSX href props must use quoted strings, such as href="/guides/quickstart", not JSX expressions.`
3214
+ `Invalid JSX ${hrefAttribute.name} in ${args.sourceFile}. Direct JSX ${hrefAttribute.name} props must use quoted strings, such as ${example}, not JSX expressions.`
2948
3215
  );
2949
3216
  }
2950
- if (typeof hrefAttribute.value !== "string") continue;
2951
3217
  validateDocsRootAbsoluteHref({
2952
3218
  href: hrefAttribute.value,
2953
3219
  sourceFile: args.sourceFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "radiant-docs-validator",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Shared validation for Radiant documentation repositories",
5
5
  "type": "module",
6
6
  "scripts": {