specra 0.1.13 → 0.2.0

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 (276) hide show
  1. package/LICENSE.MD +25 -4
  2. package/README.md +67 -58
  3. package/config/specra.config.schema.json +16 -0
  4. package/config/svelte-config.js +63 -0
  5. package/dist/api-parser.types.d.ts +59 -0
  6. package/dist/api-parser.types.js +5 -0
  7. package/dist/api.types.d.ts +137 -0
  8. package/dist/api.types.js +5 -0
  9. package/dist/category.d.ts +21 -0
  10. package/dist/category.js +48 -0
  11. package/dist/components/ConfigProvider.svelte +13 -0
  12. package/dist/components/ConfigProvider.svelte.d.ts +31 -0
  13. package/dist/components/docs/Accordion.svelte +18 -0
  14. package/dist/components/docs/Accordion.svelte.d.ts +10 -0
  15. package/dist/components/docs/AccordionItem.svelte +41 -0
  16. package/dist/components/docs/AccordionItem.svelte.d.ts +10 -0
  17. package/dist/components/docs/Badge.svelte +28 -0
  18. package/dist/components/docs/Badge.svelte.d.ts +9 -0
  19. package/dist/components/docs/Breadcrumb.svelte +80 -0
  20. package/dist/components/docs/Breadcrumb.svelte.d.ts +8 -0
  21. package/dist/components/docs/Callout.svelte +96 -0
  22. package/dist/components/docs/Callout.svelte.d.ts +10 -0
  23. package/dist/components/docs/Card.svelte +63 -0
  24. package/dist/components/docs/Card.svelte.d.ts +12 -0
  25. package/dist/components/docs/CardGrid.svelte +24 -0
  26. package/dist/components/docs/CardGrid.svelte.d.ts +8 -0
  27. package/dist/components/docs/CategoryIndex.svelte +110 -0
  28. package/dist/components/docs/CategoryIndex.svelte.d.ts +29 -0
  29. package/dist/components/docs/CodeBlock.svelte +172 -0
  30. package/dist/components/docs/CodeBlock.svelte.d.ts +8 -0
  31. package/dist/components/docs/Column.svelte +25 -0
  32. package/dist/components/docs/Column.svelte.d.ts +8 -0
  33. package/dist/components/docs/Columns.svelte +38 -0
  34. package/dist/components/docs/Columns.svelte.d.ts +13 -0
  35. package/dist/components/docs/DevModeBadge.svelte +15 -0
  36. package/dist/components/docs/DevModeBadge.svelte.d.ts +18 -0
  37. package/dist/components/docs/DocBadge.svelte +28 -0
  38. package/dist/components/docs/DocBadge.svelte.d.ts +9 -0
  39. package/dist/components/docs/DocLayout.svelte +107 -0
  40. package/dist/components/docs/DocLayout.svelte.d.ts +32 -0
  41. package/dist/components/docs/DocLoading.svelte +53 -0
  42. package/dist/components/docs/DocLoading.svelte.d.ts +18 -0
  43. package/dist/components/docs/DocMetadata.svelte +106 -0
  44. package/dist/components/docs/DocMetadata.svelte.d.ts +18 -0
  45. package/dist/components/docs/DocNavigation.svelte +56 -0
  46. package/dist/components/docs/DocNavigation.svelte.d.ts +12 -0
  47. package/dist/components/docs/DocTags.svelte +22 -0
  48. package/dist/components/docs/DocTags.svelte.d.ts +6 -0
  49. package/dist/components/docs/DraftBadge.svelte +10 -0
  50. package/dist/components/docs/DraftBadge.svelte.d.ts +18 -0
  51. package/dist/components/docs/Footer.svelte +72 -0
  52. package/dist/components/docs/Footer.svelte.d.ts +7 -0
  53. package/dist/components/docs/Frame.svelte +27 -0
  54. package/dist/components/docs/Frame.svelte.d.ts +9 -0
  55. package/dist/components/docs/Header.svelte +123 -0
  56. package/dist/components/docs/Header.svelte.d.ts +9 -0
  57. package/dist/components/docs/HeaderWithMenu.svelte +34 -0
  58. package/dist/components/docs/HeaderWithMenu.svelte.d.ts +17 -0
  59. package/dist/components/docs/HotReloadIndicator.svelte +44 -0
  60. package/dist/components/docs/HotReloadIndicator.svelte.d.ts +3 -0
  61. package/dist/components/docs/Icon.svelte +103 -0
  62. package/dist/components/docs/Icon.svelte.d.ts +11 -0
  63. package/dist/components/docs/Image.svelte +88 -0
  64. package/dist/components/docs/Image.svelte.d.ts +11 -0
  65. package/dist/components/docs/ImageCard.svelte +91 -0
  66. package/dist/components/docs/ImageCard.svelte.d.ts +12 -0
  67. package/dist/components/docs/ImageCardGrid.svelte +25 -0
  68. package/dist/components/docs/ImageCardGrid.svelte.d.ts +8 -0
  69. package/dist/components/docs/LayoutProviders.svelte +57 -0
  70. package/dist/components/docs/LayoutProviders.svelte.d.ts +9 -0
  71. package/dist/components/docs/Logo.svelte +25 -0
  72. package/dist/components/docs/Logo.svelte.d.ts +11 -0
  73. package/dist/components/docs/Math.svelte +54 -0
  74. package/dist/components/docs/Math.svelte.d.ts +7 -0
  75. package/dist/components/docs/MdxContent.svelte +41 -0
  76. package/dist/components/docs/MdxHotReload.svelte +78 -0
  77. package/dist/components/docs/MdxHotReload.svelte.d.ts +9 -0
  78. package/dist/components/docs/MdxLayout.svelte +16 -0
  79. package/dist/components/docs/MdxLayout.svelte.d.ts +6 -0
  80. package/dist/components/docs/Mermaid.svelte +88 -0
  81. package/dist/components/docs/Mermaid.svelte.d.ts +7 -0
  82. package/dist/components/docs/MobileDocLayout.svelte +211 -0
  83. package/dist/components/docs/MobileDocLayout.svelte.d.ts +35 -0
  84. package/dist/components/docs/MobileSidebar.svelte +122 -0
  85. package/dist/components/docs/MobileSidebar.svelte.d.ts +31 -0
  86. package/dist/components/docs/MobileSidebarWrapper.svelte +122 -0
  87. package/dist/components/docs/MobileSidebarWrapper.svelte.d.ts +32 -0
  88. package/dist/components/docs/NotFoundContent.svelte +40 -0
  89. package/dist/components/docs/NotFoundContent.svelte.d.ts +6 -0
  90. package/dist/components/docs/SearchHighlight.svelte +116 -0
  91. package/dist/components/docs/SearchHighlight.svelte.d.ts +3 -0
  92. package/dist/components/docs/SearchModal.svelte +239 -0
  93. package/dist/components/docs/SearchModal.svelte.d.ts +9 -0
  94. package/dist/components/docs/Sidebar.svelte +69 -0
  95. package/dist/components/docs/Sidebar.svelte.d.ts +31 -0
  96. package/dist/components/docs/SidebarMenuItems.svelte +344 -0
  97. package/dist/components/docs/SidebarMenuItems.svelte.d.ts +33 -0
  98. package/dist/components/docs/SidebarSkeleton.svelte +50 -0
  99. package/dist/components/docs/SidebarSkeleton.svelte.d.ts +18 -0
  100. package/dist/components/docs/SiteBanner.svelte +92 -0
  101. package/dist/components/docs/SiteBanner.svelte.d.ts +7 -0
  102. package/dist/components/docs/Step.svelte +44 -0
  103. package/dist/components/docs/Step.svelte.d.ts +8 -0
  104. package/dist/components/docs/Steps.svelte +15 -0
  105. package/dist/components/docs/Steps.svelte.d.ts +7 -0
  106. package/dist/components/docs/Tab.svelte +40 -0
  107. package/dist/components/docs/Tab.svelte.d.ts +8 -0
  108. package/dist/components/docs/TabGroups.svelte +183 -0
  109. package/dist/components/docs/TabGroups.svelte.d.ts +25 -0
  110. package/dist/components/docs/TableOfContents.svelte +100 -0
  111. package/dist/components/docs/TableOfContents.svelte.d.ts +9 -0
  112. package/dist/components/docs/Tabs.svelte +69 -0
  113. package/dist/components/docs/Tabs.svelte.d.ts +8 -0
  114. package/dist/components/docs/ThemeToggle.svelte +16 -0
  115. package/dist/components/docs/ThemeToggle.svelte.d.ts +18 -0
  116. package/dist/components/docs/Tooltip.svelte +44 -0
  117. package/dist/components/docs/Tooltip.svelte.d.ts +10 -0
  118. package/dist/components/docs/VersionSwitcher.svelte +95 -0
  119. package/dist/components/docs/VersionSwitcher.svelte.d.ts +7 -0
  120. package/dist/components/docs/Video.svelte +84 -0
  121. package/dist/components/docs/Video.svelte.d.ts +12 -0
  122. package/dist/components/docs/api/ApiEndpoint.svelte +61 -0
  123. package/dist/components/docs/api/ApiEndpoint.svelte.d.ts +11 -0
  124. package/dist/components/docs/api/ApiParams.svelte +80 -0
  125. package/dist/components/docs/api/ApiParams.svelte.d.ts +14 -0
  126. package/dist/components/docs/api/ApiPlayground.svelte +259 -0
  127. package/dist/components/docs/api/ApiPlayground.svelte.d.ts +16 -0
  128. package/dist/components/docs/api/ApiReference.svelte +278 -0
  129. package/dist/components/docs/api/ApiReference.svelte.d.ts +23 -0
  130. package/dist/components/docs/api/ApiResponse.svelte +66 -0
  131. package/dist/components/docs/api/ApiResponse.svelte.d.ts +9 -0
  132. package/dist/components/docs/api/index.d.ts +5 -0
  133. package/dist/components/docs/api/index.js +5 -0
  134. package/dist/components/docs/componentTextProps.d.ts +3 -0
  135. package/dist/components/docs/componentTextProps.js +61 -0
  136. package/dist/components/docs/index.d.ts +54 -0
  137. package/dist/components/docs/index.js +56 -0
  138. package/dist/components/global/VersionNotFound.svelte +48 -0
  139. package/dist/components/global/VersionNotFound.svelte.d.ts +7 -0
  140. package/dist/components/global/index.d.ts +1 -0
  141. package/dist/components/global/index.js +1 -0
  142. package/dist/components/index.d.ts +6 -822
  143. package/dist/components/index.js +11 -3854
  144. package/dist/components/ui/Badge.svelte +48 -0
  145. package/dist/components/ui/Badge.svelte.d.ts +15 -0
  146. package/dist/components/ui/Button.svelte +58 -0
  147. package/dist/components/ui/Button.svelte.d.ts +17 -0
  148. package/dist/components/ui/Dialog.svelte +16 -0
  149. package/dist/components/ui/Dialog.svelte.d.ts +9 -0
  150. package/dist/components/ui/DialogClose.svelte +16 -0
  151. package/dist/components/ui/DialogClose.svelte.d.ts +9 -0
  152. package/dist/components/ui/DialogContent.svelte +43 -0
  153. package/dist/components/ui/DialogContent.svelte.d.ts +10 -0
  154. package/dist/components/ui/DialogDescription.svelte +21 -0
  155. package/dist/components/ui/DialogDescription.svelte.d.ts +9 -0
  156. package/dist/components/ui/DialogFooter.svelte +20 -0
  157. package/dist/components/ui/DialogFooter.svelte.d.ts +9 -0
  158. package/dist/components/ui/DialogHeader.svelte +20 -0
  159. package/dist/components/ui/DialogHeader.svelte.d.ts +9 -0
  160. package/dist/components/ui/DialogTitle.svelte +21 -0
  161. package/dist/components/ui/DialogTitle.svelte.d.ts +9 -0
  162. package/dist/components/ui/Input.svelte +23 -0
  163. package/dist/components/ui/Input.svelte.d.ts +8 -0
  164. package/dist/components/ui/Textarea.svelte +19 -0
  165. package/dist/components/ui/Textarea.svelte.d.ts +7 -0
  166. package/dist/components/ui/index.d.ts +11 -0
  167. package/dist/components/ui/index.js +11 -0
  168. package/dist/config.d.ts +8 -0
  169. package/dist/config.js +9 -0
  170. package/dist/config.schema.json +471 -0
  171. package/dist/config.server.d.ts +46 -0
  172. package/dist/config.server.js +149 -0
  173. package/dist/{mdx-ColN3Cyg.d.mts → config.types.d.ts} +22 -75
  174. package/dist/config.types.js +39 -0
  175. package/dist/dev-utils.d.ts +29 -0
  176. package/dist/dev-utils.js +63 -0
  177. package/dist/index.d.ts +19 -4
  178. package/dist/index.js +25 -4861
  179. package/dist/mdx-cache.d.ts +41 -0
  180. package/dist/mdx-cache.js +160 -0
  181. package/dist/mdx-components.js +50 -1931
  182. package/dist/mdx-security.d.ts +76 -0
  183. package/dist/mdx-security.js +217 -0
  184. package/dist/mdx.d.ts +73 -0
  185. package/dist/mdx.js +1099 -0
  186. package/dist/middleware/index.d.ts +1 -0
  187. package/dist/middleware/index.js +2 -0
  188. package/dist/middleware/security.d.ts +22 -47
  189. package/dist/middleware/security.js +111 -137
  190. package/dist/parsers/base-parser.d.ts +14 -0
  191. package/dist/parsers/base-parser.js +1 -0
  192. package/dist/parsers/index.d.ts +16 -0
  193. package/dist/parsers/index.js +51 -0
  194. package/dist/parsers/openapi-parser.d.ts +18 -0
  195. package/dist/parsers/openapi-parser.js +209 -0
  196. package/dist/parsers/postman-parser.d.ts +20 -0
  197. package/dist/parsers/postman-parser.js +260 -0
  198. package/dist/parsers/specra-parser.d.ts +10 -0
  199. package/dist/parsers/specra-parser.js +18 -0
  200. package/dist/redirects.d.ts +12 -0
  201. package/dist/redirects.js +30 -0
  202. package/dist/remark-code-meta.d.ts +6 -0
  203. package/dist/remark-code-meta.js +21 -0
  204. package/dist/sidebar-utils.d.ts +59 -0
  205. package/dist/sidebar-utils.js +144 -0
  206. package/dist/stores/config.d.ts +20 -0
  207. package/dist/stores/config.js +45 -0
  208. package/dist/stores/index.d.ts +4 -0
  209. package/dist/stores/index.js +4 -0
  210. package/dist/stores/sidebar.d.ts +7 -0
  211. package/dist/stores/sidebar.js +12 -0
  212. package/dist/stores/tabs.d.ts +6 -0
  213. package/dist/stores/tabs.js +41 -0
  214. package/dist/stores/theme.d.ts +7 -0
  215. package/dist/stores/theme.js +75 -0
  216. package/dist/{styles.css → styles/globals.css} +136 -6
  217. package/dist/toc.d.ts +9 -0
  218. package/dist/toc.js +15 -0
  219. package/dist/utils.d.ts +13 -0
  220. package/dist/utils.js +30 -0
  221. package/package.json +47 -90
  222. package/dist/app/api/mdx-watch/route.d.mts +0 -10
  223. package/dist/app/api/mdx-watch/route.d.ts +0 -10
  224. package/dist/app/api/mdx-watch/route.js +0 -118
  225. package/dist/app/api/mdx-watch/route.js.map +0 -1
  226. package/dist/app/api/mdx-watch/route.mjs +0 -91
  227. package/dist/app/api/mdx-watch/route.mjs.map +0 -1
  228. package/dist/chunk-6S3EJVEO.mjs +0 -259
  229. package/dist/chunk-6S3EJVEO.mjs.map +0 -1
  230. package/dist/chunk-BE7EROIW.mjs +0 -212
  231. package/dist/chunk-BE7EROIW.mjs.map +0 -1
  232. package/dist/chunk-CWHRZHZO.mjs +0 -168
  233. package/dist/chunk-CWHRZHZO.mjs.map +0 -1
  234. package/dist/chunk-D5VDVYFY.mjs +0 -1325
  235. package/dist/chunk-D5VDVYFY.mjs.map +0 -1
  236. package/dist/chunk-WMCO2UX5.mjs +0 -585
  237. package/dist/chunk-WMCO2UX5.mjs.map +0 -1
  238. package/dist/chunk-XEMGCPZZ.mjs +0 -475
  239. package/dist/chunk-XEMGCPZZ.mjs.map +0 -1
  240. package/dist/components/index.d.mts +0 -822
  241. package/dist/components/index.js.map +0 -1
  242. package/dist/components/index.mjs +0 -3741
  243. package/dist/components/index.mjs.map +0 -1
  244. package/dist/index.d.mts +0 -4
  245. package/dist/index.js.map +0 -1
  246. package/dist/index.mjs +0 -1897
  247. package/dist/index.mjs.map +0 -1
  248. package/dist/layouts/index.d.mts +0 -34
  249. package/dist/layouts/index.d.ts +0 -34
  250. package/dist/layouts/index.js +0 -453
  251. package/dist/layouts/index.js.map +0 -1
  252. package/dist/layouts/index.mjs +0 -173
  253. package/dist/layouts/index.mjs.map +0 -1
  254. package/dist/lib/index.d.mts +0 -583
  255. package/dist/lib/index.d.ts +0 -583
  256. package/dist/lib/index.js +0 -1595
  257. package/dist/lib/index.js.map +0 -1
  258. package/dist/lib/index.mjs +0 -111
  259. package/dist/lib/index.mjs.map +0 -1
  260. package/dist/mdx-ColN3Cyg.d.ts +0 -352
  261. package/dist/mdx-components.d.mts +0 -86
  262. package/dist/mdx-components.d.ts +0 -86
  263. package/dist/mdx-components.js.map +0 -1
  264. package/dist/mdx-components.mjs +0 -206
  265. package/dist/mdx-components.mjs.map +0 -1
  266. package/dist/middleware/security.d.mts +0 -82
  267. package/dist/middleware/security.js.map +0 -1
  268. package/dist/middleware/security.mjs +0 -84
  269. package/dist/middleware/security.mjs.map +0 -1
  270. package/dist/styles.css.map +0 -1
  271. package/dist/styles.d.mts +0 -2
  272. package/dist/styles.d.ts +0 -2
  273. package/dist/styles.js +0 -2
  274. package/dist/styles.js.map +0 -1
  275. package/dist/styles.mjs +0 -1
  276. package/dist/styles.mjs.map +0 -1
package/dist/mdx.js ADDED
@@ -0,0 +1,1099 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import matter from "gray-matter";
4
+ import { unified } from "unified";
5
+ import remarkParse from "remark-parse";
6
+ import remarkGfm from "remark-gfm";
7
+ import remarkMath from "remark-math";
8
+ import remarkRehype from "remark-rehype";
9
+ import rehypeSlug from "rehype-slug";
10
+ import rehypeRaw from "rehype-raw";
11
+ import rehypeKatex from "rehype-katex";
12
+ import rehypeStringify from "rehype-stringify";
13
+ import { toHtml } from "hast-util-to-html";
14
+ import { getAllCategoryConfigs } from "./category";
15
+ import { sortSidebarItems, sortSidebarGroups, buildSidebarStructure } from "./sidebar-utils";
16
+ import { sanitizePath, validatePathWithinDirectory, validateMDXSecurity } from "./mdx-security";
17
+ import { getConfig } from "./config";
18
+ const DOCS_DIR = path.join(process.cwd(), "docs");
19
+ /**
20
+ * Map of lowercased HTML tag names to PascalCase component names.
21
+ * When rehype processes MDX, it lowercases all custom tags.
22
+ * This map restores the correct component name for rendering.
23
+ */
24
+ const COMPONENT_TAG_MAP = {
25
+ accordion: 'Accordion',
26
+ accordionitem: 'AccordionItem',
27
+ tabs: 'Tabs',
28
+ tab: 'Tab',
29
+ callout: 'Callout',
30
+ card: 'Card',
31
+ cardgrid: 'CardGrid',
32
+ imagecard: 'ImageCard',
33
+ imagecardgrid: 'ImageCardGrid',
34
+ steps: 'Steps',
35
+ step: 'Step',
36
+ icon: 'Icon',
37
+ mermaid: 'Mermaid',
38
+ math: 'Math',
39
+ columns: 'Columns',
40
+ column: 'Column',
41
+ docbadge: 'DocBadge',
42
+ badge: 'Badge',
43
+ tooltip: 'Tooltip',
44
+ frame: 'Frame',
45
+ codeblock: 'CodeBlock',
46
+ image: 'Image',
47
+ 'specra-image': 'Image',
48
+ 'specra-math': 'Math',
49
+ video: 'Video',
50
+ apiendpoint: 'ApiEndpoint',
51
+ apiparams: 'ApiParams',
52
+ apiresponse: 'ApiResponse',
53
+ apiplayground: 'ApiPlayground',
54
+ apireference: 'ApiReference',
55
+ };
56
+ /**
57
+ * Map of lowercased attribute names to their correct camelCase form.
58
+ * HTML lowercases all attribute names, so we need to restore them.
59
+ */
60
+ const PROP_NAME_MAP = {
61
+ defaultopen: 'defaultOpen',
62
+ defaultvalue: 'defaultValue',
63
+ classname: 'className',
64
+ tabgroup: 'tabGroup',
65
+ defaultchecked: 'defaultChecked',
66
+ defaultselected: 'defaultSelected',
67
+ apikey: 'apiKey',
68
+ baseurl: 'baseURL',
69
+ };
70
+ /**
71
+ * Pre-process markdown to convert JSX expression attributes into
72
+ * HTML-safe string attributes before the remark/rehype pipeline.
73
+ *
74
+ * JSX expressions like `cols={{ sm: 1, md: 2 }}` and `span={2}` are
75
+ * not valid HTML and get mangled by the HTML parser. This converts them
76
+ * to quoted string attributes that survive parsing, using a special
77
+ * `__jsx:` prefix so `convertProps` can parse them back.
78
+ *
79
+ * Examples:
80
+ * cols={{ sm: 1, md: 2 }} → cols="__jsx:{ sm: 1, md: 2 }"
81
+ * span={2} → span="__jsx:2"
82
+ * variant="success" → (unchanged, already a string)
83
+ */
84
+ function preprocessJsxExpressions(markdown) {
85
+ // Split markdown into fenced code blocks and non-code segments.
86
+ // Only process JSX expressions in non-code segments to avoid corrupting code examples.
87
+ // Matches ```` ``` ```` or ```` ```` ```` fenced blocks (3+ backticks or tildes).
88
+ const fencedCodeRegex = /(^|\n)((`{3,}|~{3,}).*\n[\s\S]*?\n\3\s*(?:\n|$))/g;
89
+ const segments = [];
90
+ let lastIndex = 0;
91
+ let match;
92
+ while ((match = fencedCodeRegex.exec(markdown)) !== null) {
93
+ const codeStart = match.index + (match[1]?.length || 0);
94
+ // Add the non-code segment before this code block
95
+ if (codeStart > lastIndex) {
96
+ segments.push({ text: markdown.slice(lastIndex, codeStart), isCode: false });
97
+ }
98
+ // Add the code block as-is
99
+ segments.push({ text: match[2], isCode: true });
100
+ lastIndex = match.index + match[0].length;
101
+ }
102
+ // Add remaining non-code segment
103
+ if (lastIndex < markdown.length) {
104
+ segments.push({ text: markdown.slice(lastIndex), isCode: false });
105
+ }
106
+ // Build a pattern that matches known component tag names (case-insensitive for safety)
107
+ const allNames = [...new Set([
108
+ ...Object.values(COMPONENT_TAG_MAP),
109
+ ...Object.keys(COMPONENT_TAG_MAP),
110
+ ])].join('|');
111
+ // Regex to find tag starts — the actual tag end is found by the scanner below,
112
+ // because a simple [^>] regex breaks on `>` inside JSX expressions (e.g. Mermaid's `-->`).
113
+ const tagStartRegex = new RegExp(`<((?:${allNames}))(?=\\s|/?>)`, 'gi');
114
+ // Map of HTML5 element names that need renaming to avoid parser collisions.
115
+ // <image> → <img> (HTML5 spec), <math> → MathML namespace switch.
116
+ const HTML5_RENAMES = {
117
+ image: 'specra-image',
118
+ math: 'specra-math',
119
+ };
120
+ // Only process non-code segments
121
+ return segments.map(({ text, isCode }) => {
122
+ if (isCode)
123
+ return text;
124
+ let processed = '';
125
+ let lastEnd = 0;
126
+ // Reset regex state for each segment
127
+ tagStartRegex.lastIndex = 0;
128
+ let startMatch;
129
+ while ((startMatch = tagStartRegex.exec(text)) !== null) {
130
+ const tagStart = startMatch.index;
131
+ const tagName = startMatch[1];
132
+ // Scan forward from after the tag name to find the tag end,
133
+ // properly handling quotes, template literals, and brace expressions.
134
+ let pos = tagStart + startMatch[0].length;
135
+ let inDoubleQuote = false;
136
+ let inSingleQuote = false;
137
+ let inTemplateLiteral = false;
138
+ let braceDepth = 0;
139
+ let tagEnd = -1;
140
+ let isSelfClosing = false;
141
+ while (pos < text.length) {
142
+ const ch = text[pos];
143
+ if (inDoubleQuote) {
144
+ if (ch === '\\')
145
+ pos++; // skip escaped char
146
+ else if (ch === '"')
147
+ inDoubleQuote = false;
148
+ }
149
+ else if (inSingleQuote) {
150
+ if (ch === '\\')
151
+ pos++; // skip escaped char
152
+ else if (ch === "'")
153
+ inSingleQuote = false;
154
+ }
155
+ else if (inTemplateLiteral) {
156
+ if (ch === '\\')
157
+ pos++; // skip escaped char
158
+ else if (ch === '`')
159
+ inTemplateLiteral = false;
160
+ }
161
+ else if (braceDepth > 0) {
162
+ if (ch === '{')
163
+ braceDepth++;
164
+ else if (ch === '}')
165
+ braceDepth--;
166
+ else if (ch === '"')
167
+ inDoubleQuote = true;
168
+ else if (ch === "'")
169
+ inSingleQuote = true;
170
+ else if (ch === '`')
171
+ inTemplateLiteral = true;
172
+ }
173
+ else {
174
+ // Top level of tag attributes
175
+ if (ch === '"')
176
+ inDoubleQuote = true;
177
+ else if (ch === "'")
178
+ inSingleQuote = true;
179
+ else if (ch === '{')
180
+ braceDepth++;
181
+ else if (ch === '/' && text[pos + 1] === '>') {
182
+ isSelfClosing = true;
183
+ tagEnd = pos + 2;
184
+ break;
185
+ }
186
+ else if (ch === '>') {
187
+ tagEnd = pos + 1;
188
+ break;
189
+ }
190
+ }
191
+ pos++;
192
+ }
193
+ if (tagEnd === -1)
194
+ continue; // Unclosed tag, skip
195
+ // Extract attributes between tag name and closing >
196
+ const attrsStart = tagStart + startMatch[0].length;
197
+ const attrsEnd = isSelfClosing ? tagEnd - 2 : tagEnd - 1;
198
+ const attrs = text.slice(attrsStart, attrsEnd);
199
+ // Process JSX expression attributes (name={...}) into HTML-safe string attributes
200
+ let result = '';
201
+ let aPos = 0;
202
+ while (aPos < attrs.length) {
203
+ const attrMatch = attrs.slice(aPos).match(/^(\w+)=\{/);
204
+ if (attrMatch) {
205
+ const attrName = attrMatch[1];
206
+ const braceStart2 = aPos + attrMatch[0].length;
207
+ // Find matching closing brace, handling nesting + quotes
208
+ let depth = 1;
209
+ let inDQ = false, inSQ = false, inTL = false;
210
+ let j = braceStart2;
211
+ for (; j < attrs.length && depth > 0; j++) {
212
+ const c = attrs[j];
213
+ if (inDQ) {
214
+ if (c === '\\')
215
+ j++;
216
+ else if (c === '"')
217
+ inDQ = false;
218
+ }
219
+ else if (inSQ) {
220
+ if (c === '\\')
221
+ j++;
222
+ else if (c === "'")
223
+ inSQ = false;
224
+ }
225
+ else if (inTL) {
226
+ if (c === '\\')
227
+ j++;
228
+ else if (c === '`')
229
+ inTL = false;
230
+ }
231
+ else {
232
+ if (c === '{')
233
+ depth++;
234
+ else if (c === '}')
235
+ depth--;
236
+ else if (c === '"')
237
+ inDQ = true;
238
+ else if (c === "'")
239
+ inSQ = true;
240
+ else if (c === '`')
241
+ inTL = true;
242
+ }
243
+ }
244
+ if (depth === 0) {
245
+ const expression = attrs.slice(braceStart2, j - 1);
246
+ // Encode " and newlines so the value survives HTML attribute parsing
247
+ // and the multiline-collapsing step. parse5 decodes &#10; back to \n.
248
+ const escaped = expression.replace(/"/g, '&quot;').replace(/\n/g, '&#10;');
249
+ result += `${attrName}="__jsx:${escaped}"`;
250
+ aPos = j;
251
+ }
252
+ else {
253
+ result += attrs[aPos];
254
+ aPos++;
255
+ }
256
+ }
257
+ else {
258
+ result += attrs[aPos];
259
+ aPos++;
260
+ }
261
+ }
262
+ // Collapse multiline attributes to a single line so remark-parse
263
+ // recognizes the tag as inline HTML.
264
+ result = result.replace(/\s*\n\s*/g, ' ');
265
+ // Rename tags that collide with HTML5 built-in elements
266
+ const rename = HTML5_RENAMES[tagName.toLowerCase()];
267
+ const safeName = rename || tagName;
268
+ const safeOpen = `<${safeName}`;
269
+ // Add text before this tag
270
+ processed += text.slice(lastEnd, tagStart);
271
+ // Emit the processed tag
272
+ if (isSelfClosing) {
273
+ // Convert self-closing to explicit open+close (HTML5 doesn't honor />
274
+ // on non-void elements — it swallows subsequent siblings as children).
275
+ processed += `${safeOpen}${result}></${safeName}>`;
276
+ }
277
+ else {
278
+ processed += `${safeOpen}${result}>`;
279
+ }
280
+ lastEnd = tagEnd;
281
+ // Advance regex past this tag to avoid re-matching inside attrs
282
+ tagStartRegex.lastIndex = tagEnd;
283
+ }
284
+ // Add remaining text
285
+ processed += text.slice(lastEnd);
286
+ // Rename closing tags to match the opening tag renames
287
+ processed = processed.replace(/<\/Image\s*>/gi, '</specra-image>');
288
+ processed = processed.replace(/<\/Math\s*>/gi, '</specra-math>');
289
+ // Convert JSX string children to a `children` prop attribute.
290
+ // In JSX, <Math>{"E = mc^2"}</Math> passes the string as children.
291
+ // In Svelte, slot content is not a string prop, so we convert it to an attribute.
292
+ const jsxChildrenRegex = new RegExp(`(<(?:${allNames})[^>]*>)\\s*\\{\\s*(["'])([\\s\\S]*?)\\2\\s*\\}\\s*(<\\/(?:${allNames})\\s*>)`, 'gi');
293
+ processed = processed.replace(jsxChildrenRegex, (_match, openTag, _quote, content, closeTag) => {
294
+ // Unescape JavaScript string escape sequences (e.g. \\ → \, \n → newline)
295
+ const unescaped = content.replace(/\\(.)/g, (_, ch) => {
296
+ switch (ch) {
297
+ case 'n': return '\n';
298
+ case 't': return '\t';
299
+ case 'r': return '\r';
300
+ case '\\': return '\\';
301
+ case '"': return '"';
302
+ case "'": return "'";
303
+ default: return ch;
304
+ }
305
+ });
306
+ // Escape for HTML attribute value
307
+ const escaped = unescaped.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
308
+ // Inject children prop into the opening tag
309
+ const newOpenTag = openTag.slice(0, -1) + ` children="${escaped}">`;
310
+ return `${newOpenTag}${closeTag}`;
311
+ });
312
+ return processed;
313
+ }).join('');
314
+ }
315
+ /**
316
+ * Parse a JSX expression string into a JavaScript value.
317
+ * Handles objects like `{ sm: 1, md: 2 }`, numbers, booleans, and strings.
318
+ */
319
+ function parseJsxExpression(expr) {
320
+ const trimmed = expr.trim();
321
+ // Number
322
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
323
+ return Number(trimmed);
324
+ }
325
+ // Boolean
326
+ if (trimmed === 'true')
327
+ return true;
328
+ if (trimmed === 'false')
329
+ return false;
330
+ // null/undefined
331
+ if (trimmed === 'null')
332
+ return null;
333
+ if (trimmed === 'undefined')
334
+ return undefined;
335
+ // String literal (quoted or template literal)
336
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
337
+ (trimmed.startsWith("'") && trimmed.endsWith("'")) ||
338
+ (trimmed.startsWith('`') && trimmed.endsWith('`'))) {
339
+ return trimmed.slice(1, -1);
340
+ }
341
+ // Object literal like { sm: 1, md: 2 }
342
+ // Convert JS object syntax to JSON: add quotes around keys
343
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
344
+ try {
345
+ // Try direct JSON parse first
346
+ return JSON.parse(trimmed);
347
+ }
348
+ catch {
349
+ // Convert JS object notation to JSON: { sm: 1, md: 2 } → {"sm": 1, "md": 2}
350
+ const jsonStr = trimmed.replace(/(\w+)\s*:/g, '"$1":').replace(/:\s*'([^']*)'/g, ': "$1"');
351
+ try {
352
+ return JSON.parse(jsonStr);
353
+ }
354
+ catch {
355
+ return trimmed;
356
+ }
357
+ }
358
+ }
359
+ // Array literal
360
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
361
+ try {
362
+ return JSON.parse(trimmed);
363
+ }
364
+ catch {
365
+ return trimmed;
366
+ }
367
+ }
368
+ return trimmed;
369
+ }
370
+ /**
371
+ * Process markdown content to HTML using remark/rehype pipeline.
372
+ */
373
+ async function processMarkdownToHtml(markdown) {
374
+ const result = await unified()
375
+ .use(remarkParse)
376
+ .use(remarkGfm)
377
+ .use(remarkMath)
378
+ .use(remarkRehype, { allowDangerousHtml: true })
379
+ .use(rehypeRaw)
380
+ .use(rehypeSlug)
381
+ .use(rehypeKatex)
382
+ .use(rehypeStringify)
383
+ .process(markdown);
384
+ return String(result);
385
+ }
386
+ /**
387
+ * Convert hast properties to component props with correct casing.
388
+ * Also parses back JSX expression values that were encoded during preprocessing.
389
+ */
390
+ function convertProps(properties) {
391
+ const props = {};
392
+ for (const [key, value] of Object.entries(properties)) {
393
+ const propName = PROP_NAME_MAP[key] || key;
394
+ // HTML boolean attributes come through as empty strings
395
+ if (value === '' || value === true) {
396
+ props[propName] = true;
397
+ }
398
+ else if (typeof value === 'string' && value.startsWith('__jsx:')) {
399
+ // Parse back JSX expressions that were encoded during preprocessing
400
+ const expression = value.slice(6).replace(/&quot;/g, '"').replace(/&#10;/g, '\n');
401
+ props[propName] = parseJsxExpression(expression);
402
+ }
403
+ else {
404
+ props[propName] = value;
405
+ }
406
+ }
407
+ return props;
408
+ }
409
+ /**
410
+ * Check if a hast node is a component element (custom tag).
411
+ */
412
+ function isComponentElement(node) {
413
+ return node.type === 'element' && COMPONENT_TAG_MAP[node.tagName] !== undefined;
414
+ }
415
+ /**
416
+ * Check if a hast node is a fenced code block (<pre><code class="language-*">).
417
+ * Returns the extracted props for CodeBlock if it is, or null otherwise.
418
+ */
419
+ function extractCodeBlockProps(node) {
420
+ if (node.type !== 'element' || node.tagName !== 'pre')
421
+ return null;
422
+ const codeChild = node.children?.find((c) => c.type === 'element' && c.tagName === 'code');
423
+ if (!codeChild)
424
+ return null;
425
+ // Extract language from className like ['language-javascript']
426
+ const classNames = codeChild.properties?.className || [];
427
+ const langClass = classNames.find((c) => typeof c === 'string' && c.startsWith('language-'));
428
+ if (!langClass)
429
+ return null;
430
+ const language = langClass.replace('language-', '');
431
+ // Extract text content from the code element
432
+ const code = extractTextContent(codeChild).replace(/\n$/, '');
433
+ // Check for filename in data attributes (e.g. from remark-code-meta)
434
+ const filename = node.properties?.['data-filename'] || codeChild.properties?.['data-filename'];
435
+ return { code, language, ...(filename ? { filename } : {}) };
436
+ }
437
+ /**
438
+ * Recursively extract text content from a hast node.
439
+ */
440
+ function extractTextContent(node) {
441
+ if (node.type === 'text')
442
+ return node.value || '';
443
+ if (node.children) {
444
+ return node.children.map((c) => extractTextContent(c)).join('');
445
+ }
446
+ return '';
447
+ }
448
+ /**
449
+ * Check if hast children contain raw markdown text that needs re-processing.
450
+ * This happens when markdown content is inside custom component tags —
451
+ * the HTML parser treats it as plain text instead of parsing it as markdown.
452
+ */
453
+ function childrenContainMarkdownText(children) {
454
+ for (const child of children) {
455
+ if (child.type === 'text' && child.value) {
456
+ const text = child.value.trim();
457
+ if (!text)
458
+ continue;
459
+ // Check for markdown patterns: headings, bold, italic, links, lists
460
+ // Text nodes inside HTML-parsed component tags often start with \n + whitespace
461
+ if (/(?:^|\n)\s*#{1,6}\s/.test(child.value) || // headings
462
+ /\*\*/.test(text) || // bold
463
+ /\[.*\]\(/.test(text) || // links
464
+ /(?:^|\n)\s*[-*+]\s/.test(child.value) || // unordered lists
465
+ /(?:^|\n)\s*\d+\.\s/.test(child.value) || // ordered lists
466
+ (text.length > 10 && /\n/.test(text.trim())) // multi-line text content (paragraphs)
467
+ ) {
468
+ return true;
469
+ }
470
+ }
471
+ }
472
+ return false;
473
+ }
474
+ /**
475
+ * Remove common leading whitespace from all lines in a text block.
476
+ * This is necessary because markdown content inside component tags
477
+ * inherits indentation from the MDX formatting, and 4+ spaces of
478
+ * indentation would cause remark to treat lines as code blocks.
479
+ */
480
+ function dedent(text) {
481
+ const lines = text.split('\n');
482
+ // Find the minimum indentation of non-empty lines
483
+ let minIndent = Infinity;
484
+ for (const line of lines) {
485
+ if (line.trim().length === 0)
486
+ continue;
487
+ const indent = line.match(/^(\s*)/)?.[1].length ?? 0;
488
+ if (indent < minIndent)
489
+ minIndent = indent;
490
+ }
491
+ if (minIndent === 0 || minIndent === Infinity)
492
+ return text;
493
+ // Strip the common indent from all lines
494
+ return lines.map(line => line.slice(minIndent)).join('\n');
495
+ }
496
+ /**
497
+ * Extract raw text from hast children, preserving component tags as placeholders.
498
+ * Returns the markdown text and a map of placeholders to component MdxNodes.
499
+ */
500
+ async function processComponentChildren(children) {
501
+ // Separate text runs from component/element children.
502
+ // For sequences of text-only nodes, re-process through markdown.
503
+ // For component elements, process recursively as before.
504
+ const result = [];
505
+ let textBuffer = '';
506
+ async function flushTextBuffer() {
507
+ if (textBuffer.trim()) {
508
+ // Dedent the text to remove inherited indentation from MDX formatting,
509
+ // otherwise 4+ space indented lines get parsed as code blocks
510
+ const dedented = dedent(textBuffer);
511
+ // Re-process accumulated text through the markdown pipeline
512
+ const processor = unified()
513
+ .use(remarkParse)
514
+ .use(remarkGfm)
515
+ .use(remarkMath)
516
+ .use(remarkRehype, { allowDangerousHtml: true })
517
+ .use(rehypeRaw)
518
+ .use(rehypeSlug)
519
+ .use(rehypeKatex);
520
+ const mdast = processor.parse(dedented);
521
+ const hast = await processor.run(mdast);
522
+ const processedChildren = hast.children || [];
523
+ // These children are now proper HTML elements (headings, paragraphs, etc.)
524
+ const nodes = await hastChildrenToMdxNodes(processedChildren);
525
+ result.push(...nodes);
526
+ }
527
+ textBuffer = '';
528
+ }
529
+ for (const child of children) {
530
+ if (child.type === 'text') {
531
+ textBuffer += child.value || '';
532
+ }
533
+ else if (isComponentElement(child)) {
534
+ await flushTextBuffer();
535
+ const componentName = COMPONENT_TAG_MAP[child.tagName];
536
+ const props = convertProps(child.properties || {});
537
+ const childNodes = child.children && child.children.length > 0
538
+ ? await processSmartChildren(child.children)
539
+ : [];
540
+ result.push({
541
+ type: 'component',
542
+ name: componentName,
543
+ props,
544
+ children: childNodes,
545
+ });
546
+ }
547
+ else if (child.type === 'element') {
548
+ // Regular HTML element inside a component — flush text first, then serialize
549
+ await flushTextBuffer();
550
+ const codeBlockProps = extractCodeBlockProps(child);
551
+ if (codeBlockProps) {
552
+ result.push({
553
+ type: 'component',
554
+ name: 'CodeBlock',
555
+ props: codeBlockProps,
556
+ children: [],
557
+ });
558
+ }
559
+ else if (hasNestedComponent(child)) {
560
+ const openTag = toHtml({ ...child, children: [] }).replace(/<\/[^>]+>$/, '');
561
+ result.push({ type: 'html', content: openTag });
562
+ result.push(...await processSmartChildren(child.children));
563
+ result.push({ type: 'html', content: `</${child.tagName}>` });
564
+ }
565
+ else {
566
+ const html = toHtml(child).trim();
567
+ if (html) {
568
+ result.push({ type: 'html', content: html });
569
+ }
570
+ }
571
+ }
572
+ }
573
+ await flushTextBuffer();
574
+ return result;
575
+ }
576
+ /**
577
+ * Smart child processing: detects if children contain raw markdown text
578
+ * and re-processes it through the markdown pipeline if needed.
579
+ */
580
+ async function processSmartChildren(children) {
581
+ if (childrenContainMarkdownText(children)) {
582
+ return processComponentChildren(children);
583
+ }
584
+ return hastChildrenToMdxNodes(children);
585
+ }
586
+ /**
587
+ * Recursively convert hast children to MdxNode array.
588
+ * Groups consecutive non-component nodes into single HTML blocks.
589
+ */
590
+ async function hastChildrenToMdxNodes(children) {
591
+ const nodes = [];
592
+ let htmlBuffer = [];
593
+ function flushHtmlBuffer() {
594
+ if (htmlBuffer.length > 0) {
595
+ const html = toHtml({ type: 'root', children: htmlBuffer });
596
+ const trimmed = html.trim();
597
+ if (trimmed) {
598
+ nodes.push({ type: 'html', content: trimmed });
599
+ }
600
+ else if (html.includes(' ') && !html.includes('\n')) {
601
+ // Preserve horizontal whitespace between elements (e.g., spaces between inline components).
602
+ // Newline-only whitespace is formatting and can be discarded.
603
+ nodes.push({ type: 'html', content: ' ' });
604
+ }
605
+ htmlBuffer = [];
606
+ }
607
+ }
608
+ for (const child of children) {
609
+ // Check for fenced code blocks first (<pre><code class="language-*">)
610
+ const codeBlockProps = extractCodeBlockProps(child);
611
+ if (codeBlockProps) {
612
+ flushHtmlBuffer();
613
+ nodes.push({
614
+ type: 'component',
615
+ name: 'CodeBlock',
616
+ props: codeBlockProps,
617
+ children: [],
618
+ });
619
+ }
620
+ else if (isComponentElement(child)) {
621
+ flushHtmlBuffer();
622
+ const componentName = COMPONENT_TAG_MAP[child.tagName];
623
+ const props = convertProps(child.properties || {});
624
+ const childNodes = child.children && child.children.length > 0
625
+ ? await processSmartChildren(child.children)
626
+ : [];
627
+ nodes.push({
628
+ type: 'component',
629
+ name: componentName,
630
+ props,
631
+ children: childNodes,
632
+ });
633
+ }
634
+ else {
635
+ // Check if this regular element contains any component elements nested within
636
+ if (hasNestedComponent(child)) {
637
+ flushHtmlBuffer();
638
+ // This is a regular HTML element that contains component children
639
+ // We need to handle it specially - wrap it as an HTML open tag,
640
+ // then process children, then close tag
641
+ if (child.type === 'element') {
642
+ const openTag = toHtml({ ...child, children: [] }).replace(/<\/[^>]+>$/, '');
643
+ nodes.push({ type: 'html', content: openTag });
644
+ nodes.push(...await hastChildrenToMdxNodes(child.children));
645
+ nodes.push({ type: 'html', content: `</${child.tagName}>` });
646
+ }
647
+ else {
648
+ htmlBuffer.push(child);
649
+ }
650
+ }
651
+ else {
652
+ htmlBuffer.push(child);
653
+ }
654
+ }
655
+ }
656
+ flushHtmlBuffer();
657
+ return nodes;
658
+ }
659
+ /**
660
+ * Check if a hast node or any of its descendants is a component element.
661
+ */
662
+ function hasNestedComponent(node) {
663
+ if (isComponentElement(node))
664
+ return true;
665
+ if (node.children) {
666
+ return node.children.some((child) => hasNestedComponent(child));
667
+ }
668
+ return false;
669
+ }
670
+ /**
671
+ * Process markdown content to a structured MdxNode tree.
672
+ * Runs the same remark/rehype pipeline but produces an AST
673
+ * instead of a stringified HTML output.
674
+ */
675
+ async function processMarkdownToMdxNodes(markdown) {
676
+ // Pre-process JSX expression attributes into HTML-safe string attributes
677
+ const preprocessed = preprocessJsxExpressions(markdown);
678
+ const processor = unified()
679
+ .use(remarkParse)
680
+ .use(remarkGfm)
681
+ .use(remarkMath)
682
+ .use(remarkRehype, { allowDangerousHtml: true })
683
+ .use(rehypeRaw)
684
+ .use(rehypeSlug)
685
+ .use(rehypeKatex);
686
+ const mdast = processor.parse(preprocessed);
687
+ const hast = await processor.run(mdast);
688
+ // The hast root has children - process them into MdxNodes
689
+ const children = hast.children || [];
690
+ return hastChildrenToMdxNodes(children);
691
+ }
692
+ /**
693
+ * Calculate reading time based on word count
694
+ * Average reading speed: 200 words per minute
695
+ */
696
+ function calculateReadingTime(content) {
697
+ const words = content.trim().split(/\s+/).length;
698
+ const minutes = Math.ceil(words / 200);
699
+ return { minutes, words };
700
+ }
701
+ export function getVersions() {
702
+ try {
703
+ const versions = fs.readdirSync(DOCS_DIR);
704
+ return versions.filter((v) => fs.statSync(path.join(DOCS_DIR, v)).isDirectory());
705
+ }
706
+ catch (error) {
707
+ return ["v1.0.0"];
708
+ }
709
+ }
710
+ /**
711
+ * Recursively find all MDX files in a directory
712
+ */
713
+ function findMdxFiles(dir, baseDir = dir) {
714
+ const files = [];
715
+ try {
716
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
717
+ for (const entry of entries) {
718
+ const fullPath = path.join(dir, entry.name);
719
+ if (entry.isDirectory()) {
720
+ files.push(...findMdxFiles(fullPath, baseDir));
721
+ }
722
+ else if (entry.isFile() && entry.name.endsWith(".mdx")) {
723
+ // Get relative path from base directory and normalize to forward slashes
724
+ const relativePath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
725
+ files.push(relativePath);
726
+ }
727
+ }
728
+ }
729
+ catch (error) {
730
+ console.error(`Error reading directory ${dir}:`, error);
731
+ }
732
+ return files;
733
+ }
734
+ /**
735
+ * Internal function to read a doc from file path
736
+ */
737
+ function readDocFromFile(filePath, originalSlug) {
738
+ try {
739
+ if (!fs.existsSync(filePath)) {
740
+ return null;
741
+ }
742
+ // Validate path is within allowed directory
743
+ if (!validatePathWithinDirectory(filePath, DOCS_DIR)) {
744
+ console.error(`[Security] Path traversal attempt blocked: ${filePath}`);
745
+ return null;
746
+ }
747
+ const fileContents = fs.readFileSync(filePath, "utf8");
748
+ const { data, content } = matter(fileContents);
749
+ // Security: Validate MDX content for dangerous patterns
750
+ const securityCheck = validateMDXSecurity(content, {
751
+ strictMode: process.env.NODE_ENV === 'production',
752
+ blockDangerousPatterns: true,
753
+ });
754
+ if (!securityCheck.valid) {
755
+ console.error(`[Security] MDX validation failed for ${filePath}:`, securityCheck.issues);
756
+ if (process.env.NODE_ENV === 'production') {
757
+ return null;
758
+ }
759
+ // In development, log warnings but continue
760
+ console.warn('[Security] Continuing in development mode with sanitized content');
761
+ }
762
+ // Use sanitized content if available
763
+ const safeContent = securityCheck.sanitized || content;
764
+ // Calculate reading time
765
+ const { minutes, words } = calculateReadingTime(safeContent);
766
+ // If custom slug provided, replace only the filename part, keep the folder structure
767
+ let finalSlug = originalSlug;
768
+ if (data.slug) {
769
+ const customSlug = data.slug.replace(/^\//, '');
770
+ const parts = originalSlug.split("/");
771
+ if (parts.length > 1) {
772
+ // Keep folder structure, replace only filename
773
+ parts[parts.length - 1] = customSlug;
774
+ finalSlug = parts.join("/");
775
+ }
776
+ else {
777
+ // Root level file, use custom slug as-is
778
+ finalSlug = customSlug;
779
+ }
780
+ }
781
+ return {
782
+ slug: finalSlug,
783
+ filePath: originalSlug, // Keep original file path for sidebar
784
+ title: data.title || originalSlug,
785
+ meta: {
786
+ ...data,
787
+ content: safeContent,
788
+ reading_time: minutes,
789
+ word_count: words,
790
+ ...(data.protected === true ? { isProtected: true } : {}),
791
+ },
792
+ content: safeContent,
793
+ };
794
+ }
795
+ catch (error) {
796
+ console.error(`Error reading file ${filePath}:`, error);
797
+ return null;
798
+ }
799
+ }
800
+ export function getI18nConfig() {
801
+ const config = getConfig();
802
+ const i18n = config.features?.i18n;
803
+ if (!i18n)
804
+ return null;
805
+ if (typeof i18n === 'boolean') {
806
+ return i18n ? {
807
+ defaultLocale: 'en',
808
+ locales: ['en'],
809
+ localeNames: { en: 'English' }
810
+ } : null;
811
+ }
812
+ return i18n;
813
+ }
814
+ export async function getDocBySlug(slug, version = "v1.0.0", locale) {
815
+ try {
816
+ // Security: Sanitize and validate slug
817
+ const sanitizedVersion = sanitizePath(version);
818
+ let sanitizedSlug = sanitizePath(slug);
819
+ // Get i18n config
820
+ const i18nConfig = getI18nConfig();
821
+ // Determine locale from slug if not provided
822
+ let detectedLocale = locale || i18nConfig?.defaultLocale;
823
+ if (i18nConfig) {
824
+ const parts = sanitizedSlug.split('/');
825
+ if (parts.length > 0 && i18nConfig.locales.includes(parts[0])) {
826
+ detectedLocale = parts[0];
827
+ sanitizedSlug = parts.slice(1).join('/');
828
+ if (sanitizedSlug === "")
829
+ sanitizedSlug = "index";
830
+ }
831
+ }
832
+ const targetLocale = detectedLocale;
833
+ const isDefaultLocale = targetLocale === i18nConfig?.defaultLocale;
834
+ // Try finding the file in this order:
835
+ // 1. Localized extension: slug.locale.mdx (e.g. guide.fr.mdx)
836
+ // 2. Default file: slug.mdx (only if using default locale and configured to fallback or strictly default)
837
+ // Construct potential paths
838
+ const basePath = path.join(DOCS_DIR, sanitizedVersion);
839
+ let result = null;
840
+ // 1. Try localized file extension
841
+ if (targetLocale) {
842
+ const localizedPath = path.join(basePath, `${sanitizedSlug}.${targetLocale}.mdx`);
843
+ const doc = readDocFromFile(localizedPath, sanitizedSlug);
844
+ if (doc) {
845
+ doc.slug = i18nConfig ? `${targetLocale}/${sanitizedSlug}` : sanitizedSlug;
846
+ doc.meta.locale = targetLocale;
847
+ result = doc;
848
+ }
849
+ }
850
+ // 2. Try default file
851
+ if (!result) {
852
+ const defaultPath = path.join(basePath, `${sanitizedSlug}.mdx`);
853
+ const doc = readDocFromFile(defaultPath, sanitizedSlug);
854
+ if (doc && (isDefaultLocale || !i18nConfig)) {
855
+ const usePrefix = i18nConfig && (i18nConfig.prefixDefault || targetLocale !== i18nConfig.defaultLocale);
856
+ if (usePrefix && targetLocale) {
857
+ doc.slug = `${targetLocale}/${doc.slug}`;
858
+ }
859
+ doc.meta.locale = targetLocale || 'en';
860
+ result = doc;
861
+ }
862
+ }
863
+ // Process markdown for the found doc
864
+ if (result) {
865
+ const rawContent = result.content;
866
+ result.content = await processMarkdownToHtml(rawContent);
867
+ result.contentNodes = await processMarkdownToMdxNodes(rawContent);
868
+ }
869
+ return result;
870
+ }
871
+ catch (error) {
872
+ console.error(`Error reading doc ${slug}:`, error);
873
+ return null;
874
+ }
875
+ }
876
+ export function getAllDocs(version = "v1.0.0", locale) {
877
+ try {
878
+ const versionDir = path.join(DOCS_DIR, version);
879
+ if (!fs.existsSync(versionDir)) {
880
+ return [];
881
+ }
882
+ // Get i18n config
883
+ const i18nConfig = getI18nConfig();
884
+ const targetLocale = locale || i18nConfig?.defaultLocale || 'en';
885
+ const mdxFiles = findMdxFiles(versionDir);
886
+ const categoryConfigs = getAllCategoryConfigs(version);
887
+ const docs = mdxFiles.map((file) => {
888
+ // file contains path relative to version dir, e.g. "getting-started/intro.mdx" or "intro.fr.mdx"
889
+ let originalFilePath = file.replace(/\.mdx$/, "");
890
+ // Handle localized files
891
+ let isLocalized = false;
892
+ let fileLocale = i18nConfig?.defaultLocale || 'en';
893
+ if (i18nConfig) {
894
+ // Check for .<locale> suffix
895
+ const parts = originalFilePath.split('.');
896
+ const lastPart = parts[parts.length - 1];
897
+ if (i18nConfig.locales.includes(lastPart)) {
898
+ fileLocale = lastPart;
899
+ isLocalized = true;
900
+ originalFilePath = parts.slice(0, -1).join('.');
901
+ }
902
+ }
903
+ // Read doc directly from file (no HTML processing - sidebar only needs metadata)
904
+ const slug = isLocalized ? originalFilePath : originalFilePath;
905
+ const filePath = isLocalized
906
+ ? path.join(versionDir, `${originalFilePath}.${fileLocale}.mdx`)
907
+ : path.join(versionDir, `${originalFilePath}.mdx`);
908
+ const doc = readDocFromFile(filePath, slug);
909
+ if (!doc)
910
+ return null;
911
+ // Set locale info
912
+ if (i18nConfig) {
913
+ const usePrefix = i18nConfig.prefixDefault || fileLocale !== i18nConfig.defaultLocale;
914
+ doc.slug = usePrefix ? `${fileLocale}/${doc.slug}` : doc.slug;
915
+ }
916
+ doc.meta.locale = fileLocale;
917
+ // Override filePath properties for sidebar grouping
918
+ // (we want grouped by logical path, not physically localized path if possible)
919
+ doc.filePath = originalFilePath; // Use logical path (without .fr) for grouping
920
+ const folderPath = path.dirname(originalFilePath).replace(/\\/g, '/');
921
+ if (folderPath !== ".") {
922
+ const categoryConfig = categoryConfigs.get(folderPath);
923
+ if (categoryConfig) {
924
+ doc.categoryLabel = categoryConfig.label;
925
+ doc.categoryPosition = categoryConfig.position ?? categoryConfig.sidebar_position;
926
+ doc.categoryCollapsible = categoryConfig.collapsible;
927
+ doc.categoryCollapsed = categoryConfig.collapsed;
928
+ doc.categoryIcon = categoryConfig.icon;
929
+ doc.categoryTabGroup = categoryConfig.tab_group;
930
+ }
931
+ }
932
+ return doc;
933
+ });
934
+ const isDevelopment = process.env.NODE_ENV === "development";
935
+ // Create a map to track unique slugs and avoid duplicates, prioritizing target locale
936
+ const uniqueDocs = new Map();
937
+ // Sort docs such that target locale comes first? No, we need to filter/merge.
938
+ const validDocs = docs.filter((doc) => doc !== null && (isDevelopment || !doc.meta.draft));
939
+ // Group by logical slug (we stored logical path in filePath, maybe use that?)
940
+ // Actually doc.slug might differ if custom slug used.
941
+ // If we have intro.mdx (en) and intro.fr.mdx (fr)
942
+ // And targetLocale is 'fr'
943
+ // We want the 'fr' one.
944
+ validDocs.forEach(doc => {
945
+ // Identify logical slug.
946
+ // If doc.slug already has prefix (e.g. fr/intro), stripped slug is 'intro'.
947
+ let logicalSlug = doc.slug;
948
+ if (i18nConfig) {
949
+ const parts = logicalSlug.split('/');
950
+ if (i18nConfig.locales.includes(parts[0])) {
951
+ logicalSlug = parts.slice(1).join('/');
952
+ }
953
+ }
954
+ const existing = uniqueDocs.get(logicalSlug);
955
+ if (!existing) {
956
+ // If doc matches target locale or is default (and we allow default fallback), take it.
957
+ // For now, take everything, filter later?
958
+ // Better: Only add if it matches target locale OR is default and we don't have target yet.
959
+ if (doc.meta.locale === targetLocale) {
960
+ uniqueDocs.set(logicalSlug, doc);
961
+ }
962
+ else if (doc.meta.locale === i18nConfig?.defaultLocale) {
963
+ uniqueDocs.set(logicalSlug, doc);
964
+ }
965
+ }
966
+ else {
967
+ // We have an existing entry. prefer targetLocale
968
+ if (doc.meta.locale === targetLocale && existing.meta.locale !== targetLocale) {
969
+ uniqueDocs.set(logicalSlug, doc);
970
+ }
971
+ }
972
+ });
973
+ const sortedDocs = Array.from(uniqueDocs.values()).sort((a, b) => {
974
+ const orderA = a.meta.sidebar_position ?? a.meta.order ?? 999;
975
+ const orderB = b.meta.sidebar_position ?? b.meta.order ?? 999;
976
+ return orderA - orderB;
977
+ });
978
+ return sortedDocs;
979
+ }
980
+ catch (error) {
981
+ console.error(`Error getting all docs for version ${version}:`, error);
982
+ return [];
983
+ }
984
+ }
985
+ // export function getAdjacentDocs(currentSlug: string, allDocs: Doc[]): { previous?: Doc; next?: Doc } {
986
+ // const currentIndex = allDocs.findIndex((doc) => doc.slug === currentSlug)
987
+ // if (currentIndex === -1) {
988
+ // return {}
989
+ // }
990
+ // return {
991
+ // previous: currentIndex > 0 ? allDocs[currentIndex - 1] : undefined,
992
+ // next: currentIndex < allDocs.length - 1 ? allDocs[currentIndex + 1] : undefined,
993
+ // }
994
+ // }
995
+ // Flatten the sidebar structure into a linear order
996
+ function flattenSidebarOrder(rootGroups, standalone) {
997
+ const flatDocs = [];
998
+ // Recursively flatten groups - intermix folders and files by position
999
+ const flattenGroup = (group) => {
1000
+ const sortedChildren = sortSidebarGroups(group.children);
1001
+ const sortedItems = sortSidebarItems(group.items);
1002
+ // Merge child groups and items, then sort by position
1003
+ const merged = [
1004
+ ...sortedChildren.map(([, childGroup]) => ({
1005
+ type: 'group',
1006
+ group: childGroup,
1007
+ position: childGroup.position
1008
+ })),
1009
+ ...sortedItems.map((doc) => ({
1010
+ type: 'item',
1011
+ doc,
1012
+ position: doc.meta.sidebar_position ?? doc.meta.order ?? 999
1013
+ }))
1014
+ ];
1015
+ // Sort by position
1016
+ merged.sort((a, b) => a.position - b.position);
1017
+ // Process in sorted order
1018
+ merged.forEach((item) => {
1019
+ if (item.type === 'group') {
1020
+ flattenGroup(item.group);
1021
+ }
1022
+ else {
1023
+ flatDocs.push(item.doc);
1024
+ }
1025
+ });
1026
+ };
1027
+ // Add standalone items first
1028
+ sortSidebarItems(standalone).forEach((doc) => {
1029
+ flatDocs.push(doc);
1030
+ });
1031
+ // Then add all grouped items
1032
+ const sortedRootGroups = sortSidebarGroups(rootGroups);
1033
+ sortedRootGroups.forEach(([, group]) => {
1034
+ flattenGroup(group);
1035
+ });
1036
+ return flatDocs;
1037
+ }
1038
+ export function getAdjacentDocs(currentSlug, allDocs) {
1039
+ // Build the same sidebar structure
1040
+ const { rootGroups, standalone } = buildSidebarStructure(allDocs);
1041
+ // Flatten into the same order as shown in the sidebar
1042
+ const orderedDocs = flattenSidebarOrder(rootGroups, standalone);
1043
+ // Find current doc in the ordered list
1044
+ const currentIndex = orderedDocs.findIndex((doc) => doc.slug === currentSlug);
1045
+ if (currentIndex === -1) {
1046
+ return {};
1047
+ }
1048
+ const currentDoc = orderedDocs[currentIndex];
1049
+ // Get current doc's tab group (from meta or category)
1050
+ const currentTabGroup = currentDoc.meta?.tab_group || currentDoc.categoryTabGroup;
1051
+ // Filter docs to match the current doc's tab group status
1052
+ // If current has a tab group, only show docs in the same tab group
1053
+ // If current has NO tab group, only show docs with NO tab group
1054
+ const filteredDocs = orderedDocs.filter((doc) => {
1055
+ const docTabGroup = doc.meta?.tab_group || doc.categoryTabGroup;
1056
+ // If current doc has a tab group, only include docs with the same tab group
1057
+ if (currentTabGroup) {
1058
+ return docTabGroup === currentTabGroup;
1059
+ }
1060
+ // If current doc has no tab group, only include docs with no tab group
1061
+ return !docTabGroup;
1062
+ });
1063
+ // Find current doc's index within the filtered list
1064
+ const filteredIndex = filteredDocs.findIndex((doc) => doc.slug === currentSlug);
1065
+ if (filteredIndex === -1) {
1066
+ return {};
1067
+ }
1068
+ return {
1069
+ previous: filteredIndex > 0 ? filteredDocs[filteredIndex - 1] : undefined,
1070
+ next: filteredIndex < filteredDocs.length - 1 ? filteredDocs[filteredIndex + 1] : undefined,
1071
+ };
1072
+ }
1073
+ export function extractTableOfContents(content) {
1074
+ const headingRegex = /^(#{2,3})\s+(.+)$/gm;
1075
+ const toc = [];
1076
+ let match;
1077
+ while ((match = headingRegex.exec(content)) !== null) {
1078
+ const level = match[1].length;
1079
+ const text = match[2];
1080
+ // Generate ID the same way rehype-slug does
1081
+ const id = text
1082
+ .toLowerCase()
1083
+ .replace(/\s+/g, "-") // Replace spaces with hyphens first
1084
+ .replace(/[^a-z0-9-]/g, "") // Remove special chars (dots, slashes, etc)
1085
+ .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
1086
+ toc.push({ id, title: text, level });
1087
+ }
1088
+ return toc;
1089
+ }
1090
+ /**
1091
+ * Check if a slug represents a category (has child documents)
1092
+ */
1093
+ export function isCategoryPage(slug, allDocs) {
1094
+ return allDocs.some((doc) => {
1095
+ const parts = doc.slug.split("/");
1096
+ const docParent = parts.slice(0, -1).join("/");
1097
+ return docParent === slug && doc.slug !== slug;
1098
+ });
1099
+ }