@unfold-mdx/react 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shakya47
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,275 @@
1
+ # unfold-mdx
2
+
3
+ Progressive-depth prose & code explanations for React + MDX.
4
+
5
+ Write full text and code snapshots at each depth level — the library diffs consecutive snapshots at **sentence** and **line/token** granularity, optionally highlights what changed, and lets the reader step through at their own pace. No accordion collapses, no page navigation, no scroll-jumps.
6
+
7
+ ## Features
8
+
9
+ - **Sentence-level prose diffing** — automatically detects added, modified, and equal sentences between steps
10
+ - **Line & token-level code diffing** — detects added lines, changed lines (with inline token diffs), and equal lines
11
+ - **Opt-in Shiki syntax highlighting** — `unfold-mdx/shiki` adapter colors your code with any Shiki theme
12
+ - **Headless by default** — ships zero CSS; every element uses `data-*` attributes you can target freely
13
+ - **Opinionated theme included** — `import 'unfold-mdx/theme.css'` for a polished dark-mode starting point
14
+ - **SSR / no-JS fallback** — renders the deepest level server-side; hydrates to interactive on the client
15
+ - **Controlled & uncontrolled** — use `defaultIndex` for fire-and-forget, or `selectedIndex` + `onChange` for full control
16
+ - **Accessible** — ARIA labels, `aria-live` regions, `role="tablist"` indicators, `prefers-reduced-motion` support
17
+ - **Tiny** — ~13 KB ESM, only `diff-match-patch` as a runtime dependency
18
+
19
+ ---
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install unfold-mdx
25
+ # or
26
+ pnpm add unfold-mdx
27
+ # or
28
+ yarn add unfold-mdx
29
+ ```
30
+
31
+ ### Peer Dependencies
32
+
33
+ | Package | Version | Required? |
34
+ |-------------|----------|-----------|
35
+ | `react` | ≥ 18 | ✅ Yes |
36
+ | `react-dom` | ≥ 18 | ✅ Yes |
37
+ | `shiki` | ^1.0.0 | ❌ Optional |
38
+
39
+ ---
40
+
41
+ ## Quick Start
42
+
43
+ ```tsx
44
+ import { Depth, DepthLevel, DepthCode } from "unfold-mdx";
45
+ import "unfold-mdx/theme.css"; // optional — opinionated dark theme
46
+
47
+ export default function Demo() {
48
+ return (
49
+ <Depth show="both" orientation="horizontal" indicators buttonVariant="arrow">
50
+ <DepthLevel label="Overview">
51
+ Quicksort is a divide-and-conquer sorting algorithm.
52
+ It selects a 'pivot' element and partitions the array around it.
53
+ </DepthLevel>
54
+ <DepthCode lang="typescript">
55
+ {`function quicksort(arr: number[]): number[] {
56
+ if (arr.length <= 1) return arr;
57
+ }`}
58
+ </DepthCode>
59
+
60
+ <DepthLevel label="Partitioning">
61
+ Quicksort is a divide-and-conquer sorting algorithm.
62
+ It selects a 'pivot' element and partitions the array around it.
63
+ Elements smaller than the pivot go left; larger ones go right.
64
+ </DepthLevel>
65
+ <DepthCode lang="typescript">
66
+ {`function quicksort(arr: number[]): number[] {
67
+ if (arr.length <= 1) return arr;
68
+
69
+ const pivot = arr[arr.length - 1];
70
+ const left = arr.filter((x) => x < pivot);
71
+ const right = arr.filter((x) => x > pivot);
72
+ }`}
73
+ </DepthCode>
74
+ </Depth>
75
+ );
76
+ }
77
+ ```
78
+
79
+ Click **Next** → the library diffs the two snapshots and renders only the current step, marking new sentences and code lines.
80
+
81
+ ---
82
+
83
+ ## Adding Shiki Syntax Highlighting
84
+
85
+ ```tsx
86
+ import { Depth, DepthLevel, DepthCode } from "unfold-mdx";
87
+ import { createShikiHighlighter } from "unfold-mdx/shiki";
88
+ import "unfold-mdx/theme.css";
89
+
90
+ // Create once at module scope
91
+ const highlighter = createShikiHighlighter({
92
+ theme: "vitesse-dark",
93
+ langs: ["typescript"],
94
+ });
95
+
96
+ export default function Demo() {
97
+ return (
98
+ <Depth show="both" highlighter={highlighter} indicators>
99
+ {/* ...steps... */}
100
+ </Depth>
101
+ );
102
+ }
103
+ ```
104
+
105
+ Shiki loads asynchronously. Before it's ready, code renders with the raw diff tokens (no colors). Once loaded, the component re-renders with full syntax coloring — no layout shift.
106
+
107
+ ---
108
+
109
+ ## API Reference
110
+
111
+ ### `<Depth>` Props
112
+
113
+ | Prop | Type | Default | Description |
114
+ |------|------|---------|-------------|
115
+ | `selectedIndex` | `number` | — | Controlled mode: current step index (0-based). |
116
+ | `defaultIndex` | `number` | `0` | Uncontrolled mode: initial step index. |
117
+ | `onChange` | `(i: number) => void` | — | Callback when the step changes. |
118
+ | `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Pane layout direction (when `show="both"`). |
119
+ | `ratio` | `number` | `0.5` | Prose/code width split (0–1). Exposed as `--unfold-ratio`. |
120
+ | `show` | `"both" \| "prose" \| "code"` | `"both"` | Which panes to render. |
121
+ | `indicators` | `boolean` | `false` | Show clickable step-indicator dots. |
122
+ | `buttonVariant` | `"text" \| "arrow" \| "chevron" \| "minimal"` | `"text"` | Navigation button label style. |
123
+ | `animate` | `boolean` | `true` | Set `data-enter="true"` for one frame on new elements. |
124
+ | `highlight` | `boolean` | `true` | Mark added/changed elements with `data-sentence="added"` / `data-code-line="added"`. Set `false` to disable all visual diff markers. |
125
+ | `highlighter` | `ShikiHighlighter` | — | Shiki adapter instance from `createShikiHighlighter()`. |
126
+
127
+ ### `<DepthLevel>` Props
128
+
129
+ | Prop | Type | Description |
130
+ |------|------|-------------|
131
+ | `label` | `string` | Human-readable step name (used in ARIA labels and indicators). |
132
+ | `children` | `ReactNode` | Full prose snapshot for this step. |
133
+
134
+ ### `<DepthCode>` Props
135
+
136
+ | Prop | Type | Description |
137
+ |------|------|-------------|
138
+ | `lang` | `string` | Language identifier (e.g. `"typescript"`, `"python"`). |
139
+ | `label` | `string` | Optional label for the code pane. |
140
+ | `children` | `string` | Full raw code string for this step. |
141
+
142
+ ---
143
+
144
+ ## Styling
145
+
146
+ ### Headless (default)
147
+
148
+ `unfold-mdx` renders plain HTML with `data-*` attributes. You style everything yourself:
149
+
150
+ ```css
151
+ /* Prose highlights */
152
+ [data-sentence="added"] { border-left: 2px solid #3b82f6; padding-left: 8px; }
153
+ [data-sentence="equal"] { /* normal text */ }
154
+
155
+ /* Code line highlights */
156
+ [data-code-line="added"],
157
+ [data-code-line="changed"] { background: rgba(59, 130, 246, 0.08); }
158
+ [data-code-line="equal"] { /* normal code */ }
159
+
160
+ /* Controls */
161
+ [data-unfold-controls] { display: flex; gap: 12px; margin-top: 16px; }
162
+ [data-unfold-dot][data-active="true"] { background: #3b82f6; }
163
+
164
+ /* Enter animation */
165
+ [data-enter="true"] { animation: flash 0.4s ease-out; }
166
+ @keyframes flash {
167
+ from { background-color: rgba(59, 130, 246, 0.15); }
168
+ to { background-color: transparent; }
169
+ }
170
+ ```
171
+
172
+ ### Opinionated Theme
173
+
174
+ ```tsx
175
+ import "unfold-mdx/theme.css";
176
+ ```
177
+
178
+ Override any design token with CSS custom properties:
179
+
180
+ | Variable | Default | Description |
181
+ |----------|---------|-------------|
182
+ | `--unfold-ratio` | `0.5` | Prose/code split |
183
+ | `--unfold-prose-color` | `#e2e8f0` | Prose text color |
184
+ | `--unfold-code-bg` | `#0d1117` | Code pane background |
185
+ | `--unfold-code-border` | `rgba(56,189,248,0.15)` | Code pane border |
186
+ | `--unfold-code-text` | `#c9d1d9` | Code fallback text |
187
+ | `--unfold-highlight` | `rgba(56,189,248,0.5)` | Highlight border color |
188
+ | `--unfold-highlight-bg` | `rgba(56,189,248,0.06)` | Highlight background |
189
+ | `--unfold-btn-bg` | `rgba(255,255,255,0.06)` | Button background |
190
+ | `--unfold-btn-border` | `rgba(255,255,255,0.12)` | Button border |
191
+ | `--unfold-btn-text` | `#e2e8f0` | Button text |
192
+ | `--unfold-btn-hover-bg` | `rgba(255,255,255,0.12)` | Button hover background |
193
+ | `--unfold-dot-bg` | `rgba(255,255,255,0.15)` | Inactive dot |
194
+ | `--unfold-dot-active` | `#38bdf8` | Active dot |
195
+
196
+ ---
197
+
198
+ ## DOM Output
199
+
200
+ ```html
201
+ <div data-unfold-root data-orientation="horizontal" data-show="both"
202
+ data-level="1" data-total-levels="3"
203
+ role="region" aria-label="Partitioning"
204
+ style="--unfold-ratio: 0.5;">
205
+ <div data-unfold-panes>
206
+ <div data-unfold-pane="prose" aria-live="polite">
207
+ <span data-sentence="equal">Quicksort is a divide-and-conquer sorting algorithm. </span>
208
+ <span data-sentence="equal">It selects a 'pivot' element... </span>
209
+ <span data-sentence="added" data-enter="true">Elements smaller than the pivot go left; larger ones go right. </span>
210
+ </div>
211
+ <div data-unfold-pane="code" aria-live="polite">
212
+ <pre data-lang="typescript">
213
+ <code>
214
+ <span data-code-line="equal"><span data-code-token="equal" style="color:#CB7676">function</span>...</span>
215
+ <span data-code-line="added" data-enter="true"><span data-code-token="added" style="color:#BD976A">const</span>...</span>
216
+ </code>
217
+ </pre>
218
+ </div>
219
+ </div>
220
+ <div data-unfold-controls data-button-variant="arrow">
221
+ <button data-unfold-prev aria-disabled="false">← Prev</button>
222
+ <div data-unfold-indicators role="tablist">
223
+ <button data-unfold-dot role="tab" aria-selected="false"></button>
224
+ <button data-unfold-dot role="tab" aria-selected="true" data-active="true"></button>
225
+ <button data-unfold-dot role="tab" aria-selected="false"></button>
226
+ </div>
227
+ <button data-unfold-next aria-disabled="false">Next →</button>
228
+ </div>
229
+ </div>
230
+ ```
231
+
232
+ ---
233
+
234
+ ## SSR / No-JS Fallback
235
+
236
+ `<Depth>` renders all children server-side with `data-unfold-active` on the deepest level. Add this CSS to hide inactive levels before hydration:
237
+
238
+ ```css
239
+ [data-unfold-root] > [data-depth-level]:not([data-unfold-active]),
240
+ [data-unfold-root] > pre[data-lang]:not([data-unfold-active]) {
241
+ display: none;
242
+ }
243
+ ```
244
+
245
+ This is included in `theme.css` by default.
246
+
247
+ ---
248
+
249
+ ## Standalone Diff Utilities
250
+
251
+ The diff engine is available as a separate entry point for use outside of React:
252
+
253
+ ```ts
254
+ import { sentenceDiff, codeDiff, tokenize } from "unfold-mdx/diff";
255
+
256
+ const prose = sentenceDiff(
257
+ "The sky is blue.",
258
+ "The sky is blue. The grass is green."
259
+ );
260
+ // => [{ kind: "equal", sentence: "The sky is blue." },
261
+ // { kind: "added", sentence: "The grass is green." }]
262
+
263
+ const code = codeDiff(
264
+ "const x = 1;",
265
+ "const x = 1;\nconst y = 2;"
266
+ );
267
+ // => [{ kind: "equal", line: "const x = 1;" },
268
+ // { kind: "added", tokens: [...] }]
269
+ ```
270
+
271
+ ---
272
+
273
+ ## License
274
+
275
+ [MIT](./LICENSE)
@@ -0,0 +1,218 @@
1
+ // src/diff/tokenize.ts
2
+ function tokenize(text) {
3
+ if (!text) {
4
+ return [];
5
+ }
6
+ const splitRegex = /(?<=(?:(?<!\.)\.(?!\.)|[!?]))(?=\s|$)/;
7
+ return text.split(splitRegex).map((token) => token.trim()).filter((token) => token.length > 0);
8
+ }
9
+
10
+ // src/diff/sentenceDiff.ts
11
+ import { diff_match_patch } from "diff-match-patch";
12
+ function sentenceDiff(prev, next) {
13
+ const prevSentences = tokenize(prev);
14
+ const nextSentences = tokenize(next);
15
+ const sentenceToChar = /* @__PURE__ */ new Map();
16
+ const charToSentence = [];
17
+ function getCharForSentence(sentence) {
18
+ let char = sentenceToChar.get(sentence);
19
+ if (char === void 0) {
20
+ const code = charToSentence.length;
21
+ char = String.fromCharCode(code);
22
+ sentenceToChar.set(sentence, char);
23
+ charToSentence.push(sentence);
24
+ }
25
+ return char;
26
+ }
27
+ const prevChars = prevSentences.map(getCharForSentence).join("");
28
+ const nextChars = nextSentences.map(getCharForSentence).join("");
29
+ const dmp = new diff_match_patch();
30
+ const diffs = dmp.diff_main(prevChars, nextChars);
31
+ dmp.diff_cleanupSemantic(diffs);
32
+ const result = [];
33
+ let i = 0;
34
+ while (i < diffs.length) {
35
+ const [op, text] = diffs[i];
36
+ if (op === 0) {
37
+ for (let j = 0; j < text.length; j++) {
38
+ result.push({
39
+ kind: "equal",
40
+ sentence: charToSentence[text.charCodeAt(j)]
41
+ });
42
+ }
43
+ i++;
44
+ } else if (op === -1 && i + 1 < diffs.length && diffs[i + 1][0] === 1) {
45
+ const deleteText = text;
46
+ const insertText = diffs[i + 1][1];
47
+ const deleteLen = deleteText.length;
48
+ const insertLen = insertText.length;
49
+ const minLen = Math.min(deleteLen, insertLen);
50
+ for (let j = 0; j < minLen; j++) {
51
+ result.push({
52
+ kind: "modified",
53
+ before: charToSentence[deleteText.charCodeAt(j)],
54
+ after: charToSentence[insertText.charCodeAt(j)]
55
+ });
56
+ }
57
+ for (let j = minLen; j < deleteLen; j++) {
58
+ result.push({
59
+ kind: "removed",
60
+ sentence: charToSentence[deleteText.charCodeAt(j)]
61
+ });
62
+ }
63
+ for (let j = minLen; j < insertLen; j++) {
64
+ result.push({
65
+ kind: "added",
66
+ sentence: charToSentence[insertText.charCodeAt(j)]
67
+ });
68
+ }
69
+ i += 2;
70
+ } else if (op === -1) {
71
+ for (let j = 0; j < text.length; j++) {
72
+ result.push({
73
+ kind: "removed",
74
+ sentence: charToSentence[text.charCodeAt(j)]
75
+ });
76
+ }
77
+ i++;
78
+ } else if (op === 1) {
79
+ for (let j = 0; j < text.length; j++) {
80
+ result.push({
81
+ kind: "added",
82
+ sentence: charToSentence[text.charCodeAt(j)]
83
+ });
84
+ }
85
+ i++;
86
+ }
87
+ }
88
+ return result;
89
+ }
90
+
91
+ // src/diff/codeTokenize.ts
92
+ function tokenizeLines(code) {
93
+ if (code === "") return [];
94
+ return code.split(/\r?\n/);
95
+ }
96
+ function tokenizeCodeLine(line) {
97
+ if (line === "") return [];
98
+ const regex = /([a-zA-Z0-9_]+|\s+|[^a-zA-Z0-9_\s])/g;
99
+ const tokens = [];
100
+ let match;
101
+ while ((match = regex.exec(line)) !== null) {
102
+ if (match[0].length > 0) {
103
+ tokens.push(match[0]);
104
+ }
105
+ }
106
+ return tokens;
107
+ }
108
+
109
+ // src/diff/codeDiff.ts
110
+ import { diff_match_patch as diff_match_patch2 } from "diff-match-patch";
111
+ function codeDiff(prev, next) {
112
+ if (prev === "" && next !== "") {
113
+ return tokenizeLines(next).map((line) => ({
114
+ kind: "added",
115
+ tokens: tokenizeCodeLine(line).map((t) => ({ kind: "added", text: t }))
116
+ }));
117
+ }
118
+ const prevLines = tokenizeLines(prev);
119
+ const nextLines = tokenizeLines(next);
120
+ const lineToChar = /* @__PURE__ */ new Map();
121
+ const charToLine = [];
122
+ function getCharForLine(line) {
123
+ let char = lineToChar.get(line);
124
+ if (char === void 0) {
125
+ const code = charToLine.length;
126
+ char = String.fromCharCode(code);
127
+ lineToChar.set(line, char);
128
+ charToLine.push(line);
129
+ }
130
+ return char;
131
+ }
132
+ const prevChars = prevLines.map(getCharForLine).join("");
133
+ const nextChars = nextLines.map(getCharForLine).join("");
134
+ const dmp = new diff_match_patch2();
135
+ const diffs = dmp.diff_main(prevChars, nextChars);
136
+ dmp.diff_cleanupSemantic(diffs);
137
+ const result = [];
138
+ let i = 0;
139
+ while (i < diffs.length) {
140
+ const [op, text] = diffs[i];
141
+ if (op === 0) {
142
+ for (let j = 0; j < text.length; j++) {
143
+ result.push({
144
+ kind: "equal",
145
+ line: charToLine[text.charCodeAt(j)]
146
+ });
147
+ }
148
+ i++;
149
+ } else if (op === -1 && i + 1 < diffs.length && diffs[i + 1][0] === 1) {
150
+ const deleteText = text;
151
+ const insertText = diffs[i + 1][1];
152
+ const deleteLen = deleteText.length;
153
+ const insertLen = insertText.length;
154
+ const minLen = Math.min(deleteLen, insertLen);
155
+ for (let j = 0; j < minLen; j++) {
156
+ let getCharForToken2 = function(tok) {
157
+ let char = tokenToChar.get(tok);
158
+ if (char === void 0) {
159
+ const code = charToToken.length;
160
+ char = String.fromCharCode(code);
161
+ tokenToChar.set(tok, char);
162
+ charToToken.push(tok);
163
+ }
164
+ return char;
165
+ };
166
+ var getCharForToken = getCharForToken2;
167
+ const prevLine = charToLine[deleteText.charCodeAt(j)];
168
+ const nextLine = charToLine[insertText.charCodeAt(j)];
169
+ const prevTokens = tokenizeCodeLine(prevLine);
170
+ const nextTokens = tokenizeCodeLine(nextLine);
171
+ const tokenToChar = /* @__PURE__ */ new Map();
172
+ const charToToken = [];
173
+ const prevTokChars = prevTokens.map(getCharForToken2).join("");
174
+ const nextTokChars = nextTokens.map(getCharForToken2).join("");
175
+ const tokDiffs = dmp.diff_main(prevTokChars, nextTokChars);
176
+ dmp.diff_cleanupSemantic(tokDiffs);
177
+ const tokens = [];
178
+ for (let k = 0; k < tokDiffs.length; k++) {
179
+ const [tokOp, tokText] = tokDiffs[k];
180
+ if (tokOp === 0) {
181
+ for (let c = 0; c < tokText.length; c++) {
182
+ tokens.push({ kind: "equal", text: charToToken[tokText.charCodeAt(c)] });
183
+ }
184
+ } else if (tokOp === 1) {
185
+ for (let c = 0; c < tokText.length; c++) {
186
+ tokens.push({ kind: "added", text: charToToken[tokText.charCodeAt(c)] });
187
+ }
188
+ }
189
+ }
190
+ result.push({ kind: "changed", tokens });
191
+ }
192
+ for (let j = minLen; j < insertLen; j++) {
193
+ const line = charToLine[insertText.charCodeAt(j)];
194
+ const tokens = tokenizeCodeLine(line).map((t) => ({ kind: "added", text: t }));
195
+ result.push({ kind: "added", tokens });
196
+ }
197
+ i += 2;
198
+ } else if (op === -1) {
199
+ i++;
200
+ } else if (op === 1) {
201
+ for (let j = 0; j < text.length; j++) {
202
+ const line = charToLine[text.charCodeAt(j)];
203
+ const tokens = tokenizeCodeLine(line).map((t) => ({ kind: "added", text: t }));
204
+ result.push({ kind: "added", tokens });
205
+ }
206
+ i++;
207
+ }
208
+ }
209
+ return result;
210
+ }
211
+
212
+ export {
213
+ tokenize,
214
+ sentenceDiff,
215
+ tokenizeLines,
216
+ tokenizeCodeLine,
217
+ codeDiff
218
+ };
@@ -0,0 +1,19 @@
1
+ interface CodeToken {
2
+ kind: "equal" | "added";
3
+ text: string;
4
+ color?: string;
5
+ }
6
+ type CodeLineDiff = {
7
+ kind: "equal";
8
+ line: string;
9
+ tokens?: CodeToken[];
10
+ } | {
11
+ kind: "added";
12
+ tokens: CodeToken[];
13
+ } | {
14
+ kind: "changed";
15
+ tokens: CodeToken[];
16
+ };
17
+ declare function codeDiff(prev: string, next: string): CodeLineDiff[];
18
+
19
+ export { type CodeLineDiff as C, type CodeToken as a, codeDiff as c };
@@ -0,0 +1,19 @@
1
+ interface CodeToken {
2
+ kind: "equal" | "added";
3
+ text: string;
4
+ color?: string;
5
+ }
6
+ type CodeLineDiff = {
7
+ kind: "equal";
8
+ line: string;
9
+ tokens?: CodeToken[];
10
+ } | {
11
+ kind: "added";
12
+ tokens: CodeToken[];
13
+ } | {
14
+ kind: "changed";
15
+ tokens: CodeToken[];
16
+ };
17
+ declare function codeDiff(prev: string, next: string): CodeLineDiff[];
18
+
19
+ export { type CodeLineDiff as C, type CodeToken as a, codeDiff as c };