jamdesk 1.1.94 → 1.1.96

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.94",
3
+ "version": "1.1.96",
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,20 @@
1
1
  'use client';
2
2
 
3
3
  import dynamic from 'next/dynamic';
4
+ import { useMemo, 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; refined to the exact diagram height on a
12
+ // cache hit by the layout effect below.
13
+ const DEFAULT_MERMAID_RESERVE_PX = 192;
16
14
 
17
- // Dynamic import - ssr: false because mermaid requires browser APIs
18
15
  const MermaidInner = dynamic(
19
16
  () => import('./MermaidInner').then(mod => ({ default: mod.MermaidInner })),
20
- {
21
- ssr: false,
22
- loading: () => <MermaidSkeleton />
23
- }
17
+ { ssr: false, loading: () => null }
24
18
  );
25
19
 
26
20
  interface MermaidProps {
@@ -31,5 +25,46 @@ interface MermaidProps {
31
25
  }
32
26
 
33
27
  export function Mermaid({ children, className, minWidth }: MermaidProps) {
34
- return <MermaidInner className={className} minWidth={minWidth}>{children}</MermaidInner>;
28
+ // Server + first (hydration) render emit the constant default — identical
29
+ // markup, so no hydration mismatch. The sessionStorage read happens only
30
+ // in the post-hydration layout effect, never in this initializer.
31
+ const [reservedPx, setReservedPx] = useState(DEFAULT_MERMAID_RESERVE_PX);
32
+
33
+ // Fires once, after the hydration commit, synchronously before that
34
+ // commit's paint — and BEFORE the MermaidInner chunk has resolved or
35
+ // injected anything. On a cache hit, refine the reserved space to the
36
+ // exact diagram height so the SVG paints into already-exact space.
37
+ // Distinct from the reverted spike: one pre-paint settle before injection,
38
+ // never a re-render around an already-injected node. minWidth still flows
39
+ // to MermaidInner unchanged; the wrapper owns height only.
40
+ useIsomorphicLayoutEffect(() => {
41
+ const h = readCachedHeight(children);
42
+ if (h > 0) setReservedPx(h);
43
+ }, [children]);
44
+
45
+ // Stable element identity across reservedPx changes. The pre-paint
46
+ // setReservedPx above re-renders this wrapper; without this memo that
47
+ // re-render also re-renders the dynamic MermaidInner, making React
48
+ // re-commit its raw-HTML injection and discard the DOM that MermaidInner
49
+ // imperatively themed post-commit. That theming is reapplied only on a
50
+ // genuine content change, not on this re-commit, so on soft-navigation the
51
+ // diagram paints raw mermaid "neutral" colors instead of the active theme.
52
+ // Memoizing the element makes the wrapper own min-height ONLY.
53
+ // Soundness depends on MermaidInner being a stable reference: the dynamic()
54
+ // call MUST stay module-scoped (above) — moving it into this component
55
+ // would memoize an element closed over a stale component identity.
56
+ const inner = useMemo(
57
+ () => (
58
+ <MermaidInner className={className} minWidth={minWidth}>
59
+ {children}
60
+ </MermaidInner>
61
+ ),
62
+ [children, className, minWidth]
63
+ );
64
+
65
+ return (
66
+ <div data-testid="mermaid-wrapper" style={{ minHeight: reservedPx }}>
67
+ {inner}
68
+ </div>
69
+ );
35
70
  }
@@ -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,74 +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
- 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
- // Defense-in-depth: mermaid sanitizes at render time (securityLevel:
38
- // 'strict'), but the cache-read path injects the stored bytes via React's
39
- // raw inner-HTML prop without re-sanitizing. A tampered sessionStorage
40
- // entry must not be trusted — reject anything that isn't a bare <svg> root
41
- // or that carries a <script> or an inline on*= event handler. On rejection
42
- // we return null so the caller falls back to a fresh, re-sanitized render.
43
- const svg = parsed.svg.trimStart();
44
- if (
45
- !svg.startsWith('<svg') ||
46
- /<script\b/i.test(svg) ||
47
- /<[^>]+\son[a-z]+\s*=/i.test(svg)
48
- ) {
49
- return null;
50
- }
51
- // Normalize the shape so the return honestly matches CachedDiagram:
52
- // legacy v1 entries predate height tracking, and a tampered/foreign
53
- // entry could carry a non-numeric height. Coerce both to a number.
54
- return {
55
- svg: parsed.svg,
56
- height: typeof parsed.height === 'number' ? parsed.height : 0,
57
- };
58
- } catch {
59
- return null;
60
- }
61
- }
62
-
63
- export function writeCache(source: string, entry: CachedDiagram): void {
64
- try {
65
- sessionStorage.setItem(CACHE_KEY_PREFIX + hashDiagram(source), JSON.stringify(entry));
66
- } catch {}
67
- }
68
-
69
- // Real mermaid output does NOT carry a `height` attribute — it sizes the root
70
- // <svg> via `viewBox` + `style="max-width"`. So read the explicit `height`
71
- // attribute first (covers other renderers / older mermaid), then fall back to
72
- // the 4th `viewBox` token (its height). Parsing the markup rather than
73
- // getBoundingClientRect works in jsdom and avoids a forced reflow on the
74
- // production hot path.
75
- export function readSvgHeight(svgMarkup: string): number {
76
- const attr = svgMarkup.match(/<svg\b[^>]*\bheight=["']([\d.]+)(?:px)?["']/i);
77
- if (attr) return parseFloat(attr[1]);
78
- const viewBox = svgMarkup.match(
79
- /<svg\b[^>]*\bviewBox=["']\s*[\d.+-]+\s+[\d.+-]+\s+[\d.+-]+\s+([\d.]+)/i
80
- );
81
- return viewBox ? parseFloat(viewBox[1]) : 0;
82
- }
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 };
83
32
 
84
33
  interface MermaidInnerProps {
85
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;