radiant-docs 0.1.39 → 0.1.41

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 (49) hide show
  1. package/package.json +1 -1
  2. package/template/astro.config.mjs +38 -7
  3. package/template/package-lock.json +19 -7
  4. package/template/package.json +3 -3
  5. package/template/public/favicon.svg +16 -8
  6. package/template/scripts/generate-robots-txt.mjs +29 -1
  7. package/template/scripts/remove-assistant-for-non-pro.mjs +28 -0
  8. package/template/scripts/stamp-image-versions.mjs +59 -33
  9. package/template/src/components/Footer.astro +2 -1
  10. package/template/src/components/Header.astro +10 -8
  11. package/template/src/components/LogoLink.astro +2 -1
  12. package/template/src/components/MdxPage.astro +15 -4
  13. package/template/src/components/PagePagination.astro +61 -0
  14. package/template/src/components/SidebarDropdown.astro +12 -8
  15. package/template/src/components/SidebarGroup.astro +1 -1
  16. package/template/src/components/SidebarMenu.astro +1 -1
  17. package/template/src/components/SidebarSegmented.astro +6 -5
  18. package/template/src/components/TableOfContents.astro +4 -13
  19. package/template/src/components/chat/AskAiWidget.tsx +274 -39
  20. package/template/src/components/chat/AssistantDocsWidget.astro +16 -0
  21. package/template/src/components/chat/AssistantDocsWidget.tsx +402 -0
  22. package/template/src/components/chat/AssistantEmbedPanel.tsx +1693 -0
  23. package/template/src/components/chat/AssistantEmbedPanelPage.astro +95 -0
  24. package/template/src/components/endpoint/PlaygroundForm.astro +2 -1
  25. package/template/src/components/user/Callout.astro +10 -4
  26. package/template/src/components/user/CodeBlock.astro +1 -1
  27. package/template/src/components/user/CodeGroup.astro +16 -1
  28. package/template/src/components/user/ComponentPreviewBlock.astro +1 -0
  29. package/template/src/components/user/Image.astro +43 -53
  30. package/template/src/layouts/Layout.astro +104 -35
  31. package/template/src/lib/assistant-chrome-defaults.ts +74 -0
  32. package/template/src/lib/assistant-chrome.ts +39 -0
  33. package/template/src/lib/assistant-embed-script.ts +897 -0
  34. package/template/src/lib/assistant-panel-config.ts +80 -0
  35. package/template/src/lib/base-path.ts +98 -0
  36. package/template/src/lib/component-error.ts +49 -10
  37. package/template/src/lib/favicon.ts +31 -0
  38. package/template/src/lib/mdx/remark-resolve-internal-links.ts +128 -18
  39. package/template/src/lib/pagefind.ts +62 -14
  40. package/template/src/lib/routes.ts +49 -1
  41. package/template/src/lib/static-asset-url.ts +3 -1
  42. package/template/src/lib/theme-css.ts +176 -0
  43. package/template/src/lib/utils.ts +12 -4
  44. package/template/src/lib/validation.ts +754 -37
  45. package/template/src/pages/-/assistant/embed.js.ts +15 -0
  46. package/template/src/pages/-/assistant/panel.astro +5 -0
  47. package/template/src/pages/404.astro +6 -5
  48. package/template/src/pages/[...slug].astro +68 -6
  49. package/template/src/styles/global.css +62 -1
@@ -167,22 +167,74 @@ export type NavbarItem = {
167
167
  href: string;
168
168
  icon?: string | null;
169
169
  };
170
- export type LogoVariant = string | {
171
- image: string;
172
- padding?: {
173
- top?: number;
174
- bottom?: number;
175
- };
170
+ export type HiddenPageRoute = {
171
+ filePath: string;
172
+ href: string;
173
+ };
174
+ type InternalPageHrefResolution = HiddenPageRoute & {
175
+ linkHref: string;
176
176
  };
177
+ export type LogoVariant =
178
+ | string
179
+ | {
180
+ image: string;
181
+ padding?: {
182
+ top?: number;
183
+ bottom?: number;
184
+ };
185
+ };
177
186
  export type Logo = {
178
187
  light?: LogoVariant;
179
188
  dark?: LogoVariant;
180
189
  href?: string;
181
190
  pill?: string | false;
182
191
  };
192
+ export const BASE_COLOR_OPTIONS = [
193
+ "slate",
194
+ "gray",
195
+ "zinc",
196
+ "neutral",
197
+ "stone",
198
+ "taupe",
199
+ "mauve",
200
+ "mist",
201
+ "olive",
202
+ ] as const;
203
+ export type BaseColorOption = (typeof BASE_COLOR_OPTIONS)[number];
204
+ export type BaseColorByMode = {
205
+ light: BaseColorOption;
206
+ dark: BaseColorOption;
207
+ };
208
+ export const DEFAULT_THEME_COLOR_LIGHT = "#171717";
209
+ export const DEFAULT_THEME_COLOR_DARK = "#f5f5f5";
210
+ export type ThemeColorByMode = {
211
+ light?: string;
212
+ dark?: string;
213
+ };
214
+ export type DocsTheme = {
215
+ baseColor?: BaseColorOption | BaseColorByMode;
216
+ themeColor?: string | ThemeColorByMode;
217
+ };
218
+ export type AssistantIcon = {
219
+ src?: string;
220
+ color?: string;
221
+ };
222
+ export type AssistantButtonSize = "small" | "default";
223
+ export type AssistantButtonConfig = {
224
+ size?: AssistantButtonSize;
225
+ };
226
+ export type AssistantConfig = {
227
+ button?: AssistantButtonConfig;
228
+ heading?: string;
229
+ questions?: string[];
230
+ themeColor?: string | ThemeColorByMode;
231
+ icon?: AssistantIcon;
232
+ };
183
233
  export type DocsConfig = {
184
234
  title: string;
185
235
  logo?: Logo;
236
+ theme?: DocsTheme;
237
+ assistant?: AssistantConfig;
186
238
  home?: string;
187
239
  navigation: NavigationItem;
188
240
  navbar?: {
@@ -195,6 +247,7 @@ export type DocsConfig = {
195
247
  proxy?: boolean;
196
248
  };
197
249
  footer?: Footer;
250
+ hiddenPageRoutes?: HiddenPageRoute[];
198
251
  };
199
252
 
200
253
  export type SocialPlatform =
@@ -255,6 +308,38 @@ function checkType(
255
308
  }
256
309
  }
257
310
 
311
+ function normalizeHexColor(
312
+ value: unknown,
313
+ currentPath: Path,
314
+ label: string,
315
+ ): string {
316
+ checkType(value, "string", currentPath, label);
317
+ if (typeof value !== "string") {
318
+ throwConfigError(`${label} must be a string.`, currentPath);
319
+ }
320
+
321
+ const trimmedValue = value.trim();
322
+ if (trimmedValue.length === 0) {
323
+ throwConfigError(`${label} cannot be empty.`, currentPath);
324
+ }
325
+
326
+ const normalizedValue = trimmedValue.startsWith("#")
327
+ ? trimmedValue
328
+ : `#${trimmedValue}`;
329
+ if (
330
+ !/^#(?:[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/.test(
331
+ normalizedValue,
332
+ )
333
+ ) {
334
+ throwConfigError(
335
+ `${label} must be a valid hex color (for example: #1d4ed8).`,
336
+ currentPath,
337
+ );
338
+ }
339
+
340
+ return normalizedValue.toLowerCase();
341
+ }
342
+
258
343
  function validateFileExistence(filePath: string, currentPath: Path): void {
259
344
  // Assuming relative path from DOCS_DIR and .mdx extension
260
345
  const fullPath = path.join(DOCS_DIR, `${filePath}.mdx`);
@@ -295,6 +380,58 @@ function normalizeDocsPagePath(
295
380
  return normalizedPath;
296
381
  }
297
382
 
383
+ function splitHrefPathAndSuffix(href: string): {
384
+ pathname: string;
385
+ suffix: string;
386
+ } {
387
+ const match = href.match(/^([^?#]*)(.*)$/);
388
+ return {
389
+ pathname: match?.[1] ?? href,
390
+ suffix: match?.[2] ?? "",
391
+ };
392
+ }
393
+
394
+ function normalizeInternalPageHref(
395
+ href: string,
396
+ currentPath: Path,
397
+ label: string,
398
+ ): InternalPageHrefResolution | null {
399
+ const trimmedHref = href.trim();
400
+ if (trimmedHref === "") {
401
+ throwConfigError(`${label} cannot be an empty string`, currentPath);
402
+ }
403
+
404
+ if (isUrl(trimmedHref)) {
405
+ return null;
406
+ }
407
+
408
+ if (!trimmedHref.startsWith("/")) {
409
+ throwConfigError(
410
+ `${label} must be either a valid URL (http:// or https://) or an internal path (starting with /)`,
411
+ currentPath,
412
+ );
413
+ }
414
+
415
+ const { pathname, suffix } = splitHrefPathAndSuffix(trimmedHref);
416
+ const normalizedPathname = pathname.replace(/\/{2,}/g, "/");
417
+ if (normalizedPathname === "/" || normalizedPathname === "") {
418
+ return null;
419
+ }
420
+
421
+ const filePath = normalizeDocsPagePath(
422
+ normalizedPathname,
423
+ currentPath,
424
+ label,
425
+ );
426
+ validateFileExistence(filePath, currentPath);
427
+
428
+ return {
429
+ filePath,
430
+ href: `/${filePath}`,
431
+ linkHref: `/${filePath}${suffix}`,
432
+ };
433
+ }
434
+
298
435
  // Cache for OpenAPI specs (key: filePathOrUrl, value: parsed spec)
299
436
  const openApiSpecCache = new Map<string, any>();
300
437
 
@@ -662,7 +799,11 @@ async function validateNavigationNode(
662
799
  continue;
663
800
  }
664
801
 
665
- await validateNavigationNode(child, [...path, "pages", i], groupDepth + 1);
802
+ await validateNavigationNode(
803
+ child,
804
+ [...path, "pages", i],
805
+ groupDepth + 1,
806
+ );
666
807
  }
667
808
  return;
668
809
  }
@@ -1049,9 +1190,12 @@ async function validateNavMenu(menu: any, currentPath: Path) {
1049
1190
  }
1050
1191
  }
1051
1192
 
1052
- function validateNavbarItem(item: any, currentPath: Path): void {
1193
+ function validateNavbarItem(
1194
+ item: any,
1195
+ currentPath: Path,
1196
+ ): HiddenPageRoute | null {
1053
1197
  // Check if object exists, otherwise we skip (it's optional)
1054
- if (item === undefined) return;
1198
+ if (item === undefined) return null;
1055
1199
 
1056
1200
  checkType(item, "object", currentPath, "Navbar item");
1057
1201
 
@@ -1069,8 +1213,18 @@ function validateNavbarItem(item: any, currentPath: Path): void {
1069
1213
  ]);
1070
1214
  }
1071
1215
 
1216
+ const hiddenPageRoute = normalizeInternalPageHref(
1217
+ item.href,
1218
+ [...currentPath, "href"],
1219
+ "Navbar item href",
1220
+ );
1221
+ if (hiddenPageRoute) {
1222
+ item.href = hiddenPageRoute.linkHref;
1223
+ }
1224
+
1072
1225
  // Optional property
1073
1226
  validateIcon(item.icon, [...currentPath, "icon"]);
1227
+ return hiddenPageRoute;
1074
1228
  }
1075
1229
 
1076
1230
  // --- Top-Level Validation Functions (Your Clean API) ---
@@ -1080,7 +1234,11 @@ function validateTitle(title: DocsConfig["title"]) {
1080
1234
  if (!title) throwConfigError("Title is missing.", ["title"]);
1081
1235
  }
1082
1236
 
1083
- function validateLogoPaddingValue(value: unknown, currentPath: Path, label: string): void {
1237
+ function validateLogoPaddingValue(
1238
+ value: unknown,
1239
+ currentPath: Path,
1240
+ label: string,
1241
+ ): void {
1084
1242
  if (value === undefined) return;
1085
1243
 
1086
1244
  if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -1116,10 +1274,85 @@ function validateLogoImagePath(
1116
1274
 
1117
1275
  if (!fs.existsSync(fullPath)) {
1118
1276
  throwConfigError(
1119
- `${label} file not found. Expected: ${normalizedPath} (relative to content/docs folder)`,
1277
+ `${label} file not found. Expected: ${normalizedPath}`,
1278
+ currentPath,
1279
+ );
1280
+ }
1281
+ }
1282
+
1283
+ function validateAssistantIconSource(
1284
+ iconSource: unknown,
1285
+ currentPath: Path,
1286
+ ): string {
1287
+ checkType(iconSource, "string", currentPath, "Assistant icon source");
1288
+ if (typeof iconSource !== "string") {
1289
+ throwConfigError("Assistant icon source must be a string.", currentPath);
1290
+ }
1291
+
1292
+ const trimmedSource = iconSource.trim();
1293
+ if (trimmedSource.length === 0) {
1294
+ throwConfigError("Assistant icon source cannot be empty.", currentPath);
1295
+ }
1296
+
1297
+ if (isUrl(trimmedSource)) {
1298
+ throwConfigError(
1299
+ "Assistant icon source must be a local image path relative to docs.json.",
1300
+ currentPath,
1301
+ );
1302
+ }
1303
+
1304
+ if (trimmedSource.includes(":")) {
1305
+ throwConfigError(
1306
+ `Invalid assistant icon source: "${trimmedSource}". Assistant icons must be local image files, not Iconify icon names.`,
1307
+ currentPath,
1308
+ );
1309
+ }
1310
+
1311
+ if (
1312
+ trimmedSource.startsWith("//") ||
1313
+ trimmedSource.startsWith("#") ||
1314
+ trimmedSource.startsWith("?") ||
1315
+ trimmedSource.startsWith("./") ||
1316
+ trimmedSource.startsWith("../")
1317
+ ) {
1318
+ throwConfigError(
1319
+ "Assistant icon source must be a local image path relative to docs.json.",
1320
+ currentPath,
1321
+ );
1322
+ }
1323
+
1324
+ const parsed = new URL(trimmedSource, "https://docs.invalid/");
1325
+ const validExtensions = [
1326
+ ".svg",
1327
+ ".png",
1328
+ ".jpg",
1329
+ ".jpeg",
1330
+ ".webp",
1331
+ ".gif",
1332
+ ".ico",
1333
+ ".avif",
1334
+ ];
1335
+ const hasValidExtension = validExtensions.some((ext) =>
1336
+ parsed.pathname.toLowerCase().endsWith(ext),
1337
+ );
1338
+ if (!hasValidExtension) {
1339
+ throwConfigError(
1340
+ "Assistant icon source must be a valid image file (.svg, .png, .jpg, .jpeg, .webp, .gif, .ico, .avif).",
1341
+ currentPath,
1342
+ );
1343
+ }
1344
+
1345
+ const normalizedPath = parsed.pathname.replace(/^\/+/, "");
1346
+ const fullPath = path.join(DOCS_DIR, normalizedPath);
1347
+
1348
+ if (!fs.existsSync(fullPath)) {
1349
+ throwConfigError(
1350
+ `Assistant icon source file not found. Expected: ${normalizedPath}`,
1120
1351
  currentPath,
1121
1352
  );
1122
1353
  }
1354
+
1355
+ return trimmedSource;
1123
1356
  }
1124
1357
 
1125
1358
  function validateLogoVariant(
@@ -1134,7 +1367,11 @@ function validateLogoVariant(
1134
1367
  return;
1135
1368
  }
1136
1369
 
1137
- if (typeof variant !== "object" || variant === null || Array.isArray(variant)) {
1370
+ if (
1371
+ typeof variant !== "object" ||
1372
+ variant === null ||
1373
+ Array.isArray(variant)
1374
+ ) {
1138
1375
  throwConfigError(
1139
1376
  `Logo ${mode} must be a string path or an object with 'image' and optional 'padding'.`,
1140
1377
  currentPath,
@@ -1152,13 +1389,17 @@ function validateLogoVariant(
1152
1389
  }
1153
1390
 
1154
1391
  if (typeof variant.image !== "string") {
1155
- throwConfigError(
1156
- `Logo ${mode} object must include an 'image' string.`,
1157
- [...currentPath, "image"],
1158
- );
1392
+ throwConfigError(`Logo ${mode} object must include an 'image' string.`, [
1393
+ ...currentPath,
1394
+ "image",
1395
+ ]);
1159
1396
  }
1160
1397
 
1161
- validateLogoImagePath(variant.image, [...currentPath, "image"], `Logo ${mode} image`);
1398
+ validateLogoImagePath(
1399
+ variant.image,
1400
+ [...currentPath, "image"],
1401
+ `Logo ${mode} image`,
1402
+ );
1162
1403
 
1163
1404
  if (variant.padding === undefined) return;
1164
1405
 
@@ -1242,10 +1483,463 @@ function validateLogo(logo: DocsConfig["logo"]) {
1242
1483
  );
1243
1484
  }
1244
1485
  } else if (logo.pill !== false) {
1245
- throwConfigError("Logo pill must be a string or false.", ["logo", "pill"]);
1486
+ throwConfigError("Logo pill must be a string or false.", [
1487
+ "logo",
1488
+ "pill",
1489
+ ]);
1246
1490
  }
1247
1491
  }
1492
+ }
1493
+
1494
+ function validateTheme(theme: DocsConfig["theme"]): void {
1495
+ if (theme === undefined) return;
1496
+
1497
+ checkType(theme, "object", ["theme"], "Theme configuration");
1498
+ if (typeof theme !== "object" || theme === null || Array.isArray(theme)) {
1499
+ throwConfigError("Theme configuration must be an object.", ["theme"]);
1500
+ }
1501
+
1502
+ const normalizeBaseColor = (
1503
+ value: unknown,
1504
+ currentPath: Path,
1505
+ label: string,
1506
+ ): BaseColorOption => {
1507
+ checkType(value, "string", currentPath, label);
1508
+ if (typeof value !== "string") {
1509
+ throwConfigError(`${label} must be a string.`, currentPath);
1510
+ }
1511
+
1512
+ const normalizedBaseColor = (value as string).trim().toLowerCase();
1513
+ if (normalizedBaseColor.length === 0) {
1514
+ throwConfigError(`${label} cannot be empty.`, currentPath);
1515
+ }
1516
+
1517
+ if (!BASE_COLOR_OPTIONS.includes(normalizedBaseColor as BaseColorOption)) {
1518
+ throwConfigError(
1519
+ `${label} must be one of: ${BASE_COLOR_OPTIONS.join(", ")}.`,
1520
+ currentPath,
1521
+ );
1522
+ }
1523
+
1524
+ return normalizedBaseColor as BaseColorOption;
1525
+ };
1526
+
1527
+ if (theme.baseColor !== undefined) {
1528
+ if (typeof theme.baseColor === "string") {
1529
+ theme.baseColor = normalizeBaseColor(
1530
+ theme.baseColor,
1531
+ ["theme", "baseColor"],
1532
+ "Theme base color",
1533
+ );
1534
+ } else {
1535
+ checkType(
1536
+ theme.baseColor,
1537
+ "object",
1538
+ ["theme", "baseColor"],
1539
+ "Theme base color",
1540
+ );
1541
+ if (
1542
+ typeof theme.baseColor !== "object" ||
1543
+ theme.baseColor === null ||
1544
+ Array.isArray(theme.baseColor)
1545
+ ) {
1546
+ throwConfigError(
1547
+ "Theme base color must be a string or an object with light/dark values.",
1548
+ ["theme", "baseColor"],
1549
+ );
1550
+ }
1551
+
1552
+ const baseColorByMode = theme.baseColor as Record<string, unknown>;
1553
+ const allowedKeys = new Set(["light", "dark"]);
1554
+ for (const key of Object.keys(baseColorByMode)) {
1555
+ if (!allowedKeys.has(key)) {
1556
+ throwConfigError(
1557
+ "Theme base color object only supports 'light' and 'dark'.",
1558
+ ["theme", "baseColor", key],
1559
+ );
1560
+ }
1561
+ }
1248
1562
 
1563
+ const light =
1564
+ baseColorByMode.light !== undefined
1565
+ ? normalizeBaseColor(
1566
+ baseColorByMode.light,
1567
+ ["theme", "baseColor", "light"],
1568
+ "Theme base color light",
1569
+ )
1570
+ : undefined;
1571
+ const dark =
1572
+ baseColorByMode.dark !== undefined
1573
+ ? normalizeBaseColor(
1574
+ baseColorByMode.dark,
1575
+ ["theme", "baseColor", "dark"],
1576
+ "Theme base color dark",
1577
+ )
1578
+ : undefined;
1579
+
1580
+ if (!light && !dark) {
1581
+ throwConfigError(
1582
+ "Theme base color object must include 'light', 'dark', or both.",
1583
+ ["theme", "baseColor"],
1584
+ );
1585
+ }
1586
+
1587
+ const resolvedLight: BaseColorOption = light ?? "neutral";
1588
+ const resolvedDark: BaseColorOption = dark ?? "neutral";
1589
+
1590
+ theme.baseColor = {
1591
+ light: resolvedLight,
1592
+ dark: resolvedDark,
1593
+ };
1594
+ }
1595
+ }
1596
+
1597
+ if (theme.themeColor === undefined) {
1598
+ return;
1599
+ }
1600
+
1601
+ if (typeof theme.themeColor === "string") {
1602
+ theme.themeColor = normalizeHexColor(
1603
+ theme.themeColor,
1604
+ ["theme", "themeColor"],
1605
+ "Theme color",
1606
+ );
1607
+ return;
1608
+ }
1609
+
1610
+ checkType(theme.themeColor, "object", ["theme", "themeColor"], "Theme color");
1611
+ if (
1612
+ typeof theme.themeColor !== "object" ||
1613
+ theme.themeColor === null ||
1614
+ Array.isArray(theme.themeColor)
1615
+ ) {
1616
+ throwConfigError(
1617
+ "Theme color must be a string or an object with light/dark values.",
1618
+ ["theme", "themeColor"],
1619
+ );
1620
+ }
1621
+
1622
+ const themeColorByMode = theme.themeColor as Record<string, unknown>;
1623
+ const allowedKeys = new Set(["light", "dark"]);
1624
+ for (const key of Object.keys(themeColorByMode)) {
1625
+ if (!allowedKeys.has(key)) {
1626
+ throwConfigError("Theme color object only supports 'light' and 'dark'.", [
1627
+ "theme",
1628
+ "themeColor",
1629
+ key,
1630
+ ]);
1631
+ }
1632
+ }
1633
+
1634
+ const light =
1635
+ themeColorByMode.light !== undefined
1636
+ ? normalizeHexColor(
1637
+ themeColorByMode.light,
1638
+ ["theme", "themeColor", "light"],
1639
+ "Theme color light",
1640
+ )
1641
+ : undefined;
1642
+ const dark =
1643
+ themeColorByMode.dark !== undefined
1644
+ ? normalizeHexColor(
1645
+ themeColorByMode.dark,
1646
+ ["theme", "themeColor", "dark"],
1647
+ "Theme color dark",
1648
+ )
1649
+ : undefined;
1650
+
1651
+ if (!light && !dark) {
1652
+ throwConfigError(
1653
+ "Theme color object must include 'light', 'dark', or both.",
1654
+ ["theme", "themeColor"],
1655
+ );
1656
+ }
1657
+
1658
+ theme.themeColor = {
1659
+ ...(light !== undefined ? { light } : {}),
1660
+ ...(dark !== undefined ? { dark } : {}),
1661
+ };
1662
+ }
1663
+
1664
+ function validateAssistant(assistant: DocsConfig["assistant"]): void {
1665
+ if (assistant === undefined) return;
1666
+
1667
+ checkType(assistant, "object", ["assistant"], "Assistant configuration");
1668
+ if (
1669
+ typeof assistant !== "object" ||
1670
+ assistant === null ||
1671
+ Array.isArray(assistant)
1672
+ ) {
1673
+ throwConfigError("Assistant configuration must be an object.", [
1674
+ "assistant",
1675
+ ]);
1676
+ }
1677
+
1678
+ const allowedAssistantKeys = new Set([
1679
+ "button",
1680
+ "heading",
1681
+ "questions",
1682
+ "themeColor",
1683
+ "icon",
1684
+ ]);
1685
+ for (const key of Object.keys(assistant)) {
1686
+ if (!allowedAssistantKeys.has(key)) {
1687
+ throwConfigError(
1688
+ "Assistant configuration only supports 'button', 'heading', 'questions', 'themeColor', and 'icon'.",
1689
+ ["assistant", key],
1690
+ );
1691
+ }
1692
+ }
1693
+
1694
+ if (assistant.button !== undefined) {
1695
+ checkType(
1696
+ assistant.button,
1697
+ "object",
1698
+ ["assistant", "button"],
1699
+ "Assistant button configuration",
1700
+ );
1701
+ if (
1702
+ typeof assistant.button !== "object" ||
1703
+ assistant.button === null ||
1704
+ Array.isArray(assistant.button)
1705
+ ) {
1706
+ throwConfigError("Assistant button configuration must be an object.", [
1707
+ "assistant",
1708
+ "button",
1709
+ ]);
1710
+ }
1711
+
1712
+ const allowedButtonKeys = new Set(["size"]);
1713
+ for (const key of Object.keys(assistant.button)) {
1714
+ if (!allowedButtonKeys.has(key)) {
1715
+ throwConfigError(
1716
+ "Assistant button configuration only supports 'size'.",
1717
+ ["assistant", "button", key],
1718
+ );
1719
+ }
1720
+ }
1721
+
1722
+ if (assistant.button.size !== undefined) {
1723
+ checkType(
1724
+ assistant.button.size,
1725
+ "string",
1726
+ ["assistant", "button", "size"],
1727
+ "Assistant button size",
1728
+ );
1729
+ if (typeof assistant.button.size !== "string") {
1730
+ throwConfigError("Assistant button size must be a string.", [
1731
+ "assistant",
1732
+ "button",
1733
+ "size",
1734
+ ]);
1735
+ }
1736
+
1737
+ const trimmedSize = assistant.button.size.trim();
1738
+ if (trimmedSize !== "small" && trimmedSize !== "default") {
1739
+ throwConfigError(
1740
+ "Assistant button size must be either 'small' or 'default'.",
1741
+ ["assistant", "button", "size"],
1742
+ );
1743
+ }
1744
+ assistant.button.size = trimmedSize;
1745
+ }
1746
+ }
1747
+
1748
+ if (assistant.heading !== undefined) {
1749
+ checkType(
1750
+ assistant.heading,
1751
+ "string",
1752
+ ["assistant", "heading"],
1753
+ "Assistant heading",
1754
+ );
1755
+ if (typeof assistant.heading !== "string") {
1756
+ throwConfigError("Assistant heading must be a string.", [
1757
+ "assistant",
1758
+ "heading",
1759
+ ]);
1760
+ }
1761
+
1762
+ const trimmedHeading = assistant.heading.trim();
1763
+ if (trimmedHeading.length === 0) {
1764
+ throwConfigError("Assistant heading cannot be empty.", [
1765
+ "assistant",
1766
+ "heading",
1767
+ ]);
1768
+ }
1769
+ assistant.heading = trimmedHeading;
1770
+ }
1771
+
1772
+ if (assistant.questions !== undefined) {
1773
+ checkType(
1774
+ assistant.questions,
1775
+ "array",
1776
+ ["assistant", "questions"],
1777
+ "Assistant questions",
1778
+ );
1779
+ if (!Array.isArray(assistant.questions)) {
1780
+ throwConfigError("Assistant questions must be an array.", [
1781
+ "assistant",
1782
+ "questions",
1783
+ ]);
1784
+ }
1785
+
1786
+ if (assistant.questions.length > 3) {
1787
+ throwConfigError("Assistant questions can include at most 3 questions.", [
1788
+ "assistant",
1789
+ "questions",
1790
+ ]);
1791
+ }
1792
+
1793
+ assistant.questions = assistant.questions.map((question, index) => {
1794
+ checkType(
1795
+ question,
1796
+ "string",
1797
+ ["assistant", "questions", String(index)],
1798
+ "Assistant question",
1799
+ );
1800
+ if (typeof question !== "string") {
1801
+ throwConfigError("Assistant question must be a string.", [
1802
+ "assistant",
1803
+ "questions",
1804
+ String(index),
1805
+ ]);
1806
+ }
1807
+
1808
+ const trimmedQuestion = question.trim();
1809
+ if (trimmedQuestion.length === 0) {
1810
+ throwConfigError(
1811
+ "Assistant question cannot be empty.",
1812
+ ["assistant", "questions", String(index)],
1813
+ );
1814
+ }
1815
+
1816
+ return trimmedQuestion;
1817
+ });
1818
+ }
1819
+
1820
+ if (assistant.themeColor !== undefined) {
1821
+ if (typeof assistant.themeColor === "string") {
1822
+ assistant.themeColor = normalizeHexColor(
1823
+ assistant.themeColor,
1824
+ ["assistant", "themeColor"],
1825
+ "Assistant theme color",
1826
+ );
1827
+ } else {
1828
+ checkType(
1829
+ assistant.themeColor,
1830
+ "object",
1831
+ ["assistant", "themeColor"],
1832
+ "Assistant theme color",
1833
+ );
1834
+ if (
1835
+ typeof assistant.themeColor !== "object" ||
1836
+ assistant.themeColor === null ||
1837
+ Array.isArray(assistant.themeColor)
1838
+ ) {
1839
+ throwConfigError(
1840
+ "Assistant theme color must be a string or an object with light/dark values.",
1841
+ ["assistant", "themeColor"],
1842
+ );
1843
+ }
1844
+
1845
+ const assistantThemeColorByMode = assistant.themeColor as Record<
1846
+ string,
1847
+ unknown
1848
+ >;
1849
+ const allowedThemeColorKeys = new Set(["light", "dark"]);
1850
+ for (const key of Object.keys(assistantThemeColorByMode)) {
1851
+ if (!allowedThemeColorKeys.has(key)) {
1852
+ throwConfigError(
1853
+ "Assistant theme color object only supports 'light' and 'dark'.",
1854
+ ["assistant", "themeColor", key],
1855
+ );
1856
+ }
1857
+ }
1858
+
1859
+ const light =
1860
+ assistantThemeColorByMode.light !== undefined
1861
+ ? normalizeHexColor(
1862
+ assistantThemeColorByMode.light,
1863
+ ["assistant", "themeColor", "light"],
1864
+ "Assistant theme color light",
1865
+ )
1866
+ : undefined;
1867
+ const dark =
1868
+ assistantThemeColorByMode.dark !== undefined
1869
+ ? normalizeHexColor(
1870
+ assistantThemeColorByMode.dark,
1871
+ ["assistant", "themeColor", "dark"],
1872
+ "Assistant theme color dark",
1873
+ )
1874
+ : undefined;
1875
+
1876
+ if (!light && !dark) {
1877
+ throwConfigError(
1878
+ "Assistant theme color object must include 'light', 'dark', or both.",
1879
+ ["assistant", "themeColor"],
1880
+ );
1881
+ }
1882
+
1883
+ assistant.themeColor = {
1884
+ ...(light !== undefined ? { light } : {}),
1885
+ ...(dark !== undefined ? { dark } : {}),
1886
+ };
1887
+ }
1888
+ }
1889
+
1890
+ if (assistant.icon === undefined) return;
1891
+
1892
+ checkType(
1893
+ assistant.icon,
1894
+ "object",
1895
+ ["assistant", "icon"],
1896
+ "Assistant icon",
1897
+ );
1898
+ if (
1899
+ typeof assistant.icon !== "object" ||
1900
+ assistant.icon === null ||
1901
+ Array.isArray(assistant.icon)
1902
+ ) {
1903
+ throwConfigError("Assistant icon must be an object.", [
1904
+ "assistant",
1905
+ "icon",
1906
+ ]);
1907
+ }
1908
+
1909
+ const allowedIconKeys = new Set(["src", "color"]);
1910
+ for (const key of Object.keys(assistant.icon)) {
1911
+ if (!allowedIconKeys.has(key)) {
1912
+ throwConfigError(
1913
+ "Assistant icon only supports 'src' and 'color'.",
1914
+ ["assistant", "icon", key],
1915
+ );
1916
+ }
1917
+ }
1918
+
1919
+ if (
1920
+ assistant.icon.src === undefined &&
1921
+ assistant.icon.color === undefined
1922
+ ) {
1923
+ throwConfigError(
1924
+ "Assistant icon must include 'src', 'color', or both.",
1925
+ ["assistant", "icon"],
1926
+ );
1927
+ }
1928
+
1929
+ if (assistant.icon.src !== undefined) {
1930
+ assistant.icon.src = validateAssistantIconSource(
1931
+ assistant.icon.src,
1932
+ ["assistant", "icon", "src"],
1933
+ );
1934
+ }
1935
+
1936
+ if (assistant.icon.color !== undefined) {
1937
+ assistant.icon.color = normalizeHexColor(
1938
+ assistant.icon.color,
1939
+ ["assistant", "icon", "color"],
1940
+ "Assistant icon color",
1941
+ );
1942
+ }
1249
1943
  }
1250
1944
 
1251
1945
  function validateHome(home: DocsConfig["home"]): string | undefined {
@@ -1256,8 +1950,9 @@ function validateHome(home: DocsConfig["home"]): string | undefined {
1256
1950
  return normalizedHome;
1257
1951
  }
1258
1952
 
1259
- function validateNavbar(navbar: DocsConfig["navbar"]) {
1260
- if (navbar === undefined) return; // Navbar itself is optional
1953
+ function validateNavbar(navbar: DocsConfig["navbar"]): HiddenPageRoute[] {
1954
+ const hiddenPageRoutes: HiddenPageRoute[] = [];
1955
+ if (navbar === undefined) return hiddenPageRoutes; // Navbar itself is optional
1261
1956
 
1262
1957
  checkType(navbar, "object", ["navbar"], "Navbar configuration");
1263
1958
 
@@ -1265,10 +1960,18 @@ function validateNavbar(navbar: DocsConfig["navbar"]) {
1265
1960
  checkType(navbar.blur, "boolean", ["navbar", "blur"], "Navbar blur setting");
1266
1961
 
1267
1962
  // Validate 'primary' item
1268
- validateNavbarItem(navbar.primary, ["navbar", "primary"]);
1963
+ const primaryPageRoute = validateNavbarItem(navbar.primary, [
1964
+ "navbar",
1965
+ "primary",
1966
+ ]);
1967
+ if (primaryPageRoute) hiddenPageRoutes.push(primaryPageRoute);
1269
1968
 
1270
1969
  // Validate 'secondary' item
1271
- validateNavbarItem(navbar.secondary, ["navbar", "secondary"]);
1970
+ const secondaryPageRoute = validateNavbarItem(navbar.secondary, [
1971
+ "navbar",
1972
+ "secondary",
1973
+ ]);
1974
+ if (secondaryPageRoute) hiddenPageRoutes.push(secondaryPageRoute);
1272
1975
 
1273
1976
  // Validate 'links' array
1274
1977
  if (navbar.links !== undefined) {
@@ -1282,13 +1985,17 @@ function validateNavbar(navbar: DocsConfig["navbar"]) {
1282
1985
  }
1283
1986
 
1284
1987
  navbar.links.forEach((link: any, i: number) => {
1285
- validateNavbarItem(link, ["navbar", "links", i]);
1988
+ const hiddenPageRoute = validateNavbarItem(link, ["navbar", "links", i]);
1989
+ if (hiddenPageRoute) hiddenPageRoutes.push(hiddenPageRoute);
1286
1990
  });
1287
1991
  }
1992
+
1993
+ return hiddenPageRoutes;
1288
1994
  }
1289
1995
 
1290
- function validateFooter(footer: DocsConfig["footer"]) {
1291
- if (footer === undefined) return;
1996
+ function validateFooter(footer: DocsConfig["footer"]): HiddenPageRoute[] {
1997
+ const hiddenPageRoutes: HiddenPageRoute[] = [];
1998
+ if (footer === undefined) return hiddenPageRoutes;
1292
1999
 
1293
2000
  checkType(footer, "object", ["footer"], "Footer configuration");
1294
2001
 
@@ -1367,19 +2074,19 @@ function validateFooter(footer: DocsConfig["footer"]) {
1367
2074
  ]);
1368
2075
  }
1369
2076
 
1370
- const trimmedHref = link.href.trim();
1371
- const isExternal =
1372
- trimmedHref.startsWith("http://") || trimmedHref.startsWith("https://");
1373
- const isInternal = trimmedHref.startsWith("/");
1374
-
1375
- if (!isExternal && !isInternal) {
1376
- throwConfigError(
1377
- "Footer link href must be either a valid URL (http:// or https://) or an internal path (starting with /)",
1378
- ["footer", "links", i, "href"],
1379
- );
2077
+ const hiddenPageRoute = normalizeInternalPageHref(
2078
+ link.href,
2079
+ ["footer", "links", i, "href"],
2080
+ "Footer link href",
2081
+ );
2082
+ if (hiddenPageRoute) {
2083
+ link.href = hiddenPageRoute.linkHref;
2084
+ hiddenPageRoutes.push(hiddenPageRoute);
1380
2085
  }
1381
2086
  });
1382
2087
  }
2088
+
2089
+ return hiddenPageRoutes;
1383
2090
  }
1384
2091
 
1385
2092
  async function validateNavigation(navigation: DocsConfig["navigation"]) {
@@ -1433,8 +2140,8 @@ async function validateConfig(config: any): Promise<DocsConfig> {
1433
2140
  // Execute top-level checks sequentially
1434
2141
  validateTitle(config.title);
1435
2142
  validateLogo(config.logo);
1436
- validateNavbar(config.navbar);
1437
- validateFooter(config.footer);
2143
+ validateTheme(config.theme);
2144
+ validateAssistant(config.assistant);
1438
2145
  await validateNavigation(config.navigation);
1439
2146
  config.home = validateHome(config.home);
1440
2147
 
@@ -1449,6 +2156,16 @@ async function validateConfig(config: any): Promise<DocsConfig> {
1449
2156
  config.home = fallbackHome;
1450
2157
  }
1451
2158
 
2159
+ const hiddenPageRoutes = [
2160
+ ...validateNavbar(config.navbar),
2161
+ ...validateFooter(config.footer),
2162
+ ];
2163
+ const dedupedHiddenPageRoutes = new Map<string, HiddenPageRoute>();
2164
+ for (const route of hiddenPageRoutes) {
2165
+ dedupedHiddenPageRoutes.set(route.href, route);
2166
+ }
2167
+ config.hiddenPageRoutes = Array.from(dedupedHiddenPageRoutes.values());
2168
+
1452
2169
  // --- 4. Validate Playground ---
1453
2170
  if (config.playground !== undefined) {
1454
2171
  checkType(config.playground, "object", ["playground"], "Playground");