jamdesk 1.1.144 → 1.1.146
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/dist/lib/normalize-config.d.ts +7 -3
- package/dist/lib/normalize-config.d.ts.map +1 -1
- package/dist/lib/normalize-config.js +21 -11
- package/dist/lib/normalize-config.js.map +1 -1
- package/package.json +1 -1
- package/vendored/components/layout/BannerBar.tsx +80 -0
- package/vendored/components/layout/LayoutWrapper.tsx +59 -50
- package/vendored/components/mdx/NativeSubscribeForm.tsx +54 -13
- package/vendored/components/mdx/Update.tsx +13 -1
- package/vendored/components/mdx/UpdateSlugContext.tsx +60 -0
- package/vendored/components/navigation/Header.tsx +5 -2
- package/vendored/components/navigation/LastUpdated.tsx +44 -0
- package/vendored/components/navigation/Sidebar.tsx +1 -1
- package/vendored/lib/banner-dismiss.ts +53 -0
- package/vendored/lib/content-loader.ts +17 -2
- package/vendored/lib/heading-extractor.ts +14 -8
- package/vendored/lib/inline-markdown.tsx +59 -0
- package/vendored/lib/layout-helpers.tsx +27 -0
- package/vendored/lib/normalize-config.ts +32 -15
- package/vendored/lib/page-timestamps.ts +144 -0
- package/vendored/lib/render-doc-page.tsx +41 -23
- package/vendored/public/_jd/fonts/fontawesome/css/all.css +68 -1
- package/vendored/public/_jd/fonts/fontawesome/css/all.min.css +3 -1
- package/vendored/public/_jd/fonts/fontawesome/webfonts/fa-sharp-duotone-solid-900.woff2 +0 -0
- package/vendored/schema/docs-schema.json +2 -0
- package/vendored/workspace-package-lock.json +87 -3
|
@@ -12,19 +12,23 @@
|
|
|
12
12
|
* Field mappings:
|
|
13
13
|
* - modeToggle.default -> appearance.default
|
|
14
14
|
* - modeToggle.isHidden -> appearance.strict
|
|
15
|
-
* - metadata ->
|
|
15
|
+
* - metadata.timestamp -> config.metadata.timestamp (real feature flag, kept)
|
|
16
|
+
* - metadata.<other> -> seo.metatags (legacy Mintlify metatags, deprecated)
|
|
16
17
|
* - navbar.style -> preserved but ignored in rendering
|
|
17
18
|
* - layout -> removed (theme determines layout)
|
|
18
19
|
*/
|
|
19
20
|
/**
|
|
20
|
-
* Input config type for normalization
|
|
21
|
+
* Input config type for normalization.
|
|
22
|
+
*
|
|
23
|
+
* `metadata` is overloaded for backward compatibility: historically a Mintlify
|
|
24
|
+
* metatags string-map, it now also carries the real `timestamp` feature flag.
|
|
21
25
|
*/
|
|
22
26
|
interface ConfigInput {
|
|
23
27
|
modeToggle?: {
|
|
24
28
|
default?: 'dark' | 'light';
|
|
25
29
|
isHidden?: boolean;
|
|
26
30
|
};
|
|
27
|
-
metadata?: Record<string,
|
|
31
|
+
metadata?: Record<string, unknown>;
|
|
28
32
|
layout?: string;
|
|
29
33
|
appearance?: unknown;
|
|
30
34
|
seo?: {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"normalize-config.d.ts","sourceRoot":"","sources":["../../src/lib/normalize-config.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"normalize-config.d.ts","sourceRoot":"","sources":["../../src/lib/normalize-config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH;;;;;GAKG;AACH,UAAU,WAAW;IACnB,UAAU,CAAC,EAAE;QACX,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;QAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;KACpB,CAAC;IACF,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE;QACJ,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAClC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC;IACF,MAAM,CAAC,EAAE;QACP,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC;IACF,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,WAAW,CAAC;IACpB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,WAAW,GAAG,eAAe,CA4DpE"}
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* Field mappings:
|
|
13
13
|
* - modeToggle.default -> appearance.default
|
|
14
14
|
* - modeToggle.isHidden -> appearance.strict
|
|
15
|
-
* - metadata ->
|
|
15
|
+
* - metadata.timestamp -> config.metadata.timestamp (real feature flag, kept)
|
|
16
|
+
* - metadata.<other> -> seo.metatags (legacy Mintlify metatags, deprecated)
|
|
16
17
|
* - navbar.style -> preserved but ignored in rendering
|
|
17
18
|
* - layout -> removed (theme determines layout)
|
|
18
19
|
*/
|
|
@@ -38,17 +39,26 @@ export function normalizeConfig(config) {
|
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
|
-
// 2. Normalize metadata
|
|
42
|
+
// 2. Normalize metadata.
|
|
43
|
+
// - `timestamp` is a real page-metadata feature flag -> config.metadata.timestamp.
|
|
44
|
+
// - any OTHER keys are legacy Mintlify metatags -> seo.metatags (deprecated).
|
|
42
45
|
if (metadata) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
46
|
+
const { timestamp, ...legacyMetatags } = metadata;
|
|
47
|
+
if (timestamp !== undefined) {
|
|
48
|
+
normalized.metadata = { timestamp: timestamp === true };
|
|
49
|
+
}
|
|
50
|
+
const legacyKeys = Object.keys(legacyMetatags);
|
|
51
|
+
if (legacyKeys.length > 0) {
|
|
52
|
+
warnings.push('metadata is deprecated. Use seo: { metatags: { ... } } instead.');
|
|
53
|
+
const existingMetatags = normalized.seo?.metatags || {};
|
|
54
|
+
normalized.seo = {
|
|
55
|
+
...normalized.seo,
|
|
56
|
+
metatags: {
|
|
57
|
+
...legacyMetatags,
|
|
58
|
+
...existingMetatags,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
52
62
|
}
|
|
53
63
|
// 3. Warn about layout
|
|
54
64
|
if (layout) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"normalize-config.js","sourceRoot":"","sources":["../../src/lib/normalize-config.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"normalize-config.js","sourceRoot":"","sources":["../../src/lib/normalize-config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAmCH;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,MAAmB;IACjD,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,MAAM,CAAC;IACzD,MAAM,QAAQ,GAAa,EAAE,CAAC;IAE9B,MAAM,UAAU,GAAgB,EAAE,GAAG,IAAI,EAAE,CAAC;IAE5C,wCAAwC;IACxC,IAAI,UAAU,EAAE,CAAC;QACf,QAAQ,CAAC,IAAI,CACX,sFAAsF,CACvF,CAAC;QACF,IAAI,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC;YACvB,UAAU,CAAC,UAAU,GAAG;gBACtB,OAAO,EAAE,UAAU,CAAC,OAAO,IAAI,QAAQ;gBACvC,MAAM,EAAE,UAAU,CAAC,QAAQ,KAAK,IAAI;aACrC,CAAC;QACJ,CAAC;IACH,CAAC;IAED,yBAAyB;IACzB,sFAAsF;IACtF,iFAAiF;IACjF,IAAI,QAAQ,EAAE,CAAC;QACb,MAAM,EAAE,SAAS,EAAE,GAAG,cAAc,EAAE,GAAG,QAAQ,CAAC;QAElD,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;YAC5B,UAAU,CAAC,QAAQ,GAAG,EAAE,SAAS,EAAE,SAAS,KAAK,IAAI,EAAE,CAAC;QAC1D,CAAC;QAED,MAAM,UAAU,GAAG,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QAC/C,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC1B,QAAQ,CAAC,IAAI,CACX,iEAAiE,CAClE,CAAC;YACF,MAAM,gBAAgB,GAAI,UAAU,CAAC,GAAW,EAAE,QAAQ,IAAI,EAAE,CAAC;YACjE,UAAU,CAAC,GAAG,GAAG;gBACf,GAAG,UAAU,CAAC,GAAG;gBACjB,QAAQ,EAAE;oBACR,GAAI,cAAyC;oBAC7C,GAAG,gBAAgB;iBACpB;aACF,CAAC;QACJ,CAAC;IACH,CAAC;IAED,uBAAuB;IACvB,IAAI,MAAM,EAAE,CAAC;QACX,QAAQ,CAAC,IAAI,CACX,oGAAoG,CACrG,CAAC;IACJ,CAAC;IAED,6BAA6B;IAC7B,IAAI,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC;QACvB,QAAQ,CAAC,IAAI,CACX,6DAA6D,CAC9D,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC;AAC1C,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jamdesk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.146",
|
|
4
4
|
"description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jamdesk",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type { DocsConfig } from '@/lib/docs-types';
|
|
5
|
+
import { useProjectSlug } from '@/lib/project-slug-context';
|
|
6
|
+
import { renderInlineMarkdown } from '@/lib/inline-markdown';
|
|
7
|
+
import { isBannerDismissed, dismissBanner } from '@/lib/banner-dismiss';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Site-wide announcement bar pinned to the top of the page. Reads the global
|
|
11
|
+
* `banner` from docs.json. Renders nothing when absent, blank, or dismissed.
|
|
12
|
+
* Publishes its height to `--jd-banner-height` so the sticky header/sidebar can
|
|
13
|
+
* sit below it on mobile (where the page itself scrolls).
|
|
14
|
+
*/
|
|
15
|
+
export function BannerBar({ config }: { config: DocsConfig }) {
|
|
16
|
+
const content = config.banner?.content?.trim() ?? '';
|
|
17
|
+
const dismissible = config.banner?.dismissible === true;
|
|
18
|
+
const slug = useProjectSlug();
|
|
19
|
+
const [dismissed, setDismissed] = useState(false);
|
|
20
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
21
|
+
|
|
22
|
+
// Sync dismissed state from storage after mount (keyed to the content hash).
|
|
23
|
+
// Only honor a stored dismissal when the banner is currently dismissible —
|
|
24
|
+
// otherwise a stale hash from a previous dismissible version of the same
|
|
25
|
+
// content would permanently hide a now-mandatory banner.
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (content && dismissible) setDismissed(isBannerDismissed(slug, content));
|
|
28
|
+
}, [slug, content, dismissible]);
|
|
29
|
+
|
|
30
|
+
// Publish the banner height so the sticky header/sidebar can offset below it.
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const el = ref.current;
|
|
33
|
+
const root = document.documentElement;
|
|
34
|
+
if (!el || dismissed || !content) {
|
|
35
|
+
root.style.removeProperty('--jd-banner-height');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const apply = () => root.style.setProperty('--jd-banner-height', `${el.offsetHeight}px`);
|
|
39
|
+
apply();
|
|
40
|
+
const ro = new ResizeObserver(apply);
|
|
41
|
+
ro.observe(el);
|
|
42
|
+
return () => {
|
|
43
|
+
ro.disconnect();
|
|
44
|
+
root.style.removeProperty('--jd-banner-height');
|
|
45
|
+
};
|
|
46
|
+
}, [dismissed, content]);
|
|
47
|
+
|
|
48
|
+
if (!content || dismissed) return null;
|
|
49
|
+
|
|
50
|
+
const handleDismiss = () => {
|
|
51
|
+
dismissBanner(slug, content);
|
|
52
|
+
setDismissed(true);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
ref={ref}
|
|
58
|
+
data-jd-banner
|
|
59
|
+
role="region"
|
|
60
|
+
aria-label="Site announcement"
|
|
61
|
+
className="sticky top-0 z-50 w-full flex-shrink-0 bg-[var(--color-accent)] text-white"
|
|
62
|
+
>
|
|
63
|
+
<div className="mx-auto flex max-w-[1440px] items-center gap-3 px-6 py-2.5 text-sm">
|
|
64
|
+
<div className="flex-1 [&_a]:font-medium [&_a]:text-white [&_a]:underline">
|
|
65
|
+
{renderInlineMarkdown(content)}
|
|
66
|
+
</div>
|
|
67
|
+
{dismissible && (
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={handleDismiss}
|
|
71
|
+
aria-label="Dismiss announcement"
|
|
72
|
+
className="flex-shrink-0 cursor-pointer p-1 text-white/80 transition-colors hover:text-white"
|
|
73
|
+
>
|
|
74
|
+
<i className="fa-solid fa-xmark text-[16px]" aria-hidden="true" />
|
|
75
|
+
</button>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -7,6 +7,7 @@ import { TabsNav } from '@/components/navigation/TabsNav';
|
|
|
7
7
|
import { LazyChatPanel } from '@/components/chat/LazyChatPanel';
|
|
8
8
|
import { ChatResizeHandle } from '@/components/chat/ChatResizeHandle';
|
|
9
9
|
import { ChatPanel } from '@/components/chat/ChatPanel';
|
|
10
|
+
import { BannerBar } from '@/components/layout/BannerBar';
|
|
10
11
|
import { ChatPanelProvider, useChatPanel } from '@/hooks/useChatPanel';
|
|
11
12
|
import { useHashNavigation } from '@/hooks/useHashNavigation';
|
|
12
13
|
import type { DocsConfig } from '@/lib/docs-types';
|
|
@@ -108,73 +109,81 @@ export function LayoutWrapper({ config, children, embed }: LayoutWrapperProps) {
|
|
|
108
109
|
</ChatPanelProvider>
|
|
109
110
|
);
|
|
110
111
|
|
|
111
|
-
// Sidebar-logo layout: Sidebar is full height, header only above
|
|
112
|
+
// Sidebar-logo layout (Pulsar): Sidebar is full height, header only above
|
|
113
|
+
// content. Flex-column shell puts the banner above the fixed sidebar.
|
|
112
114
|
if (layout === 'sidebar-logo') {
|
|
113
115
|
return wrapWithProvider(
|
|
114
|
-
<div className="flex min-h-screen lg:h-screen lg:overflow-hidden">
|
|
115
|
-
<
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
isOpen={isSidebarOpen}
|
|
119
|
-
onClose={closeSidebar}
|
|
120
|
-
/>
|
|
121
|
-
|
|
122
|
-
<div className="flex-1 min-w-0 lg:ml-[295px] flex flex-col lg:h-screen bg-[var(--color-bg-content,var(--color-bg-primary))]">
|
|
123
|
-
<Header
|
|
116
|
+
<div className="flex flex-col min-h-screen lg:h-screen lg:overflow-hidden">
|
|
117
|
+
<BannerBar config={config} />
|
|
118
|
+
<div className="flex flex-1 min-h-0 lg:overflow-hidden">
|
|
119
|
+
<Sidebar
|
|
124
120
|
config={config}
|
|
125
121
|
layout={layout}
|
|
126
|
-
|
|
127
|
-
|
|
122
|
+
isOpen={isSidebarOpen}
|
|
123
|
+
onClose={closeSidebar}
|
|
128
124
|
/>
|
|
129
|
-
<main
|
|
130
|
-
id="main-content"
|
|
131
|
-
className="flex-1 flex flex-col lg:min-h-0 transition-colors overflow-x-hidden"
|
|
132
|
-
>
|
|
133
|
-
{children}
|
|
134
|
-
</main>
|
|
135
|
-
</div>
|
|
136
125
|
|
|
137
|
-
|
|
138
|
-
|
|
126
|
+
<div className="flex-1 min-w-0 lg:ml-[295px] flex flex-col lg:h-full bg-[var(--color-bg-content,var(--color-bg-primary))]">
|
|
127
|
+
<Header
|
|
128
|
+
config={config}
|
|
129
|
+
layout={layout}
|
|
130
|
+
isSidebarOpen={isSidebarOpen}
|
|
131
|
+
onToggleSidebar={toggleSidebar}
|
|
132
|
+
/>
|
|
133
|
+
<main
|
|
134
|
+
id="main-content"
|
|
135
|
+
className="flex-1 flex flex-col lg:min-h-0 transition-colors overflow-x-hidden"
|
|
136
|
+
>
|
|
137
|
+
{children}
|
|
138
|
+
</main>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{isDesktop && <DesktopChatColumn />}
|
|
142
|
+
{chatOverlay}
|
|
143
|
+
</div>
|
|
139
144
|
</div>,
|
|
140
145
|
);
|
|
141
146
|
}
|
|
142
147
|
|
|
143
|
-
// Header-logo layout (Jam, Nebula) —
|
|
148
|
+
// Header-logo layout (Jam, Nebula) — flex-column shell so the banner sits
|
|
149
|
+
// above everything full-bleed; the row below fills the remaining height.
|
|
144
150
|
return wrapWithProvider(
|
|
145
|
-
<div className="flex min-h-screen lg:h-screen lg:overflow-hidden">
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
isSidebarOpen={isSidebarOpen}
|
|
154
|
-
onToggleSidebar={toggleSidebar}
|
|
155
|
-
/>
|
|
156
|
-
|
|
157
|
-
<TabsNav config={config} />
|
|
158
|
-
|
|
159
|
-
<div className="flex flex-1 lg:min-h-0">
|
|
160
|
-
<Sidebar
|
|
151
|
+
<div className="flex flex-col min-h-screen lg:h-screen lg:overflow-hidden">
|
|
152
|
+
<BannerBar config={config} />
|
|
153
|
+
<div className="flex flex-1 min-h-0 lg:overflow-hidden">
|
|
154
|
+
<div
|
|
155
|
+
className="flex-1 min-w-0 flex flex-col lg:overflow-hidden mx-auto px-4 lg:px-6"
|
|
156
|
+
style={{ maxWidth: 'var(--layout-max-width, none)' }}
|
|
157
|
+
>
|
|
158
|
+
<Header
|
|
161
159
|
config={config}
|
|
162
160
|
layout={layout}
|
|
163
|
-
|
|
164
|
-
|
|
161
|
+
isSidebarOpen={isSidebarOpen}
|
|
162
|
+
onToggleSidebar={toggleSidebar}
|
|
165
163
|
/>
|
|
166
164
|
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
165
|
+
<TabsNav config={config} />
|
|
166
|
+
|
|
167
|
+
<div className="flex flex-1 lg:min-h-0">
|
|
168
|
+
<Sidebar
|
|
169
|
+
config={config}
|
|
170
|
+
layout={layout}
|
|
171
|
+
isOpen={isSidebarOpen}
|
|
172
|
+
onClose={closeSidebar}
|
|
173
|
+
/>
|
|
174
|
+
|
|
175
|
+
<main
|
|
176
|
+
id="main-content"
|
|
177
|
+
className="flex-1 flex flex-col lg:min-h-0 bg-[var(--color-bg-primary)] transition-colors overflow-x-hidden"
|
|
178
|
+
>
|
|
179
|
+
{children}
|
|
180
|
+
</main>
|
|
181
|
+
</div>
|
|
173
182
|
</div>
|
|
174
|
-
</div>
|
|
175
183
|
|
|
176
|
-
|
|
177
|
-
|
|
184
|
+
{isDesktop && <DesktopChatColumn />}
|
|
185
|
+
{chatOverlay}
|
|
186
|
+
</div>
|
|
178
187
|
</div>,
|
|
179
188
|
);
|
|
180
189
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// builder/build-service/components/mdx/NativeSubscribeForm.tsx
|
|
2
2
|
'use client';
|
|
3
3
|
|
|
4
|
-
import { useEffect, useState, type CSSProperties, type FormEvent } from 'react';
|
|
4
|
+
import { useEffect, useState, type CSSProperties, type FormEvent, type ReactNode } from 'react';
|
|
5
5
|
|
|
6
6
|
export interface NativeSubscribeFormProps {
|
|
7
7
|
provider: string; // 'resend'
|
|
@@ -13,10 +13,11 @@ export interface NativeSubscribeFormProps {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
// Solid primary button (the collapsed trigger and the form's submit share it).
|
|
16
|
-
// Kept compact — the iOS 16px-min rule is for inputs, not buttons.
|
|
16
|
+
// Kept compact — the iOS 16px-min rule is for inputs, not buttons. Radius tracks
|
|
17
|
+
// the theme's button token (--radius-md) so it matches the docs' own buttons.
|
|
17
18
|
const PRIMARY_BUTTON: CSSProperties = {
|
|
18
|
-
fontSize: '0.
|
|
19
|
-
borderRadius: '0.375rem', cursor: 'pointer',
|
|
19
|
+
fontSize: '0.8125rem', padding: '0.35rem 0.75rem', border: 0,
|
|
20
|
+
borderRadius: 'var(--radius-md, 0.375rem)', cursor: 'pointer',
|
|
20
21
|
background: 'var(--color-primary, #2563eb)', color: '#fff',
|
|
21
22
|
};
|
|
22
23
|
|
|
@@ -26,6 +27,24 @@ const LINK_BUTTON: CSSProperties = {
|
|
|
26
27
|
cursor: 'pointer', textDecoration: 'underline', font: 'inherit',
|
|
27
28
|
};
|
|
28
29
|
|
|
30
|
+
// Themed card surface that gives the signup its own space. Built entirely from
|
|
31
|
+
// theme tokens — the same vocabulary the docs' <Card> uses — so it tracks the
|
|
32
|
+
// active theme: Nebula's sharp corners, Jam/Pulsar's flat (no-shadow) look, and
|
|
33
|
+
// each theme's border weight and colors, with hardcoded fallbacks only for
|
|
34
|
+
// contexts that ship no theme CSS. The secondary-bg surface sits a shade off the
|
|
35
|
+
// page's primary background; the email field below stays primary-bg, so it pops
|
|
36
|
+
// against the card (page → card → field layering, like the docs' code panels).
|
|
37
|
+
const CARD: CSSProperties = {
|
|
38
|
+
background: 'var(--color-bg-secondary, #f9fafb)',
|
|
39
|
+
border: 'var(--border-width, 1px) solid var(--color-border, #e4e4e7)',
|
|
40
|
+
borderRadius: 'var(--radius-lg, 0.75rem)',
|
|
41
|
+
boxShadow: 'var(--shadow-sm, none)',
|
|
42
|
+
padding: '1.25rem',
|
|
43
|
+
// Tidy widget width: the form inside caps at 28rem, so a wider card would leave
|
|
44
|
+
// dead space on the right. Capping keeps it a contained box, not a full-bleed band.
|
|
45
|
+
maxWidth: '32rem',
|
|
46
|
+
};
|
|
47
|
+
|
|
29
48
|
/** Site-wide "this visitor already subscribed" flag. Per-origin (localStorage is
|
|
30
49
|
* already scoped to the published site), so one signup is remembered across
|
|
31
50
|
* every changelog page rather than re-nagging the same reader. */
|
|
@@ -58,6 +77,25 @@ function subscribeEndpoint(): string {
|
|
|
58
77
|
return '/_jd/subscribe';
|
|
59
78
|
}
|
|
60
79
|
|
|
80
|
+
/** Base wrapper classes shared by every render state. `jd-emailsubscribe` (and
|
|
81
|
+
* the `--card` / `--placeholder` modifiers) are stable, intentionally-unstyled
|
|
82
|
+
* hooks for customer custom-CSS — no theme stylesheet targets them today. */
|
|
83
|
+
const BASE_CLASS = 'jd-emailsubscribe not-prose my-6';
|
|
84
|
+
|
|
85
|
+
/** Themed card wrapper around the active-form states (the expanded form and the
|
|
86
|
+
* post-submit confirmation). Keeps the box stable through submit so the layout
|
|
87
|
+
* never jumps mid-interaction. The compact `collapsed` trigger and the
|
|
88
|
+
* returning-subscriber line render bare instead — they're meant to recede, not
|
|
89
|
+
* occupy a full card. */
|
|
90
|
+
function Shell({ className, role, children }: { className?: string; role?: string; children: ReactNode }) {
|
|
91
|
+
return (
|
|
92
|
+
<div className={`${BASE_CLASS} jd-emailsubscribe--card ${className ?? ''}`.trim()}
|
|
93
|
+
role={role} style={CARD}>
|
|
94
|
+
{children}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
61
99
|
/**
|
|
62
100
|
* On-brand, first-party signup form for native-capture providers (Resend). Posts
|
|
63
101
|
* the email to Jamdesk's same-origin /_jd/subscribe; the customer's API key stays
|
|
@@ -107,14 +145,16 @@ export function NativeSubscribeForm({ provider, title, description, collapsed, c
|
|
|
107
145
|
}
|
|
108
146
|
}
|
|
109
147
|
|
|
110
|
-
const wrapperClass =
|
|
148
|
+
const wrapperClass = `${BASE_CLASS} ${className ?? ''}`.trim();
|
|
149
|
+
// Shared title heading — rendered identically in the two card states (done + form).
|
|
150
|
+
const titleEl = title ? <p className="mb-1 font-semibold text-theme-text-primary">{title}</p> : null;
|
|
111
151
|
|
|
112
152
|
if (status === 'done') {
|
|
113
153
|
return (
|
|
114
|
-
<
|
|
115
|
-
{
|
|
154
|
+
<Shell className={className} role="status">
|
|
155
|
+
{titleEl}
|
|
116
156
|
<p className="text-sm text-theme-text-secondary">Thanks — you’re subscribed.</p>
|
|
117
|
-
</
|
|
157
|
+
</Shell>
|
|
118
158
|
);
|
|
119
159
|
}
|
|
120
160
|
|
|
@@ -146,8 +186,8 @@ export function NativeSubscribeForm({ provider, title, description, collapsed, c
|
|
|
146
186
|
}
|
|
147
187
|
|
|
148
188
|
return (
|
|
149
|
-
<
|
|
150
|
-
{
|
|
189
|
+
<Shell className={className}>
|
|
190
|
+
{titleEl}
|
|
151
191
|
{description && <p className="mb-3 text-sm text-theme-text-secondary">{description}</p>}
|
|
152
192
|
<form onSubmit={onSubmit} style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center', maxWidth: '28rem' }}>
|
|
153
193
|
{/* Honeypot: a deliberately odd name (NOT website/url/email, which password
|
|
@@ -155,11 +195,12 @@ export function NativeSubscribeForm({ provider, title, description, collapsed, c
|
|
|
155
195
|
fill it; humans can't see it. Off-screen + aria-hidden + tabIndex -1. */}
|
|
156
196
|
<input type="text" name="jd_hp" tabIndex={-1} autoComplete="off" aria-hidden="true"
|
|
157
197
|
defaultValue="" style={{ position: 'absolute', left: '-5000px' }} />
|
|
158
|
-
{/* font-size:16px avoids iOS auto-zoom (monorepo UI rule).
|
|
198
|
+
{/* font-size:16px avoids iOS auto-zoom (monorepo UI rule). primary-bg field
|
|
199
|
+
pops against the card's secondary-bg surface; radius tracks the theme. */}
|
|
159
200
|
<input type="email" name="email" required value={email}
|
|
160
201
|
onChange={(e) => { setEmail(e.target.value); if (status === 'error') setStatus('idle'); }}
|
|
161
202
|
disabled={status === 'submitting'} placeholder="you@example.com" aria-label="Email address"
|
|
162
|
-
style={{ flex: 1, minWidth: 0, fontSize: '16px', padding: '0.
|
|
203
|
+
style={{ flex: 1, minWidth: 0, fontSize: '16px', padding: '0.35rem 0.7rem', border: 'var(--border-width, 1px) solid var(--color-border, #d4d4d8)', borderRadius: 'var(--radius-md, 0.375rem)', background: 'var(--color-bg-primary, #fff)', color: 'var(--color-text-primary, #18181b)' }} />
|
|
163
204
|
<button type="submit" disabled={status === 'submitting'} style={PRIMARY_BUTTON}>
|
|
164
205
|
{status === 'submitting' ? 'Subscribing…' : 'Subscribe'}
|
|
165
206
|
</button>
|
|
@@ -167,6 +208,6 @@ export function NativeSubscribeForm({ provider, title, description, collapsed, c
|
|
|
167
208
|
{status === 'error' && (
|
|
168
209
|
<p className="mt-2 text-sm text-amber-700 dark:text-amber-400" role="alert">Something went wrong. Please try again.</p>
|
|
169
210
|
)}
|
|
170
|
-
</
|
|
211
|
+
</Shell>
|
|
171
212
|
);
|
|
172
213
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
1
3
|
import { generateSlug } from '@/lib/heading-extractor';
|
|
4
|
+
import { useUpdateSlug } from './UpdateSlugContext';
|
|
2
5
|
|
|
3
6
|
interface UpdateProps {
|
|
4
7
|
label?: string;
|
|
@@ -12,6 +15,11 @@ interface UpdateProps {
|
|
|
12
15
|
* Generates a URL-friendly slug from a label string.
|
|
13
16
|
* Used to create anchor IDs for Update components.
|
|
14
17
|
* Uses shared generateSlug to stay in sync with TOC and link validation.
|
|
18
|
+
*
|
|
19
|
+
* Stateless: two Updates with the same label produce the same slug here. The
|
|
20
|
+
* Update component itself resolves duplicate-label collisions via useUpdateSlug
|
|
21
|
+
* (page-scoped dedup); this export stays for the no-provider fallback, the RSS
|
|
22
|
+
* feed's parallel anchor algorithm, and link validation.
|
|
15
23
|
*/
|
|
16
24
|
export function generateUpdateId(label?: string): string | undefined {
|
|
17
25
|
if (!label) return undefined;
|
|
@@ -21,9 +29,13 @@ export function generateUpdateId(label?: string): string | undefined {
|
|
|
21
29
|
/**
|
|
22
30
|
* Update component - for changelog/whatsnew entries.
|
|
23
31
|
* Creates timeline-style entries with automatic anchor links and TOC integration.
|
|
32
|
+
*
|
|
33
|
+
* Anchor id comes from useUpdateSlug so duplicate labels on one page (e.g. two
|
|
34
|
+
* "June 2026" entries) get distinct, TOC-aligned ids (`june-2026`,
|
|
35
|
+
* `june-2026-1`) instead of colliding on a single `#june-2026`.
|
|
24
36
|
*/
|
|
25
37
|
export function Update({ label, description, tags, date, children }: UpdateProps) {
|
|
26
|
-
const id =
|
|
38
|
+
const id = useUpdateSlug(label);
|
|
27
39
|
|
|
28
40
|
return (
|
|
29
41
|
<div
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useRef, ReactNode } from 'react';
|
|
4
|
+
import { generateSlug } from '@/lib/heading-extractor';
|
|
5
|
+
|
|
6
|
+
export interface UpdateSlugEntry {
|
|
7
|
+
label: string;
|
|
8
|
+
slug: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface UpdateSlugContextValue {
|
|
12
|
+
// Per-label counter — advances each time useUpdateSlug is called for that label.
|
|
13
|
+
counts: Map<string, number>;
|
|
14
|
+
entries: UpdateSlugEntry[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const UpdateSlugCtx = createContext<UpdateSlugContextValue | null>(null);
|
|
18
|
+
|
|
19
|
+
export function UpdateSlugProvider({
|
|
20
|
+
entries,
|
|
21
|
+
children,
|
|
22
|
+
}: {
|
|
23
|
+
entries: UpdateSlugEntry[];
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
}) {
|
|
26
|
+
// useRef so the same Map persists across renders. We rely on the same
|
|
27
|
+
// contract React's own useId rests on: source-order rendering during SSR
|
|
28
|
+
// and during client hydration produces matching ids.
|
|
29
|
+
const ref = useRef<UpdateSlugContextValue | null>(null);
|
|
30
|
+
if (ref.current === null || ref.current.entries !== entries) {
|
|
31
|
+
ref.current = { counts: new Map(), entries };
|
|
32
|
+
}
|
|
33
|
+
return <UpdateSlugCtx.Provider value={ref.current}>{children}</UpdateSlugCtx.Provider>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the unique slug for an <Update> with this label. Each call advances
|
|
38
|
+
* the per-label counter, so two Updates with the same label (e.g. two
|
|
39
|
+
* "June 2026" changelog entries) get sequential slugs from the provider's
|
|
40
|
+
* `entries` list (`june-2026`, `june-2026-1`) — matching the page-scoped
|
|
41
|
+
* github-slugger ids that extractHeadings (and the TOC) already compute. Falls
|
|
42
|
+
* back to a stateless `generateSlug(label)` if no provider is mounted
|
|
43
|
+
* (preview/storybook/etc), which preserves the pre-dedup single-Update behavior.
|
|
44
|
+
*/
|
|
45
|
+
export function useUpdateSlug(label: string | undefined): string | undefined {
|
|
46
|
+
const ctx = useContext(UpdateSlugCtx);
|
|
47
|
+
if (!label) return undefined;
|
|
48
|
+
if (!ctx) return generateSlug(label) || undefined;
|
|
49
|
+
|
|
50
|
+
const idx = ctx.counts.get(label) ?? 0;
|
|
51
|
+
ctx.counts.set(label, idx + 1);
|
|
52
|
+
|
|
53
|
+
let seen = 0;
|
|
54
|
+
for (const entry of ctx.entries) {
|
|
55
|
+
if (entry.label !== label) continue;
|
|
56
|
+
if (seen === idx) return entry.slug;
|
|
57
|
+
seen += 1;
|
|
58
|
+
}
|
|
59
|
+
return generateSlug(label) || undefined;
|
|
60
|
+
}
|
|
@@ -275,9 +275,12 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
275
275
|
// Different header positioning based on layout
|
|
276
276
|
// sidebar-logo: sticky (for Pulsar theme)
|
|
277
277
|
// header-logo: sticky (for Jam, Nebula - supports max-width constraint)
|
|
278
|
+
// `top` is offset by the banner height on mobile (where the page scrolls) so
|
|
279
|
+
// the sticky header parks just below the banner. On desktop the header lives
|
|
280
|
+
// in an overflow-hidden column already below the banner, so reset to 0.
|
|
278
281
|
const headerClasses = layout === 'sidebar-logo'
|
|
279
|
-
? 'sticky top-0 z-40 bg-[var(--color-bg-content,var(--color-bg-primary))]/80 backdrop-blur-md border-b border-[var(--color-border)] transition-colors'
|
|
280
|
-
: 'sticky top-0 z-50 bg-[var(--color-bg-primary)]/80 backdrop-blur-md transition-colors';
|
|
282
|
+
? 'sticky top-[var(--jd-banner-height,0px)] lg:top-0 z-40 bg-[var(--color-bg-content,var(--color-bg-primary))]/80 backdrop-blur-md border-b border-[var(--color-border)] transition-colors'
|
|
283
|
+
: 'sticky top-[var(--jd-banner-height,0px)] lg:top-0 z-50 bg-[var(--color-bg-primary)]/80 backdrop-blur-md transition-colors';
|
|
281
284
|
|
|
282
285
|
return (
|
|
283
286
|
<>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Last updated on <date>" line shown at the bottom of a docs page when the
|
|
3
|
+
* project enables `metadata.timestamp` in docs.json.
|
|
4
|
+
*
|
|
5
|
+
* The date string is injected into page frontmatter at build time (the date of
|
|
6
|
+
* the last git commit that touched the file — see lib/git-last-modified.ts).
|
|
7
|
+
* Rendered server-side as an absolute date so the output is deterministic and
|
|
8
|
+
* stable inside the ISR cache (no relative "N days ago" drift, no client JS).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Format in UTC so the displayed day matches the stored day regardless of the
|
|
12
|
+
// server's timezone. Built once at module load and reused across page renders.
|
|
13
|
+
const DATE_FORMAT = new Intl.DateTimeFormat('en-US', {
|
|
14
|
+
year: 'numeric',
|
|
15
|
+
month: 'long',
|
|
16
|
+
day: 'numeric',
|
|
17
|
+
timeZone: 'UTC',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** Format a stored YYYY-MM-DD (or ISO) date as e.g. "June 15, 2026". */
|
|
21
|
+
export function formatLastUpdated(value: string): string | null {
|
|
22
|
+
// Date-only strings parse as UTC midnight, matching DATE_FORMAT's timeZone.
|
|
23
|
+
const date = new Date(value);
|
|
24
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
25
|
+
return DATE_FORMAT.format(date);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface LastUpdatedProps {
|
|
29
|
+
/** Stored date string (YYYY-MM-DD), or undefined when unavailable. */
|
|
30
|
+
date?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function LastUpdated({ date }: LastUpdatedProps) {
|
|
34
|
+
if (!date) return null;
|
|
35
|
+
const formatted = formatLastUpdated(date);
|
|
36
|
+
if (!formatted) return null;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<p className="mt-8 text-sm text-[var(--color-text-muted)]">
|
|
40
|
+
Last updated on{' '}
|
|
41
|
+
<time dateTime={date}>{formatted}</time>
|
|
42
|
+
</p>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -694,7 +694,7 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
|
|
|
694
694
|
const showTopDivider = resolvedNav.externalAnchors.length > 0;
|
|
695
695
|
const hasTopTabsBar = getTabsFromConfig(config, pathname || undefined).length > 1 && !showTabsInSidebar;
|
|
696
696
|
const desktopSidebarClasses = layout === 'sidebar-logo'
|
|
697
|
-
? 'lg:fixed lg:top-
|
|
697
|
+
? 'lg:fixed lg:top-[var(--jd-banner-height,0px)] lg:left-0 lg:bottom-0 lg:z-40'
|
|
698
698
|
: 'lg:sticky lg:self-start lg:z-30 lg:flex-shrink-0';
|
|
699
699
|
|
|
700
700
|
return (
|