jamdesk 1.1.205 → 1.1.207
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/__tests__/unit/turbopack-loader-config-drift.test.d.ts +20 -0
- package/dist/__tests__/unit/turbopack-loader-config-drift.test.d.ts.map +1 -0
- package/dist/__tests__/unit/turbopack-loader-config-drift.test.js +79 -0
- package/dist/__tests__/unit/turbopack-loader-config-drift.test.js.map +1 -0
- package/dist/__tests__/unit/vendored-sync.test.js +9 -0
- package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
- package/dist/lib/deps.js +3 -3
- package/dist/lib/deps.js.map +1 -1
- package/package.json +5 -5
- package/vendored/app/[[...slug]]/page.tsx +1 -102
- package/vendored/app/api/jd/auth/logout/route.ts +36 -4
- package/vendored/app/api/jd/unlock/route.ts +1 -4
- package/vendored/app/layout.tsx +43 -4
- package/vendored/components/CodeBlockCopyButton.tsx +7 -2
- package/vendored/components/layout/LayoutWrapper.tsx +7 -0
- package/vendored/components/mdx/CodeGroup.tsx +161 -13
- package/vendored/components/mdx/MDXComponents.tsx +93 -50
- package/vendored/components/navigation/Header.tsx +7 -0
- package/vendored/components/navigation/LanguageSelector.tsx +35 -0
- package/vendored/components/navigation/ThemePreviewPicker.tsx +197 -0
- package/vendored/components/ui/CodePanel.tsx +88 -51
- package/vendored/components/ui/CodePanelModal.tsx +57 -36
- package/vendored/hooks/useWheelScrollChaining.ts +181 -0
- package/vendored/lib/auth-plane-write-warning.ts +84 -0
- package/vendored/lib/docs-types.ts +12 -0
- package/vendored/lib/jwt-key-build-warning.ts +38 -0
- package/vendored/lib/language-cookie.ts +20 -0
- package/vendored/lib/language-matcher.ts +124 -0
- package/vendored/lib/language-utils.ts +103 -0
- package/vendored/lib/languages-artifact.ts +160 -0
- package/vendored/lib/layout-helpers.tsx +16 -3
- package/vendored/lib/mdx-import-scan.ts +110 -0
- package/vendored/lib/middleware-helpers.ts +228 -1
- package/vendored/lib/page-timestamps.ts +36 -0
- package/vendored/lib/rehype-code-meta.ts +74 -9
- package/vendored/lib/render-doc-page.tsx +14 -3
- package/vendored/lib/revalidation-helpers.ts +3 -0
- package/vendored/lib/root-page-slug.ts +9 -4
- package/vendored/lib/shiki-transformers.ts +13 -0
- package/vendored/lib/static-artifacts.ts +70 -2
- package/vendored/lib/theme-preview-context.tsx +39 -0
- package/vendored/lib/theme-preview.ts +150 -0
- package/vendored/lib/unlock-audit.ts +10 -0
- package/vendored/next.config.js +32 -0
- package/vendored/schema/docs-schema.json +21 -0
- package/vendored/scripts/turbopack-js-to-ts-loader.cjs +51 -0
- package/vendored/workspace-package-lock.json +231 -177
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { ReactNode, Children, isValidElement, memo } from 'react';
|
|
3
|
+
import { ReactNode, ReactElement, Children, Fragment, cloneElement, isValidElement, memo } from 'react';
|
|
4
4
|
import { CodePanel, CodePanelTab } from '../ui/CodePanel';
|
|
5
5
|
import { formatLanguage } from '@/lib/code-utils';
|
|
6
6
|
|
|
@@ -11,6 +11,13 @@ interface CodeGroupProps {
|
|
|
11
11
|
// Loose shape of `pre`/`code` props from MDX/Shiki — only the fields we read.
|
|
12
12
|
type PreProps = {
|
|
13
13
|
'data-language'?: string;
|
|
14
|
+
'data-nocopy'?: string;
|
|
15
|
+
// The author's own fence meta, when rehypeRestoreDataTitle classified it as
|
|
16
|
+
// a language/tool token and so declined to promote it to data-title.
|
|
17
|
+
'data-language-label'?: string;
|
|
18
|
+
// rehypeCodeMeta/rehypeRestoreDataTitle mirror the parsed fence title onto
|
|
19
|
+
// the pre as well as the nested code element.
|
|
20
|
+
'data-title'?: string;
|
|
14
21
|
children?: ReactNode;
|
|
15
22
|
};
|
|
16
23
|
type CodeProps = {
|
|
@@ -47,13 +54,25 @@ function getRawLanguage(child: ReactNode): string | undefined {
|
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
/**
|
|
50
|
-
* Extract title
|
|
51
|
-
*
|
|
57
|
+
* Extract the title of a code block (e.g., title="utils.js").
|
|
58
|
+
*
|
|
59
|
+
* The pre's own data-title is checked FIRST because it is the only one that
|
|
60
|
+
* survives a real render: rehypeCodeMeta stamps data-title on both the pre
|
|
61
|
+
* and its nested code element, but Shiki then rebuilds the pre/code subtree
|
|
62
|
+
* from scratch and only rehypeRestoreDataTitle's pre-level attribute comes
|
|
63
|
+
* out the other side. Reading solely off the code element (as this did
|
|
64
|
+
* before) returned undefined for every compiled-MDX block — invisible while
|
|
65
|
+
* the enclosing filter matched nothing, and a dropped caption the moment it
|
|
66
|
+
* did. The code-element lookup stays as the fallback for hand-built
|
|
67
|
+
* children, which carry the title there.
|
|
52
68
|
*/
|
|
53
69
|
function getTitle(child: ReactNode): string | undefined {
|
|
54
70
|
if (!isValidElement(child)) return undefined;
|
|
55
71
|
|
|
56
72
|
const childProps = child.props as PreProps;
|
|
73
|
+
const preTitle = childProps['data-title'];
|
|
74
|
+
if (preTitle) return preTitle;
|
|
75
|
+
|
|
57
76
|
const codeElement = childProps?.children;
|
|
58
77
|
|
|
59
78
|
if (!isValidElement(codeElement)) return undefined;
|
|
@@ -62,15 +81,40 @@ function getTitle(child: ReactNode): string | undefined {
|
|
|
62
81
|
return codeProps['data-title'];
|
|
63
82
|
}
|
|
64
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Whether a pre element carries the nocopy flag. Read directly off the pre's
|
|
86
|
+
* own props — data-nocopy is set on the PRE element itself by the Shiki
|
|
87
|
+
* transformer (shiki-transformers.ts, transformerLineFeatures().pre()), the
|
|
88
|
+
* same place getRawLanguage reads data-language from, not getTitle's
|
|
89
|
+
* off-the-nested-code-element lookup.
|
|
90
|
+
*/
|
|
91
|
+
function getNocopy(child: ReactNode): boolean {
|
|
92
|
+
if (!isValidElement(child)) return false;
|
|
93
|
+
|
|
94
|
+
const childProps = child.props as PreProps;
|
|
95
|
+
return childProps['data-nocopy'] !== undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
65
98
|
/**
|
|
66
99
|
* Extract tab label from a pre element
|
|
67
|
-
* Priority: data-language > data-meta > className language-*
|
|
100
|
+
* Priority: data-language-label > data-language > data-meta > className language-*
|
|
101
|
+
*
|
|
102
|
+
* data-language-label comes first because it is what the AUTHOR typed after
|
|
103
|
+
* the language (```bash npm), and in a CodeGroup the fence meta is the tab
|
|
104
|
+
* label — Mintlify's semantics. Falling straight through to data-language
|
|
105
|
+
* instead rendered `npm`, `yarn` and `bun` all as "cURL" (lib/code-utils.ts
|
|
106
|
+
* maps bash -> cURL for RequestExample, where it is correct), so 75 groups in
|
|
107
|
+
* projects/ showed two or three indistinguishable tabs and a reader could not
|
|
108
|
+
* pick npm over yarn.
|
|
68
109
|
*/
|
|
69
110
|
function getTabLabel(child: ReactNode): string {
|
|
70
111
|
if (!isValidElement(child)) return 'Code';
|
|
71
112
|
|
|
72
113
|
const childProps = child.props as PreProps;
|
|
73
114
|
|
|
115
|
+
const authoredLabel = childProps['data-language-label'];
|
|
116
|
+
if (authoredLabel) return authoredLabel;
|
|
117
|
+
|
|
74
118
|
// Check for data-language on the pre element (added by Shiki transformer)
|
|
75
119
|
const preLanguage = childProps['data-language'];
|
|
76
120
|
if (preLanguage) {
|
|
@@ -82,7 +126,11 @@ function getTabLabel(child: ReactNode): string {
|
|
|
82
126
|
|
|
83
127
|
const codeProps = codeElement.props as CodeProps;
|
|
84
128
|
|
|
85
|
-
// Check for title in data-meta or meta props (e.g., "Success Response")
|
|
129
|
+
// Check for title in data-meta or meta props (e.g., "Success Response").
|
|
130
|
+
// Reachable only for hand-built children: compiled MDX never gets here,
|
|
131
|
+
// because Shiki rebuilds the code subtree and drops rehypeCodeMeta's
|
|
132
|
+
// code-level data-meta, exactly as it drops code-level data-title (see
|
|
133
|
+
// getTitle above). Kept so JSX callers behave as they always have.
|
|
86
134
|
const metaString = codeProps['data-meta'] || codeProps.meta || '';
|
|
87
135
|
if (metaString) {
|
|
88
136
|
return metaString;
|
|
@@ -99,33 +147,133 @@ function getTabLabel(child: ReactNode): string {
|
|
|
99
147
|
return 'Code';
|
|
100
148
|
}
|
|
101
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Property name stamped on MDXComponents' code-fence renderer (MdxPreBlock).
|
|
152
|
+
* Keep this string literal identical to the marker assignment just below
|
|
153
|
+
* MdxPreBlock in MDXComponents.tsx — the long doc comment above that
|
|
154
|
+
* function explains why the two files agree on a marker property instead of
|
|
155
|
+
* this file importing MdxPreBlock for a `===` identity check.
|
|
156
|
+
*/
|
|
157
|
+
const CODE_FENCE_MARKER = 'jdCodeFenceMarker';
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Unwrap one <CodeGroup> child down to its code-fence element, or null when
|
|
161
|
+
* the child is not a fence at all.
|
|
162
|
+
*
|
|
163
|
+
* Two layers have to be peeled, and the pre-fix filter (`child.type ===
|
|
164
|
+
* 'pre'`) saw through neither, which is why no <CodeGroup> in production
|
|
165
|
+
* ever rendered a tab strip:
|
|
166
|
+
*
|
|
167
|
+
* 1. Compiled MDX hands each fence over wrapped in a single-child
|
|
168
|
+
* Fragment, not as the fence element directly.
|
|
169
|
+
* 2. Inside that Fragment the element's `type` is `_components.pre` — in
|
|
170
|
+
* this app the MdxPreBlock *function* — never the string 'pre'.
|
|
171
|
+
*
|
|
172
|
+
* The literal-'pre' case is kept because it costs nothing and leaves
|
|
173
|
+
* callers that hand-build host elements behaving exactly as before.
|
|
174
|
+
*/
|
|
175
|
+
function unwrapCodeFence(child: ReactNode): ReactElement | null {
|
|
176
|
+
if (!isValidElement(child)) return null;
|
|
177
|
+
|
|
178
|
+
const { type } = child;
|
|
179
|
+
if (type === 'pre') return child;
|
|
180
|
+
if (
|
|
181
|
+
typeof type === 'function' &&
|
|
182
|
+
(type as unknown as Record<string, unknown>)[CODE_FENCE_MARKER] === true
|
|
183
|
+
) {
|
|
184
|
+
return child;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Only a *single-child* Fragment is transparent here. A multi-child one
|
|
188
|
+
// carries content besides the fence, which must not be silently dropped.
|
|
189
|
+
if (type === Fragment) {
|
|
190
|
+
const inner = Children.toArray((child.props as { children?: ReactNode }).children);
|
|
191
|
+
return inner.length === 1 ? unwrapCodeFence(inner[0]) : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Children that are neither code fences nor the blank text nodes MDX emits
|
|
199
|
+
* between block children. These are rendered after the panel so a
|
|
200
|
+
* <CodeGroup> cannot silently eat authored prose: before this fix such
|
|
201
|
+
* content was visible (the whole group fell back to a plain <div>), so
|
|
202
|
+
* dropping it now would be a content regression rather than a no-op.
|
|
203
|
+
*/
|
|
204
|
+
function isRenderableExtra(child: ReactNode): boolean {
|
|
205
|
+
if (unwrapCodeFence(child) !== null) return false;
|
|
206
|
+
if (typeof child === 'string') return child.trim().length > 0;
|
|
207
|
+
return child !== null && child !== undefined && typeof child !== 'boolean';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Tab content for one fence.
|
|
212
|
+
*
|
|
213
|
+
* A fence carrying data-title makes MdxPreBlock take its titled branch and
|
|
214
|
+
* render a whole CodePanel of its own. Dropped straight into a tab that
|
|
215
|
+
* would be a panel nested inside a panel — two borders, two headers, two
|
|
216
|
+
* copy buttons. Strip the attribute so MdxPreBlock yields the bare <pre>
|
|
217
|
+
* the panel body expects; the title is not lost, it moves to the tab label
|
|
218
|
+
* (see getTabLabel's caller below).
|
|
219
|
+
*/
|
|
220
|
+
function toTabContent(block: ReactElement): ReactNode {
|
|
221
|
+
if ((block.props as PreProps)['data-title'] === undefined) return block;
|
|
222
|
+
return cloneElement(block as ReactElement<Record<string, unknown>>, { 'data-title': undefined });
|
|
223
|
+
}
|
|
224
|
+
|
|
102
225
|
/**
|
|
103
226
|
* CodeGroup wraps multiple code blocks in a tabbed interface.
|
|
104
227
|
* Uses the shared CodePanel component for consistent styling.
|
|
105
228
|
*/
|
|
106
229
|
export const CodeGroup = memo(function CodeGroup({ children }: CodeGroupProps) {
|
|
107
230
|
// Extract code blocks from children
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
231
|
+
const allChildren = Children.toArray(children);
|
|
232
|
+
const codeBlocks = allChildren
|
|
233
|
+
.map(unwrapCodeFence)
|
|
234
|
+
.filter((block): block is ReactElement => block !== null);
|
|
111
235
|
|
|
112
236
|
if (codeBlocks.length === 0) {
|
|
113
237
|
return <div>{children}</div>;
|
|
114
238
|
}
|
|
115
239
|
|
|
240
|
+
const extras = allChildren.filter(isRenderableExtra);
|
|
241
|
+
|
|
242
|
+
// Extract title from first code block (only shown for single blocks)
|
|
243
|
+
const title = codeBlocks.length === 1 ? getTitle(codeBlocks[0]) : undefined;
|
|
244
|
+
|
|
116
245
|
// Convert code blocks to CodePanel tabs
|
|
117
246
|
// Note: Icons disabled by default - can be enabled via docs.json in future
|
|
118
247
|
const tabs: CodePanelTab[] = codeBlocks.map((block) => {
|
|
119
248
|
const language = getRawLanguage(block);
|
|
249
|
+
// A single block already shows its title as the panel caption above the
|
|
250
|
+
// strip, so labelling its one tab with the same string would just print
|
|
251
|
+
// the filename twice. Multi-block groups have no caption, so the title
|
|
252
|
+
// is the tab label — which is also the label Mintlify documents
|
|
253
|
+
// (`\`\`\`javascript helloWorld.js` → a "helloWorld.js" tab) and the only
|
|
254
|
+
// place the filename still appears once toTabContent strips it.
|
|
255
|
+
const fenceTitle = codeBlocks.length === 1 ? undefined : getTitle(block);
|
|
120
256
|
return {
|
|
121
|
-
label: getTabLabel(block),
|
|
122
|
-
content: block
|
|
257
|
+
label: fenceTitle ?? getTabLabel(block),
|
|
258
|
+
content: toTabContent(block),
|
|
123
259
|
language,
|
|
260
|
+
// Per tab, not per panel. The earlier panel-wide flag had to be gated
|
|
261
|
+
// on `codeBlocks.length === 1` so a nocopy fence could not hide copy
|
|
262
|
+
// for its unflagged siblings — which meant nocopy did nothing at all
|
|
263
|
+
// inside a multi-fence group, where the pre-tab-strip <div> path used
|
|
264
|
+
// to suppress it (CodeBlockCopyButton skips pre.shiki[data-nocopy]).
|
|
265
|
+
hideCopy: getNocopy(block),
|
|
124
266
|
};
|
|
125
267
|
});
|
|
126
268
|
|
|
127
|
-
|
|
128
|
-
const title = codeBlocks.length === 1 ? getTitle(codeBlocks[0]) : undefined;
|
|
269
|
+
const panel = <CodePanel tabs={tabs} title={title} className="my-6" enableFullscreen />;
|
|
129
270
|
|
|
130
|
-
|
|
271
|
+
if (extras.length === 0) return panel;
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<>
|
|
275
|
+
{panel}
|
|
276
|
+
{extras}
|
|
277
|
+
</>
|
|
278
|
+
);
|
|
131
279
|
});
|
|
@@ -179,6 +179,98 @@ function isVideoUrl(src: string | undefined): boolean {
|
|
|
179
179
|
return VIDEO_EXTENSIONS_IMG.some(ext => pathOnly.toLowerCase().endsWith(ext));
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Renders a fenced code block. Marked with CODE_FENCE_MARKER (a static
|
|
184
|
+
* property, not identity) so CodeGroup.tsx can recognize its own children —
|
|
185
|
+
* compiled MDX always passes the *component function* as `_components.pre`
|
|
186
|
+
* (never the string 'pre'), so CodeGroup's `Children.toArray(children)`
|
|
187
|
+
* sees unresolved elements whose `.type` is this function reference, not
|
|
188
|
+
* its rendered output. Verified against a real compile(): each fence also
|
|
189
|
+
* arrives wrapped in a single-child Fragment, so CodeGroup unwraps one
|
|
190
|
+
* layer before testing the marker.
|
|
191
|
+
*
|
|
192
|
+
* A marker property, not a direct import of this function for `===`
|
|
193
|
+
* comparison, on purpose: MDXComponents already imports CodeGroup (to
|
|
194
|
+
* register it as the <CodeGroup> tag below), so CodeGroup importing
|
|
195
|
+
* MDXComponents back would be circular; and reference equality would be
|
|
196
|
+
* fragile across the dashboard's and CLI's separately vendored copies of
|
|
197
|
+
* this file if either ever mounted more than one instance. Keep this
|
|
198
|
+
* function's marker assignment (just below its definition) and
|
|
199
|
+
* CodeGroup.tsx's CODE_FENCE_MARKER string literal identical — both files
|
|
200
|
+
* are always vendored together as one unit (vendor-builder.js and
|
|
201
|
+
* cli/scripts/vendor.js both copy the whole components/ directory), so they
|
|
202
|
+
* can't drift apart from a partial vendor, only from an un-mirrored edit.
|
|
203
|
+
*/
|
|
204
|
+
function MdxPreBlock({
|
|
205
|
+
children,
|
|
206
|
+
'data-title': dataTitle,
|
|
207
|
+
'data-nocopy': dataNocopy,
|
|
208
|
+
...props
|
|
209
|
+
}: HTMLAttributes<HTMLPreElement> & { 'data-title'?: string; 'data-nocopy'?: string }) {
|
|
210
|
+
const language = getCodeLanguage(props, children);
|
|
211
|
+
|
|
212
|
+
// Check for mermaid diagrams - render with Mermaid component
|
|
213
|
+
if (language === 'mermaid') {
|
|
214
|
+
const diagramCode = extractTextFromChildren(children);
|
|
215
|
+
return (
|
|
216
|
+
<>
|
|
217
|
+
<Mermaid>{diagramCode}</Mermaid>
|
|
218
|
+
{/* Crawler / AI fallback: the client-only SVG leaves the server HTML empty.
|
|
219
|
+
Use <noscript>, NOT sr-only: a JS-disabled crawler reads it, while users
|
|
220
|
+
with JS (and screen readers) get the real rendered diagram and never see
|
|
221
|
+
duplicate text. */}
|
|
222
|
+
{diagramCode.trim() && (
|
|
223
|
+
<noscript>
|
|
224
|
+
<pre>{diagramCode}</pre>
|
|
225
|
+
</noscript>
|
|
226
|
+
)}
|
|
227
|
+
</>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Check for HTTP code blocks - render with special styling
|
|
232
|
+
if (language === 'http') {
|
|
233
|
+
const codeText = extractTextFromChildren(children);
|
|
234
|
+
const parsed = parseHttpRequest(codeText);
|
|
235
|
+
if (parsed) {
|
|
236
|
+
return <HttpCodeBlock method={parsed.method} url={parsed.url} />;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// If no title, render the pre element directly
|
|
241
|
+
// Copy button is added by CodeBlockCopyButton client component, which
|
|
242
|
+
// reads data-nocopy straight off this DOM node — keep it in place here
|
|
243
|
+
// (unlike the CodePanel path below, where CodePanel's own hideCopy prop
|
|
244
|
+
// does the suppressing instead).
|
|
245
|
+
if (!dataTitle) {
|
|
246
|
+
return (
|
|
247
|
+
<pre {...props} data-nocopy={dataNocopy}>
|
|
248
|
+
{children}
|
|
249
|
+
</pre>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Wrap in CodePanel to show the title. `data-nocopy` is deliberately left
|
|
254
|
+
// off this inner <pre> — CodeBlockCopyButton skips anything inside
|
|
255
|
+
// [data-code-panel] regardless, and CodePanel's own copy button(s) are
|
|
256
|
+
// suppressed via `hideCopy` instead.
|
|
257
|
+
const preElement = <pre {...props}>{children}</pre>;
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<CodePanel
|
|
261
|
+
tabs={[{ label: language, content: preElement, language }]}
|
|
262
|
+
title={dataTitle}
|
|
263
|
+
hideTabs={true}
|
|
264
|
+
className="my-6"
|
|
265
|
+
enableFullscreen
|
|
266
|
+
hideCopy={dataNocopy !== undefined}
|
|
267
|
+
/>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
// See the doc comment above MdxPreBlock — must match CodeGroup.tsx's
|
|
271
|
+
// CODE_FENCE_MARKER string literal exactly.
|
|
272
|
+
(MdxPreBlock as unknown as { jdCodeFenceMarker?: true }).jdCodeFenceMarker = true;
|
|
273
|
+
|
|
182
274
|
export const MDXComponents = {
|
|
183
275
|
Card,
|
|
184
276
|
// Callout components
|
|
@@ -411,56 +503,7 @@ export const MDXComponents = {
|
|
|
411
503
|
/>
|
|
412
504
|
),
|
|
413
505
|
// Custom pre component to wrap code blocks with titles in CodePanel
|
|
414
|
-
pre:
|
|
415
|
-
const language = getCodeLanguage(props, children);
|
|
416
|
-
|
|
417
|
-
// Check for mermaid diagrams - render with Mermaid component
|
|
418
|
-
if (language === 'mermaid') {
|
|
419
|
-
const diagramCode = extractTextFromChildren(children);
|
|
420
|
-
return (
|
|
421
|
-
<>
|
|
422
|
-
<Mermaid>{diagramCode}</Mermaid>
|
|
423
|
-
{/* Crawler / AI fallback: the client-only SVG leaves the server HTML empty.
|
|
424
|
-
Use <noscript>, NOT sr-only: a JS-disabled crawler reads it, while users
|
|
425
|
-
with JS (and screen readers) get the real rendered diagram and never see
|
|
426
|
-
duplicate text. */}
|
|
427
|
-
{diagramCode.trim() && (
|
|
428
|
-
<noscript>
|
|
429
|
-
<pre>{diagramCode}</pre>
|
|
430
|
-
</noscript>
|
|
431
|
-
)}
|
|
432
|
-
</>
|
|
433
|
-
);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// Check for HTTP code blocks - render with special styling
|
|
437
|
-
if (language === 'http') {
|
|
438
|
-
const codeText = extractTextFromChildren(children);
|
|
439
|
-
const parsed = parseHttpRequest(codeText);
|
|
440
|
-
if (parsed) {
|
|
441
|
-
return <HttpCodeBlock method={parsed.method} url={parsed.url} />;
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// If no title, render the pre element directly
|
|
446
|
-
// Copy button is added by CodeBlockCopyButton client component
|
|
447
|
-
if (!dataTitle) {
|
|
448
|
-
return <pre {...props}>{children}</pre>;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// Wrap in CodePanel to show the title
|
|
452
|
-
const preElement = <pre {...props}>{children}</pre>;
|
|
453
|
-
|
|
454
|
-
return (
|
|
455
|
-
<CodePanel
|
|
456
|
-
tabs={[{ label: language, content: preElement, language }]}
|
|
457
|
-
title={dataTitle}
|
|
458
|
-
hideTabs={true}
|
|
459
|
-
className="my-6"
|
|
460
|
-
enableFullscreen
|
|
461
|
-
/>
|
|
462
|
-
);
|
|
463
|
-
},
|
|
506
|
+
pre: MdxPreBlock,
|
|
464
507
|
// Inline code styling is done via CSS in base.css (.prose :not(pre) > code)
|
|
465
508
|
table: (props: TableHTMLAttributes<HTMLTableElement>) => (
|
|
466
509
|
<div className="overflow-x-auto my-6">
|
|
@@ -13,6 +13,7 @@ import { ThemeToggle } from '@/components/theme/ThemeToggle';
|
|
|
13
13
|
import { LazySearchModal as SearchModal } from '@/components/search/LazySearchModal';
|
|
14
14
|
import { DefaultLogo } from './DefaultLogo';
|
|
15
15
|
import { LanguageSelector } from './LanguageSelector';
|
|
16
|
+
import { ThemePreviewPicker } from './ThemePreviewPicker';
|
|
16
17
|
import LogoutButton from './LogoutButton';
|
|
17
18
|
import { resolveNavigation } from '@/lib/navigation-resolver';
|
|
18
19
|
import { getIconClass } from '@/lib/icon-utils';
|
|
@@ -555,6 +556,12 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
555
556
|
renders nothing until the client confirms an active session. */}
|
|
556
557
|
{config.auth?.jwt?.enabled === true && <LogoutButton />}
|
|
557
558
|
|
|
559
|
+
{/* Theme preview picker — jamdesk-docs only. The component returns
|
|
560
|
+
null unless the ThemePreviewProvider enables it, and carries its
|
|
561
|
+
own `hidden lg:block`, so there is no stray wrapper on customer
|
|
562
|
+
sites. */}
|
|
563
|
+
<ThemePreviewPicker />
|
|
564
|
+
|
|
558
565
|
{/* Theme toggle - hidden on mobile since it's in the sidebar menu.
|
|
559
566
|
Also hidden when appearance.strict pins users to the configured mode. */}
|
|
560
567
|
{!config.appearance?.strict && (
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
extractLanguageFromPath,
|
|
14
14
|
toHreflang,
|
|
15
15
|
} from '@/lib/language-utils';
|
|
16
|
+
import { LANGUAGE_COOKIE } from '@/lib/language-cookie';
|
|
16
17
|
import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
17
18
|
import { useProjectSlug } from '@/lib/project-slug-context';
|
|
18
19
|
import { getUiStrings } from '@/lib/ui-strings';
|
|
@@ -80,6 +81,40 @@ export function LanguageSelector({
|
|
|
80
81
|
// Find actual default language from config
|
|
81
82
|
const actualDefault = displayedLanguages.find((l) => l.isDefault)?.code || defaultLanguage;
|
|
82
83
|
|
|
84
|
+
// One-time migration: a preference saved before server-side routing existed
|
|
85
|
+
// lives only in localStorage, so the edge cannot see it and would negotiate
|
|
86
|
+
// over the top of it. Seed the cookie from it once. saveLanguagePreference
|
|
87
|
+
// writes both stores, so this converges after a single page view and is a
|
|
88
|
+
// no-op on every visit after that.
|
|
89
|
+
//
|
|
90
|
+
// Ordering, stated plainly: this effect runs AFTER the page has already
|
|
91
|
+
// loaded, which is AFTER any server-side 307 has already picked a language
|
|
92
|
+
// and pinned its own cookie — no client code can run before a redirect it
|
|
93
|
+
// hasn't been GET to yet. So a visitor with a stale localStorage
|
|
94
|
+
// preference and no cookie can land on one wrong-language page (server
|
|
95
|
+
// negotiated from Accept-Language, disagreeing with their saved choice);
|
|
96
|
+
// this effect then corrects the cookie on that same page. The wrong
|
|
97
|
+
// language is shown exactly once — their NEXT visit to the root reads the
|
|
98
|
+
// corrected cookie (decideLanguageRedirect prioritizes cookie over
|
|
99
|
+
// Accept-Language) and self-heals. See
|
|
100
|
+
// __tests__/lib/language-cookie-self-heal.test.ts. Inherent, not a bug —
|
|
101
|
+
// do not try to make this effect win a race it structurally cannot enter.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const saved = getLanguagePreference(projectSlug);
|
|
104
|
+
if (!saved) return;
|
|
105
|
+
let current: string | undefined;
|
|
106
|
+
try {
|
|
107
|
+
current = document.cookie
|
|
108
|
+
.split('; ')
|
|
109
|
+
.find((c) => c.startsWith(`${LANGUAGE_COOKIE}=`))
|
|
110
|
+
?.split('=')[1];
|
|
111
|
+
} catch {
|
|
112
|
+
return; // cookies unavailable — nothing to migrate into
|
|
113
|
+
}
|
|
114
|
+
if (current !== saved) saveLanguagePreference(saved, projectSlug);
|
|
115
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only by design; see comment above
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
83
118
|
// Check localStorage preference on mount and redirect if needed.
|
|
84
119
|
// Intentionally mount-only: re-running on pathname/language changes would
|
|
85
120
|
// fight the user's in-session navigation choices (they may have explicitly
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useOnClickOutside } from '@/hooks/useOnClickOutside';
|
|
5
|
+
import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
6
|
+
import { useThemePreview } from '@/lib/theme-preview-context';
|
|
7
|
+
import { THEME_PREVIEW_COOKIE } from '@/lib/theme-preview';
|
|
8
|
+
import { getAllThemes, type ThemeName } from '@/themes';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Jamdesk-only affordance: re-skins jamdesk.com/docs with any built-in theme so
|
|
12
|
+
* prospects can see all four on a real docs site.
|
|
13
|
+
*
|
|
14
|
+
* The gate is server-side (lib/theme-preview.ts decides whether the provider is
|
|
15
|
+
* enabled); this component ALSO self-guards on `enabled` so a mistake in
|
|
16
|
+
* Header's conditional can never leak it onto a customer site.
|
|
17
|
+
*
|
|
18
|
+
* Strings are English-only by design. jamdesk-docs has fr/es locales, but the
|
|
19
|
+
* only prose here is "Reset to default" and "Default" — everything else is a
|
|
20
|
+
* proper noun — and adding entries to lib/ui-strings.ts for a sales affordance
|
|
21
|
+
* isn't worth the churn across every locale.
|
|
22
|
+
*/
|
|
23
|
+
export function ThemePreviewPicker() {
|
|
24
|
+
const { enabled, activeTheme, defaultTheme } = useThemePreview();
|
|
25
|
+
const linkPrefix = useLinkPrefix();
|
|
26
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
27
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
28
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
29
|
+
const listRef = useRef<HTMLUListElement>(null);
|
|
30
|
+
|
|
31
|
+
// Every close path but a theme selection runs through here. A click-outside
|
|
32
|
+
// would otherwise unmount the focused option and drop focus to <body>, so
|
|
33
|
+
// the visitor's next Tab restarts at the top of the document. Synchronous on
|
|
34
|
+
// purpose: this runs during the mousedown dispatch, so a click that lands on
|
|
35
|
+
// something focusable still ends up there — the browser's own focus default
|
|
36
|
+
// wins afterwards, which is what it should do.
|
|
37
|
+
function closeAndRestoreFocus() {
|
|
38
|
+
const hadFocus = !!containerRef.current?.contains(document.activeElement);
|
|
39
|
+
setIsOpen(false);
|
|
40
|
+
if (hadFocus) buttonRef.current?.focus();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
useOnClickOutside(containerRef, closeAndRestoreFocus, isOpen);
|
|
44
|
+
|
|
45
|
+
// Move focus onto the active option when the menu opens. Without this the
|
|
46
|
+
// trigger keeps focus and Tab is the only way forward — and an earlier cut of
|
|
47
|
+
// this component closed the menu on Tab (copied from LanguageSelector, where
|
|
48
|
+
// that is safe only because ArrowUp/Down have already moved focus into the
|
|
49
|
+
// list), which left the widget keyboard-inoperable: WCAG 2.1.1.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!isOpen) return;
|
|
52
|
+
const options = listRef.current?.querySelectorAll<HTMLElement>('[role="option"]');
|
|
53
|
+
if (!options?.length) return;
|
|
54
|
+
const active = Array.from(options).find((o) => o.getAttribute('aria-selected') === 'true');
|
|
55
|
+
(active ?? options[0]).focus();
|
|
56
|
+
}, [isOpen]);
|
|
57
|
+
|
|
58
|
+
if (!enabled) return null;
|
|
59
|
+
|
|
60
|
+
// Escape only. Arrow-key roving focus is deliberately NOT copied from
|
|
61
|
+
// LanguageSelector: its options are a customer-configurable list, whereas
|
|
62
|
+
// this is a fixed four-row Jamdesk-only affordance whose rows are ordinary
|
|
63
|
+
// buttons that Tab already walks. Escape must stay — it is the way back out
|
|
64
|
+
// of a menu focus has just been moved into.
|
|
65
|
+
function handleKeyDown(e: React.KeyboardEvent) {
|
|
66
|
+
if (e.key === 'Escape') closeAndRestoreFocus();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Tabbing past the last row leaves the widget; close behind it. This is what
|
|
70
|
+
// LanguageSelector's Tab branch achieved, without eating the Tab that is this
|
|
71
|
+
// component's only way INTO the option list.
|
|
72
|
+
function handleFocusOut(e: React.FocusEvent) {
|
|
73
|
+
// A null relatedTarget means focus landed on <body>, NOT that focus left
|
|
74
|
+
// the widget — and since the menu always opens with focus inside itself,
|
|
75
|
+
// that blur comes from inside. Safari and Firefox-on-macOS produce it on
|
|
76
|
+
// every mousedown over a <button>; Chrome produces it for a mousedown on
|
|
77
|
+
// this panel's own padding or its divider band. Closing there unmounts the
|
|
78
|
+
// row before its click can land, so no theme is ever applied. Do not
|
|
79
|
+
// "simplify" this branch away: genuine outside-pointer closes are
|
|
80
|
+
// useOnClickOutside's job, and Escape and Tab both supply a real
|
|
81
|
+
// relatedTarget. The only thing given up is close-on-window-blur.
|
|
82
|
+
if (!(e.relatedTarget instanceof Element)) return;
|
|
83
|
+
if (!e.currentTarget.contains(e.relatedTarget)) setIsOpen(false);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// `null` clears the cookie. Path mirrors the docs prefix so the cookie is
|
|
87
|
+
// scoped to /docs on jamdesk.com and to / on jamdesk-docs.jamdesk.app.
|
|
88
|
+
function applyTheme(name: ThemeName | null) {
|
|
89
|
+
const path = linkPrefix || '/';
|
|
90
|
+
// SESSION cookie on the set path — no Max-Age, so the preview dies with the
|
|
91
|
+
// browser. The picker is desktop-only (`hidden lg:block`), so a persisted
|
|
92
|
+
// preview would follow a visitor to mobile with no affordance to reset it.
|
|
93
|
+
// The CLEAR path is the opposite and must stay explicit: `Max-Age=0` is
|
|
94
|
+
// what expires an existing cookie, and omitting it there would leave the
|
|
95
|
+
// preview in place.
|
|
96
|
+
const expiry = name === null ? '; Max-Age=0' : '';
|
|
97
|
+
// `Secure` means the browser silently DROPS this cookie over plain http, so
|
|
98
|
+
// the picker looks inert when QA'd on http://localhost — use https or a
|
|
99
|
+
// deployed host.
|
|
100
|
+
document.cookie =
|
|
101
|
+
`${THEME_PREVIEW_COOKIE}=${name ?? ''}; Path=${path}${expiry}; SameSite=Lax; Secure`;
|
|
102
|
+
// Close first: the reload is not instant, and an open menu left showing the
|
|
103
|
+
// previous selection reads as a dead click.
|
|
104
|
+
setIsOpen(false);
|
|
105
|
+
// Full reload — the theme is applied by the server render (CSS variables,
|
|
106
|
+
// font class and layout variant all key off config.theme), so there is no
|
|
107
|
+
// client-side state to update.
|
|
108
|
+
window.location.reload();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const allThemes = getAllThemes();
|
|
112
|
+
const activeDisplayName = allThemes.find((t) => t.name === activeTheme)?.displayName ?? '';
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div
|
|
116
|
+
ref={containerRef}
|
|
117
|
+
className="relative hidden lg:block"
|
|
118
|
+
onKeyDown={handleKeyDown}
|
|
119
|
+
onBlur={handleFocusOut}
|
|
120
|
+
>
|
|
121
|
+
<button
|
|
122
|
+
ref={buttonRef}
|
|
123
|
+
onClick={() => {
|
|
124
|
+
setIsOpen(!isOpen);
|
|
125
|
+
// Closing unmounts the focused option. Safari does not focus a
|
|
126
|
+
// button on mousedown, so without this focus would land on <body>.
|
|
127
|
+
// Mirrors Prompt.tsx's trigger.
|
|
128
|
+
if (isOpen) buttonRef.current?.focus();
|
|
129
|
+
}}
|
|
130
|
+
aria-expanded={isOpen}
|
|
131
|
+
aria-haspopup="listbox"
|
|
132
|
+
aria-label={`Preview a theme: ${activeDisplayName}`}
|
|
133
|
+
className="flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer transition-colors text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-tertiary)]"
|
|
134
|
+
>
|
|
135
|
+
<i
|
|
136
|
+
className="fa-solid fa-palette text-[12px] text-[var(--color-text-tertiary)]"
|
|
137
|
+
aria-hidden="true"
|
|
138
|
+
/>
|
|
139
|
+
<i
|
|
140
|
+
className={`fa-solid fa-chevron-down text-[10px] text-[var(--color-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
|
141
|
+
aria-hidden="true"
|
|
142
|
+
/>
|
|
143
|
+
</button>
|
|
144
|
+
|
|
145
|
+
{isOpen && (
|
|
146
|
+
<div
|
|
147
|
+
className="absolute right-0 top-full mt-1 z-50 min-w-[190px] py-1 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-lg"
|
|
148
|
+
style={{ boxShadow: 'var(--shadow-lg)' }}
|
|
149
|
+
>
|
|
150
|
+
<ul ref={listRef} role="listbox" aria-label="Preview a theme">
|
|
151
|
+
{allThemes.map((theme) => {
|
|
152
|
+
const isActive = theme.name === activeTheme;
|
|
153
|
+
return (
|
|
154
|
+
<li key={theme.name}>
|
|
155
|
+
<button
|
|
156
|
+
role="option"
|
|
157
|
+
aria-selected={isActive}
|
|
158
|
+
onClick={() => applyTheme(theme.name as ThemeName)}
|
|
159
|
+
className="w-full flex items-center justify-between gap-3 px-3 py-2 text-left text-sm cursor-pointer text-[var(--color-text-primary)] hover:bg-[var(--color-bg-tertiary)]"
|
|
160
|
+
>
|
|
161
|
+
<span>{theme.displayName}</span>
|
|
162
|
+
<span className="flex items-center gap-2">
|
|
163
|
+
{theme.name === defaultTheme && (
|
|
164
|
+
<span className="text-xs text-[var(--color-text-tertiary)]">Default</span>
|
|
165
|
+
)}
|
|
166
|
+
{isActive && (
|
|
167
|
+
<i
|
|
168
|
+
className="fa-solid fa-check text-[11px] text-[var(--color-accent)]"
|
|
169
|
+
aria-hidden="true"
|
|
170
|
+
/>
|
|
171
|
+
)}
|
|
172
|
+
</span>
|
|
173
|
+
</button>
|
|
174
|
+
</li>
|
|
175
|
+
);
|
|
176
|
+
})}
|
|
177
|
+
</ul>
|
|
178
|
+
|
|
179
|
+
{/* Outside the <ul>: a `role="listbox"` may only own `option` and
|
|
180
|
+
`group` children, and a reset action is neither. Nesting it as a
|
|
181
|
+
bare <li><button> would put a `listitem` inside a listbox, which
|
|
182
|
+
screen readers drop or mis-announce. LanguageSelector — the
|
|
183
|
+
pattern this component mirrors — has no extra row, so there is no
|
|
184
|
+
existing precedent to copy here. */}
|
|
185
|
+
<div className="mt-1 pt-1 border-t border-[var(--color-border)]">
|
|
186
|
+
<button
|
|
187
|
+
onClick={() => applyTheme(null)}
|
|
188
|
+
className="w-full px-3 py-2 text-left text-sm cursor-pointer text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-tertiary)]"
|
|
189
|
+
>
|
|
190
|
+
Reset to default
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|