@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
@@ -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
1
  export { MarkdownView } from './render/MarkdownView.js';
2
2
  export type { MarkdownViewProps } from './render/MarkdownView.js';
3
3
  export { defaultComponents } 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, } 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
5
  export { MarkdownEditor } from './editor/MarkdownEditor.js';
6
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';
7
12
  export { mdToDoc } from './editor/convert/mdToDoc.js';
13
+ export type { MdToDocOptions, ExtensionSpanMapper } from './editor/convert/mdToDoc.js';
8
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';
9
21
  export { createMarkdownStream } from './stream.js';
10
22
  export type { MarkdownStream, CreateMarkdownStreamOptions } from './stream.js';
11
23
  export { createIncrementalEngine } from './parser/incremental.js';
12
- export type { IncrementalEngine } from './parser/incremental.js';
24
+ export type { IncrementalEngine, IncrementalEngineOptions } from './parser/incremental.js';
13
25
  export { parseBlocks } from './parser/blocks.js';
14
26
  export { parseInline } from './parser/inline.js';
27
+ export type { ParserInlineExtension } from './parser/extensions.js';
15
28
  export type * from './ast.js';
package/dist/index.js CHANGED
@@ -6,8 +6,12 @@ export { defaultComponents } from './render/components.js';
6
6
  // True-WYSIWYG editor on the native <sigx-richtext> element
7
7
  // (requires the optional @sigx/lynx-richtext peer).
8
8
  export { MarkdownEditor } from './editor/MarkdownEditor.js';
9
+ export { EditorToolbar } from './editor/toolbar/Toolbar.js';
10
+ export { defaultToolbarItems } from './editor/toolbar/items.js';
9
11
  export { mdToDoc } from './editor/convert/mdToDoc.js';
10
12
  export { docToMd } from './editor/convert/docToMd.js';
13
+ export { SuggestionPopup } from './editor/trigger/SuggestionPopup.js';
14
+ export { createTriggerSessionManager } from './editor/trigger/session.js';
11
15
  // Streaming controller for AI token loops.
12
16
  export { createMarkdownStream } from './stream.js';
13
17
  // Parser primitives (for advanced consumers / testing).
@@ -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[];
@@ -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 declare function createIncrementalEngine(): IncrementalEngine;
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();
@@ -1,16 +1,26 @@
1
1
  /**
2
2
  * Inline tokenizer: a markdown text run → `InlineNode[]` (nested spans).
3
3
  *
4
- * Precedence (highest first): backtick code spans, backslash escapes, images,
5
- * links, angle/bare autolinks, then emphasis (`**`/`__` strong, `*`/`_` em,
6
- * `~~` del) resolved with a delimiter-run stack, then hard breaks, then text.
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
- /** Parse an inline text run into a node array. */
14
- export declare function parseInline(input: string): InlineNode[];
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;
@@ -1,29 +1,43 @@
1
1
  /**
2
2
  * Inline tokenizer: a markdown text run → `InlineNode[]` (nested spans).
3
3
  *
4
- * Precedence (highest first): backtick code spans, backslash escapes, images,
5
- * links, angle/bare autolinks, then emphasis (`**`/`__` strong, `*`/`_` em,
6
- * `~~` del) resolved with a delimiter-run stack, then hard breaks, then text.
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
- /** Parse an inline text run into a node array. */
18
- export function parseInline(input) {
19
- const tokens = tokenize(input);
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
- if (next !== undefined && isEscapable(next)) {
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;
@@ -23,6 +23,13 @@
23
23
  * ```
24
24
  */
25
25
  import { type Define } from '@sigx/lynx';
26
+ import type { ParserInlineExtension } from '../parser/extensions.js';
26
27
  import { type MarkdownComponents } from './components.js';
27
- 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>;
28
35
  export declare const MarkdownView: import("@sigx/runtime-core").ComponentFactory<MarkdownViewProps, void, {}>;
@@ -27,8 +27,17 @@ import { createIncrementalEngine } from '../parser/incremental.js';
27
27
  import { defaultComponents } from './components.js';
28
28
  import { renderDocument } from './engine.js';
29
29
  export const MarkdownView = component(({ props }) => {
30
- const engine = createIncrementalEngine();
31
- const blocks = computed(() => engine.parse(props.value ?? ''));
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
+ });
32
41
  return () => {
33
42
  const ctx = {
34
43
  components: props.components
@@ -12,7 +12,7 @@
12
12
  * component only decides *what element to wrap children in*.
13
13
  */
14
14
  import type { JSXElement } from '@sigx/lynx';
15
- import type { BlockquoteBlock, CodeBlock, HeadingBlock, HeadingLevel, InlineAutolink, InlineCodeSpan, InlineDel, InlineEm, InlineImage, InlineLink, InlineStrong, ListBlock, ListItem, ParagraphBlock, TableAlign, TableBlock, ThematicBreakBlock } from '../ast.js';
15
+ import type { BlockquoteBlock, CodeBlock, HeadingBlock, HeadingLevel, InlineAutolink, InlineCodeSpan, InlineDel, InlineEm, InlineExtension, InlineImage, InlineLink, InlineStrong, ListBlock, ListItem, ParagraphBlock, TableAlign, TableBlock, ThematicBreakBlock } from '../ast.js';
16
16
  /** A renderable child: a JSX element or a raw string (for text/`<br>`). */
17
17
  export type MarkdownChild = JSXElement | string;
18
18
  export interface RootProps {
@@ -111,6 +111,13 @@ export interface ImageProps {
111
111
  onImageTap?: (src: string) => void;
112
112
  node: InlineImage;
113
113
  }
114
+ export interface ExtensionProps {
115
+ name: string;
116
+ attrs: Record<string, string>;
117
+ /** Rendered `node.children`; `[]` for leaf extensions. */
118
+ children: MarkdownChild[];
119
+ node: InlineExtension;
120
+ }
114
121
  /**
115
122
  * Map of node type → render function. Pass a partial map to `<Markdown
116
123
  * components={…}>` to override any subset; unspecified types fall back to the
@@ -136,5 +143,10 @@ export interface MarkdownComponents {
136
143
  autolink(props: AutolinkProps): MarkdownChild;
137
144
  image(props: ImageProps): MarkdownChild;
138
145
  br(): MarkdownChild;
146
+ /**
147
+ * Renderers for plugin inline extensions, keyed by extension name. A node
148
+ * with no matching renderer falls back to its `raw` source as plain text.
149
+ */
150
+ extension?: Record<string, (props: ExtensionProps) => MarkdownChild>;
139
151
  }
140
152
  export declare const defaultComponents: MarkdownComponents;
@@ -141,5 +141,16 @@ function renderInlineNode(node, ctx) {
141
141
  onImageTap: ctx.onImageTap,
142
142
  node,
143
143
  });
144
+ case 'extension': {
145
+ const renderer = C.extension?.[node.name];
146
+ if (!renderer)
147
+ return node.raw; // no renderer registered → literal source
148
+ return renderer({
149
+ name: node.name,
150
+ attrs: node.attrs,
151
+ children: node.children ? renderInline(node.children, ctx) : [],
152
+ node,
153
+ });
154
+ }
144
155
  }
145
156
  }