Skip to content
Open
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
89 changes: 72 additions & 17 deletions packages/ui/components/Viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { AnnotationType, type Block, type Annotation, type EditorMode, type Inpu
import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
import { paintCodeBlockMark } from '../utils/codeBlockMark';
import { useFenceTheme } from '../hooks/useFenceTheme';
import { computeListIndices, groupBlocks, type Frontmatter } from '../utils/parser';
import { computeListIndices, groupBlocks, type Frontmatter, type FrontmatterValue } from '../utils/parser';
import { buildHeadingSlugMap } from '../utils/slugify';
import { copyTextToClipboard } from '../utils/clipboard';
import { BlockRenderer } from './BlockRenderer';
Expand Down Expand Up @@ -184,6 +184,76 @@ interface CodeBlockToolbarTarget {
readonly activation: 'pointer' | 'keyboard';
}

// Named type guard so both taken and fallthrough branches narrow.
function isFrontmatterMap(value: FrontmatterValue): value is { [key: string]: FrontmatterValue } {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/**
* Renders a single frontmatter field or recursive sub-structure.
*/
const FrontmatterRow: React.FC<{ field: string; value: FrontmatterValue }> = ({ field, value }) => {
if (isFrontmatterMap(value)) {
const subEntries = Object.entries(value);
return (
<div className="flex flex-col gap-1.5">
<span className="font-medium text-muted-foreground">{field}:</span>
<div className="pl-4 grid gap-1.5">
{subEntries.map(([k, v]) => (
<FrontmatterRow key={k} field={k} value={v} />
))}
</div>
</div>
);
}

if (Array.isArray(value)) {
const isArrayOfMaps = value.some((v) => typeof v === 'object' && v !== null);
if (isArrayOfMaps) {
return (
<div className="flex flex-col gap-1.5">
<span className="font-medium text-muted-foreground">{field}:</span>
<div className="pl-4 grid gap-2">
{value.map((item, i) => (
<div key={i} className="p-2 bg-muted/40 border border-border/40 rounded grid gap-1.5">
{isFrontmatterMap(item) ? (
Object.entries(item).map(([k, v]) => (
<FrontmatterRow key={k} field={k} value={v} />
))
) : (
<span className="text-foreground">{typeof item === 'string' ? item : String(item)}</span>
)}
</div>
))}
</div>
</div>
);
}

return (
<div className="flex gap-2">
<span className="font-medium text-muted-foreground min-w-[80px]">{field}:</span>
<span className="text-foreground">
<span className="flex flex-wrap gap-1">
{value.map((v, i) => (
<span key={i} className="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-xs">
{typeof v === 'string' ? v : String(v)}
</span>
))}
</span>
</span>
</div>
);
}

return (
<div className="flex gap-2">
<span className="font-medium text-muted-foreground min-w-[80px]">{field}:</span>
<span className="text-foreground">{value}</span>
</div>
);
};

/**
* Renders YAML frontmatter as a styled metadata card.
*/
Expand All @@ -195,22 +265,7 @@ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }
<div className="mt-4 mb-6 p-4 bg-muted/30 border border-border/50 rounded-lg">
<div className="grid gap-2 text-sm">
{entries.map(([key, value]) => (
<div key={key} className="flex gap-2">
<span className="font-medium text-muted-foreground min-w-[80px]">{key}:</span>
<span className="text-foreground">
{Array.isArray(value) ? (
<span className="flex flex-wrap gap-1">
{value.map((v, i) => (
<span key={i} className="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-xs">
{v}
</span>
))}
</span>
) : (
value
)}
</span>
</div>
<FrontmatterRow key={key} field={key} value={value} />
))}
</div>
</div>
Expand Down
130 changes: 130 additions & 0 deletions packages/ui/utils/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1669,6 +1669,136 @@ tags:
});
});

describe("extractFrontmatter — nested structures (#1485)", () => {
test("nested maps and arrays of maps parse into hierarchical structure", () => {
const md = `---
title: Nested Plan
generated:
by: agent-alpha
at: 2026-09-16T10:00:00Z
verified:
- by: reviewer-beta
at: 2026-09-16T11:00:00Z
sources:
- url: https://example.com/spec
name: specification
---
# Content`;
const { frontmatter, content, contentStartLine } = extractFrontmatter(md);
expect(content).toBe("# Content");
expect(contentStartLine).toBe(13);
expect(frontmatter).toEqual({
title: "Nested Plan",
generated: {
by: "agent-alpha",
at: "2026-09-16T10:00:00Z",
},
verified: [
{
by: "reviewer-beta",
at: "2026-09-16T11:00:00Z",
sources: [
{
url: "https://example.com/spec",
name: "specification",
},
],
},
],
});
});

test("same-named child keys at different hierarchy levels do not clobber", () => {
const md = `---
generated:
by: bot
at: 2026-09-16T10:00:00Z
verified:
- by: human
at: 2026-09-16T12:00:00Z
---
body`;
const { frontmatter } = extractFrontmatter(md);
expect(frontmatter?.generated).toEqual({
by: "bot",
at: "2026-09-16T10:00:00Z",
});
expect(frontmatter?.verified).toEqual([
{
by: "human",
at: "2026-09-16T12:00:00Z",
},
]);
});

test("array-of-map items group correctly per item", () => {
const md = `---
reviewers:
- name: alice
role: lead
- name: bob
role: peer
---
body`;
const { frontmatter } = extractFrontmatter(md);
expect(frontmatter?.reviewers).toEqual([
{ name: "alice", role: "lead" },
{ name: "bob", role: "peer" },
]);
});

test("flat scalars and string arrays remain unchanged", () => {
const md = `---
title: Plain Title
status: draft
tags:
- architecture
- performance
categories:
- dev
- ops
---
body`;
const { frontmatter } = extractFrontmatter(md);
expect(frontmatter).toEqual({
title: "Plain Title",
status: "draft",
tags: ["architecture", "performance"],
categories: ["dev", "ops"],
});
});

test("block scalars inside nested maps and CRLF are supported", () => {
const md = "---\r\ngenerated:\r\n summary: >-\r\n line one\r\n line two\r\n by: bot\r\n---\r\nbody";
const { frontmatter } = extractFrontmatter(md);
expect(frontmatter?.generated).toEqual({
summary: "line one line two",
by: "bot",
});
});

test("array of scalar strings inside a map element parses correctly", () => {
const md = `---
verified:
- by: reviewer
sources:
- https://example.com/a
- https://example.com/b
---
body`;
const { frontmatter } = extractFrontmatter(md);
expect(frontmatter?.verified).toEqual([
{
by: "reviewer",
sources: [
"https://example.com/a",
"https://example.com/b",
],
},
]);
});
});

describe("parseMarkdownToBlocks — startLine accuracy", () => {
test("basic blocks get correct startLine", () => {
const md = "# Heading\n\nParagraph\n\n- Item";
Expand Down
Loading