streamdown-angular 0.4.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,1936 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, signal, Injectable, makeEnvironmentProviders, inject, RendererFactory2, input, ChangeDetectionStrategy, Component, ViewContainerRef, viewChild, computed, effect, Renderer2, ViewEncapsulation, ElementRef, provideEnvironmentInitializer } from '@angular/core';
3
+ import { clsx } from 'clsx';
4
+ import { DomSanitizer } from '@angular/platform-browser';
5
+ import { unified } from 'unified';
6
+ import remarkParse from 'remark-parse';
7
+ import remarkGfm from 'remark-gfm';
8
+ import remarkRehype from 'remark-rehype';
9
+ import rehypeRaw from 'rehype-raw';
10
+ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
11
+ import remend from 'remend';
12
+ import { marked } from 'marked';
13
+ import remarkMath from 'remark-math';
14
+ import rehypeKatex from 'rehype-katex';
15
+
16
+ /**
17
+ * Merges class values into a single string (Streamdown `cn`).
18
+ * Accepts array/object/conditional values: `cn('a', cond && 'b', ['c'])`.
19
+ * (No Tailwind needed — styles now live as plain CSS in `streamdown.css`.)
20
+ */
21
+ function cn(...inputs) {
22
+ return clsx(inputs);
23
+ }
24
+ /**
25
+ * HTML tagName → extra class. Empty (default) — because all typography is
26
+ * provided via element selectors in `streamdown.css` (`.ngx-streamdown h1` ...) and
27
+ * inlined automatically into `HastRendererComponent` (no Tailwind needed).
28
+ *
29
+ * Users can add their own extra classes for tags here, for example:
30
+ * ELEMENT_CLASSES['h1'] = 'my-heading';
31
+ * This value is merged with the element's own `className` via `cn()`.
32
+ */
33
+ const ELEMENT_CLASSES = {};
34
+ /**
35
+ * Tags that require a dedicated Angular component (tagName → component class).
36
+ * Currently empty; in later stages `pre` → CodeBlock, `table` → Table will be added.
37
+ * Tags not in this map are rendered as generic DOM elements.
38
+ */
39
+ const COMPONENT_MAP = new Map();
40
+
41
+ /** Whether link safety is enabled (default: no). */
42
+ const STREAMDOWN_LINK_SAFETY = new InjectionToken('STREAMDOWN_LINK_SAFETY');
43
+ /**
44
+ * Manages the confirmation modal shown when an external link is clicked.
45
+ * Angular equivalent of the Streamdown `lib/link-modal.tsx` logic.
46
+ */
47
+ class LinkModalService {
48
+ /** URL awaiting confirmation (modal is closed when null). */
49
+ pendingUrl = signal(null, ...(ngDevMode ? [{ debugName: "pendingUrl" }] : /* istanbul ignore next */ []));
50
+ /** Opens the modal with the given URL. */
51
+ request(url) {
52
+ this.pendingUrl.set(url);
53
+ }
54
+ /** Confirms: opens the link in a new window and closes the modal. */
55
+ confirm() {
56
+ const url = this.pendingUrl();
57
+ if (url && typeof window !== 'undefined') {
58
+ window.open(url, '_blank', 'noopener,noreferrer');
59
+ }
60
+ this.pendingUrl.set(null);
61
+ }
62
+ /** Cancels: closes the modal, the link is not opened. */
63
+ cancel() {
64
+ this.pendingUrl.set(null);
65
+ }
66
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: LinkModalService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
67
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: LinkModalService, providedIn: 'root' });
68
+ }
69
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: LinkModalService, decorators: [{
70
+ type: Injectable,
71
+ args: [{ providedIn: 'root' }]
72
+ }] });
73
+ /**
74
+ * Enables the confirmation modal for external links (safety UX).
75
+ * When enabled, clicking http(s) links first shows the modal.
76
+ */
77
+ function provideStreamdownLinkSafety() {
78
+ return makeEnvironmentProviders([{ provide: STREAMDOWN_LINK_SAFETY, useValue: true }]);
79
+ }
80
+
81
+ /**
82
+ * Fade-in animation for new elements (those that appear during streaming).
83
+ * The styles must be global (the renderer creates DOM outside the Angular template),
84
+ * so we inject them into `document.head` once.
85
+ */
86
+ const FADE_CLASS = 'ngx-sd-fade';
87
+ let injected$1 = false;
88
+ /** Injects the fade-in keyframe + class styles once. */
89
+ function ensureFadeStyles() {
90
+ if (injected$1 || typeof document === 'undefined') {
91
+ return;
92
+ }
93
+ injected$1 = true;
94
+ const style = document.createElement('style');
95
+ style.textContent = `
96
+ @keyframes ngx-sd-fadein { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: none; } }
97
+ .${FADE_CLASS} { animation: ngx-sd-fadein 0.4s ease-out; }
98
+ @media (prefers-reduced-motion: reduce) { .${FADE_CLASS} { animation: none; } }
99
+ `;
100
+ document.head.appendChild(style);
101
+ }
102
+
103
+ /** Add a fade-in animation to new elements (optional). */
104
+ const STREAMDOWN_ANIMATE = new InjectionToken('STREAMDOWN_ANIMATE');
105
+ /**
106
+ * The core of the HAST renderer. Turns a HAST tree into DOM and — most importantly —
107
+ * on subsequent renders **diffs (reconciles)** against the existing DOM instead of
108
+ * rebuilding the whole tree: only changed text/attributes/elements are updated. During streaming this:
109
+ * - eliminates "flicker" (unchanged DOM is preserved),
110
+ * - does not break scroll/selection/focus,
111
+ * - updates the `node` input of special components (code-block, table) without recreating them,
112
+ * - applies a fade-in animation naturally to new elements (only for new DOM).
113
+ */
114
+ class HastRendererService {
115
+ // Renderer2 cannot be injected directly in a providedIn:'root' service — use RendererFactory2 instead
116
+ renderer = inject(RendererFactory2).createRenderer(null, null);
117
+ // link safety (optional): when enabled, external links are opened through a modal
118
+ linkModal = inject(LinkModalService);
119
+ linkSafety = inject(STREAMDOWN_LINK_SAFETY, { optional: true }) ?? false;
120
+ // fade-in animation for new elements (optional)
121
+ animate = inject(STREAMDOWN_ANIMATE, { optional: true }) ?? false;
122
+ /**
123
+ * Renders the `next` HAST nodes into `parent`, diffing against the `previous` records.
124
+ * On the first render `previous` is an empty array. Returns: the new tree of records
125
+ * (passed to the next reconcile).
126
+ */
127
+ reconcile(parent, previous, next, vcr) {
128
+ const result = [];
129
+ const length = Math.max(previous.length, next.length);
130
+ for (let i = 0; i < length; i++) {
131
+ const old = previous[i];
132
+ const node = next[i];
133
+ // no node — remove the old record
134
+ if (node === undefined) {
135
+ if (old) {
136
+ this.destroyRendered(old, parent);
137
+ }
138
+ continue;
139
+ }
140
+ // no old — create the new one and append it at the end
141
+ if (old === undefined) {
142
+ const created = this.createRendered(node, vcr);
143
+ this.renderer.appendChild(parent, created.dom);
144
+ result.push(created);
145
+ continue;
146
+ }
147
+ // same type — update in place (patch)
148
+ if (sameType(old.hast, node)) {
149
+ result.push(this.patch(old, node, vcr));
150
+ }
151
+ else {
152
+ // different type — replace (put the new one in place of the old)
153
+ const created = this.createRendered(node, vcr);
154
+ this.renderer.insertBefore(parent, created.dom, old.dom);
155
+ this.destroyRendered(old, parent);
156
+ result.push(created);
157
+ }
158
+ }
159
+ return result;
160
+ }
161
+ /** Fully cleans up records rendered via reconcile (destroys component refs). */
162
+ destroyAll(rendered) {
163
+ for (const rec of rendered) {
164
+ rec.componentRef?.destroy();
165
+ if (rec.children) {
166
+ this.destroyAll(rec.children);
167
+ }
168
+ }
169
+ }
170
+ // ── simple (create-only) API — used by table/element components ──────────
171
+ /**
172
+ * Creates the `nodes` list into `parent` (without diffing). Returns: the ComponentRefs.
173
+ * Kept for special cases (recursive render inside a table).
174
+ */
175
+ renderNodes(nodes, parent, vcr) {
176
+ const refs = [];
177
+ for (const node of nodes) {
178
+ const created = this.createRendered(node, vcr);
179
+ this.renderer.appendChild(parent, created.dom);
180
+ collectRefs(created, refs);
181
+ }
182
+ return refs;
183
+ }
184
+ /** Removes the DOM children under `parent` and destroys the ComponentRefs. */
185
+ clear(parent, refs) {
186
+ for (const ref of refs) {
187
+ ref.destroy();
188
+ }
189
+ while (parent.firstChild) {
190
+ this.renderer.removeChild(parent, parent.firstChild);
191
+ }
192
+ }
193
+ // ── internal: create / patch / destroy ─────────────────────────────────────────
194
+ /** Creates a new DOM/component for a single HAST node (returns detached dom). */
195
+ createRendered(node, vcr) {
196
+ if (node.type === 'text') {
197
+ // text node — XSS-safe: only createText (NEVER innerHTML)
198
+ return { hast: node, dom: this.renderer.createText(node.value) };
199
+ }
200
+ if (node.type === 'element') {
201
+ const el = node;
202
+ const Cmp = COMPONENT_MAP.get(el.tagName);
203
+ // special tag — create the Angular component
204
+ if (Cmp) {
205
+ const ref = vcr.createComponent(Cmp);
206
+ ref.setInput('node', el);
207
+ ref.changeDetectorRef.detectChanges();
208
+ return { hast: node, dom: ref.location.nativeElement, componentRef: ref };
209
+ }
210
+ // generic tag — a real DOM element
211
+ const domEl = this.renderer.createElement(el.tagName);
212
+ this.applyProperties(domEl, el.properties);
213
+ this.applyClasses(domEl, el);
214
+ if (el.tagName === 'a') {
215
+ this.enhanceAnchor(domEl, el);
216
+ }
217
+ if (this.animate) {
218
+ ensureFadeStyles();
219
+ this.renderer.addClass(domEl, FADE_CLASS); // new element — fade-in
220
+ }
221
+ const children = [];
222
+ for (const child of el.children) {
223
+ const childRec = this.createRendered(child, vcr);
224
+ this.renderer.appendChild(domEl, childRec.dom);
225
+ children.push(childRec);
226
+ }
227
+ return { hast: node, dom: domEl, children };
228
+ }
229
+ // 'comment' | 'doctype' | 'raw' — hold the slot with an empty text node
230
+ return { hast: node, dom: this.renderer.createText('') };
231
+ }
232
+ /** Updates existing DOM in place with the new HAST node (without recreating). */
233
+ patch(old, node, vcr) {
234
+ if (node.type === 'text') {
235
+ const oldValue = old.hast.value;
236
+ const newValue = node.value;
237
+ if (oldValue !== newValue) {
238
+ old.dom.nodeValue = newValue;
239
+ }
240
+ return { hast: node, dom: old.dom };
241
+ }
242
+ const el = node;
243
+ const domEl = old.dom;
244
+ // special component — do not recreate, only update its `node` input (memoization)
245
+ if (old.componentRef) {
246
+ old.componentRef.setInput('node', el);
247
+ old.componentRef.changeDetectorRef.detectChanges();
248
+ return { hast: node, dom: domEl, componentRef: old.componentRef };
249
+ }
250
+ // generic element — apply the attribute/class diff and recursively reconcile children
251
+ this.updateAttributes(domEl, old.hast.properties, el.properties);
252
+ this.applyClasses(domEl, el);
253
+ const children = this.reconcile(domEl, old.children ?? [], el.children, vcr);
254
+ return { hast: node, dom: domEl, children };
255
+ }
256
+ /** Cleans up the record (and its children): destroy the component + remove from DOM. */
257
+ destroyRendered(rec, parent) {
258
+ rec.componentRef?.destroy();
259
+ if (rec.children) {
260
+ // child DOM is removed together with rec.dom; only destroy the component refs
261
+ this.destroyAll(rec.children);
262
+ }
263
+ if (rec.dom.parentNode === parent) {
264
+ this.renderer.removeChild(parent, rec.dom);
265
+ }
266
+ }
267
+ // ── attribute / class / anchor ─────────────────────────────────────────────────
268
+ /**
269
+ * For `a` tags: external links get `target=_blank` + `rel=noopener noreferrer`;
270
+ * when link safety is enabled, intercept the click and open through a modal.
271
+ */
272
+ enhanceAnchor(domEl, el) {
273
+ const href = el.properties?.['href'];
274
+ if (typeof href !== 'string') {
275
+ return;
276
+ }
277
+ if (/^https?:\/\//i.test(href)) {
278
+ this.renderer.setAttribute(domEl, 'target', '_blank');
279
+ this.renderer.setAttribute(domEl, 'rel', 'noopener noreferrer');
280
+ if (this.linkSafety) {
281
+ this.renderer.listen(domEl, 'click', (event) => {
282
+ event.preventDefault();
283
+ this.linkModal.request(href);
284
+ });
285
+ }
286
+ }
287
+ }
288
+ /** Merges the ELEMENT_CLASSES base with the element's own className. */
289
+ applyClasses(domEl, el) {
290
+ const base = ELEMENT_CLASSES[el.tagName] ?? '';
291
+ const own = el.properties?.['className'];
292
+ const ownStr = Array.isArray(own) ? own.join(' ') : typeof own === 'string' ? own : '';
293
+ const animateClass = this.animate && domEl.classList.contains(FADE_CLASS) ? FADE_CLASS : '';
294
+ const merged = cn(base, ownStr, animateClass);
295
+ if (merged) {
296
+ this.renderer.setAttribute(domEl, 'class', merged);
297
+ }
298
+ else {
299
+ this.renderer.removeAttribute(domEl, 'class');
300
+ }
301
+ }
302
+ /** HAST properties → DOM attributes (initial, full application). */
303
+ applyProperties(domEl, props) {
304
+ if (!props) {
305
+ return;
306
+ }
307
+ for (const [key, value] of Object.entries(props)) {
308
+ this.setProp(domEl, key, value);
309
+ }
310
+ }
311
+ /** Applies the attribute diff: removes the deleted ones, sets the new ones. */
312
+ updateAttributes(domEl, oldProps, newProps) {
313
+ const oldP = oldProps ?? {};
314
+ const newP = newProps ?? {};
315
+ // removed attributes
316
+ for (const key of Object.keys(oldP)) {
317
+ if (!(key in newP) && key !== 'className' && !key.toLowerCase().startsWith('on')) {
318
+ this.renderer.removeAttribute(domEl, attrName(key));
319
+ }
320
+ }
321
+ // new/changed attributes
322
+ for (const [key, value] of Object.entries(newP)) {
323
+ this.setProp(domEl, key, value);
324
+ }
325
+ }
326
+ /** Writes a single HAST property to a DOM attribute (with safe allowlist logic). */
327
+ setProp(domEl, key, value) {
328
+ // className is handled separately in applyClasses; event handlers (on*) — XSS risk, skip them
329
+ if (key === 'className' || key.toLowerCase().startsWith('on')) {
330
+ return;
331
+ }
332
+ const attr = attrName(key);
333
+ if (value === null || value === undefined || value === false) {
334
+ this.renderer.removeAttribute(domEl, attr);
335
+ return;
336
+ }
337
+ if (value === true) {
338
+ this.renderer.setAttribute(domEl, attr, '');
339
+ return;
340
+ }
341
+ this.renderer.setAttribute(domEl, attr, Array.isArray(value) ? value.join(' ') : String(value));
342
+ }
343
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: HastRendererService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
344
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: HastRendererService, providedIn: 'root' });
345
+ }
346
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: HastRendererService, decorators: [{
347
+ type: Injectable,
348
+ args: [{ providedIn: 'root' }]
349
+ }] });
350
+ /** Converts a HAST property name to a DOM attribute name. */
351
+ function attrName(key) {
352
+ return key === 'htmlFor' ? 'for' : key.toLowerCase();
353
+ }
354
+ /** Are two HAST nodes the same type (text↔text or same tagName)? */
355
+ function sameType(a, b) {
356
+ if (a.type === 'text' && b.type === 'text') {
357
+ return true;
358
+ }
359
+ if (a.type === 'element' && b.type === 'element') {
360
+ return a.tagName === b.tagName;
361
+ }
362
+ return false;
363
+ }
364
+ /** Collects all ComponentRefs in the record tree. */
365
+ function collectRefs(rec, out) {
366
+ if (rec.componentRef) {
367
+ out.push(rec.componentRef);
368
+ }
369
+ if (rec.children) {
370
+ for (const child of rec.children) {
371
+ collectRefs(child, out);
372
+ }
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Finds the inner `<code>` in a `<pre>` HAST element and extracts the language and raw code.
378
+ *
379
+ * The language is taken from the value starting with `language-...` in the `<code>`'s
380
+ * `className` (e.g. `language-ts` → `ts`). Returns `'text'` if not found.
381
+ * The code is built by concatenating all descendant text nodes of the `<code>`.
382
+ */
383
+ function extractCodeInfo(pre) {
384
+ const codeEl = findCodeElement(pre);
385
+ const source = codeEl ?? pre;
386
+ return {
387
+ code: collectText$1(source),
388
+ language: extractLanguage(codeEl),
389
+ };
390
+ }
391
+ /** Converts a language name to a file extension (for the download button). Defaults to `'txt'`. */
392
+ function languageToExtension(lang) {
393
+ return LANGUAGE_EXTENSIONS[lang.toLowerCase()] ?? 'txt';
394
+ }
395
+ // ── internal ─────────────────────────────────────────────────────────────────
396
+ /** Finds the first `<code>` element among the direct children of `<pre>`. */
397
+ function findCodeElement(pre) {
398
+ for (const child of pre.children) {
399
+ if (child.type === 'element' && child.tagName === 'code') {
400
+ return child;
401
+ }
402
+ }
403
+ return undefined;
404
+ }
405
+ /** Finds `language-XXX` in the `<code>`'s className and returns `XXX` (lowercase). */
406
+ function extractLanguage(codeEl) {
407
+ const className = codeEl?.properties?.['className'];
408
+ const classes = Array.isArray(className)
409
+ ? className.map(String)
410
+ : typeof className === 'string'
411
+ ? className.split(/\s+/)
412
+ : [];
413
+ for (const cls of classes) {
414
+ if (cls.startsWith('language-')) {
415
+ const lang = cls.slice('language-'.length).trim().toLowerCase();
416
+ if (lang) {
417
+ return lang;
418
+ }
419
+ }
420
+ }
421
+ return 'text';
422
+ }
423
+ /** Recursively concatenates all text nodes under an element. */
424
+ function collectText$1(node) {
425
+ let out = '';
426
+ const walk = (children) => {
427
+ for (const child of children) {
428
+ if (child.type === 'text') {
429
+ out += child.value;
430
+ }
431
+ else if (child.type === 'element') {
432
+ walk(child.children);
433
+ }
434
+ }
435
+ };
436
+ walk(node.children);
437
+ return out;
438
+ }
439
+ /** Language → extension table. */
440
+ const LANGUAGE_EXTENSIONS = {
441
+ ts: 'ts',
442
+ typescript: 'ts',
443
+ tsx: 'tsx',
444
+ js: 'js',
445
+ javascript: 'js',
446
+ jsx: 'jsx',
447
+ json: 'json',
448
+ html: 'html',
449
+ css: 'css',
450
+ scss: 'scss',
451
+ sass: 'sass',
452
+ less: 'less',
453
+ python: 'py',
454
+ py: 'py',
455
+ ruby: 'rb',
456
+ rb: 'rb',
457
+ go: 'go',
458
+ rust: 'rs',
459
+ rs: 'rs',
460
+ java: 'java',
461
+ kotlin: 'kt',
462
+ kt: 'kt',
463
+ swift: 'swift',
464
+ c: 'c',
465
+ cpp: 'cpp',
466
+ 'c++': 'cpp',
467
+ csharp: 'cs',
468
+ cs: 'cs',
469
+ php: 'php',
470
+ shell: 'sh',
471
+ bash: 'sh',
472
+ sh: 'sh',
473
+ zsh: 'sh',
474
+ yaml: 'yaml',
475
+ yml: 'yml',
476
+ toml: 'toml',
477
+ xml: 'xml',
478
+ sql: 'sql',
479
+ markdown: 'md',
480
+ md: 'md',
481
+ text: 'txt',
482
+ };
483
+
484
+ /**
485
+ * Language (lowercase) → component registry.
486
+ *
487
+ * Optional plugins (e.g. `mermaid`) can "claim" a specific code language:
488
+ * if a component is registered for that language, `CodeBlockComponent` creates
489
+ * that component instead of highlighting with Shiki.
490
+ *
491
+ * A registered component MUST accept a `code: string` (raw code) input.
492
+ */
493
+ const CODE_LANGUAGE_COMPONENTS = new Map();
494
+
495
+ /**
496
+ * Service that syntax-highlights code via Shiki.
497
+ *
498
+ * `shiki` is an OPTIONAL peer dependency — so the dynamic `import()` is run
499
+ * once (cached) inside a try/catch. If shiki is not installed, the import
500
+ * fails, or the language is unknown, we fall back to a plain escaped
501
+ * `<pre><code>`. This service NEVER throws.
502
+ */
503
+ class ShikiHighlighterService {
504
+ /** Cached promise for loading the Shiki module once. */
505
+ shikiPromise = null;
506
+ /**
507
+ * Highlights `code` for the `lang` language and returns HTML.
508
+ * On error, falls back to an escaped `<pre><code>`.
509
+ */
510
+ async highlight(code, lang) {
511
+ const shiki = await this.loadShiki();
512
+ if (!shiki) {
513
+ return this.fallback(code);
514
+ }
515
+ try {
516
+ return await shiki.codeToHtml(code, {
517
+ lang,
518
+ themes: { light: 'github-light', dark: 'github-dark' },
519
+ });
520
+ }
521
+ catch {
522
+ // unknown language or other error — safe fallback
523
+ return this.fallback(code);
524
+ }
525
+ }
526
+ // ── internal ─────────────────────────────────────────────────────────────────
527
+ /** Lazily loads the Shiki module (once); returns `null` on failure. */
528
+ loadShiki() {
529
+ if (!this.shikiPromise) {
530
+ this.shikiPromise = (async () => {
531
+ try {
532
+ const mod = (await import('shiki'));
533
+ if (typeof mod.codeToHtml === 'function') {
534
+ return { codeToHtml: mod.codeToHtml };
535
+ }
536
+ return null;
537
+ }
538
+ catch {
539
+ // shiki not installed — fallback mode
540
+ return null;
541
+ }
542
+ })();
543
+ }
544
+ return this.shikiPromise;
545
+ }
546
+ /** Plain escaped `<pre><code>` (XSS-safe fallback). */
547
+ fallback(code) {
548
+ return `<pre><code>${escapeHtml(code)}</code></pre>`;
549
+ }
550
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: ShikiHighlighterService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
551
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: ShikiHighlighterService, providedIn: 'root' });
552
+ }
553
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: ShikiHighlighterService, decorators: [{
554
+ type: Injectable,
555
+ args: [{ providedIn: 'root' }]
556
+ }] });
557
+ /** Escapes HTML special characters. */
558
+ function escapeHtml(value) {
559
+ return value
560
+ .replace(/&/g, '&amp;')
561
+ .replace(/</g, '&lt;')
562
+ .replace(/>/g, '&gt;')
563
+ .replace(/"/g, '&quot;')
564
+ .replace(/'/g, '&#39;');
565
+ }
566
+
567
+ const svg = (paths) => `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
568
+ /** Default icons (minimal SVG in lucide style). */
569
+ const DEFAULT_ICONS = {
570
+ copy: svg('<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>'),
571
+ check: svg('<polyline points="20 6 9 17 4 12"/>'),
572
+ download: svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>'),
573
+ externalLink: svg('<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>'),
574
+ close: svg('<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>'),
575
+ chevronDown: svg('<polyline points="6 9 12 15 18 9"/>'),
576
+ };
577
+ const STREAMDOWN_ICONS = new InjectionToken('STREAMDOWN_ICONS');
578
+ /** Components get icons through this service. */
579
+ class IconService {
580
+ overrides = inject(STREAMDOWN_ICONS, { optional: true }) ?? {};
581
+ /** Merged icons (default + override). */
582
+ icons = { ...DEFAULT_ICONS, ...this.overrides };
583
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: IconService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
584
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: IconService, providedIn: 'root' });
585
+ }
586
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: IconService, decorators: [{
587
+ type: Injectable,
588
+ args: [{ providedIn: 'root' }]
589
+ }] });
590
+ /** Overrides the icons. */
591
+ function provideStreamdownIcons(icons) {
592
+ return makeEnvironmentProviders([{ provide: STREAMDOWN_ICONS, useValue: icons }]);
593
+ }
594
+
595
+ /** Default (English) strings. */
596
+ const DEFAULT_TRANSLATIONS = {
597
+ copy: 'Copy',
598
+ copied: 'Copied',
599
+ download: 'Download',
600
+ copyMarkdown: 'Copy MD',
601
+ copyCsv: 'Copy CSV',
602
+ downloadCsv: 'Download CSV',
603
+ openLink: 'Open link',
604
+ cancel: 'Cancel',
605
+ open: 'Open',
606
+ linkWarningTitle: 'Open external link?',
607
+ linkWarningBody: 'You are about to open an external link:',
608
+ mermaidError: 'Mermaid render error',
609
+ renderingDiagram: 'Rendering diagram…',
610
+ imageError: 'Failed to load image',
611
+ };
612
+ const STREAMDOWN_TRANSLATIONS = new InjectionToken('STREAMDOWN_TRANSLATIONS');
613
+ /** Components get UI strings through this service. */
614
+ class TranslationsService {
615
+ overrides = inject(STREAMDOWN_TRANSLATIONS, { optional: true }) ?? {};
616
+ /** Merged strings (default + override). */
617
+ t = { ...DEFAULT_TRANSLATIONS, ...this.overrides };
618
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: TranslationsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
619
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: TranslationsService, providedIn: 'root' });
620
+ }
621
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: TranslationsService, decorators: [{
622
+ type: Injectable,
623
+ args: [{ providedIn: 'root' }]
624
+ }] });
625
+ /**
626
+ * Overrides the UI strings (i18n). For example, in Uzbek:
627
+ * provideStreamdownTranslations({ copy: 'Nusxa', copied: 'Nusxalandi', download: 'Yuklab olish' })
628
+ */
629
+ function provideStreamdownTranslations(translations) {
630
+ return makeEnvironmentProviders([
631
+ { provide: STREAMDOWN_TRANSLATIONS, useValue: translations },
632
+ ]);
633
+ }
634
+
635
+ /**
636
+ * Small button that copies code to the clipboard.
637
+ *
638
+ * On click, `navigator.clipboard.writeText(text())` is called and it briefly
639
+ * switches to the "copied" state (signal + setTimeout). The timeout is cleared in
640
+ * `ngOnDestroy`. Text (i18n) comes from `TranslationsService`, the icon from `IconService`.
641
+ */
642
+ class CopyButtonComponent {
643
+ /** Text to copy to the clipboard. */
644
+ text = input.required(...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
645
+ copied = signal(false, ...(ngDevMode ? [{ debugName: "copied" }] : /* istanbul ignore next */ []));
646
+ sanitizer = inject(DomSanitizer);
647
+ icons = inject(IconService).icons;
648
+ t = inject(TranslationsService).t;
649
+ copyIcon = this.sanitizer.bypassSecurityTrustHtml(this.icons.copy);
650
+ checkIcon = this.sanitizer.bypassSecurityTrustHtml(this.icons.check);
651
+ timeoutId = null;
652
+ async copy() {
653
+ try {
654
+ await navigator.clipboard.writeText(this.text());
655
+ this.copied.set(true);
656
+ this.clearTimer();
657
+ this.timeoutId = setTimeout(() => this.copied.set(false), 2000);
658
+ }
659
+ catch {
660
+ // clipboard unavailable / permission denied — silently ignore
661
+ }
662
+ }
663
+ ngOnDestroy() {
664
+ this.clearTimer();
665
+ }
666
+ clearTimer() {
667
+ if (this.timeoutId !== null) {
668
+ clearTimeout(this.timeoutId);
669
+ this.timeoutId = null;
670
+ }
671
+ }
672
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: CopyButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
673
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.17", type: CopyButtonComponent, isStandalone: true, selector: "ngx-cb-copy", inputs: { text: { classPropertyName: "text", publicName: "text", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
674
+ <button
675
+ type="button"
676
+ class="sd-btn"
677
+ [attr.aria-label]="copied() ? t.copied : t.copy"
678
+ [attr.title]="copied() ? t.copied : t.copy"
679
+ (click)="copy()"
680
+ >
681
+ <span [innerHTML]="copied() ? checkIcon : copyIcon"></span>
682
+ <span>{{ copied() ? t.copied : t.copy }}</span>
683
+ </button>
684
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
685
+ }
686
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: CopyButtonComponent, decorators: [{
687
+ type: Component,
688
+ args: [{
689
+ selector: 'ngx-cb-copy',
690
+ standalone: true,
691
+ changeDetection: ChangeDetectionStrategy.OnPush,
692
+ template: `
693
+ <button
694
+ type="button"
695
+ class="sd-btn"
696
+ [attr.aria-label]="copied() ? t.copied : t.copy"
697
+ [attr.title]="copied() ? t.copied : t.copy"
698
+ (click)="copy()"
699
+ >
700
+ <span [innerHTML]="copied() ? checkIcon : copyIcon"></span>
701
+ <span>{{ copied() ? t.copied : t.copy }}</span>
702
+ </button>
703
+ `,
704
+ }]
705
+ }], propDecorators: { text: [{ type: i0.Input, args: [{ isSignal: true, alias: "text", required: true }] }] } });
706
+
707
+ /**
708
+ * Small button that downloads code as a file.
709
+ *
710
+ * On click, a `Blob` is created from the text and the download is triggered via a
711
+ * temporary `<a download>` (the object URL is revoked afterwards).
712
+ */
713
+ class DownloadButtonComponent {
714
+ sanitizer = inject(DomSanitizer);
715
+ t = inject(TranslationsService).t;
716
+ downloadIcon = this.sanitizer.bypassSecurityTrustHtml(inject(IconService).icons.download);
717
+ /** Text to write to the file. */
718
+ text = input.required(...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
719
+ /** Name of the downloaded file. */
720
+ filename = input.required(...(ngDevMode ? [{ debugName: "filename" }] : /* istanbul ignore next */ []));
721
+ download() {
722
+ const blob = new Blob([this.text()], { type: 'text/plain;charset=utf-8' });
723
+ const url = URL.createObjectURL(blob);
724
+ const anchor = document.createElement('a');
725
+ anchor.href = url;
726
+ anchor.download = this.filename();
727
+ document.body.appendChild(anchor);
728
+ anchor.click();
729
+ document.body.removeChild(anchor);
730
+ URL.revokeObjectURL(url);
731
+ }
732
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: DownloadButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
733
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.17", type: DownloadButtonComponent, isStandalone: true, selector: "ngx-cb-download", inputs: { text: { classPropertyName: "text", publicName: "text", isSignal: true, isRequired: true, transformFunction: null }, filename: { classPropertyName: "filename", publicName: "filename", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
734
+ <button
735
+ type="button"
736
+ class="sd-btn"
737
+ [attr.aria-label]="t.download"
738
+ [attr.title]="t.download"
739
+ (click)="download()"
740
+ >
741
+ <span [innerHTML]="downloadIcon"></span>
742
+ <span>{{ t.download }}</span>
743
+ </button>
744
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
745
+ }
746
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: DownloadButtonComponent, decorators: [{
747
+ type: Component,
748
+ args: [{
749
+ selector: 'ngx-cb-download',
750
+ standalone: true,
751
+ changeDetection: ChangeDetectionStrategy.OnPush,
752
+ template: `
753
+ <button
754
+ type="button"
755
+ class="sd-btn"
756
+ [attr.aria-label]="t.download"
757
+ [attr.title]="t.download"
758
+ (click)="download()"
759
+ >
760
+ <span [innerHTML]="downloadIcon"></span>
761
+ <span>{{ t.download }}</span>
762
+ </button>
763
+ `,
764
+ }]
765
+ }], propDecorators: { text: [{ type: i0.Input, args: [{ isSignal: true, alias: "text", required: true }] }], filename: [{ type: i0.Input, args: [{ isSignal: true, alias: "filename", required: true }] }] } });
766
+
767
+ /**
768
+ * MAIN component that renders a Markdown code block (`<pre>`).
769
+ *
770
+ * In COMPONENT_MAP this component is bound to the `pre` tag; the renderer
771
+ * calls `setInput('node', preElement)`.
772
+ *
773
+ * Logic:
774
+ * 1) First look up a component by language in `CODE_LANGUAGE_COMPONENTS`
775
+ * (e.g. `mermaid`). If found, create it, pass the `code` input,
776
+ * place it into the body and do NOT run Shiki.
777
+ * 2) Otherwise, show the code card: header (language + copy/download) and
778
+ * body (Shiki output). Until highlighting is ready, plain escaped
779
+ * code is shown (so it appears immediately during streaming).
780
+ */
781
+ class CodeBlockComponent {
782
+ /** The `<pre>` HAST element to render. */
783
+ node = input.required(...(ngDevMode ? [{ debugName: "node" }] : /* istanbul ignore next */ []));
784
+ vcr = inject(ViewContainerRef);
785
+ highlighter = inject(ShikiHighlighterService);
786
+ sanitizer = inject(DomSanitizer);
787
+ /** Host where the plugin component is placed (for registered languages). */
788
+ pluginHost = viewChild.required('pluginHost');
789
+ /** Created plugin ComponentRefs (for cleanup). */
790
+ pluginRefs = [];
791
+ /** Code block info: {code, language}. */
792
+ info = computed(() => extractCodeInfo(this.node()), ...(ngDevMode ? [{ debugName: "info" }] : /* istanbul ignore next */ []));
793
+ code = computed(() => this.info().code, ...(ngDevMode ? [{ debugName: "code" }] : /* istanbul ignore next */ []));
794
+ language = computed(() => this.info().language, ...(ngDevMode ? [{ debugName: "language" }] : /* istanbul ignore next */ []));
795
+ /** Name of the downloadable file (e.g. `code.ts`). */
796
+ downloadName = computed(() => `code.${languageToExtension(this.language())}`, ...(ngDevMode ? [{ debugName: "downloadName" }] : /* istanbul ignore next */ []));
797
+ /** Is a dedicated component registered for this language? */
798
+ hasRegisteredComponent = computed(() => CODE_LANGUAGE_COMPONENTS.has(this.language()), ...(ngDevMode ? [{ debugName: "hasRegisteredComponent" }] : /* istanbul ignore next */ []));
799
+ /** Shiki-highlighted, sanitized HTML (null until loaded). */
800
+ highlighted = signal(null, ...(ngDevMode ? [{ debugName: "highlighted" }] : /* istanbul ignore next */ []));
801
+ constructor() {
802
+ // Create/update the registered language component (such as mermaid).
803
+ effect(() => {
804
+ const lang = this.language();
805
+ const codeText = this.code();
806
+ const Cmp = CODE_LANGUAGE_COMPONENTS.get(lang);
807
+ // each time, dispose the previous plugin components
808
+ this.disposePlugins();
809
+ if (Cmp) {
810
+ const ref = this.vcr.createComponent(Cmp);
811
+ ref.setInput('code', codeText);
812
+ ref.changeDetectorRef.detectChanges();
813
+ this.pluginHost().nativeElement.appendChild(ref.location.nativeElement);
814
+ this.pluginRefs.push(ref);
815
+ }
816
+ });
817
+ // Shiki highlight (only for non-registered languages).
818
+ effect((onCleanup) => {
819
+ const lang = this.language();
820
+ const codeText = this.code();
821
+ if (CODE_LANGUAGE_COMPONENTS.has(lang)) {
822
+ this.highlighted.set(null);
823
+ return;
824
+ }
825
+ let cancelled = false;
826
+ // cancel the stale result if the effect re-runs or is destroyed
827
+ onCleanup(() => {
828
+ cancelled = true;
829
+ });
830
+ void this.highlighter.highlight(codeText, lang).then((html) => {
831
+ if (cancelled) {
832
+ return;
833
+ }
834
+ // Shiki output is safe (it escapes the code itself) — trust directly.
835
+ this.highlighted.set(this.sanitizer.bypassSecurityTrustHtml(html));
836
+ });
837
+ });
838
+ }
839
+ ngOnDestroy() {
840
+ this.disposePlugins();
841
+ }
842
+ disposePlugins() {
843
+ for (const ref of this.pluginRefs) {
844
+ ref.destroy();
845
+ }
846
+ this.pluginRefs = [];
847
+ }
848
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: CodeBlockComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
849
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: CodeBlockComponent, isStandalone: true, selector: "ngx-code-block", inputs: { node: { classPropertyName: "node", publicName: "node", isSignal: true, isRequired: true, transformFunction: null } }, viewQueries: [{ propertyName: "pluginHost", first: true, predicate: ["pluginHost"], descendants: true, isSignal: true }], ngImport: i0, template: `
850
+ @if (!hasRegisteredComponent()) {
851
+ <div class="sd-codeblock">
852
+ <!-- header bar -->
853
+ <div class="sd-codeblock-header">
854
+ <span class="sd-codeblock-lang">{{ language() }}</span>
855
+ <span class="sd-codeblock-actions">
856
+ <ngx-cb-copy [text]="code()" />
857
+ <ngx-cb-download [text]="code()" [filename]="downloadName()" />
858
+ </span>
859
+ </div>
860
+
861
+ <!-- body: Shiki output or (until loaded) plain escaped code -->
862
+ <div class="sd-codeblock-body">
863
+ @if (highlighted(); as html) {
864
+ <div [innerHTML]="html"></div>
865
+ } @else {
866
+ <pre><code>{{ code() }}</code></pre>
867
+ }
868
+ </div>
869
+ </div>
870
+ }
871
+
872
+ <!-- registered language component (e.g. mermaid) is placed here -->
873
+ <div #pluginHost></div>
874
+ `, isInline: true, dependencies: [{ kind: "component", type: CopyButtonComponent, selector: "ngx-cb-copy", inputs: ["text"] }, { kind: "component", type: DownloadButtonComponent, selector: "ngx-cb-download", inputs: ["text", "filename"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
875
+ }
876
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: CodeBlockComponent, decorators: [{
877
+ type: Component,
878
+ args: [{
879
+ selector: 'ngx-code-block',
880
+ standalone: true,
881
+ imports: [CopyButtonComponent, DownloadButtonComponent],
882
+ changeDetection: ChangeDetectionStrategy.OnPush,
883
+ template: `
884
+ @if (!hasRegisteredComponent()) {
885
+ <div class="sd-codeblock">
886
+ <!-- header bar -->
887
+ <div class="sd-codeblock-header">
888
+ <span class="sd-codeblock-lang">{{ language() }}</span>
889
+ <span class="sd-codeblock-actions">
890
+ <ngx-cb-copy [text]="code()" />
891
+ <ngx-cb-download [text]="code()" [filename]="downloadName()" />
892
+ </span>
893
+ </div>
894
+
895
+ <!-- body: Shiki output or (until loaded) plain escaped code -->
896
+ <div class="sd-codeblock-body">
897
+ @if (highlighted(); as html) {
898
+ <div [innerHTML]="html"></div>
899
+ } @else {
900
+ <pre><code>{{ code() }}</code></pre>
901
+ }
902
+ </div>
903
+ </div>
904
+ }
905
+
906
+ <!-- registered language component (e.g. mermaid) is placed here -->
907
+ <div #pluginHost></div>
908
+ `,
909
+ }]
910
+ }], ctorParameters: () => [], propDecorators: { node: [{ type: i0.Input, args: [{ isSignal: true, alias: "node", required: true }] }], pluginHost: [{ type: i0.ViewChild, args: ['pluginHost', { isSignal: true }] }] } });
911
+
912
+ /**
913
+ * Pure functions that extract plain-text data (rows × cells) from a HAST `table`
914
+ * element and convert it into various formats (CSV, TSV, Markdown).
915
+ *
916
+ * This file has no DOM dependency — it works only on the HAST tree, so it is
917
+ * fully testable and reusable.
918
+ */
919
+ /** Type guard that selects only element nodes. */
920
+ function isElement(node) {
921
+ return node.type === 'element';
922
+ }
923
+ /** Recursively collects the values of all text nodes under a node. */
924
+ function collectText(node) {
925
+ if (node.type === 'text') {
926
+ return node.value;
927
+ }
928
+ if (node.type === 'element') {
929
+ let out = '';
930
+ for (const child of node.children) {
931
+ out += collectText(child);
932
+ }
933
+ return out;
934
+ }
935
+ // 'comment' and other node types — produce no text
936
+ return '';
937
+ }
938
+ /** Finds the given tags among the element's direct children. */
939
+ function childrenByTag(parent, ...tags) {
940
+ const set = new Set(tags);
941
+ return parent.children.filter((c) => isElement(c) && set.has(c.tagName));
942
+ }
943
+ /**
944
+ * Walks a HAST `table` element and collects the text of each cell.
945
+ * Structure: table → (thead | tbody) → tr → (th | td). A `tr` placed directly
946
+ * under the table is also supported.
947
+ * Each cell's text is `trim()`-ed.
948
+ */
949
+ function extractTableData(table) {
950
+ const rows = [];
951
+ // collect the trs: first from thead/tbody/tfoot, then the direct ones
952
+ const trElements = [];
953
+ const sections = childrenByTag(table, 'thead', 'tbody', 'tfoot');
954
+ if (sections.length > 0) {
955
+ for (const section of sections) {
956
+ trElements.push(...childrenByTag(section, 'tr'));
957
+ }
958
+ }
959
+ // in some HAST trees a tr can sit directly under the table
960
+ trElements.push(...childrenByTag(table, 'tr'));
961
+ for (const tr of trElements) {
962
+ const cells = childrenByTag(tr, 'th', 'td');
963
+ const row = cells.map((cell) => collectText(cell).trim());
964
+ rows.push(row);
965
+ }
966
+ return rows;
967
+ }
968
+ /**
969
+ * Escapes a single CSV cell per RFC 4180 rules.
970
+ * If it contains a comma, quote, or newline — it is wrapped in quotes and
971
+ * inner quotes are doubled.
972
+ */
973
+ function escapeCsvCell(value) {
974
+ if (/[",\r\n]/.test(value)) {
975
+ return `"${value.replace(/"/g, '""')}"`;
976
+ }
977
+ return value;
978
+ }
979
+ /** Converts rows to RFC 4180 CSV text (CRLF row separator). */
980
+ function toCsv(rows) {
981
+ return rows.map((row) => row.map(escapeCsvCell).join(',')).join('\r\n');
982
+ }
983
+ /**
984
+ * Converts rows to TSV (tab-separated) text.
985
+ * Tabs and newlines inside a cell are replaced with spaces (TSV has no escaping).
986
+ */
987
+ function toTsv(rows) {
988
+ return rows
989
+ .map((row) => row.map((cell) => cell.replace(/[\t\r\n]+/g, ' ')).join('\t'))
990
+ .join('\n');
991
+ }
992
+ /** Escapes the `|` character for a Markdown table cell and removes newlines. */
993
+ function escapeMarkdownCell(value) {
994
+ return value.replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ');
995
+ }
996
+ /**
997
+ * Converts rows to a GFM Markdown table.
998
+ * The first row is taken as the header and a `---` separator row is added after it.
999
+ * Returns an empty string for empty input.
1000
+ */
1001
+ function toMarkdown(rows) {
1002
+ if (rows.length === 0) {
1003
+ return '';
1004
+ }
1005
+ // table width — the longest row (to pad ragged rows)
1006
+ const colCount = rows.reduce((max, row) => Math.max(max, row.length), 0);
1007
+ const renderRow = (row) => {
1008
+ const cells = [];
1009
+ for (let i = 0; i < colCount; i++) {
1010
+ cells.push(escapeMarkdownCell(row[i] ?? ''));
1011
+ }
1012
+ return `| ${cells.join(' | ')} |`;
1013
+ };
1014
+ const header = renderRow(rows[0]);
1015
+ const separator = `| ${Array.from({ length: colCount }, () => '---').join(' | ')} |`;
1016
+ const body = rows.slice(1).map(renderRow);
1017
+ return [header, separator, ...body].join('\n');
1018
+ }
1019
+
1020
+ /**
1021
+ * Custom component bound to `table` HAST tags (`table` → this in COMPONENT_MAP).
1022
+ *
1023
+ * Operating principle (AVOIDING RECURSION):
1024
+ * If we re-render the `table` node ITSELF, we get infinite recursion
1025
+ * (because `table` is in COMPONENT_MAP). So we create the `<table>` DOM
1026
+ * element ourselves with Renderer2 and render only the node's CHILDREN
1027
+ * (thead/tbody/tr ...) into that element.
1028
+ *
1029
+ * Additionally, a small toolbar is added for copying the table as Markdown/CSV
1030
+ * and downloading it as CSV.
1031
+ */
1032
+ class TableComponent {
1033
+ /** HAST `table` element node to render */
1034
+ node = input.required(...(ngDevMode ? [{ debugName: "node" }] : /* istanbul ignore next */ []));
1035
+ t = inject(TranslationsService).t;
1036
+ /** Temporary "copied" message for a toolbar action (`null` when none) */
1037
+ copied = signal(null, ...(ngDevMode ? [{ debugName: "copied" }] : /* istanbul ignore next */ []));
1038
+ // tableHost — target DOM element for imperatively building the `<table>`
1039
+ hostElRef = viewChild.required('tableHost');
1040
+ vcr = inject(ViewContainerRef);
1041
+ renderer = inject(HastRendererService);
1042
+ renderer2 = inject(Renderer2);
1043
+ refs = [];
1044
+ builtTable = null;
1045
+ feedbackTimer = null;
1046
+ constructor() {
1047
+ // When node() changes, clear the old table and rebuild a new one
1048
+ effect(() => {
1049
+ const tableNode = this.node();
1050
+ const host = this.hostElRef().nativeElement;
1051
+ // clear the previous render (destroy ComponentRefs + remove DOM children)
1052
+ this.cleanup(host);
1053
+ // build the `<table>` ourselves — render the node's CHILDREN, not the node ITSELF
1054
+ const tableEl = this.renderer2.createElement('table');
1055
+ this.renderer2.setAttribute(tableEl, 'class', 'sd-table-el');
1056
+ this.refs = this.renderer.renderNodes(tableNode.children, tableEl, this.vcr);
1057
+ this.renderer2.appendChild(host, tableEl);
1058
+ this.builtTable = tableEl;
1059
+ });
1060
+ }
1061
+ ngOnDestroy() {
1062
+ const host = this.hostElRef().nativeElement;
1063
+ this.cleanup(host);
1064
+ if (this.feedbackTimer !== null) {
1065
+ clearTimeout(this.feedbackTimer);
1066
+ this.feedbackTimer = null;
1067
+ }
1068
+ }
1069
+ /** Copies the Markdown representation of the table to the clipboard. */
1070
+ async copyMarkdown() {
1071
+ await this.copyToClipboard(toMarkdown(extractTableData(this.node())), this.t.copied);
1072
+ }
1073
+ /** Copies the CSV representation of the table to the clipboard. */
1074
+ async copyCsv() {
1075
+ await this.copyToClipboard(toCsv(extractTableData(this.node())), this.t.copied);
1076
+ }
1077
+ /** Downloads the table as a `table.csv` file (via Blob). */
1078
+ downloadCsv() {
1079
+ const csv = toCsv(extractTableData(this.node()));
1080
+ // add a BOM so Excel reads UTF-8 correctly
1081
+ const blob = new Blob(['', csv], { type: 'text/csv;charset=utf-8;' });
1082
+ const url = URL.createObjectURL(blob);
1083
+ const a = this.renderer2.createElement('a');
1084
+ a.href = url;
1085
+ a.download = 'table.csv';
1086
+ a.click();
1087
+ URL.revokeObjectURL(url);
1088
+ this.flashFeedback('Downloaded');
1089
+ }
1090
+ // ── internal ───────────────────────────────────────────────────────────
1091
+ /** Writes text to the clipboard and shows a temporary message (when the Clipboard API is available). */
1092
+ async copyToClipboard(text, message) {
1093
+ try {
1094
+ await navigator.clipboard.writeText(text);
1095
+ this.flashFeedback(message);
1096
+ }
1097
+ catch {
1098
+ // if the clipboard is unavailable (old browser / SSR) — fail silently
1099
+ this.flashFeedback('Copy failed');
1100
+ }
1101
+ }
1102
+ /** Sets a temporary feedback message visible for ~2s. */
1103
+ flashFeedback(message) {
1104
+ this.copied.set(message);
1105
+ if (this.feedbackTimer !== null) {
1106
+ clearTimeout(this.feedbackTimer);
1107
+ }
1108
+ this.feedbackTimer = setTimeout(() => {
1109
+ this.copied.set(null);
1110
+ this.feedbackTimer = null;
1111
+ }, 2000);
1112
+ }
1113
+ /** Clears the built table and its ComponentRefs. */
1114
+ cleanup(host) {
1115
+ if (this.builtTable) {
1116
+ this.renderer.clear(this.builtTable, this.refs);
1117
+ this.renderer2.removeChild(host, this.builtTable);
1118
+ this.builtTable = null;
1119
+ }
1120
+ else if (this.refs.length > 0) {
1121
+ for (const ref of this.refs) {
1122
+ ref.destroy();
1123
+ }
1124
+ }
1125
+ this.refs = [];
1126
+ }
1127
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: TableComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1128
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: TableComponent, isStandalone: true, selector: "ngx-table", inputs: { node: { classPropertyName: "node", publicName: "node", isSignal: true, isRequired: true, transformFunction: null } }, viewQueries: [{ propertyName: "hostElRef", first: true, predicate: ["tableHost"], descendants: true, isSignal: true }], ngImport: i0, template: `
1129
+ <div class="sd-table">
1130
+ <div class="sd-table-toolbar">
1131
+ <button type="button" class="sd-table-btn" (click)="copyMarkdown()">
1132
+ 📋 {{ t.copyMarkdown }}
1133
+ </button>
1134
+ <button type="button" class="sd-table-btn" (click)="copyCsv()">
1135
+ 📋 {{ t.copyCsv }}
1136
+ </button>
1137
+ <button type="button" class="sd-table-btn" (click)="downloadCsv()">
1138
+ ⬇️ {{ t.downloadCsv }}
1139
+ </button>
1140
+ @if (copied()) {
1141
+ <span class="sd-table-msg">{{ copied() }}</span>
1142
+ }
1143
+ </div>
1144
+ <div #tableHost class="sd-table-scroll"></div>
1145
+ </div>
1146
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1147
+ }
1148
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: TableComponent, decorators: [{
1149
+ type: Component,
1150
+ args: [{
1151
+ selector: 'ngx-table',
1152
+ standalone: true,
1153
+ template: `
1154
+ <div class="sd-table">
1155
+ <div class="sd-table-toolbar">
1156
+ <button type="button" class="sd-table-btn" (click)="copyMarkdown()">
1157
+ 📋 {{ t.copyMarkdown }}
1158
+ </button>
1159
+ <button type="button" class="sd-table-btn" (click)="copyCsv()">
1160
+ 📋 {{ t.copyCsv }}
1161
+ </button>
1162
+ <button type="button" class="sd-table-btn" (click)="downloadCsv()">
1163
+ ⬇️ {{ t.downloadCsv }}
1164
+ </button>
1165
+ @if (copied()) {
1166
+ <span class="sd-table-msg">{{ copied() }}</span>
1167
+ }
1168
+ </div>
1169
+ <div #tableHost class="sd-table-scroll"></div>
1170
+ </div>
1171
+ `,
1172
+ changeDetection: ChangeDetectionStrategy.OnPush,
1173
+ }]
1174
+ }], ctorParameters: () => [], propDecorators: { node: [{ type: i0.Input, args: [{ isSignal: true, alias: "node", required: true }] }], hostElRef: [{ type: i0.ViewChild, args: ['tableHost', { isSignal: true }] }] } });
1175
+
1176
+ /**
1177
+ * Component for markdown images (`img`). Equivalent of Streamdown `lib/image.tsx`.
1178
+ *
1179
+ * Bound to the `img` tag in COMPONENT_MAP. Comes with a loading skeleton, error state and
1180
+ * lazy-loading. The `node` input is the hast `img` element node.
1181
+ */
1182
+ class ImageComponent {
1183
+ /** hast `img` element node */
1184
+ node = input.required(...(ngDevMode ? [{ debugName: "node" }] : /* istanbul ignore next */ []));
1185
+ t = inject(TranslationsService).t;
1186
+ loaded = signal(false, ...(ngDevMode ? [{ debugName: "loaded" }] : /* istanbul ignore next */ []));
1187
+ errored = signal(false, ...(ngDevMode ? [{ debugName: "errored" }] : /* istanbul ignore next */ []));
1188
+ src = computed(() => this.prop('src'), ...(ngDevMode ? [{ debugName: "src" }] : /* istanbul ignore next */ []));
1189
+ alt = computed(() => this.prop('alt'), ...(ngDevMode ? [{ debugName: "alt" }] : /* istanbul ignore next */ []));
1190
+ title = computed(() => this.prop('title') || null, ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
1191
+ prop(name) {
1192
+ const value = this.node().properties?.[name];
1193
+ return typeof value === 'string' ? value : '';
1194
+ }
1195
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: ImageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1196
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: ImageComponent, isStandalone: true, selector: "ngx-image", inputs: { node: { classPropertyName: "node", publicName: "node", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
1197
+ @if (errored()) {
1198
+ <span class="sd-img-error">🖼 {{ t.imageError }}</span>
1199
+ } @else {
1200
+ @if (!loaded()) {
1201
+ <span class="sd-img-skeleton"></span>
1202
+ }
1203
+ <img
1204
+ [src]="src()"
1205
+ [alt]="alt()"
1206
+ [attr.title]="title()"
1207
+ loading="lazy"
1208
+ class="sd-img"
1209
+ [class.sd-img--hidden]="!loaded()"
1210
+ (load)="loaded.set(true)"
1211
+ (error)="errored.set(true)"
1212
+ />
1213
+ }
1214
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1215
+ }
1216
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: ImageComponent, decorators: [{
1217
+ type: Component,
1218
+ args: [{
1219
+ selector: 'ngx-image',
1220
+ standalone: true,
1221
+ changeDetection: ChangeDetectionStrategy.OnPush,
1222
+ template: `
1223
+ @if (errored()) {
1224
+ <span class="sd-img-error">🖼 {{ t.imageError }}</span>
1225
+ } @else {
1226
+ @if (!loaded()) {
1227
+ <span class="sd-img-skeleton"></span>
1228
+ }
1229
+ <img
1230
+ [src]="src()"
1231
+ [alt]="alt()"
1232
+ [attr.title]="title()"
1233
+ loading="lazy"
1234
+ class="sd-img"
1235
+ [class.sd-img--hidden]="!loaded()"
1236
+ (load)="loaded.set(true)"
1237
+ (error)="errored.set(true)"
1238
+ />
1239
+ }
1240
+ `,
1241
+ }]
1242
+ }], propDecorators: { node: [{ type: i0.Input, args: [{ isSignal: true, alias: "node", required: true }] }] } });
1243
+
1244
+ let registered = false;
1245
+ /**
1246
+ * Registers the default custom components into COMPONENT_MAP (once).
1247
+ *
1248
+ * Called in the `HastRendererComponent` constructor (at runtime) — so the `pre` and
1249
+ * `table` tags are bound to their components before any render begins.
1250
+ * The runtime call is safe from bundler tree-shaking (not a side-effect import).
1251
+ *
1252
+ * After this call, users can add their own tags to COMPONENT_MAP or replace the
1253
+ * defaults.
1254
+ */
1255
+ function registerDefaultComponents() {
1256
+ if (registered) {
1257
+ return;
1258
+ }
1259
+ registered = true;
1260
+ if (!COMPONENT_MAP.has('pre')) {
1261
+ COMPONENT_MAP.set('pre', CodeBlockComponent); // code blocks (Shiki, copy/download)
1262
+ }
1263
+ if (!COMPONENT_MAP.has('table')) {
1264
+ COMPONENT_MAP.set('table', TableComponent); // tables (copy/download MD/CSV)
1265
+ }
1266
+ if (!COMPONENT_MAP.has('img')) {
1267
+ COMPONENT_MAP.set('img', ImageComponent); // images (loading/error states)
1268
+ }
1269
+ }
1270
+
1271
+ /**
1272
+ * Streaming "caret" (typing cursor) animation. In the spirit of Streamdown `lib/animate.ts`.
1273
+ *
1274
+ * The caret is placed inside DOM dynamically created by the renderer (not an Angular
1275
+ * template), so the styles must be global — we inject them into `document.head` once
1276
+ * (to avoid requiring CSS from the consumer).
1277
+ */
1278
+ const CARET_CLASS = 'ngx-sd-caret';
1279
+ let injected = false;
1280
+ /** Injects the global keyframe + class styles for the caret once. */
1281
+ function ensureCaretStyles() {
1282
+ if (injected || typeof document === 'undefined') {
1283
+ return;
1284
+ }
1285
+ injected = true;
1286
+ const style = document.createElement('style');
1287
+ style.textContent = `
1288
+ @keyframes ngx-sd-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
1289
+ .${CARET_CLASS} {
1290
+ display: inline-block;
1291
+ width: 0.5em;
1292
+ height: 1em;
1293
+ margin-left: 1px;
1294
+ vertical-align: text-bottom;
1295
+ background: currentColor;
1296
+ border-radius: 1px;
1297
+ animation: ngx-sd-blink 1s step-start infinite;
1298
+ }
1299
+ `;
1300
+ document.head.appendChild(style);
1301
+ }
1302
+ /** Creates the caret `<span>` element. */
1303
+ function createCaret() {
1304
+ ensureCaretStyles();
1305
+ const span = document.createElement('span');
1306
+ span.className = CARET_CLASS;
1307
+ span.setAttribute('aria-hidden', 'true');
1308
+ return span;
1309
+ }
1310
+
1311
+ /**
1312
+ * The entry point of the HAST renderer.
1313
+ *
1314
+ * When the `hast` input (Root) changes, it does not rebuild the whole tree — it
1315
+ * **diffs (reconciles)** against the previous render: only changed text/attributes/elements
1316
+ * are updated. During streaming this eliminates flicker, preserves scroll/selection, and updates
1317
+ * the `node` input of special components (code-block, table) without recreating them.
1318
+ */
1319
+ class HastRendererComponent {
1320
+ /** The HAST Root tree to render */
1321
+ hast = input.required(...(ngDevMode ? [{ debugName: "hast" }] : /* istanbul ignore next */ []));
1322
+ /** Append a streaming caret (text cursor) at the end */
1323
+ caret = input(false, ...(ngDevMode ? [{ debugName: "caret" }] : /* istanbul ignore next */ []));
1324
+ hostRef = viewChild.required('host');
1325
+ vcr = inject(ViewContainerRef);
1326
+ renderer = inject(HastRendererService);
1327
+ rendered = [];
1328
+ caretEl = null;
1329
+ constructor() {
1330
+ registerDefaultComponents(); // registers the pre/table/img components (once)
1331
+ // diff when hast/caret changes (effect tracks signals automatically)
1332
+ effect(() => {
1333
+ const root = this.hast();
1334
+ const showCaret = this.caret();
1335
+ const host = this.hostRef().nativeElement;
1336
+ this.removeCaret(); // remove the caret before diffing (it is not a hast node)
1337
+ this.rendered = this.renderer.reconcile(host, this.rendered, root.children, this.vcr);
1338
+ if (showCaret) {
1339
+ this.addCaret(host);
1340
+ }
1341
+ });
1342
+ }
1343
+ ngOnDestroy() {
1344
+ // clean up dynamic components (prevent memory leaks)
1345
+ this.renderer.destroyAll(this.rendered);
1346
+ }
1347
+ /** Appends the caret inside the last block element (inline) or to the host. */
1348
+ addCaret(host) {
1349
+ const target = host.lastElementChild ?? host;
1350
+ this.caretEl = createCaret();
1351
+ target.appendChild(this.caretEl);
1352
+ }
1353
+ /** Removes the existing caret from the DOM. */
1354
+ removeCaret() {
1355
+ this.caretEl?.remove();
1356
+ this.caretEl = null;
1357
+ }
1358
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: HastRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1359
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.17", type: HastRendererComponent, isStandalone: true, selector: "ngx-hast-renderer", inputs: { hast: { classPropertyName: "hast", publicName: "hast", isSignal: true, isRequired: true, transformFunction: null }, caret: { classPropertyName: "caret", publicName: "caret", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "hostRef", first: true, predicate: ["host"], descendants: true, isSignal: true }], ngImport: i0, template: `<div #host class="ngx-streamdown"></div>`, isInline: true, styles: [".ngx-streamdown{color:#1f2937;line-height:1.7;word-wrap:break-word}.ngx-streamdown h1,.ngx-streamdown h2,.ngx-streamdown h3,.ngx-streamdown h4,.ngx-streamdown h5,.ngx-streamdown h6{margin:1.5rem 0 .75rem;font-weight:600;line-height:1.25}.ngx-streamdown h1{font-size:1.875rem}.ngx-streamdown h2{font-size:1.5rem}.ngx-streamdown h3{font-size:1.25rem}.ngx-streamdown h4{font-size:1.125rem}.ngx-streamdown h5{font-size:1rem}.ngx-streamdown h6{font-size:.875rem;color:#6b7280}.ngx-streamdown p{margin:1rem 0}.ngx-streamdown blockquote{margin:1rem 0;padding-left:1rem;border-left:3px solid #d1d5db;color:#6b7280;font-style:italic}.ngx-streamdown hr{margin:1.5rem 0;border:0;border-top:1px solid #e5e7eb}.ngx-streamdown ul,.ngx-streamdown ol{margin:1rem 0;padding-left:1.5rem}.ngx-streamdown ul{list-style:disc}.ngx-streamdown ol{list-style:decimal}.ngx-streamdown li{margin:.25rem 0}.ngx-streamdown li::marker{color:#9ca3af}.ngx-streamdown li>input[type=checkbox]{margin-right:.4rem}.ngx-streamdown a{color:#2563eb;text-decoration:underline;text-underline-offset:2px;font-weight:500}.ngx-streamdown a:hover{color:#1e40af}.ngx-streamdown strong{font-weight:600}.ngx-streamdown em{font-style:italic}.ngx-streamdown del{text-decoration:line-through}.ngx-streamdown sup{vertical-align:super;font-size:.75em}.ngx-streamdown sub{vertical-align:sub;font-size:.75em}.ngx-streamdown mark{background:#fef08a;padding:0 .125rem}.ngx-streamdown abbr{cursor:help;text-decoration:underline dotted}.ngx-streamdown kbd{border:1px solid #d1d5db;background:#f9fafb;border-radius:.25rem;padding:.1rem .35rem;font:.8em monospace;box-shadow:0 1px #00000014}.ngx-streamdown :not(pre)>code{background:#f3f4f6;border-radius:.25rem;padding:.1rem .35rem;font:.85em monospace}.ngx-streamdown table,ngx-table .sd-table-el{width:100%;border-collapse:collapse;font-size:.875rem;border:1px solid #e5e7eb;border-radius:.5rem;overflow:hidden}.ngx-streamdown thead,ngx-table thead{background:#f9fafb}.ngx-streamdown th,ngx-table th{padding:.5rem 1rem;text-align:left;font-weight:500}.ngx-streamdown td,ngx-table td{padding:.5rem 1rem;border-top:1px solid #f3f4f6}.ngx-streamdown img{margin:1rem 0;max-width:100%;border-radius:.5rem}ngx-cb-copy .sd-btn,ngx-cb-download .sd-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.125rem .375rem;font-size:.75rem;color:#6b7280;background:transparent;border:0;border-radius:.25rem;cursor:pointer;transition:background-color .15s,color .15s}ngx-cb-copy .sd-btn:hover,ngx-cb-download .sd-btn:hover{background:#e5e7eb;color:#374151}ngx-cb-copy .sd-btn svg,ngx-cb-download .sd-btn svg{display:inline-flex}ngx-code-block{display:block}.sd-codeblock{margin:1rem 0;overflow:hidden;border:1px solid #e5e7eb;border-radius:.5rem}.sd-codeblock-header{display:flex;align-items:center;justify-content:space-between;padding:.375rem .75rem;background:#f3f4f6;font-size:.75rem;color:#4b5563}.sd-codeblock-lang{font-family:monospace;text-transform:lowercase}.sd-codeblock-actions{display:inline-flex;align-items:center;gap:.25rem}.sd-codeblock-body{overflow-x:auto;background:#fff;font-size:.875rem}.sd-codeblock-body pre{margin:0;padding:1rem;font-family:monospace;font-size:.85rem}.sd-codeblock-body .shiki{margin:0;padding:1rem;border-radius:0}ngx-table{display:block}.sd-table{margin:1rem 0}.sd-table-toolbar{display:flex;justify-content:flex-end;gap:.25rem;margin-bottom:.25rem}.sd-table-btn{font-size:.75rem;padding:.25rem .5rem;border:0;background:transparent;border-radius:.25rem;cursor:pointer;color:#4b5563}.sd-table-btn:hover{background:#f3f4f6}.sd-table-msg{font-size:.75rem;padding:.25rem .5rem;color:#16a34a}.sd-table-scroll{overflow-x:auto}ngx-image{display:inline-block}.sd-img{margin:1rem 0;max-width:100%;border-radius:.5rem}.sd-img--hidden{display:none}.sd-img-skeleton{display:inline-block;width:100%;max-width:24rem;height:8rem;margin:1rem 0;border-radius:.5rem;background:#e5e7eb;animation:ngx-sd-pulse 1.5s ease-in-out infinite}.sd-img-error{display:inline-flex;align-items:center;gap:.25rem;padding:.25rem .5rem;font-size:.75rem;color:#6b7280;background:#f9fafb;border:1px solid #e5e7eb;border-radius:.25rem}@keyframes ngx-sd-pulse{0%,to{opacity:1}50%{opacity:.5}}ngx-mermaid{display:block}ngx-mermaid .ngx-mermaid svg{max-width:100%;height:auto}.sd-mermaid-loading{font-size:.875rem;color:#6b7280}.sd-mermaid-error{font-size:.875rem;color:#dc2626;border:1px solid #fecaca;border-radius:.25rem;padding:.5rem}.sd-mermaid-error pre{margin-top:.5rem;overflow-x:auto;white-space:pre-wrap}.sd-modal-overlay{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;background:#0006;padding:1rem}.sd-modal{width:100%;max-width:28rem;background:#fff;border-radius:.5rem;padding:1.25rem;box-shadow:0 20px 25px -5px #0000001a,0 8px 10px -6px #0000001a}.sd-modal-title{margin:0 0 .5rem;font-size:1.125rem;font-weight:600}.sd-modal-body{font-size:.875rem;color:#4b5563}.sd-modal-url{margin:.75rem 0;padding:.5rem;background:#f3f4f6;border-radius:.25rem;font:.75rem monospace;word-break:break-all}.sd-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}.sd-modal-btn{padding:.375rem .75rem;font-size:.875rem;border:0;background:transparent;border-radius:.25rem;cursor:pointer}.sd-modal-btn:hover{background:#f3f4f6}.sd-modal-btn--primary{background:#2563eb;color:#fff;font-weight:500}.sd-modal-btn--primary:hover{background:#1d4ed8}.ngx-streamdown[dir=rtl] ul,.ngx-streamdown[dir=rtl] ol{padding-right:1.5rem;padding-left:0}.ngx-streamdown[dir=rtl] blockquote{border-left:0;border-right:3px solid #d1d5db;padding-left:0;padding-right:1rem}.ngx-streamdown[dir=rtl] li>input[type=checkbox]{margin-right:0;margin-left:.4rem}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1360
+ }
1361
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: HastRendererComponent, decorators: [{
1362
+ type: Component,
1363
+ args: [{ selector: 'ngx-hast-renderer', standalone: true, template: `<div #host class="ngx-streamdown"></div>`, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, styles: [".ngx-streamdown{color:#1f2937;line-height:1.7;word-wrap:break-word}.ngx-streamdown h1,.ngx-streamdown h2,.ngx-streamdown h3,.ngx-streamdown h4,.ngx-streamdown h5,.ngx-streamdown h6{margin:1.5rem 0 .75rem;font-weight:600;line-height:1.25}.ngx-streamdown h1{font-size:1.875rem}.ngx-streamdown h2{font-size:1.5rem}.ngx-streamdown h3{font-size:1.25rem}.ngx-streamdown h4{font-size:1.125rem}.ngx-streamdown h5{font-size:1rem}.ngx-streamdown h6{font-size:.875rem;color:#6b7280}.ngx-streamdown p{margin:1rem 0}.ngx-streamdown blockquote{margin:1rem 0;padding-left:1rem;border-left:3px solid #d1d5db;color:#6b7280;font-style:italic}.ngx-streamdown hr{margin:1.5rem 0;border:0;border-top:1px solid #e5e7eb}.ngx-streamdown ul,.ngx-streamdown ol{margin:1rem 0;padding-left:1.5rem}.ngx-streamdown ul{list-style:disc}.ngx-streamdown ol{list-style:decimal}.ngx-streamdown li{margin:.25rem 0}.ngx-streamdown li::marker{color:#9ca3af}.ngx-streamdown li>input[type=checkbox]{margin-right:.4rem}.ngx-streamdown a{color:#2563eb;text-decoration:underline;text-underline-offset:2px;font-weight:500}.ngx-streamdown a:hover{color:#1e40af}.ngx-streamdown strong{font-weight:600}.ngx-streamdown em{font-style:italic}.ngx-streamdown del{text-decoration:line-through}.ngx-streamdown sup{vertical-align:super;font-size:.75em}.ngx-streamdown sub{vertical-align:sub;font-size:.75em}.ngx-streamdown mark{background:#fef08a;padding:0 .125rem}.ngx-streamdown abbr{cursor:help;text-decoration:underline dotted}.ngx-streamdown kbd{border:1px solid #d1d5db;background:#f9fafb;border-radius:.25rem;padding:.1rem .35rem;font:.8em monospace;box-shadow:0 1px #00000014}.ngx-streamdown :not(pre)>code{background:#f3f4f6;border-radius:.25rem;padding:.1rem .35rem;font:.85em monospace}.ngx-streamdown table,ngx-table .sd-table-el{width:100%;border-collapse:collapse;font-size:.875rem;border:1px solid #e5e7eb;border-radius:.5rem;overflow:hidden}.ngx-streamdown thead,ngx-table thead{background:#f9fafb}.ngx-streamdown th,ngx-table th{padding:.5rem 1rem;text-align:left;font-weight:500}.ngx-streamdown td,ngx-table td{padding:.5rem 1rem;border-top:1px solid #f3f4f6}.ngx-streamdown img{margin:1rem 0;max-width:100%;border-radius:.5rem}ngx-cb-copy .sd-btn,ngx-cb-download .sd-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.125rem .375rem;font-size:.75rem;color:#6b7280;background:transparent;border:0;border-radius:.25rem;cursor:pointer;transition:background-color .15s,color .15s}ngx-cb-copy .sd-btn:hover,ngx-cb-download .sd-btn:hover{background:#e5e7eb;color:#374151}ngx-cb-copy .sd-btn svg,ngx-cb-download .sd-btn svg{display:inline-flex}ngx-code-block{display:block}.sd-codeblock{margin:1rem 0;overflow:hidden;border:1px solid #e5e7eb;border-radius:.5rem}.sd-codeblock-header{display:flex;align-items:center;justify-content:space-between;padding:.375rem .75rem;background:#f3f4f6;font-size:.75rem;color:#4b5563}.sd-codeblock-lang{font-family:monospace;text-transform:lowercase}.sd-codeblock-actions{display:inline-flex;align-items:center;gap:.25rem}.sd-codeblock-body{overflow-x:auto;background:#fff;font-size:.875rem}.sd-codeblock-body pre{margin:0;padding:1rem;font-family:monospace;font-size:.85rem}.sd-codeblock-body .shiki{margin:0;padding:1rem;border-radius:0}ngx-table{display:block}.sd-table{margin:1rem 0}.sd-table-toolbar{display:flex;justify-content:flex-end;gap:.25rem;margin-bottom:.25rem}.sd-table-btn{font-size:.75rem;padding:.25rem .5rem;border:0;background:transparent;border-radius:.25rem;cursor:pointer;color:#4b5563}.sd-table-btn:hover{background:#f3f4f6}.sd-table-msg{font-size:.75rem;padding:.25rem .5rem;color:#16a34a}.sd-table-scroll{overflow-x:auto}ngx-image{display:inline-block}.sd-img{margin:1rem 0;max-width:100%;border-radius:.5rem}.sd-img--hidden{display:none}.sd-img-skeleton{display:inline-block;width:100%;max-width:24rem;height:8rem;margin:1rem 0;border-radius:.5rem;background:#e5e7eb;animation:ngx-sd-pulse 1.5s ease-in-out infinite}.sd-img-error{display:inline-flex;align-items:center;gap:.25rem;padding:.25rem .5rem;font-size:.75rem;color:#6b7280;background:#f9fafb;border:1px solid #e5e7eb;border-radius:.25rem}@keyframes ngx-sd-pulse{0%,to{opacity:1}50%{opacity:.5}}ngx-mermaid{display:block}ngx-mermaid .ngx-mermaid svg{max-width:100%;height:auto}.sd-mermaid-loading{font-size:.875rem;color:#6b7280}.sd-mermaid-error{font-size:.875rem;color:#dc2626;border:1px solid #fecaca;border-radius:.25rem;padding:.5rem}.sd-mermaid-error pre{margin-top:.5rem;overflow-x:auto;white-space:pre-wrap}.sd-modal-overlay{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;background:#0006;padding:1rem}.sd-modal{width:100%;max-width:28rem;background:#fff;border-radius:.5rem;padding:1.25rem;box-shadow:0 20px 25px -5px #0000001a,0 8px 10px -6px #0000001a}.sd-modal-title{margin:0 0 .5rem;font-size:1.125rem;font-weight:600}.sd-modal-body{font-size:.875rem;color:#4b5563}.sd-modal-url{margin:.75rem 0;padding:.5rem;background:#f3f4f6;border-radius:.25rem;font:.75rem monospace;word-break:break-all}.sd-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}.sd-modal-btn{padding:.375rem .75rem;font-size:.875rem;border:0;background:transparent;border-radius:.25rem;cursor:pointer}.sd-modal-btn:hover{background:#f3f4f6}.sd-modal-btn--primary{background:#2563eb;color:#fff;font-weight:500}.sd-modal-btn--primary:hover{background:#1d4ed8}.ngx-streamdown[dir=rtl] ul,.ngx-streamdown[dir=rtl] ol{padding-right:1.5rem;padding-left:0}.ngx-streamdown[dir=rtl] blockquote{border-left:0;border-right:3px solid #d1d5db;padding-left:0;padding-right:1rem}.ngx-streamdown[dir=rtl] li>input[type=checkbox]{margin-right:0;margin-left:.4rem}\n"] }]
1364
+ }], ctorParameters: () => [], propDecorators: { hast: [{ type: i0.Input, args: [{ isSignal: true, alias: "hast", required: true }] }], caret: [{ type: i0.Input, args: [{ isSignal: true, alias: "caret", required: false }] }], hostRef: [{ type: i0.ViewChild, args: ['host', { isSignal: true }] }] } });
1365
+
1366
+ const STREAMDOWN_PIPELINE = new InjectionToken('STREAMDOWN_PIPELINE');
1367
+ /**
1368
+ * Converts a markdown string into a HAST (HTML AST) tree.
1369
+ * Matches the Streamdown `lib/markdown.ts` pipeline, but extensible via plugins:
1370
+ * remark-parse → remark-gfm → [remarkPlugins] → remark-rehype → rehype-raw
1371
+ * → [rehypePlugins] → rehype-sanitize
1372
+ */
1373
+ class MarkdownService {
1374
+ // Plugin configurations provided via DI (math, ...). Empty if none.
1375
+ configs = inject(STREAMDOWN_PIPELINE, { optional: true }) ?? [];
1376
+ processor = this.build();
1377
+ /**
1378
+ * Markdown → HAST tree (sync). Uses `runSync` so each render is fast during streaming.
1379
+ * @param options.remend "close" incomplete markdown (default: true)
1380
+ */
1381
+ toHast(markdown, options) {
1382
+ const source = options?.remend === false ? markdown : remend(markdown);
1383
+ const mdast = this.processor.parse(source);
1384
+ const hast = this.processor.runSync(mdast);
1385
+ return hast;
1386
+ }
1387
+ /** Builds the pipeline with plugins (once). */
1388
+ build() {
1389
+ const processor = unified().use(remarkParse).use(remarkGfm);
1390
+ for (const cfg of this.configs) {
1391
+ if (cfg.remarkPlugins) {
1392
+ processor.use(cfg.remarkPlugins);
1393
+ }
1394
+ }
1395
+ processor.use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw);
1396
+ for (const cfg of this.configs) {
1397
+ if (cfg.rehypePlugins) {
1398
+ processor.use(cfg.rehypePlugins);
1399
+ }
1400
+ }
1401
+ const schema = this.configs.find((c) => c.sanitizeSchema)?.sanitizeSchema;
1402
+ processor.use(rehypeSanitize, schema);
1403
+ return processor;
1404
+ }
1405
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: MarkdownService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1406
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: MarkdownService, providedIn: 'root' });
1407
+ }
1408
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: MarkdownService, decorators: [{
1409
+ type: Injectable,
1410
+ args: [{ providedIn: 'root' }]
1411
+ }] });
1412
+
1413
+ /**
1414
+ * Renders a single markdown block (Streamdown `Block`, memoized).
1415
+ *
1416
+ * Thanks to `OnPush` + the parent's `@for track $index`, only the changed block
1417
+ * re-renders — the streaming equivalent of React's `memo()`.
1418
+ */
1419
+ class BlockComponent {
1420
+ /** Markdown text of a single block */
1421
+ content = input.required(...(ngDevMode ? [{ debugName: "content" }] : /* istanbul ignore next */ []));
1422
+ /** Fix incomplete markdown (remend) */
1423
+ parseIncompleteMarkdown = input(true, ...(ngDevMode ? [{ debugName: "parseIncompleteMarkdown" }] : /* istanbul ignore next */ []));
1424
+ /** Whether to append a streaming caret at the end of this block (last block only) */
1425
+ caret = input(false, ...(ngDevMode ? [{ debugName: "caret" }] : /* istanbul ignore next */ []));
1426
+ md = inject(MarkdownService);
1427
+ // React's useMemo(unified.process) → computed
1428
+ hast = computed(() => this.md.toHast(this.content(), { remend: this.parseIncompleteMarkdown() }), ...(ngDevMode ? [{ debugName: "hast" }] : /* istanbul ignore next */ []));
1429
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: BlockComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1430
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.17", type: BlockComponent, isStandalone: true, selector: "ngx-block", inputs: { content: { classPropertyName: "content", publicName: "content", isSignal: true, isRequired: true, transformFunction: null }, parseIncompleteMarkdown: { classPropertyName: "parseIncompleteMarkdown", publicName: "parseIncompleteMarkdown", isSignal: true, isRequired: false, transformFunction: null }, caret: { classPropertyName: "caret", publicName: "caret", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `<ngx-hast-renderer [hast]="hast()" [caret]="caret()" />`, isInline: true, dependencies: [{ kind: "component", type: HastRendererComponent, selector: "ngx-hast-renderer", inputs: ["hast", "caret"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1431
+ }
1432
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: BlockComponent, decorators: [{
1433
+ type: Component,
1434
+ args: [{
1435
+ selector: 'ngx-block',
1436
+ standalone: true,
1437
+ imports: [HastRendererComponent],
1438
+ template: `<ngx-hast-renderer [hast]="hast()" [caret]="caret()" />`,
1439
+ changeDetection: ChangeDetectionStrategy.OnPush,
1440
+ }]
1441
+ }], propDecorators: { content: [{ type: i0.Input, args: [{ isSignal: true, alias: "content", required: true }] }], parseIncompleteMarkdown: [{ type: i0.Input, args: [{ isSignal: true, alias: "parseIncompleteMarkdown", required: false }] }], caret: [{ type: i0.Input, args: [{ isSignal: true, alias: "caret", required: false }] }] } });
1442
+
1443
+ /**
1444
+ * External link confirmation modal. Added to the `StreamdownComponent` template and
1445
+ * shown when `LinkModalService.pendingUrl` is set. Otherwise renders nothing.
1446
+ */
1447
+ class LinkModalComponent {
1448
+ svc = inject(LinkModalService);
1449
+ t = inject(TranslationsService).t;
1450
+ url = this.svc.pendingUrl;
1451
+ confirm() {
1452
+ this.svc.confirm();
1453
+ }
1454
+ cancel() {
1455
+ this.svc.cancel();
1456
+ }
1457
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: LinkModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1458
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: LinkModalComponent, isStandalone: true, selector: "ngx-link-modal", ngImport: i0, template: `
1459
+ @if (url(); as link) {
1460
+ <div class="sd-modal-overlay" (click)="cancel()">
1461
+ <div class="sd-modal" (click)="$event.stopPropagation()">
1462
+ <h3 class="sd-modal-title">{{ t.linkWarningTitle }}</h3>
1463
+ <p class="sd-modal-body">{{ t.linkWarningBody }}</p>
1464
+ <p class="sd-modal-url">{{ link }}</p>
1465
+ <div class="sd-modal-actions">
1466
+ <button type="button" class="sd-modal-btn" (click)="cancel()">
1467
+ {{ t.cancel }}
1468
+ </button>
1469
+ <button type="button" class="sd-modal-btn sd-modal-btn--primary" (click)="confirm()">
1470
+ {{ t.open }}
1471
+ </button>
1472
+ </div>
1473
+ </div>
1474
+ </div>
1475
+ }
1476
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1477
+ }
1478
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: LinkModalComponent, decorators: [{
1479
+ type: Component,
1480
+ args: [{
1481
+ selector: 'ngx-link-modal',
1482
+ standalone: true,
1483
+ changeDetection: ChangeDetectionStrategy.OnPush,
1484
+ template: `
1485
+ @if (url(); as link) {
1486
+ <div class="sd-modal-overlay" (click)="cancel()">
1487
+ <div class="sd-modal" (click)="$event.stopPropagation()">
1488
+ <h3 class="sd-modal-title">{{ t.linkWarningTitle }}</h3>
1489
+ <p class="sd-modal-body">{{ t.linkWarningBody }}</p>
1490
+ <p class="sd-modal-url">{{ link }}</p>
1491
+ <div class="sd-modal-actions">
1492
+ <button type="button" class="sd-modal-btn" (click)="cancel()">
1493
+ {{ t.cancel }}
1494
+ </button>
1495
+ <button type="button" class="sd-modal-btn sd-modal-btn--primary" (click)="confirm()">
1496
+ {{ t.open }}
1497
+ </button>
1498
+ </div>
1499
+ </div>
1500
+ </div>
1501
+ }
1502
+ `,
1503
+ }]
1504
+ }] });
1505
+
1506
+ /**
1507
+ * Splits markdown into top-level blocks (Streamdown `lib/parse-blocks.tsx`).
1508
+ *
1509
+ * The `marked` lexer breaks markdown into tokens; each token's `raw` text is one block.
1510
+ * This matters for streaming: only the changed (last) block re-renders, while the
1511
+ * rest stay unchanged thanks to `@for track $index` + OnPush.
1512
+ */
1513
+ function parseMarkdownIntoBlocks(markdown) {
1514
+ if (!markdown) {
1515
+ return [];
1516
+ }
1517
+ const tokens = marked.lexer(markdown);
1518
+ return tokens.map((token) => token.raw);
1519
+ }
1520
+
1521
+ /**
1522
+ * Detects text direction (Streamdown `lib/detect-direction.ts`).
1523
+ * Returns 'rtl' if RTL characters (Arabic, Hebrew, Persian) are present, otherwise 'ltr'.
1524
+ */
1525
+ const RTL_CHARS = /[֑-߿܀-ݏހ-޿ࢠ-ࣿיִ-﷽ﹰ-ﻼ]/;
1526
+ function detectDirection(text) {
1527
+ return RTL_CHARS.test(text) ? 'rtl' : 'ltr';
1528
+ }
1529
+
1530
+ /**
1531
+ * Main public component (Streamdown `index.tsx`).
1532
+ *
1533
+ * Usage:
1534
+ * <ngx-streamdown [content]="markdown()" />
1535
+ *
1536
+ * Data flow (React useMemo chain → Angular computed):
1537
+ * content → parseMarkdownIntoBlocks → @for(block) → ngx-block → hast-renderer
1538
+ */
1539
+ class StreamdownComponent {
1540
+ /** Markdown text to render (grows during streaming) */
1541
+ content = input('', ...(ngDevMode ? [{ debugName: "content" }] : /* istanbul ignore next */ []));
1542
+ /** Fix incomplete markdown (such as `**bold` during streaming). Default: true */
1543
+ parseIncompleteMarkdown = input(true, ...(ngDevMode ? [{ debugName: "parseIncompleteMarkdown" }] : /* istanbul ignore next */ []));
1544
+ /** Show a streaming caret (typing cursor) at the end of the last block. Default: false */
1545
+ caret = input(false, ...(ngDevMode ? [{ debugName: "caret" }] : /* istanbul ignore next */ []));
1546
+ // Split into blocks — only the changed block re-renders
1547
+ blocks = computed(() => parseMarkdownIntoBlocks(this.content() ?? ''), ...(ngDevMode ? [{ debugName: "blocks" }] : /* istanbul ignore next */ []));
1548
+ // Automatic RTL/LTR detection
1549
+ dir = computed(() => detectDirection(this.content() ?? ''), ...(ngDevMode ? [{ debugName: "dir" }] : /* istanbul ignore next */ []));
1550
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: StreamdownComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1551
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: StreamdownComponent, isStandalone: true, selector: "ngx-streamdown", inputs: { content: { classPropertyName: "content", publicName: "content", isSignal: true, isRequired: false, transformFunction: null }, parseIncompleteMarkdown: { classPropertyName: "parseIncompleteMarkdown", publicName: "parseIncompleteMarkdown", isSignal: true, isRequired: false, transformFunction: null }, caret: { classPropertyName: "caret", publicName: "caret", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "attr.dir": "dir()" }, classAttribute: "ngx-streamdown" }, ngImport: i0, template: `
1552
+ @for (block of blocks(); track $index) {
1553
+ <ngx-block
1554
+ [content]="block"
1555
+ [parseIncompleteMarkdown]="parseIncompleteMarkdown()"
1556
+ [caret]="caret() && $last"
1557
+ />
1558
+ }
1559
+ <ngx-link-modal />
1560
+ `, isInline: true, dependencies: [{ kind: "component", type: BlockComponent, selector: "ngx-block", inputs: ["content", "parseIncompleteMarkdown", "caret"] }, { kind: "component", type: LinkModalComponent, selector: "ngx-link-modal" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1561
+ }
1562
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: StreamdownComponent, decorators: [{
1563
+ type: Component,
1564
+ args: [{
1565
+ selector: 'ngx-streamdown',
1566
+ standalone: true,
1567
+ imports: [BlockComponent, LinkModalComponent],
1568
+ template: `
1569
+ @for (block of blocks(); track $index) {
1570
+ <ngx-block
1571
+ [content]="block"
1572
+ [parseIncompleteMarkdown]="parseIncompleteMarkdown()"
1573
+ [caret]="caret() && $last"
1574
+ />
1575
+ }
1576
+ <ngx-link-modal />
1577
+ `,
1578
+ host: {
1579
+ class: 'ngx-streamdown',
1580
+ '[attr.dir]': 'dir()',
1581
+ },
1582
+ changeDetection: ChangeDetectionStrategy.OnPush,
1583
+ }]
1584
+ }], propDecorators: { content: [{ type: i0.Input, args: [{ isSignal: true, alias: "content", required: false }] }], parseIncompleteMarkdown: [{ type: i0.Input, args: [{ isSignal: true, alias: "parseIncompleteMarkdown", required: false }] }], caret: [{ type: i0.Input, args: [{ isSignal: true, alias: "caret", required: false }] }] } });
1585
+
1586
+ /**
1587
+ * A standalone component that renders a single generic HAST element node.
1588
+ *
1589
+ * The main render path goes through `HastRendererService` directly with Renderer2
1590
+ * (wrapper-free, for correct HTML semantics). This component serves as a reusable
1591
+ * unit: for when special components want to render their HAST children, or to use it
1592
+ * from a template as `<ngx-hast-element [node]="...">`.
1593
+ *
1594
+ * `:host { display: contents }` — the `<ngx-hast-element>` wrapper does not affect layout.
1595
+ */
1596
+ class HastElementComponent {
1597
+ /** The HAST element node to render */
1598
+ node = input.required(...(ngDevMode ? [{ debugName: "node" }] : /* istanbul ignore next */ []));
1599
+ hostRef = inject(ElementRef);
1600
+ vcr = inject(ViewContainerRef);
1601
+ renderer = inject(HastRendererService);
1602
+ refs = [];
1603
+ ngOnInit() {
1604
+ // render the node itself (and its children) into the host element
1605
+ this.refs = this.renderer.renderNodes([this.node()], this.hostRef.nativeElement, this.vcr);
1606
+ }
1607
+ ngOnDestroy() {
1608
+ this.renderer.clear(this.hostRef.nativeElement, this.refs);
1609
+ }
1610
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: HastElementComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1611
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.17", type: HastElementComponent, isStandalone: true, selector: "ngx-hast-element", inputs: { node: { classPropertyName: "node", publicName: "node", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: '', isInline: true, styles: [":host{display:contents}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1612
+ }
1613
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: HastElementComponent, decorators: [{
1614
+ type: Component,
1615
+ args: [{ selector: 'ngx-hast-element', standalone: true, template: '', changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{display:contents}\n"] }]
1616
+ }], propDecorators: { node: [{ type: i0.Input, args: [{ isSignal: true, alias: "node", required: true }] }] } });
1617
+
1618
+ /**
1619
+ * Enables the fade-in animation for elements that newly appear during streaming.
1620
+ * Thanks to diffing (reconcile), only NEW DOM is animated — existing elements don't replay.
1621
+ * `prefers-reduced-motion` is respected.
1622
+ */
1623
+ function provideStreamdownAnimation() {
1624
+ return makeEnvironmentProviders([{ provide: STREAMDOWN_ANIMATE, useValue: true }]);
1625
+ }
1626
+
1627
+ /**
1628
+ * Module-level counter that provides a stable, unique id for each diagram.
1629
+ *
1630
+ * Instead of `Math.random`/`Date` we use a simple incrementing counter — this is
1631
+ * deterministic in SSR/test environments and guarantees the unique id mermaid requires.
1632
+ */
1633
+ let diagramCounter = 0;
1634
+ /**
1635
+ * Module-level flag for initializing the mermaid library only once.
1636
+ *
1637
+ * Since `mermaid` is an optional peer dependency, it is loaded via dynamic `import()`;
1638
+ * therefore we also keep the init state at module level (shared across instances).
1639
+ */
1640
+ let mermaidInitialized = false;
1641
+ /**
1642
+ * Component that converts a `mermaid` language block into an SVG diagram.
1643
+ *
1644
+ * It is registered in the code-language registry via `provideStreamdownMermaid()`;
1645
+ * after that, ```mermaid code blocks are rendered with this component instead of Shiki highlighting.
1646
+ *
1647
+ * Data flow:
1648
+ * code() changes → effect → renderDiagram() → dynamic import('mermaid') → m.render() → innerHTML
1649
+ */
1650
+ class MermaidComponent {
1651
+ /** Raw mermaid source to render (provided by the registry via the `code` input) */
1652
+ code = input.required(...(ngDevMode ? [{ debugName: "code" }] : /* istanbul ignore next */ []));
1653
+ /** Container where the SVG is placed */
1654
+ host = viewChild.required('host');
1655
+ renderer = inject(Renderer2);
1656
+ t = inject(TranslationsService).t;
1657
+ /** Indicates a render is in progress (loading state) */
1658
+ rendering = signal(false, ...(ngDevMode ? [{ debugName: "rendering" }] : /* istanbul ignore next */ []));
1659
+ /** Error message (when non-empty, the error box is shown) */
1660
+ errorMessage = signal(null, ...(ngDevMode ? [{ debugName: "errorMessage" }] : /* istanbul ignore next */ []));
1661
+ /** Whether at least one successful render has occurred (for streaming UX) */
1662
+ hasRendered = signal(false, ...(ngDevMode ? [{ debugName: "hasRendered" }] : /* istanbul ignore next */ []));
1663
+ /**
1664
+ * Sequence number of the most recent render request. Async renders may finish out of order —
1665
+ * we only write the result of the latest request to the DOM (to avoid a race condition).
1666
+ */
1667
+ renderSeq = 0;
1668
+ constructor() {
1669
+ // re-render the diagram whenever code() or host() changes.
1670
+ // the effect is created in an injection context (the constructor).
1671
+ effect(() => {
1672
+ // read the dependencies so the effect re-runs when a signal changes
1673
+ this.code();
1674
+ this.host();
1675
+ // delegate the async work to a separate helper (the effect callback itself must not be async)
1676
+ void this.renderDiagram();
1677
+ });
1678
+ }
1679
+ /**
1680
+ * Dynamically loads mermaid and renders the current `code()` to SVG.
1681
+ *
1682
+ * IMPORTANT: first we validate the syntax with `mermaid.parse(..., { suppressErrors: true })`.
1683
+ * During streaming, when the code is not yet complete, parse returns `false` — in that case
1684
+ * we do NOT call `m.render()` AT ALL (so mermaid's "Syntax error" bomb graphic does not
1685
+ * appear) and we keep the previous diagram.
1686
+ */
1687
+ async renderDiagram() {
1688
+ const source = this.code().trim();
1689
+ const container = this.host().nativeElement;
1690
+ const seq = ++this.renderSeq;
1691
+ // empty source — do nothing
1692
+ if (!source) {
1693
+ this.rendering.set(false);
1694
+ return;
1695
+ }
1696
+ this.rendering.set(true);
1697
+ try {
1698
+ // mermaid is an optional peer dependency — dynamic import keeps it out of the base bundle
1699
+ const m = (await import('mermaid')).default;
1700
+ if (seq !== this.renderSeq) {
1701
+ return; // this request is stale — a newer one is in flight
1702
+ }
1703
+ // initialize only once; securityLevel 'strict' — output is trusted
1704
+ if (!mermaidInitialized) {
1705
+ m.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'default' });
1706
+ mermaidInitialized = true;
1707
+ }
1708
+ // validate the syntax without throwing; if false — the code is not yet complete/invalid
1709
+ const valid = await m.parse(source, { suppressErrors: true });
1710
+ if (seq !== this.renderSeq) {
1711
+ return;
1712
+ }
1713
+ if (!valid) {
1714
+ // streaming is ongoing or the code is invalid — don't show the bomb, keep the previous state
1715
+ this.errorMessage.set(null);
1716
+ this.rendering.set(false);
1717
+ return;
1718
+ }
1719
+ const id = `ngx-mermaid-${diagramCounter++}`;
1720
+ const { svg } = await m.render(id, source);
1721
+ if (seq !== this.renderSeq) {
1722
+ return; // the result is stale — don't write it
1723
+ }
1724
+ // with securityLevel 'strict', mermaid's output is considered trusted
1725
+ this.renderer.setProperty(container, 'innerHTML', svg);
1726
+ this.errorMessage.set(null);
1727
+ this.hasRendered.set(true);
1728
+ }
1729
+ catch (error) {
1730
+ if (seq !== this.renderSeq) {
1731
+ return;
1732
+ }
1733
+ // parse succeeded but render failed unexpectedly — show the error box
1734
+ this.errorMessage.set(error instanceof Error ? error.message : String(error));
1735
+ }
1736
+ finally {
1737
+ if (seq === this.renderSeq) {
1738
+ this.rendering.set(false);
1739
+ }
1740
+ // on a render error mermaid may leave an orphan `#d<id>` element in the DOM — clean it up
1741
+ removeOrphanMermaidNodes();
1742
+ }
1743
+ }
1744
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: MermaidComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1745
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: MermaidComponent, isStandalone: true, selector: "ngx-mermaid", inputs: { code: { classPropertyName: "code", publicName: "code", isSignal: true, isRequired: true, transformFunction: null } }, viewQueries: [{ propertyName: "host", first: true, predicate: ["host"], descendants: true, isSignal: true }], ngImport: i0, template: `
1746
+ @if (errorMessage(); as err) {
1747
+ <div class="sd-mermaid-error">
1748
+ <p><strong>{{ t.mermaidError }}</strong></p>
1749
+ <p>{{ err }}</p>
1750
+ <pre>{{ code() }}</pre>
1751
+ </div>
1752
+ } @else if (rendering() && !hasRendered()) {
1753
+ <p class="sd-mermaid-loading">{{ t.renderingDiagram }}</p>
1754
+ }
1755
+ <!-- The diagram SVG is placed in this container (shown when there is no error) -->
1756
+ <div #host class="ngx-mermaid" [hidden]="!!errorMessage()"></div>
1757
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1758
+ }
1759
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: MermaidComponent, decorators: [{
1760
+ type: Component,
1761
+ args: [{
1762
+ selector: 'ngx-mermaid',
1763
+ standalone: true,
1764
+ template: `
1765
+ @if (errorMessage(); as err) {
1766
+ <div class="sd-mermaid-error">
1767
+ <p><strong>{{ t.mermaidError }}</strong></p>
1768
+ <p>{{ err }}</p>
1769
+ <pre>{{ code() }}</pre>
1770
+ </div>
1771
+ } @else if (rendering() && !hasRendered()) {
1772
+ <p class="sd-mermaid-loading">{{ t.renderingDiagram }}</p>
1773
+ }
1774
+ <!-- The diagram SVG is placed in this container (shown when there is no error) -->
1775
+ <div #host class="ngx-mermaid" [hidden]="!!errorMessage()"></div>
1776
+ `,
1777
+ changeDetection: ChangeDetectionStrategy.OnPush,
1778
+ }]
1779
+ }], ctorParameters: () => [], propDecorators: { code: [{ type: i0.Input, args: [{ isSignal: true, alias: "code", required: true }] }], host: [{ type: i0.ViewChild, args: ['host', { isSignal: true }] }] } });
1780
+ /**
1781
+ * Cleans up temporary elements (`#dngx-mermaid-*` and `#ngx-mermaid-*`) left orphaned
1782
+ * on `document.body` when mermaid `render()` fails — so the "bomb" graphic does not
1783
+ * linger on the page.
1784
+ */
1785
+ function removeOrphanMermaidNodes() {
1786
+ if (typeof document === 'undefined') {
1787
+ return;
1788
+ }
1789
+ const orphans = document.querySelectorAll('body > [id^="dngx-mermaid-"], body > [id^="ngx-mermaid-"]');
1790
+ orphans.forEach((node) => node.remove());
1791
+ }
1792
+
1793
+ /**
1794
+ * Enables mermaid diagrams. Add `provideStreamdownMermaid()` in `app.config.ts`:
1795
+ *
1796
+ * export const appConfig: ApplicationConfig = {
1797
+ * providers: [provideStreamdownMermaid()],
1798
+ * };
1799
+ *
1800
+ * This binds the `mermaid` language to the code-language registry, so ```mermaid code blocks
1801
+ * are rendered as SVG diagrams via `MermaidComponent`.
1802
+ */
1803
+ function provideStreamdownMermaid() {
1804
+ return makeEnvironmentProviders([
1805
+ provideEnvironmentInitializer(() => {
1806
+ CODE_LANGUAGE_COMPONENTS.set('mermaid', MermaidComponent);
1807
+ }),
1808
+ ]);
1809
+ }
1810
+
1811
+ /**
1812
+ * Additional tags allowed for KaTeX MathML/SVG output.
1813
+ * The MathML and SVG elements that rehype-katex produces, otherwise
1814
+ * rehype-sanitize would strip them.
1815
+ */
1816
+ const KATEX_TAG_NAMES = [
1817
+ 'span',
1818
+ 'div',
1819
+ 'math',
1820
+ 'semantics',
1821
+ 'annotation',
1822
+ 'mrow',
1823
+ 'mi',
1824
+ 'mo',
1825
+ 'mn',
1826
+ 'ms',
1827
+ 'mtext',
1828
+ 'msup',
1829
+ 'msub',
1830
+ 'msubsup',
1831
+ 'mfrac',
1832
+ 'msqrt',
1833
+ 'mroot',
1834
+ 'munder',
1835
+ 'mover',
1836
+ 'munderover',
1837
+ 'mtable',
1838
+ 'mtr',
1839
+ 'mtd',
1840
+ 'mspace',
1841
+ 'mpadded',
1842
+ 'mphantom',
1843
+ 'menclose',
1844
+ 'mstyle',
1845
+ 'mglyph',
1846
+ 'line',
1847
+ 'svg',
1848
+ 'path',
1849
+ 'g',
1850
+ ];
1851
+ // `defaultSchema` is loosely typed (the token expects `unknown`), so we
1852
+ // cast it to a local type for convenient reading.
1853
+ const base = defaultSchema;
1854
+ // Take the existing `attributes['*']` list and allow `className` and `style`
1855
+ // (KaTeX uses inline styles) on all elements.
1856
+ const baseWildcardAttributes = base.attributes?.['*'] ?? [];
1857
+ const wildcardAttributes = [
1858
+ ...baseWildcardAttributes,
1859
+ 'className',
1860
+ 'class',
1861
+ 'style',
1862
+ 'ariaHidden',
1863
+ 'aria-hidden',
1864
+ ];
1865
+ // Merge the existing tagNames with the KaTeX tags and remove duplicates.
1866
+ const baseTagNames = base.tagNames ?? [];
1867
+ const tagNames = Array.from(new Set([...baseTagNames, ...KATEX_TAG_NAMES]));
1868
+ /**
1869
+ * Sanitize schema that preserves KaTeX output.
1870
+ * A deeply extended copy of `defaultSchema`:
1871
+ * - allows `className`/`style`/`aria-hidden` on all elements,
1872
+ * - adds MathML + SVG (katex) tags to `tagNames`.
1873
+ * Loosely typed because the token expects `unknown`.
1874
+ */
1875
+ const katexSanitizeSchema = {
1876
+ ...base,
1877
+ attributes: {
1878
+ ...(base.attributes ?? {}),
1879
+ '*': wildcardAttributes,
1880
+ },
1881
+ tagNames,
1882
+ };
1883
+
1884
+ /**
1885
+ * Enables KaTeX math formulas ($inline$ and $$block$$).
1886
+ * IMPORTANT: the consuming app MUST import `katex/dist/katex.min.css`.
1887
+ */
1888
+ function provideStreamdownMath() {
1889
+ const config = {
1890
+ remarkPlugins: [remarkMath],
1891
+ rehypePlugins: [rehypeKatex],
1892
+ sanitizeSchema: katexSanitizeSchema,
1893
+ };
1894
+ return makeEnvironmentProviders([
1895
+ { provide: STREAMDOWN_PIPELINE, multi: true, useValue: config },
1896
+ ]);
1897
+ }
1898
+
1899
+ /**
1900
+ * CSS class/data-attribute prefix. Equivalent of Streamdown `prefix-context.tsx`.
1901
+ * Provides a stable "hook" for styling (e.g. `data-streamdown="code-block"`).
1902
+ */
1903
+ const STREAMDOWN_PREFIX = new InjectionToken('STREAMDOWN_PREFIX');
1904
+ class PrefixService {
1905
+ /** Default prefix 'streamdown'. */
1906
+ prefix = inject(STREAMDOWN_PREFIX, { optional: true }) ?? 'streamdown';
1907
+ /** `prefixed('code-block')` → `'streamdown-code-block'`. */
1908
+ prefixed(name) {
1909
+ return `${this.prefix}-${name}`;
1910
+ }
1911
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: PrefixService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1912
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: PrefixService, providedIn: 'root' });
1913
+ }
1914
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: PrefixService, decorators: [{
1915
+ type: Injectable,
1916
+ args: [{ providedIn: 'root' }]
1917
+ }] });
1918
+ /** Overrides the class/data-attribute prefix. */
1919
+ function provideStreamdownPrefix(prefix) {
1920
+ return makeEnvironmentProviders([{ provide: STREAMDOWN_PREFIX, useValue: prefix }]);
1921
+ }
1922
+
1923
+ /*
1924
+ * ngx-streamdown — Angular port of the Vercel Streamdown (React) library.
1925
+ *
1926
+ * Original source: github.com/vercel/streamdown (Apache-2.0, Copyright 2023 Vercel, Inc.)
1927
+ * Public API surface of ngx-streamdown.
1928
+ */
1929
+ // ── main public component ───────────────────────────────────────────────────
1930
+
1931
+ /**
1932
+ * Generated bundle index. Do not edit.
1933
+ */
1934
+
1935
+ export { BlockComponent, CODE_LANGUAGE_COMPONENTS, COMPONENT_MAP, CodeBlockComponent, DEFAULT_ICONS, DEFAULT_TRANSLATIONS, ELEMENT_CLASSES, HastElementComponent, HastRendererComponent, HastRendererService, IconService, ImageComponent, LinkModalComponent, LinkModalService, MarkdownService, MermaidComponent, PrefixService, STREAMDOWN_ANIMATE, STREAMDOWN_ICONS, STREAMDOWN_LINK_SAFETY, STREAMDOWN_PIPELINE, STREAMDOWN_PREFIX, STREAMDOWN_TRANSLATIONS, ShikiHighlighterService, StreamdownComponent, TableComponent, TranslationsService, cn, detectDirection, extractCodeInfo, extractTableData, katexSanitizeSchema, languageToExtension, parseMarkdownIntoBlocks, provideStreamdownAnimation, provideStreamdownIcons, provideStreamdownLinkSafety, provideStreamdownMath, provideStreamdownMermaid, provideStreamdownPrefix, provideStreamdownTranslations, toCsv, toMarkdown, toTsv };
1936
+ //# sourceMappingURL=streamdown-angular.mjs.map