diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx
index b094f0466..bce775157 100644
--- a/packages/ui/components/Viewer.tsx
+++ b/packages/ui/components/Viewer.tsx
@@ -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';
@@ -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 (
+
+
{field}:
+
+ {subEntries.map(([k, v]) => (
+
+ ))}
+
+
+ );
+ }
+
+ if (Array.isArray(value)) {
+ const isArrayOfMaps = value.some((v) => typeof v === 'object' && v !== null);
+ if (isArrayOfMaps) {
+ return (
+
+
{field}:
+
+ {value.map((item, i) => (
+
+ {isFrontmatterMap(item) ? (
+ Object.entries(item).map(([k, v]) => (
+
+ ))
+ ) : (
+ {typeof item === 'string' ? item : String(item)}
+ )}
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+ {field}:
+
+
+ {value.map((v, i) => (
+
+ {typeof v === 'string' ? v : String(v)}
+
+ ))}
+
+
+
+ );
+ }
+
+ return (
+
+ {field}:
+ {value}
+
+ );
+};
+
/**
* Renders YAML frontmatter as a styled metadata card.
*/
@@ -195,22 +265,7 @@ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }
{entries.map(([key, value]) => (
-
- {key}:
-
- {Array.isArray(value) ? (
-
- {value.map((v, i) => (
-
- {v}
-
- ))}
-
- ) : (
- value
- )}
-
-
+
))}
diff --git a/packages/ui/utils/parser.test.ts b/packages/ui/utils/parser.test.ts
index 4e9a4a64b..5156298b3 100644
--- a/packages/ui/utils/parser.test.ts
+++ b/packages/ui/utils/parser.test.ts
@@ -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";
diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts
index b53b82f3c..f11a9342f 100644
--- a/packages/ui/utils/parser.ts
+++ b/packages/ui/utils/parser.ts
@@ -3,11 +3,19 @@ import { planDenyFeedback } from '@plannotator/core/feedback-templates';
import { resolveReplyParents } from '@plannotator/core/annotation-threads';
import { skillReferenceExportBlock } from './skillReferences';
+/**
+ * Parsed YAML frontmatter value: scalar string, array, or nested map.
+ */
+export type FrontmatterValue =
+ | string
+ | FrontmatterValue[]
+ | { [key: string]: FrontmatterValue };
+
/**
* Parsed YAML frontmatter as key-value pairs.
*/
export interface Frontmatter {
- [key: string]: string | string[];
+ [key: string]: FrontmatterValue;
}
/** Number of leading whitespace characters on a line. */
@@ -80,6 +88,24 @@ function parseBlockScalar(
return { value, endIndex: j - 1 };
}
+/**
+ * Parse a simple `key: value` pair from a line. Returns null if the line
+ * is not a valid YAML mapping entry (e.g. scalar URLs or quoted strings).
+ */
+function parseKeyValue(str: string): { key: string; value: string } | null {
+ if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) {
+ return null;
+ }
+ const colonIndex = str.indexOf(':');
+ if (colonIndex <= 0) return null;
+ if (colonIndex < str.length - 1 && str[colonIndex + 1] !== ' ' && str[colonIndex + 1] !== '\t') {
+ return null;
+ }
+ const key = str.slice(0, colonIndex).trim();
+ const value = str.slice(colonIndex + 1).trim();
+ return { key, value };
+}
+
/**
* Extract YAML frontmatter from markdown if present.
* Returns the parsed frontmatter, the remaining markdown, and the 1-based
@@ -111,57 +137,122 @@ export function extractFrontmatter(markdown: string): { frontmatter: Frontmatter
const consumedTotal = leadingChars + consumedInTrimmed;
const contentStartLine = (markdown.slice(0, consumedTotal).match(/\n/g) || []).length + 1;
- // Parse simple YAML (key: value pairs)
+ // Parse simple YAML (key: value pairs, indentation-aware)
const frontmatter: Frontmatter = {};
- let currentKey: string | null = null;
- let currentArray: string[] | null = null;
+ const mapStack: { indent: number; map: { [key: string]: FrontmatterValue } }[] = [
+ { indent: -1, map: frontmatter },
+ ];
+ const arrayStack: { indent: number; array: FrontmatterValue[] }[] = [];
+ let pendingKey: {
+ key: string;
+ indent: number;
+ parentMap: { [key: string]: FrontmatterValue };
+ } | null = null;
const lines = frontmatterRaw.split('\n');
for (let i = 0; i < lines.length; i++) {
- const rawLine = lines[i];
+ const rawLine = lines[i].replace(/\r$/, '');
const trimmedLine = rawLine.trim();
- // Array item (- value)
- if (trimmedLine.startsWith('- ') && currentKey) {
- const value = trimmedLine.slice(2).trim();
- if (!currentArray) {
- currentArray = [];
- frontmatter[currentKey] = currentArray;
+ if (!trimmedLine) continue;
+
+ const lineIndent = indentWidth(rawLine);
+
+ // Array item (- value or - key: value)
+ if (trimmedLine.startsWith('- ')) {
+ const afterDash = trimmedLine.slice(2).trim();
+ const kv = parseKeyValue(afterDash);
+
+ if (pendingKey && lineIndent >= pendingKey.indent) {
+ const newArray: FrontmatterValue[] = [];
+ pendingKey.parentMap[pendingKey.key] = newArray;
+ arrayStack.push({ indent: lineIndent, array: newArray });
+ pendingKey = null;
+ } else {
+ pendingKey = null;
+ while (arrayStack.length > 0 && arrayStack[arrayStack.length - 1].indent > lineIndent) {
+ arrayStack.pop();
+ }
+ }
+
+ while (mapStack.length > 1 && mapStack[mapStack.length - 1].indent >= lineIndent) {
+ mapStack.pop();
+ }
+
+ const targetArray = arrayStack.length > 0 ? arrayStack[arrayStack.length - 1].array : null;
+
+ if (kv) {
+ const blockScalar = kv.value.match(/^([|>])[+-]?$/);
+ let scalarVal = kv.value;
+ if (blockScalar) {
+ const { value: parsedScalar, endIndex } = parseBlockScalar(
+ lines,
+ i + 1,
+ lineIndent,
+ blockScalar[1] === '>',
+ );
+ scalarVal = parsedScalar;
+ i = endIndex;
+ }
+
+ const mapElem: { [key: string]: FrontmatterValue } = {};
+ if (scalarVal) {
+ mapElem[kv.key] = scalarVal;
+ } else {
+ pendingKey = { key: kv.key, indent: lineIndent, parentMap: mapElem };
+ }
+
+ if (targetArray) {
+ targetArray.push(mapElem);
+ }
+ mapStack.push({ indent: lineIndent, map: mapElem });
+ } else {
+ if (targetArray) {
+ targetArray.push(afterDash);
+ }
}
- currentArray.push(value);
continue;
}
// Key: value pair
- const colonIndex = trimmedLine.indexOf(':');
- if (colonIndex > 0) {
- currentKey = trimmedLine.slice(0, colonIndex).trim();
- const value = trimmedLine.slice(colonIndex + 1).trim();
- currentArray = null;
-
- // Block scalar: `|` (literal, keep newlines) or `>` (folded, join with
- // spaces), each with optional chomping indicator (`-`/`+`). The value
- // spans the following lines indented deeper than the key, e.g.
- // description: >-
- // line one
- // line two
- // Without this, the indicator (">-") was stored verbatim and the body
- // silently dropped.
- const blockScalar = value.match(/^([|>])[+-]?$/);
+ const kv = parseKeyValue(trimmedLine);
+ if (kv) {
+ if (pendingKey) {
+ if (lineIndent > pendingKey.indent) {
+ const newMap: { [key: string]: FrontmatterValue } = {};
+ pendingKey.parentMap[pendingKey.key] = newMap;
+ mapStack.push({ indent: pendingKey.indent, map: newMap });
+ }
+ pendingKey = null;
+ }
+
+ while (arrayStack.length > 0 && arrayStack[arrayStack.length - 1].indent >= lineIndent) {
+ arrayStack.pop();
+ }
+ while (mapStack.length > 1 && mapStack[mapStack.length - 1].indent >= lineIndent) {
+ mapStack.pop();
+ }
+
+ const parentMap = mapStack[mapStack.length - 1].map;
+
+ // Block scalar: `|` or `>`
+ const blockScalar = kv.value.match(/^([|>])[+-]?$/);
if (blockScalar) {
const { value: scalarValue, endIndex } = parseBlockScalar(
lines,
i + 1,
- indentWidth(rawLine),
+ lineIndent,
blockScalar[1] === '>',
);
- frontmatter[currentKey] = scalarValue;
+ parentMap[kv.key] = scalarValue;
i = endIndex;
continue;
}
- if (value) {
- frontmatter[currentKey] = value;
+ if (kv.value) {
+ parentMap[kv.key] = kv.value;
+ } else {
+ pendingKey = { key: kv.key, indent: lineIndent, parentMap };
}
}
}