Skip to content
Merged
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
## 0.0.22 — 2026-05-04

### Added

- **`@ngaf/chat`** — markdown view components for GFM tables (`chat-md-table`, `chat-md-table-row`, `chat-md-table-cell`) and task-list checkbox prefix on `MarkdownListItemComponent`. The view registry now exposes all 22 node types emitted by `@cacheplane/partial-markdown@0.2.0`.

### Changed

- All 16 @ngaf libraries synchronized to `0.0.22`.

---

## 0.0.21 — 2026-05-04

### Added
Expand Down
2 changes: 1 addition & 1 deletion libs/a2ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/a2ui",
"version": "0.0.21",
"version": "0.0.22",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/ag-ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/ag-ui",
"version": "0.0.21",
"version": "0.0.22",
"peerDependencies": {
"@ngaf/chat": "*",
"@ngaf/licensing": "*",
Expand Down
2 changes: 1 addition & 1 deletion libs/chat/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/chat",
"version": "0.0.21",
"version": "0.0.22",
"exports": {
".": {
"types": "./index.d.ts",
Expand Down
5 changes: 4 additions & 1 deletion libs/chat/src/lib/markdown/cacheplane-markdown-views.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { describe, it, expect } from 'vitest';
import { cacheplaneMarkdownViews } from './cacheplane-markdown-views';

describe('cacheplaneMarkdownViews', () => {
it('registers all 19 markdown node types (v0.2 adds citation-reference)', () => {
it('registers all 22 markdown node types (v0.2 adds table, table-row, table-cell)', () => {
expect(Object.keys(cacheplaneMarkdownViews).sort()).toEqual([
'autolink',
'blockquote',
Expand All @@ -23,6 +23,9 @@ describe('cacheplaneMarkdownViews', () => {
'soft-break',
'strikethrough',
'strong',
'table',
'table-cell',
'table-row',
'text',
'thematic-break',
]);
Expand Down
8 changes: 7 additions & 1 deletion libs/chat/src/lib/markdown/cacheplane-markdown-views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ import { MarkdownImageComponent } from './views/markdown-image.component';
import { MarkdownSoftBreakComponent } from './views/markdown-soft-break.component';
import { MarkdownHardBreakComponent } from './views/markdown-hard-break.component';
import { MarkdownCitationReferenceComponent } from './views/markdown-citation-reference.component';
import { MarkdownTableComponent } from './views/markdown-table.component';
import { MarkdownTableRowComponent } from './views/markdown-table-row.component';
import { MarkdownTableCellComponent } from './views/markdown-table-cell.component';

/**
* Default view registry consumed by <chat-streaming-md>. Maps every
* MarkdownNode.type emitted by @cacheplane/partial-markdown@0.1 to its
* MarkdownNode.type emitted by @cacheplane/partial-markdown@0.2 to its
* corresponding Angular component.
*
* Override per-node-type via `withViews(cacheplaneMarkdownViews, { … })`.
Expand All @@ -48,4 +51,7 @@ export const cacheplaneMarkdownViews: ViewRegistry = views({
'soft-break': MarkdownSoftBreakComponent,
'hard-break': MarkdownHardBreakComponent,
'citation-reference': MarkdownCitationReferenceComponent,
'table': MarkdownTableComponent,
'table-row': MarkdownTableRowComponent,
'table-cell': MarkdownTableCellComponent,
});
13 changes: 13 additions & 0 deletions libs/chat/src/lib/markdown/markdown-table-row.token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// libs/chat/src/lib/markdown/markdown-table-row.token.ts
// SPDX-License-Identifier: MIT
import { InjectionToken, Signal, signal } from '@angular/core';

/**
* Provided by MarkdownTableRowComponent for header rows so that
* MarkdownTableCellComponent can render <th> instead of <td>.
* The value is a Signal<boolean> so that it tracks the row's isHeader reactively.
*/
export const IS_HEADER_ROW = new InjectionToken<Signal<boolean>>('IS_HEADER_ROW', {
providedIn: null,
factory: () => signal(false),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// libs/chat/src/lib/markdown/views/markdown-list-item.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 { MarkdownListItemNode } from '@cacheplane/partial-markdown';
import { MarkdownListItemComponent } from './markdown-list-item.component';
import { MarkdownTextComponent } from './markdown-text.component';
import { MARKDOWN_VIEW_REGISTRY } from '../markdown-view-registry';

function makeItemNode(task?: { checked: boolean }): MarkdownListItemNode {
return {
id: 10, type: 'list-item', status: 'complete',
parent: null, index: null,
task,
children: [],
} as MarkdownListItemNode;
}

@Component({
standalone: true,
imports: [MarkdownListItemComponent],
template: `<chat-md-list-item [node]="node()" />`,
})
class HostComponent {
node = signal<MarkdownListItemNode>(makeItemNode());
}

describe('MarkdownListItemComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HostComponent],
providers: [{
provide: MARKDOWN_VIEW_REGISTRY,
useValue: views({ 'text': MarkdownTextComponent }),
}],
});
});

it('renders a <li> element', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('li')).toBeTruthy();
});

it('does not render a checkbox for plain items', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('input[type="checkbox"]')).toBeFalsy();
});

it('does not apply task class for plain items', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const li = fixture.nativeElement.querySelector('li');
expect(li.classList.contains('chat-md-list-item--task')).toBe(false);
});

it('renders a disabled unchecked checkbox for task items (unchecked)', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.componentInstance.node.set(makeItemNode({ checked: false }));
fixture.detectChanges();
const checkbox = fixture.nativeElement.querySelector('input[type="checkbox"]');
expect(checkbox).toBeTruthy();
expect(checkbox.disabled).toBe(true);
expect(checkbox.checked).toBe(false);
});

it('renders a disabled checked checkbox for task items (checked)', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.componentInstance.node.set(makeItemNode({ checked: true }));
fixture.detectChanges();
const checkbox = fixture.nativeElement.querySelector('input[type="checkbox"]');
expect(checkbox).toBeTruthy();
expect(checkbox.disabled).toBe(true);
expect(checkbox.checked).toBe(true);
});

it('applies task class when task is defined', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.componentInstance.node.set(makeItemNode({ checked: false }));
fixture.detectChanges();
const li = fixture.nativeElement.querySelector('li');
expect(li.classList.contains('chat-md-list-item--task')).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ import { MarkdownChildrenComponent } from '../markdown-children.component';
standalone: true,
imports: [MarkdownChildrenComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<li><chat-md-children [parent]="node()" /></li>`,
template: `
<li [class.chat-md-list-item--task]="node().task !== undefined">
@if (node().task !== undefined) {
<input type="checkbox" disabled [checked]="node().task!.checked" />
}
<chat-md-children [parent]="node()" />
</li>
`,
})
export class MarkdownListItemComponent {
readonly node = input.required<MarkdownListItemNode>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// libs/chat/src/lib/markdown/views/markdown-table-cell.component.spec.ts
// SPDX-License-Identifier: MIT
import { describe, it, expect, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { Component, signal, Signal } from '@angular/core';
import { views } from '@ngaf/render';
import type { MarkdownTableCellNode } from '@cacheplane/partial-markdown';
import { MarkdownTableCellComponent } from './markdown-table-cell.component';
import { MarkdownTextComponent } from './markdown-text.component';
import { IS_HEADER_ROW } from '../markdown-table-row.token';
import { MARKDOWN_VIEW_REGISTRY } from '../markdown-view-registry';

function makeCellNode(alignment: MarkdownTableCellNode['alignment'] = null): MarkdownTableCellNode {
return {
id: 3, type: 'table-cell', status: 'complete',
parent: null, index: null,
alignment,
children: [],
} as MarkdownTableCellNode;
}

@Component({
standalone: true,
imports: [MarkdownTableCellComponent],
template: `<chat-md-table-cell [node]="node()" />`,
})
class HostComponent {
node = signal<MarkdownTableCellNode>(makeCellNode());
}

describe('MarkdownTableCellComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HostComponent],
providers: [{
provide: MARKDOWN_VIEW_REGISTRY,
useValue: views({ 'text': MarkdownTextComponent }),
}],
});
});

it('renders <td> by default (no IS_HEADER_ROW token)', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('td')).toBeTruthy();
expect(fixture.nativeElement.querySelector('th')).toBeFalsy();
});

it('renders <th> when IS_HEADER_ROW token is true', () => {
TestBed.overrideProvider(IS_HEADER_ROW, { useValue: signal(true) as Signal<boolean> });
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('th')).toBeTruthy();
expect(fixture.nativeElement.querySelector('td')).toBeFalsy();
});

it('does not set text-align style when alignment is null', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const td = fixture.nativeElement.querySelector('td');
expect(td.style.textAlign).toBe('');
});

it('sets text-align style from alignment value', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.componentInstance.node.set(makeCellNode('center'));
fixture.detectChanges();
const td = fixture.nativeElement.querySelector('td');
expect(td.style.textAlign).toBe('center');
});

it('applies chat-md-table-cell class', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const el = fixture.nativeElement.querySelector('td') ?? fixture.nativeElement.querySelector('th');
expect(el.classList.contains('chat-md-table-cell')).toBe(true);
});
});
31 changes: 31 additions & 0 deletions libs/chat/src/lib/markdown/views/markdown-table-cell.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// libs/chat/src/lib/markdown/views/markdown-table-cell.component.ts
// SPDX-License-Identifier: MIT
import { Component, ChangeDetectionStrategy, input, inject, computed } from '@angular/core';
import type { MarkdownTableCellNode } from '@cacheplane/partial-markdown';
import { MarkdownChildrenComponent } from '../markdown-children.component';
import { IS_HEADER_ROW } from '../markdown-table-row.token';

@Component({
selector: 'chat-md-table-cell',
standalone: true,
imports: [MarkdownChildrenComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (isHeader()) {
<th class="chat-md-table-cell" [style.text-align]="alignment() ?? null">
<chat-md-children [parent]="node()" />
</th>
} @else {
<td class="chat-md-table-cell" [style.text-align]="alignment() ?? null">
<chat-md-children [parent]="node()" />
</td>
}
`,
})
export class MarkdownTableCellComponent {
readonly node = input.required<MarkdownTableCellNode>();

private readonly isHeaderRowToken = inject(IS_HEADER_ROW, { optional: true });
protected readonly isHeader = computed(() => this.isHeaderRowToken ? this.isHeaderRowToken() : false);
protected readonly alignment = computed(() => this.node().alignment);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// libs/chat/src/lib/markdown/views/markdown-table-row.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 { MarkdownTableRowNode } from '@cacheplane/partial-markdown';
import { MarkdownTableRowComponent } from './markdown-table-row.component';
import { MarkdownTableCellComponent } from './markdown-table-cell.component';
import { MARKDOWN_VIEW_REGISTRY } from '../markdown-view-registry';

function makeRowNode(isHeader: boolean, children: MarkdownTableRowNode['children'] = []): MarkdownTableRowNode {
return {
id: 2, type: 'table-row', status: 'complete',
parent: null, index: null,
isHeader,
children,
} as MarkdownTableRowNode;
}

@Component({
standalone: true,
imports: [MarkdownTableRowComponent],
template: `<chat-md-table-row [node]="node()" />`,
})
class HostComponent {
node = signal<MarkdownTableRowNode>(makeRowNode(false));
}

describe('MarkdownTableRowComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HostComponent],
providers: [{
provide: MARKDOWN_VIEW_REGISTRY,
useValue: views({ 'table-cell': MarkdownTableCellComponent }),
}],
});
});

it('renders a <tr> element', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('tr')).toBeTruthy();
});

it('applies chat-md-table-row class', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('tr.chat-md-table-row')).toBeTruthy();
});

it('does NOT apply header class for body rows', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const tr = fixture.nativeElement.querySelector('tr');
expect(tr.classList.contains('chat-md-table-row--header')).toBe(false);
});

it('applies header class for header rows', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.componentInstance.node.set(makeRowNode(true));
fixture.detectChanges();
const tr = fixture.nativeElement.querySelector('tr');
expect(tr.classList.contains('chat-md-table-row--header')).toBe(true);
});
});
Loading
Loading