From 44fc679140e6978effc6d5e9ca7cf32b4bb70257 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 10:53:29 -0700 Subject: [PATCH 01/11] chore(chat): migrate to @cacheplane/partial-json --- libs/chat/package.json | 4 +++- libs/chat/src/lib/streaming/content-classifier.ts | 2 +- libs/chat/src/lib/streaming/parse-tree-store.spec.ts | 2 +- libs/chat/src/lib/streaming/parse-tree-store.ts | 4 ++-- package-lock.json | 7 +++++++ package.json | 1 + 6 files changed, 15 insertions(+), 5 deletions(-) diff --git a/libs/chat/package.json b/libs/chat/package.json index a28577eb7..cb7e3e050 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -12,6 +12,9 @@ }, "./chat.css": "./chat.css" }, + "dependencies": { + "@cacheplane/partial-json": "^0.1.1" + }, "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0", @@ -20,7 +23,6 @@ "@ngaf/licensing": "*", "@ngaf/render": "*", "@ngaf/a2ui": "*", - "@ngaf/partial-json": "*", "@json-render/core": "^0.16.0", "@langchain/core": "^1.1.33", "rxjs": "~7.8.0", diff --git a/libs/chat/src/lib/streaming/content-classifier.ts b/libs/chat/src/lib/streaming/content-classifier.ts index 5318427c2..a92af95ea 100644 --- a/libs/chat/src/lib/streaming/content-classifier.ts +++ b/libs/chat/src/lib/streaming/content-classifier.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT import { signal, untracked, type Signal } from '@angular/core'; import type { Spec } from '@json-render/core'; -import { createPartialJsonParser } from '@ngaf/partial-json'; +import { createPartialJsonParser } from '@cacheplane/partial-json'; import { createParseTreeStore, type ElementAccumulationState, type ParseTreeStore } from './parse-tree-store'; import { createA2uiMessageParser, type A2uiMessageParser } from '@ngaf/a2ui'; import type { A2uiSurface } from '@ngaf/a2ui'; diff --git a/libs/chat/src/lib/streaming/parse-tree-store.spec.ts b/libs/chat/src/lib/streaming/parse-tree-store.spec.ts index a0efa08ba..1398662d7 100644 --- a/libs/chat/src/lib/streaming/parse-tree-store.spec.ts +++ b/libs/chat/src/lib/streaming/parse-tree-store.spec.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT import { describe, it, expect } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { createPartialJsonParser } from '@ngaf/partial-json'; +import { createPartialJsonParser } from '@cacheplane/partial-json'; import type { Spec } from '@json-render/core'; import { createParseTreeStore } from './parse-tree-store'; diff --git a/libs/chat/src/lib/streaming/parse-tree-store.ts b/libs/chat/src/lib/streaming/parse-tree-store.ts index 1af371161..ef669d665 100644 --- a/libs/chat/src/lib/streaming/parse-tree-store.ts +++ b/libs/chat/src/lib/streaming/parse-tree-store.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT import { signal, type Signal } from '@angular/core'; import type { Spec } from '@json-render/core'; -import type { PartialJsonParser, JsonObjectNode } from '@ngaf/partial-json'; -import { materialize } from '@ngaf/partial-json'; +import type { PartialJsonParser, JsonObjectNode } from '@cacheplane/partial-json'; +import { materialize } from '@cacheplane/partial-json'; export interface ElementAccumulationState { hasType: boolean; diff --git a/package-lock.json b/package-lock.json index fe4d52216..c4ad18605 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@angular/forms": "~21.1.0", "@angular/platform-browser": "~21.1.0", "@angular/router": "~21.1.0", + "@cacheplane/partial-json": "^0.1.1", "@langchain/core": "^1.1.33", "@langchain/langgraph-sdk": "^1.7.4", "@modelcontextprotocol/sdk": "^1.27.1", @@ -6927,6 +6928,12 @@ "dev": true, "license": "(Apache-2.0 WITH LLVM-exception)" }, + "node_modules/@cacheplane/partial-json": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@cacheplane/partial-json/-/partial-json-0.1.1.tgz", + "integrity": "sha512-A6579pz2ad6eMxNALJBhiRyqnqNL2G5uYptbjCqPUqaFX7O3u5NkUTG/7IkEDaWfPk10mR0ZJJXlnAnkLsH+YQ==", + "license": "MIT" + }, "node_modules/@cfworker/json-schema": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", diff --git a/package.json b/package.json index bafc26aeb..9fe149dd6 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "@angular/forms": "~21.1.0", "@angular/platform-browser": "~21.1.0", "@angular/router": "~21.1.0", + "@cacheplane/partial-json": "^0.1.1", "@langchain/core": "^1.1.33", "@langchain/langgraph-sdk": "^1.7.4", "@modelcontextprotocol/sdk": "^1.27.1", From 6ac4e5bbdbb5bb2d65bdc314da982d3e09e109e7 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 11:15:04 -0700 Subject: [PATCH 02/11] chore(partial-json): mark @ngaf/partial-json deprecated in favor of @cacheplane/partial-json --- libs/partial-json/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/partial-json/package.json b/libs/partial-json/package.json index f92ec3400..d17fc00ef 100644 --- a/libs/partial-json/package.json +++ b/libs/partial-json/package.json @@ -1,6 +1,7 @@ { "name": "@ngaf/partial-json", "version": "0.0.2", + "deprecated": "Replaced by @cacheplane/partial-json. No further versions will be published from this package.", "license": "MIT", "repository": { "type": "git", From 64edc629733f198e1934a32b1eea610fdee8d951 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 11:17:10 -0700 Subject: [PATCH 03/11] feat(chat): localStorage-gated stream-trace harness --- libs/chat/src/lib/streaming/trace.spec.ts | 45 +++++++++++++++++++++++ libs/chat/src/lib/streaming/trace.ts | 29 +++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 libs/chat/src/lib/streaming/trace.spec.ts create mode 100644 libs/chat/src/lib/streaming/trace.ts diff --git a/libs/chat/src/lib/streaming/trace.spec.ts b/libs/chat/src/lib/streaming/trace.spec.ts new file mode 100644 index 000000000..b4f98a263 --- /dev/null +++ b/libs/chat/src/lib/streaming/trace.spec.ts @@ -0,0 +1,45 @@ +// libs/chat/src/lib/streaming/trace.spec.ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { isTraceEnabled, trace } from './trace'; + +describe('trace', () => { + let consoleSpy: ReturnType; + let originalWindow: any; + + beforeEach(() => { + originalWindow = (globalThis as any).window; + consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + // Completely restore the original window + (globalThis as any).window = originalWindow; + }); + + it('returns false when no flag is set', () => { + expect(isTraceEnabled()).toBe(false); + }); + + it('returns true when window.__ngafChatTrace === true', () => { + (globalThis as any).window = { ...((globalThis as any).window ?? {}), __ngafChatTrace: true }; + expect(isTraceEnabled()).toBe(true); + }); + + it('returns true when localStorage NGAF_CHAT_STREAM_TRACE === "1"', () => { + const ls = { getItem: (k: string) => (k === 'NGAF_CHAT_STREAM_TRACE' ? '1' : null) }; + (globalThis as any).window = { ...((globalThis as any).window ?? {}), localStorage: ls }; + expect(isTraceEnabled()).toBe(true); + }); + + it('does not call console.debug when disabled', () => { + trace('hello'); + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it('calls console.debug with prefix when enabled', () => { + (globalThis as any).window = { ...((globalThis as any).window ?? {}), __ngafChatTrace: true }; + trace('hello', { foo: 1 }); + expect(consoleSpy).toHaveBeenCalledWith('[ngaf-chat-stream]', 'hello', { foo: 1 }); + }); +}); diff --git a/libs/chat/src/lib/streaming/trace.ts b/libs/chat/src/lib/streaming/trace.ts new file mode 100644 index 000000000..bddf09097 --- /dev/null +++ b/libs/chat/src/lib/streaming/trace.ts @@ -0,0 +1,29 @@ +// libs/chat/src/lib/streaming/trace.ts +// SPDX-License-Identifier: MIT +// +// localStorage / window-flag-gated debug tracer for @ngaf/chat streaming. +// Off by default. Enable via: +// window.__ngafChatTrace = true +// localStorage.NGAF_CHAT_STREAM_TRACE = '1' +// +// All call sites should be guarded with `if (isTraceEnabled())` so the +// argument-collection cost is paid only when tracing is on. + +export function isTraceEnabled(): boolean { + if (typeof globalThis === 'undefined') return false; + const win = (globalThis as { window?: { __ngafChatTrace?: boolean; localStorage?: Storage } }).window; + if (!win) return false; + if (win.__ngafChatTrace === true) return true; + try { + return win.localStorage?.getItem('NGAF_CHAT_STREAM_TRACE') === '1'; + } catch { + return false; + } +} + +export function trace(...args: unknown[]): void { + if (isTraceEnabled()) { + // eslint-disable-next-line no-console + console.debug('[ngaf-chat-stream]', ...args); + } +} From 7fd736f0b83507c85512b176cc6906fe7cc82eb0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 11:20:04 -0700 Subject: [PATCH 04/11] test(chat): failing specs for RAF-batched streaming markdown --- .../streaming-markdown.component.spec.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts diff --git a/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts new file mode 100644 index 000000000..9dce63257 --- /dev/null +++ b/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChatStreamingMdComponent } from './streaming-markdown.component'; +import '../../test-setup'; + +function flushRaf(): Promise { + return new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); +} + +describe('ChatStreamingMdComponent', () => { + let fixture: ComponentFixture; + let component: ChatStreamingMdComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ChatStreamingMdComponent], + }); + fixture = TestBed.createComponent(ChatStreamingMdComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('content', ''); + }); + + it('renders markdown into innerHTML on first content', async () => { + fixture.componentRef.setInput('content', '# Heading'); + fixture.detectChanges(); + await flushRaf(); + const el = fixture.nativeElement as HTMLElement; + expect(el.innerHTML).toContain(' { + fixture.componentRef.setInput('content', '# A'); + fixture.detectChanges(); + fixture.componentRef.setInput('content', '# AB'); + fixture.detectChanges(); + fixture.componentRef.setInput('content', '# ABC'); + fixture.detectChanges(); + await flushRaf(); + const el = fixture.nativeElement as HTMLElement; + expect(el.innerHTML).toContain('ABC'); + }); + + it('handles content shrinking without freezing (regression)', async () => { + fixture.componentRef.setInput('content', '# Long heading'); + fixture.detectChanges(); + await flushRaf(); + fixture.componentRef.setInput('content', '# Short'); + fixture.detectChanges(); + await flushRaf(); + const el = fixture.nativeElement as HTMLElement; + expect(el.innerHTML).toContain('Short'); + expect(el.innerHTML).not.toContain('Long heading'); + }); + + it('cleans up pending RAF on destroy', async () => { + const spy = vi.spyOn(globalThis, 'cancelAnimationFrame'); + fixture.componentRef.setInput('content', '# X'); + fixture.detectChanges(); + fixture.destroy(); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); From e93cf89cd48425499446aa65ce73e72f72b0931d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 11:27:19 -0700 Subject: [PATCH 05/11] refactor(chat): rewrite chat-streaming-md as RAF-batched full reparse --- .../streaming-markdown.component.spec.ts | 95 +++++++++++++------ .../streaming/streaming-markdown.component.ts | 90 +++++++++--------- 2 files changed, 111 insertions(+), 74 deletions(-) diff --git a/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts index 9dce63257..9104c7bcd 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts @@ -1,65 +1,106 @@ // SPDX-License-Identifier: MIT import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ElementRef, Injector, runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { ChatStreamingMdComponent } from './streaming-markdown.component'; import '../../test-setup'; +// Signal-input components can't be exercised via TestBed.createComponent + +// componentRef.setInput() under vitest JIT (Angular's JIT compiler does not +// process signal-input metadata, so setInput throws NG0303 — the same reason +// chat-trace, chat-suggestions, and chat-typing-indicator specs in this +// library avoid template-driven signal inputs). Instead we instantiate the +// component inside an injection context with a real DOM host element and +// drive its input by writing to the InputSignal's underlying signal node. + +function setSignalInput(sig: unknown, value: T): void { + const obj = sig as Record; + const signalSymbol = Object.getOwnPropertySymbols(obj).find( + (s) => s.description === 'SIGNAL', + ); + if (!signalSymbol) throw new Error('Could not find SIGNAL symbol on input'); + const node = obj[signalSymbol] as { + applyValueToInputSignal?: (n: unknown, v: T) => void; + value?: T; + }; + if (typeof node.applyValueToInputSignal === 'function') { + node.applyValueToInputSignal(node, value); + } else { + node.value = value; + } +} + function flushRaf(): Promise { - return new Promise(resolve => { + return new Promise((resolve) => { requestAnimationFrame(() => resolve()); }); } +interface Fixture { + component: ChatStreamingMdComponent; + host: HTMLElement; + destroy: () => void; +} + +function makeFixture(): Fixture { + const host = document.createElement('div'); + document.body.appendChild(host); + TestBed.configureTestingModule({ + providers: [{ provide: ElementRef, useValue: new ElementRef(host) }], + }); + const injector = TestBed.inject(Injector); + let component!: ChatStreamingMdComponent; + runInInjectionContext(injector, () => { + component = new ChatStreamingMdComponent(); + }); + return { + component, + host, + destroy: () => { + TestBed.resetTestingModule(); + host.remove(); + }, + }; +} + describe('ChatStreamingMdComponent', () => { - let fixture: ComponentFixture; - let component: ChatStreamingMdComponent; + let fixture: Fixture; beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ChatStreamingMdComponent], - }); - fixture = TestBed.createComponent(ChatStreamingMdComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('content', ''); + fixture = makeFixture(); + setSignalInput(fixture.component.content, ''); }); it('renders markdown into innerHTML on first content', async () => { - fixture.componentRef.setInput('content', '# Heading'); - fixture.detectChanges(); + setSignalInput(fixture.component.content, '# Heading'); await flushRaf(); - const el = fixture.nativeElement as HTMLElement; + const el = fixture.host; expect(el.innerHTML).toContain(' { - fixture.componentRef.setInput('content', '# A'); - fixture.detectChanges(); - fixture.componentRef.setInput('content', '# AB'); - fixture.detectChanges(); - fixture.componentRef.setInput('content', '# ABC'); - fixture.detectChanges(); + setSignalInput(fixture.component.content, '# A'); + setSignalInput(fixture.component.content, '# AB'); + setSignalInput(fixture.component.content, '# ABC'); await flushRaf(); - const el = fixture.nativeElement as HTMLElement; + const el = fixture.host; expect(el.innerHTML).toContain('ABC'); }); it('handles content shrinking without freezing (regression)', async () => { - fixture.componentRef.setInput('content', '# Long heading'); - fixture.detectChanges(); + setSignalInput(fixture.component.content, '# Long heading'); await flushRaf(); - fixture.componentRef.setInput('content', '# Short'); - fixture.detectChanges(); + setSignalInput(fixture.component.content, '# Short'); await flushRaf(); - const el = fixture.nativeElement as HTMLElement; + const el = fixture.host; expect(el.innerHTML).toContain('Short'); expect(el.innerHTML).not.toContain('Long heading'); }); it('cleans up pending RAF on destroy', async () => { const spy = vi.spyOn(globalThis, 'cancelAnimationFrame'); - fixture.componentRef.setInput('content', '# X'); - fixture.detectChanges(); + setSignalInput(fixture.component.content, '# X'); fixture.destroy(); expect(spy).toHaveBeenCalled(); spy.mockRestore(); diff --git a/libs/chat/src/lib/streaming/streaming-markdown.component.ts b/libs/chat/src/lib/streaming/streaming-markdown.component.ts index 16685b530..ee5f9b5bb 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.component.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.component.ts @@ -1,24 +1,26 @@ +// libs/chat/src/lib/streaming/streaming-markdown.component.ts // SPDX-License-Identifier: MIT import { Component, - input, - effect, + ChangeDetectionStrategy, + DestroyRef, ElementRef, + effect, inject, - ChangeDetectionStrategy, + input, untracked, } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; -import { createStreamingMarkdownRenderer, type StreamingMarkdownRenderer } from './streaming-markdown'; import { renderMarkdownToString } from './markdown-render'; +import { isTraceEnabled, trace } from './trace'; /** - * Renders markdown content using a streaming append-only DOM renderer - * during active streaming, then switches to a full marked.parse() render - * once the content stabilises (no new content for a frame). + * Renders markdown content via marked.parse + sanitized innerHTML, coalesced + * to one render per animation frame. No incremental renderer state, no delta + * math — just write the latest content. Idempotent within a frame. * - * This eliminates the jank caused by full innerHTML replacement on every - * SSE token — the streaming renderer only appends new DOM nodes. + * The `streaming` input is informational (it can drive parent-level decisions + * like showing a caret), but doesn't change the render strategy here. */ @Component({ selector: 'chat-streaming-md', @@ -28,55 +30,49 @@ import { renderMarkdownToString } from './markdown-render'; styles: `:host { display: block; }`, }) export class ChatStreamingMdComponent { - private readonly el = inject(ElementRef).nativeElement as HTMLElement; - private readonly sanitizer = inject(DomSanitizer); - - /** Full markdown content (updated on every partial) */ readonly content = input.required(); - /** Whether the parent stream is still loading */ readonly streaming = input(false); - private renderer: StreamingMarkdownRenderer | null = null; - private lastContent = ''; - private finalised = false; + private readonly el = inject(ElementRef).nativeElement as HTMLElement; + private readonly sanitizer = inject(DomSanitizer); + private readonly destroyRef = inject(DestroyRef); + + private rafHandle = 0; + private pendingContent = ''; constructor() { effect(() => { - const content = this.content(); - const isStreaming = this.streaming(); - - untracked(() => this.render(content, isStreaming)); + const next = this.content(); + untracked(() => this.schedule(next)); }); - } - - private render(content: string, isStreaming: boolean): void { - if (!content) return; - if (isStreaming) { - // Streaming mode: use append-only renderer with deltas - if (!this.renderer) { - this.renderer = createStreamingMarkdownRenderer(); - this.el.textContent = ''; - this.el.appendChild(this.renderer.container); - this.finalised = false; + this.destroyRef.onDestroy(() => { + if (this.rafHandle) { + cancelAnimationFrame(this.rafHandle); + this.rafHandle = 0; } + }); + } - // Compute delta from last known content - const delta = content.slice(this.lastContent.length); - this.lastContent = content; - - if (delta) { - this.renderer.push(delta); - } - } else { - // Stream complete: do a single high-quality marked.parse() render - if (!this.finalised || content !== this.lastContent) { - this.lastContent = content; - this.finalised = true; - this.renderer = null; + private schedule(content: string): void { + this.pendingContent = content; + if (this.rafHandle !== 0) return; + this.rafHandle = requestAnimationFrame(() => { + this.rafHandle = 0; + this.flush(); + }); + } - this.el.innerHTML = renderMarkdownToString(content, this.sanitizer); - } + private flush(): void { + const content = this.pendingContent; + if (!content) { + this.el.innerHTML = ''; + return; + } + const start = isTraceEnabled() ? performance.now() : 0; + this.el.innerHTML = renderMarkdownToString(content, this.sanitizer); + if (isTraceEnabled()) { + trace('streaming-md.flush', { contentLength: content.length, durationMs: performance.now() - start }); } } } From d39f401cc9750ea355a319ec3d489a85431f54dd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 11:28:58 -0700 Subject: [PATCH 06/11] refactor(chat): delete bespoke append-only markdown renderer --- .../lib/streaming/streaming-markdown.spec.ts | 405 --------------- .../src/lib/streaming/streaming-markdown.ts | 485 ------------------ 2 files changed, 890 deletions(-) delete mode 100644 libs/chat/src/lib/streaming/streaming-markdown.spec.ts delete mode 100644 libs/chat/src/lib/streaming/streaming-markdown.ts diff --git a/libs/chat/src/lib/streaming/streaming-markdown.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.spec.ts deleted file mode 100644 index cb46402da..000000000 --- a/libs/chat/src/lib/streaming/streaming-markdown.spec.ts +++ /dev/null @@ -1,405 +0,0 @@ -// SPDX-License-Identifier: MIT -import { describe, it, expect, beforeEach } from 'vitest'; -import { - createStreamingMarkdownRenderer, - type StreamingMarkdownRenderer, -} from './streaming-markdown'; - -describe('StreamingMarkdownRenderer', () => { - let renderer: StreamingMarkdownRenderer; - - beforeEach(() => { - renderer = createStreamingMarkdownRenderer(); - }); - - describe('container', () => { - it('should have class chat-md', () => { - expect(renderer.container.className).toBe('chat-md'); - }); - - it('should be a div element', () => { - expect(renderer.container.tagName).toBe('DIV'); - }); - }); - - describe('plain text renders as paragraph', () => { - it('should wrap plain text in a

tag', () => { - renderer.push('Hello world'); - renderer.finish(); - const p = renderer.container.querySelector('p'); - expect(p).not.toBeNull(); - expect(p!.textContent).toBe('Hello world'); - }); - - it('should create separate paragraphs for text separated by blank lines', () => { - renderer.push('First paragraph\n\nSecond paragraph'); - renderer.finish(); - const paragraphs = renderer.container.querySelectorAll('p'); - expect(paragraphs).toHaveLength(2); - expect(paragraphs[0].textContent).toBe('First paragraph'); - expect(paragraphs[1].textContent).toBe('Second paragraph'); - }); - - it('should join consecutive non-blank lines in the same paragraph', () => { - renderer.push('Line one\nLine two'); - renderer.finish(); - const paragraphs = renderer.container.querySelectorAll('p'); - expect(paragraphs).toHaveLength(1); - expect(paragraphs[0].textContent).toBe('Line one Line two'); - }); - }); - - describe('bold and italic inline formatting', () => { - it('should render **text** as ', () => { - renderer.push('This is **bold** text'); - renderer.finish(); - const strong = renderer.container.querySelector('strong'); - expect(strong).not.toBeNull(); - expect(strong!.textContent).toBe('bold'); - }); - - it('should render *text* as ', () => { - renderer.push('This is *italic* text'); - renderer.finish(); - const em = renderer.container.querySelector('em'); - expect(em).not.toBeNull(); - expect(em!.textContent).toBe('italic'); - }); - - it('should handle bold and italic in the same line', () => { - renderer.push('**bold** and *italic*'); - renderer.finish(); - expect(renderer.container.querySelector('strong')!.textContent).toBe('bold'); - expect(renderer.container.querySelector('em')!.textContent).toBe('italic'); - }); - - it('should handle nested bold inside text', () => { - renderer.push('Start **middle** end'); - renderer.finish(); - const p = renderer.container.querySelector('p')!; - expect(p.innerHTML).toBe('Start middle end'); - }); - }); - - describe('headers (h1-h4)', () => { - it('should render # as h1', () => { - renderer.push('# Heading 1'); - renderer.finish(); - const h1 = renderer.container.querySelector('h1'); - expect(h1).not.toBeNull(); - expect(h1!.textContent).toBe('Heading 1'); - }); - - it('should render ## as h2', () => { - renderer.push('## Heading 2'); - renderer.finish(); - const h2 = renderer.container.querySelector('h2'); - expect(h2).not.toBeNull(); - expect(h2!.textContent).toBe('Heading 2'); - }); - - it('should render ### as h3', () => { - renderer.push('### Heading 3'); - renderer.finish(); - const h3 = renderer.container.querySelector('h3'); - expect(h3).not.toBeNull(); - expect(h3!.textContent).toBe('Heading 3'); - }); - - it('should render #### as h4', () => { - renderer.push('#### Heading 4'); - renderer.finish(); - const h4 = renderer.container.querySelector('h4'); - expect(h4).not.toBeNull(); - expect(h4!.textContent).toBe('Heading 4'); - }); - - it('should support inline formatting in headers', () => { - renderer.push('## A **bold** heading'); - renderer.finish(); - const h2 = renderer.container.querySelector('h2')!; - expect(h2.querySelector('strong')!.textContent).toBe('bold'); - }); - }); - - describe('unordered and ordered lists', () => { - it('should render - items as