@colixsystems/widget-sdk 0.122.0 → 0.123.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 +37 -3
- package/dist/contract.cjs +38 -4
- package/dist/contract.js +38 -4
- package/dist/index.d.ts +124 -0
- package/dist/index.js +17 -0
- package/dist/index.native.js +15 -0
- package/dist/linter.cjs +43 -0
- package/dist/linter.js +58 -0
- package/dist/markdown-edit.js +97 -0
- package/dist/markdown-input-view.js +171 -0
- package/dist/markdown-input.js +9 -0
- package/dist/markdown-input.native.js +12 -0
- package/dist/markdown.js +229 -0
- package/dist/richtext-tokens.js +59 -0
- package/dist/richtext-view.js +156 -0
- package/dist/richtext.js +11 -0
- package/dist/richtext.native.js +8 -0
- package/package.json +2 -2
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// sc-6970 — the ONE implementation behind the `<MarkdownInput>` SDK primitive.
|
|
2
|
+
//
|
|
3
|
+
// The authoring half of `<RichText>`: a multi-line field whose toolbar wraps the
|
|
4
|
+
// author's SELECTION in markdown markers and whose preview renders the result
|
|
5
|
+
// live. Widgets previously had only a bare `TextInput`, so a widget asked for a
|
|
6
|
+
// formatting toolbar had to splice literal `<strong>`/`<br>` tags into the text
|
|
7
|
+
// — markup the author then read as tag soup, with no preview of the real output.
|
|
8
|
+
//
|
|
9
|
+
// The two platform bindings differ ONLY in where the primitives come from; the
|
|
10
|
+
// component is defined once here so the hosts cannot drift (CLAUDE.md §3, §8).
|
|
11
|
+
|
|
12
|
+
import React from "react";
|
|
13
|
+
import { useHostTheme } from "./hooks.js";
|
|
14
|
+
import { resolveRichTextTokens } from "./richtext-tokens.js";
|
|
15
|
+
import { markdownEditOps } from "./markdown-edit.js";
|
|
16
|
+
|
|
17
|
+
const TOOLS = Object.freeze([
|
|
18
|
+
{ key: "bold", label: "B", accessibilityLabel: "Bold", weight: "700" },
|
|
19
|
+
{ key: "italic", label: "I", accessibilityLabel: "Italic", italic: true },
|
|
20
|
+
{ key: "code", label: "‹›", accessibilityLabel: "Code" },
|
|
21
|
+
{ key: "heading2", label: "H2", accessibilityLabel: "Heading" },
|
|
22
|
+
{ key: "heading3", label: "H3", accessibilityLabel: "Subheading" },
|
|
23
|
+
{ key: "bullet", label: "•", accessibilityLabel: "Bulleted list" },
|
|
24
|
+
{ key: "ordered", label: "1.", accessibilityLabel: "Numbered list" },
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export function makeMarkdownInput(rn, RichText) {
|
|
28
|
+
const { Text, View, Pressable, TextInput } = rn;
|
|
29
|
+
|
|
30
|
+
function MarkdownInput({
|
|
31
|
+
value,
|
|
32
|
+
onChange,
|
|
33
|
+
placeholder,
|
|
34
|
+
previewLabel = "Preview",
|
|
35
|
+
showPreview = true,
|
|
36
|
+
renderImage,
|
|
37
|
+
minHeight = 150,
|
|
38
|
+
maxHeight = 280,
|
|
39
|
+
accessibilityLabel,
|
|
40
|
+
style,
|
|
41
|
+
testID,
|
|
42
|
+
}) {
|
|
43
|
+
const theme = useHostTheme();
|
|
44
|
+
const tokens = React.useMemo(() => resolveRichTextTokens(theme), [theme]);
|
|
45
|
+
const [selection, setSelection] = React.useState({ start: 0, end: 0 });
|
|
46
|
+
|
|
47
|
+
const text = typeof value === "string" ? value : "";
|
|
48
|
+
|
|
49
|
+
const onSelectionChange = React.useCallback((event) => {
|
|
50
|
+
const next = event && event.nativeEvent && event.nativeEvent.selection;
|
|
51
|
+
if (next) setSelection({ start: next.start, end: next.end });
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const applyTool = React.useCallback(
|
|
55
|
+
(toolKey) => {
|
|
56
|
+
if (typeof onChange !== "function") return;
|
|
57
|
+
const edit = markdownEditOps[toolKey];
|
|
58
|
+
if (!edit) return;
|
|
59
|
+
const next = edit(text, selection);
|
|
60
|
+
onChange(next.value);
|
|
61
|
+
setSelection(next.selection);
|
|
62
|
+
},
|
|
63
|
+
[onChange, text, selection],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const fieldStyle = {
|
|
67
|
+
fontFamily: tokens.bodyFont,
|
|
68
|
+
fontSize: tokens.bodySize,
|
|
69
|
+
lineHeight: tokens.lineHeight,
|
|
70
|
+
color: tokens.color,
|
|
71
|
+
backgroundColor: tokens.surface,
|
|
72
|
+
borderWidth: 1,
|
|
73
|
+
borderColor: tokens.border,
|
|
74
|
+
borderRadius: tokens.radius,
|
|
75
|
+
padding: tokens.padding,
|
|
76
|
+
minHeight,
|
|
77
|
+
maxHeight,
|
|
78
|
+
textAlignVertical: "top",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return React.createElement(
|
|
82
|
+
View,
|
|
83
|
+
{ style, testID },
|
|
84
|
+
React.createElement(
|
|
85
|
+
View,
|
|
86
|
+
{
|
|
87
|
+
style: {
|
|
88
|
+
flexDirection: "row",
|
|
89
|
+
flexWrap: "wrap",
|
|
90
|
+
marginBottom: tokens.blockGap,
|
|
91
|
+
},
|
|
92
|
+
testID: testID ? `${testID}-toolbar` : undefined,
|
|
93
|
+
},
|
|
94
|
+
TOOLS.map((tool) =>
|
|
95
|
+
React.createElement(
|
|
96
|
+
Pressable,
|
|
97
|
+
{
|
|
98
|
+
key: tool.key,
|
|
99
|
+
onPress: () => applyTool(tool.key),
|
|
100
|
+
accessibilityRole: "button",
|
|
101
|
+
accessibilityLabel: tool.accessibilityLabel,
|
|
102
|
+
testID: testID ? `${testID}-${tool.key}` : undefined,
|
|
103
|
+
style: {
|
|
104
|
+
minWidth: 36,
|
|
105
|
+
paddingVertical: 6,
|
|
106
|
+
paddingHorizontal: 10,
|
|
107
|
+
marginRight: tokens.markerGap,
|
|
108
|
+
marginBottom: tokens.markerGap,
|
|
109
|
+
borderWidth: 1,
|
|
110
|
+
borderColor: tokens.border,
|
|
111
|
+
borderRadius: tokens.radius,
|
|
112
|
+
alignItems: "center",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
React.createElement(
|
|
116
|
+
Text,
|
|
117
|
+
{
|
|
118
|
+
style: {
|
|
119
|
+
fontFamily: tokens.bodyFont,
|
|
120
|
+
fontSize: tokens.codeSize,
|
|
121
|
+
color: tokens.color,
|
|
122
|
+
fontWeight: tool.weight || "500",
|
|
123
|
+
fontStyle: tool.italic ? "italic" : "normal",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
tool.label,
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
React.createElement(TextInput, {
|
|
132
|
+
value: text,
|
|
133
|
+
onChangeText: onChange,
|
|
134
|
+
onSelectionChange,
|
|
135
|
+
placeholder,
|
|
136
|
+
placeholderTextColor: tokens.mutedColor,
|
|
137
|
+
accessibilityLabel,
|
|
138
|
+
multiline: true,
|
|
139
|
+
numberOfLines: 6,
|
|
140
|
+
style: fieldStyle,
|
|
141
|
+
testID: testID ? `${testID}-field` : undefined,
|
|
142
|
+
}),
|
|
143
|
+
showPreview && text.trim()
|
|
144
|
+
? React.createElement(
|
|
145
|
+
View,
|
|
146
|
+
{ style: { marginTop: tokens.padding } },
|
|
147
|
+
React.createElement(
|
|
148
|
+
Text,
|
|
149
|
+
{
|
|
150
|
+
style: {
|
|
151
|
+
fontFamily: tokens.bodyFont,
|
|
152
|
+
fontSize: tokens.codeSize,
|
|
153
|
+
color: tokens.mutedColor,
|
|
154
|
+
marginBottom: tokens.blockGap,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
previewLabel,
|
|
158
|
+
),
|
|
159
|
+
React.createElement(RichText, {
|
|
160
|
+
value: text,
|
|
161
|
+
renderImage,
|
|
162
|
+
testID: testID ? `${testID}-preview` : undefined,
|
|
163
|
+
}),
|
|
164
|
+
)
|
|
165
|
+
: null,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
MarkdownInput.displayName = "MarkdownInput";
|
|
170
|
+
return MarkdownInput;
|
|
171
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// sc-6970 — `<MarkdownInput>` (web). Binds the shared implementation in
|
|
2
|
+
// ./markdown-input-view.js to react-native-web's primitives, and to the web
|
|
3
|
+
// `<RichText>` it renders its preview with.
|
|
4
|
+
|
|
5
|
+
import * as ReactNative from "react-native-web";
|
|
6
|
+
import { makeMarkdownInput } from "./markdown-input-view.js";
|
|
7
|
+
import { RichText } from "./richtext.js";
|
|
8
|
+
|
|
9
|
+
export const MarkdownInput = makeMarkdownInput(ReactNative, RichText);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// sc-6970 — `<MarkdownInput>` (native). Binds the shared implementation in
|
|
2
|
+
// ./markdown-input-view.js to React Native's own primitives and the native
|
|
3
|
+
// `<RichText>`. Only the imports differ from the web mirror.
|
|
4
|
+
|
|
5
|
+
import { Text, View, Pressable, TextInput } from "react-native";
|
|
6
|
+
import { makeMarkdownInput } from "./markdown-input-view.js";
|
|
7
|
+
import { RichText } from "./richtext.native.js";
|
|
8
|
+
|
|
9
|
+
export const MarkdownInput = makeMarkdownInput(
|
|
10
|
+
{ Text, View, Pressable, TextInput },
|
|
11
|
+
RichText,
|
|
12
|
+
);
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// sc-6970 — the ONE markdown-subset grammar widget content is authored in.
|
|
2
|
+
//
|
|
3
|
+
// A widget that lets an app user write formatted text has no HTML path: widgets
|
|
4
|
+
// render through React Native primitives, which have no `dangerouslySetInnerHTML`
|
|
5
|
+
// on either host, and `document` is a banned identifier. A generated widget that
|
|
6
|
+
// was asked for a formatting toolbar therefore spliced literal `<strong>`/`<br>`
|
|
7
|
+
// tags into a plain TextInput, and the author read tag soup instead of a preview.
|
|
8
|
+
//
|
|
9
|
+
// So content is markdown text, parsed here into a flat block/span tree that
|
|
10
|
+
// `<RichText>` renders with SDK primitives on both hosts. No HTML is ever
|
|
11
|
+
// interpreted — legacy rows that still hold markup are STRIPPED, never rendered.
|
|
12
|
+
//
|
|
13
|
+
// The supported subset is deliberately small: `#`/`##`/`###` headings, `-`/`*`
|
|
14
|
+
// bullets, `1.` ordered items, one-line `` images, and inline
|
|
15
|
+
// `**bold**`, `*italic*`/`_italic_`, and `` `code` ``.
|
|
16
|
+
|
|
17
|
+
const HEADING_RE = /^(#{1,3})\s+(.*)$/;
|
|
18
|
+
const BULLET_RE = /^\s*[-*]\s+(.*)$/;
|
|
19
|
+
const ORDERED_RE = /^\s*(\d+)[.)]\s+(.*)$/;
|
|
20
|
+
|
|
21
|
+
// An image is its own block, written on one line with the size in the title
|
|
22
|
+
// slot. Keeping blocks in the SAME markdown column as the prose is what lets
|
|
23
|
+
// content written before images existed keep rendering with no migration.
|
|
24
|
+
const IMAGE_RE = /^!\[((?:[^\]\\]|\\.)*)\]\(\s*(\S+?)(?:\s+"([^"]*)")?\s*\)$/;
|
|
25
|
+
|
|
26
|
+
export const MARKDOWN_IMAGE_SIZES = Object.freeze([
|
|
27
|
+
"small",
|
|
28
|
+
"medium",
|
|
29
|
+
"large",
|
|
30
|
+
"full",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const DEFAULT_IMAGE_SIZE = "full";
|
|
34
|
+
|
|
35
|
+
const HTTP_SRC_RE = /^https?:\/\//i;
|
|
36
|
+
// A filestore id, spelled positively. Blacklisting schemes instead would admit
|
|
37
|
+
// anything a leading control or zero-width character can hide `javascript:`
|
|
38
|
+
// behind — `javascript:` is not matched by /^[a-z]/.
|
|
39
|
+
const FILE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
40
|
+
|
|
41
|
+
/** True when `src` is an http(s) URL rather than a filestore id. */
|
|
42
|
+
export function isHttpImageSrc(src) {
|
|
43
|
+
return typeof src === "string" && HTTP_SRC_RE.test(src);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Content is author-supplied, so an image src is untrusted input: it is kept
|
|
47
|
+
// only when it is an http(s) URL or a filestore id, never otherwise.
|
|
48
|
+
export function isSafeMarkdownImageSrc(src) {
|
|
49
|
+
if (typeof src !== "string") return false;
|
|
50
|
+
return isHttpImageSrc(src) || FILE_ID_RE.test(src);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function normaliseMarkdownImageSize(size) {
|
|
54
|
+
return MARKDOWN_IMAGE_SIZES.indexOf(size) === -1 ? DEFAULT_IMAGE_SIZE : size;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A caption is the only prose an image-only post has, so `]` must survive the
|
|
58
|
+
// round trip rather than being eaten, to keep the one-line grammar unambiguous.
|
|
59
|
+
function escapeAlt(alt) {
|
|
60
|
+
return String(alt || "")
|
|
61
|
+
.replace(/[\r\n]+/g, " ")
|
|
62
|
+
.replace(/([\\\]])/g, "\\$1")
|
|
63
|
+
.trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Un-escapes an alt captured by the image grammar. */
|
|
67
|
+
export function readMarkdownAlt(raw) {
|
|
68
|
+
return String(raw || "").replace(/\\(.)/g, "$1");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const readAlt = readMarkdownAlt;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Reads one line as an image block, or returns null when it is not one.
|
|
75
|
+
*
|
|
76
|
+
* @returns {{src: string, alt: string, size: string} | null}
|
|
77
|
+
*/
|
|
78
|
+
export function parseMarkdownImage(line) {
|
|
79
|
+
const match = IMAGE_RE.exec(String(line == null ? "" : line).trim());
|
|
80
|
+
if (!match) return null;
|
|
81
|
+
return {
|
|
82
|
+
// An unsafe src is emptied, not dropped: the reader still sees the
|
|
83
|
+
// broken-image fallback where the author placed a picture.
|
|
84
|
+
src: isSafeMarkdownImageSrc(match[2]) ? match[2] : "",
|
|
85
|
+
alt: readAlt(match[1]),
|
|
86
|
+
size: normaliseMarkdownImageSize(match[3]),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Writes an image block back to its one-line markdown form. */
|
|
91
|
+
export function formatMarkdownImage({ src, alt, size } = {}) {
|
|
92
|
+
const safeSize = normaliseMarkdownImageSize(size);
|
|
93
|
+
return `} "${safeSize}")`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Legacy rows written by an HTML-storing predecessor still hold markup.
|
|
97
|
+
// Rendering it as literal text would show tag soup, so tags are stripped and
|
|
98
|
+
// the handful of entities that survive are decoded — never re-interpreted.
|
|
99
|
+
const HTML_TAG_RE = /<[^>]*>/g;
|
|
100
|
+
const BLOCK_TAG_RE = /<\/(?:p|div|h[1-6]|li|ul|ol|br)\s*>|<br\s*\/?>/gi;
|
|
101
|
+
const ENTITIES = {
|
|
102
|
+
"&": "&",
|
|
103
|
+
"<": "<",
|
|
104
|
+
">": ">",
|
|
105
|
+
""": '"',
|
|
106
|
+
"'": "'",
|
|
107
|
+
" ": " ",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
function looksLikeHtml(text) {
|
|
111
|
+
return typeof text === "string" && /<[a-z!/][^>]*>/i.test(text);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Normalises stored content to markdown: HTML-bearing text has its tags
|
|
116
|
+
* stripped to line breaks, anything else is returned unchanged.
|
|
117
|
+
*/
|
|
118
|
+
export function stripHtmlToMarkdown(text) {
|
|
119
|
+
if (typeof text !== "string") return "";
|
|
120
|
+
if (!looksLikeHtml(text)) return text;
|
|
121
|
+
return text
|
|
122
|
+
.replace(BLOCK_TAG_RE, "\n")
|
|
123
|
+
.replace(HTML_TAG_RE, "")
|
|
124
|
+
.replace(/&[a-z#0-9]+;/gi, (entity) => ENTITIES[entity.toLowerCase()] ?? entity)
|
|
125
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
126
|
+
.trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Splits one line into `{ text, bold?, italic?, code? }` spans. */
|
|
130
|
+
function parseInline(line) {
|
|
131
|
+
const source = typeof line === "string" ? line : "";
|
|
132
|
+
const spans = [];
|
|
133
|
+
// One pass over the three delimiters. Alternation order matters: `**` must
|
|
134
|
+
// be tried before `*` or the bold marker is consumed as two italics.
|
|
135
|
+
const pattern = /(\*\*)(.+?)\1|(`)([^`]+?)\3|([*_])(.+?)\5/g;
|
|
136
|
+
let cursor = 0;
|
|
137
|
+
let match = pattern.exec(source);
|
|
138
|
+
while (match) {
|
|
139
|
+
if (match.index > cursor) {
|
|
140
|
+
spans.push({ text: source.slice(cursor, match.index) });
|
|
141
|
+
}
|
|
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 });
|
|
145
|
+
cursor = match.index + match[0].length;
|
|
146
|
+
match = pattern.exec(source);
|
|
147
|
+
}
|
|
148
|
+
if (cursor < source.length) spans.push({ text: source.slice(cursor) });
|
|
149
|
+
return spans.length > 0 ? spans : [{ text: source }];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Parses markdown text into renderable blocks:
|
|
154
|
+
* `{ type: "heading"|"paragraph"|"bullet"|"ordered", level?, marker?, spans }`
|
|
155
|
+
* or `{ type: "image", src, alt, size }`.
|
|
156
|
+
*/
|
|
157
|
+
export function parseMarkdown(text) {
|
|
158
|
+
const source = stripHtmlToMarkdown(text);
|
|
159
|
+
if (!source.trim()) return [];
|
|
160
|
+
|
|
161
|
+
const blocks = [];
|
|
162
|
+
let paragraph = [];
|
|
163
|
+
|
|
164
|
+
const flushParagraph = () => {
|
|
165
|
+
if (paragraph.length === 0) return;
|
|
166
|
+
blocks.push({ type: "paragraph", spans: parseInline(paragraph.join(" ")) });
|
|
167
|
+
paragraph = [];
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
for (const rawLine of source.split(/\r?\n/)) {
|
|
171
|
+
const line = rawLine.trimEnd();
|
|
172
|
+
if (!line.trim()) {
|
|
173
|
+
flushParagraph();
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const image = parseMarkdownImage(line);
|
|
178
|
+
if (image) {
|
|
179
|
+
flushParagraph();
|
|
180
|
+
blocks.push({ type: "image", ...image });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const heading = HEADING_RE.exec(line);
|
|
185
|
+
if (heading) {
|
|
186
|
+
flushParagraph();
|
|
187
|
+
blocks.push({
|
|
188
|
+
type: "heading",
|
|
189
|
+
level: heading[1].length,
|
|
190
|
+
spans: parseInline(heading[2]),
|
|
191
|
+
});
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const bullet = BULLET_RE.exec(line);
|
|
196
|
+
if (bullet) {
|
|
197
|
+
flushParagraph();
|
|
198
|
+
blocks.push({ type: "bullet", marker: "•", spans: parseInline(bullet[1]) });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const ordered = ORDERED_RE.exec(line);
|
|
203
|
+
if (ordered) {
|
|
204
|
+
flushParagraph();
|
|
205
|
+
blocks.push({
|
|
206
|
+
type: "ordered",
|
|
207
|
+
marker: `${ordered[1]}.`,
|
|
208
|
+
spans: parseInline(ordered[2]),
|
|
209
|
+
});
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
paragraph.push(line.trim());
|
|
214
|
+
}
|
|
215
|
+
flushParagraph();
|
|
216
|
+
return blocks;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Plain-text projection — used for search, previews, and a11y labels. */
|
|
220
|
+
export function markdownToPlainText(text) {
|
|
221
|
+
return parseMarkdown(text)
|
|
222
|
+
.map((block) =>
|
|
223
|
+
block.type === "image"
|
|
224
|
+
? block.alt
|
|
225
|
+
: block.spans.map((span) => span.text).join(""),
|
|
226
|
+
)
|
|
227
|
+
.filter(Boolean)
|
|
228
|
+
.join("\n");
|
|
229
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// sc-6970 — the values `<RichText>` and `<MarkdownInput>` are painted with.
|
|
2
|
+
//
|
|
3
|
+
// Presentation-free and platform-free, the same way `overlay-tokens.js` is:
|
|
4
|
+
// both hosts render from the SAME components, and this module is where the
|
|
5
|
+
// theme becomes numbers. Keeping the maths here makes it unit-testable without
|
|
6
|
+
// a renderer, and makes the type scale a thing tests can pin.
|
|
7
|
+
|
|
8
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
9
|
+
|
|
10
|
+
// Percentages, not pixels, so a phone scales the same way a desktop does.
|
|
11
|
+
const IMAGE_WIDTH = Object.freeze({
|
|
12
|
+
small: "40%",
|
|
13
|
+
medium: "65%",
|
|
14
|
+
large: "85%",
|
|
15
|
+
full: "100%",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export function resolveRichTextTokens(theme) {
|
|
19
|
+
const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
|
|
20
|
+
const fallback = DEFAULT_THEME_TOKENS;
|
|
21
|
+
const colors = base.colors || fallback.colors;
|
|
22
|
+
const spacing = base.spacing || fallback.spacing;
|
|
23
|
+
const radii = base.radii || fallback.radii;
|
|
24
|
+
const typography = base.typography || fallback.typography;
|
|
25
|
+
const sizes = typography.sizes || fallback.typography.sizes;
|
|
26
|
+
|
|
27
|
+
const body = sizes.md || fallback.typography.sizes.md;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
color: colors.onSurface || fallback.colors.onSurface,
|
|
31
|
+
mutedColor: colors.onSurfaceMuted || fallback.colors.onSurfaceMuted,
|
|
32
|
+
border: colors.border || fallback.colors.border,
|
|
33
|
+
surface: colors.surface || fallback.colors.surface,
|
|
34
|
+
accent: colors.primary || fallback.colors.primary,
|
|
35
|
+
onAccent: colors.onPrimary || fallback.colors.onPrimary,
|
|
36
|
+
bodyFont: typography.fontFamily || fallback.typography.fontFamily,
|
|
37
|
+
// Headings take the display face when the workspace pairs two.
|
|
38
|
+
headingFont:
|
|
39
|
+
typography.headingFontFamily ||
|
|
40
|
+
typography.fontFamily ||
|
|
41
|
+
fallback.typography.fontFamily,
|
|
42
|
+
bodySize: body,
|
|
43
|
+
codeSize: sizes.sm || fallback.typography.sizes.sm,
|
|
44
|
+
// Prose needs more leading than a UI label; 1.6 is the readable ratio.
|
|
45
|
+
lineHeight: Math.round(body * 1.6),
|
|
46
|
+
headingSizes: {
|
|
47
|
+
1: sizes.xl || fallback.typography.sizes.xl,
|
|
48
|
+
2: sizes.lg || fallback.typography.sizes.lg,
|
|
49
|
+
3: body,
|
|
50
|
+
},
|
|
51
|
+
blockGap: spacing.sm || fallback.spacing.sm,
|
|
52
|
+
markerGap: spacing.sm || fallback.spacing.sm,
|
|
53
|
+
padding: spacing.md || fallback.spacing.md,
|
|
54
|
+
radius: radii.md || fallback.radii.md,
|
|
55
|
+
imageWidth: IMAGE_WIDTH,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { IMAGE_WIDTH as RICH_TEXT_IMAGE_WIDTH };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// sc-6970 — the ONE implementation behind the `<RichText>` SDK primitive.
|
|
2
|
+
//
|
|
3
|
+
// Renders the markdown-subset block tree from `markdown.js` with React Native
|
|
4
|
+
// primitives, so formatted content reads the same in the web Player and the
|
|
5
|
+
// exported Expo app. There is deliberately no HTML path — see `markdown.js`.
|
|
6
|
+
//
|
|
7
|
+
// The two platform bindings (`richtext.js` / `richtext.native.js`) differ ONLY
|
|
8
|
+
// in where the primitives come from; the component is defined once here so the
|
|
9
|
+
// hosts cannot drift (CLAUDE.md §3, §8).
|
|
10
|
+
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { useHostTheme } from "./hooks.js";
|
|
13
|
+
import { parseMarkdown, isHttpImageSrc } from "./markdown.js";
|
|
14
|
+
import { resolveRichTextTokens } from "./richtext-tokens.js";
|
|
15
|
+
|
|
16
|
+
export function makeRichText(rn) {
|
|
17
|
+
const { Text, View, Image } = rn;
|
|
18
|
+
|
|
19
|
+
function Spans({ spans, tokens }) {
|
|
20
|
+
return spans.map((span, i) =>
|
|
21
|
+
React.createElement(
|
|
22
|
+
Text,
|
|
23
|
+
{
|
|
24
|
+
key: i,
|
|
25
|
+
style: [
|
|
26
|
+
span.bold ? { fontWeight: "700" } : null,
|
|
27
|
+
span.italic ? { fontStyle: "italic" } : null,
|
|
28
|
+
span.code
|
|
29
|
+
? {
|
|
30
|
+
// A code span needs a mono face; the theme has no mono token.
|
|
31
|
+
fontFamily: "monospace",
|
|
32
|
+
fontSize: tokens.codeSize,
|
|
33
|
+
color: tokens.mutedColor,
|
|
34
|
+
}
|
|
35
|
+
: null,
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
span.text,
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function RichText({ value, renderImage, style, testID }) {
|
|
44
|
+
const theme = useHostTheme();
|
|
45
|
+
const tokens = React.useMemo(() => resolveRichTextTokens(theme), [theme]);
|
|
46
|
+
const blocks = React.useMemo(() => parseMarkdown(value), [value]);
|
|
47
|
+
|
|
48
|
+
if (blocks.length === 0) return null;
|
|
49
|
+
|
|
50
|
+
const paragraph = {
|
|
51
|
+
fontFamily: tokens.bodyFont,
|
|
52
|
+
fontSize: tokens.bodySize,
|
|
53
|
+
lineHeight: tokens.lineHeight,
|
|
54
|
+
color: tokens.color,
|
|
55
|
+
marginBottom: tokens.blockGap,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return React.createElement(
|
|
59
|
+
View,
|
|
60
|
+
{ style, testID },
|
|
61
|
+
blocks.map((block, i) => {
|
|
62
|
+
const spans = React.createElement(Spans, { spans: block.spans, tokens });
|
|
63
|
+
|
|
64
|
+
if (block.type === "heading") {
|
|
65
|
+
return React.createElement(
|
|
66
|
+
Text,
|
|
67
|
+
{
|
|
68
|
+
key: i,
|
|
69
|
+
style: [
|
|
70
|
+
paragraph,
|
|
71
|
+
{
|
|
72
|
+
fontFamily: tokens.headingFont,
|
|
73
|
+
fontWeight: "700",
|
|
74
|
+
fontSize: tokens.headingSizes[block.level] || tokens.headingSizes[3],
|
|
75
|
+
lineHeight: undefined,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
spans,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (block.type === "image") {
|
|
84
|
+
if (typeof renderImage === "function") {
|
|
85
|
+
return React.createElement(
|
|
86
|
+
View,
|
|
87
|
+
{ key: i, style: { marginBottom: tokens.blockGap } },
|
|
88
|
+
renderImage(block),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
// Without a resolver only an absolute URL can be shown; a filestore
|
|
92
|
+
// id needs the widget's own scopes, so it renders as its caption.
|
|
93
|
+
return React.createElement(
|
|
94
|
+
View,
|
|
95
|
+
{
|
|
96
|
+
key: i,
|
|
97
|
+
style: {
|
|
98
|
+
marginBottom: tokens.blockGap,
|
|
99
|
+
width: tokens.imageWidth[block.size] || tokens.imageWidth.full,
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
isHttpImageSrc(block.src)
|
|
103
|
+
? React.createElement(Image, {
|
|
104
|
+
source: { uri: block.src },
|
|
105
|
+
accessibilityLabel: block.alt || undefined,
|
|
106
|
+
resizeMode: "cover",
|
|
107
|
+
style: {
|
|
108
|
+
width: "100%",
|
|
109
|
+
aspectRatio: 16 / 9,
|
|
110
|
+
borderRadius: tokens.radius,
|
|
111
|
+
backgroundColor: tokens.surface,
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
: null,
|
|
115
|
+
block.alt
|
|
116
|
+
? React.createElement(
|
|
117
|
+
Text,
|
|
118
|
+
{
|
|
119
|
+
style: {
|
|
120
|
+
fontFamily: tokens.bodyFont,
|
|
121
|
+
fontSize: tokens.codeSize,
|
|
122
|
+
color: tokens.mutedColor,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
block.alt,
|
|
126
|
+
)
|
|
127
|
+
: null,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (block.type === "bullet" || block.type === "ordered") {
|
|
132
|
+
return React.createElement(
|
|
133
|
+
View,
|
|
134
|
+
{ key: i, style: { flexDirection: "row" } },
|
|
135
|
+
React.createElement(
|
|
136
|
+
Text,
|
|
137
|
+
{
|
|
138
|
+
style: [
|
|
139
|
+
paragraph,
|
|
140
|
+
{ color: tokens.mutedColor, marginRight: tokens.markerGap },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
block.marker,
|
|
144
|
+
),
|
|
145
|
+
React.createElement(Text, { style: [paragraph, { flex: 1 }] }, spans),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return React.createElement(Text, { key: i, style: paragraph }, spans);
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
RichText.displayName = "RichText";
|
|
155
|
+
return RichText;
|
|
156
|
+
}
|
package/dist/richtext.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// sc-6970 — `<RichText>` (web). Binds the shared implementation in
|
|
2
|
+
// ./richtext-view.js to react-native-web's primitives.
|
|
3
|
+
//
|
|
4
|
+
// The import is `react-native-web` rather than the bare `react-native`
|
|
5
|
+
// specifier for the rolldown optional-peer-dep reason spelled out at the top of
|
|
6
|
+
// ./primitives.js.
|
|
7
|
+
|
|
8
|
+
import * as ReactNative from "react-native-web";
|
|
9
|
+
import { makeRichText } from "./richtext-view.js";
|
|
10
|
+
|
|
11
|
+
export const RichText = makeRichText(ReactNative);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// sc-6970 — `<RichText>` (native). Binds the shared implementation in
|
|
2
|
+
// ./richtext-view.js to React Native's own primitives. Only the import differs
|
|
3
|
+
// from the web mirror in ./richtext.js.
|
|
4
|
+
|
|
5
|
+
import { Text, View, Image } from "react-native";
|
|
6
|
+
import { makeRichText } from "./richtext-view.js";
|
|
7
|
+
|
|
8
|
+
export const RichText = makeRichText({ Text, View, Image });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.123.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",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
],
|
|
50
50
|
"scripts": {
|
|
51
51
|
"build": "node scripts/build.js",
|
|
52
|
-
"test": "node --test src/__tests__/contract.test.js src/__tests__/vetted-imports-audit.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-page-url.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-hardcoded-design.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/flatten-entry.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/corner-radius.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/interaction-lift.test.js src/__tests__/toast-host.test.js src/__tests__/overlay-tokens.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js src/__tests__/hooks-camera.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js src/__tests__/widget-route.test.js"
|
|
52
|
+
"test": "node --test src/__tests__/contract.test.js src/__tests__/vetted-imports-audit.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-page-url.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-hardcoded-design.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/flatten-entry.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/corner-radius.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/interaction-lift.test.js src/__tests__/toast-host.test.js src/__tests__/overlay-tokens.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js src/__tests__/hooks-camera.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js src/__tests__/widget-route.test.js src/__tests__/linter-html-in-content.test.js src/__tests__/markdown.test.js src/__tests__/markdown-edit.test.js src/__tests__/richtext-tokens.test.js"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
|
55
55
|
"node": ">=18"
|