radiant-docs 0.1.40 → 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.
@@ -208,17 +208,33 @@ export type BaseColorByMode = {
208
208
  export const DEFAULT_THEME_COLOR_LIGHT = "#171717";
209
209
  export const DEFAULT_THEME_COLOR_DARK = "#f5f5f5";
210
210
  export type ThemeColorByMode = {
211
- light: string;
212
- dark: string;
211
+ light?: string;
212
+ dark?: string;
213
213
  };
214
214
  export type DocsTheme = {
215
215
  baseColor?: BaseColorOption | BaseColorByMode;
216
216
  themeColor?: string | ThemeColorByMode;
217
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
+ };
218
233
  export type DocsConfig = {
219
234
  title: string;
220
235
  logo?: Logo;
221
236
  theme?: DocsTheme;
237
+ assistant?: AssistantConfig;
222
238
  home?: string;
223
239
  navigation: NavigationItem;
224
240
  navbar?: {
@@ -292,6 +308,38 @@ function checkType(
292
308
  }
293
309
  }
294
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
+
295
343
  function validateFileExistence(filePath: string, currentPath: Path): void {
296
344
  // Assuming relative path from DOCS_DIR and .mdx extension
297
345
  const fullPath = path.join(DOCS_DIR, `${filePath}.mdx`);
@@ -370,7 +418,11 @@ function normalizeInternalPageHref(
370
418
  return null;
371
419
  }
372
420
 
373
- const filePath = normalizeDocsPagePath(normalizedPathname, currentPath, label);
421
+ const filePath = normalizeDocsPagePath(
422
+ normalizedPathname,
423
+ currentPath,
424
+ label,
425
+ );
374
426
  validateFileExistence(filePath, currentPath);
375
427
 
376
428
  return {
@@ -1222,10 +1274,85 @@ function validateLogoImagePath(
1222
1274
 
1223
1275
  if (!fs.existsSync(fullPath)) {
1224
1276
  throwConfigError(
1225
- `${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}`,
1226
1351
  currentPath,
1227
1352
  );
1228
1353
  }
1354
+
1355
+ return trimmedSource;
1229
1356
  }
1230
1357
 
1231
1358
  function validateLogoVariant(
@@ -1397,38 +1524,6 @@ function validateTheme(theme: DocsConfig["theme"]): void {
1397
1524
  return normalizedBaseColor as BaseColorOption;
1398
1525
  };
1399
1526
 
1400
- const normalizeThemeColor = (
1401
- value: unknown,
1402
- currentPath: Path,
1403
- label: string,
1404
- ): string => {
1405
- checkType(value, "string", currentPath, label);
1406
- if (typeof value !== "string") {
1407
- throwConfigError(`${label} must be a string.`, currentPath);
1408
- }
1409
-
1410
- const trimmedValue = (value as string).trim();
1411
- if (trimmedValue.length === 0) {
1412
- throwConfigError(`${label} cannot be empty.`, currentPath);
1413
- }
1414
-
1415
- const normalizedValue = trimmedValue.startsWith("#")
1416
- ? trimmedValue
1417
- : `#${trimmedValue}`;
1418
- if (
1419
- !/^#(?:[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/.test(
1420
- normalizedValue,
1421
- )
1422
- ) {
1423
- throwConfigError(
1424
- `${label} must be a valid hex color (for example: #1d4ed8).`,
1425
- currentPath,
1426
- );
1427
- }
1428
-
1429
- return normalizedValue.toLowerCase();
1430
- };
1431
-
1432
1527
  if (theme.baseColor !== undefined) {
1433
1528
  if (typeof theme.baseColor === "string") {
1434
1529
  theme.baseColor = normalizeBaseColor(
@@ -1504,7 +1599,7 @@ function validateTheme(theme: DocsConfig["theme"]): void {
1504
1599
  }
1505
1600
 
1506
1601
  if (typeof theme.themeColor === "string") {
1507
- theme.themeColor = normalizeThemeColor(
1602
+ theme.themeColor = normalizeHexColor(
1508
1603
  theme.themeColor,
1509
1604
  ["theme", "themeColor"],
1510
1605
  "Theme color",
@@ -1538,7 +1633,7 @@ function validateTheme(theme: DocsConfig["theme"]): void {
1538
1633
 
1539
1634
  const light =
1540
1635
  themeColorByMode.light !== undefined
1541
- ? normalizeThemeColor(
1636
+ ? normalizeHexColor(
1542
1637
  themeColorByMode.light,
1543
1638
  ["theme", "themeColor", "light"],
1544
1639
  "Theme color light",
@@ -1546,7 +1641,7 @@ function validateTheme(theme: DocsConfig["theme"]): void {
1546
1641
  : undefined;
1547
1642
  const dark =
1548
1643
  themeColorByMode.dark !== undefined
1549
- ? normalizeThemeColor(
1644
+ ? normalizeHexColor(
1550
1645
  themeColorByMode.dark,
1551
1646
  ["theme", "themeColor", "dark"],
1552
1647
  "Theme color dark",
@@ -1561,11 +1656,292 @@ function validateTheme(theme: DocsConfig["theme"]): void {
1561
1656
  }
1562
1657
 
1563
1658
  theme.themeColor = {
1564
- light: light ?? DEFAULT_THEME_COLOR_LIGHT,
1565
- dark: dark ?? DEFAULT_THEME_COLOR_DARK,
1659
+ ...(light !== undefined ? { light } : {}),
1660
+ ...(dark !== undefined ? { dark } : {}),
1566
1661
  };
1567
1662
  }
1568
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
+ }
1943
+ }
1944
+
1569
1945
  function validateHome(home: DocsConfig["home"]): string | undefined {
1570
1946
  if (home === undefined) return undefined;
1571
1947
 
@@ -1765,6 +2141,7 @@ async function validateConfig(config: any): Promise<DocsConfig> {
1765
2141
  validateTitle(config.title);
1766
2142
  validateLogo(config.logo);
1767
2143
  validateTheme(config.theme);
2144
+ validateAssistant(config.assistant);
1768
2145
  await validateNavigation(config.navigation);
1769
2146
  config.home = validateHome(config.home);
1770
2147
 
@@ -0,0 +1,15 @@
1
+ import { renderAssistantEmbedScript } from "../../../lib/assistant-embed-script";
2
+ import { getConfig } from "../../../lib/validation";
3
+
4
+ export const prerender = true;
5
+
6
+ export async function GET(): Promise<Response> {
7
+ const config = await getConfig();
8
+
9
+ return new Response(renderAssistantEmbedScript(config), {
10
+ headers: {
11
+ "Content-Type": "application/javascript; charset=utf-8",
12
+ "Cache-Control": "no-cache, max-age=0, must-revalidate",
13
+ },
14
+ });
15
+ }
@@ -0,0 +1,5 @@
1
+ ---
2
+ import AssistantEmbedPanelPage from "../../../components/chat/AssistantEmbedPanelPage.astro";
3
+ ---
4
+
5
+ <AssistantEmbedPanelPage />
@@ -9,10 +9,10 @@ import Layout from "../layouts/Layout.astro";
9
9
  pageDescription="The page you requested could not be found."
10
10
  >
11
11
  <section
12
- class="mx-auto mt-8 h-full my-auto flex max-w-xl flex-col items-center gap-5 rounded-2xl bg-background px-6 py-10 text-center"
12
+ class="mx-auto mt-20 h-full my-auto flex max-w-xl flex-col items-center gap-5 rounded-2xl text-center"
13
13
  >
14
14
  <p
15
- class="font-mono text-sm font-medium tracking-[0.22em] text-neutral-500 uppercase dark:text-neutral-400 border px-1 py-px pr-0.5 rounded"
15
+ class="rounded border border-neutral-200 bg-neutral-50 px-1 py-px pr-0.5 font-mono text-sm font-medium tracking-[0.22em] text-neutral-500 uppercase dark:border-neutral-800 dark:bg-neutral-900/60 dark:text-neutral-400"
16
16
  >
17
17
  404
18
18
  </p>
@@ -27,7 +27,7 @@ import Layout from "../layouts/Layout.astro";
27
27
  <div class="flex flex-wrap items-center justify-center gap-3">
28
28
  <button
29
29
  type="button"
30
- class="inline-flex items-center justify-center gap-1.5 rounded-lg [corner-shape:superellipse(1.2)] border shadow-xs px-4 py-2 text-sm font-medium text-neutral-700/85 hover:text-neutral-700 cursor-pointer"
30
+ class="inline-flex items-center justify-center gap-1.5 rounded-lg [corner-shape:superellipse(1.2)] border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-700/85 shadow-xs transition hover:bg-neutral-50 hover:text-neutral-700 dark:border-neutral-800 dark:bg-neutral-900/60 dark:text-neutral-300 dark:hover:bg-neutral-800/70 dark:hover:text-neutral-100 cursor-pointer"
31
31
  onclick="history.back()"
32
32
  >
33
33
  <Icon name="lucide:arrow-big-left-dash" class="size-4" />
@@ -35,7 +35,7 @@ import Layout from "../layouts/Layout.astro";
35
35
  </button>
36
36
  <a
37
37
  href={withBasePath("/")}
38
- class="inline-flex items-center justify-center gap-2 rounded-lg [corner-shape:superellipse(1.2)] border border-border px-4 py-2 text-sm font-[350] dark:font-[450] text-white bg-linear-to-b from-neutral-900/85 to-neutral-900 dark:from-neutral-100 dark:to-neutral-200 shadow-sm"
38
+ class="inline-flex items-center justify-center gap-2 rounded-lg [corner-shape:superellipse(1.2)] border border-border bg-linear-to-b from-neutral-900/85 to-neutral-900 px-4 py-2 text-sm font-[350] text-white shadow-sm transition hover:opacity-95 dark:from-neutral-100 dark:to-neutral-200 dark:font-[450] dark:text-neutral-950"
39
39
  >
40
40
  <Icon name="lucide:house" class="size-4" />
41
41
  Take me home