jamdesk 1.1.221 → 1.1.222
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/custom-assets.test.js +157 -6
- package/dist/__tests__/unit/custom-assets.test.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +26 -8
- package/dist/commands/dev.js.map +1 -1
- package/dist/lib/custom-assets.d.ts +17 -0
- package/dist/lib/custom-assets.d.ts.map +1 -1
- package/dist/lib/custom-assets.js +109 -1
- package/dist/lib/custom-assets.js.map +1 -1
- package/package.json +1 -1
- package/vendored/app/api/chat/[project]/route.ts +31 -7
- package/vendored/components/chat/ChatBrandIcon.tsx +17 -0
- package/vendored/components/chat/ChatEmptyState.tsx +5 -5
- package/vendored/components/chat/ChatPanel.tsx +10 -7
- package/vendored/components/chat/useChatLabel.ts +9 -0
- package/vendored/components/layout/LayoutWrapper.tsx +3 -0
- package/vendored/components/mdx/Mermaid.tsx +16 -3
- package/vendored/components/mdx/MermaidInner.tsx +272 -19
- package/vendored/components/mdx/MermaidZoomModal.tsx +87 -0
- package/vendored/components/mdx/ZoomableImage.tsx +8 -1
- package/vendored/components/navigation/Header.tsx +15 -10
- package/vendored/components/navigation/Sidebar.tsx +6 -3
- package/vendored/components/navigation/TabsNav.tsx +9 -2
- package/vendored/components/search/SearchModal.tsx +6 -3
- package/vendored/hooks/useChat.ts +2 -0
- package/vendored/hooks/useChatPanel.tsx +9 -2
- package/vendored/lib/anthropic-client.ts +7 -0
- package/vendored/lib/build/error-parser.ts +17 -0
- package/vendored/lib/build/extract-tarball.ts +1 -29
- package/vendored/lib/build/validate-no-symlink-escape.ts +100 -0
- package/vendored/lib/chat-branding.ts +26 -0
- package/vendored/lib/chat-prompt.ts +2 -2
- package/vendored/lib/docs-types.ts +20 -0
- package/vendored/lib/generate-starter-questions.ts +24 -6
- package/vendored/lib/github-git-auth.ts +47 -0
- package/vendored/lib/hedge-strip.ts +1 -1
- package/vendored/lib/isr-build-executor.ts +63 -12
- package/vendored/lib/layout-helpers.tsx +13 -6
- package/vendored/lib/link-rewriter.ts +1 -1
- package/vendored/lib/mcp-auth.ts +67 -0
- package/vendored/lib/openapi/cache.ts +5 -2
- package/vendored/lib/openapi/expand-spec-pages.ts +5 -3
- package/vendored/lib/openapi/safe-ref-resolvers.ts +455 -0
- package/vendored/lib/openapi/validator.ts +46 -9
- package/vendored/lib/openapi-isr.ts +24 -11
- package/vendored/lib/page-timestamps.ts +29 -6
- package/vendored/lib/query-rewriter.ts +7 -3
- package/vendored/lib/r2-manifest.ts +2 -0
- package/vendored/lib/runtime-flags.ts +54 -0
- package/vendored/schema/docs-schema.json +34 -0
- package/vendored/scripts/copy-files.cjs +90 -37
- package/vendored/scripts/openapi-safe-ref-resolvers.cjs +354 -0
- package/vendored/scripts/openapi-spec-loader.cjs +10 -5
- package/vendored/themes/base.css +48 -0
- package/vendored/workspace-package-lock.json +18 -18
- package/vendored/components/chat/ChatFAB.tsx +0 -36
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import mermaid from 'mermaid';
|
|
5
5
|
import {
|
|
6
6
|
CACHE_KEY_PREFIX,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
readSvgHeight,
|
|
11
11
|
type CachedDiagram,
|
|
12
12
|
} from './mermaidCache';
|
|
13
|
+
import { MermaidZoomModal, isZoomDisabled, withZoomIds } from './MermaidZoomModal';
|
|
13
14
|
|
|
14
15
|
mermaid.initialize({
|
|
15
16
|
startOnLoad: false,
|
|
@@ -35,6 +36,48 @@ interface MermaidInnerProps {
|
|
|
35
36
|
className?: string;
|
|
36
37
|
/** Minimum width for the SVG diagram (e.g., "700px", "100%") */
|
|
37
38
|
minWidth?: string;
|
|
39
|
+
/** Turns off click to expand. All three spellings: see isZoomDisabled. */
|
|
40
|
+
noZoom?: boolean | string;
|
|
41
|
+
nozoom?: boolean | string;
|
|
42
|
+
'data-no-zoom'?: boolean | string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Same breakpoint as ZoomableImage (Tailwind `md`): below it the modal would
|
|
46
|
+
// be no larger than the inline diagram.
|
|
47
|
+
const ZOOM_MEDIA_QUERY = '(min-width: 768px)';
|
|
48
|
+
const ZOOM_MIN_VIEWPORT_PX = 768;
|
|
49
|
+
|
|
50
|
+
// The panel is 90vw, so without a cap a small pie or a narrow sequence diagram
|
|
51
|
+
// is scaled several times over and has to be scrolled. 1.5x keeps a default
|
|
52
|
+
// pie (about 570x450) fully visible in a 90vh panel on a 900px-tall screen,
|
|
53
|
+
// where 2x did not. Wide diagrams still fit the panel width; tall ones scroll.
|
|
54
|
+
const ZOOM_MAX_SCALE = 1.5;
|
|
55
|
+
|
|
56
|
+
function isZoomViewport(): boolean {
|
|
57
|
+
return typeof window.matchMedia === 'function'
|
|
58
|
+
? window.matchMedia(ZOOM_MEDIA_QUERY).matches
|
|
59
|
+
: window.innerWidth >= ZOOM_MIN_VIEWPORT_PX;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Mermaid writes its render width as an inline px max-width (`useMaxWidth`);
|
|
63
|
+
// the viewBox width is the same number when that is off.
|
|
64
|
+
function naturalSvgWidth(svgEl: SVGSVGElement): number | null {
|
|
65
|
+
const maxWidth = svgEl.style.maxWidth;
|
|
66
|
+
if (maxWidth.endsWith('px') && parseFloat(maxWidth) > 0) return parseFloat(maxWidth);
|
|
67
|
+
const viewBoxWidth = Number(svgEl.getAttribute('viewBox')?.trim().split(/[\s,]+/)[2]);
|
|
68
|
+
return viewBoxWidth > 0 ? viewBoxWidth : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function setButtonSemantics(el: HTMLElement, on: boolean): void {
|
|
72
|
+
if (on) {
|
|
73
|
+
el.setAttribute('role', 'button');
|
|
74
|
+
el.setAttribute('tabindex', '0');
|
|
75
|
+
el.setAttribute('aria-label', 'Expand diagram');
|
|
76
|
+
} else {
|
|
77
|
+
el.removeAttribute('role');
|
|
78
|
+
el.removeAttribute('tabindex');
|
|
79
|
+
el.removeAttribute('aria-label');
|
|
80
|
+
}
|
|
38
81
|
}
|
|
39
82
|
|
|
40
83
|
interface ColorPalette {
|
|
@@ -96,10 +139,31 @@ const lightPalette: ColorPalette = {
|
|
|
96
139
|
pieSliceStroke: '#ffffff', // white — crisp separation between slices on a light bg
|
|
97
140
|
};
|
|
98
141
|
|
|
142
|
+
// Mermaid marks author styles (`style`, `classDef`, subgraph `style`) as inline
|
|
143
|
+
// `!important` and never marks its own defaults, so this is the stable signal for
|
|
144
|
+
// "the author chose this". We never write `!important` ourselves, so the signal
|
|
145
|
+
// survives re-theming on a dark-mode toggle.
|
|
146
|
+
function isAuthored(el: Element, prop: string): boolean {
|
|
147
|
+
return (el as HTMLElement).style.getPropertyPriority(prop) === 'important';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function kebab(prop: string): string {
|
|
151
|
+
return prop.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Writes a property unless the author set it; setProperty (not `style.x =`)
|
|
155
|
+
// is used because plain assignment would also drop an authored `!important`.
|
|
156
|
+
function setThemed(el: Element, prop: string, value: string): void {
|
|
157
|
+
if (isAuthored(el, prop)) return;
|
|
158
|
+
(el as HTMLElement).style.setProperty(prop, value);
|
|
159
|
+
}
|
|
160
|
+
|
|
99
161
|
// Styling helper utilities
|
|
100
162
|
function styleElements(svgEl: SVGElement, selector: string, styles: Partial<CSSStyleDeclaration>): void {
|
|
101
163
|
svgEl.querySelectorAll(selector).forEach((el) => {
|
|
102
|
-
|
|
164
|
+
for (const [prop, value] of Object.entries(styles)) {
|
|
165
|
+
setThemed(el, kebab(prop), String(value));
|
|
166
|
+
}
|
|
103
167
|
});
|
|
104
168
|
}
|
|
105
169
|
|
|
@@ -243,7 +307,8 @@ function applyCommonStyles(svgEl: SVGElement, palette: ColorPalette): void {
|
|
|
243
307
|
// Cylinder/database shapes (paths with light fills)
|
|
244
308
|
svgEl.querySelectorAll('.node path').forEach((path) => {
|
|
245
309
|
if (isLightColor(window.getComputedStyle(path).fill)) {
|
|
246
|
-
|
|
310
|
+
setThemed(path, 'fill', palette.nodeFill);
|
|
311
|
+
setThemed(path, 'stroke', palette.nodeStroke);
|
|
247
312
|
}
|
|
248
313
|
});
|
|
249
314
|
|
|
@@ -271,7 +336,12 @@ function applyDarkModeInversions(svgEl: SVGElement, palette: ColorPalette): void
|
|
|
271
336
|
|
|
272
337
|
// Invert text colors. text.slice (pie percentage labels) is excluded so the
|
|
273
338
|
// white set in applyPieChartStyles stays legible on the colored slices.
|
|
274
|
-
|
|
339
|
+
svgEl.querySelectorAll('text:not(.slice), .nodeLabel, .edgeLabel, .label, tspan').forEach((el) => {
|
|
340
|
+
const label = el.closest('.label');
|
|
341
|
+
if (isAuthored(el, 'fill') || isAuthored(el, 'color')) return;
|
|
342
|
+
if (label && (isAuthored(label, 'fill') || isAuthored(label, 'color'))) return;
|
|
343
|
+
setThemed(el, 'fill', palette.text);
|
|
344
|
+
});
|
|
275
345
|
styleElements(svgEl, 'text.actor, .messageText, .labelText, .loopText, .noteText', { fill: palette.text });
|
|
276
346
|
|
|
277
347
|
// Invert foreignObject text and clear light backgrounds
|
|
@@ -280,7 +350,7 @@ function applyDarkModeInversions(svgEl: SVGElement, palette: ColorPalette): void
|
|
|
280
350
|
const computed = window.getComputedStyle(htmlEl);
|
|
281
351
|
|
|
282
352
|
if (isDarkColor(computed.color)) {
|
|
283
|
-
htmlEl
|
|
353
|
+
setThemed(htmlEl, 'color', palette.text);
|
|
284
354
|
}
|
|
285
355
|
if (isLightColor(computed.backgroundColor)) {
|
|
286
356
|
htmlEl.style.backgroundColor = 'transparent';
|
|
@@ -293,10 +363,10 @@ function applyDarkModeInversions(svgEl: SVGElement, palette: ColorPalette): void
|
|
|
293
363
|
if ((el as Element).classList.contains('pieCircle')) return;
|
|
294
364
|
const computed = window.getComputedStyle(el);
|
|
295
365
|
if (computed.stroke && computed.stroke !== 'none') {
|
|
296
|
-
(el
|
|
366
|
+
setThemed(el, 'stroke', palette.line);
|
|
297
367
|
}
|
|
298
368
|
if (isLightColor(computed.fill)) {
|
|
299
|
-
(el
|
|
369
|
+
setThemed(el, 'fill', 'transparent');
|
|
300
370
|
}
|
|
301
371
|
});
|
|
302
372
|
|
|
@@ -306,9 +376,83 @@ function applyDarkModeInversions(svgEl: SVGElement, palette: ColorPalette): void
|
|
|
306
376
|
// Circles (state diagram start/end states)
|
|
307
377
|
svgEl.querySelectorAll('circle').forEach((circle) => {
|
|
308
378
|
if (isDarkColor(window.getComputedStyle(circle).fill)) {
|
|
309
|
-
(circle
|
|
379
|
+
setThemed(circle, 'fill', palette.line);
|
|
310
380
|
}
|
|
311
|
-
(circle
|
|
381
|
+
setThemed(circle, 'stroke', palette.line);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Common CSS names seen in mermaid docs; only a fallback for engines whose
|
|
386
|
+
// computed style doesn't resolve a named color to rgb() (browsers do).
|
|
387
|
+
const NAMED_COLORS: Record<string, string> = {
|
|
388
|
+
white: '255,255,255', black: '0,0,0', red: '255,0,0', green: '0,128,0', blue: '0,0,255',
|
|
389
|
+
yellow: '255,255,0', orange: '255,165,0', purple: '128,0,128', pink: '255,192,203',
|
|
390
|
+
gray: '128,128,128', grey: '128,128,128', lightblue: '173,216,230', lightgreen: '144,238,144',
|
|
391
|
+
lightgray: '211,211,211', lightgrey: '211,211,211', lightyellow: '255,255,224',
|
|
392
|
+
navy: '0,0,128', teal: '0,128,128', cyan: '0,255,255', magenta: '255,0,255',
|
|
393
|
+
brown: '165,42,42', gold: '255,215,0', silver: '192,192,192', darkblue: '0,0,139',
|
|
394
|
+
darkgreen: '0,100,0', darkred: '139,0,0',
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
function hslToRgb(value: string): string | null {
|
|
398
|
+
const m = value.match(/hsla?\(\s*([\d.]+)(?:deg)?\s*[, ]\s*([\d.]+)%\s*[, ]\s*([\d.]+)%/i);
|
|
399
|
+
if (!m) return null;
|
|
400
|
+
const h = (+m[1] % 360) / 360;
|
|
401
|
+
const sat = +m[2] / 100;
|
|
402
|
+
const l = +m[3] / 100;
|
|
403
|
+
const q = l < 0.5 ? l * (1 + sat) : l + sat - l * sat;
|
|
404
|
+
const p = 2 * l - q;
|
|
405
|
+
const channel = (t: number) => {
|
|
406
|
+
const x = (t + 1) % 1;
|
|
407
|
+
if (x < 1 / 6) return p + (q - p) * 6 * x;
|
|
408
|
+
if (x < 1 / 2) return q;
|
|
409
|
+
if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6;
|
|
410
|
+
return p;
|
|
411
|
+
};
|
|
412
|
+
return [channel(h + 1 / 3), channel(h), channel(h - 1 / 3)].map((c) => Math.round(c * 255)).join(',');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Computed style first (browsers resolve names and hsl() to rgb()), then the
|
|
416
|
+
// raw inline value parsed by hand for engines that don't.
|
|
417
|
+
function resolveRgb(shape: Element): [number, number, number] | null {
|
|
418
|
+
const raw = (shape as HTMLElement).style.getPropertyValue('fill').trim().toLowerCase();
|
|
419
|
+
const parsed =
|
|
420
|
+
normalizeColor(window.getComputedStyle(shape).fill) ??
|
|
421
|
+
normalizeColor(raw) ??
|
|
422
|
+
hslToRgb(raw) ??
|
|
423
|
+
NAMED_COLORS[raw] ??
|
|
424
|
+
null;
|
|
425
|
+
return parsed ? (parsed.split(',').map(Number) as [number, number, number]) : null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function relativeLuminance(rgb: [number, number, number]): number {
|
|
429
|
+
const [r, g, b] = rgb.map((v) => {
|
|
430
|
+
const c = v / 255;
|
|
431
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
432
|
+
});
|
|
433
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// A node with an authored fill but no authored label color would get the
|
|
437
|
+
// theme's text color, which can vanish against the author's fill (pale text on
|
|
438
|
+
// a light classDef fill in dark mode). Pick black or white from the fill. Runs
|
|
439
|
+
// last in both modes so the dark-mode inversions can't overwrite it.
|
|
440
|
+
function applyAuthoredContrast(svgEl: SVGElement): void {
|
|
441
|
+
svgEl.querySelectorAll('.node').forEach((node) => {
|
|
442
|
+
const shape = Array.from(node.querySelectorAll('rect, polygon, path, circle, ellipse')).find((s) =>
|
|
443
|
+
isAuthored(s, 'fill'),
|
|
444
|
+
);
|
|
445
|
+
if (!shape) return;
|
|
446
|
+
const rgb = resolveRgb(shape);
|
|
447
|
+
if (!rgb) return;
|
|
448
|
+
const textColor = relativeLuminance(rgb) > 0.5 ? '#111827' : '#ffffff';
|
|
449
|
+
|
|
450
|
+
const labels = Array.from(node.querySelectorAll('.label, .nodeLabel, foreignObject div, foreignObject span, tspan'));
|
|
451
|
+
if (labels.some((l) => isAuthored(l, 'color') || isAuthored(l, 'fill'))) return;
|
|
452
|
+
labels.forEach((l) => {
|
|
453
|
+
(l as HTMLElement).style.setProperty('color', textColor);
|
|
454
|
+
if (l.tagName.toLowerCase() === 'tspan') (l as HTMLElement).style.setProperty('fill', textColor);
|
|
455
|
+
});
|
|
312
456
|
});
|
|
313
457
|
}
|
|
314
458
|
|
|
@@ -347,8 +491,17 @@ function applyLightModeCleanup(svgEl: SVGElement): void {
|
|
|
347
491
|
* A --> B --> C
|
|
348
492
|
* </Mermaid>
|
|
349
493
|
*/
|
|
350
|
-
export function MermaidInner({
|
|
494
|
+
export function MermaidInner({
|
|
495
|
+
children,
|
|
496
|
+
className,
|
|
497
|
+
minWidth,
|
|
498
|
+
noZoom,
|
|
499
|
+
nozoom,
|
|
500
|
+
'data-no-zoom': dataNoZoom,
|
|
501
|
+
}: MermaidInnerProps) {
|
|
351
502
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
503
|
+
const zoomEnabled = !isZoomDisabled(noZoom, nozoom, dataNoZoom);
|
|
504
|
+
const [zoom, setZoom] = useState<{ svgHtml: string; maxWidthPx?: number } | null>(null);
|
|
352
505
|
const [svg, setSvg] = useState<string>(() => {
|
|
353
506
|
if (typeof window === 'undefined') return '';
|
|
354
507
|
return readCache(children)?.svg ?? '';
|
|
@@ -427,11 +580,13 @@ export function MermaidInner({ children, className, minWidth }: MermaidInnerProp
|
|
|
427
580
|
const applyDarkModeStyles = () => {
|
|
428
581
|
applyCommonStyles(svgEl, darkPalette);
|
|
429
582
|
applyDarkModeInversions(svgEl, darkPalette);
|
|
583
|
+
applyAuthoredContrast(svgEl);
|
|
430
584
|
};
|
|
431
585
|
|
|
432
586
|
const applyLightModeStyles = () => {
|
|
433
587
|
applyCommonStyles(svgEl, lightPalette);
|
|
434
588
|
applyLightModeCleanup(svgEl);
|
|
589
|
+
applyAuthoredContrast(svgEl);
|
|
435
590
|
};
|
|
436
591
|
|
|
437
592
|
if (isDarkMode) {
|
|
@@ -461,6 +616,100 @@ export function MermaidInner({ children, className, minWidth }: MermaidInnerProp
|
|
|
461
616
|
return () => observer.disconnect();
|
|
462
617
|
}, [svg, minWidth]);
|
|
463
618
|
|
|
619
|
+
// Button semantics only where activation works: once the diagram exists and
|
|
620
|
+
// at >= 768px. Elsewhere (mobile, loading) screen readers get the SVG's own
|
|
621
|
+
// accessible text. Set imperatively: putting them in React state would
|
|
622
|
+
// re-render the container on every breakpoint crossing (see the memo note
|
|
623
|
+
// below), and React leaves attributes it doesn't own alone.
|
|
624
|
+
useEffect(() => {
|
|
625
|
+
const el = containerRef.current;
|
|
626
|
+
if (!el || !zoomEnabled || !svg) return;
|
|
627
|
+
const mq = typeof window.matchMedia === 'function' ? window.matchMedia(ZOOM_MEDIA_QUERY) : null;
|
|
628
|
+
const sync = () => setButtonSemantics(el, mq ? mq.matches : window.innerWidth >= ZOOM_MIN_VIEWPORT_PX);
|
|
629
|
+
sync();
|
|
630
|
+
mq?.addEventListener('change', sync);
|
|
631
|
+
return () => {
|
|
632
|
+
mq?.removeEventListener('change', sync);
|
|
633
|
+
setButtonSemantics(el, false);
|
|
634
|
+
};
|
|
635
|
+
}, [svg, zoomEnabled]);
|
|
636
|
+
|
|
637
|
+
// Width is checked again at open time, like ZoomableImage.
|
|
638
|
+
/** Returns whether the modal opened, so a click only claims itself then. */
|
|
639
|
+
const openZoom = useCallback((): boolean => {
|
|
640
|
+
const el = containerRef.current;
|
|
641
|
+
const svgEl = el?.querySelector('svg');
|
|
642
|
+
// No <svg> yet: the diagram is still rendering, and a snapshot would open
|
|
643
|
+
// an empty modal.
|
|
644
|
+
if (!el || !svgEl || !isZoomViewport()) return false;
|
|
645
|
+
const natural = naturalSvgWidth(svgEl);
|
|
646
|
+
// The container's DOM is already themed, so the copy keeps the active
|
|
647
|
+
// palette and author colors without re-running the style pass.
|
|
648
|
+
setZoom({
|
|
649
|
+
svgHtml: withZoomIds(el.innerHTML),
|
|
650
|
+
maxWidthPx: natural ? natural * ZOOM_MAX_SCALE : undefined,
|
|
651
|
+
});
|
|
652
|
+
return true;
|
|
653
|
+
}, []);
|
|
654
|
+
|
|
655
|
+
const handleClick = useCallback(
|
|
656
|
+
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
657
|
+
// A customer's delegated zoom script may run before or after this
|
|
658
|
+
// handler. Whoever goes second sees defaultPrevented and backs off, so
|
|
659
|
+
// the diagram never opens two modals.
|
|
660
|
+
if (e.defaultPrevented) return;
|
|
661
|
+
// A drag that selects label text also fires click; so does a link the
|
|
662
|
+
// author put in the diagram. Neither should open the modal.
|
|
663
|
+
if (window.getSelection()?.toString()) return;
|
|
664
|
+
if ((e.target as Element).closest('a')) return;
|
|
665
|
+
// Only claim the click when the modal really opens. Below 768px it does
|
|
666
|
+
// not, and a customer zoom script that honors defaultPrevented must
|
|
667
|
+
// still get its turn.
|
|
668
|
+
if (openZoom()) e.preventDefault();
|
|
669
|
+
},
|
|
670
|
+
[openZoom],
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
const handleKeyDown = useCallback(
|
|
674
|
+
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
675
|
+
// Keys from a focused link inside the diagram bubble here; leave them
|
|
676
|
+
// alone so Enter still follows the link.
|
|
677
|
+
if (e.target !== e.currentTarget) return;
|
|
678
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
679
|
+
e.preventDefault(); // Space would otherwise scroll the page
|
|
680
|
+
openZoom();
|
|
681
|
+
},
|
|
682
|
+
[openZoom],
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
const closeZoom = useCallback(() => {
|
|
686
|
+
setZoom(null);
|
|
687
|
+
containerRef.current?.focus();
|
|
688
|
+
}, []);
|
|
689
|
+
|
|
690
|
+
// Security: Mermaid's securityLevel: 'strict' sanitizes the SVG output,
|
|
691
|
+
// similar to how KaTeX sanitizes LaTeX in the Latex component.
|
|
692
|
+
// The dangerouslySetInnerHTML is safe here because mermaid.render()
|
|
693
|
+
// outputs sanitized SVG when securityLevel is 'strict'.
|
|
694
|
+
//
|
|
695
|
+
// Memoized so opening or closing the zoom modal (a state change here) does
|
|
696
|
+
// not re-render this element. A re-write of its innerHTML would throw away
|
|
697
|
+
// the imperative theming above, which only re-runs on [svg, minWidth]. React
|
|
698
|
+
// 19.3 skips the write while __html is unchanged; the memo keeps that from
|
|
699
|
+
// being the only guard. Same trap as the memoized element in Mermaid.tsx.
|
|
700
|
+
const zoomReady = zoomEnabled && svg !== '';
|
|
701
|
+
const diagram = useMemo(
|
|
702
|
+
() => (
|
|
703
|
+
<div
|
|
704
|
+
ref={containerRef}
|
|
705
|
+
className={`mermaid-container my-6 flex justify-center items-start overflow-x-auto ${zoomReady ? 'md:cursor-zoom-in' : ''} ${className || ''}`}
|
|
706
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
707
|
+
{...(zoomEnabled && { onClick: handleClick, onKeyDown: handleKeyDown })}
|
|
708
|
+
/>
|
|
709
|
+
),
|
|
710
|
+
[svg, className, zoomEnabled, zoomReady, handleClick, handleKeyDown],
|
|
711
|
+
);
|
|
712
|
+
|
|
464
713
|
if (error) {
|
|
465
714
|
return (
|
|
466
715
|
<div className="my-4 p-4 rounded-lg bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800">
|
|
@@ -470,15 +719,19 @@ export function MermaidInner({ children, className, minWidth }: MermaidInnerProp
|
|
|
470
719
|
);
|
|
471
720
|
}
|
|
472
721
|
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
// The dangerouslySetInnerHTML is safe here because mermaid.render()
|
|
476
|
-
// outputs sanitized SVG when securityLevel is 'strict'.
|
|
722
|
+
// The modal is a portal into document.body, so it adds no DOM child here:
|
|
723
|
+
// Mermaid.tsx's firstElementChild height probe still sees the container.
|
|
477
724
|
return (
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
725
|
+
<>
|
|
726
|
+
{diagram}
|
|
727
|
+
{zoom !== null && (
|
|
728
|
+
<MermaidZoomModal
|
|
729
|
+
svgHtml={zoom.svgHtml}
|
|
730
|
+
maxWidthPx={zoom.maxWidthPx}
|
|
731
|
+
label="Expanded diagram"
|
|
732
|
+
onClose={closeZoom}
|
|
733
|
+
/>
|
|
734
|
+
)}
|
|
735
|
+
</>
|
|
483
736
|
);
|
|
484
737
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { useBodyScrollLock } from '../../hooks/useBodyScrollLock';
|
|
6
|
+
|
|
7
|
+
type ZoomFlag = boolean | string | undefined;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mirrors the image opt-out in MDXComponents.tsx: preprocess-mdx turns a bare
|
|
11
|
+
* `noZoom` attribute into `data-no-zoom="true"`, and HTML-style parsing can
|
|
12
|
+
* lowercase it to `nozoom`.
|
|
13
|
+
*/
|
|
14
|
+
export function isZoomDisabled(...flags: ZoomFlag[]): boolean {
|
|
15
|
+
return flags.some((f) => f === true || f === 'true');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Mermaid scopes its `<style>` rules and marker references to the root svg id
|
|
20
|
+
* (`#X .node`, `url(#X_arrow)`), so a second copy with the same id would clash
|
|
21
|
+
* with the inline diagram. Rewrites every occurrence of the id. The ids are
|
|
22
|
+
* random (`mermaid-<9 chars>`), so a plain substring replace can't hit
|
|
23
|
+
* unrelated text.
|
|
24
|
+
*/
|
|
25
|
+
export function withZoomIds(svgHtml: string): string {
|
|
26
|
+
const id = svgHtml.match(/^\s*<svg\b[^>]*?\sid="([^"]+)"/)?.[1];
|
|
27
|
+
if (!id) return svgHtml;
|
|
28
|
+
return svgHtml.split(id).join(`${id}-zoom`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface MermaidZoomModalProps {
|
|
32
|
+
svgHtml: string;
|
|
33
|
+
label: string;
|
|
34
|
+
onClose: () => void;
|
|
35
|
+
/** Upper bound on the diagram's width; the panel width still applies. */
|
|
36
|
+
maxWidthPx?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function MermaidZoomModal({ svgHtml, label, onClose, maxWidthPx }: MermaidZoomModalProps) {
|
|
40
|
+
const closeRef = useRef<HTMLButtonElement>(null);
|
|
41
|
+
|
|
42
|
+
useBodyScrollLock(true);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
closeRef.current?.focus();
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
50
|
+
if (e.key === 'Escape') onClose();
|
|
51
|
+
};
|
|
52
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
53
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
54
|
+
}, [onClose]);
|
|
55
|
+
|
|
56
|
+
// Only a click on the backdrop itself closes: clicks inside the panel must
|
|
57
|
+
// not, so the reader can scroll and select within a large diagram.
|
|
58
|
+
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
59
|
+
if (e.target === e.currentTarget) onClose();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Security: the markup is a copy of the inline diagram's DOM, which mermaid
|
|
63
|
+
// produced with securityLevel 'strict' (see MermaidInner).
|
|
64
|
+
return createPortal(
|
|
65
|
+
<div className="image-zoom-modal" role="dialog" aria-modal="true" aria-label={label}>
|
|
66
|
+
<div className="image-zoom-backdrop" onClick={handleBackdropClick}>
|
|
67
|
+
<div className="mermaid-zoom-panel">
|
|
68
|
+
<button
|
|
69
|
+
ref={closeRef}
|
|
70
|
+
type="button"
|
|
71
|
+
className="mermaid-zoom-close"
|
|
72
|
+
aria-label="Close diagram"
|
|
73
|
+
onClick={onClose}
|
|
74
|
+
>
|
|
75
|
+
<i className="fa-solid fa-xmark" aria-hidden="true" />
|
|
76
|
+
</button>
|
|
77
|
+
<div
|
|
78
|
+
className="mermaid-zoom-content"
|
|
79
|
+
style={maxWidthPx ? { maxWidth: `${maxWidthPx}px` } : undefined}
|
|
80
|
+
dangerouslySetInnerHTML={{ __html: svgHtml }}
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>,
|
|
85
|
+
document.body,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -32,9 +32,16 @@ export function ZoomableImage({
|
|
|
32
32
|
|
|
33
33
|
useBodyScrollLock(isZoomed);
|
|
34
34
|
|
|
35
|
-
const handleClick = useCallback(() => {
|
|
35
|
+
const handleClick = useCallback((e: React.MouseEvent<HTMLImageElement>) => {
|
|
36
|
+
// A customer's delegated zoom script may run first; back off if it did,
|
|
37
|
+
// and mark the click handled so it backs off if we ran first.
|
|
38
|
+
if (e.defaultPrevented) return;
|
|
39
|
+
// An image inside a link must leave the link's navigation alone; before
|
|
40
|
+
// the defaultPrevented guard, the click bubbled to the anchor and followed it.
|
|
41
|
+
if ((e.target as Element).closest('a')) return;
|
|
36
42
|
// Check mobile at click time — avoids per-image resize listeners
|
|
37
43
|
if (!noZoom && window.innerWidth >= 768) {
|
|
44
|
+
e.preventDefault();
|
|
38
45
|
setIsZoomed(true);
|
|
39
46
|
}
|
|
40
47
|
}, [noZoom]);
|
|
@@ -18,6 +18,8 @@ import { resolveNavigation } from '@/lib/navigation-resolver';
|
|
|
18
18
|
import { getIconClass } from '@/lib/icon-utils';
|
|
19
19
|
import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
20
20
|
import { useChatOpenHandler } from '@/hooks/useChatPanel';
|
|
21
|
+
import { ChatBrandIcon } from '@/components/chat/ChatBrandIcon';
|
|
22
|
+
import { useChatLabel } from '@/components/chat/useChatLabel';
|
|
21
23
|
import { useSearchOpenHandler } from '@/hooks/useSearchOpenHandler';
|
|
22
24
|
import { extractLanguageFromPath } from '@/lib/language-utils';
|
|
23
25
|
import { getUiStrings } from '@/lib/ui-strings';
|
|
@@ -70,6 +72,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
70
72
|
const linkPrefix = useLinkPrefix();
|
|
71
73
|
const currentLang = extractLanguageFromPath(pathname || '/');
|
|
72
74
|
const ui = getUiStrings(currentLang);
|
|
75
|
+
const chatLabel = useChatLabel(ui.askAi);
|
|
73
76
|
|
|
74
77
|
// In sidebar-logo layout, logo is in sidebar, not header
|
|
75
78
|
const showLogoInHeader = layout === 'header-logo';
|
|
@@ -358,9 +361,11 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
358
361
|
// that the logo gives way.
|
|
359
362
|
<div className="@container flex-1 flex justify-center lg:px-4 lg:min-w-[8rem]">
|
|
360
363
|
<div className="hidden lg:flex items-center gap-2">
|
|
364
|
+
{/* shrink-0: a long custom chat label (up to 40 chars) must
|
|
365
|
+
truncate itself, not squeeze "Search" down to "Se…". */}
|
|
361
366
|
<button
|
|
362
367
|
onClick={() => onSearchOpen()}
|
|
363
|
-
className={`${BAR_BUTTON} text-[var(--color-text-muted)] text-sm hover:border-[var(--color-border-hover)] transition-colors cursor-pointer`}
|
|
368
|
+
className={`${BAR_BUTTON} shrink-0 text-[var(--color-text-muted)] text-sm hover:border-[var(--color-border-hover)] transition-colors cursor-pointer`}
|
|
364
369
|
aria-label={ui.search}
|
|
365
370
|
title={`${ui.search} (⌘K)`}
|
|
366
371
|
>
|
|
@@ -375,11 +380,11 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
375
380
|
<button
|
|
376
381
|
onClick={onChatOpen}
|
|
377
382
|
className={`${BAR_BUTTON} hover:border-[var(--color-border-hover)] cursor-pointer transition-colors text-sm text-[var(--color-text-muted)]`}
|
|
378
|
-
aria-label={
|
|
379
|
-
title={`${
|
|
383
|
+
aria-label={chatLabel}
|
|
384
|
+
title={`${chatLabel} (⌘I)`}
|
|
380
385
|
>
|
|
381
|
-
<
|
|
382
|
-
<span className={BAR_LABEL}>{
|
|
386
|
+
<ChatBrandIcon className="text-[14px] text-[var(--color-accent)]" sizePx={14} />
|
|
387
|
+
<span className={BAR_LABEL}>{chatLabel}</span>
|
|
383
388
|
<kbd className={`${BAR_HINT} items-center gap-[2px] px-2 py-0.5 text-xs text-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded`}>
|
|
384
389
|
<span className="text-sm leading-none">⌘</span>
|
|
385
390
|
<span>I</span>
|
|
@@ -510,9 +515,9 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
510
515
|
<button
|
|
511
516
|
onClick={onChatOpen}
|
|
512
517
|
className={MOBILE_ICON_BUTTON}
|
|
513
|
-
aria-label={
|
|
518
|
+
aria-label={chatLabel}
|
|
514
519
|
>
|
|
515
|
-
<
|
|
520
|
+
<ChatBrandIcon className="text-[16px] text-[var(--color-accent)]" sizePx={16} />
|
|
516
521
|
</button>
|
|
517
522
|
)}
|
|
518
523
|
|
|
@@ -570,10 +575,10 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
570
575
|
<button
|
|
571
576
|
onClick={onChatOpen}
|
|
572
577
|
className={`${COMPACT_ICON_BUTTON} text-[var(--color-accent)]`}
|
|
573
|
-
aria-label={
|
|
574
|
-
title={`${
|
|
578
|
+
aria-label={chatLabel}
|
|
579
|
+
title={`${chatLabel} (⌘I)`}
|
|
575
580
|
>
|
|
576
|
-
<
|
|
581
|
+
<ChatBrandIcon className="text-[16px]" sizePx={16} />
|
|
577
582
|
</button>
|
|
578
583
|
)}
|
|
579
584
|
</>
|
|
@@ -20,6 +20,8 @@ import { getIconClass } from '@/lib/icon-utils';
|
|
|
20
20
|
import { getTabsFromConfig } from '@/lib/navigation-utils';
|
|
21
21
|
import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
22
22
|
import { useChatOpenHandler } from '@/hooks/useChatPanel';
|
|
23
|
+
import { ChatBrandIcon } from '@/components/chat/ChatBrandIcon';
|
|
24
|
+
import { useChatLabel } from '@/components/chat/useChatLabel';
|
|
23
25
|
import { useSearchOpenHandler } from '@/hooks/useSearchOpenHandler';
|
|
24
26
|
import { useDelayedNavigationSpinner } from '@/hooks/useDelayedNavigationSpinner';
|
|
25
27
|
|
|
@@ -213,6 +215,7 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
|
|
|
213
215
|
const pathname = usePathname() ?? '';
|
|
214
216
|
const router = useRouter();
|
|
215
217
|
const linkPrefix = useLinkPrefix();
|
|
218
|
+
const chatLabel = useChatLabel('Ask AI');
|
|
216
219
|
// Seed both `expanded: true` groups and the current page's ancestor groups
|
|
217
220
|
// synchronously so SSR HTML matches first client paint. A useEffect-only seed
|
|
218
221
|
// (the previous approach) renders the sidebar collapsed on the server, then
|
|
@@ -938,10 +941,10 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
|
|
|
938
941
|
<button
|
|
939
942
|
onClick={onChatOpen}
|
|
940
943
|
className="flex-shrink-0 flex items-center justify-center w-9 h-9 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-muted)] hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-tertiary)] transition-colors cursor-pointer"
|
|
941
|
-
aria-label=
|
|
942
|
-
title=
|
|
944
|
+
aria-label={chatLabel}
|
|
945
|
+
title={`${chatLabel} (⌘I)`}
|
|
943
946
|
>
|
|
944
|
-
<
|
|
947
|
+
<ChatBrandIcon className="text-[14px] text-[var(--color-accent)]" sizePx={14} />
|
|
945
948
|
</button>
|
|
946
949
|
)}
|
|
947
950
|
</div>
|
|
@@ -4,13 +4,20 @@ import { useMemo } from 'react';
|
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { usePathname } from 'next/navigation';
|
|
6
6
|
// Icons use Font Awesome CSS classes for lightweight rendering
|
|
7
|
-
import type { DocsConfig, TabsPosition } from '@/lib/docs-types';
|
|
7
|
+
import type { DocsConfig, TabsAlignment, TabsPosition } from '@/lib/docs-types';
|
|
8
8
|
import { resolveNavigation } from '@/lib/navigation-resolver';
|
|
9
9
|
import { getIconClass } from '@/lib/icon-utils';
|
|
10
10
|
import { getTheme } from '@/themes';
|
|
11
11
|
import { getTabsFromConfig } from '@/lib/navigation-utils';
|
|
12
12
|
import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
13
13
|
|
|
14
|
+
// Logical flex justification: under dir="rtl" `start` stays at the reading start.
|
|
15
|
+
export function tabsJustifyClass(alignment: TabsAlignment | undefined): 'justify-start' | 'justify-center' | 'justify-end' {
|
|
16
|
+
if (alignment === 'center') return 'justify-center';
|
|
17
|
+
if (alignment === 'end') return 'justify-end';
|
|
18
|
+
return 'justify-start';
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
interface TabsNavProps {
|
|
15
22
|
config: DocsConfig;
|
|
16
23
|
className?: string;
|
|
@@ -114,7 +121,7 @@ export function TabsNav({ config, className = '' }: TabsNavProps) {
|
|
|
114
121
|
would not flip under `dir="rtl"` and would invert the misalignment
|
|
115
122
|
for Arabic/Hebrew docs sites with `tabsPosition: 'top'`. */}
|
|
116
123
|
<div
|
|
117
|
-
className=
|
|
124
|
+
className={`flex flex-wrap items-center gap-x-2 gap-y-1 py-3 ${tabsJustifyClass(config.tabsAlignment)}`}
|
|
118
125
|
style={{ marginBlock: 0, marginInline: '12px 24px', borderBottom: '0.5px solid var(--color-border)' }}
|
|
119
126
|
>
|
|
120
127
|
{tabsWithPaths.map((tab) => {
|
|
@@ -11,6 +11,8 @@ import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
|
11
11
|
import { useProjectSlug } from '@/lib/project-slug-context';
|
|
12
12
|
import type { SearchResult, SearchGroup } from '@/lib/search-client';
|
|
13
13
|
import { useChatPanelOptional } from '@/hooks/useChatPanel';
|
|
14
|
+
import { ChatBrandIcon } from '@/components/chat/ChatBrandIcon';
|
|
15
|
+
import { useChatLabel } from '@/components/chat/useChatLabel';
|
|
14
16
|
import { modifierKeyLabel } from '@/lib/platform-keys';
|
|
15
17
|
import { getIconString, type IconConfig } from '@/lib/docs-types';
|
|
16
18
|
import { getIconClass } from '@/lib/icon-utils';
|
|
@@ -145,6 +147,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate, placeho
|
|
|
145
147
|
const linkPrefix = useLinkPrefix();
|
|
146
148
|
const projectSlug = useProjectSlug();
|
|
147
149
|
const chatPanel = useChatPanelOptional();
|
|
150
|
+
const chatLabel = useChatLabel('Ask AI');
|
|
148
151
|
// Ask AI is offered only when the docs chrome (ChatPanelProvider) is present
|
|
149
152
|
// AND chat is enabled. The 404 page renders SearchModal without the provider,
|
|
150
153
|
// so chatPanel is null there and the affordance is hidden.
|
|
@@ -747,10 +750,10 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate, placeho
|
|
|
747
750
|
type="button"
|
|
748
751
|
onClick={handleAskAi}
|
|
749
752
|
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-[11px] font-medium text-[var(--color-accent)] hover:bg-[var(--color-bg-tertiary)] transition-colors cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
|
|
750
|
-
aria-label={
|
|
753
|
+
aria-label={`${chatLabel} about ${query.trim()}`}
|
|
751
754
|
>
|
|
752
|
-
<
|
|
753
|
-
<span>
|
|
755
|
+
<ChatBrandIcon className="text-[10px]" sizePx={10} />
|
|
756
|
+
<span>{chatLabel}</span>
|
|
754
757
|
{/* One keycap for the whole chord (⌘/Ctrl + ↵). Mouse-only:
|
|
755
758
|
hidden on touch (coarse pointer), where there's no shortcut
|
|
756
759
|
to press and the label tap suffices. */}
|