@smooai/chat-widget 0.5.1 → 0.6.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,368 @@
1
+ /**
2
+ * A tiny, safe-by-default Markdown → HTML renderer for the chat widget.
3
+ *
4
+ * ## Why a hand-rolled renderer (and not markdown-it / snarkdown)?
5
+ *
6
+ * The widget renders **untrusted** text in two places: the assistant's reply
7
+ * (LLM output, which can echo attacker-supplied content) and citation snippets
8
+ * (raw scraped page chunks). Today both are written via `textContent`, so
9
+ * `**bold**`, numbered lists, and `[links](url)` show up literally. We want
10
+ * them rendered — without re-opening the XSS hole that `textContent` was
11
+ * guarding against.
12
+ *
13
+ * markdown-it with `html:false` is safe-by-default but ships ~30 kB min into
14
+ * what is an embeddable **global** bundle, where every kilobyte is on the host
15
+ * page's critical path. snarkdown is ~1 kB but emits raw HTML, so it would
16
+ * require bolting on a separate sanitizer. Instead, this renderer is
17
+ * **safe-by-construction**:
18
+ *
19
+ * 1. It is a *tokenizer*, not an HTML passthrough. It only ever emits a
20
+ * fixed allowlist of tags (`p`, `br`, `strong`, `em`, `ul`/`ol`/`li`,
21
+ * `code`/`pre`, `a`, `blockquote`). There is no code path that copies a
22
+ * tag out of the input — a literal `<script>` in the input is treated as
23
+ * plain text.
24
+ * 2. **Every** text run is HTML-escaped via {@link escapeHtml} before it
25
+ * reaches the output. Raw `<`, `>`, `&`, `"`, `'` can never become markup.
26
+ * 3. **Images are dropped entirely** — `![alt](src)` renders as its alt text,
27
+ * no `<img>` is ever produced (a scraped tracking pixel must not load).
28
+ * 4. **Links** are gated through {@link safeHttpUrl}: only absolute `http(s)`
29
+ * URLs become anchors (with `target="_blank"` + a hardened `rel`);
30
+ * `javascript:`/`data:`/relative/etc. fall back to plain (escaped) text.
31
+ * 5. **Headings** (`#`..`######`) are *downgraded* to bold lines — a full
32
+ * `<h1>` is far too large inside a chat bubble or citation card.
33
+ *
34
+ * The output is a string of HTML that is only ever assigned to `innerHTML` of
35
+ * an element the caller controls; because of (1)–(4) it can only contain the
36
+ * allowlisted, attribute-sanitized tags.
37
+ *
38
+ * Supported subset (deliberately small):
39
+ * - Paragraphs (blank-line separated) and hard/soft line breaks
40
+ * - `**bold**` / `__bold__`, `*italic*` / `_italic_`
41
+ * - `` `inline code` `` and fenced ``` ```code blocks``` ```
42
+ * - `- ` / `* ` / `+ ` unordered lists, `1.` ordered lists
43
+ * - `> ` blockquotes
44
+ * - `[text](http(s)://url)` links (images dropped to alt text)
45
+ * - `#`..`######` headings → bold line
46
+ */
47
+
48
+ /** Escape the five HTML-significant characters so a text run can never be markup. */
49
+ export function escapeHtml(value: string): string {
50
+ return value.replace(/[&<>"']/g, (c) => {
51
+ switch (c) {
52
+ case '&':
53
+ return '&amp;';
54
+ case '<':
55
+ return '&lt;';
56
+ case '>':
57
+ return '&gt;';
58
+ case '"':
59
+ return '&quot;';
60
+ default:
61
+ return '&#39;';
62
+ }
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
68
+ *
69
+ * SECURITY: link targets here originate from untrusted content (LLM output /
70
+ * scraped citation chunks). Allowing an arbitrary string as an `href` permits
71
+ * `javascript:`/`data:`/`vbscript:` URLs that execute on click — a stored-XSS
72
+ * vector. Only absolute http(s) links are rendered as anchors; anything else
73
+ * falls back to plain text upstream.
74
+ */
75
+ export function safeHttpUrl(url: string | undefined | null): string | null {
76
+ if (!url) return null;
77
+ try {
78
+ const parsed = new URL(url);
79
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ // ───────────────────────────── Inline rendering ─────────────────────────────
86
+
87
+ /**
88
+ * Render the inline span grammar of a single line/segment to safe HTML.
89
+ *
90
+ * Order matters: code spans are extracted first (their contents are *not*
91
+ * further parsed), then images are stripped to alt text, then links, then
92
+ * emphasis. Every literal text run is escaped on the way out.
93
+ */
94
+ function renderInline(input: string): string {
95
+ let out = '';
96
+ let i = 0;
97
+ const n = input.length;
98
+
99
+ // Accumulate escaped literal text, flushing on each recognized token.
100
+ let buf = '';
101
+ const flush = () => {
102
+ if (buf) {
103
+ out += escapeHtml(buf);
104
+ buf = '';
105
+ }
106
+ };
107
+
108
+ while (i < n) {
109
+ const ch = input[i]!;
110
+
111
+ // Inline code: `...` — contents are literal (escaped), no nested parsing.
112
+ if (ch === '`') {
113
+ const end = input.indexOf('`', i + 1);
114
+ if (end > i) {
115
+ flush();
116
+ out += `<code>${escapeHtml(input.slice(i + 1, end))}</code>`;
117
+ i = end + 1;
118
+ continue;
119
+ }
120
+ }
121
+
122
+ // Image: ![alt](src) — DROPPED. Emit only the (escaped) alt text; never
123
+ // produce an <img> (a scraped tracking pixel must not load).
124
+ if (ch === '!' && input[i + 1] === '[') {
125
+ const m = imageAt(input, i);
126
+ if (m) {
127
+ flush();
128
+ out += renderInline(m.alt); // alt may itself contain emphasis
129
+ i = m.end;
130
+ continue;
131
+ }
132
+ }
133
+
134
+ // Link: [text](href)
135
+ if (ch === '[') {
136
+ const m = linkAt(input, i);
137
+ if (m) {
138
+ flush();
139
+ const safe = safeHttpUrl(m.href);
140
+ const inner = renderInline(m.text);
141
+ if (safe) {
142
+ out += `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer nofollow">${inner}</a>`;
143
+ } else {
144
+ // Unsafe scheme → render as plain (already-escaped) text, no anchor.
145
+ out += inner;
146
+ }
147
+ i = m.end;
148
+ continue;
149
+ }
150
+ }
151
+
152
+ // Bold: **...** or __...__
153
+ if ((ch === '*' && input[i + 1] === '*') || (ch === '_' && input[i + 1] === '_')) {
154
+ const marker = ch + ch;
155
+ const end = input.indexOf(marker, i + 2);
156
+ if (end > i + 1) {
157
+ flush();
158
+ out += `<strong>${renderInline(input.slice(i + 2, end))}</strong>`;
159
+ i = end + 2;
160
+ continue;
161
+ }
162
+ }
163
+
164
+ // Italic: *...* or _..._ (single marker, non-empty, not touching the other marker)
165
+ if (ch === '*' || ch === '_') {
166
+ const end = input.indexOf(ch, i + 1);
167
+ if (end > i + 1 && input[i + 1] !== ch) {
168
+ flush();
169
+ out += `<em>${renderInline(input.slice(i + 1, end))}</em>`;
170
+ i = end + 1;
171
+ continue;
172
+ }
173
+ }
174
+
175
+ buf += ch;
176
+ i++;
177
+ }
178
+
179
+ flush();
180
+ return out;
181
+ }
182
+
183
+ /** Parse a `[text](href)` link starting at `start` (`input[start] === '['`). */
184
+ function linkAt(input: string, start: number): { text: string; href: string; end: number } | null {
185
+ const close = matchBracket(input, start);
186
+ if (close < 0 || input[close + 1] !== '(') return null;
187
+ const paren = input.indexOf(')', close + 2);
188
+ if (paren < 0) return null;
189
+ const text = input.slice(start + 1, close);
190
+ // href is the first whitespace-delimited token inside (...) — ignore any
191
+ // markdown "title" portion; we never render titles.
192
+ const href = input.slice(close + 2, paren).trim().split(/\s+/)[0] ?? '';
193
+ return { text, href, end: paren + 1 };
194
+ }
195
+
196
+ /** Parse a `![alt](src)` image starting at `start` (`input[start] === '!'`). */
197
+ function imageAt(input: string, start: number): { alt: string; end: number } | null {
198
+ const link = linkAt(input, start + 1);
199
+ if (!link) return null;
200
+ return { alt: link.text, end: link.end };
201
+ }
202
+
203
+ /** Find the matching `]` for a `[` at `open`, honoring one level of nesting. */
204
+ function matchBracket(input: string, open: number): number {
205
+ let depth = 0;
206
+ for (let i = open; i < input.length; i++) {
207
+ const c = input[i];
208
+ if (c === '[') depth++;
209
+ else if (c === ']') {
210
+ depth--;
211
+ if (depth === 0) return i;
212
+ }
213
+ }
214
+ return -1;
215
+ }
216
+
217
+ // ───────────────────────────── Block rendering ──────────────────────────────
218
+
219
+ const UL_RE = /^\s*[-*+]\s+(.*)$/;
220
+ const OL_RE = /^\s*\d+[.)]\s+(.*)$/;
221
+ const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*)$/;
222
+ const QUOTE_RE = /^\s*>\s?(.*)$/;
223
+ const FENCE_RE = /^\s*(`{3,}|~{3,})\s*(.*)$/;
224
+
225
+ /**
226
+ * Render a full Markdown string to safe HTML.
227
+ *
228
+ * @returns a string containing only the allowlisted tags described in the
229
+ * module doc. Safe to assign to `innerHTML` of a caller-owned element.
230
+ */
231
+ export function renderMarkdown(src: string): string {
232
+ const lines = src.replace(/\r\n?/g, '\n').split('\n');
233
+ const out: string[] = [];
234
+
235
+ let i = 0;
236
+ while (i < lines.length) {
237
+ const line = lines[i]!;
238
+
239
+ // Fenced code block: ```lang ... ```
240
+ const fence = FENCE_RE.exec(line);
241
+ if (fence) {
242
+ const marker = fence[1]!;
243
+ const body: string[] = [];
244
+ i++;
245
+ while (i < lines.length && !lines[i]!.trimStart().startsWith(marker)) {
246
+ body.push(lines[i]!);
247
+ i++;
248
+ }
249
+ if (i < lines.length) i++; // consume closing fence
250
+ out.push(`<pre><code>${escapeHtml(body.join('\n'))}</code></pre>`);
251
+ continue;
252
+ }
253
+
254
+ // Blank line → paragraph boundary.
255
+ if (line.trim() === '') {
256
+ i++;
257
+ continue;
258
+ }
259
+
260
+ // Heading → downgraded to a bold line (an <h1> is too big in a bubble).
261
+ const heading = HEADING_RE.exec(line);
262
+ if (heading) {
263
+ out.push(`<p><strong>${renderInline(heading[2]!)}</strong></p>`);
264
+ i++;
265
+ continue;
266
+ }
267
+
268
+ // Unordered / ordered list — consume the contiguous run.
269
+ if (UL_RE.test(line) || OL_RE.test(line)) {
270
+ const ordered = OL_RE.test(line) && !UL_RE.test(line);
271
+ const re = ordered ? OL_RE : UL_RE;
272
+ const items: string[] = [];
273
+ while (i < lines.length) {
274
+ const m = re.exec(lines[i]!);
275
+ if (!m) break;
276
+ items.push(`<li>${renderInline(m[1]!)}</li>`);
277
+ i++;
278
+ }
279
+ out.push(`<${ordered ? 'ol' : 'ul'}>${items.join('')}</${ordered ? 'ol' : 'ul'}>`);
280
+ continue;
281
+ }
282
+
283
+ // Blockquote — consume the contiguous run.
284
+ if (QUOTE_RE.test(line)) {
285
+ const quoted: string[] = [];
286
+ while (i < lines.length) {
287
+ const m = QUOTE_RE.exec(lines[i]!);
288
+ if (!m) break;
289
+ quoted.push(m[1]!);
290
+ i++;
291
+ }
292
+ out.push(`<blockquote>${renderInline(quoted.join('\n')).replace(/\n/g, '<br>')}</blockquote>`);
293
+ continue;
294
+ }
295
+
296
+ // Paragraph — gather consecutive non-blank, non-block lines; soft breaks → <br>.
297
+ const para: string[] = [];
298
+ while (i < lines.length) {
299
+ const l = lines[i]!;
300
+ if (
301
+ l.trim() === '' ||
302
+ FENCE_RE.test(l) ||
303
+ HEADING_RE.test(l) ||
304
+ UL_RE.test(l) ||
305
+ OL_RE.test(l) ||
306
+ QUOTE_RE.test(l)
307
+ ) {
308
+ break;
309
+ }
310
+ para.push(l);
311
+ i++;
312
+ }
313
+ out.push(`<p>${renderInline(para.join('\n')).replace(/\n/g, '<br>')}</p>`);
314
+ }
315
+
316
+ return out.join('');
317
+ }
318
+
319
+ // ───────────────────────── Citation-snippet cleanup ─────────────────────────
320
+
321
+ const SNIPPET_MAX = 260;
322
+
323
+ /**
324
+ * Clean a raw scraped citation snippet into a short, readable excerpt.
325
+ *
326
+ * Scraped chunks frequently begin with page boilerplate — a logo image wrapped
327
+ * in a link, standalone nav, repeated whitespace — e.g.
328
+ * `[![Logo](…)](…) # Our Work We build…`. The source itself is already linked
329
+ * from the citation card, so the snippet only needs to be a clean teaser.
330
+ *
331
+ * Steps:
332
+ * 1. Strip a leading image / logo-link (`[![…](…)](…)` or `![…](…)`).
333
+ * 2. Drop a leading standalone heading marker (`#`/`##`).
334
+ * 3. Collapse all runs of whitespace to single spaces.
335
+ * 4. Truncate to ~{@link SNIPPET_MAX} chars at a word boundary, adding `…`.
336
+ *
337
+ * The result is still rendered through {@link renderMarkdown} downstream, so any
338
+ * remaining inline markup (bold/links) stays safe.
339
+ */
340
+ export function cleanCitationSnippet(raw: string): string {
341
+ let s = raw ?? '';
342
+
343
+ // Repeatedly peel leading boilerplate tokens.
344
+ let changed = true;
345
+ while (changed) {
346
+ changed = false;
347
+ const before = s;
348
+ // Leading linked image: [![alt](imgsrc)](href)
349
+ s = s.replace(/^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*/, '');
350
+ // Leading bare image: ![alt](src)
351
+ s = s.replace(/^\s*!\[[^\]]*\]\([^)]*\)\s*/, '');
352
+ // Leading heading marker(s): "# ", "## " (keep the heading text)
353
+ s = s.replace(/^\s*#{1,6}\s+/, '');
354
+ if (s !== before) changed = true;
355
+ }
356
+
357
+ // Collapse whitespace.
358
+ s = s.replace(/\s+/g, ' ').trim();
359
+
360
+ // Truncate at a word boundary.
361
+ if (s.length > SNIPPET_MAX) {
362
+ const cut = s.slice(0, SNIPPET_MAX);
363
+ const lastSpace = cut.lastIndexOf(' ');
364
+ s = (lastSpace > SNIPPET_MAX * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd() + '…';
365
+ }
366
+
367
+ return s;
368
+ }
package/src/styles.ts CHANGED
@@ -327,6 +327,50 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove
327
327
  }
328
328
  @keyframes sac-blink { to { opacity: 0 } }
329
329
 
330
+ /* ─────────────── Rendered markdown (assistant bubbles / snippets) ─────────── */
331
+ /* The renderer (markdown.ts) emits a small allowlisted set of tags; these rules
332
+ keep them legible inside the tight Aurora-Glass bubble + citation card. */
333
+ /* Block-level markdown drives its own spacing/wrapping, so opt out of the
334
+ bubble's pre-wrap (which would otherwise add stray blank lines). */
335
+ .bubble.md { white-space: normal; }
336
+ .md > :first-child { margin-top: 0; }
337
+ .md > :last-child { margin-bottom: 0; }
338
+ .md p { margin: 0 0 8px; }
339
+ .md ul, .md ol { margin: 6px 0 8px; padding-left: 20px; }
340
+ .md li { margin: 2px 0; }
341
+ .md li::marker { color: color-mix(in srgb, var(--sac-primary) 75%, transparent); }
342
+ .md a {
343
+ color: color-mix(in srgb, var(--sac-primary) 92%, #fff);
344
+ text-decoration: underline;
345
+ text-underline-offset: 2px;
346
+ word-break: break-word;
347
+ }
348
+ .md a:hover { text-decoration: none; }
349
+ .md strong { font-weight: 700; }
350
+ .md em { font-style: italic; }
351
+ .md code {
352
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
353
+ font-size: .9em;
354
+ padding: 1px 5px;
355
+ border-radius: 5px;
356
+ background: color-mix(in srgb, var(--sac-text) 10%, transparent);
357
+ }
358
+ .md pre {
359
+ margin: 6px 0 8px;
360
+ padding: 9px 11px;
361
+ border-radius: 9px;
362
+ overflow-x: auto;
363
+ background: color-mix(in srgb, var(--sac-text) 9%, transparent);
364
+ border: 1px solid color-mix(in srgb, var(--sac-text) 8%, transparent);
365
+ }
366
+ .md pre code { padding: 0; background: none; font-size: 12px; line-height: 1.45; }
367
+ .md blockquote {
368
+ margin: 6px 0;
369
+ padding: 2px 0 2px 11px;
370
+ border-left: 2px solid color-mix(in srgb, var(--sac-primary) 55%, transparent);
371
+ color: color-mix(in srgb, var(--sac-text) 78%, transparent);
372
+ }
373
+
330
374
  /* Full-page: center the conversation in a readable column. */
331
375
  .panel.fullpage .messages { padding: 26px 20px; }
332
376
  .panel.fullpage .row { max-width: 760px; width: 100%; margin-left: auto; margin-right: auto; }