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 diff --git a/src/components/editor/EditorCodePanel.tsx b/src/components/editor/EditorCodePanel.tsx index 933dbc5..83ed783 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,33 @@ 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++; + 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 }, + ]); + editor.pushUndoStop(); + } } - } - - 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 }, - ]); + handleCodeChange(result.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}`); } - 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..26651fc --- /dev/null +++ b/src/lib/mermaid-format.ts @@ -0,0 +1,229 @@ +import { determineDiagramType } from "@/lib/diagrams/utils"; + +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", + "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*$/; + +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); +} + +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; + const firstWord = stripped.split(/\s+/)[0].toLowerCase(); + return DIAGRAM_DECLARATIONS.has(firstWord); +} + +/** + * 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) { + 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 { + 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. 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, + 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" : ""; + const trailingNewline = code.endsWith("\n"); + const newlineSuffix = trailingNewline ? "\n" : ""; + + if (INDENT_SENSITIVE.has(diagramType)) { + const cleaned = lightCleanupPreserveIndent(bodyLines); + 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") + newlineSuffix; + + 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"); + }); + }); +});