Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion libs/chat/ng-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "." }
]
Expand Down
5 changes: 3 additions & 2 deletions libs/chat/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/chat",
"version": "0.0.19",
"version": "0.0.20",
"exports": {
".": {
"types": "./index.d.ts",
Expand All @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions libs/chat/src/lib/markdown/cacheplane-markdown-views.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
49 changes: 49 additions & 0 deletions libs/chat/src/lib/markdown/cacheplane-markdown-views.ts
Original file line number Diff line number Diff line change
@@ -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 <chat-streaming-md>. 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,
});
110 changes: 110 additions & 0 deletions libs/chat/src/lib/markdown/markdown-children.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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: `<span data-test="text">{{ node().text }}</span>`,
})
class TextStub {
readonly node = input.required<MarkdownTextNode>();
}

@Component({
standalone: true,
selector: 'chat-md-paragraph-stub',
imports: [MarkdownChildrenComponent],
template: `<p data-test="paragraph"><chat-md-children [parent]="node()" /></p>`,
})
class ParagraphStub {
readonly node = input.required<MarkdownParagraphNode>();
}

@Component({
standalone: true,
imports: [MarkdownChildrenComponent],
template: `<chat-md-children [parent]="parent()" />`,
})
class HostComponent {
parent = signal<MarkdownNode>({
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<unknown>,
'text': TextStub as Type<unknown>,
});
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);
});
});
53 changes: 53 additions & 0 deletions libs/chat/src/lib/markdown/markdown-children.component.ts
Original file line number Diff line number Diff line change
@@ -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) {
<ng-container *ngComponentOutlet="comp; inputs: { node: child }" />
}
}
`,
})
export class MarkdownChildrenComponent {
readonly parent = input.required<MarkdownNode>();
private readonly registry = inject<ViewRegistry>(MARKDOWN_VIEW_REGISTRY);

protected readonly children = computed<readonly MarkdownNode[]>(() => {
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<unknown> | null {
return this.registry[child.type] ?? null;
}
}
18 changes: 18 additions & 0 deletions libs/chat/src/lib/markdown/markdown-view-registry.ts
Original file line number Diff line number Diff line change
@@ -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 <chat-streaming-md>
* and <md-children>. Maps MarkdownNode.type strings (e.g. "paragraph",
* "heading") to Angular components that render that node type.
*
* `<chat-streaming-md>` provides the runtime registry on its component-level
* injector — either the consumer-supplied [viewRegistry] input, or
* `cacheplaneMarkdownViews` (the default) — so descendant <md-children>
* components resolve the right components for each node.
*/
export const MARKDOWN_VIEW_REGISTRY = new InjectionToken<ViewRegistry>(
'MARKDOWN_VIEW_REGISTRY',
);
14 changes: 14 additions & 0 deletions libs/chat/src/lib/markdown/views/markdown-autolink.component.ts
Original file line number Diff line number Diff line change
@@ -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: `<a [href]="node().url">{{ node().url }}</a>`,
})
export class MarkdownAutolinkComponent {
readonly node = input.required<MarkdownAutolinkNode>();
}
16 changes: 16 additions & 0 deletions libs/chat/src/lib/markdown/views/markdown-blockquote.component.ts
Original file line number Diff line number Diff line change
@@ -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: `<blockquote><chat-md-children [parent]="node()" /></blockquote>`,
})
export class MarkdownBlockquoteComponent {
readonly node = input.required<MarkdownBlockquoteNode>();
}
Original file line number Diff line number Diff line change
@@ -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: `<chat-md-code-block [node]="node()" />`,
})
class HostComponent {
node = signal<MarkdownCodeBlockNode>({
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 <pre><code> 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('');
});
});
18 changes: 18 additions & 0 deletions libs/chat/src/lib/markdown/views/markdown-code-block.component.ts
Original file line number Diff line number Diff line change
@@ -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: `<pre><code [class]="languageClass()">{{ node().text }}</code></pre>`,
})
export class MarkdownCodeBlockComponent {
readonly node = input.required<MarkdownCodeBlockNode>();
protected readonly languageClass = computed(() => {
const lang = this.node().language;
return lang ? `language-${lang}` : '';
});
}
Loading
Loading