@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.
- package/dist/native/AgentDock.d.ts +32 -0
- package/dist/native/MarkdownView.d.ts +24 -0
- package/dist/native/Pinagent.d.ts +5 -4
- package/dist/native/StreamSheet.d.ts +18 -13
- package/dist/native/inspector.d.ts +60 -0
- package/dist/native/keyboard-height.d.ts +2 -0
- package/dist/native/keyboard.d.ts +35 -0
- package/dist/native/markdown.d.ts +65 -0
- package/dist/native/run-state.d.ts +120 -0
- package/dist/native/scroll-follow.d.ts +32 -0
- package/dist/native/ws-client.d.ts +8 -2
- package/package.json +3 -3
- package/src/native/AgentDock.tsx +232 -0
- package/src/native/MarkdownView.tsx +154 -0
- package/src/native/Pinagent.tsx +80 -58
- package/src/native/StreamSheet.tsx +188 -135
- package/src/native/inspector.ts +228 -34
- package/src/native/keyboard-height.ts +34 -0
- package/src/native/keyboard.ts +40 -0
- package/src/native/markdown.ts +194 -0
- package/src/native/run-state.ts +204 -0
- package/src/native/scroll-follow.ts +47 -0
- package/src/native/ws-client.ts +13 -5
package/src/native/inspector.ts
CHANGED
|
@@ -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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
+
}
|