@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/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 `![alt](src "size")` images, and inline
15
- // `**bold**`, `*italic*`/`_italic_`, and `` `code` ``.
14
+ // bullets, `1.` ordered items, one-line `![alt](src "size")` 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
- function escapeAlt(alt) {
60
- return String(alt || "")
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
- /** Splits one line into `{ text, bold?, italic?, code? }` spans. */
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 three delimiters. Alternation order matters: `**` must
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 = /(\*\*)(.+?)\1|(`)([^`]+?)\3|([*_])(.+?)\5/g;
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[1]) spans.push({ text: match[2], bold: true });
143
- else if (match[3]) spans.push({ text: match[4], code: true });
144
- else spans.push({ text: match[6], italic: true });
172
+ if (match[3] !== undefined) {
173
+ // An image is its own whole-line block, so an inline `![alt](src)` 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
- * or `{ type: "image", src, alt, size }`.
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
- for (const rawLine of source.split(/\r?\n/)) {
171
- const line = rawLine.trimEnd();
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
- ? block.alt
225
- : block.spans.map((span) => span.text).join(""),
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
  }
@@ -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
- React.createElement(
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
- const spans = React.createElement(Spans, { spans: block.spans, tokens });
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.133.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",