@kud/ink-markdown 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Erwann Mest
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
13
+ all 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,98 @@
1
+ <div align="center">
2
+
3
+ ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white)
4
+ ![Node.js](https://img.shields.io/badge/Node.js-339933?style=flat-square&logo=node.js&logoColor=white)
5
+ ![Status](https://img.shields.io/badge/status-alpha-yellow?style=flat-square)
6
+ ![MIT](https://img.shields.io/badge/licence-MIT-22C55E?style=flat-square)
7
+
8
+ **A high-performance Markdown / code / diff rendering engine for Ink 7.**
9
+
10
+ <a href="https://kud.io/projects/ink-markdown">Website</a> · <a href="https://kud.io/projects/ink-markdown/docs">Documentation</a>
11
+
12
+ </div>
13
+
14
+ > **Alpha.** The rendering path works today: parse a document, lay it out for a width, and scroll a virtualised viewport over it. `MarkdownViewport`, `useMarkdownScroll` and the whole `core` surface are real and covered by tests. Still ahead: syntax highlighting for fenced code (M4), the `useMarkdownStream` hook, overridable per-block renderers, and performance instrumentation — each is called out where it appears below. The API may still move before 1.0.
15
+
16
+ ## Features
17
+
18
+ Ink apps that display large, code-heavy, streaming documents (AI responses, code review, diffs, source files) hit a wall with naïve Markdown renderers: parse the whole document into a React tree, run Yoga layout over all of it, re-diff the entire output every frame. `ink-markdown` is designed to keep that cost bounded to the viewport instead:
19
+
20
+ - **Viewport virtualisation** ✅ — only the visible slice is ever composed, so React/Yoga work scales with viewport size, not document size. `MarkdownViewport` mounts exactly one `<Text>` whatever the document length.
21
+ - **Block-based incremental parsing** ✅ — Markdown is segmented into top-level blocks with stable IDs and source hashes, so an edit in one block leaves the others untouched (`updateMarkdownDocument`).
22
+ - **Cached, width-aware layout** ✅ — wrapping, Unicode width and styling are computed once per `(block, width, theme)` and reused until one of those changes, so a terminal resize doesn't force a full relayout.
23
+ - **Unified-diff rendering** ✅ — hunk headers, additions and deletions are styled from the theme rather than treated as flat text.
24
+ - **Syntax highlighting for fenced code** ⏳ — planned for M4; code blocks currently render unhighlighted, clipped rather than wrapped.
25
+ - **Streaming with a mutable tail** ⏳ — the immutable-completed-blocks design is in place, but the `useMarkdownStream` hook that drives it is not written yet.
26
+ - **Overridable block renderers and perf instrumentation** ⏳ — designed in [`prd.md`](./prd.md), not yet built.
27
+
28
+ ★ **Where the performance claims come from** — a benchmark spike proved the bet before the package was built: parsing a 10,000-line document took ~6.9ms (against a 100ms target), the pre-composed-string render path stayed at a constant ~1.6µs per frame regardless of document size, virtualisation mounted 1 Ink node against 9,167 for a naïve full-tree baseline, and CPU per scroll frame measured ~7.7ms — under Ink's ~26ms repaint throttle, so scroll latency is throttle-bound rather than compute-bound. The shipped engine implements that architecture; the numbers above are the spike's, not a benchmark of the current code.
29
+
30
+ ## Install
31
+
32
+ ```sh
33
+ npm install @kud/ink-markdown
34
+ ```
35
+
36
+ `ink-markdown` targets **Ink 7.x** and **React 19.x** as peer dependencies, and requires **Node.js 20+**.
37
+
38
+ ## Usage
39
+
40
+ `height` is a prop, not something the component measures for itself — the caller owns the viewport, so there is no measurement round-trip to get wrong.
41
+
42
+ ```tsx
43
+ import {
44
+ createMarkdownDocument,
45
+ createMarkdownLayout,
46
+ MarkdownViewport,
47
+ useMarkdownScroll,
48
+ } from "@kud/ink-markdown"
49
+
50
+ const Reader = ({ source, width, height }) => {
51
+ const [offset, setOffset] = useState(0)
52
+
53
+ // Laying out once and passing `layout` skips the parse on every scroll frame.
54
+ const layout = useMemo(
55
+ () =>
56
+ createMarkdownLayout(createMarkdownDocument(source), source, { width }),
57
+ [source, width],
58
+ )
59
+
60
+ useMarkdownScroll({
61
+ totalLines: layout.totalLines,
62
+ height,
63
+ offset,
64
+ onChange: setOffset,
65
+ })
66
+
67
+ return (
68
+ <MarkdownViewport
69
+ layout={layout}
70
+ width={width}
71
+ height={height}
72
+ scrollOffset={offset}
73
+ />
74
+ )
75
+ }
76
+ ```
77
+
78
+ Pass `source` instead of `layout` and the viewport parses and lays out for you — fine for short, static documents, and one prop less to thread.
79
+
80
+ `useMarkdownScroll` is optional — it binds `j`/`k`, arrows, space, `b`, `g` and `G`. The core owns scrolling _primitives_ (an offset in, lines out), not keybindings, so an app with its own bindings drives `scrollOffset` directly and ignores the hook.
81
+
82
+ `createMarkdownLayout` also takes a `theme` and a `cache` — a `Map` you own and reuse across renders, so a resize re-lays out only the blocks whose width actually changed. `layout.reused` and `layout.computed` report how that went.
83
+
84
+ Streaming (`useMarkdownStream`), overridable per-block renderers and performance instrumentation are designed but not yet built — see [`prd.md`](./prd.md) for the full specification and [`plan.md`](./plan.md) for the decisions and spike results behind it.
85
+
86
+ ## Development
87
+
88
+ ```sh
89
+ git clone https://github.com/kud/ink-markdown.git
90
+ cd ink-markdown
91
+ npm install
92
+ npm run build
93
+ npm test
94
+ ```
95
+
96
+ Other scripts: `npm run build:watch` (rebuild on change), `npm run typecheck`, `npm run test:watch`.
97
+
98
+ 📚 **Full documentation → [ink-markdown/docs](https://kud.io/projects/ink-markdown/docs)**
@@ -0,0 +1,128 @@
1
+ import * as react from 'react';
2
+
3
+ type MarkdownBlockType = "paragraph" | "heading" | "code" | "diff" | "list" | "quote" | "thematic-break";
4
+ type MarkdownBlock = {
5
+ id: string;
6
+ type: MarkdownBlockType;
7
+ sourceStart: number;
8
+ sourceEnd: number;
9
+ sourceHash: string;
10
+ /** Fence info string for `code` / `diff` blocks (e.g. "ts", "diff"). */
11
+ lang?: string;
12
+ };
13
+ type MarkdownDocument = {
14
+ version: number;
15
+ blocks: readonly MarkdownBlock[];
16
+ sourceLength: number;
17
+ };
18
+ declare const createMarkdownDocument: (source: string, version?: number) => MarkdownDocument;
19
+ declare const updateMarkdownDocument: (previous: MarkdownDocument, source: string) => MarkdownDocument;
20
+
21
+ declare const hash: (input: string) => string;
22
+
23
+ declare const segment: (source: string) => MarkdownBlock[];
24
+
25
+ type SpanStyle = {
26
+ color?: string;
27
+ backgroundColor?: string;
28
+ bold?: boolean;
29
+ italic?: boolean;
30
+ underline?: boolean;
31
+ dim?: boolean;
32
+ inverse?: boolean;
33
+ };
34
+ type TextSpan = {
35
+ text: string;
36
+ style?: SpanStyle;
37
+ };
38
+ type LayoutLine = {
39
+ id: string;
40
+ spans: readonly TextSpan[];
41
+ plainText: string;
42
+ displayWidth: number;
43
+ };
44
+ type BlockLayout = {
45
+ blockId: string;
46
+ width: number;
47
+ height: number;
48
+ lines: readonly LayoutLine[];
49
+ };
50
+ type MarkdownTheme = {
51
+ foreground?: string;
52
+ muted: string;
53
+ heading: string;
54
+ link: string;
55
+ inlineCode: string;
56
+ quote: string;
57
+ added: string;
58
+ removed: string;
59
+ context: string;
60
+ };
61
+ declare const defaultTheme: MarkdownTheme;
62
+ /** Display width in terminal columns (Unicode-aware: wide chars count as 2). */
63
+ declare const measureWidth: (text: string) => number;
64
+ /** Slice a string to at most `max` display columns without splitting a code point. */
65
+ declare const sliceByWidth: (text: string, max: number) => string;
66
+ declare const parseInline: (text: string, theme: MarkdownTheme) => TextSpan[];
67
+ declare const wrapSpans: (spans: readonly TextSpan[], max: number) => TextSpan[][];
68
+ /** Truncate a run of spans to `max` display columns (for code / diff — clip, don't wrap). */
69
+ declare const clipSpans: (spans: readonly TextSpan[], max: number) => TextSpan[];
70
+
71
+ type LayoutOptions = {
72
+ width: number;
73
+ theme?: MarkdownTheme;
74
+ themeId?: string;
75
+ /** Optional cross-call cache (survives scroll/resize) keyed by block id + width + theme. */
76
+ cache?: Map<string, readonly LayoutLine[]>;
77
+ };
78
+ type MarkdownLayout = {
79
+ width: number;
80
+ themeId: string;
81
+ blocks: readonly BlockLayout[];
82
+ /** All lines in document order — O(1) viewport slicing. */
83
+ lines: readonly LayoutLine[];
84
+ /** Cumulative first-line index of each block (parallel to blocks). */
85
+ lineOffsets: readonly number[];
86
+ totalLines: number;
87
+ reused: number;
88
+ computed: number;
89
+ };
90
+ declare const createMarkdownLayout: (document: MarkdownDocument, source: string, options: LayoutOptions) => MarkdownLayout;
91
+ /** Visible slice for a viewport — clamped so the last page never scrolls past the end. */
92
+ declare const sliceLines: (layout: MarkdownLayout, offset: number, height: number) => readonly LayoutLine[];
93
+ /** Index of the first block whose cumulative offset ≤ line — the log-n first-visible lookup. */
94
+ declare const firstBlockAt: (layout: MarkdownLayout, line: number) => number;
95
+
96
+ type MarkdownViewportProps = {
97
+ /** Raw Markdown source. Ignored when `layout` is provided. */
98
+ source?: string;
99
+ /** Pre-parsed document (reused with `source` to skip re-parsing). */
100
+ document?: MarkdownDocument;
101
+ /** Pre-computed layout — skips parse + layout entirely. */
102
+ layout?: MarkdownLayout;
103
+ width: number;
104
+ height: number;
105
+ /** First visible document line. Clamped so the last page never scrolls past the end. */
106
+ scrollOffset?: number;
107
+ theme?: MarkdownTheme;
108
+ /** Layout cache shared across renders (survives scroll/resize). */
109
+ cache?: Map<string, readonly LayoutLine[]>;
110
+ };
111
+ declare const MarkdownViewport: ({ source, document, layout: providedLayout, width, height, scrollOffset, theme, cache, }: MarkdownViewportProps) => react.JSX.Element;
112
+
113
+ type MarkdownScrollOptions = {
114
+ totalLines: number;
115
+ height: number;
116
+ offset: number;
117
+ onChange: (offset: number) => void;
118
+ /** Disable the key handler (e.g. when another pane has focus). */
119
+ active?: boolean;
120
+ };
121
+ declare const useMarkdownScroll: ({ totalLines, height, offset, onChange, active, }: MarkdownScrollOptions) => void;
122
+
123
+ /** Compose one layout line's styled spans into a single ANSI string. */
124
+ declare const lineToAnsi: (line: LayoutLine) => string;
125
+ /** Path-A composition: the visible span-lines → one ANSI string for a single <Text>. */
126
+ declare const linesToAnsi: (lines: readonly LayoutLine[]) => string;
127
+
128
+ export { type BlockLayout, type LayoutLine, type LayoutOptions, type MarkdownBlock, type MarkdownBlockType, type MarkdownDocument, type MarkdownLayout, type MarkdownScrollOptions, type MarkdownTheme, MarkdownViewport, type MarkdownViewportProps, type SpanStyle, type TextSpan, clipSpans, createMarkdownDocument, createMarkdownLayout, defaultTheme, firstBlockAt, hash, lineToAnsi, linesToAnsi, measureWidth, parseInline, segment, sliceByWidth, sliceLines, updateMarkdownDocument, useMarkdownScroll, wrapSpans };
package/dist/index.js ADDED
@@ -0,0 +1,467 @@
1
+ // src/core/hash.ts
2
+ var hash = (input) => {
3
+ let h = 2166136261;
4
+ for (let i = 0; i < input.length; i++) {
5
+ h ^= input.charCodeAt(i);
6
+ h = Math.imul(h, 16777619);
7
+ }
8
+ return (h >>> 0).toString(36);
9
+ };
10
+
11
+ // src/core/segment.ts
12
+ var FENCE = /^```(\S*)\s*$/;
13
+ var HEADING = /^#{1,6}\s+/;
14
+ var THEMATIC_BREAK = /^(-{3,}|\*{3,}|_{3,})\s*$/;
15
+ var QUOTE = /^>\s?/;
16
+ var LIST = /^(\s*)([-*+]|\d+\.)\s+/;
17
+ var segment = (source) => {
18
+ const lines = source.split("\n");
19
+ const lineStart = [];
20
+ let offset = 0;
21
+ for (const line of lines) {
22
+ lineStart.push(offset);
23
+ offset += line.length + 1;
24
+ }
25
+ const blocks = [];
26
+ const seen = /* @__PURE__ */ new Map();
27
+ const push = (type, fromLine, toLine2, lang) => {
28
+ const lastLine = toLine2 - 1;
29
+ const sourceStart = lineStart[fromLine];
30
+ const sourceEnd = lineStart[lastLine] + lines[lastLine].length;
31
+ const text = lines.slice(fromLine, toLine2).join("\n");
32
+ const sourceHash = hash(text);
33
+ const count = seen.get(sourceHash) ?? 0;
34
+ seen.set(sourceHash, count + 1);
35
+ const id = count === 0 ? sourceHash : `${sourceHash}~${count}`;
36
+ blocks.push({
37
+ id,
38
+ type,
39
+ sourceStart,
40
+ sourceEnd,
41
+ sourceHash,
42
+ ...lang ? { lang } : {}
43
+ });
44
+ };
45
+ let i = 0;
46
+ while (i < lines.length) {
47
+ const line = lines[i];
48
+ if (line.trim() === "") {
49
+ i++;
50
+ continue;
51
+ }
52
+ const fence = line.match(FENCE);
53
+ if (fence) {
54
+ let j2 = i + 1;
55
+ while (j2 < lines.length && !FENCE.test(lines[j2])) j2++;
56
+ const toLine2 = j2 < lines.length ? j2 + 1 : lines.length;
57
+ const lang = fence[1] || "";
58
+ push(lang === "diff" ? "diff" : "code", i, toLine2, lang || void 0);
59
+ i = toLine2;
60
+ continue;
61
+ }
62
+ if (HEADING.test(line)) {
63
+ push("heading", i, i + 1);
64
+ i++;
65
+ continue;
66
+ }
67
+ if (THEMATIC_BREAK.test(line)) {
68
+ push("thematic-break", i, i + 1);
69
+ i++;
70
+ continue;
71
+ }
72
+ if (QUOTE.test(line)) {
73
+ let j2 = i;
74
+ while (j2 < lines.length && QUOTE.test(lines[j2])) j2++;
75
+ push("quote", i, j2);
76
+ i = j2;
77
+ continue;
78
+ }
79
+ if (LIST.test(line)) {
80
+ let j2 = i;
81
+ while (j2 < lines.length && lines[j2].trim() !== "" && !FENCE.test(lines[j2]))
82
+ j2++;
83
+ push("list", i, j2);
84
+ i = j2;
85
+ continue;
86
+ }
87
+ let j = i;
88
+ while (j < lines.length && lines[j].trim() !== "" && !FENCE.test(lines[j]) && !HEADING.test(lines[j]) && !THEMATIC_BREAK.test(lines[j]) && !QUOTE.test(lines[j]) && !LIST.test(lines[j]))
89
+ j++;
90
+ push("paragraph", i, j);
91
+ i = j;
92
+ }
93
+ return blocks;
94
+ };
95
+
96
+ // src/core/document.ts
97
+ var createMarkdownDocument = (source, version = 1) => ({
98
+ version,
99
+ blocks: segment(source),
100
+ sourceLength: source.length
101
+ });
102
+ var updateMarkdownDocument = (previous, source) => createMarkdownDocument(source, previous.version + 1);
103
+
104
+ // src/core/spans.ts
105
+ import stringWidth from "string-width";
106
+ var defaultTheme = {
107
+ muted: "gray",
108
+ heading: "cyan",
109
+ link: "blue",
110
+ inlineCode: "yellow",
111
+ quote: "gray",
112
+ added: "green",
113
+ removed: "red",
114
+ context: "gray"
115
+ };
116
+ var measureWidth = (text) => stringWidth(text);
117
+ var sliceByWidth = (text, max) => {
118
+ let used = 0;
119
+ let out = "";
120
+ for (const ch of text) {
121
+ const cw = stringWidth(ch);
122
+ if (used + cw > max) break;
123
+ out += ch;
124
+ used += cw;
125
+ }
126
+ return out;
127
+ };
128
+ var sameStyle = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
129
+ var append = (line, text, style) => {
130
+ const last = line[line.length - 1];
131
+ if (last && sameStyle(last.style, style))
132
+ line[line.length - 1] = { text: last.text + text, style };
133
+ else line.push(style ? { text, style } : { text });
134
+ };
135
+ var INLINE = /(\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*|_([^_]+)_|\[([^\]]+)\]\(([^)]+)\))/g;
136
+ var parseInline = (text, theme) => {
137
+ const spans = [];
138
+ const re = new RegExp(INLINE);
139
+ let last = 0;
140
+ let m;
141
+ while ((m = re.exec(text)) !== null) {
142
+ if (m.index > last) spans.push({ text: text.slice(last, m.index) });
143
+ if (m[2] !== void 0) spans.push({ text: m[2], style: { bold: true } });
144
+ else if (m[3] !== void 0)
145
+ spans.push({ text: m[3], style: { color: theme.inlineCode } });
146
+ else if (m[4] !== void 0)
147
+ spans.push({ text: m[4], style: { italic: true } });
148
+ else if (m[5] !== void 0)
149
+ spans.push({ text: m[5], style: { italic: true } });
150
+ else if (m[6] !== void 0)
151
+ spans.push({ text: m[6], style: { color: theme.link, underline: true } });
152
+ last = m.index + m[0].length;
153
+ }
154
+ if (last < text.length) spans.push({ text: text.slice(last) });
155
+ return spans.length > 0 ? spans : [{ text }];
156
+ };
157
+ var wrapSpans = (spans, max) => {
158
+ const width = Math.max(1, max);
159
+ const lines = [];
160
+ let line = [];
161
+ let used = 0;
162
+ const flush = () => {
163
+ lines.push(line);
164
+ line = [];
165
+ used = 0;
166
+ };
167
+ for (const span of spans) {
168
+ const tokens = span.text.match(/\s+|\S+/g) ?? [];
169
+ for (const token of tokens) {
170
+ const w = stringWidth(token);
171
+ if (/^\s+$/.test(token)) {
172
+ if (used > 0 && used + w <= width) {
173
+ append(line, token, span.style);
174
+ used += w;
175
+ }
176
+ continue;
177
+ }
178
+ if (used + w <= width) {
179
+ append(line, token, span.style);
180
+ used += w;
181
+ continue;
182
+ }
183
+ if (used > 0) flush();
184
+ if (w <= width) {
185
+ append(line, token, span.style);
186
+ used += w;
187
+ } else {
188
+ let rest = token;
189
+ while (stringWidth(rest) > width) {
190
+ const chunk = sliceByWidth(rest, width);
191
+ append(line, chunk, span.style);
192
+ flush();
193
+ rest = rest.slice(chunk.length);
194
+ }
195
+ if (rest) {
196
+ append(line, rest, span.style);
197
+ used = stringWidth(rest);
198
+ }
199
+ }
200
+ }
201
+ }
202
+ if (line.length > 0) lines.push(line);
203
+ return lines.length > 0 ? lines : [[]];
204
+ };
205
+ var clipSpans = (spans, max) => {
206
+ const out = [];
207
+ let used = 0;
208
+ for (const span of spans) {
209
+ if (used >= max) break;
210
+ const w = stringWidth(span.text);
211
+ if (used + w <= max) {
212
+ out.push(span);
213
+ used += w;
214
+ } else {
215
+ out.push({ text: sliceByWidth(span.text, max - used), style: span.style });
216
+ break;
217
+ }
218
+ }
219
+ return out;
220
+ };
221
+
222
+ // src/core/layout.ts
223
+ var blockText = (block, source) => source.slice(block.sourceStart, block.sourceEnd);
224
+ var toLine = (blockId, index, spans) => {
225
+ const plainText = spans.map((span) => span.text).join("");
226
+ return {
227
+ id: `${blockId}:${index}`,
228
+ spans,
229
+ plainText,
230
+ displayWidth: measureWidth(plainText)
231
+ };
232
+ };
233
+ var stripFences = (text) => {
234
+ const lines = text.split("\n");
235
+ if (lines[0]?.startsWith("```")) lines.shift();
236
+ if (lines.length > 0 && lines[lines.length - 1]?.startsWith("```"))
237
+ lines.pop();
238
+ return lines;
239
+ };
240
+ var diffStyle = (line, theme) => {
241
+ if (line.startsWith("@@")) return { color: theme.heading };
242
+ if (/^(\+\+\+|---|diff |index )/.test(line)) return { bold: true };
243
+ if (line.startsWith("+")) return { color: theme.added };
244
+ if (line.startsWith("-")) return { color: theme.removed };
245
+ return { color: theme.context };
246
+ };
247
+ var layoutBlock = (block, source, width, theme) => {
248
+ const text = blockText(block, source);
249
+ const rows = [];
250
+ switch (block.type) {
251
+ case "heading": {
252
+ const content = text.replace(/^#{1,6}\s+/, "");
253
+ rows.push(
254
+ ...wrapSpans(
255
+ [{ text: content, style: { color: theme.heading, bold: true } }],
256
+ width
257
+ )
258
+ );
259
+ break;
260
+ }
261
+ case "thematic-break": {
262
+ rows.push([
263
+ { text: "\u2500".repeat(Math.max(1, width)), style: { color: theme.muted } }
264
+ ]);
265
+ break;
266
+ }
267
+ case "quote": {
268
+ const content = text.replace(/^>\s?/gm, "").split("\n").join(" ");
269
+ const prefix = { text: "\u2502 ", style: { color: theme.quote } };
270
+ for (const wrapped of wrapSpans(parseInline(content, theme), width - 2))
271
+ rows.push([prefix, ...wrapped]);
272
+ break;
273
+ }
274
+ case "list": {
275
+ for (const raw of text.split("\n")) {
276
+ const item = raw.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
277
+ if (!item) continue;
278
+ wrapSpans(parseInline(item[3], theme), width - 2).forEach(
279
+ (wrapped, i) => {
280
+ const prefix = i === 0 ? { text: "\u2022 ", style: { color: theme.heading } } : { text: " " };
281
+ rows.push([prefix, ...wrapped]);
282
+ }
283
+ );
284
+ }
285
+ break;
286
+ }
287
+ case "code": {
288
+ for (const codeLine of stripFences(text))
289
+ rows.push(clipSpans([{ text: codeLine }], width));
290
+ break;
291
+ }
292
+ case "diff": {
293
+ const body = block.lang === "diff" ? stripFences(text) : text.split("\n");
294
+ for (const diffLine of body)
295
+ rows.push(
296
+ clipSpans(
297
+ [{ text: diffLine, style: diffStyle(diffLine, theme) }],
298
+ width
299
+ )
300
+ );
301
+ break;
302
+ }
303
+ default: {
304
+ const content = text.split("\n").join(" ");
305
+ rows.push(...wrapSpans(parseInline(content, theme), width));
306
+ }
307
+ }
308
+ return rows.map((spans, index) => toLine(block.id, index, spans));
309
+ };
310
+ var createMarkdownLayout = (document, source, options) => {
311
+ const theme = options.theme ?? defaultTheme;
312
+ const themeId = options.themeId ?? (options.theme ? hash(JSON.stringify(theme)) : "default");
313
+ const width = options.width;
314
+ const cache = options.cache;
315
+ const blocks = [];
316
+ const lines = [];
317
+ const lineOffsets = [];
318
+ let reused = 0;
319
+ let computed = 0;
320
+ for (const block of document.blocks) {
321
+ const key = `${block.id}:${width}:${themeId}`;
322
+ let blockLines = cache?.get(key);
323
+ if (blockLines) {
324
+ reused++;
325
+ } else {
326
+ blockLines = layoutBlock(block, source, width, theme);
327
+ cache?.set(key, blockLines);
328
+ computed++;
329
+ }
330
+ lineOffsets.push(lines.length);
331
+ blocks.push({
332
+ blockId: block.id,
333
+ width,
334
+ height: blockLines.length,
335
+ lines: blockLines
336
+ });
337
+ for (const line of blockLines) lines.push(line);
338
+ }
339
+ return {
340
+ width,
341
+ themeId,
342
+ blocks,
343
+ lines,
344
+ lineOffsets,
345
+ totalLines: lines.length,
346
+ reused,
347
+ computed
348
+ };
349
+ };
350
+ var sliceLines = (layout, offset, height) => {
351
+ const start = Math.max(
352
+ 0,
353
+ Math.min(offset, Math.max(0, layout.totalLines - height))
354
+ );
355
+ return layout.lines.slice(start, start + height);
356
+ };
357
+ var firstBlockAt = (layout, line) => {
358
+ const offsets = layout.lineOffsets;
359
+ let lo = 0;
360
+ let hi = offsets.length - 1;
361
+ let answer = 0;
362
+ while (lo <= hi) {
363
+ const mid = lo + hi >> 1;
364
+ if (offsets[mid] <= line) {
365
+ answer = mid;
366
+ lo = mid + 1;
367
+ } else {
368
+ hi = mid - 1;
369
+ }
370
+ }
371
+ return answer;
372
+ };
373
+
374
+ // src/ink/markdown-viewport.tsx
375
+ import { useMemo } from "react";
376
+ import { Box, Text } from "ink";
377
+
378
+ // src/ink/ansi.ts
379
+ import chalk from "chalk";
380
+ var chain = (fn, key) => {
381
+ const next = fn[key];
382
+ return typeof next === "function" ? next : fn;
383
+ };
384
+ var capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1);
385
+ var applyStyle = (text, style) => {
386
+ if (!style) return text;
387
+ let fn = chalk;
388
+ if (style.color) fn = chain(fn, style.color);
389
+ if (style.backgroundColor)
390
+ fn = chain(fn, `bg${capitalize(style.backgroundColor)}`);
391
+ if (style.bold) fn = chain(fn, "bold");
392
+ if (style.italic) fn = chain(fn, "italic");
393
+ if (style.underline) fn = chain(fn, "underline");
394
+ if (style.dim) fn = chain(fn, "dim");
395
+ if (style.inverse) fn = chain(fn, "inverse");
396
+ return fn(text);
397
+ };
398
+ var lineToAnsi = (line) => line.spans.map((span) => applyStyle(span.text, span.style)).join("");
399
+ var linesToAnsi = (lines) => lines.map(lineToAnsi).join("\n");
400
+
401
+ // src/ink/markdown-viewport.tsx
402
+ import { jsx } from "react/jsx-runtime";
403
+ var MarkdownViewport = ({
404
+ source,
405
+ document,
406
+ layout: providedLayout,
407
+ width,
408
+ height,
409
+ scrollOffset = 0,
410
+ theme,
411
+ cache
412
+ }) => {
413
+ const layout = useMemo(() => {
414
+ if (providedLayout) return providedLayout;
415
+ const text = source ?? "";
416
+ const doc = document ?? createMarkdownDocument(text);
417
+ return createMarkdownLayout(doc, text, { width, theme, cache });
418
+ }, [providedLayout, document, source, width, theme, cache]);
419
+ const composed = linesToAnsi(sliceLines(layout, scrollOffset, height));
420
+ return /* @__PURE__ */ jsx(Box, { width, height, flexDirection: "column", children: /* @__PURE__ */ jsx(Text, { wrap: "truncate", children: composed }) });
421
+ };
422
+
423
+ // src/ink/use-markdown-scroll.ts
424
+ import { useInput } from "ink";
425
+ var clamp = (value, max) => Math.max(0, Math.min(value, max));
426
+ var useMarkdownScroll = ({
427
+ totalLines,
428
+ height,
429
+ offset,
430
+ onChange,
431
+ active = true
432
+ }) => {
433
+ const max = Math.max(0, totalLines - height);
434
+ useInput(
435
+ (input, key) => {
436
+ if (key.downArrow || input === "j") onChange(clamp(offset + 1, max));
437
+ else if (key.upArrow || input === "k") onChange(clamp(offset - 1, max));
438
+ else if (key.pageDown || input === " ")
439
+ onChange(clamp(offset + height, max));
440
+ else if (key.pageUp || input === "b")
441
+ onChange(clamp(offset - height, max));
442
+ else if (input === "g") onChange(0);
443
+ else if (input === "G") onChange(max);
444
+ },
445
+ { isActive: active }
446
+ );
447
+ };
448
+ export {
449
+ MarkdownViewport,
450
+ clipSpans,
451
+ createMarkdownDocument,
452
+ createMarkdownLayout,
453
+ defaultTheme,
454
+ firstBlockAt,
455
+ hash,
456
+ lineToAnsi,
457
+ linesToAnsi,
458
+ measureWidth,
459
+ parseInline,
460
+ segment,
461
+ sliceByWidth,
462
+ sliceLines,
463
+ updateMarkdownDocument,
464
+ useMarkdownScroll,
465
+ wrapSpans
466
+ };
467
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/hash.ts","../src/core/segment.ts","../src/core/document.ts","../src/core/spans.ts","../src/core/layout.ts","../src/ink/markdown-viewport.tsx","../src/ink/ansi.ts","../src/ink/use-markdown-scroll.ts"],"sourcesContent":["// FNV-1a — cheap, stable content hash used for block identity and layout-cache keys.\nexport const hash = (input: string): string => {\n let h = 0x811c9dc5\n for (let i = 0; i < input.length; i++) {\n h ^= input.charCodeAt(i)\n h = Math.imul(h, 0x01000193)\n }\n return (h >>> 0).toString(36)\n}\n","import { hash } from \"./hash.js\"\nimport type { MarkdownBlock, MarkdownBlockType } from \"./document.js\"\n\nconst FENCE = /^```(\\S*)\\s*$/\nconst HEADING = /^#{1,6}\\s+/\nconst THEMATIC_BREAK = /^(-{3,}|\\*{3,}|_{3,})\\s*$/\nconst QUOTE = /^>\\s?/\nconst LIST = /^(\\s*)([-*+]|\\d+\\.)\\s+/\n\n// Line-scan segmenter → renderer-independent top-level blocks with stable, content-based\n// identity. Minimal GFM-ish block set; inline parsing happens later in the layout stage.\n// An unterminated fence stays a single open block, which is what the streaming tail needs.\nexport const segment = (source: string): MarkdownBlock[] => {\n const lines = source.split(\"\\n\")\n const lineStart: number[] = []\n let offset = 0\n for (const line of lines) {\n lineStart.push(offset)\n offset += line.length + 1 // + newline\n }\n\n const blocks: MarkdownBlock[] = []\n const seen = new Map<string, number>()\n\n const push = (\n type: MarkdownBlockType,\n fromLine: number,\n toLine: number,\n lang?: string,\n ) => {\n const lastLine = toLine - 1\n const sourceStart = lineStart[fromLine]\n const sourceEnd = lineStart[lastLine] + lines[lastLine].length\n const text = lines.slice(fromLine, toLine).join(\"\\n\")\n const sourceHash = hash(text)\n // Content hash is the identity; disambiguate genuinely-identical blocks by occurrence so\n // React keys stay unique without reintroducing position sensitivity for unique content.\n const count = seen.get(sourceHash) ?? 0\n seen.set(sourceHash, count + 1)\n const id = count === 0 ? sourceHash : `${sourceHash}~${count}`\n blocks.push({\n id,\n type,\n sourceStart,\n sourceEnd,\n sourceHash,\n ...(lang ? { lang } : {}),\n })\n }\n\n let i = 0\n while (i < lines.length) {\n const line = lines[i]\n\n if (line.trim() === \"\") {\n i++\n continue\n }\n\n const fence = line.match(FENCE)\n if (fence) {\n let j = i + 1\n while (j < lines.length && !FENCE.test(lines[j])) j++\n const toLine = j < lines.length ? j + 1 : lines.length // include closing fence, or EOF\n const lang = fence[1] || \"\"\n push(lang === \"diff\" ? \"diff\" : \"code\", i, toLine, lang || undefined)\n i = toLine\n continue\n }\n\n if (HEADING.test(line)) {\n push(\"heading\", i, i + 1)\n i++\n continue\n }\n\n if (THEMATIC_BREAK.test(line)) {\n push(\"thematic-break\", i, i + 1)\n i++\n continue\n }\n\n if (QUOTE.test(line)) {\n let j = i\n while (j < lines.length && QUOTE.test(lines[j])) j++\n push(\"quote\", i, j)\n i = j\n continue\n }\n\n if (LIST.test(line)) {\n let j = i\n while (\n j < lines.length &&\n lines[j].trim() !== \"\" &&\n !FENCE.test(lines[j])\n )\n j++\n push(\"list\", i, j)\n i = j\n continue\n }\n\n // Paragraph — consume consecutive non-blank, non-structural lines.\n let j = i\n while (\n j < lines.length &&\n lines[j].trim() !== \"\" &&\n !FENCE.test(lines[j]) &&\n !HEADING.test(lines[j]) &&\n !THEMATIC_BREAK.test(lines[j]) &&\n !QUOTE.test(lines[j]) &&\n !LIST.test(lines[j])\n )\n j++\n push(\"paragraph\", i, j)\n i = j\n }\n\n return blocks\n}\n","import { segment } from \"./segment.js\"\n\nexport type MarkdownBlockType =\n | \"paragraph\"\n | \"heading\"\n | \"code\"\n | \"diff\"\n | \"list\"\n | \"quote\"\n | \"thematic-break\"\n\nexport type MarkdownBlock = {\n id: string\n type: MarkdownBlockType\n sourceStart: number\n sourceEnd: number\n sourceHash: string\n /** Fence info string for `code` / `diff` blocks (e.g. \"ts\", \"diff\"). */\n lang?: string\n}\n\nexport type MarkdownDocument = {\n version: number\n blocks: readonly MarkdownBlock[]\n sourceLength: number\n}\n\nexport const createMarkdownDocument = (\n source: string,\n version = 1,\n): MarkdownDocument => ({\n version,\n blocks: segment(source),\n sourceLength: source.length,\n})\n\n// Reparse after an edit. Block identity is content-based, so unchanged blocks keep their ids\n// automatically — cached layouts downstream stay valid — and this just restamps the version.\nexport const updateMarkdownDocument = (\n previous: MarkdownDocument,\n source: string,\n): MarkdownDocument => createMarkdownDocument(source, previous.version + 1)\n","import stringWidth from \"string-width\"\n\n// Semantic (renderer-independent) styling. No ANSI here — the Ink view maps these to\n// <Text> props, a future OpenTUI view maps them to its own styling, and a path-A composer\n// turns them into ANSI. Colours are resolved theme values (e.g. \"cyan\"), not tokens.\nexport type SpanStyle = {\n color?: string\n backgroundColor?: string\n bold?: boolean\n italic?: boolean\n underline?: boolean\n dim?: boolean\n inverse?: boolean\n}\n\nexport type TextSpan = {\n text: string\n style?: SpanStyle\n}\n\nexport type LayoutLine = {\n id: string\n spans: readonly TextSpan[]\n plainText: string\n displayWidth: number\n}\n\nexport type BlockLayout = {\n blockId: string\n width: number\n height: number\n lines: readonly LayoutLine[]\n}\n\nexport type MarkdownTheme = {\n foreground?: string\n muted: string\n heading: string\n link: string\n inlineCode: string\n quote: string\n added: string\n removed: string\n context: string\n}\n\nexport const defaultTheme: MarkdownTheme = {\n muted: \"gray\",\n heading: \"cyan\",\n link: \"blue\",\n inlineCode: \"yellow\",\n quote: \"gray\",\n added: \"green\",\n removed: \"red\",\n context: \"gray\",\n}\n\n/** Display width in terminal columns (Unicode-aware: wide chars count as 2). */\nexport const measureWidth = (text: string): number => stringWidth(text)\n\n/** Slice a string to at most `max` display columns without splitting a code point. */\nexport const sliceByWidth = (text: string, max: number): string => {\n let used = 0\n let out = \"\"\n for (const ch of text) {\n const cw = stringWidth(ch)\n if (used + cw > max) break\n out += ch\n used += cw\n }\n return out\n}\n\nconst sameStyle = (a?: SpanStyle, b?: SpanStyle): boolean =>\n JSON.stringify(a ?? null) === JSON.stringify(b ?? null)\n\n// Append text to a span list, merging into the previous span when the style matches so\n// runs of identical styling stay a single span (fewer nodes for the renderer).\nconst append = (line: TextSpan[], text: string, style?: SpanStyle): void => {\n const last = line[line.length - 1]\n if (last && sameStyle(last.style, style))\n line[line.length - 1] = { text: last.text + text, style }\n else line.push(style ? { text, style } : { text })\n}\n\nconst INLINE =\n /(\\*\\*([^*]+)\\*\\*|`([^`]+)`|\\*([^*]+)\\*|_([^_]+)_|\\[([^\\]]+)\\]\\(([^)]+)\\))/g\n\n// Minimal inline parser → styled spans: bold, italic, inline code, links. Markdown-it can\n// replace this later for fuller GFM inline; this covers the common PR-comment cases.\nexport const parseInline = (text: string, theme: MarkdownTheme): TextSpan[] => {\n const spans: TextSpan[] = []\n const re = new RegExp(INLINE)\n let last = 0\n let m: RegExpExecArray | null\n while ((m = re.exec(text)) !== null) {\n if (m.index > last) spans.push({ text: text.slice(last, m.index) })\n if (m[2] !== undefined) spans.push({ text: m[2], style: { bold: true } })\n else if (m[3] !== undefined)\n spans.push({ text: m[3], style: { color: theme.inlineCode } })\n else if (m[4] !== undefined)\n spans.push({ text: m[4], style: { italic: true } })\n else if (m[5] !== undefined)\n spans.push({ text: m[5], style: { italic: true } })\n else if (m[6] !== undefined)\n spans.push({ text: m[6], style: { color: theme.link, underline: true } })\n last = m.index + m[0].length\n }\n if (last < text.length) spans.push({ text: text.slice(last) })\n return spans.length > 0 ? spans : [{ text }]\n}\n\n// Word-wrap a run of styled spans to `max` display columns, splitting spans at column\n// boundaries and hard-breaking any single token longer than the line.\nexport const wrapSpans = (\n spans: readonly TextSpan[],\n max: number,\n): TextSpan[][] => {\n const width = Math.max(1, max)\n const lines: TextSpan[][] = []\n let line: TextSpan[] = []\n let used = 0\n const flush = () => {\n lines.push(line)\n line = []\n used = 0\n }\n\n for (const span of spans) {\n const tokens = span.text.match(/\\s+|\\S+/g) ?? []\n for (const token of tokens) {\n const w = stringWidth(token)\n if (/^\\s+$/.test(token)) {\n // Keep inter-word whitespace, but drop it at a line start (no leading spaces on wraps).\n if (used > 0 && used + w <= width) {\n append(line, token, span.style)\n used += w\n }\n continue\n }\n if (used + w <= width) {\n append(line, token, span.style)\n used += w\n continue\n }\n if (used > 0) flush()\n if (w <= width) {\n append(line, token, span.style)\n used += w\n } else {\n let rest = token\n while (stringWidth(rest) > width) {\n const chunk = sliceByWidth(rest, width)\n append(line, chunk, span.style)\n flush()\n rest = rest.slice(chunk.length)\n }\n if (rest) {\n append(line, rest, span.style)\n used = stringWidth(rest)\n }\n }\n }\n }\n if (line.length > 0) lines.push(line)\n return lines.length > 0 ? lines : [[]]\n}\n\n/** Truncate a run of spans to `max` display columns (for code / diff — clip, don't wrap). */\nexport const clipSpans = (\n spans: readonly TextSpan[],\n max: number,\n): TextSpan[] => {\n const out: TextSpan[] = []\n let used = 0\n for (const span of spans) {\n if (used >= max) break\n const w = stringWidth(span.text)\n if (used + w <= max) {\n out.push(span)\n used += w\n } else {\n out.push({ text: sliceByWidth(span.text, max - used), style: span.style })\n break\n }\n }\n return out\n}\n","import { hash } from \"./hash.js\"\nimport type { MarkdownBlock, MarkdownDocument } from \"./document.js\"\nimport {\n type BlockLayout,\n type LayoutLine,\n type MarkdownTheme,\n type TextSpan,\n clipSpans,\n defaultTheme,\n measureWidth,\n parseInline,\n wrapSpans,\n} from \"./spans.js\"\n\nconst blockText = (block: MarkdownBlock, source: string): string =>\n source.slice(block.sourceStart, block.sourceEnd)\n\nconst toLine = (\n blockId: string,\n index: number,\n spans: TextSpan[],\n): LayoutLine => {\n const plainText = spans.map((span) => span.text).join(\"\")\n return {\n id: `${blockId}:${index}`,\n spans,\n plainText,\n displayWidth: measureWidth(plainText),\n }\n}\n\nconst stripFences = (text: string): string[] => {\n const lines = text.split(\"\\n\")\n if (lines[0]?.startsWith(\"```\")) lines.shift()\n if (lines.length > 0 && lines[lines.length - 1]?.startsWith(\"```\"))\n lines.pop()\n return lines\n}\n\nconst diffStyle = (line: string, theme: MarkdownTheme) => {\n if (line.startsWith(\"@@\")) return { color: theme.heading }\n if (/^(\\+\\+\\+|---|diff |index )/.test(line)) return { bold: true }\n if (line.startsWith(\"+\")) return { color: theme.added }\n if (line.startsWith(\"-\")) return { color: theme.removed }\n return { color: theme.context }\n}\n\n// One block → its visual terminal lines. Prose wraps; code/diff clip. Pure + deterministic\n// given (block text, width, theme) → safe to cache. Syntax highlighting arrives in M4;\n// code renders plain here.\nconst layoutBlock = (\n block: MarkdownBlock,\n source: string,\n width: number,\n theme: MarkdownTheme,\n): LayoutLine[] => {\n const text = blockText(block, source)\n const rows: TextSpan[][] = []\n\n switch (block.type) {\n case \"heading\": {\n const content = text.replace(/^#{1,6}\\s+/, \"\")\n rows.push(\n ...wrapSpans(\n [{ text: content, style: { color: theme.heading, bold: true } }],\n width,\n ),\n )\n break\n }\n case \"thematic-break\": {\n rows.push([\n { text: \"─\".repeat(Math.max(1, width)), style: { color: theme.muted } },\n ])\n break\n }\n case \"quote\": {\n const content = text.replace(/^>\\s?/gm, \"\").split(\"\\n\").join(\" \")\n const prefix: TextSpan = { text: \"│ \", style: { color: theme.quote } }\n for (const wrapped of wrapSpans(parseInline(content, theme), width - 2))\n rows.push([prefix, ...wrapped])\n break\n }\n case \"list\": {\n for (const raw of text.split(\"\\n\")) {\n const item = raw.match(/^(\\s*)([-*+]|\\d+\\.)\\s+(.*)$/)\n if (!item) continue\n wrapSpans(parseInline(item[3], theme), width - 2).forEach(\n (wrapped, i) => {\n const prefix: TextSpan =\n i === 0\n ? { text: \"• \", style: { color: theme.heading } }\n : { text: \" \" }\n rows.push([prefix, ...wrapped])\n },\n )\n }\n break\n }\n case \"code\": {\n for (const codeLine of stripFences(text))\n rows.push(clipSpans([{ text: codeLine }], width))\n break\n }\n case \"diff\": {\n const body = block.lang === \"diff\" ? stripFences(text) : text.split(\"\\n\")\n for (const diffLine of body)\n rows.push(\n clipSpans(\n [{ text: diffLine, style: diffStyle(diffLine, theme) }],\n width,\n ),\n )\n break\n }\n default: {\n const content = text.split(\"\\n\").join(\" \")\n rows.push(...wrapSpans(parseInline(content, theme), width))\n }\n }\n\n return rows.map((spans, index) => toLine(block.id, index, spans))\n}\n\nexport type LayoutOptions = {\n width: number\n theme?: MarkdownTheme\n themeId?: string\n /** Optional cross-call cache (survives scroll/resize) keyed by block id + width + theme. */\n cache?: Map<string, readonly LayoutLine[]>\n}\n\nexport type MarkdownLayout = {\n width: number\n themeId: string\n blocks: readonly BlockLayout[]\n /** All lines in document order — O(1) viewport slicing. */\n lines: readonly LayoutLine[]\n /** Cumulative first-line index of each block (parallel to blocks). */\n lineOffsets: readonly number[]\n totalLines: number\n reused: number\n computed: number\n}\n\n// Lay out every block for a given width, reusing cached block layouts where possible. Scroll\n// never recomputes; a width change invalidates only width-dependent entries via the key.\nexport const createMarkdownLayout = (\n document: MarkdownDocument,\n source: string,\n options: LayoutOptions,\n): MarkdownLayout => {\n const theme = options.theme ?? defaultTheme\n const themeId =\n options.themeId ?? (options.theme ? hash(JSON.stringify(theme)) : \"default\")\n const width = options.width\n const cache = options.cache\n const blocks: BlockLayout[] = []\n const lines: LayoutLine[] = []\n const lineOffsets: number[] = []\n let reused = 0\n let computed = 0\n\n for (const block of document.blocks) {\n const key = `${block.id}:${width}:${themeId}`\n let blockLines = cache?.get(key)\n if (blockLines) {\n reused++\n } else {\n blockLines = layoutBlock(block, source, width, theme)\n cache?.set(key, blockLines)\n computed++\n }\n lineOffsets.push(lines.length)\n blocks.push({\n blockId: block.id,\n width,\n height: blockLines.length,\n lines: blockLines,\n })\n for (const line of blockLines) lines.push(line)\n }\n\n return {\n width,\n themeId,\n blocks,\n lines,\n lineOffsets,\n totalLines: lines.length,\n reused,\n computed,\n }\n}\n\n/** Visible slice for a viewport — clamped so the last page never scrolls past the end. */\nexport const sliceLines = (\n layout: MarkdownLayout,\n offset: number,\n height: number,\n): readonly LayoutLine[] => {\n const start = Math.max(\n 0,\n Math.min(offset, Math.max(0, layout.totalLines - height)),\n )\n return layout.lines.slice(start, start + height)\n}\n\n/** Index of the first block whose cumulative offset ≤ line — the log-n first-visible lookup. */\nexport const firstBlockAt = (layout: MarkdownLayout, line: number): number => {\n const offsets = layout.lineOffsets\n let lo = 0\n let hi = offsets.length - 1\n let answer = 0\n while (lo <= hi) {\n const mid = (lo + hi) >> 1\n if (offsets[mid] <= line) {\n answer = mid\n lo = mid + 1\n } else {\n hi = mid - 1\n }\n }\n return answer\n}\n","import { useMemo } from \"react\"\nimport { Box, Text } from \"ink\"\nimport {\n createMarkdownDocument,\n createMarkdownLayout,\n sliceLines,\n type LayoutLine,\n type MarkdownDocument,\n type MarkdownLayout,\n type MarkdownTheme,\n} from \"../core/index.js\"\nimport { linesToAnsi } from \"./ansi.js\"\n\nexport type MarkdownViewportProps = {\n /** Raw Markdown source. Ignored when `layout` is provided. */\n source?: string\n /** Pre-parsed document (reused with `source` to skip re-parsing). */\n document?: MarkdownDocument\n /** Pre-computed layout — skips parse + layout entirely. */\n layout?: MarkdownLayout\n width: number\n height: number\n /** First visible document line. Clamped so the last page never scrolls past the end. */\n scrollOffset?: number\n theme?: MarkdownTheme\n /** Layout cache shared across renders (survives scroll/resize). */\n cache?: Map<string, readonly LayoutLine[]>\n}\n\n// The viewport mounts exactly ONE <Text>, whatever the document size: the visible slice is\n// composed into a single pre-rendered ANSI string (render-path A). React/Yoga work stays\n// O(viewport), not O(document) — the whole performance thesis, made concrete.\nexport const MarkdownViewport = ({\n source,\n document,\n layout: providedLayout,\n width,\n height,\n scrollOffset = 0,\n theme,\n cache,\n}: MarkdownViewportProps) => {\n const layout = useMemo(() => {\n if (providedLayout) return providedLayout\n const text = source ?? \"\"\n const doc = document ?? createMarkdownDocument(text)\n return createMarkdownLayout(doc, text, { width, theme, cache })\n }, [providedLayout, document, source, width, theme, cache])\n\n const composed = linesToAnsi(sliceLines(layout, scrollOffset, height))\n\n return (\n <Box width={width} height={height} flexDirection=\"column\">\n <Text wrap=\"truncate\">{composed}</Text>\n </Box>\n )\n}\n","import chalk, { type ChalkInstance } from \"chalk\"\nimport type { LayoutLine, SpanStyle } from \"../core/index.js\"\n\n// Path-A composition lives here: semantic spans → one ANSI string. Chalk auto-detects the\n// terminal's colour support, so this degrades to plain text where colour is unavailable.\nconst chain = (fn: ChalkInstance, key: string): ChalkInstance => {\n const next = (fn as unknown as Record<string, unknown>)[key]\n return typeof next === \"function\" ? (next as ChalkInstance) : fn\n}\n\nconst capitalize = (value: string): string =>\n value.charAt(0).toUpperCase() + value.slice(1)\n\nconst applyStyle = (text: string, style?: SpanStyle): string => {\n if (!style) return text\n let fn: ChalkInstance = chalk\n if (style.color) fn = chain(fn, style.color)\n if (style.backgroundColor)\n fn = chain(fn, `bg${capitalize(style.backgroundColor)}`)\n if (style.bold) fn = chain(fn, \"bold\")\n if (style.italic) fn = chain(fn, \"italic\")\n if (style.underline) fn = chain(fn, \"underline\")\n if (style.dim) fn = chain(fn, \"dim\")\n if (style.inverse) fn = chain(fn, \"inverse\")\n return fn(text)\n}\n\n/** Compose one layout line's styled spans into a single ANSI string. */\nexport const lineToAnsi = (line: LayoutLine): string =>\n line.spans.map((span) => applyStyle(span.text, span.style)).join(\"\")\n\n/** Path-A composition: the visible span-lines → one ANSI string for a single <Text>. */\nexport const linesToAnsi = (lines: readonly LayoutLine[]): string =>\n lines.map(lineToAnsi).join(\"\\n\")\n","import { useInput } from \"ink\"\n\nexport type MarkdownScrollOptions = {\n totalLines: number\n height: number\n offset: number\n onChange: (offset: number) => void\n /** Disable the key handler (e.g. when another pane has focus). */\n active?: boolean\n}\n\nconst clamp = (value: number, max: number): number =>\n Math.max(0, Math.min(value, max))\n\n// Optional keyboard controller — the core owns scrolling *primitives* (offset in/out), not\n// keybindings. Apps that want their own bindings ignore this and drive `scrollOffset` directly.\nexport const useMarkdownScroll = ({\n totalLines,\n height,\n offset,\n onChange,\n active = true,\n}: MarkdownScrollOptions): void => {\n const max = Math.max(0, totalLines - height)\n useInput(\n (input, key) => {\n if (key.downArrow || input === \"j\") onChange(clamp(offset + 1, max))\n else if (key.upArrow || input === \"k\") onChange(clamp(offset - 1, max))\n else if (key.pageDown || input === \" \")\n onChange(clamp(offset + height, max))\n else if (key.pageUp || input === \"b\")\n onChange(clamp(offset - height, max))\n else if (input === \"g\") onChange(0)\n else if (input === \"G\") onChange(max)\n },\n { isActive: active },\n )\n}\n"],"mappings":"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","names":["toLine","j"]}
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@kud/ink-markdown",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "A high-performance Markdown / code / diff rendering engine for Ink 7.",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsup",
19
+ "build:watch": "tsup --watch",
20
+ "typecheck": "tsc --noEmit",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "homepage": "https://kud.io/projects/ink-markdown",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/kud/ink-markdown.git"
29
+ },
30
+ "author": "Erwann Mest <m@kud.io>",
31
+ "license": "MIT",
32
+ "engines": {
33
+ "node": ">=20.0.0"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "peerDependencies": {
39
+ "ink": "7.x",
40
+ "react": "19.x"
41
+ },
42
+ "dependencies": {
43
+ "chalk": "5.6.2",
44
+ "cli-highlight": "2.1.11",
45
+ "markdown-it": "14.3.0",
46
+ "slice-ansi": "9.0.0",
47
+ "string-width": "8.2.2",
48
+ "wrap-ansi": "10.0.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/markdown-it": "14.1.2",
52
+ "@types/node": "26.1.1",
53
+ "@types/react": "19.2.17",
54
+ "ink": "7.1.0",
55
+ "ink-testing-library": "4.0.0",
56
+ "react": "19.2.7",
57
+ "tsup": "8.5.1",
58
+ "typescript": "5.9.3",
59
+ "vitest": "4.1.10"
60
+ }
61
+ }