paseo-form-render 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,470 @@
1
+ import type { PluginHostProps } from "@getpaseo/plugin/client";
2
+ import React, { useMemo, type ReactNode } from "react";
3
+ import { Platform, StyleSheet, Text, View } from "react-native";
4
+
5
+ interface MarkdownBlock {
6
+ type: "heading" | "paragraph" | "list-item" | "code" | "table";
7
+ text?: string;
8
+ level?: number;
9
+ bullet?: string;
10
+ code?: string;
11
+ lang?: string;
12
+ headers?: string[];
13
+ rows?: string[][];
14
+ }
15
+
16
+ function isTableSeparatorLine(line: string): boolean {
17
+ const trimmed = line.trim();
18
+ if (!trimmed.includes("|") || !trimmed.includes("-")) return false;
19
+ const cells = parseTableRow(trimmed);
20
+ if (cells.length === 0) return false;
21
+ return cells.every((c) => /^:?-{1,}:?$/.test(c.trim()));
22
+ }
23
+
24
+ function parseTableRow(line: string): string[] {
25
+ let trimmed = line.trim();
26
+ if (trimmed.startsWith("|")) trimmed = trimmed.slice(1);
27
+ if (trimmed.endsWith("|")) trimmed = trimmed.slice(0, -1);
28
+ return trimmed.split("|").map((cell) => cell.trim());
29
+ }
30
+
31
+ function parseBlocks(src: string): MarkdownBlock[] {
32
+ const lines = src.replace(/\r\n/g, "\n").split("\n");
33
+ const blocks: MarkdownBlock[] = [];
34
+ let inCode = false;
35
+ let codeLines: string[] = [];
36
+ let codeLang = "";
37
+ let paragraph: string[] = [];
38
+
39
+ const flushParagraph = () => {
40
+ if (paragraph.length > 0) {
41
+ blocks.push({ type: "paragraph", text: paragraph.join("\n") });
42
+ paragraph = [];
43
+ }
44
+ };
45
+
46
+ for (let i = 0; i < lines.length; i++) {
47
+ const line = lines[i];
48
+ if (line.trim().startsWith("```")) {
49
+ if (inCode) {
50
+ blocks.push({ type: "code", code: codeLines.join("\n"), lang: codeLang });
51
+ codeLines = [];
52
+ inCode = false;
53
+ } else {
54
+ flushParagraph();
55
+ inCode = true;
56
+ codeLang = line.trim().slice(3).trim();
57
+ }
58
+ continue;
59
+ }
60
+ if (inCode) {
61
+ codeLines.push(line);
62
+ continue;
63
+ }
64
+ if (!line.trim()) {
65
+ flushParagraph();
66
+ continue;
67
+ }
68
+ if (
69
+ line.includes("|") &&
70
+ i + 1 < lines.length &&
71
+ isTableSeparatorLine(lines[i + 1])
72
+ ) {
73
+ flushParagraph();
74
+ const headers = parseTableRow(line);
75
+ const rows: string[][] = [];
76
+ i += 2;
77
+ while (i < lines.length && lines[i].trim().includes("|")) {
78
+ rows.push(parseTableRow(lines[i]));
79
+ i++;
80
+ }
81
+ i--;
82
+ blocks.push({ type: "table", headers, rows });
83
+ continue;
84
+ }
85
+ const hMatch = line.match(/^(#{1,6})\s+(.*)$/);
86
+ if (hMatch) {
87
+ flushParagraph();
88
+ blocks.push({ type: "heading", level: hMatch[1].length, text: hMatch[2] });
89
+ continue;
90
+ }
91
+ const listMatch = line.match(/^(\*|-|\d+\.)\s+(.*)$/);
92
+ if (listMatch) {
93
+ flushParagraph();
94
+ blocks.push({ type: "list-item", bullet: listMatch[1], text: listMatch[2] });
95
+ continue;
96
+ }
97
+ paragraph.push(line);
98
+ }
99
+ flushParagraph();
100
+ if (inCode && codeLines.length > 0) {
101
+ blocks.push({ type: "code", code: codeLines.join("\n"), lang: codeLang });
102
+ }
103
+ return blocks;
104
+ }
105
+
106
+ const WEB_TEXT_SELECTABLE = Platform.OS === "web" ? { userSelect: "text" as const } : {};
107
+
108
+ /**
109
+ * Typography that mirrors Paseo's own markdown scale: body text uses `fontSize.content` (15) with a
110
+ * 1.4 line height, inline/code blocks use `fontSize.code` (12) and headings are scaled from the same
111
+ * base (h1 28, h2 24, h3 21, h4 19, h5/h6 17 with a 1.3 line height).
112
+ *
113
+ * This matters because a message that contains a pfr-form block is rendered by this plugin instead
114
+ * of the host's markdown renderer. With smaller values the surrounding text of such a reply looked
115
+ * shrunk compared to the same text in a plain assistant message.
116
+ */
117
+ const CONTENT_FONT_SIZE = 15;
118
+ const CONTENT_LINE_HEIGHT = 21;
119
+ const CODE_FONT_SIZE = 12;
120
+ const CODE_LINE_HEIGHT = 22;
121
+ const HEADING_FONT_SIZES = [28, 24, 21, 19, 17, 17];
122
+ const HEADING_LINE_HEIGHTS = [36, 31, 27, 25, 22, 22];
123
+ /** Host spacing scale (spacing[2] = 8, spacing[3] = 12, spacing[4] = 16, spacing[6] = 24). */
124
+ const SPACE_1 = 4;
125
+ const SPACE_2 = 8;
126
+ const SPACE_3 = 12;
127
+ const SPACE_4 = 16;
128
+ const SPACE_6 = 24;
129
+
130
+ function parseInline(text: string, color: string, codeBg: string): ReactNode[] {
131
+ const regex = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g;
132
+ const parts: ReactNode[] = [];
133
+ let lastIndex = 0;
134
+ let match: RegExpExecArray | null;
135
+
136
+ while ((match = regex.exec(text)) !== null) {
137
+ if (match.index > lastIndex) {
138
+ parts.push(text.slice(lastIndex, match.index));
139
+ }
140
+ const token = match[0];
141
+ if (token.startsWith("`") && token.endsWith("`")) {
142
+ parts.push(
143
+ <Text
144
+ key={match.index}
145
+ selectable
146
+ dataSet={{ paseoMarkdownTag: "code" }}
147
+ style={{
148
+ color,
149
+ fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }),
150
+ fontSize: CODE_FONT_SIZE,
151
+ lineHeight: CODE_LINE_HEIGHT,
152
+ backgroundColor: codeBg,
153
+ paddingHorizontal: SPACE_1,
154
+ paddingVertical: 2,
155
+ borderRadius: 4,
156
+ ...WEB_TEXT_SELECTABLE,
157
+ }}
158
+ >
159
+ {token.slice(1, -1)}
160
+ </Text>,
161
+ );
162
+ } else if (token.startsWith("**") && token.endsWith("**")) {
163
+ parts.push(
164
+ <Text
165
+ key={match.index}
166
+ selectable
167
+ dataSet={{ paseoMarkdownTag: "strong" }}
168
+ style={{
169
+ fontWeight: "bold",
170
+ color,
171
+ ...WEB_TEXT_SELECTABLE,
172
+ }}
173
+ >
174
+ {token.slice(2, -2)}
175
+ </Text>,
176
+ );
177
+ } else if (token.startsWith("*") && token.endsWith("*")) {
178
+ parts.push(
179
+ <Text
180
+ key={match.index}
181
+ selectable
182
+ dataSet={{ paseoMarkdownTag: "em" }}
183
+ style={{
184
+ fontStyle: "italic",
185
+ color,
186
+ ...WEB_TEXT_SELECTABLE,
187
+ }}
188
+ >
189
+ {token.slice(1, -1)}
190
+ </Text>,
191
+ );
192
+ } else if (token.startsWith("[") && token.includes("](")) {
193
+ const closeBracket = token.indexOf("]");
194
+ const linkText = token.slice(1, closeBracket);
195
+ parts.push(
196
+ <Text
197
+ key={match.index}
198
+ selectable
199
+ dataSet={{ paseoMarkdownTag: "a" }}
200
+ style={{
201
+ color,
202
+ textDecorationLine: "underline",
203
+ ...WEB_TEXT_SELECTABLE,
204
+ }}
205
+ >
206
+ {linkText}
207
+ </Text>,
208
+ );
209
+ }
210
+ lastIndex = regex.lastIndex;
211
+ }
212
+ if (lastIndex < text.length) {
213
+ parts.push(text.slice(lastIndex));
214
+ }
215
+ return parts;
216
+ }
217
+
218
+ export function MarkdownText({
219
+ text,
220
+ theme,
221
+ }: Pick<PluginHostProps, "theme"> & { text: string }) {
222
+ const blocks = useMemo(() => {
223
+ try {
224
+ return parseBlocks(text);
225
+ } catch {
226
+ return [{ type: "paragraph" as const, text }];
227
+ }
228
+ }, [text]);
229
+ const color = theme.colors.foreground;
230
+ const codeBg = theme.colors.surface2;
231
+
232
+ return (
233
+ <View style={styles.container}>
234
+ {blocks.map((block, index) => {
235
+ if (block.type === "heading") {
236
+ const level = Math.min(Math.max(block.level || 1, 1), 6);
237
+ const fontSize = HEADING_FONT_SIZES[level - 1];
238
+ const lineHeight = HEADING_LINE_HEIGHTS[level - 1];
239
+ return (
240
+ <Text
241
+ key={index}
242
+ selectable
243
+ dataSet={{ paseoMarkdownTag: `h${level}` }}
244
+ style={{
245
+ color,
246
+ fontSize,
247
+ lineHeight,
248
+ fontWeight: level <= 2 ? "bold" : "600",
249
+ marginTop: level === 1 ? SPACE_6 : level <= 4 ? SPACE_4 : SPACE_3,
250
+ marginBottom: level === 1 ? SPACE_3 : level <= 4 ? SPACE_2 : SPACE_1,
251
+ ...(level === 1
252
+ ? {
253
+ borderBottomWidth: 1,
254
+ borderBottomColor: theme.colors.border,
255
+ paddingBottom: SPACE_2,
256
+ }
257
+ : null),
258
+ ...(level === 6 ? { textTransform: "uppercase" as const, letterSpacing: 0.5 } : null),
259
+ ...WEB_TEXT_SELECTABLE,
260
+ }}
261
+ >
262
+ {parseInline(block.text ?? "", color, codeBg)}
263
+ </Text>
264
+ );
265
+ }
266
+ if (block.type === "paragraph") {
267
+ return (
268
+ <Text
269
+ key={index}
270
+ selectable
271
+ dataSet={{ paseoMarkdownTag: "p" }}
272
+ style={{
273
+ color,
274
+ lineHeight: CONTENT_LINE_HEIGHT,
275
+ fontSize: CONTENT_FONT_SIZE,
276
+ marginBottom: SPACE_3,
277
+ ...WEB_TEXT_SELECTABLE,
278
+ }}
279
+ >
280
+ {parseInline(block.text ?? "", color, codeBg)}
281
+ </Text>
282
+ );
283
+ }
284
+ if (block.type === "list-item") {
285
+ return (
286
+ <View
287
+ key={index}
288
+ dataSet={{ paseoMarkdownTag: "li" }}
289
+ style={styles.listRow}
290
+ >
291
+ <Text
292
+ selectable
293
+ dataSet={{ paseoMarkdownIgnore: "true", paseoMarkdownListMarker: "true" }}
294
+ style={{
295
+ color: theme.colors.foregroundMuted,
296
+ lineHeight: CONTENT_LINE_HEIGHT,
297
+ fontSize: CONTENT_FONT_SIZE,
298
+ width: 18,
299
+ }}
300
+ >
301
+ {block.bullet?.endsWith(".") ? block.bullet : "•"}
302
+ </Text>
303
+ <Text
304
+ selectable
305
+ style={{
306
+ color,
307
+ lineHeight: CONTENT_LINE_HEIGHT,
308
+ fontSize: CONTENT_FONT_SIZE,
309
+ flex: 1,
310
+ ...WEB_TEXT_SELECTABLE,
311
+ }}
312
+ >
313
+ {parseInline(block.text ?? "", color, codeBg)}
314
+ </Text>
315
+ </View>
316
+ );
317
+ }
318
+ if (block.type === "table") {
319
+ const headers = block.headers ?? [];
320
+ const rows = block.rows ?? [];
321
+ return (
322
+ <View
323
+ key={index}
324
+ dataSet={{ paseoMarkdownTag: "table" }}
325
+ style={[
326
+ styles.tableContainer,
327
+ { borderColor: theme.colors.border },
328
+ ]}
329
+ >
330
+ <View
331
+ dataSet={{ paseoMarkdownTag: "thead" }}
332
+ style={[
333
+ styles.tableHeaderRow,
334
+ {
335
+ backgroundColor: theme.colors.surface2,
336
+ borderBottomColor: theme.colors.border,
337
+ },
338
+ ]}
339
+ >
340
+ {headers.map((header, colIdx) => (
341
+ <View
342
+ key={colIdx}
343
+ dataSet={{ paseoMarkdownTag: "th" }}
344
+ style={styles.tableCell}
345
+ >
346
+ <Text
347
+ selectable
348
+ style={{
349
+ color,
350
+ fontSize: CONTENT_FONT_SIZE,
351
+ lineHeight: CONTENT_LINE_HEIGHT,
352
+ fontWeight: "600",
353
+ ...WEB_TEXT_SELECTABLE,
354
+ }}
355
+ >
356
+ {parseInline(header, color, codeBg)}
357
+ </Text>
358
+ </View>
359
+ ))}
360
+ </View>
361
+ {rows.map((row, rowIdx) => (
362
+ <View
363
+ key={rowIdx}
364
+ dataSet={{ paseoMarkdownTag: "tr" }}
365
+ style={[
366
+ styles.tableRow,
367
+ rowIdx < rows.length - 1
368
+ ? {
369
+ borderBottomWidth: 1,
370
+ borderBottomColor: theme.colors.border,
371
+ }
372
+ : null,
373
+ ]}
374
+ >
375
+ {headers.map((_, colIdx) => (
376
+ <View
377
+ key={colIdx}
378
+ dataSet={{ paseoMarkdownTag: "td" }}
379
+ style={styles.tableCell}
380
+ >
381
+ <Text
382
+ selectable
383
+ style={{
384
+ color,
385
+ fontSize: CONTENT_FONT_SIZE,
386
+ lineHeight: CONTENT_LINE_HEIGHT,
387
+ ...WEB_TEXT_SELECTABLE,
388
+ }}
389
+ >
390
+ {parseInline(row[colIdx] ?? "", color, codeBg)}
391
+ </Text>
392
+ </View>
393
+ ))}
394
+ </View>
395
+ ))}
396
+ </View>
397
+ );
398
+ }
399
+ if (block.type === "code") {
400
+ return (
401
+ <View
402
+ key={index}
403
+ dataSet={{
404
+ paseoMarkdownTag: "pre",
405
+ paseoMarkdownLanguage: block.lang || undefined,
406
+ }}
407
+ style={[
408
+ styles.codeBlock,
409
+ { backgroundColor: codeBg },
410
+ ]}
411
+ >
412
+ <Text
413
+ selectable
414
+ dataSet={{ paseoMarkdownTag: "code" }}
415
+ style={{
416
+ color,
417
+ fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }),
418
+ fontSize: CODE_FONT_SIZE,
419
+ lineHeight: CODE_LINE_HEIGHT,
420
+ ...WEB_TEXT_SELECTABLE,
421
+ }}
422
+ >
423
+ {block.code?.trimEnd() ?? ""}
424
+ </Text>
425
+ </View>
426
+ );
427
+ }
428
+ return null;
429
+ })}
430
+ </View>
431
+ );
432
+ }
433
+
434
+ const styles = StyleSheet.create({
435
+ container: {
436
+ marginVertical: 2,
437
+ ...(Platform.OS === "web" ? { userSelect: "text" as const } : {}),
438
+ },
439
+ listRow: {
440
+ flexDirection: "row",
441
+ gap: 6,
442
+ marginVertical: 2,
443
+ },
444
+ tableContainer: {
445
+ borderWidth: 1,
446
+ borderRadius: 6,
447
+ overflow: "hidden",
448
+ marginVertical: SPACE_2,
449
+ },
450
+ tableHeaderRow: {
451
+ flexDirection: "row",
452
+ borderBottomWidth: 1,
453
+ paddingVertical: SPACE_2,
454
+ paddingHorizontal: SPACE_2,
455
+ },
456
+ tableRow: {
457
+ flexDirection: "row",
458
+ paddingVertical: SPACE_2,
459
+ paddingHorizontal: SPACE_2,
460
+ },
461
+ tableCell: {
462
+ flex: 1,
463
+ paddingRight: SPACE_2,
464
+ },
465
+ codeBlock: {
466
+ borderRadius: 6,
467
+ padding: SPACE_3,
468
+ marginVertical: SPACE_2,
469
+ },
470
+ });