@pinagent/react-native 0.2.3 → 0.2.5

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.
@@ -140,6 +140,9 @@ const HOST_COMPONENT = 5;
140
140
  interface FiberLike {
141
141
  tag?: number;
142
142
  return?: FiberLike | null;
143
+ /** First child / next sibling — the render-tree walk for the measure fallback. */
144
+ child?: FiberLike | null;
145
+ sibling?: FiberLike | null;
143
146
  stateNode?: { canonical?: { publicInstance?: unknown } } | null;
144
147
  /** Host fibers carry the committed props — where `data-pa-loc` rides. */
145
148
  memoizedProps?: RawProps;
@@ -224,6 +227,12 @@ function paLocOf(props: RawProps): Loc | null {
224
227
  return parsePaLoc(props?.['data-pa-loc']);
225
228
  }
226
229
 
230
+ /** The enclosing component name the babel plugin records as `data-pa-comp`. */
231
+ function compOf(props: RawProps): string | null {
232
+ const c = props?.['data-pa-comp'];
233
+ return typeof c === 'string' && c.length > 0 ? c : null;
234
+ }
235
+
227
236
  /**
228
237
  * Walk a host fiber's render-tree parent (`return`) chain, returning the first
229
238
  * `data-pa-loc` found on a fiber's `memoizedProps` — the tapped element itself,
@@ -429,6 +438,160 @@ export function measureFrame(measure?: (cb: RawMeasureCb) => void): Promise<Fram
429
438
  });
430
439
  }
431
440
 
441
+ /** True when window-coordinate point (x, y) lies within frame `f`. */
442
+ export function frameContains(f: Frame, x: number, y: number): boolean {
443
+ return x >= f.x && y >= f.y && x <= f.x + f.width && y <= f.y + f.height;
444
+ }
445
+
446
+ /** A measure-resolved hit: the deepest tagged host whose frame holds the tap. */
447
+ interface MeasuredHit {
448
+ fiber: FiberLike;
449
+ loc: Loc;
450
+ name: string | null;
451
+ frame: Frame;
452
+ }
453
+
454
+ /**
455
+ * Measure a host fiber's on-screen rect via its public instance's
456
+ * `measureInWindow` (window coordinates). Guarded + timed-out like
457
+ * {@link measureFrame}; resolves null for a non-host fiber, a missing instance,
458
+ * or a zero-size / never-firing measure. The RN-runtime half of the measure
459
+ * fallback — not unit-tested (see {@link measureHitTest}, which injects this).
460
+ */
461
+ function measureFiberInWindow(fiber: FiberLike): Promise<Frame | null> {
462
+ const inst = fiber.stateNode?.canonical?.publicInstance as
463
+ | { measureInWindow?: (cb: (x: number, y: number, w: number, h: number) => void) => void }
464
+ | undefined;
465
+ const measure = inst?.measureInWindow;
466
+ if (typeof measure !== 'function') return Promise.resolve(null);
467
+ return new Promise((resolve) => {
468
+ let settled = false;
469
+ const finish = (f: Frame | null) => {
470
+ if (!settled) {
471
+ settled = true;
472
+ resolve(f);
473
+ }
474
+ };
475
+ const timer = setTimeout(() => finish(null), 120);
476
+ try {
477
+ measure.call(inst, (x, y, width, height) => {
478
+ clearTimeout(timer);
479
+ finish(width > 0 || height > 0 ? { x, y, width, height } : null);
480
+ });
481
+ } catch {
482
+ clearTimeout(timer);
483
+ finish(null);
484
+ }
485
+ });
486
+ }
487
+
488
+ /**
489
+ * Measure-based hit-test — the fallback for when RN's geometric
490
+ * `findNodeAtPoint` can't descend to the tapped element.
491
+ *
492
+ * `react-native-pager-view` (and anything that hosts content in a native
493
+ * container) detaches a page's *native* views from the Fabric shadow tree
494
+ * `findNodeAtPoint` walks, so the native hit-test bottoms out at the page
495
+ * wrapper — it returns no touched instance at all (`closestPublicInstance` is
496
+ * null). But the React **fiber** tree is intact and the page's widgets are
497
+ * on-screen, hence measurable — so we hit-test ourselves: DFS the fiber subtree
498
+ * under `root` (the app root, an ancestor of every on-screen view), measuring
499
+ * each host, and keep the DEEPEST tagged host inside the tapped region.
500
+ *
501
+ * `region` threads the frame of the nearest measurable host ancestor that
502
+ * CONTAINS the tap (null until we enter one). Two subtleties this handles:
503
+ *
504
+ * - **Pruning.** A host with a real frame that MISSES the tap can't have a
505
+ * (non-overflowing) descendant that hits, so its subtree is skipped — like
506
+ * the browser's `elementFromPoint`.
507
+ * - **Flattened / detached hosts.** RN flattens layout-only `<View>`s (no
508
+ * native view → `measure` returns null) and pager pages are detached, so a
509
+ * widget's own tagged hosts often can't be measured. Once we're inside a
510
+ * measurable containing region we keep recording tagged hosts even when they
511
+ * can't be measured (they borrow the region's frame for the highlight) and
512
+ * descend through them — otherwise geometry bottoms out at the outermost
513
+ * non-flattened wrapper (e.g. an animated card) and every widget collapses to
514
+ * that shared wrapper's source. Measurable siblings still prune wrong
515
+ * branches, so we stay within the tapped element.
516
+ *
517
+ * Composite / fragment fibers carry no frame, so we always descend through them
518
+ * to reach their hosts. `measure` is injected so the traversal is unit-testable
519
+ * without an RN runtime.
520
+ */
521
+ export async function measureHitTest(
522
+ root: FiberLike | null,
523
+ x: number,
524
+ y: number,
525
+ measure: (fiber: FiberLike) => Promise<Frame | null>,
526
+ ): Promise<MeasuredHit | null> {
527
+ let best: MeasuredHit | null = null;
528
+ let bestDepth = -1;
529
+
530
+ async function visitChildren(
531
+ parent: FiberLike,
532
+ depth: number,
533
+ region: Frame | null,
534
+ ): Promise<void> {
535
+ let n = 0;
536
+ for (let node = parent.child ?? null; node && n < 100_000; node = node.sibling ?? null, n++) {
537
+ await visitNode(node, depth, region);
538
+ }
539
+ }
540
+
541
+ async function visitNode(node: FiberLike, depth: number, region: Frame | null): Promise<void> {
542
+ let nextRegion = region;
543
+ if (node.tag === HOST_COMPONENT) {
544
+ const frame = await measure(node);
545
+ if (frame) {
546
+ // A measurable host that misses the tap prunes its whole subtree.
547
+ if (!frameContains(frame, x, y)) return;
548
+ nextRegion = frame; // tighten the containing region to this host
549
+ }
550
+ // Record any tagged host reached INSIDE a measurable containing region —
551
+ // including flattened ones (null frame) geometry can't see. Deepest wins;
552
+ // on a tie the later (over-painted) sibling does. Flattened hosts borrow
553
+ // the region's frame for the highlight.
554
+ if (nextRegion) {
555
+ const loc = paLocOf(node.memoizedProps);
556
+ if (loc && depth >= bestDepth) {
557
+ best = { fiber: node, loc, name: compOf(node.memoizedProps), frame: frame ?? nextRegion };
558
+ bestDepth = depth;
559
+ }
560
+ }
561
+ }
562
+ await visitChildren(node, depth + 1, nextRegion);
563
+ }
564
+
565
+ if (root) await visitNode(root, 0, null);
566
+ return best;
567
+ }
568
+
569
+ /**
570
+ * The chain of distinctly-located tagged hosts from `leaf` up to the root,
571
+ * root-first (matching {@link crumbsOf}'s order). Built from the fiber `return`
572
+ * chain for the measure-fallback breadcrumb. Consecutive hosts sharing a
573
+ * `data-pa-loc` (a forwarding wrapper re-emitting the call site) collapse to a
574
+ * single crumb; names come from the babel plugin's `data-pa-comp`. Pure over a
575
+ * fiber-like chain (capped against a malformed `return` cycle); exported for
576
+ * unit testing.
577
+ */
578
+ export function taggedAncestors(
579
+ leaf: FiberLike | null,
580
+ ): { name: string; loc: Loc; fiber: FiberLike }[] {
581
+ const out: { name: string; loc: Loc; fiber: FiberLike }[] = [];
582
+ const seen = new Set<string>();
583
+ for (let f = leaf, i = 0; f && i < 10_000; f = f.return ?? null, i++) {
584
+ if (f.tag !== HOST_COMPONENT) continue;
585
+ const loc = paLocOf(f.memoizedProps);
586
+ if (!loc) continue;
587
+ const key = `${loc.file}:${loc.line}:${loc.col}`;
588
+ if (seen.has(key)) continue;
589
+ seen.add(key);
590
+ out.push({ name: compOf(f.memoizedProps) ?? 'View', loc, fiber: f });
591
+ }
592
+ return out.reverse();
593
+ }
594
+
432
595
  /**
433
596
  * Resolve a tap (in window coordinates) to a {@link PickResult}.
434
597
  *
@@ -466,42 +629,73 @@ export function resolvePick(
466
629
  try {
467
630
  fn(inspectedView, x, y, (data) => {
468
631
  clearTimeout(timer);
469
- const frame = data.frame
470
- ? {
471
- x: data.frame.left,
472
- y: data.frame.top,
473
- width: data.frame.width,
474
- height: data.frame.height,
632
+ void (async () => {
633
+ const frame = data.frame
634
+ ? {
635
+ x: data.frame.left,
636
+ y: data.frame.top,
637
+ width: data.frame.width,
638
+ height: data.frame.height,
639
+ }
640
+ : null;
641
+ const rawCrumbs = crumbsOf(data);
642
+ const loc = pickLoc(data, projectRoot);
643
+ const nameChain = nameChainOf(data);
644
+
645
+ // Measure fallback: RN's native `findNodeAtPoint` can't descend into
646
+ // content hosted by a native container — react-native-pager-view
647
+ // detaches each page from the Fabric shadow tree the hit-test walks,
648
+ // so a tap inside a pager page resolves NO touched instance
649
+ // (`closestPublicInstance` is null) and bottoms out at the page
650
+ // wrapper. The React fiber tree stays intact and the page's widgets
651
+ // are on-screen, so when there's no touched instance we hit-test
652
+ // ourselves: DFS the fiber tree from the app root (an ancestor of
653
+ // every on-screen view), measuring each host, and take the deepest
654
+ // tagged host inside the measurable region under the tap.
655
+ //
656
+ // Gated on the native hit-test failing to resolve an instance, so
657
+ // every screen where it succeeds (the common case) keeps its existing
658
+ // native path untouched — no regression. Paper surfaces only a numeric
659
+ // view tag, so `getHandleFromPublicInstance` returns null there too
660
+ // and the app-root bridge below also fails, degrading to the native
661
+ // path.
662
+ const nativeLeaf = getHandleFromPublicInstance(data.closestPublicInstance);
663
+ if (!nativeLeaf) {
664
+ const appRoot = getHandleFromPublicInstance(inspectedView);
665
+ const hit = appRoot ? await measureHitTest(appRoot, x, y, measureFiberInWindow) : null;
666
+ if (hit) {
667
+ const ancestors = taggedAncestors(hit.fiber);
668
+ const crumbFrames = await Promise.all(
669
+ ancestors.map((a) => measureFiberInWindow(a.fiber)),
670
+ );
671
+ done({
672
+ loc: hit.loc,
673
+ nameChain: ancestors.map((a) => a.name),
674
+ chain: ancestors.map((a, i) => ({
675
+ name: a.name,
676
+ loc: a.loc,
677
+ frame: crumbFrames[i] ?? null,
678
+ })),
679
+ frame: hit.frame,
680
+ });
681
+ return;
475
682
  }
476
- : null;
477
- const rawCrumbs = crumbsOf(data);
478
- const loc = pickLoc(data, projectRoot);
479
- const nameChain = nameChainOf(data);
480
- // Measure each crumb so pressing one can move the on-screen highlight
481
- // to that ancestor. Concurrent, each guarded — adds ~one measure pass.
482
- Promise.all(rawCrumbs.map((c) => measureFrame(c.measure)))
483
- .then((frames) => {
484
- done({
485
- loc,
486
- nameChain,
487
- chain: rawCrumbs.map((c, i) => ({
488
- name: c.name,
489
- loc: c.loc,
490
- frame: frames[i] ?? null,
491
- })),
492
- frame,
493
- });
494
- })
495
- .catch(() => {
496
- // Measuring failed wholesale — still return the pick without
497
- // per-crumb frames rather than hang.
498
- done({
499
- loc,
500
- nameChain,
501
- chain: rawCrumbs.map((c) => ({ name: c.name, loc: c.loc, frame: null })),
502
- frame,
503
- });
683
+ }
684
+
685
+ // Native pick: measure each crumb so pressing one can move the
686
+ // on-screen highlight to that ancestor. Concurrent, each guarded.
687
+ const frames = await Promise.all(rawCrumbs.map((c) => measureFrame(c.measure)));
688
+ done({
689
+ loc,
690
+ nameChain,
691
+ chain: rawCrumbs.map((c, i) => ({
692
+ name: c.name,
693
+ loc: c.loc,
694
+ frame: frames[i] ?? null,
695
+ })),
696
+ frame,
504
697
  });
698
+ })().catch(() => done({ loc: null, nameChain: [], chain: [], frame: null }));
505
699
  });
506
700
  } catch {
507
701
  clearTimeout(timer);
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * Soft-keyboard height hook for the RN widget's modal sheets.
5
+ *
6
+ * Both the composer (Pinagent.tsx) and the stream sheet (StreamSheet.tsx)
7
+ * present in their own `Modal`, and both need to lift their pinned input above
8
+ * the soft keyboard. `KeyboardAvoidingView` is unreliable inside a `Modal` —
9
+ * the modal presents in its own window, so the view's measured origin is wrong
10
+ * and the computed inset never lifts the sheet. Driving a `paddingBottom` inset
11
+ * off the live keyboard frame is the robust cross-platform path, so the logic
12
+ * lives here once and both sheets share it.
13
+ *
14
+ * iOS fires the `*Will*` events (in sync with the slide animation); Android
15
+ * only fires `*Did*`.
16
+ */
17
+ import { useEffect, useState } from 'react';
18
+ import { Keyboard, Platform } from 'react-native';
19
+
20
+ /** Live soft-keyboard height in px (0 when hidden). */
21
+ export function useKeyboardHeight(): number {
22
+ const [height, setHeight] = useState(0);
23
+ useEffect(() => {
24
+ const showEvt = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
25
+ const hideEvt = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
26
+ const show = Keyboard.addListener(showEvt, (e) => setHeight(e.endCoordinates.height));
27
+ const hide = Keyboard.addListener(hideEvt, () => setHeight(0));
28
+ return () => {
29
+ show.remove();
30
+ hide.remove();
31
+ };
32
+ }, []);
33
+ return height;
34
+ }
@@ -0,0 +1,40 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * Keyboard-shortcut helpers for the React Native widget.
5
+ *
6
+ * The browser widget wires its hotkeys to `document`-level `keydown`
7
+ * listeners (see packages/widget/src/keyboard.ts). React Native has no such
8
+ * global key stream in JS: the core `Keyboard` module only reports the soft
9
+ * keyboard showing/hiding, never key presses, and there's no document to
10
+ * listen on. A faithful port of the *global* web hotkeys (toggle picker,
11
+ * hop-to-next-agent, minimize-all) would need a native module — iOS
12
+ * `UIKeyCommand` / an Android key bridge — which we deliberately avoid: the RN
13
+ * widget ships as plain JS source for Metro with zero native setup.
14
+ *
15
+ * What IS reachable, and all we need in practice, are the key events a focused
16
+ * `TextInput` surfaces — so the shortcuts live where a hardware keyboard is
17
+ * actually in play (the composer and the stream sheet, both modal):
18
+ * - Return/Enter, via `onSubmitEditing` on the single-line inputs — submits
19
+ * the agent answer and the follow-up, mirroring the web composer's
20
+ * Enter-to-send. Wired directly on the input (no key inspection needed).
21
+ * - Escape, via `onKeyPress` — backs out of a sheet, mirroring the web
22
+ * widget's Escape. Decided here so the (RN-runtime-only, untestable)
23
+ * components stay thin and the rule is unit-tested.
24
+ *
25
+ * `onKeyPress`'s `nativeEvent` only carries `key` on native platforms (no
26
+ * modifier flags), so these predicates take the bare key name — there's no
27
+ * Shift/Cmd to branch on, which is also why plain Enter stays a newline in the
28
+ * multiline composer rather than hijacking submit. Hardware Back (Android) is
29
+ * handled separately by each Modal's `onRequestClose`.
30
+ */
31
+
32
+ /**
33
+ * Should this key event back out of the current sheet? Escape on a hardware
34
+ * keyboard — the RN analog of the web widget's Escape. Only the Escape key
35
+ * qualifies; every printable key (i.e. normal typing) is ignored, so an
36
+ * `onKeyPress` handler built on this never interferes with text entry.
37
+ */
38
+ export function isDismissKey(key: string): boolean {
39
+ return key === 'Escape';
40
+ }
@@ -0,0 +1,194 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Tiny, dependency-free Markdown parser for the RN widget.
4
+ *
5
+ * The agent streams its replies as Markdown — Claude writes **bold**, `code`,
6
+ * fenced blocks, bullet/numbered lists, headings and links. The StreamSheet
7
+ * used to drop that straight into a <Text>, so the markers showed up as
8
+ * literal characters. This module folds the raw text into a small block/inline
9
+ * tree that `Markdown.tsx` renders with React Native primitives — no
10
+ * markdown-it, no extra runtime dependency, in keeping with the rest of the
11
+ * native source (see transcript.ts for the same "mirror, don't import" stance).
12
+ *
13
+ * It is intentionally a *subset* of CommonMark covering what shows up in chat:
14
+ * ATX headings, fenced code, blockquotes, unordered/ordered lists, thematic
15
+ * breaks, paragraphs, and inline code / bold / italic / links. Anything it
16
+ * doesn't recognise falls through as plain text, so the worst case degrades to
17
+ * the old behaviour rather than dropping content. Pure and deterministic, so
18
+ * it's unit-tested like the transcript reducer.
19
+ */
20
+
21
+ /** An inline run carrying at most the marks we render. */
22
+ export interface InlineSpan {
23
+ text: string;
24
+ bold?: boolean;
25
+ italic?: boolean;
26
+ code?: boolean;
27
+ /** Present on link spans; the destination URL. */
28
+ href?: string;
29
+ }
30
+
31
+ export type MdBlock =
32
+ | { type: 'heading'; level: number; spans: InlineSpan[] }
33
+ | { type: 'paragraph'; spans: InlineSpan[] }
34
+ | { type: 'code'; text: string; lang?: string }
35
+ | { type: 'list'; ordered: boolean; items: InlineSpan[][] }
36
+ | { type: 'quote'; spans: InlineSpan[] }
37
+ | { type: 'hr' };
38
+
39
+ /**
40
+ * Inline scanner: walk the string and, at each step, take the earliest of the
41
+ * recognised markers — ties broken by priority (code > link > bold > italic),
42
+ * so `**x**` reads as one bold run rather than two italics, and a backtick span
43
+ * is never re-parsed for emphasis. Text between markers is emitted verbatim;
44
+ * an unbalanced marker (a lone `*`) never matches and stays literal, so we
45
+ * never swallow content.
46
+ */
47
+ export function parseInline(input: string): InlineSpan[] {
48
+ const matchers: Array<{ re: RegExp; make: (m: RegExpExecArray) => InlineSpan }> = [
49
+ { re: /`([^`]+)`/, make: (m) => ({ text: m[1] ?? '', code: true }) },
50
+ { re: /\[([^\]]+)\]\(([^)\s]+)\)/, make: (m) => ({ text: m[1] ?? '', href: m[2] ?? '' }) },
51
+ { re: /\*\*([^*]+)\*\*/, make: (m) => ({ text: m[1] ?? '', bold: true }) },
52
+ { re: /__([^_]+)__/, make: (m) => ({ text: m[1] ?? '', bold: true }) },
53
+ { re: /\*([^*]+)\*/, make: (m) => ({ text: m[1] ?? '', italic: true }) },
54
+ { re: /_([^_]+)_/, make: (m) => ({ text: m[1] ?? '', italic: true }) },
55
+ ];
56
+
57
+ const spans: InlineSpan[] = [];
58
+ let rest = input;
59
+ while (rest.length > 0) {
60
+ let best: { index: number; length: number; span: InlineSpan } | null = null;
61
+ for (const { re, make } of matchers) {
62
+ const m = re.exec(rest);
63
+ // Strictly-less keeps the higher-priority matcher on an index tie.
64
+ if (m && (best === null || m.index < best.index)) {
65
+ best = { index: m.index, length: (m[0] ?? '').length, span: make(m) };
66
+ }
67
+ }
68
+ if (best === null) {
69
+ pushText(spans, rest);
70
+ break;
71
+ }
72
+ pushText(spans, rest.slice(0, best.index));
73
+ spans.push(best.span);
74
+ rest = rest.slice(best.index + best.length);
75
+ }
76
+ return spans;
77
+ }
78
+
79
+ function pushText(spans: InlineSpan[], text: string): void {
80
+ if (text) spans.push({ text });
81
+ }
82
+
83
+ /**
84
+ * Fold raw Markdown into render-ready blocks. Line-oriented and greedy:
85
+ * consecutive list items fold into one list, consecutive `>` lines into one
86
+ * quote, and consecutive plain lines into one paragraph (soft-wrapped lines are
87
+ * joined with a space). Blank lines separate paragraphs. Pure and
88
+ * deterministic.
89
+ */
90
+ export function parseMarkdown(source: string): MdBlock[] {
91
+ const blocks: MdBlock[] = [];
92
+ const lines = source.replace(/\r\n?/g, '\n').split('\n');
93
+
94
+ let paragraph: string[] = [];
95
+ function flushParagraph(): void {
96
+ const text = paragraph.join(' ').trim();
97
+ paragraph = [];
98
+ if (text) blocks.push({ type: 'paragraph', spans: parseInline(text) });
99
+ }
100
+
101
+ let i = 0;
102
+ while (i < lines.length) {
103
+ const line = lines[i] ?? '';
104
+
105
+ // Fenced code block: ``` or ```lang … until a closing ``` (or EOF).
106
+ const fence = /^\s*```(.*)$/.exec(line);
107
+ if (fence) {
108
+ flushParagraph();
109
+ const lang = (fence[1] ?? '').trim();
110
+ const body: string[] = [];
111
+ i++;
112
+ while (i < lines.length) {
113
+ const l = lines[i] ?? '';
114
+ if (/^\s*```\s*$/.test(l)) break;
115
+ body.push(l);
116
+ i++;
117
+ }
118
+ i++; // consume the closing fence (no-op at EOF)
119
+ blocks.push({ type: 'code', text: body.join('\n'), ...(lang ? { lang } : {}) });
120
+ continue;
121
+ }
122
+
123
+ // Blank line: end the current paragraph.
124
+ if (/^\s*$/.test(line)) {
125
+ flushParagraph();
126
+ i++;
127
+ continue;
128
+ }
129
+
130
+ // Thematic break: a line of 3+ identical -, * or _.
131
+ if (/^\s*([-*_])\1{2,}\s*$/.test(line)) {
132
+ flushParagraph();
133
+ blocks.push({ type: 'hr' });
134
+ i++;
135
+ continue;
136
+ }
137
+
138
+ // ATX heading (#–######), trailing #'s stripped.
139
+ const heading = /^\s*(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
140
+ if (heading) {
141
+ flushParagraph();
142
+ blocks.push({
143
+ type: 'heading',
144
+ level: (heading[1] ?? '').length,
145
+ spans: parseInline(heading[2] ?? ''),
146
+ });
147
+ i++;
148
+ continue;
149
+ }
150
+
151
+ // Blockquote: fold consecutive `>` lines together.
152
+ if (/^\s*>/.test(line)) {
153
+ flushParagraph();
154
+ const quoted: string[] = [];
155
+ while (i < lines.length) {
156
+ const l = lines[i] ?? '';
157
+ if (!/^\s*>/.test(l)) break;
158
+ quoted.push(l.replace(/^\s*>\s?/, ''));
159
+ i++;
160
+ }
161
+ blocks.push({ type: 'quote', spans: parseInline(quoted.join(' ').trim()) });
162
+ continue;
163
+ }
164
+
165
+ // List: fold consecutive items of the same kind (ordered vs unordered).
166
+ const first = matchListItem(line);
167
+ if (first) {
168
+ flushParagraph();
169
+ const items: InlineSpan[][] = [];
170
+ while (i < lines.length) {
171
+ const it = matchListItem(lines[i] ?? '');
172
+ if (!it || it.ordered !== first.ordered) break;
173
+ items.push(parseInline(it.text));
174
+ i++;
175
+ }
176
+ blocks.push({ type: 'list', ordered: first.ordered, items });
177
+ continue;
178
+ }
179
+
180
+ // Otherwise accumulate into the running paragraph.
181
+ paragraph.push(line.trim());
182
+ i++;
183
+ }
184
+ flushParagraph();
185
+ return blocks;
186
+ }
187
+
188
+ function matchListItem(line: string): { ordered: boolean; text: string } | null {
189
+ const ul = /^\s*[-*+]\s+(.*)$/.exec(line);
190
+ if (ul) return { ordered: false, text: ul[1] ?? '' };
191
+ const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
192
+ if (ol) return { ordered: true, text: ol[1] ?? '' };
193
+ return null;
194
+ }