@colixsystems/widget-sdk 0.122.0 → 0.124.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 +52 -3
- package/dist/contract.cjs +54 -4
- package/dist/contract.js +54 -4
- package/dist/index.d.ts +129 -1
- 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,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
|
+
);
|
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 };
|