From 3ce6062441d23ceacdbc6953e1e0d91dcc16d93e Mon Sep 17 00:00:00 2001 From: Peter Liu Date: Fri, 7 Aug 2026 15:16:40 +0000 Subject: [PATCH 1/3] docs: add implementation principles for agents --- AGENTS.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 00c42ce..1afdc2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,20 @@ If your change is subsystem-specific, you MUST also read the relevant deep-dive + + +# Implementation Principles + +- Do not preserve backward compatibility. Remove obsolete paths instead of adding compatibility layers, fallbacks, or migrations. +- Choose the simplest implementation that fully meets the current requirements. Avoid speculative abstractions, configuration, and indirection. +- Grow the system in layers. Start from the smallest version that works end to end, and add each new capability on top of a product that already works. Never trade a working product for unfinished complexity. +- Keep components modular and concerns clearly separated. +- Prefer established, well-maintained libraries when they reduce overall complexity or improve reliability. Do not reimplement common functionality without a clear reason. +- Lean on the dependencies already in the project before writing your own implementation or adding packages. Do not assume a library lacks a capability without checking its documentation and types. +- Make architectural decisions for the long term. Do not accept a stopgap that only works for now and is meant to be replaced later. + + + # This is NOT the Next.js you know From c8eea70fa6c5d9696ad67cf8f3887526822d42f9 Mon Sep 17 00:00:00 2001 From: Peter Liu Date: Sat, 8 Aug 2026 13:09:57 +0000 Subject: [PATCH 2/3] feat: improve Mermaid format action for nested and indent-sensitive diagrams --- src/components/editor/EditorCodePanel.tsx | 135 +++++---------- src/lib/mermaid-format.ts | 192 ++++++++++++++++++++++ tsconfig.json | 20 ++- 3 files changed, 246 insertions(+), 101 deletions(-) create mode 100644 src/lib/mermaid-format.ts diff --git a/src/components/editor/EditorCodePanel.tsx b/src/components/editor/EditorCodePanel.tsx index 933dbc5..ccbec34 100644 --- a/src/components/editor/EditorCodePanel.tsx +++ b/src/components/editor/EditorCodePanel.tsx @@ -2,6 +2,7 @@ import Editor, { type OnMount, type BeforeMount, type Monaco } from "@monaco-edi import { useTheme } from "next-themes"; import { useCallback, useEffect, useRef } from "react"; import { registerMermaidLanguage } from "@/lib/diagrams/mermaidMonarch"; +import { formatMermaidSource } from "@/lib/mermaid-format"; import { Copy, AlignLeft } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -93,107 +94,47 @@ export function EditorCodePanel({ }, [code]); const handleFormat = useCallback(() => { - const INDENT = " "; - const BLOCK_OPEN = new Set([ - "subgraph", - "loop", - "alt", - "opt", - "par", - "critical", - "break", - "rect", - ]); - const BLOCK_SAME = new Set(["else", "and", "option"]); - const BLOCK_CLOSE = "end"; - - const lines = code.split("\n"); - - // Extract and preserve YAML front matter (--- … ---) at the start. - let frontMatterLines: string[] = []; - let bodyStartIndex = 0; - if (lines.length > 0 && lines[0].trim() === "---") { - const endIndex = lines.findIndex((l, i) => i > 0 && l.trim() === "---"); - if (endIndex > 0) { - frontMatterLines = lines.slice(0, endIndex + 1); - bodyStartIndex = endIndex + 1; - // Skip blank lines between front matter and body. - while (bodyStartIndex < lines.length && lines[bodyStartIndex].trim() === "") { - bodyStartIndex++; - } - } - } - const bodyLines = lines.slice(bodyStartIndex); - - // Pass 1: trim trailing whitespace on every line. - const trimmed = bodyLines.map((l) => l.replace(/\s+$/, "")); - - // Pass 2: collapse consecutive blank lines, keep leading/trailing blanks. - const collapsed: string[] = []; - let prevBlank = false; - for (const line of trimmed) { - const isBlank = line.trim() === ""; - if (isBlank && prevBlank) continue; - collapsed.push(line); - prevBlank = isBlank; - } - while (collapsed.length > 0 && collapsed[0].trim() === "") collapsed.shift(); - while (collapsed.length > 0 && collapsed[collapsed.length - 1].trim() === "") collapsed.pop(); - - // Pass 3: re-indent based on block depth. - let depth = 0; - const reformatted: string[] = []; - for (const rawLine of collapsed) { - const stripped = rawLine.trim(); - if (stripped === "") { - reformatted.push(""); - continue; - } - - // Extract the first keyword (case-insensitive). - const firstWord = stripped.split(/\s+/)[0].toLowerCase(); - - // "end" closes a block — outdent BEFORE printing. - if (firstWord === BLOCK_CLOSE) { - depth = Math.max(0, depth - 1); - reformatted.push(INDENT.repeat(depth) + stripped); - continue; - } - - // "else", "and", "option" are at the SAME level as their parent block. - if (BLOCK_SAME.has(firstWord)) { - reformatted.push(INDENT.repeat(Math.max(0, depth - 1)) + stripped); - continue; + try { + const result = formatMermaidSource(code); + if (result.status === "unchanged") { + toast.info("Already formatted"); + return; } - - // Normal line — print at current depth. - reformatted.push(INDENT.repeat(depth) + stripped); - - // If this line opens a block, increase depth for subsequent lines. - if (BLOCK_OPEN.has(firstWord)) { - depth++; + if (result.status === "skipped-indent-sensitive") { + if (result.formatted !== code) { + const editor = editorRef.current; + if (editor) { + const model = editor.getModel(); + if (model) { + editor.pushUndoStop(); + editor.executeEdits("format", [ + { range: model.getFullModelRange(), text: result.formatted, forceMoveMarkers: true }, + ]); + } + } + handleCodeChange(result.formatted); + } + toast.info( + `Structural formatting skipped for ${result.diagramType} (indent is semantic)`, + ); + return; } - } - - const bodyFormatted = reformatted.join("\n"); - const frontMatter = frontMatterLines.length > 0 ? frontMatterLines.join("\n") + "\n" : ""; - const formatted = frontMatter + bodyFormatted; - if (formatted === code) { - toast.info("Already formatted"); - return; - } - const editor = editorRef.current; - if (editor) { - const model = editor.getModel(); - if (model) { - editor.pushUndoStop(); - editor.executeEdits("format", [ - { range: model.getFullModelRange(), text: formatted, forceMoveMarkers: true }, - ]); + const editor = editorRef.current; + if (editor) { + const model = editor.getModel(); + if (model) { + editor.pushUndoStop(); + editor.executeEdits("format", [ + { range: model.getFullModelRange(), text: result.formatted, forceMoveMarkers: true }, + ]); + } } + handleCodeChange(result.formatted); + toast.success("Code formatted"); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + toast.error(`Formatting failed: ${message}`); } - handleCodeChange(formatted); - toast.success("Code formatted"); }, [code, handleCodeChange]); return ( diff --git a/src/lib/mermaid-format.ts b/src/lib/mermaid-format.ts new file mode 100644 index 0000000..0687bb9 --- /dev/null +++ b/src/lib/mermaid-format.ts @@ -0,0 +1,192 @@ +import { determineDiagramType } from "@/lib/diagrams/utils"; + +export type MermaidFormatStatus = + | "changed" + | "unchanged" + | "skipped-indent-sensitive"; + +export type MermaidFormatResult = { + formatted: string; + status: MermaidFormatStatus; + diagramType: string; +}; + +const DEFAULT_INDENT = " "; + +const BLOCK_OPEN = new Set([ + "subgraph", + "loop", + "alt", + "opt", + "par", + "critical", + "break", + "rect", +]); + +const BLOCK_SAME = new Set(["else", "and", "option"]); + +const INDENT_SENSITIVE = new Set(["mindmap", "timeline"]); + +const BRACE_KEYWORD_OPEN = + /^(?:class|state|namespace)\s+\S[\s\S]*\{\s*$/i; + +/** ER entity bodies and similar: `CUSTOMER {` / `ORDER {` */ +const BRACE_GENERIC_OPEN = /^[A-Za-z_][\w-]*\s*\{\s*$/; + +function isBraceOpen(stripped: string): boolean { + return BRACE_KEYWORD_OPEN.test(stripped) || BRACE_GENERIC_OPEN.test(stripped); +} + +function extractFrontMatter(lines: string[]): { + frontMatterLines: string[]; + bodyStartIndex: number; +} { + if (lines.length === 0 || lines[0].trim() !== "---") { + return { frontMatterLines: [], bodyStartIndex: 0 }; + } + const endIndex = lines.findIndex((l, i) => i > 0 && l.trim() === "---"); + if (endIndex <= 0) { + return { frontMatterLines: [], bodyStartIndex: 0 }; + } + let bodyStartIndex = endIndex + 1; + while (bodyStartIndex < lines.length && lines[bodyStartIndex].trim() === "") { + bodyStartIndex++; + } + return { + frontMatterLines: lines.slice(0, endIndex + 1), + bodyStartIndex, + }; +} + +function collapseBlankLines(lines: string[]): string[] { + const collapsed: string[] = []; + let prevBlank = false; + for (const line of lines) { + const isBlank = line.trim() === ""; + if (isBlank && prevBlank) continue; + collapsed.push(line); + prevBlank = isBlank; + } + while (collapsed.length > 0 && collapsed[0].trim() === "") collapsed.shift(); + while (collapsed.length > 0 && collapsed[collapsed.length - 1].trim() === "") { + collapsed.pop(); + } + return collapsed; +} + +function isDiagramDeclaration(stripped: string): boolean { + if (!stripped || stripped.startsWith("%%")) return false; + return ( + /^(?:flowchart|graph)(?:\s|$)/i.test(stripped) || + /^(?:sequenceDiagram|classDiagram(?:-v2)?|erDiagram|stateDiagram(?:-v2)?|gitGraph|requirementDiagram|gantt|pie|journey|mindmap|timeline|quadrantChart|sankey(?:-beta)?|xychart(?:-beta)?|block(?:-beta)?|architecture-beta)\b/.test( + stripped, + ) || + /^C4(?:Context|Container|Component|Dynamic|Deployment)\b/.test(stripped) + ); +} + +/** + * Light cleanup for indent-sensitive diagrams: trim trailing whitespace and + * collapse blank lines without changing leading indentation of content. + */ +function lightCleanupPreserveIndent(bodyLines: string[]): string[] { + const trimmed = bodyLines.map((l) => l.replace(/\s+$/, "")); + return collapseBlankLines(trimmed); +} + +function formatBody(bodyLines: string[], indentUnit: string): string[] { + const trimmed = bodyLines.map((l) => l.replace(/\s+$/, "")); + const collapsed = collapseBlankLines(trimmed); + + let endBlockDepth = 0; + let braceDepth = 0; + let seenDiagramDecl = false; + const reformatted: string[] = []; + + for (const rawLine of collapsed) { + const stripped = rawLine.trim(); + if (stripped === "") { + reformatted.push(""); + continue; + } + + // Directives stay at column 0. + if (stripped.startsWith("%%{")) { + reformatted.push(stripped); + continue; + } + + const firstWord = stripped.split(/\s+/)[0].toLowerCase(); + const isDecl = isDiagramDeclaration(stripped); + + if (stripped === "}") { + braceDepth = Math.max(0, braceDepth - 1); + } else if (firstWord === "end") { + endBlockDepth = Math.max(0, endBlockDepth - 1); + } + + let depth: number; + if (isDecl || stripped.startsWith("%%{")) { + depth = 0; + } else { + const base = seenDiagramDecl ? 1 : 0; + if (BLOCK_SAME.has(firstWord)) { + depth = base + braceDepth + Math.max(0, endBlockDepth - 1); + } else if (stripped === "}") { + depth = base + braceDepth; + } else if (firstWord === "end") { + depth = base + braceDepth + endBlockDepth; + } else { + depth = base + braceDepth + endBlockDepth; + } + } + + reformatted.push(indentUnit.repeat(depth) + stripped); + + if (isDecl) { + seenDiagramDecl = true; + } + if (isBraceOpen(stripped)) { + braceDepth++; + } else if (BLOCK_OPEN.has(firstWord)) { + endBlockDepth++; + } + } + + return reformatted; +} + +/** + * Format Mermaid source for LiveMaid's editor Format action. + * Preserves YAML front matter. Skips structural re-indent for mindmap/timeline. + */ +export function formatMermaidSource( + code: string, + options?: { indent?: string }, +): MermaidFormatResult { + const indentUnit = options?.indent ?? DEFAULT_INDENT; + const diagramType = determineDiagramType(code); + const lines = code.split("\n"); + const { frontMatterLines, bodyStartIndex } = extractFrontMatter(lines); + const bodyLines = lines.slice(bodyStartIndex); + const frontMatter = + frontMatterLines.length > 0 ? frontMatterLines.join("\n") + "\n" : ""; + + if (INDENT_SENSITIVE.has(diagramType)) { + const cleaned = lightCleanupPreserveIndent(bodyLines); + const formatted = frontMatter + cleaned.join("\n"); + if (formatted === code) { + return { formatted: code, status: "unchanged", diagramType }; + } + return { formatted, status: "skipped-indent-sensitive", diagramType }; + } + + const reformatted = formatBody(bodyLines, indentUnit); + const formatted = frontMatter + reformatted.join("\n"); + + if (formatted === code) { + return { formatted, status: "unchanged", diagramType }; + } + return { formatted, status: "changed", diagramType }; +} diff --git a/tsconfig.json b/tsconfig.json index 2ed3f7d..c67cb87 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -19,7 +23,9 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, "include": [ @@ -28,7 +34,13 @@ "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", - "**/*.mts" + "**/*.mts", + "/root/projects/livemaid/tmp/livemaid-test-next-BuDPfG/types/**/*.ts", + "/root/projects/livemaid/tmp/livemaid-test-next-BuDPfG/dev/types/**/*.ts" ], - "exclude": ["node_modules", "scratch", "tmp"] + "exclude": [ + "node_modules", + "scratch", + "tmp" + ] } From df078b14e3fbe73eb5fb5255b7891f6fe8c47c33 Mon Sep 17 00:00:00 2001 From: Peter Liu Date: Sat, 8 Aug 2026 13:31:21 +0000 Subject: [PATCH 3/3] fix: address CodeRabbit review feedback on PR #132 - Add box to BLOCK_OPEN so sequence diagram box...end groups indent correctly - Recognize all supported diagram declarations (flowchart-elk, kanban, packet, radar, treemap, zenuml, C4) so body indentation is not dropped - Simplify depth computation (drop unreachable directive branch, merge the identical end/fallback branches) - Refactor status model: report changed/unchanged plus a separate skippedIndentSensitive flag; collapse the duplicated Monaco apply path - Preserve the input document's trailing newline so already-formatted newline-terminated documents stay unchanged - Remove machine-specific temp-directory entries from tsconfig.json include - Add unit tests covering all of the above --- src/components/editor/EditorCodePanel.tsx | 26 +---- src/lib/mermaid-format.ts | 95 ++++++++++----- src/test/mermaid-format.test.ts | 136 ++++++++++++++++++++++ tsconfig.json | 20 +--- 4 files changed, 212 insertions(+), 65 deletions(-) create mode 100644 src/test/mermaid-format.test.ts diff --git a/src/components/editor/EditorCodePanel.tsx b/src/components/editor/EditorCodePanel.tsx index ccbec34..83ed783 100644 --- a/src/components/editor/EditorCodePanel.tsx +++ b/src/components/editor/EditorCodePanel.tsx @@ -100,25 +100,6 @@ export function EditorCodePanel({ toast.info("Already formatted"); return; } - if (result.status === "skipped-indent-sensitive") { - if (result.formatted !== code) { - const editor = editorRef.current; - if (editor) { - const model = editor.getModel(); - if (model) { - editor.pushUndoStop(); - editor.executeEdits("format", [ - { range: model.getFullModelRange(), text: result.formatted, forceMoveMarkers: true }, - ]); - } - } - handleCodeChange(result.formatted); - } - toast.info( - `Structural formatting skipped for ${result.diagramType} (indent is semantic)`, - ); - return; - } const editor = editorRef.current; if (editor) { const model = editor.getModel(); @@ -127,10 +108,15 @@ export function EditorCodePanel({ editor.executeEdits("format", [ { range: model.getFullModelRange(), text: result.formatted, forceMoveMarkers: true }, ]); + editor.pushUndoStop(); } } handleCodeChange(result.formatted); - toast.success("Code formatted"); + if (result.skippedIndentSensitive) { + toast.info(`Structural formatting skipped for ${result.diagramType} (indent is semantic)`); + } else { + toast.success("Code formatted"); + } } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; toast.error(`Formatting failed: ${message}`); diff --git a/src/lib/mermaid-format.ts b/src/lib/mermaid-format.ts index 0687bb9..26651fc 100644 --- a/src/lib/mermaid-format.ts +++ b/src/lib/mermaid-format.ts @@ -1,20 +1,19 @@ import { determineDiagramType } from "@/lib/diagrams/utils"; -export type MermaidFormatStatus = - | "changed" - | "unchanged" - | "skipped-indent-sensitive"; +export type MermaidFormatStatus = "changed" | "unchanged"; export type MermaidFormatResult = { formatted: string; status: MermaidFormatStatus; diagramType: string; + skippedIndentSensitive?: boolean; }; const DEFAULT_INDENT = " "; const BLOCK_OPEN = new Set([ "subgraph", + "box", "loop", "alt", "opt", @@ -28,12 +27,51 @@ const BLOCK_SAME = new Set(["else", "and", "option"]); const INDENT_SENSITIVE = new Set(["mindmap", "timeline"]); -const BRACE_KEYWORD_OPEN = - /^(?:class|state|namespace)\s+\S[\s\S]*\{\s*$/i; +const BRACE_KEYWORD_OPEN = /^(?:class|state|namespace)\s+\S[\s\S]*\{\s*$/i; /** ER entity bodies and similar: `CUSTOMER {` / `ORDER {` */ const BRACE_GENERIC_OPEN = /^[A-Za-z_][\w-]*\s*\{\s*$/; +const DIAGRAM_DECLARATIONS = new Set([ + "flowchart", + "flowchart-elk", + "graph", + "sequencediagram", + "classdiagram", + "classdiagram-v2", + "erdiagram", + "statediagram", + "statediagram-v2", + "gitgraph", + "requirementdiagram", + "gantt", + "pie", + "journey", + "mindmap", + "timeline", + "quadrantchart", + "sankey", + "sankey-beta", + "xychart", + "xychart-beta", + "block", + "block-beta", + "architecture-beta", + "kanban", + "packet", + "packet-beta", + "radar", + "radar-beta", + "treemap", + "treemap-beta", + "zenuml", + "c4context", + "c4container", + "c4component", + "c4dynamic", + "c4deployment", +]); + function isBraceOpen(stripped: string): boolean { return BRACE_KEYWORD_OPEN.test(stripped) || BRACE_GENERIC_OPEN.test(stripped); } @@ -77,13 +115,8 @@ function collapseBlankLines(lines: string[]): string[] { function isDiagramDeclaration(stripped: string): boolean { if (!stripped || stripped.startsWith("%%")) return false; - return ( - /^(?:flowchart|graph)(?:\s|$)/i.test(stripped) || - /^(?:sequenceDiagram|classDiagram(?:-v2)?|erDiagram|stateDiagram(?:-v2)?|gitGraph|requirementDiagram|gantt|pie|journey|mindmap|timeline|quadrantChart|sankey(?:-beta)?|xychart(?:-beta)?|block(?:-beta)?|architecture-beta)\b/.test( - stripped, - ) || - /^C4(?:Context|Container|Component|Dynamic|Deployment)\b/.test(stripped) - ); + const firstWord = stripped.split(/\s+/)[0].toLowerCase(); + return DIAGRAM_DECLARATIONS.has(firstWord); } /** @@ -127,7 +160,7 @@ function formatBody(bodyLines: string[], indentUnit: string): string[] { } let depth: number; - if (isDecl || stripped.startsWith("%%{")) { + if (isDecl) { depth = 0; } else { const base = seenDiagramDecl ? 1 : 0; @@ -135,8 +168,6 @@ function formatBody(bodyLines: string[], indentUnit: string): string[] { depth = base + braceDepth + Math.max(0, endBlockDepth - 1); } else if (stripped === "}") { depth = base + braceDepth; - } else if (firstWord === "end") { - depth = base + braceDepth + endBlockDepth; } else { depth = base + braceDepth + endBlockDepth; } @@ -159,7 +190,9 @@ function formatBody(bodyLines: string[], indentUnit: string): string[] { /** * Format Mermaid source for LiveMaid's editor Format action. - * Preserves YAML front matter. Skips structural re-indent for mindmap/timeline. + * Preserves YAML front matter. Indent-sensitive diagrams (mindmap/timeline) get + * light cleanup only and report `skippedIndentSensitive` so callers can explain + * that structural re-indentation is skipped. */ export function formatMermaidSource( code: string, @@ -170,23 +203,27 @@ export function formatMermaidSource( const lines = code.split("\n"); const { frontMatterLines, bodyStartIndex } = extractFrontMatter(lines); const bodyLines = lines.slice(bodyStartIndex); - const frontMatter = - frontMatterLines.length > 0 ? frontMatterLines.join("\n") + "\n" : ""; + const frontMatter = frontMatterLines.length > 0 ? frontMatterLines.join("\n") + "\n" : ""; + const trailingNewline = code.endsWith("\n"); + const newlineSuffix = trailingNewline ? "\n" : ""; if (INDENT_SENSITIVE.has(diagramType)) { const cleaned = lightCleanupPreserveIndent(bodyLines); - const formatted = frontMatter + cleaned.join("\n"); - if (formatted === code) { - return { formatted: code, status: "unchanged", diagramType }; - } - return { formatted, status: "skipped-indent-sensitive", diagramType }; + const formatted = frontMatter + cleaned.join("\n") + newlineSuffix; + return { + formatted, + status: formatted === code ? "unchanged" : "changed", + diagramType, + skippedIndentSensitive: true, + }; } const reformatted = formatBody(bodyLines, indentUnit); - const formatted = frontMatter + reformatted.join("\n"); + const formatted = frontMatter + reformatted.join("\n") + newlineSuffix; - if (formatted === code) { - return { formatted, status: "unchanged", diagramType }; - } - return { formatted, status: "changed", diagramType }; + return { + formatted, + status: formatted === code ? "unchanged" : "changed", + diagramType, + }; } diff --git a/src/test/mermaid-format.test.ts b/src/test/mermaid-format.test.ts new file mode 100644 index 0000000..b3ed35b --- /dev/null +++ b/src/test/mermaid-format.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { formatMermaidSource } from "@/lib/mermaid-format"; + +describe("formatMermaidSource", () => { + describe("status model", () => { + it("returns unchanged for already-formatted source", () => { + const code = "flowchart LR\n A --> B\n"; + const result = formatMermaidSource(code); + expect(result.status).toBe("unchanged"); + expect(result.formatted).toBe(code); + }); + + it("returns changed and reformats when indentation is missing", () => { + const result = formatMermaidSource("flowchart LR\nA --> B\n"); + expect(result.status).toBe("changed"); + expect(result.formatted).toBe("flowchart LR\n A --> B\n"); + }); + + it("reports skippedIndentSensitive only for indent-sensitive types", () => { + expect(formatMermaidSource("mindmap\n root\n").skippedIndentSensitive).toBe(true); + expect(formatMermaidSource("timeline\n title A\n").skippedIndentSensitive).toBe(true); + expect( + formatMermaidSource("flowchart LR\n A --> B\n").skippedIndentSensitive, + ).toBeUndefined(); + }); + }); + + describe("trailing newline preservation", () => { + it("keeps an existing trailing newline so formatted docs stay unchanged", () => { + const code = "flowchart LR\n A --> B\n"; + const result = formatMermaidSource(code); + expect(result.status).toBe("unchanged"); + expect(result.formatted).toBe(code); + }); + + it("does not add a trailing newline that was not present", () => { + const result = formatMermaidSource("flowchart LR\n A --> B"); + expect(result.status).toBe("unchanged"); + expect(result.formatted.endsWith("\n")).toBe(false); + }); + }); + + describe("block indentation", () => { + it("indents nested subgraphs", () => { + const code = "flowchart TB\nsubgraph A\nsubgraph B\nA1-->B1\nend\nend\n"; + const result = formatMermaidSource(code); + expect(result.formatted).toBe( + "flowchart TB\n subgraph A\n subgraph B\n A1-->B1\n end\n end\n", + ); + }); + + it("indents sequence diagram box...end groups", () => { + const code = "sequenceDiagram\nbox Aqua Alice & Bob\nalice->>bob: hi\nend\n"; + const result = formatMermaidSource(code); + expect(result.formatted).toBe( + "sequenceDiagram\n box Aqua Alice & Bob\n alice->>bob: hi\n end\n", + ); + }); + + it("aligns else at the parent block level inside sequence alt", () => { + const code = + "sequenceDiagram\nAlice->>Bob: OK\nalt success\nAlice->>Bob: done\nelse failure\nAlice->>Bob: fail\nend\n"; + const result = formatMermaidSource(code); + expect(result.formatted).toBe( + "sequenceDiagram\n Alice->>Bob: OK\n alt success\n Alice->>Bob: done\n else failure\n Alice->>Bob: fail\n end\n", + ); + }); + }); + + describe("brace bodies", () => { + it("re-indents class diagram brace bodies", () => { + const code = "classDiagram\nclass Animal {\nString name\n}\n"; + const result = formatMermaidSource(code); + expect(result.formatted).toBe( + "classDiagram\n class Animal {\n String name\n }\n", + ); + }); + + it("re-indents ER diagram entity bodies", () => { + const code = "erDiagram\nCUSTOMER {\nstring id\n}\n"; + const result = formatMermaidSource(code); + expect(result.formatted).toBe("erDiagram\n CUSTOMER {\n string id\n }\n"); + }); + }); + + describe("diagram declaration detection", () => { + it.each([ + ["flowchart-elk", "flowchart-elk LR"], + ["kanban", "kanban"], + ["packet-beta", "packet-beta"], + ["radar-beta", "radar-beta"], + ["treemap-beta", "treemap-beta"], + ["zenuml", "zenuml"], + ])("indents body after %s declaration", (_label, decl) => { + const code = `${decl}\nline one\n`; + const result = formatMermaidSource(code); + expect(result.status).toBe("changed"); + expect(result.formatted).toBe(`${decl}\n line one\n`); + }); + }); + + describe("front matter", () => { + it("preserves YAML front matter and indents the body", () => { + const code = "---\nconfig:\n theme: dark\n---\nflowchart LR\nA --> B\n"; + const result = formatMermaidSource(code); + expect(result.formatted).toBe( + "---\nconfig:\n theme: dark\n---\nflowchart LR\n A --> B\n", + ); + }); + }); + + describe("whitespace handling", () => { + it("collapses consecutive blank lines", () => { + const result = formatMermaidSource("flowchart LR\nA --> B\n\n\nC --> D\n"); + expect(result.formatted).toBe("flowchart LR\n A --> B\n\n C --> D\n"); + }); + }); + + describe("indent-sensitive diagrams", () => { + it("keeps mindmap leading indentation intact", () => { + const code = "mindmap\n root\n child\n leaf\n"; + const result = formatMermaidSource(code); + expect(result.status).toBe("unchanged"); + expect(result.skippedIndentSensitive).toBe(true); + expect(result.formatted).toBe(code); + }); + + it("applies light cleanup (trailing whitespace) without re-indenting", () => { + const code = "timeline\n title A \n"; + const result = formatMermaidSource(code); + expect(result.status).toBe("changed"); + expect(result.skippedIndentSensitive).toBe(true); + expect(result.formatted).toBe("timeline\n title A\n"); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index c67cb87..2ed3f7d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2017", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -23,9 +19,7 @@ } ], "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } }, "include": [ @@ -34,13 +28,7 @@ "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", - "**/*.mts", - "/root/projects/livemaid/tmp/livemaid-test-next-BuDPfG/types/**/*.ts", - "/root/projects/livemaid/tmp/livemaid-test-next-BuDPfG/dev/types/**/*.ts" + "**/*.mts" ], - "exclude": [ - "node_modules", - "scratch", - "tmp" - ] + "exclude": ["node_modules", "scratch", "tmp"] }