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.
Files changed (178) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -2
  3. package/bin/cli.js +394 -0
  4. package/bin/commands/buildAndroid.js +200 -0
  5. package/bin/commands/buildIOS.js +237 -0
  6. package/bin/commands/create.js +406 -0
  7. package/bin/commands/createBundle.js +375 -0
  8. package/bin/commands/createComponent.js +115 -0
  9. package/bin/commands/createScreen.js +161 -0
  10. package/bin/commands/killServer.js +107 -0
  11. package/bin/commands/package.js +131 -0
  12. package/bin/commands/release.js +189 -0
  13. package/bin/commands/runBundle.js +62 -0
  14. package/bin/commands/runCloudflare.js +89 -0
  15. package/bin/commands/runNative.js +109 -0
  16. package/bin/commands/runServer.js +390 -0
  17. package/bin/ota/loader.js +361 -0
  18. package/bin/utils/bundleConfig.js +178 -0
  19. package/bin/utils/capacitor.js +137 -0
  20. package/bin/utils/colors.js +215 -0
  21. package/bin/utils/helpers.js +80 -0
  22. package/bin/utils/spinner.js +96 -0
  23. package/bin/utils/templates.js +1824 -0
  24. package/bin/utils/version.js +108 -0
  25. package/package.json +58 -4
  26. package/src/animations/AnimatedBox.js +607 -0
  27. package/src/animations/AnimatedText.js +105 -0
  28. package/src/animations/MatrixRain.js +220 -0
  29. package/src/animations/ParallaxBox.js +369 -0
  30. package/src/animations/animate.js +332 -0
  31. package/src/animations/index.js +25 -0
  32. package/src/app.js +41 -0
  33. package/src/assets/icon-1024-maskable.png +0 -0
  34. package/src/assets/icon-192-maskable.png +0 -0
  35. package/src/assets/icon-192.png +0 -0
  36. package/src/assets/icon-512-maskable.png +0 -0
  37. package/src/assets/icon-512.png +0 -0
  38. package/src/assets/logo.png +0 -0
  39. package/src/core/App.js +44 -0
  40. package/src/core/hmr-client.js +142 -0
  41. package/src/core/index.js +34 -0
  42. package/src/core/pwa.js +100 -0
  43. package/src/core/runApp.js +430 -0
  44. package/src/create-index.sh +237 -0
  45. package/src/fonts/MaterialIcons-Regular.woff2 +0 -0
  46. package/src/fonts/icons.css +23 -0
  47. package/src/index.d.ts +1930 -0
  48. package/src/index.js +288 -0
  49. package/src/navigations/AdaptiveScaffold.js +149 -0
  50. package/src/navigations/AppBar.js +331 -0
  51. package/src/navigations/BottomNavigation.js +298 -0
  52. package/src/navigations/CollapsibleSideBar.js +139 -0
  53. package/src/navigations/Drawer.js +333 -0
  54. package/src/navigations/DrawerItem.js +260 -0
  55. package/src/navigations/Router.js +475 -0
  56. package/src/navigations/Scaffold.js +537 -0
  57. package/src/navigations/Tabs.js +384 -0
  58. package/src/navigations/index.js +57 -0
  59. package/src/services/RamStore.js +213 -0
  60. package/src/services/Session.js +310 -0
  61. package/src/services/Storage.js +309 -0
  62. package/src/services/http.js +350 -0
  63. package/src/services/index.js +57 -0
  64. package/src/tools/animation.js +42 -0
  65. package/src/tools/array.js +95 -0
  66. package/src/tools/border.js +28 -0
  67. package/src/tools/clipboard.js +104 -0
  68. package/src/tools/color.js +329 -0
  69. package/src/tools/createList.js +215 -0
  70. package/src/tools/delay.js +55 -0
  71. package/src/tools/device.js +138 -0
  72. package/src/tools/dict.js +297 -0
  73. package/src/tools/dimensions.js +118 -0
  74. package/src/tools/filter.js +41 -0
  75. package/src/tools/flex.js +26 -0
  76. package/src/tools/gradient.js +34 -0
  77. package/src/tools/grid.js +57 -0
  78. package/src/tools/index.js +38 -0
  79. package/src/tools/mapList.js +107 -0
  80. package/src/tools/margin.js +93 -0
  81. package/src/tools/memo.js +60 -0
  82. package/src/tools/os.js +104 -0
  83. package/src/tools/padding.js +93 -0
  84. package/src/tools/print.js +69 -0
  85. package/src/tools/random.js +299 -0
  86. package/src/tools/ref.js +70 -0
  87. package/src/tools/rgba.js +4 -0
  88. package/src/tools/shadow.js +23 -0
  89. package/src/tools/string.js +85 -0
  90. package/src/tools/time.js +674 -0
  91. package/src/tools/transform.js +68 -0
  92. package/src/tools/transition.js +48 -0
  93. package/src/tools/useState.js +268 -0
  94. package/src/tools/uuid.js +42 -0
  95. package/src/utils/TextInputValidator.js +256 -0
  96. package/src/utils/composeUpdate.js +65 -0
  97. package/src/utils/getWidgetProps.js +35 -0
  98. package/src/utils/index.js +76 -0
  99. package/src/utils/markdownParser.js +368 -0
  100. package/src/utils/mediaTime.js +79 -0
  101. package/src/utils/navigation.js +263 -0
  102. package/src/utils/scriptInjector.js +73 -0
  103. package/src/utils/stackPosition.js +31 -0
  104. package/src/utils/stopWebRefresh.js +77 -0
  105. package/src/utils/styleInjector.js +38 -0
  106. package/src/utils/syntaxHighlight.js +339 -0
  107. package/src/utils/themes.js +347 -0
  108. package/src/utils/units.js +87 -0
  109. package/src/utils/visualEffects.js +169 -0
  110. package/src/widget-factory/addChildren.js +114 -0
  111. package/src/widget-factory/assignProps.js +256 -0
  112. package/src/widget-factory/createWidget.js +28 -0
  113. package/src/widget-factory/effects.js +132 -0
  114. package/src/widget-factory/index.js +7 -0
  115. package/src/widget-factory/lifecycle.js +210 -0
  116. package/src/widget-factory/makeParentable.js +89 -0
  117. package/src/widget-factory/pixTool.js +40 -0
  118. package/src/widget-factory/processProps.js +122 -0
  119. package/src/widget-factory/reactivity.js +98 -0
  120. package/src/widget-factory/remTool.js +34 -0
  121. package/src/widget-factory/stackPosition.js +87 -0
  122. package/src/widget-factory/stringTool.js +26 -0
  123. package/src/widget-factory/tools.js +249 -0
  124. package/src/widget-factory/translateProps.js +1049 -0
  125. package/src/widget-factory/warnings.js +284 -0
  126. package/src/widget-factory/widgetFactory.js +200 -0
  127. package/src/widgets/Accordion.js +562 -0
  128. package/src/widgets/AdMob.js +315 -0
  129. package/src/widgets/AdSense.js +268 -0
  130. package/src/widgets/AlertDialog.js +162 -0
  131. package/src/widgets/Audio.js +311 -0
  132. package/src/widgets/Avatar.js +210 -0
  133. package/src/widgets/Badge.js +189 -0
  134. package/src/widgets/BottomSheet.js +287 -0
  135. package/src/widgets/Button.js +247 -0
  136. package/src/widgets/Card.js +33 -0
  137. package/src/widgets/Carousel.js +301 -0
  138. package/src/widgets/Chart.js +632 -0
  139. package/src/widgets/Checkbox.js +106 -0
  140. package/src/widgets/Chip.js +153 -0
  141. package/src/widgets/CircularBar.js +352 -0
  142. package/src/widgets/CircularChart.js +549 -0
  143. package/src/widgets/CodeViewer.js +236 -0
  144. package/src/widgets/Column.js +29 -0
  145. package/src/widgets/Container.js +31 -0
  146. package/src/widgets/DataTable.js +257 -0
  147. package/src/widgets/Divider.js +93 -0
  148. package/src/widgets/DraggBox.js +115 -0
  149. package/src/widgets/Dropdown.js +505 -0
  150. package/src/widgets/DroppBox.js +180 -0
  151. package/src/widgets/FloatingActionButton.js +91 -0
  152. package/src/widgets/GridView.js +20 -0
  153. package/src/widgets/Icon.js +33 -0
  154. package/src/widgets/Image.js +15 -0
  155. package/src/widgets/Input.js +565 -0
  156. package/src/widgets/Inspector.js +254 -0
  157. package/src/widgets/InstallButton.js +110 -0
  158. package/src/widgets/ListTile.js +224 -0
  159. package/src/widgets/ListView.js +503 -0
  160. package/src/widgets/Markdown.js +1236 -0
  161. package/src/widgets/Modal.js +496 -0
  162. package/src/widgets/Pagination.js +195 -0
  163. package/src/widgets/ProgressBar.js +178 -0
  164. package/src/widgets/QRCode.js +467 -0
  165. package/src/widgets/Radio.js +132 -0
  166. package/src/widgets/Rating.js +261 -0
  167. package/src/widgets/Row.js +29 -0
  168. package/src/widgets/Skeleton.js +243 -0
  169. package/src/widgets/Slider.js +527 -0
  170. package/src/widgets/SnackBar.js +227 -0
  171. package/src/widgets/Stack.js +29 -0
  172. package/src/widgets/Stepper.js +296 -0
  173. package/src/widgets/Switch.js +102 -0
  174. package/src/widgets/Text.js +98 -0
  175. package/src/widgets/Tooltip.js +267 -0
  176. package/src/widgets/TreeView.js +370 -0
  177. package/src/widgets/Video.js +92 -0
  178. 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, "&amp;")
21
+ .replace(/</g, "&lt;")
22
+ .replace(/>/g, "&gt;")
23
+ .replace(/"/g, "&quot;")
24
+ .replace(/'/g, "&#39;");
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 "![alt](src)" 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;