import type { Block, Annotation, CodeAnnotation, EditorAnnotation, ImageAttachment } from '../types'; import { planDenyFeedback } from '@plannotator/core/feedback-templates'; import { resolveReplyParents } from '@plannotator/core/annotation-threads'; import { diagramAnchorLocationLine, parseDiagramAnchor } from '@plannotator/core/diagram-anchor'; import { formatQuestionAnswerLines, formatQuestionAnswerText, formatQuestionAnswersSection, indexQuestionBlocks, parseQuestionAnswer, questionExportItems, type QuestionAnswer, type QuestionExportItem, } from '@plannotator/core/question-block'; import { DIRECTIVE_OPEN_RE, HTML_BLOCK_TAGS, codeFenceCloseIndex, directiveCloseIndex, htmlBlockEndAt, resolveReferenceLinks, scanDisplayMath, splitFrontmatter, type TagCloseIndex, } from '@plannotator/core/markdown-structure'; import { skillReferenceExportBlock } from './skillReferences'; // The structural helpers moved to `@plannotator/core/markdown-structure` so // `findQuestionBlocks` (core) splits a document exactly as this parser does. export { HTML_BLOCK_TAGS, resolveReferenceLinks }; /** * 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]: FrontmatterValue; } /** Number of leading whitespace characters on a line. */ function indentWidth(line: string): number { return line.length - line.trimStart().length; } /** Strip the common leading indentation shared by all non-empty lines. */ function dedentLines(lines: string[]): string[] { const indents = lines.filter((l) => l !== '').map(indentWidth); const minIndent = indents.length ? Math.min(...indents) : 0; return lines.map((l) => (l === '' ? '' : l.slice(minIndent))); } /** * Fold YAML `>`-style scalar lines: adjacent non-empty lines join with a * single space, and a run of N blank lines between paragraphs folds to N * newlines. */ function foldScalarLines(lines: string[]): string { let text = ''; let started = false; let blanks = 0; for (const l of lines) { if (l === '') { blanks++; continue; } if (!started) { text = l; started = true; } else { text += blanks > 0 ? '\n'.repeat(blanks) : ' '; text += l; } blanks = 0; } return text; } /** * Parse a YAML block scalar (`|` literal keeps newlines / `>` folded joins * with spaces) whose body is the run of lines below `bodyStart` indented * deeper than `keyIndent`. Trailing blank lines are dropped and chomping * indicators are treated as strip. Returns the value and the index of the * last line the scalar consumed. */ function parseBlockScalar( lines: string[], bodyStart: number, keyIndent: number, folded: boolean, ): { value: string; endIndex: number } { const body: string[] = []; let j = bodyStart; for (; j < lines.length; j++) { // CRLF sources split on '\n' leave a trailing '\r' that would otherwise // survive into the folded value (every other parser path trims lines). const bodyLine = lines[j].replace(/\r$/, ''); if (bodyLine.trim() === '') { body.push(''); continue; } if (indentWidth(bodyLine) <= keyIndent) break; // dedent ends the block body.push(bodyLine); } const dedented = dedentLines(body); while (dedented.length && dedented[dedented.length - 1] === '') dedented.pop(); const value = (folded ? foldScalarLines(dedented) : dedented.join('\n')).trim(); 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 { // A QUOTED KEY is still a mapping entry: `"title": "Doc"` must yield // { title: "Doc" }, not nothing. Only a line that is nothing but a quoted // scalar (`"just a string"`) is not an entry — which is the case the char // after the closing quote distinguishes. const quote = str[0]; if (quote === '"' || quote === "'") { const closing = str.indexOf(quote, 1); if (closing === -1) return null; if (str[closing + 1] !== ':') return null; // Same rule the unquoted branch applies: `key:value` is a scalar, not a // mapping entry. A colon at end of line (empty value) is fine. const afterColon = str[closing + 2]; if (afterColon !== undefined && afterColon !== ' ' && afterColon !== '\t') { return null; } return { key: str.slice(1, closing), value: str.slice(closing + 2).trim() }; } 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 * line number where content begins in the original file (so downstream * line references stay accurate). */ export function extractFrontmatter(markdown: string): { frontmatter: Frontmatter | null; content: string; contentStartLine: number } { const { raw: frontmatterRaw, content: afterFrontmatter, contentStartLine } = splitFrontmatter(markdown); if (frontmatterRaw === null) { return { frontmatter: null, content: markdown, contentStartLine: 1 }; } // Parse simple YAML (key: value pairs, indentation-aware) const frontmatter: Frontmatter = {}; 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].replace(/\r$/, ''); const trimmedLine = rawLine.trim(); 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 { // The key sits two columns right of the dash (`- meta:`), so that — // not the dash's own indent — is the indent its nested block must be // measured against. Recording `lineIndent` here made the nested map // swallow the item's later sibling keys, because a sibling indented // to the key's column never dedented past the dash. pendingKey = { key: kv.key, indent: lineIndent + 2, parentMap: mapElem }; } if (targetArray) { targetArray.push(mapElem); } mapStack.push({ indent: lineIndent, map: mapElem }); } else { if (targetArray) { targetArray.push(afterDash); } } continue; } // Key: value pair 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, lineIndent, blockScalar[1] === '>', ); parentMap[kv.key] = scalarValue; i = endIndex; continue; } if (kv.value) { parentMap[kv.key] = kv.value; } else { pendingKey = { key: kv.key, indent: lineIndent, parentMap }; } } } return { frontmatter, content: afterFrontmatter, contentStartLine }; } export interface ParseMarkdownOptions { /** * Strip a leading `--- ... ---` pair as frontmatter (default true). * Pass false for non-markdown plain-text sources (.yaml/.json/.txt/…) * where the delimiters are real content — a multi-document YAML starts * with them (see shouldStripFrontmatter in @plannotator/core/annotatable). */ frontmatter?: boolean; } /** * The block list for a whole-file diagram source (`plannotator annotate * flow.mmd`): ONE code block carrying the file's raw text, which `Viewer` * hands to the same `DiagramBlock` a ```mermaid fence in a plan produces. * Everything downstream — diagram comments, the annotations rail, the export's * `Diagram node