@colixsystems/widget-sdk 0.133.1 → 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 CHANGED
@@ -70,7 +70,23 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
70
70
 
71
71
  ## Status
72
72
 
73
- `v0.133.1` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
73
+ `v0.134.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
74
+
75
+ ### What's new in 0.134.0 (contract 1.103.0)
76
+
77
+ **Formatted content can carry links and tables (sc-7349).** `<MarkdownInput>` and `<RichText>` covered emphasis, headings, lists and images, but a link was not in the grammar at all — so a post that said "see the Booking page" had no way to get the reader there, and a comparison table could only be faked with spaces.
78
+
79
+ ```jsx
80
+ <MarkdownInput value={draft} onChange={setDraft} pages={pages} />
81
+ <RichText value={post.body} />
82
+ ```
83
+
84
+ - **Links: `[label](target)`.** The target is stored **verbatim** and followed through the host's `navigation.openLink`, which already decides page / external / refuse. An absolute URL leaves the app; a bare page id or slug navigates inside it; anything unfollowable (`javascript:`, a control character) is refused by that one resolver. Never pre-filter a target yourself, and never route one through `Linking.openURL` — it performs anything. A link label keeps its own emphasis, so `[**Book now**](booking)` reads bold.
85
+ - **Tables: a header row, a `| --- | --- |` divider, then body rows.** `:--` / `--:` / `:-:` in a divider cell sets that column's alignment, and `\|` puts a literal pipe in a cell. Cells are equal-width columns whose text wraps, so a wide table stays inside the widget on a phone instead of scrolling sideways.
86
+ - **Two new toolbar buttons.** Table inserts a skeleton with the first header cell selected. Link opens a form for the link text plus its target — and when you pass the new optional `pages` prop (`[{ id, name }]`) it offers those pages by name, the way `renderImage` is supplied for filestore images. Without `pages` the form offers only an external address, because a widget cannot know the app's pages on its own.
87
+ - **`<RichText>` gains `followLinks`** (default `true`). The editor's own preview passes `false`, so links render styled but inert and a tap while writing cannot navigate away from the draft.
88
+
89
+ Additive — one shared view module per primitive, bound per host, so both hosts gain this together. Every string already stored keeps parsing identically: a line needs a divider row to become a table, and `![alt](src)` is still read as an image, never a link. `CONTRACT.version` → `1.103.0`.
74
90
 
75
91
  ### What's new in 0.133.1 (contract 1.102.1)
76
92
 
package/dist/contract.cjs CHANGED
@@ -1925,7 +1925,7 @@ const PRIMITIVES = [
1925
1925
  {
1926
1926
  name: "RichText",
1927
1927
  description:
1928
- 'Formatted-content renderer. `<RichText value={record.body} />`. THE way to display text an app user wrote with formatting — bold, italic, inline code, `#`/`##`/`###` headings, `-` bullets, `1.` numbered items, and one-line `![alt](src "large")` images. `value` is markdown text, NOT HTML: widgets render through React Native primitives, which have no `dangerouslySetInnerHTML` on either host, so HTML inside a content string shows up as literal tag soup — never assemble content out of `<strong>`/`<br>` tags. Stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. Props: `value` (markdown string), `renderImage` (optional `({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving those needs the scopes your own widget holds; without it only absolute http(s) URLs render), `style`, `testID`. Colour, type scale and leading all come from the workspace theme — never re-style them.',
1928
+ 'Formatted-content renderer. `<RichText value={record.body} />`. THE way to display text an app user wrote with formatting — bold, italic, inline code, `#`/`##`/`###` headings, `-` bullets, `1.` numbered items, `[label](target)` links, `| a | b |` pipe tables (a header row, a `| --- | --- |` divider, then body rows; `:--` / `--:` / `:-:` in a divider cell sets that column\'s alignment), and one-line `![alt](src "large")` images. `value` is markdown text, NOT HTML: widgets render through React Native primitives, which have no `dangerouslySetInnerHTML` on either host, so HTML inside a content string shows up as literal tag soup — never assemble content out of `<strong>`/`<br>`/`<table>` tags. Stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. A link is pressed through the host\'s `navigation.openLink`, which is what decides page / external / refuse — so a link target is just a string here: an absolute URL leaves the app, a bare page id or slug navigates inside it, and anything unfollowable (`javascript:`, a control character) is refused by that one resolver. Never pre-filter a target yourself and never route one through `Linking.openURL`, which performs anything. Props: `value` (markdown string), `renderImage` (optional `({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving those needs the scopes your own widget holds; without it only absolute http(s) URLs render), `followLinks` (default true; pass false to render links styled but inert, which is what an editing preview wants so a tap cannot discard the draft), `style`, `testID`. Colour, type scale and leading all come from the workspace theme — never re-style them. Table cells are equal-width columns whose text wraps, so a wide table stays inside the widget on a phone instead of scrolling sideways.',
1929
1929
  rnComponent: null,
1930
1930
  docsUrl: null,
1931
1931
  },
@@ -1935,7 +1935,7 @@ const PRIMITIVES = [
1935
1935
  {
1936
1936
  name: "MarkdownInput",
1937
1937
  description:
1938
- 'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. THE way to let an app USER write formatted text: a multi-line field, a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list), and a live `<RichText>` preview of the result underneath. Use it instead of a bare `TextInput` plus hand-rolled formatting buttons — buttons that splice `<strong>`/`<br>` tags into the string leave the author reading tag soup with no preview of the real output. The stored value is markdown text. Props: `value`, `onChange(next)`, `placeholder`, `previewLabel` (default "Preview"), `showPreview` (default true), `renderImage` (passed through to the preview), `minHeight` (default 150) and `maxHeight` (default 280) so the field scrolls internally instead of growing without bound, `accessibilityLabel`, `style`, `testID`.',
1938
+ 'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. THE way to let an app USER write formatted text: a multi-line field, a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list), a Table button that inserts a pipe-table skeleton with the first header cell selected, a Link button that opens a form for the link text plus its target, and a live `<RichText>` preview of the result underneath. Use it instead of a bare `TextInput` plus hand-rolled formatting buttons — buttons that splice `<strong>`/`<br>`/`<table>` tags into the string leave the author reading tag soup with no preview of the real output. The stored value is markdown text. Props: `value`, `onChange(next)`, `placeholder`, `previewLabel` (default "Preview"), `showPreview` (default true), `renderImage` (passed through to the preview), `pages` (optional `[{ id, name }]` — supply it and the Link form offers those pages to link to by name, the way `renderImage` is supplied for filestore images; without it the form offers only an external address, because a widget cannot know the app\'s pages on its own), `minHeight` (default 150) and `maxHeight` (default 280) so the field scrolls internally instead of growing without bound, `accessibilityLabel`, `style`, `testID`. Links in the preview are deliberately inert, so a tap while editing cannot navigate away from the draft.',
1939
1939
  rnComponent: null,
1940
1940
  docsUrl: null,
1941
1941
  },
@@ -3879,7 +3879,24 @@ const CONTRACT = deepFreeze({
3879
3879
  // fails the publish gate. Every signature is now a valid call and the
3880
3880
  // omittable parts moved to a new `optionalArgs` array the prompt prints
3881
3881
  // beside it. Documentation only; no hook changed shape or behaviour.
3882
- version: "1.102.1",
3882
+ // 1.103.0: additive (sc-7349) — the markdown subset behind `<RichText>` /
3883
+ // `<MarkdownInput>` gains inline `[label](target)` links and `| a | b |`
3884
+ // pipe tables, plus the two toolbar buttons that author them (Table
3885
+ // inserts a skeleton; Link opens a form for the text and its target, and
3886
+ // offers the app's pages by name when the host passes the new optional
3887
+ // `pages` prop). Formatted content could not carry a link AT ALL, so a
3888
+ // post saying "see the Booking page" had no way to get the reader there.
3889
+ // A target is stored VERBATIM and followed through the host's existing
3890
+ // `navigation.openLink`, which already decides page / external / refuse —
3891
+ // no second resolver, and no way for a stored `javascript:` to be
3892
+ // performed. `<RichText>` also gains `followLinks` (default true) so the
3893
+ // editor's own preview renders links styled but inert. Both primitives
3894
+ // stay ONE shared view module bound per host, so this is full parity, not
3895
+ // a §8 native-only case. Every string already stored keeps parsing
3896
+ // identically: a line needs a `| --- |` divider to become a table, and an
3897
+ // `![alt](src)` image is still read as an image, never a link. Minor bump
3898
+ // on the pre-1.0 channel.
3899
+ version: "1.103.0",
3883
3900
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3884
3901
  hooks: HOOKS,
3885
3902
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1925,7 +1925,7 @@ const PRIMITIVES = [
1925
1925
  {
1926
1926
  name: "RichText",
1927
1927
  description:
1928
- 'Formatted-content renderer. `<RichText value={record.body} />`. THE way to display text an app user wrote with formatting — bold, italic, inline code, `#`/`##`/`###` headings, `-` bullets, `1.` numbered items, and one-line `![alt](src "large")` images. `value` is markdown text, NOT HTML: widgets render through React Native primitives, which have no `dangerouslySetInnerHTML` on either host, so HTML inside a content string shows up as literal tag soup — never assemble content out of `<strong>`/`<br>` tags. Stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. Props: `value` (markdown string), `renderImage` (optional `({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving those needs the scopes your own widget holds; without it only absolute http(s) URLs render), `style`, `testID`. Colour, type scale and leading all come from the workspace theme — never re-style them.',
1928
+ 'Formatted-content renderer. `<RichText value={record.body} />`. THE way to display text an app user wrote with formatting — bold, italic, inline code, `#`/`##`/`###` headings, `-` bullets, `1.` numbered items, `[label](target)` links, `| a | b |` pipe tables (a header row, a `| --- | --- |` divider, then body rows; `:--` / `--:` / `:-:` in a divider cell sets that column\'s alignment), and one-line `![alt](src "large")` images. `value` is markdown text, NOT HTML: widgets render through React Native primitives, which have no `dangerouslySetInnerHTML` on either host, so HTML inside a content string shows up as literal tag soup — never assemble content out of `<strong>`/`<br>`/`<table>` tags. Stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. A link is pressed through the host\'s `navigation.openLink`, which is what decides page / external / refuse — so a link target is just a string here: an absolute URL leaves the app, a bare page id or slug navigates inside it, and anything unfollowable (`javascript:`, a control character) is refused by that one resolver. Never pre-filter a target yourself and never route one through `Linking.openURL`, which performs anything. Props: `value` (markdown string), `renderImage` (optional `({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving those needs the scopes your own widget holds; without it only absolute http(s) URLs render), `followLinks` (default true; pass false to render links styled but inert, which is what an editing preview wants so a tap cannot discard the draft), `style`, `testID`. Colour, type scale and leading all come from the workspace theme — never re-style them. Table cells are equal-width columns whose text wraps, so a wide table stays inside the widget on a phone instead of scrolling sideways.',
1929
1929
  rnComponent: null,
1930
1930
  docsUrl: null,
1931
1931
  },
@@ -1935,7 +1935,7 @@ const PRIMITIVES = [
1935
1935
  {
1936
1936
  name: "MarkdownInput",
1937
1937
  description:
1938
- 'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. THE way to let an app USER write formatted text: a multi-line field, a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list), and a live `<RichText>` preview of the result underneath. Use it instead of a bare `TextInput` plus hand-rolled formatting buttons — buttons that splice `<strong>`/`<br>` tags into the string leave the author reading tag soup with no preview of the real output. The stored value is markdown text. Props: `value`, `onChange(next)`, `placeholder`, `previewLabel` (default "Preview"), `showPreview` (default true), `renderImage` (passed through to the preview), `minHeight` (default 150) and `maxHeight` (default 280) so the field scrolls internally instead of growing without bound, `accessibilityLabel`, `style`, `testID`.',
1938
+ 'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. THE way to let an app USER write formatted text: a multi-line field, a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list), a Table button that inserts a pipe-table skeleton with the first header cell selected, a Link button that opens a form for the link text plus its target, and a live `<RichText>` preview of the result underneath. Use it instead of a bare `TextInput` plus hand-rolled formatting buttons — buttons that splice `<strong>`/`<br>`/`<table>` tags into the string leave the author reading tag soup with no preview of the real output. The stored value is markdown text. Props: `value`, `onChange(next)`, `placeholder`, `previewLabel` (default "Preview"), `showPreview` (default true), `renderImage` (passed through to the preview), `pages` (optional `[{ id, name }]` — supply it and the Link form offers those pages to link to by name, the way `renderImage` is supplied for filestore images; without it the form offers only an external address, because a widget cannot know the app\'s pages on its own), `minHeight` (default 150) and `maxHeight` (default 280) so the field scrolls internally instead of growing without bound, `accessibilityLabel`, `style`, `testID`. Links in the preview are deliberately inert, so a tap while editing cannot navigate away from the draft.',
1939
1939
  rnComponent: null,
1940
1940
  docsUrl: null,
1941
1941
  },
@@ -3879,7 +3879,24 @@ const CONTRACT = deepFreeze({
3879
3879
  // fails the publish gate. Every signature is now a valid call and the
3880
3880
  // omittable parts moved to a new `optionalArgs` array the prompt prints
3881
3881
  // beside it. Documentation only; no hook changed shape or behaviour.
3882
- version: "1.102.1",
3882
+ // 1.103.0: additive (sc-7349) — the markdown subset behind `<RichText>` /
3883
+ // `<MarkdownInput>` gains inline `[label](target)` links and `| a | b |`
3884
+ // pipe tables, plus the two toolbar buttons that author them (Table
3885
+ // inserts a skeleton; Link opens a form for the text and its target, and
3886
+ // offers the app's pages by name when the host passes the new optional
3887
+ // `pages` prop). Formatted content could not carry a link AT ALL, so a
3888
+ // post saying "see the Booking page" had no way to get the reader there.
3889
+ // A target is stored VERBATIM and followed through the host's existing
3890
+ // `navigation.openLink`, which already decides page / external / refuse —
3891
+ // no second resolver, and no way for a stored `javascript:` to be
3892
+ // performed. `<RichText>` also gains `followLinks` (default true) so the
3893
+ // editor's own preview renders links styled but inert. Both primitives
3894
+ // stay ONE shared view module bound per host, so this is full parity, not
3895
+ // a §8 native-only case. Every string already stored keeps parsing
3896
+ // identically: a line needs a `| --- |` divider to become a table, and an
3897
+ // `![alt](src)` image is still read as an image, never a link. Minor bump
3898
+ // on the pre-1.0 channel.
3899
+ version: "1.103.0",
3883
3900
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3884
3901
  hooks: HOOKS,
3885
3902
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -188,6 +188,17 @@ export function useHostTheme() {
188
188
  return ctx && ctx.workspace ? ctx.workspace.theme : undefined;
189
189
  }
190
190
 
191
+ // @internal — the navigation counterpart of useHostTheme: reads the host's
192
+ // navigation slice WITHOUT throwing when no provider is mounted, unlike
193
+ // useNavigation() which asserts a widget context. `<RichText>` reads it so a
194
+ // link in formatted content is followable inside a widget yet the component
195
+ // stays renderable anywhere (a Studio preview, a test harness). Not part of the
196
+ // public widget hook surface — not re-exported from index.js.
197
+ export function useHostNavigation() {
198
+ const ctx = useContext(HostWidgetContext);
199
+ return ctx ? ctx.navigation : undefined;
200
+ }
201
+
191
202
  /**
192
203
  * REQ-THEME-13 — returns the author-set per-widget style values: the object the
193
204
  * host delivers under `props.style`, keyed by the style-field names the widget
package/dist/index.js CHANGED
@@ -113,6 +113,8 @@ export {
113
113
  isHttpImageSrc,
114
114
  isSafeMarkdownImageSrc,
115
115
  normaliseMarkdownImageSize,
116
+ escapeMarkdownLabel,
117
+ formatMarkdownTable,
116
118
  MARKDOWN_IMAGE_SIZES,
117
119
  } from "./markdown.js";
118
120
  export {
@@ -107,6 +107,8 @@ export {
107
107
  isHttpImageSrc,
108
108
  isSafeMarkdownImageSrc,
109
109
  normaliseMarkdownImageSize,
110
+ escapeMarkdownLabel,
111
+ formatMarkdownTable,
110
112
  MARKDOWN_IMAGE_SIZES,
111
113
  } from "./markdown.js";
112
114
  export {
@@ -5,8 +5,13 @@
5
5
  // author's SELECTION rather than appending at the end, and that is a thing
6
6
  // tests can pin without a renderer.
7
7
 
8
+ import { escapeMarkdownLabel, formatMarkdownTable } from "./markdown.js";
9
+
8
10
  const LINE_RE = /^(\s*)((?:#{1,3}\s)|(?:[-*]\s)|(?:\d+[.)]\s))?([\s\S]*)$/;
9
11
 
12
+ // The literal the inserted table skeleton opens with; the caret lands on it.
13
+ const FIRST_HEADING = "Column 1";
14
+
10
15
  const LINE_KINDS = Object.freeze({
11
16
  heading2: { marker: "## ", test: /^##\s/ },
12
17
  heading3: { marker: "### ", test: /^###\s/ },
@@ -86,6 +91,66 @@ function linePrefix(kind) {
86
91
  };
87
92
  }
88
93
 
94
+ // sc-7349 — a link needs a TARGET, which no selection can supply, so this is
95
+ // the one op that takes a third argument. The target is stored verbatim: the
96
+ // host's `navigation.openLink` is what decides page / external / refuse, so
97
+ // this must not pre-judge it (see markdown.js).
98
+ function linkOp() {
99
+ return (text, selection, options) => {
100
+ const { src, start, end } = readRange(text, selection);
101
+ // A bare string is accepted as the target, so the common case stays terse.
102
+ const opts =
103
+ options && typeof options === "object" ? options : { target: options };
104
+ const href = typeof opts.target === "string" ? opts.target.trim() : "";
105
+ // Nothing to point at — leave the text exactly as the author left it.
106
+ if (!href) return { value: src, selection: { start, end } };
107
+
108
+ // An explicit label wins, so a dialog can name the link when the author
109
+ // had nothing selected; otherwise the selection IS the label.
110
+ const chosen =
111
+ typeof opts.label === "string" && opts.label.trim()
112
+ ? opts.label
113
+ : src.slice(start, end);
114
+ const label = escapeMarkdownLabel(chosen) || href;
115
+ const markup = `[${label}](${href})`;
116
+ return {
117
+ value: src.slice(0, start) + markup + src.slice(end),
118
+ // Leaves the LABEL selected, so typing replaces the link text rather
119
+ // than the markup around it.
120
+ selection: { start: start + 1, end: start + 1 + label.length },
121
+ };
122
+ };
123
+ }
124
+
125
+ // A table is a block, so it needs its own lines: a blank line before keeps it
126
+ // from fusing with the paragraph the caret was in.
127
+ function tableOp() {
128
+ return (text, selection) => {
129
+ const { src, start, end } = readRange(text, selection);
130
+ const before = src.slice(0, start);
131
+ const after = src.slice(end);
132
+ const lead =
133
+ before.length === 0 || before.endsWith("\n\n")
134
+ ? ""
135
+ : before.endsWith("\n")
136
+ ? "\n"
137
+ : "\n\n";
138
+ const trail = after.startsWith("\n") ? "" : "\n";
139
+
140
+ const skeleton = formatMarkdownTable();
141
+ const offset = before.length + lead.length;
142
+ return {
143
+ value: before + lead + skeleton + trail + after,
144
+ // Selects the first header cell, so the author types over "Column 1"
145
+ // instead of hunting for the caret inside the pipes.
146
+ selection: {
147
+ start: offset + 2,
148
+ end: offset + 2 + FIRST_HEADING.length,
149
+ },
150
+ };
151
+ };
152
+ }
153
+
89
154
  export const markdownEditOps = Object.freeze({
90
155
  bold: wrap("**"),
91
156
  italic: wrap("*"),
@@ -94,4 +159,6 @@ export const markdownEditOps = Object.freeze({
94
159
  heading3: linePrefix("heading3"),
95
160
  bullet: linePrefix("bullet"),
96
161
  ordered: linePrefix("ordered"),
162
+ link: linkOp(),
163
+ table: tableOp(),
97
164
  });
@@ -13,6 +13,7 @@ import React from "react";
13
13
  import { useHostTheme } from "./hooks.js";
14
14
  import { resolveRichTextTokens } from "./richtext-tokens.js";
15
15
  import { markdownEditOps } from "./markdown-edit.js";
16
+ import { makeMarkdownLinkDialog } from "./markdown-link-dialog.js";
16
17
 
17
18
  const TOOLS = Object.freeze([
18
19
  { key: "bold", label: "B", accessibilityLabel: "Bold", weight: "700" },
@@ -22,10 +23,20 @@ const TOOLS = Object.freeze([
22
23
  { key: "heading3", label: "H3", accessibilityLabel: "Subheading" },
23
24
  { key: "bullet", label: "•", accessibilityLabel: "Bulleted list" },
24
25
  { key: "ordered", label: "1.", accessibilityLabel: "Numbered list" },
26
+ { key: "table", label: "Table", accessibilityLabel: "Insert table" },
27
+ // sc-7349: the one tool a selection cannot complete — a link needs a target,
28
+ // so this button opens the form instead of editing the string directly.
29
+ {
30
+ key: "link",
31
+ label: "Link",
32
+ accessibilityLabel: "Insert link",
33
+ opensDialog: true,
34
+ },
25
35
  ]);
26
36
 
27
- export function makeMarkdownInput(rn, RichText) {
37
+ export function makeMarkdownInput(rn, RichText, Overlay) {
28
38
  const { Text, View, Pressable, TextInput } = rn;
39
+ const MarkdownLinkDialog = makeMarkdownLinkDialog(rn, Overlay);
29
40
 
30
41
  function MarkdownInput({
31
42
  value,
@@ -34,6 +45,7 @@ export function makeMarkdownInput(rn, RichText) {
34
45
  previewLabel = "Preview",
35
46
  showPreview = true,
36
47
  renderImage,
48
+ pages,
37
49
  minHeight = 150,
38
50
  maxHeight = 280,
39
51
  accessibilityLabel,
@@ -43,6 +55,7 @@ export function makeMarkdownInput(rn, RichText) {
43
55
  const theme = useHostTheme();
44
56
  const tokens = React.useMemo(() => resolveRichTextTokens(theme), [theme]);
45
57
  const [selection, setSelection] = React.useState({ start: 0, end: 0 });
58
+ const [linkOpen, setLinkOpen] = React.useState(false);
46
59
 
47
60
  const text = typeof value === "string" ? value : "";
48
61
 
@@ -52,17 +65,43 @@ export function makeMarkdownInput(rn, RichText) {
52
65
  }, []);
53
66
 
54
67
  const applyTool = React.useCallback(
55
- (toolKey) => {
68
+ (toolKey, options) => {
56
69
  if (typeof onChange !== "function") return;
57
70
  const edit = markdownEditOps[toolKey];
58
71
  if (!edit) return;
59
- const next = edit(text, selection);
72
+ const next = edit(text, selection, options);
60
73
  onChange(next.value);
61
74
  setSelection(next.selection);
62
75
  },
63
76
  [onChange, text, selection],
64
77
  );
65
78
 
79
+ const pressTool = React.useCallback(
80
+ (tool) => {
81
+ if (tool.opensDialog) {
82
+ setLinkOpen(true);
83
+ return;
84
+ }
85
+ applyTool(tool.key);
86
+ },
87
+ [applyTool],
88
+ );
89
+
90
+ const insertLink = React.useCallback(
91
+ (result) => {
92
+ setLinkOpen(false);
93
+ applyTool("link", result);
94
+ },
95
+ [applyTool],
96
+ );
97
+
98
+ // The selection seeds the dialog's Link text, so highlighting a phrase and
99
+ // pressing Link keeps that phrase as the label.
100
+ const selected = text.slice(
101
+ Math.min(selection.start, selection.end),
102
+ Math.max(selection.start, selection.end),
103
+ );
104
+
66
105
  const fieldStyle = {
67
106
  fontFamily: tokens.bodyFont,
68
107
  fontSize: tokens.bodySize,
@@ -96,7 +135,7 @@ export function makeMarkdownInput(rn, RichText) {
96
135
  Pressable,
97
136
  {
98
137
  key: tool.key,
99
- onPress: () => applyTool(tool.key),
138
+ onPress: () => pressTool(tool),
100
139
  accessibilityRole: "button",
101
140
  accessibilityLabel: tool.accessibilityLabel,
102
141
  testID: testID ? `${testID}-${tool.key}` : undefined,
@@ -159,10 +198,23 @@ export function makeMarkdownInput(rn, RichText) {
159
198
  React.createElement(RichText, {
160
199
  value: text,
161
200
  renderImage,
201
+ // sc-7349: the preview shows the reader's document, but a tap
202
+ // must NOT leave the page — that would throw away the draft the
203
+ // author is still writing.
204
+ followLinks: false,
162
205
  testID: testID ? `${testID}-preview` : undefined,
163
206
  }),
164
207
  )
165
208
  : null,
209
+ React.createElement(MarkdownLinkDialog, {
210
+ visible: linkOpen,
211
+ onClose: () => setLinkOpen(false),
212
+ onSubmit: insertLink,
213
+ tokens,
214
+ pages,
215
+ initialLabel: selected,
216
+ testID,
217
+ }),
166
218
  );
167
219
  }
168
220
 
@@ -5,5 +5,6 @@
5
5
  import * as ReactNative from "react-native-web";
6
6
  import { makeMarkdownInput } from "./markdown-input-view.js";
7
7
  import { RichText } from "./richtext.js";
8
+ import { Overlay } from "./overlay.js";
8
9
 
9
- export const MarkdownInput = makeMarkdownInput(ReactNative, RichText);
10
+ export const MarkdownInput = makeMarkdownInput(ReactNative, RichText, Overlay);
@@ -2,11 +2,15 @@
2
2
  // ./markdown-input-view.js to React Native's own primitives and the native
3
3
  // `<RichText>`. Only the imports differ from the web mirror.
4
4
 
5
- import { Text, View, Pressable, TextInput } from "react-native";
5
+ import { Text, View, Pressable, TextInput, ScrollView } from "react-native";
6
6
  import { makeMarkdownInput } from "./markdown-input-view.js";
7
7
  import { RichText } from "./richtext.native.js";
8
+ import { Overlay } from "./overlay.native.js";
8
9
 
10
+ // sc-7349: ScrollView joins the set for the link form's page list, which must
11
+ // scroll rather than grow an app's every page past the panel.
9
12
  export const MarkdownInput = makeMarkdownInput(
10
- { Text, View, Pressable, TextInput },
13
+ { Text, View, Pressable, TextInput, ScrollView },
11
14
  RichText,
15
+ Overlay,
12
16
  );
@@ -0,0 +1,284 @@
1
+ // sc-7349 — the link form `<MarkdownInput>`'s Link button opens.
2
+ //
3
+ // A link needs a TARGET, and no selection can supply one: the author has to
4
+ // name either a page in this app or an external address. That is a form, and a
5
+ // form belongs in an `<Overlay>` — the SDK primitive that escapes the widget's
6
+ // own clipping box on both hosts — not in a bespoke absolutely-positioned panel
7
+ // the layout container would crop.
8
+ //
9
+ // Split out of markdown-input-view.js so each file stays one component's worth
10
+ // of markup. Like every view module here it is bound per host by the caller
11
+ // (see markdown-input.js / .native.js), so the two hosts cannot drift.
12
+
13
+ import React from "react";
14
+
15
+ // The page list scrolls rather than growing an app's 40 pages past the panel.
16
+ const PAGE_LIST_MAX_HEIGHT = 180;
17
+
18
+ export function makeMarkdownLinkDialog(rn, Overlay) {
19
+ const { Text, View, Pressable, TextInput, ScrollView } = rn;
20
+
21
+ function Field({ label, tokens, children }) {
22
+ return React.createElement(
23
+ View,
24
+ { style: { marginBottom: tokens.blockGap } },
25
+ React.createElement(
26
+ Text,
27
+ {
28
+ style: {
29
+ fontFamily: tokens.bodyFont,
30
+ fontSize: tokens.codeSize,
31
+ color: tokens.mutedColor,
32
+ marginBottom: tokens.markerGap,
33
+ },
34
+ },
35
+ label,
36
+ ),
37
+ children,
38
+ );
39
+ }
40
+
41
+ /**
42
+ * @param {object} props
43
+ * @param {boolean} props.visible
44
+ * @param {() => void} props.onClose
45
+ * @param {(result: {label: string, target: string}) => void} props.onSubmit
46
+ * @param {object} props.tokens — resolveRichTextTokens output.
47
+ * @param {Array<{id: string, name: string}>} props.pages — pickable pages;
48
+ * empty means this host offered none, so only the URL mode is shown.
49
+ * @param {string} props.initialLabel — the author's current selection.
50
+ * @param {string} [props.testID]
51
+ */
52
+ function MarkdownLinkDialog({
53
+ visible,
54
+ onClose,
55
+ onSubmit,
56
+ tokens,
57
+ pages,
58
+ initialLabel,
59
+ testID,
60
+ }) {
61
+ const pageList = Array.isArray(pages) ? pages.filter((p) => p && p.id) : [];
62
+ const canPickPage = pageList.length > 0;
63
+
64
+ const [label, setLabel] = React.useState("");
65
+ const [url, setUrl] = React.useState("");
66
+ const [pageId, setPageId] = React.useState("");
67
+ const [mode, setMode] = React.useState("url");
68
+
69
+ // Re-seed on each open: the author's selection and the mode they should
70
+ // land in are both properties of THIS opening, not of the component.
71
+ React.useEffect(() => {
72
+ if (!visible) return;
73
+ setLabel(typeof initialLabel === "string" ? initialLabel : "");
74
+ setUrl("");
75
+ setPageId("");
76
+ setMode(canPickPage ? "page" : "url");
77
+ }, [visible, initialLabel, canPickPage]);
78
+
79
+ const target = mode === "page" ? pageId : url.trim();
80
+
81
+ const submit = () => {
82
+ if (!target) return;
83
+ if (typeof onSubmit === "function") onSubmit({ label, target });
84
+ };
85
+
86
+ const inputStyle = {
87
+ fontFamily: tokens.bodyFont,
88
+ fontSize: tokens.bodySize,
89
+ color: tokens.color,
90
+ backgroundColor: tokens.surface,
91
+ borderWidth: 1,
92
+ borderColor: tokens.border,
93
+ borderRadius: tokens.radius,
94
+ padding: tokens.markerGap,
95
+ };
96
+
97
+ const tab = (key, text) =>
98
+ React.createElement(
99
+ Pressable,
100
+ {
101
+ key,
102
+ onPress: () => setMode(key),
103
+ accessibilityRole: "button",
104
+ accessibilityState: { selected: mode === key },
105
+ testID: testID ? `${testID}-mode-${key}` : undefined,
106
+ style: {
107
+ paddingVertical: tokens.markerGap,
108
+ paddingHorizontal: tokens.padding,
109
+ marginRight: tokens.markerGap,
110
+ borderWidth: 1,
111
+ borderColor: mode === key ? tokens.accent : tokens.border,
112
+ borderRadius: tokens.radius,
113
+ ...(mode === key ? { backgroundColor: tokens.accent } : null),
114
+ },
115
+ },
116
+ React.createElement(
117
+ Text,
118
+ {
119
+ style: {
120
+ fontFamily: tokens.bodyFont,
121
+ fontSize: tokens.codeSize,
122
+ fontWeight: "500",
123
+ color: mode === key ? tokens.onAccent : tokens.color,
124
+ },
125
+ },
126
+ text,
127
+ ),
128
+ );
129
+
130
+ const action = (key, text, { primary, disabled, onPress }) =>
131
+ React.createElement(
132
+ Pressable,
133
+ {
134
+ key,
135
+ onPress,
136
+ disabled,
137
+ accessibilityRole: "button",
138
+ testID: testID ? `${testID}-${key}` : undefined,
139
+ style: {
140
+ paddingVertical: tokens.markerGap,
141
+ paddingHorizontal: tokens.padding,
142
+ marginLeft: tokens.markerGap,
143
+ borderWidth: 1,
144
+ borderColor: primary ? tokens.accent : tokens.border,
145
+ borderRadius: tokens.radius,
146
+ opacity: disabled ? 0.5 : 1,
147
+ ...(primary ? { backgroundColor: tokens.accent } : null),
148
+ },
149
+ },
150
+ React.createElement(
151
+ Text,
152
+ {
153
+ style: {
154
+ fontFamily: tokens.bodyFont,
155
+ fontSize: tokens.codeSize,
156
+ fontWeight: "500",
157
+ color: primary ? tokens.onAccent : tokens.color,
158
+ },
159
+ },
160
+ text,
161
+ ),
162
+ );
163
+
164
+ return React.createElement(
165
+ Overlay,
166
+ {
167
+ visible: !!visible,
168
+ onRequestClose: onClose,
169
+ size: "sm",
170
+ accessibilityLabel: "Insert link",
171
+ testID: testID ? `${testID}-dialog` : undefined,
172
+ },
173
+ React.createElement(
174
+ Field,
175
+ { label: "Link text", tokens },
176
+ React.createElement(TextInput, {
177
+ value: label,
178
+ onChangeText: setLabel,
179
+ placeholder: "Text the reader sees",
180
+ placeholderTextColor: tokens.mutedColor,
181
+ accessibilityLabel: "Link text",
182
+ style: inputStyle,
183
+ testID: testID ? `${testID}-label` : undefined,
184
+ }),
185
+ ),
186
+
187
+ // With no pages to offer there is nothing to choose between, so the
188
+ // switcher is hidden rather than shown with one dead option.
189
+ canPickPage
190
+ ? React.createElement(
191
+ View,
192
+ { style: { flexDirection: "row", marginBottom: tokens.blockGap } },
193
+ tab("page", "A page in this app"),
194
+ tab("url", "An external URL"),
195
+ )
196
+ : null,
197
+
198
+ mode === "page" && canPickPage
199
+ ? React.createElement(
200
+ Field,
201
+ { label: "Page", tokens },
202
+ React.createElement(
203
+ ScrollView,
204
+ {
205
+ style: {
206
+ maxHeight: PAGE_LIST_MAX_HEIGHT,
207
+ borderWidth: 1,
208
+ borderColor: tokens.border,
209
+ borderRadius: tokens.radius,
210
+ },
211
+ testID: testID ? `${testID}-pages` : undefined,
212
+ },
213
+ pageList.map((page) =>
214
+ React.createElement(
215
+ Pressable,
216
+ {
217
+ key: page.id,
218
+ onPress: () => setPageId(page.id),
219
+ accessibilityRole: "button",
220
+ accessibilityState: { selected: pageId === page.id },
221
+ style: {
222
+ padding: tokens.markerGap,
223
+ ...(pageId === page.id
224
+ ? { backgroundColor: tokens.surface }
225
+ : null),
226
+ },
227
+ },
228
+ React.createElement(
229
+ Text,
230
+ {
231
+ style: {
232
+ fontFamily: tokens.bodyFont,
233
+ fontSize: tokens.bodySize,
234
+ color: pageId === page.id ? tokens.accent : tokens.color,
235
+ fontWeight: pageId === page.id ? "700" : "400",
236
+ },
237
+ },
238
+ page.name || page.id,
239
+ ),
240
+ ),
241
+ ),
242
+ ),
243
+ )
244
+ : React.createElement(
245
+ Field,
246
+ { label: "Address", tokens },
247
+ React.createElement(TextInput, {
248
+ value: url,
249
+ onChangeText: setUrl,
250
+ placeholder: "https://example.com",
251
+ placeholderTextColor: tokens.mutedColor,
252
+ accessibilityLabel: "Link address",
253
+ autoCapitalize: "none",
254
+ autoCorrect: false,
255
+ keyboardType: "url",
256
+ style: inputStyle,
257
+ testID: testID ? `${testID}-url` : undefined,
258
+ }),
259
+ ),
260
+
261
+ React.createElement(
262
+ View,
263
+ {
264
+ style: {
265
+ flexDirection: "row",
266
+ justifyContent: "flex-end",
267
+ marginTop: tokens.markerGap,
268
+ },
269
+ },
270
+ action("cancel", "Cancel", { onPress: onClose }),
271
+ // Disabled until there is somewhere to go — an empty target would
272
+ // write `[text]()`, which reads as a link and goes nowhere.
273
+ action("insert", "Insert link", {
274
+ primary: true,
275
+ disabled: !target,
276
+ onPress: submit,
277
+ }),
278
+ ),
279
+ );
280
+ }
281
+
282
+ MarkdownLinkDialog.displayName = "MarkdownLinkDialog";
283
+ return MarkdownLinkDialog;
284
+ }
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.1",
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",