radiant-docs-validator 0.1.14 → 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 +312 -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",
@@ -467,6 +504,9 @@ function validateRadiantComponentProps(componentName, props, options) {
467
504
  validateIconProp("Callout", "icon", props.icon, options);
468
505
  assertType("Callout", "accent", props.accent, ["boolean"], options.sourceFile);
469
506
  assertType("Callout", "color", props.color, ["string"], options.sourceFile);
507
+ if (props.color !== void 0) {
508
+ assertHexColor("Callout", "color", props.color, options.sourceFile);
509
+ }
470
510
  if (props.title === true) {
471
511
  componentError("Callout", 'Invalid prop "title": expected string or false, got true', options.sourceFile);
472
512
  }
@@ -483,6 +523,18 @@ function validateRadiantComponentProps(componentName, props, options) {
483
523
  validateImage(props, options);
484
524
  return;
485
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
+ }
486
538
  if (componentName === "Columns") {
487
539
  assertNoUnknownProps("Columns", props, ["columns"], options.sourceFile);
488
540
  assertType("Columns", "columns", props.columns, ["number", "string"], options.sourceFile);
@@ -693,6 +745,16 @@ function configureDocsValidator({
693
745
  lastMtime = 0;
694
746
  }
695
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
+ ]);
696
758
  function isUrl(str) {
697
759
  try {
698
760
  const url = new URL(str);
@@ -747,14 +809,19 @@ function validateIcon(icon, currentPath) {
747
809
  }
748
810
  return;
749
811
  }
750
- const localRelativePath = icon.startsWith("/") ? icon.slice(1) : icon;
751
- const localPath = path.join(DOCS_DIR, localRelativePath);
752
- 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)) {
753
819
  throwConfigError(
754
- `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}`,
755
821
  currentPath
756
822
  );
757
823
  }
824
+ return;
758
825
  }
759
826
  function validateComponentIcon(icon, currentPath) {
760
827
  const trimmedIcon = icon.trim();
@@ -806,13 +873,25 @@ var throwConfigError = (message, currentPath) => {
806
873
  throw new Error(`${message}${location}
807
874
  `);
808
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
+ }
809
888
  function checkType(value, type, currentPath, label) {
810
- if (value === void 0 || value === null) return;
889
+ if (value === void 0) return;
811
890
  if (type === "array") {
812
891
  if (!Array.isArray(value))
813
892
  throwConfigError(`${label} must be an array.`, currentPath);
814
893
  } else if (type === "object") {
815
- if (typeof value !== "object" || value === null)
894
+ if (typeof value !== "object" || value === null || Array.isArray(value))
816
895
  throwConfigError(`${label} must be an object.`, currentPath);
817
896
  } else {
818
897
  if (typeof value !== type)
@@ -946,8 +1025,13 @@ function normalizeSeedValue(value, currentPath, label) {
946
1025
  return trimmedValue;
947
1026
  }
948
1027
  function validateFileExistence(filePath, currentPath) {
949
- const fullPath = path.join(DOCS_DIR, `${filePath}.mdx`);
950
- 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()) {
951
1035
  throwConfigError(
952
1036
  `Referenced file not found. Expected: ${filePath}`,
953
1037
  currentPath
@@ -979,12 +1063,90 @@ function normalizeDocsPagePath(value, currentPath, label = "Page path") {
979
1063
  currentPath
980
1064
  );
981
1065
  }
982
- 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(/\/+$/, "");
983
1074
  if (normalizedPath === "") {
984
1075
  throwConfigError(`${label} cannot be '/'`, currentPath);
985
1076
  }
986
1077
  return normalizedPath;
987
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
+ }
988
1150
  function splitHrefPathAndSuffix(href) {
989
1151
  const match = href.match(/^([^?#]*)(.*)$/);
990
1152
  return {
@@ -1046,7 +1208,12 @@ async function loadOpenApiSpec(filePathOrUrl) {
1046
1208
  );
1047
1209
  }
1048
1210
  } else {
1049
- 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);
1050
1217
  fileContent = fs.readFileSync(fullPath, "utf-8");
1051
1218
  }
1052
1219
  const trimmedContent = fileContent.trim();
@@ -1087,13 +1254,7 @@ async function validateOpenApiFile(filePathOrUrl, currentPath) {
1087
1254
  currentPath
1088
1255
  );
1089
1256
  }
1090
- const fullPath = path.join(DOCS_DIR, filePathOrUrl);
1091
- if (!fs.existsSync(fullPath)) {
1092
- throwConfigError(
1093
- `Referenced OpenAPI file not found. Expected: ${filePathOrUrl}`,
1094
- currentPath
1095
- );
1096
- }
1257
+ validateLocalDocsFile(filePathOrUrl, currentPath, "OpenAPI file");
1097
1258
  } else {
1098
1259
  try {
1099
1260
  const url = new URL(filePathOrUrl);
@@ -1221,6 +1382,12 @@ function parseEndpointString(endpointStr) {
1221
1382
  }
1222
1383
  async function validateNavOpenApiPage(navOpenApiPage, currentPath) {
1223
1384
  checkType(navOpenApiPage, "object", currentPath, "Open API page");
1385
+ assertNoUnknownKeys(
1386
+ navOpenApiPage,
1387
+ ["source", "endpoint"],
1388
+ currentPath,
1389
+ "Open API page"
1390
+ );
1224
1391
  if (typeof navOpenApiPage.source !== "string") {
1225
1392
  throwConfigError(
1226
1393
  "Open API page must include a 'source' property that is a string.",
@@ -1270,6 +1437,12 @@ async function validateNavigationNode(item, currentPath, groupDepth = 0) {
1270
1437
  }
1271
1438
  if (isGroup) {
1272
1439
  const path2 = [...currentPath];
1440
+ assertNoUnknownKeys(
1441
+ item,
1442
+ ["group", "pages", "icon", "expanded", "tag"],
1443
+ path2,
1444
+ "Navigation group"
1445
+ );
1273
1446
  if (groupDepth >= 2) {
1274
1447
  throwConfigError("Groups can only be nested up to 2 levels deep.", path2);
1275
1448
  }
@@ -1298,6 +1471,12 @@ async function validateNavigationNode(item, currentPath, groupDepth = 0) {
1298
1471
  }
1299
1472
  if (isPage) {
1300
1473
  const path2 = [...currentPath];
1474
+ assertNoUnknownKeys(
1475
+ item,
1476
+ ["page", "icon", "tag", "title"],
1477
+ path2,
1478
+ "Navigation page"
1479
+ );
1301
1480
  const normalizedPagePath = normalizeDocsPagePath(item.page, [
1302
1481
  ...path2,
1303
1482
  "page"
@@ -1318,6 +1497,12 @@ async function validateNavigationNode(item, currentPath, groupDepth = 0) {
1318
1497
  }
1319
1498
  if (isOpenApiPage) {
1320
1499
  const path2 = [...currentPath];
1500
+ assertNoUnknownKeys(
1501
+ item,
1502
+ ["openapi", "title", "tag"],
1503
+ path2,
1504
+ "Open API navigation page"
1505
+ );
1321
1506
  if ("icon" in item) {
1322
1507
  throwConfigError(
1323
1508
  "Open API page items cannot have an 'icon'. Method badges are displayed automatically.",
@@ -1411,6 +1596,12 @@ function getFirstRouteFromNavigation(navigation) {
1411
1596
  }
1412
1597
  async function validateNavOpenApi(navOpenApi, currentPath) {
1413
1598
  checkType(navOpenApi, "object", currentPath, "Open API object");
1599
+ assertNoUnknownKeys(
1600
+ navOpenApi,
1601
+ ["source", "include", "exclude"],
1602
+ currentPath,
1603
+ "Open API object"
1604
+ );
1414
1605
  if (typeof navOpenApi.source !== "string") {
1415
1606
  throwConfigError(
1416
1607
  "Open API object must have an 'source' property that is a string.",
@@ -1506,6 +1697,12 @@ async function validateNavOpenApi(navOpenApi, currentPath) {
1506
1697
  }
1507
1698
  async function validateNavMenuItem(item, currentPath) {
1508
1699
  checkType(item, "object", currentPath, "Menu item");
1700
+ assertNoUnknownKeys(
1701
+ item,
1702
+ ["label", "submenu", "icon"],
1703
+ currentPath,
1704
+ "Menu item"
1705
+ );
1509
1706
  validateIcon(item.icon, [...currentPath, "icon"]);
1510
1707
  if (!item.label) {
1511
1708
  throwConfigError("Menu item must have a 'label' property.", [
@@ -1600,6 +1797,7 @@ async function validateNavMenuItem(item, currentPath) {
1600
1797
  }
1601
1798
  async function validateNavMenu(menu, currentPath) {
1602
1799
  checkType(menu, "object", currentPath, "Menu");
1800
+ assertNoUnknownKeys(menu, ["type", "label", "items"], currentPath, "Menu");
1603
1801
  if (menu.type !== void 0) {
1604
1802
  if (menu.type !== "dropdown" && menu.type !== "segmented") {
1605
1803
  throwConfigError(
@@ -1623,6 +1821,12 @@ async function validateNavMenu(menu, currentPath) {
1623
1821
  function validateNavbarItem(item, currentPath) {
1624
1822
  if (item === void 0) return null;
1625
1823
  checkType(item, "object", currentPath, "Navbar item");
1824
+ assertNoUnknownKeys(
1825
+ item,
1826
+ ["text", "href", "icon", "color"],
1827
+ currentPath,
1828
+ "Navbar item"
1829
+ );
1626
1830
  if (typeof item.text !== "string") {
1627
1831
  throwConfigError("Navbar item must have a 'text' property.", [
1628
1832
  ...currentPath,
@@ -1674,24 +1878,16 @@ function validateLogoPaddingValue(value, currentPath, label) {
1674
1878
  }
1675
1879
  }
1676
1880
  function validateLogoImagePath(imagePath, currentPath, label) {
1677
- const validExtensions = [".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif"];
1678
- const hasValidExtension = validExtensions.some(
1679
- (ext) => imagePath.toLowerCase().endsWith(ext)
1680
- );
1881
+ const normalizedPath = normalizeLocalDocsPath(imagePath, currentPath, label);
1882
+ const extension = path.extname(normalizedPath).toLowerCase();
1883
+ const hasValidExtension = LOCAL_IMAGE_EXTENSIONS.has(extension);
1681
1884
  if (!hasValidExtension) {
1682
1885
  throwConfigError(
1683
- `${label} must be a valid image file (.svg, .png, .jpg, .jpeg, .webp, .gif)`,
1684
- currentPath
1685
- );
1686
- }
1687
- const normalizedPath = imagePath.startsWith("/") ? imagePath.slice(1) : imagePath;
1688
- const fullPath = path.join(DOCS_DIR, normalizedPath);
1689
- if (!fs.existsSync(fullPath)) {
1690
- throwConfigError(
1691
- `${label} file not found. Expected: ${normalizedPath}`,
1886
+ `${label} must be a valid image file (.svg, .png, .jpg, .jpeg, .webp, .gif, .ico, .avif)`,
1692
1887
  currentPath
1693
1888
  );
1694
1889
  }
1890
+ validateLocalDocsFile(imagePath, currentPath, label);
1695
1891
  }
1696
1892
  function validateAssistantIconSource(iconSource, currentPath) {
1697
1893
  checkType(iconSource, "string", currentPath, "Assistant icon source");
@@ -1740,14 +1936,7 @@ function validateAssistantIconSource(iconSource, currentPath) {
1740
1936
  currentPath
1741
1937
  );
1742
1938
  }
1743
- const normalizedPath = parsed.pathname.replace(/^\/+/, "");
1744
- const fullPath = path.join(DOCS_DIR, normalizedPath);
1745
- if (!fs.existsSync(fullPath)) {
1746
- throwConfigError(
1747
- `Assistant icon source file not found. Expected: ${normalizedPath}`,
1748
- currentPath
1749
- );
1750
- }
1939
+ validateLocalDocsFile(parsed.pathname, currentPath, "Assistant icon source");
1751
1940
  return trimmedSource;
1752
1941
  }
1753
1942
  function validateLogoVariant(variant, currentPath, mode) {
@@ -1812,6 +2001,12 @@ function validateLogoVariant(variant, currentPath, mode) {
1812
2001
  function validateLogo(logo) {
1813
2002
  if (logo === void 0) return;
1814
2003
  checkType(logo, "object", ["logo"], "Logo configuration");
2004
+ assertNoUnknownKeys(
2005
+ logo,
2006
+ ["light", "dark", "href", "pill"],
2007
+ ["logo"],
2008
+ "Logo configuration"
2009
+ );
1815
2010
  validateLogoVariant(logo.light, ["logo", "light"], "light");
1816
2011
  validateLogoVariant(logo.dark, ["logo", "dark"], "dark");
1817
2012
  if (logo.href !== void 0) {
@@ -2426,6 +2621,12 @@ function validateNavbar(navbar) {
2426
2621
  const hiddenPageRoutes = [];
2427
2622
  if (navbar === void 0) return hiddenPageRoutes;
2428
2623
  checkType(navbar, "object", ["navbar"], "Navbar configuration");
2624
+ assertNoUnknownKeys(
2625
+ navbar,
2626
+ ["blur", "primary", "secondary", "links"],
2627
+ ["navbar"],
2628
+ "Navbar configuration"
2629
+ );
2429
2630
  checkType(navbar.blur, "boolean", ["navbar", "blur"], "Navbar blur setting");
2430
2631
  const primaryPageRoute = validateNavbarItem(navbar.primary, [
2431
2632
  "navbar",
@@ -2456,6 +2657,12 @@ function validateFooter(footer) {
2456
2657
  const hiddenPageRoutes = [];
2457
2658
  if (footer === void 0) return hiddenPageRoutes;
2458
2659
  checkType(footer, "object", ["footer"], "Footer configuration");
2660
+ assertNoUnknownKeys(
2661
+ footer,
2662
+ ["socials", "links"],
2663
+ ["footer"],
2664
+ "Footer configuration"
2665
+ );
2459
2666
  if (footer.socials !== void 0) {
2460
2667
  checkType(
2461
2668
  footer.socials,
@@ -2509,6 +2716,12 @@ function validateFooter(footer) {
2509
2716
  checkType(footer.links, "array", ["footer", "links"], "Footer links");
2510
2717
  footer.links.forEach((link, i) => {
2511
2718
  checkType(link, "object", ["footer", "links", i], "Footer link");
2719
+ assertNoUnknownKeys(
2720
+ link,
2721
+ ["text", "href"],
2722
+ ["footer", "links", i],
2723
+ "Footer link"
2724
+ );
2512
2725
  if (typeof link.text !== "string") {
2513
2726
  throwConfigError("Footer link must have a 'text' property.", [
2514
2727
  "footer",
@@ -2540,6 +2753,12 @@ function validateFooter(footer) {
2540
2753
  }
2541
2754
  async function validateNavigation(navigation) {
2542
2755
  checkType(navigation, "object", ["navigation"], "Navigation");
2756
+ assertNoUnknownKeys(
2757
+ navigation,
2758
+ ["pages", "menu", "openapi"],
2759
+ ["navigation"],
2760
+ "Navigation"
2761
+ );
2543
2762
  const keys = Object.keys(navigation);
2544
2763
  const validKeys = ["pages", "menu", "openapi"];
2545
2764
  const navKeys = keys.filter((key) => validKeys.includes(key));
@@ -2584,6 +2803,25 @@ async function validateNavigation(navigation) {
2584
2803
  }
2585
2804
  }
2586
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
+ );
2587
2825
  validateTitle(config.title);
2588
2826
  validateLogo(config.logo);
2589
2827
  validateTheme(config.theme);
@@ -2612,6 +2850,12 @@ async function validateConfig(config) {
2612
2850
  config.hiddenPageRoutes = Array.from(dedupedHiddenPageRoutes.values());
2613
2851
  if (config.playground !== void 0) {
2614
2852
  checkType(config.playground, "object", ["playground"], "Playground");
2853
+ assertNoUnknownKeys(
2854
+ config.playground,
2855
+ ["proxy"],
2856
+ ["playground"],
2857
+ "Playground"
2858
+ );
2615
2859
  if (config.playground.proxy !== void 0) {
2616
2860
  checkType(
2617
2861
  config.playground.proxy,
@@ -2871,16 +3115,38 @@ function validateDocsRootAbsoluteHref(args) {
2871
3115
  }
2872
3116
  if (resolvedHref.kind === "local-asset") {
2873
3117
  const fullAssetPath = path.join(DOCS_DIR, resolvedHref.filePath);
2874
- 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()) {
2875
3125
  throw new Error(
2876
3126
  `Invalid local asset "${args.href}" in ${args.sourceFile}. No matching file was found. Expected "${resolvedHref.filePath}" under the docs root.`
2877
3127
  );
2878
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
+ }
2879
3134
  if (!resolvedHref.publishable) {
2880
3135
  throw new Error(
2881
3136
  `Invalid local asset "${args.href}" in ${args.sourceFile}. Files with extension "${resolvedHref.extension}" are not published as docs assets.`
2882
3137
  );
2883
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
+ }
2884
3150
  return;
2885
3151
  }
2886
3152
  if (args.expectedTarget === "asset") {
@@ -2939,12 +3205,15 @@ function createInternalLinkValidationPlugin(args) {
2939
3205
  if (hrefAttribute.name !== "href" && hrefAttribute.name !== "src") {
2940
3206
  continue;
2941
3207
  }
2942
- 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"';
2943
3213
  throw new Error(
2944
- `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.`
2945
3215
  );
2946
3216
  }
2947
- if (typeof hrefAttribute.value !== "string") continue;
2948
3217
  validateDocsRootAbsoluteHref({
2949
3218
  href: hrefAttribute.value,
2950
3219
  sourceFile: args.sourceFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "radiant-docs-validator",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Shared validation for Radiant documentation repositories",
5
5
  "type": "module",
6
6
  "scripts": {