@karimsa/mdxserve 0.0.0-stage → 0.1.0

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.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -2
  3. package/client/App.tsx +7 -0
  4. package/client/CodeBlock.tsx +395 -0
  5. package/client/CrossFade.tsx +72 -0
  6. package/client/DocContext.ts +14 -0
  7. package/client/DocView.tsx +107 -0
  8. package/client/ErrorBox.tsx +23 -0
  9. package/client/Heading.tsx +31 -0
  10. package/client/HomeEmptyState.tsx +101 -0
  11. package/client/HomeView.tsx +78 -0
  12. package/client/ListingView.tsx +663 -0
  13. package/client/MdSection.tsx +234 -0
  14. package/client/MdSectionEditor.tsx +233 -0
  15. package/client/Mermaid.tsx +435 -0
  16. package/client/RenderErrorBoundary.tsx +40 -0
  17. package/client/Table.tsx +14 -0
  18. package/client/TaskCheckbox.tsx +38 -0
  19. package/client/api.ts +138 -0
  20. package/client/app.css +372 -0
  21. package/client/builtins/Badge.tsx +109 -0
  22. package/client/builtins/Button.tsx +111 -0
  23. package/client/builtins/Callout.tsx +97 -0
  24. package/client/builtins/Card.tsx +111 -0
  25. package/client/builtins/Chart.tsx +875 -0
  26. package/client/builtins/Diff.tsx +722 -0
  27. package/client/builtins/Dropdown.tsx +417 -0
  28. package/client/builtins/FileTree.tsx +87 -0
  29. package/client/builtins/Kbd.tsx +18 -0
  30. package/client/builtins/Screenshot.tsx +209 -0
  31. package/client/builtins/Sparkline.tsx +63 -0
  32. package/client/builtins/Tabs.tsx +169 -0
  33. package/client/builtins/Tooltip.tsx +52 -0
  34. package/client/builtins/chart-data.ts +133 -0
  35. package/client/builtins/index.ts +167 -0
  36. package/client/design/base/editor.css +151 -0
  37. package/client/design/base/prose.css +143 -0
  38. package/client/design/base/reset.css +79 -0
  39. package/client/design/tokens/colors.css +188 -0
  40. package/client/design/tokens/elevation.css +42 -0
  41. package/client/design/tokens/fonts.css +6 -0
  42. package/client/design/tokens/motion.css +76 -0
  43. package/client/design/tokens/spacing.css +34 -0
  44. package/client/design/tokens/typography.css +56 -0
  45. package/client/doc-module-cache.ts +17 -0
  46. package/client/editor-link.ts +27 -0
  47. package/client/entry.tsx +51 -0
  48. package/client/export-doc.ts +80 -0
  49. package/client/export-save.ts +96 -0
  50. package/client/favicon.svg +1 -0
  51. package/client/file-system-access.d.ts +29 -0
  52. package/client/format.ts +17 -0
  53. package/client/hooks.ts +34 -0
  54. package/client/lucide-icons.d.ts +9 -0
  55. package/client/mdx-components-base.ts +32 -0
  56. package/client/mdx-components.ts +18 -0
  57. package/client/mermaid-chart.ts +109 -0
  58. package/client/mermaid-direction.ts +73 -0
  59. package/client/motion.ts +104 -0
  60. package/client/platform.ts +16 -0
  61. package/client/route-path.ts +15 -0
  62. package/client/router.ts +452 -0
  63. package/client/shell/AppShell.tsx +401 -0
  64. package/client/shell/Footer.tsx +33 -0
  65. package/client/shell/NotFoundView.tsx +22 -0
  66. package/client/shell/Sidebar.tsx +169 -0
  67. package/client/shell/StandaloneShell.tsx +65 -0
  68. package/client/shell/TocRail.tsx +53 -0
  69. package/client/shell/TopBar.tsx +117 -0
  70. package/client/shell/use-doc-width.ts +61 -0
  71. package/client/shell/useToc.ts +77 -0
  72. package/client/ssr-entry.tsx +22 -0
  73. package/client/standalone-entry.tsx +51 -0
  74. package/client/state.ts +90 -0
  75. package/client/theme.ts +65 -0
  76. package/client/ui/Breadcrumb.tsx +49 -0
  77. package/client/ui/ConfirmDeleteDialog.tsx +113 -0
  78. package/client/ui/ExpandModal.tsx +342 -0
  79. package/client/ui/Icon.tsx +114 -0
  80. package/client/ui/IconButton.tsx +63 -0
  81. package/client/ui/Kbd.tsx +17 -0
  82. package/client/ui/PageNav.tsx +77 -0
  83. package/client/ui/ResizeHandle.tsx +201 -0
  84. package/client/ui/SearchDialog.tsx +187 -0
  85. package/client/ui/Tag.tsx +44 -0
  86. package/client/ui/Toast.tsx +189 -0
  87. package/client/ui/TocList.tsx +71 -0
  88. package/client/ui/icon-set.ts +102 -0
  89. package/client/ui/toast-count.ts +28 -0
  90. package/dist/cli.js +5091 -0
  91. package/dist/registry.json +703 -0
  92. package/dist/render-worker.js +145 -0
  93. package/package.json +115 -5
  94. package/skills/mdxserve/SKILL.md +178 -0
@@ -0,0 +1,209 @@
1
+ import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react";
2
+ import { motion } from "framer-motion";
3
+ import { z } from "zod";
4
+ import { TRANSITIONS } from "../motion";
5
+ import { IconButton } from "../ui/IconButton";
6
+ import { ExpandModal } from "../ui/ExpandModal";
7
+
8
+ export const screenshotProps = z.object({
9
+ src: z.string().describe("Image URL, relative to the document like a Markdown image."),
10
+ alt: z.string().default("").describe("Accessible description of the image."),
11
+ title: z
12
+ .string()
13
+ .optional()
14
+ .describe("Window title shown centred in the title bar, e.g. the app or page name."),
15
+ caption: z.string().optional().describe("Caption shown below the image on a hairline rule."),
16
+ width: z
17
+ .union([z.number(), z.string()])
18
+ .optional()
19
+ .describe("Max width of the window (px or any CSS length); defaults to the full column."),
20
+ });
21
+
22
+ export type ScreenshotProps = z.infer<typeof screenshotProps>;
23
+
24
+ // macOS traffic-light colours; the same in both themes, like the real thing.
25
+ const TRAFFIC_LIGHTS = ["#ff5f57", "#febc2e", "#28c840"];
26
+
27
+ const MIN_SCALE = 0.25;
28
+ const MAX_SCALE = 8;
29
+ /** Zoom factor per +/− press; wheel zoom scales continuously with the delta. */
30
+ const ZOOM_STEP = 1.25;
31
+
32
+ interface View {
33
+ scale: number;
34
+ x: number;
35
+ y: number;
36
+ }
37
+
38
+ const FIT_VIEW: View = { scale: 1, x: 0, y: 0 };
39
+
40
+ function clampScale(scale: number): number {
41
+ return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale));
42
+ }
43
+
44
+ /**
45
+ * Zoom `view` by `factor` keeping the viewport point `focus` (relative to
46
+ * the viewport centre) fixed under the cursor. The image is translated then
47
+ * scaled about its centre, so a point `p` on it lands at `x + scale * p`.
48
+ */
49
+ function zoomAt(view: View, factor: number, focus: { x: number; y: number }): View {
50
+ const scale = clampScale(view.scale * factor);
51
+ const ratio = scale / view.scale;
52
+ return {
53
+ scale,
54
+ x: focus.x - (focus.x - view.x) * ratio,
55
+ y: focus.y - (focus.y - view.y) * ratio,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Fills its parent with the image fitted to the available area at rest;
61
+ * drag to pan, wheel to zoom about the cursor, double-click to reset, with
62
+ * +/−/reset controls in the corner like the mermaid diagram viewport.
63
+ */
64
+ function PanZoomImage({ src, alt }: { src: string; alt: string }) {
65
+ const hostRef = useRef<HTMLDivElement>(null);
66
+ const [view, setView] = useState<View>(FIT_VIEW);
67
+ const dragRef = useRef<{
68
+ pointerId: number;
69
+ startX: number;
70
+ startY: number;
71
+ origin: View;
72
+ } | null>(null);
73
+
74
+ const reset = useCallback(() => setView(FIT_VIEW), []);
75
+ const zoomBy = useCallback(
76
+ (factor: number) => setView((current) => zoomAt(current, factor, { x: 0, y: 0 })),
77
+ [],
78
+ );
79
+
80
+ // Wheel zoom has to preventDefault so the page behind the modal doesn't
81
+ // scroll, and React registers wheel listeners as passive — so attach it by
82
+ // hand.
83
+ useEffect(() => {
84
+ const host = hostRef.current;
85
+ if (!host) return;
86
+ const onWheel = (event: WheelEvent) => {
87
+ event.preventDefault();
88
+ const rect = host.getBoundingClientRect();
89
+ const focus = {
90
+ x: event.clientX - (rect.left + rect.width / 2),
91
+ y: event.clientY - (rect.top + rect.height / 2),
92
+ };
93
+ const factor = Math.exp(-event.deltaY * 0.002);
94
+ setView((current) => zoomAt(current, factor, focus));
95
+ };
96
+ host.addEventListener("wheel", onWheel, { passive: false });
97
+ return () => host.removeEventListener("wheel", onWheel);
98
+ }, []);
99
+
100
+ const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
101
+ if (event.button !== 0) return;
102
+ event.currentTarget.setPointerCapture(event.pointerId);
103
+ dragRef.current = {
104
+ pointerId: event.pointerId,
105
+ startX: event.clientX,
106
+ startY: event.clientY,
107
+ origin: view,
108
+ };
109
+ };
110
+ const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
111
+ const drag = dragRef.current;
112
+ if (!drag || drag.pointerId !== event.pointerId) return;
113
+ setView({
114
+ scale: drag.origin.scale,
115
+ x: drag.origin.x + (event.clientX - drag.startX),
116
+ y: drag.origin.y + (event.clientY - drag.startY),
117
+ });
118
+ };
119
+ const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
120
+ if (dragRef.current?.pointerId !== event.pointerId) return;
121
+ dragRef.current = null;
122
+ event.currentTarget.releasePointerCapture(event.pointerId);
123
+ };
124
+
125
+ return (
126
+ <div className="relative h-full bg-surface-sunken">
127
+ <div
128
+ ref={hostRef}
129
+ className="flex h-full w-full cursor-grab touch-none select-none items-center justify-center overflow-hidden active:cursor-grabbing"
130
+ onPointerDown={onPointerDown}
131
+ onPointerMove={onPointerMove}
132
+ onPointerUp={onPointerUp}
133
+ onPointerCancel={onPointerUp}
134
+ onDoubleClick={reset}
135
+ >
136
+ <img
137
+ src={src}
138
+ alt={alt}
139
+ draggable={false}
140
+ className="max-h-full max-w-full"
141
+ style={{
142
+ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})`,
143
+ transformOrigin: "center",
144
+ }}
145
+ />
146
+ </div>
147
+ <motion.div
148
+ initial={{ opacity: 0 }}
149
+ animate={{ opacity: 1 }}
150
+ transition={{ ...TRANSITIONS.base, delay: 0.1 }}
151
+ className="absolute right-3 bottom-3 flex flex-col divide-y divide-border-subtle overflow-hidden rounded-md border border-border-default bg-surface-card shadow-xs"
152
+ >
153
+ <IconButton icon="plus" label="Zoom in" size="sm" onClick={() => zoomBy(ZOOM_STEP)} />
154
+ <IconButton icon="maximize" label="Reset view" size="sm" onClick={reset} />
155
+ <IconButton icon="minus" label="Zoom out" size="sm" onClick={() => zoomBy(1 / ZOOM_STEP)} />
156
+ </motion.div>
157
+ </div>
158
+ );
159
+ }
160
+
161
+ export default function Screenshot({ src, alt = "", title, caption, width }: ScreenshotProps) {
162
+ const [expanded, setExpanded] = useState(false);
163
+
164
+ return (
165
+ <figure
166
+ className="not-prose mx-auto overflow-hidden rounded-lg border border-border-default bg-surface-card shadow-md"
167
+ style={width === undefined ? undefined : { maxWidth: width }}
168
+ >
169
+ <div className="relative flex h-[34px] items-center border-b border-border-subtle bg-surface-raised px-3">
170
+ <div className="flex items-center gap-2" aria-hidden="true">
171
+ {TRAFFIC_LIGHTS.map((color) => (
172
+ <span
173
+ key={color}
174
+ className="block size-3 rounded-full border border-black/10"
175
+ style={{ backgroundColor: color }}
176
+ />
177
+ ))}
178
+ </div>
179
+ {title ? (
180
+ <span className="pointer-events-none absolute inset-x-16 truncate text-center font-sans font-medium leading-[1.62] text-[length:var(--size-xs)] text-text-subtle">
181
+ {title}
182
+ </span>
183
+ ) : null}
184
+ <IconButton
185
+ icon="expand"
186
+ label="Expand screenshot"
187
+ size="sm"
188
+ className="-mr-1 ml-auto"
189
+ onClick={() => setExpanded(true)}
190
+ />
191
+ </div>
192
+ <img src={src} alt={alt} className="block w-full" />
193
+ {caption ? (
194
+ <figcaption className="border-t border-border-subtle px-4 py-2.5 font-sans font-medium leading-normal text-[length:var(--size-xs)] text-text-subtle">
195
+ {caption}
196
+ </figcaption>
197
+ ) : null}
198
+ <ExpandModal
199
+ open={expanded}
200
+ onClose={() => setExpanded(false)}
201
+ icon="image"
202
+ title={title || alt || "Screenshot"}
203
+ hint="Drag to pan · scroll to zoom"
204
+ >
205
+ <PanZoomImage src={src} alt={alt} />
206
+ </ExpandModal>
207
+ </figure>
208
+ );
209
+ }
@@ -0,0 +1,63 @@
1
+ import { z } from "zod";
2
+
3
+ export const sparklineProps = z.object({
4
+ values: z.array(z.number()).describe("Series to plot; at least two numbers."),
5
+ width: z.number().default(84).describe("SVG width in px."),
6
+ height: z.number().default(20).describe("SVG height in px."),
7
+ color: z
8
+ .string()
9
+ .default("var(--chart-1)")
10
+ .describe("Any CSS color, typically a `--chart-1`..`--chart-6` token."),
11
+ fill: z.boolean().default(true).describe("Tinted area under the line."),
12
+ });
13
+
14
+ export type SparklineProps = z.infer<typeof sparklineProps>;
15
+
16
+ export default function Sparkline({
17
+ values = [],
18
+ width = 84,
19
+ height = 20,
20
+ color = "var(--chart-1)",
21
+ fill = true,
22
+ }: SparklineProps) {
23
+ const nums = values.map(Number).filter((value) => !Number.isNaN(value));
24
+ if (nums.length < 2) return null;
25
+
26
+ const min = Math.min(...nums);
27
+ const max = Math.max(...nums);
28
+ const span = max - min || 1;
29
+ const xAt = (index: number) => (index / (nums.length - 1)) * (width - 2) + 1;
30
+ const yAt = (value: number) => height - 2 - ((value - min) / span) * (height - 4);
31
+ const pathData = nums
32
+ .map(
33
+ (value, index) => (index ? "L" : "M") + xAt(index).toFixed(1) + " " + yAt(value).toFixed(1),
34
+ )
35
+ .join(" ");
36
+
37
+ return (
38
+ <svg
39
+ width={width}
40
+ height={height}
41
+ viewBox={`0 0 ${width} ${height}`}
42
+ className="not-prose inline-block align-middle"
43
+ role="img"
44
+ >
45
+ {fill ? (
46
+ <path
47
+ d={`${pathData} L${width - 1} ${height} L1 ${height} Z`}
48
+ fill={color}
49
+ opacity="0.13"
50
+ />
51
+ ) : null}
52
+ <path
53
+ d={pathData}
54
+ fill="none"
55
+ stroke={color}
56
+ strokeWidth="1.5"
57
+ strokeLinecap="round"
58
+ strokeLinejoin="round"
59
+ />
60
+ <circle cx={xAt(nums.length - 1)} cy={yAt(nums[nums.length - 1])} r="2" fill={color} />
61
+ </svg>
62
+ );
63
+ }
@@ -0,0 +1,169 @@
1
+ import {
2
+ Children,
3
+ isValidElement,
4
+ useId,
5
+ useRef,
6
+ useState,
7
+ type KeyboardEvent,
8
+ type ReactElement,
9
+ type ReactNode,
10
+ } from "react";
11
+ import { motion } from "framer-motion";
12
+ import { z } from "zod";
13
+ import { TRANSITIONS } from "../motion";
14
+ import { CrossFade } from "../CrossFade";
15
+
16
+ export const tabProps = z.object({
17
+ label: z.string().describe("Tab label shown in the tab list."),
18
+ value: z
19
+ .string()
20
+ .optional()
21
+ .describe("Stable identifier for this tab; defaults to a slug of `label`."),
22
+ children: z.custom<ReactNode>().describe("Tab panel content; any MDX/Markdown content."),
23
+ });
24
+
25
+ export type TabProps = z.infer<typeof tabProps>;
26
+
27
+ /**
28
+ * Renders nothing on its own — `Tabs` reads each `Tab` child's props to build
29
+ * the tab list and panels, matching on `props.label` rather than `type ===
30
+ * Tab` (component identity changes under Fast Refresh; see client/CodeBlock.tsx).
31
+ */
32
+ export function Tab(_props: TabProps) {
33
+ return null;
34
+ }
35
+
36
+ export const tabsProps = z.object({
37
+ defaultValue: z
38
+ .string()
39
+ .optional()
40
+ .describe("Value of the tab selected initially; defaults to the first tab."),
41
+ children: z.custom<ReactNode>().describe("One or more `<Tab>` elements."),
42
+ });
43
+
44
+ export type TabsProps = z.infer<typeof tabsProps>;
45
+
46
+ function slugify(label: string): string {
47
+ return label
48
+ .toLowerCase()
49
+ .trim()
50
+ .replace(/[^a-z0-9]+/g, "-")
51
+ .replace(/(^-|-$)/g, "");
52
+ }
53
+
54
+ interface TabEntry {
55
+ value: string;
56
+ label: string;
57
+ content: ReactNode;
58
+ }
59
+
60
+ type TabLikeElement = ReactElement<{ label: string; value?: string; children?: ReactNode }>;
61
+
62
+ /**
63
+ * Pull { label, value, children } out of each `Tab` child. Matches by
64
+ * `typeof props.label === "string"` rather than `child.type === Tab` — under
65
+ * Fast Refresh, component identity changes across re-executions, so a type
66
+ * check would silently stop matching (see the `Figure` lesson in CodeBlock.tsx).
67
+ */
68
+ function collectTabs(children: ReactNode): TabEntry[] {
69
+ return Children.toArray(children)
70
+ .filter(
71
+ (child): child is TabLikeElement =>
72
+ isValidElement(child) && typeof (child.props as { label?: unknown }).label === "string",
73
+ )
74
+ .map((child) => {
75
+ const { label, value, children: content } = child.props;
76
+ return { label, value: value ?? slugify(label), content };
77
+ });
78
+ }
79
+
80
+ export default function Tabs({ defaultValue, children }: TabsProps) {
81
+ const tabs = collectTabs(children);
82
+ const [active, setActive] = useState<string | undefined>(defaultValue ?? tabs[0]?.value);
83
+ const scopeId = useId();
84
+ const listRef = useRef<HTMLDivElement>(null);
85
+
86
+ const activeIndex = Math.max(
87
+ 0,
88
+ tabs.findIndex((tab) => tab.value === active),
89
+ );
90
+ const activeTab = tabs[activeIndex];
91
+
92
+ function focusTabAt(index: number) {
93
+ const buttons = listRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]');
94
+ buttons?.[index]?.focus();
95
+ }
96
+
97
+ function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
98
+ if (tabs.length === 0) return;
99
+ let nextIndex: number | null = null;
100
+ if (event.key === "ArrowRight") nextIndex = (activeIndex + 1) % tabs.length;
101
+ else if (event.key === "ArrowLeft") nextIndex = (activeIndex - 1 + tabs.length) % tabs.length;
102
+ else if (event.key === "Home") nextIndex = 0;
103
+ else if (event.key === "End") nextIndex = tabs.length - 1;
104
+
105
+ if (nextIndex !== null) {
106
+ event.preventDefault();
107
+ setActive(tabs[nextIndex].value);
108
+ focusTabAt(nextIndex);
109
+ }
110
+ }
111
+
112
+ if (!activeTab) return null;
113
+
114
+ return (
115
+ <div>
116
+ <div
117
+ ref={listRef}
118
+ role="tablist"
119
+ onKeyDown={handleKeyDown}
120
+ className="not-prose flex gap-1 border-b border-border-default"
121
+ >
122
+ {tabs.map((tab) => {
123
+ const isActive = tab.value === activeTab.value;
124
+ return (
125
+ <button
126
+ key={tab.value}
127
+ type="button"
128
+ role="tab"
129
+ id={`${scopeId}-tab-${tab.value}`}
130
+ aria-selected={isActive}
131
+ aria-controls={`${scopeId}-panel-${tab.value}`}
132
+ tabIndex={isActive ? 0 : -1}
133
+ onClick={() => setActive(tab.value)}
134
+ className={
135
+ "relative h-[34px] cursor-pointer px-3 font-sans font-medium leading-normal text-[length:var(--size-sm)] transition-colors " +
136
+ (isActive
137
+ ? "font-semibold text-text-heading"
138
+ : "font-medium text-text-subtle hover:text-text-body")
139
+ }
140
+ >
141
+ {tab.label}
142
+ {isActive ? (
143
+ <motion.span
144
+ layoutId={`${scopeId}-tab-underline`}
145
+ transition={TRANSITIONS.snap}
146
+ className="absolute inset-x-0 -bottom-px h-0.5 rounded-full bg-teal-500"
147
+ />
148
+ ) : null}
149
+ </button>
150
+ );
151
+ })}
152
+ </div>
153
+ <CrossFade
154
+ active={activeTab.value}
155
+ panes={tabs.map((tab) => ({
156
+ key: tab.value,
157
+ node: (
158
+ <div className="pt-4 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{tab.content}</div>
159
+ ),
160
+ props: {
161
+ id: `${scopeId}-panel-${tab.value}`,
162
+ role: "tabpanel",
163
+ "aria-labelledby": `${scopeId}-tab-${tab.value}`,
164
+ },
165
+ }))}
166
+ />
167
+ </div>
168
+ );
169
+ }
@@ -0,0 +1,52 @@
1
+ import { useRef, type ReactNode, type RefObject } from "react";
2
+ import Tippy from "@tippyjs/react";
3
+ import { z } from "zod";
4
+
5
+ // tippy.js's base CSS is pulled in via client/app.css (`@import
6
+ // "tippy.js/dist/tippy.css"`) instead of a side-effect import here: this
7
+ // module is imported by scripts/build-registry.ts under plain Node (via
8
+ // tsx) to read `tooltipProps`, and a top-level `import "*.css"` there would
9
+ // crash with ERR_UNKNOWN_FILE_EXTENSION. Routing all CSS through app.css also
10
+ // matches how the rest of the app's styling is centralized.
11
+
12
+ export const tooltipProps = z.object({
13
+ content: z.string().describe("Tooltip text shown on hover/focus."),
14
+ placement: z
15
+ .enum(["top", "bottom", "left", "right"])
16
+ .default("top")
17
+ .describe("Preferred side of the target."),
18
+ delay: z.number().default(100).describe("Delay in milliseconds before the tooltip appears."),
19
+ children: z.custom<ReactNode>().describe("The element the tooltip is attached to."),
20
+ });
21
+
22
+ export type TooltipProps = z.infer<typeof tooltipProps>;
23
+
24
+ export default function Tooltip({
25
+ content,
26
+ placement = "top",
27
+ delay = 100,
28
+ children,
29
+ }: TooltipProps) {
30
+ // Attach via `reference` rather than wrapping a child element: @tippyjs/react
31
+ // clones its child and reads `element.ref`, which React 19 warns about.
32
+ const target = useRef<HTMLSpanElement>(null) as RefObject<HTMLSpanElement>;
33
+ return (
34
+ <>
35
+ <span
36
+ ref={target}
37
+ tabIndex={0}
38
+ className="cursor-help underline decoration-dotted decoration-text-subtle underline-offset-2"
39
+ >
40
+ {children}
41
+ </span>
42
+ <Tippy
43
+ reference={target}
44
+ content={content}
45
+ placement={placement}
46
+ delay={delay}
47
+ theme="mdxserve"
48
+ arrow
49
+ />
50
+ </>
51
+ );
52
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Pure maths for `Chart` (`Chart.tsx`): number coercion, "nice" axis maxima,
3
+ * tick placement, histogram binning, and the label-fitting used by the
4
+ * horizontal-bar category column. No React or DOM, so this runs in vitest's
5
+ * plain node environment the same way `mermaid-direction.ts` does.
6
+ */
7
+
8
+ export interface HistogramBin {
9
+ /** `"<start>–<end>"`, formatted to the bin width's precision. */
10
+ label: string;
11
+ /** Count of values that fell in this bin. */
12
+ value: number;
13
+ start: number;
14
+ end: number;
15
+ }
16
+
17
+ function clamp(value: number, min: number, max: number): number {
18
+ return Math.min(max, Math.max(min, value));
19
+ }
20
+
21
+ /** `Number(value)` when finite, else `0` — a single bad datum should drop a
22
+ bar or a point, never the whole series (a `NaN` in an SVG path breaks it). */
23
+ export function toNumber(value: string | number | undefined): number {
24
+ const number = typeof value === "number" ? value : Number(value);
25
+ return Number.isFinite(number) ? number : 0;
26
+ }
27
+
28
+ /** Rounds `value` up to a "nice" leading digit at its magnitude (94 → 100,
29
+ 118 → 200). Non-finite or non-positive input becomes `1`. */
30
+ export function niceMax(value: number): number {
31
+ if (!Number.isFinite(value) || value <= 0) return 1;
32
+ const magnitude = Math.pow(10, Math.floor(Math.log10(value)));
33
+ return Math.ceil(value / magnitude) * magnitude;
34
+ }
35
+
36
+ /** Unique ascending integer ticks from `0` to `max`, at most `desired`
37
+ of them. Rounding two fractions to the same integer (a small `max`)
38
+ collapses to one tick instead of a visible duplicate. */
39
+ export function countTicks(max: number, desired = 5): number[] {
40
+ const safeMax = Number.isFinite(max) && max > 0 ? max : 1;
41
+ const steps = Math.max(1, desired - 1);
42
+ const ticks: number[] = [];
43
+ for (let index = 0; index <= steps; index++) {
44
+ const tick = Math.round((safeMax * index) / steps);
45
+ if (ticks.length === 0 || tick > ticks[ticks.length - 1]!) ticks.push(tick);
46
+ }
47
+ return ticks;
48
+ }
49
+
50
+ /** Sturges' rule (`ceil(log2 n + 1)`), clamped to `[1, 20]` — 5–8 bins for
51
+ the tens-of-values datasets a histogram here is meant for. */
52
+ export function autoBinCount(count: number): number {
53
+ if (!Number.isFinite(count) || count <= 0) return 1;
54
+ return clamp(Math.ceil(Math.log2(count) + 1), 1, 20);
55
+ }
56
+
57
+ /** Decimal places implied by `step` — none once a bin spans 10 or more,
58
+ one below that, and enough to show a sub-unit step (`0.03` → 3) — so an
59
+ edge computed as `0.30000000000000004` prints `0.3`, and `22.333…` prints
60
+ `22.3` rather than six noise digits. */
61
+ export function formatBinEdge(value: number, step: number): string {
62
+ const size = Number.isFinite(step) ? Math.abs(step) : 0;
63
+ let decimals = 0;
64
+ if (size > 0 && size < 10) {
65
+ decimals = size >= 1 ? 1 : Math.min(6, Math.ceil(-Math.log10(size)) + 1);
66
+ }
67
+ return String(Number(value.toFixed(decimals)));
68
+ }
69
+
70
+ /** Bins finite `values` into equal-width buckets (`bins`, else a Sturges
71
+ estimate, clamped to `[1, 50]`). Every value lands in exactly one
72
+ half-open bin except the last, which is closed at `max`. An empty input
73
+ yields `[]`; a zero-width range (every value equal) yields one bin
74
+ holding everything. */
75
+ export function binValues(values: number[], bins?: number): HistogramBin[] {
76
+ const finite = values.filter((value) => Number.isFinite(value));
77
+ if (finite.length === 0) return [];
78
+ const min = Math.min(...finite);
79
+ const max = Math.max(...finite);
80
+ if (max === min) {
81
+ return [{ label: formatBinEdge(min, 1), value: finite.length, start: min, end: max }];
82
+ }
83
+ const count = clamp(bins ?? autoBinCount(finite.length), 1, 50);
84
+ const step = (max - min) / count;
85
+ const counts = new Array(count).fill(0) as number[];
86
+ for (const value of finite) {
87
+ const index = Math.min(count - 1, Math.floor(((value - min) / (max - min)) * count));
88
+ counts[index]++;
89
+ }
90
+ return counts.map((value, index) => {
91
+ const start = min + step * index;
92
+ const end = index === count - 1 ? max : min + step * (index + 1);
93
+ return {
94
+ label: `${formatBinEdge(start, step)}–${formatBinEdge(end, step)}`,
95
+ value,
96
+ start,
97
+ end,
98
+ };
99
+ });
100
+ }
101
+
102
+ /** A stride-thinned subset of `[0, edgeCount)`, always including the first
103
+ and last index, with at most `maxLabels` entries. */
104
+ export function edgeTickIndices(edgeCount: number, maxLabels: number): number[] {
105
+ if (edgeCount <= 0) return [];
106
+ if (edgeCount === 1) return [0];
107
+ const cap = Math.max(2, maxLabels);
108
+ if (edgeCount <= cap) return Array.from({ length: edgeCount }, (_element, index) => index);
109
+ const stride = Math.ceil((edgeCount - 1) / (cap - 1));
110
+ const indices: number[] = [];
111
+ for (let index = 0; index < edgeCount; index += stride) indices.push(index);
112
+ if (indices[indices.length - 1] !== edgeCount - 1) indices.push(edgeCount - 1);
113
+ return indices;
114
+ }
115
+
116
+ /** Width in px for a category-label column: the longest label at roughly
117
+ 0.58em per character, clamped to `[min, max]`. */
118
+ export function labelColumnWidth(
119
+ labels: string[],
120
+ { fontSize, min, max }: { fontSize: number; min: number; max: number },
121
+ ): number {
122
+ const longest = labels.reduce((widest, label) => Math.max(widest, label.length), 0);
123
+ return clamp(longest * 0.58 * fontSize + 8, min, max);
124
+ }
125
+
126
+ /** `label` unchanged if it fits in `maxWidth` at `fontSize`, else truncated
127
+ with a trailing `…`. */
128
+ export function fitLabel(label: string, maxWidth: number, fontSize: number): string {
129
+ const charWidth = 0.58 * fontSize;
130
+ if (label.length * charWidth <= maxWidth) return label;
131
+ const maxChars = Math.max(1, Math.floor(maxWidth / charWidth) - 1);
132
+ return `${label.slice(0, maxChars)}…`;
133
+ }