@dorsk/tsumikit 0.61.1 → 0.62.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 +49 -1
- package/dist/components/molecules/ChatBubble.svelte +28 -2
- package/dist/components/molecules/ChatBubble.svelte.d.ts +8 -1
- package/dist/components/molecules/Composer.svelte +43 -5
- package/dist/components/molecules/Composer.svelte.d.ts +3 -0
- package/dist/components/molecules/StatusBar.svelte +90 -0
- package/dist/components/molecules/StatusBar.svelte.d.ts +14 -0
- package/dist/components/organisms/ConversationFeed.svelte +148 -0
- package/dist/components/organisms/ConversationFeed.svelte.d.ts +44 -0
- package/dist/components/organisms/ConversationPanel.svelte +68 -0
- package/dist/components/organisms/ConversationPanel.svelte.d.ts +13 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/markdown.d.ts +12 -0
- package/dist/markdown.js +222 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -295,6 +295,10 @@ shows a `N%` bubble while it is moved; `oninput` live, `onchange` on commit;
|
|
|
295
295
|
`clearAtMax`/`uncappedLabel` express "no cap", `layout`/`stackBelow` put the
|
|
296
296
|
readout above a full-width track, `capLabel`/`capValueText` localise the ARIA
|
|
297
297
|
strings),
|
|
298
|
+
StatusBar (thin `role="status"` row: `tone` idle/busy/ok/warn/error dot or spinner, `label` or
|
|
299
|
+
`children`, `trailing`), ChatBubble (`timestamp`, `footer` snippet, `markdown` body rendered
|
|
300
|
+
through `renderMarkdown` into `Prose` when there are no `children`),
|
|
301
|
+
Composer (`toolbarStart`/`toolbarEnd` snippets move attach + send into a row under the field),
|
|
298
302
|
Drawer (side-panel `<dialog>`: `side`, `width` clamped to the viewport, full-screen
|
|
299
303
|
under 48rem; `nav` page column that turns into a horizontal strip on narrow
|
|
300
304
|
screens, or `navMobile`; sticky `footer`; Escape / scrim / close button all close),
|
|
@@ -312,7 +316,8 @@ ArrowUp/Down, Enter/Tab (`selectOn`), Escape and blur; `open`/`index` are
|
|
|
312
316
|
bindable, `onselect`/`onclose(reason)` report picks; `findTrigger`/`applyTrigger`
|
|
313
317
|
are exported for trigger-char detection and token replacement).
|
|
314
318
|
|
|
315
|
-
**Organisms:**
|
|
319
|
+
**Organisms:** ConversationPanel / ConversationFeed (chat column + sticky
|
|
320
|
+
feed, see below), DataTable (generic `<T>`, typed columns + cell snippets;
|
|
316
321
|
`layout="fixed"` makes column widths authoritative, `Column.truncate` /
|
|
317
322
|
`nowrap` / `hideBelow="sm|md|lg"` (container-query on the table's own box),
|
|
318
323
|
`hideHeader` clips the header but keeps it for assistive tech, `rowTone(row)`
|
|
@@ -562,6 +567,49 @@ it is neither focusable nor announced. `Composer` inherits the behaviour.
|
|
|
562
567
|
<AttachmentList tiles files={[{ name: 'cover.png', size: 1024, type: 'image/png', url }]} />
|
|
563
568
|
```
|
|
564
569
|
|
|
570
|
+
### ConversationPanel, ConversationFeed, StatusBar
|
|
571
|
+
|
|
572
|
+
`ConversationPanel` is the chat column: `header`, the feed as `children`
|
|
573
|
+
(flexes), `status`, `composer`, filling its parent with token borders between
|
|
574
|
+
the rows. `ConversationFeed` (generic `<T>`) takes `items`, `key(item)` and an
|
|
575
|
+
`item(item, index)` snippet; `empty` renders when there is nothing. It sticks
|
|
576
|
+
to the bottom while the user is within 80px of it (`follow`, default true),
|
|
577
|
+
holds the viewport still when older items are prepended, and shows a
|
|
578
|
+
`jumpLabel` pill ("Jump to latest") when new items land while scrolled up.
|
|
579
|
+
`loadMore` idle | loading | error | done renders a `LoadMore` pill at the top
|
|
580
|
+
that calls `onloadmore`; `scrollToBottom(behavior?)` is exported on the
|
|
581
|
+
instance. `StatusBar` is the thin live row under the feed: `tone` idle |
|
|
582
|
+
busy (Spinner) | ok | warn | error, `label` or `children`, `trailing`.
|
|
583
|
+
|
|
584
|
+
`renderMarkdown(src, { tables?, autolink?, highlight? })` turns chat markdown
|
|
585
|
+
into HTML for `Prose`: everything is escaped first, then headings, emphasis,
|
|
586
|
+
inline/fenced code, lists, blockquotes, rules, GFM tables and links with safe
|
|
587
|
+
schemes only (`javascript:`/`data:` stay text). `highlight(code, lang)` is an
|
|
588
|
+
optional hook returning safe HTML for fenced blocks; `stripAnsi` and
|
|
589
|
+
`escapeHtml` are exported too. `ChatBubble` takes `markdown` directly, plus
|
|
590
|
+
`timestamp` (after the role pill) and a `footer` snippet. `Composer` gains
|
|
591
|
+
`toolbarStart` / `toolbarEnd`: either one moves attach and send into a row
|
|
592
|
+
under the field, start slot left, end slot then send right.
|
|
593
|
+
|
|
594
|
+
```svelte
|
|
595
|
+
<ConversationPanel>
|
|
596
|
+
{#snippet header()}<Text weight="medium">Session</Text>{/snippet}
|
|
597
|
+
<ConversationFeed bind:this={feed} items={msgs} key={(m) => m.id} loadMore={more} onloadmore={loadOlder}>
|
|
598
|
+
{#snippet item(m)}
|
|
599
|
+
<ChatBubble role={m.role} timestamp={m.at} markdown={m.text}>
|
|
600
|
+
{#snippet footer()}<span>{m.ms}ms</span>{/snippet}
|
|
601
|
+
</ChatBubble>
|
|
602
|
+
{/snippet}
|
|
603
|
+
</ConversationFeed>
|
|
604
|
+
{#snippet status()}<StatusBar tone={busy ? 'busy' : 'ok'} label={busy ? 'Working…' : 'Listening'} />{/snippet}
|
|
605
|
+
{#snippet composer()}
|
|
606
|
+
<Composer onsubmit={send}>
|
|
607
|
+
{#snippet toolbarStart()}<Toggle pressed={pick} onclick={togglePick}>⌖ Pick</Toggle>{/snippet}
|
|
608
|
+
</Composer>
|
|
609
|
+
{/snippet}
|
|
610
|
+
</ConversationPanel>
|
|
611
|
+
```
|
|
612
|
+
|
|
565
613
|
### Artwork
|
|
566
614
|
|
|
567
615
|
`Artwork` is the one cover/thumbnail tile: `src` + `alt` (required) render a
|
|
@@ -5,12 +5,19 @@
|
|
|
5
5
|
import Badge from '../atoms/Badge.svelte';
|
|
6
6
|
import Button from '../atoms/Button.svelte';
|
|
7
7
|
import CopyButton from './CopyButton.svelte';
|
|
8
|
+
import Prose from './Prose.svelte';
|
|
9
|
+
import Timestamp from './Timestamp.svelte';
|
|
10
|
+
import { renderMarkdown } from '../../markdown';
|
|
11
|
+
import type { TimeInput } from '../../timestamp';
|
|
8
12
|
|
|
9
13
|
let {
|
|
10
14
|
role = 'assistant',
|
|
11
15
|
roleLabel,
|
|
12
16
|
align,
|
|
17
|
+
timestamp,
|
|
13
18
|
meta,
|
|
19
|
+
footer,
|
|
20
|
+
markdown,
|
|
14
21
|
actions,
|
|
15
22
|
state: delivery,
|
|
16
23
|
onretry,
|
|
@@ -29,7 +36,13 @@
|
|
|
29
36
|
roleLabel?: string;
|
|
30
37
|
/** Defaults to `end` for the user, `start` otherwise. */
|
|
31
38
|
align?: 'start' | 'end';
|
|
39
|
+
/** Rendered as a `Timestamp` after the role pill. */
|
|
40
|
+
timestamp?: TimeInput;
|
|
32
41
|
meta?: Snippet;
|
|
42
|
+
/** Muted row under the body: durations, token usage, file info. */
|
|
43
|
+
footer?: Snippet;
|
|
44
|
+
/** Rendered through `renderMarkdown` into `Prose` when `children` is absent. */
|
|
45
|
+
markdown?: string;
|
|
33
46
|
actions?: Snippet;
|
|
34
47
|
state?: 'sending' | 'failed';
|
|
35
48
|
onretry?: () => void;
|
|
@@ -41,13 +54,14 @@
|
|
|
41
54
|
selected?: boolean;
|
|
42
55
|
/** Renders a CopyButton in the actions row. */
|
|
43
56
|
copyText?: string;
|
|
44
|
-
children
|
|
57
|
+
children?: Snippet;
|
|
45
58
|
class?: string;
|
|
46
59
|
style?: string;
|
|
47
60
|
} = $props();
|
|
48
61
|
|
|
49
62
|
const side = $derived(align ?? (role === 'user' ? 'end' : 'start'));
|
|
50
63
|
let expanded = $state(false);
|
|
64
|
+
const html = $derived(children || markdown === undefined ? undefined : renderMarkdown(markdown));
|
|
51
65
|
</script>
|
|
52
66
|
|
|
53
67
|
<article
|
|
@@ -62,6 +76,7 @@
|
|
|
62
76
|
>
|
|
63
77
|
<header class="meta">
|
|
64
78
|
<Badge size="xs" class="role-pill">{roleLabel ?? role}</Badge>
|
|
79
|
+
{#if timestamp !== undefined}<Timestamp value={timestamp} mode="time" size="xs" />{/if}
|
|
65
80
|
{#if meta}{@render meta()}{/if}
|
|
66
81
|
{#if delivery === 'sending'}<span class="state">sending…</span>{/if}
|
|
67
82
|
{#if delivery === 'failed'}
|
|
@@ -75,12 +90,15 @@
|
|
|
75
90
|
</span>
|
|
76
91
|
{/if}
|
|
77
92
|
</header>
|
|
78
|
-
<div class="body">
|
|
93
|
+
<div class="body">
|
|
94
|
+
{#if children}{@render children()}{:else if html !== undefined}<Prose compact {html} />{/if}
|
|
95
|
+
</div>
|
|
79
96
|
{#if clamp}
|
|
80
97
|
<Button size="sm" variant="link" onclick={() => (expanded = !expanded)}>
|
|
81
98
|
{expanded ? collapseLabel : expandLabel}
|
|
82
99
|
</Button>
|
|
83
100
|
{/if}
|
|
101
|
+
{#if footer}<footer class="footer">{@render footer()}</footer>{/if}
|
|
84
102
|
</article>
|
|
85
103
|
|
|
86
104
|
<style>
|
|
@@ -148,6 +166,14 @@
|
|
|
148
166
|
min-width: 0;
|
|
149
167
|
overflow-wrap: anywhere;
|
|
150
168
|
}
|
|
169
|
+
.footer {
|
|
170
|
+
display: flex;
|
|
171
|
+
flex-wrap: wrap;
|
|
172
|
+
align-items: center;
|
|
173
|
+
gap: var(--sp-2);
|
|
174
|
+
font-size: var(--fs-xs);
|
|
175
|
+
color: var(--text-muted);
|
|
176
|
+
}
|
|
151
177
|
.clamped .body {
|
|
152
178
|
display: -webkit-box;
|
|
153
179
|
-webkit-box-orient: vertical;
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { TimeInput } from '../../timestamp';
|
|
2
3
|
type $$ComponentProps = {
|
|
3
4
|
role?: 'user' | 'assistant' | 'system' | 'tool' | 'mcp';
|
|
4
5
|
/** Text of the role pill; defaults to the role name. */
|
|
5
6
|
roleLabel?: string;
|
|
6
7
|
/** Defaults to `end` for the user, `start` otherwise. */
|
|
7
8
|
align?: 'start' | 'end';
|
|
9
|
+
/** Rendered as a `Timestamp` after the role pill. */
|
|
10
|
+
timestamp?: TimeInput;
|
|
8
11
|
meta?: Snippet;
|
|
12
|
+
/** Muted row under the body: durations, token usage, file info. */
|
|
13
|
+
footer?: Snippet;
|
|
14
|
+
/** Rendered through `renderMarkdown` into `Prose` when `children` is absent. */
|
|
15
|
+
markdown?: string;
|
|
9
16
|
actions?: Snippet;
|
|
10
17
|
state?: 'sending' | 'failed';
|
|
11
18
|
onretry?: () => void;
|
|
@@ -17,7 +24,7 @@ type $$ComponentProps = {
|
|
|
17
24
|
selected?: boolean;
|
|
18
25
|
/** Renders a CopyButton in the actions row. */
|
|
19
26
|
copyText?: string;
|
|
20
|
-
children
|
|
27
|
+
children?: Snippet;
|
|
21
28
|
class?: string;
|
|
22
29
|
style?: string;
|
|
23
30
|
};
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Chat composer: autoresizing Textarea with attach + send controls fused into
|
|
3
3
|
// one InputGroup, submit shortcuts, prompt history on ↑/↓ at the text edges,
|
|
4
4
|
// paste-to-attach and drag-over state. Attachments render above the field as
|
|
5
|
-
// removable chips, or as tiles once the composer is narrow.
|
|
5
|
+
// removable chips, or as tiles once the composer is narrow. `toolbarStart` /
|
|
6
|
+
// `toolbarEnd` move attach + send into a row under the field.
|
|
6
7
|
import type { Snippet } from 'svelte';
|
|
7
8
|
import Button from '../atoms/Button.svelte';
|
|
8
9
|
import Textarea from '../atoms/Textarea.svelte';
|
|
@@ -29,6 +30,8 @@
|
|
|
29
30
|
attachLabel = 'Attach',
|
|
30
31
|
leading,
|
|
31
32
|
trailing,
|
|
33
|
+
toolbarStart,
|
|
34
|
+
toolbarEnd,
|
|
32
35
|
class: klass = '',
|
|
33
36
|
style: styleProp = ''
|
|
34
37
|
}: {
|
|
@@ -53,6 +56,9 @@
|
|
|
53
56
|
attachLabel?: string;
|
|
54
57
|
leading?: Snippet;
|
|
55
58
|
trailing?: Snippet;
|
|
59
|
+
/** Either one renders a row under the field: start slot left, end slot then send right. */
|
|
60
|
+
toolbarStart?: Snippet;
|
|
61
|
+
toolbarEnd?: Snippet;
|
|
56
62
|
class?: string;
|
|
57
63
|
style?: string;
|
|
58
64
|
} = $props();
|
|
@@ -66,6 +72,7 @@
|
|
|
66
72
|
const mode = $derived(submitOn === 'auto' ? (fine() ? 'enter' : 'mod-enter') : submitOn);
|
|
67
73
|
const canSend = $derived(!busy && !disabled && (value.trim().length > 0 || attachments.length > 0));
|
|
68
74
|
const hasAttach = $derived(!!onfiles || accept !== undefined);
|
|
75
|
+
const toolbar = $derived(!!toolbarStart || !!toolbarEnd);
|
|
69
76
|
|
|
70
77
|
function submit() {
|
|
71
78
|
if (!canSend) return;
|
|
@@ -129,13 +136,13 @@
|
|
|
129
136
|
}
|
|
130
137
|
</script>
|
|
131
138
|
|
|
132
|
-
{#snippet
|
|
139
|
+
{#snippet startControls()}
|
|
133
140
|
{#if leading}{@render leading()}{/if}
|
|
134
141
|
{#if hasAttach}
|
|
135
142
|
<FileButton onfiles={addFiles} {accept} multiple iconOnly label={attachLabel} variant="ghost" box="sm" {disabled} />
|
|
136
143
|
{/if}
|
|
137
144
|
{/snippet}
|
|
138
|
-
{#snippet
|
|
145
|
+
{#snippet endControls()}
|
|
139
146
|
{#if trailing}{@render trailing()}{/if}
|
|
140
147
|
<Button variant="primary" box="sm" loading={busy} disabled={!canSend} aria-label={sendLabel} title={sendLabel} onclick={submit}>
|
|
141
148
|
<span aria-hidden="true">➤</span>
|
|
@@ -160,8 +167,8 @@
|
|
|
160
167
|
<InputGroup
|
|
161
168
|
align="end"
|
|
162
169
|
{disabled}
|
|
163
|
-
leading={leading || hasAttach ?
|
|
164
|
-
trailing={
|
|
170
|
+
leading={!toolbar && (leading || hasAttach) ? startControls : undefined}
|
|
171
|
+
trailing={toolbar ? undefined : endControls}
|
|
165
172
|
>
|
|
166
173
|
<Textarea
|
|
167
174
|
bind:value
|
|
@@ -177,6 +184,18 @@
|
|
|
177
184
|
{onpaste}
|
|
178
185
|
/>
|
|
179
186
|
</InputGroup>
|
|
187
|
+
{#if toolbar}
|
|
188
|
+
<div class="toolbar">
|
|
189
|
+
<div class="toolbar-start">
|
|
190
|
+
{@render startControls()}
|
|
191
|
+
{#if toolbarStart}{@render toolbarStart()}{/if}
|
|
192
|
+
</div>
|
|
193
|
+
<div class="toolbar-end">
|
|
194
|
+
{#if toolbarEnd}{@render toolbarEnd()}{/if}
|
|
195
|
+
{@render endControls()}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
{/if}
|
|
180
199
|
<span class="hint">{mode === 'enter' ? 'Enter to send · Shift+Enter for a new line' : 'Ctrl/⌘+Enter to send'}</span>
|
|
181
200
|
</div>
|
|
182
201
|
|
|
@@ -191,6 +210,25 @@
|
|
|
191
210
|
outline: 2px dashed var(--accent);
|
|
192
211
|
outline-offset: var(--sp-1);
|
|
193
212
|
}
|
|
213
|
+
.toolbar {
|
|
214
|
+
display: flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
justify-content: space-between;
|
|
217
|
+
gap: var(--sp-2);
|
|
218
|
+
min-width: 0;
|
|
219
|
+
}
|
|
220
|
+
.toolbar-start,
|
|
221
|
+
.toolbar-end {
|
|
222
|
+
display: flex;
|
|
223
|
+
align-items: center;
|
|
224
|
+
flex-wrap: wrap;
|
|
225
|
+
gap: var(--sp-1);
|
|
226
|
+
min-width: 0;
|
|
227
|
+
}
|
|
228
|
+
.toolbar-end {
|
|
229
|
+
margin-inline-start: auto;
|
|
230
|
+
justify-content: flex-end;
|
|
231
|
+
}
|
|
194
232
|
.hint {
|
|
195
233
|
font-size: var(--fs-xs);
|
|
196
234
|
color: var(--text-faint);
|
|
@@ -21,6 +21,9 @@ type $$ComponentProps = {
|
|
|
21
21
|
attachLabel?: string;
|
|
22
22
|
leading?: Snippet;
|
|
23
23
|
trailing?: Snippet;
|
|
24
|
+
/** Either one renders a row under the field: start slot left, end slot then send right. */
|
|
25
|
+
toolbarStart?: Snippet;
|
|
26
|
+
toolbarEnd?: Snippet;
|
|
24
27
|
class?: string;
|
|
25
28
|
style?: string;
|
|
26
29
|
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Thin live-status row for a panel: a tone dot (spinner while busy), a label
|
|
3
|
+
// or custom children, and a trailing slot for elapsed time / counters.
|
|
4
|
+
import type { Snippet } from 'svelte';
|
|
5
|
+
import Dot from '../atoms/Dot.svelte';
|
|
6
|
+
import Spinner from '../atoms/Spinner.svelte';
|
|
7
|
+
|
|
8
|
+
type Tone = 'idle' | 'busy' | 'ok' | 'warn' | 'error';
|
|
9
|
+
|
|
10
|
+
const TONE_COLOR: Record<Exclude<Tone, 'busy'>, string> = {
|
|
11
|
+
idle: 'var(--text-faint)',
|
|
12
|
+
ok: 'var(--ok)',
|
|
13
|
+
warn: 'var(--warn)',
|
|
14
|
+
error: 'var(--danger)'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
let {
|
|
18
|
+
tone = 'idle',
|
|
19
|
+
label,
|
|
20
|
+
children,
|
|
21
|
+
trailing,
|
|
22
|
+
class: klass = '',
|
|
23
|
+
style: styleProp = ''
|
|
24
|
+
}: {
|
|
25
|
+
tone?: Tone;
|
|
26
|
+
label?: string;
|
|
27
|
+
/** Replaces `label`. */
|
|
28
|
+
children?: Snippet;
|
|
29
|
+
trailing?: Snippet;
|
|
30
|
+
class?: string;
|
|
31
|
+
style?: string;
|
|
32
|
+
} = $props();
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<div
|
|
36
|
+
data-tsu="StatusBar"
|
|
37
|
+
data-tone={tone}
|
|
38
|
+
class="status-bar {klass}"
|
|
39
|
+
class:idle={tone === 'idle'}
|
|
40
|
+
style={styleProp}
|
|
41
|
+
role="status"
|
|
42
|
+
aria-live="polite"
|
|
43
|
+
aria-busy={tone === 'busy' || undefined}
|
|
44
|
+
>
|
|
45
|
+
{#if tone === 'busy'}
|
|
46
|
+
<Spinner size={10} label={undefined} class="status-glyph" />
|
|
47
|
+
{:else}
|
|
48
|
+
<Dot color={TONE_COLOR[tone]} class="status-glyph" aria-hidden="true" />
|
|
49
|
+
{/if}
|
|
50
|
+
<span class="status-label">
|
|
51
|
+
{#if children}{@render children()}{:else}{label}{/if}
|
|
52
|
+
</span>
|
|
53
|
+
{#if trailing}<span class="status-trailing">{@render trailing()}</span>{/if}
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<style>
|
|
57
|
+
.status-bar {
|
|
58
|
+
flex: none;
|
|
59
|
+
display: flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
gap: var(--sp-2);
|
|
62
|
+
min-width: 0;
|
|
63
|
+
padding: var(--sp-1) var(--sp-3);
|
|
64
|
+
font-size: var(--fs-xs);
|
|
65
|
+
color: var(--text-muted);
|
|
66
|
+
}
|
|
67
|
+
.status-bar.idle {
|
|
68
|
+
color: var(--text-faint);
|
|
69
|
+
}
|
|
70
|
+
.status-bar :global(.status-glyph) {
|
|
71
|
+
flex: none;
|
|
72
|
+
color: var(--accent);
|
|
73
|
+
}
|
|
74
|
+
.status-label {
|
|
75
|
+
flex: 1;
|
|
76
|
+
min-width: 0;
|
|
77
|
+
overflow: hidden;
|
|
78
|
+
text-overflow: ellipsis;
|
|
79
|
+
white-space: nowrap;
|
|
80
|
+
}
|
|
81
|
+
.status-trailing {
|
|
82
|
+
flex: none;
|
|
83
|
+
display: inline-flex;
|
|
84
|
+
align-items: center;
|
|
85
|
+
gap: var(--sp-2);
|
|
86
|
+
color: var(--text-faint);
|
|
87
|
+
font-variant-numeric: tabular-nums;
|
|
88
|
+
white-space: nowrap;
|
|
89
|
+
}
|
|
90
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type Tone = 'idle' | 'busy' | 'ok' | 'warn' | 'error';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
tone?: Tone;
|
|
5
|
+
label?: string;
|
|
6
|
+
/** Replaces `label`. */
|
|
7
|
+
children?: Snippet;
|
|
8
|
+
trailing?: Snippet;
|
|
9
|
+
class?: string;
|
|
10
|
+
style?: string;
|
|
11
|
+
};
|
|
12
|
+
declare const StatusBar: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
13
|
+
type StatusBar = ReturnType<typeof StatusBar>;
|
|
14
|
+
export default StatusBar;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<script lang="ts" generics="T">
|
|
2
|
+
// Scrolling message feed: sticks to the bottom while the user is there,
|
|
3
|
+
// keeps the viewport still when older items are prepended, and offers a
|
|
4
|
+
// "jump to latest" pill once new items land while scrolled up.
|
|
5
|
+
import { type Snippet, tick, untrack } from 'svelte';
|
|
6
|
+
import type { Attachment } from 'svelte/attachments';
|
|
7
|
+
import Button from '../atoms/Button.svelte';
|
|
8
|
+
import LoadMore from '../molecules/LoadMore.svelte';
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
items,
|
|
12
|
+
key,
|
|
13
|
+
item,
|
|
14
|
+
empty,
|
|
15
|
+
loadMore,
|
|
16
|
+
onloadmore,
|
|
17
|
+
loadMoreLabel = 'Load older',
|
|
18
|
+
follow = true,
|
|
19
|
+
jumpLabel = 'Jump to latest',
|
|
20
|
+
class: klass = '',
|
|
21
|
+
style: styleProp = ''
|
|
22
|
+
}: {
|
|
23
|
+
items: T[];
|
|
24
|
+
key: (item: T) => string | number;
|
|
25
|
+
item: Snippet<[T, number]>;
|
|
26
|
+
empty?: Snippet;
|
|
27
|
+
/** State of the LoadMore pill at the top; omit to hide it. */
|
|
28
|
+
loadMore?: 'idle' | 'loading' | 'error' | 'done';
|
|
29
|
+
/** Prepend older items; the scroll position is preserved. */
|
|
30
|
+
onloadmore?: () => void;
|
|
31
|
+
loadMoreLabel?: string;
|
|
32
|
+
/** Stick to the bottom while the user is at the bottom. */
|
|
33
|
+
follow?: boolean;
|
|
34
|
+
jumpLabel?: string;
|
|
35
|
+
class?: string;
|
|
36
|
+
style?: string;
|
|
37
|
+
} = $props();
|
|
38
|
+
|
|
39
|
+
const THRESHOLD = 80;
|
|
40
|
+
|
|
41
|
+
let scroller = $state<HTMLElement | null>(null);
|
|
42
|
+
let stuck = $state(true);
|
|
43
|
+
let pending = $state(false);
|
|
44
|
+
let prevFirst: string | number | undefined;
|
|
45
|
+
let prevLast: string | number | undefined;
|
|
46
|
+
let prevLength = 0;
|
|
47
|
+
|
|
48
|
+
const atBottom = (el: HTMLElement) => el.scrollHeight - el.scrollTop - el.clientHeight <= THRESHOLD;
|
|
49
|
+
|
|
50
|
+
export function scrollToBottom(behavior: ScrollBehavior = 'auto') {
|
|
51
|
+
const el = scroller;
|
|
52
|
+
if (el) el.scrollTo({ top: el.scrollHeight, behavior });
|
|
53
|
+
stuck = true;
|
|
54
|
+
pending = false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function onscroll() {
|
|
58
|
+
if (!scroller) return;
|
|
59
|
+
stuck = atBottom(scroller);
|
|
60
|
+
if (stuck) pending = false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
$effect.pre(() => {
|
|
64
|
+
const el = scroller;
|
|
65
|
+
const first = items.length ? key(items[0]) : undefined;
|
|
66
|
+
const last = items.length ? key(items[items.length - 1]) : undefined;
|
|
67
|
+
const length = items.length;
|
|
68
|
+
if (!el) return;
|
|
69
|
+
const prepended = prevFirst !== undefined && first !== prevFirst && length > prevLength;
|
|
70
|
+
const appended = last !== undefined && last !== prevLast;
|
|
71
|
+
prevFirst = first;
|
|
72
|
+
prevLast = last;
|
|
73
|
+
prevLength = length;
|
|
74
|
+
const fromBottom = el.scrollHeight - el.scrollTop;
|
|
75
|
+
tick().then(() => {
|
|
76
|
+
if (prepended) {
|
|
77
|
+
el.scrollTop = el.scrollHeight - fromBottom;
|
|
78
|
+
} else if (appended) {
|
|
79
|
+
if (untrack(() => follow && stuck)) scrollToBottom();
|
|
80
|
+
else pending = true;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const pin: Attachment = (node) => {
|
|
86
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
87
|
+
const ro = new ResizeObserver(() => {
|
|
88
|
+
if (follow && stuck && scroller) scroller.scrollTop = scroller.scrollHeight;
|
|
89
|
+
});
|
|
90
|
+
ro.observe(node);
|
|
91
|
+
return () => ro.disconnect();
|
|
92
|
+
};
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<div data-tsu="ConversationFeed" class="feed-wrap {klass}" style={styleProp}>
|
|
96
|
+
<div class="feed" bind:this={scroller} {onscroll}>
|
|
97
|
+
{#if loadMore}
|
|
98
|
+
<LoadMore pill state={loadMore} onload={onloadmore} label={loadMoreLabel} />
|
|
99
|
+
{/if}
|
|
100
|
+
{#if items.length === 0 && empty}
|
|
101
|
+
{@render empty()}
|
|
102
|
+
{/if}
|
|
103
|
+
<div class="feed-items" {@attach pin}>
|
|
104
|
+
{#each items as entry, i (key(entry))}
|
|
105
|
+
{@render item(entry, i)}
|
|
106
|
+
{/each}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
{#if pending}
|
|
110
|
+
<div class="jump">
|
|
111
|
+
<Button pill size="sm" onclick={() => scrollToBottom('smooth')}>{jumpLabel} ↓</Button>
|
|
112
|
+
</div>
|
|
113
|
+
{/if}
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<style>
|
|
117
|
+
.feed-wrap {
|
|
118
|
+
position: relative;
|
|
119
|
+
flex: 1;
|
|
120
|
+
display: flex;
|
|
121
|
+
flex-direction: column;
|
|
122
|
+
min-height: 0;
|
|
123
|
+
}
|
|
124
|
+
.feed {
|
|
125
|
+
flex: 1;
|
|
126
|
+
min-height: 0;
|
|
127
|
+
overflow: auto;
|
|
128
|
+
overscroll-behavior: contain;
|
|
129
|
+
display: flex;
|
|
130
|
+
flex-direction: column;
|
|
131
|
+
gap: var(--sp-3);
|
|
132
|
+
padding: var(--sp-3);
|
|
133
|
+
}
|
|
134
|
+
.feed-items {
|
|
135
|
+
display: flex;
|
|
136
|
+
flex-direction: column;
|
|
137
|
+
gap: var(--sp-3);
|
|
138
|
+
}
|
|
139
|
+
.jump {
|
|
140
|
+
position: absolute;
|
|
141
|
+
inset-inline-start: 50%;
|
|
142
|
+
bottom: var(--sp-3);
|
|
143
|
+
transform: translateX(-50%);
|
|
144
|
+
z-index: 1;
|
|
145
|
+
border-radius: var(--r-pill);
|
|
146
|
+
box-shadow: var(--shadow-md);
|
|
147
|
+
}
|
|
148
|
+
</style>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
declare function $$render<T>(): {
|
|
3
|
+
props: {
|
|
4
|
+
items: T[];
|
|
5
|
+
key: (item: T) => string | number;
|
|
6
|
+
item: Snippet<[T, number]>;
|
|
7
|
+
empty?: Snippet;
|
|
8
|
+
/** State of the LoadMore pill at the top; omit to hide it. */
|
|
9
|
+
loadMore?: "idle" | "loading" | "error" | "done";
|
|
10
|
+
/** Prepend older items; the scroll position is preserved. */
|
|
11
|
+
onloadmore?: () => void;
|
|
12
|
+
loadMoreLabel?: string;
|
|
13
|
+
/** Stick to the bottom while the user is at the bottom. */
|
|
14
|
+
follow?: boolean;
|
|
15
|
+
jumpLabel?: string;
|
|
16
|
+
class?: string;
|
|
17
|
+
style?: string;
|
|
18
|
+
};
|
|
19
|
+
exports: {
|
|
20
|
+
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
|
21
|
+
};
|
|
22
|
+
bindings: "";
|
|
23
|
+
slots: {};
|
|
24
|
+
events: {};
|
|
25
|
+
};
|
|
26
|
+
declare class __sveltets_Render<T> {
|
|
27
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
28
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
29
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
30
|
+
bindings(): "";
|
|
31
|
+
exports(): {
|
|
32
|
+
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
interface $$IsomorphicComponent {
|
|
36
|
+
new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
37
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
38
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
39
|
+
<T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
40
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
41
|
+
}
|
|
42
|
+
declare const ConversationFeed: $$IsomorphicComponent;
|
|
43
|
+
type ConversationFeed<T> = InstanceType<typeof ConversationFeed<T>>;
|
|
44
|
+
export default ConversationFeed;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Column layout for a chat panel: header, the feed (flexes), status row,
|
|
3
|
+
// composer. Layout only.
|
|
4
|
+
import type { Snippet } from 'svelte';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
header,
|
|
8
|
+
children,
|
|
9
|
+
status,
|
|
10
|
+
composer,
|
|
11
|
+
class: klass = '',
|
|
12
|
+
style: styleProp = ''
|
|
13
|
+
}: {
|
|
14
|
+
header?: Snippet;
|
|
15
|
+
/** The feed; fills the space between header and status. */
|
|
16
|
+
children: Snippet;
|
|
17
|
+
status?: Snippet;
|
|
18
|
+
composer?: Snippet;
|
|
19
|
+
class?: string;
|
|
20
|
+
style?: string;
|
|
21
|
+
} = $props();
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<section data-tsu="ConversationPanel" class="panel {klass}" style={styleProp}>
|
|
25
|
+
{#if header}<header class="panel-header">{@render header()}</header>{/if}
|
|
26
|
+
<div class="panel-body">{@render children()}</div>
|
|
27
|
+
{#if status}<div class="panel-status">{@render status()}</div>{/if}
|
|
28
|
+
{#if composer}<div class="panel-composer">{@render composer()}</div>{/if}
|
|
29
|
+
</section>
|
|
30
|
+
|
|
31
|
+
<style>
|
|
32
|
+
.panel {
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
flex: 1;
|
|
36
|
+
min-height: 0;
|
|
37
|
+
min-width: 0;
|
|
38
|
+
height: 100%;
|
|
39
|
+
background: var(--surface);
|
|
40
|
+
}
|
|
41
|
+
.panel-header {
|
|
42
|
+
flex: none;
|
|
43
|
+
display: flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
gap: var(--sp-2);
|
|
46
|
+
min-width: 0;
|
|
47
|
+
padding: var(--sp-2) var(--sp-3);
|
|
48
|
+
border-bottom: 1px solid var(--border);
|
|
49
|
+
}
|
|
50
|
+
.panel-body {
|
|
51
|
+
flex: 1;
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-direction: column;
|
|
54
|
+
min-height: 0;
|
|
55
|
+
}
|
|
56
|
+
.panel-status {
|
|
57
|
+
flex: none;
|
|
58
|
+
border-top: 1px solid var(--border);
|
|
59
|
+
}
|
|
60
|
+
.panel-composer {
|
|
61
|
+
flex: none;
|
|
62
|
+
padding: var(--sp-2) var(--sp-3) var(--sp-3);
|
|
63
|
+
border-top: 1px solid var(--border);
|
|
64
|
+
}
|
|
65
|
+
.panel-status + .panel-composer {
|
|
66
|
+
border-top: 0;
|
|
67
|
+
}
|
|
68
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
header?: Snippet;
|
|
4
|
+
/** The feed; fills the space between header and status. */
|
|
5
|
+
children: Snippet;
|
|
6
|
+
status?: Snippet;
|
|
7
|
+
composer?: Snippet;
|
|
8
|
+
class?: string;
|
|
9
|
+
style?: string;
|
|
10
|
+
};
|
|
11
|
+
declare const ConversationPanel: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
|
+
type ConversationPanel = ReturnType<typeof ConversationPanel>;
|
|
13
|
+
export default ConversationPanel;
|
package/dist/index.d.ts
CHANGED
|
@@ -84,6 +84,7 @@ export { default as SectionHeader } from './components/molecules/SectionHeader.s
|
|
|
84
84
|
export { default as SegmentedControl, type SegmentOption, } from './components/molecules/SegmentedControl.svelte';
|
|
85
85
|
export { default as SelectButton } from './components/molecules/SelectButton.svelte';
|
|
86
86
|
export { default as SplitButton } from './components/molecules/SplitButton.svelte';
|
|
87
|
+
export { default as StatusBar } from './components/molecules/StatusBar.svelte';
|
|
87
88
|
export { default as Tabs, type TabItem } from './components/molecules/Tabs.svelte';
|
|
88
89
|
export { default as ThemePicker } from './components/molecules/ThemePicker.svelte';
|
|
89
90
|
export { default as Timestamp } from './components/molecules/Timestamp.svelte';
|
|
@@ -92,11 +93,14 @@ export { default as Toggle } from './components/molecules/Toggle.svelte';
|
|
|
92
93
|
export { default as Tooltip } from './components/molecules/Tooltip.svelte';
|
|
93
94
|
export { default as Truncate } from './components/molecules/Truncate.svelte';
|
|
94
95
|
export { default as WorkingDir } from './components/molecules/WorkingDir.svelte';
|
|
96
|
+
export { default as ConversationFeed } from './components/organisms/ConversationFeed.svelte';
|
|
97
|
+
export { default as ConversationPanel } from './components/organisms/ConversationPanel.svelte';
|
|
95
98
|
export { type Column, default as DataTable, type RowTone, } from './components/organisms/DataTable.svelte';
|
|
96
99
|
export { default as FilterSearchBar } from './components/organisms/FilterSearchBar.svelte';
|
|
97
100
|
export { formatCount, hasCount } from './count';
|
|
98
101
|
export { EMOJI_GROUPS, type EmojiEntry, type EmojiGroup, searchEmoji } from './emoji';
|
|
99
102
|
export { FIELD_KEY, type FieldContext, getFieldContext, setFieldContext, warnUnlabelled, } from './field-context';
|
|
103
|
+
export { escapeHtml, type Highlight, type RenderMarkdownOptions, renderMarkdown, stripAnsi, } from './markdown';
|
|
100
104
|
export * as filterQuery from './query';
|
|
101
105
|
export { type AndNode, activeToken, compilePredicate, defaultOperator, type ExprNode, type FieldDef, type FieldType, type FilterNode, filters, findField, freeText, type LeafNode, type NotNode, OPERATORS, type Operator, type OperatorId, type OrNode, operatorByCode, operatorById, operatorsFor, parse, type Query, type QueryNode, resolveValues, type Schema, type Suggestion, type SuggestKind, type SuggestState, serialize, serializeFilter, singleQuery, suggest, suggestSingle, type TextNode, toSql, type ValueContext, type ValueOption, type ValueProvider, walk, } from './query';
|
|
102
106
|
export { type OptionSection, sectionOptions } from './select-options';
|
package/dist/index.js
CHANGED
|
@@ -92,6 +92,7 @@ export { default as SectionHeader } from './components/molecules/SectionHeader.s
|
|
|
92
92
|
export { default as SegmentedControl, } from './components/molecules/SegmentedControl.svelte';
|
|
93
93
|
export { default as SelectButton } from './components/molecules/SelectButton.svelte';
|
|
94
94
|
export { default as SplitButton } from './components/molecules/SplitButton.svelte';
|
|
95
|
+
export { default as StatusBar } from './components/molecules/StatusBar.svelte';
|
|
95
96
|
export { default as Tabs } from './components/molecules/Tabs.svelte';
|
|
96
97
|
export { default as ThemePicker } from './components/molecules/ThemePicker.svelte';
|
|
97
98
|
export { default as Timestamp } from './components/molecules/Timestamp.svelte';
|
|
@@ -101,11 +102,14 @@ export { default as Tooltip } from './components/molecules/Tooltip.svelte';
|
|
|
101
102
|
export { default as Truncate } from './components/molecules/Truncate.svelte';
|
|
102
103
|
export { default as WorkingDir } from './components/molecules/WorkingDir.svelte';
|
|
103
104
|
// ---- organisms ----
|
|
105
|
+
export { default as ConversationFeed } from './components/organisms/ConversationFeed.svelte';
|
|
106
|
+
export { default as ConversationPanel } from './components/organisms/ConversationPanel.svelte';
|
|
104
107
|
export { default as DataTable, } from './components/organisms/DataTable.svelte';
|
|
105
108
|
export { default as FilterSearchBar } from './components/organisms/FilterSearchBar.svelte';
|
|
106
109
|
export { formatCount, hasCount } from './count';
|
|
107
110
|
export { EMOJI_GROUPS, searchEmoji } from './emoji';
|
|
108
111
|
export { FIELD_KEY, getFieldContext, setFieldContext, warnUnlabelled, } from './field-context';
|
|
112
|
+
export { escapeHtml, renderMarkdown, stripAnsi, } from './markdown';
|
|
109
113
|
// ---- query core (headless: schema / parser / AST / suggest / compilers) ----
|
|
110
114
|
export * as filterQuery from './query';
|
|
111
115
|
export { activeToken, compilePredicate, defaultOperator, filters, findField, freeText, OPERATORS, operatorByCode, operatorById, operatorsFor, parse, resolveValues, serialize, serializeFilter, singleQuery, suggest, suggestSingle, toSql, walk, } from './query';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type Highlight = (code: string, lang: string) => string;
|
|
2
|
+
export type RenderMarkdownOptions = {
|
|
3
|
+
/** Render GFM pipe tables as `<table>`; default true. */
|
|
4
|
+
tables?: boolean;
|
|
5
|
+
/** Must return HTML-safe markup for the (unescaped) code body. */
|
|
6
|
+
highlight?: Highlight;
|
|
7
|
+
/** Turn bare http(s) URLs into links; default true. */
|
|
8
|
+
autolink?: boolean;
|
|
9
|
+
};
|
|
10
|
+
export declare function escapeHtml(s: string): string;
|
|
11
|
+
export declare function stripAnsi(s: string): string;
|
|
12
|
+
export declare function renderMarkdown(src: string, opts?: RenderMarkdownOptions): string;
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Markdown → HTML for chat text, meant for `Prose`. Everything is escaped
|
|
2
|
+
// first, then a fixed tag set is re-introduced, so no raw HTML survives.
|
|
3
|
+
// Highlighting is an optional hook returning safe HTML; no runtime deps.
|
|
4
|
+
export function escapeHtml(s) {
|
|
5
|
+
return s
|
|
6
|
+
.replace(/&/g, '&')
|
|
7
|
+
.replace(/</g, '<')
|
|
8
|
+
.replace(/>/g, '>')
|
|
9
|
+
.replace(/"/g, '"');
|
|
10
|
+
}
|
|
11
|
+
// Placeholder sentinels: control chars that never survive the C0 strip below.
|
|
12
|
+
const BLOCK_L = '\x0E';
|
|
13
|
+
const BLOCK_R = '\x0F';
|
|
14
|
+
const BLOCK_RE = new RegExp(`${BLOCK_L}(\\d+)${BLOCK_R}`, 'g');
|
|
15
|
+
const SPAN_L = '\x10';
|
|
16
|
+
const SPAN_R = '\x11';
|
|
17
|
+
const SPAN_RE = new RegExp(`${SPAN_L}(\\d+)${SPAN_R}`, 'g');
|
|
18
|
+
// Terminal escape sequences (the ansi-regex pattern), then the remaining C0
|
|
19
|
+
// controls except tab and newline, plus DEL.
|
|
20
|
+
const ANSI_RE =
|
|
21
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: sanitising control bytes is the point
|
|
22
|
+
/[\x1B\x9B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\x07)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
|
|
23
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: sanitising control bytes is the point
|
|
24
|
+
const C0_RE = /[\x00-\x08\x0B-\x1F\x7F]/g;
|
|
25
|
+
export function stripAnsi(s) {
|
|
26
|
+
return s.replace(ANSI_RE, '').replace(C0_RE, '');
|
|
27
|
+
}
|
|
28
|
+
const SAFE_HREF = /^(?:https?:|mailto:)/i;
|
|
29
|
+
function safeHref(url) {
|
|
30
|
+
const trimmed = url.trim();
|
|
31
|
+
if (SAFE_HREF.test(trimmed))
|
|
32
|
+
return trimmed;
|
|
33
|
+
if (/^(?:\/(?!\/)|#)/.test(trimmed))
|
|
34
|
+
return trimmed;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
function stash(blocks, html) {
|
|
38
|
+
return `${BLOCK_L}${blocks.push(html) - 1}${BLOCK_R}`;
|
|
39
|
+
}
|
|
40
|
+
function restore(s, blocks) {
|
|
41
|
+
return s.replace(BLOCK_RE, (_m, i) => blocks[Number(i)]);
|
|
42
|
+
}
|
|
43
|
+
function autolinkUrls(s) {
|
|
44
|
+
const saved = [];
|
|
45
|
+
s = s.replace(/<a\b[^>]*>[\s\S]*?<\/a>|<code\b[^>]*>[\s\S]*?<\/code>/g, (m) => `${SPAN_L}${saved.push(m) - 1}${SPAN_R}`);
|
|
46
|
+
s = s.replace(/\bhttps?:\/\/(?:&|[^\s&<>"])+/g, (raw) => {
|
|
47
|
+
let url = raw;
|
|
48
|
+
let trail = '';
|
|
49
|
+
const punct = url.match(/[.,;:!?]+$/);
|
|
50
|
+
if (punct) {
|
|
51
|
+
trail = punct[0];
|
|
52
|
+
url = url.slice(0, -trail.length);
|
|
53
|
+
}
|
|
54
|
+
const close = url.slice(-1);
|
|
55
|
+
if ((close === ')' || close === ']') && !url.includes(close === ')' ? '(' : '[')) {
|
|
56
|
+
trail = close + trail;
|
|
57
|
+
url = url.slice(0, -1);
|
|
58
|
+
}
|
|
59
|
+
if (!url)
|
|
60
|
+
return raw;
|
|
61
|
+
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>${trail}`;
|
|
62
|
+
});
|
|
63
|
+
return s.replace(SPAN_RE, (_m, i) => saved[Number(i)]);
|
|
64
|
+
}
|
|
65
|
+
// Inline passes on already-escaped text.
|
|
66
|
+
function inline(s, autolink) {
|
|
67
|
+
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
68
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
69
|
+
s = s.replace(/(^|[^*\w])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
|
70
|
+
s = s.replace(/(^|[^\w])_([^_\n]+)_(?!\w)/g, '$1<em>$2</em>');
|
|
71
|
+
s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>');
|
|
72
|
+
s = s.replace(/\[([^\]]+)\]\(([^\s)]+)\)/g, (m, text, url) => {
|
|
73
|
+
const href = safeHref(url);
|
|
74
|
+
if (!href)
|
|
75
|
+
return m;
|
|
76
|
+
const external = /^https?:/i.test(href) ? ' target="_blank" rel="noopener noreferrer"' : '';
|
|
77
|
+
return `<a href="${href}"${external}>${text}</a>`;
|
|
78
|
+
});
|
|
79
|
+
return autolink ? autolinkUrls(s) : s;
|
|
80
|
+
}
|
|
81
|
+
function splitRow(row) {
|
|
82
|
+
const cells = [];
|
|
83
|
+
let cur = '';
|
|
84
|
+
const t = row.trim().replace(/^\|/, '').replace(/\|$/, '');
|
|
85
|
+
for (let i = 0; i < t.length; i++) {
|
|
86
|
+
if (t[i] === '\\' && t[i + 1] === '|') {
|
|
87
|
+
cur += '|';
|
|
88
|
+
i++;
|
|
89
|
+
}
|
|
90
|
+
else if (t[i] === '|') {
|
|
91
|
+
cells.push(cur);
|
|
92
|
+
cur = '';
|
|
93
|
+
}
|
|
94
|
+
else
|
|
95
|
+
cur += t[i];
|
|
96
|
+
}
|
|
97
|
+
cells.push(cur);
|
|
98
|
+
return cells;
|
|
99
|
+
}
|
|
100
|
+
const TABLE_RE = /(?:^|\n)([ \t]*\|.+\|[ \t]*)\n([ \t]*\|(?:[ \t]*:?-+:?[ \t]*\|)+[ \t]*)\n((?:[ \t]*\|.*\|[ \t]*(?:\n|$))+)/g;
|
|
101
|
+
function table(header, delim, body, autolink) {
|
|
102
|
+
const aligns = splitRow(delim).map((c) => {
|
|
103
|
+
const l = c.trim().startsWith(':');
|
|
104
|
+
const r = c.trim().endsWith(':');
|
|
105
|
+
return r && l ? 'center' : r ? 'right' : l ? 'left' : '';
|
|
106
|
+
});
|
|
107
|
+
const cell = (txt, i, tag) => {
|
|
108
|
+
const a = aligns[i] ? ` style="text-align:${aligns[i]}"` : '';
|
|
109
|
+
return `<${tag}${a}>${inline(txt.trim(), autolink)}</${tag}>`;
|
|
110
|
+
};
|
|
111
|
+
const head = `<tr>${splitRow(header)
|
|
112
|
+
.map((c, i) => cell(c, i, 'th'))
|
|
113
|
+
.join('')}</tr>`;
|
|
114
|
+
const rows = body
|
|
115
|
+
.split('\n')
|
|
116
|
+
.filter((r) => r.trim())
|
|
117
|
+
.map((r) => `<tr>${splitRow(r)
|
|
118
|
+
.map((c, i) => cell(c, i, 'td'))
|
|
119
|
+
.join('')}</tr>`)
|
|
120
|
+
.join('');
|
|
121
|
+
return `<table><thead>${head}</thead><tbody>${rows}</tbody></table>`;
|
|
122
|
+
}
|
|
123
|
+
// Block pass over the escaped, inline-rendered lines. Produces paragraphs,
|
|
124
|
+
// headings, lists, blockquotes and rules; stashed blocks stay opaque.
|
|
125
|
+
function blocks(lines) {
|
|
126
|
+
const out = [];
|
|
127
|
+
let para = [];
|
|
128
|
+
let list = null;
|
|
129
|
+
let quote = [];
|
|
130
|
+
const flushPara = () => {
|
|
131
|
+
if (para.length)
|
|
132
|
+
out.push(`<p>${para.join('<br />')}</p>`);
|
|
133
|
+
para = [];
|
|
134
|
+
};
|
|
135
|
+
const flushList = () => {
|
|
136
|
+
if (list)
|
|
137
|
+
out.push(`<${list.kind}>${list.items.map((i) => `<li>${i}</li>`).join('')}</${list.kind}>`);
|
|
138
|
+
list = null;
|
|
139
|
+
};
|
|
140
|
+
const flushQuote = () => {
|
|
141
|
+
if (quote.length)
|
|
142
|
+
out.push(`<blockquote>${blocks(quote)}</blockquote>`);
|
|
143
|
+
quote = [];
|
|
144
|
+
};
|
|
145
|
+
const flushAll = () => {
|
|
146
|
+
flushPara();
|
|
147
|
+
flushList();
|
|
148
|
+
flushQuote();
|
|
149
|
+
};
|
|
150
|
+
for (const line of lines) {
|
|
151
|
+
const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*$/);
|
|
152
|
+
const item = line.match(/^\s{0,3}(?:([-*+])|(\d{1,9})[.)])\s+(.*)$/);
|
|
153
|
+
const quoted = line.match(/^\s{0,3}>\s?(.*)$/);
|
|
154
|
+
const opaque = line.match(new RegExp(`^\\s*${BLOCK_L}\\d+${BLOCK_R}\\s*$`));
|
|
155
|
+
if (quoted) {
|
|
156
|
+
flushPara();
|
|
157
|
+
flushList();
|
|
158
|
+
quote.push(quoted[1]);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
flushQuote();
|
|
162
|
+
if (!line.trim()) {
|
|
163
|
+
flushPara();
|
|
164
|
+
flushList();
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (opaque) {
|
|
168
|
+
flushAll();
|
|
169
|
+
out.push(line.trim());
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (/^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
173
|
+
flushAll();
|
|
174
|
+
out.push('<hr />');
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (heading) {
|
|
178
|
+
flushAll();
|
|
179
|
+
const level = heading[1].length;
|
|
180
|
+
out.push(`<h${level}>${heading[2]}</h${level}>`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (item) {
|
|
184
|
+
flushPara();
|
|
185
|
+
const kind = item[2] ? 'ol' : 'ul';
|
|
186
|
+
if (list && list.kind !== kind)
|
|
187
|
+
flushList();
|
|
188
|
+
if (!list)
|
|
189
|
+
list = { kind, items: [] };
|
|
190
|
+
list.items.push(item[3]);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (list && /^\s{2,}\S/.test(line)) {
|
|
194
|
+
list.items[list.items.length - 1] += `<br />${line.trim()}`;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
flushList();
|
|
198
|
+
para.push(line);
|
|
199
|
+
}
|
|
200
|
+
flushAll();
|
|
201
|
+
return out.join('');
|
|
202
|
+
}
|
|
203
|
+
export function renderMarkdown(src, opts = {}) {
|
|
204
|
+
const tables = opts.tables !== false;
|
|
205
|
+
const autolink = opts.autolink !== false;
|
|
206
|
+
const saved = [];
|
|
207
|
+
let s = stripAnsi(src.replace(/\r\n?/g, '\n'));
|
|
208
|
+
s = s.replace(/```([^\n`]*)\n?([\s\S]*?)```/g, (_m, info, code) => {
|
|
209
|
+
const lang = (info || '').trim().split(/\s+/)[0] ?? '';
|
|
210
|
+
const raw = code.replace(/\n$/, '');
|
|
211
|
+
const body = opts.highlight && lang ? opts.highlight(raw, lang) : escapeHtml(raw);
|
|
212
|
+
const attr = lang ? ` data-lang="${escapeHtml(lang)}"` : '';
|
|
213
|
+
return stash(saved, `<pre${attr}><code>${body}</code></pre>`);
|
|
214
|
+
});
|
|
215
|
+
s = escapeHtml(s);
|
|
216
|
+
if (tables) {
|
|
217
|
+
s = s.replace(TABLE_RE, (_m, header, delim, body) => `\n${stash(saved, table(header, delim, body, autolink))}\n`);
|
|
218
|
+
}
|
|
219
|
+
s = inline(s, autolink);
|
|
220
|
+
s = blocks(s.split('\n'));
|
|
221
|
+
return restore(s, saved);
|
|
222
|
+
}
|
package/package.json
CHANGED