@hearthscale/app-ui 0.1.0 → 0.2.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.
@@ -0,0 +1,25 @@
1
+ import * as react from 'react';
2
+
3
+ /** What a reply's links reach. */
4
+ interface MarkdownLinks {
5
+ /** A web address or a `hearthscale:` link the reply links to. */
6
+ open(href: string): void;
7
+ /** A file of the reply's turn named by its name: a picture's address
8
+ * and what opening it does at the press that opened it, or null for no
9
+ * such file. */
10
+ file(name: string): {
11
+ picture: string | Promise<string> | null;
12
+ open(at: HTMLElement): void;
13
+ } | null;
14
+ }
15
+ /** A reply's text as the transcript draws it. @category Chat */
16
+ declare const Markdown: react.NamedExoticComponent<{
17
+ children: string;
18
+ links: MarkdownLinks;
19
+ /** How the reply shows while it is still arriving: its words as they
20
+ * come, or a finished block at a time. */
21
+ streaming?: "tokens" | "blocks";
22
+ }>;
23
+
24
+ export { Markdown };
25
+ export type { MarkdownLinks };
@@ -0,0 +1,365 @@
1
+ import {
2
+ CopyGlyph
3
+ } from "./chunks/chunk-E3RFTBRS.js";
4
+
5
+ // ../ui/dist/chat/markdown.js
6
+ import { jsx as _jsx5, Fragment as _Fragment } from "react/jsx-runtime";
7
+ import { Children, createContext as createContext2, isValidElement, memo, useContext as useContext2, useEffect as useEffect5, useMemo, useState as useState5 } from "react";
8
+ import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
9
+ import remarkGfm from "remark-gfm";
10
+
11
+ // ../ui/dist/Chart.js
12
+ import { jsx as _jsx3 } from "react/jsx-runtime";
13
+ import { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
14
+
15
+ // ../ui/dist/CodeBlock.js
16
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
17
+
18
+ // ../ui/dist/copy.js
19
+ import { useEffect, useRef, useState } from "react";
20
+
21
+ // ../ui/dist/motion/motion.js
22
+ function tokenMs(el, token) {
23
+ const value = getComputedStyle(el).getPropertyValue(token).trim();
24
+ return value.endsWith("ms") ? parseFloat(value) : parseFloat(value) * 1e3;
25
+ }
26
+
27
+ // ../ui/dist/copy.js
28
+ function useCopy() {
29
+ const [copied, setCopied] = useState(false);
30
+ const timer = useRef(void 0);
31
+ useEffect(() => () => clearTimeout(timer.current), []);
32
+ const copy = (what, press) => {
33
+ const hold = tokenMs(press, "--dur-copied");
34
+ const written = typeof what === "string" ? navigator.clipboard.writeText(what) : what();
35
+ void written.then(() => {
36
+ clearTimeout(timer.current);
37
+ setCopied(true);
38
+ timer.current = setTimeout(() => setCopied(false), hold);
39
+ }, () => {
40
+ });
41
+ };
42
+ return { copied, copy };
43
+ }
44
+
45
+ // ../ui/dist/CodeBlock.js
46
+ function CodeBlock({ code, language, reason }) {
47
+ const { copied, copy } = useCopy();
48
+ return _jsxs("div", { className: "hs-code-block", children: [_jsxs("div", { className: "hs-code-head", children: [_jsx("span", { className: "hs-code-language", children: language ?? "code" }), reason && _jsx("span", { className: "hs-code-reason", children: reason }), _jsxs("button", { type: "button", onClick: (e) => copy(code.replace(/\n$/, ""), e.currentTarget), className: "hs-code-copy", children: [_jsx(CopyGlyph, { size: 11 }), copied ? "Copied" : "Copy"] })] }), _jsx("pre", { className: "hs-code-pre", children: code })] });
49
+ }
50
+
51
+ // ../ui/dist/ThemeRoot.js
52
+ import { jsx as _jsx2, jsxs as _jsxs2 } from "react/jsx-runtime";
53
+ import { createContext, useContext, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
54
+ var SchemeContext = createContext(null);
55
+ var LookContext = createContext("");
56
+ function useLook() {
57
+ return useContext(LookContext);
58
+ }
59
+
60
+ // ../ui/dist/Chart.js
61
+ function chrome(element) {
62
+ const style = getComputedStyle(element);
63
+ const token = (name) => style.getPropertyValue(name).trim();
64
+ const ink = token("--mut");
65
+ const line = token("--cardb");
66
+ const font = token("--font-app");
67
+ return {
68
+ title: { color: token("--text"), subtitleColor: ink, font, subtitleFont: font },
69
+ axis: {
70
+ domainColor: line,
71
+ gridColor: token("--line"),
72
+ tickColor: line,
73
+ labelColor: ink,
74
+ titleColor: ink
75
+ },
76
+ legend: { labelColor: ink, titleColor: ink },
77
+ style: { "guide-label": { font }, "guide-title": { font } }
78
+ };
79
+ }
80
+ function Chart({ spec }) {
81
+ const host = useRef3(null);
82
+ const [reason, setReason] = useState3(null);
83
+ const look = useLook();
84
+ useEffect3(() => {
85
+ const element = host.current;
86
+ if (!element)
87
+ return;
88
+ let live = true;
89
+ let view = null;
90
+ setReason(null);
91
+ void (async () => {
92
+ try {
93
+ const [vega, vegaLite, { expressionInterpreter }] = await Promise.all([
94
+ import("vega"),
95
+ import("vega-lite"),
96
+ import("vega-interpreter")
97
+ ]);
98
+ const compiled = vegaLite.compile(JSON.parse(spec)).spec;
99
+ if (!live)
100
+ return;
101
+ const runtime = vega.parse(compiled, chrome(element), { ast: true });
102
+ const created = new vega.View(runtime, {
103
+ renderer: "svg",
104
+ container: element,
105
+ expr: expressionInterpreter
106
+ });
107
+ created.background("transparent");
108
+ await created.runAsync();
109
+ if (!live) {
110
+ created.finalize();
111
+ return;
112
+ }
113
+ view = created;
114
+ } catch (error) {
115
+ if (live)
116
+ setReason(error instanceof Error ? error.message : String(error));
117
+ }
118
+ })();
119
+ return () => {
120
+ live = false;
121
+ view?.finalize();
122
+ element.replaceChildren();
123
+ };
124
+ }, [spec, look]);
125
+ if (reason !== null)
126
+ return _jsx3(CodeBlock, { code: spec, language: "vega-lite", reason });
127
+ return _jsx3("div", { ref: host, className: "hs-chart" });
128
+ }
129
+
130
+ // ../ui/dist/Diagram.js
131
+ import { jsx as _jsx4 } from "react/jsx-runtime";
132
+ import { useEffect as useEffect4, useRef as useRef4, useState as useState4 } from "react";
133
+ var counter = 0;
134
+ function Diagram({ source }) {
135
+ const host = useRef4(null);
136
+ const [svg, setSvg] = useState4(null);
137
+ const [reason, setReason] = useState4(null);
138
+ const look = useLook();
139
+ useEffect4(() => {
140
+ const element = host.current;
141
+ if (!element)
142
+ return;
143
+ let live = true;
144
+ setSvg(null);
145
+ setReason(null);
146
+ const token = (name) => getComputedStyle(element).getPropertyValue(name).trim();
147
+ void (async () => {
148
+ try {
149
+ const [{ default: mermaid }, { default: DOMPurify }] = await Promise.all([
150
+ import("mermaid"),
151
+ import("dompurify")
152
+ ]);
153
+ mermaid.initialize({
154
+ startOnLoad: false,
155
+ securityLevel: "strict",
156
+ suppressErrorRendering: true,
157
+ // Labels are SVG text, not HTML in a `foreignObject`: the sanitiser
158
+ // allows the SVG vocabulary only, and drops HTML inside SVG whole.
159
+ htmlLabels: false,
160
+ theme: "base",
161
+ fontFamily: token("--font-app"),
162
+ themeVariables: {
163
+ background: token("--card"),
164
+ primaryColor: token("--win"),
165
+ primaryTextColor: token("--text"),
166
+ primaryBorderColor: token("--cardb"),
167
+ secondaryColor: token("--hov"),
168
+ tertiaryColor: token("--card"),
169
+ lineColor: token("--mut"),
170
+ textColor: token("--text")
171
+ }
172
+ });
173
+ counter += 1;
174
+ const rendered = await mermaid.render(`hs-diagram-${counter}`, source);
175
+ if (!live)
176
+ return;
177
+ setSvg(DOMPurify.sanitize(rendered.svg, {
178
+ USE_PROFILES: { svg: true, svgFilters: true },
179
+ ADD_ATTR: ["dominant-baseline"]
180
+ }));
181
+ } catch (error) {
182
+ if (live)
183
+ setReason(error instanceof Error ? error.message : String(error));
184
+ }
185
+ })();
186
+ return () => {
187
+ live = false;
188
+ };
189
+ }, [source, look]);
190
+ if (reason !== null)
191
+ return _jsx4(CodeBlock, { code: source, language: "mermaid", reason });
192
+ return _jsx4("div", { ref: host, className: "hs-diagram", dangerouslySetInnerHTML: svg === null ? void 0 : { __html: svg } });
193
+ }
194
+
195
+ // ../ui/dist/chat/markdown-incremental.js
196
+ function hasDefinitions(node) {
197
+ return node.type === "definition" || node.type === "footnoteDefinition" || "children" in node && node.children.some((child) => hasDefinitions(child));
198
+ }
199
+ function shiftPositions(node, offset, lines) {
200
+ if (node.position) {
201
+ for (const point of [node.position.start, node.position.end]) {
202
+ if (point.offset !== void 0)
203
+ point.offset += offset;
204
+ point.line += lines;
205
+ }
206
+ }
207
+ if ("children" in node) {
208
+ for (const child of node.children)
209
+ shiftPositions(child, offset, lines);
210
+ }
211
+ }
212
+ function incrementalParser(parse) {
213
+ let cached;
214
+ return (source, file) => {
215
+ if (source.includes("\r") || source.includes("\uFEFF"))
216
+ return parse(source, file);
217
+ const prefix = cached && source.startsWith(cached.source) ? cached : void 0;
218
+ let root;
219
+ if (prefix) {
220
+ root = parse(source.slice(prefix.offset), file);
221
+ if (hasDefinitions(root))
222
+ return parse(source, file);
223
+ shiftPositions(root, prefix.offset, prefix.line - 1);
224
+ if (root.position)
225
+ root.position.start = { line: 1, column: 1, offset: 0 };
226
+ root.children.unshift(...structuredClone(prefix.children));
227
+ } else {
228
+ root = parse(source, file);
229
+ if (hasDefinitions(root))
230
+ return root;
231
+ }
232
+ for (let index = root.children.length - 1; index >= 0; index--) {
233
+ const node = root.children[index];
234
+ if (node?.type !== "code")
235
+ continue;
236
+ const start = node.position?.start.offset;
237
+ const end = node.position?.end.offset;
238
+ if (start === void 0 || end === void 0)
239
+ continue;
240
+ if (prefix && end < prefix.offset)
241
+ break;
242
+ const value = source.slice(start, end);
243
+ const opening = /^ {0,3}(`{3,}|~{3,})[^\n]*\n/.exec(value)?.[1];
244
+ if (!opening)
245
+ continue;
246
+ const lastLine = value.slice(value.lastIndexOf("\n") + 1);
247
+ const closing = new RegExp(`^ {0,3}${opening[0]}{${opening.length},}[ \\t]*$`);
248
+ const separator = /^\n[ \t]*\n/.exec(source.slice(end))?.[0];
249
+ if (!closing.test(lastLine) || !separator)
250
+ continue;
251
+ const offset = end + separator.length;
252
+ cached = {
253
+ source: source.slice(0, offset),
254
+ offset,
255
+ line: node.position.end.line + 2,
256
+ children: structuredClone(root.children.slice(0, index + 1))
257
+ };
258
+ break;
259
+ }
260
+ return root;
261
+ };
262
+ }
263
+ function incrementalMarkdown() {
264
+ let parser;
265
+ return function() {
266
+ const original = this.parser;
267
+ if (!original)
268
+ return;
269
+ parser ??= incrementalParser((source, file) => original(source, file));
270
+ const parseDocument = parser;
271
+ let documentParsed = false;
272
+ this.parser = (source, file) => {
273
+ if (documentParsed)
274
+ return original(source, file);
275
+ documentParsed = true;
276
+ return parseDocument(source, file);
277
+ };
278
+ };
279
+ }
280
+
281
+ // ../ui/dist/chat/markdown.js
282
+ function fenceText(children) {
283
+ return Children.toArray(children).map((child) => typeof child === "string" ? child : "").join("").replace(/\n$/, "");
284
+ }
285
+ function nameOf(target) {
286
+ try {
287
+ return decodeURIComponent(target.replace(/^\.\//, ""));
288
+ } catch {
289
+ return target;
290
+ }
291
+ }
292
+ function FilePicture({ src, alt, onOpen }) {
293
+ const [known, setKnown] = useState5(typeof src === "string" ? src : null);
294
+ useEffect5(() => {
295
+ if (typeof src === "string")
296
+ return;
297
+ let live = true;
298
+ void src.then((url) => {
299
+ if (live)
300
+ setKnown(url);
301
+ }, () => void 0);
302
+ return () => {
303
+ live = false;
304
+ };
305
+ }, [src]);
306
+ return known ? _jsx5("button", { type: "button", className: "hs-md-picture", "aria-label": `Show ${alt}`, onClick: (e) => onOpen(e.currentTarget), children: _jsx5("img", { src: known, alt }) }) : null;
307
+ }
308
+ var APP_LINK = /^hearthscale:/i;
309
+ var keptUrl = (url) => APP_LINK.test(url) ? url : defaultUrlTransform(url);
310
+ var MarkdownState = createContext2(null);
311
+ function useMarkdownState() {
312
+ const state = useContext2(MarkdownState);
313
+ if (!state)
314
+ throw new Error("a part of a reply is drawn outside its Markdown");
315
+ return state;
316
+ }
317
+ var PARTS = {
318
+ pre: function MarkdownFence({ children }) {
319
+ const { streaming } = useMarkdownState();
320
+ const code = Children.toArray(children).find(isValidElement);
321
+ if (!code)
322
+ return null;
323
+ const source = fenceText(code.props.children);
324
+ const language = /language-([\w-]+)/.exec(code.props.className ?? "")?.[1];
325
+ if (language === "mermaid" && !streaming)
326
+ return _jsx5(Diagram, { source });
327
+ if (language === "vega-lite" && !streaming)
328
+ return _jsx5(Chart, { spec: source });
329
+ return _jsx5(CodeBlock, { code: source, ...language && { language } });
330
+ },
331
+ a: function MarkdownLink({ href, children: words }) {
332
+ const { links } = useMarkdownState();
333
+ const target = href ?? "";
334
+ const file = /^[a-z][a-z0-9+.-]*:/i.test(target) ? null : links.file(nameOf(target));
335
+ const web = /^https?:\/\//i.test(target);
336
+ const linked = web || APP_LINK.test(target);
337
+ if (!file && !linked)
338
+ return _jsx5(_Fragment, { children: words });
339
+ return _jsx5("a", { href: web ? target : void 0, className: "hs-md-link", onClick: (event) => {
340
+ event.preventDefault();
341
+ if (file)
342
+ file.open(event.currentTarget);
343
+ else if (linked)
344
+ links.open(target);
345
+ }, children: words });
346
+ },
347
+ img: function MarkdownPicture({ src, alt }) {
348
+ const { links } = useMarkdownState();
349
+ const target = typeof src === "string" ? src : "";
350
+ const file = /^[a-z][a-z0-9+.-]*:/i.test(target) ? null : links.file(nameOf(target));
351
+ return file?.picture ? _jsx5(FilePicture, { src: file.picture, alt: alt ?? "", onOpen: file.open }) : _jsx5("span", { className: "hs-md-missing", children: alt || nameOf(target) });
352
+ }
353
+ };
354
+ var WHOLE = [remarkGfm];
355
+ var FENCED = /(?:^|\n) {0,3}(?:`{3}|~{3})/;
356
+ var Markdown = memo(function Markdown2({ children, links, streaming }) {
357
+ const arriving = streaming !== void 0;
358
+ const incremental = arriving && FENCED.test(children);
359
+ const plugins = useMemo(() => incremental ? [remarkGfm, incrementalMarkdown()] : WHOLE, [incremental]);
360
+ const state = useMemo(() => ({ links, streaming: arriving }), [links, arriving]);
361
+ return _jsx5("div", { className: "hs-md hs-assistant-text", "data-streaming": streaming, children: _jsx5(MarkdownState.Provider, { value: state, children: _jsx5(ReactMarkdown, { remarkPlugins: plugins, components: PARTS, urlTransform: keptUrl, children }) }) });
362
+ });
363
+ export {
364
+ Markdown
365
+ };