@sigx/lynx-markdown 0.4.8 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +18 -0
  2. package/dist/ast.d.ts +18 -1
  3. package/dist/editor/MarkdownEditor.d.ts +26 -1
  4. package/dist/editor/MarkdownEditor.js +134 -18
  5. package/dist/editor/convert/docToMd.d.ts +12 -2
  6. package/dist/editor/convert/docToMd.js +27 -4
  7. package/dist/editor/convert/mdToDoc.d.ts +15 -2
  8. package/dist/editor/convert/mdToDoc.js +45 -11
  9. package/dist/editor/plugin.d.ts +118 -0
  10. package/dist/editor/plugin.js +16 -0
  11. package/dist/editor/toolbar/Toolbar.d.ts +25 -0
  12. package/dist/editor/toolbar/Toolbar.js +51 -0
  13. package/dist/editor/toolbar/items.d.ts +35 -0
  14. package/dist/editor/toolbar/items.js +29 -0
  15. package/dist/editor/trigger/SuggestionPopup.d.ts +28 -0
  16. package/dist/editor/trigger/SuggestionPopup.js +77 -0
  17. package/dist/editor/trigger/position.d.ts +47 -0
  18. package/dist/editor/trigger/position.js +62 -0
  19. package/dist/editor/trigger/session.d.ts +49 -0
  20. package/dist/editor/trigger/session.js +162 -0
  21. package/dist/index.d.ts +15 -2
  22. package/dist/index.js +4 -0
  23. package/dist/parser/blocks.d.ts +2 -1
  24. package/dist/parser/blocks.js +13 -13
  25. package/dist/parser/extensions.d.ts +51 -0
  26. package/dist/parser/extensions.js +18 -0
  27. package/dist/parser/incremental.d.ts +10 -1
  28. package/dist/parser/incremental.js +5 -2
  29. package/dist/parser/inline.d.ts +15 -5
  30. package/dist/parser/inline.js +55 -9
  31. package/dist/render/MarkdownView.d.ts +8 -1
  32. package/dist/render/MarkdownView.js +11 -2
  33. package/dist/render/components.d.ts +13 -1
  34. package/dist/render/engine.js +11 -0
  35. package/package.json +11 -6
package/README.md CHANGED
@@ -147,6 +147,24 @@ ctrl?.setHeading(2);
147
147
  ctrl?.clear(); // chat send
148
148
  ```
149
149
 
150
+ ### Toolbar
151
+
152
+ The built-in toolbar mirrors the renderer's override pattern — items are data,
153
+ rendering is replaceable:
154
+
155
+ ```tsx
156
+ <MarkdownEditor toolbar /> // neutral default, below the input
157
+ <MarkdownEditor toolbar="top" /> // above instead
158
+ <MarkdownEditor toolbar toolbarItems={items} /> // custom ToolbarItem[]
159
+ <MarkdownEditor toolbar renderToolbarItem={fn} />// re-skin (what daisyUI does)
160
+ ```
161
+
162
+ `<EditorToolbar>` is also exported standalone (pass `controller` + `selection`
163
+ yourself — e.g. inside a keyboard-sticky send bar). `ToolbarItem` is
164
+ `{ id, label, icon?, group?, isActive?(sel), run(ctx) }`; plugins contribute
165
+ items through the same shape (P3). The toolbar root carries `ignore-focus` so
166
+ taps never blur the editor. The daisyUI skin lives in `@sigx/lynx-daisyui`.
167
+
150
168
  v1 models paragraphs, headings, and bold/italic/strike/code/link in-field;
151
169
  everything else (lists, tables, code fences) round-trips **losslessly** as raw
152
170
  markdown source via `raw` blocks until later phases model them. Conversion
package/dist/ast.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * reconciliation) and `raw` (the exact source slice, used for memo equality and
9
9
  * introspection).
10
10
  */
11
- export type InlineNode = InlineText | InlineStrong | InlineEm | InlineDel | InlineCodeSpan | InlineLink | InlineImage | InlineAutolink | InlineBreak;
11
+ export type InlineNode = InlineText | InlineStrong | InlineEm | InlineDel | InlineCodeSpan | InlineLink | InlineImage | InlineAutolink | InlineBreak | InlineExtension;
12
12
  /** A run of literal text. */
13
13
  export interface InlineText {
14
14
  type: 'text';
@@ -58,6 +58,23 @@ export interface InlineAutolink {
58
58
  export interface InlineBreak {
59
59
  type: 'br';
60
60
  }
61
+ /**
62
+ * A plugin-defined inline construct (e.g. a mention `@[label](id)`), produced
63
+ * by a `ParserInlineExtension`'s `match`. Opaque to the emphasis stack — same
64
+ * precedence tier as code spans and links. Rendered through
65
+ * `components.extension[name]`, falling back to `raw` as literal text.
66
+ */
67
+ export interface InlineExtension {
68
+ type: 'extension';
69
+ /** Extension name; the render dispatch key. */
70
+ name: string;
71
+ /** Opaque structured payload (e.g. `{ id, label }` for a mention). */
72
+ attrs: Record<string, string>;
73
+ /** Optional nested inline content; absent for leaf extensions. */
74
+ children?: InlineNode[];
75
+ /** Exact source slice — the literal-text fallback and round-trip source. */
76
+ raw: string;
77
+ }
61
78
  export interface BlockBase {
62
79
  /** Stable reconciliation key, assigned by the incremental engine. */
63
80
  key: string;
@@ -23,6 +23,9 @@
23
23
  */
24
24
  import { type Define } from '@sigx/lynx';
25
25
  import { type SelectionState } from '@sigx/lynx-richtext';
26
+ import { type ToolbarRenderItem } from './toolbar/Toolbar.js';
27
+ import { type ToolbarItem } from './toolbar/items.js';
28
+ import type { MarkdownEditorPlugin } from './plugin.js';
26
29
  export type MarkdownEditorMode = 'auto' | 'fixed' | 'fullscreen';
27
30
  /** Imperative command surface — what toolbars and plugins drive. */
28
31
  export interface MarkdownEditorController {
@@ -33,6 +36,12 @@ export interface MarkdownEditorController {
33
36
  /** 1–6 sets a heading; 0 reverts to paragraph. */
34
37
  setHeading(level: 0 | 1 | 2 | 3 | 4 | 5 | 6): void;
35
38
  insertText(text: string): void;
39
+ /**
40
+ * Replace `[start, end)` (UTF-16 offsets in the document text) with
41
+ * `text`, leaving the caret after it. What trigger plugins use to swap
42
+ * the typed query for the selected suggestion.
43
+ */
44
+ replaceRange(start: number, end: number, text: string): void;
36
45
  /** Clear the document (chat send). */
37
46
  clear(): void;
38
47
  focus(): void;
@@ -42,7 +51,23 @@ export interface MarkdownEditorController {
42
51
  /** The current selection state (as of the last selection event). */
43
52
  getSelection(): SelectionState | null;
44
53
  }
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>
54
+ 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>
55
+ /**
56
+ * Built-in formatting toolbar. `true` ≡ `'bottom'` — below the input is
57
+ * the common chat placement (selection handles and the iOS edit menu pop
58
+ * up *above* the selection, so a toolbar on top would sit under them).
59
+ */
60
+ & Define.Prop<'toolbar', boolean | 'top' | 'bottom', false>
61
+ /** Override the built-in toolbar's items (defaults to `defaultToolbarItems`). */
62
+ & Define.Prop<'toolbarItems', ToolbarItem[], false>
63
+ /** Re-skin the built-in toolbar's item rendering (what daisyUI does). */
64
+ & Define.Prop<'renderToolbarItem', ToolbarRenderItem, false>
65
+ /**
66
+ * Editor plugins ({@link MarkdownEditorPlugin}) — inline syntax, trigger
67
+ * suggestions, extra toolbar items. Pass a stable array (e.g. a module
68
+ * constant); the set is captured at mount.
69
+ */
70
+ & Define.Prop<'plugins', MarkdownEditorPlugin[], 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
71
  /** Receives the imperative controller once on mount. */
47
72
  & Define.Prop<'controllerRef', (ctrl: MarkdownEditorController) => void, false>;
48
73
  export declare const MarkdownEditor: import("@sigx/runtime-core").ComponentFactory<MarkdownEditorProps, void, {}>;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx } from "@sigx/lynx/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@sigx/lynx/jsx-runtime";
2
2
  /**
3
3
  * `<MarkdownEditor>` — true-WYSIWYG markdown editing on the native
4
4
  * `<sigx-richtext>` element.
@@ -22,27 +22,89 @@ import { jsx as _jsx } from "@sigx/lynx/jsx-runtime";
22
22
  * `mode="fixed"` pins the height at `maxLines`; `mode="fullscreen"` fills the
23
23
  * parent.
24
24
  */
25
- import { component, signal, watch } from '@sigx/lynx';
25
+ import { component, signal, useElementLayout, watch } from '@sigx/lynx';
26
26
  import { RichTextInput, RichTextMethods, docEquals, normalizeDoc, emptyDoc, } from '@sigx/lynx-richtext';
27
27
  import { mdToDoc } from './convert/mdToDoc.js';
28
28
  import { docToMd } from './convert/docToMd.js';
29
+ import { EditorToolbar } from './toolbar/Toolbar.js';
30
+ import { defaultToolbarItems } from './toolbar/items.js';
31
+ import { createTriggerSessionManager } from './trigger/session.js';
32
+ import { SuggestionPopup } from './trigger/SuggestionPopup.js';
29
33
  const DEFAULT_FONT_SIZE = 16;
30
34
  /** Vertical padding the element applies internally (8 top + 8 bottom). */
31
35
  const ELEMENT_PADDING = 16;
32
36
  export const MarkdownEditor = component(({ props }) => {
33
37
  let el = null;
38
+ // --- plugins (captured at mount; pass a stable array) ---
39
+ const plugins = props.plugins ?? [];
40
+ const inlinePlugins = plugins.filter((p) => p.inline);
41
+ // Duplicate identifiers would silently last-win (conversion maps) or make
42
+ // trigger routing ambiguous (plugin name lookups) — flag the config error.
43
+ const warnDuplicates = (key, values) => {
44
+ const seen = new Set();
45
+ for (const value of values) {
46
+ if (seen.has(value)) {
47
+ // Conversion maps resolve last-wins, trigger routing first-wins
48
+ // — don't promise either; duplicates are a config error.
49
+ console.warn(`[MarkdownEditor] duplicate plugin ${key} "${value}" — resolution is ambiguous, rename to disambiguate.`);
50
+ }
51
+ seen.add(value);
52
+ }
53
+ };
54
+ warnDuplicates('name', plugins.map((p) => p.name));
55
+ warnDuplicates('syntax.name', inlinePlugins.map((p) => p.inline.syntax.name));
56
+ warnDuplicates('docMapping.spanType', inlinePlugins.map((p) => p.inline.docMapping.spanType));
57
+ // Trigger routing is first-match-wins — a duplicate char/pattern means the
58
+ // later plugin's trigger is silently unreachable.
59
+ warnDuplicates('trigger', plugins
60
+ .filter((p) => p.trigger)
61
+ .map((p) => (p.trigger.char !== undefined ? `char:${p.trigger.char}` : `pattern:${p.trigger.pattern}`)));
62
+ const convertIn = inlinePlugins.length
63
+ ? {
64
+ extensions: inlinePlugins.map((p) => p.inline.syntax),
65
+ spanMappers: Object.fromEntries(inlinePlugins.map((p) => [p.inline.syntax.name, p.inline.docMapping.toSpan])),
66
+ }
67
+ : undefined;
68
+ const convertOut = inlinePlugins.length
69
+ ? {
70
+ serializers: new Map(inlinePlugins.map((p) => [
71
+ p.inline.docMapping.spanType,
72
+ (span, text) => p.inline.serialize(span, text),
73
+ ])),
74
+ }
75
+ : undefined;
76
+ const pluginToolbarItems = plugins.flatMap((p) => p.toolbar ?? []);
34
77
  // --- sync state (see module docs) ---
35
78
  const initialMd = typeof props.value === 'string' ? props.value : '';
36
79
  let lastEmittedMd = initialMd;
37
- let lastDocFromElement = normalizeDoc(mdToDoc(initialMd));
80
+ let lastDocFromElement = normalizeDoc(mdToDoc(initialMd, 0, convertIn));
38
81
  let lastSeenVersion = 0;
39
82
  let composing = false;
40
83
  let pendingExternal = null;
41
- let currentSel = null;
84
+ // Reactive box (not a plain var): the built-in toolbar derives active
85
+ // states from it, so selection events must re-render.
86
+ const selBox = signal({ current: null });
42
87
  // Auto-grow: the native element reports its (clamped) content height and
43
88
  // the editor feeds it back as the element's layout height — Lynx layout
44
89
  // sizes views from styles, never from native intrinsic content.
45
90
  const reportedHeight = signal(0);
91
+ // --- trigger sessions (suggestion popup) ---
92
+ const triggers = plugins
93
+ .filter((p) => p.trigger)
94
+ .map((p) => ({ plugin: p.name, spec: p.trigger }));
95
+ // Boxed like selBox: signal values must be objects.
96
+ const sessionBox = signal({ current: null });
97
+ const triggerManager = triggers.length
98
+ ? createTriggerSessionManager({
99
+ triggers,
100
+ onUpdate: (s) => {
101
+ sessionBox.current = s;
102
+ },
103
+ })
104
+ : null;
105
+ // Page-absolute frame of the input's relative wrapper — the popup needs
106
+ // it to relate the element-local caret rect to the keyboard.
107
+ const { layout: inputFrame, onLayoutChange: onInputLayout } = useElementLayout();
46
108
  const applyExternal = (md) => {
47
109
  if (md === lastEmittedMd)
48
110
  return; // our own echo
@@ -50,7 +112,7 @@ export const MarkdownEditor = component(({ props }) => {
50
112
  pendingExternal = md;
51
113
  return;
52
114
  }
53
- const doc = mdToDoc(md, lastSeenVersion);
115
+ const doc = mdToDoc(md, lastSeenVersion, convertIn);
54
116
  if (docEquals(normalizeDoc(doc), lastDocFromElement)) {
55
117
  lastEmittedMd = md; // same content, different markdown spelling
56
118
  return;
@@ -65,9 +127,10 @@ export const MarkdownEditor = component(({ props }) => {
65
127
  composing = isComposing;
66
128
  lastSeenVersion = doc.v;
67
129
  lastDocFromElement = normalizeDoc(doc);
130
+ triggerManager?.syncText(doc.text);
68
131
  if (isComposing)
69
132
  return;
70
- const md = docToMd(doc);
133
+ const md = docToMd(doc, convertOut);
71
134
  if (md !== lastEmittedMd) {
72
135
  lastEmittedMd = md;
73
136
  props.onChange?.(md);
@@ -90,13 +153,35 @@ export const MarkdownEditor = component(({ props }) => {
90
153
  RichTextMethods.setBlockType(el, 'heading', level);
91
154
  },
92
155
  insertText: (text) => RichTextMethods.insertText(el, text),
156
+ replaceRange: (start, end, text) => {
157
+ // insertText replaces the selection — two existing fire-and-forget
158
+ // commands compose into a range replace (no new native method).
159
+ RichTextMethods.setSelectionRange(el, start, end);
160
+ RichTextMethods.insertText(el, text);
161
+ },
93
162
  clear: () => RichTextMethods.setDocument(el, emptyDoc(lastSeenVersion)),
94
163
  focus: () => RichTextMethods.focus(el),
95
164
  blur: () => RichTextMethods.blur(el),
96
165
  getMarkdown: () => lastEmittedMd ?? '',
97
- getSelection: () => currentSel,
166
+ getSelection: () => selBox.current,
98
167
  };
99
168
  props.controllerRef?.(controller);
169
+ const handleTriggerSelect = (item) => {
170
+ const session = triggerManager?.session;
171
+ if (!session)
172
+ return;
173
+ const spec = plugins.find((p) => p.name === session.plugin)?.trigger;
174
+ if (!spec)
175
+ return;
176
+ const range = { start: session.anchor, end: session.caret };
177
+ const api = {
178
+ replaceQuery: (text) => controller.replaceRange(range.start, range.end, text),
179
+ range,
180
+ controller,
181
+ };
182
+ triggerManager.close();
183
+ spec.onSelect(item, api);
184
+ };
100
185
  return () => {
101
186
  const fontSize = props.fontSize ?? DEFAULT_FONT_SIZE;
102
187
  const lineHeight = Math.round(fontSize * 1.5);
@@ -109,19 +194,50 @@ export const MarkdownEditor = component(({ props }) => {
109
194
  minHeight = maxHeight;
110
195
  if (mode === 'fullscreen')
111
196
  maxHeight = 0; // unbounded; element fills parent
112
- return (_jsx("view", { class: props.class, style: {
197
+ const toolbarPlacement = props.toolbar === true ? 'bottom' : props.toolbar;
198
+ // Plugin items append after the base set (explicit `toolbarItems` wins
199
+ // as the base, otherwise the defaults).
200
+ const toolbarItems = pluginToolbarItems.length
201
+ ? [...(props.toolbarItems ?? defaultToolbarItems), ...pluginToolbarItems]
202
+ : props.toolbarItems;
203
+ const toolbarNode = toolbarPlacement
204
+ ? (_jsx(EditorToolbar, { controller: controller, selection: selBox.current, items: toolbarItems, renderItem: props.renderToolbarItem }))
205
+ : null;
206
+ const session = sessionBox.current;
207
+ const activeTrigger = session
208
+ ? plugins.find((p) => p.name === session.plugin)?.trigger
209
+ : undefined;
210
+ // Gate on the wrapper frame being measured — before the first
211
+ // bindlayoutchange the placement math would clamp against a 0-height
212
+ // container and misposition the popup.
213
+ const popupNode = session && activeTrigger && session.items.length > 0 && inputFrame.value
214
+ ? (_jsx(SuggestionPopup, { items: session.items, caretRect: selBox.current?.caretRect ?? null, containerFrame: inputFrame.value, renderItem: activeTrigger.renderItem, onSelect: handleTriggerSelect }))
215
+ : null;
216
+ const inputNode = (_jsx(RichTextInput, { value: mdToDoc(initialMd, 0, convertIn), placeholder: props.placeholder, editable: props.disabled !== true, minHeight: minHeight, maxHeight: maxHeight, fontSize: fontSize, textColor: props.textColor, accentColor: props.accentColor, placeholderColor: props.placeholderColor, confirmType: props.confirmType, autoFocus: props.autoFocus, style: mode === 'fullscreen'
217
+ ? { flexGrow: 1 }
218
+ : { height: Math.max(minHeight, Math.min(reportedHeight.value || minHeight, maxHeight)) }, onElement: (handle) => {
219
+ el = handle;
220
+ }, onHeightChange: (height) => {
221
+ reportedHeight.value = height;
222
+ }, onChange: handleChange, onSelection: (sel) => {
223
+ selBox.current = sel;
224
+ triggerManager?.syncCaret(sel.start === sel.end ? sel.start : -1);
225
+ props.onSelectionChange?.(sel);
226
+ }, onFocus: () => props.onFocus?.(), onBlur: () => {
227
+ triggerManager?.close();
228
+ props.onBlur?.();
229
+ } }));
230
+ return (_jsxs("view", { class: props.class, style: {
113
231
  display: 'flex',
114
232
  flexDirection: 'column',
115
233
  ...(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?.() }) }));
234
+ }, children: [toolbarPlacement === 'top' ? toolbarNode : null, triggers.length
235
+ ? (_jsxs("view", { bindlayoutchange: onInputLayout, style: {
236
+ position: 'relative',
237
+ ...(mode === 'fullscreen'
238
+ ? { display: 'flex', flexDirection: 'column', flexGrow: 1 }
239
+ : {}),
240
+ }, children: [inputNode, popupNode] }))
241
+ : inputNode, toolbarPlacement === 'bottom' ? toolbarNode : null] }));
126
242
  };
127
243
  });
@@ -10,5 +10,15 @@
10
10
  * *normalized* `doc` — emphasis markers, hard breaks (→ paragraph breaks), and
11
11
  * blank lines normalize; `raw` blocks round-trip byte-for-byte.
12
12
  */
13
- import type { RichDoc } from '@sigx/lynx-richtext';
14
- export declare function docToMd(doc: RichDoc): string;
13
+ import type { InlineSpan, RichDoc } from '@sigx/lynx-richtext';
14
+ /** Serialize one plugin-owned span back to markdown (a plugin's `inline.serialize`). */
15
+ export type SpanSerializer = (span: InlineSpan, text: string) => string;
16
+ export interface DocToMdOptions {
17
+ /**
18
+ * Plugin serializers keyed by the span type they own
19
+ * (`docMapping.spanType`). A plugin-owned span is emitted **atomically**:
20
+ * the serializer's output replaces the covered text entirely.
21
+ */
22
+ serializers?: ReadonlyMap<string, SpanSerializer>;
23
+ }
24
+ export declare function docToMd(doc: RichDoc, options?: DocToMdOptions): string;
@@ -11,14 +11,18 @@
11
11
  * blank lines normalize; `raw` blocks round-trip byte-for-byte.
12
12
  */
13
13
  import { computeRuns, markPriority } from './overlap.js';
14
- export function docToMd(doc) {
14
+ export function docToMd(doc, options) {
15
15
  const parts = [];
16
+ const serializers = options?.serializers;
17
+ // Plugin-owned spans, prefiltered once for the whole doc — the per-run
18
+ // lookup searches only these (typically zero or a handful).
19
+ const pluginSpans = serializers ? doc.spans.filter((s) => serializers.has(s.type)) : [];
16
20
  for (const seg of segmentize(doc)) {
17
21
  if (seg.type === 'raw') {
18
22
  parts.push(doc.text.slice(seg.start, seg.end));
19
23
  continue;
20
24
  }
21
- const inline = serializeInline(doc, seg.start, seg.end, seg.type === 'paragraph');
25
+ const inline = serializeInline(doc, seg.start, seg.end, seg.type === 'paragraph', serializers, pluginSpans);
22
26
  if (seg.type === 'heading') {
23
27
  const level = Math.min(6, Math.max(1, seg.level ?? 1));
24
28
  parts.push(`${'#'.repeat(level)} ${inline}`);
@@ -84,7 +88,7 @@ function segmentize(doc) {
84
88
  return segments;
85
89
  }
86
90
  /** Serialize one segment's text + spans into markdown inline syntax. */
87
- function serializeInline(doc, start, end, escapeLineStart) {
91
+ function serializeInline(doc, start, end, escapeLineStart, serializers, pluginSpans = []) {
88
92
  const runs = computeRuns(doc.spans, start, end);
89
93
  if (runs.length === 0) {
90
94
  return escapeText(doc.text.slice(start, end), escapeLineStart);
@@ -107,7 +111,16 @@ function serializeInline(doc, start, end, escapeLineStart) {
107
111
  let first = true;
108
112
  for (let i = 0; i < runs.length; i++) {
109
113
  const run = runs[i];
110
- const desired = [...run.active].sort((a, b) => {
114
+ // A plugin-owned mark serializes atomically: its serializer output
115
+ // replaces the covered text, and the mark never enters the
116
+ // close/reopen stack (other marks may still wrap it). Atomic emission
117
+ // only applies when exactly ONE plugin mark covers the run —
118
+ // overlapping plugin spans are ambiguous, so the run degrades to its
119
+ // plain text (content preserved, no plugin syntax emitted).
120
+ const pluginMarks = serializers ? run.active.filter((m) => serializers.has(m.type)) : [];
121
+ const pluginMark = pluginMarks.length === 1 ? pluginMarks[0] : undefined;
122
+ const active = pluginMarks.length ? run.active.filter((m) => !serializers.has(m.type)) : run.active;
123
+ const desired = [...active].sort((a, b) => {
111
124
  const ea = contEnd[i].get(a.key) ?? run.end;
112
125
  const eb = contEnd[i].get(b.key) ?? run.end;
113
126
  return eb - ea || markPriority(a) - markPriority(b);
@@ -123,6 +136,16 @@ function serializeInline(doc, start, end, escapeLineStart) {
123
136
  out += delimiter(desired[j], doc, 'open');
124
137
  open.push(desired[j]);
125
138
  }
139
+ if (pluginMark) {
140
+ // Emit the serialized form once — on the span's first run within
141
+ // this segment — and suppress the covered text everywhere.
142
+ const span = pluginSpans.find((s) => s.type === pluginMark.type && s.start <= run.start && s.end >= run.end);
143
+ if (span && run.start === Math.max(span.start, start)) {
144
+ out += serializers.get(pluginMark.type)(span, doc.text.slice(span.start, span.end));
145
+ }
146
+ first = false;
147
+ continue;
148
+ }
126
149
  const slice = doc.text.slice(run.start, run.end);
127
150
  out += open.some((m) => m.type === 'code')
128
151
  ? slice
@@ -15,5 +15,18 @@
15
15
  * serializes doc lines back as blank-line-separated paragraphs (hard breaks
16
16
  * normalize to paragraph breaks — documented).
17
17
  */
18
- import type { RichDoc } from '@sigx/lynx-richtext';
19
- export declare function mdToDoc(markdown: string, v?: number): RichDoc;
18
+ import type { InlineSpan, RichDoc } from '@sigx/lynx-richtext';
19
+ import type { ParserInlineExtension } from '../../parser/extensions.js';
20
+ import type { InlineExtension } from '../../ast.js';
21
+ /** AST extension node → editor span (a plugin's `docMapping.toSpan`). Must be pure. */
22
+ export type ExtensionSpanMapper = (node: InlineExtension) => {
23
+ text: string;
24
+ span: Omit<InlineSpan, 'start' | 'end'>;
25
+ } | null;
26
+ export interface MdToDocOptions {
27
+ /** Inline extensions to parse with (plugin `inline.syntax`). */
28
+ extensions?: readonly ParserInlineExtension[];
29
+ /** Span mappers keyed by extension name (plugin `docMapping.toSpan`). */
30
+ spanMappers?: Record<string, ExtensionSpanMapper>;
31
+ }
32
+ export declare function mdToDoc(markdown: string, v?: number, options?: MdToDocOptions): RichDoc;
@@ -16,8 +16,9 @@
16
16
  * normalize to paragraph breaks — documented).
17
17
  */
18
18
  import { parseBlocks } from '../../parser/blocks.js';
19
- export function mdToDoc(markdown, v = 0) {
20
- const ast = parseBlocks(markdown ?? '');
19
+ export function mdToDoc(markdown, v = 0, options) {
20
+ const ast = parseBlocks(markdown ?? '', undefined, options?.extensions);
21
+ const mappers = options?.spanMappers;
21
22
  let text = '';
22
23
  const spans = [];
23
24
  const blocks = [];
@@ -40,23 +41,23 @@ export function mdToDoc(markdown, v = 0) {
40
41
  for (const block of ast) {
41
42
  switch (block.type) {
42
43
  case 'paragraph': {
43
- if (!inlineRepresentable(block.children)) {
44
+ if (!inlineRepresentable(block.children, mappers)) {
44
45
  push(block.raw, { type: 'raw' });
45
46
  break;
46
47
  }
47
48
  for (const line of splitOnBreaks(block.children)) {
48
- const flat = flattenInline(line, text.length);
49
+ const flat = flattenInline(line, text.length, mappers);
49
50
  spans.push(...flat.spans);
50
51
  push(flat.text);
51
52
  }
52
53
  break;
53
54
  }
54
55
  case 'heading': {
55
- if (!inlineRepresentable(block.children)) {
56
+ if (!inlineRepresentable(block.children, mappers)) {
56
57
  push(block.raw, { type: 'raw' });
57
58
  break;
58
59
  }
59
- const flat = flattenInline(block.children, text.length);
60
+ const flat = flattenInline(block.children, text.length, mappers);
60
61
  spans.push(...flat.spans);
61
62
  push(flat.text, { type: 'heading', level: block.level });
62
63
  break;
@@ -76,7 +77,7 @@ export function mdToDoc(markdown, v = 0) {
76
77
  return { text, spans, blocks, v };
77
78
  }
78
79
  /** Inline node types the editor can model in-field. */
79
- function inlineRepresentable(nodes) {
80
+ function inlineRepresentable(nodes, mappers) {
80
81
  for (const node of nodes) {
81
82
  switch (node.type) {
82
83
  case 'text':
@@ -87,19 +88,36 @@ function inlineRepresentable(nodes) {
87
88
  case 'strong':
88
89
  case 'em':
89
90
  case 'del':
90
- if (!inlineRepresentable(node.children))
91
+ if (!inlineRepresentable(node.children, mappers))
91
92
  return false;
92
93
  break;
93
94
  case 'link':
94
- if (!inlineRepresentable(node.children))
95
+ if (!inlineRepresentable(node.children, mappers))
96
+ return false;
97
+ break;
98
+ case 'extension':
99
+ // Representable only when a plugin maps it to an editor span
100
+ // (toSpan is pure, so probing here and mapping later agree).
101
+ // A throwing mapper means "not representable" — the block
102
+ // degrades to raw instead of crashing the conversion.
103
+ if (!tryMap(mappers, node))
95
104
  return false;
96
105
  break;
97
106
  default:
98
- return false; // image, future extension nodes
107
+ return false; // image, unmapped extension nodes
99
108
  }
100
109
  }
101
110
  return true;
102
111
  }
112
+ /** Run a plugin mapper defensively: a throwing mapper counts as no mapping. */
113
+ function tryMap(mappers, node) {
114
+ try {
115
+ return mappers?.[node.name]?.(node) ?? null;
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
103
121
  /** Split a paragraph's inline children into visual lines at top-level hard breaks. */
104
122
  function splitOnBreaks(nodes) {
105
123
  const lines = [];
@@ -117,7 +135,7 @@ function splitOnBreaks(nodes) {
117
135
  return lines;
118
136
  }
119
137
  /** Depth-first flatten of an inline tree into text + overlapping spans. */
120
- function flattenInline(nodes, base) {
138
+ function flattenInline(nodes, base, mappers) {
121
139
  let text = '';
122
140
  const spans = [];
123
141
  const walk = (list) => {
@@ -176,6 +194,22 @@ function flattenInline(nodes, base) {
176
194
  });
177
195
  break;
178
196
  }
197
+ case 'extension': {
198
+ const mapped = tryMap(mappers, node);
199
+ if (!mapped) {
200
+ // Defense in depth: if the mapper disagrees with the
201
+ // earlier representability probe (impure/buggy), keep
202
+ // the source text rather than silently dropping it.
203
+ text += node.raw;
204
+ break;
205
+ }
206
+ const start = base + text.length;
207
+ text += mapped.text;
208
+ // Plugin fields first — the converter always controls the
209
+ // final range, even if a mapper sneaks in start/end.
210
+ spans.push({ ...mapped.span, start, end: base + text.length });
211
+ break;
212
+ }
179
213
  default:
180
214
  break; // unreachable — filtered by inlineRepresentable
181
215
  }