jamdesk 1.1.144 → 1.1.145
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 +53 -12
- 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/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 +6 -0
- 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 +3 -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.145",
|
|
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
19
|
fontSize: '0.875rem', padding: '0.4rem 0.85rem', border: 0,
|
|
19
|
-
borderRadius: '0.375rem', cursor: 'pointer',
|
|
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.4rem 0.75rem', border: '1px solid var(--color-border, #d4d4d8)', borderRadius: '0.375rem', background: 'var(--color-bg-primary, #fff)', color: 'var(--color-text-primary, #18181b)' }} />
|
|
203
|
+
style={{ flex: 1, minWidth: 0, fontSize: '16px', padding: '0.4rem 0.75rem', 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
|
}
|
|
@@ -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 (
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Banner dismissal persistence. The dismissal is keyed to a hash of the banner
|
|
2
|
+
// content so that editing the message makes it reappear for everyone.
|
|
3
|
+
// Modeled on lib/recent-searches.ts (SSR-safe localStorage access).
|
|
4
|
+
|
|
5
|
+
const PREFIX = 'jamdesk_banner_dismissed_';
|
|
6
|
+
|
|
7
|
+
export function bannerDismissKey(slug: string): string {
|
|
8
|
+
return `${PREFIX}${slug}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Small deterministic djb2 hash → base36. Not cryptographic; only needs to
|
|
12
|
+
// change when the content changes and stay stable otherwise.
|
|
13
|
+
export function bannerHash(content: string): string {
|
|
14
|
+
let h = 5381;
|
|
15
|
+
for (let i = 0; i < content.length; i++) {
|
|
16
|
+
h = ((h << 5) + h + content.charCodeAt(i)) | 0;
|
|
17
|
+
}
|
|
18
|
+
return (h >>> 0).toString(36);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isBannerDismissed(slug: string, content: string): boolean {
|
|
22
|
+
if (typeof window === 'undefined') return false;
|
|
23
|
+
try {
|
|
24
|
+
return localStorage.getItem(bannerDismissKey(slug)) === bannerHash(content);
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function dismissBanner(slug: string, content: string): void {
|
|
31
|
+
if (typeof window === 'undefined') return;
|
|
32
|
+
try {
|
|
33
|
+
localStorage.setItem(bannerDismissKey(slug), bannerHash(content));
|
|
34
|
+
} catch {
|
|
35
|
+
// Storage unavailable (private mode, quota) — fail open; banner just won't
|
|
36
|
+
// remember the dismissal.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Inline JS for the pre-paint <head> script. When the stored hash matches the
|
|
42
|
+
* current content it sets `data-jd-banner-dismissed` on <html>, so CSS can hide
|
|
43
|
+
* the bar before first paint (no flash for repeat visitors). slug + hash are
|
|
44
|
+
* JSON-encoded into the string for safety.
|
|
45
|
+
*/
|
|
46
|
+
export function bannerNoFlashScript(slug: string, content: string): string {
|
|
47
|
+
// Defense-in-depth: JSON.stringify escapes `<` in V8 but the spec does not
|
|
48
|
+
// require it, so escape it ourselves — a slug can never break out of the
|
|
49
|
+
// surrounding <script> tag.
|
|
50
|
+
const key = JSON.stringify(bannerDismissKey(slug)).replace(/</g, '\\u003c');
|
|
51
|
+
const hash = JSON.stringify(bannerHash(content)).replace(/</g, '\\u003c');
|
|
52
|
+
return `(function(){try{if(localStorage.getItem(${key})===${hash}){document.documentElement.setAttribute('data-jd-banner-dismissed','')}}catch(e){}})();`;
|
|
53
|
+
}
|
|
@@ -14,6 +14,7 @@ import fs from 'fs';
|
|
|
14
14
|
import path from 'path';
|
|
15
15
|
import { parseFrontmatterLenient } from './frontmatter-utils';
|
|
16
16
|
import { getDocsConfig as getStaticDocsConfig, getContentDir } from './docs';
|
|
17
|
+
import { getGitLastModified, injectLastUpdated } from './page-timestamps';
|
|
17
18
|
import {
|
|
18
19
|
getDocsConfig as getIsrDocsConfig,
|
|
19
20
|
getMdxContent,
|
|
@@ -41,6 +42,9 @@ export interface ContentLoader {
|
|
|
41
42
|
*/
|
|
42
43
|
class StaticContentLoader implements ContentLoader {
|
|
43
44
|
private contentDir: string;
|
|
45
|
+
// Per-page git date cache (dev preview): avoids a git spawn on every re-render.
|
|
46
|
+
// null = looked up, no committed date (untracked/new file or non-git dir).
|
|
47
|
+
private dateCache = new Map<string, string | null>();
|
|
44
48
|
|
|
45
49
|
constructor() {
|
|
46
50
|
this.contentDir = getContentDir();
|
|
@@ -51,8 +55,19 @@ class StaticContentLoader implements ContentLoader {
|
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
async getContent(pagePath: string): Promise<string> {
|
|
54
|
-
const
|
|
55
|
-
|
|
58
|
+
const relPath = pagePath + '.mdx';
|
|
59
|
+
const content = fs.readFileSync(path.join(this.contentDir, relPath), 'utf8');
|
|
60
|
+
|
|
61
|
+
// Dev parity with the build: when metadata.timestamp is on, inject the
|
|
62
|
+
// page's last git-commit date so the renderer shows "Last updated on …".
|
|
63
|
+
if (!this.getConfig().metadata?.timestamp) return content;
|
|
64
|
+
|
|
65
|
+
let date = this.dateCache.get(pagePath);
|
|
66
|
+
if (date === undefined) {
|
|
67
|
+
date = getGitLastModified(this.contentDir, relPath);
|
|
68
|
+
this.dateCache.set(pagePath, date);
|
|
69
|
+
}
|
|
70
|
+
return date ? injectLastUpdated(content, date) : content;
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
async getAllPaths(): Promise<string[]> {
|