@colixsystems/widget-sdk 0.133.0 → 0.134.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.
- package/README.md +36 -14
- package/dist/contract.cjs +68 -23
- package/dist/contract.js +68 -23
- package/dist/hooks.js +11 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/markdown-edit.js +67 -0
- package/dist/markdown-input-view.js +56 -4
- package/dist/markdown-input.js +2 -1
- package/dist/markdown-input.native.js +6 -2
- package/dist/markdown-link-dialog.js +284 -0
- package/dist/markdown.js +153 -19
- package/dist/richtext-view.js +118 -6
- package/package.json +1 -1
package/dist/markdown.js
CHANGED
|
@@ -11,13 +11,24 @@
|
|
|
11
11
|
// interpreted — legacy rows that still hold markup are STRIPPED, never rendered.
|
|
12
12
|
//
|
|
13
13
|
// The supported subset is deliberately small: `#`/`##`/`###` headings, `-`/`*`
|
|
14
|
-
// bullets, `1.` ordered items, one-line `` images,
|
|
15
|
-
// `**bold**`, `*italic*`/`_italic_`,
|
|
14
|
+
// bullets, `1.` ordered items, one-line `` images, GFM pipe
|
|
15
|
+
// tables, and inline `**bold**`, `*italic*`/`_italic_`, `` `code` `` and
|
|
16
|
+
// `[label](target)` links.
|
|
17
|
+
//
|
|
18
|
+
// sc-7349: a link target is NOT classified here. The host's `navigation.openLink`
|
|
19
|
+
// owns that one decision — page id/slug, external URL, or refuse — so a
|
|
20
|
+
// `javascript:` target can never be performed and the two hosts cannot disagree
|
|
21
|
+
// about what a link means.
|
|
16
22
|
|
|
17
23
|
const HEADING_RE = /^(#{1,3})\s+(.*)$/;
|
|
18
24
|
const BULLET_RE = /^\s*[-*]\s+(.*)$/;
|
|
19
25
|
const ORDERED_RE = /^\s*(\d+)[.)]\s+(.*)$/;
|
|
20
26
|
|
|
27
|
+
// A pipe table needs BOTH a header row and a `---` divider under it, so a line
|
|
28
|
+
// of prose that merely contains a `|` is never mistaken for one.
|
|
29
|
+
const TABLE_ROW_RE = /^\s*\|(.*)\|\s*$/;
|
|
30
|
+
const TABLE_DIVIDER_RE = /^\s*\|(?:\s*:?-+:?\s*\|)+\s*$/;
|
|
31
|
+
|
|
21
32
|
// An image is its own block, written on one line with the size in the title
|
|
22
33
|
// slot. Keeping blocks in the SAME markdown column as the prose is what lets
|
|
23
34
|
// content written before images existed keep rendering with no migration.
|
|
@@ -56,13 +67,17 @@ export function normaliseMarkdownImageSize(size) {
|
|
|
56
67
|
|
|
57
68
|
// A caption is the only prose an image-only post has, so `]` must survive the
|
|
58
69
|
// round trip rather than being eaten, to keep the one-line grammar unambiguous.
|
|
59
|
-
|
|
60
|
-
|
|
70
|
+
// sc-7349: a link label is escaped by the same rule — both sit in a `[...]`
|
|
71
|
+
// slot, so one escaper keeps them from drifting apart.
|
|
72
|
+
export function escapeMarkdownLabel(value) {
|
|
73
|
+
return String(value || "")
|
|
61
74
|
.replace(/[\r\n]+/g, " ")
|
|
62
75
|
.replace(/([\\\]])/g, "\\$1")
|
|
63
76
|
.trim();
|
|
64
77
|
}
|
|
65
78
|
|
|
79
|
+
const escapeAlt = escapeMarkdownLabel;
|
|
80
|
+
|
|
66
81
|
/** Un-escapes an alt captured by the image grammar. */
|
|
67
82
|
export function readMarkdownAlt(raw) {
|
|
68
83
|
return String(raw || "").replace(/\\(.)/g, "$1");
|
|
@@ -126,22 +141,42 @@ export function stripHtmlToMarkdown(text) {
|
|
|
126
141
|
.trim();
|
|
127
142
|
}
|
|
128
143
|
|
|
129
|
-
|
|
144
|
+
// A link label keeps its own emphasis, so `[**Book now**](booking)` reads bold.
|
|
145
|
+
// The RAW label is re-parsed rather than the unescaped one: by construction it
|
|
146
|
+
// holds no unescaped `]`, so the nested pass cannot form another link and the
|
|
147
|
+
// recursion is exactly one level deep.
|
|
148
|
+
function linkSpans(rawLabel, href) {
|
|
149
|
+
const spans = parseInline(rawLabel).map((span) => ({
|
|
150
|
+
...span,
|
|
151
|
+
text: readMarkdownAlt(span.text),
|
|
152
|
+
href,
|
|
153
|
+
}));
|
|
154
|
+
// `[](https://example.com)` has nothing to show but the target itself.
|
|
155
|
+
return spans.some((span) => span.text) ? spans : [{ text: href, href }];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Splits one line into `{ text, bold?, italic?, code?, href? }` spans. */
|
|
130
159
|
function parseInline(line) {
|
|
131
160
|
const source = typeof line === "string" ? line : "";
|
|
132
161
|
const spans = [];
|
|
133
|
-
// One pass over the
|
|
162
|
+
// One pass over the four delimiters. Alternation order matters: `**` must
|
|
134
163
|
// be tried before `*` or the bold marker is consumed as two italics.
|
|
135
|
-
const pattern =
|
|
164
|
+
const pattern =
|
|
165
|
+
/(!?)\[((?:[^\]\\]|\\.)*)\]\(\s*(\S+?)\s*\)|(\*\*)(.+?)\4|(`)([^`]+?)\6|([*_])(.+?)\8/g;
|
|
136
166
|
let cursor = 0;
|
|
137
167
|
let match = pattern.exec(source);
|
|
138
168
|
while (match) {
|
|
139
169
|
if (match.index > cursor) {
|
|
140
170
|
spans.push({ text: source.slice(cursor, match.index) });
|
|
141
171
|
}
|
|
142
|
-
if (match[
|
|
143
|
-
|
|
144
|
-
|
|
172
|
+
if (match[3] !== undefined) {
|
|
173
|
+
// An image is its own whole-line block, so an inline `` stays
|
|
174
|
+
// literal text rather than becoming a link to the image.
|
|
175
|
+
if (match[1]) spans.push({ text: match[0] });
|
|
176
|
+
else for (const span of linkSpans(match[2], match[3])) spans.push(span);
|
|
177
|
+
} else if (match[4]) spans.push({ text: match[5], bold: true });
|
|
178
|
+
else if (match[6]) spans.push({ text: match[7], code: true });
|
|
179
|
+
else spans.push({ text: match[9], italic: true });
|
|
145
180
|
cursor = match.index + match[0].length;
|
|
146
181
|
match = pattern.exec(source);
|
|
147
182
|
}
|
|
@@ -149,10 +184,87 @@ function parseInline(line) {
|
|
|
149
184
|
return spans.length > 0 ? spans : [{ text: source }];
|
|
150
185
|
}
|
|
151
186
|
|
|
187
|
+
// `\|` is how a literal pipe survives inside a cell, so the split has to walk
|
|
188
|
+
// the line rather than use a lookbehind — Hermes does not ship one.
|
|
189
|
+
function splitTableCells(raw) {
|
|
190
|
+
const cells = [];
|
|
191
|
+
let current = "";
|
|
192
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
193
|
+
const char = raw.charAt(i);
|
|
194
|
+
if (char === "\\" && raw.charAt(i + 1) === "|") {
|
|
195
|
+
current += "|";
|
|
196
|
+
i += 1;
|
|
197
|
+
} else if (char === "|") {
|
|
198
|
+
cells.push(current);
|
|
199
|
+
current = "";
|
|
200
|
+
} else {
|
|
201
|
+
current += char;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
cells.push(current);
|
|
205
|
+
return cells.map((cell) => cell.trim());
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A divider cell's colons carry the column's alignment: `:--` left, `--:` right,
|
|
209
|
+
// `:-:` centre. Unmarked columns return null so the renderer leaves them alone.
|
|
210
|
+
function readColumnAlignment(cell) {
|
|
211
|
+
const value = cell.trim();
|
|
212
|
+
const left = value.charAt(0) === ":";
|
|
213
|
+
const right = value.length > 1 && value.charAt(value.length - 1) === ":";
|
|
214
|
+
if (left && right) return "center";
|
|
215
|
+
if (right) return "right";
|
|
216
|
+
if (left) return "left";
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Reads a pipe table starting at `lines[start]`, or returns null when one does
|
|
222
|
+
* not begin there.
|
|
223
|
+
*
|
|
224
|
+
* @returns {{block: object, next: number} | null} `next` is the first line
|
|
225
|
+
* AFTER the table.
|
|
226
|
+
*/
|
|
227
|
+
function readTable(lines, start) {
|
|
228
|
+
const header = TABLE_ROW_RE.exec(lines[start]);
|
|
229
|
+
if (!header) return null;
|
|
230
|
+
if (!TABLE_DIVIDER_RE.test(lines[start + 1] || "")) return null;
|
|
231
|
+
|
|
232
|
+
const divider = TABLE_ROW_RE.exec(lines[start + 1]);
|
|
233
|
+
const align = splitTableCells(divider[1]).map(readColumnAlignment);
|
|
234
|
+
const rows = [
|
|
235
|
+
{ header: true, cells: splitTableCells(header[1]).map((c) => parseInline(c)) },
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
let index = start + 2;
|
|
239
|
+
while (index < lines.length) {
|
|
240
|
+
const body = TABLE_ROW_RE.exec(lines[index]);
|
|
241
|
+
if (!body) break;
|
|
242
|
+
rows.push({
|
|
243
|
+
header: false,
|
|
244
|
+
cells: splitTableCells(body[1]).map((c) => parseInline(c)),
|
|
245
|
+
});
|
|
246
|
+
index += 1;
|
|
247
|
+
}
|
|
248
|
+
return { block: { type: "table", rows, align }, next: index };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Writes an empty table of `columns` x `rows` body rows back to markdown. */
|
|
252
|
+
export function formatMarkdownTable(columns = 3, rows = 2) {
|
|
253
|
+
const width = Math.min(Math.max(Math.trunc(columns) || 0, 1), 8);
|
|
254
|
+
const height = Math.min(Math.max(Math.trunc(rows) || 0, 1), 20);
|
|
255
|
+
const line = (cells) => `| ${cells.join(" | ")} |`;
|
|
256
|
+
const heads = [];
|
|
257
|
+
for (let i = 0; i < width; i += 1) heads.push(`Column ${i + 1}`);
|
|
258
|
+
const out = [line(heads), line(heads.map(() => "---"))];
|
|
259
|
+
for (let r = 0; r < height; r += 1) out.push(line(heads.map(() => "")));
|
|
260
|
+
return out.join("\n");
|
|
261
|
+
}
|
|
262
|
+
|
|
152
263
|
/**
|
|
153
264
|
* Parses markdown text into renderable blocks:
|
|
154
|
-
* `{ type: "heading"|"paragraph"|"bullet"|"ordered", level?, marker?, spans }
|
|
155
|
-
*
|
|
265
|
+
* `{ type: "heading"|"paragraph"|"bullet"|"ordered", level?, marker?, spans }`,
|
|
266
|
+
* `{ type: "image", src, alt, size }`, or
|
|
267
|
+
* `{ type: "table", rows: [{ header, cells: spans[] }], align }`.
|
|
156
268
|
*/
|
|
157
269
|
export function parseMarkdown(text) {
|
|
158
270
|
const source = stripHtmlToMarkdown(text);
|
|
@@ -167,13 +279,24 @@ export function parseMarkdown(text) {
|
|
|
167
279
|
paragraph = [];
|
|
168
280
|
};
|
|
169
281
|
|
|
170
|
-
|
|
171
|
-
|
|
282
|
+
// Indexed rather than for-of: a table is only a table if the NEXT line is its
|
|
283
|
+
// divider, so the scan needs one line of lookahead.
|
|
284
|
+
const lines = source.split(/\r?\n/);
|
|
285
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
286
|
+
const line = lines[i].trimEnd();
|
|
172
287
|
if (!line.trim()) {
|
|
173
288
|
flushParagraph();
|
|
174
289
|
continue;
|
|
175
290
|
}
|
|
176
291
|
|
|
292
|
+
const table = readTable(lines, i);
|
|
293
|
+
if (table) {
|
|
294
|
+
flushParagraph();
|
|
295
|
+
blocks.push(table.block);
|
|
296
|
+
i = table.next - 1;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
177
300
|
const image = parseMarkdownImage(line);
|
|
178
301
|
if (image) {
|
|
179
302
|
flushParagraph();
|
|
@@ -216,14 +339,25 @@ export function parseMarkdown(text) {
|
|
|
216
339
|
return blocks;
|
|
217
340
|
}
|
|
218
341
|
|
|
342
|
+
function spansToText(spans) {
|
|
343
|
+
return spans.map((span) => span.text).join("");
|
|
344
|
+
}
|
|
345
|
+
|
|
219
346
|
/** Plain-text projection — used for search, previews, and a11y labels. */
|
|
220
347
|
export function markdownToPlainText(text) {
|
|
221
348
|
return parseMarkdown(text)
|
|
222
|
-
.map((block) =>
|
|
223
|
-
block.type === "image"
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
349
|
+
.map((block) => {
|
|
350
|
+
if (block.type === "image") return block.alt;
|
|
351
|
+
// A table has no `spans` of its own; one line per row keeps a search
|
|
352
|
+
// match and a screen reader reading the cells in their visual order.
|
|
353
|
+
if (block.type === "table") {
|
|
354
|
+
return block.rows
|
|
355
|
+
.map((row) => row.cells.map(spansToText).join(" "))
|
|
356
|
+
.filter((row) => row.trim())
|
|
357
|
+
.join("\n");
|
|
358
|
+
}
|
|
359
|
+
return spansToText(block.spans);
|
|
360
|
+
})
|
|
227
361
|
.filter(Boolean)
|
|
228
362
|
.join("\n");
|
|
229
363
|
}
|
package/dist/richtext-view.js
CHANGED
|
@@ -9,19 +9,22 @@
|
|
|
9
9
|
// hosts cannot drift (CLAUDE.md §3, §8).
|
|
10
10
|
|
|
11
11
|
import React from "react";
|
|
12
|
-
import { useHostTheme } from "./hooks.js";
|
|
12
|
+
import { useHostTheme, useHostNavigation } from "./hooks.js";
|
|
13
13
|
import { parseMarkdown, isHttpImageSrc } from "./markdown.js";
|
|
14
14
|
import { resolveRichTextTokens } from "./richtext-tokens.js";
|
|
15
15
|
|
|
16
16
|
export function makeRichText(rn) {
|
|
17
17
|
const { Text, View, Image } = rn;
|
|
18
18
|
|
|
19
|
-
function Spans({ spans, tokens }) {
|
|
20
|
-
return spans.map((span, i) =>
|
|
21
|
-
|
|
19
|
+
function Spans({ spans, tokens, onLinkPress }) {
|
|
20
|
+
return spans.map((span, i) => {
|
|
21
|
+
const followable = !!span.href && typeof onLinkPress === "function";
|
|
22
|
+
return React.createElement(
|
|
22
23
|
Text,
|
|
23
24
|
{
|
|
24
25
|
key: i,
|
|
26
|
+
onPress: followable ? () => onLinkPress(span.href) : undefined,
|
|
27
|
+
accessibilityRole: followable ? "link" : undefined,
|
|
25
28
|
style: [
|
|
26
29
|
span.bold ? { fontWeight: "700" } : null,
|
|
27
30
|
span.italic ? { fontStyle: "italic" } : null,
|
|
@@ -33,18 +36,113 @@ export function makeRichText(rn) {
|
|
|
33
36
|
color: tokens.mutedColor,
|
|
34
37
|
}
|
|
35
38
|
: null,
|
|
39
|
+
// sc-7349: a link looks like a link whether or not THIS host can
|
|
40
|
+
// follow it, so the editor preview shows the reader's document.
|
|
41
|
+
span.href
|
|
42
|
+
? { color: tokens.accent, textDecorationLine: "underline" }
|
|
43
|
+
: null,
|
|
36
44
|
],
|
|
37
45
|
},
|
|
38
46
|
span.text,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// sc-7349: cells are equal-width flex columns rather than a real table
|
|
52
|
+
// layout — `display: table` has no native equivalent, and a percentage grid
|
|
53
|
+
// is the one shape both hosts resolve identically. Long cell text therefore
|
|
54
|
+
// WRAPS instead of scrolling sideways, which is what a phone wants.
|
|
55
|
+
function TableBlock({ block, tokens, paragraph, onLinkPress }) {
|
|
56
|
+
const align = Array.isArray(block.align) ? block.align : [];
|
|
57
|
+
const rows = (Array.isArray(block.rows) ? block.rows : []).map((row) => ({
|
|
58
|
+
header: !!row.header,
|
|
59
|
+
cells: Array.isArray(row.cells) ? row.cells : [],
|
|
60
|
+
}));
|
|
61
|
+
const columns = rows.reduce(
|
|
62
|
+
(widest, row) => Math.max(widest, row.cells.length),
|
|
63
|
+
0,
|
|
64
|
+
);
|
|
65
|
+
if (columns === 0) return null;
|
|
66
|
+
|
|
67
|
+
return React.createElement(
|
|
68
|
+
View,
|
|
69
|
+
{
|
|
70
|
+
style: {
|
|
71
|
+
borderWidth: 1,
|
|
72
|
+
borderColor: tokens.border,
|
|
73
|
+
borderRadius: tokens.radius,
|
|
74
|
+
overflow: "hidden",
|
|
75
|
+
marginBottom: tokens.blockGap,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
rows.map((row, r) =>
|
|
79
|
+
React.createElement(
|
|
80
|
+
View,
|
|
81
|
+
{
|
|
82
|
+
key: r,
|
|
83
|
+
style: {
|
|
84
|
+
flexDirection: "row",
|
|
85
|
+
borderBottomWidth: r === rows.length - 1 ? 0 : 1,
|
|
86
|
+
borderBottomColor: tokens.border,
|
|
87
|
+
...(row.header ? { backgroundColor: tokens.surface } : null),
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
// Every row is padded out to the widest, so a short row cannot
|
|
91
|
+
// collapse the grid out of alignment.
|
|
92
|
+
Array.from({ length: columns }, (_unused, c) =>
|
|
93
|
+
React.createElement(
|
|
94
|
+
View,
|
|
95
|
+
{
|
|
96
|
+
key: c,
|
|
97
|
+
style: {
|
|
98
|
+
flex: 1,
|
|
99
|
+
padding: tokens.markerGap,
|
|
100
|
+
borderRightWidth: c === columns - 1 ? 0 : 1,
|
|
101
|
+
borderRightColor: tokens.border,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
React.createElement(
|
|
105
|
+
Text,
|
|
106
|
+
{
|
|
107
|
+
style: [
|
|
108
|
+
paragraph,
|
|
109
|
+
{
|
|
110
|
+
marginBottom: 0,
|
|
111
|
+
fontWeight: row.header ? "700" : "400",
|
|
112
|
+
textAlign: align[c] || "left",
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
React.createElement(Spans, {
|
|
117
|
+
spans: row.cells[c] || [],
|
|
118
|
+
tokens,
|
|
119
|
+
onLinkPress,
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
),
|
|
39
125
|
),
|
|
40
126
|
);
|
|
41
127
|
}
|
|
42
128
|
|
|
43
|
-
function RichText({ value, renderImage, style, testID }) {
|
|
129
|
+
function RichText({ value, renderImage, followLinks = true, style, testID }) {
|
|
44
130
|
const theme = useHostTheme();
|
|
131
|
+
// The non-throwing reader, so RichText stays renderable outside a widget
|
|
132
|
+
// host (a Studio preview, a test) the way DateTimePicker does.
|
|
133
|
+
const navigation = useHostNavigation();
|
|
45
134
|
const tokens = React.useMemo(() => resolveRichTextTokens(theme), [theme]);
|
|
46
135
|
const blocks = React.useMemo(() => parseMarkdown(value), [value]);
|
|
47
136
|
|
|
137
|
+
// sc-7349: `openLink` is the platform's ONE link resolver — it decides page
|
|
138
|
+
// / external / refuse, so a stored `javascript:` target can never be
|
|
139
|
+
// performed here. Never Linking.openURL, which performs anything.
|
|
140
|
+
const onLinkPress = React.useMemo(() => {
|
|
141
|
+
if (!followLinks) return null;
|
|
142
|
+
if (!navigation || typeof navigation.openLink !== "function") return null;
|
|
143
|
+
return (href) => navigation.openLink(href);
|
|
144
|
+
}, [followLinks, navigation]);
|
|
145
|
+
|
|
48
146
|
if (blocks.length === 0) return null;
|
|
49
147
|
|
|
50
148
|
const paragraph = {
|
|
@@ -59,7 +157,21 @@ export function makeRichText(rn) {
|
|
|
59
157
|
View,
|
|
60
158
|
{ style, testID },
|
|
61
159
|
blocks.map((block, i) => {
|
|
62
|
-
|
|
160
|
+
if (block.type === "table") {
|
|
161
|
+
return React.createElement(TableBlock, {
|
|
162
|
+
key: i,
|
|
163
|
+
block,
|
|
164
|
+
tokens,
|
|
165
|
+
paragraph,
|
|
166
|
+
onLinkPress,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const spans = React.createElement(Spans, {
|
|
171
|
+
spans: block.spans,
|
|
172
|
+
tokens,
|
|
173
|
+
onLinkPress,
|
|
174
|
+
});
|
|
63
175
|
|
|
64
176
|
if (block.type === "heading") {
|
|
65
177
|
return React.createElement(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.134.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"homepage": "https://github.com/Colix-AB/AppStudio",
|
|
6
6
|
"type": "module",
|