flet-box 0.0.0-stage → 1.0.2
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/LICENSE +21 -0
- package/README.md +190 -2
- package/bin/cli.js +394 -0
- package/bin/commands/buildAndroid.js +200 -0
- package/bin/commands/buildIOS.js +237 -0
- package/bin/commands/create.js +406 -0
- package/bin/commands/createBundle.js +375 -0
- package/bin/commands/createComponent.js +115 -0
- package/bin/commands/createScreen.js +161 -0
- package/bin/commands/killServer.js +107 -0
- package/bin/commands/package.js +131 -0
- package/bin/commands/release.js +189 -0
- package/bin/commands/runBundle.js +62 -0
- package/bin/commands/runCloudflare.js +89 -0
- package/bin/commands/runNative.js +109 -0
- package/bin/commands/runServer.js +390 -0
- package/bin/ota/loader.js +361 -0
- package/bin/utils/bundleConfig.js +178 -0
- package/bin/utils/capacitor.js +137 -0
- package/bin/utils/colors.js +215 -0
- package/bin/utils/helpers.js +80 -0
- package/bin/utils/spinner.js +96 -0
- package/bin/utils/templates.js +1824 -0
- package/bin/utils/version.js +108 -0
- package/package.json +58 -4
- package/src/animations/AnimatedBox.js +607 -0
- package/src/animations/AnimatedText.js +105 -0
- package/src/animations/MatrixRain.js +220 -0
- package/src/animations/ParallaxBox.js +369 -0
- package/src/animations/animate.js +332 -0
- package/src/animations/index.js +25 -0
- package/src/app.js +41 -0
- package/src/assets/icon-1024-maskable.png +0 -0
- package/src/assets/icon-192-maskable.png +0 -0
- package/src/assets/icon-192.png +0 -0
- package/src/assets/icon-512-maskable.png +0 -0
- package/src/assets/icon-512.png +0 -0
- package/src/assets/logo.png +0 -0
- package/src/core/App.js +44 -0
- package/src/core/hmr-client.js +142 -0
- package/src/core/index.js +34 -0
- package/src/core/pwa.js +100 -0
- package/src/core/runApp.js +430 -0
- package/src/create-index.sh +237 -0
- package/src/fonts/MaterialIcons-Regular.woff2 +0 -0
- package/src/fonts/icons.css +23 -0
- package/src/index.d.ts +1930 -0
- package/src/index.js +288 -0
- package/src/navigations/AdaptiveScaffold.js +149 -0
- package/src/navigations/AppBar.js +331 -0
- package/src/navigations/BottomNavigation.js +298 -0
- package/src/navigations/CollapsibleSideBar.js +139 -0
- package/src/navigations/Drawer.js +333 -0
- package/src/navigations/DrawerItem.js +260 -0
- package/src/navigations/Router.js +475 -0
- package/src/navigations/Scaffold.js +537 -0
- package/src/navigations/Tabs.js +384 -0
- package/src/navigations/index.js +57 -0
- package/src/services/RamStore.js +213 -0
- package/src/services/Session.js +310 -0
- package/src/services/Storage.js +309 -0
- package/src/services/http.js +350 -0
- package/src/services/index.js +57 -0
- package/src/tools/animation.js +42 -0
- package/src/tools/array.js +95 -0
- package/src/tools/border.js +28 -0
- package/src/tools/clipboard.js +104 -0
- package/src/tools/color.js +329 -0
- package/src/tools/createList.js +215 -0
- package/src/tools/delay.js +55 -0
- package/src/tools/device.js +138 -0
- package/src/tools/dict.js +297 -0
- package/src/tools/dimensions.js +118 -0
- package/src/tools/filter.js +41 -0
- package/src/tools/flex.js +26 -0
- package/src/tools/gradient.js +34 -0
- package/src/tools/grid.js +57 -0
- package/src/tools/index.js +38 -0
- package/src/tools/mapList.js +107 -0
- package/src/tools/margin.js +93 -0
- package/src/tools/memo.js +60 -0
- package/src/tools/os.js +104 -0
- package/src/tools/padding.js +93 -0
- package/src/tools/print.js +69 -0
- package/src/tools/random.js +299 -0
- package/src/tools/ref.js +70 -0
- package/src/tools/rgba.js +4 -0
- package/src/tools/shadow.js +23 -0
- package/src/tools/string.js +85 -0
- package/src/tools/time.js +674 -0
- package/src/tools/transform.js +68 -0
- package/src/tools/transition.js +48 -0
- package/src/tools/useState.js +268 -0
- package/src/tools/uuid.js +42 -0
- package/src/utils/TextInputValidator.js +256 -0
- package/src/utils/composeUpdate.js +65 -0
- package/src/utils/getWidgetProps.js +35 -0
- package/src/utils/index.js +76 -0
- package/src/utils/markdownParser.js +368 -0
- package/src/utils/mediaTime.js +79 -0
- package/src/utils/navigation.js +263 -0
- package/src/utils/scriptInjector.js +73 -0
- package/src/utils/stackPosition.js +31 -0
- package/src/utils/stopWebRefresh.js +77 -0
- package/src/utils/styleInjector.js +38 -0
- package/src/utils/syntaxHighlight.js +339 -0
- package/src/utils/themes.js +347 -0
- package/src/utils/units.js +87 -0
- package/src/utils/visualEffects.js +169 -0
- package/src/widget-factory/addChildren.js +114 -0
- package/src/widget-factory/assignProps.js +256 -0
- package/src/widget-factory/createWidget.js +28 -0
- package/src/widget-factory/effects.js +132 -0
- package/src/widget-factory/index.js +7 -0
- package/src/widget-factory/lifecycle.js +210 -0
- package/src/widget-factory/makeParentable.js +89 -0
- package/src/widget-factory/pixTool.js +40 -0
- package/src/widget-factory/processProps.js +122 -0
- package/src/widget-factory/reactivity.js +98 -0
- package/src/widget-factory/remTool.js +34 -0
- package/src/widget-factory/stackPosition.js +87 -0
- package/src/widget-factory/stringTool.js +26 -0
- package/src/widget-factory/tools.js +249 -0
- package/src/widget-factory/translateProps.js +1049 -0
- package/src/widget-factory/warnings.js +284 -0
- package/src/widget-factory/widgetFactory.js +200 -0
- package/src/widgets/Accordion.js +562 -0
- package/src/widgets/AdMob.js +315 -0
- package/src/widgets/AdSense.js +268 -0
- package/src/widgets/AlertDialog.js +162 -0
- package/src/widgets/Audio.js +311 -0
- package/src/widgets/Avatar.js +210 -0
- package/src/widgets/Badge.js +189 -0
- package/src/widgets/BottomSheet.js +287 -0
- package/src/widgets/Button.js +247 -0
- package/src/widgets/Card.js +33 -0
- package/src/widgets/Carousel.js +301 -0
- package/src/widgets/Chart.js +632 -0
- package/src/widgets/Checkbox.js +106 -0
- package/src/widgets/Chip.js +153 -0
- package/src/widgets/CircularBar.js +352 -0
- package/src/widgets/CircularChart.js +549 -0
- package/src/widgets/CodeViewer.js +236 -0
- package/src/widgets/Column.js +29 -0
- package/src/widgets/Container.js +31 -0
- package/src/widgets/DataTable.js +257 -0
- package/src/widgets/Divider.js +93 -0
- package/src/widgets/DraggBox.js +115 -0
- package/src/widgets/Dropdown.js +505 -0
- package/src/widgets/DroppBox.js +180 -0
- package/src/widgets/FloatingActionButton.js +91 -0
- package/src/widgets/GridView.js +20 -0
- package/src/widgets/Icon.js +33 -0
- package/src/widgets/Image.js +15 -0
- package/src/widgets/Input.js +565 -0
- package/src/widgets/Inspector.js +254 -0
- package/src/widgets/InstallButton.js +110 -0
- package/src/widgets/ListTile.js +224 -0
- package/src/widgets/ListView.js +503 -0
- package/src/widgets/Markdown.js +1236 -0
- package/src/widgets/Modal.js +496 -0
- package/src/widgets/Pagination.js +195 -0
- package/src/widgets/ProgressBar.js +178 -0
- package/src/widgets/QRCode.js +467 -0
- package/src/widgets/Radio.js +132 -0
- package/src/widgets/Rating.js +261 -0
- package/src/widgets/Row.js +29 -0
- package/src/widgets/Skeleton.js +243 -0
- package/src/widgets/Slider.js +527 -0
- package/src/widgets/SnackBar.js +227 -0
- package/src/widgets/Stack.js +29 -0
- package/src/widgets/Stepper.js +296 -0
- package/src/widgets/Switch.js +102 -0
- package/src/widgets/Text.js +98 -0
- package/src/widgets/Tooltip.js +267 -0
- package/src/widgets/TreeView.js +370 -0
- package/src/widgets/Video.js +92 -0
- package/src/widgets/index.js +52 -0
|
@@ -0,0 +1,1236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Markdown.js
|
|
3
|
+
* @description Markdown renderer with per-instance scoped styles and one shared
|
|
4
|
+
* sanitizer for both initial render and `updateContent`.
|
|
5
|
+
*/
|
|
6
|
+
import { WidgetFactory } from "../widget-factory/index.js";
|
|
7
|
+
import { colors } from "../utils/themes.js";
|
|
8
|
+
import { generateHighlightedHtml } from "../utils/syntaxHighlight.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Escapes text for safe insertion as HTML. Local to this file on purpose:
|
|
12
|
+
* importing it from utils/markdownParser.js would pull six widget modules into
|
|
13
|
+
* the dependency graph of every Markdown instance.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} text - Raw text to escape.
|
|
16
|
+
* @returns {string} Text with `&`, `<`, `>`, `"` and `'` replaced by entities.
|
|
17
|
+
*/
|
|
18
|
+
const escapeCode = (text) =>
|
|
19
|
+
String(text)
|
|
20
|
+
.replace(/&/g, "&")
|
|
21
|
+
.replace(/</g, "<")
|
|
22
|
+
.replace(/>/g, ">")
|
|
23
|
+
.replace(/"/g, """)
|
|
24
|
+
.replace(/'/g, "'");
|
|
25
|
+
|
|
26
|
+
/** Monotonic counter giving each Markdown instance its own CSS scope class. */
|
|
27
|
+
let markdownScopeUid = 0;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sanitizes Markdown-rendered HTML. Strips `<script>` elements, then walks real
|
|
31
|
+
* tags to remove inline event handlers and `javascript:` URLs. It deliberately
|
|
32
|
+
* avoids a global regex over the raw text so code samples containing
|
|
33
|
+
* `onerror=` or `= await` survive untouched. This is NOT a full XSS defense:
|
|
34
|
+
* for untrusted input use a proper sanitizer.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} html - Raw HTML produced by `parseMarkdown`.
|
|
37
|
+
* @returns {string} The sanitized HTML.
|
|
38
|
+
*/
|
|
39
|
+
const sanitizeMarkdown = (html) => {
|
|
40
|
+
const withoutScripts = html.replace(
|
|
41
|
+
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
|
|
42
|
+
"",
|
|
43
|
+
);
|
|
44
|
+
return withoutScripts.replace(/<[^<>]+>/g, (tag) =>
|
|
45
|
+
tag
|
|
46
|
+
.replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
|
|
47
|
+
.replace(
|
|
48
|
+
/\s(href|src|xlink:href|formaction|action)\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*'|javascript:[^\s>]+)/gi,
|
|
49
|
+
' $1="#"',
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ─── Syntax highlighting ────────────────────────────────────────────────────
|
|
55
|
+
//
|
|
56
|
+
// JS/FletBox blocks delegate to the existing tokenizer in syntaxHighlight.js.
|
|
57
|
+
// All other languages use the lightweight per-language highlighters below,
|
|
58
|
+
// which share the same HTML-escape pass and produce <span style="color:…">
|
|
59
|
+
// tokens identical to the JS highlighter's output.
|
|
60
|
+
//
|
|
61
|
+
// Design decision: keep this self-contained inside Markdown.js so the widget
|
|
62
|
+
// has a single import boundary and the parsers are never pulled into unrelated
|
|
63
|
+
// dependency graphs.
|
|
64
|
+
|
|
65
|
+
/** One dark–inspired palette shared by every language highlighter. */
|
|
66
|
+
const SH = {
|
|
67
|
+
keyword: "#c678dd",
|
|
68
|
+
string: "#98c379",
|
|
69
|
+
number: "#d19a66",
|
|
70
|
+
comment: "#5c6370",
|
|
71
|
+
tag: "#e06c75",
|
|
72
|
+
attr: "#d19a66",
|
|
73
|
+
selector: "#e06c75",
|
|
74
|
+
property: "#61afef",
|
|
75
|
+
value: "#98c379",
|
|
76
|
+
function: "#61afef",
|
|
77
|
+
builtin: "#56b6c2",
|
|
78
|
+
punctuation: "#abb2bf",
|
|
79
|
+
operator: "#56b6c2",
|
|
80
|
+
variable: "#e5c07b",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Wraps `text` in a `<span>` with the given color, HTML-escaping the content.
|
|
85
|
+
* Returns plain escaped text when `color` is falsy.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} text
|
|
88
|
+
* @param {string} [color]
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
const shSpan = (text, color) => {
|
|
92
|
+
const safe = escapeCode(text);
|
|
93
|
+
return color ? `<span style="color:${color}">${safe}</span>` : safe;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Applies a sequence of `[regex, color]` replacement rules to `line`, escaping
|
|
98
|
+
* every character that is NOT inside a match so raw `<`/`>` cannot break the
|
|
99
|
+
* surrounding `<code>` element.
|
|
100
|
+
*
|
|
101
|
+
* Rules are applied left-to-right in declaration order. Each match is replaced
|
|
102
|
+
* with the colored span immediately and the remaining text is processed by
|
|
103
|
+
* subsequent rules. This is intentionally simple — it is not a full lexer, but
|
|
104
|
+
* it handles 95 % of real-world Markdown code snippets correctly.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} line - One source line (without the trailing newline).
|
|
107
|
+
* @param {Array<[RegExp, string]>} rules - Ordered list of [pattern, color] pairs.
|
|
108
|
+
* @returns {string} HTML fragment safe for `innerHTML`.
|
|
109
|
+
*/
|
|
110
|
+
const applyRules = (line, rules) => {
|
|
111
|
+
// Build a combined pattern that captures which rule matched via named groups.
|
|
112
|
+
const combined = new RegExp(
|
|
113
|
+
rules.map(([re], i) => `(?<r${i}>${re.source})`).join("|"),
|
|
114
|
+
"g",
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
let out = "";
|
|
118
|
+
let last = 0;
|
|
119
|
+
let m;
|
|
120
|
+
while ((m = combined.exec(line)) !== null) {
|
|
121
|
+
// Text between the previous match and this one — escape and emit as-is.
|
|
122
|
+
if (m.index > last) out += escapeCode(line.slice(last, m.index));
|
|
123
|
+
// Find which named group matched.
|
|
124
|
+
const ruleIndex = rules.findIndex((_, i) => m.groups[`r${i}`] !== undefined);
|
|
125
|
+
const color = rules[ruleIndex][1];
|
|
126
|
+
out += `<span style="color:${color}">${escapeCode(m[0])}</span>`;
|
|
127
|
+
last = m.index + m[0].length;
|
|
128
|
+
}
|
|
129
|
+
// Remaining plain text.
|
|
130
|
+
if (last < line.length) out += escapeCode(line.slice(last));
|
|
131
|
+
return out;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// ── Per-language rule tables ─────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
const RULES_PYTHON = [
|
|
137
|
+
[/(#.*)$/, SH.comment],
|
|
138
|
+
[/("""[\s\S]*?"""|'''[\s\S]*?'''|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/, SH.string],
|
|
139
|
+
[/\b(def|class|if|elif|else|for|while|try|except|finally|with|as|import|from|return|yield|pass|break|continue|raise|lambda|not|and|or|in|is|None|True|False|async|await|global|nonlocal|del)\b/, SH.keyword],
|
|
140
|
+
[/\b(print|len|range|type|str|int|float|bool|list|dict|tuple|set|enumerate|zip|map|filter|sorted|reversed|open|super|self|cls)\b/, SH.builtin],
|
|
141
|
+
[/\b([0-9]+(?:\.[0-9]+)?)\b/, SH.number],
|
|
142
|
+
[/([+\-*/=<>!&|^~%]+)/, SH.operator],
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
const RULES_CSS = [
|
|
146
|
+
[/(\/\*[\s\S]*?\*\/)/, SH.comment],
|
|
147
|
+
[/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/, SH.string],
|
|
148
|
+
[/(#[0-9a-fA-F]{3,8})\b/, SH.value],
|
|
149
|
+
[/\b([0-9]+(?:\.[0-9]+)?(?:px|em|rem|%|vh|vw|vmin|vmax|pt|cm|mm|deg|s|ms)?)\b/, SH.number],
|
|
150
|
+
[/(:[a-zA-Z-]+\(|:[a-zA-Z-]+\b|::[a-zA-Z-]+)/, SH.selector],
|
|
151
|
+
[/([.#]?[a-zA-Z][a-zA-Z0-9_-]*)(?=\s*\{)/, SH.selector],
|
|
152
|
+
[/\b(animation|background|border|color|display|flex|font|gap|grid|height|justify|margin|max|min|opacity|overflow|padding|position|text|transform|transition|width|z-index|align|content|cursor|float|outline|pointer|shadow|top|bottom|left|right)\b/, SH.property],
|
|
153
|
+
[/\b(inherit|initial|none|auto|normal|bold|italic|relative|absolute|fixed|sticky|block|inline|flex|grid|center|left|right|top|bottom|middle|start|end|solid|dashed|dotted|transparent|var)\b/, SH.keyword],
|
|
154
|
+
[/([{}:;,])/, SH.punctuation],
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
const RULES_HTML = [
|
|
158
|
+
[/(<!--[\s\S]*?-->)/, SH.comment],
|
|
159
|
+
[/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/, SH.string],
|
|
160
|
+
[/(<\/?)([a-zA-Z][a-zA-Z0-9-]*)/, SH.tag], // tag name
|
|
161
|
+
[/\b([a-zA-Z:][a-zA-Z0-9_:-]*)(?=\s*=)/, SH.attr], // attribute name
|
|
162
|
+
[/([<>/=])/, SH.punctuation],
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
const RULES_BASH = [
|
|
166
|
+
[/(#.*)$/, SH.comment],
|
|
167
|
+
[/("(?:[^"\\$]|\\.|\$[^{(]|\$\{[^}]*\}|\$\([^)]*\))*"|'[^']*')/, SH.string],
|
|
168
|
+
[/\b(if|then|else|elif|fi|for|while|do|done|case|esac|in|function|return|exit|local|export|readonly|declare|source|echo|cd|ls|mkdir|rm|mv|cp|cat|grep|sed|awk|find|curl|wget|git|npm|node|python|pip|sudo|chmod|chown|eval|exec|trap|set|unset|shift|read|printf|test)\b/, SH.keyword],
|
|
169
|
+
[/(\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|\$[0-9@#*!?$-])/, SH.variable],
|
|
170
|
+
[/\b([0-9]+)\b/, SH.number],
|
|
171
|
+
[/([|&;<>(){}[\]])/, SH.operator],
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
const RULES_JSON = [
|
|
175
|
+
[/("(?:[^"\\]|\\.)*")(?=\s*:)/, SH.property],
|
|
176
|
+
[/("(?:[^"\\]|\\.)*")/, SH.string],
|
|
177
|
+
[/\b(true|false|null)\b/, SH.keyword],
|
|
178
|
+
[/(-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/, SH.number],
|
|
179
|
+
[/([{}[\]:,])/, SH.punctuation],
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
const RULES_TS = RULES_PYTHON; // TypeScript re-uses the applyRules path below via JS highlighter override
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Highlights one source line using the appropriate language highlighter.
|
|
186
|
+
* Falls through to plain HTML-escaped text for unknown languages.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} line - One line of source code (no trailing newline).
|
|
189
|
+
* @param {string} lang - Normalised language identifier (lower-case).
|
|
190
|
+
* @returns {string} HTML fragment.
|
|
191
|
+
*/
|
|
192
|
+
const highlightLine = (line, lang) => {
|
|
193
|
+
switch (lang) {
|
|
194
|
+
case "js":
|
|
195
|
+
case "jsx":
|
|
196
|
+
case "javascript":
|
|
197
|
+
case "ts":
|
|
198
|
+
case "tsx":
|
|
199
|
+
case "typescript":
|
|
200
|
+
// Delegate to the existing FletBox tokenizer for JS/TS.
|
|
201
|
+
return generateHighlightedHtml(line);
|
|
202
|
+
|
|
203
|
+
case "py":
|
|
204
|
+
case "python":
|
|
205
|
+
return applyRules(line, RULES_PYTHON);
|
|
206
|
+
|
|
207
|
+
case "css":
|
|
208
|
+
case "scss":
|
|
209
|
+
case "less":
|
|
210
|
+
return applyRules(line, RULES_CSS);
|
|
211
|
+
|
|
212
|
+
case "html":
|
|
213
|
+
case "xml":
|
|
214
|
+
case "svg":
|
|
215
|
+
return applyRules(line, RULES_HTML);
|
|
216
|
+
|
|
217
|
+
case "sh":
|
|
218
|
+
case "bash":
|
|
219
|
+
case "shell":
|
|
220
|
+
case "zsh":
|
|
221
|
+
return applyRules(line, RULES_BASH);
|
|
222
|
+
|
|
223
|
+
case "json":
|
|
224
|
+
case "jsonc":
|
|
225
|
+
return applyRules(line, RULES_JSON);
|
|
226
|
+
|
|
227
|
+
default:
|
|
228
|
+
return escapeCode(line);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Copies text using the Clipboard API, falling back to a temporary textarea and
|
|
234
|
+
* `document.execCommand('copy')` for older or non-secure environments.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} text - Text to copy.
|
|
237
|
+
* @param {() => void} onDone - Called once the copy succeeds.
|
|
238
|
+
*/
|
|
239
|
+
const copyTextToClipboard = (text, onDone) => {
|
|
240
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
241
|
+
navigator.clipboard.writeText(text).then(onDone).catch(() => {
|
|
242
|
+
legacyCopy(text);
|
|
243
|
+
onDone();
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
legacyCopy(text);
|
|
248
|
+
onDone();
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/** Legacy clipboard path: select a throwaway textarea and execCommand('copy'). */
|
|
252
|
+
const legacyCopy = (text) => {
|
|
253
|
+
const ta = document.createElement("textarea");
|
|
254
|
+
ta.value = text;
|
|
255
|
+
ta.setAttribute("readonly", "");
|
|
256
|
+
ta.style.position = "fixed";
|
|
257
|
+
ta.style.opacity = "0";
|
|
258
|
+
document.body.appendChild(ta);
|
|
259
|
+
ta.select();
|
|
260
|
+
try {
|
|
261
|
+
document.execCommand("copy");
|
|
262
|
+
} finally {
|
|
263
|
+
document.body.removeChild(ta);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Renders a fenced code block as a `<div class="md-code-block">` containing
|
|
269
|
+
* a copy button and a `<pre><code>` with syntax-highlighted lines.
|
|
270
|
+
*
|
|
271
|
+
* The copy button carries no inline handler: the default sanitizer strips
|
|
272
|
+
* `on*=` attributes, so the click is wired by a delegated listener on the
|
|
273
|
+
* content node after mount (see the Markdown body).
|
|
274
|
+
*
|
|
275
|
+
* @param {{ lang: string, code: string }} block
|
|
276
|
+
* @returns {string} HTML fragment.
|
|
277
|
+
*/
|
|
278
|
+
const renderFencedBlock = (block) => {
|
|
279
|
+
const lang = block.lang.replace(/[^A-Za-z0-9#+._-]/g, "").toLowerCase();
|
|
280
|
+
const cls = lang ? ` class="language-${lang}"` : "";
|
|
281
|
+
const label = lang ? `<span class="md-lang-label">${escapeCode(lang)}</span>` : "";
|
|
282
|
+
|
|
283
|
+
// Highlight line-by-line. Lines are joined with a bare "\n" — no wrapper
|
|
284
|
+
// <span> per line because display:block + \n produced two line separators,
|
|
285
|
+
// doubling the visual gap regardless of line-height.
|
|
286
|
+
const lines = block.code.split("\n");
|
|
287
|
+
const highlighted = lines
|
|
288
|
+
.map((line) => highlightLine(line, lang))
|
|
289
|
+
.join("\n");
|
|
290
|
+
|
|
291
|
+
// The raw code is embedded in a hidden <textarea> so the copy handler can
|
|
292
|
+
// read it without stripping HTML and without touching the DOM of <code>.
|
|
293
|
+
const safeRaw = escapeCode(block.code);
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
`<div class="md-code-block">` +
|
|
297
|
+
`<div class="md-code-header">` +
|
|
298
|
+
label +
|
|
299
|
+
`<button type="button" class="md-copy-btn">Copy</button>` +
|
|
300
|
+
`</div>` +
|
|
301
|
+
`<textarea class="md-raw-src" readonly aria-hidden="true" tabindex="-1">${safeRaw}</textarea>` +
|
|
302
|
+
`<pre><code${cls}>${highlighted}</code></pre>` +
|
|
303
|
+
`</div>`
|
|
304
|
+
);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// ── End syntax highlighting ──────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
/** Opening fence: up to three spaces of indent, the run of backticks or tildes, then an optional info string. */
|
|
310
|
+
const FENCE_OPEN = /^[ \t]{0,3}(`{3,}|~{3,})[ \t]*([^\s`~]*)[^\n]*$/;
|
|
311
|
+
/** Closing fence: the run alone, optionally followed by whitespace. */
|
|
312
|
+
const FENCE_CLOSE = /^[ \t]{0,3}(`{3,}|~{3,})[ \t]*$/;
|
|
313
|
+
/**
|
|
314
|
+
* Placeholder left in place of a fenced block while the other rules run. It
|
|
315
|
+
* starts with `<f` so the paragraph wrapper below (`^(?!<[a-z]|$)`) skips it and
|
|
316
|
+
* does not emit a `<p>` around a `<pre>`.
|
|
317
|
+
*/
|
|
318
|
+
const fencePlaceholder = (index) => `<fletbox-fence-${index}></fletbox-fence-${index}>`;
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Replaces every fenced code block with a placeholder and returns the rewritten
|
|
322
|
+
* text plus the collected blocks.
|
|
323
|
+
*
|
|
324
|
+
* This runs before every other rule because each of them corrupts code samples:
|
|
325
|
+
* the inline-code rule consumes the ``` fence itself, the heading rule turns a
|
|
326
|
+
* leading `#` into an `<h1>`, the emphasis rules mangle `*` and `_`, and the
|
|
327
|
+
* paragraph wrapper splits the sample into `<p>` lines.
|
|
328
|
+
*
|
|
329
|
+
* @param {string} text - Markdown source.
|
|
330
|
+
* @returns {{ text: string, blocks: Array<{ lang: string, code: string }> }}
|
|
331
|
+
*/
|
|
332
|
+
const extractFencedCode = (text) => {
|
|
333
|
+
const blocks = [];
|
|
334
|
+
const out = [];
|
|
335
|
+
let open = null;
|
|
336
|
+
|
|
337
|
+
for (const line of text.split("\n")) {
|
|
338
|
+
if (open) {
|
|
339
|
+
const close = line.match(FENCE_CLOSE);
|
|
340
|
+
// A closing fence must not be shorter than the opening one, and for
|
|
341
|
+
// backticks the info string may not contain a backtick.
|
|
342
|
+
if (close && close[1][0] === open.marker[0] && close[1].length >= open.marker.length) {
|
|
343
|
+
blocks.push({ lang: open.lang, code: open.code.join("\n") });
|
|
344
|
+
out.push(fencePlaceholder(blocks.length - 1));
|
|
345
|
+
open = null;
|
|
346
|
+
} else {
|
|
347
|
+
open.code.push(line);
|
|
348
|
+
}
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const start = line.match(FENCE_OPEN);
|
|
353
|
+
if (start) {
|
|
354
|
+
open = { marker: start[1], lang: start[2], code: [] };
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
out.push(line);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Unterminated fence: CommonMark runs it to the end of the document.
|
|
362
|
+
if (open) {
|
|
363
|
+
blocks.push({ lang: open.lang, code: open.code.join("\n") });
|
|
364
|
+
out.push(fencePlaceholder(blocks.length - 1));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return { text: out.join("\n"), blocks };
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Raw HTML tag: a comment, or an opening/closing/self-closing tag with a name.
|
|
372
|
+
* Attribute values may not contain `<` or `>`, which keeps this from matching
|
|
373
|
+
* across a whole paragraph. `fletbox-*` is excluded so the fence placeholders
|
|
374
|
+
* are not mistaken for tags.
|
|
375
|
+
*/
|
|
376
|
+
const RAW_HTML =
|
|
377
|
+
/<!--[\s\S]*?-->|<\/?(?!fletbox\b)[a-zA-Z][a-zA-Z0-9-]*(?:\s[^<>]*?)?\/?>/g;
|
|
378
|
+
/** Placeholder standing in for a raw HTML tag while the inline rules run. */
|
|
379
|
+
const htmlPlaceholder = (index) => `<fletbox-html-${index}>`;
|
|
380
|
+
|
|
381
|
+
/** A delimiter cell is only hyphens plus optional colons: `:---`, `---:`, `:---:` or `---`. */
|
|
382
|
+
const DELIMITER_CELL = /^:?-+:?$/;
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Splits one table row into cells. The leading/trailing pipe is optional and a
|
|
386
|
+
* backslash-escaped pipe stays inside its cell instead of starting a new one.
|
|
387
|
+
*
|
|
388
|
+
* @param {string} line - Raw row text.
|
|
389
|
+
* @returns {string[]}
|
|
390
|
+
*/
|
|
391
|
+
const splitTableRow = (line) => {
|
|
392
|
+
let s = line.trim();
|
|
393
|
+
if (s.startsWith("|")) s = s.slice(1);
|
|
394
|
+
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
|
|
395
|
+
|
|
396
|
+
const cells = [];
|
|
397
|
+
let current = "";
|
|
398
|
+
for (let i = 0; i < s.length; i++) {
|
|
399
|
+
const ch = s[i];
|
|
400
|
+
if (ch === "\\" && s[i + 1] === "|") {
|
|
401
|
+
current += "|";
|
|
402
|
+
i++;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (ch === "|") {
|
|
406
|
+
cells.push(current);
|
|
407
|
+
current = "";
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
current += ch;
|
|
411
|
+
}
|
|
412
|
+
cells.push(current);
|
|
413
|
+
return cells.map((cell) => cell.trim());
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Reads the alignment of each column from a delimiter row, or returns null when
|
|
418
|
+
* the line is not a delimiter row at all.
|
|
419
|
+
*
|
|
420
|
+
* @param {string} line - Candidate line.
|
|
421
|
+
* @returns {string[] | null} One of `"left"`, `"center"`, `"right"` or `""` per column.
|
|
422
|
+
*/
|
|
423
|
+
const parseDelimiterRow = (line) => {
|
|
424
|
+
if (!line.includes("|")) return null;
|
|
425
|
+
const cells = splitTableRow(line);
|
|
426
|
+
if (cells.length === 0 || !cells.every((cell) => DELIMITER_CELL.test(cell))) return null;
|
|
427
|
+
return cells.map((cell) => {
|
|
428
|
+
const left = cell.startsWith(":");
|
|
429
|
+
const right = cell.endsWith(":");
|
|
430
|
+
if (left && right) return "center";
|
|
431
|
+
if (right) return "right";
|
|
432
|
+
if (left) return "left";
|
|
433
|
+
return "";
|
|
434
|
+
});
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
/** Placeholder standing in for a table while the paragraph wrapper runs. */
|
|
438
|
+
const tablePlaceholder = (index) => `<fletbox-table-${index}></fletbox-table-${index}>`;
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Replaces every GFM pipe table with a placeholder and returns the rewritten text
|
|
442
|
+
* plus the collected tables.
|
|
443
|
+
*
|
|
444
|
+
* Runs before the paragraph wrapper, otherwise each row would be sealed into its
|
|
445
|
+
* own `<p>` and the columns would collapse into stacked lines of text. Cells are
|
|
446
|
+
* taken verbatim: the inline rules have already run, so they still hold `<code>`,
|
|
447
|
+
* `<a>` and the raw-HTML placeholders.
|
|
448
|
+
*
|
|
449
|
+
* @param {string} text - Markdown source with fences and inline code already extracted.
|
|
450
|
+
* @returns {{ text: string, tables: Array<{ head: string[], rows: string[][], align: string[] }> }}
|
|
451
|
+
*/
|
|
452
|
+
const extractTables = (text) => {
|
|
453
|
+
const tables = [];
|
|
454
|
+
const out = [];
|
|
455
|
+
const lines = text.split("\n");
|
|
456
|
+
|
|
457
|
+
let i = 0;
|
|
458
|
+
while (i < lines.length) {
|
|
459
|
+
const header = lines[i];
|
|
460
|
+
const align =
|
|
461
|
+
i + 1 < lines.length && header.includes("|")
|
|
462
|
+
? parseDelimiterRow(lines[i + 1])
|
|
463
|
+
: null;
|
|
464
|
+
|
|
465
|
+
if (align && header.trim() && !DELIMITER_CELL.test(header.trim())) {
|
|
466
|
+
const head = splitTableRow(header);
|
|
467
|
+
const rows = [];
|
|
468
|
+
let j = i + 2;
|
|
469
|
+
// A row ends at the first line without a pipe; a blank line ends the table.
|
|
470
|
+
for (; j < lines.length && lines[j].trim() && lines[j].includes("|"); j++) {
|
|
471
|
+
rows.push(splitTableRow(lines[j]));
|
|
472
|
+
}
|
|
473
|
+
tables.push({ head, rows, align });
|
|
474
|
+
out.push(tablePlaceholder(tables.length - 1));
|
|
475
|
+
i = j;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
out.push(lines[i]);
|
|
480
|
+
i++;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return { text: out.join("\n"), tables };
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Builds the `<table>` markup. Rows are padded or trimmed to the header width so
|
|
488
|
+
* a ragged column cannot shift the layout.
|
|
489
|
+
*
|
|
490
|
+
* @param {{ head: string[], rows: string[][], align: string[] }} table
|
|
491
|
+
* @returns {string}
|
|
492
|
+
*/
|
|
493
|
+
const renderTable = ({ head, rows, align }) => {
|
|
494
|
+
const style = (column) => (align[column] ? ` style="text-align:${align[column]}"` : "");
|
|
495
|
+
const headerCells = head.map((cell, column) => `<th${style(column)}>${cell}</th>`).join("");
|
|
496
|
+
const bodyRows = rows
|
|
497
|
+
.map((row) => {
|
|
498
|
+
const cells = head
|
|
499
|
+
.map((_, column) => row[column] ?? "")
|
|
500
|
+
.map((cell, column) => `<td${style(column)}>${cell}</td>`)
|
|
501
|
+
.join("");
|
|
502
|
+
return `<tr>${cells}</tr>`;
|
|
503
|
+
})
|
|
504
|
+
.join("");
|
|
505
|
+
const body = bodyRows ? `<tbody>${bodyRows}</tbody>` : "";
|
|
506
|
+
return `<table class="markdown-table"><thead><tr>${headerCells}</tr></thead>${body}</table>`;
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Replaces raw HTML tags with placeholders so the inline rules cannot rewrite
|
|
511
|
+
* them.
|
|
512
|
+
*
|
|
513
|
+
* Without this, `window.__pwned` inside `onerror="window.__pwned = 1"` becomes
|
|
514
|
+
* `window.<em></em>pwned = 1`: the `<em>` landed inside the tag, which then made
|
|
515
|
+
* the sanitizer's tag matcher stop at that `>` and let the event handler through.
|
|
516
|
+
* A placeholder is inert to every rule and is restored verbatim afterwards.
|
|
517
|
+
*
|
|
518
|
+
* @param {string} text - Markdown source with fenced blocks already extracted.
|
|
519
|
+
* @returns {{ text: string, tags: string[] }}
|
|
520
|
+
*/
|
|
521
|
+
const extractRawHtml = (text) => {
|
|
522
|
+
const tags = [];
|
|
523
|
+
const out = text.replace(RAW_HTML, (tag) => {
|
|
524
|
+
tags.push(tag);
|
|
525
|
+
return htmlPlaceholder(tags.length - 1);
|
|
526
|
+
});
|
|
527
|
+
return { text: out, tags };
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Lightweight Markdown-to-HTML parser with no external dependencies.
|
|
532
|
+
*
|
|
533
|
+
* Supports: headings (h1–h3), bold, italic, inline code, code blocks,
|
|
534
|
+
* links, images, unordered/ordered lists, blockquotes, horizontal rules,
|
|
535
|
+
* and basic paragraphs.
|
|
536
|
+
*
|
|
537
|
+
* This is intentionally a simplified implementation — for production use
|
|
538
|
+
* with complex Markdown (tables, nested lists, GFM extensions) consider
|
|
539
|
+
* replacing this with a battle-tested library such as `marked` or `micromark`.
|
|
540
|
+
*
|
|
541
|
+
* @param {string} text - Raw Markdown string to convert.
|
|
542
|
+
* @returns {string} HTML string. Output may contain tags but no `<script>` or event handlers
|
|
543
|
+
* unless `allowDangerousHtml` is explicitly set on the widget.
|
|
544
|
+
*/
|
|
545
|
+
const parseMarkdown = (text) => {
|
|
546
|
+
if (!text) return "";
|
|
547
|
+
|
|
548
|
+
let html = text.replace(/\r\n?/g, "\n");
|
|
549
|
+
|
|
550
|
+
// Pull fenced code blocks out first and restore them last, so their contents
|
|
551
|
+
// are escaped verbatim instead of being run through the inline rules.
|
|
552
|
+
const { text: withoutFences, blocks } = extractFencedCode(html);
|
|
553
|
+
html = withoutFences;
|
|
554
|
+
|
|
555
|
+
// Inline code, before the raw-HTML pass and before bold/italic: a code span is
|
|
556
|
+
// literal, so emphasis must not apply inside it, and its body is escaped. An
|
|
557
|
+
// unescaped `<div>` or `<style>` typed in a sample becomes a real element, and
|
|
558
|
+
// a `<style>` opened by a sample swallows every following block as CSS.
|
|
559
|
+
html = html.replace(
|
|
560
|
+
/`(.*?)`/g,
|
|
561
|
+
(_match, code) => `<code>${escapeCode(code)}</code>`,
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
// Pull real HTML tags out too, so the inline rules below never rewrite an
|
|
565
|
+
// attribute value. Runs after the fences and the code spans: both hold literal
|
|
566
|
+
// samples that must not be reinterpreted as markup.
|
|
567
|
+
const { text: withoutHtml, tags } = extractRawHtml(html);
|
|
568
|
+
html = withoutHtml;
|
|
569
|
+
|
|
570
|
+
// Headings — deepest level first so "##" does not partially match "###".
|
|
571
|
+
// Covers h1–h6 as CommonMark specifies: 1–6 hash characters followed by a
|
|
572
|
+
// space and the heading text.
|
|
573
|
+
html = html.replace(/^###### (.*$)/gm, "<h6>$1</h6>");
|
|
574
|
+
html = html.replace(/^##### (.*$)/gm, "<h5>$1</h5>");
|
|
575
|
+
html = html.replace(/^#### (.*$)/gm, "<h4>$1</h4>");
|
|
576
|
+
html = html.replace(/^### (.*$)/gm, "<h3>$1</h3>");
|
|
577
|
+
html = html.replace(/^## (.*$)/gm, "<h2>$1</h2>");
|
|
578
|
+
html = html.replace(/^# (.*$)/gm, "<h1>$1</h1>");
|
|
579
|
+
|
|
580
|
+
// Bold (both ** and __ syntax)
|
|
581
|
+
html = html.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
|
|
582
|
+
html = html.replace(/__(.*?)__/g, "<strong>$1</strong>");
|
|
583
|
+
|
|
584
|
+
// Italic (both * and _ syntax)
|
|
585
|
+
html = html.replace(/\*(.*?)\*/g, "<em>$1</em>");
|
|
586
|
+
html = html.replace(/_(.*?)_/g, "<em>$1</em>");
|
|
587
|
+
|
|
588
|
+
// Images — must run BEFORE the link rule so "" is not consumed
|
|
589
|
+
// by the link regex which would leave a bare "!" in the output.
|
|
590
|
+
html = html.replace(
|
|
591
|
+
/!\[(.*?)\]\((.*?)\)/g,
|
|
592
|
+
(_match, alt, src) =>
|
|
593
|
+
`<img src="${escapeCode(src)}" alt="${escapeCode(alt)}" loading="lazy">`,
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
// Links — opens in a new tab with rel="noopener" for security. The URL is
|
|
597
|
+
// escaped so it cannot close the attribute or truncate the tag; that also lets
|
|
598
|
+
// the sanitizer below recognise `javascript:` hrefs as one well-formed tag.
|
|
599
|
+
html = html.replace(
|
|
600
|
+
/\[(.*?)\]\((.*?)\)/g,
|
|
601
|
+
(_match, label, href) =>
|
|
602
|
+
`<a href="${escapeCode(href)}" target="_blank" rel="noopener">${label}</a>`,
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
// Unordered lists — wrap consecutive <li> items produced by the "-" rule in
|
|
606
|
+
// a single <ul>. The replacement uses a non-greedy match anchored to the
|
|
607
|
+
// first <li> so it does not accidentally swallow ordered-list items too.
|
|
608
|
+
// Step 1: convert each "- item" line into a raw <li>.
|
|
609
|
+
html = html.replace(/^[ \t]*-[ \t]+(.*$)/gm, "<li>$1</li>");
|
|
610
|
+
// Step 2: group consecutive unordered <li> runs. We tag them with a
|
|
611
|
+
// data-ul attribute so the ordered-list pass below does not re-wrap them.
|
|
612
|
+
html = html.replace(
|
|
613
|
+
/(<li>(?:(?!<\/ol>).)*?<\/li>(\n|$))+/gs,
|
|
614
|
+
(match) => `<ul>${match.replace(/\n$/, "")}</ul>\n`,
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
// Ordered lists
|
|
618
|
+
// Step 1: convert each "1. item" line into a raw <li>.
|
|
619
|
+
html = html.replace(/^[ \t]*\d+\.[ \t]+(.*$)/gm, "<li>$1</li>");
|
|
620
|
+
// Step 2: group consecutive ordered <li> runs that are NOT already inside a <ul>.
|
|
621
|
+
html = html.replace(
|
|
622
|
+
/(<li>(?:(?!<\/ul>).)*?<\/li>(\n|$))+/gs,
|
|
623
|
+
(match) => `<ol>${match.replace(/\n$/, "")}</ol>\n`,
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
// Blockquotes
|
|
627
|
+
html = html.replace(/^> (.*$)/gm, "<blockquote>$1</blockquote>");
|
|
628
|
+
|
|
629
|
+
// Horizontal rule
|
|
630
|
+
html = html.replace(/^---$/gm, "<hr>");
|
|
631
|
+
|
|
632
|
+
// Tables, before the paragraph wrapper seals each row into its own <p>.
|
|
633
|
+
const { text: withoutTables, tables } = extractTables(html);
|
|
634
|
+
html = withoutTables;
|
|
635
|
+
|
|
636
|
+
// Wrap bare text lines in <p> tags (skip lines that already start with an HTML tag)
|
|
637
|
+
html = html.replace(/^(?!<[a-z]|$)(.*$)/gm, "<p>$1</p>");
|
|
638
|
+
|
|
639
|
+
// Double newlines between INLINE content become a single line break.
|
|
640
|
+
// We deliberately skip this conversion when both neighbours are block-level
|
|
641
|
+
// HTML tags (headings, lists, blockquotes, pre, hr, p, div, table) because
|
|
642
|
+
// those elements already carry margin and a bare <br> between them creates a
|
|
643
|
+
// visible empty line that doubles the visual gap.
|
|
644
|
+
html = html.replace(/\n\n/g, (_, offset, str) => {
|
|
645
|
+
// Look at the character immediately before and after the double newline.
|
|
646
|
+
const before = str.slice(0, offset).trimEnd();
|
|
647
|
+
const after = str.slice(offset + 2).trimStart();
|
|
648
|
+
const BLOCK = /^<\/?(?:h[1-6]|ul|ol|li|p|pre|blockquote|hr|div|table|thead|tbody|tr|td|th|figure|figcaption|article|section|header|footer|nav|aside|main)\b/i;
|
|
649
|
+
// If either side is a block element, swallow the break entirely — the
|
|
650
|
+
// block's own margins provide the vertical spacing.
|
|
651
|
+
if (BLOCK.test(before.slice(-20)) || BLOCK.test(after.slice(0, 20))) {
|
|
652
|
+
return "\n";
|
|
653
|
+
}
|
|
654
|
+
return "<br>";
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// Restore the code blocks with syntax highlighting and a copy button.
|
|
658
|
+
html = html.replace(
|
|
659
|
+
/<fletbox-fence-(\d+)><\/fletbox-fence-\1>/g,
|
|
660
|
+
(_match, rawIndex) => {
|
|
661
|
+
const block = blocks[Number(rawIndex)];
|
|
662
|
+
if (!block) return "";
|
|
663
|
+
return renderFencedBlock(block);
|
|
664
|
+
},
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
// Restore tables, after the paragraph wrapper has run over everything else.
|
|
668
|
+
html = html.replace(
|
|
669
|
+
/<fletbox-table-(\d+)><\/fletbox-table-\1>/g,
|
|
670
|
+
(_match, rawIndex) => {
|
|
671
|
+
const table = tables[Number(rawIndex)];
|
|
672
|
+
if (!table) return "";
|
|
673
|
+
return renderTable(table);
|
|
674
|
+
},
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
// Restore raw HTML verbatim, after every rule has run. The sanitizer downstream
|
|
678
|
+
// still gets the chance to strip dangerous tags and handlers.
|
|
679
|
+
html = html.replace(/<fletbox-html-(\d+)>/g, (_match, rawIndex) => {
|
|
680
|
+
return tags[Number(rawIndex)] ?? "";
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
return html;
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* @typedef {Object} MarkdownProps
|
|
688
|
+
* @property {string} [text] - Markdown source string. Also aliased as `source` and `content`.
|
|
689
|
+
* @property {string} [source] - Alias for `text`.
|
|
690
|
+
* @property {string} [content] - Alias for `text`.
|
|
691
|
+
* @property {string} [children] - Alias for `text` (supports passing markdown as a child string).
|
|
692
|
+
*
|
|
693
|
+
* @property {number} [fontSize=14] - Base font size in pixels.
|
|
694
|
+
* @property {string} [fontFamily] - CSS font-family for the rendered text.
|
|
695
|
+
* @property {number} [lineHeight=1.6] - Line height multiplier.
|
|
696
|
+
* @property {string} [color] - Default text color.
|
|
697
|
+
*
|
|
698
|
+
* @property {string} [linkColor] - Color of hyperlinks.
|
|
699
|
+
* @property {string} [linkHoverColor] - Color of hyperlinks on hover.
|
|
700
|
+
* @property {boolean} [linkUnderline=false] - Whether to underline links by default.
|
|
701
|
+
*
|
|
702
|
+
* @property {string} [codeBgColor] - Background color for inline `code` spans.
|
|
703
|
+
* @property {string} [codeColor] - Text color for inline `code` spans.
|
|
704
|
+
* @property {number} [codeFontSize=12] - Font size for inline code.
|
|
705
|
+
* @property {string} [codeFontFamily] - Font family for inline code.
|
|
706
|
+
* @property {number} [codeBorderRadius=4] - Border radius for inline code backgrounds.
|
|
707
|
+
* @property {string} [codePadding] - Padding for inline code.
|
|
708
|
+
*
|
|
709
|
+
* @property {string} [preBgColor] - Background color for fenced code blocks.
|
|
710
|
+
* @property {number} [preBorderRadius=8] - Border radius for code block containers.
|
|
711
|
+
* @property {string} [prePadding] - Padding inside code blocks.
|
|
712
|
+
* @property {string} [preMargin] - Margin around code blocks.
|
|
713
|
+
*
|
|
714
|
+
* @property {string} [blockquoteBorderColor] - Left-border color for blockquotes.
|
|
715
|
+
* @property {number} [blockquoteBorderWidth=4] - Left-border width for blockquotes in pixels.
|
|
716
|
+
* @property {string} [blockquoteColor] - Text color inside blockquotes.
|
|
717
|
+
* @property {string} [blockquotePadding] - Padding inside blockquotes.
|
|
718
|
+
* @property {string} [blockquoteMargin] - Margin around blockquotes.
|
|
719
|
+
*
|
|
720
|
+
* @property {string} [tableBorderColor] - Border color for table cells.
|
|
721
|
+
* @property {string} [tableHeaderBgColor] - Background color for header cells.
|
|
722
|
+
* @property {string} [tableCellPadding] - Padding inside table cells.
|
|
723
|
+
*
|
|
724
|
+
* @property {string} [headingColor] - Color applied to all heading elements.
|
|
725
|
+
* @property {string} [headingMargin] - Margin applied to all heading elements.
|
|
726
|
+
*
|
|
727
|
+
* @property {string} [listMargin] - Margin around list elements.
|
|
728
|
+
* @property {string} [listPadding] - Padding (left) for list elements.
|
|
729
|
+
* @property {string} [listItemMargin] - Margin for individual list items.
|
|
730
|
+
*
|
|
731
|
+
* @property {string} [imageMaxWidth="100%"] - Max width for rendered images.
|
|
732
|
+
* @property {number} [imageBorderRadius=0] - Border radius for rendered images.
|
|
733
|
+
*
|
|
734
|
+
* @property {number|string} [padding=0] - Padding of the outer container.
|
|
735
|
+
* @property {number} [maxHeight] - Maximum height; enables scrolling when content overflows.
|
|
736
|
+
* @property {string} [overflow="auto"] - CSS overflow value.
|
|
737
|
+
* @property {string} [backgroundColor="transparent"] - Background color of the container.
|
|
738
|
+
* @property {number} [borderRadius=0] - Border radius of the container.
|
|
739
|
+
*
|
|
740
|
+
* @property {boolean} [allowDangerousHtml=false] - When true, parsed HTML is set directly without
|
|
741
|
+
* sanitization. Only use with fully trusted Markdown sources.
|
|
742
|
+
*/
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Renders a Markdown string as styled HTML inside a widget container.
|
|
746
|
+
*
|
|
747
|
+
* Injects a single `<style id="markdown-styles">` tag into `<head>` the first time
|
|
748
|
+
* a Markdown widget is mounted, so all styling is shared across instances while still
|
|
749
|
+
* respecting the per-instance style props (the styles are regenerated each time a new
|
|
750
|
+
* widget is created — the last one to mount wins for shared rules).
|
|
751
|
+
*
|
|
752
|
+
* Content is sanitized by default: `<script>` tags and inline event handlers (`on*`)
|
|
753
|
+
* are stripped. Set `allowDangerousHtml` to bypass this only when the source is trusted.
|
|
754
|
+
*
|
|
755
|
+
* Public API on the returned element:
|
|
756
|
+
* - `updateContent(newText)` — re-renders with new Markdown source
|
|
757
|
+
* - `getContent()` — returns current inner HTML
|
|
758
|
+
* - `getSource()` — returns the original Markdown string
|
|
759
|
+
*
|
|
760
|
+
* @param {MarkdownProps} props
|
|
761
|
+
* @returns {HTMLElement} The container element with rendered Markdown inside.
|
|
762
|
+
*/
|
|
763
|
+
export const Markdown = (props) => {
|
|
764
|
+
const {
|
|
765
|
+
text,
|
|
766
|
+
source, // alias for text
|
|
767
|
+
content, // alias for text
|
|
768
|
+
children,
|
|
769
|
+
|
|
770
|
+
// Font styling
|
|
771
|
+
fontSize = 14,
|
|
772
|
+
fontFamily = "system-ui, -apple-system, sans-serif",
|
|
773
|
+
lineHeight = 1.6,
|
|
774
|
+
color = colors.text,
|
|
775
|
+
|
|
776
|
+
// Link styling
|
|
777
|
+
linkColor = colors.primary,
|
|
778
|
+
linkHoverColor = colors.primary,
|
|
779
|
+
linkUnderline = false,
|
|
780
|
+
|
|
781
|
+
// Inline code styling
|
|
782
|
+
codeBgColor = colors.gray100,
|
|
783
|
+
codeColor = colors.danger,
|
|
784
|
+
codeFontSize = 12,
|
|
785
|
+
codeFontFamily =
|
|
786
|
+
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Courier New", monospace',
|
|
787
|
+
codeBorderRadius = 4,
|
|
788
|
+
codePadding = "0.2em 0.45em",
|
|
789
|
+
|
|
790
|
+
// Fenced code block styling
|
|
791
|
+
preBgColor = colors.gray100,
|
|
792
|
+
preBorderRadius = 8,
|
|
793
|
+
prePadding = "1em",
|
|
794
|
+
preMargin = "1em 0",
|
|
795
|
+
|
|
796
|
+
// Blockquote styling
|
|
797
|
+
blockquoteBorderColor = colors.primary,
|
|
798
|
+
blockquoteBorderWidth = 4,
|
|
799
|
+
blockquoteColor = colors.textSecondary,
|
|
800
|
+
blockquotePadding = "0 1em",
|
|
801
|
+
blockquoteMargin = "1em 0",
|
|
802
|
+
|
|
803
|
+
// Table styling
|
|
804
|
+
tableBorderColor = colors.gray300,
|
|
805
|
+
tableHeaderBgColor = colors.gray100,
|
|
806
|
+
tableCellPadding = "0.4em 0.75em",
|
|
807
|
+
|
|
808
|
+
// Heading styling
|
|
809
|
+
headingColor = colors.text,
|
|
810
|
+
headingMargin = "0.67em 0",
|
|
811
|
+
|
|
812
|
+
// List styling
|
|
813
|
+
listMargin = "0em 0",
|
|
814
|
+
listPadding = "0 0 0 1em",
|
|
815
|
+
listItemMargin = "0.0em 0",
|
|
816
|
+
|
|
817
|
+
// Image styling
|
|
818
|
+
imageMaxWidth = "100%",
|
|
819
|
+
imageBorderRadius = 0,
|
|
820
|
+
|
|
821
|
+
// Container styling
|
|
822
|
+
padding = 0,
|
|
823
|
+
maxHeight,
|
|
824
|
+
overflow = "auto",
|
|
825
|
+
backgroundColor = "transparent",
|
|
826
|
+
borderRadius = 0,
|
|
827
|
+
|
|
828
|
+
// Safety: set to true only when the Markdown source is fully trusted
|
|
829
|
+
allowDangerousHtml = false,
|
|
830
|
+
|
|
831
|
+
...rest
|
|
832
|
+
} = props;
|
|
833
|
+
|
|
834
|
+
// Resolve the markdown source from any of the supported prop aliases
|
|
835
|
+
const markdownText = text || source || content || children || "";
|
|
836
|
+
|
|
837
|
+
// Per-instance CSS scope: `.markdown-content` was shared by every instance,
|
|
838
|
+
// so the last mounted widget's style props leaked into all the others.
|
|
839
|
+
const scope = `markdown-scope-${++markdownScopeUid}`;
|
|
840
|
+
|
|
841
|
+
// Parse Markdown to raw HTML
|
|
842
|
+
const rawHtml = parseMarkdown(markdownText);
|
|
843
|
+
|
|
844
|
+
// Outer container
|
|
845
|
+
const container = WidgetFactory({
|
|
846
|
+
tag: "div",
|
|
847
|
+
widgetName: "Markdown",
|
|
848
|
+
fontFamily: fontFamily,
|
|
849
|
+
fontSize: typeof fontSize === "number" ? `${fontSize}px` : fontSize,
|
|
850
|
+
lineHeight: lineHeight,
|
|
851
|
+
color: color,
|
|
852
|
+
padding: typeof padding === "number" ? `${padding}px` : padding,
|
|
853
|
+
maxHeight: typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight,
|
|
854
|
+
overflow: overflow,
|
|
855
|
+
backgroundColor: backgroundColor,
|
|
856
|
+
borderRadius:
|
|
857
|
+
typeof borderRadius === "number" ? `${borderRadius}px` : borderRadius,
|
|
858
|
+
...rest,
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// Inner div that receives the parsed HTML
|
|
862
|
+
const contentDiv = WidgetFactory({
|
|
863
|
+
tag: "div",
|
|
864
|
+
className: `${scope} markdown-content`,
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
// Sanitize by default; `allowDangerousHtml` bypasses it for trusted sources.
|
|
868
|
+
contentDiv.innerHTML = allowDangerousHtml
|
|
869
|
+
? rawHtml
|
|
870
|
+
: sanitizeMarkdown(rawHtml);
|
|
871
|
+
|
|
872
|
+
// Delegated copy handler. The button cannot carry an inline onclick because
|
|
873
|
+
// the sanitizer strips on*= attributes, and a per-button listener would not
|
|
874
|
+
// survive updateContent(); delegation on the persistent content node does.
|
|
875
|
+
contentDiv.addEventListener("click", (event) => {
|
|
876
|
+
const btn = event.target.closest?.(".md-copy-btn");
|
|
877
|
+
if (!btn) return;
|
|
878
|
+
const src = btn.closest(".md-code-block")?.querySelector(".md-raw-src");
|
|
879
|
+
const raw = src ? src.value : "";
|
|
880
|
+
const previous = btn.textContent;
|
|
881
|
+
copyTextToClipboard(raw, () => {
|
|
882
|
+
btn.textContent = "✓ Copied";
|
|
883
|
+
setTimeout(() => {
|
|
884
|
+
btn.textContent = previous;
|
|
885
|
+
}, 1500);
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// Per-instance stylesheet. The tag lives inside the container so it is removed
|
|
890
|
+
// with the widget on unmount — no unscoped tag in `document.head`, no leak.
|
|
891
|
+
const styleText = `
|
|
892
|
+
/* Containment: the rendered content must never contribute its
|
|
893
|
+
intrinsic (min-content) width to flex/grid ancestors, or a long
|
|
894
|
+
code line would stretch the whole layout past the viewport. */
|
|
895
|
+
.markdown-content {
|
|
896
|
+
min-width: 0;
|
|
897
|
+
max-width: 100%;
|
|
898
|
+
}
|
|
899
|
+
.markdown-content h1,
|
|
900
|
+
.markdown-content h2,
|
|
901
|
+
.markdown-content h3,
|
|
902
|
+
.markdown-content h4,
|
|
903
|
+
.markdown-content h5,
|
|
904
|
+
.markdown-content h6 {
|
|
905
|
+
color: ${headingColor};
|
|
906
|
+
margin: ${headingMargin};
|
|
907
|
+
font-weight: bold;
|
|
908
|
+
/* Prevent headings from inheriting auto margins that shift them
|
|
909
|
+
away from the left edge of the content area. */
|
|
910
|
+
margin-left: 0;
|
|
911
|
+
margin-right: 0;
|
|
912
|
+
padding: 0;
|
|
913
|
+
text-align: left;
|
|
914
|
+
width: 100%;
|
|
915
|
+
box-sizing: border-box;
|
|
916
|
+
display: block;
|
|
917
|
+
}
|
|
918
|
+
.markdown-content h1 { font-size: 2em; }
|
|
919
|
+
.markdown-content h2 { font-size: 1.5em; }
|
|
920
|
+
.markdown-content h3 { font-size: 1.17em; }
|
|
921
|
+
.markdown-content h4 { font-size: 1em; }
|
|
922
|
+
.markdown-content h5 { font-size: 0.83em; }
|
|
923
|
+
.markdown-content h6 { font-size: 0.67em; }
|
|
924
|
+
|
|
925
|
+
/* Paragraphs: collapse margin so double-newline <br> and <p> don't
|
|
926
|
+
stack and create oversized vertical gaps. */
|
|
927
|
+
.markdown-content p {
|
|
928
|
+
margin: 0.6em 0;
|
|
929
|
+
padding: 0;
|
|
930
|
+
margin-left: 0;
|
|
931
|
+
margin-right: 0;
|
|
932
|
+
display: block;
|
|
933
|
+
width: 100%;
|
|
934
|
+
box-sizing: border-box;
|
|
935
|
+
}
|
|
936
|
+
/* First and last <p> shouldn't add extra whitespace at edges */
|
|
937
|
+
.markdown-content p:first-child { margin-top: 0; }
|
|
938
|
+
.markdown-content p:last-child { margin-bottom: 0; }
|
|
939
|
+
|
|
940
|
+
/* Remove the <br> double-newline spacers when they land right before
|
|
941
|
+
or after a block element — they're redundant with block margins. */
|
|
942
|
+
.markdown-content h1 + br,
|
|
943
|
+
.markdown-content h2 + br,
|
|
944
|
+
.markdown-content h3 + br,
|
|
945
|
+
.markdown-content h4 + br,
|
|
946
|
+
.markdown-content h5 + br,
|
|
947
|
+
.markdown-content h6 + br,
|
|
948
|
+
.markdown-content ul + br,
|
|
949
|
+
.markdown-content ol + br,
|
|
950
|
+
.markdown-content .md-code-block + br,
|
|
951
|
+
.markdown-content blockquote + br,
|
|
952
|
+
.markdown-content hr + br {
|
|
953
|
+
display: none;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
.markdown-content ul,
|
|
957
|
+
.markdown-content ol {
|
|
958
|
+
margin: ${listMargin};
|
|
959
|
+
/* Left-align to the content edge; the padding-left provides
|
|
960
|
+
the bullet/number indent without shifting the whole block. */
|
|
961
|
+
margin-left: 0;
|
|
962
|
+
margin-right: 0;
|
|
963
|
+
padding: ${listPadding};
|
|
964
|
+
box-sizing: border-box;
|
|
965
|
+
width: 100%;
|
|
966
|
+
display: block;
|
|
967
|
+
}
|
|
968
|
+
.markdown-content li {
|
|
969
|
+
margin: ${listItemMargin};
|
|
970
|
+
text-align: left;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
.markdown-content table.markdown-table {
|
|
974
|
+
border-collapse: collapse;
|
|
975
|
+
margin: 1em 0;
|
|
976
|
+
display: block;
|
|
977
|
+
width: max-content;
|
|
978
|
+
max-width: 100%;
|
|
979
|
+
overflow-x: auto;
|
|
980
|
+
}
|
|
981
|
+
.markdown-content table.markdown-table th,
|
|
982
|
+
.markdown-content table.markdown-table td {
|
|
983
|
+
border: 1px solid ${tableBorderColor};
|
|
984
|
+
padding: ${tableCellPadding};
|
|
985
|
+
text-align: left;
|
|
986
|
+
vertical-align: top;
|
|
987
|
+
}
|
|
988
|
+
.markdown-content table.markdown-table th {
|
|
989
|
+
background-color: ${tableHeaderBgColor};
|
|
990
|
+
font-weight: bold;
|
|
991
|
+
white-space: nowrap;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
.markdown-content code {
|
|
995
|
+
background-color: ${codeBgColor};
|
|
996
|
+
color: ${codeColor};
|
|
997
|
+
padding: ${codePadding};
|
|
998
|
+
border-radius: ${typeof codeBorderRadius === "number" ? `${codeBorderRadius}px` : codeBorderRadius};
|
|
999
|
+
font-family: ${codeFontFamily};
|
|
1000
|
+
font-size: ${typeof codeFontSize === "number" ? `${codeFontSize}px` : codeFontSize};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/* Bare <pre> elements (raw HTML injected via allowDangerousHtml or future extensions).
|
|
1004
|
+
Fenced blocks now live inside .md-code-block and have their own rules above. */
|
|
1005
|
+
.markdown-content pre:not(.md-code-block pre) {
|
|
1006
|
+
background-color: ${preBgColor};
|
|
1007
|
+
padding: ${prePadding};
|
|
1008
|
+
border-radius: ${typeof preBorderRadius === "number" ? `${preBorderRadius}px` : preBorderRadius};
|
|
1009
|
+
overflow: auto;
|
|
1010
|
+
overflow-x: auto;
|
|
1011
|
+
margin: ${preMargin};
|
|
1012
|
+
margin-left: 0;
|
|
1013
|
+
margin-right: 0;
|
|
1014
|
+
width: 100%;
|
|
1015
|
+
max-width: 100%;
|
|
1016
|
+
box-sizing: border-box;
|
|
1017
|
+
display: block;
|
|
1018
|
+
}
|
|
1019
|
+
.markdown-content pre code {
|
|
1020
|
+
background: none;
|
|
1021
|
+
padding: 0;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
.markdown-content a {
|
|
1025
|
+
color: ${linkColor};
|
|
1026
|
+
text-decoration: ${linkUnderline ? "underline" : "none"};
|
|
1027
|
+
}
|
|
1028
|
+
.markdown-content a:hover {
|
|
1029
|
+
color: ${linkHoverColor};
|
|
1030
|
+
text-decoration: underline;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
.markdown-content blockquote {
|
|
1034
|
+
border-left: ${blockquoteBorderWidth}px solid ${blockquoteBorderColor};
|
|
1035
|
+
margin: ${blockquoteMargin};
|
|
1036
|
+
margin-left: 0;
|
|
1037
|
+
margin-right: 0;
|
|
1038
|
+
padding: ${blockquotePadding};
|
|
1039
|
+
color: ${blockquoteColor};
|
|
1040
|
+
box-sizing: border-box;
|
|
1041
|
+
width: 100%;
|
|
1042
|
+
display: block;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
.markdown-content img {
|
|
1046
|
+
max-width: ${imageMaxWidth};
|
|
1047
|
+
height: auto;
|
|
1048
|
+
border-radius: ${typeof imageBorderRadius === "number" ? `${imageBorderRadius}px` : imageBorderRadius};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
.markdown-content hr {
|
|
1052
|
+
border: none;
|
|
1053
|
+
border-top: 1px solid ${colors.border};
|
|
1054
|
+
margin: 1em 0;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/* ── Code block wrapper (replaces bare <pre>) ── */
|
|
1058
|
+
.markdown-content .md-code-block {
|
|
1059
|
+
position: relative;
|
|
1060
|
+
margin-left: 0;
|
|
1061
|
+
margin-right: 0;
|
|
1062
|
+
/* Fill the reading column; min-width:0 stops the code's
|
|
1063
|
+
intrinsic width from stretching flex/grid ancestors. */
|
|
1064
|
+
width: 100%;
|
|
1065
|
+
max-width: 100%;
|
|
1066
|
+
min-width: 0;
|
|
1067
|
+
box-sizing: border-box;
|
|
1068
|
+
border-radius: ${typeof preBorderRadius === "number" ? `${preBorderRadius}px` : preBorderRadius};
|
|
1069
|
+
overflow: hidden;
|
|
1070
|
+
margin-top: 1em;
|
|
1071
|
+
margin-bottom: 1em;
|
|
1072
|
+
background-color: ${preBgColor};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/* Header bar: language label + copy button */
|
|
1076
|
+
.markdown-content .md-code-header {
|
|
1077
|
+
display: flex;
|
|
1078
|
+
align-items: center;
|
|
1079
|
+
justify-content: space-between;
|
|
1080
|
+
padding: 0.3em 0.75em;
|
|
1081
|
+
background-color: rgba(0,0,0,0.18);
|
|
1082
|
+
min-height: 28px;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
.markdown-content .md-lang-label {
|
|
1086
|
+
font-family: ${codeFontFamily};
|
|
1087
|
+
font-size: 0.75em;
|
|
1088
|
+
color: ${SH.comment};
|
|
1089
|
+
text-transform: uppercase;
|
|
1090
|
+
letter-spacing: 0.06em;
|
|
1091
|
+
user-select: none;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
.markdown-content .md-copy-btn {
|
|
1095
|
+
background: none;
|
|
1096
|
+
border: 1px solid rgba(255,255,255,0.15);
|
|
1097
|
+
border-radius: 4px;
|
|
1098
|
+
color: ${SH.comment};
|
|
1099
|
+
cursor: pointer;
|
|
1100
|
+
font-size: 0.72em;
|
|
1101
|
+
padding: 0.15em 0.55em;
|
|
1102
|
+
transition: color 0.15s, border-color 0.15s;
|
|
1103
|
+
line-height: 1.6;
|
|
1104
|
+
font-family: ${codeFontFamily};
|
|
1105
|
+
}
|
|
1106
|
+
.markdown-content .md-copy-btn:hover {
|
|
1107
|
+
color: #fff;
|
|
1108
|
+
border-color: rgba(255,255,255,0.4);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/* Hidden textarea holding the raw source for the copy handler */
|
|
1112
|
+
.markdown-content .md-raw-src {
|
|
1113
|
+
position: absolute;
|
|
1114
|
+
width: 1px;
|
|
1115
|
+
height: 1px;
|
|
1116
|
+
opacity: 0;
|
|
1117
|
+
pointer-events: none;
|
|
1118
|
+
overflow: hidden;
|
|
1119
|
+
white-space: pre;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/* The <pre> inside the block — no padding here; it lives on <code>
|
|
1123
|
+
so the scrollbar sits flush with the block edge without gaps. */
|
|
1124
|
+
.markdown-content .md-code-block pre {
|
|
1125
|
+
margin: 0;
|
|
1126
|
+
padding: 0;
|
|
1127
|
+
background: transparent;
|
|
1128
|
+
border-radius: 0;
|
|
1129
|
+
overflow-x: auto;
|
|
1130
|
+
width: 100%;
|
|
1131
|
+
max-width: 100%;
|
|
1132
|
+
min-width: 0;
|
|
1133
|
+
box-sizing: border-box;
|
|
1134
|
+
/* Reset any inherited line-height from ancestor containers
|
|
1135
|
+
before the explicit value on <code> takes over. */
|
|
1136
|
+
line-height: 1;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
.markdown-content .md-code-block pre code {
|
|
1140
|
+
background: none;
|
|
1141
|
+
/* Padding on <code> (not <pre>) keeps the scrollbar aligned. */
|
|
1142
|
+
padding: ${prePadding};
|
|
1143
|
+
font-family: ${codeFontFamily};
|
|
1144
|
+
font-size: ${typeof codeFontSize === "number" ? `${codeFontSize}px` : codeFontSize};
|
|
1145
|
+
/* 1.5 matches VS Code / GitHub's code block density.
|
|
1146
|
+
Setting it here AND on md-code-line stops any ancestor
|
|
1147
|
+
line-height (the widget's default 1.6) from bleeding in. */
|
|
1148
|
+
line-height: 1.5;
|
|
1149
|
+
display: block;
|
|
1150
|
+
box-sizing: border-box;
|
|
1151
|
+
/* Grow to the longest line so the <pre> scrolls horizontally;
|
|
1152
|
+
min-width:100% keeps short blocks filling the box. Wrapping
|
|
1153
|
+
the code hid content and read as "broken" on mobile. */
|
|
1154
|
+
min-width: 100%;
|
|
1155
|
+
width: max-content;
|
|
1156
|
+
white-space: pre;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/* Line spacing inside code blocks. <code> uses white-space:pre so
|
|
1160
|
+
plain \n separators between highlighted lines are sufficient —
|
|
1161
|
+
no per-line wrapper elements needed. */
|
|
1162
|
+
|
|
1163
|
+
.markdown-content table {
|
|
1164
|
+
border-collapse: collapse;
|
|
1165
|
+
width: 100%;
|
|
1166
|
+
margin: 1em 0;
|
|
1167
|
+
}
|
|
1168
|
+
.markdown-content th,
|
|
1169
|
+
.markdown-content td {
|
|
1170
|
+
border: 1px solid ${colors.border};
|
|
1171
|
+
padding: 8px;
|
|
1172
|
+
text-align: left;
|
|
1173
|
+
}
|
|
1174
|
+
.markdown-content th {
|
|
1175
|
+
background-color: ${colors.gray100};
|
|
1176
|
+
}
|
|
1177
|
+
`.replaceAll(".markdown-content", "." + scope);
|
|
1178
|
+
|
|
1179
|
+
// Component styles, scoped per instance. They ride an adopted stylesheet so
|
|
1180
|
+
// they never become DOM children of the wrapper (which must hold exactly one
|
|
1181
|
+
// `.markdown-content` child) and are dropped when the widget unmounts.
|
|
1182
|
+
if (typeof CSSStyleSheet === "function") {
|
|
1183
|
+
const sheet = new CSSStyleSheet();
|
|
1184
|
+
sheet.replaceSync(styleText);
|
|
1185
|
+
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
|
|
1186
|
+
container.onUnmount(() => {
|
|
1187
|
+
document.adoptedStyleSheets = document.adoptedStyleSheets.filter(
|
|
1188
|
+
(s) => s !== sheet,
|
|
1189
|
+
);
|
|
1190
|
+
});
|
|
1191
|
+
} else {
|
|
1192
|
+
// Legacy fallback for engines without adopted stylesheets: keep the tag
|
|
1193
|
+
// inside the container so it is removed with the widget on unmount.
|
|
1194
|
+
const styleFallback = document.createElement("style");
|
|
1195
|
+
styleFallback.setAttribute("data-widget", "Markdown");
|
|
1196
|
+
styleFallback.textContent = styleText;
|
|
1197
|
+
container.appendChild(styleFallback);
|
|
1198
|
+
}
|
|
1199
|
+
container.appendChild(contentDiv);
|
|
1200
|
+
|
|
1201
|
+
// ========== PUBLIC METHODS ==========
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Re-renders the widget with new Markdown source.
|
|
1205
|
+
*
|
|
1206
|
+
* Uses the same two-pass sanitizer as the constructor: first strips
|
|
1207
|
+
* `<script>` elements entirely, then walks real tags to remove `on*`
|
|
1208
|
+
* handlers and `javascript:` URLs — never applies a global regex over the
|
|
1209
|
+
* raw text so code samples containing `onerror=` or `= await` are left
|
|
1210
|
+
* untouched.
|
|
1211
|
+
*
|
|
1212
|
+
* @param {string} newText - The updated Markdown string.
|
|
1213
|
+
*/
|
|
1214
|
+
container.updateContent = (newText) => {
|
|
1215
|
+
const newHtml = parseMarkdown(newText);
|
|
1216
|
+
contentDiv.innerHTML = allowDangerousHtml
|
|
1217
|
+
? newHtml
|
|
1218
|
+
: sanitizeMarkdown(newHtml);
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Returns the currently rendered inner HTML string.
|
|
1223
|
+
* @returns {string}
|
|
1224
|
+
*/
|
|
1225
|
+
container.getContent = () => contentDiv.innerHTML;
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Returns the original Markdown source string passed to the widget.
|
|
1229
|
+
* @returns {string}
|
|
1230
|
+
*/
|
|
1231
|
+
container.getSource = () => markdownText;
|
|
1232
|
+
|
|
1233
|
+
return container;
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
export default Markdown;
|