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.
- package/LICENSE.MD +25 -4
- package/README.md +67 -58
- package/config/specra.config.schema.json +16 -0
- package/config/svelte-config.js +63 -0
- package/dist/api-parser.types.d.ts +59 -0
- package/dist/api-parser.types.js +5 -0
- package/dist/api.types.d.ts +137 -0
- package/dist/api.types.js +5 -0
- package/dist/category.d.ts +21 -0
- package/dist/category.js +48 -0
- package/dist/components/ConfigProvider.svelte +13 -0
- package/dist/components/ConfigProvider.svelte.d.ts +31 -0
- package/dist/components/docs/Accordion.svelte +18 -0
- package/dist/components/docs/Accordion.svelte.d.ts +10 -0
- package/dist/components/docs/AccordionItem.svelte +41 -0
- package/dist/components/docs/AccordionItem.svelte.d.ts +10 -0
- package/dist/components/docs/Badge.svelte +28 -0
- package/dist/components/docs/Badge.svelte.d.ts +9 -0
- package/dist/components/docs/Breadcrumb.svelte +80 -0
- package/dist/components/docs/Breadcrumb.svelte.d.ts +8 -0
- package/dist/components/docs/Callout.svelte +96 -0
- package/dist/components/docs/Callout.svelte.d.ts +10 -0
- package/dist/components/docs/Card.svelte +63 -0
- package/dist/components/docs/Card.svelte.d.ts +12 -0
- package/dist/components/docs/CardGrid.svelte +24 -0
- package/dist/components/docs/CardGrid.svelte.d.ts +8 -0
- package/dist/components/docs/CategoryIndex.svelte +110 -0
- package/dist/components/docs/CategoryIndex.svelte.d.ts +29 -0
- package/dist/components/docs/CodeBlock.svelte +172 -0
- package/dist/components/docs/CodeBlock.svelte.d.ts +8 -0
- package/dist/components/docs/Column.svelte +25 -0
- package/dist/components/docs/Column.svelte.d.ts +8 -0
- package/dist/components/docs/Columns.svelte +38 -0
- package/dist/components/docs/Columns.svelte.d.ts +13 -0
- package/dist/components/docs/DevModeBadge.svelte +15 -0
- package/dist/components/docs/DevModeBadge.svelte.d.ts +18 -0
- package/dist/components/docs/DocBadge.svelte +28 -0
- package/dist/components/docs/DocBadge.svelte.d.ts +9 -0
- package/dist/components/docs/DocLayout.svelte +107 -0
- package/dist/components/docs/DocLayout.svelte.d.ts +32 -0
- package/dist/components/docs/DocLoading.svelte +53 -0
- package/dist/components/docs/DocLoading.svelte.d.ts +18 -0
- package/dist/components/docs/DocMetadata.svelte +106 -0
- package/dist/components/docs/DocMetadata.svelte.d.ts +18 -0
- package/dist/components/docs/DocNavigation.svelte +56 -0
- package/dist/components/docs/DocNavigation.svelte.d.ts +12 -0
- package/dist/components/docs/DocTags.svelte +22 -0
- package/dist/components/docs/DocTags.svelte.d.ts +6 -0
- package/dist/components/docs/DraftBadge.svelte +10 -0
- package/dist/components/docs/DraftBadge.svelte.d.ts +18 -0
- package/dist/components/docs/Footer.svelte +72 -0
- package/dist/components/docs/Footer.svelte.d.ts +7 -0
- package/dist/components/docs/Frame.svelte +27 -0
- package/dist/components/docs/Frame.svelte.d.ts +9 -0
- package/dist/components/docs/Header.svelte +123 -0
- package/dist/components/docs/Header.svelte.d.ts +9 -0
- package/dist/components/docs/HeaderWithMenu.svelte +34 -0
- package/dist/components/docs/HeaderWithMenu.svelte.d.ts +17 -0
- package/dist/components/docs/HotReloadIndicator.svelte +44 -0
- package/dist/components/docs/HotReloadIndicator.svelte.d.ts +3 -0
- package/dist/components/docs/Icon.svelte +103 -0
- package/dist/components/docs/Icon.svelte.d.ts +11 -0
- package/dist/components/docs/Image.svelte +88 -0
- package/dist/components/docs/Image.svelte.d.ts +11 -0
- package/dist/components/docs/ImageCard.svelte +91 -0
- package/dist/components/docs/ImageCard.svelte.d.ts +12 -0
- package/dist/components/docs/ImageCardGrid.svelte +25 -0
- package/dist/components/docs/ImageCardGrid.svelte.d.ts +8 -0
- package/dist/components/docs/LayoutProviders.svelte +57 -0
- package/dist/components/docs/LayoutProviders.svelte.d.ts +9 -0
- package/dist/components/docs/Logo.svelte +25 -0
- package/dist/components/docs/Logo.svelte.d.ts +11 -0
- package/dist/components/docs/Math.svelte +54 -0
- package/dist/components/docs/Math.svelte.d.ts +7 -0
- package/dist/components/docs/MdxContent.svelte +41 -0
- package/dist/components/docs/MdxHotReload.svelte +78 -0
- package/dist/components/docs/MdxHotReload.svelte.d.ts +9 -0
- package/dist/components/docs/MdxLayout.svelte +16 -0
- package/dist/components/docs/MdxLayout.svelte.d.ts +6 -0
- package/dist/components/docs/Mermaid.svelte +88 -0
- package/dist/components/docs/Mermaid.svelte.d.ts +7 -0
- package/dist/components/docs/MobileDocLayout.svelte +211 -0
- package/dist/components/docs/MobileDocLayout.svelte.d.ts +35 -0
- package/dist/components/docs/MobileSidebar.svelte +122 -0
- package/dist/components/docs/MobileSidebar.svelte.d.ts +31 -0
- package/dist/components/docs/MobileSidebarWrapper.svelte +122 -0
- package/dist/components/docs/MobileSidebarWrapper.svelte.d.ts +32 -0
- package/dist/components/docs/NotFoundContent.svelte +40 -0
- package/dist/components/docs/NotFoundContent.svelte.d.ts +6 -0
- package/dist/components/docs/SearchHighlight.svelte +116 -0
- package/dist/components/docs/SearchHighlight.svelte.d.ts +3 -0
- package/dist/components/docs/SearchModal.svelte +239 -0
- package/dist/components/docs/SearchModal.svelte.d.ts +9 -0
- package/dist/components/docs/Sidebar.svelte +69 -0
- package/dist/components/docs/Sidebar.svelte.d.ts +31 -0
- package/dist/components/docs/SidebarMenuItems.svelte +344 -0
- package/dist/components/docs/SidebarMenuItems.svelte.d.ts +33 -0
- package/dist/components/docs/SidebarSkeleton.svelte +50 -0
- package/dist/components/docs/SidebarSkeleton.svelte.d.ts +18 -0
- package/dist/components/docs/SiteBanner.svelte +92 -0
- package/dist/components/docs/SiteBanner.svelte.d.ts +7 -0
- package/dist/components/docs/Step.svelte +44 -0
- package/dist/components/docs/Step.svelte.d.ts +8 -0
- package/dist/components/docs/Steps.svelte +15 -0
- package/dist/components/docs/Steps.svelte.d.ts +7 -0
- package/dist/components/docs/Tab.svelte +40 -0
- package/dist/components/docs/Tab.svelte.d.ts +8 -0
- package/dist/components/docs/TabGroups.svelte +183 -0
- package/dist/components/docs/TabGroups.svelte.d.ts +25 -0
- package/dist/components/docs/TableOfContents.svelte +100 -0
- package/dist/components/docs/TableOfContents.svelte.d.ts +9 -0
- package/dist/components/docs/Tabs.svelte +69 -0
- package/dist/components/docs/Tabs.svelte.d.ts +8 -0
- package/dist/components/docs/ThemeToggle.svelte +16 -0
- package/dist/components/docs/ThemeToggle.svelte.d.ts +18 -0
- package/dist/components/docs/Tooltip.svelte +44 -0
- package/dist/components/docs/Tooltip.svelte.d.ts +10 -0
- package/dist/components/docs/VersionSwitcher.svelte +95 -0
- package/dist/components/docs/VersionSwitcher.svelte.d.ts +7 -0
- package/dist/components/docs/Video.svelte +84 -0
- package/dist/components/docs/Video.svelte.d.ts +12 -0
- package/dist/components/docs/api/ApiEndpoint.svelte +61 -0
- package/dist/components/docs/api/ApiEndpoint.svelte.d.ts +11 -0
- package/dist/components/docs/api/ApiParams.svelte +80 -0
- package/dist/components/docs/api/ApiParams.svelte.d.ts +14 -0
- package/dist/components/docs/api/ApiPlayground.svelte +259 -0
- package/dist/components/docs/api/ApiPlayground.svelte.d.ts +16 -0
- package/dist/components/docs/api/ApiReference.svelte +278 -0
- package/dist/components/docs/api/ApiReference.svelte.d.ts +23 -0
- package/dist/components/docs/api/ApiResponse.svelte +66 -0
- package/dist/components/docs/api/ApiResponse.svelte.d.ts +9 -0
- package/dist/components/docs/api/index.d.ts +5 -0
- package/dist/components/docs/api/index.js +5 -0
- package/dist/components/docs/componentTextProps.d.ts +3 -0
- package/dist/components/docs/componentTextProps.js +61 -0
- package/dist/components/docs/index.d.ts +54 -0
- package/dist/components/docs/index.js +56 -0
- package/dist/components/global/VersionNotFound.svelte +48 -0
- package/dist/components/global/VersionNotFound.svelte.d.ts +7 -0
- package/dist/components/global/index.d.ts +1 -0
- package/dist/components/global/index.js +1 -0
- package/dist/components/index.d.ts +6 -822
- package/dist/components/index.js +11 -3854
- package/dist/components/ui/Badge.svelte +48 -0
- package/dist/components/ui/Badge.svelte.d.ts +15 -0
- package/dist/components/ui/Button.svelte +58 -0
- package/dist/components/ui/Button.svelte.d.ts +17 -0
- package/dist/components/ui/Dialog.svelte +16 -0
- package/dist/components/ui/Dialog.svelte.d.ts +9 -0
- package/dist/components/ui/DialogClose.svelte +16 -0
- package/dist/components/ui/DialogClose.svelte.d.ts +9 -0
- package/dist/components/ui/DialogContent.svelte +43 -0
- package/dist/components/ui/DialogContent.svelte.d.ts +10 -0
- package/dist/components/ui/DialogDescription.svelte +21 -0
- package/dist/components/ui/DialogDescription.svelte.d.ts +9 -0
- package/dist/components/ui/DialogFooter.svelte +20 -0
- package/dist/components/ui/DialogFooter.svelte.d.ts +9 -0
- package/dist/components/ui/DialogHeader.svelte +20 -0
- package/dist/components/ui/DialogHeader.svelte.d.ts +9 -0
- package/dist/components/ui/DialogTitle.svelte +21 -0
- package/dist/components/ui/DialogTitle.svelte.d.ts +9 -0
- package/dist/components/ui/Input.svelte +23 -0
- package/dist/components/ui/Input.svelte.d.ts +8 -0
- package/dist/components/ui/Textarea.svelte +19 -0
- package/dist/components/ui/Textarea.svelte.d.ts +7 -0
- package/dist/components/ui/index.d.ts +11 -0
- package/dist/components/ui/index.js +11 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +9 -0
- package/dist/config.schema.json +471 -0
- package/dist/config.server.d.ts +46 -0
- package/dist/config.server.js +149 -0
- package/dist/{mdx-ColN3Cyg.d.mts → config.types.d.ts} +22 -75
- package/dist/config.types.js +39 -0
- package/dist/dev-utils.d.ts +29 -0
- package/dist/dev-utils.js +63 -0
- package/dist/index.d.ts +19 -4
- package/dist/index.js +25 -4861
- package/dist/mdx-cache.d.ts +41 -0
- package/dist/mdx-cache.js +160 -0
- package/dist/mdx-components.js +50 -1931
- package/dist/mdx-security.d.ts +76 -0
- package/dist/mdx-security.js +217 -0
- package/dist/mdx.d.ts +73 -0
- package/dist/mdx.js +1099 -0
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.js +2 -0
- package/dist/middleware/security.d.ts +22 -47
- package/dist/middleware/security.js +111 -137
- package/dist/parsers/base-parser.d.ts +14 -0
- package/dist/parsers/base-parser.js +1 -0
- package/dist/parsers/index.d.ts +16 -0
- package/dist/parsers/index.js +51 -0
- package/dist/parsers/openapi-parser.d.ts +18 -0
- package/dist/parsers/openapi-parser.js +209 -0
- package/dist/parsers/postman-parser.d.ts +20 -0
- package/dist/parsers/postman-parser.js +260 -0
- package/dist/parsers/specra-parser.d.ts +10 -0
- package/dist/parsers/specra-parser.js +18 -0
- package/dist/redirects.d.ts +12 -0
- package/dist/redirects.js +30 -0
- package/dist/remark-code-meta.d.ts +6 -0
- package/dist/remark-code-meta.js +21 -0
- package/dist/sidebar-utils.d.ts +59 -0
- package/dist/sidebar-utils.js +144 -0
- package/dist/stores/config.d.ts +20 -0
- package/dist/stores/config.js +45 -0
- package/dist/stores/index.d.ts +4 -0
- package/dist/stores/index.js +4 -0
- package/dist/stores/sidebar.d.ts +7 -0
- package/dist/stores/sidebar.js +12 -0
- package/dist/stores/tabs.d.ts +6 -0
- package/dist/stores/tabs.js +41 -0
- package/dist/stores/theme.d.ts +7 -0
- package/dist/stores/theme.js +75 -0
- package/dist/{styles.css → styles/globals.css} +136 -6
- package/dist/toc.d.ts +9 -0
- package/dist/toc.js +15 -0
- package/dist/utils.d.ts +13 -0
- package/dist/utils.js +30 -0
- package/package.json +47 -90
- package/dist/app/api/mdx-watch/route.d.mts +0 -10
- package/dist/app/api/mdx-watch/route.d.ts +0 -10
- package/dist/app/api/mdx-watch/route.js +0 -118
- package/dist/app/api/mdx-watch/route.js.map +0 -1
- package/dist/app/api/mdx-watch/route.mjs +0 -91
- package/dist/app/api/mdx-watch/route.mjs.map +0 -1
- package/dist/chunk-6S3EJVEO.mjs +0 -259
- package/dist/chunk-6S3EJVEO.mjs.map +0 -1
- package/dist/chunk-BE7EROIW.mjs +0 -212
- package/dist/chunk-BE7EROIW.mjs.map +0 -1
- package/dist/chunk-CWHRZHZO.mjs +0 -168
- package/dist/chunk-CWHRZHZO.mjs.map +0 -1
- package/dist/chunk-D5VDVYFY.mjs +0 -1325
- package/dist/chunk-D5VDVYFY.mjs.map +0 -1
- package/dist/chunk-WMCO2UX5.mjs +0 -585
- package/dist/chunk-WMCO2UX5.mjs.map +0 -1
- package/dist/chunk-XEMGCPZZ.mjs +0 -475
- package/dist/chunk-XEMGCPZZ.mjs.map +0 -1
- package/dist/components/index.d.mts +0 -822
- package/dist/components/index.js.map +0 -1
- package/dist/components/index.mjs +0 -3741
- package/dist/components/index.mjs.map +0 -1
- package/dist/index.d.mts +0 -4
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -1897
- package/dist/index.mjs.map +0 -1
- package/dist/layouts/index.d.mts +0 -34
- package/dist/layouts/index.d.ts +0 -34
- package/dist/layouts/index.js +0 -453
- package/dist/layouts/index.js.map +0 -1
- package/dist/layouts/index.mjs +0 -173
- package/dist/layouts/index.mjs.map +0 -1
- package/dist/lib/index.d.mts +0 -583
- package/dist/lib/index.d.ts +0 -583
- package/dist/lib/index.js +0 -1595
- package/dist/lib/index.js.map +0 -1
- package/dist/lib/index.mjs +0 -111
- package/dist/lib/index.mjs.map +0 -1
- package/dist/mdx-ColN3Cyg.d.ts +0 -352
- package/dist/mdx-components.d.mts +0 -86
- package/dist/mdx-components.d.ts +0 -86
- package/dist/mdx-components.js.map +0 -1
- package/dist/mdx-components.mjs +0 -206
- package/dist/mdx-components.mjs.map +0 -1
- package/dist/middleware/security.d.mts +0 -82
- package/dist/middleware/security.js.map +0 -1
- package/dist/middleware/security.mjs +0 -84
- package/dist/middleware/security.mjs.map +0 -1
- package/dist/styles.css.map +0 -1
- package/dist/styles.d.mts +0 -2
- package/dist/styles.d.ts +0 -2
- package/dist/styles.js +0 -2
- package/dist/styles.js.map +0 -1
- package/dist/styles.mjs +0 -1
- 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 back to \n.
|
|
248
|
+
const escaped = expression.replace(/"/g, '"').replace(/\n/g, ' ');
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
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(/"/g, '"').replace(/ /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
|
+
}
|