jamdesk 1.1.95 → 1.1.97

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.95",
3
+ "version": "1.1.97",
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",
@@ -1,26 +1,29 @@
1
1
  'use client';
2
2
 
3
3
  import dynamic from 'next/dynamic';
4
+ import { useMemo, useRef, useState } from 'react';
5
+ import { readCachedHeight } from './mermaidCache';
6
+ import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect';
4
7
 
5
- // Skeleton shown during lazy load
6
- function MermaidSkeleton({ className }: { className?: string }) {
7
- return (
8
- <div
9
- className={`my-6 flex justify-center ${className || ''}`}
10
- data-testid="mermaid-skeleton"
11
- >
12
- <div className="animate-pulse bg-gray-200 dark:bg-gray-700 rounded-lg w-full max-w-xl h-48" />
13
- </div>
14
- );
15
- }
8
+ // Neutral floor reserved in the SSR markup itself (present on the browser's
9
+ // first paint, before any JS) so a cache miss / pre-hydration window shows
10
+ // correctly-sized empty space instead of a collapse or a grey pulse. Roughly
11
+ // the prior skeleton footprint; a cache hit refines it to the exact diagram
12
+ // height pre-paint, and once the diagram renders the floor is released
13
+ // entirely (see the layout effect below).
14
+ const DEFAULT_MERMAID_RESERVE_PX = 192;
15
+
16
+ // Sentinel: floor released, MermaidInner's own min-height governs the box.
17
+ const FLOOR_RELEASED_PX = 0;
18
+
19
+ // A rendered diagram (or the error box) is always taller than this; the empty
20
+ // pre-render container is 0px. Distinguishes "content has painted" from "still
21
+ // reserving the gap" so the floor is released only once it is dead weight.
22
+ const RENDERED_FLOOR_RELEASE_PX = 8;
16
23
 
17
- // Dynamic import - ssr: false because mermaid requires browser APIs
18
24
  const MermaidInner = dynamic(
19
25
  () => import('./MermaidInner').then(mod => ({ default: mod.MermaidInner })),
20
- {
21
- ssr: false,
22
- loading: () => <MermaidSkeleton />
23
- }
26
+ { ssr: false, loading: () => null }
24
27
  );
25
28
 
26
29
  interface MermaidProps {
@@ -31,5 +34,74 @@ interface MermaidProps {
31
34
  }
32
35
 
33
36
  export function Mermaid({ children, className, minWidth }: MermaidProps) {
34
- return <MermaidInner className={className} minWidth={minWidth}>{children}</MermaidInner>;
37
+ const wrapperRef = useRef<HTMLDivElement>(null);
38
+
39
+ // Server + first (hydration) render emit the constant default — identical
40
+ // markup, so no hydration mismatch. The sessionStorage read happens only
41
+ // in the post-hydration layout effect, never in this initializer.
42
+ const [reservedPx, setReservedPx] = useState(DEFAULT_MERMAID_RESERVE_PX);
43
+
44
+ // The reserved min-height only bridges the pre-render gap; it must never
45
+ // outlive the diagram or it leaves dead space under any diagram shorter
46
+ // than the floor (the cache-miss path never refines it down).
47
+ useIsomorphicLayoutEffect(() => {
48
+ const wrapper = wrapperRef.current;
49
+
50
+ // Relies on MermaidInner rendering exactly one HTMLElement root (the
51
+ // diagram container or the error box); if that contract changes, this
52
+ // height probe silently targets the wrong node.
53
+ const releaseIfRendered = (): boolean => {
54
+ const content = wrapper?.firstElementChild as HTMLElement | null;
55
+ if (content && content.clientHeight > RENDERED_FLOOR_RELEASE_PX) {
56
+ setReservedPx(FLOOR_RELEASED_PX);
57
+ return true;
58
+ }
59
+ return false;
60
+ };
61
+
62
+ // Already painted (warm soft-nav: SVG hydrated synchronously from cache) —
63
+ // the floor is already dead weight, skip the cache refine entirely.
64
+ if (releaseIfRendered()) return;
65
+
66
+ // Not yet painted: cache hit → reserve the exact height pre-paint (zero
67
+ // shift); cache miss / new diagram → hold the default floor for its gap.
68
+ const cached = readCachedHeight(children);
69
+ setReservedPx(cached > 0 ? cached : DEFAULT_MERMAID_RESERVE_PX);
70
+
71
+ if (!wrapper || typeof MutationObserver === 'undefined') return;
72
+ // childList only — no `attributes`: MermaidInner's post-commit style pass
73
+ // mutates SVG child styles heavily; observing attributes would fire a
74
+ // callback storm. The SVG-insert is a childList mutation, all we need.
75
+ const mo = new MutationObserver(() => {
76
+ if (releaseIfRendered()) mo.disconnect();
77
+ });
78
+ mo.observe(wrapper, { childList: true, subtree: true });
79
+ return () => mo.disconnect();
80
+ }, [children]);
81
+
82
+ // Stable element identity across reservedPx changes. The pre-paint
83
+ // setReservedPx above re-renders this wrapper; without this memo that
84
+ // re-render also re-renders the dynamic MermaidInner, making React
85
+ // re-commit its raw-HTML injection and discard the DOM that MermaidInner
86
+ // imperatively themed post-commit. That theming is reapplied only on a
87
+ // genuine content change, not on this re-commit, so on soft-navigation the
88
+ // diagram paints raw mermaid "neutral" colors instead of the active theme.
89
+ // Memoizing the element makes the wrapper own min-height ONLY.
90
+ // Soundness depends on MermaidInner being a stable reference: the dynamic()
91
+ // call MUST stay module-scoped (above) — moving it into this component
92
+ // would memoize an element closed over a stale component identity.
93
+ const inner = useMemo(
94
+ () => (
95
+ <MermaidInner className={className} minWidth={minWidth}>
96
+ {children}
97
+ </MermaidInner>
98
+ ),
99
+ [children, className, minWidth]
100
+ );
101
+
102
+ return (
103
+ <div ref={wrapperRef} data-testid="mermaid-wrapper" style={{ minHeight: reservedPx }}>
104
+ {inner}
105
+ </div>
106
+ );
35
107
  }
@@ -2,6 +2,14 @@
2
2
 
3
3
  import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
4
4
  import mermaid from 'mermaid';
5
+ import {
6
+ CACHE_KEY_PREFIX,
7
+ hashDiagram,
8
+ readCache,
9
+ writeCache,
10
+ readSvgHeight,
11
+ type CachedDiagram,
12
+ } from './mermaidCache';
5
13
 
6
14
  mermaid.initialize({
7
15
  startOnLoad: false,
@@ -12,73 +20,15 @@ mermaid.initialize({
12
20
  },
13
21
  });
14
22
 
15
- export const CACHE_KEY_PREFIX = 'mermaid:v1:';
16
-
17
- // djb2; collisions are theoretically possible but the input space is tiny for a docs site.
18
- export function hashDiagram(source: string): string {
19
- let h = 5381;
20
- for (let i = 0; i < source.length; i++) {
21
- h = ((h << 5) + h) ^ source.charCodeAt(i);
22
- }
23
- return (h >>> 0).toString(36);
24
- }
25
-
26
- export interface CachedDiagram {
27
- svg: string;
28
- height: number;
29
- }
30
-
31
- // Coarse tamper check, NOT a sanitizer. mermaid's securityLevel:'strict' is
32
- // the real defense (it sanitizes at render). This only guards the cache-read
33
- // path, which re-injects stored bytes via React's raw inner-HTML prop without
34
- // re-sanitizing — so a tampered same-origin sessionStorage entry is rejected
35
- // here, forcing a fresh re-sanitized render. Catches the obvious signatures
36
- // only (non-<svg> root, <script>, inline on*= handlers).
37
- function looksTampered(svg: string): boolean {
38
- return (
39
- !/^\s*<svg/.test(svg) ||
40
- /<script\b/i.test(svg) ||
41
- /<[^>]+\son[a-z]+\s*=/i.test(svg)
42
- );
43
- }
44
-
45
- export function readCache(source: string): CachedDiagram | null {
46
- try {
47
- const raw = sessionStorage.getItem(CACHE_KEY_PREFIX + hashDiagram(source));
48
- if (!raw) return null;
49
- const parsed = JSON.parse(raw) as Partial<CachedDiagram>;
50
- if (typeof parsed?.svg !== 'string') return null;
51
- if (looksTampered(parsed.svg)) return null;
52
- // Normalize the shape so the return honestly matches CachedDiagram:
53
- // legacy v1 entries predate height tracking, and a tampered/foreign
54
- // entry could carry a non-numeric height. Coerce both to a number.
55
- return {
56
- svg: parsed.svg,
57
- height: typeof parsed.height === 'number' ? parsed.height : 0,
58
- };
59
- } catch {
60
- return null;
61
- }
62
- }
63
-
64
- export function writeCache(source: string, entry: CachedDiagram): void {
65
- try {
66
- sessionStorage.setItem(CACHE_KEY_PREFIX + hashDiagram(source), JSON.stringify(entry));
67
- } catch {}
68
- }
69
-
70
- // Real mermaid output has no `height` attribute (it sizes via viewBox +
71
- // max-width), so fall back to the viewBox height; an explicit `height` still
72
- // wins when present (other/older renderers). Markup-parsing instead of
73
- // getBoundingClientRect keeps this jsdom-safe and reflow-free on the hot path.
74
- export function readSvgHeight(svgMarkup: string): number {
75
- const attr = svgMarkup.match(/<svg\b[^>]*\bheight=["']([\d.]+)(?:px)?["']/i);
76
- if (attr) return parseFloat(attr[1]);
77
- const viewBox = svgMarkup.match(
78
- /<svg\b[^>]*\bviewBox=["']\s*[\d.+-]+\s+[\d.+-]+\s+[\d.+-]+\s+([\d.]+)/i
79
- );
80
- return viewBox ? parseFloat(viewBox[1]) : 0;
81
- }
23
+ // readCachedHeight intentionally not re-exported; Mermaid.tsx imports it directly from mermaidCache
24
+ export {
25
+ CACHE_KEY_PREFIX,
26
+ hashDiagram,
27
+ readCache,
28
+ writeCache,
29
+ readSvgHeight,
30
+ };
31
+ export type { CachedDiagram };
82
32
 
83
33
  interface MermaidInnerProps {
84
34
  children: string;
@@ -0,0 +1,75 @@
1
+ export const CACHE_KEY_PREFIX = 'mermaid:v1:';
2
+
3
+ // djb2; collisions are theoretically possible but the input space is tiny for a docs site.
4
+ export function hashDiagram(source: string): string {
5
+ let h = 5381;
6
+ for (let i = 0; i < source.length; i++) {
7
+ h = ((h << 5) + h) ^ source.charCodeAt(i);
8
+ }
9
+ return (h >>> 0).toString(36);
10
+ }
11
+
12
+ export interface CachedDiagram {
13
+ svg: string;
14
+ height: number;
15
+ }
16
+
17
+ // Coarse tamper check, NOT a sanitizer. mermaid's securityLevel:'strict' is
18
+ // the real defense (it sanitizes at render). This only guards the cache-read
19
+ // path, which re-injects stored bytes via React's raw inner-HTML prop without
20
+ // re-sanitizing — so a tampered same-origin sessionStorage entry is rejected
21
+ // here, forcing a fresh re-sanitized render. Catches the obvious signatures
22
+ // only (non-<svg> root, <script>, inline on*= handlers).
23
+ function looksTampered(svg: string): boolean {
24
+ return (
25
+ !/^\s*<svg/.test(svg) ||
26
+ /<script\b/i.test(svg) ||
27
+ /<[^>]+\son[a-z]+\s*=/i.test(svg)
28
+ );
29
+ }
30
+
31
+ export function readCache(source: string): CachedDiagram | null {
32
+ try {
33
+ const raw = sessionStorage.getItem(CACHE_KEY_PREFIX + hashDiagram(source));
34
+ if (!raw) return null;
35
+ const parsed = JSON.parse(raw) as Partial<CachedDiagram>;
36
+ if (typeof parsed?.svg !== 'string') return null;
37
+ if (looksTampered(parsed.svg)) return null;
38
+ // Normalize the shape so the return honestly matches CachedDiagram:
39
+ // legacy v1 entries predate height tracking, and a tampered/foreign
40
+ // entry could carry a non-numeric height. Coerce both to a number.
41
+ return {
42
+ svg: parsed.svg,
43
+ height: typeof parsed.height === 'number' ? parsed.height : 0,
44
+ };
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ export function writeCache(source: string, entry: CachedDiagram): void {
51
+ try {
52
+ sessionStorage.setItem(CACHE_KEY_PREFIX + hashDiagram(source), JSON.stringify(entry));
53
+ } catch {}
54
+ }
55
+
56
+ // Real mermaid output has no `height` attribute (it sizes via viewBox +
57
+ // max-width), so fall back to the viewBox height; an explicit `height` still
58
+ // wins when present (other/older renderers). Markup-parsing instead of
59
+ // getBoundingClientRect keeps this jsdom-safe and reflow-free on the hot path.
60
+ export function readSvgHeight(svgMarkup: string): number {
61
+ const attr = svgMarkup.match(/<svg\b[^>]*\bheight=["']([\d.]+)(?:px)?["']/i);
62
+ if (attr) return parseFloat(attr[1]);
63
+ const viewBox = svgMarkup.match(
64
+ /<svg\b[^>]*\bviewBox=["']\s*[\d.+-]+\s+[\d.+-]+\s+[\d.+-]+\s+([\d.]+)/i
65
+ );
66
+ return viewBox ? parseFloat(viewBox[1]) : 0;
67
+ }
68
+
69
+ // Cached pixel height for a diagram, or 0 when not cached. Lets the
70
+ // always-loaded Mermaid.tsx shell refine its reserved space to the exact
71
+ // diagram height before the mermaid chunk loads, without reaching into the
72
+ // cache entry shape itself.
73
+ export function readCachedHeight(source: string): number {
74
+ return readCache(source)?.height ?? 0;
75
+ }
@@ -0,0 +1,8 @@
1
+ import { useEffect, useLayoutEffect } from 'react';
2
+
3
+ // useLayoutEffect on the client (runs after commit, synchronously before
4
+ // paint), useEffect on the server (no-op) — avoids React's SSR warning and
5
+ // keeps the rules-of-hooks lint happy (single named hook, no conditional
6
+ // hook-through-a-variable at the call site).
7
+ export const useIsomorphicLayoutEffect =
8
+ typeof window !== 'undefined' ? useLayoutEffect : useEffect;