@smooai/chat-widget 0.5.2 → 0.7.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/dist/chat-widget.global.js +1633 -71
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +381 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1478 -72
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/config.ts +31 -1
- package/src/conversation.ts +531 -18
- package/src/element.ts +455 -57
- package/src/fingerprint.ts +122 -0
- package/src/index.ts +14 -0
- package/src/markdown.ts +368 -0
- package/src/persistence.ts +271 -0
- package/src/styles.ts +110 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser fingerprint — a stable, privacy-light identifier used by the
|
|
3
|
+
* smooth-operator server to correlate an anonymous visitor's sessions across
|
|
4
|
+
* page loads (and, server-side, to match an anonymous fingerprint to a known
|
|
5
|
+
* CRM contact). It rides every `create_conversation_session` as
|
|
6
|
+
* `browserFingerprint` (see ADR-048).
|
|
7
|
+
*
|
|
8
|
+
* ## Why not ThumbmarkJS
|
|
9
|
+
*
|
|
10
|
+
* The ADR floats ThumbmarkJS as the reference implementation. For an *embed*
|
|
11
|
+
* that is injected onto arbitrary host pages, ThumbmarkJS is too heavy: its
|
|
12
|
+
* full build pulls in extensive device-detection tables and async
|
|
13
|
+
* component collection, adding tens of kilobytes (and async surface) to a
|
|
14
|
+
* bundle whose whole selling point is staying out of the host page's
|
|
15
|
+
* LCP/TBT budget. The fingerprint here is a few hundred bytes.
|
|
16
|
+
*
|
|
17
|
+
* ## What this is (and the tradeoff)
|
|
18
|
+
*
|
|
19
|
+
* The correlation that actually matters — "is this the same browser as last
|
|
20
|
+
* time?" — is carried by a **persisted random UUID** (the `browserFingerprint`
|
|
21
|
+
* field in the persisted store). That UUID is generated once and reused for
|
|
22
|
+
* the life of the localStorage entry, so same-browser resume is exact and
|
|
23
|
+
* deterministic. The signal hash below is a best-effort entropy *supplement*
|
|
24
|
+
* mixed into that UUID's derivation seed on first generation — it gives the
|
|
25
|
+
* server-side resolver a soft signal for the (rare) cross-storage case where
|
|
26
|
+
* the UUID was cleared but the device is unchanged. It is intentionally NOT a
|
|
27
|
+
* high-entropy device fingerprint: no canvas/WebGL/audio probes, no font
|
|
28
|
+
* enumeration, nothing that reads as invasive tracking. The tradeoff is weaker
|
|
29
|
+
* cross-storage matching in exchange for a tiny, transparent, no-network,
|
|
30
|
+
* XSS-safe implementation. The server's resolver (SMOODEV-2129d) is the source
|
|
31
|
+
* of truth for any fuzzy matching; the client just supplies a stable token.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Collect a small set of stable, non-invasive browser signals. */
|
|
35
|
+
function collectSignals(): string {
|
|
36
|
+
const parts: string[] = [];
|
|
37
|
+
try {
|
|
38
|
+
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
|
|
39
|
+
if (nav) {
|
|
40
|
+
parts.push(`ua:${nav.userAgent ?? ''}`);
|
|
41
|
+
parts.push(`lang:${nav.language ?? ''}`);
|
|
42
|
+
parts.push(`langs:${Array.isArray(nav.languages) ? nav.languages.join(',') : ''}`);
|
|
43
|
+
// `platform` is deprecated but still widely present and stable.
|
|
44
|
+
parts.push(`plat:${(nav as Navigator & { platform?: string }).platform ?? ''}`);
|
|
45
|
+
parts.push(`hc:${(nav as Navigator & { hardwareConcurrency?: number }).hardwareConcurrency ?? ''}`);
|
|
46
|
+
parts.push(`dm:${(nav as Navigator & { deviceMemory?: number }).deviceMemory ?? ''}`);
|
|
47
|
+
}
|
|
48
|
+
if (typeof screen !== 'undefined') {
|
|
49
|
+
parts.push(`scr:${screen.width}x${screen.height}x${screen.colorDepth}`);
|
|
50
|
+
}
|
|
51
|
+
if (typeof Intl !== 'undefined') {
|
|
52
|
+
try {
|
|
53
|
+
parts.push(`tz:${Intl.DateTimeFormat().resolvedOptions().timeZone ?? ''}`);
|
|
54
|
+
} catch {
|
|
55
|
+
/* resolvedOptions can throw in locked-down environments */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
parts.push(`tzo:${new Date().getTimezoneOffset()}`);
|
|
59
|
+
} catch {
|
|
60
|
+
/* a hostile/locked-down navigator must never break widget boot */
|
|
61
|
+
}
|
|
62
|
+
return parts.join('|');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A small, fast, non-cryptographic string hash (FNV-1a, 32-bit) rendered as
|
|
67
|
+
* an unsigned hex string. Stable across runs for the same input. Not used for
|
|
68
|
+
* any security decision — only to derive a deterministic seed from the signal
|
|
69
|
+
* string above.
|
|
70
|
+
*/
|
|
71
|
+
function fnv1a(input: string): string {
|
|
72
|
+
let h = 0x811c9dc5;
|
|
73
|
+
for (let i = 0; i < input.length; i++) {
|
|
74
|
+
h ^= input.charCodeAt(i);
|
|
75
|
+
// 32-bit FNV prime multiply via shifts to stay in int range.
|
|
76
|
+
h = Math.imul(h, 0x01000193);
|
|
77
|
+
}
|
|
78
|
+
// >>> 0 → unsigned; pad to 8 hex chars.
|
|
79
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Generate a UUID, preferring `crypto.randomUUID`, falling back to a v4-shaped string. */
|
|
83
|
+
function generateUuid(): string {
|
|
84
|
+
try {
|
|
85
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
86
|
+
return crypto.randomUUID();
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
/* fall through to the manual generator */
|
|
90
|
+
}
|
|
91
|
+
// RFC 4122 v4 shape from Math.random — only reached when crypto.randomUUID
|
|
92
|
+
// is unavailable (very old engines). Sufficient for a correlation token.
|
|
93
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
94
|
+
const r = (Math.random() * 16) | 0;
|
|
95
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
96
|
+
return v.toString(16);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Compute a fresh fingerprint token: a stable random UUID, suffixed with the
|
|
102
|
+
* FNV hash of the device signals so the server can recover a soft device
|
|
103
|
+
* signal even if it only has the token. Called ONCE per browser (the result is
|
|
104
|
+
* persisted and reused) — see {@link getOrCreateFingerprint}.
|
|
105
|
+
*/
|
|
106
|
+
export function computeFingerprint(): string {
|
|
107
|
+
const signalHash = fnv1a(collectSignals());
|
|
108
|
+
return `${generateUuid()}.${signalHash}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Return the cached fingerprint if one was already computed for this browser,
|
|
113
|
+
* otherwise compute + persist a new one via the provided accessors. The
|
|
114
|
+
* persistence layer owns storage; this function owns the "compute once" policy.
|
|
115
|
+
*/
|
|
116
|
+
export function getOrCreateFingerprint(get: () => string | null, set: (fp: string) => void): string {
|
|
117
|
+
const existing = get();
|
|
118
|
+
if (existing) return existing;
|
|
119
|
+
const fp = computeFingerprint();
|
|
120
|
+
set(fp);
|
|
121
|
+
return fp;
|
|
122
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -33,9 +33,23 @@ export type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config
|
|
|
33
33
|
export { initChatWidgetLoader } from './loader-core.js';
|
|
34
34
|
export {
|
|
35
35
|
ConversationController,
|
|
36
|
+
httpBaseFromWsEndpoint,
|
|
36
37
|
type ChatMessage,
|
|
37
38
|
type Citation,
|
|
38
39
|
type ConnectionStatus,
|
|
39
40
|
type ConversationEvents,
|
|
41
|
+
type IdentityRestore,
|
|
42
|
+
type RestorableConversation,
|
|
40
43
|
type Role,
|
|
44
|
+
type UserInfo,
|
|
41
45
|
} from './conversation.js';
|
|
46
|
+
export {
|
|
47
|
+
createWidgetStore,
|
|
48
|
+
PERSIST_VERSION,
|
|
49
|
+
storageKey,
|
|
50
|
+
type ConsentState,
|
|
51
|
+
type IdentityState,
|
|
52
|
+
type PersistedWidgetState,
|
|
53
|
+
type WidgetStore,
|
|
54
|
+
} from './persistence.js';
|
|
55
|
+
export { computeFingerprint, getOrCreateFingerprint } from './fingerprint.js';
|
package/src/markdown.ts
ADDED
|
@@ -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** — `` 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 '&';
|
|
54
|
+
case '<':
|
|
55
|
+
return '<';
|
|
56
|
+
case '>':
|
|
57
|
+
return '>';
|
|
58
|
+
case '"':
|
|
59
|
+
return '"';
|
|
60
|
+
default:
|
|
61
|
+
return ''';
|
|
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:  — 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 `` 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
|
+
* `[](…) # 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: [](href)
|
|
349
|
+
s = s.replace(/^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*/, '');
|
|
350
|
+
// Leading bare image: 
|
|
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
|
+
}
|