@cianfrani/ai-ui 0.1.0-alpha.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/dist/ai-ui.js +3218 -0
  4. package/dist/types/index.d.ts +7 -0
  5. package/dist/types/lib/has-slot-controller.d.ts +21 -0
  6. package/dist/types/lib/tool-tone.d.ts +2 -0
  7. package/dist/types/native-styles.d.ts +5 -0
  8. package/dist/types/semantic/ai-conversation.d.ts +28 -0
  9. package/dist/types/semantic/ai-event.d.ts +91 -0
  10. package/dist/types/semantic/ai-message.d.ts +45 -0
  11. package/dist/types/semantic/ai-thinking.d.ts +41 -0
  12. package/dist/types/semantic/ai-tool-call.d.ts +59 -0
  13. package/dist/types/semantic/ai-tool-result.d.ts +44 -0
  14. package/dist/types/semantic/index.d.ts +6 -0
  15. package/dist/types/visual/avatar.d.ts +34 -0
  16. package/dist/types/visual/badge.d.ts +32 -0
  17. package/dist/types/visual/divider.d.ts +26 -0
  18. package/dist/types/visual/icon.d.ts +24 -0
  19. package/dist/types/visual/index.d.ts +9 -0
  20. package/dist/types/visual/markdown.d.ts +52 -0
  21. package/dist/types/visual/stack.d.ts +32 -0
  22. package/dist/types/visual/status.d.ts +33 -0
  23. package/dist/types/visual/surface.d.ts +34 -0
  24. package/dist/types/visual/text.d.ts +37 -0
  25. package/package.json +67 -0
  26. package/src/custom-elements.json +3741 -0
  27. package/src/index.ts +8 -0
  28. package/src/lib/has-slot-controller.ts +61 -0
  29. package/src/lib/tool-tone.ts +18 -0
  30. package/src/native-styles.ts +29 -0
  31. package/src/semantic/ai-conversation.ts +84 -0
  32. package/src/semantic/ai-event.ts +452 -0
  33. package/src/semantic/ai-message.ts +235 -0
  34. package/src/semantic/ai-thinking.ts +190 -0
  35. package/src/semantic/ai-tool-call.ts +513 -0
  36. package/src/semantic/ai-tool-result.ts +239 -0
  37. package/src/semantic/index.ts +6 -0
  38. package/src/visual/avatar.ts +163 -0
  39. package/src/visual/badge.ts +141 -0
  40. package/src/visual/divider.ts +97 -0
  41. package/src/visual/icon.ts +97 -0
  42. package/src/visual/index.ts +9 -0
  43. package/src/visual/markdown.ts +888 -0
  44. package/src/visual/stack.ts +115 -0
  45. package/src/visual/status.ts +170 -0
  46. package/src/visual/surface.ts +150 -0
  47. package/src/visual/text.ts +141 -0
@@ -0,0 +1,888 @@
1
+ import { LitElement, css, html, nothing } from "lit";
2
+ import { customElement, property } from "lit/decorators.js";
3
+ import { map } from "lit/directives/map.js";
4
+
5
+ // --- Markdown types & parser ---
6
+
7
+ type InlinePart =
8
+ | { type: "text"; value: string }
9
+ | { type: "code"; value: string }
10
+ | { type: "strong"; value: string }
11
+ | { type: "emphasis"; value: string }
12
+ | { type: "link"; text: string; href: string };
13
+
14
+ type ListItem = {
15
+ text: string;
16
+ children: Block[];
17
+ };
18
+
19
+ type Block =
20
+ | { type: "paragraph"; text: string }
21
+ | { type: "heading"; level: 1 | 2 | 3; text: string }
22
+ | { type: "code"; code: string; language: string }
23
+ | { type: "ul"; items: ListItem[] }
24
+ | { type: "ol"; items: ListItem[] }
25
+ | { type: "blockquote"; blocks: Block[] }
26
+ | { type: "table"; headers: string[]; rows: string[][]; aligns: ("left" | "center" | "right")[] };
27
+
28
+ /**
29
+ * Markdown content renderer. Parses and renders markdown source.
30
+ *
31
+ * @slot - Fallback content if content property is empty.
32
+ *
33
+ * @cssprop --ai-markdown-color - Body text color.
34
+ * @cssprop --ai-markdown-link-color - Link color.
35
+ * @cssprop --ai-markdown-code-background - Inline/block code background.
36
+ * @cssprop --ai-markdown-code-color - Code text color.
37
+ * @cssprop --ai-markdown-border-color - Blockquote/table/pre border.
38
+ * @cssprop --ai-markdown-heading-color - Heading color.
39
+ * @cssprop --ai-markdown-gap - Vertical rhythm between markdown blocks.
40
+ */
41
+ @customElement("ai-markdown")
42
+ export class AiMarkdown extends LitElement {
43
+ static override styles = css`
44
+ *,
45
+ *::before,
46
+ *::after {
47
+ box-sizing: border-box;
48
+ }
49
+
50
+ :host {
51
+ display: block;
52
+ max-width: 100%;
53
+ color: var(--ai-markdown-color, var(--text-block-color, var(--text, var(--ai-color-text))));
54
+ white-space: normal;
55
+ word-break: normal;
56
+ overflow-wrap: anywhere;
57
+ font-size: var(--font-size-body, var(--ai-font-size-body, 0.875rem));
58
+ line-height: var(--line-height-loose, var(--ai-line-height, 1.6));
59
+ }
60
+
61
+ .markdown {
62
+ display: block;
63
+ }
64
+
65
+ .markdown > * + * {
66
+ margin-top: var(--ai-markdown-gap, var(--content-gap, 10px));
67
+ }
68
+
69
+ p,
70
+ ul,
71
+ ol,
72
+ pre,
73
+ h1,
74
+ h2,
75
+ h3,
76
+ blockquote {
77
+ margin: 0;
78
+ }
79
+
80
+ p,
81
+ li {
82
+ white-space: pre-wrap;
83
+ }
84
+
85
+ h1,
86
+ h2,
87
+ h3 {
88
+ line-height: var(--line-height-snug, 1.3);
89
+ color: var(
90
+ --ai-markdown-heading-color,
91
+ var(--heading-color, var(--text, var(--ai-markdown-color, var(--ai-color-text))))
92
+ );
93
+ }
94
+
95
+ h1 {
96
+ font-size: var(--font-size-title, 1.125rem);
97
+ font-weight: var(--font-weight-bold, 700);
98
+ }
99
+
100
+ h2 {
101
+ font-size: var(--font-size-body, 1rem);
102
+ font-weight: var(--font-weight-bold, 700);
103
+ }
104
+
105
+ h3 {
106
+ font-size: var(--font-size-ui, 0.875rem);
107
+ font-weight: var(--font-weight-title, 600);
108
+ }
109
+
110
+ ul,
111
+ ol {
112
+ padding-left: 1.1rem;
113
+ }
114
+
115
+ li {
116
+ margin: 0;
117
+ }
118
+
119
+ li + li {
120
+ margin-top: 4px;
121
+ }
122
+
123
+ li > ul,
124
+ li > ol {
125
+ margin-top: 4px;
126
+ margin-bottom: 0;
127
+ }
128
+
129
+ blockquote {
130
+ border-left: 3px solid
131
+ var(
132
+ --ai-markdown-border-color,
133
+ var(--blockquote-border-color, var(--border, var(--ai-color-border, #333)))
134
+ );
135
+ padding-left: var(--spacing-sm, var(--ai-space-sm, 0.5rem));
136
+ color: color-mix(
137
+ in srgb,
138
+ var(--text, var(--ai-markdown-color, var(--ai-color-text))) 80%,
139
+ transparent
140
+ );
141
+ }
142
+
143
+ blockquote > * + * {
144
+ margin-top: 6px;
145
+ }
146
+
147
+ blockquote blockquote {
148
+ margin-top: 6px;
149
+ }
150
+
151
+ code {
152
+ padding: 0.12rem 0.35rem;
153
+ border-radius: 6px;
154
+ background: var(
155
+ --ai-markdown-code-background,
156
+ var(--code-background-color, color-mix(in srgb, var(--text, currentColor) 8%, transparent))
157
+ );
158
+ color: var(
159
+ --ai-markdown-code-color,
160
+ var(--code-text-color, var(--text, var(--ai-color-text)))
161
+ );
162
+ font-family: var(--font-family-mono, var(--ai-font-family-mono, monospace));
163
+ font-size: 0.92em;
164
+ }
165
+
166
+ pre {
167
+ max-width: 100%;
168
+ overflow-x: auto;
169
+ -webkit-overflow-scrolling: touch;
170
+ padding: calc(var(--spacing-sm, var(--ai-space-sm, 0.5rem)) + 2px)
171
+ var(--spacing-md, var(--ai-space-md, 0.75rem));
172
+ border-radius: calc(var(--radius, var(--ai-radius, 8px)) + 2px);
173
+ background: var(
174
+ --ai-markdown-code-background,
175
+ var(--code-background-color, color-mix(in srgb, var(--text, currentColor) 5%, transparent))
176
+ );
177
+ border: 1px solid
178
+ var(
179
+ --ai-markdown-border-color,
180
+ var(--blockquote-border-color, var(--border, var(--ai-color-border, #333)))
181
+ );
182
+ }
183
+
184
+ pre code {
185
+ padding: 0;
186
+ border-radius: 0;
187
+ background: transparent;
188
+ color: inherit;
189
+ font-size: 0.9em;
190
+ }
191
+
192
+ strong {
193
+ color: var(--text, var(--ai-markdown-color, var(--ai-color-text)));
194
+ font-weight: var(--font-weight-title, 600);
195
+ }
196
+
197
+ em {
198
+ font-style: italic;
199
+ }
200
+
201
+ a {
202
+ color: var(
203
+ --ai-markdown-link-color,
204
+ var(--link-color, var(--accent, var(--ai-color-accent, #0066cc)))
205
+ );
206
+ text-decoration: underline;
207
+ text-underline-offset: 2px;
208
+ cursor: pointer;
209
+ }
210
+
211
+ a:hover {
212
+ opacity: 0.8;
213
+ }
214
+
215
+ :host([tone="user"]) {
216
+ color: var(--ai-markdown-color, var(--text-on-accent, HighlightText));
217
+ line-height: var(--line-height-snug, 1.25);
218
+ }
219
+
220
+ :host([tone="user"]) .markdown > * + * {
221
+ margin-top: 4px;
222
+ }
223
+
224
+ :host([tone="user"]) a {
225
+ color: var(--ai-markdown-link-color, var(--text-on-accent, HighlightText));
226
+ text-decoration: underline;
227
+ text-underline-offset: 2px;
228
+ }
229
+
230
+ :host([tone="user"]) blockquote {
231
+ border-left-color: color-mix(in srgb, var(--text-on-accent, currentColor) 30%, transparent);
232
+ color: var(--text-on-accent, currentColor);
233
+ }
234
+
235
+ :host([tone="user"]) code,
236
+ :host([tone="user"]) pre {
237
+ background: var(
238
+ --ai-markdown-code-background,
239
+ color-mix(in srgb, var(--text, currentColor) 12%, transparent)
240
+ );
241
+ border-color: var(
242
+ --ai-markdown-border-color,
243
+ color-mix(in srgb, var(--text, currentColor) 15%, transparent)
244
+ );
245
+ color: var(--ai-markdown-code-color, var(--text, currentColor));
246
+ }
247
+
248
+ :host([tone="user"]) th {
249
+ background: color-mix(in srgb, var(--text, currentColor) 8%, transparent);
250
+ }
251
+
252
+ :host([tone="user"]) th,
253
+ :host([tone="user"]) td {
254
+ border-color: var(
255
+ --ai-markdown-border-color,
256
+ color-mix(in srgb, var(--text, currentColor) 15%, transparent)
257
+ );
258
+ }
259
+
260
+ :host([tone="system"]),
261
+ :host([tone="tool"]) {
262
+ color: var(--ai-markdown-color, var(--text-muted, var(--ai-color-text-muted, #888)));
263
+ font-size: var(--font-size-meta, var(--ai-font-size-meta, 0.8125rem));
264
+ }
265
+
266
+ .table-wrapper {
267
+ width: 100%;
268
+ overflow-x: auto;
269
+ -webkit-overflow-scrolling: touch;
270
+ }
271
+
272
+ table {
273
+ width: 100%;
274
+ min-width: max-content;
275
+ border-collapse: collapse;
276
+ font-size: 0.92em;
277
+ margin: 0;
278
+ }
279
+
280
+ th,
281
+ td {
282
+ padding: var(--spacing-xs, var(--ai-space-xs, 0.25rem))
283
+ var(--spacing-sm, var(--ai-space-sm, 0.5rem));
284
+ border: 1px solid
285
+ var(
286
+ --ai-markdown-border-color,
287
+ var(--table-border-color, var(--border, var(--ai-color-border, #333)))
288
+ );
289
+ text-align: left;
290
+ }
291
+
292
+ th {
293
+ background: color-mix(
294
+ in srgb,
295
+ var(--text, var(--ai-markdown-color, var(--ai-color-text))) 4%,
296
+ transparent
297
+ );
298
+ font-weight: var(--font-weight-semibold, 600);
299
+ color: var(--text, var(--ai-markdown-color, var(--ai-color-text)));
300
+ }
301
+
302
+ td {
303
+ background: transparent;
304
+ }
305
+
306
+ tr:nth-child(even) td {
307
+ background: color-mix(
308
+ in srgb,
309
+ var(--text, var(--ai-markdown-color, var(--ai-color-text))) 2%,
310
+ transparent
311
+ );
312
+ }
313
+ `;
314
+
315
+ /** Markdown content to render. Does NOT reflect (large content). */
316
+ @property({ type: String })
317
+ content = "";
318
+
319
+ /** Visual tone for prose style parity with pi-ui messages. */
320
+ @property({ reflect: true })
321
+ tone: "assistant" | "user" | "system" | "tool" = "assistant";
322
+
323
+ /** Whether the content is trusted (affects link behavior). */
324
+ @property({ type: Boolean, reflect: true })
325
+ trusted = false;
326
+
327
+ // --- Inline parser ---
328
+
329
+ private parseInlines(text: string): InlinePart[] {
330
+ const parts: InlinePart[] = [];
331
+ const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\[[^\]]+\]\([^)]+\)|\*[^*]+\*|_[^_]+_)/g;
332
+ let lastIndex = 0;
333
+
334
+ for (const match of text.matchAll(pattern)) {
335
+ const token = match[0];
336
+ const index = match.index ?? 0;
337
+ if (index > lastIndex) {
338
+ parts.push({ type: "text", value: text.slice(lastIndex, index) });
339
+ }
340
+ if (token.startsWith("`")) {
341
+ parts.push({ type: "code", value: token.slice(1, -1) });
342
+ } else if (token.startsWith("**")) {
343
+ parts.push({ type: "strong", value: token.slice(2, -2) });
344
+ } else if (token.startsWith("[")) {
345
+ const linkMatch = token.match(/^\[(.+)\]\((.+)\)$/);
346
+ if (linkMatch) {
347
+ parts.push({
348
+ type: "link",
349
+ text: linkMatch[1] ?? "",
350
+ href: linkMatch[2] ?? "",
351
+ });
352
+ } else {
353
+ parts.push({ type: "text", value: token });
354
+ }
355
+ } else if (token.startsWith("_")) {
356
+ parts.push({ type: "emphasis", value: token.slice(1, -1) });
357
+ } else {
358
+ parts.push({ type: "emphasis", value: token.slice(1, -1) });
359
+ }
360
+ lastIndex = index + token.length;
361
+ }
362
+
363
+ if (lastIndex < text.length) {
364
+ parts.push({ type: "text", value: text.slice(lastIndex) });
365
+ }
366
+
367
+ return parts;
368
+ }
369
+
370
+ private renderInline(text: string) {
371
+ return map(this.parseInlines(text), (part) => {
372
+ if (part.type === "code") {
373
+ return html`<code>${part.value}</code>`;
374
+ }
375
+ if (part.type === "strong") {
376
+ return html`<strong>${part.value}</strong>`;
377
+ }
378
+ if (part.type === "emphasis") {
379
+ return html`<em>${part.value}</em>`;
380
+ }
381
+ if (part.type === "link") {
382
+ const isAllowed =
383
+ /^https?:\/\//i.test(part.href) || !/^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(part.href);
384
+ const safe = isAllowed ? part.href : "";
385
+ return html`<a
386
+ href="${safe}"
387
+ target="_blank"
388
+ rel="noopener noreferrer"
389
+ >${part.text}</a
390
+ >`;
391
+ }
392
+ return part.value;
393
+ });
394
+ }
395
+
396
+ // --- Table helpers ---
397
+
398
+ private parseTableRow(line: string): string[] {
399
+ const content = line.replace(/^\|/, "").replace(/\|$/, "");
400
+ return content.split("|").map((cell) => cell.trim());
401
+ }
402
+
403
+ private parseTableAligns(separator: string): ("left" | "center" | "right")[] {
404
+ const cells = this.parseTableRow(separator);
405
+ return cells.map((cell) => {
406
+ const trimmed = cell.trim();
407
+ const leftAlign = trimmed.startsWith(":");
408
+ const rightAlign = trimmed.endsWith(":");
409
+ if (leftAlign && rightAlign) {
410
+ return "center";
411
+ }
412
+ if (rightAlign) {
413
+ return "right";
414
+ }
415
+ return "left";
416
+ });
417
+ }
418
+
419
+ private isTableSeparator(line: string, columnCount: number): boolean {
420
+ const cells = this.parseTableRow(line);
421
+ if (cells.length !== columnCount || cells.length === 0) {
422
+ return false;
423
+ }
424
+ return cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
425
+ }
426
+
427
+ // --- Indent helper ---
428
+
429
+ private indentLevel(line: string): number {
430
+ let n = 0;
431
+ for (const ch of line) {
432
+ if (ch === " ") {
433
+ n++;
434
+ } else if (ch === "\t") {
435
+ n += 2;
436
+ } else {
437
+ break;
438
+ }
439
+ }
440
+ return n;
441
+ }
442
+
443
+ // --- Block parsers ---
444
+
445
+ private parseBlocks(source: string): Block[] {
446
+ const lines = source.replace(/\r\n/g, "\n").split("\n");
447
+ const blocks: Block[] = [];
448
+ let i = 0;
449
+
450
+ while (i < lines.length) {
451
+ const trimmed = (lines[i] ?? "").trim();
452
+
453
+ if (!trimmed) {
454
+ i++;
455
+ continue;
456
+ }
457
+
458
+ const codeBlock = this.parseCodeBlock(lines, i, trimmed);
459
+ if (codeBlock) {
460
+ blocks.push(codeBlock.block);
461
+ i = codeBlock.nextIndex;
462
+ continue;
463
+ }
464
+
465
+ const headingBlock = this.parseHeadingBlock(trimmed);
466
+ if (headingBlock) {
467
+ blocks.push(headingBlock);
468
+ i++;
469
+ continue;
470
+ }
471
+
472
+ const blockquoteBlock = this.parseBlockquoteBlock(lines, i, trimmed);
473
+ if (blockquoteBlock) {
474
+ blocks.push(blockquoteBlock.block);
475
+ i = blockquoteBlock.nextIndex;
476
+ continue;
477
+ }
478
+
479
+ const listBlock = this.parseListBlock(lines, i, trimmed);
480
+ if (listBlock) {
481
+ blocks.push(listBlock.block);
482
+ i = listBlock.nextIndex;
483
+ continue;
484
+ }
485
+
486
+ const tableBlock = this.parseTableBlock(lines, i, trimmed);
487
+ if (tableBlock) {
488
+ blocks.push(tableBlock.block);
489
+ i = tableBlock.nextIndex;
490
+ continue;
491
+ }
492
+
493
+ const paragraphBlock = this.parseParagraphBlock(lines, i);
494
+ blocks.push(paragraphBlock.block);
495
+ i = paragraphBlock.nextIndex;
496
+ }
497
+
498
+ return blocks;
499
+ }
500
+
501
+ private parseCodeBlock(lines: string[], startIndex: number, trimmed: string) {
502
+ if (!trimmed.startsWith("```")) {
503
+ return undefined;
504
+ }
505
+
506
+ const language = trimmed.slice(3).trim();
507
+ const codeLines: string[] = [];
508
+ let i = startIndex + 1;
509
+ while (i < lines.length && !(lines[i] ?? "").trim().startsWith("```")) {
510
+ codeLines.push(lines[i] ?? "");
511
+ i++;
512
+ }
513
+ if (i < lines.length) {
514
+ i++;
515
+ }
516
+
517
+ return {
518
+ block: { type: "code", code: codeLines.join("\n"), language } as Block,
519
+ nextIndex: i,
520
+ };
521
+ }
522
+
523
+ private parseHeadingBlock(trimmed: string): Block | undefined {
524
+ const heading = trimmed.match(/^(#{1,3})\s+(.+)$/);
525
+ if (!heading?.[1] || !heading?.[2]) {
526
+ return undefined;
527
+ }
528
+
529
+ return {
530
+ type: "heading",
531
+ level: heading[1].length as 1 | 2 | 3,
532
+ text: heading[2].trim(),
533
+ };
534
+ }
535
+
536
+ private parseBlockquoteBlock(lines: string[], startIndex: number, trimmed: string) {
537
+ if (!trimmed.startsWith(">")) {
538
+ return undefined;
539
+ }
540
+
541
+ const quoteLines: string[] = [];
542
+ let i = startIndex;
543
+
544
+ while (i < lines.length) {
545
+ const line = lines[i] ?? "";
546
+ const lineTrimmed = line.trim();
547
+ if (!lineTrimmed) {
548
+ let j = i + 1;
549
+ while (j < lines.length && !(lines[j] ?? "").trim()) {
550
+ j++;
551
+ }
552
+ if (j < lines.length && (lines[j] ?? "").trim().startsWith(">")) {
553
+ quoteLines.push("");
554
+ i = j;
555
+ continue;
556
+ }
557
+ break;
558
+ }
559
+ if (!lineTrimmed.startsWith(">")) {
560
+ break;
561
+ }
562
+ quoteLines.push(lineTrimmed.replace(/^>\s?/, ""));
563
+ i++;
564
+ }
565
+
566
+ if (quoteLines.length === 0) {
567
+ return undefined;
568
+ }
569
+
570
+ const innerBlocks = this.parseBlocks(quoteLines.join("\n"));
571
+ return {
572
+ block: { type: "blockquote", blocks: innerBlocks } as Block,
573
+ nextIndex: i,
574
+ };
575
+ }
576
+
577
+ private parseListBlock(lines: string[], startIndex: number, trimmed: string) {
578
+ if (/^[-*]\s+(.+)$/.test(trimmed)) {
579
+ return this.collectList(lines, startIndex, "ul");
580
+ }
581
+ if (/^\d+\.\s+(.+)$/.test(trimmed)) {
582
+ return this.collectList(lines, startIndex, "ol");
583
+ }
584
+ return undefined;
585
+ }
586
+
587
+ private skipBlankLines(lines: string[], from: number): number {
588
+ let j = from;
589
+ while (j < lines.length && !(lines[j] ?? "").trim()) {
590
+ j++;
591
+ }
592
+ return j;
593
+ }
594
+
595
+ private parseNestedChildren(lines: string[], startIndex: number, baseIndent: number): Block[] {
596
+ const nestedLines: string[] = [];
597
+ let i = startIndex;
598
+
599
+ while (i < lines.length) {
600
+ const nestedLine = lines[i] ?? "";
601
+ const nestedTrimmed = nestedLine.trim();
602
+ if (!nestedTrimmed) {
603
+ const j = this.skipBlankLines(lines, i + 1);
604
+ if (j < lines.length && this.indentLevel(lines[j] ?? "") > baseIndent) {
605
+ nestedLines.push("");
606
+ i = j;
607
+ continue;
608
+ }
609
+ break;
610
+ }
611
+ if (this.indentLevel(nestedLine) <= baseIndent) {
612
+ break;
613
+ }
614
+ nestedLines.push(nestedLine);
615
+ i++;
616
+ }
617
+
618
+ if (nestedLines.length === 0) {
619
+ return [];
620
+ }
621
+
622
+ const nonEmpty = nestedLines.filter((l) => l.trim());
623
+ const minIndent =
624
+ nonEmpty.length > 0 ? Math.min(...nonEmpty.map((l) => this.indentLevel(l))) : 0;
625
+ const unindented = nestedLines.map((l) => (l.trim() ? l.slice(minIndent) : l));
626
+ return this.parseBlocks(unindented.join("\n"));
627
+ }
628
+
629
+ private collectList(lines: string[], startIndex: number, type: "ul" | "ol") {
630
+ const baseIndent = this.indentLevel(lines[startIndex] ?? "");
631
+ const listPattern = type === "ul" ? /^[-*]\s+(.+)$/ : /^\d+\.\s+(.+)$/;
632
+ const items: ListItem[] = [];
633
+ let i = startIndex;
634
+
635
+ while (i < lines.length) {
636
+ const line = lines[i] ?? "";
637
+ const trimmed = line.trim();
638
+ const indent = this.indentLevel(line);
639
+
640
+ if (!trimmed) {
641
+ const j = this.skipBlankLines(lines, i + 1);
642
+ if (
643
+ j < lines.length &&
644
+ this.indentLevel(lines[j] ?? "") === baseIndent &&
645
+ listPattern.test((lines[j] ?? "").trim())
646
+ ) {
647
+ i = j;
648
+ continue;
649
+ }
650
+ break;
651
+ }
652
+
653
+ if (indent < baseIndent) {
654
+ break;
655
+ }
656
+
657
+ if (indent === baseIndent) {
658
+ const match = trimmed.match(listPattern);
659
+ if (!match?.[1]) {
660
+ break;
661
+ }
662
+ items.push({ text: match[1].trim(), children: [] });
663
+ i++;
664
+ continue;
665
+ }
666
+
667
+ // Deeper indentation — nested content belonging to last item
668
+ const current = items[items.length - 1];
669
+ if (!current) {
670
+ break;
671
+ }
672
+
673
+ current.children = this.parseNestedChildren(lines, i, baseIndent);
674
+ const consumed = this.countNestedLines(lines, i, baseIndent);
675
+ i += consumed;
676
+ }
677
+
678
+ return { block: { type, items } as Block, nextIndex: i };
679
+ }
680
+
681
+ private countNestedLines(lines: string[], startIndex: number, baseIndent: number): number {
682
+ let count = 0;
683
+ let i = startIndex;
684
+ while (i < lines.length) {
685
+ const line = lines[i] ?? "";
686
+ const trimmed = line.trim();
687
+ if (!trimmed) {
688
+ const j = this.skipBlankLines(lines, i + 1);
689
+ if (j < lines.length && this.indentLevel(lines[j] ?? "") > baseIndent) {
690
+ count += j - i;
691
+ i = j;
692
+ continue;
693
+ }
694
+ break;
695
+ }
696
+ if (this.indentLevel(line) <= baseIndent) {
697
+ break;
698
+ }
699
+ count++;
700
+ i++;
701
+ }
702
+ return count;
703
+ }
704
+
705
+ private parseTableBlock(lines: string[], startIndex: number, trimmed: string) {
706
+ if (!this.startsTable(trimmed)) {
707
+ return undefined;
708
+ }
709
+
710
+ const headers = this.parseTableRow(trimmed);
711
+ const separator = (lines[startIndex + 1] ?? "").trim();
712
+ if (!separator.startsWith("|") || !this.isTableSeparator(separator, headers.length)) {
713
+ return undefined;
714
+ }
715
+
716
+ const aligns = this.parseTableAligns(separator);
717
+ const rows: string[][] = [];
718
+ let i = startIndex + 2;
719
+
720
+ while (i < lines.length) {
721
+ const tableLine = (lines[i] ?? "").trim();
722
+ if (!this.startsTable(tableLine)) {
723
+ break;
724
+ }
725
+ rows.push(this.parseTableRow(tableLine));
726
+ i++;
727
+ }
728
+
729
+ return {
730
+ block: { type: "table", headers, rows, aligns } as Block,
731
+ nextIndex: i,
732
+ };
733
+ }
734
+
735
+ private parseParagraphBlock(lines: string[], startIndex: number) {
736
+ const paragraphLines: string[] = [];
737
+ let i = startIndex;
738
+
739
+ while (i < lines.length) {
740
+ const nextTrimmed = (lines[i] ?? "").trim();
741
+ if (!nextTrimmed || this.startsStructuredBlock(lines, i, nextTrimmed)) {
742
+ break;
743
+ }
744
+ paragraphLines.push(nextTrimmed);
745
+ i++;
746
+ }
747
+
748
+ return {
749
+ block: {
750
+ type: "paragraph",
751
+ text: paragraphLines.join(" "),
752
+ } as Block,
753
+ nextIndex: i,
754
+ };
755
+ }
756
+
757
+ private startsStructuredBlock(lines: string[], index: number, trimmed: string): boolean {
758
+ return (
759
+ /^(#{1,3})\s+/.test(trimmed) ||
760
+ /^[-*]\s+/.test(trimmed) ||
761
+ /^\d+\.\s+/.test(trimmed) ||
762
+ trimmed.startsWith("```") ||
763
+ trimmed.startsWith(">") ||
764
+ this.isTableStart(lines, index, trimmed)
765
+ );
766
+ }
767
+
768
+ private startsTable(trimmed: string): boolean {
769
+ return trimmed.startsWith("|") && trimmed.includes("|");
770
+ }
771
+
772
+ private isTableStart(lines: string[], index: number, trimmed: string): boolean {
773
+ if (!this.startsTable(trimmed)) {
774
+ return false;
775
+ }
776
+ const headers = this.parseTableRow(trimmed);
777
+ const separator = (lines[index + 1] ?? "").trim();
778
+ return separator.startsWith("|") && this.isTableSeparator(separator, headers.length);
779
+ }
780
+
781
+ // --- Render ---
782
+
783
+ private renderBlocks(blocks: Block[]): unknown {
784
+ return map(blocks, (block) => this.renderBlock(block));
785
+ }
786
+
787
+ private renderBlock(block: Block): unknown {
788
+ if (block.type === "paragraph") {
789
+ return html`<p>${this.renderInline(block.text)}</p>`;
790
+ }
791
+ if (block.type === "heading") {
792
+ if (block.level === 1) {
793
+ return html`<h1>${this.renderInline(block.text)}</h1>`;
794
+ }
795
+ if (block.level === 2) {
796
+ return html`<h2>${this.renderInline(block.text)}</h2>`;
797
+ }
798
+ return html`<h3>${this.renderInline(block.text)}</h3>`;
799
+ }
800
+ if (block.type === "code") {
801
+ return html`<pre><code>${block.code}</code></pre>`;
802
+ }
803
+ if (block.type === "ul") {
804
+ return html`<ul
805
+ >${map(
806
+ block.items,
807
+ (item) =>
808
+ html`<li
809
+ >${this.renderInline(item.text)}${
810
+ item.children.length ? this.renderBlocks(item.children) : nothing
811
+ }</li
812
+ >`,
813
+ )}</ul
814
+ >`;
815
+ }
816
+ if (block.type === "ol") {
817
+ return html`<ol
818
+ >${map(
819
+ block.items,
820
+ (item) =>
821
+ html`<li
822
+ >${this.renderInline(item.text)}${
823
+ item.children.length ? this.renderBlocks(item.children) : nothing
824
+ }</li
825
+ >`,
826
+ )}</ol
827
+ >`;
828
+ }
829
+ if (block.type === "blockquote") {
830
+ return html`<blockquote>${this.renderBlocks(block.blocks)}</blockquote>`;
831
+ }
832
+ if (block.type === "table") {
833
+ return html`
834
+ <div class="table-wrapper">
835
+ <table>
836
+ <thead>
837
+ <tr>
838
+ ${map(block.headers, (header, idx) => {
839
+ const align = block.aligns[idx] ?? "left";
840
+ return html`<th style="text-align: ${align}"
841
+ >${this.renderInline(header)}</th
842
+ >`;
843
+ })}
844
+ </tr>
845
+ </thead>
846
+ <tbody>
847
+ ${map(
848
+ block.rows,
849
+ (row) => html`
850
+ <tr>
851
+ ${map(row, (cell, idx) => {
852
+ const align = block.aligns[idx] ?? "left";
853
+ return html`<td style="text-align: ${align}"
854
+ >${this.renderInline(cell)}</td
855
+ >`;
856
+ })}
857
+ </tr>
858
+ `,
859
+ )}
860
+ </tbody>
861
+ </table>
862
+ </div>
863
+ `;
864
+ }
865
+
866
+ return nothing;
867
+ }
868
+
869
+ private renderMarkdown(source: string) {
870
+ const blocks = this.parseBlocks(source.trim());
871
+ return html`<div class="markdown">${this.renderBlocks(blocks)}</div>`;
872
+ }
873
+
874
+ override render() {
875
+ if (this.content) {
876
+ return this.renderMarkdown(this.content);
877
+ }
878
+ return html`
879
+ <slot></slot>
880
+ `;
881
+ }
882
+ }
883
+
884
+ declare global {
885
+ interface HTMLElementTagNameMap {
886
+ "ai-markdown": AiMarkdown;
887
+ }
888
+ }