@sigx/lynx-markdown 0.4.5 → 0.4.6

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.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Shared low-level scanning helpers: source normalization, line classification,
3
+ * escape handling, and URL scanning. Pure functions, no AST/JSX dependency.
4
+ */
5
+ /** Characters that may be backslash-escaped in markdown to their literal form. */
6
+ const ESCAPABLE = new Set('\\`*_{}[]()#+-.!>~|"\'');
7
+ /**
8
+ * Normalize source for uniform parsing: CRLF/CR → LF, and expand leading tabs
9
+ * to four spaces so indentation logic is consistent. Tabs after the first
10
+ * non-space content are left alone (they only matter for block indentation).
11
+ */
12
+ export function normalize(src) {
13
+ const unified = src.replace(/\r\n?/g, '\n');
14
+ if (unified.indexOf('\t') === -1)
15
+ return unified;
16
+ return unified
17
+ .split('\n')
18
+ .map(expandLeadingTabs)
19
+ .join('\n');
20
+ }
21
+ function expandLeadingTabs(line) {
22
+ let i = 0;
23
+ let out = '';
24
+ let col = 0;
25
+ while (i < line.length) {
26
+ const ch = line[i];
27
+ if (ch === '\t') {
28
+ const next = col + (4 - (col % 4));
29
+ out += ' '.repeat(next - col);
30
+ col = next;
31
+ }
32
+ else if (ch === ' ') {
33
+ out += ' ';
34
+ col++;
35
+ }
36
+ else {
37
+ break;
38
+ }
39
+ i++;
40
+ }
41
+ return out + line.slice(i);
42
+ }
43
+ /** Count leading spaces (indentation) of a line. */
44
+ export function indentOf(line) {
45
+ let i = 0;
46
+ while (i < line.length && line[i] === ' ')
47
+ i++;
48
+ return i;
49
+ }
50
+ /** True when a line is blank (empty or whitespace-only). */
51
+ export function isBlank(line) {
52
+ return /^\s*$/.test(line);
53
+ }
54
+ /** Match an opening (or closing) fenced-code-block marker. */
55
+ export function matchFence(line) {
56
+ const m = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line);
57
+ if (!m)
58
+ return null;
59
+ const fence = m[2];
60
+ const info = m[3].trim();
61
+ // An opening info string for a backtick fence may not contain a backtick.
62
+ if (fence[0] === '`' && info.indexOf('`') !== -1)
63
+ return null;
64
+ return { char: fence[0], length: fence.length, indent: m[1].length, info };
65
+ }
66
+ /** True when `line` closes a fence opened by `open`. */
67
+ export function isFenceClose(line, open) {
68
+ const m = /^( {0,3})(`{3,}|~{3,})\s*$/.exec(line);
69
+ if (!m)
70
+ return false;
71
+ const fence = m[2];
72
+ return fence[0] === open.char && fence.length >= open.length;
73
+ }
74
+ /** Match an ATX heading (`#`..`######`). Returns level + text, or null. */
75
+ export function matchHeading(line) {
76
+ const m = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/.exec(line);
77
+ if (!m)
78
+ return null;
79
+ // A `#` must be followed by a space or end of line to be a heading.
80
+ if (m[2] === undefined && line.replace(/^ {0,3}#{1,6}/, '').trim() !== '')
81
+ return null;
82
+ return { level: m[1].length, text: (m[2] ?? '').trim() };
83
+ }
84
+ /** Match a thematic break (`---`, `***`, `___`, optionally spaced). */
85
+ export function isThematicBreak(line) {
86
+ const t = line.trim();
87
+ if (t.length < 3)
88
+ return false;
89
+ if (!/^[-*_]/.test(t))
90
+ return false;
91
+ const ch = t[0];
92
+ return t.split('').every((c) => c === ch || c === ' ') &&
93
+ t.replace(/\s/g, '').length >= 3;
94
+ }
95
+ /** Match a list-item marker (`- `, `* `, `+ `, `1. `, `1) `). */
96
+ export function matchListMarker(line) {
97
+ const m = /^( {0,3})(?:([-*+])|(\d{1,9})([.)]))([ \t]+)(.*)$/.exec(line);
98
+ if (!m)
99
+ return null;
100
+ const markerIndent = m[1].length;
101
+ const ordered = m[3] !== undefined;
102
+ const delimiter = ordered ? m[4] : m[2];
103
+ const markerText = ordered ? m[3] + m[4] : m[2];
104
+ const spaces = m[5].length;
105
+ const contentIndent = markerIndent + markerText.length + spaces;
106
+ return {
107
+ ordered,
108
+ start: ordered ? parseInt(m[3], 10) : 1,
109
+ delimiter,
110
+ markerIndent,
111
+ contentIndent,
112
+ content: m[6],
113
+ };
114
+ }
115
+ /** Match an empty list item (`-` / `1.` with nothing after). */
116
+ export function matchEmptyListMarker(line) {
117
+ const m = /^( {0,3})(?:([-*+])|(\d{1,9})([.)]))[ \t]*$/.exec(line);
118
+ if (!m)
119
+ return null;
120
+ const markerIndent = m[1].length;
121
+ const ordered = m[3] !== undefined;
122
+ const delimiter = ordered ? m[4] : m[2];
123
+ const markerText = ordered ? m[3] + m[4] : m[2];
124
+ return {
125
+ ordered,
126
+ start: ordered ? parseInt(m[3], 10) : 1,
127
+ delimiter,
128
+ markerIndent,
129
+ contentIndent: markerIndent + markerText.length + 1,
130
+ content: '',
131
+ };
132
+ }
133
+ /**
134
+ * Parse a GFM table delimiter row (`| --- | :--: | --: |`) into per-column
135
+ * alignment, or return null if the line is not a valid delimiter row.
136
+ */
137
+ export function matchTableDelimiter(line) {
138
+ const t = line.trim();
139
+ if (t.indexOf('-') === -1)
140
+ return null;
141
+ const cells = splitTableRow(t);
142
+ if (cells.length === 0)
143
+ return null;
144
+ const align = [];
145
+ for (const cell of cells) {
146
+ const c = cell.trim();
147
+ if (!/^:?-+:?$/.test(c))
148
+ return null;
149
+ const left = c.startsWith(':');
150
+ const right = c.endsWith(':');
151
+ align.push(left && right ? 'center' : right ? 'right' : left ? 'left' : null);
152
+ }
153
+ return align;
154
+ }
155
+ /** Split a table row into raw cell strings, honoring `\|` escapes. */
156
+ export function splitTableRow(line) {
157
+ let s = line.trim();
158
+ if (s.startsWith('|'))
159
+ s = s.slice(1);
160
+ if (s.endsWith('|') && !s.endsWith('\\|'))
161
+ s = s.slice(0, -1);
162
+ const cells = [];
163
+ let buf = '';
164
+ for (let i = 0; i < s.length; i++) {
165
+ const ch = s[i];
166
+ if (ch === '\\' && s[i + 1] === '|') {
167
+ buf += '|';
168
+ i++;
169
+ }
170
+ else if (ch === '|') {
171
+ cells.push(buf);
172
+ buf = '';
173
+ }
174
+ else {
175
+ buf += ch;
176
+ }
177
+ }
178
+ cells.push(buf);
179
+ return cells.map((c) => c.trim());
180
+ }
181
+ // ---------------------------------------------------------------------------
182
+ // Escapes & URLs
183
+ // ---------------------------------------------------------------------------
184
+ /** True when `ch` is a backslash-escapable punctuation character. */
185
+ export function isEscapable(ch) {
186
+ return ESCAPABLE.has(ch);
187
+ }
188
+ /** A conservative URL scheme allow-list for link safety. */
189
+ const SAFE_SCHEME = /^(https?:|mailto:|tel:)/i;
190
+ /**
191
+ * Return a link href if it is safe to expose to handlers, else `'#'`. Blocks
192
+ * `javascript:`/`data:` and other unexpected schemes. Relative/anchor links and
193
+ * the recognized safe schemes pass through unchanged.
194
+ */
195
+ export function sanitizeHref(href) {
196
+ const h = href.trim();
197
+ if (h === '')
198
+ return '#';
199
+ if (/^[a-z][a-z0-9+.-]*:/i.test(h)) {
200
+ return SAFE_SCHEME.test(h) ? h : '#';
201
+ }
202
+ // No scheme → relative, anchor, or protocol-relative; allow.
203
+ return h;
204
+ }
205
+ /** Trailing punctuation trimmed from bare-URL autolinks (GFM behavior). */
206
+ export function trimAutolinkTail(url) {
207
+ let end = url.length;
208
+ while (end > 0 && '?!.,:*_~)'.includes(url[end - 1])) {
209
+ // A trailing ')' is only trimmed when unbalanced within the URL.
210
+ if (url[end - 1] === ')') {
211
+ const open = (url.slice(0, end).match(/\(/g) ?? []).length;
212
+ const close = (url.slice(0, end).match(/\)/g) ?? []).length;
213
+ if (close <= open)
214
+ break;
215
+ }
216
+ end--;
217
+ }
218
+ return { url: url.slice(0, end), tail: url.slice(end) };
219
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `<MarkdownView>` — a SignalX-native, streaming-aware markdown renderer.
3
+ *
4
+ * Parses markdown in JS (zero dependencies) and renders to Lynx primitives, so
5
+ * it works identically on every platform — unlike {@link XMarkdown}, which wraps
6
+ * the platform-gated native `<x-markdown>` element. For an editable counterpart,
7
+ * see `MarkdownEditor`.
8
+ *
9
+ * Rendering is **generic**: the package ships neutral, theme-agnostic defaults
10
+ * and exposes a `components` map so any design system can fully control the look
11
+ * (e.g. `@sigx/lynx-daisyui`'s `markdownComponents`). See
12
+ * {@link MarkdownComponents}.
13
+ *
14
+ * The `value` prop is reactive: as it grows (e.g. driven by an AI token loop via
15
+ * {@link createMarkdownStream}), finalized blocks keep a stable identity and are
16
+ * never re-parsed or remounted, so completed content does not flicker or reflow.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * import { MarkdownView } from '@sigx/lynx-markdown';
21
+ * import { markdownComponents } from '@sigx/lynx-daisyui';
22
+ *
23
+ * <MarkdownView value={md} components={markdownComponents} onLink={openUrl} />
24
+ * ```
25
+ */
26
+ import { type Define } from '@sigx/lynx';
27
+ import { type MarkdownComponents } from './components.js';
28
+ export type MarkdownViewProps = Define.Prop<'value', string, false> & Define.Prop<'onLink', (href: string) => void, false> & Define.Prop<'onImageTap', (src: string) => void, false> & Define.Prop<'components', Partial<MarkdownComponents>, false>;
29
+ export declare const MarkdownView: import("@sigx/runtime-core").ComponentFactory<MarkdownViewProps, void, {}>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `<MarkdownView>` — a SignalX-native, streaming-aware markdown renderer.
3
+ *
4
+ * Parses markdown in JS (zero dependencies) and renders to Lynx primitives, so
5
+ * it works identically on every platform — unlike {@link XMarkdown}, which wraps
6
+ * the platform-gated native `<x-markdown>` element. For an editable counterpart,
7
+ * see `MarkdownEditor`.
8
+ *
9
+ * Rendering is **generic**: the package ships neutral, theme-agnostic defaults
10
+ * and exposes a `components` map so any design system can fully control the look
11
+ * (e.g. `@sigx/lynx-daisyui`'s `markdownComponents`). See
12
+ * {@link MarkdownComponents}.
13
+ *
14
+ * The `value` prop is reactive: as it grows (e.g. driven by an AI token loop via
15
+ * {@link createMarkdownStream}), finalized blocks keep a stable identity and are
16
+ * never re-parsed or remounted, so completed content does not flicker or reflow.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * import { MarkdownView } from '@sigx/lynx-markdown';
21
+ * import { markdownComponents } from '@sigx/lynx-daisyui';
22
+ *
23
+ * <MarkdownView value={md} components={markdownComponents} onLink={openUrl} />
24
+ * ```
25
+ */
26
+ import { component, computed } from '@sigx/lynx';
27
+ import { createIncrementalEngine } from '../parser/incremental.js';
28
+ import { defaultComponents } from './components.js';
29
+ import { renderDocument } from './engine.js';
30
+ export const MarkdownView = component(({ props }) => {
31
+ const engine = createIncrementalEngine();
32
+ const blocks = computed(() => engine.parse(props.value ?? ''));
33
+ return () => {
34
+ const ctx = {
35
+ components: props.components
36
+ ? { ...defaultComponents, ...props.components }
37
+ : defaultComponents,
38
+ onLink: props.onLink,
39
+ onImageTap: props.onImageTap,
40
+ };
41
+ return renderDocument(blocks.value, ctx);
42
+ };
43
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The render-function component contract and the neutral default renderers.
3
+ *
4
+ * `@sigx/lynx-markdown` is **generic**: the defaults here use only plain inline
5
+ * styles (numbers + theme-agnostic colors) so the renderer works standalone on
6
+ * any platform/theme with zero design-system coupling. A design system (e.g.
7
+ * `@sigx/lynx-daisyui`) supplies its own {@link MarkdownComponents} to control
8
+ * the look — see the `components` prop on `<Markdown>`.
9
+ *
10
+ * Each component receives its already-rendered `children` plus the raw AST
11
+ * `node`; the engine owns AST recursion and stable streaming keys, so a
12
+ * component only decides *what element to wrap children in*.
13
+ */
14
+ import type { JSXElement } from '@sigx/lynx';
15
+ import type { BlockquoteBlock, CodeBlock, HeadingBlock, HeadingLevel, InlineAutolink, InlineCodeSpan, InlineDel, InlineEm, InlineImage, InlineLink, InlineStrong, ListBlock, ListItem, ParagraphBlock, TableAlign, TableBlock, ThematicBreakBlock } from '../ast.js';
16
+ /** A renderable child: a JSX element or a raw string (for text/`<br>`). */
17
+ export type MarkdownChild = JSXElement | string;
18
+ export interface RootProps {
19
+ children: MarkdownChild[];
20
+ }
21
+ export interface HeadingProps {
22
+ level: HeadingLevel;
23
+ children: MarkdownChild[];
24
+ node: HeadingBlock;
25
+ }
26
+ export interface ParagraphProps {
27
+ children: MarkdownChild[];
28
+ node: ParagraphBlock;
29
+ }
30
+ export interface BlockquoteProps {
31
+ children: MarkdownChild[];
32
+ node: BlockquoteBlock;
33
+ }
34
+ export interface ListProps {
35
+ ordered: boolean;
36
+ start: number;
37
+ tight: boolean;
38
+ children: MarkdownChild[];
39
+ node: ListBlock;
40
+ }
41
+ export interface ListItemProps {
42
+ ordered: boolean;
43
+ /** Zero-based index within the list. */
44
+ index: number;
45
+ /** Display number for ordered lists (`start + index`). */
46
+ number: number;
47
+ /** GFM task state, or `null` when not a task item. */
48
+ checked: boolean | null;
49
+ children: MarkdownChild[];
50
+ item: ListItem;
51
+ }
52
+ export interface CodeProps {
53
+ lang?: string;
54
+ value: string;
55
+ /** `false` while the fence is still streaming/unterminated. */
56
+ closed: boolean;
57
+ node: CodeBlock;
58
+ }
59
+ export interface ThematicBreakProps {
60
+ node: ThematicBreakBlock;
61
+ }
62
+ export interface TableProps {
63
+ align: (TableAlign | null)[];
64
+ children: MarkdownChild[];
65
+ node: TableBlock;
66
+ }
67
+ export interface TableRowProps {
68
+ header: boolean;
69
+ children: MarkdownChild[];
70
+ node: TableBlock;
71
+ }
72
+ export interface TableCellProps {
73
+ header: boolean;
74
+ align: TableAlign | null;
75
+ children: MarkdownChild[];
76
+ node: TableBlock;
77
+ }
78
+ export interface StrongProps {
79
+ children: MarkdownChild[];
80
+ node: InlineStrong;
81
+ }
82
+ export interface EmProps {
83
+ children: MarkdownChild[];
84
+ node: InlineEm;
85
+ }
86
+ export interface DelProps {
87
+ children: MarkdownChild[];
88
+ node: InlineDel;
89
+ }
90
+ export interface CodeSpanProps {
91
+ value: string;
92
+ node: InlineCodeSpan;
93
+ }
94
+ export interface LinkProps {
95
+ href: string;
96
+ title?: string;
97
+ children: MarkdownChild[];
98
+ onLink?: (href: string) => void;
99
+ node: InlineLink;
100
+ }
101
+ export interface AutolinkProps {
102
+ href: string;
103
+ value: string;
104
+ onLink?: (href: string) => void;
105
+ node: InlineAutolink;
106
+ }
107
+ export interface ImageProps {
108
+ src: string;
109
+ alt: string;
110
+ title?: string;
111
+ onImageTap?: (src: string) => void;
112
+ node: InlineImage;
113
+ }
114
+ /**
115
+ * Map of node type → render function. Pass a partial map to `<Markdown
116
+ * components={…}>` to override any subset; unspecified types fall back to the
117
+ * neutral {@link defaultComponents}.
118
+ */
119
+ export interface MarkdownComponents {
120
+ root(props: RootProps): JSXElement;
121
+ heading(props: HeadingProps): JSXElement;
122
+ paragraph(props: ParagraphProps): JSXElement;
123
+ blockquote(props: BlockquoteProps): JSXElement;
124
+ list(props: ListProps): JSXElement;
125
+ listItem(props: ListItemProps): JSXElement;
126
+ code(props: CodeProps): JSXElement;
127
+ thematicBreak(props: ThematicBreakProps): JSXElement;
128
+ table(props: TableProps): JSXElement;
129
+ tableRow(props: TableRowProps): JSXElement;
130
+ tableCell(props: TableCellProps): JSXElement;
131
+ strong(props: StrongProps): MarkdownChild;
132
+ em(props: EmProps): MarkdownChild;
133
+ del(props: DelProps): MarkdownChild;
134
+ codeSpan(props: CodeSpanProps): MarkdownChild;
135
+ link(props: LinkProps): MarkdownChild;
136
+ autolink(props: AutolinkProps): MarkdownChild;
137
+ image(props: ImageProps): MarkdownChild;
138
+ br(): MarkdownChild;
139
+ }
140
+ export declare const defaultComponents: MarkdownComponents;
@@ -0,0 +1,99 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@sigx/lynx/jsx-runtime";
2
+ // -- Neutral, theme-agnostic defaults ----------------------------------------
3
+ /** Faint neutral fill that reads on both light and dark backgrounds. */
4
+ const SURFACE = 'rgba(127, 127, 127, 0.14)';
5
+ const BORDER = 'rgba(127, 127, 127, 0.32)';
6
+ const LINK = '#3478f6';
7
+ const HEADING_SIZE = { 1: 30, 2: 24, 3: 20, 4: 18, 5: 16, 6: 14 };
8
+ export const defaultComponents = {
9
+ root: ({ children }) => (_jsx("view", { style: { display: 'flex', flexDirection: 'column', gap: 10 }, children: children })),
10
+ heading: ({ level, children }) => (_jsx("text", { style: {
11
+ fontSize: HEADING_SIZE[level],
12
+ fontWeight: level <= 2 ? 700 : 600,
13
+ ...(level >= 6 ? { opacity: 0.8 } : {}),
14
+ }, children: children })),
15
+ paragraph: ({ children }) => _jsx("text", { style: { fontSize: 16, lineHeight: 24 }, children: children }),
16
+ blockquote: ({ children }) => (_jsx("view", { style: {
17
+ display: 'flex',
18
+ flexDirection: 'column',
19
+ gap: 8,
20
+ paddingLeft: 12,
21
+ borderLeftWidth: 4,
22
+ borderLeftStyle: 'solid',
23
+ borderLeftColor: BORDER,
24
+ opacity: 0.85,
25
+ }, children: children })),
26
+ list: ({ children }) => (_jsx("view", { style: { display: 'flex', flexDirection: 'column', gap: 4 }, children: children })),
27
+ listItem: ({ ordered, number, checked, children }) => {
28
+ const isBullet = checked === null && !ordered;
29
+ return (_jsxs("view", { style: { display: 'flex', flexDirection: 'row', gap: 6, alignItems: 'flex-start' }, children: [isBullet ? (_jsx("view", { style: {
30
+ width: 6,
31
+ height: 6,
32
+ borderRadius: 3,
33
+ marginTop: 9,
34
+ marginLeft: 2,
35
+ backgroundColor: 'rgba(120, 120, 120, 0.9)',
36
+ } })) : checked !== null ? (_jsx("view", { style: {
37
+ width: 16,
38
+ height: 16,
39
+ marginTop: 4,
40
+ borderRadius: 4,
41
+ borderWidth: 1.5,
42
+ borderStyle: 'solid',
43
+ borderColor: checked ? '#3478f6' : 'rgba(127, 127, 127, 0.6)',
44
+ backgroundColor: checked ? '#3478f6' : 'transparent',
45
+ display: 'flex',
46
+ alignItems: 'center',
47
+ justifyContent: 'center',
48
+ }, children: checked ? (_jsx("text", { style: { color: '#ffffff', fontSize: 11, lineHeight: 12 }, children: "\u2713" })) : null })) : (_jsx("text", { style: { fontSize: 16, lineHeight: 24, opacity: 0.6 }, children: `${number}.` })), _jsx("view", { style: { display: 'flex', flexDirection: 'column', gap: 4, flexGrow: 1, flexShrink: 1 }, children: children })] }));
49
+ },
50
+ code: ({ lang, value }) => (_jsxs("view", { style: {
51
+ display: 'flex',
52
+ flexDirection: 'column',
53
+ backgroundColor: SURFACE,
54
+ borderRadius: 8,
55
+ padding: 12,
56
+ }, children: [lang ? (_jsx("text", { style: { fontFamily: 'monospace', fontSize: 12, opacity: 0.6, marginBottom: 6 }, children: lang })) : null, _jsx("text", { style: { fontFamily: 'monospace', fontSize: 14, whiteSpace: 'pre-wrap' }, children: value })] })),
57
+ thematicBreak: () => (_jsx("view", { style: { height: 1, backgroundColor: BORDER, marginTop: 8, marginBottom: 8 } })),
58
+ table: ({ children }) => (_jsx("view", { style: {
59
+ display: 'flex',
60
+ flexDirection: 'column',
61
+ borderWidth: 1,
62
+ borderStyle: 'solid',
63
+ borderColor: BORDER,
64
+ borderRadius: 8,
65
+ overflow: 'hidden',
66
+ }, children: children })),
67
+ tableRow: ({ header, children }) => (_jsx("view", { style: {
68
+ display: 'flex',
69
+ flexDirection: 'row',
70
+ ...(header ? { backgroundColor: SURFACE } : {}),
71
+ }, children: children })),
72
+ tableCell: ({ header, align, children }) => (_jsx("view", { style: {
73
+ flexGrow: 1,
74
+ flexShrink: 1,
75
+ flexBasis: 0,
76
+ paddingLeft: 8,
77
+ paddingRight: 8,
78
+ paddingTop: 5,
79
+ paddingBottom: 5,
80
+ borderBottomWidth: 1,
81
+ borderBottomStyle: 'solid',
82
+ borderBottomColor: BORDER,
83
+ }, children: _jsx("text", { style: { fontSize: 15, fontWeight: header ? 600 : 400, textAlign: align ?? 'left' }, children: children }) })),
84
+ strong: ({ children }) => _jsx("text", { style: { fontWeight: 700 }, children: children }),
85
+ em: ({ children }) => _jsx("text", { style: { fontStyle: 'italic' }, children: children }),
86
+ del: ({ children }) => (_jsx("text", { style: { textDecoration: 'line-through', opacity: 0.8 }, children: children })),
87
+ codeSpan: ({ value }) => (_jsx("text", { style: {
88
+ fontFamily: 'monospace',
89
+ fontSize: 14,
90
+ backgroundColor: SURFACE,
91
+ borderRadius: 4,
92
+ paddingLeft: 3,
93
+ paddingRight: 3,
94
+ }, children: value })),
95
+ link: ({ href, children, onLink }) => (_jsx("text", { style: { color: LINK, textDecoration: 'underline' }, bindtap: () => onLink?.(href), children: children })),
96
+ autolink: ({ href, value, onLink }) => (_jsx("text", { style: { color: LINK, textDecoration: 'underline' }, bindtap: () => onLink?.(href), children: value })),
97
+ image: ({ src, alt, onImageTap }) => (_jsx("text", { style: { color: LINK, textDecoration: 'underline' }, bindtap: () => onImageTap?.(src), children: alt || src })),
98
+ br: () => '\n',
99
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Render engine: walks a `BlockNode[]` AST and dispatches each node to a
3
+ * {@link MarkdownComponents} renderer.
4
+ *
5
+ * Responsibilities the engine keeps (so components stay simple and design
6
+ * systems can't accidentally break streaming):
7
+ * - AST recursion — children are fully rendered before a component is called.
8
+ * - Stable reconciliation keys — `VNode.key` is stamped from the AST node's
9
+ * streaming key after the component returns, so finalized blocks never
10
+ * remount regardless of which element a component produced.
11
+ */
12
+ import type { JSXElement } from '@sigx/lynx';
13
+ import type { BlockNode } from '../ast.js';
14
+ import type { MarkdownComponents } from './components.js';
15
+ export interface RenderContext {
16
+ components: MarkdownComponents;
17
+ onLink?: (href: string) => void;
18
+ onImageTap?: (src: string) => void;
19
+ }
20
+ /** Render top-level blocks and wrap them in the `root` container. */
21
+ export declare function renderDocument(blocks: BlockNode[], ctx: RenderContext): JSXElement;