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.
@@ -12,19 +12,23 @@
12
12
  * Field mappings:
13
13
  * - modeToggle.default -> appearance.default
14
14
  * - modeToggle.isHidden -> appearance.strict
15
- * - metadata -> seo.metatags
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, 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;;;;;;;;;;;;;;;;;GAiBG;AAEH;;GAEG;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,MAAM,CAAC,CAAC;IAClC,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,CAiDpE"}
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 -> seo.metatags
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 -> seo.metatags
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
- warnings.push('metadata is deprecated. Use seo: { metatags: { ... } } instead.');
44
- const existingMetatags = normalized.seo?.metatags || {};
45
- normalized.seo = {
46
- ...normalized.seo,
47
- metatags: {
48
- ...metadata,
49
- ...existingMetatags,
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;;;;;;;;;;;;;;;;;GAiBG;AAgCH;;;;;;;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,wCAAwC;IACxC,IAAI,QAAQ,EAAE,CAAC;QACb,QAAQ,CAAC,IAAI,CACX,iEAAiE,CAClE,CAAC;QACF,MAAM,gBAAgB,GAAI,UAAU,CAAC,GAAW,EAAE,QAAQ,IAAI,EAAE,CAAC;QACjE,UAAU,CAAC,GAAG,GAAG;YACf,GAAG,UAAU,CAAC,GAAG;YACjB,QAAQ,EAAE;gBACR,GAAG,QAAQ;gBACX,GAAG,gBAAgB;aACpB;SACF,CAAC;IACJ,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"}
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.144",
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 content
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
- <Sidebar
116
- config={config}
117
- layout={layout}
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
- isSidebarOpen={isSidebarOpen}
127
- onToggleSidebar={toggleSidebar}
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
- {isDesktop && <DesktopChatColumn />}
138
- {chatOverlay}
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) — outer flex row for layout + chat column
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
- <div
147
- className="flex-1 min-w-0 flex flex-col lg:overflow-hidden mx-auto px-4 lg:px-6"
148
- style={{ maxWidth: 'var(--layout-max-width, none)' }}
149
- >
150
- <Header
151
- config={config}
152
- layout={layout}
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
- isOpen={isSidebarOpen}
164
- onClose={closeSidebar}
161
+ isSidebarOpen={isSidebarOpen}
162
+ onToggleSidebar={toggleSidebar}
165
163
  />
166
164
 
167
- <main
168
- id="main-content"
169
- className="flex-1 flex flex-col lg:min-h-0 bg-[var(--color-bg-primary)] transition-colors overflow-x-hidden"
170
- >
171
- {children}
172
- </main>
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
- {isDesktop && <DesktopChatColumn />}
177
- {chatOverlay}
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 = `jd-emailsubscribe not-prose my-6 ${className ?? ''}`.trim();
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
- <div className={wrapperClass} role="status">
115
- {title && <p className="mb-1 font-semibold text-theme-text-primary">{title}</p>}
154
+ <Shell className={className} role="status">
155
+ {titleEl}
116
156
  <p className="text-sm text-theme-text-secondary">Thanks — you&rsquo;re subscribed.</p>
117
- </div>
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
- <div className={wrapperClass}>
150
- {title && <p className="mb-1 font-semibold text-theme-text-primary">{title}</p>}
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
- </div>
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-0 lg:left-0 lg:bottom-0 lg:z-40'
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 filePath = path.join(this.contentDir, pagePath) + '.mdx';
55
- return fs.readFileSync(filePath, 'utf8');
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[]> {