@colixsystems/widget-sdk 0.121.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/dist/linter.js CHANGED
@@ -294,6 +294,62 @@ function _hostApiUrlRules(source) {
294
294
  return findings;
295
295
  }
296
296
 
297
+ // sc-6970 — no-html-in-content.
298
+ //
299
+ // Widgets draw with React Native primitives, so there is no
300
+ // `dangerouslySetInnerHTML` on either host: an HTML tag inside a STRING stays
301
+ // literal text the reader sees as tag soup. This is the defect a generated
302
+ // "manage content" widget shipped — a hand-rolled B / I / H2 toolbar whose only
303
+ // possible action was splicing `<strong>` and `<br>` into a TextInput, leaving
304
+ // the author reading markup instead of a preview. `severity: "warning"` so a
305
+ // human submission still publishes and the review queue flags it, while the AI
306
+ // publish loop treats it as a blocking check that drives a repair turn.
307
+ //
308
+ // Only STRING content is scanned. JSX is excluded by construction (the masked
309
+ // and unmasked sources agree outside string literals), so a widget's own
310
+ // `<View>` / `<Text>` elements can never trigger it.
311
+ const HTML_IN_CONTENT_RE =
312
+ /<\/\s*[a-z][a-z0-9]*\s*>|<(?:br|hr|img|p|div|span|strong|em|b|i|u|s|h[1-6]|ul|ol|li|a|blockquote|code|pre|table|thead|tbody|tr|td|th)(?:\s[^<>]*)?\/?>/i;
313
+
314
+ // Everything the masked source hides but the string-keeping source shows is,
315
+ // by definition, string/template text. Column positions are preserved by both
316
+ // maskings, so the result still lines up 1:1 with the original lines.
317
+ function _stringLiteralLines(source) {
318
+ const withStrings = _stripNonCode(source, { keepStrings: true }).split(
319
+ /\r?\n/,
320
+ );
321
+ const withoutStrings = _stripNonCode(source).split(/\r?\n/);
322
+ return withStrings.map((line, i) => {
323
+ const masked = withoutStrings[i] || "";
324
+ let out = "";
325
+ for (let c = 0; c < line.length; c += 1) {
326
+ out += masked[c] === line[c] ? " " : line[c];
327
+ }
328
+ return out;
329
+ });
330
+ }
331
+
332
+ function _htmlInContentRules(source) {
333
+ const findings = [];
334
+ const lines = source.split(/\r?\n/);
335
+ const stringLines = _stringLiteralLines(source);
336
+ for (let i = 0; i < stringLines.length; i += 1) {
337
+ if (!HTML_IN_CONTENT_RE.test(stringLines[i])) continue;
338
+ findings.push({
339
+ rule: "no-html-in-content",
340
+ severity: "warning",
341
+ label:
342
+ "HTML tag in a string — widgets have no HTML renderer on either " +
343
+ "host, so it shows as literal tag soup. Author formatted text with " +
344
+ "<MarkdownInput> and render it with <RichText> (markdown), never " +
345
+ "spliced tags.",
346
+ line: i + 1,
347
+ snippet: lines[i].trim().slice(0, 200),
348
+ });
349
+ }
350
+ return findings;
351
+ }
352
+
297
353
  // sc-5619 — no-hand-built-page-url.
298
354
  //
299
355
  // A published app answers on two URL shapes: `/play/<tenantId>/page/<slug>` on
@@ -1477,6 +1533,8 @@ export function lintSource(source, options) {
1477
1533
  );
1478
1534
  // REQ-WSDK-PLATFORM §3.5: soft host-API URL warning (does not block).
1479
1535
  findings.push(..._hostApiUrlRules(source));
1536
+ // sc-6970 — soft warning: HTML markup spliced into a content string.
1537
+ findings.push(..._htmlInContentRules(source));
1480
1538
  findings.push(..._translationApiRules(source));
1481
1539
  findings.push(..._handBuiltPageUrlRules(source));
1482
1540
  findings.push(..._lucideIconRules(source));
@@ -0,0 +1,97 @@
1
+ // sc-6970 — the selection edits `<MarkdownInput>`'s toolbar performs.
2
+ //
3
+ // Pure string maths, kept out of the component the way `richtext-tokens.js`
4
+ // keeps the theme maths out: a toolbar button is only correct if it wraps the
5
+ // author's SELECTION rather than appending at the end, and that is a thing
6
+ // tests can pin without a renderer.
7
+
8
+ const LINE_RE = /^(\s*)((?:#{1,3}\s)|(?:[-*]\s)|(?:\d+[.)]\s))?([\s\S]*)$/;
9
+
10
+ const LINE_KINDS = Object.freeze({
11
+ heading2: { marker: "## ", test: /^##\s/ },
12
+ heading3: { marker: "### ", test: /^###\s/ },
13
+ bullet: { marker: "- ", test: /^[-*]\s/ },
14
+ ordered: { marker: null, test: /^\d+[.)]\s/ },
15
+ });
16
+
17
+ function clamp(index, max) {
18
+ const n = Number.isFinite(index) ? Math.trunc(index) : 0;
19
+ return Math.min(Math.max(n, 0), max);
20
+ }
21
+
22
+ function readRange(text, selection) {
23
+ const src = typeof text === "string" ? text : "";
24
+ const raw = selection && typeof selection === "object" ? selection : {};
25
+ const a = clamp(raw.start, src.length);
26
+ const b = clamp(raw.end, src.length);
27
+ return { src, start: Math.min(a, b), end: Math.max(a, b) };
28
+ }
29
+
30
+ function wrap(marker) {
31
+ return (text, selection) => {
32
+ const { src, start, end } = readRange(text, selection);
33
+ const selected = src.slice(start, end);
34
+ const pair = marker.length * 2;
35
+
36
+ // Already wrapped — the button toggles the emphasis back off.
37
+ if (selected.length >= pair && selected.startsWith(marker) && selected.endsWith(marker)) {
38
+ const inner = selected.slice(marker.length, selected.length - marker.length);
39
+ return {
40
+ value: src.slice(0, start) + inner + src.slice(end),
41
+ selection: { start, end: start + inner.length },
42
+ };
43
+ }
44
+
45
+ return {
46
+ value: src.slice(0, start) + marker + selected + marker + src.slice(end),
47
+ // Leaves the author's own text selected (or the caret between an empty
48
+ // pair), so typing continues inside the emphasis they just asked for.
49
+ selection: { start: start + marker.length, end: end + marker.length },
50
+ };
51
+ };
52
+ }
53
+
54
+ function linePrefix(kind) {
55
+ const spec = LINE_KINDS[kind];
56
+ return (text, selection) => {
57
+ const { src, start, end } = readRange(text, selection);
58
+ const blockStart = src.lastIndexOf("\n", Math.max(start - 1, 0)) + 1;
59
+ const newlineAfter = src.indexOf("\n", end);
60
+ const blockEnd = newlineAfter === -1 ? src.length : newlineAfter;
61
+
62
+ const lines = src.slice(blockStart, blockEnd).split("\n");
63
+ const filled = lines.filter((line) => line.trim());
64
+ // Toggled off only when EVERY line already carries this kind, so a mixed
65
+ // selection becomes uniformly marked rather than half-stripped.
66
+ const removing =
67
+ filled.length > 0 &&
68
+ filled.every((line) => spec.test.test(line.replace(/^\s*/, "")));
69
+
70
+ let ordinal = 0;
71
+ const rebuilt = lines.map((line) => {
72
+ const match = LINE_RE.exec(line);
73
+ const indent = match[1];
74
+ const body = match[3];
75
+ if (!line.trim()) return line;
76
+ if (removing) return indent + body;
77
+ ordinal += 1;
78
+ return indent + (spec.marker || `${ordinal}. `) + body;
79
+ });
80
+
81
+ const block = rebuilt.join("\n");
82
+ return {
83
+ value: src.slice(0, blockStart) + block + src.slice(blockEnd),
84
+ selection: { start: blockStart, end: blockStart + block.length },
85
+ };
86
+ };
87
+ }
88
+
89
+ export const markdownEditOps = Object.freeze({
90
+ bold: wrap("**"),
91
+ italic: wrap("*"),
92
+ code: wrap("`"),
93
+ heading2: linePrefix("heading2"),
94
+ heading3: linePrefix("heading3"),
95
+ bullet: linePrefix("bullet"),
96
+ ordered: linePrefix("ordered"),
97
+ });
@@ -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
+ );
@@ -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 `![alt](src "size")` 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 `![${escapeAlt(alt)}](${String(src || "")} "${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
+ "&amp;": "&",
103
+ "&lt;": "<",
104
+ "&gt;": ">",
105
+ "&quot;": '"',
106
+ "&#39;": "'",
107
+ "&nbsp;": " ",
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 };