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 }; } } }