@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,188 @@
1
+ /* ── mdxserve colors ───────────────────────────────────────────────
2
+ Warm neutral paper + one deep teal accent. Content is the subject;
3
+ chrome is neutral, accent is used sparingly for state and links. */
4
+ :root {
5
+ /* neutrals — warm grey "paper" ramp */
6
+ --n-0: #ffffff;
7
+ --n-25: #fbfbf9;
8
+ --n-50: #f6f6f3;
9
+ --n-100: #eeeeea;
10
+ --n-200: #e3e2dd;
11
+ --n-300: #cfcec7;
12
+ --n-400: #a9a8a0;
13
+ --n-500: #82817a;
14
+ --n-600: #615f5a;
15
+ --n-700: #464540;
16
+ --n-800: #2b2a27;
17
+ --n-900: #1a1918;
18
+ --n-950: #111110;
19
+
20
+ /* accent — deep teal */
21
+ --teal-50: #e8f6f5;
22
+ --teal-100: #c9eae8;
23
+ --teal-200: #9bd8d5;
24
+ --teal-300: #63c1be;
25
+ --teal-400: #2ba5a2;
26
+ --teal-500: #0e8f8c;
27
+ /* fill under white text (primary button, checkbox): the one step that
28
+ clears 4.5:1 on white and 3:1 against both page grounds */
29
+ --teal-550: #0c8280;
30
+ --teal-600: #0a7472;
31
+ --teal-700: #085b59;
32
+ --teal-800: #064442;
33
+ --teal-900: #042c2b;
34
+
35
+ /* support hues — semantic + diagram fills only, never decoration */
36
+ --blue-100: #dce7fd;
37
+ --blue-500: #2f6fed;
38
+ --blue-700: #1d4dab;
39
+ --green-100: #d8f0e3;
40
+ --green-500: #1a7f4f;
41
+ --green-700: #12603b;
42
+ --amber-100: #fbeccd;
43
+ --amber-500: #b07405;
44
+ --amber-700: #855603;
45
+ --red-100: #fbdfd9;
46
+ --red-500: #c8412f;
47
+ --red-700: #9a2f20;
48
+ --violet-100: #e6e2fb;
49
+ --violet-500: #5b4fd0;
50
+ --violet-700: #433a9e;
51
+
52
+ /* ── semantic aliases ── */
53
+ --surface-page: var(--n-25);
54
+ --surface-card: var(--n-0);
55
+ --surface-sunken: var(--n-50);
56
+ --surface-raised: var(--n-0);
57
+ --surface-inverse: var(--n-900);
58
+ --surface-hover: var(--n-100);
59
+ --surface-active: var(--n-200);
60
+ --surface-accent-soft: var(--teal-50);
61
+ --surface-accent: var(--teal-550);
62
+
63
+ --text-body: var(--n-800);
64
+ --text-heading: var(--n-950);
65
+ --text-muted: var(--n-600);
66
+ --text-subtle: var(--n-500);
67
+ --text-inverse: var(--n-25);
68
+ --text-accent: var(--teal-600);
69
+ --text-link: var(--teal-600);
70
+ --text-link-hover: var(--teal-700);
71
+
72
+ --border-subtle: var(--n-100);
73
+ --border-default: var(--n-200);
74
+ --border-strong: var(--n-300);
75
+ --border-accent: var(--teal-400);
76
+ --focus-ring: color-mix(in oklab, var(--teal-500) 45%, transparent);
77
+
78
+ --status-info-bg: var(--blue-100);
79
+ --status-info-fg: var(--blue-700);
80
+ --status-ok-bg: var(--green-100);
81
+ --status-ok-fg: var(--green-700);
82
+ --status-warn-bg: var(--amber-100);
83
+ --status-warn-fg: var(--amber-700);
84
+ --status-danger-bg: var(--red-100);
85
+ --status-danger-fg: var(--red-700);
86
+ --status-note-bg: var(--violet-100);
87
+ --status-note-fg: var(--violet-700);
88
+
89
+ /* diagrams + charts — categorical series, in order of use */
90
+ --chart-1: var(--teal-500);
91
+ --chart-2: var(--blue-500);
92
+ --chart-3: var(--amber-500);
93
+ --chart-4: var(--violet-500);
94
+ --chart-5: var(--green-500);
95
+ --chart-6: var(--red-500);
96
+ --chart-grid: var(--border-subtle);
97
+ --chart-axis: var(--n-300);
98
+ --chart-label: var(--text-subtle);
99
+ --diagram-bg: var(--surface-card);
100
+ --diagram-node-bg: var(--surface-accent-soft);
101
+ --diagram-node-border: var(--teal-400);
102
+ --diagram-node-fg: var(--teal-700);
103
+ --diagram-alt-node-bg: var(--surface-sunken);
104
+ --diagram-alt-node-border: var(--border-strong);
105
+ --diagram-line: var(--n-400);
106
+ --diagram-label-bg: var(--surface-card);
107
+
108
+ /* code surfaces + syntax */
109
+ --code-bg: var(--n-50);
110
+ --code-bg-inline: var(--teal-50);
111
+ --code-fg: var(--n-800);
112
+ --code-border: var(--n-200);
113
+ --code-gutter: var(--n-400);
114
+ --code-highlight: color-mix(in oklab, var(--teal-500) 9%, transparent);
115
+ --syntax-keyword: #8a3fa8;
116
+ --syntax-string: #0a7472;
117
+ --syntax-number: #b07405;
118
+ --syntax-comment: #82817a;
119
+ --syntax-function: #2f6fed;
120
+ --syntax-tag: #c8412f;
121
+ --syntax-attr: #5b4fd0;
122
+ --syntax-punct: #615f5a;
123
+ }
124
+
125
+ /* Dark: the pre-design-system blue-black (Tailwind gray-950) kept on request — a
126
+ cool near-black ramp instead of the warm one the design project shipped
127
+ (#141514). Teal is lifted for contrast exactly as in the design. */
128
+ :root[data-theme="dark"] {
129
+ --surface-page: #030712;
130
+ --surface-card: #0b1120;
131
+ --surface-sunken: #020509;
132
+ --surface-raised: #111827;
133
+ --surface-inverse: var(--n-50);
134
+ --surface-hover: #1b2436;
135
+ --surface-active: #263044;
136
+ --surface-accent-soft: var(--teal-900);
137
+ --surface-accent: var(--teal-550);
138
+
139
+ --text-body: #d1d5db;
140
+ --text-heading: #f9fafb;
141
+ --text-muted: #9ca3af;
142
+ --text-subtle: #6b7280;
143
+ --text-inverse: var(--n-950);
144
+ --text-accent: var(--teal-300);
145
+ --text-link: var(--teal-300);
146
+ --text-link-hover: var(--teal-200);
147
+
148
+ --border-subtle: #111827;
149
+ --border-default: #1f2937;
150
+ --border-strong: #374151;
151
+ --border-accent: var(--teal-400);
152
+
153
+ --status-info-bg: #182a4d;
154
+ --status-info-fg: #9dbcfb;
155
+ --status-ok-bg: #122f22;
156
+ --status-ok-fg: #8fd8b0;
157
+ --status-warn-bg: #33260a;
158
+ --status-warn-fg: #e9c078;
159
+ --status-danger-bg: #39190f;
160
+ --status-danger-fg: #eda99b;
161
+ --status-note-bg: #221d44;
162
+ --status-note-fg: #b9b1f2;
163
+
164
+ --chart-1: var(--teal-300);
165
+ --chart-2: #8db3fb;
166
+ --chart-3: #e9c078;
167
+ --chart-4: #b9b1f2;
168
+ --chart-5: #8fd8b0;
169
+ --chart-6: #eda99b;
170
+ --chart-grid: #111827;
171
+ --chart-axis: #1f2937;
172
+ --diagram-node-fg: var(--teal-200);
173
+ --diagram-line: #4b5563;
174
+
175
+ --code-bg: #0b1120;
176
+ --code-bg-inline: #0f2422;
177
+ --code-fg: #d1d5db;
178
+ --code-border: #1f2937;
179
+ --code-gutter: #4b5563;
180
+ --syntax-keyword: #d79ae8;
181
+ --syntax-string: #63c1be;
182
+ --syntax-number: #e9c078;
183
+ --syntax-comment: #6b7280;
184
+ --syntax-function: #8db3fb;
185
+ --syntax-tag: #eda99b;
186
+ --syntax-attr: #b9b1f2;
187
+ --syntax-punct: #9ca3af;
188
+ }
@@ -0,0 +1,42 @@
1
+ :root {
2
+ /* radii — 6px is the default; nothing in the system is pill-shaped except tags/kbd */
3
+ --radius-xs: 3px;
4
+ --radius-sm: 4px;
5
+ --radius-md: 6px;
6
+ --radius-lg: 10px;
7
+ --radius-xl: 14px;
8
+ --radius-pill: 999px;
9
+
10
+ --border-width: 1px;
11
+ --border-width-thick: 2px;
12
+
13
+ /* shadows — hairline-first. Borders do the work; shadow only signals lift. */
14
+ --shadow-none: none;
15
+ --shadow-hairline: 0 0 0 1px var(--border-default);
16
+ --shadow-xs: 0 1px 2px rgba(26, 25, 24, 0.05);
17
+ --shadow-sm: 0 1px 2px rgba(26, 25, 24, 0.05), 0 2px 6px rgba(26, 25, 24, 0.04);
18
+ --shadow-md: 0 2px 4px rgba(26, 25, 24, 0.05), 0 8px 20px rgba(26, 25, 24, 0.07);
19
+ --shadow-lg: 0 4px 8px rgba(26, 25, 24, 0.06), 0 18px 44px rgba(26, 25, 24, 0.11);
20
+ --shadow-inset: inset 0 1px 0 rgba(255, 255, 255, 0.6);
21
+ --shadow-focus: 0 0 0 3px var(--focus-ring);
22
+ /* The one deliberate coloured ring/glow in the system — toasts only. */
23
+ --shadow-toast:
24
+ 0 0 0 1px rgba(43, 165, 162, 0.18), 0 8px 24px rgba(43, 165, 162, 0.12),
25
+ 0 2px 4px rgba(26, 25, 24, 0.05), 0 8px 20px rgba(26, 25, 24, 0.07);
26
+
27
+ /* glass — used only for the sticky topbar and the command palette scrim */
28
+ --blur-chrome: saturate(1.4) blur(12px); /* @kind other */
29
+ --scrim: color-mix(in oklab, var(--n-950) 34%, transparent); /* @kind color */
30
+ }
31
+
32
+ :root[data-theme="dark"] {
33
+ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
34
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.3);
35
+ --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.4), 0 8px 20px rgba(0, 0, 0, 0.45);
36
+ --shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.45), 0 18px 44px rgba(0, 0, 0, 0.55);
37
+ --shadow-inset: inset 0 1px 0 rgba(255, 255, 255, 0.05);
38
+ --shadow-toast:
39
+ 0 0 0 1px rgba(43, 165, 162, 0.28), 0 8px 24px rgba(43, 165, 162, 0.22),
40
+ 0 2px 4px rgba(0, 0, 0, 0.4), 0 8px 20px rgba(0, 0, 0, 0.45);
41
+ --scrim: rgba(0, 0, 0, 0.6);
42
+ }
@@ -0,0 +1,6 @@
1
+ /* Webfonts, self-hosted from the @fontsource-variable packages so mdxserve works
2
+ offline and never phones out to a font CDN. Explicit index.css subpaths: the
3
+ Tailwind CSS resolver that processes these imports does not try package
4
+ "index" files on its own. */
5
+ @import "@fontsource-variable/manrope/index.css";
6
+ @import "@fontsource-variable/jetbrains-mono/index.css";
@@ -0,0 +1,76 @@
1
+ :root {
2
+ /* Motion is short and flat. Nothing bounces past 2%; nothing travels over 10px.
3
+ Springs are the default for anything a person touches or that changes size;
4
+ tweens are for colour and opacity only. Implemented with Framer Motion via
5
+ components/motion/MotionKit.jsx — these tokens are the same numbers. */
6
+ --dur-instant: 80ms; /* @kind other */
7
+ --dur-fast: 120ms; /* @kind other */
8
+ --dur-base: 180ms; /* @kind other */
9
+ --dur-slow: 260ms; /* @kind other */
10
+ --ease-standard: cubic-bezier(0.2, 0, 0.2, 1); /* @kind other */
11
+ --ease-out: cubic-bezier(0, 0, 0.2, 1); /* @kind other */
12
+ --ease-in: cubic-bezier(0.4, 0, 1, 1); /* @kind other */
13
+
14
+ --transition-color:
15
+ color var(--dur-fast) var(--ease-standard),
16
+ background-color var(--dur-fast) var(--ease-standard),
17
+ border-color var(--dur-fast) var(--ease-standard);
18
+ --transition-lift:
19
+ box-shadow var(--dur-base) var(--ease-standard), transform var(--dur-fast) var(--ease-standard);
20
+
21
+ /* Springs — mirrored in MotionKit's T.snap / T.glide. */
22
+ --spring-snap: 620 34 0.7; /* @kind other */
23
+ --spring-glide: 340 30 0.9; /* @kind other */
24
+
25
+ /* Travel distances. Anything larger reads as a page transition, which this system has none of. */
26
+ --travel-xs: 4px;
27
+ --travel-sm: 6px;
28
+ --travel-md: 10px;
29
+ --press-offset: 0.5px;
30
+ --hover-nudge: 2px;
31
+ --pop-scale: 0.985; /* @kind other */
32
+ --enter-pop: popIn var(--dur-base) var(--ease-out);
33
+ --enter-fade: fadeIn var(--dur-base) var(--ease-out);
34
+
35
+ --z-sticky: 10; /* @kind other */
36
+ --z-dropdown: 40; /* @kind other */
37
+ --z-scrim: 60; /* @kind other */
38
+ --z-modal: 70; /* @kind other */
39
+ --z-toast: 80; /* @kind other */
40
+ }
41
+ @keyframes fadeIn {
42
+ from {
43
+ opacity: 0;
44
+ }
45
+ to {
46
+ opacity: 1;
47
+ }
48
+ }
49
+ @keyframes popIn {
50
+ from {
51
+ opacity: 0;
52
+ transform: translateY(4px) scale(0.985);
53
+ }
54
+ to {
55
+ opacity: 1;
56
+ transform: none;
57
+ }
58
+ }
59
+ @keyframes slideUp {
60
+ from {
61
+ opacity: 0;
62
+ transform: translateY(8px);
63
+ }
64
+ to {
65
+ opacity: 1;
66
+ transform: none;
67
+ }
68
+ }
69
+ @media (prefers-reduced-motion: reduce) {
70
+ :root {
71
+ --dur-instant: 0ms; /* @kind other */
72
+ --dur-fast: 0ms; /* @kind other */
73
+ --dur-base: 0ms; /* @kind other */
74
+ --dur-slow: 0ms /* @kind other */;
75
+ }
76
+ }
@@ -0,0 +1,34 @@
1
+ :root {
2
+ /* 4px base, with 2px and 6px steps where UI density needs them */
3
+ --space-0: 0;
4
+ --space-px: 1px;
5
+ --space-0-5: 2px;
6
+ --space-1: 4px;
7
+ --space-1-5: 6px;
8
+ --space-2: 8px;
9
+ --space-2-5: 10px;
10
+ --space-3: 12px;
11
+ --space-4: 16px;
12
+ --space-5: 20px;
13
+ --space-6: 24px;
14
+ --space-7: 28px;
15
+ --space-8: 32px;
16
+ --space-10: 40px;
17
+ --space-12: 48px;
18
+ --space-16: 64px;
19
+ --space-20: 80px;
20
+ --space-24: 96px;
21
+ --space-32: 128px;
22
+
23
+ /* rhythm — vertical spacing inside rendered markdown */
24
+ --prose-gap: var(--space-5);
25
+ --prose-gap-heading: var(--space-8);
26
+ --prose-gap-block: var(--space-6);
27
+
28
+ /* layout */
29
+ --sidebar-width: 264px;
30
+ --toc-width: 220px;
31
+ --topbar-height: 52px;
32
+ --gutter-page: var(--space-8);
33
+ --gutter-page-lg: var(--space-12);
34
+ }
@@ -0,0 +1,56 @@
1
+ :root {
2
+ --font-core:
3
+ "Manrope Variable", "Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
4
+ --font-mono:
5
+ "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
6
+
7
+ --weight-regular: 400;
8
+ --weight-medium: 500;
9
+ --weight-semibold: 600;
10
+ --weight-bold: 700;
11
+ --weight-extrabold: 800;
12
+
13
+ /* size ramp — 15px UI base, 16.5px prose base */
14
+ --size-2xs: 11px;
15
+ --size-xs: 12px;
16
+ --size-sm: 13px;
17
+ --size-md: 15px;
18
+ --size-prose: 16.5px;
19
+ --size-lg: 18px;
20
+ --size-xl: 21px;
21
+ --size-2xl: 26px;
22
+ --size-3xl: 32px;
23
+ --size-4xl: 40px;
24
+ --size-5xl: 52px;
25
+ --size-6xl: 68px;
26
+
27
+ --leading-tight: 1.15;
28
+ --leading-snug: 1.3;
29
+ --leading-normal: 1.5;
30
+ --leading-prose: 1.72;
31
+ --leading-code: 1.62;
32
+
33
+ --tracking-tight: -0.022em;
34
+ --tracking-snug: -0.012em;
35
+ --tracking-normal: 0;
36
+ --tracking-wide: 0.04em;
37
+ --tracking-caps: 0.09em;
38
+
39
+ /* composed roles */
40
+ --type-display: var(--weight-extrabold) var(--size-5xl)/var(--leading-tight) var(--font-core);
41
+ --type-h1: var(--weight-bold) var(--size-3xl)/1.2 var(--font-core);
42
+ --type-h2: var(--weight-bold) var(--size-2xl)/1.25 var(--font-core);
43
+ --type-h3: var(--weight-semibold) var(--size-xl)/1.35 var(--font-core);
44
+ --type-h4: var(--weight-semibold) var(--size-lg)/1.4 var(--font-core);
45
+ --type-prose: var(--weight-regular) var(--size-prose)/var(--leading-prose) var(--font-core);
46
+ --type-ui: var(--weight-medium) var(--size-md)/var(--leading-normal) var(--font-core);
47
+ --type-ui-sm: var(--weight-medium) var(--size-sm)/var(--leading-normal) var(--font-core);
48
+ --type-code: var(--weight-regular) var(--size-sm)/var(--leading-code) var(--font-mono);
49
+ --type-code-inline: var(--weight-medium) 0.88em/1.4 var(--font-mono);
50
+ --type-eyebrow: var(--weight-semibold) var(--size-2xs)/1.2 var(--font-mono);
51
+
52
+ /* prose measure */
53
+ --measure-prose: none; /* @kind other */
54
+ --width-prose: 44rem;
55
+ --width-content: 52rem;
56
+ }
@@ -0,0 +1,17 @@
1
+ import type { ComponentType } from "react";
2
+
3
+ export type DocModuleState =
4
+ { status: "ok"; Component: ComponentType } | { status: "error"; message: string };
5
+
6
+ /**
7
+ * Modules imported for doc routes, keyed by the doc's path (its absolute path
8
+ * in the live viewer, an opaque key in an exported file). Shared across the
9
+ * whole session (module scope, not component state) so DocView can render
10
+ * synchronously once a route resolves, and so re-visiting a doc doesn't
11
+ * re-trigger the dynamic import.
12
+ *
13
+ * Lives in its own module — not client/router.ts — so the standalone shell
14
+ * (client/standalone-entry.tsx) can seed and read it without pulling the
15
+ * router's tRPC/react-query graph into an exported file.
16
+ */
17
+ export const docModuleCache = new Map<string, DocModuleState>();
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The subset of a `MouseEvent` that decides whether a click on a link inside
3
+ * the section editor should open it. Pure so it can be tested without a DOM.
4
+ */
5
+ export interface LinkClick {
6
+ button: number;
7
+ metaKey: boolean;
8
+ ctrlKey: boolean;
9
+ }
10
+
11
+ /**
12
+ * The href a click inside the editor should open in a new tab, or `null` to
13
+ * let ProseMirror place the caret as usual.
14
+ *
15
+ * In read mode a link is a link, so cmd/ctrl+click gets the browser's own
16
+ * new-tab behaviour. Inside the contenteditable that default never fires —
17
+ * the browser treats the anchor as editable text — and Tiptap's Link
18
+ * extension is configured with `openOnClick: false` so a plain click can edit
19
+ * the link's text. That leaves the editor convention every other editor
20
+ * (VS Code, Notion, Google Docs) follows: hold cmd (or ctrl) to follow it.
21
+ */
22
+ export function modifiedLinkHref(click: LinkClick, href: string | null | undefined): string | null {
23
+ if (click.button !== 0) return null;
24
+ if (!(click.metaKey || click.ctrlKey)) return null;
25
+ if (!href) return null;
26
+ return href;
27
+ }
@@ -0,0 +1,51 @@
1
+ import { createRoot, type Root } from "react-dom/client";
2
+ import { QueryClientProvider } from "@tanstack/react-query";
3
+ import { MDXProvider } from "@mdx-js/react";
4
+ import { MotionConfig } from "framer-motion";
5
+ import { App } from "./App";
6
+ import { queryClient } from "./api";
7
+ import { mdxComponents } from "./mdx-components";
8
+ import { ToastStack } from "./ui/Toast";
9
+ import { shellInfo, type Route } from "./router";
10
+
11
+ function parseInitialRoute(): Route {
12
+ const fallback: Route = { kind: "notfound", path: location.pathname };
13
+ const el = document.getElementById("__mdxserve_route");
14
+ if (!el?.textContent) return fallback;
15
+ const rootCount = Number.parseInt(el.dataset.rootCount ?? "", 10);
16
+ if (Number.isFinite(rootCount)) shellInfo.rootCount = rootCount;
17
+ shellInfo.sameMachine = el.dataset.sameMachine === "1";
18
+ try {
19
+ return JSON.parse(el.textContent) as Route;
20
+ } catch {
21
+ return fallback;
22
+ }
23
+ }
24
+
25
+ function main() {
26
+ const rootEl = document.getElementById("root");
27
+ if (!rootEl) return;
28
+
29
+ // Reuse the root across HMR re-executions of this module (self-accepting
30
+ // below) instead of calling createRoot twice on the same container.
31
+ const globalWindow = window as Window & { __mdxserveRoot?: Root };
32
+ const root = (globalWindow.__mdxserveRoot ??= createRoot(rootEl));
33
+ const initialRoute = parseInitialRoute();
34
+
35
+ root.render(
36
+ <QueryClientProvider client={queryClient}>
37
+ <MotionConfig reducedMotion="user">
38
+ <MDXProvider components={mdxComponents}>
39
+ <App initialRoute={initialRoute} />
40
+ </MDXProvider>
41
+ <ToastStack />
42
+ </MotionConfig>
43
+ </QueryClientProvider>,
44
+ );
45
+ }
46
+
47
+ main();
48
+
49
+ if (import.meta.hot) {
50
+ import.meta.hot.accept();
51
+ }
@@ -0,0 +1,80 @@
1
+ import type { inferRouterOutputs } from "@trpc/server";
2
+ // Type-only: client/ may only import types from src/ (see client/api.ts).
3
+ import type { AppRouter } from "../src/api/router";
4
+ import type { ExportFormat } from "../src/export/service";
5
+ import { trpcClient } from "./api";
6
+ import { formatSize } from "./format";
7
+ import { pushToast } from "./ui/Toast";
8
+ import {
9
+ canUseSavePicker,
10
+ decodeContents,
11
+ downloadViaAnchor,
12
+ gestureStillActive,
13
+ pickSaveFile,
14
+ writeToHandle,
15
+ } from "./export-save";
16
+
17
+ type ExportDocResult = inferRouterOutputs<AppRouter>["exportDoc"];
18
+
19
+ type Picked = { kind: "picked"; handle: FileSystemFileHandle | null } | { kind: "expired" };
20
+
21
+ /**
22
+ * The viewer's Export flow: build on the server, then hand the bytes to the
23
+ * browser. The native save picker needs a live user gesture, and Chrome
24
+ * expires one about 5 s after the click — so it is only tried when the build
25
+ * finished inside that window; otherwise (and on Firefox/Safari, which have
26
+ * no picker) the file goes out as an ordinary download. Never picker-before-
27
+ * build: the picker truncates an existing file the moment it is chosen, and
28
+ * a failed build would leave that file empty.
29
+ */
30
+ export async function exportDocFromViewer(input: {
31
+ path: string;
32
+ format: ExportFormat;
33
+ }): Promise<void> {
34
+ let result: ExportDocResult;
35
+ try {
36
+ result = await trpcClient.exportDoc.mutate({ path: input.path, format: input.format });
37
+ } catch (error) {
38
+ console.error(error);
39
+ pushToast({ tone: "danger", text: "Export failed" });
40
+ return;
41
+ }
42
+
43
+ const blob = decodeContents(result.contents, result.encoding);
44
+ try {
45
+ if (canUseSavePicker() && gestureStillActive()) {
46
+ const picked: Picked = await pickSaveFile(result.fileName, input.format).then(
47
+ (handle) => ({ kind: "picked", handle }),
48
+ // SecurityError: the gesture expired between the check and the
49
+ // call. A plain download needs no gesture.
50
+ () => ({ kind: "expired" }),
51
+ );
52
+ if (picked.kind === "picked") {
53
+ // The reader closed the picker: nothing was saved, nothing to say.
54
+ if (!picked.handle) return;
55
+ await writeToHandle(picked.handle, blob);
56
+ } else {
57
+ downloadViaAnchor(result.fileName, blob);
58
+ }
59
+ } else {
60
+ downloadViaAnchor(result.fileName, blob);
61
+ }
62
+ } catch (error) {
63
+ console.error(error);
64
+ pushToast({ tone: "danger", text: "Couldn't save the file" });
65
+ return;
66
+ }
67
+
68
+ pushToast({
69
+ tone: "ok",
70
+ icon: "download",
71
+ text: `Exported ${result.fileName} · ${formatSize(result.bytes)}`,
72
+ });
73
+ if (result.warnings.length > 0) {
74
+ console.warn(result.warnings.join("\n"));
75
+ pushToast({
76
+ tone: "warn",
77
+ text: `${result.warnings.length} export warning${result.warnings.length === 1 ? "" : "s"}`,
78
+ });
79
+ }
80
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Pure(ish) helpers behind the Export dialog's save flow (client/ui/ExportDialog.tsx).
3
+ * Nothing here touches `window`/`document`/`navigator` at module scope, so this
4
+ * module stays importable under node — the file-name/encoding logic is unit
5
+ * tested the same way client/mermaid-direction.ts is.
6
+ */
7
+
8
+ /**
9
+ * `/a/b/guide.md` -> `guide.html`; handles `\` separators, a case-insensitive
10
+ * `.md`/`.mdx` source extension, and a stray trailing `.<extension>` (so
11
+ * re-running this on its own previous output — e.g. re-exporting the same
12
+ * doc — never doubles the extension).
13
+ */
14
+ export function exportFileName(docPath: string, extension: string): string {
15
+ const lastSegment = docPath.split(/[/\\]/).pop() ?? docPath;
16
+ const targetExtensionPattern = extension.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17
+ const stem = lastSegment
18
+ .replace(/\.mdx?$/i, "")
19
+ .replace(new RegExp(`\\.${targetExtensionPattern}$`, "i"), "");
20
+ return `${stem}.${extension}`;
21
+ }
22
+
23
+ const MIME_TYPES: Record<string, string> = {
24
+ html: "text/html",
25
+ };
26
+
27
+ /** "html" -> "text/html"; any other extension falls back to a generic binary type. */
28
+ export function mimeFor(extension: string): string {
29
+ return MIME_TYPES[extension] ?? "application/octet-stream";
30
+ }
31
+
32
+ /** Decodes `exportDoc`'s wire contents (utf8 text or base64 bytes) into a Blob. */
33
+ export function decodeContents(contents: string, encoding: "utf8" | "base64"): Blob {
34
+ if (encoding === "utf8") return new Blob([contents], { type: "text/plain" });
35
+ const binary = atob(contents);
36
+ const bytes = new Uint8Array(binary.length);
37
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
38
+ return new Blob([bytes]);
39
+ }
40
+
41
+ /** Whether the browser exposes the File System Access save-picker API. */
42
+ export function canUseSavePicker(): boolean {
43
+ return typeof window !== "undefined" && "showSaveFilePicker" in window;
44
+ }
45
+
46
+ /** Whether a user gesture (click) is still active — the picker requires one. */
47
+ export function gestureStillActive(): boolean {
48
+ return navigator.userActivation?.isActive ?? false;
49
+ }
50
+
51
+ /**
52
+ * Opens the native save-file picker; resolves `null` when the user cancels
53
+ * (the picker rejects with `AbortError`). Must be called while a user gesture
54
+ * is still active, or the browser rejects with a `SecurityError`.
55
+ */
56
+ export async function pickSaveFile(
57
+ suggestedName: string,
58
+ extension: string,
59
+ ): Promise<FileSystemFileHandle | null> {
60
+ if (!window.showSaveFilePicker) return null;
61
+ try {
62
+ return await window.showSaveFilePicker({
63
+ suggestedName,
64
+ types: [
65
+ {
66
+ description: extension.toUpperCase(),
67
+ accept: { [mimeFor(extension)]: [`.${extension}`] },
68
+ },
69
+ ],
70
+ });
71
+ } catch (error) {
72
+ if (error instanceof DOMException && error.name === "AbortError") return null;
73
+ throw error;
74
+ }
75
+ }
76
+
77
+ /** Writes `blob` to a handle from `pickSaveFile` and closes the stream. */
78
+ export async function writeToHandle(handle: FileSystemFileHandle, blob: Blob): Promise<void> {
79
+ const writable = await handle.createWritable();
80
+ await writable.write(blob);
81
+ await writable.close();
82
+ }
83
+
84
+ /** Firefox/Safari fallback: a hidden `<a download>` click, then revoke the object URL. */
85
+ export function downloadViaAnchor(fileName: string, blob: Blob): void {
86
+ const url = URL.createObjectURL(blob);
87
+ const anchor = document.createElement("a");
88
+ anchor.href = url;
89
+ anchor.download = fileName;
90
+ document.body.appendChild(anchor);
91
+ anchor.click();
92
+ anchor.remove();
93
+ // Revoke after the click has been dispatched; some browsers start the
94
+ // download asynchronously and would find the URL already gone.
95
+ setTimeout(() => URL.revokeObjectURL(url), 0);
96
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#111827"/><text x="16" y="22" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="15" font-weight="700" fill="#f9fafb">M↓</text></svg>