specra 0.2.3 → 0.2.4
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/config/specra.config.schema.json +9 -14
- package/dist/components/docs/Callout.svelte +5 -4
- package/dist/components/docs/CodeBlock.svelte +2 -2
- package/dist/components/docs/DocLayout.svelte +1 -1
- package/dist/components/docs/Header.svelte +73 -3
- package/dist/components/docs/Header.svelte.d.ts +11 -0
- package/dist/components/docs/MdxLayout.svelte +1 -1
- package/dist/components/docs/MobileDocLayout.svelte +3 -15
- package/dist/components/docs/Sidebar.svelte +4 -11
- package/dist/components/docs/SidebarMenuItems.svelte +1 -3
- package/dist/components/docs/TabGroups.svelte +2 -2
- package/dist/components/docs/TableOfContents.svelte +3 -10
- package/dist/components/docs/VersionBanner.svelte +60 -0
- package/dist/components/docs/VersionBanner.svelte.d.ts +10 -0
- package/dist/components/docs/VersionSwitcher.svelte +40 -13
- package/dist/components/docs/VersionSwitcher.svelte.d.ts +7 -0
- package/dist/components/docs/index.d.ts +1 -0
- package/dist/components/docs/index.js +1 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.server.d.ts +28 -1
- package/dist/config.server.js +66 -0
- package/dist/config.types.d.ts +25 -0
- package/dist/mdx.js +325 -42
- package/dist/styles/globals.css +71 -0
- package/package.json +1 -1
|
@@ -45,31 +45,26 @@
|
|
|
45
45
|
},
|
|
46
46
|
"BannerConfig": {
|
|
47
47
|
"additionalProperties": false,
|
|
48
|
-
"description": "Site-wide banner configuration",
|
|
48
|
+
"description": "Banner configuration for version-level or site-level banners. Site-wide banner configuration",
|
|
49
49
|
"properties": {
|
|
50
|
-
"
|
|
51
|
-
"description": "
|
|
52
|
-
"type": "boolean"
|
|
53
|
-
},
|
|
54
|
-
"enabled": {
|
|
55
|
-
"description": "Whether the banner is enabled",
|
|
56
|
-
"type": "boolean"
|
|
57
|
-
},
|
|
58
|
-
"message": {
|
|
59
|
-
"description": "Banner message",
|
|
50
|
+
"text": {
|
|
51
|
+
"description": "Banner message text. Supports markdown links like [text](/url).",
|
|
60
52
|
"type": "string"
|
|
61
53
|
},
|
|
62
54
|
"type": {
|
|
63
|
-
"description": "Banner type",
|
|
55
|
+
"description": "Banner style: info, warning, error, success Banner type",
|
|
64
56
|
"enum": [
|
|
65
57
|
"info",
|
|
66
58
|
"warning",
|
|
67
|
-
"
|
|
68
|
-
"
|
|
59
|
+
"error",
|
|
60
|
+
"success"
|
|
69
61
|
],
|
|
70
62
|
"type": "string"
|
|
71
63
|
}
|
|
72
64
|
},
|
|
65
|
+
"required": [
|
|
66
|
+
"text"
|
|
67
|
+
],
|
|
73
68
|
"type": "object"
|
|
74
69
|
},
|
|
75
70
|
"DeploymentConfig": {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import {
|
|
3
3
|
Info,
|
|
4
|
+
PenLine,
|
|
4
5
|
AlertTriangle,
|
|
5
6
|
CheckCircle2,
|
|
6
7
|
XCircle,
|
|
@@ -33,10 +34,10 @@
|
|
|
33
34
|
defaultTitle: 'Info',
|
|
34
35
|
},
|
|
35
36
|
note: {
|
|
36
|
-
icon:
|
|
37
|
-
className: 'bg-
|
|
38
|
-
iconClassName: 'text-
|
|
39
|
-
titleClassName: 'text-
|
|
37
|
+
icon: PenLine,
|
|
38
|
+
className: 'bg-slate-500/10 border-slate-500/30 text-slate-900 dark:bg-slate-400/5 dark:border-slate-400/20 dark:text-slate-300',
|
|
39
|
+
iconClassName: 'text-slate-600 dark:text-slate-400',
|
|
40
|
+
titleClassName: 'text-slate-700 dark:text-slate-300',
|
|
40
41
|
defaultTitle: 'Note',
|
|
41
42
|
},
|
|
42
43
|
warning: {
|
|
@@ -114,8 +114,8 @@
|
|
|
114
114
|
</div>
|
|
115
115
|
|
|
116
116
|
<!-- Code content -->
|
|
117
|
-
<div class="bg-
|
|
118
|
-
<pre class="p-2 text-[13px] font-mono leading-relaxed text-gray-800 dark:text-gray-200"><code class="table w-full">{#each lines as line, i}{@const isDeletion = line.startsWith('-')}{@const isAddition = line.startsWith('+')}{@const isDiff = isDeletion || isAddition}{@const diffBgClass = isDeletion ? 'bg-red-500/5 dark:bg-red-500/10' : isAddition ? 'bg-green-500/5 dark:bg-green-500/10' : ''}{@const diffMarkerClass = isDeletion ? 'text-red-600 dark:text-red-400' : isAddition ? 'text-green-600 dark:text-green-400' : ''}{@const tokens = tokenizeLine(line)}<div class="table-row {diffBgClass}"><span class="table-cell pr-4 text-right select-none text-muted-foreground/40 w-8 align-top">{i + 1}</span><span class="table-cell align-top">{#if tokens.length === 0} {:else}{#each tokens as token, j}{#if j === 0 && isDiff && token.value.length > 0 && (token.value[0] === '+' || token.value[0] === '-')}<span class="{diffMarkerClass} font-bold">{token.value[0]}</span>{#if token.value.length > 1}<span class="token-{token.type}">{token.value.slice(1)}</span>{/if}{:else}<span class="token-{token.type}">{token.value}</span>{/if}{/each}{/if}</span></div>{/each}</code></pre>
|
|
117
|
+
<div class="bg-muted/30 dark:bg-muted/20 rounded-b-xl overflow-x-auto border border-border/50">
|
|
118
|
+
<pre class="p-2 text-[13px] font-mono leading-relaxed text-gray-800 dark:text-gray-200"><code class="table w-full">{#each lines as line, i}{@const isDeletion = language === 'diff' && line.startsWith('-') && !line.startsWith('--')}{@const isAddition = language === 'diff' && line.startsWith('+') && !line.startsWith('++')}{@const isDiff = isDeletion || isAddition}{@const diffBgClass = isDeletion ? 'bg-red-500/5 dark:bg-red-500/10' : isAddition ? 'bg-green-500/5 dark:bg-green-500/10' : ''}{@const diffMarkerClass = isDeletion ? 'text-red-600 dark:text-red-400' : isAddition ? 'text-green-600 dark:text-green-400' : ''}{@const tokens = tokenizeLine(line)}<div class="table-row {diffBgClass}"><span class="table-cell pr-4 text-right select-none text-muted-foreground/40 w-8 align-top">{i + 1}</span><span class="table-cell align-top">{#if tokens.length === 0} {:else}{#each tokens as token, j}{#if j === 0 && isDiff && token.value.length > 0 && (token.value[0] === '+' || token.value[0] === '-')}<span class="{diffMarkerClass} font-bold">{token.value[0]}</span>{#if token.value.length > 1}<span class="token-{token.type}">{token.value.slice(1)}</span>{/if}{:else}<span class="token-{token.type}">{token.value}</span>{/if}{/each}{/if}</span></div>{/each}</code></pre>
|
|
119
119
|
</div>
|
|
120
120
|
</div>
|
|
121
121
|
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
|
|
67
67
|
<DocMetadata {meta} {config} />
|
|
68
68
|
|
|
69
|
-
<div class="prose prose-slate dark:prose-invert max-w-none prose-headings:scroll-mt-24 prose-headings:font-semibold prose-h1:text-4xl prose-h2:text-3xl prose-h2:mt-12 prose-h2:mb-4 prose-h3:text-2xl prose-h3:mt-8 prose-h3:mb-3 prose-p:text-base prose-p:leading-7 prose-p:text-muted-foreground prose-p:mb-4 prose-a:font-normal prose-a:transition-all prose-code:text-primary prose-code:bg-muted/50 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:text-[13px] prose-code:font-mono prose-code:border prose-code:border-border/50 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-transparent prose-pre:p-0 prose-ul:list-disc prose-ul:
|
|
69
|
+
<div class="prose prose-slate dark:prose-invert max-w-none prose-headings:scroll-mt-24 prose-headings:font-semibold prose-h1:text-4xl prose-h2:text-3xl prose-h2:mt-12 prose-h2:mb-4 prose-h3:text-2xl prose-h3:mt-8 prose-h3:mb-3 prose-p:text-base prose-p:leading-7 prose-p:text-muted-foreground prose-p:mb-4 prose-a:font-normal prose-a:transition-all prose-code:text-primary prose-code:bg-muted/50 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:text-[13px] prose-code:font-mono prose-code:border prose-code:border-border/50 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-transparent prose-pre:p-0 prose-ul:list-disc prose-ul:space-y-2 prose-ul:mb-4 prose-ol:list-decimal prose-ol:space-y-2 prose-ol:mb-4 prose-li:leading-7 prose-li:text-muted-foreground [&_ul_ul]:ps-4 [&_ul_ul]:mt-2 [&_ul_ul]:mb-0 [&_ol_ol]:ps-4 [&_ol_ol]:mt-2 [&_ol_ol]:mb-0 [&_ol_ul]:ps-4 [&_ol_ul]:mt-2 [&_ol_ul]:mb-0 [&_ul_ol]:ps-4 [&_ul_ol]:mt-2 [&_ul_ol]:mb-0 prose-strong:text-foreground prose-strong:font-semibold">
|
|
70
70
|
{@render children()}
|
|
71
71
|
</div>
|
|
72
72
|
|
|
@@ -3,23 +3,63 @@
|
|
|
3
3
|
import { getConfigContext } from '../../stores/config.js';
|
|
4
4
|
import { sidebarStore } from '../../stores/sidebar.js';
|
|
5
5
|
import VersionSwitcher from './VersionSwitcher.svelte';
|
|
6
|
+
import VersionBanner from './VersionBanner.svelte';
|
|
6
7
|
import ThemeToggle from './ThemeToggle.svelte';
|
|
7
8
|
import SearchModal from './SearchModal.svelte';
|
|
8
9
|
import Logo from './Logo.svelte';
|
|
10
|
+
import Icon from './Icon.svelte';
|
|
9
11
|
import type { SpecraConfig } from '../../config.types.js';
|
|
12
|
+
import type { BannerConfig } from '../../config.types.js';
|
|
13
|
+
import type { Snippet } from 'svelte';
|
|
14
|
+
import { browser } from '$app/environment';
|
|
15
|
+
|
|
16
|
+
interface VersionMeta {
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
badge?: string;
|
|
20
|
+
hidden?: boolean;
|
|
21
|
+
}
|
|
10
22
|
|
|
11
23
|
interface Props {
|
|
12
24
|
currentVersion: string;
|
|
13
25
|
versions: string[];
|
|
26
|
+
versionsMeta?: VersionMeta[];
|
|
27
|
+
versionBanner?: BannerConfig;
|
|
14
28
|
config?: SpecraConfig;
|
|
29
|
+
subheader?: Snippet;
|
|
15
30
|
}
|
|
16
31
|
|
|
17
|
-
let { currentVersion, versions, config: configProp }: Props = $props();
|
|
32
|
+
let { currentVersion, versions, versionsMeta, versionBanner, config: configProp, subheader }: Props = $props();
|
|
18
33
|
|
|
19
34
|
const configStore = getConfigContext();
|
|
20
35
|
let config = $derived(configProp || $configStore);
|
|
21
36
|
let searchOpen = $state(false);
|
|
22
37
|
let isFlush = $derived(config?.navigation?.sidebarStyle === 'flush');
|
|
38
|
+
let headerEl = $state<HTMLElement | null>(null);
|
|
39
|
+
|
|
40
|
+
// Set a CSS variable on :root with the header's actual height so sidebars
|
|
41
|
+
// can use it for their sticky top offset. Uses ResizeObserver to stay
|
|
42
|
+
// in sync when banner is dismissed or tabs change.
|
|
43
|
+
$effect(() => {
|
|
44
|
+
if (!browser || !headerEl) return;
|
|
45
|
+
|
|
46
|
+
function updateHeight() {
|
|
47
|
+
if (headerEl) {
|
|
48
|
+
const height = headerEl.offsetHeight;
|
|
49
|
+
document.documentElement.style.setProperty('--header-height', `${height}px`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Set immediately to avoid flash of wrong position
|
|
54
|
+
updateHeight();
|
|
55
|
+
// Also set on next frame in case layout hasn't settled
|
|
56
|
+
requestAnimationFrame(updateHeight);
|
|
57
|
+
|
|
58
|
+
const observer = new ResizeObserver(updateHeight);
|
|
59
|
+
observer.observe(headerEl);
|
|
60
|
+
|
|
61
|
+
return () => observer.disconnect();
|
|
62
|
+
});
|
|
23
63
|
|
|
24
64
|
$effect(() => {
|
|
25
65
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
@@ -33,7 +73,7 @@
|
|
|
33
73
|
});
|
|
34
74
|
</script>
|
|
35
75
|
|
|
36
|
-
<header class="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
|
|
76
|
+
<header bind:this={headerEl} class="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
|
|
37
77
|
<div class="{isFlush ? '' : 'container mx-auto'} flex h-16 items-center justify-between px-4 md:px-6">
|
|
38
78
|
<div class="flex items-center gap-1">
|
|
39
79
|
<button
|
|
@@ -76,7 +116,7 @@
|
|
|
76
116
|
{/if}
|
|
77
117
|
|
|
78
118
|
{#if config.features?.versioning}
|
|
79
|
-
<VersionSwitcher {currentVersion} {versions} />
|
|
119
|
+
<VersionSwitcher {currentVersion} {versions} {versionsMeta} />
|
|
80
120
|
{/if}
|
|
81
121
|
|
|
82
122
|
<!-- Social Links -->
|
|
@@ -113,6 +153,28 @@
|
|
|
113
153
|
<MessageCircle class="h-4 w-4" />
|
|
114
154
|
</a>
|
|
115
155
|
{/if}
|
|
156
|
+
{#if config.social?.custom}
|
|
157
|
+
{#each config.social.custom as link}
|
|
158
|
+
<span class="relative hidden md:inline-flex group/tip">
|
|
159
|
+
<a
|
|
160
|
+
href={link.url}
|
|
161
|
+
target="_blank"
|
|
162
|
+
rel="noopener noreferrer"
|
|
163
|
+
class="flex items-center justify-center h-9 w-9 rounded-md hover:bg-muted transition-colors"
|
|
164
|
+
aria-label={link.label}
|
|
165
|
+
>
|
|
166
|
+
{#if link.icon}
|
|
167
|
+
<Icon icon={link.icon} size={16} />
|
|
168
|
+
{:else}
|
|
169
|
+
<span class="text-xs font-medium">{link.label.slice(0, 2)}</span>
|
|
170
|
+
{/if}
|
|
171
|
+
</a>
|
|
172
|
+
<span class="absolute top-full left-1/2 -translate-x-1/2 mt-1 px-2 py-1 text-xs text-popover-foreground bg-popover border border-border rounded-md shadow-sm whitespace-nowrap pointer-events-none opacity-0 group-hover/tip:opacity-100 transition-opacity z-50">
|
|
173
|
+
{link.label}
|
|
174
|
+
</span>
|
|
175
|
+
</span>
|
|
176
|
+
{/each}
|
|
177
|
+
{/if}
|
|
116
178
|
|
|
117
179
|
<ThemeToggle />
|
|
118
180
|
</div>
|
|
@@ -120,4 +182,12 @@
|
|
|
120
182
|
|
|
121
183
|
<!-- Search Modal -->
|
|
122
184
|
<SearchModal isOpen={searchOpen} onClose={() => (searchOpen = false)} {config} />
|
|
185
|
+
|
|
186
|
+
{#if versionBanner}
|
|
187
|
+
<VersionBanner banner={versionBanner} />
|
|
188
|
+
{/if}
|
|
189
|
+
|
|
190
|
+
{#if subheader}
|
|
191
|
+
{@render subheader()}
|
|
192
|
+
{/if}
|
|
123
193
|
</header>
|
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import type { SpecraConfig } from '../../config.types.js';
|
|
2
|
+
import type { BannerConfig } from '../../config.types.js';
|
|
3
|
+
import type { Snippet } from 'svelte';
|
|
4
|
+
interface VersionMeta {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
badge?: string;
|
|
8
|
+
hidden?: boolean;
|
|
9
|
+
}
|
|
2
10
|
interface Props {
|
|
3
11
|
currentVersion: string;
|
|
4
12
|
versions: string[];
|
|
13
|
+
versionsMeta?: VersionMeta[];
|
|
14
|
+
versionBanner?: BannerConfig;
|
|
5
15
|
config?: SpecraConfig;
|
|
16
|
+
subheader?: Snippet;
|
|
6
17
|
}
|
|
7
18
|
declare const Header: import("svelte").Component<Props, {}, "">;
|
|
8
19
|
type Header = ReturnType<typeof Header>;
|
|
@@ -11,6 +11,6 @@
|
|
|
11
11
|
let { children }: { children?: import('svelte').Snippet } = $props()
|
|
12
12
|
</script>
|
|
13
13
|
|
|
14
|
-
<div class="prose prose-slate dark:prose-invert max-w-none prose-headings:scroll-mt-24 prose-headings:font-semibold prose-h1:text-4xl prose-h2:text-3xl prose-h2:mt-12 prose-h2:mb-4 prose-h3:text-2xl prose-h3:mt-8 prose-h3:mb-3 prose-p:text-base prose-p:leading-7 prose-p:text-muted-foreground prose-p:mb-4 prose-a:font-normal prose-a:transition-all prose-code:text-primary prose-code:bg-muted/50 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:text-[13px] prose-code:font-mono prose-code:border prose-code:border-border/50 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-transparent prose-pre:p-0 prose-ul:list-disc prose-ul:
|
|
14
|
+
<div class="prose prose-slate dark:prose-invert max-w-none prose-headings:scroll-mt-24 prose-headings:font-semibold prose-h1:text-4xl prose-h2:text-3xl prose-h2:mt-12 prose-h2:mb-4 prose-h3:text-2xl prose-h3:mt-8 prose-h3:mb-3 prose-p:text-base prose-p:leading-7 prose-p:text-muted-foreground prose-p:mb-4 prose-a:font-normal prose-a:transition-all prose-code:text-primary prose-code:bg-muted/50 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:text-[13px] prose-code:font-mono prose-code:border prose-code:border-border/50 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-transparent prose-pre:p-0 prose-ul:list-disc prose-ul:space-y-2 prose-ul:mb-4 prose-ol:list-decimal prose-ol:space-y-2 prose-ol:mb-4 prose-li:leading-7 prose-li:text-muted-foreground [&_ul_ul]:ps-4 [&_ul_ul]:mt-2 [&_ul_ul]:mb-0 [&_ol_ol]:ps-4 [&_ol_ol]:mt-2 [&_ol_ol]:mb-0 [&_ol_ul]:ps-4 [&_ol_ul]:mt-2 [&_ol_ul]:mb-0 [&_ul_ol]:ps-4 [&_ul_ol]:mt-2 [&_ul_ol]:mb-0 prose-strong:text-foreground prose-strong:font-semibold">
|
|
15
15
|
{@render children?.()}
|
|
16
16
|
</div>
|
|
@@ -56,24 +56,12 @@
|
|
|
56
56
|
</script>
|
|
57
57
|
|
|
58
58
|
<div class="min-h-screen bg-background">
|
|
59
|
-
<!-- Header -->
|
|
59
|
+
<!-- Header (includes banner and tab groups via subheader snippet) -->
|
|
60
60
|
{@render header()}
|
|
61
61
|
|
|
62
62
|
<!-- Site-wide Banner -->
|
|
63
63
|
<SiteBanner {config} />
|
|
64
64
|
|
|
65
|
-
<!-- Tab Groups - shown only if configured -->
|
|
66
|
-
{#if config.navigation?.tabGroups && config.navigation.tabGroups.length > 0}
|
|
67
|
-
<TabGroups
|
|
68
|
-
tabGroups={config.navigation.tabGroups}
|
|
69
|
-
activeTabId={activeTabGroup}
|
|
70
|
-
onTabChange={handleTabChange}
|
|
71
|
-
flush={isFlush}
|
|
72
|
-
{docs}
|
|
73
|
-
{version}
|
|
74
|
-
/>
|
|
75
|
-
{/if}
|
|
76
|
-
|
|
77
65
|
<!-- Mobile Sidebar Overlay -->
|
|
78
66
|
{#if sidebarOpen}
|
|
79
67
|
<div
|
|
@@ -179,7 +167,7 @@
|
|
|
179
167
|
</main>
|
|
180
168
|
</div>
|
|
181
169
|
{:else}
|
|
182
|
-
<main class="container mx-auto px-2 md:px-6
|
|
170
|
+
<main class="container mx-auto px-2 md:px-6">
|
|
183
171
|
<div class="flex">
|
|
184
172
|
<!-- Desktop Sidebar - inside container -->
|
|
185
173
|
<div class="hidden lg:block">
|
|
@@ -191,7 +179,7 @@
|
|
|
191
179
|
/>
|
|
192
180
|
</div>
|
|
193
181
|
|
|
194
|
-
<div class="flex-1 min-w-0">
|
|
182
|
+
<div class="flex-1 min-w-0 py-4">
|
|
195
183
|
<div class="flex flex-col gap-2 px-2 md:px-8">
|
|
196
184
|
<!-- Content -->
|
|
197
185
|
{@render children()}
|
|
@@ -33,24 +33,17 @@
|
|
|
33
33
|
|
|
34
34
|
let { docs, version, onLinkClick, config, activeTabGroup }: Props = $props();
|
|
35
35
|
|
|
36
|
-
let hasTabGroups = $derived(
|
|
37
|
-
config.navigation?.tabGroups && config.navigation.tabGroups.length > 0
|
|
38
|
-
);
|
|
39
36
|
let isFlush = $derived(config.navigation?.sidebarStyle === 'flush');
|
|
40
|
-
let stickyTop = $derived(hasTabGroups ? 'top-[7.5rem]' : 'top-24');
|
|
41
|
-
let maxHeight = $derived(
|
|
42
|
-
hasTabGroups ? 'max-h-[calc(100vh-10rem)]' : 'max-h-[calc(100vh-7rem)]'
|
|
43
|
-
);
|
|
44
37
|
let containerClass = $derived(
|
|
45
38
|
isFlush
|
|
46
|
-
?
|
|
47
|
-
:
|
|
39
|
+
? `overflow-y-auto p-4 border-r border-border`
|
|
40
|
+
: `overflow-y-auto bg-muted/30 dark:bg-muted/10 rounded-2xl p-4 border border-border/50`
|
|
48
41
|
);
|
|
49
42
|
</script>
|
|
50
43
|
|
|
51
44
|
{#if config.navigation?.showSidebar}
|
|
52
|
-
<aside class="w-64 shrink-0 sticky
|
|
53
|
-
<div class={containerClass}>
|
|
45
|
+
<aside class="w-64 shrink-0 sticky self-start pt-4" style="top: var(--header-height, 4rem);">
|
|
46
|
+
<div class={containerClass} style="max-height: calc(100vh - var(--header-height, 4rem) - 2rem);">
|
|
54
47
|
<div class="flex items-center justify-between mb-4 px-2">
|
|
55
48
|
<h2 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Documentation</h2>
|
|
56
49
|
{#if config.features?.showVersionBadge && version}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { page } from '$app/stores';
|
|
3
|
-
import { ChevronRight, ChevronDown,
|
|
3
|
+
import { ChevronRight, ChevronDown, Lock } from 'lucide-svelte';
|
|
4
4
|
import type { SpecraConfig } from '../../config.types.js';
|
|
5
5
|
import Icon from './Icon.svelte';
|
|
6
6
|
import { sortSidebarItems, sortSidebarGroups } from '../../sidebar-utils.js';
|
|
@@ -260,8 +260,6 @@
|
|
|
260
260
|
>
|
|
261
261
|
{#if group.icon}
|
|
262
262
|
<Icon icon={group.icon} size={16} className="shrink-0" />
|
|
263
|
-
{:else}
|
|
264
|
-
<FolderOpen size={16} class="shrink-0" />
|
|
265
263
|
{/if}
|
|
266
264
|
{group.label}
|
|
267
265
|
</a>
|
|
@@ -111,8 +111,8 @@
|
|
|
111
111
|
{/if}
|
|
112
112
|
</div>
|
|
113
113
|
{:else}
|
|
114
|
-
<!-- Full version
|
|
115
|
-
<div class="
|
|
114
|
+
<!-- Full version -->
|
|
115
|
+
<div class="border-b border-border bg-muted/40 backdrop-blur supports-[backdrop-filter]:bg-muted/30">
|
|
116
116
|
<div class="{flush ? '' : 'container mx-auto'} px-4 md:px-6">
|
|
117
117
|
<!-- Mobile Dropdown -->
|
|
118
118
|
<div class="md:hidden relative">
|
|
@@ -16,14 +16,7 @@
|
|
|
16
16
|
const maxDepth = $derived(config.navigation?.tocMaxDepth ?? 3);
|
|
17
17
|
const filteredItems = $derived(items.filter((item) => item.level <= maxDepth));
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
const hasTabGroups = $derived(
|
|
21
|
-
config.navigation?.tabGroups != null && config.navigation.tabGroups.length > 0
|
|
22
|
-
);
|
|
23
|
-
|
|
24
|
-
// Adjust top position based on whether tabs are present
|
|
25
|
-
const stickyTop = $derived(hasTabGroups ? 'top-[7.5rem]' : 'top-24');
|
|
26
|
-
const maxHeight = $derived(hasTabGroups ? 'max-h-[calc(100vh-10rem)]' : 'max-h-[calc(100vh-7rem)]');
|
|
19
|
+
// Removed hardcoded top/max-height — now uses --header-height CSS variable set by Header
|
|
27
20
|
|
|
28
21
|
$effect(() => {
|
|
29
22
|
if (!browser || !showToc || filteredItems.length === 0) return;
|
|
@@ -79,8 +72,8 @@
|
|
|
79
72
|
</script>
|
|
80
73
|
|
|
81
74
|
{#if showToc && filteredItems.length > 0}
|
|
82
|
-
<aside class="w-64 hidden xl:block shrink-0 sticky
|
|
83
|
-
<div class="
|
|
75
|
+
<aside class="w-64 hidden xl:block shrink-0 sticky self-start pt-4" style="top: var(--header-height, 4rem);">
|
|
76
|
+
<div class="overflow-y-auto bg-muted/30 dark:bg-muted/10 rounded-2xl p-4 border border-border/50" style="max-height: calc(100vh - var(--header-height, 4rem) - 2rem);">
|
|
84
77
|
<h3 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-4 px-2">On this page</h3>
|
|
85
78
|
<nav class="space-y-1">
|
|
86
79
|
{#each filteredItems as item, index}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Info, AlertTriangle, XCircle, CheckCircle2, X } from 'lucide-svelte';
|
|
3
|
+
|
|
4
|
+
interface BannerConfig {
|
|
5
|
+
text: string;
|
|
6
|
+
type?: 'info' | 'warning' | 'error' | 'success';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
banner: BannerConfig;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let { banner }: Props = $props();
|
|
14
|
+
|
|
15
|
+
let dismissed = $state(false);
|
|
16
|
+
|
|
17
|
+
const styles = {
|
|
18
|
+
info: {
|
|
19
|
+
bg: 'bg-blue-500/10 border-blue-500/30',
|
|
20
|
+
text: 'text-blue-900 dark:text-blue-300',
|
|
21
|
+
icon: Info,
|
|
22
|
+
},
|
|
23
|
+
warning: {
|
|
24
|
+
bg: 'bg-yellow-500/10 border-yellow-500/30',
|
|
25
|
+
text: 'text-yellow-900 dark:text-yellow-300',
|
|
26
|
+
icon: AlertTriangle,
|
|
27
|
+
},
|
|
28
|
+
error: {
|
|
29
|
+
bg: 'bg-red-500/10 border-red-500/30',
|
|
30
|
+
text: 'text-red-900 dark:text-red-300',
|
|
31
|
+
icon: XCircle,
|
|
32
|
+
},
|
|
33
|
+
success: {
|
|
34
|
+
bg: 'bg-green-500/10 border-green-500/30',
|
|
35
|
+
text: 'text-green-900 dark:text-green-300',
|
|
36
|
+
icon: CheckCircle2,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let style = $derived(styles[banner.type || 'info']);
|
|
41
|
+
let IconComponent = $derived(style.icon);
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
{#if !dismissed}
|
|
45
|
+
<div class="border-b {style.bg} {style.text}">
|
|
46
|
+
<div class="max-w-7xl mx-auto px-4 py-2.5 flex items-center justify-between gap-3">
|
|
47
|
+
<div class="flex items-center gap-2 text-sm">
|
|
48
|
+
<IconComponent class="h-4 w-4 shrink-0" />
|
|
49
|
+
<span>{banner.text}</span>
|
|
50
|
+
</div>
|
|
51
|
+
<button
|
|
52
|
+
onclick={() => dismissed = true}
|
|
53
|
+
class="shrink-0 p-0.5 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
|
54
|
+
aria-label="Dismiss banner"
|
|
55
|
+
>
|
|
56
|
+
<X class="h-3.5 w-3.5" />
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
{/if}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
interface BannerConfig {
|
|
2
|
+
text: string;
|
|
3
|
+
type?: 'info' | 'warning' | 'error' | 'success';
|
|
4
|
+
}
|
|
5
|
+
interface Props {
|
|
6
|
+
banner: BannerConfig;
|
|
7
|
+
}
|
|
8
|
+
declare const VersionBanner: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type VersionBanner = ReturnType<typeof VersionBanner>;
|
|
10
|
+
export default VersionBanner;
|
|
@@ -4,16 +4,38 @@
|
|
|
4
4
|
import { page } from '$app/stores';
|
|
5
5
|
import { browser } from '$app/environment';
|
|
6
6
|
|
|
7
|
+
interface VersionMeta {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
badge?: string;
|
|
11
|
+
hidden?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
interface Props {
|
|
8
15
|
currentVersion: string;
|
|
9
16
|
versions: string[];
|
|
17
|
+
versionsMeta?: VersionMeta[];
|
|
10
18
|
}
|
|
11
19
|
|
|
12
|
-
let { currentVersion, versions }: Props = $props();
|
|
20
|
+
let { currentVersion, versions, versionsMeta }: Props = $props();
|
|
13
21
|
|
|
14
22
|
let isOpen = $state(false);
|
|
15
23
|
let dropdownEl = $state<HTMLDivElement | null>(null);
|
|
16
24
|
|
|
25
|
+
// Build version list: use metadata if available, filter hidden versions
|
|
26
|
+
let visibleVersions = $derived.by(() => {
|
|
27
|
+
if (versionsMeta && versionsMeta.length > 0) {
|
|
28
|
+
return versionsMeta.filter(v => !v.hidden);
|
|
29
|
+
}
|
|
30
|
+
return versions.map(id => ({ id, label: id }));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Get current version display label
|
|
34
|
+
let currentLabel = $derived.by(() => {
|
|
35
|
+
const meta = versionsMeta?.find(v => v.id === currentVersion);
|
|
36
|
+
return meta?.label || currentVersion;
|
|
37
|
+
});
|
|
38
|
+
|
|
17
39
|
$effect(() => {
|
|
18
40
|
if (!browser || !isOpen) return;
|
|
19
41
|
|
|
@@ -38,8 +60,8 @@
|
|
|
38
60
|
};
|
|
39
61
|
});
|
|
40
62
|
|
|
41
|
-
function switchVersion(
|
|
42
|
-
if (
|
|
63
|
+
function switchVersion(versionId: string) {
|
|
64
|
+
if (versionId === currentVersion) {
|
|
43
65
|
isOpen = false;
|
|
44
66
|
return;
|
|
45
67
|
}
|
|
@@ -47,7 +69,7 @@
|
|
|
47
69
|
const currentPath = $page.url.pathname;
|
|
48
70
|
const newPath = currentPath.replace(
|
|
49
71
|
new RegExp(`/${currentVersion}(/|$)`),
|
|
50
|
-
`/${
|
|
72
|
+
`/${versionId}$1`
|
|
51
73
|
);
|
|
52
74
|
|
|
53
75
|
isOpen = false;
|
|
@@ -55,7 +77,7 @@
|
|
|
55
77
|
}
|
|
56
78
|
</script>
|
|
57
79
|
|
|
58
|
-
{#if
|
|
80
|
+
{#if visibleVersions.length > 1}
|
|
59
81
|
<div class="relative" bind:this={dropdownEl}>
|
|
60
82
|
<button
|
|
61
83
|
onclick={() => (isOpen = !isOpen)}
|
|
@@ -64,27 +86,32 @@
|
|
|
64
86
|
aria-haspopup="listbox"
|
|
65
87
|
aria-label="Switch version"
|
|
66
88
|
>
|
|
67
|
-
<span>{
|
|
89
|
+
<span>{currentLabel}</span>
|
|
68
90
|
<ChevronDown class="h-3.5 w-3.5 text-muted-foreground transition-transform {isOpen ? 'rotate-180' : ''}" />
|
|
69
91
|
</button>
|
|
70
92
|
|
|
71
93
|
{#if isOpen}
|
|
72
94
|
<div
|
|
73
|
-
class="absolute top-full right-0 mt-1 w-
|
|
95
|
+
class="absolute top-full right-0 mt-1 w-48 py-1 bg-popover border border-border rounded-md shadow-lg z-50"
|
|
74
96
|
role="listbox"
|
|
75
97
|
aria-label="Available versions"
|
|
76
98
|
>
|
|
77
|
-
{#each
|
|
99
|
+
{#each visibleVersions as version}
|
|
78
100
|
<button
|
|
79
101
|
role="option"
|
|
80
|
-
aria-selected={version === currentVersion}
|
|
81
|
-
onclick={() => switchVersion(version)}
|
|
82
|
-
class="w-full flex items-center justify-between px-3 py-1.5 text-sm transition-colors {version === currentVersion
|
|
102
|
+
aria-selected={version.id === currentVersion}
|
|
103
|
+
onclick={() => switchVersion(version.id)}
|
|
104
|
+
class="w-full flex items-center justify-between px-3 py-1.5 text-sm transition-colors {version.id === currentVersion
|
|
83
105
|
? 'text-primary bg-accent/50 font-medium'
|
|
84
106
|
: 'text-foreground hover:bg-accent'}"
|
|
85
107
|
>
|
|
86
|
-
<span>
|
|
87
|
-
|
|
108
|
+
<span class="flex items-center gap-2">
|
|
109
|
+
{version.label}
|
|
110
|
+
{#if version.badge}
|
|
111
|
+
<span class="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-primary/10 text-primary leading-none">{version.badge}</span>
|
|
112
|
+
{/if}
|
|
113
|
+
</span>
|
|
114
|
+
{#if version.id === currentVersion}
|
|
88
115
|
<Check class="h-3.5 w-3.5 text-primary" />
|
|
89
116
|
{/if}
|
|
90
117
|
</button>
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
interface VersionMeta {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
badge?: string;
|
|
5
|
+
hidden?: boolean;
|
|
6
|
+
}
|
|
1
7
|
interface Props {
|
|
2
8
|
currentVersion: string;
|
|
3
9
|
versions: string[];
|
|
10
|
+
versionsMeta?: VersionMeta[];
|
|
4
11
|
}
|
|
5
12
|
declare const VersionSwitcher: import("svelte").Component<Props, {}, "">;
|
|
6
13
|
type VersionSwitcher = ReturnType<typeof VersionSwitcher>;
|
|
@@ -51,6 +51,7 @@ export { default as TimelineItem } from './TimelineItem.svelte';
|
|
|
51
51
|
export { default as Tabs } from './Tabs.svelte';
|
|
52
52
|
export { default as ThemeToggle } from './ThemeToggle.svelte';
|
|
53
53
|
export { default as Tooltip } from './Tooltip.svelte';
|
|
54
|
+
export { default as VersionBanner } from './VersionBanner.svelte';
|
|
54
55
|
export { default as VersionSwitcher } from './VersionSwitcher.svelte';
|
|
55
56
|
export { default as Video } from './Video.svelte';
|
|
56
57
|
export { ApiEndpoint, ApiParams, ApiResponse, ApiResponseDisplay, ApiPlayground, ApiReference, } from './api/index.js';
|
|
@@ -52,6 +52,7 @@ export { default as TimelineItem } from './TimelineItem.svelte';
|
|
|
52
52
|
export { default as Tabs } from './Tabs.svelte';
|
|
53
53
|
export { default as ThemeToggle } from './ThemeToggle.svelte';
|
|
54
54
|
export { default as Tooltip } from './Tooltip.svelte';
|
|
55
|
+
export { default as VersionBanner } from './VersionBanner.svelte';
|
|
55
56
|
export { default as VersionSwitcher } from './VersionSwitcher.svelte';
|
|
56
57
|
export { default as Video } from './Video.svelte';
|
|
57
58
|
// API components
|
package/dist/config.d.ts
CHANGED
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* The actual config loading happens on the server and is passed as props
|
|
5
5
|
*/
|
|
6
6
|
export { defaultConfig } from "./config.types";
|
|
7
|
-
export type { SpecraConfig } from "./config.types";
|
|
7
|
+
export type { SpecraConfig, VersionConfig, BannerConfig } from "./config.types";
|
|
8
8
|
export { getConfig, getConfigValue, loadConfig, processContentWithEnv, replaceEnvVariables, validateConfig, reloadConfig, } from "./config.server";
|
package/dist/config.server.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SpecraConfig } from "./config.types";
|
|
1
|
+
import type { SpecraConfig, VersionConfig } from "./config.types";
|
|
2
2
|
/**
|
|
3
3
|
* Load and parse the Specra configuration file
|
|
4
4
|
* Falls back to default configuration if file doesn't exist or is invalid
|
|
@@ -41,6 +41,33 @@ export declare function getConfig(): SpecraConfig;
|
|
|
41
41
|
* Reload the configuration (useful for development) (SERVER ONLY)
|
|
42
42
|
*/
|
|
43
43
|
export declare function reloadConfig(userConfig: Partial<SpecraConfig>): SpecraConfig;
|
|
44
|
+
export declare function loadVersionConfig(version: string): VersionConfig | null;
|
|
45
|
+
/**
|
|
46
|
+
* Get the effective config for a specific version.
|
|
47
|
+
* Merges global config with per-version overrides from _version_.json.
|
|
48
|
+
* If no _version_.json exists, returns the global config unchanged.
|
|
49
|
+
*/
|
|
50
|
+
export declare function getEffectiveConfig(version: string): SpecraConfig;
|
|
51
|
+
/**
|
|
52
|
+
* Version metadata for display in the version switcher.
|
|
53
|
+
*/
|
|
54
|
+
export interface VersionMeta {
|
|
55
|
+
/** Directory name (e.g., "v1.0.0") — used for routing */
|
|
56
|
+
id: string;
|
|
57
|
+
/** Display label (e.g., "v1.0 (Stable)") — defaults to id */
|
|
58
|
+
label: string;
|
|
59
|
+
/** Short badge text (e.g., "Beta", "LTS") */
|
|
60
|
+
badge?: string;
|
|
61
|
+
/** Whether this version is hidden from the switcher */
|
|
62
|
+
hidden?: boolean;
|
|
63
|
+
/** Version-level banner */
|
|
64
|
+
banner?: import("./config.types").BannerConfig;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Get metadata for all versions, enriched with _version_.json data.
|
|
68
|
+
* Hidden versions are included but marked — the UI decides whether to show them.
|
|
69
|
+
*/
|
|
70
|
+
export declare function getVersionsMeta(versions: string[]): VersionMeta[];
|
|
44
71
|
/**
|
|
45
72
|
* Export the loaded config as default (SERVER ONLY)
|
|
46
73
|
*/
|
package/dist/config.server.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
1
3
|
import { defaultConfig } from "./config.types";
|
|
2
4
|
/**
|
|
3
5
|
* Deep merge two objects
|
|
@@ -143,6 +145,70 @@ export function reloadConfig(userConfig) {
|
|
|
143
145
|
configInstance = loadConfig(userConfig);
|
|
144
146
|
return configInstance;
|
|
145
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Load per-version configuration from docs/{version}/_version_.json.
|
|
150
|
+
* Returns null if the file doesn't exist or is invalid.
|
|
151
|
+
*/
|
|
152
|
+
const versionConfigCache = new Map();
|
|
153
|
+
const VCFG_TTL = process.env.NODE_ENV === 'development' ? 5000 : 60000;
|
|
154
|
+
export function loadVersionConfig(version) {
|
|
155
|
+
const cached = versionConfigCache.get(version);
|
|
156
|
+
if (cached && Date.now() - cached.timestamp < VCFG_TTL) {
|
|
157
|
+
return cached.data;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const versionConfigPath = path.join(process.cwd(), "docs", version, "_version_.json");
|
|
161
|
+
if (!fs.existsSync(versionConfigPath)) {
|
|
162
|
+
versionConfigCache.set(version, { data: null, timestamp: Date.now() });
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const content = fs.readFileSync(versionConfigPath, "utf8");
|
|
166
|
+
const data = JSON.parse(content);
|
|
167
|
+
versionConfigCache.set(version, { data, timestamp: Date.now() });
|
|
168
|
+
return data;
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
console.error(`Error loading _version_.json for ${version}:`, error);
|
|
172
|
+
versionConfigCache.set(version, { data: null, timestamp: Date.now() });
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Get the effective config for a specific version.
|
|
178
|
+
* Merges global config with per-version overrides from _version_.json.
|
|
179
|
+
* If no _version_.json exists, returns the global config unchanged.
|
|
180
|
+
*/
|
|
181
|
+
export function getEffectiveConfig(version) {
|
|
182
|
+
const globalConfig = getConfig();
|
|
183
|
+
const versionConfig = loadVersionConfig(version);
|
|
184
|
+
if (!versionConfig) {
|
|
185
|
+
return globalConfig;
|
|
186
|
+
}
|
|
187
|
+
const effective = { ...globalConfig };
|
|
188
|
+
if (versionConfig.tabGroups !== undefined) {
|
|
189
|
+
effective.navigation = {
|
|
190
|
+
...effective.navigation,
|
|
191
|
+
tabGroups: versionConfig.tabGroups,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return effective;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Get metadata for all versions, enriched with _version_.json data.
|
|
198
|
+
* Hidden versions are included but marked — the UI decides whether to show them.
|
|
199
|
+
*/
|
|
200
|
+
export function getVersionsMeta(versions) {
|
|
201
|
+
return versions.map(id => {
|
|
202
|
+
const versionConfig = loadVersionConfig(id);
|
|
203
|
+
return {
|
|
204
|
+
id,
|
|
205
|
+
label: versionConfig?.label || id,
|
|
206
|
+
badge: versionConfig?.badge,
|
|
207
|
+
hidden: versionConfig?.hidden,
|
|
208
|
+
banner: versionConfig?.banner,
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
}
|
|
146
212
|
/**
|
|
147
213
|
* Export the loaded config as default (SERVER ONLY)
|
|
148
214
|
*/
|
package/dist/config.types.d.ts
CHANGED
|
@@ -59,6 +59,31 @@ export interface TabGroup {
|
|
|
59
59
|
/** Optional icon name (lucide-react icon) */
|
|
60
60
|
icon?: string;
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Banner configuration for version-level or site-level banners.
|
|
64
|
+
*/
|
|
65
|
+
export interface BannerConfig {
|
|
66
|
+
/** Banner message text. Supports markdown links like [text](/url). */
|
|
67
|
+
text: string;
|
|
68
|
+
/** Banner style: info, warning, error, success */
|
|
69
|
+
type?: 'info' | 'warning' | 'error' | 'success';
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Per-version configuration that can override global config settings.
|
|
73
|
+
* Loaded from docs/{version}/_version_.json
|
|
74
|
+
*/
|
|
75
|
+
export interface VersionConfig {
|
|
76
|
+
/** Display label for this version (e.g., "v1.0 (Stable)"). Defaults to directory name. */
|
|
77
|
+
label?: string;
|
|
78
|
+
/** Hide this version from the version switcher. Useful for unreleased versions. */
|
|
79
|
+
hidden?: boolean;
|
|
80
|
+
/** Short badge text shown next to the version (e.g., "Beta", "LTS", "Deprecated"). */
|
|
81
|
+
badge?: string;
|
|
82
|
+
/** Banner shown at the top of every page in this version. Overrides global banner. */
|
|
83
|
+
banner?: BannerConfig;
|
|
84
|
+
/** Override tab groups for this version. Empty array = no tabs. */
|
|
85
|
+
tabGroups?: TabGroup[];
|
|
86
|
+
}
|
|
62
87
|
/**
|
|
63
88
|
* Navigation and sidebar configuration
|
|
64
89
|
*/
|
package/dist/mdx.js
CHANGED
|
@@ -81,28 +81,34 @@ const PROP_NAME_MAP = {
|
|
|
81
81
|
* span={2} → span="__jsx:2"
|
|
82
82
|
* variant="success" → (unchanged, already a string)
|
|
83
83
|
*/
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Split markdown into fenced code blocks and non-code segments.
|
|
86
|
+
* Code blocks are preserved as-is; only non-code segments should be
|
|
87
|
+
* processed by JSX/component preprocessing functions.
|
|
88
|
+
* Matches 3+ backticks or tildes as fence markers.
|
|
89
|
+
*/
|
|
90
|
+
function splitByCodeFences(markdown) {
|
|
88
91
|
const fencedCodeRegex = /(^|\n)((`{3,}|~{3,}).*\n[\s\S]*?\n\3\s*(?:\n|$))/g;
|
|
89
92
|
const segments = [];
|
|
90
93
|
let lastIndex = 0;
|
|
91
94
|
let match;
|
|
92
95
|
while ((match = fencedCodeRegex.exec(markdown)) !== null) {
|
|
93
96
|
const codeStart = match.index + (match[1]?.length || 0);
|
|
94
|
-
// Add the non-code segment before this code block
|
|
95
97
|
if (codeStart > lastIndex) {
|
|
96
98
|
segments.push({ text: markdown.slice(lastIndex, codeStart), isCode: false });
|
|
97
99
|
}
|
|
98
|
-
// Add the code block as-is
|
|
99
100
|
segments.push({ text: match[2], isCode: true });
|
|
100
101
|
lastIndex = match.index + match[0].length;
|
|
101
102
|
}
|
|
102
|
-
// Add remaining non-code segment
|
|
103
103
|
if (lastIndex < markdown.length) {
|
|
104
104
|
segments.push({ text: markdown.slice(lastIndex), isCode: false });
|
|
105
105
|
}
|
|
106
|
+
return segments;
|
|
107
|
+
}
|
|
108
|
+
function preprocessJsxExpressions(markdown) {
|
|
109
|
+
// Split markdown into fenced code blocks and non-code segments.
|
|
110
|
+
// Only process JSX expressions in non-code segments to avoid corrupting code examples.
|
|
111
|
+
const segments = splitByCodeFences(markdown);
|
|
106
112
|
// Build a pattern that matches known component tag names (case-insensitive for safety)
|
|
107
113
|
const allNames = [...new Set([
|
|
108
114
|
...Object.values(COMPONENT_TAG_MAP),
|
|
@@ -425,9 +431,7 @@ function extractCodeBlockProps(node) {
|
|
|
425
431
|
// Extract language from className like ['language-javascript']
|
|
426
432
|
const classNames = codeChild.properties?.className || [];
|
|
427
433
|
const langClass = classNames.find((c) => typeof c === 'string' && c.startsWith('language-'));
|
|
428
|
-
|
|
429
|
-
return null;
|
|
430
|
-
const language = langClass.replace('language-', '');
|
|
434
|
+
const language = langClass ? langClass.replace('language-', '') : 'txt';
|
|
431
435
|
// Extract text content from the code element
|
|
432
436
|
const code = extractTextContent(codeChild).replace(/\n$/, '');
|
|
433
437
|
// Check for filename in data attributes (e.g. from remark-code-meta)
|
|
@@ -463,6 +467,7 @@ function childrenContainMarkdownText(children) {
|
|
|
463
467
|
/\[.*\]\(/.test(text) || // links
|
|
464
468
|
/(?:^|\n)\s*[-*+]\s/.test(child.value) || // unordered lists
|
|
465
469
|
/(?:^|\n)\s*\d+\.\s/.test(child.value) || // ordered lists
|
|
470
|
+
/(?:^|\n)\s*(`{3,}|~{3,})/.test(child.value) || // fenced code blocks
|
|
466
471
|
(text.length > 10 && /\n/.test(text.trim())) // multi-line text content (paragraphs)
|
|
467
472
|
) {
|
|
468
473
|
return true;
|
|
@@ -493,9 +498,72 @@ function dedent(text) {
|
|
|
493
498
|
// Strip the common indent from all lines
|
|
494
499
|
return lines.map(line => line.slice(minIndent)).join('\n');
|
|
495
500
|
}
|
|
501
|
+
/**
|
|
502
|
+
* Check if a text buffer currently has an unclosed fenced code block.
|
|
503
|
+
* Returns the fence marker (e.g. "```") if inside a code fence, or null otherwise.
|
|
504
|
+
*/
|
|
505
|
+
function getOpenCodeFence(text) {
|
|
506
|
+
const lines = text.split('\n');
|
|
507
|
+
let openFence = null;
|
|
508
|
+
for (const line of lines) {
|
|
509
|
+
if (openFence) {
|
|
510
|
+
// Check if this line closes the fence (same or more chars of same type)
|
|
511
|
+
const trimmed = line.trim();
|
|
512
|
+
if (trimmed.startsWith(openFence) && /^(`{3,}|~{3,})\s*$/.test(trimmed)) {
|
|
513
|
+
openFence = null;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
// Check if this line opens a fence
|
|
518
|
+
const fenceMatch = line.match(/^(`{3,}|~{3,})/);
|
|
519
|
+
if (fenceMatch) {
|
|
520
|
+
openFence = fenceMatch[1];
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return openFence;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Serialize a hast element back to its original tag text representation.
|
|
528
|
+
* Used to reconstruct component/element tags as plain text when they
|
|
529
|
+
* appear inside fenced code blocks (where they should be code, not components).
|
|
530
|
+
*/
|
|
531
|
+
function hastElementToText(node) {
|
|
532
|
+
const tagName = node.tagName || 'div';
|
|
533
|
+
// Restore PascalCase for known components
|
|
534
|
+
const displayName = COMPONENT_TAG_MAP[tagName] || tagName;
|
|
535
|
+
const props = node.properties || {};
|
|
536
|
+
const attrs = Object.entries(props)
|
|
537
|
+
.filter(([key]) => key !== 'className' || props[key]?.length > 0)
|
|
538
|
+
.map(([key, value]) => {
|
|
539
|
+
if (value === true || value === '')
|
|
540
|
+
return key;
|
|
541
|
+
if (typeof value === 'string' && value.startsWith('__jsx:')) {
|
|
542
|
+
// Restore JSX expression syntax
|
|
543
|
+
const expr = value.slice(6).replace(/"/g, '"').replace(/ /g, '\n');
|
|
544
|
+
return `${key}={${expr}}`;
|
|
545
|
+
}
|
|
546
|
+
return `${key}="${value}"`;
|
|
547
|
+
})
|
|
548
|
+
.join(' ');
|
|
549
|
+
const openTag = attrs ? `<${displayName} ${attrs}>` : `<${displayName}>`;
|
|
550
|
+
const childText = (node.children || []).map((c) => {
|
|
551
|
+
if (c.type === 'text')
|
|
552
|
+
return c.value || '';
|
|
553
|
+
if (c.type === 'element')
|
|
554
|
+
return hastElementToText(c);
|
|
555
|
+
return '';
|
|
556
|
+
}).join('');
|
|
557
|
+
return `${openTag}${childText}</${displayName}>`;
|
|
558
|
+
}
|
|
496
559
|
/**
|
|
497
560
|
* Extract raw text from hast children, preserving component tags as placeholders.
|
|
498
561
|
* Returns the markdown text and a map of placeholders to component MdxNodes.
|
|
562
|
+
*
|
|
563
|
+
* Tracks open code fences in the text buffer: if the buffer contains an unclosed
|
|
564
|
+
* fenced code block (e.g. ``` opened but not closed), subsequent component/element
|
|
565
|
+
* children are serialized back to text instead of being processed as real components.
|
|
566
|
+
* This prevents component tags inside code examples from rendering as live components.
|
|
499
567
|
*/
|
|
500
568
|
async function processComponentChildren(children) {
|
|
501
569
|
// Separate text runs from component/element children.
|
|
@@ -530,42 +598,52 @@ async function processComponentChildren(children) {
|
|
|
530
598
|
if (child.type === 'text') {
|
|
531
599
|
textBuffer += child.value || '';
|
|
532
600
|
}
|
|
533
|
-
else if (isComponentElement(child)) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
await flushTextBuffer();
|
|
550
|
-
const codeBlockProps = extractCodeBlockProps(child);
|
|
551
|
-
if (codeBlockProps) {
|
|
601
|
+
else if (isComponentElement(child) || child.type === 'element') {
|
|
602
|
+
// Check if we're inside an unclosed code fence in the text buffer.
|
|
603
|
+
// If so, this element is part of a code example — serialize it back
|
|
604
|
+
// to text rather than processing it as a real component.
|
|
605
|
+
const openFence = getOpenCodeFence(textBuffer);
|
|
606
|
+
if (openFence) {
|
|
607
|
+
// We're inside a code fence — serialize this element as text
|
|
608
|
+
textBuffer += hastElementToText(child);
|
|
609
|
+
}
|
|
610
|
+
else if (isComponentElement(child)) {
|
|
611
|
+
await flushTextBuffer();
|
|
612
|
+
const componentName = COMPONENT_TAG_MAP[child.tagName];
|
|
613
|
+
const props = convertProps(child.properties || {});
|
|
614
|
+
const childNodes = child.children && child.children.length > 0
|
|
615
|
+
? await processSmartChildren(child.children)
|
|
616
|
+
: [];
|
|
552
617
|
result.push({
|
|
553
618
|
type: 'component',
|
|
554
|
-
name:
|
|
555
|
-
props
|
|
556
|
-
children:
|
|
619
|
+
name: componentName,
|
|
620
|
+
props,
|
|
621
|
+
children: childNodes,
|
|
557
622
|
});
|
|
558
623
|
}
|
|
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
624
|
else {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
625
|
+
// Regular HTML element inside a component — flush text first, then serialize
|
|
626
|
+
await flushTextBuffer();
|
|
627
|
+
const codeBlockProps = extractCodeBlockProps(child);
|
|
628
|
+
if (codeBlockProps) {
|
|
629
|
+
result.push({
|
|
630
|
+
type: 'component',
|
|
631
|
+
name: 'CodeBlock',
|
|
632
|
+
props: codeBlockProps,
|
|
633
|
+
children: [],
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
else if (hasNestedComponent(child)) {
|
|
637
|
+
const openTag = toHtml({ ...child, children: [] }).replace(/<\/[^>]+>$/, '');
|
|
638
|
+
result.push({ type: 'html', content: openTag });
|
|
639
|
+
result.push(...await processSmartChildren(child.children));
|
|
640
|
+
result.push({ type: 'html', content: `</${child.tagName}>` });
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
const html = toHtml(child).trim();
|
|
644
|
+
if (html) {
|
|
645
|
+
result.push({ type: 'html', content: html });
|
|
646
|
+
}
|
|
569
647
|
}
|
|
570
648
|
}
|
|
571
649
|
}
|
|
@@ -672,9 +750,214 @@ function hasNestedComponent(node) {
|
|
|
672
750
|
* Runs the same remark/rehype pipeline but produces an AST
|
|
673
751
|
* instead of a stringified HTML output.
|
|
674
752
|
*/
|
|
753
|
+
/**
|
|
754
|
+
* Dedent content inside component tags before remark processing.
|
|
755
|
+
* In CommonMark, 4+ spaces of indentation create code blocks.
|
|
756
|
+
* When users indent content inside component tags (e.g. <Tabs> inside <Step>),
|
|
757
|
+
* the inherited indentation causes remark to misinterpret child tags and
|
|
758
|
+
* markdown as indented code blocks. This strips common leading indentation
|
|
759
|
+
* from the content between known component open/close tags.
|
|
760
|
+
*
|
|
761
|
+
* Only processes the outermost component tags (those not nested inside another
|
|
762
|
+
* known component). Inner components are handled naturally since their parent's
|
|
763
|
+
* dedent removes the shared indentation.
|
|
764
|
+
*/
|
|
765
|
+
/**
|
|
766
|
+
* Identify which lines in a text block are inside fenced code blocks.
|
|
767
|
+
* Returns a Set of line indices (0-based) that are inside code fences.
|
|
768
|
+
*/
|
|
769
|
+
function getCodeFenceLineIndices(lines) {
|
|
770
|
+
const indices = new Set();
|
|
771
|
+
let openFence = null;
|
|
772
|
+
let openIndex = -1;
|
|
773
|
+
for (let i = 0; i < lines.length; i++) {
|
|
774
|
+
const line = lines[i];
|
|
775
|
+
if (openFence) {
|
|
776
|
+
indices.add(i);
|
|
777
|
+
const trimmed = line.trim();
|
|
778
|
+
if (trimmed.startsWith(openFence) && new RegExp(`^${openFence[0]}{${openFence.length},}\\s*$`).test(trimmed)) {
|
|
779
|
+
openFence = null;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
else {
|
|
783
|
+
const fenceMatch = line.match(/^(\s*)(`{3,}|~{3,})/);
|
|
784
|
+
if (fenceMatch) {
|
|
785
|
+
openFence = fenceMatch[2];
|
|
786
|
+
openIndex = i;
|
|
787
|
+
indices.add(i);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return indices;
|
|
792
|
+
}
|
|
793
|
+
function dedentComponentChildren(markdown) {
|
|
794
|
+
// Build a single regex that matches any known component opening tag
|
|
795
|
+
const allNames = [...new Set([
|
|
796
|
+
...Object.values(COMPONENT_TAG_MAP),
|
|
797
|
+
...Object.keys(COMPONENT_TAG_MAP),
|
|
798
|
+
])];
|
|
799
|
+
const namesPattern = allNames.join('|');
|
|
800
|
+
// Find outermost component blocks and dedent their entire content
|
|
801
|
+
// (including nested component tags) so remark doesn't misinterpret indentation.
|
|
802
|
+
const openTagRegex = new RegExp(`(<(?:${namesPattern})(?:\\s[^>]*)?>)(\\n)`, 'gi');
|
|
803
|
+
let result = '';
|
|
804
|
+
let lastIndex = 0;
|
|
805
|
+
let match;
|
|
806
|
+
openTagRegex.lastIndex = 0;
|
|
807
|
+
while ((match = openTagRegex.exec(markdown)) !== null) {
|
|
808
|
+
// Skip if this tag is nested inside a previously processed block
|
|
809
|
+
if (match.index < lastIndex)
|
|
810
|
+
continue;
|
|
811
|
+
const tagName = match[1].match(/<(\w+)/)?.[1];
|
|
812
|
+
if (!tagName)
|
|
813
|
+
continue;
|
|
814
|
+
const contentStart = match.index + match[0].length;
|
|
815
|
+
// Find the matching closing tag, handling nesting of the SAME tag
|
|
816
|
+
const closeTagRegex = new RegExp(`</${tagName}\\s*>`, 'gi');
|
|
817
|
+
const openNestRegex = new RegExp(`<${tagName}(?:\\s[^>]*)?>`, 'gi');
|
|
818
|
+
closeTagRegex.lastIndex = contentStart;
|
|
819
|
+
openNestRegex.lastIndex = contentStart;
|
|
820
|
+
let depth = 1;
|
|
821
|
+
let closeMatch = null;
|
|
822
|
+
while (depth > 0 && (closeMatch = closeTagRegex.exec(markdown)) !== null) {
|
|
823
|
+
// Count any nested opens of the same tag before this close
|
|
824
|
+
while (true) {
|
|
825
|
+
const nested = openNestRegex.exec(markdown);
|
|
826
|
+
if (!nested || nested.index >= closeMatch.index) {
|
|
827
|
+
openNestRegex.lastIndex = closeMatch.index + closeMatch[0].length;
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
830
|
+
depth++;
|
|
831
|
+
}
|
|
832
|
+
depth--;
|
|
833
|
+
}
|
|
834
|
+
if (!closeMatch)
|
|
835
|
+
continue;
|
|
836
|
+
const contentEnd = closeMatch.index;
|
|
837
|
+
const innerContent = markdown.slice(contentStart, contentEnd);
|
|
838
|
+
// Find minimum indentation of non-empty lines that are NOT inside code fences.
|
|
839
|
+
// Lines inside code fences (including fence markers and their content) are
|
|
840
|
+
// excluded so that code at indent 0 doesn't prevent dedenting of component content.
|
|
841
|
+
const lines = innerContent.split('\n');
|
|
842
|
+
const codeFenceLines = getCodeFenceLineIndices(lines);
|
|
843
|
+
let minIndent = Infinity;
|
|
844
|
+
for (let i = 0; i < lines.length; i++) {
|
|
845
|
+
if (codeFenceLines.has(i))
|
|
846
|
+
continue;
|
|
847
|
+
const line = lines[i];
|
|
848
|
+
if (line.trim().length === 0)
|
|
849
|
+
continue;
|
|
850
|
+
const indent = line.match(/^(\s*)/)?.[1].length ?? 0;
|
|
851
|
+
if (indent > 0 && indent < minIndent)
|
|
852
|
+
minIndent = indent;
|
|
853
|
+
}
|
|
854
|
+
if (minIndent > 0 && minIndent !== Infinity) {
|
|
855
|
+
// Dedent non-code-fence lines by the common indent.
|
|
856
|
+
// Code fence lines are left as-is to preserve their content.
|
|
857
|
+
const dedented = lines.map((line, i) => {
|
|
858
|
+
if (codeFenceLines.has(i))
|
|
859
|
+
return line;
|
|
860
|
+
if (line.trim().length === 0)
|
|
861
|
+
return line;
|
|
862
|
+
return line.slice(Math.min(minIndent, line.match(/^(\s*)/)?.[1].length ?? 0));
|
|
863
|
+
}).join('\n');
|
|
864
|
+
result += markdown.slice(lastIndex, contentStart) + dedented;
|
|
865
|
+
lastIndex = contentEnd;
|
|
866
|
+
// Advance past this entire component block
|
|
867
|
+
openTagRegex.lastIndex = closeMatch.index + closeMatch[0].length;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (lastIndex > 0) {
|
|
871
|
+
result += markdown.slice(lastIndex);
|
|
872
|
+
markdown = result;
|
|
873
|
+
// Re-run to handle inner components that were previously skipped
|
|
874
|
+
// (e.g. <Step> inside <Steps> now has reduced indentation that needs further dedenting)
|
|
875
|
+
return dedentComponentChildren(markdown);
|
|
876
|
+
}
|
|
877
|
+
return markdown;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Collapse blank lines inside component blocks so remark-parse treats the
|
|
881
|
+
* entire component (from opening to closing tag) as a single HTML block.
|
|
882
|
+
*
|
|
883
|
+
* In CommonMark, a blank line inside an HTML block (type 6) ends the block.
|
|
884
|
+
* This causes remark to split component content into separate blocks, and
|
|
885
|
+
* plain text between child components becomes a `<p>` element that disrupts
|
|
886
|
+
* parse5's handling of unknown element nesting (siblings become children).
|
|
887
|
+
*
|
|
888
|
+
* By removing blank lines inside component blocks, the entire component tree
|
|
889
|
+
* stays as one HTML block that parse5 can parse correctly.
|
|
890
|
+
*/
|
|
891
|
+
function ensureComponentBlockIntegrity(markdown) {
|
|
892
|
+
const allNames = [...new Set([
|
|
893
|
+
...Object.values(COMPONENT_TAG_MAP),
|
|
894
|
+
...Object.keys(COMPONENT_TAG_MAP),
|
|
895
|
+
])];
|
|
896
|
+
const namesPattern = allNames.join('|');
|
|
897
|
+
// Find outermost component blocks and collapse blank lines within them
|
|
898
|
+
const openTagRegex = new RegExp(`^(\\s*<(?:${namesPattern})(?:\\s[^>]*)?>)\\s*$`, 'gim');
|
|
899
|
+
let result = '';
|
|
900
|
+
let lastIndex = 0;
|
|
901
|
+
let match;
|
|
902
|
+
openTagRegex.lastIndex = 0;
|
|
903
|
+
while ((match = openTagRegex.exec(markdown)) !== null) {
|
|
904
|
+
// Skip if inside a previously processed block
|
|
905
|
+
if (match.index < lastIndex)
|
|
906
|
+
continue;
|
|
907
|
+
const tagName = match[1].match(/<(\w+)/)?.[1];
|
|
908
|
+
if (!tagName)
|
|
909
|
+
continue;
|
|
910
|
+
const blockStart = match.index;
|
|
911
|
+
// Find matching closing tag, handling nesting
|
|
912
|
+
const closeTagRegex = new RegExp(`</${tagName}\\s*>`, 'gi');
|
|
913
|
+
const openNestRegex = new RegExp(`<${tagName}(?:\\s[^>]*)?>`, 'gi');
|
|
914
|
+
closeTagRegex.lastIndex = match.index + match[0].length;
|
|
915
|
+
openNestRegex.lastIndex = match.index + match[0].length;
|
|
916
|
+
let depth = 1;
|
|
917
|
+
let closeMatch = null;
|
|
918
|
+
while (depth > 0 && (closeMatch = closeTagRegex.exec(markdown)) !== null) {
|
|
919
|
+
while (true) {
|
|
920
|
+
const nested = openNestRegex.exec(markdown);
|
|
921
|
+
if (!nested || nested.index >= closeMatch.index) {
|
|
922
|
+
openNestRegex.lastIndex = closeMatch.index + closeMatch[0].length;
|
|
923
|
+
break;
|
|
924
|
+
}
|
|
925
|
+
depth++;
|
|
926
|
+
}
|
|
927
|
+
depth--;
|
|
928
|
+
}
|
|
929
|
+
if (!closeMatch)
|
|
930
|
+
continue;
|
|
931
|
+
const blockEnd = closeMatch.index + closeMatch[0].length;
|
|
932
|
+
const block = markdown.slice(blockStart, blockEnd);
|
|
933
|
+
// Replace blank/whitespace-only lines (outside code fences) with HTML
|
|
934
|
+
// comments so remark doesn't end the HTML block at those points.
|
|
935
|
+
// In CommonMark, a blank line inside an HTML type-6 block ends the block.
|
|
936
|
+
// An HTML comment on the line prevents this while being invisible in output.
|
|
937
|
+
// Replace ALL blank/whitespace-only lines with HTML comments — even inside
|
|
938
|
+
// code fences. The code fence content is raw text inside the HTML block and
|
|
939
|
+
// will be re-processed by processComponentChildren through the markdown
|
|
940
|
+
// pipeline, which restores proper code formatting.
|
|
941
|
+
const collapsed = block.replace(/^\s*$/gm, '<!-- -->');
|
|
942
|
+
result += markdown.slice(lastIndex, blockStart) + collapsed;
|
|
943
|
+
lastIndex = blockEnd;
|
|
944
|
+
openTagRegex.lastIndex = blockEnd;
|
|
945
|
+
}
|
|
946
|
+
if (lastIndex > 0) {
|
|
947
|
+
result += markdown.slice(lastIndex);
|
|
948
|
+
return result;
|
|
949
|
+
}
|
|
950
|
+
return markdown;
|
|
951
|
+
}
|
|
675
952
|
async function processMarkdownToMdxNodes(markdown) {
|
|
676
953
|
// Pre-process JSX expression attributes into HTML-safe string attributes
|
|
677
954
|
const preprocessed = preprocessJsxExpressions(markdown);
|
|
955
|
+
// Dedent content inside component tags so that indented children
|
|
956
|
+
// (e.g. <Tabs> inside <Step>) don't get treated as code blocks
|
|
957
|
+
// by remark-parse (4+ spaces = indented code in CommonMark).
|
|
958
|
+
const dedented = dedentComponentChildren(preprocessed);
|
|
959
|
+
// Ensure component block integrity in the markdown
|
|
960
|
+
const normalized = ensureComponentBlockIntegrity(dedented);
|
|
678
961
|
const processor = unified()
|
|
679
962
|
.use(remarkParse)
|
|
680
963
|
.use(remarkGfm)
|
|
@@ -683,7 +966,7 @@ async function processMarkdownToMdxNodes(markdown) {
|
|
|
683
966
|
.use(rehypeRaw)
|
|
684
967
|
.use(rehypeSlug)
|
|
685
968
|
.use(rehypeKatex);
|
|
686
|
-
const mdast = processor.parse(
|
|
969
|
+
const mdast = processor.parse(normalized);
|
|
687
970
|
const hast = await processor.run(mdast);
|
|
688
971
|
// The hast root has children - process them into MdxNodes
|
|
689
972
|
const children = hast.children || [];
|
package/dist/styles/globals.css
CHANGED
|
@@ -147,6 +147,8 @@
|
|
|
147
147
|
scroll-behavior: smooth;
|
|
148
148
|
/* Always reserve space for scrollbar to prevent layout shift */
|
|
149
149
|
scrollbar-gutter: stable;
|
|
150
|
+
/* Default header height — updated dynamically by Header component's ResizeObserver */
|
|
151
|
+
--header-height: 4rem;
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
body {
|
|
@@ -420,6 +422,75 @@ pre code {
|
|
|
420
422
|
margin-bottom: 0.75rem;
|
|
421
423
|
}
|
|
422
424
|
|
|
425
|
+
/* Override Tailwind typography's padding-inline-start on lists.
|
|
426
|
+
The plugin applies ~1.625em to every ol/ul, which compounds on
|
|
427
|
+
nested lists — each level pushes content further right.
|
|
428
|
+
Top-level lists get a modest indent; nested lists get less. */
|
|
429
|
+
.prose :where(ol, ul):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
|
430
|
+
padding-inline-start: 1.25em;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
.prose :where(li ol, li ul):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
|
434
|
+
padding-inline-start: 1em;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/* Table styles - bordered with rounded corners matching CodeBlock/Callout */
|
|
438
|
+
.prose :where(table):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
|
439
|
+
border-collapse: separate;
|
|
440
|
+
border-spacing: 0;
|
|
441
|
+
border: 1px solid var(--border);
|
|
442
|
+
border-radius: 0.75rem;
|
|
443
|
+
overflow: hidden;
|
|
444
|
+
width: 100%;
|
|
445
|
+
margin-top: 1rem;
|
|
446
|
+
margin-bottom: 1rem;
|
|
447
|
+
display: block;
|
|
448
|
+
overflow-x: auto;
|
|
449
|
+
-webkit-overflow-scrolling: touch;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
.prose :where(thead):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
|
453
|
+
background: var(--muted);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.prose :where(thead th):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
|
457
|
+
border-bottom: 1px solid var(--border);
|
|
458
|
+
border-right: 1px solid var(--border);
|
|
459
|
+
padding: 0.625rem 1rem;
|
|
460
|
+
font-weight: 600;
|
|
461
|
+
font-size: 0.875rem;
|
|
462
|
+
text-align: left;
|
|
463
|
+
color: var(--foreground);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.prose :where(thead th:last-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
|
467
|
+
border-right: none;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
.prose :where(tbody td):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
|
471
|
+
border-bottom: 1px solid var(--border);
|
|
472
|
+
border-right: 1px solid var(--border);
|
|
473
|
+
padding: 0.625rem 1rem;
|
|
474
|
+
font-size: 0.875rem;
|
|
475
|
+
color: var(--muted-foreground);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
.prose :where(tbody td:last-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
|
479
|
+
border-right: none;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.prose :where(tbody tr:last-child td):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
|
483
|
+
border-bottom: none;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
.prose :where(tbody tr:hover):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
|
487
|
+
background: var(--muted);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
.dark .prose :where(tbody tr:hover):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
|
491
|
+
background: var(--muted);
|
|
492
|
+
}
|
|
493
|
+
|
|
423
494
|
html body[data-scroll-locked] {
|
|
424
495
|
overflow: visible !important;
|
|
425
496
|
margin-right: 0 !important;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specra",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "A modern documentation library for SvelteKit with built-in versioning, API reference generation, full-text search, and MDX support",
|
|
5
5
|
"svelte": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|