diff --git a/libs/chat/ng-package.json b/libs/chat/ng-package.json index 1ea1fd6ef..a34182b8d 100644 --- a/libs/chat/ng-package.json +++ b/libs/chat/ng-package.json @@ -4,7 +4,7 @@ "lib": { "entryFile": "src/public-api.ts" }, - "allowedNonPeerDependencies": ["@cacheplane/partial-json"], + "allowedNonPeerDependencies": ["@cacheplane/partial-json", "@cacheplane/partial-markdown"], "assets": [ { "input": "src/lib/styles", "glob": "chat.css", "output": "." } ] diff --git a/libs/chat/package.json b/libs/chat/package.json index 2cea298c1..77f58e75a 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/chat", - "version": "0.0.19", + "version": "0.0.20", "exports": { ".": { "types": "./index.d.ts", @@ -13,7 +13,8 @@ "./chat.css": "./chat.css" }, "dependencies": { - "@cacheplane/partial-json": "^0.1.1" + "@cacheplane/partial-json": "^0.1.1", + "@cacheplane/partial-markdown": "^0.1.0" }, "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", diff --git a/libs/chat/src/lib/markdown/cacheplane-markdown-views.spec.ts b/libs/chat/src/lib/markdown/cacheplane-markdown-views.spec.ts new file mode 100644 index 000000000..7245067c2 --- /dev/null +++ b/libs/chat/src/lib/markdown/cacheplane-markdown-views.spec.ts @@ -0,0 +1,33 @@ +// libs/chat/src/lib/markdown/cacheplane-markdown-views.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { cacheplaneMarkdownViews } from './cacheplane-markdown-views'; + +describe('cacheplaneMarkdownViews', () => { + it('registers all 18 v0.1 markdown node types', () => { + expect(Object.keys(cacheplaneMarkdownViews).sort()).toEqual([ + 'autolink', + 'blockquote', + 'code-block', + 'document', + 'emphasis', + 'hard-break', + 'heading', + 'image', + 'inline-code', + 'link', + 'list', + 'list-item', + 'paragraph', + 'soft-break', + 'strikethrough', + 'strong', + 'text', + 'thematic-break', + ]); + }); + + it('is a frozen registry (immutable at runtime)', () => { + expect(Object.isFrozen(cacheplaneMarkdownViews)).toBe(true); + }); +}); diff --git a/libs/chat/src/lib/markdown/cacheplane-markdown-views.ts b/libs/chat/src/lib/markdown/cacheplane-markdown-views.ts new file mode 100644 index 000000000..db45f2b88 --- /dev/null +++ b/libs/chat/src/lib/markdown/cacheplane-markdown-views.ts @@ -0,0 +1,49 @@ +// libs/chat/src/lib/markdown/cacheplane-markdown-views.ts +// SPDX-License-Identifier: MIT +import { views, type ViewRegistry } from '@ngaf/render'; +import { MarkdownDocumentComponent } from './views/markdown-document.component'; +import { MarkdownParagraphComponent } from './views/markdown-paragraph.component'; +import { MarkdownHeadingComponent } from './views/markdown-heading.component'; +import { MarkdownBlockquoteComponent } from './views/markdown-blockquote.component'; +import { MarkdownListComponent } from './views/markdown-list.component'; +import { MarkdownListItemComponent } from './views/markdown-list-item.component'; +import { MarkdownCodeBlockComponent } from './views/markdown-code-block.component'; +import { MarkdownThematicBreakComponent } from './views/markdown-thematic-break.component'; +import { MarkdownTextComponent } from './views/markdown-text.component'; +import { MarkdownEmphasisComponent } from './views/markdown-emphasis.component'; +import { MarkdownStrongComponent } from './views/markdown-strong.component'; +import { MarkdownStrikethroughComponent } from './views/markdown-strikethrough.component'; +import { MarkdownInlineCodeComponent } from './views/markdown-inline-code.component'; +import { MarkdownLinkComponent } from './views/markdown-link.component'; +import { MarkdownAutolinkComponent } from './views/markdown-autolink.component'; +import { MarkdownImageComponent } from './views/markdown-image.component'; +import { MarkdownSoftBreakComponent } from './views/markdown-soft-break.component'; +import { MarkdownHardBreakComponent } from './views/markdown-hard-break.component'; + +/** + * Default view registry consumed by . Maps every + * MarkdownNode.type emitted by @cacheplane/partial-markdown@0.1 to its + * corresponding Angular component. + * + * Override per-node-type via `withViews(cacheplaneMarkdownViews, { … })`. + */ +export const cacheplaneMarkdownViews: ViewRegistry = views({ + 'document': MarkdownDocumentComponent, + 'paragraph': MarkdownParagraphComponent, + 'heading': MarkdownHeadingComponent, + 'blockquote': MarkdownBlockquoteComponent, + 'list': MarkdownListComponent, + 'list-item': MarkdownListItemComponent, + 'code-block': MarkdownCodeBlockComponent, + 'thematic-break': MarkdownThematicBreakComponent, + 'text': MarkdownTextComponent, + 'emphasis': MarkdownEmphasisComponent, + 'strong': MarkdownStrongComponent, + 'strikethrough': MarkdownStrikethroughComponent, + 'inline-code': MarkdownInlineCodeComponent, + 'link': MarkdownLinkComponent, + 'autolink': MarkdownAutolinkComponent, + 'image': MarkdownImageComponent, + 'soft-break': MarkdownSoftBreakComponent, + 'hard-break': MarkdownHardBreakComponent, +}); diff --git a/libs/chat/src/lib/markdown/markdown-children.component.spec.ts b/libs/chat/src/lib/markdown/markdown-children.component.spec.ts new file mode 100644 index 000000000..f8b427322 --- /dev/null +++ b/libs/chat/src/lib/markdown/markdown-children.component.spec.ts @@ -0,0 +1,110 @@ +// libs/chat/src/lib/markdown/markdown-children.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal, Type, input } from '@angular/core'; +import { views, type ViewRegistry } from '@ngaf/render'; +import type { MarkdownNode, MarkdownParagraphNode, MarkdownTextNode } from '@cacheplane/partial-markdown'; +import { MarkdownChildrenComponent } from './markdown-children.component'; +import { MARKDOWN_VIEW_REGISTRY } from './markdown-view-registry'; + +@Component({ + standalone: true, + selector: 'chat-md-text-stub', + template: `{{ node().text }}`, +}) +class TextStub { + readonly node = input.required(); +} + +@Component({ + standalone: true, + selector: 'chat-md-paragraph-stub', + imports: [MarkdownChildrenComponent], + template: `

`, +}) +class ParagraphStub { + readonly node = input.required(); +} + +@Component({ + standalone: true, + imports: [MarkdownChildrenComponent], + template: ``, +}) +class HostComponent { + parent = signal({ + id: 0, type: 'paragraph', status: 'complete', + parent: null, index: null, + children: [], + } as MarkdownParagraphNode); +} + +describe('MarkdownChildrenComponent', () => { + let registry: ViewRegistry; + + beforeEach(() => { + registry = views({ + 'paragraph': ParagraphStub as Type, + 'text': TextStub as Type, + }); + TestBed.configureTestingModule({ + imports: [HostComponent], + providers: [{ provide: MARKDOWN_VIEW_REGISTRY, useValue: registry }], + }); + }); + + it('renders nothing when parent has no children', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelectorAll('[data-test]')).toHaveLength(0); + }); + + it('dispatches each child through the registry', () => { + const fixture = TestBed.createComponent(HostComponent); + const text1: MarkdownTextNode = { + id: 1, type: 'text', status: 'complete', + parent: null, index: 0, text: 'hi', + }; + const text2: MarkdownTextNode = { + id: 2, type: 'text', status: 'complete', + parent: null, index: 1, text: ' there', + }; + fixture.componentInstance.parent.set({ + id: 0, type: 'paragraph', status: 'complete', + parent: null, index: null, + children: [text1, text2], + } as MarkdownParagraphNode); + fixture.detectChanges(); + const spans = fixture.nativeElement.querySelectorAll('[data-test="text"]'); + expect(spans).toHaveLength(2); + expect(spans[0].textContent).toBe('hi'); + expect(spans[1].textContent).toBe(' there'); + }); + + it('skips nodes whose type is not in the registry', () => { + const fixture = TestBed.createComponent(HostComponent); + const unknownNode = { + id: 1, type: 'mystery', status: 'complete', + parent: null, index: 0, + } as unknown as MarkdownNode; + fixture.componentInstance.parent.set({ + id: 0, type: 'paragraph', status: 'complete', + parent: null, index: null, + children: [unknownNode], + } as MarkdownParagraphNode); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelectorAll('[data-test]')).toHaveLength(0); + }); + + it('returns empty children array for non-container nodes', () => { + const fixture = TestBed.createComponent(HostComponent); + const text: MarkdownTextNode = { + id: 0, type: 'text', status: 'complete', + parent: null, index: null, text: 'hello', + }; + fixture.componentInstance.parent.set(text as unknown as MarkdownNode); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelectorAll('[data-test]')).toHaveLength(0); + }); +}); diff --git a/libs/chat/src/lib/markdown/markdown-children.component.ts b/libs/chat/src/lib/markdown/markdown-children.component.ts new file mode 100644 index 000000000..1a5ceffa8 --- /dev/null +++ b/libs/chat/src/lib/markdown/markdown-children.component.ts @@ -0,0 +1,53 @@ +// libs/chat/src/lib/markdown/markdown-children.component.ts +// SPDX-License-Identifier: MIT +import { + Component, + ChangeDetectionStrategy, + inject, + input, + computed, + Type, +} from '@angular/core'; +import { NgComponentOutlet } from '@angular/common'; +import type { ViewRegistry } from '@ngaf/render'; +import type { MarkdownNode } from '@cacheplane/partial-markdown'; +import { MARKDOWN_VIEW_REGISTRY } from './markdown-view-registry'; + +/** + * Recursively dispatches a parent node's children through the markdown view + * registry. Each child's `type` is looked up in the registry; the resolved + * component is rendered with `[node]` bound to that child. + * + * Identity-preserving: `track $any(child)` keys on the JS reference of the + * child node. Because @cacheplane/partial-markdown preserves node identity + * across pushes, unchanged subtrees never re-render. + */ +@Component({ + selector: 'chat-md-children', + standalone: true, + imports: [NgComponentOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (child of children(); track $any(child)) { + @let comp = resolve(child); + @if (comp) { + + } + } + `, +}) +export class MarkdownChildrenComponent { + readonly parent = input.required(); + private readonly registry = inject(MARKDOWN_VIEW_REGISTRY); + + protected readonly children = computed(() => { + const p = this.parent(); + return 'children' in p && Array.isArray((p as { children?: MarkdownNode[] }).children) + ? ((p as { children: MarkdownNode[] }).children as readonly MarkdownNode[]) + : []; + }); + + protected resolve(child: MarkdownNode): Type | null { + return this.registry[child.type] ?? null; + } +} diff --git a/libs/chat/src/lib/markdown/markdown-view-registry.ts b/libs/chat/src/lib/markdown/markdown-view-registry.ts new file mode 100644 index 000000000..906cae0bf --- /dev/null +++ b/libs/chat/src/lib/markdown/markdown-view-registry.ts @@ -0,0 +1,18 @@ +// libs/chat/src/lib/markdown/markdown-view-registry.ts +// SPDX-License-Identifier: MIT +import { InjectionToken } from '@angular/core'; +import type { ViewRegistry } from '@ngaf/render'; + +/** + * DI token for the markdown view registry consumed by + * and . Maps MarkdownNode.type strings (e.g. "paragraph", + * "heading") to Angular components that render that node type. + * + * `` provides the runtime registry on its component-level + * injector — either the consumer-supplied [viewRegistry] input, or + * `cacheplaneMarkdownViews` (the default) — so descendant + * components resolve the right components for each node. + */ +export const MARKDOWN_VIEW_REGISTRY = new InjectionToken( + 'MARKDOWN_VIEW_REGISTRY', +); diff --git a/libs/chat/src/lib/markdown/views/markdown-autolink.component.ts b/libs/chat/src/lib/markdown/views/markdown-autolink.component.ts new file mode 100644 index 000000000..60ac5af6c --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-autolink.component.ts @@ -0,0 +1,14 @@ +// libs/chat/src/lib/markdown/views/markdown-autolink.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownAutolinkNode } from '@cacheplane/partial-markdown'; + +@Component({ + selector: 'chat-md-autolink', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: `{{ node().url }}`, +}) +export class MarkdownAutolinkComponent { + readonly node = input.required(); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-blockquote.component.ts b/libs/chat/src/lib/markdown/views/markdown-blockquote.component.ts new file mode 100644 index 000000000..6a742572c --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-blockquote.component.ts @@ -0,0 +1,16 @@ +// libs/chat/src/lib/markdown/views/markdown-blockquote.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownBlockquoteNode } from '@cacheplane/partial-markdown'; +import { MarkdownChildrenComponent } from '../markdown-children.component'; + +@Component({ + selector: 'chat-md-blockquote', + standalone: true, + imports: [MarkdownChildrenComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: `
`, +}) +export class MarkdownBlockquoteComponent { + readonly node = input.required(); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-code-block.component.spec.ts b/libs/chat/src/lib/markdown/views/markdown-code-block.component.spec.ts new file mode 100644 index 000000000..9a114f503 --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-code-block.component.spec.ts @@ -0,0 +1,48 @@ +// libs/chat/src/lib/markdown/views/markdown-code-block.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import type { MarkdownCodeBlockNode } from '@cacheplane/partial-markdown'; +import { MarkdownCodeBlockComponent } from './markdown-code-block.component'; + +@Component({ + standalone: true, + imports: [MarkdownCodeBlockComponent], + template: ``, +}) +class HostComponent { + node = signal({ + id: 0, type: 'code-block', status: 'complete', + parent: null, index: null, + variant: 'fenced', language: 'ts', text: 'const x = 1;', + }); +} + +describe('MarkdownCodeBlockComponent', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [HostComponent] })); + + it('renders
 with the text', () => {
+    const fixture = TestBed.createComponent(HostComponent);
+    fixture.detectChanges();
+    const code = fixture.nativeElement.querySelector('pre code') as HTMLElement;
+    expect(code.textContent).toBe('const x = 1;');
+  });
+
+  it('sets language-XX class when language is provided', () => {
+    const fixture = TestBed.createComponent(HostComponent);
+    fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('pre code')?.className).toBe('language-ts');
+  });
+
+  it('omits language class when language is empty', () => {
+    const fixture = TestBed.createComponent(HostComponent);
+    fixture.componentInstance.node.set({
+      id: 0, type: 'code-block', status: 'complete',
+      parent: null, index: null,
+      variant: 'fenced', language: '', text: 'plain',
+    });
+    fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('pre code')?.className).toBe('');
+  });
+});
diff --git a/libs/chat/src/lib/markdown/views/markdown-code-block.component.ts b/libs/chat/src/lib/markdown/views/markdown-code-block.component.ts
new file mode 100644
index 000000000..b299d8006
--- /dev/null
+++ b/libs/chat/src/lib/markdown/views/markdown-code-block.component.ts
@@ -0,0 +1,18 @@
+// libs/chat/src/lib/markdown/views/markdown-code-block.component.ts
+// SPDX-License-Identifier: MIT
+import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';
+import type { MarkdownCodeBlockNode } from '@cacheplane/partial-markdown';
+
+@Component({
+  selector: 'chat-md-code-block',
+  standalone: true,
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
{{ node().text }}
`, +}) +export class MarkdownCodeBlockComponent { + readonly node = input.required(); + protected readonly languageClass = computed(() => { + const lang = this.node().language; + return lang ? `language-${lang}` : ''; + }); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-document.component.ts b/libs/chat/src/lib/markdown/views/markdown-document.component.ts new file mode 100644 index 000000000..ecc3d9d0e --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-document.component.ts @@ -0,0 +1,16 @@ +// libs/chat/src/lib/markdown/views/markdown-document.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownDocumentNode } from '@cacheplane/partial-markdown'; +import { MarkdownChildrenComponent } from '../markdown-children.component'; + +@Component({ + selector: 'chat-md-document', + standalone: true, + imports: [MarkdownChildrenComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, +}) +export class MarkdownDocumentComponent { + readonly node = input.required(); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-emphasis.component.ts b/libs/chat/src/lib/markdown/views/markdown-emphasis.component.ts new file mode 100644 index 000000000..a77b14419 --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-emphasis.component.ts @@ -0,0 +1,16 @@ +// libs/chat/src/lib/markdown/views/markdown-emphasis.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownEmphasisNode } from '@cacheplane/partial-markdown'; +import { MarkdownChildrenComponent } from '../markdown-children.component'; + +@Component({ + selector: 'chat-md-emphasis', + standalone: true, + imports: [MarkdownChildrenComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, +}) +export class MarkdownEmphasisComponent { + readonly node = input.required(); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-hard-break.component.ts b/libs/chat/src/lib/markdown/views/markdown-hard-break.component.ts new file mode 100644 index 000000000..b0d5b491d --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-hard-break.component.ts @@ -0,0 +1,14 @@ +// libs/chat/src/lib/markdown/views/markdown-hard-break.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownHardBreakNode } from '@cacheplane/partial-markdown'; + +@Component({ + selector: 'chat-md-hard-break', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: `
`, +}) +export class MarkdownHardBreakComponent { + readonly node = input.required(); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-heading.component.spec.ts b/libs/chat/src/lib/markdown/views/markdown-heading.component.spec.ts new file mode 100644 index 000000000..d41f044bf --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-heading.component.spec.ts @@ -0,0 +1,56 @@ +// libs/chat/src/lib/markdown/views/markdown-heading.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import { views } from '@ngaf/render'; +import type { MarkdownHeadingNode, MarkdownTextNode } from '@cacheplane/partial-markdown'; +import { MarkdownHeadingComponent } from './markdown-heading.component'; +import { MarkdownTextComponent } from './markdown-text.component'; +import { MARKDOWN_VIEW_REGISTRY } from '../markdown-view-registry'; + +@Component({ + standalone: true, + imports: [MarkdownHeadingComponent], + template: ``, +}) +class HostComponent { + node = signal({ + id: 0, type: 'heading', status: 'complete', + parent: null, index: null, + level: 1, + children: [{ + id: 1, type: 'text', status: 'complete', + parent: null, index: 0, text: 'Title', + } as MarkdownTextNode], + }); +} + +describe('MarkdownHeadingComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HostComponent], + providers: [{ + provide: MARKDOWN_VIEW_REGISTRY, + useValue: views({ 'text': MarkdownTextComponent }), + }], + }); + }); + + for (const level of [1, 2, 3, 4, 5, 6] as const) { + it(`renders an h${level} for level ${level}`, () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.node.set({ + id: 0, type: 'heading', status: 'complete', + parent: null, index: null, + level, + children: [{ + id: 1, type: 'text', status: 'complete', + parent: null, index: 0, text: 'X', + } as MarkdownTextNode], + }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector(`h${level}`)).toBeTruthy(); + }); + } +}); diff --git a/libs/chat/src/lib/markdown/views/markdown-heading.component.ts b/libs/chat/src/lib/markdown/views/markdown-heading.component.ts new file mode 100644 index 000000000..b44d2e041 --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-heading.component.ts @@ -0,0 +1,25 @@ +// libs/chat/src/lib/markdown/views/markdown-heading.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownHeadingNode } from '@cacheplane/partial-markdown'; +import { MarkdownChildrenComponent } from '../markdown-children.component'; + +@Component({ + selector: 'chat-md-heading', + standalone: true, + imports: [MarkdownChildrenComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @switch (node().level) { + @case (1) {

} + @case (2) {

} + @case (3) {

} + @case (4) {

} + @case (5) {
} + @case (6) {
} + } + `, +}) +export class MarkdownHeadingComponent { + readonly node = input.required(); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-image.component.spec.ts b/libs/chat/src/lib/markdown/views/markdown-image.component.spec.ts new file mode 100644 index 000000000..bc184d42a --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-image.component.spec.ts @@ -0,0 +1,48 @@ +// libs/chat/src/lib/markdown/views/markdown-image.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import type { MarkdownImageNode } from '@cacheplane/partial-markdown'; +import { MarkdownImageComponent } from './markdown-image.component'; + +@Component({ + standalone: true, + imports: [MarkdownImageComponent], + template: ``, +}) +class HostComponent { + node = signal({ + id: 0, type: 'image', status: 'complete', + parent: null, index: null, + url: 'https://example.com/x.png', + alt: 'logo', + title: '', + }); +} + +describe('MarkdownImageComponent', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [HostComponent] })); + + it('renders with src/alt', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + const img = fixture.nativeElement.querySelector('img') as HTMLImageElement; + expect(img.getAttribute('src')).toBe('https://example.com/x.png'); + expect(img.getAttribute('alt')).toBe('logo'); + expect(img.getAttribute('title')).toBeNull(); + }); + + it('sets title attribute when present', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.node.set({ + id: 0, type: 'image', status: 'complete', + parent: null, index: null, + url: 'https://example.com/x.png', + alt: 'logo', + title: 'Company logo', + }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('img')?.getAttribute('title')).toBe('Company logo'); + }); +}); diff --git a/libs/chat/src/lib/markdown/views/markdown-image.component.ts b/libs/chat/src/lib/markdown/views/markdown-image.component.ts new file mode 100644 index 000000000..83868f150 --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-image.component.ts @@ -0,0 +1,14 @@ +// libs/chat/src/lib/markdown/views/markdown-image.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownImageNode } from '@cacheplane/partial-markdown'; + +@Component({ + selector: 'chat-md-image', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, +}) +export class MarkdownImageComponent { + readonly node = input.required(); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-inline-code.component.ts b/libs/chat/src/lib/markdown/views/markdown-inline-code.component.ts new file mode 100644 index 000000000..9fb4c77c7 --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-inline-code.component.ts @@ -0,0 +1,14 @@ +// libs/chat/src/lib/markdown/views/markdown-inline-code.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownInlineCodeNode } from '@cacheplane/partial-markdown'; + +@Component({ + selector: 'chat-md-inline-code', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: `{{ node().text }}`, +}) +export class MarkdownInlineCodeComponent { + readonly node = input.required(); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-link.component.spec.ts b/libs/chat/src/lib/markdown/views/markdown-link.component.spec.ts new file mode 100644 index 000000000..0be68dfb6 --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-link.component.spec.ts @@ -0,0 +1,76 @@ +// libs/chat/src/lib/markdown/views/markdown-link.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import { views } from '@ngaf/render'; +import type { MarkdownLinkNode, MarkdownTextNode } from '@cacheplane/partial-markdown'; +import { MarkdownLinkComponent } from './markdown-link.component'; +import { MarkdownTextComponent } from './markdown-text.component'; +import { MARKDOWN_VIEW_REGISTRY } from '../markdown-view-registry'; + +@Component({ + standalone: true, + imports: [MarkdownLinkComponent], + template: ``, +}) +class HostComponent { + node = signal({ + id: 0, type: 'link', status: 'complete', + parent: null, index: null, + url: 'https://example.com', + title: '', + children: [{ + id: 1, type: 'text', status: 'complete', + parent: null, index: 0, text: 'docs', + } as MarkdownTextNode], + }); +} + +describe('MarkdownLinkComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HostComponent], + providers: [{ + provide: MARKDOWN_VIEW_REGISTRY, + useValue: views({ 'text': MarkdownTextComponent }), + }], + }); + }); + + it('renders with href', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + const a = fixture.nativeElement.querySelector('a') as HTMLAnchorElement; + expect(a.getAttribute('href')).toBe('https://example.com'); + }); + + it('renders link text via the registry', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('a')?.textContent?.trim()).toBe('docs'); + }); + + it('omits title attribute when blank', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('a')?.hasAttribute('title')).toBe(false); + }); + + it('Angular sanitizes javascript: URLs', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.node.set({ + id: 0, type: 'link', status: 'complete', + parent: null, index: null, + url: 'javascript:alert(1)', + title: '', + children: [{ + id: 1, type: 'text', status: 'complete', + parent: null, index: 0, text: 'click', + } as MarkdownTextNode], + }); + fixture.detectChanges(); + const href = fixture.nativeElement.querySelector('a')?.getAttribute('href') ?? ''; + expect(href).toMatch(/^unsafe:|^$/); + }); +}); diff --git a/libs/chat/src/lib/markdown/views/markdown-link.component.ts b/libs/chat/src/lib/markdown/views/markdown-link.component.ts new file mode 100644 index 000000000..60f3a84f3 --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-link.component.ts @@ -0,0 +1,16 @@ +// libs/chat/src/lib/markdown/views/markdown-link.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownLinkNode } from '@cacheplane/partial-markdown'; +import { MarkdownChildrenComponent } from '../markdown-children.component'; + +@Component({ + selector: 'chat-md-link', + standalone: true, + imports: [MarkdownChildrenComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, +}) +export class MarkdownLinkComponent { + readonly node = input.required(); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-list-item.component.ts b/libs/chat/src/lib/markdown/views/markdown-list-item.component.ts new file mode 100644 index 000000000..20cc64f33 --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-list-item.component.ts @@ -0,0 +1,16 @@ +// libs/chat/src/lib/markdown/views/markdown-list-item.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownListItemNode } from '@cacheplane/partial-markdown'; +import { MarkdownChildrenComponent } from '../markdown-children.component'; + +@Component({ + selector: 'chat-md-list-item', + standalone: true, + imports: [MarkdownChildrenComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: `
  • `, +}) +export class MarkdownListItemComponent { + readonly node = input.required(); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-list.component.spec.ts b/libs/chat/src/lib/markdown/views/markdown-list.component.spec.ts new file mode 100644 index 000000000..47b15e1ba --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-list.component.spec.ts @@ -0,0 +1,67 @@ +// libs/chat/src/lib/markdown/views/markdown-list.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import { views } from '@ngaf/render'; +import type { MarkdownListNode } from '@cacheplane/partial-markdown'; +import { MarkdownListComponent } from './markdown-list.component'; +import { MarkdownListItemComponent } from './markdown-list-item.component'; +import { MARKDOWN_VIEW_REGISTRY } from '../markdown-view-registry'; + +@Component({ + standalone: true, + imports: [MarkdownListComponent], + template: ``, +}) +class HostComponent { + node = signal({ + id: 0, type: 'list', status: 'complete', + parent: null, index: null, + ordered: false, start: null, tight: true, + children: [], + }); +} + +describe('MarkdownListComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HostComponent], + providers: [{ + provide: MARKDOWN_VIEW_REGISTRY, + useValue: views({ 'list-item': MarkdownListItemComponent }), + }], + }); + }); + + it('renders
      for unordered lists', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('ul')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('ol')).toBeFalsy(); + }); + + it('renders
        for ordered lists', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.node.set({ + id: 0, type: 'list', status: 'complete', + parent: null, index: null, + ordered: true, start: 1, tight: true, + children: [], + }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('ol')).toBeTruthy(); + }); + + it('honors ordered list start attribute when not 1', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.node.set({ + id: 0, type: 'list', status: 'complete', + parent: null, index: null, + ordered: true, start: 5, tight: true, + children: [], + }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('ol')?.getAttribute('start')).toBe('5'); + }); +}); diff --git a/libs/chat/src/lib/markdown/views/markdown-list.component.ts b/libs/chat/src/lib/markdown/views/markdown-list.component.ts new file mode 100644 index 000000000..dd25586b3 --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-list.component.ts @@ -0,0 +1,22 @@ +// libs/chat/src/lib/markdown/views/markdown-list.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownListNode } from '@cacheplane/partial-markdown'; +import { MarkdownChildrenComponent } from '../markdown-children.component'; + +@Component({ + selector: 'chat-md-list', + standalone: true, + imports: [MarkdownChildrenComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (node().ordered) { +
        + } @else { +
        + } + `, +}) +export class MarkdownListComponent { + readonly node = input.required(); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-paragraph.component.ts b/libs/chat/src/lib/markdown/views/markdown-paragraph.component.ts new file mode 100644 index 000000000..1cc92fe1d --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-paragraph.component.ts @@ -0,0 +1,16 @@ +// libs/chat/src/lib/markdown/views/markdown-paragraph.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownParagraphNode } from '@cacheplane/partial-markdown'; +import { MarkdownChildrenComponent } from '../markdown-children.component'; + +@Component({ + selector: 'chat-md-paragraph', + standalone: true, + imports: [MarkdownChildrenComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: `

        `, +}) +export class MarkdownParagraphComponent { + readonly node = input.required(); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-soft-break.component.ts b/libs/chat/src/lib/markdown/views/markdown-soft-break.component.ts new file mode 100644 index 000000000..4d4093094 --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-soft-break.component.ts @@ -0,0 +1,14 @@ +// libs/chat/src/lib/markdown/views/markdown-soft-break.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownSoftBreakNode } from '@cacheplane/partial-markdown'; + +@Component({ + selector: 'chat-md-soft-break', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: `
        `, +}) +export class MarkdownSoftBreakComponent { + readonly node = input.required(); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-strikethrough.component.ts b/libs/chat/src/lib/markdown/views/markdown-strikethrough.component.ts new file mode 100644 index 000000000..91ba42850 --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-strikethrough.component.ts @@ -0,0 +1,16 @@ +// libs/chat/src/lib/markdown/views/markdown-strikethrough.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownStrikethroughNode } from '@cacheplane/partial-markdown'; +import { MarkdownChildrenComponent } from '../markdown-children.component'; + +@Component({ + selector: 'chat-md-strikethrough', + standalone: true, + imports: [MarkdownChildrenComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, +}) +export class MarkdownStrikethroughComponent { + readonly node = input.required(); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-strong.component.ts b/libs/chat/src/lib/markdown/views/markdown-strong.component.ts new file mode 100644 index 000000000..71142def0 --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-strong.component.ts @@ -0,0 +1,16 @@ +// libs/chat/src/lib/markdown/views/markdown-strong.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownStrongNode } from '@cacheplane/partial-markdown'; +import { MarkdownChildrenComponent } from '../markdown-children.component'; + +@Component({ + selector: 'chat-md-strong', + standalone: true, + imports: [MarkdownChildrenComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, +}) +export class MarkdownStrongComponent { + readonly node = input.required(); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-text.component.spec.ts b/libs/chat/src/lib/markdown/views/markdown-text.component.spec.ts new file mode 100644 index 000000000..c97477217 --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-text.component.spec.ts @@ -0,0 +1,40 @@ +// libs/chat/src/lib/markdown/views/markdown-text.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import type { MarkdownTextNode } from '@cacheplane/partial-markdown'; +import { MarkdownTextComponent } from './markdown-text.component'; + +@Component({ + standalone: true, + imports: [MarkdownTextComponent], + template: ``, +}) +class HostComponent { + node = signal({ + id: 0, type: 'text', status: 'complete', + parent: null, index: null, text: 'hello', + }); +} + +describe('MarkdownTextComponent', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [HostComponent] })); + + it('renders the node text', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('chat-md-text')?.textContent).toBe('hello'); + }); + + it('updates when the text changes', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + fixture.componentInstance.node.set({ + id: 0, type: 'text', status: 'streaming', + parent: null, index: null, text: 'hello world', + }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('chat-md-text')?.textContent).toBe('hello world'); + }); +}); diff --git a/libs/chat/src/lib/markdown/views/markdown-text.component.ts b/libs/chat/src/lib/markdown/views/markdown-text.component.ts new file mode 100644 index 000000000..842e23f96 --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-text.component.ts @@ -0,0 +1,14 @@ +// libs/chat/src/lib/markdown/views/markdown-text.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownTextNode } from '@cacheplane/partial-markdown'; + +@Component({ + selector: 'chat-md-text', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: `{{ node().text }}`, +}) +export class MarkdownTextComponent { + readonly node = input.required(); +} diff --git a/libs/chat/src/lib/markdown/views/markdown-thematic-break.component.ts b/libs/chat/src/lib/markdown/views/markdown-thematic-break.component.ts new file mode 100644 index 000000000..ae962d3a2 --- /dev/null +++ b/libs/chat/src/lib/markdown/views/markdown-thematic-break.component.ts @@ -0,0 +1,14 @@ +// libs/chat/src/lib/markdown/views/markdown-thematic-break.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import type { MarkdownThematicBreakNode } from '@cacheplane/partial-markdown'; + +@Component({ + selector: 'chat-md-thematic-break', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: `
        `, +}) +export class MarkdownThematicBreakComponent { + readonly node = input.required(); +} 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 9104c7bcd..d26121f6b 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts @@ -1,108 +1,76 @@ +// libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts // SPDX-License-Identifier: MIT -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { ElementRef, Injector, runInInjectionContext } from '@angular/core'; +import { describe, it, expect, beforeEach } from 'vitest'; import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; 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) => { - 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(); - }, - }; +@Component({ + standalone: true, + imports: [ChatStreamingMdComponent], + template: ``, +}) +class HostComponent { + content = signal(''); + streaming = signal(false); } describe('ChatStreamingMdComponent', () => { - let fixture: Fixture; + beforeEach(() => TestBed.configureTestingModule({ imports: [HostComponent] })); - beforeEach(() => { - fixture = makeFixture(); - setSignalInput(fixture.component.content, ''); + it('renders a heading from markdown', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.content.set('# Heading\n'); + fixture.componentInstance.streaming.set(false); + fixture.detectChanges(); + const h1 = fixture.nativeElement.querySelector('h1'); + expect(h1).toBeTruthy(); + expect(h1.textContent?.trim()).toBe('Heading'); }); - it('renders markdown into innerHTML on first content', async () => { - setSignalInput(fixture.component.content, '# Heading'); - await flushRaf(); - const el = fixture.host; - expect(el.innerHTML).toContain(' { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.content.set('Hello world.\n'); + fixture.componentInstance.streaming.set(false); + fixture.detectChanges(); + const p = fixture.nativeElement.querySelector('p'); + expect(p).toBeTruthy(); + expect(p.textContent?.trim()).toBe('Hello world.'); }); - it('coalesces multiple updates into one render per frame', async () => { - setSignalInput(fixture.component.content, '# A'); - setSignalInput(fixture.component.content, '# AB'); - setSignalInput(fixture.component.content, '# ABC'); - await flushRaf(); - const el = fixture.host; - expect(el.innerHTML).toContain('ABC'); + it('updates rendered output when content changes (shrink)', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.content.set('# Long heading\n'); + fixture.componentInstance.streaming.set(false); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('h1')?.textContent?.trim()).toBe('Long heading'); + + // Content shrinks — component resets and re-parses from scratch + fixture.componentInstance.content.set('# Short\n'); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('h1')?.textContent?.trim()).toBe('Short'); }); - it('handles content shrinking without freezing (regression)', async () => { - setSignalInput(fixture.component.content, '# Long heading'); - await flushRaf(); - setSignalInput(fixture.component.content, '# Short'); - await flushRaf(); - const el = fixture.host; - expect(el.innerHTML).toContain('Short'); - expect(el.innerHTML).not.toContain('Long heading'); + it('renders multiple paragraphs when content extends the prior prefix', () => { + const fixture = TestBed.createComponent(HostComponent); + // Start with one paragraph (non-streaming — this is the common finalized state) + fixture.componentInstance.content.set('First.\n\nSecond.\n\n'); + fixture.componentInstance.streaming.set(false); + fixture.detectChanges(); + + const ps = fixture.nativeElement.querySelectorAll('p'); + expect(ps.length).toBe(2); + expect(ps[0].textContent?.trim()).toBe('First.'); + expect(ps[1].textContent?.trim()).toBe('Second.'); }); - it('cleans up pending RAF on destroy', async () => { - const spy = vi.spyOn(globalThis, 'cancelAnimationFrame'); - setSignalInput(fixture.component.content, '# X'); - fixture.destroy(); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); + it('renders nothing when content is empty', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.content.set(''); + fixture.componentInstance.streaming.set(false); + fixture.detectChanges(); + // No block-level elements should be present + expect(fixture.nativeElement.querySelector('p')).toBeNull(); + expect(fixture.nativeElement.querySelector('h1')).toBeNull(); }); }); diff --git a/libs/chat/src/lib/streaming/streaming-markdown.component.ts b/libs/chat/src/lib/streaming/streaming-markdown.component.ts index abb7839ad..e7f98efed 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.component.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.component.ts @@ -3,86 +3,101 @@ import { Component, ChangeDetectionStrategy, - DestroyRef, - ElementRef, ViewEncapsulation, - effect, - inject, + computed, input, - untracked, } from '@angular/core'; -import { DomSanitizer } from '@angular/platform-browser'; -import { renderMarkdownToString } from './markdown-render'; -import { isTraceEnabled, trace } from './trace'; +import { + createPartialMarkdownParser, + materialize, + type MarkdownDocumentNode, + type PartialMarkdownParser, +} from '@cacheplane/partial-markdown'; +import type { ViewRegistry } from '@ngaf/render'; import { CHAT_MARKDOWN_STYLES } from '../styles/chat-markdown.styles'; +import { MARKDOWN_VIEW_REGISTRY } from '../markdown/markdown-view-registry'; +import { MarkdownChildrenComponent } from '../markdown/markdown-children.component'; +import { cacheplaneMarkdownViews } from '../markdown/cacheplane-markdown-views'; /** - * 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. + * Renders streaming markdown by walking a @cacheplane/partial-markdown AST + * through @ngaf/render's view registry. + * + * Reactivity model: the live `parser.root` keeps a stable JS reference + * across pushes (partial-markdown's identity guarantee). To make Angular + * signals propagate downstream when the underlying tree changes, we surface + * a materialized snapshot via `materialize()`. The snapshot shares + * structurally — unchanged subtrees keep the SAME reference, and any + * descendant change yields a NEW root reference. This lets Angular's + * `Object.is` equality check both detect changes (root reference differs) + * and short-circuit unchanged subtrees (per-node references stable). * - * The `streaming` input is informational (it can drive parent-level decisions - * like showing a caret), but doesn't change the render strategy here. + * Override per-node-type renderers via the `[viewRegistry]` input or by + * supplying a different `MARKDOWN_VIEW_REGISTRY` provider in the injector + * tree. */ @Component({ selector: 'chat-streaming-md', standalone: true, + imports: [MarkdownChildrenComponent], changeDetection: ChangeDetectionStrategy.OnPush, - // Disable emulated view encapsulation. The component sets its content via - // `innerHTML` (Angular's sanitized markdown render), so the resulting DOM - // nodes never carry the `_ngcontent-xxx` attribute that emulated styles - // require to match descendants. Without this, `chat-streaming-md ul` and - // friends in CHAT_MARKDOWN_STYLES never apply, and bullets/headings/code - // blocks render unstyled. We scope every selector to `chat-streaming-md` - // in CHAT_MARKDOWN_STYLES so the rules don't leak globally. encapsulation: ViewEncapsulation.None, - template: '', styles: CHAT_MARKDOWN_STYLES, + template: ` + @if (root(); as r) { + + } + `, + providers: [ + { + provide: MARKDOWN_VIEW_REGISTRY, + useFactory: (host: ChatStreamingMdComponent) => host.resolvedRegistry(), + deps: [ChatStreamingMdComponent], + }, + ], }) export class ChatStreamingMdComponent { - readonly content = input.required(); + readonly content = input(''); readonly streaming = input(false); + readonly viewRegistry = input(undefined); - private readonly el = inject(ElementRef).nativeElement as HTMLElement; - private readonly sanitizer = inject(DomSanitizer); - private readonly destroyRef = inject(DestroyRef); - - private rafHandle = 0; - private pendingContent = ''; + readonly resolvedRegistry = computed( + () => this.viewRegistry() ?? cacheplaneMarkdownViews, + ); - constructor() { - effect(() => { - const next = this.content(); - untracked(() => this.schedule(next)); - }); + // Parser instance is rebuilt only when content diverges from the prior + // prefix (rare). For the common streaming case where content extends the + // prior content, we push the delta and reuse the existing parser tree. + private parser: PartialMarkdownParser = createPartialMarkdownParser(); + private prior = ''; + private finished = false; - this.destroyRef.onDestroy(() => { - if (this.rafHandle) { - cancelAnimationFrame(this.rafHandle); - this.rafHandle = 0; + readonly root = computed(() => { + const c = this.content(); + const isStreaming = this.streaming(); + if (c !== this.prior) { + if (c.startsWith(this.prior)) { + this.parser.push(c.slice(this.prior.length)); + } else { + // Content shrank or diverged — reset. + this.parser = createPartialMarkdownParser(); + this.finished = false; + if (c.length > 0) this.parser.push(c); } - }); - } - - private schedule(content: string): void { - this.pendingContent = content; - if (this.rafHandle !== 0) return; - this.rafHandle = requestAnimationFrame(() => { - this.rafHandle = 0; - this.flush(); - }); - } - - 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 }); + if (!isStreaming && !this.finished) { + this.parser.finish(); + this.finished = true; + } + this.prior = c; + } else if (!isStreaming && !this.finished) { + // Streaming flipped to false without new content; ensure parser is finalized. + this.parser.finish(); + this.finished = true; } - } + // Materialize for Angular reactivity: produces a NEW root reference when + // any descendant subtree changed; same reference when nothing changed + // (structural sharing). This is what makes signal-based CD propagate + // downstream changes despite the parser preserving identity. + return materialize(this.parser.root) as MarkdownDocumentNode | null; + }); } diff --git a/libs/chat/src/lib/streaming/streaming-markdown.identity.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.identity.spec.ts new file mode 100644 index 000000000..5eca17256 --- /dev/null +++ b/libs/chat/src/lib/streaming/streaming-markdown.identity.spec.ts @@ -0,0 +1,49 @@ +// libs/chat/src/lib/streaming/streaming-markdown.identity.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import { ChatStreamingMdComponent } from './streaming-markdown.component'; + +@Component({ + standalone: true, + imports: [ChatStreamingMdComponent], + template: ``, +}) +class HostComponent { + content = signal(''); + streaming = signal(true); +} + +describe('chat-streaming-md — identity preservation', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [HostComponent] })); + + it('keeps the first paragraph DOM stable when a second paragraph is appended', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.content.set('First.\n\n'); + fixture.detectChanges(); + const firstP = fixture.nativeElement.querySelector('p'); + expect(firstP?.textContent?.trim()).toBe('First.'); + + fixture.componentInstance.content.set('First.\n\nSecond.\n\n'); + fixture.detectChanges(); + + const allPs = fixture.nativeElement.querySelectorAll('p'); + expect(allPs).toHaveLength(2); + // The first

        is the same DOM node — Angular preserved it because + // materialize() returned the same JS reference for the unchanged subtree. + expect(allPs[0]).toBe(firstP); + }); + + it('keeps the heading DOM stable when subsequent paragraphs stream in', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.content.set('# Title\n\n'); + fixture.detectChanges(); + const h1 = fixture.nativeElement.querySelector('h1'); + + fixture.componentInstance.content.set('# Title\n\nA paragraph.\n\n'); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('h1')).toBe(h1); + }); +}); diff --git a/libs/chat/src/lib/streaming/streaming-markdown.integration.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.integration.spec.ts new file mode 100644 index 000000000..ba82c60c2 --- /dev/null +++ b/libs/chat/src/lib/streaming/streaming-markdown.integration.spec.ts @@ -0,0 +1,102 @@ +// libs/chat/src/lib/streaming/streaming-markdown.integration.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import { ChatStreamingMdComponent } from './streaming-markdown.component'; + +@Component({ + standalone: true, + imports: [ChatStreamingMdComponent], + template: ``, +}) +class HostComponent { + content = signal(''); + streaming = signal(false); +} + +const samples: { name: string; markdown: string; assertDom: (el: HTMLElement) => void }[] = [ + { + name: 'paragraph', + markdown: 'Hello world.\n', + assertDom: (el) => expect(el.querySelector('p')?.textContent?.trim()).toBe('Hello world.'), + }, + { + name: 'h1 heading', + markdown: '# Title\n', + assertDom: (el) => expect(el.querySelector('h1')?.textContent?.trim()).toBe('Title'), + }, + { + name: 'unordered list', + markdown: '- a\n- b\n\n', + assertDom: (el) => { + expect(el.querySelector('ul')).toBeTruthy(); + expect(el.querySelectorAll('li')).toHaveLength(2); + }, + }, + { + name: 'fenced code block', + markdown: '```ts\nconst x = 1;\n```\n', + assertDom: (el) => { + const code = el.querySelector('pre code') as HTMLElement; + expect(code?.className).toBe('language-ts'); + expect(code?.textContent).toBe('const x = 1;'); + }, + }, + { + name: 'inline emphasis + strong + code', + markdown: 'A *em* and **strong** and `code`.\n', + assertDom: (el) => { + expect(el.querySelector('em')?.textContent?.trim()).toBe('em'); + expect(el.querySelector('strong')?.textContent?.trim()).toBe('strong'); + expect(el.querySelector('code')?.textContent).toBe('code'); + }, + }, + { + name: 'link', + markdown: 'See [docs](https://example.com).\n', + assertDom: (el) => { + const a = el.querySelector('a') as HTMLAnchorElement; + expect(a.getAttribute('href')).toBe('https://example.com'); + expect(a.textContent?.trim()).toBe('docs'); + }, + }, + { + name: 'blockquote', + markdown: '> hello\n> world\n\n', + assertDom: (el) => expect(el.querySelector('blockquote')).toBeTruthy(), + }, + { + name: 'thematic break', + markdown: 'before\n\n---\n\nafter\n', + assertDom: (el) => expect(el.querySelector('hr')).toBeTruthy(), + }, +]; + +describe('chat-streaming-md integration', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [HostComponent] })); + + for (const sample of samples) { + it(`renders ${sample.name} (whole-string)`, () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.content.set(sample.markdown); + fixture.componentInstance.streaming.set(false); + fixture.detectChanges(); + sample.assertDom(fixture.nativeElement); + }); + + it(`renders ${sample.name} (chunked with per-chunk CD)`, () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.streaming.set(true); + let acc = ''; + for (const ch of sample.markdown) { + acc += ch; + fixture.componentInstance.content.set(acc); + fixture.detectChanges(); // per-chunk CD must work — materialize() gives new root ref when tree changes + } + fixture.componentInstance.streaming.set(false); + fixture.detectChanges(); + sample.assertDom(fixture.nativeElement); + }); + } +}); diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 88b1bd538..3ccac1ebe 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -79,6 +79,32 @@ export { ChatSubagentCardComponent, statusColor } from './lib/compositions/chat- // Streaming export { ChatStreamingMdComponent } from './lib/streaming/streaming-markdown.component'; +// Markdown rendering primitives + registry +export { MARKDOWN_VIEW_REGISTRY } from './lib/markdown/markdown-view-registry'; +export { MarkdownChildrenComponent } from './lib/markdown/markdown-children.component'; +export { cacheplaneMarkdownViews } from './lib/markdown/cacheplane-markdown-views'; + +// Per-node-type markdown view components (consumers use these to override +// individual nodes via withViews(cacheplaneMarkdownViews, { … })). +export { MarkdownDocumentComponent } from './lib/markdown/views/markdown-document.component'; +export { MarkdownParagraphComponent } from './lib/markdown/views/markdown-paragraph.component'; +export { MarkdownHeadingComponent } from './lib/markdown/views/markdown-heading.component'; +export { MarkdownBlockquoteComponent } from './lib/markdown/views/markdown-blockquote.component'; +export { MarkdownListComponent } from './lib/markdown/views/markdown-list.component'; +export { MarkdownListItemComponent } from './lib/markdown/views/markdown-list-item.component'; +export { MarkdownCodeBlockComponent } from './lib/markdown/views/markdown-code-block.component'; +export { MarkdownThematicBreakComponent } from './lib/markdown/views/markdown-thematic-break.component'; +export { MarkdownTextComponent } from './lib/markdown/views/markdown-text.component'; +export { MarkdownEmphasisComponent } from './lib/markdown/views/markdown-emphasis.component'; +export { MarkdownStrongComponent } from './lib/markdown/views/markdown-strong.component'; +export { MarkdownStrikethroughComponent } from './lib/markdown/views/markdown-strikethrough.component'; +export { MarkdownInlineCodeComponent } from './lib/markdown/views/markdown-inline-code.component'; +export { MarkdownLinkComponent } from './lib/markdown/views/markdown-link.component'; +export { MarkdownAutolinkComponent } from './lib/markdown/views/markdown-autolink.component'; +export { MarkdownImageComponent } from './lib/markdown/views/markdown-image.component'; +export { MarkdownSoftBreakComponent } from './lib/markdown/views/markdown-soft-break.component'; +export { MarkdownHardBreakComponent } from './lib/markdown/views/markdown-hard-break.component'; + // Shared styles & utilities export { CHAT_MARKDOWN_STYLES } from './lib/styles/chat-markdown.styles'; export { renderMarkdown } from './lib/streaming/markdown-render'; diff --git a/package-lock.json b/package-lock.json index c4ad18605..fc4dd25a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@angular/platform-browser": "~21.1.0", "@angular/router": "~21.1.0", "@cacheplane/partial-json": "^0.1.1", + "@cacheplane/partial-markdown": "^0.1.0", "@langchain/core": "^1.1.33", "@langchain/langgraph-sdk": "^1.7.4", "@modelcontextprotocol/sdk": "^1.27.1", @@ -6934,6 +6935,15 @@ "integrity": "sha512-A6579pz2ad6eMxNALJBhiRyqnqNL2G5uYptbjCqPUqaFX7O3u5NkUTG/7IkEDaWfPk10mR0ZJJXlnAnkLsH+YQ==", "license": "MIT" }, + "node_modules/@cacheplane/partial-markdown": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@cacheplane/partial-markdown/-/partial-markdown-0.1.0.tgz", + "integrity": "sha512-aAKyaf3NU8SDJ9WD+Pu0CEDm1whfb61CipHbDOM+gNkNDnTVT9n8pNGUQEKUqfexoV6KEPc2tG2+OrG7k1pCbA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "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 9fe149dd6..36e727583 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "@angular/platform-browser": "~21.1.0", "@angular/router": "~21.1.0", "@cacheplane/partial-json": "^0.1.1", + "@cacheplane/partial-markdown": "^0.1.0", "@langchain/core": "^1.1.33", "@langchain/langgraph-sdk": "^1.7.4", "@modelcontextprotocol/sdk": "^1.27.1",