@sigx/lynx-markdown 0.4.7 → 0.4.8
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/README.md +30 -18
- package/dist/editor/MarkdownEditor.d.ts +48 -0
- package/dist/editor/MarkdownEditor.js +127 -0
- package/dist/editor/convert/docToMd.d.ts +14 -0
- package/dist/editor/convert/docToMd.js +201 -0
- package/dist/editor/convert/mdToDoc.d.ts +19 -0
- package/dist/editor/convert/mdToDoc.js +187 -0
- package/dist/editor/convert/overlap.d.ts +31 -0
- package/dist/editor/convert/overlap.js +70 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +5 -3
- package/dist/render/MarkdownView.d.ts +2 -3
- package/dist/render/MarkdownView.js +2 -3
- package/package.json +13 -7
- package/dist/XMarkdown.d.ts +0 -36
- package/dist/XMarkdown.js +0 -36
- package/dist/jsx-augment.d.ts +0 -83
- package/dist/jsx-augment.js +0 -1
package/README.md
CHANGED
|
@@ -9,10 +9,6 @@ AI output: as the source string grows token-by-token, finalized blocks keep a
|
|
|
9
9
|
stable identity and are never re-parsed or remounted, so completed content
|
|
10
10
|
doesn't flicker or reflow while new tokens stream in.
|
|
11
11
|
|
|
12
|
-
The package also ships [`XMarkdown`](#xmarkdown--native-element-wrapper), the
|
|
13
|
-
thin wrapper around Lynx's native `<x-markdown>` element, for cases where you
|
|
14
|
-
want the platform's built-in renderer.
|
|
15
|
-
|
|
16
12
|
## Install
|
|
17
13
|
|
|
18
14
|
```
|
|
@@ -120,22 +116,38 @@ import { markdownComponents } from '@sigx/lynx-daisyui';
|
|
|
120
116
|
<MarkdownView value={src} components={markdownComponents} />;
|
|
121
117
|
```
|
|
122
118
|
|
|
123
|
-
## `XMarkdown` — native element wrapper
|
|
124
|
-
|
|
125
|
-
`XMarkdown` wraps Lynx's native `<x-markdown>` XElement. It's fast where
|
|
126
|
-
available but platform-gated and opaque (the engine owns parsing and styling):
|
|
127
119
|
|
|
128
|
-
|
|
129
|
-
| -------- | --------------- | --------------------------------------------------- |
|
|
130
|
-
| Harmony | Lynx 3.7.0 | Stable. |
|
|
131
|
-
| Android | Lynx 3.8.0-rc.0 | Ships as `org.lynxsdk.lynx:lynx_xelement_markdown`. |
|
|
132
|
-
| iOS | (post-3.8.0) | Currently only on the upstream `main` branch. |
|
|
120
|
+
## `MarkdownEditor` — true-WYSIWYG editing
|
|
133
121
|
|
|
134
|
-
|
|
135
|
-
`
|
|
122
|
+
`MarkdownEditor` provides WYSIWYG markdown editing on the native
|
|
123
|
+
[`@sigx/lynx-richtext`](../lynx-richtext) element (optional peer dependency —
|
|
124
|
+
bold is bold *inside* the input, not `**` markers). The external contract stays
|
|
125
|
+
markdown: `value` in, `onChange(markdown)` out.
|
|
136
126
|
|
|
137
127
|
```tsx
|
|
138
|
-
import {
|
|
139
|
-
|
|
140
|
-
|
|
128
|
+
import { MarkdownEditor, type MarkdownEditorController } from '@sigx/lynx-markdown';
|
|
129
|
+
|
|
130
|
+
let ctrl: MarkdownEditorController | null = null;
|
|
131
|
+
|
|
132
|
+
<MarkdownEditor
|
|
133
|
+
value={draft}
|
|
134
|
+
placeholder="Message…"
|
|
135
|
+
minLines={1}
|
|
136
|
+
maxLines={4} // chat-style: grow 1→4 lines, then scroll
|
|
137
|
+
mode="auto" // 'auto' | 'fixed' | 'fullscreen'
|
|
138
|
+
confirmType="send"
|
|
139
|
+
onChange={(md) => { draft = md; }}
|
|
140
|
+
onSelectionChange={(sel) => toolbarState(sel.activeFormats)}
|
|
141
|
+
controllerRef={(c) => { ctrl = c; }}
|
|
142
|
+
/>;
|
|
143
|
+
|
|
144
|
+
// Imperative commands (what a toolbar drives):
|
|
145
|
+
ctrl?.toggleBold();
|
|
146
|
+
ctrl?.setHeading(2);
|
|
147
|
+
ctrl?.clear(); // chat send
|
|
141
148
|
```
|
|
149
|
+
|
|
150
|
+
v1 models paragraphs, headings, and bold/italic/strike/code/link in-field;
|
|
151
|
+
everything else (lists, tables, code fences) round-trips **losslessly** as raw
|
|
152
|
+
markdown source via `raw` blocks until later phases model them. Conversion
|
|
153
|
+
helpers `mdToDoc`/`docToMd` are exported for advanced use.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<MarkdownEditor>` — true-WYSIWYG markdown editing on the native
|
|
3
|
+
* `<sigx-richtext>` element.
|
|
4
|
+
*
|
|
5
|
+
* The external contract is **markdown**: `value` in, `onChange(markdown)` out.
|
|
6
|
+
* Internally the editor converts markdown ↔ the element's `RichDoc` span model
|
|
7
|
+
* (`convert/mdToDoc`, `convert/docToMd`) and drives formatting through
|
|
8
|
+
* fire-and-forget commands; the element is the single source of truth for live
|
|
9
|
+
* text and selection (lightly-controlled — keystrokes are never echoed back).
|
|
10
|
+
*
|
|
11
|
+
* ### Echo / IME rules (JS side)
|
|
12
|
+
* - An incoming `value` identical to the last markdown we emitted is our own
|
|
13
|
+
* echo → ignored (string compare; exact).
|
|
14
|
+
* - Otherwise it's compared structurally against the element's last document —
|
|
15
|
+
* only genuinely different content is pushed via `setDocument`.
|
|
16
|
+
* - While the IME is composing, external values are buffered and applied on
|
|
17
|
+
* the composition-end change; `onChange` is also suppressed mid-composition.
|
|
18
|
+
*
|
|
19
|
+
* Sizing: `minLines`/`maxLines` × line height drive the element's auto-grow
|
|
20
|
+
* window (`mode="auto"`, chat-style 1 → N lines then internal scroll);
|
|
21
|
+
* `mode="fixed"` pins the height at `maxLines`; `mode="fullscreen"` fills the
|
|
22
|
+
* parent.
|
|
23
|
+
*/
|
|
24
|
+
import { type Define } from '@sigx/lynx';
|
|
25
|
+
import { type SelectionState } from '@sigx/lynx-richtext';
|
|
26
|
+
export type MarkdownEditorMode = 'auto' | 'fixed' | 'fullscreen';
|
|
27
|
+
/** Imperative command surface — what toolbars and plugins drive. */
|
|
28
|
+
export interface MarkdownEditorController {
|
|
29
|
+
toggleBold(): void;
|
|
30
|
+
toggleItalic(): void;
|
|
31
|
+
toggleStrike(): void;
|
|
32
|
+
toggleCode(): void;
|
|
33
|
+
/** 1–6 sets a heading; 0 reverts to paragraph. */
|
|
34
|
+
setHeading(level: 0 | 1 | 2 | 3 | 4 | 5 | 6): void;
|
|
35
|
+
insertText(text: string): void;
|
|
36
|
+
/** Clear the document (chat send). */
|
|
37
|
+
clear(): void;
|
|
38
|
+
focus(): void;
|
|
39
|
+
blur(): void;
|
|
40
|
+
/** The current markdown (as of the last element change). */
|
|
41
|
+
getMarkdown(): string;
|
|
42
|
+
/** The current selection state (as of the last selection event). */
|
|
43
|
+
getSelection(): SelectionState | null;
|
|
44
|
+
}
|
|
45
|
+
export type MarkdownEditorProps = Define.Prop<'value', string, false> & Define.Prop<'placeholder', string, false> & Define.Prop<'minLines', number, false> & Define.Prop<'maxLines', number, false> & Define.Prop<'mode', MarkdownEditorMode, false> & Define.Prop<'fontSize', number, false> & Define.Prop<'textColor', string, false> & Define.Prop<'accentColor', string, false> & Define.Prop<'placeholderColor', string, false> & Define.Prop<'confirmType', 'send' | 'search' | 'next' | 'go' | 'done', false> & Define.Prop<'autoFocus', boolean, false> & Define.Prop<'disabled', boolean, false> & Define.Prop<'class', string, false> & Define.Prop<'onChange', (markdown: string) => void, false> & Define.Prop<'onSelectionChange', (sel: SelectionState) => void, false> & Define.Prop<'onFocus', () => void, false> & Define.Prop<'onBlur', () => void, false>
|
|
46
|
+
/** Receives the imperative controller once on mount. */
|
|
47
|
+
& Define.Prop<'controllerRef', (ctrl: MarkdownEditorController) => void, false>;
|
|
48
|
+
export declare const MarkdownEditor: import("@sigx/runtime-core").ComponentFactory<MarkdownEditorProps, void, {}>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { jsx as _jsx } from "@sigx/lynx/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* `<MarkdownEditor>` — true-WYSIWYG markdown editing on the native
|
|
4
|
+
* `<sigx-richtext>` element.
|
|
5
|
+
*
|
|
6
|
+
* The external contract is **markdown**: `value` in, `onChange(markdown)` out.
|
|
7
|
+
* Internally the editor converts markdown ↔ the element's `RichDoc` span model
|
|
8
|
+
* (`convert/mdToDoc`, `convert/docToMd`) and drives formatting through
|
|
9
|
+
* fire-and-forget commands; the element is the single source of truth for live
|
|
10
|
+
* text and selection (lightly-controlled — keystrokes are never echoed back).
|
|
11
|
+
*
|
|
12
|
+
* ### Echo / IME rules (JS side)
|
|
13
|
+
* - An incoming `value` identical to the last markdown we emitted is our own
|
|
14
|
+
* echo → ignored (string compare; exact).
|
|
15
|
+
* - Otherwise it's compared structurally against the element's last document —
|
|
16
|
+
* only genuinely different content is pushed via `setDocument`.
|
|
17
|
+
* - While the IME is composing, external values are buffered and applied on
|
|
18
|
+
* the composition-end change; `onChange` is also suppressed mid-composition.
|
|
19
|
+
*
|
|
20
|
+
* Sizing: `minLines`/`maxLines` × line height drive the element's auto-grow
|
|
21
|
+
* window (`mode="auto"`, chat-style 1 → N lines then internal scroll);
|
|
22
|
+
* `mode="fixed"` pins the height at `maxLines`; `mode="fullscreen"` fills the
|
|
23
|
+
* parent.
|
|
24
|
+
*/
|
|
25
|
+
import { component, signal, watch } from '@sigx/lynx';
|
|
26
|
+
import { RichTextInput, RichTextMethods, docEquals, normalizeDoc, emptyDoc, } from '@sigx/lynx-richtext';
|
|
27
|
+
import { mdToDoc } from './convert/mdToDoc.js';
|
|
28
|
+
import { docToMd } from './convert/docToMd.js';
|
|
29
|
+
const DEFAULT_FONT_SIZE = 16;
|
|
30
|
+
/** Vertical padding the element applies internally (8 top + 8 bottom). */
|
|
31
|
+
const ELEMENT_PADDING = 16;
|
|
32
|
+
export const MarkdownEditor = component(({ props }) => {
|
|
33
|
+
let el = null;
|
|
34
|
+
// --- sync state (see module docs) ---
|
|
35
|
+
const initialMd = typeof props.value === 'string' ? props.value : '';
|
|
36
|
+
let lastEmittedMd = initialMd;
|
|
37
|
+
let lastDocFromElement = normalizeDoc(mdToDoc(initialMd));
|
|
38
|
+
let lastSeenVersion = 0;
|
|
39
|
+
let composing = false;
|
|
40
|
+
let pendingExternal = null;
|
|
41
|
+
let currentSel = null;
|
|
42
|
+
// Auto-grow: the native element reports its (clamped) content height and
|
|
43
|
+
// the editor feeds it back as the element's layout height — Lynx layout
|
|
44
|
+
// sizes views from styles, never from native intrinsic content.
|
|
45
|
+
const reportedHeight = signal(0);
|
|
46
|
+
const applyExternal = (md) => {
|
|
47
|
+
if (md === lastEmittedMd)
|
|
48
|
+
return; // our own echo
|
|
49
|
+
if (composing) {
|
|
50
|
+
pendingExternal = md;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const doc = mdToDoc(md, lastSeenVersion);
|
|
54
|
+
if (docEquals(normalizeDoc(doc), lastDocFromElement)) {
|
|
55
|
+
lastEmittedMd = md; // same content, different markdown spelling
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
RichTextMethods.setDocument(el, doc);
|
|
59
|
+
};
|
|
60
|
+
watch(() => props.value, (next) => {
|
|
61
|
+
if (typeof next === 'string')
|
|
62
|
+
applyExternal(next);
|
|
63
|
+
});
|
|
64
|
+
const handleChange = (doc, isComposing) => {
|
|
65
|
+
composing = isComposing;
|
|
66
|
+
lastSeenVersion = doc.v;
|
|
67
|
+
lastDocFromElement = normalizeDoc(doc);
|
|
68
|
+
if (isComposing)
|
|
69
|
+
return;
|
|
70
|
+
const md = docToMd(doc);
|
|
71
|
+
if (md !== lastEmittedMd) {
|
|
72
|
+
lastEmittedMd = md;
|
|
73
|
+
props.onChange?.(md);
|
|
74
|
+
}
|
|
75
|
+
if (pendingExternal !== null) {
|
|
76
|
+
const pending = pendingExternal;
|
|
77
|
+
pendingExternal = null;
|
|
78
|
+
applyExternal(pending);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const controller = {
|
|
82
|
+
toggleBold: () => RichTextMethods.toggleFormat(el, 'bold'),
|
|
83
|
+
toggleItalic: () => RichTextMethods.toggleFormat(el, 'italic'),
|
|
84
|
+
toggleStrike: () => RichTextMethods.toggleFormat(el, 'strike'),
|
|
85
|
+
toggleCode: () => RichTextMethods.toggleFormat(el, 'code'),
|
|
86
|
+
setHeading: (level) => {
|
|
87
|
+
if (level === 0)
|
|
88
|
+
RichTextMethods.setBlockType(el, 'paragraph');
|
|
89
|
+
else
|
|
90
|
+
RichTextMethods.setBlockType(el, 'heading', level);
|
|
91
|
+
},
|
|
92
|
+
insertText: (text) => RichTextMethods.insertText(el, text),
|
|
93
|
+
clear: () => RichTextMethods.setDocument(el, emptyDoc(lastSeenVersion)),
|
|
94
|
+
focus: () => RichTextMethods.focus(el),
|
|
95
|
+
blur: () => RichTextMethods.blur(el),
|
|
96
|
+
getMarkdown: () => lastEmittedMd ?? '',
|
|
97
|
+
getSelection: () => currentSel,
|
|
98
|
+
};
|
|
99
|
+
props.controllerRef?.(controller);
|
|
100
|
+
return () => {
|
|
101
|
+
const fontSize = props.fontSize ?? DEFAULT_FONT_SIZE;
|
|
102
|
+
const lineHeight = Math.round(fontSize * 1.5);
|
|
103
|
+
const mode = props.mode ?? 'auto';
|
|
104
|
+
const minLines = Math.max(1, props.minLines ?? 1);
|
|
105
|
+
const maxLines = Math.max(minLines, props.maxLines ?? 4);
|
|
106
|
+
let minHeight = minLines * lineHeight + ELEMENT_PADDING;
|
|
107
|
+
let maxHeight = maxLines * lineHeight + ELEMENT_PADDING;
|
|
108
|
+
if (mode === 'fixed')
|
|
109
|
+
minHeight = maxHeight;
|
|
110
|
+
if (mode === 'fullscreen')
|
|
111
|
+
maxHeight = 0; // unbounded; element fills parent
|
|
112
|
+
return (_jsx("view", { class: props.class, style: {
|
|
113
|
+
display: 'flex',
|
|
114
|
+
flexDirection: 'column',
|
|
115
|
+
...(mode === 'fullscreen' ? { flexGrow: 1, flexShrink: 1 } : {}),
|
|
116
|
+
}, children: _jsx(RichTextInput, { value: mdToDoc(initialMd), placeholder: props.placeholder, editable: props.disabled ? false : undefined, minHeight: minHeight, maxHeight: maxHeight, fontSize: fontSize, textColor: props.textColor, accentColor: props.accentColor, placeholderColor: props.placeholderColor, confirmType: props.confirmType, autoFocus: props.autoFocus, style: mode === 'fullscreen'
|
|
117
|
+
? { flexGrow: 1 }
|
|
118
|
+
: { height: Math.max(minHeight, Math.min(reportedHeight.value || minHeight, maxHeight)) }, onElement: (handle) => {
|
|
119
|
+
el = handle;
|
|
120
|
+
}, onHeightChange: (height) => {
|
|
121
|
+
reportedHeight.value = height;
|
|
122
|
+
}, onChange: handleChange, onSelection: (sel) => {
|
|
123
|
+
currentSel = sel;
|
|
124
|
+
props.onSelectionChange?.(sel);
|
|
125
|
+
}, onFocus: () => props.onFocus?.(), onBlur: () => props.onBlur?.() }) }));
|
|
126
|
+
};
|
|
127
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {@link RichDoc} → markdown — the serializer (inverse of `mdToDoc`).
|
|
3
|
+
*
|
|
4
|
+
* Segments the flat text by `blocks[]` (raw chunks verbatim, heading lines
|
|
5
|
+
* prefixed, remaining lines as paragraphs), then serializes inline spans per
|
|
6
|
+
* segment via elementary runs + a close/reopen delimiter stack (valid nesting
|
|
7
|
+
* from arbitrarily overlapping spans).
|
|
8
|
+
*
|
|
9
|
+
* Round-trip contract: `mdToDoc(docToMd(doc))` is structurally equal to a
|
|
10
|
+
* *normalized* `doc` — emphasis markers, hard breaks (→ paragraph breaks), and
|
|
11
|
+
* blank lines normalize; `raw` blocks round-trip byte-for-byte.
|
|
12
|
+
*/
|
|
13
|
+
import type { RichDoc } from '@sigx/lynx-richtext';
|
|
14
|
+
export declare function docToMd(doc: RichDoc): string;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {@link RichDoc} → markdown — the serializer (inverse of `mdToDoc`).
|
|
3
|
+
*
|
|
4
|
+
* Segments the flat text by `blocks[]` (raw chunks verbatim, heading lines
|
|
5
|
+
* prefixed, remaining lines as paragraphs), then serializes inline spans per
|
|
6
|
+
* segment via elementary runs + a close/reopen delimiter stack (valid nesting
|
|
7
|
+
* from arbitrarily overlapping spans).
|
|
8
|
+
*
|
|
9
|
+
* Round-trip contract: `mdToDoc(docToMd(doc))` is structurally equal to a
|
|
10
|
+
* *normalized* `doc` — emphasis markers, hard breaks (→ paragraph breaks), and
|
|
11
|
+
* blank lines normalize; `raw` blocks round-trip byte-for-byte.
|
|
12
|
+
*/
|
|
13
|
+
import { computeRuns, markPriority } from './overlap.js';
|
|
14
|
+
export function docToMd(doc) {
|
|
15
|
+
const parts = [];
|
|
16
|
+
for (const seg of segmentize(doc)) {
|
|
17
|
+
if (seg.type === 'raw') {
|
|
18
|
+
parts.push(doc.text.slice(seg.start, seg.end));
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const inline = serializeInline(doc, seg.start, seg.end, seg.type === 'paragraph');
|
|
22
|
+
if (seg.type === 'heading') {
|
|
23
|
+
const level = Math.min(6, Math.max(1, seg.level ?? 1));
|
|
24
|
+
parts.push(`${'#'.repeat(level)} ${inline}`);
|
|
25
|
+
}
|
|
26
|
+
else if (inline !== '') {
|
|
27
|
+
parts.push(inline);
|
|
28
|
+
}
|
|
29
|
+
// Empty paragraph lines are visual spacing only — they normalize away.
|
|
30
|
+
}
|
|
31
|
+
return parts.join('\n\n');
|
|
32
|
+
}
|
|
33
|
+
/** Cover the text with block segments; uncovered regions split per line into paragraphs. */
|
|
34
|
+
function segmentize(doc) {
|
|
35
|
+
const segments = [];
|
|
36
|
+
const text = doc.text;
|
|
37
|
+
const blocks = [...doc.blocks]
|
|
38
|
+
.map((b) => ({
|
|
39
|
+
...b,
|
|
40
|
+
start: Math.max(0, Math.min(b.start, text.length)),
|
|
41
|
+
end: Math.max(0, Math.min(b.end, text.length)),
|
|
42
|
+
}))
|
|
43
|
+
.filter((b) => b.end >= b.start)
|
|
44
|
+
.sort((a, b) => a.start - b.start);
|
|
45
|
+
/**
|
|
46
|
+
* Split an uncovered region `[from, to)` into per-line paragraph segments.
|
|
47
|
+
* A `\n` ends the line before it; a region ending exactly on a separator
|
|
48
|
+
* `\n` emits no phantom trailing line.
|
|
49
|
+
*/
|
|
50
|
+
const emitLines = (from, to) => {
|
|
51
|
+
let lineStart = from;
|
|
52
|
+
for (let i = from; i < to; i++) {
|
|
53
|
+
if (text[i] === '\n') {
|
|
54
|
+
segments.push({ start: lineStart, end: i, type: 'paragraph' });
|
|
55
|
+
lineStart = i + 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (lineStart < to)
|
|
59
|
+
segments.push({ start: lineStart, end: to, type: 'paragraph' });
|
|
60
|
+
};
|
|
61
|
+
let cursor = 0;
|
|
62
|
+
for (const block of blocks) {
|
|
63
|
+
const start = Math.max(cursor, block.start);
|
|
64
|
+
if (start > cursor)
|
|
65
|
+
emitLines(cursor, start);
|
|
66
|
+
const end = Math.max(start, block.end);
|
|
67
|
+
// Block ranges include their trailing newline (paragraphRange
|
|
68
|
+
// semantics) — content excludes it.
|
|
69
|
+
const contentEnd = end > start && text[end - 1] === '\n' ? end - 1 : end;
|
|
70
|
+
if (block.type === 'heading') {
|
|
71
|
+
segments.push({ start, end: contentEnd, type: 'heading', level: block.level });
|
|
72
|
+
}
|
|
73
|
+
else if (block.type === 'raw') {
|
|
74
|
+
segments.push({ start, end: contentEnd, type: 'raw' });
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
// Unknown/unstyled block types degrade to paragraphs (per line).
|
|
78
|
+
emitLines(start, contentEnd);
|
|
79
|
+
}
|
|
80
|
+
cursor = end;
|
|
81
|
+
}
|
|
82
|
+
if (cursor < text.length)
|
|
83
|
+
emitLines(cursor, text.length);
|
|
84
|
+
return segments;
|
|
85
|
+
}
|
|
86
|
+
/** Serialize one segment's text + spans into markdown inline syntax. */
|
|
87
|
+
function serializeInline(doc, start, end, escapeLineStart) {
|
|
88
|
+
const runs = computeRuns(doc.spans, start, end);
|
|
89
|
+
if (runs.length === 0) {
|
|
90
|
+
return escapeText(doc.text.slice(start, end), escapeLineStart);
|
|
91
|
+
}
|
|
92
|
+
// Extent-aware nesting (the ProseMirror-serializer trick): per run, order
|
|
93
|
+
// marks so the one that stays active the LONGEST sits outermost. A shorter
|
|
94
|
+
// mark is closed-and-reopened inside it, which avoids the unserializable
|
|
95
|
+
// adjacent-delimiter runs (`***`) that naive close/reopen produces.
|
|
96
|
+
const contEnd = [];
|
|
97
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
98
|
+
const map = new Map();
|
|
99
|
+
for (const mark of runs[i].active) {
|
|
100
|
+
const next = i + 1 < runs.length ? contEnd[i + 1].get(mark.key) : undefined;
|
|
101
|
+
map.set(mark.key, next !== undefined && runs[i + 1].start === runs[i].end ? next : runs[i].end);
|
|
102
|
+
}
|
|
103
|
+
contEnd[i] = map;
|
|
104
|
+
}
|
|
105
|
+
let out = '';
|
|
106
|
+
const open = [];
|
|
107
|
+
let first = true;
|
|
108
|
+
for (let i = 0; i < runs.length; i++) {
|
|
109
|
+
const run = runs[i];
|
|
110
|
+
const desired = [...run.active].sort((a, b) => {
|
|
111
|
+
const ea = contEnd[i].get(a.key) ?? run.end;
|
|
112
|
+
const eb = contEnd[i].get(b.key) ?? run.end;
|
|
113
|
+
return eb - ea || markPriority(a) - markPriority(b);
|
|
114
|
+
});
|
|
115
|
+
// Keep the common open-stack prefix; close the rest (innermost first).
|
|
116
|
+
let keep = 0;
|
|
117
|
+
while (keep < open.length && keep < desired.length && open[keep].key === desired[keep].key)
|
|
118
|
+
keep++;
|
|
119
|
+
for (let j = open.length - 1; j >= keep; j--)
|
|
120
|
+
out += delimiter(open[j], doc, 'close');
|
|
121
|
+
open.length = keep;
|
|
122
|
+
for (let j = keep; j < desired.length; j++) {
|
|
123
|
+
out += delimiter(desired[j], doc, 'open');
|
|
124
|
+
open.push(desired[j]);
|
|
125
|
+
}
|
|
126
|
+
const slice = doc.text.slice(run.start, run.end);
|
|
127
|
+
out += open.some((m) => m.type === 'code')
|
|
128
|
+
? slice
|
|
129
|
+
: escapeText(slice, escapeLineStart && first && open.length === 0);
|
|
130
|
+
first = false;
|
|
131
|
+
}
|
|
132
|
+
for (let j = open.length - 1; j >= 0; j--)
|
|
133
|
+
out += delimiter(open[j], doc, 'close');
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
function delimiter(mark, doc, side) {
|
|
137
|
+
switch (mark.type) {
|
|
138
|
+
case 'bold':
|
|
139
|
+
return '**';
|
|
140
|
+
case 'italic':
|
|
141
|
+
// `_` (not `*`) so a bold/italic split never emits an ambiguous
|
|
142
|
+
// `***` delimiter run (`**a*b***c*` parses wrong; `**a_b_**_c_`
|
|
143
|
+
// can't collide). Trade-off: mid-word italic won't re-parse
|
|
144
|
+
// (intraword `_` rule) — documented normalization.
|
|
145
|
+
return '_';
|
|
146
|
+
case 'strike':
|
|
147
|
+
return '~~';
|
|
148
|
+
case 'code': {
|
|
149
|
+
// Widen the fence beyond any backtick run in the content.
|
|
150
|
+
const fence = '`'.repeat(maxBacktickRun(doc.text) + 1);
|
|
151
|
+
return fence.length > 1 ? fence : '`';
|
|
152
|
+
}
|
|
153
|
+
case 'link':
|
|
154
|
+
return side === 'open' ? '[' : `](${escapeLinkDest(String(mark.attrs?.href ?? ''))})`;
|
|
155
|
+
case 'mention':
|
|
156
|
+
// P3 — until the mention plugin lands, serialize as plain label.
|
|
157
|
+
return '';
|
|
158
|
+
default:
|
|
159
|
+
return '';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Escape a link destination so it re-parses to the same href (mirrors
|
|
164
|
+
* `scanLinkDest`'s two accepted forms):
|
|
165
|
+
*
|
|
166
|
+
* - bare form: backslash-escape `\`, `(`, `)`, `<` (a leading `<` would
|
|
167
|
+
* otherwise flip the parser into the angle branch) — valid as long as
|
|
168
|
+
* the destination has no whitespace;
|
|
169
|
+
* - whitespace anywhere → angle form `<…>`, whose only terminators are `>`
|
|
170
|
+
* and newline; those get percent-encoded (they're invalid raw in URLs
|
|
171
|
+
* anyway, so this is normalization, not loss).
|
|
172
|
+
*/
|
|
173
|
+
function escapeLinkDest(href) {
|
|
174
|
+
if (/\s/.test(href)) {
|
|
175
|
+
return `<${href.replace(/>/g, '%3E').replace(/\n/g, '%0A')}>`;
|
|
176
|
+
}
|
|
177
|
+
return href.replace(/[\\()<]/g, (c) => `\\${c}`);
|
|
178
|
+
}
|
|
179
|
+
function maxBacktickRun(text) {
|
|
180
|
+
let max = 0;
|
|
181
|
+
let run = 0;
|
|
182
|
+
for (const ch of text) {
|
|
183
|
+
run = ch === '`' ? run + 1 : 0;
|
|
184
|
+
if (run > max)
|
|
185
|
+
max = run;
|
|
186
|
+
}
|
|
187
|
+
return max;
|
|
188
|
+
}
|
|
189
|
+
/** Escape markdown-significant characters in literal text. */
|
|
190
|
+
function escapeText(text, atLineStart) {
|
|
191
|
+
let out = text.replace(/([\\`*_~[\]])/g, '\\$1');
|
|
192
|
+
if (atLineStart) {
|
|
193
|
+
// Constructs that only bind at the start of a line.
|
|
194
|
+
out = out
|
|
195
|
+
.replace(/^(#{1,6})(\s)/, '\\$1$2')
|
|
196
|
+
.replace(/^>/, '\\>')
|
|
197
|
+
.replace(/^([-+])(\s)/, '\\$1$2')
|
|
198
|
+
.replace(/^(\d+)([.)])(\s)/, '$1\\$2$3');
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown → {@link RichDoc} — flatten the parsed AST into the rich-text
|
|
3
|
+
* element's flat text+spans+blocks model.
|
|
4
|
+
*
|
|
5
|
+
* v1 models paragraphs, headings, and the inline set
|
|
6
|
+
* (bold/italic/strike/code/link). Everything else — lists, blockquotes, code
|
|
7
|
+
* blocks, tables, thematic breaks, and any paragraph containing an
|
|
8
|
+
* unrepresentable inline (images) — becomes a **`raw` block**: the original
|
|
9
|
+
* markdown source verbatim, edited as source and serialized back
|
|
10
|
+
* byte-for-byte. That's the lossless escape hatch that makes the round trip
|
|
11
|
+
* safe long before every node type is WYSIWYG-editable.
|
|
12
|
+
*
|
|
13
|
+
* Line convention (chat-style): every `\n` in `doc.text` is a paragraph
|
|
14
|
+
* boundary. Markdown hard breaks split into separate doc lines; `docToMd`
|
|
15
|
+
* serializes doc lines back as blank-line-separated paragraphs (hard breaks
|
|
16
|
+
* normalize to paragraph breaks — documented).
|
|
17
|
+
*/
|
|
18
|
+
import type { RichDoc } from '@sigx/lynx-richtext';
|
|
19
|
+
export declare function mdToDoc(markdown: string, v?: number): RichDoc;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown → {@link RichDoc} — flatten the parsed AST into the rich-text
|
|
3
|
+
* element's flat text+spans+blocks model.
|
|
4
|
+
*
|
|
5
|
+
* v1 models paragraphs, headings, and the inline set
|
|
6
|
+
* (bold/italic/strike/code/link). Everything else — lists, blockquotes, code
|
|
7
|
+
* blocks, tables, thematic breaks, and any paragraph containing an
|
|
8
|
+
* unrepresentable inline (images) — becomes a **`raw` block**: the original
|
|
9
|
+
* markdown source verbatim, edited as source and serialized back
|
|
10
|
+
* byte-for-byte. That's the lossless escape hatch that makes the round trip
|
|
11
|
+
* safe long before every node type is WYSIWYG-editable.
|
|
12
|
+
*
|
|
13
|
+
* Line convention (chat-style): every `\n` in `doc.text` is a paragraph
|
|
14
|
+
* boundary. Markdown hard breaks split into separate doc lines; `docToMd`
|
|
15
|
+
* serializes doc lines back as blank-line-separated paragraphs (hard breaks
|
|
16
|
+
* normalize to paragraph breaks — documented).
|
|
17
|
+
*/
|
|
18
|
+
import { parseBlocks } from '../../parser/blocks.js';
|
|
19
|
+
export function mdToDoc(markdown, v = 0) {
|
|
20
|
+
const ast = parseBlocks(markdown ?? '');
|
|
21
|
+
let text = '';
|
|
22
|
+
const spans = [];
|
|
23
|
+
const blocks = [];
|
|
24
|
+
/** Append one doc line (or multi-line raw chunk) plus its block attr. */
|
|
25
|
+
const push = (chunk, attr) => {
|
|
26
|
+
// Raw chunks may carry trailing blank lines consumed by the block
|
|
27
|
+
// parser (loose lists) — the inter-block separator is reconstructed
|
|
28
|
+
// by the serializer's join, so strip them here for stable round trips.
|
|
29
|
+
if (attr?.type === 'raw')
|
|
30
|
+
chunk = chunk.replace(/\n+$/, '');
|
|
31
|
+
const start = text.length;
|
|
32
|
+
text += chunk;
|
|
33
|
+
if (attr) {
|
|
34
|
+
// Range includes the trailing newline once it exists — matching how
|
|
35
|
+
// native paragraph ranges read back (paragraphRange semantics).
|
|
36
|
+
blocks.push({ start, end: text.length + 1, ...attr });
|
|
37
|
+
}
|
|
38
|
+
text += '\n';
|
|
39
|
+
};
|
|
40
|
+
for (const block of ast) {
|
|
41
|
+
switch (block.type) {
|
|
42
|
+
case 'paragraph': {
|
|
43
|
+
if (!inlineRepresentable(block.children)) {
|
|
44
|
+
push(block.raw, { type: 'raw' });
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
for (const line of splitOnBreaks(block.children)) {
|
|
48
|
+
const flat = flattenInline(line, text.length);
|
|
49
|
+
spans.push(...flat.spans);
|
|
50
|
+
push(flat.text);
|
|
51
|
+
}
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case 'heading': {
|
|
55
|
+
if (!inlineRepresentable(block.children)) {
|
|
56
|
+
push(block.raw, { type: 'raw' });
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
const flat = flattenInline(block.children, text.length);
|
|
60
|
+
spans.push(...flat.spans);
|
|
61
|
+
push(flat.text, { type: 'heading', level: block.level });
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
default:
|
|
65
|
+
push(block.raw, { type: 'raw' });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Drop the final separator newline; clamp any block range that reached
|
|
69
|
+
// past it (the "+1 for trailing newline" of the last block).
|
|
70
|
+
if (text.endsWith('\n'))
|
|
71
|
+
text = text.slice(0, -1);
|
|
72
|
+
for (const b of blocks) {
|
|
73
|
+
if (b.end > text.length)
|
|
74
|
+
b.end = text.length;
|
|
75
|
+
}
|
|
76
|
+
return { text, spans, blocks, v };
|
|
77
|
+
}
|
|
78
|
+
/** Inline node types the editor can model in-field. */
|
|
79
|
+
function inlineRepresentable(nodes) {
|
|
80
|
+
for (const node of nodes) {
|
|
81
|
+
switch (node.type) {
|
|
82
|
+
case 'text':
|
|
83
|
+
case 'br':
|
|
84
|
+
case 'codeSpan':
|
|
85
|
+
case 'autolink':
|
|
86
|
+
break;
|
|
87
|
+
case 'strong':
|
|
88
|
+
case 'em':
|
|
89
|
+
case 'del':
|
|
90
|
+
if (!inlineRepresentable(node.children))
|
|
91
|
+
return false;
|
|
92
|
+
break;
|
|
93
|
+
case 'link':
|
|
94
|
+
if (!inlineRepresentable(node.children))
|
|
95
|
+
return false;
|
|
96
|
+
break;
|
|
97
|
+
default:
|
|
98
|
+
return false; // image, future extension nodes
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
/** Split a paragraph's inline children into visual lines at top-level hard breaks. */
|
|
104
|
+
function splitOnBreaks(nodes) {
|
|
105
|
+
const lines = [];
|
|
106
|
+
let current = [];
|
|
107
|
+
for (const node of nodes) {
|
|
108
|
+
if (node.type === 'br') {
|
|
109
|
+
lines.push(current);
|
|
110
|
+
current = [];
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
current.push(node);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
lines.push(current);
|
|
117
|
+
return lines;
|
|
118
|
+
}
|
|
119
|
+
/** Depth-first flatten of an inline tree into text + overlapping spans. */
|
|
120
|
+
function flattenInline(nodes, base) {
|
|
121
|
+
let text = '';
|
|
122
|
+
const spans = [];
|
|
123
|
+
const walk = (list) => {
|
|
124
|
+
for (const node of list) {
|
|
125
|
+
switch (node.type) {
|
|
126
|
+
case 'text':
|
|
127
|
+
text += node.value;
|
|
128
|
+
break;
|
|
129
|
+
case 'br':
|
|
130
|
+
// Nested hard break (inside emphasis) — degrade to a space.
|
|
131
|
+
text += ' ';
|
|
132
|
+
break;
|
|
133
|
+
case 'codeSpan': {
|
|
134
|
+
const start = base + text.length;
|
|
135
|
+
text += node.value;
|
|
136
|
+
spans.push({ start, end: base + text.length, type: 'code' });
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case 'strong': {
|
|
140
|
+
const start = base + text.length;
|
|
141
|
+
walk(node.children);
|
|
142
|
+
spans.push({ start, end: base + text.length, type: 'bold' });
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case 'em': {
|
|
146
|
+
const start = base + text.length;
|
|
147
|
+
walk(node.children);
|
|
148
|
+
spans.push({ start, end: base + text.length, type: 'italic' });
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case 'del': {
|
|
152
|
+
const start = base + text.length;
|
|
153
|
+
walk(node.children);
|
|
154
|
+
spans.push({ start, end: base + text.length, type: 'strike' });
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case 'link': {
|
|
158
|
+
const start = base + text.length;
|
|
159
|
+
walk(node.children);
|
|
160
|
+
spans.push({
|
|
161
|
+
start,
|
|
162
|
+
end: base + text.length,
|
|
163
|
+
type: 'link',
|
|
164
|
+
attrs: { href: node.href },
|
|
165
|
+
});
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case 'autolink': {
|
|
169
|
+
const start = base + text.length;
|
|
170
|
+
text += node.value;
|
|
171
|
+
spans.push({
|
|
172
|
+
start,
|
|
173
|
+
end: base + text.length,
|
|
174
|
+
type: 'link',
|
|
175
|
+
attrs: { href: node.href },
|
|
176
|
+
});
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
default:
|
|
180
|
+
break; // unreachable — filtered by inlineRepresentable
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
walk(nodes);
|
|
185
|
+
// Zero-length spans (empty emphasis) carry no information — drop them.
|
|
186
|
+
return { text, spans: spans.filter((s) => s.end > s.start) };
|
|
187
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Elementary-run computation for inline serialization.
|
|
3
|
+
*
|
|
4
|
+
* The doc model permits arbitrarily overlapping spans; markdown needs properly
|
|
5
|
+
* nested delimiters. Step one of the serializer is to slice a text range into
|
|
6
|
+
* **elementary runs** — maximal intervals over which the set of active marks
|
|
7
|
+
* is constant. The emitter (docToMd) then walks the runs with a close/reopen
|
|
8
|
+
* stack, which is guaranteed to produce valid nesting.
|
|
9
|
+
*/
|
|
10
|
+
import type { InlineSpan, InlineSpanType } from '@sigx/lynx-richtext';
|
|
11
|
+
export interface ActiveMark {
|
|
12
|
+
type: InlineSpanType;
|
|
13
|
+
/** Identity key — distinguishes links by href so adjacent different links don't merge. */
|
|
14
|
+
key: string;
|
|
15
|
+
attrs?: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
export interface Run {
|
|
18
|
+
start: number;
|
|
19
|
+
end: number;
|
|
20
|
+
/** Marks active over the whole run. */
|
|
21
|
+
active: ActiveMark[];
|
|
22
|
+
}
|
|
23
|
+
export declare function markPriority(mark: ActiveMark): number;
|
|
24
|
+
/**
|
|
25
|
+
* Slice `[start, end)` into elementary runs for the given spans.
|
|
26
|
+
*
|
|
27
|
+
* `code` is terminal: within a code span every other mark except an enclosing
|
|
28
|
+
* `link` is suppressed (markdown cannot style inside code spans, but
|
|
29
|
+
* `[`code`](href)` is valid).
|
|
30
|
+
*/
|
|
31
|
+
export declare function computeRuns(spans: InlineSpan[], start: number, end: number): Run[];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Elementary-run computation for inline serialization.
|
|
3
|
+
*
|
|
4
|
+
* The doc model permits arbitrarily overlapping spans; markdown needs properly
|
|
5
|
+
* nested delimiters. Step one of the serializer is to slice a text range into
|
|
6
|
+
* **elementary runs** — maximal intervals over which the set of active marks
|
|
7
|
+
* is constant. The emitter (docToMd) then walks the runs with a close/reopen
|
|
8
|
+
* stack, which is guaranteed to produce valid nesting.
|
|
9
|
+
*/
|
|
10
|
+
/** Outer-to-inner nesting priority (lower index opens first / sits outermost). */
|
|
11
|
+
const PRIORITY = { link: 0, code: 1, bold: 2, italic: 3, strike: 4 };
|
|
12
|
+
export function markPriority(mark) {
|
|
13
|
+
return PRIORITY[mark.type] ?? 99;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Slice `[start, end)` into elementary runs for the given spans.
|
|
17
|
+
*
|
|
18
|
+
* `code` is terminal: within a code span every other mark except an enclosing
|
|
19
|
+
* `link` is suppressed (markdown cannot style inside code spans, but
|
|
20
|
+
* `[`code`](href)` is valid).
|
|
21
|
+
*/
|
|
22
|
+
export function computeRuns(spans, start, end) {
|
|
23
|
+
if (end <= start)
|
|
24
|
+
return [];
|
|
25
|
+
const relevant = spans
|
|
26
|
+
.filter((s) => s.end > start && s.start < end && s.end > s.start)
|
|
27
|
+
.map((s) => ({
|
|
28
|
+
start: Math.max(s.start, start),
|
|
29
|
+
end: Math.min(s.end, end),
|
|
30
|
+
mark: toMark(s),
|
|
31
|
+
}));
|
|
32
|
+
const boundaries = new Set([start, end]);
|
|
33
|
+
for (const s of relevant) {
|
|
34
|
+
boundaries.add(s.start);
|
|
35
|
+
boundaries.add(s.end);
|
|
36
|
+
}
|
|
37
|
+
const points = [...boundaries].sort((a, b) => a - b);
|
|
38
|
+
const runs = [];
|
|
39
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
40
|
+
const a = points[i];
|
|
41
|
+
const b = points[i + 1];
|
|
42
|
+
if (b <= a)
|
|
43
|
+
continue;
|
|
44
|
+
let active = relevant
|
|
45
|
+
.filter((s) => s.start <= a && s.end >= b)
|
|
46
|
+
.map((s) => s.mark);
|
|
47
|
+
active = dedupe(active);
|
|
48
|
+
if (active.some((m) => m.type === 'code')) {
|
|
49
|
+
active = active.filter((m) => m.type === 'code' || m.type === 'link');
|
|
50
|
+
}
|
|
51
|
+
active.sort((x, y) => markPriority(x) - markPriority(y) || x.key.localeCompare(y.key));
|
|
52
|
+
runs.push({ start: a, end: b, active });
|
|
53
|
+
}
|
|
54
|
+
return runs;
|
|
55
|
+
}
|
|
56
|
+
function toMark(span) {
|
|
57
|
+
const key = span.type === 'link' ? `link:${span.attrs?.href ?? ''}` : span.type;
|
|
58
|
+
return { type: span.type, key, ...(span.attrs ? { attrs: span.attrs } : {}) };
|
|
59
|
+
}
|
|
60
|
+
function dedupe(marks) {
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const m of marks) {
|
|
64
|
+
if (!seen.has(m.key)) {
|
|
65
|
+
seen.add(m.key);
|
|
66
|
+
out.push(m);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import './jsx-augment.js';
|
|
2
1
|
export { MarkdownView } from './render/MarkdownView.js';
|
|
3
2
|
export type { MarkdownViewProps } from './render/MarkdownView.js';
|
|
4
3
|
export { defaultComponents } from './render/components.js';
|
|
5
4
|
export type { MarkdownComponents, MarkdownChild, RootProps, HeadingProps, ParagraphProps, BlockquoteProps, ListProps, ListItemProps, CodeProps, ThematicBreakProps, TableProps, TableRowProps, TableCellProps, StrongProps, EmProps, DelProps, CodeSpanProps, LinkProps, AutolinkProps, ImageProps, } from './render/components.js';
|
|
5
|
+
export { MarkdownEditor } from './editor/MarkdownEditor.js';
|
|
6
|
+
export type { MarkdownEditorProps, MarkdownEditorController, MarkdownEditorMode, } from './editor/MarkdownEditor.js';
|
|
7
|
+
export { mdToDoc } from './editor/convert/mdToDoc.js';
|
|
8
|
+
export { docToMd } from './editor/convert/docToMd.js';
|
|
6
9
|
export { createMarkdownStream } from './stream.js';
|
|
7
10
|
export type { MarkdownStream, CreateMarkdownStreamOptions } from './stream.js';
|
|
8
|
-
export { XMarkdown } from './XMarkdown.js';
|
|
9
|
-
export type { XMarkdownProps, XMarkdownEffect } from './XMarkdown.js';
|
|
10
11
|
export { createIncrementalEngine } from './parser/incremental.js';
|
|
11
12
|
export type { IncrementalEngine } from './parser/incremental.js';
|
|
12
13
|
export { parseBlocks } from './parser/blocks.js';
|
|
13
14
|
export { parseInline } from './parser/inline.js';
|
|
14
15
|
export type * from './ast.js';
|
|
15
|
-
export type { XMarkdownAttributes, MarkdownLinkEvent, MarkdownLinkEventDetail, MarkdownImageTapEvent, MarkdownImageTapEventDetail, MarkdownParseEndEvent, MarkdownParseEndEventDetail, } from './jsx-augment.js';
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import './jsx-augment.js';
|
|
2
1
|
// Primary: the SignalX-native streaming renderer. (An editable `MarkdownEditor`
|
|
3
2
|
// is planned as a sibling export.)
|
|
4
3
|
export { MarkdownView } from './render/MarkdownView.js';
|
|
5
4
|
// Generic render-function override API (design systems plug in here).
|
|
6
5
|
export { defaultComponents } from './render/components.js';
|
|
6
|
+
// True-WYSIWYG editor on the native <sigx-richtext> element
|
|
7
|
+
// (requires the optional @sigx/lynx-richtext peer).
|
|
8
|
+
export { MarkdownEditor } from './editor/MarkdownEditor.js';
|
|
9
|
+
export { mdToDoc } from './editor/convert/mdToDoc.js';
|
|
10
|
+
export { docToMd } from './editor/convert/docToMd.js';
|
|
7
11
|
// Streaming controller for AI token loops.
|
|
8
12
|
export { createMarkdownStream } from './stream.js';
|
|
9
|
-
// Native `<x-markdown>` wrapper, preserved for platforms that ship the element.
|
|
10
|
-
export { XMarkdown } from './XMarkdown.js';
|
|
11
13
|
// Parser primitives (for advanced consumers / testing).
|
|
12
14
|
export { createIncrementalEngine } from './parser/incremental.js';
|
|
13
15
|
export { parseBlocks } from './parser/blocks.js';
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
* `<MarkdownView>` — a SignalX-native, streaming-aware markdown renderer.
|
|
3
3
|
*
|
|
4
4
|
* Parses markdown in JS (zero dependencies) and renders to Lynx primitives, so
|
|
5
|
-
* it works identically on every platform
|
|
6
|
-
*
|
|
7
|
-
* see `MarkdownEditor`.
|
|
5
|
+
* it works identically on every platform. For an editable counterpart, see
|
|
6
|
+
* `MarkdownEditor`.
|
|
8
7
|
*
|
|
9
8
|
* Rendering is **generic**: the package ships neutral, theme-agnostic defaults
|
|
10
9
|
* and exposes a `components` map so any design system can fully control the look
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
* `<MarkdownView>` — a SignalX-native, streaming-aware markdown renderer.
|
|
3
3
|
*
|
|
4
4
|
* Parses markdown in JS (zero dependencies) and renders to Lynx primitives, so
|
|
5
|
-
* it works identically on every platform
|
|
6
|
-
*
|
|
7
|
-
* see `MarkdownEditor`.
|
|
5
|
+
* it works identically on every platform. For an editable counterpart, see
|
|
6
|
+
* `MarkdownEditor`.
|
|
8
7
|
*
|
|
9
8
|
* Rendering is **generic**: the package ships neutral, theme-agnostic defaults
|
|
10
9
|
* and exposes a `components` map so any design system can fully control the look
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sigx/lynx-markdown",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "SignalX-native streaming markdown renderer for Lynx. Parses markdown in JS (zero deps) and renders to native <view>/<text> primitives, with incremental streaming for AI output.
|
|
3
|
+
"version": "0.4.8",
|
|
4
|
+
"description": "SignalX-native streaming markdown renderer for Lynx. Parses markdown in JS (zero deps) and renders to native <view>/<text> primitives, with incremental streaming for AI output.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -19,13 +19,20 @@
|
|
|
19
19
|
"LICENSE"
|
|
20
20
|
],
|
|
21
21
|
"peerDependencies": {
|
|
22
|
-
"@sigx/lynx": "^0.4.
|
|
22
|
+
"@sigx/lynx": "^0.4.8",
|
|
23
|
+
"@sigx/lynx-richtext": "^0.4.8"
|
|
24
|
+
},
|
|
25
|
+
"peerDependenciesMeta": {
|
|
26
|
+
"@sigx/lynx-richtext": {
|
|
27
|
+
"optional": true
|
|
28
|
+
}
|
|
23
29
|
},
|
|
24
30
|
"devDependencies": {
|
|
25
31
|
"@typescript/native-preview": "7.0.0-dev.20260521.1",
|
|
26
32
|
"typescript": "^6.0.3",
|
|
27
|
-
"@sigx/lynx": "^0.4.
|
|
28
|
-
"@sigx/lynx-
|
|
33
|
+
"@sigx/lynx": "^0.4.8",
|
|
34
|
+
"@sigx/lynx-richtext": "^0.4.8",
|
|
35
|
+
"@sigx/lynx-testing": "^0.4.8"
|
|
29
36
|
},
|
|
30
37
|
"author": "Andreas Ekdahl",
|
|
31
38
|
"license": "MIT",
|
|
@@ -48,8 +55,7 @@
|
|
|
48
55
|
"markdown",
|
|
49
56
|
"native",
|
|
50
57
|
"streaming",
|
|
51
|
-
"ai"
|
|
52
|
-
"x-markdown"
|
|
58
|
+
"ai"
|
|
53
59
|
],
|
|
54
60
|
"scripts": {
|
|
55
61
|
"build": "node ../../scripts/clean.mjs dist && tsgo",
|
package/dist/XMarkdown.d.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { type Define } from '@sigx/lynx';
|
|
2
|
-
import './jsx-augment.js';
|
|
3
|
-
import type { MarkdownLinkEvent, MarkdownImageTapEvent, MarkdownParseEndEvent } from './jsx-augment.js';
|
|
4
|
-
export type XMarkdownEffect = 'typewriter' | 'none' | (string & {});
|
|
5
|
-
export type XMarkdownProps = Define.Prop<'value', string, false> & Define.Prop<'effect', XMarkdownEffect, false> & Define.Prop<'attachments', ReadonlyArray<unknown>, false> & Define.Prop<'class', string, false> & Define.Prop<'style', string | Record<string, string | number>, false> & Define.Prop<'onLink', (e: MarkdownLinkEvent) => void, false> & Define.Prop<'onImageTap', (e: MarkdownImageTapEvent) => void, false> & Define.Prop<'onParseEnd', (e: MarkdownParseEndEvent) => void, false>;
|
|
6
|
-
/**
|
|
7
|
-
* Render a markdown document using Lynx's native `<x-markdown>` XElement.
|
|
8
|
-
*
|
|
9
|
-
* This is the thin wrapper over the platform's native markdown element. It is
|
|
10
|
-
* fast where available but platform-gated (Harmony 3.7.0+, Android 3.8.0-rc.0+,
|
|
11
|
-
* iOS not yet in a tagged release) and opaque — the engine owns parsing and
|
|
12
|
-
* styling. For a cross-platform, fully-controllable, streaming-aware renderer
|
|
13
|
-
* built on Lynx `<view>`/`<text>` primitives, use {@link Markdown} instead.
|
|
14
|
-
*
|
|
15
|
-
* The markdown source is passed via the `value` prop; it is delivered to the
|
|
16
|
-
* native element as a raw-text child (per the 3.7.0 "raw-text node
|
|
17
|
-
* optimization" path). Event props use signalx's automatic
|
|
18
|
-
* `onLink`→`bindlink` mapping in `nodeOps.parseEventProp`, so handlers wire
|
|
19
|
-
* up without any per-event glue.
|
|
20
|
-
*
|
|
21
|
-
* @example
|
|
22
|
-
* ```tsx
|
|
23
|
-
* <XMarkdown
|
|
24
|
-
* value={"# Hello\n\nThis is **markdown**."}
|
|
25
|
-
* effect="typewriter"
|
|
26
|
-
* onLink={(e) => console.log('tapped', e.detail.url)}
|
|
27
|
-
* />
|
|
28
|
-
* ```
|
|
29
|
-
*
|
|
30
|
-
* @remarks
|
|
31
|
-
* Availability of the `<x-markdown>` element is platform-dependent — see
|
|
32
|
-
* `jsx-augment.ts` for the per-platform schedule. On platforms where the
|
|
33
|
-
* native element is not registered, the engine logs a warning and renders
|
|
34
|
-
* no view; there is no JS-side feature gate.
|
|
35
|
-
*/
|
|
36
|
-
export declare const XMarkdown: import("@sigx/runtime-core").ComponentFactory<XMarkdownProps, void, {}>;
|
package/dist/XMarkdown.js
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx } from "@sigx/lynx/jsx-runtime";
|
|
2
|
-
import { component } from '@sigx/lynx';
|
|
3
|
-
import './jsx-augment.js';
|
|
4
|
-
/**
|
|
5
|
-
* Render a markdown document using Lynx's native `<x-markdown>` XElement.
|
|
6
|
-
*
|
|
7
|
-
* This is the thin wrapper over the platform's native markdown element. It is
|
|
8
|
-
* fast where available but platform-gated (Harmony 3.7.0+, Android 3.8.0-rc.0+,
|
|
9
|
-
* iOS not yet in a tagged release) and opaque — the engine owns parsing and
|
|
10
|
-
* styling. For a cross-platform, fully-controllable, streaming-aware renderer
|
|
11
|
-
* built on Lynx `<view>`/`<text>` primitives, use {@link Markdown} instead.
|
|
12
|
-
*
|
|
13
|
-
* The markdown source is passed via the `value` prop; it is delivered to the
|
|
14
|
-
* native element as a raw-text child (per the 3.7.0 "raw-text node
|
|
15
|
-
* optimization" path). Event props use signalx's automatic
|
|
16
|
-
* `onLink`→`bindlink` mapping in `nodeOps.parseEventProp`, so handlers wire
|
|
17
|
-
* up without any per-event glue.
|
|
18
|
-
*
|
|
19
|
-
* @example
|
|
20
|
-
* ```tsx
|
|
21
|
-
* <XMarkdown
|
|
22
|
-
* value={"# Hello\n\nThis is **markdown**."}
|
|
23
|
-
* effect="typewriter"
|
|
24
|
-
* onLink={(e) => console.log('tapped', e.detail.url)}
|
|
25
|
-
* />
|
|
26
|
-
* ```
|
|
27
|
-
*
|
|
28
|
-
* @remarks
|
|
29
|
-
* Availability of the `<x-markdown>` element is platform-dependent — see
|
|
30
|
-
* `jsx-augment.ts` for the per-platform schedule. On platforms where the
|
|
31
|
-
* native element is not registered, the engine logs a warning and renders
|
|
32
|
-
* no view; there is no JS-side feature gate.
|
|
33
|
-
*/
|
|
34
|
-
export const XMarkdown = component(({ props }) => {
|
|
35
|
-
return () => (_jsx("x-markdown", { "markdown-effect": props.effect, "text-mark-attachments": props.attachments, class: props.class, style: props.style, bindlink: props.onLink, bindimageTap: props.onImageTap, bindparseEnd: props.onParseEnd, children: props.value ?? '' }));
|
|
36
|
-
});
|
package/dist/jsx-augment.d.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* JSX intrinsic type augmentation for Lynx's `<x-markdown>` XElement.
|
|
3
|
-
*
|
|
4
|
-
* Importing this module registers `'x-markdown'` as a valid JSX intrinsic
|
|
5
|
-
* with its 3.7.0+ attributes and events. Pulled in automatically by
|
|
6
|
-
* `@sigx/lynx-markdown`'s entry point so consumers do not need to import
|
|
7
|
-
* it directly.
|
|
8
|
-
*
|
|
9
|
-
* The native element ships per-platform on different schedules:
|
|
10
|
-
* - Harmony: available since 3.7.0
|
|
11
|
-
* - Android: available since 3.8.0-rc.0 (artifact `lynx_xelement_markdown`)
|
|
12
|
-
* - iOS: not yet in any tagged release; lands on the main branch
|
|
13
|
-
* post-3.8.0
|
|
14
|
-
*
|
|
15
|
-
* `<x-markdown>` props in JSX still type-check on every platform. At
|
|
16
|
-
* runtime the SignalX renderer issues a `__CreateElement('x-markdown')`
|
|
17
|
-
* op unconditionally; on platforms where the native element is not
|
|
18
|
-
* registered, the underlying engine handles the unknown tag (today it
|
|
19
|
-
* logs a warning and emits no view). There is no JS-side feature gate
|
|
20
|
-
* in this package — once you upgrade to a Lynx release that ships the
|
|
21
|
-
* element on your target platforms, rendering activates automatically.
|
|
22
|
-
*/
|
|
23
|
-
import type { LynxCommonAttributes, LynxEventHandler } from '@sigx/lynx-runtime';
|
|
24
|
-
/** Detail payload of `bindlink` — the engine ships `url` plus optional fields. */
|
|
25
|
-
export interface MarkdownLinkEventDetail {
|
|
26
|
-
url: string;
|
|
27
|
-
[k: string]: unknown;
|
|
28
|
-
}
|
|
29
|
-
export interface MarkdownLinkEvent {
|
|
30
|
-
type: 'link';
|
|
31
|
-
detail: MarkdownLinkEventDetail;
|
|
32
|
-
}
|
|
33
|
-
/** Detail payload of `bindimageTap`. */
|
|
34
|
-
export interface MarkdownImageTapEventDetail {
|
|
35
|
-
src: string;
|
|
36
|
-
[k: string]: unknown;
|
|
37
|
-
}
|
|
38
|
-
export interface MarkdownImageTapEvent {
|
|
39
|
-
type: 'imageTap';
|
|
40
|
-
detail: MarkdownImageTapEventDetail;
|
|
41
|
-
}
|
|
42
|
-
/** Detail payload of `bindparseEnd`. */
|
|
43
|
-
export interface MarkdownParseEndEventDetail {
|
|
44
|
-
[k: string]: unknown;
|
|
45
|
-
}
|
|
46
|
-
export interface MarkdownParseEndEvent {
|
|
47
|
-
type: 'parseEnd';
|
|
48
|
-
detail: MarkdownParseEndEventDetail;
|
|
49
|
-
}
|
|
50
|
-
export interface XMarkdownAttributes extends LynxCommonAttributes {
|
|
51
|
-
/**
|
|
52
|
-
* Raw markdown source. Lynx parses the first text child of
|
|
53
|
-
* `<x-markdown>` per the 3.7.0 raw-text-node optimization. Passing a
|
|
54
|
-
* single string here is the common path; JSX expressions resolving to
|
|
55
|
-
* a string also work.
|
|
56
|
-
*/
|
|
57
|
-
children?: any;
|
|
58
|
-
/**
|
|
59
|
-
* Render-time effect applied to the parsed markdown output.
|
|
60
|
-
* Known values: `'typewriter'`, `'none'`. The engine treats unknown
|
|
61
|
-
* strings as `'none'`.
|
|
62
|
-
*/
|
|
63
|
-
'markdown-effect'?: string;
|
|
64
|
-
/**
|
|
65
|
-
* Inline view attachments referenced by markdown text marks. Shape is
|
|
66
|
-
* engine-defined; passed through as-is.
|
|
67
|
-
*/
|
|
68
|
-
'text-mark-attachments'?: ReadonlyArray<unknown>;
|
|
69
|
-
/** Fires when the user taps an `[anchor](url)` link. */
|
|
70
|
-
bindlink?: LynxEventHandler<MarkdownLinkEvent>;
|
|
71
|
-
/** Fires when the user taps an inline image. */
|
|
72
|
-
bindimageTap?: LynxEventHandler<MarkdownImageTapEvent>;
|
|
73
|
-
/** Fires once the engine finishes parsing the source. */
|
|
74
|
-
bindparseEnd?: LynxEventHandler<MarkdownParseEndEvent>;
|
|
75
|
-
}
|
|
76
|
-
declare global {
|
|
77
|
-
namespace JSX {
|
|
78
|
-
interface IntrinsicElements {
|
|
79
|
-
'x-markdown': XMarkdownAttributes;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
export {};
|
package/dist/jsx-augment.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|