@sigx/lynx-markdown 0.4.7 → 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.
- package/README.md +47 -17
- package/dist/ast.d.ts +18 -1
- package/dist/editor/MarkdownEditor.d.ts +73 -0
- package/dist/editor/MarkdownEditor.js +243 -0
- package/dist/editor/convert/docToMd.d.ts +24 -0
- package/dist/editor/convert/docToMd.js +224 -0
- package/dist/editor/convert/mdToDoc.d.ts +32 -0
- package/dist/editor/convert/mdToDoc.js +221 -0
- package/dist/editor/convert/overlap.d.ts +31 -0
- package/dist/editor/convert/overlap.js +70 -0
- package/dist/editor/plugin.d.ts +118 -0
- package/dist/editor/plugin.js +16 -0
- package/dist/editor/toolbar/Toolbar.d.ts +25 -0
- package/dist/editor/toolbar/Toolbar.js +51 -0
- package/dist/editor/toolbar/items.d.ts +35 -0
- package/dist/editor/toolbar/items.js +29 -0
- package/dist/editor/trigger/SuggestionPopup.d.ts +28 -0
- package/dist/editor/trigger/SuggestionPopup.js +77 -0
- package/dist/editor/trigger/position.d.ts +47 -0
- package/dist/editor/trigger/position.js +62 -0
- package/dist/editor/trigger/session.d.ts +49 -0
- package/dist/editor/trigger/session.js +162 -0
- package/dist/index.d.ts +19 -6
- package/dist/index.js +9 -3
- package/dist/parser/blocks.d.ts +2 -1
- package/dist/parser/blocks.js +13 -13
- package/dist/parser/extensions.d.ts +51 -0
- package/dist/parser/extensions.js +18 -0
- package/dist/parser/incremental.d.ts +10 -1
- package/dist/parser/incremental.js +5 -2
- package/dist/parser/inline.d.ts +15 -5
- package/dist/parser/inline.js +55 -9
- package/dist/render/MarkdownView.d.ts +10 -4
- package/dist/render/MarkdownView.js +13 -5
- package/dist/render/components.d.ts +13 -1
- package/dist/render/engine.js +11 -0
- package/package.json +18 -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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trigger sessions — the state machine behind the suggestion popup.
|
|
3
|
+
*
|
|
4
|
+
* The element exposes no keystrokes, so the session is a pure function of the
|
|
5
|
+
* editor's two event streams: document text (`bindchange`) and the collapsed
|
|
6
|
+
* caret offset (`bindselection`). On every sync we look at the **run** — the
|
|
7
|
+
* non-whitespace text between the last boundary and the caret. A run starting
|
|
8
|
+
* with a trigger (`@` / a pattern match) means an active session whose query
|
|
9
|
+
* is the rest of the run; anything else means no session. Whitespace, caret
|
|
10
|
+
* exits, blur and selection all close it for free, because they all change
|
|
11
|
+
* the run.
|
|
12
|
+
*
|
|
13
|
+
* `onQuery` may be async: results are tagged with an epoch and discarded when
|
|
14
|
+
* a newer query (or a close) supersedes them. An optional per-trigger
|
|
15
|
+
* `debounce` batches fast typing.
|
|
16
|
+
*/
|
|
17
|
+
/** Trigger-prefix length when `run` starts with this trigger, else `-1`. */
|
|
18
|
+
function matchTrigger(spec, run) {
|
|
19
|
+
if (spec.char !== undefined) {
|
|
20
|
+
return run.startsWith(spec.char) ? spec.char.length : -1;
|
|
21
|
+
}
|
|
22
|
+
if (spec.pattern) {
|
|
23
|
+
// Reset stateful lastIndex (g/y flags) so matching is deterministic.
|
|
24
|
+
spec.pattern.lastIndex = 0;
|
|
25
|
+
const m = spec.pattern.exec(run);
|
|
26
|
+
return m && m.index === 0 ? m[0].length : -1;
|
|
27
|
+
}
|
|
28
|
+
return -1;
|
|
29
|
+
}
|
|
30
|
+
export function createTriggerSessionManager(opts) {
|
|
31
|
+
let text = '';
|
|
32
|
+
let caret = -1;
|
|
33
|
+
let session = null;
|
|
34
|
+
/** Bumped on every query change/close; stale async results check it. */
|
|
35
|
+
let epoch = 0;
|
|
36
|
+
let debounceTimer = null;
|
|
37
|
+
const clearTimer = () => {
|
|
38
|
+
if (debounceTimer !== null) {
|
|
39
|
+
clearTimeout(debounceTimer);
|
|
40
|
+
debounceTimer = null;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const emit = () => {
|
|
44
|
+
opts.onUpdate(session ? { ...session, items: [...session.items] } : null);
|
|
45
|
+
};
|
|
46
|
+
const close = () => {
|
|
47
|
+
if (!session)
|
|
48
|
+
return;
|
|
49
|
+
epoch++;
|
|
50
|
+
clearTimer();
|
|
51
|
+
session = null;
|
|
52
|
+
emit();
|
|
53
|
+
};
|
|
54
|
+
const runQuery = (spec) => {
|
|
55
|
+
if (!session)
|
|
56
|
+
return;
|
|
57
|
+
const myEpoch = ++epoch;
|
|
58
|
+
const query = session.query;
|
|
59
|
+
const exec = () => {
|
|
60
|
+
debounceTimer = null;
|
|
61
|
+
if (epoch !== myEpoch || !session)
|
|
62
|
+
return;
|
|
63
|
+
let result;
|
|
64
|
+
try {
|
|
65
|
+
result = spec.onQuery(query);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// A throwing onQuery behaves like a rejected async query.
|
|
69
|
+
session.items = [];
|
|
70
|
+
session.loading = false;
|
|
71
|
+
emit();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(result)) {
|
|
75
|
+
session.items = result;
|
|
76
|
+
session.loading = false;
|
|
77
|
+
emit();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Guard non-thenable returns from misbehaving plugins — treat
|
|
81
|
+
// them like an empty result instead of throwing on .then.
|
|
82
|
+
if (!result || typeof result.then !== 'function') {
|
|
83
|
+
session.items = [];
|
|
84
|
+
session.loading = false;
|
|
85
|
+
emit();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
result.then((items) => {
|
|
89
|
+
// Discard stale resolutions: a newer query or a close won.
|
|
90
|
+
if (epoch !== myEpoch || !session)
|
|
91
|
+
return;
|
|
92
|
+
session.items = items;
|
|
93
|
+
session.loading = false;
|
|
94
|
+
emit();
|
|
95
|
+
}, () => {
|
|
96
|
+
if (epoch !== myEpoch || !session)
|
|
97
|
+
return;
|
|
98
|
+
session.items = [];
|
|
99
|
+
session.loading = false;
|
|
100
|
+
emit();
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
clearTimer();
|
|
104
|
+
if (spec.debounce && spec.debounce > 0) {
|
|
105
|
+
debounceTimer = setTimeout(exec, spec.debounce);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
exec();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const evaluate = () => {
|
|
112
|
+
if (caret < 0 || caret > text.length) {
|
|
113
|
+
close();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// The run: non-whitespace text between the last boundary and the caret.
|
|
117
|
+
let start = caret;
|
|
118
|
+
while (start > 0 && !/\s/.test(text[start - 1]))
|
|
119
|
+
start--;
|
|
120
|
+
const run = text.slice(start, caret);
|
|
121
|
+
for (const { plugin, spec } of opts.triggers) {
|
|
122
|
+
const prefixLen = matchTrigger(spec, run);
|
|
123
|
+
if (prefixLen < 0)
|
|
124
|
+
continue;
|
|
125
|
+
const query = run.slice(prefixLen);
|
|
126
|
+
if (session && session.plugin === plugin && session.anchor === start) {
|
|
127
|
+
session.caret = caret;
|
|
128
|
+
if (session.query !== query) {
|
|
129
|
+
session.query = query;
|
|
130
|
+
// Clear stale results: the popup must never offer the
|
|
131
|
+
// previous query's suggestions while the new one resolves.
|
|
132
|
+
session.items = [];
|
|
133
|
+
session.loading = true;
|
|
134
|
+
emit();
|
|
135
|
+
runQuery(spec);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
session = { plugin, anchor: start, query, caret, items: [], loading: true };
|
|
140
|
+
emit();
|
|
141
|
+
runQuery(spec);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
close();
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
syncText: (t) => {
|
|
148
|
+
text = t ?? '';
|
|
149
|
+
evaluate();
|
|
150
|
+
},
|
|
151
|
+
syncCaret: (c) => {
|
|
152
|
+
caret = c;
|
|
153
|
+
evaluate();
|
|
154
|
+
},
|
|
155
|
+
close,
|
|
156
|
+
get session() {
|
|
157
|
+
// Snapshot — mutating the returned object must not desync internal
|
|
158
|
+
// state or bypass onUpdate (which also emits clones).
|
|
159
|
+
return session ? { ...session, items: [...session.items] } : null;
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
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
|
-
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';
|
|
4
|
+
export type { MarkdownComponents, MarkdownChild, RootProps, HeadingProps, ParagraphProps, BlockquoteProps, ListProps, ListItemProps, CodeProps, ThematicBreakProps, TableProps, TableRowProps, TableCellProps, StrongProps, EmProps, DelProps, CodeSpanProps, LinkProps, AutolinkProps, ImageProps, ExtensionProps, } from './render/components.js';
|
|
5
|
+
export { MarkdownEditor } from './editor/MarkdownEditor.js';
|
|
6
|
+
export type { MarkdownEditorProps, MarkdownEditorController, MarkdownEditorMode, } from './editor/MarkdownEditor.js';
|
|
7
|
+
export type { SelectionState } from '@sigx/lynx-richtext';
|
|
8
|
+
export { EditorToolbar } from './editor/toolbar/Toolbar.js';
|
|
9
|
+
export type { EditorToolbarProps, ToolbarRenderItem } from './editor/toolbar/Toolbar.js';
|
|
10
|
+
export { defaultToolbarItems } from './editor/toolbar/items.js';
|
|
11
|
+
export type { ToolbarItem, ToolbarContext } from './editor/toolbar/items.js';
|
|
12
|
+
export { mdToDoc } from './editor/convert/mdToDoc.js';
|
|
13
|
+
export type { MdToDocOptions, ExtensionSpanMapper } from './editor/convert/mdToDoc.js';
|
|
14
|
+
export { docToMd } from './editor/convert/docToMd.js';
|
|
15
|
+
export type { DocToMdOptions, SpanSerializer } from './editor/convert/docToMd.js';
|
|
16
|
+
export type { MarkdownEditorPlugin, InlinePluginSpec, TriggerSpec, TriggerItem, TriggerSelectApi, } from './editor/plugin.js';
|
|
17
|
+
export { SuggestionPopup } from './editor/trigger/SuggestionPopup.js';
|
|
18
|
+
export type { SuggestionPopupProps, SuggestionRenderItem } from './editor/trigger/SuggestionPopup.js';
|
|
19
|
+
export { createTriggerSessionManager } from './editor/trigger/session.js';
|
|
20
|
+
export type { TriggerSession, TriggerSessionManager } from './editor/trigger/session.js';
|
|
6
21
|
export { createMarkdownStream } from './stream.js';
|
|
7
22
|
export type { MarkdownStream, CreateMarkdownStreamOptions } from './stream.js';
|
|
8
|
-
export { XMarkdown } from './XMarkdown.js';
|
|
9
|
-
export type { XMarkdownProps, XMarkdownEffect } from './XMarkdown.js';
|
|
10
23
|
export { createIncrementalEngine } from './parser/incremental.js';
|
|
11
|
-
export type { IncrementalEngine } from './parser/incremental.js';
|
|
24
|
+
export type { IncrementalEngine, IncrementalEngineOptions } from './parser/incremental.js';
|
|
12
25
|
export { parseBlocks } from './parser/blocks.js';
|
|
13
26
|
export { parseInline } from './parser/inline.js';
|
|
27
|
+
export type { ParserInlineExtension } from './parser/extensions.js';
|
|
14
28
|
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,19 @@
|
|
|
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 { EditorToolbar } from './editor/toolbar/Toolbar.js';
|
|
10
|
+
export { defaultToolbarItems } from './editor/toolbar/items.js';
|
|
11
|
+
export { mdToDoc } from './editor/convert/mdToDoc.js';
|
|
12
|
+
export { docToMd } from './editor/convert/docToMd.js';
|
|
13
|
+
export { SuggestionPopup } from './editor/trigger/SuggestionPopup.js';
|
|
14
|
+
export { createTriggerSessionManager } from './editor/trigger/session.js';
|
|
7
15
|
// Streaming controller for AI token loops.
|
|
8
16
|
export { createMarkdownStream } from './stream.js';
|
|
9
|
-
// Native `<x-markdown>` wrapper, preserved for platforms that ship the element.
|
|
10
|
-
export { XMarkdown } from './XMarkdown.js';
|
|
11
17
|
// Parser primitives (for advanced consumers / testing).
|
|
12
18
|
export { createIncrementalEngine } from './parser/incremental.js';
|
|
13
19
|
export { parseBlocks } from './parser/blocks.js';
|
package/dist/parser/blocks.d.ts
CHANGED
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
* items, blockquote contents) get stable, unique keys for reconciliation.
|
|
13
13
|
*/
|
|
14
14
|
import type { BlockNode } from '../ast.js';
|
|
15
|
+
import type { ParserInlineExtension } from './extensions.js';
|
|
15
16
|
/** Monotonic key generator so every block (incl. nested) gets a unique key. */
|
|
16
17
|
export interface KeyGen {
|
|
17
18
|
next(): string;
|
|
18
19
|
}
|
|
19
20
|
export declare function makeKeyGen(prefix: string): KeyGen;
|
|
20
21
|
/** Parse a complete (or partial) source string into block nodes. */
|
|
21
|
-
export declare function parseBlocks(src: string, keys?: KeyGen): BlockNode[];
|
|
22
|
+
export declare function parseBlocks(src: string, keys?: KeyGen, extensions?: readonly ParserInlineExtension[]): BlockNode[];
|
package/dist/parser/blocks.js
CHANGED
|
@@ -18,11 +18,11 @@ export function makeKeyGen(prefix) {
|
|
|
18
18
|
return { next: () => `${prefix}-${n++}` };
|
|
19
19
|
}
|
|
20
20
|
/** Parse a complete (or partial) source string into block nodes. */
|
|
21
|
-
export function parseBlocks(src, keys = makeKeyGen('b')) {
|
|
21
|
+
export function parseBlocks(src, keys = makeKeyGen('b'), extensions) {
|
|
22
22
|
const lines = src.split('\n');
|
|
23
|
-
return parseLines(lines, keys);
|
|
23
|
+
return parseLines(lines, keys, extensions);
|
|
24
24
|
}
|
|
25
|
-
function parseLines(lines, keys) {
|
|
25
|
+
function parseLines(lines, keys, extensions) {
|
|
26
26
|
const blocks = [];
|
|
27
27
|
let i = 0;
|
|
28
28
|
while (i < lines.length) {
|
|
@@ -66,7 +66,7 @@ function parseLines(lines, keys) {
|
|
|
66
66
|
key: keys.next(),
|
|
67
67
|
raw: line,
|
|
68
68
|
level: heading.level,
|
|
69
|
-
children: parseInline(heading.text),
|
|
69
|
+
children: parseInline(heading.text, extensions),
|
|
70
70
|
});
|
|
71
71
|
i++;
|
|
72
72
|
continue;
|
|
@@ -89,14 +89,14 @@ function parseLines(lines, keys) {
|
|
|
89
89
|
type: 'blockquote',
|
|
90
90
|
key: keys.next(),
|
|
91
91
|
raw: lines.slice(start, i).join('\n'),
|
|
92
|
-
children: parseLines(inner, keys),
|
|
92
|
+
children: parseLines(inner, keys, extensions),
|
|
93
93
|
});
|
|
94
94
|
continue;
|
|
95
95
|
}
|
|
96
96
|
// List.
|
|
97
97
|
const marker = matchListMarker(line) ?? matchEmptyListMarker(line);
|
|
98
98
|
if (marker) {
|
|
99
|
-
const result = parseList(lines, i, marker, keys);
|
|
99
|
+
const result = parseList(lines, i, marker, keys, extensions);
|
|
100
100
|
blocks.push(result.block);
|
|
101
101
|
i = result.next;
|
|
102
102
|
continue;
|
|
@@ -105,7 +105,7 @@ function parseLines(lines, keys) {
|
|
|
105
105
|
if (line.indexOf('|') !== -1 && i + 1 < lines.length) {
|
|
106
106
|
const align = matchTableDelimiter(lines[i + 1]);
|
|
107
107
|
if (align) {
|
|
108
|
-
const result = parseTable(lines, i, align, keys);
|
|
108
|
+
const result = parseTable(lines, i, align, keys, extensions);
|
|
109
109
|
blocks.push(result.block);
|
|
110
110
|
i = result.next;
|
|
111
111
|
continue;
|
|
@@ -122,7 +122,7 @@ function parseLines(lines, keys) {
|
|
|
122
122
|
type: 'paragraph',
|
|
123
123
|
key: keys.next(),
|
|
124
124
|
raw: lines.slice(start, i).join('\n'),
|
|
125
|
-
children: parseInline(para.join('\n')),
|
|
125
|
+
children: parseInline(para.join('\n'), extensions),
|
|
126
126
|
});
|
|
127
127
|
}
|
|
128
128
|
return blocks;
|
|
@@ -159,7 +159,7 @@ function stripIndent(line, n) {
|
|
|
159
159
|
function sameList(a, b) {
|
|
160
160
|
return a.ordered === b.ordered && a.delimiter === b.delimiter;
|
|
161
161
|
}
|
|
162
|
-
function parseList(lines, start, first, keys) {
|
|
162
|
+
function parseList(lines, start, first, keys, extensions) {
|
|
163
163
|
const items = [];
|
|
164
164
|
let i = start;
|
|
165
165
|
let tight = true;
|
|
@@ -217,7 +217,7 @@ function parseList(lines, start, first, keys) {
|
|
|
217
217
|
items.push({
|
|
218
218
|
key: keys.next(),
|
|
219
219
|
checked,
|
|
220
|
-
children: parseLines(bodyLines, keys),
|
|
220
|
+
children: parseLines(bodyLines, keys, extensions),
|
|
221
221
|
});
|
|
222
222
|
}
|
|
223
223
|
if (sawBlankBetween)
|
|
@@ -248,12 +248,12 @@ function extractTask(itemLines) {
|
|
|
248
248
|
// ---------------------------------------------------------------------------
|
|
249
249
|
// Tables
|
|
250
250
|
// ---------------------------------------------------------------------------
|
|
251
|
-
function parseTable(lines, start, align, keys) {
|
|
252
|
-
const header = splitTableRow(lines[start]).map((c) => ({ children: parseInline(c) }));
|
|
251
|
+
function parseTable(lines, start, align, keys, extensions) {
|
|
252
|
+
const header = splitTableRow(lines[start]).map((c) => ({ children: parseInline(c, extensions) }));
|
|
253
253
|
let i = start + 2; // skip header + delimiter
|
|
254
254
|
const rows = [];
|
|
255
255
|
while (i < lines.length && !isBlank(lines[i]) && lines[i].indexOf('|') !== -1 && !interrupts(lines, i)) {
|
|
256
|
-
const cells = splitTableRow(lines[i]).map((c) => ({ children: parseInline(c) }));
|
|
256
|
+
const cells = splitTableRow(lines[i]).map((c) => ({ children: parseInline(c, extensions) }));
|
|
257
257
|
// Normalize cell count to the header width.
|
|
258
258
|
while (cells.length < header.length)
|
|
259
259
|
cells.push({ children: [] });
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser inline-extension contract: a plugin recognizes its own inline
|
|
3
|
+
* construct (a mention, an emoji shortcode, …) and produces an
|
|
4
|
+
* `InlineExtension` AST node, without touching this package.
|
|
5
|
+
*
|
|
6
|
+
* Extensions run inside the tokenizer at the same precedence tier as code
|
|
7
|
+
* spans and links: a successful match emits one opaque token the emphasis
|
|
8
|
+
* stack never looks into. Matching is gated by `triggerChars`, so the cost
|
|
9
|
+
* when no extension is in play is zero, and one set lookup per char otherwise.
|
|
10
|
+
*/
|
|
11
|
+
import type { InlineExtension } from '../ast.js';
|
|
12
|
+
export interface ParserInlineExtension {
|
|
13
|
+
/** Stable extension name; also the render dispatch key (`components.extension[name]`). */
|
|
14
|
+
name: string;
|
|
15
|
+
/**
|
|
16
|
+
* Fast-path gate: the character(s) that can begin this construct. `match`
|
|
17
|
+
* is only called when the current character is in this set. Must be
|
|
18
|
+
* non-empty — an ungated extension would have to be probed at every
|
|
19
|
+
* position, which is banned for parse cost and memoization reasons.
|
|
20
|
+
*/
|
|
21
|
+
triggerChars: readonly string[];
|
|
22
|
+
/**
|
|
23
|
+
* Try to match this construct anchored at `pos` (`text[pos]` is guaranteed
|
|
24
|
+
* to be one of `triggerChars`). Returns the produced node and the
|
|
25
|
+
* exclusive end index of the consumed source, or `null` when there is no
|
|
26
|
+
* match.
|
|
27
|
+
*
|
|
28
|
+
* Two contracts the parser relies on:
|
|
29
|
+
* - **Streaming-safe**: return `null` on a partial tail (e.g. `@[lab`
|
|
30
|
+
* while expecting `@[label](id)`) — same contract as the built-in
|
|
31
|
+
* `scanLink`. The half-typed text degrades to literal and re-resolves
|
|
32
|
+
* once the rest arrives.
|
|
33
|
+
* - **Pure**: same `(text, pos)` → same result. The incremental engine
|
|
34
|
+
* memoizes finalized blocks by reference and only re-parses the live
|
|
35
|
+
* tail; an impure `match` breaks that invariant.
|
|
36
|
+
*
|
|
37
|
+
* The extension builds `node.raw` itself (it knows its own boundaries):
|
|
38
|
+
* `raw` must equal `text.slice(pos, end)`.
|
|
39
|
+
*
|
|
40
|
+
* The tokenizer hardens against misbehaving matches — one that throws,
|
|
41
|
+
* does not advance (`end <= pos`), or runs past the input
|
|
42
|
+
* (`end > text.length`) is treated as no match, so the text degrades to
|
|
43
|
+
* literal instead of breaking parsing.
|
|
44
|
+
*/
|
|
45
|
+
match(text: string, pos: number): {
|
|
46
|
+
node: InlineExtension;
|
|
47
|
+
end: number;
|
|
48
|
+
} | null;
|
|
49
|
+
}
|
|
50
|
+
/** Union of all trigger chars, for the tokenizer's per-char fast path. */
|
|
51
|
+
export declare function buildTriggerSet(extensions: readonly ParserInlineExtension[]): Set<string>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser inline-extension contract: a plugin recognizes its own inline
|
|
3
|
+
* construct (a mention, an emoji shortcode, …) and produces an
|
|
4
|
+
* `InlineExtension` AST node, without touching this package.
|
|
5
|
+
*
|
|
6
|
+
* Extensions run inside the tokenizer at the same precedence tier as code
|
|
7
|
+
* spans and links: a successful match emits one opaque token the emphasis
|
|
8
|
+
* stack never looks into. Matching is gated by `triggerChars`, so the cost
|
|
9
|
+
* when no extension is in play is zero, and one set lookup per char otherwise.
|
|
10
|
+
*/
|
|
11
|
+
/** Union of all trigger chars, for the tokenizer's per-char fast path. */
|
|
12
|
+
export function buildTriggerSet(extensions) {
|
|
13
|
+
const set = new Set();
|
|
14
|
+
for (const ext of extensions)
|
|
15
|
+
for (const ch of ext.triggerChars)
|
|
16
|
+
set.add(ch);
|
|
17
|
+
return set;
|
|
18
|
+
}
|
|
@@ -17,10 +17,19 @@
|
|
|
17
17
|
* rather than remounting.
|
|
18
18
|
*/
|
|
19
19
|
import type { BlockNode } from '../ast.js';
|
|
20
|
+
import type { ParserInlineExtension } from './extensions.js';
|
|
20
21
|
export interface IncrementalEngine {
|
|
21
22
|
/** Parse `src`, reusing finalized blocks from prior calls where possible. */
|
|
22
23
|
parse(src: string): BlockNode[];
|
|
23
24
|
/** Drop all cached state (e.g. when the source is replaced, not appended). */
|
|
24
25
|
reset(): void;
|
|
25
26
|
}
|
|
26
|
-
export
|
|
27
|
+
export interface IncrementalEngineOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Inline extensions threaded into every parse. Captured at construction —
|
|
30
|
+
* extensions are configuration, not data: to change the set, create a new
|
|
31
|
+
* engine. Each `match` must be pure, or finalized-block reuse breaks.
|
|
32
|
+
*/
|
|
33
|
+
extensions?: readonly ParserInlineExtension[];
|
|
34
|
+
}
|
|
35
|
+
export declare function createIncrementalEngine(options?: IncrementalEngineOptions): IncrementalEngine;
|
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { parseBlocks, makeKeyGen } from './blocks.js';
|
|
20
20
|
import { normalize } from './scanner.js';
|
|
21
|
-
export function createIncrementalEngine() {
|
|
21
|
+
export function createIncrementalEngine(options) {
|
|
22
|
+
// Snapshot so in-place mutation of the caller's array can't change parse
|
|
23
|
+
// behavior under cached finalized blocks (the "captured config" guarantee).
|
|
24
|
+
const extensions = options?.extensions ? [...options.extensions] : undefined;
|
|
22
25
|
/** Finalized blocks, frozen and reused by reference across chunks. */
|
|
23
26
|
let cached = [];
|
|
24
27
|
/** Source length (in normalized chars) already folded into `cached`. */
|
|
@@ -40,7 +43,7 @@ export function createIncrementalEngine() {
|
|
|
40
43
|
if (!norm.startsWith(stableSource))
|
|
41
44
|
reset();
|
|
42
45
|
const tail = norm.slice(cut);
|
|
43
|
-
const blocks = parseBlocks(tail, makeKeyGen(`p${cut}`));
|
|
46
|
+
const blocks = parseBlocks(tail, makeKeyGen(`p${cut}`), extensions);
|
|
44
47
|
if (blocks.length === 0) {
|
|
45
48
|
// Tail is empty / all-blank: nothing live, return finalized prefix.
|
|
46
49
|
return cached.slice();
|
package/dist/parser/inline.d.ts
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Inline tokenizer: a markdown text run → `InlineNode[]` (nested spans).
|
|
3
3
|
*
|
|
4
|
-
* Precedence (highest first):
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Precedence (highest first): backslash escapes, plugin inline extensions
|
|
5
|
+
* (trigger-char gated), backtick code spans, images, links, angle/bare
|
|
6
|
+
* autolinks, then emphasis (`**`/`__` strong, `*`/`_` em, `~~` del) resolved
|
|
7
|
+
* with a delimiter-run stack, then hard breaks, then text.
|
|
7
8
|
*
|
|
8
9
|
* Robustness for streaming: any unmatched construct at the tail (a lone `**`,
|
|
9
10
|
* a half-open `[text](`, an unterminated code span) degrades to literal text.
|
|
10
11
|
* The function never throws.
|
|
11
12
|
*/
|
|
12
13
|
import type { InlineNode } from '../ast.js';
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
import { type ParserInlineExtension } from './extensions.js';
|
|
15
|
+
/**
|
|
16
|
+
* Parse an inline text run into a node array.
|
|
17
|
+
*
|
|
18
|
+
* `extensions` add plugin-defined inline constructs at the same precedence
|
|
19
|
+
* tier as code spans/links (after backslash escapes; opaque to the emphasis
|
|
20
|
+
* stack). On shared trigger chars the first registered extension wins. Each
|
|
21
|
+
* extension's `match` must be pure and streaming-safe — see
|
|
22
|
+
* {@link ParserInlineExtension}.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseInline(input: string, extensions?: readonly ParserInlineExtension[]): InlineNode[];
|
|
15
25
|
/** Flatten inline nodes to their visible text (for image alt). */
|
|
16
26
|
export declare function stripFormatting(text: string): string;
|
package/dist/parser/inline.js
CHANGED
|
@@ -1,29 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Inline tokenizer: a markdown text run → `InlineNode[]` (nested spans).
|
|
3
3
|
*
|
|
4
|
-
* Precedence (highest first):
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Precedence (highest first): backslash escapes, plugin inline extensions
|
|
5
|
+
* (trigger-char gated), backtick code spans, images, links, angle/bare
|
|
6
|
+
* autolinks, then emphasis (`**`/`__` strong, `*`/`_` em, `~~` del) resolved
|
|
7
|
+
* with a delimiter-run stack, then hard breaks, then text.
|
|
7
8
|
*
|
|
8
9
|
* Robustness for streaming: any unmatched construct at the tail (a lone `**`,
|
|
9
10
|
* a half-open `[text](`, an unterminated code span) degrades to literal text.
|
|
10
11
|
* The function never throws.
|
|
11
12
|
*/
|
|
13
|
+
import { buildTriggerSet } from './extensions.js';
|
|
12
14
|
import { isEscapable, sanitizeHref, trimAutolinkTail } from './scanner.js';
|
|
13
15
|
function isDelim(t) {
|
|
14
16
|
return t.delim === true;
|
|
15
17
|
}
|
|
16
18
|
const PUNCT = /[!-/:-@[-`{-~]/;
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Parse an inline text run into a node array.
|
|
21
|
+
*
|
|
22
|
+
* `extensions` add plugin-defined inline constructs at the same precedence
|
|
23
|
+
* tier as code spans/links (after backslash escapes; opaque to the emphasis
|
|
24
|
+
* stack). On shared trigger chars the first registered extension wins. Each
|
|
25
|
+
* extension's `match` must be pure and streaming-safe — see
|
|
26
|
+
* {@link ParserInlineExtension}.
|
|
27
|
+
*/
|
|
28
|
+
export function parseInline(input, extensions) {
|
|
29
|
+
const tokens = tokenize(input, extensions);
|
|
20
30
|
resolveEmphasis(tokens, 0);
|
|
21
31
|
return coalesce(tokensToNodes(tokens));
|
|
22
32
|
}
|
|
23
33
|
// ---------------------------------------------------------------------------
|
|
24
34
|
// Tokenization
|
|
25
35
|
// ---------------------------------------------------------------------------
|
|
26
|
-
function tokenize(text) {
|
|
36
|
+
function tokenize(text, extensions) {
|
|
37
|
+
// Normalize an empty trigger set back to null so the per-char fast path
|
|
38
|
+
// stays fully disabled when no extension can ever match.
|
|
39
|
+
const builtSet = extensions && extensions.length ? buildTriggerSet(extensions) : null;
|
|
40
|
+
const triggerSet = builtSet && builtSet.size ? builtSet : null;
|
|
27
41
|
const tokens = [];
|
|
28
42
|
let buf = '';
|
|
29
43
|
const flush = () => {
|
|
@@ -45,7 +59,9 @@ function tokenize(text) {
|
|
|
45
59
|
i += 2;
|
|
46
60
|
continue;
|
|
47
61
|
}
|
|
48
|
-
|
|
62
|
+
// Trigger chars are escapable too, so `\@` always suppresses an
|
|
63
|
+
// `@`-triggered extension regardless of the built-in escape set.
|
|
64
|
+
if (next !== undefined && (isEscapable(next) || (triggerSet !== null && triggerSet.has(next)))) {
|
|
49
65
|
buf += next;
|
|
50
66
|
i += 2;
|
|
51
67
|
continue;
|
|
@@ -54,6 +70,36 @@ function tokenize(text) {
|
|
|
54
70
|
i++;
|
|
55
71
|
continue;
|
|
56
72
|
}
|
|
73
|
+
// Inline extension (plugin construct; opaque to the emphasis stack).
|
|
74
|
+
// After escapes (so `\@` stays literal), before every built-in scanner.
|
|
75
|
+
if (triggerSet !== null && triggerSet.has(ch)) {
|
|
76
|
+
let matched = false;
|
|
77
|
+
for (const ext of extensions) {
|
|
78
|
+
if (!ext.triggerChars.includes(ch))
|
|
79
|
+
continue;
|
|
80
|
+
// Misbehaving extensions must not break the "never throws,
|
|
81
|
+
// never hangs" guarantee: a throwing match is treated as no
|
|
82
|
+
// match, and a non-advancing (end <= pos) or out-of-bounds
|
|
83
|
+
// (end > length) match is ignored.
|
|
84
|
+
let m = null;
|
|
85
|
+
try {
|
|
86
|
+
m = ext.match(text, i);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
m = null;
|
|
90
|
+
}
|
|
91
|
+
if (m && m.end > i && m.end <= n) {
|
|
92
|
+
flush();
|
|
93
|
+
tokens.push(m.node);
|
|
94
|
+
i = m.end;
|
|
95
|
+
matched = true;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (matched)
|
|
100
|
+
continue;
|
|
101
|
+
// No extension matched (or partial tail) → normal scanning.
|
|
102
|
+
}
|
|
57
103
|
// Code span.
|
|
58
104
|
if (ch === '`') {
|
|
59
105
|
const span = scanCodeSpan(text, i);
|
|
@@ -94,7 +140,7 @@ function tokenize(text) {
|
|
|
94
140
|
type: 'link',
|
|
95
141
|
href: sanitizeHref(link.href),
|
|
96
142
|
...(link.title ? { title: link.title } : {}),
|
|
97
|
-
children: parseInline(link.label),
|
|
143
|
+
children: parseInline(link.label, extensions),
|
|
98
144
|
});
|
|
99
145
|
i = link.end;
|
|
100
146
|
continue;
|
|
@@ -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
|
|
@@ -24,6 +23,13 @@
|
|
|
24
23
|
* ```
|
|
25
24
|
*/
|
|
26
25
|
import { type Define } from '@sigx/lynx';
|
|
26
|
+
import type { ParserInlineExtension } from '../parser/extensions.js';
|
|
27
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
|
|
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
|
+
/**
|
|
30
|
+
* Plugin inline extensions (see {@link ParserInlineExtension}). Pass a
|
|
31
|
+
* stable array (e.g. a module constant) — changing its identity resets
|
|
32
|
+
* the incremental parse state and re-parses from scratch.
|
|
33
|
+
*/
|
|
34
|
+
& Define.Prop<'extensions', readonly ParserInlineExtension[], false>;
|
|
29
35
|
export declare const MarkdownView: import("@sigx/runtime-core").ComponentFactory<MarkdownViewProps, void, {}>;
|
|
@@ -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
|
|
@@ -28,8 +27,17 @@ import { createIncrementalEngine } from '../parser/incremental.js';
|
|
|
28
27
|
import { defaultComponents } from './components.js';
|
|
29
28
|
import { renderDocument } from './engine.js';
|
|
30
29
|
export const MarkdownView = component(({ props }) => {
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
// The engine captures its extensions at construction; recreate it if the
|
|
31
|
+
// extensions prop changes identity (rare — normally a stable constant).
|
|
32
|
+
let engine = createIncrementalEngine({ extensions: props.extensions });
|
|
33
|
+
let lastExtensions = props.extensions;
|
|
34
|
+
const blocks = computed(() => {
|
|
35
|
+
if (props.extensions !== lastExtensions) {
|
|
36
|
+
lastExtensions = props.extensions;
|
|
37
|
+
engine = createIncrementalEngine({ extensions: lastExtensions });
|
|
38
|
+
}
|
|
39
|
+
return engine.parse(props.value ?? '');
|
|
40
|
+
});
|
|
33
41
|
return () => {
|
|
34
42
|
const ctx = {
|
|
35
43
|
components: props.components
|