ShippedMedium

Mermaid flowchart import (selection/file, auto-layout via #14) and export (clipboard/.mmd) for Diagrammer

Proposed by Jonathan Miller 1 hour agoFunding opened 1 hour agoFunded 1 hour agoShipped 1 hour ago
Specification

Motivation

Let developers and coding agents move diagrams between Diagrammer's visual editor and Mermaid-based docs (Markdown, READMEs).

Scope

Supported syntax (hand-written parser)

The supported subset is Mermaid flowchart or graph with direction TD, TB, BT, LR or RL.

  • Nodes: A, A[label], A(label), A{label}, A((label)), and quoted labels (A["label"]).
  • Edges: -->, ---, -.-> and ==>.
  • Edge labels: -->|text| and -- text -->.
  • Chains: A --> B --> C.
  • Multiple statements per line: separated by ;.
  • Comments: %% lines.

Constraints (from round 1)

  • The parser is hand-written TypeScript with no VS Code API imports. It lives in its own module and returns {nodes, edges, direction, warnings}.
  • Do not add mermaid as a dependency.
  • Layout reuses the auto-layout engine from #14 (dagre-based) if it exists in the repo. Otherwise dagre may be added.
  • Shape mapping is [ ]→rectangle, ( )→rounded, { }→diamond, (( ))→circle. Each maps only where Diagrammer already has that shape; otherwise it falls back to rectangle. No new shape types are added.

Import

  • Diagrammer: Import Mermaid from Text. Assumption: it uses the non-empty selection in the active text editor, else the whole active text editor's content. If there is no text editor, it opens an untitled mermaid document and shows an info message asking the user to paste and re-run the command.
  • Diagrammer: Import Mermaid File. Shows an open dialog filtered to .mmd and .mermaid.
  • Output document. Import always creates a new Diagrammer document (untitled, or a new file opened in the existing custom editor). It never replaces or merges into an open diagram.
  • Preserved data. Node labels, edge labels and edge source→target direction are preserved.
  • Edge styles. Assumption: dotted, thick and no-arrow edge styles are mapped only if the edge model already supports them; otherwise they import as normal edges.
  • Layout. Nodes are laid out in rank order along the Mermaid direction with no overlapping bounding boxes.
  • Unsupported constructs. subgraph/end, classDef, class, style, linkStyle and click are skipped. Each produces a warning naming the construct and its 1-based line number. Warnings are shown to the user after import.
  • Non-flowchart input. Input such as sequenceDiagram or classDiagram, or input whose first non-comment line is not flowchart/graph, raises an error. In that case no document is created.

Export

  • Diagrammer: Copy as Mermaid copies the active diagram's Mermaid text to the clipboard.
  • Diagrammer: Export Mermaid File opens a save dialog and writes a .mmd file.
  • Header. The output starts with flowchart <DIR>. The direction defaults to TD unless the diagram stores one.
  • Lines. Each node declaration and each edge goes on its own line, indented by 4 spaces.
  • Node IDs. IDs are sanitized to [A-Za-z0-9_]. Collisions after sanitizing are made unique with a numeric suffix.
  • Labels. Labels are always quoted. " is escaped as #quot;, consistent with Mermaid entity syntax, and brackets and pipes are safe inside quotes. Shapes are emitted using the inverse of the import mapping.
  • No active diagram. If no diagram is active, show an error.

Docs

Add a README section with:

  • import and export examples,
  • the supported syntax list,
  • the unsupported constructs list.

Acceptance criteria

  1. Parse counts. Parsing a sample flowchart yields the expected node count, edge count, labels, edge labels, shapes and direction.
  2. Warnings. Input containing subgraph and classDef yields warnings that include the construct name and the correct line numbers. Supported statements in the same input still parse.
  3. Rejection. sequenceDiagram input yields an error, not a result.
  4. Layout. Imported nodes have distinct, non-overlapping positions ordered by rank along the direction. For example, for A-->B-->C in TD the y-coordinates are increasing; in LR the x-coordinates are increasing.
  5. Export format. Exported output starts with flowchart and has one statement per line.
  6. Escaping. Labels containing quotes, brackets and pipes are escaped, and the exported text re-parses to the original labels.
  7. Round trip. diagram → Mermaid → parse yields the same node label set and the same set of (source label, target label, edge label) tuples.
  8. Commands. All four commands are declared in package.json contributes.commands, registered in activation, and appear in the command palette.
  9. Build and tests. The extension compiles, and existing and new tests pass. No mermaid dependency is added.
  10. README. The README documents the supported and unsupported syntax.

Out of scope

  • Non-flowchart diagram types.
  • Subgraphs and styling.
  • Pixel-perfect equivalence.
  • Storing coordinates in Mermaid.
  • Replacing the native JSON format.
  • Live sync.
  • Merging an import into an existing diagram.
  • New shape types.
Build #16
SUCCEEDED

Model claude-opus-5-5 · ceiling $5 · started 1 hour ago · finished 1 hour ago

22agent turns
1.2Mtokens
$1.41spent at list price

Builder summary
## Mermaid flowchart import and export (#16) This adds four commands for moving diagrams between Diagrammer and Mermaid flowcharts. The parser is hand-written, so `mermaid` is not a dependency. - **Import Mermaid from Text** – imports the selection in the active text editor, or the whole editor if nothing is selected. - **Import Mermaid File** – imports a `.mmd` or `.mermaid` file. - **Copy as Mermaid** – copies the active diagram as Mermaid text. - **Export Mermaid File** – saves the active diagram as a `.mmd` file. ### Verification - **Unit tests:** `npm test` passes (136 tests, 17 of them new in `src/test/unit/mermaid.test.ts`). `npm run compile` and `npm run lint` are clean. - **Integration tests:** I added 4 to `src/test/integration/suite/extension.test.ts`, plus a fixture file `test-fixtures/workspace/flow.mmd`. **They compile but have not been run**: `npm run test:integration` fails here because the sandbox can't reach `update.code.visualstudio.com` to download VS Code. Please run them in CI or locally (`xvfb-run -a npm run test:integration`). - **README examples:** I parsed both Mermaid examples in the README with the built code to confirm they behave as described. ### What changed - **`src/mermaid/parse.ts`** (new, no VS Code imports): `parseMermaid(text)` returns `{nodes, edges, direction, warnings}`. It covers the full syntax subset in the spec: `flowchart`/`graph` with TD/TB/BT/LR/RL (default TD), the four node shapes and quoted labels, all four edge types, both edge-label forms, chains, `;` and `%%` comments. Beyond the spec it also: - skips a leading `---` front-matter block; - decodes entity codes such as `#quot;` and `#35;`, and turns `<br>` into line breaks. - **Unsupported constructs** (`subgraph`, `end`, `classDef`, `class`, `style`, `linkStyle`, `click`, plus `direction` and the `:::class` shorthand) are skipped. Each gives a warning naming the construct and its 1-based line number. - **Statements it can't parse** (e.g. `A --> B & C`) are skipped with a warning; the rest of the input still imports. - **Other Mermaid shapes** (e.g. `[(db)]`) import as rectangles with a warning. - **Non-flowchart input** throws `MermaidParseError`. - **`src/mermaid/convert.ts`** (new, no VS Code imports): - `mermaidToDiagram` builds a diagram and places nodes with the existing dagre auto-layout from #14. The layout engine only does top-to-bottom and left-to-right, so for BT and RL the result is flipped along the rank axis. - `diagramToMermaid` writes `flowchart TD`, then one 4-space-indented line per node and per edge. IDs are cleaned to `[A-Za-z0-9_]`, collisions get `_2`, `_3`, … and Mermaid keywords such as `end` get a `_` suffix. Labels are always quoted: `"` becomes `#quot;`, a `#` that would read as an entity becomes `#35;`, and line breaks become `<br>`. - **`src/mermaid/commands.ts`** (new): the four commands. - Import always writes a new file and never overwrites: `flow.mmd` becomes `flow.diagram.json` next to it, then `flow-1.diagram.json`, and so on. - Text from an unsaved editor goes to `mermaid-import.diagram.json` in the first workspace folder. With no folder open, a save dialog appears. - Parse errors show an error and create no file. Warnings appear in a notification, with the full list in a "Diagrammer" output channel. - With no text editor open, Import from Text opens an untitled `mermaid` document (plain text if no extension provides that language) and asks you to paste and run it again. - Copy and Export show an error when no diagram is active. - Optional `Uri` arguments let scripts skip the dialogs. - **`src/newDiagram.ts`**: I moved the file-creation logic into `createDiagramFile(diagram, {baseName, folder, title})` so import can share it. `newDiagram` behaves as before. - **`src/extension.ts`, `package.json`**: the commands are registered on activation and declared in `contributes.commands`. They are not hidden behind a `when` clause, so they always show in the command pa
View pull request ↗Download patchnabled/feature-16-job-16

Agent log
2026-10-04T01:48:22Z  [claude] Integration tests can't run here because the VS Code download is blocked. They compile, though. Next, the README.
2026-10-04T01:48:22Z  [bash] cd /workspace/repo && python3 - <<'EOF' p='README.md' s=open(p).read() s=s.replace('''- **VS Code integration** –''','''- **Mermaid import and export** – turn a Mermaid `flowchart` from a README or a `.mmd` file into a   laid-out diagram, or copy/export a diagram as Mermaid. See [Mermaid](#mermaid)....
2026-10-04T01:48:28Z  [bash] cd /workspace/repo && npm run compile-tests >/dev/null && node -e " const {parseMermaid}=require('./out/mermaid/parse');const {mermaidToDiagram}=require('./out/mermaid/convert'); const s=require('fs').readFileSync('README.md','utf8'); for (const m of s.matchAll(/\`\`\`mermaid\n([^]*?)\`\`\`/g)){cons...
2026-10-04T01:48:49Z  [submit] success=true
## Mermaid flowchart import and export (#16)

This adds four commands for moving diagrams between Diagrammer and Mermaid flowcharts. The parser is hand-written, so `mermaid` is not a dependency.

- **Import Mermaid from Text** – imports the selection in the active text editor, or the whole editor if nothing is selected.
- **Import Mermaid File** – imports a `.mmd` or `.mermaid` file.
- **Copy as Mermaid** – copies the active diagram as Mermaid text.
- **Export Mermaid File** – saves the active diagram as a `.mmd` file.

### Verification
- **Unit tests:** `npm test` passes (136 tests, 17 of them new in `src/test/unit/mermaid.test.ts`). `npm run compile` and `npm run lint` are clean.
- **Integration tests:** I added 4 to `src/test/integration/suite/extension.test.ts`, plus a fixture file `test-fixtures/workspace/flow.mmd`. **They compile but have not been run**: `npm run test:integration` fails here because the sandbox can't reach `update.code.visualstudio.com` to download VS Code. Please run them in CI or locally (`xvfb-run -a npm run test:integration`).
- **README examples:** I parsed both Mermaid examples in the README with the built code to confirm they behave as described.

### What changed
- **`src/mermaid/parse.ts`** (new, no VS Code imports): `parseMermaid(text)` returns `{nodes, edges, direction, warnings}`. It covers the full syntax subset in the spec: `flowchart`/`graph` with TD/TB/BT/LR/RL (default TD), the four node shapes and quoted labels, all four edge types, both edge-label forms, chains, `;` and `%%` comments. Beyond the spec it also:
  - skips a leading `---` front-matter block;
  - decodes entity codes such as `#quot;` and `#35;`, and turns `<br>` into line breaks.
  - **Unsupported constructs** (`subgraph`, `end`, `classDef`, `class`, `style`, `linkStyle`, `click`, plus `direction` and the `:::class` shorthand) are skipped. Each gives a warning naming the construct and its 1-based line number.
  - **Statements it can't parse** (e.g. `A --> B & C`) are skipped with a warning; the rest of the input still imports.
  - **Other Mermaid shapes** (e.g. `[(db)]`) import as rectangles with a warning.
  - **Non-flowchart input** throws `MermaidParseError`.
- **`src/mermaid/convert.ts`** (new, no VS Code imports):
  - `mermaidToDiagram` builds a diagram and places nodes with the existing dagre auto-layout from #14. The layout engine only does top-to-bottom and left-to-right, so for BT and RL the result is flipped along the rank axis.
  - `diagramToMermaid` writes `flowchart TD`, then one 4-space-indented line per node and per edge. IDs are cleaned to `[A-Za-z0-9_]`, collisions get `_2`, `_3`, … and Mermaid keywords such as `end` get a `_` suffix. Labels are always quoted: `"` becomes `#quot;`, a `#` that would read as an entity becomes `#35;`, and line breaks become `<br>`.
- **`src/mermaid/commands.ts`** (new): the four commands.
  - Import always writes a new file and never overwrites: `flow.mmd` becomes `flow.diagram.json` next to it, then `flow-1.diagram.json`, and so on.
  - Text from an unsaved editor goes to `mermaid-import.diagram.json` in the first workspace folder. With no folder open, a save dialog appears.
  - Parse errors show an error and create no file. Warnings appear in a notification, with the full list in a "Diagrammer" output channel.
  - With no text editor open, Import from Text opens an untitled `mermaid` document (plain text if no extension provides that language) and asks you to paste and run it again.
  - Copy and Export show an error when no diagram is active.
  - Optional `Uri` arguments let scripts skip the dialogs.
- **`src/newDiagram.ts`**: I moved the file-creation logic into `createDiagramFile(diagram, {baseName, folder, title})` so import can share it. `newDiagram` behaves as before.
- **`src/extension.ts`, `package.json`**: the commands are registered on activation and declared in `contributes.commands`. They are not hidden behind a `when` clause, so they always show in the command palette.
- **`README.md`**: a new "Mermaid" section with import and export examples and the supported and unsupported syntax lists.

### Mapping decisions worth checking
- **`(( ))` → `ellipse`:** Diagrammer has no circle shape, so circles import as ellipses with equal width and height, and ellipses export as `(( ))`. Text and sticky nodes export as rectangles.
- **Edge styles:** the edge model has no dotted, thick or no-arrow styles, so those import as normal connectors. The parser still records the style in its output.
- **Direction:** the diagram model doesn't store a direction, so export always writes `TD`. `diagramToMermaid` takes an optional direction for later use.
- **Node sizes:** imported nodes are widened (up to 320px) to fit long labels.
- **Warning line numbers:** they count from the start of the text that was parsed. When importing a selection, that means from the start of the selection, not the document.
2026-10-04T01:48:58Z  [claude] The Mermaid import and export feature is built and pushed to `nabled/feature-16-job-16`. The VS Code integration tests have not been run: the sandbox couldn't download VS Code. Please run them with `xvfb-run -a npm run test:integration` before merging.

The unit tests pass (`npm test`, 136 tests, 17 of them new for this feature), and the extension compiles and lints cleanly. Both Mermaid examples in the README parse and import as described. `mermaid` was not added as a dependency.

**The four commands:**
- **Import Mermaid from Text:** imports the selection in the active text editor, or the whole editor if nothing is selected. With no text editor open, it opens an empty document and asks you to paste and run the command again.
- **Import Mermaid File:** imports a `.mmd` or `.mermaid` file.
- **Copy as Mermaid:** copies the active diagram as Mermaid text.
- **Export Mermaid File:** saves the active diagram as a `.mmd` file.

Imports always create a new file and never overwrite one: `flow.mmd` becomes `flow.diagram.json` next to it, then `flow-1.diagram.json`, and so on. Nodes are laid out with the existing auto-layout from #14, in order along the Mermaid direction. Unsupported constructs like `subgraph` and `classDef` are skipped with a warning naming the construct and line number. Non-flowchart input like `sequenceDiagram` shows an error and creates no file.

**Choices you may want to review:**
- **Circles:** Diagrammer has no circle shape, so `(( ))` imports as an ellipse with equal width and height, and ellipses export back as `(( ))`. Text and sticky-note shapes export as rectangles.
- **Edge styles:** diagrams can't store dotted, thick or no-arrow connectors, so those import as normal connectors.
- **Export direction:** diagrams don't store a direction, so export always writes `flowchart TD`, even for a diagram imported from an `LR` flowchart.
- **Warning line numbers:** they count from the start of the parsed text. When importing a selection, that's the start of the selection, not the file.
- **New-file helper:** I moved the file-creation code in `src/newDiagram.ts` into a shared helper so import could reuse it. "New Diagram" works the same as before.

The parser, conversion and commands are in `src/mermaid/`, and the README has a new "Mermaid" section listing the supported and unsupported syntax.
2026-10-04T01:48:59Z  Changed files:
modified  README.md  +95 -1
modified  package.json  +20 -0
modified  src/extension.ts  +2 -0
added  src/mermaid/commands.ts  +179 -0
added  src/mermaid/convert.ts  +152 -0
added  src/mermaid/parse.ts  +361 -0
modified  src/newDiagram.ts  +29 -7
modified  src/test/integration/suite/extension.test.ts  +60 -0
added  src/test/unit/mermaid.test.ts  +329 -0
added  test-fixtures/workspace/flow.mmd  +7 -0
2026-10-04T01:49:00Z  Opened pull request https://github.com/nabledhq/diagrammerext/pull/4
2026-10-04T01:49:00Z  Finished: success=true turns=22 tokens(in/out)=1185344/39475 list cost=$1.41

Show patch
diff --git a/README.md b/README.md
index 24998a5..d0c2edc 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,8 @@ reviewed, diffed and versioned like any other file.
   (e.g. GitHub Copilot) turns it into validated edit operations, you review a summary, and only then
   is it applied as one undoable edit. Agents can call `diagrammer.applyOperations` directly. See
   [AI editing](#ai-editing).
+- **Mermaid import and export** – turn a Mermaid `flowchart` from a README or a `.mmd` file into a
+  laid-out diagram, or copy/export a diagram as Mermaid. See [Mermaid](#mermaid).
 - **VS Code integration** – edits mark the file dirty, `Ctrl+S`/`Cmd+S` saves, and
   `Ctrl+Z`/`Ctrl+Y` (or `Cmd+Z`/`Cmd+Shift+Z`) undo and redo through VS Code's edit history. Hot
   exit/backups, *Save As* and *Revert File* are supported. Colours follow the active VS Code theme.
@@ -119,6 +121,96 @@ Operations (`addNode`, `removeNode`, `renameNode`, `moveNode`, `addConnector`, `
 Schema are documented in [docs/operations.md](docs/operations.md). Batches are atomic and applied
 through the normal edit path, so undo and saving work as usual.
 
+## Mermaid
+
+Diagrammer can import and export [Mermaid](https://mermaid.js.org/) flowcharts, so diagrams can move
+between the visual editor and Markdown docs. Parsing is done by a small built-in parser
+(`src/mermaid/parse.ts`); the `mermaid` package is not used.
+
+| Command | ID | What it does |
+| --- | --- | --- |
+| `Diagrammer: Import Mermaid from Text` | `diagrammer.importMermaidFromText` | Imports the selection in the active text editor, or its whole content if nothing is selected. Without a text editor it opens an empty untitled document to paste into; run the command again afterwards. |
+| `Diagrammer: Import Mermaid File` | `diagrammer.importMermaidFile` | Picks a `.mmd` / `.mermaid` file and imports it. |
+| `Diagrammer: Copy as Mermaid` | `diagrammer.copyAsMermaid` | Copies the active diagram as Mermaid text to the clipboard. |
+| `Diagrammer: Export Mermaid File` | `diagrammer.exportMermaidFile` | Saves the active diagram as a `.mmd` file. |
+
+### Import
+
+An import always creates a **new** diagram file and opens it; it never changes an open diagram.
+The file is named after the source (`flow.mmd` → `flow.diagram.json`, next to it; text from an
+unsaved editor → `mermaid-import.diagram.json` in the first workspace folder) and gets a `-1`,
+`-2`, … suffix instead of overwriting an existing file. Nodes are placed with
+[auto layout](#auto-layout) in rank order along the Mermaid direction (`TD`/`TB` downwards, `BT`
+upwards, `LR` to the right, `RL` to the left), without overlaps.
+
+```mermaid
+flowchart LR
+    A[Client] -->|HTTP| B(API)
+    B --> C{Cached?}
+    C -- yes --> D((Cache))
+    C -- no --> E[Database]
+```
+
+imports as five nodes (rectangle, rounded rectangle, diamond, ellipse, rectangle) and four
+connectors labelled `HTTP`, (none), `yes` and `no`, laid out left to right.
+
+**Supported syntax**
+
+- Header: `flowchart` or `graph`, optionally followed by `TD`, `TB`, `BT`, `LR` or `RL` (default `TD`).
+- Nodes: `A`, `A[label]`, `A(label)`, `A{label}`, `A((label))`, and quoted labels such as
+  `A["label"]`. A node without a label uses its id as label.
+- Shapes: `[ ]` → rectangle, `( )` → rounded rectangle, `{ }` → diamond, `(( ))` → ellipse
+  (drawn as a circle). Other Mermaid shapes (`[( )]`, `{{ }}`, `([ ])`, …) import as rectangles
+  with a warning.
+- Edges: `-->`, `---`, `-.->` and `==>` (and longer variants such as `--->`). Diagrammer
+  connectors have a single style, so dotted, thick and arrow-less edges import as normal
+  connectors; their direction (source → target) is kept.
+- Edge labels: `A -->|text| B`, `A -->|"text"| B` and `A -- text --> B` (also `-. text .->` and
+  `== text ==>`).
+- Chains: `A --> B --> C`.
+- Several statements on one line separated by `;`.
+- `%%` comment lines (including `%%{init: …}%%` directives) and a leading `---` front-matter block.
+- Entity codes in labels (`#quot;`, `#35;`, …) and `<br>` line breaks.
+
+**Unsupported constructs** are skipped and reported as warnings with their 1-based line number
+(shown after the import; *Show Warnings* opens the full list in the *Diagrammer* output channel):
+
+- `subgraph` … `end` (the nodes and edges inside a subgraph are still imported),
+- `classDef`, `class` and the `:::class` shorthand,
+- `style` and `linkStyle`,
+- `click`,
+- `direction` inside subgraphs,
+- any other statement the parser does not understand (for example `A --> B & C`).
+
+Input that is not a flowchart – e.g. `sequenceDiagram` or `classDiagram`, or text whose first
+non-comment line is not `flowchart`/`graph` – is rejected with an error and no file is created.
+
+### Export
+
+```mermaid
+flowchart TD
+    node_1["Client"]
+    node_2("API #quot;v2#quot;")
+    node_3{"Cached?"}
+    node_1 -->|"HTTP"| node_2
+    node_2 --> node_3
+```
+
+- The output starts with `flowchart TD`, followed by one line per node and then one line per
+  connector, each indented by four spaces. Positions and sizes are not exported.
+- Node ids are reduced to `[A-Za-z0-9_]` (`node-1` → `node_1`); ids that collide afterwards get a
+  numeric suffix (`node_1_2`), and Mermaid keywords such as `end` get a `_` suffix.
+- Labels are always quoted. `"` is written as `#quot;` (and a `#` that would read as an entity as
+  `#35;`), line breaks as `<br>`; brackets and pipes are safe inside the quotes.
+- Shapes use the inverse of the import mapping: rectangle `[ ]`, rounded rectangle `( )`,
+  diamond `{ }`, ellipse `(( ))`; text and sticky notes become rectangles.
+- Without an active Diagrammer editor the export commands show an error.
+
+When called through `vscode.commands.executeCommand`, `importMermaidFile` accepts the `Uri` of the
+file to import, and `copyAsMermaid` / `exportMermaidFile` accept the `Uri` of an open diagram
+(`exportMermaidFile` also takes the target `Uri` as a second argument, skipping the save dialog).
+The commands return the created `Uri` or the Mermaid text, or `undefined` if nothing happened.
+
 ## File format
 
 ```json
@@ -171,7 +263,7 @@ Development Host. In it, run **Diagrammer: New Diagram** from the Command Palett
 | `npm run compile` | Type-check (`tsc --noEmit`) and bundle the extension and webview with esbuild. |
 | `npm run watch` | Rebuild on change. |
 | `npm run lint` | Run ESLint (typescript-eslint) over `src/`. |
-| `npm test` | Compile and run the unit tests with Mocha (model, layout engine, AI operations and edit flow, webview canvas in jsdom). |
+| `npm test` | Compile and run the unit tests with Mocha (model, layout engine, AI operations and edit flow, Mermaid import/export, webview canvas in jsdom). |
 | `npm run test:integration` | Launch VS Code via `@vscode/test-electron` and run the end-to-end tests. Downloads VS Code on first run; on Linux CI wrap it in `xvfb-run -a`. |
 | `npm run package` | Production (minified) bundle. |
 
@@ -181,6 +273,8 @@ Project layout:
 - `src/diagramEditor.ts` – `CustomEditorProvider`, document model, save/revert/backup, undo/redo.
 - `src/newDiagram.ts` – the `Diagrammer: New Diagram` command.
 - `src/layout/index.ts` – pure auto-layout engine (dagre), usable without VS Code.
+- `src/mermaid/` – Mermaid support: `parse.ts` (pure flowchart parser), `convert.ts` (pure
+  Mermaid ↔ diagram conversion and layout) and `commands.ts` (the import/export commands).
 - `src/ai/` – AI editing: `operations.ts` (pure operation schema, validator and `applyOperations`),
   `prompt.ts`, `editSession.ts` (the confirm-before-apply flow), `provider.ts`
   (`DiagramAIProvider`), `vscodeLmProvider.ts` (`vscode.lm`) and `commands.ts`.
diff --git a/package.json b/package.json
index 5644938..4b1679a 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,26 @@
         "command": "diagrammer.applyOperations",
         "title": "Apply Diagram Operations",
         "category": "Diagrammer"
+      },
+      {
+        "command": "diagrammer.importMermaidFromText",
+        "title": "Import Mermaid from Text",
+        "category": "Diagrammer"
+      },
+      {
+        "command": "diagrammer.importMermaidFile",
+        "title": "Import Mermaid File",
+        "category": "Diagrammer"
+      },
+      {
+        "command": "diagrammer.copyAsMermaid",
+        "title": "Copy as Mermaid",
+        "category": "Diagrammer"
+      },
+      {
+        "command": "diagrammer.exportMermaidFile",
+        "title": "Export Mermaid File",
+        "category": "Diagrammer"
       }
     ],
     "menus": {
diff --git a/src/extension.ts b/src/extension.ts
index 093082f..7bf75fa 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -3,6 +3,7 @@ import { registerAICommands } from './ai/commands';
 import { VsCodeLanguageModelProvider } from './ai/vscodeLmProvider';
 import { DiagramEditorProvider, RenderReport } from './diagramEditor';
 import { LayoutMode } from './layout';
+import { registerMermaidCommands } from './mermaid/commands';
 import { NEW_DIAGRAM_COMMAND, newDiagram } from './newDiagram';
 
 export interface DiagrammerApi {
@@ -29,6 +30,7 @@ export function activate(context: vscode.ExtensionContext): DiagrammerApi {
         );
     }
     registerAICommands(context, provider, new VsCodeLanguageModelProvider());
+    registerMermaidCommands(context, provider);
     return { onDidRender: provider.onDidRender };
 }
 
diff --git a/src/mermaid/commands.ts b/src/mermaid/commands.ts
new file mode 100644
index 0000000..2bf1eda
--- /dev/null
+++ b/src/mermaid/commands.ts
@@ -0,0 +1,179 @@
+import * as path from 'path';
+import * as vscode from 'vscode';
+import type { DiagramDocument, DiagramEditorProvider } from '../diagramEditor';
+import { createDiagramFile } from '../newDiagram';
+import { diagramToMermaid, mermaidToDiagram } from './convert';
+import { MermaidParseError, MermaidWarning, parseMermaid } from './parse';
+
+export const IMPORT_MERMAID_TEXT_COMMAND = 'diagrammer.importMermaidFromText';
+export const IMPORT_MERMAID_FILE_COMMAND = 'diagrammer.importMermaidFile';
+export const COPY_AS_MERMAID_COMMAND = 'diagrammer.copyAsMermaid';
+export const EXPORT_MERMAID_FILE_COMMAND = 'diagrammer.exportMermaidFile';
+
+const MERMAID_FILE_FILTERS = { Mermaid: ['mmd', 'mermaid'] };
+const SHOW_WARNINGS = 'Show Warnings';
+
+/**
+ * Registers the Mermaid import/export commands. Each command also accepts an optional `Uri`
+ * argument so scripts and agents can skip the dialogs:
+ * - import file: the `.mmd` file to import;
+ * - copy/export: the open diagram to export (defaults to the active Diagrammer editor);
+ * - export file: a second `Uri` for the target file.
+ */
+export function registerMermaidCommands(context: vscode.ExtensionContext, editors: DiagramEditorProvider): void {
+    let output: vscode.OutputChannel | undefined;
+    const showWarnings = (source: string, warnings: MermaidWarning[]): void => {
+        if (warnings.length === 0) {
+            return;
+        }
+        if (!output) {
+            output = vscode.window.createOutputChannel('Diagrammer');
+            context.subscriptions.push(output);
+        }
+        output.appendLine(`Mermaid import from ${source}: ${warnings.length} warning(s)`);
+        for (const w of warnings) {
+            output.appendLine(`  ${w.message}`);
+        }
+        const preview = warnings
+            .slice(0, 3)
+            .map((w) => w.message)
+            .join(' ');
+        const more = warnings.length > 3 ? ` (+${warnings.length - 3} more)` : '';
+        const channel = output;
+        void vscode.window
+            .showWarningMessage(`Mermaid import skipped some content. ${preview}${more}`, SHOW_WARNINGS)
+            .then((choice) => choice === SHOW_WARNINGS && channel.show(true));
+    };
+
+    const importText = async (text: string, source: string, folder: vscode.Uri | undefined, baseName: string) => {
+        let flowchart;
+        try {
+            flowchart = parseMermaid(text);
+        } catch (err) {
+            if (err instanceof MermaidParseError) {
+                void vscode.window.showErrorMessage(`Cannot import Mermaid from ${source}: ${err.message}`);
+                return undefined;
+            }
+            throw err;
+        }
+        const target = await createDiagramFile(mermaidToDiagram(flowchart), {
+            baseName,
+            folder,
+            title: 'Save Imported Diagram',
+        });
+        if (target) {
+            showWarnings(source, flowchart.warnings);
+        }
+        return target;
+    };
+
+    context.subscriptions.push(
+        vscode.commands.registerCommand(IMPORT_MERMAID_TEXT_COMMAND, async (): Promise<vscode.Uri | undefined> => {
+            const editor = vscode.window.activeTextEditor;
+            if (!editor) {
+                const document = await openUntitledMermaid();
+                await vscode.window.showTextDocument(document);
+                void vscode.window.showInformationMessage(
+                    'Paste your Mermaid flowchart into this editor, then run "Diagrammer: Import Mermaid from Text" again.',
+                );
+                return undefined;
+            }
+            const selection = editor.selection;
+            const text = selection.isEmpty ? editor.document.getText() : editor.document.getText(selection);
+            const uri = editor.document.uri;
+            const isFile = uri.scheme === 'file';
+            const name = isFile ? path.basename(uri.fsPath) : 'the active editor';
+            return importText(
+                text,
+                selection.isEmpty ? name : `the selection in ${name}`,
+                isFile ? vscode.Uri.joinPath(uri, '..') : undefined,
+                isFile ? stripExtension(uri) : 'mermaid-import',
+            );
+        }),
+        vscode.commands.registerCommand(
+            IMPORT_MERMAID_FILE_COMMAND,
+            async (uri?: unknown): Promise<vscode.Uri | undefined> => {
+                let source = uri instanceof vscode.Uri ? uri : undefined;
+                if (!source) {
+                    const picked = await vscode.window.showOpenDialog({
+                        title: 'Import Mermaid File',
+                        openLabel: 'Import',
+                        canSelectMany: false,
+                        filters: MERMAID_FILE_FILTERS,
+                    });
+                    source = picked?.[0];
+                }
+                if (!source) {
+                    return undefined;
+                }
+                const text = new TextDecoder().decode(await vscode.workspace.fs.readFile(source));
+                return importText(text, path.basename(source.path), vscode.Uri.joinPath(source, '..'), stripExtension(source));
+            },
+        ),
+        vscode.commands.registerCommand(COPY_AS_MERMAID_COMMAND, async (uri?: unknown): Promise<string | undefined> => {
+            const document = exportableDocument(editors, uri);
+            if (!document) {
+                return undefined;
+            }
+            const text = diagramToMermaid(document.diagram);
+            await vscode.env.clipboard.writeText(text);
+            void vscode.window.showInformationMessage(
+                `Copied ${document.diagram.nodes.length} node(s) and ${document.diagram.edges.length} connector(s) as Mermaid.`,
+            );
+            return text;
+        }),
+        vscode.commands.registerCommand(
+            EXPORT_MERMAID_FILE_COMMAND,
+            async (uri?: unknown, targetUri?: unknown): Promise<vscode.Uri | undefined> => {
+                const document = exportableDocument(editors, uri);
+                if (!document) {
+                    return undefined;
+                }
+                let target = targetUri instanceof vscode.Uri ? targetUri : undefined;
+                if (!target) {
+                    target = await vscode.window.showSaveDialog({
+                        title: 'Export Mermaid File',
+                        saveLabel: 'Export',
+                        defaultUri: vscode.Uri.joinPath(document.uri, '..', `${stripExtension(document.uri)}.mmd`),
+                        filters: { Mermaid: ['mmd'] },
+                    });
+                }
+                if (!target) {
+                    return undefined;
+                }
+                const text = diagramToMermaid(document.diagram);
+                await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(text));
+                void vscode.window.showInformationMessage(`Exported Mermaid to ${path.basename(target.path)}.`);
+                return target;
+            },
+        ),
+    );
+}
+
+function exportableDocument(editors: DiagramEditorProvider, uri: unknown): DiagramDocument | undefined {
+    const document = editors.findDocument(uri instanceof vscode.Uri ? uri : undefined);
+    if (!document) {
+        void vscode.window.showErrorMessage('No active diagram. Open a diagram in the Diagrammer editor to export it as Mermaid.');
+        return undefined;
+    }
+    if (document.parseError) {
+        void vscode.window.showErrorMessage(`Cannot export ${document.uri.fsPath}: ${document.parseError}`);
+        return undefined;
+    }
+    return document;
+}
+
+async function openUntitledMermaid(): Promise<vscode.TextDocument> {
+    try {
+        return await vscode.workspace.openTextDocument({ language: 'mermaid', content: '' });
+    } catch {
+        // No extension contributes the `mermaid` language; fall back to plain text.
+        return vscode.workspace.openTextDocument({ content: '' });
+    }
+}
+
+/** `flow.mmd` → `flow`, `arch.diagram.json` → `arch`. */
+function stripExtension(uri: vscode.Uri): string {
+    const name = path.posix.basename(uri.path);
+    return name.replace(/(\.diagram\.json|\.[^.]+)$/i, '') || 'mermaid-import';
+}
diff --git a/src/mermaid/convert.ts b/src/mermaid/convert.ts
new file mode 100644
index 0000000..fde29f9
--- /dev/null
+++ b/src/mermaid/convert.ts
@@ -0,0 +1,152 @@
+/**
+ * Conversion between parsed Mermaid flowcharts and Diagrammer diagrams. Pure (no `vscode` imports).
+ */
+import { autoLayout, LayoutMode } from '../layout';
+import { DEFAULT_NODE_SIZES, Diagram, DIAGRAM_VERSION, DiagramEdge, DiagramNode, NodeType } from '../model/diagram';
+import { MermaidDirection, MermaidFlowchart, MermaidShape } from './parse';
+
+/** Mermaid shape → Diagrammer node type. Diagrammer has no circle, so `(( ))` becomes a round ellipse. */
+const SHAPE_TO_TYPE: Record<MermaidShape, NodeType> = {
+    rectangle: 'rectangle',
+    rounded: 'roundedRectangle',
+    diamond: 'diamond',
+    circle: 'ellipse',
+};
+
+/** Diagrammer node type → Mermaid shape delimiters (the inverse of the import mapping). */
+const TYPE_TO_DELIMITERS: Record<NodeType, [string, string]> = {
+    rectangle: ['[', ']'],
+    roundedRectangle: ['(', ')'],
+    diamond: ['{', '}'],
+    ellipse: ['((', '))'],
+    text: ['[', ']'],
+    sticky: ['[', ']'],
+};
+
+const CIRCLE_SIZE = 100;
+const CHAR_WIDTH = 8;
+const LINE_HEIGHT = 18;
+const MAX_NODE_WIDTH = 320;
+
+/**
+ * Builds a new diagram from a parsed flowchart and lays it out with the auto-layout engine so that
+ * ranks follow the Mermaid direction (TD/TB downwards, BT upwards, LR rightwards, RL leftwards).
+ * Edge styles (dotted, thick, open) are not representable and import as normal connectors.
+ */
+export function mermaidToDiagram(flowchart: MermaidFlowchart): Diagram {
+    const ids = new Map<string, string>();
+    const nodes: DiagramNode[] = flowchart.nodes.map((n, i) => {
+        const id = `node-${i + 1}`;
+        ids.set(n.id, id);
+        const type = SHAPE_TO_TYPE[n.shape];
+        return { id, type, x: 0, y: 0, ...nodeSize(type, n.label), label: n.label };
+    });
+    const edges: DiagramEdge[] = flowchart.edges.map((e, i) => {
+        const edge: DiagramEdge = { id: `edge-${i + 1}`, from: ids.get(e.from)!, to: ids.get(e.to)! };
+        if (e.label) {
+            edge.label = e.label;
+        }
+        return edge;
+    });
+    const diagram: Diagram = { version: DIAGRAM_VERSION, nodes, edges };
+    const horizontal = flowchart.direction === 'LR' || flowchart.direction === 'RL';
+    const mode: LayoutMode = horizontal ? 'left-to-right' : 'top-to-bottom';
+    const laidOut = autoLayout(diagram, { mode });
+    return flowchart.direction === 'BT' || flowchart.direction === 'RL' ? mirror(laidOut, horizontal) : laidOut;
+}
+
+/** Grows the default size of a shape so that the label fits on one line per `\n`. */
+function nodeSize(type: NodeType, label: string): { width: number; height: number } {
+    if (type === 'ellipse') {
+        const lines = label.split('\n');
+        const size = Math.max(CIRCLE_SIZE, Math.max(...lines.map((l) => l.length)) * CHAR_WIDTH * 1.2);
+        return { width: Math.min(size, MAX_NODE_WIDTH), height: Math.min(size, MAX_NODE_WIDTH) };
+    }
+    const defaults = DEFAULT_NODE_SIZES[type];
+    const lines = label.split('\n');
+    const longest = Math.max(...lines.map((l) => l.length));
+    const scale = type === 'diamond' ? 2 : 1;
+    return {
+        width: Math.min(MAX_NODE_WIDTH, Math.max(defaults.width, longest * CHAR_WIDTH * scale + 30)),
+        height: Math.max(defaults.height, lines.length * LINE_HEIGHT * scale + 30),
+    };
+}
+
+/** Flips the layout along its rank axis, keeping the same bounding box. */
+function mirror(diagram: Diagram, horizontal: boolean): Diagram {
+    if (diagram.nodes.length === 0) {
+        return diagram;
+    }
+    const min = Math.min(...diagram.nodes.map((n) => (horizontal ? n.x : n.y)));
+    const max = Math.max(...diagram.nodes.map((n) => (horizontal ? n.x + n.width : n.y + n.height)));
+    return {
+        ...diagram,
+        nodes: diagram.nodes.map((n) =>
+            horizontal ? { ...n, x: min + max - n.x - n.width } : { ...n, y: min + max - n.y - n.height },
+        ),
+    };
+}
+
+// ---------------------------------------------------------------------------
+// Export
+// ---------------------------------------------------------------------------
+
+/** Words Mermaid treats as keywords; used as a node id they would break the flowchart. */
+const RESERVED_IDS = new Set(['end', 'graph', 'flowchart', 'subgraph', 'class', 'classdef', 'style', 'linkstyle', 'click', 'direction']);
+
+/**
+ * Serialises a diagram as a Mermaid flowchart: a `flowchart <direction>` header, then one indented
+ * line per node declaration followed by one per edge. Node positions are not exported.
+ */
+export function diagramToMermaid(diagram: Diagram, direction: MermaidDirection = 'TD'): string {
+    const ids = mermaidIds(diagram.nodes.map((n) => n.id));
+    const lines = [`flowchart ${direction}`];
+    for (const node of diagram.nodes) {
+        const [open, close] = TYPE_TO_DELIMITERS[node.type];
+        lines.push(`    ${ids.get(node.id)}${open}"${encodeLabel(node.label)}"${close}`);
+    }
+    for (const edge of diagram.edges) {
+        const from = ids.get(edge.from);
+        const to = ids.get(edge.to);
+        if (from === undefined || to === undefined) {
+            continue;
+        }
+        const label = edge.label ? `|"${encodeLabel(edge.label)}"|` : '';
+        lines.push(`    ${from} -->${label} ${to}`);
+    }
+    return lines.join('\n') + '\n';
+}
+
+/**
+ * Maps diagram ids to Mermaid-safe ids: characters outside `[A-Za-z0-9_]` become `_`, reserved
+ * words get a `_` suffix, and ids that collide after that get a numeric suffix (`_2`, `_3`, …).
+ */
+export function mermaidIds(ids: readonly string[]): Map<string, string> {
+    const result = new Map<string, string>();
+    const used = new Set<string>();
+    for (const id of ids) {
+        let base = id.replace(/[^A-Za-z0-9_]/g, '_') || 'node';
+        if (RESERVED_IDS.has(base.toLowerCase())) {
+            base += '_';
+        }
+        let candidate = base;
+        for (let n = 2; used.has(candidate); n++) {
+            candidate = `${base}_${n}`;
+        }
+        used.add(candidate);
+        result.set(id, candidate);
+    }
+    return result;
+}
+
+/**
+ * Makes a label safe inside a quoted Mermaid label: `"` becomes `#quot;`, a `#` that would read as
+ * an entity becomes `#35;`, and line breaks become `<br>`. Brackets and pipes need no escaping
+ * inside quotes.
+ */
+export function encodeLabel(label: string): string {
+    return label
+        .replace(/#(?=\w+;)/g, '#35;')
+        .replace(/"/g, '#quot;')
+        .replace(/\r?\n/g, '<br>');
+}
diff --git a/src/mermaid/parse.ts b/src/mermaid/parse.ts
new file mode 100644
index 0000000..e94bd83
--- /dev/null
+++ b/src/mermaid/parse.ts
@@ -0,0 +1,361 @@
+/**
+ * Hand-written parser for the subset of Mermaid flowchart syntax Diagrammer can import.
+ *
+ * Pure TypeScript with no `vscode` (or `mermaid`) dependency, so it can run in unit tests, a CLI or
+ * the webview. See the README ("Mermaid import and export") for the supported subset.
+ */
+
+export const MERMAID_DIRECTIONS = ['TD', 'TB', 'BT', 'LR', 'RL'] as const;
+
+export type MermaidDirection = (typeof MERMAID_DIRECTIONS)[number];
+
+export type MermaidShape = 'rectangle' | 'rounded' | 'diamond' | 'circle';
+
+/** `normal` is `--`, `dotted` is `-.`, `thick` is `==`. */
+export type MermaidEdgeStyle = 'normal' | 'dotted' | 'thick';
+
+export interface MermaidNode {
+    id: string;
+    label: string;
+    shape: MermaidShape;
+}
+
+export interface MermaidEdge {
+    from: string;
+    to: string;
+    label?: string;
+    style: MermaidEdgeStyle;
+    /** `false` for open links such as `---`. */
+    arrow: boolean;
+}
+
+export interface MermaidWarning {
+    /** 1-based line number in the parsed text. */
+    line: number;
+    /** The skipped construct (e.g. `subgraph`), when the warning is about one. */
+    construct?: string;
+    message: string;
+}
+
+export interface MermaidFlowchart {
+    nodes: MermaidNode[];
+    edges: MermaidEdge[];
+    direction: MermaidDirection;
+    warnings: MermaidWarning[];
+}
+
+export class MermaidParseError extends Error {
+    constructor(message: string) {
+        super(message);
+        this.name = 'MermaidParseError';
+    }
+}
+
+/** Statements that are recognised but not supported; they are skipped with a warning. */
+export const UNSUPPORTED_CONSTRUCTS = ['subgraph', 'end', 'classDef', 'class', 'style', 'linkStyle', 'click', 'direction'];
+
+interface Statement {
+    text: string;
+    line: number;
+}
+
+/**
+ * Parses Mermaid `flowchart`/`graph` text. Throws `MermaidParseError` if the input is not a
+ * flowchart (e.g. `sequenceDiagram`) or has an invalid direction; anything inside the flowchart
+ * that cannot be imported is skipped and reported in `warnings`.
+ */
+export function parseMermaid(text: string): MermaidFlowchart {
+    const statements = splitStatements(text);
+    const header = statements.shift();
+    if (!header) {
+        throw new MermaidParseError('The text is empty: expected a Mermaid "flowchart" or "graph" diagram.');
+    }
+    const direction = parseHeader(header);
+
+    const nodes = new Map<string, MermaidNode>();
+    const edges: MermaidEdge[] = [];
+    const warnings: MermaidWarning[] = [];
+
+    for (const statement of statements) {
+        const keyword = /^[A-Za-z]+/.exec(statement.text)?.[0];
+        const construct = UNSUPPORTED_CONSTRUCTS.find((c) => c === keyword);
+        if (construct && (statement.text.length === construct.length || /^\s/.test(statement.text.slice(construct.length)))) {
+            warnings.push({
+                line: statement.line,
+                construct,
+                message: `Line ${statement.line}: "${construct}" is not supported and was skipped.`,
+            });
+            continue;
+        }
+        const error = parseStatement(statement, nodes, edges, warnings);
+        if (error) {
+            warnings.push({
+                line: statement.line,
+                message: `Line ${statement.line}: could not parse "${statement.text}" (${error}); it was skipped.`,
+            });
+        }
+    }
+
+    return { nodes: [...nodes.values()], edges, direction, warnings };
+}
+
+/**
+ * Splits the text into trimmed, non-empty statements (lines, further split on `;`), dropping `%%`
+ * comments and an optional leading `---` front-matter block.
+ */
+function splitStatements(text: string): Statement[] {
+    const lines = text.split(/\r?\n/);
+    const statements: Statement[] = [];
+    let start = 0;
+    const firstContent = lines.findIndex((l) => l.trim() !== '');
+    if (firstContent >= 0 && lines[firstContent].trim() === '---') {
+        const close = lines.findIndex((l, i) => i > firstContent && l.trim() === '---');
+        if (close > 0) {
+            start = close + 1;
+        }
+    }
+    for (let i = start; i < lines.length; i++) {
+        const line = lines[i].trim();
+        if (line === '' || line.startsWith('%%')) {
+            continue;
+        }
+        for (const part of splitOnSemicolons(line)) {
+            const trimmed = part.trim();
+            if (trimmed !== '') {
+                statements.push({ text: trimmed, line: i + 1 });
+            }
+        }
+    }
+    return statements;
+}
+
+/** Splits on `;` outside quotes, brackets, `|…|` edge labels and `#entity;` references. */
+function splitOnSemicolons(line: string): string[] {
+    const parts: string[] = [];
+    let current = '';
+    let inQuote = false;
+    let inPipe = false;
+    let depth = 0;
+    for (const ch of line) {
+        if (ch === '"') {
+            inQuote = !inQuote;
+        } else if (!inQuote) {
+            if (ch === '[' || ch === '(' || ch === '{') {
+                depth++;
+            } else if ((ch === ']' || ch === ')' || ch === '}') && depth > 0) {
+                depth--;
+            } else if (ch === '|' && depth === 0) {
+                inPipe = !inPipe;
+            } else if (ch === ';' && depth === 0 && !inPipe && !/#\w+$/.test(current)) {
+                parts.push(current);
+                current = '';
+                continue;
+            }
+        }
+        current += ch;
+    }
+    parts.push(current);
+    return parts;
+}
+
+function parseHeader(header: Statement): MermaidDirection {
+    const match = /^(\S+)(?:\s+(\S+))?\s*$/.exec(header.text);
+    const keyword = match?.[1] ?? header.text;
+    if (keyword !== 'flowchart' && keyword !== 'graph') {
+        throw new MermaidParseError(
+            `Line ${header.line}: "${keyword}" is not supported; only Mermaid "flowchart" and "graph" diagrams can be imported.`,
+        );
+    }
+    if (!match) {
+        throw new MermaidParseError(`Line ${header.line}: unexpected text after "${keyword}".`);
+    }
+    const direction = (match[2] ?? 'TD').toUpperCase();
+    if (!(MERMAID_DIRECTIONS as readonly string[]).includes(direction)) {
+        throw new MermaidParseError(
+            `Line ${header.line}: unsupported direction "${match[2]}"; expected one of ${MERMAID_DIRECTIONS.join(', ')}.`,
+        );
+    }
+    return direction as MermaidDirection;
+}
+
+// ---------------------------------------------------------------------------
+// Statements: node (link node)*
+// ---------------------------------------------------------------------------
+
+interface ShapeSyntax {
+    open: string;
+    close: string;
+    /** `undefined` for Mermaid shapes Diagrammer cannot represent (imported as rectangles). */
+    shape?: MermaidShape;
+}
+
+/** Longest openers first so that `((` wins over `(`. */
+const SHAPES: ShapeSyntax[] = [
+    { open: '(((', close: ')))' },
+    { open: '((', close: '))', shape: 'circle' },
+    { open: '([', close: '])' },
+    { open: '[[', close: ']]' },
+    { open: '[(', close: ')]' },
+    { open: '{{', close: '}}' },
+    { open: '[/', close: '/]' },
+    { open: '[\\', close: '\\]' },
+    { open: '>', close: ']' },
+    { open: '(', close: ')', shape: 'rounded' },
+    { open: '[', close: ']', shape: 'rectangle' },
+    { open: '{', close: '}', shape: 'diamond' },
+];
+
+const ID_PATTERN = /^[\p{L}\p{N}_]+/u;
+
+/** Link with inline text: `-- text -->`, `-. text .->`, `== text ==>` (and their open variants). */
+const TEXT_LINK = /^(--|-\.|==)\s*("[^"]*"|[^\s\-=.>|"][^]*?)\s*(-{2,}>|-{3,}|\.-+>|\.-+|={2,}>|={3,})/;
+/** Plain link: `-->`, `---`, `-.->`, `-.-`, `==>`, `===` (optionally longer, or `<`-prefixed). */
+const PLAIN_LINK = /^<?(-{2,}>|-{3,}|-\.+->|-\.+-|={2,}>|={3,})/;
+
+class Cursor {
+    pos = 0;
+    constructor(readonly text: string) {}
+    get rest(): string {
+        return this.text.slice(this.pos);
+    }
+    skipSpace(): void {
+        while (this.pos < this.text.length && /\s/.test(this.text[this.pos])) {
+            this.pos++;
+        }
+    }
+    done(): boolean {
+        return this.pos >= this.text.length;
+    }
+}
+
+/** Parses one statement into `nodes`/`edges`. Returns an error description, or undefined on success. */
+function parseStatement(
+    statement: Statement,
+    nodes: Map<string, MermaidNode>,
+    edges: MermaidEdge[],
+    warnings: MermaidWarning[],
+): string | undefined {
+    const cursor = new Cursor(statement.text);
+    const pendingNodes: ParsedNode[] = [];
+    const pendingEdges: MermaidEdge[] = [];
+    const pendingWarnings: MermaidWarning[] = [];
+
+    const first = readNode(cursor, statement.line, pendingWarnings);
+    if (typeof first === 'string') {
+        return first;
+    }
+    pendingNodes.push(first);
+    let previous = first;
+    cursor.skipSpace();
+    while (!cursor.done()) {
+        const link = readLink(cursor);
+        if (typeof link === 'string') {
+            return link;
+        }
+        cursor.skipSpace();
+        const next = readNode(cursor, statement.line, pendingWarnings);
+        if (typeof next === 'string') {
+            return next;
+        }
+        pendingNodes.push(next);
+        pendingEdges.push({ from: previous.id, to: next.id, ...link });
+        previous = next;
+        cursor.skipSpace();
+    }
+
+    // Only commit once the whole statement parsed, so a bad statement leaves no partial result.
+    for (const node of pendingNodes) {
+        const existing = nodes.get(node.id);
+        if (!existing || node.explicit) {
+            nodes.set(node.id, { id: node.id, label: node.label, shape: node.shape });
+        }
+    }
+    edges.push(...pendingEdges);
+    warnings.push(...pendingWarnings);
+    return undefined;
+}
+
+type ParsedNode = MermaidNode & { explicit: boolean };
+
+function readNode(cursor: Cursor, line: number, warnings: MermaidWarning[]): ParsedNode | string {
+    const idMatch = ID_PATTERN.exec(cursor.rest);
+    if (!idMatch) {
+        return cursor.done() ? 'expected a node after the link' : `unexpected "${cursor.rest[0]}"`;
+    }
+    const id = idMatch[0];
+    cursor.pos += id.length;
+    const syntax = SHAPES.find((s) => cursor.rest.startsWith(s.open));
+    if (!syntax) {
+        return { id, label: id, shape: 'rectangle', explicit: false };
+    }
+    cursor.pos += syntax.open.length;
+    let label: string;
+    const rest = cursor.rest;
+    const quoted = /^\s*"([^"]*)"\s*/.exec(rest);
+    if (quoted && rest.slice(quoted[0].length).startsWith(syntax.close)) {
+        label = quoted[1];
+        cursor.pos += quoted[0].length + syntax.close.length;
+    } else {
+        const end = rest.indexOf(syntax.close);
+        if (end < 0) {
+            return `missing "${syntax.close}" after the label of "${id}"`;
+        }
+        label = rest.slice(0, end).trim();
+        cursor.pos += end + syntax.close.length;
+    }
+    if (cursor.rest.startsWith(':::')) {
+        const cls = /^:::[\w-]+/.exec(cursor.rest)?.[0] ?? ':::';
+        cursor.pos += cls.length;
+        warnings.push({ line, construct: ':::', message: `Line ${line}: class shorthand "${cls}" on "${id}" was skipped.` });
+    }
+    if (!syntax.shape) {
+        warnings.push({
+            line,
+            message: `Line ${line}: shape "${syntax.open}…${syntax.close}" of "${id}" is not supported and was imported as a rectangle.`,
+        });
+    }
+    return { id, label: decodeLabel(label), shape: syntax.shape ?? 'rectangle', explicit: true };
+}
+
+function readLink(cursor: Cursor): Omit<MermaidEdge, 'from' | 'to'> | string {
+    const textLink = TEXT_LINK.exec(cursor.rest);
+    if (textLink) {
+        cursor.pos += textLink[0].length;
+        return { ...linkKind(textLink[3]), label: decodeLabel(unquote(textLink[2].trim())) };
+    }
+    const plain = PLAIN_LINK.exec(cursor.rest);
+    if (!plain) {
+        return cursor.rest.trim() === '' ? 'expected a link' : `expected a link at "${cursor.rest}"`;
+    }
+    cursor.pos += plain[0].length;
+    const link: Omit<MermaidEdge, 'from' | 'to'> = linkKind(plain[1]);
+    const pipe = /^\s*\|\s*("[^"]*"|[^|]*?)\s*\|/.exec(cursor.rest);
+    if (pipe) {
+        cursor.pos += pipe[0].length;
+        link.label = decodeLabel(unquote(pipe[1]));
+    }
+    return link;
+}
+
+function linkKind(token: string): { style: MermaidEdgeStyle; arrow: boolean } {
+    const style: MermaidEdgeStyle = token.includes('.') ? 'dotted' : token.includes('=') ? 'thick' : 'normal';
+    return { style, arrow: token.endsWith('>') };
+}
+
+function unquote(text: string): string {
+    return text.length >= 2 && text.startsWith('"') && text.endsWith('"') ? text.slice(1, -1) : text;
+}
+
+const NAMED_ENTITIES: Record<string, string> = { quot: '"', amp: '&', lt: '<', gt: '>', nbsp: '\u00a0', num: '#' };
+
+/** Decodes Mermaid entity codes (`#quot;`, `#35;`) and turns `<br>` into line breaks. */
+export function decodeLabel(text: string): string {
+    return text
+        .replace(/<br\s*\/?>/gi, '\n')
+        .replace(/#(\w+);/g, (whole, code: string) => {
+            if (/^\d+$/.test(code)) {
+                return String.fromCodePoint(Number(code));
+            }
+            return NAMED_ENTITIES[code] ?? whole;
+        });
+}
diff --git a/src/newDiagram.ts b/src/newDiagram.ts
index 5376627..0e36f1a 100644
--- a/src/newDiagram.ts
+++ b/src/newDiagram.ts
@@ -1,5 +1,5 @@
 import * as vscode from 'vscode';
-import { createEmptyDiagram, serializeDiagram } from './model/diagram';
+import { createEmptyDiagram, Diagram, serializeDiagram } from './model/diagram';
 import { DIAGRAM_EDITOR_VIEW_TYPE } from './diagramEditor';
 
 export const NEW_DIAGRAM_COMMAND = 'diagrammer.newDiagram';
@@ -10,13 +10,35 @@ export const NEW_DIAGRAM_COMMAND = 'diagrammer.newDiagram';
  * Without an open workspace folder the user is asked where to save the file.
  */
 export async function newDiagram(): Promise<vscode.Uri | undefined> {
-    const folder = vscode.workspace.workspaceFolders?.[0];
+    return createDiagramFile(createEmptyDiagram(), { title: 'New Diagram' });
+}
+
+export interface CreateDiagramFileOptions {
+    /** File name without `.diagram.json`; defaults to `untitled`. */
+    baseName?: string;
+    /** Folder for the new file; defaults to the first workspace folder. */
+    folder?: vscode.Uri;
+    /** Title of the save dialog shown when there is no folder. */
+    title: string;
+}
+
+/**
+ * Writes `diagram` to a new `<baseName>.diagram.json` (or `<baseName>-N.diagram.json` if taken) and
+ * opens it in the diagram editor. Never overwrites an existing file. Without a folder the user is
+ * asked where to save it; returns `undefined` if they cancel.
+ */
+export async function createDiagramFile(
+    diagram: Diagram,
+    options: CreateDiagramFileOptions,
+): Promise<vscode.Uri | undefined> {
+    const baseName = options.baseName ?? 'untitled';
+    const folder = options.folder ?? vscode.workspace.workspaceFolders?.[0]?.uri;
     let target: vscode.Uri | undefined;
     if (folder) {
-        target = await findFreeUri(folder.uri);
+        target = await findFreeUri(folder, baseName);
     } else {
         target = await vscode.window.showSaveDialog({
-            title: 'New Diagram',
+            title: options.title,
             saveLabel: 'Create Diagram',
             filters: { Diagram: ['diagram.json'] },
         });
@@ -25,14 +47,14 @@ export async function newDiagram(): Promise<vscode.Uri | undefined> {
         return undefined;
     }
 
-    await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(serializeDiagram(createEmptyDiagram())));
+    await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(serializeDiagram(diagram)));
     await vscode.commands.executeCommand('vscode.openWith', target, DIAGRAM_EDITOR_VIEW_TYPE);
     return target;
 }
 
-async function findFreeUri(folder: vscode.Uri): Promise<vscode.Uri> {
+async function findFreeUri(folder: vscode.Uri, baseName: string): Promise<vscode.Uri> {
     for (let i = 0; ; i++) {
-        const name = i === 0 ? 'untitled.diagram.json' : `untitled-${i}.diagram.json`;
+        const name = i === 0 ? `${baseName}.diagram.json` : `${baseName}-${i}.diagram.json`;
         const candidate = vscode.Uri.joinPath(folder, name);
         if (!(await exists(candidate))) {
             return candidate;
diff --git a/src/test/integration/suite/extension.test.ts b/src/test/integration/suite/extension.test.ts
index 1d3b0d1..eb9e94b 100644
--- a/src/test/integration/suite/extension.test.ts
+++ b/src/test/integration/suite/extension.test.ts
@@ -231,4 +231,64 @@ describe('Diagrammer extension', () => {
         const after = await vscode.workspace.fs.readFile(uri);
         assert.deepStrictEqual(Buffer.from(after), Buffer.from(before));
     });
+    it('registers the Mermaid import and export commands', async () => {
+        const commands = await vscode.commands.getCommands(true);
+        for (const command of [
+            'diagrammer.importMermaidFromText',
+            'diagrammer.importMermaidFile',
+            'diagrammer.copyAsMermaid',
+            'diagrammer.exportMermaidFile',
+        ]) {
+            assert.ok(commands.includes(command), command);
+        }
+    });
+
+    it('Import Mermaid File creates a new laid-out diagram next to the .mmd file and opens it', async () => {
+        const created = await vscode.commands.executeCommand<vscode.Uri>('diagrammer.importMermaidFile', workspaceUri('flow.mmd'));
+        assert.ok(created?.path.endsWith('/flow.diagram.json'), created?.path);
+        const diagram = await readDiagramFile(created);
+        assert.deepStrictEqual(diagram.nodes.map((n) => n.label), ['Client', 'API', 'Cached?', 'Cache', 'Database']);
+        assert.deepStrictEqual(diagram.nodes.map((n) => n.type), ['rectangle', 'roundedRectangle', 'diamond', 'ellipse', 'rectangle']);
+        assert.deepStrictEqual(diagram.edges.map((e) => e.label ?? ''), ['HTTP', '', 'yes', 'no']);
+        assertNoOverlaps(diagram);
+        assert.ok(diagram.nodes[0].x < diagram.nodes[1].x && diagram.nodes[1].x < diagram.nodes[2].x);
+        const input = await waitFor('custom editor tab', activeCustomEditorInput);
+        assert.strictEqual(input.uri.toString(), created.toString());
+
+        // Importing again never overwrites the first import.
+        const second = await vscode.commands.executeCommand<vscode.Uri>('diagrammer.importMermaidFile', workspaceUri('flow.mmd'));
+        assert.ok(second?.path.endsWith('/flow-1.diagram.json'), second?.path);
+    });
+
+    it('Import Mermaid from Text uses the active editor and rejects non-flowcharts', async () => {
+        const rejected = await vscode.workspace.openTextDocument({ content: 'sequenceDiagram\n    A->>B: hi\n' });
+        await vscode.window.showTextDocument(rejected);
+        assert.strictEqual(await vscode.commands.executeCommand('diagrammer.importMermaidFromText'), undefined);
+
+        const doc = await vscode.workspace.openTextDocument({ content: 'graph TD\nX[One] --> Y[Two] --> Z[Three]\n' });
+        await vscode.window.showTextDocument(doc);
+        const created = await vscode.commands.executeCommand<vscode.Uri>('diagrammer.importMermaidFromText');
+        assert.ok(created?.path.endsWith('/mermaid-import.diagram.json'), created?.path);
+        const diagram = await readDiagramFile(created);
+        assert.deepStrictEqual(diagram.nodes.map((n) => n.label), ['One', 'Two', 'Three']);
+        assert.ok(diagram.nodes[0].y < diagram.nodes[1].y && diagram.nodes[1].y < diagram.nodes[2].y);
+    });
+
+    it('Copy as Mermaid and Export Mermaid File export the active diagram', async () => {
+        assert.strictEqual(await vscode.commands.executeCommand('diagrammer.copyAsMermaid'), undefined);
+
+        const uri = workspaceUri('sample.diagram.json');
+        await vscode.commands.executeCommand('vscode.openWith', uri, VIEW_TYPE);
+        await waitFor('custom editor tab', activeCustomEditorInput);
+        const text = await vscode.commands.executeCommand<string>('diagrammer.copyAsMermaid');
+        assert.ok(text?.startsWith('flowchart TD\n'), text);
+        assert.strictEqual(await vscode.env.clipboard.readText(), text);
+        const sample = await readDiagramFile(uri);
+        assert.strictEqual(text.trimEnd().split('\n').length, 1 + sample.nodes.length + sample.edges.length);
+
+        const target = workspaceUri('exported.mmd');
+        const written = await vscode.commands.executeCommand<vscode.Uri>('diagrammer.exportMermaidFile', uri, target);
+        assert.strictEqual(written?.toString(), target.toString());
+        assert.strictEqual(new TextDecoder().decode(await vscode.workspace.fs.readFile(target)), text);
+    });
 });
diff --git a/src/test/unit/mermaid.test.ts b/src/test/unit/mermaid.test.ts
new file mode 100644
index 0000000..59d9cdb
--- /dev/null
+++ b/src/test/unit/mermaid.test.ts
@@ -0,0 +1,329 @@
+import * as assert from 'assert';
+import * as fs from 'fs';
+import * as path from 'path';
+import { Diagram, DiagramNode, validateDiagram } from '../../model/diagram';
+import { diagramToMermaid, encodeLabel, mermaidIds, mermaidToDiagram } from '../../mermaid/convert';
+import { MermaidFlowchart, MermaidParseError, parseMermaid } from '../../mermaid/parse';
+
+const SAMPLE = `%% A sample flowchart
+flowchart LR
+    A[Start] --> B(Rounded step)
+    B --> C{Decision?}
+    C -->|yes| D((Done))
+    C -- no --> E["Retry #quot;quoted#quot;"]
+    E -.-> B; E ==> F
+    F --- G
+`;
+
+function nodeLabels(fc: MermaidFlowchart): Record<string, string> {
+    return Object.fromEntries(fc.nodes.map((n) => [n.id, n.label]));
+}
+
+function overlaps(a: DiagramNode, b: DiagramNode): boolean {
+    return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
+}
+
+function assertNoOverlaps(d: Diagram): void {
+    for (let i = 0; i < d.nodes.length; i++) {
+        for (let j = i + 1; j < d.nodes.length; j++) {
+            assert.ok(!overlaps(d.nodes[i], d.nodes[j]), `${d.nodes[i].label} overlaps ${d.nodes[j].label}`);
+        }
+    }
+}
+
+function byLabel(d: Diagram, label: string): DiagramNode {
+    const node = d.nodes.find((n) => n.label === label);
+    assert.ok(node, `node "${label}" exists`);
+    return node;
+}
+
+/** (source label, target label, edge label) tuples of a diagram or parsed flowchart. */
+function edgeTuples(d: Diagram | MermaidFlowchart): string[] {
+    const label = new Map<string, string>(d.nodes.map((n) => [n.id, n.label]));
+    return d.edges.map((e) => JSON.stringify([label.get(e.from), label.get(e.to), e.label ?? ''])).sort();
+}
+
+describe('Mermaid parser', () => {
+    it('parses nodes, shapes, labels, edges, edge labels and direction', () => {
+        const fc = parseMermaid(SAMPLE);
+        assert.strictEqual(fc.direction, 'LR');
+        assert.deepStrictEqual(fc.warnings, []);
+        assert.deepStrictEqual(nodeLabels(fc), {
+            A: 'Start',
+            B: 'Rounded step',
+            C: 'Decision?',
+            D: 'Done',
+            E: 'Retry "quoted"',
+            F: 'F',
+            G: 'G',
+        });
+        assert.deepStrictEqual(
+            fc.nodes.map((n) => n.shape),
+            ['rectangle', 'rounded', 'diamond', 'circle', 'rectangle', 'rectangle', 'rectangle'],
+        );
+        assert.deepStrictEqual(
+            fc.edges.map((e) => [e.from, e.to, e.label, e.style, e.arrow]),
+            [
+                ['A', 'B', undefined, 'normal', true],
+                ['B', 'C', undefined, 'normal', true],
+                ['C', 'D', 'yes', 'normal', true],
+                ['C', 'E', 'no', 'normal', true],
+                ['E', 'B', undefined, 'dotted', true],
+                ['E', 'F', undefined, 'thick', true],
+                ['F', 'G', undefined, 'normal', false],
+            ],
+        );
+    });
+
+    it('parses chains, semicolons, graph keyword and default direction', () => {
+        const fc = parseMermaid('graph\nA --> B --> C;C-->D');
+        assert.strictEqual(fc.direction, 'TD');
+        assert.deepStrictEqual(
+            fc.edges.map((e) => `${e.from}>${e.to}`),
+            ['A>B', 'B>C', 'C>D'],
+        );
+        assert.strictEqual(parseMermaid('flowchart BT; A-->B').direction, 'BT');
+        assert.strictEqual(parseMermaid('graph rl').direction, 'RL');
+    });
+
+    it('supports all edge label forms and edge operators', () => {
+        const fc = parseMermaid(
+            [
+                'flowchart TD',
+                'A -->|plain| B',
+                'A ---|open| C',
+                'A -.->|dots| D',
+                'A ==>|thick| E',
+                'A -- text form --> F',
+                'A -. dotted text .-> G',
+                'A == thick text ==> H',
+                'A -->|"quoted | pipe"| I',
+                'A-->J',
+            ].join('\n'),
+        );
+        assert.deepStrictEqual(
+            fc.edges.map((e) => [e.to, e.label ?? '', e.style, e.arrow]),
+            [
+                ['B', 'plain', 'normal', true],
+                ['C', 'open', 'normal', false],
+                ['D', 'dots', 'dotted', true],
+                ['E', 'thick', 'thick', true],
+                ['F', 'text form', 'normal', true],
+                ['G', 'dotted text', 'dotted', true],
+                ['H', 'thick text', 'thick', true],
+                ['I', 'quoted | pipe', 'normal', true],
+                ['J', '', 'normal', true],
+            ],
+        );
+    });
+
+    it('keeps the explicit label when a node is referenced before or after its declaration', () => {
+        const fc = parseMermaid('flowchart TD\nA --> B\nB{Choose}\nB --> A');
+        assert.deepStrictEqual(nodeLabels(fc), { A: 'A', B: 'Choose' });
+        assert.strictEqual(fc.nodes[1].shape, 'diamond');
+        assert.strictEqual(fc.edges.length, 2);
+    });
+
+    it('warns about unsupported constructs with their line numbers and still parses the rest', () => {
+        const fc = parseMermaid(
+            [
+                'flowchart TD', //            1
+                '    classDef hot fill:#f00', // 2
+                '    subgraph one', //          3
+                '        A[In sub] --> B', //   4
+                '    end', //                   5
+                '    %% comment', //            6
+                '    B --> C', //               7
+                '    style A fill:#f9f', //     8
+                '    class A hot', //           9
+                '    linkStyle 0 stroke:red', // 10
+                '    click A callback', //      11
+            ].join('\n'),
+        );
+        assert.deepStrictEqual(
+            fc.warnings.map((w) => [w.construct, w.line]),
+            [
+                ['classDef', 2],
+                ['subgraph', 3],
+                ['end', 5],
+                ['style', 8],
+                ['class', 9],
+                ['linkStyle', 10],
+                ['click', 11],
+            ],
+        );
+        assert.ok(fc.warnings[0].message.includes('classDef') && fc.warnings[0].message.includes('Line 2'));
+        assert.ok(fc.warnings[1].message.includes('subgraph') && fc.warnings[1].message.includes('Line 3'));
+        assert.deepStrictEqual(nodeLabels(fc), { A: 'In sub', B: 'B', C: 'C' });
+        assert.strictEqual(fc.edges.length, 2);
+    });
+
+    it('does not mistake node ids that start with a keyword for constructs', () => {
+        const fc = parseMermaid('flowchart TD\nending --> classes\nstyled[Styled]');
+        assert.deepStrictEqual(fc.warnings, []);
+        assert.deepStrictEqual(nodeLabels(fc), { ending: 'ending', classes: 'classes', styled: 'Styled' });
+    });
+
+    it('warns about statements and shapes it cannot handle without failing', () => {
+        const fc = parseMermaid('flowchart TD\nA --> B & C\nD[(Database)] --> E\nF --> G');
+        assert.strictEqual(fc.warnings.length, 2);
+        assert.strictEqual(fc.warnings[0].line, 2);
+        assert.ok(fc.warnings[1].message.includes('imported as a rectangle'));
+        assert.deepStrictEqual(nodeLabels(fc), { D: 'Database', E: 'E', F: 'F', G: 'G' });
+    });
+
+    it('rejects non-flowchart input', () => {
+        assert.throws(() => parseMermaid('sequenceDiagram\n    Alice->>Bob: Hi'), MermaidParseError);
+        assert.throws(() => parseMermaid('classDiagram\n    Animal <|-- Duck'), /classDiagram/);
+        assert.throws(() => parseMermaid('%% comment\nA --> B'), MermaidParseError);
+        assert.throws(() => parseMermaid(''), MermaidParseError);
+        assert.throws(() => parseMermaid('flowchart XY\nA-->B'), /direction/);
+    });
+
+    it('skips comment lines and front matter before the header', () => {
+        const fc = parseMermaid('---\ntitle: Demo\n---\n%%{init: {}}%%\nflowchart LR\nA-->B');
+        assert.strictEqual(fc.direction, 'LR');
+        assert.strictEqual(fc.edges.length, 1);
+    });
+});
+
+describe('Mermaid import layout', () => {
+    const chain = (dir: string) => mermaidToDiagram(parseMermaid(`flowchart ${dir}\nA-->B-->C`));
+
+    it('orders ranks along the direction without overlaps', () => {
+        const td = chain('TD');
+        assert.ok(byLabel(td, 'A').y < byLabel(td, 'B').y && byLabel(td, 'B').y < byLabel(td, 'C').y);
+        const lr = chain('LR');
+        assert.ok(byLabel(lr, 'A').x < byLabel(lr, 'B').x && byLabel(lr, 'B').x < byLabel(lr, 'C').x);
+        const bt = chain('BT');
+        assert.ok(byLabel(bt, 'A').y > byLabel(bt, 'B').y && byLabel(bt, 'B').y > byLabel(bt, 'C').y);
+        const rl = chain('RL');
+        assert.ok(byLabel(rl, 'A').x > byLabel(rl, 'B').x && byLabel(rl, 'B').x > byLabel(rl, 'C').x);
+        for (const d of [td, lr, bt, rl]) {
+            assertNoOverlaps(d);
+            assert.ok(d.nodes.every((n) => n.x >= 0 && n.y >= 0));
+        }
+    });
+
+    it('produces a valid diagram with mapped shapes, labels and edge directions', () => {
+        const fc = parseMermaid(SAMPLE);
+        const d = mermaidToDiagram(fc);
+        assert.deepStrictEqual(validateDiagram(JSON.parse(JSON.stringify(d))), d);
+        assertNoOverlaps(d);
+        const positions = new Set(d.nodes.map((n) => `${n.x},${n.y}`));
+        assert.strictEqual(positions.size, d.nodes.length);
+        assert.deepStrictEqual(
+            d.nodes.map((n) => n.type),
+            ['rectangle', 'roundedRectangle', 'diamond', 'ellipse', 'rectangle', 'rectangle', 'rectangle'],
+        );
+        const circle = byLabel(d, 'Done');
+        assert.strictEqual(circle.width, circle.height);
+        assert.deepStrictEqual(edgeTuples(d), edgeTuples(fc));
+    });
+});
+
+describe('Mermaid export', () => {
+    const diagram: Diagram = {
+        version: 1,
+        nodes: [
+            { id: 'node-1', type: 'rectangle', x: 0, y: 0, width: 140, height: 70, label: 'Say "hi"' },
+            { id: 'node 1', type: 'roundedRectangle', x: 200, y: 0, width: 140, height: 70, label: 'a[b] (c) {d} |e|' },
+            { id: 'end', type: 'diamond', x: 0, y: 200, width: 140, height: 100, label: 'Ok?' },
+            { id: 'n4', type: 'ellipse', x: 200, y: 200, width: 140, height: 80, label: 'Line 1\nLine 2' },
+            { id: 'n5', type: 'sticky', x: 400, y: 200, width: 150, height: 120, label: 'Issue #12; see #quot;' },
+            { id: 'n6', type: 'text', x: 400, y: 400, width: 120, height: 30, label: '' },
+        ],
+        edges: [
+            { id: 'e1', from: 'node-1', to: 'node 1', label: 'uses "x" | y' },
+            { id: 'e2', from: 'node 1', to: 'end' },
+            { id: 'e3', from: 'end', to: 'n4', label: 'yes [1]' },
+            { id: 'e4', from: 'end', to: 'n5', label: 'no' },
+            { id: 'e5', from: 'n5', to: 'n6' },
+        ],
+    };
+
+    it('writes a flowchart header and one statement per indented line', () => {
+        const text = diagramToMermaid(diagram);
+        const lines = text.trimEnd().split('\n');
+        assert.strictEqual(lines[0], 'flowchart TD');
+        assert.strictEqual(lines.length, 1 + diagram.nodes.length + diagram.edges.length);
+        for (const line of lines.slice(1)) {
+            assert.match(line, /^ {4}\S/);
+        }
+        assert.ok(diagramToMermaid(diagram, 'LR').startsWith('flowchart LR\n'));
+        assert.deepStrictEqual(lines.slice(1), [
+            '    node_1["Say #quot;hi#quot;"]',
+            '    node_1_2("a[b] (c) {d} |e|")',
+            '    end_{"Ok?"}',
+            '    n4(("Line 1<br>Line 2"))',
+            '    n5["Issue #35;12; see #35;quot;"]',
+            '    n6[""]',
+            '    node_1 -->|"uses #quot;x#quot; | y"| node_1_2',
+            '    node_1_2 --> end_',
+            '    end_ -->|"yes [1]"| n4',
+            '    end_ -->|"no"| n5',
+            '    n5 --> n6',
+        ]);
+    });
+
+    it('sanitizes ids and makes collisions unique', () => {
+        const ids = mermaidIds(['a-b', 'a_b', 'a.b', '', 'ünï', 'End']);
+        assert.deepStrictEqual([...ids.values()], ['a_b', 'a_b_2', 'a_b_3', 'node', '_n_', 'End_']);
+        for (const id of ids.values()) {
+            assert.match(id, /^[A-Za-z0-9_]+$/);
+        }
+    });
+
+    it('escapes labels so that they re-parse to the original text', () => {
+        for (const label of ['"quoted"', '[brackets]', 'pipe | pipe', '{x} (y)', '#quot; literal', '#35;']) {
+            const fc = parseMermaid(`flowchart TD\n    A["${encodeLabel(label)}"] -->|"${encodeLabel(label)}"| B`);
+            assert.strictEqual(fc.nodes[0].label, label);
+            assert.strictEqual(fc.edges[0].label, label);
+        }
+    });
+
+    it('round-trips node labels, shapes and (source, target, label) tuples', () => {
+        const fc = parseMermaid(diagramToMermaid(diagram));
+        assert.deepStrictEqual(fc.warnings, []);
+        assert.deepStrictEqual(fc.nodes.map((n) => n.label).sort(), diagram.nodes.map((n) => n.label).sort());
+        assert.deepStrictEqual(edgeTuples(fc), edgeTuples(diagram));
+        const reimported = mermaidToDiagram(fc);
+        assert.deepStrictEqual(
+            reimported.nodes.map((n) => n.type),
+            ['rectangle', 'roundedRectangle', 'diamond', 'ellipse', 'rectangle', 'rectangle'],
+        );
+        assert.deepStrictEqual(edgeTuples(reimported), edgeTuples(diagram));
+    });
+
+    it('round-trips an imported Mermaid sample through a diagram and back', () => {
+        const original = parseMermaid(SAMPLE);
+        const again = parseMermaid(diagramToMermaid(mermaidToDiagram(original)));
+        assert.deepStrictEqual(again.nodes.map((n) => [n.label, n.shape]), original.nodes.map((n) => [n.label, n.shape]));
+        assert.deepStrictEqual(edgeTuples(again), edgeTuples(original));
+    });
+});
+
+describe('Mermaid commands', () => {
+    const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', '..', 'package.json'), 'utf8'));
+    const ids = [
+        'diagrammer.importMermaidFromText',
+        'diagrammer.importMermaidFile',
+        'diagrammer.copyAsMermaid',
+        'diagrammer.exportMermaidFile',
+    ];
+
+    it('are contributed to the command palette', () => {
+        const contributed = pkg.contributes.commands.map((c: { command: string }) => c.command);
+        const hidden = (pkg.contributes.menus?.commandPalette ?? [])
+            .filter((m: { when?: string }) => m.when === 'false')
+            .map((m: { command: string }) => m.command);
+        for (const id of ids) {
+            assert.ok(contributed.includes(id), `${id} is contributed`);
+            assert.ok(!hidden.includes(id), `${id} is visible in the palette`);
+        }
+    });
+
+    it('do not pull in the mermaid package', () => {
+        assert.ok(!pkg.dependencies?.mermaid && !pkg.devDependencies?.mermaid);
+    });
+});
diff --git a/test-fixtures/workspace/flow.mmd b/test-fixtures/workspace/flow.mmd
new file mode 100644
index 0000000..bea662f
--- /dev/null
+++ b/test-fixtures/workspace/flow.mmd
@@ -0,0 +1,7 @@
+%% Imported by the integration tests
+flowchart LR
+    A[Client] -->|HTTP| B(API)
+    B --> C{Cached?}
+    C -- yes --> D((Cache))
+    C -- no --> E[Database]
+    classDef db fill:#eee
Acceptance · round 1
Shipped
CINo checks
Automated reviewPass with concerns

This adds a clean, well-tested hand-written Mermaid flowchart parser, layout via the existing dagre engine, an exporter with id sanitizing and label escaping, the four commands and README docs. The unit tests map directly onto acceptance criteria 1–7 and look thorough. However, no CI ran and the integration tests for the actual VS Code commands were never executed, and those tests leave generated files in the fixture workspace, which will break reruns.

Acceptance criteria · 9 of 10 met
  • YESParsing a sample flowchart yields the expected node count, edge count, labels, edge labels, shapes and directionmermaid.test.ts 'parses nodes, shapes, labels, edges, edge labels and direction' checks all of these on SAMPLE; further tests cover chains, `;`, the graph keyword and every edge and label form.
  • YESsubgraph/classDef input yields warnings with construct name and correct line numbers; supported statements still parseThe test 'warns about unsupported constructs…' asserts the [construct, line] pairs for classDef@2, subgraph@3, end@5, etc., and checks that nodes and edges are still parsed.
  • YESsequenceDiagram input yields an error, not a resultparseHeader throws MermaidParseError; the test 'rejects non-flowchart input' covers sequenceDiagram and classDiagram, and commands.ts returns before createDiagramFile when parsing fails.
  • YESImported nodes have distinct, non-overlapping positions ordered by rank along the directionmermaidToDiagram uses the #14 autoLayout and mirrors the result for BT/RL; the test 'orders ranks along the direction without overlaps' checks TD/LR/BT/RL ordering and overlaps.
  • YESExported output starts with `flowchart` and has one statement per linediagramToMermaid emits a `flowchart TD` header and 4-space-indented node and edge lines; the export test asserts the exact lines and the line count.
  • YESLabels with quotes, brackets and pipes are escaped and re-parse to the originalencodeLabel maps `"` to `#quot;` and an entity-like `#` to `#35;`; the test 'escapes labels so that they re-parse to the original text' covers quotes, brackets, pipes and literal entities.
  • YESRound trip diagram → Mermaid → parse preserves node label set and (source, target, edge label) tuplesThe tests 'round-trips node labels, shapes and … tuples' and 'round-trips an imported Mermaid sample' compare the sorted label sets and edgeTuples.
  • YESAll four commands declared in package.json, registered in activation, visible in the palettepackage.json adds the 4 contributes.commands entries and extension.ts calls registerMermaidCommands; a unit test checks they are contributed and not hidden, but the runtime registration test is in the unrun integration suite.
  • PARTIALExtension compiles, existing and new tests pass, no mermaid dependencyThe builder reports compile, lint and 136 unit tests passing and a unit test asserts there is no mermaid dependency, but no CI ran and the integration tests were never executed.
  • YESREADME documents supported and unsupported syntaxThe new README '## Mermaid' section lists the commands, supported syntax and unsupported constructs, with import and export examples.
Concerns
  • No CI ran on this commit, and the builder says the 4 new integration tests (command registration, file import, text import, copy/export) compile but were never executed. The end-to-end behaviour of the commands is unverified until someone runs `xvfb-run -a npm run test:integration`.
  • The integration tests write `flow.diagram.json`, `mermaid-import.diagram.json` and `exported.mmd` into `test-fixtures/workspace` and never delete them. The assertions `endsWith('/flow.diagram.json')` and `flow-1.diagram.json` will fail on a second run unless the fixture workspace is reset, and the files may end up committed by accident.
  • Exports always use `flowchart TD` because the diagram model stores no direction. An LR import that is exported again comes back as TD. The spec allows this, but backers may expect the direction to survive a round trip.
  • `createDiagramFile` was factored out of `newDiagram.ts` to share file-creation logic. The refactor is reasonable and keeps `newDiagram` behaviour, but it touches an existing command, and only the unrun integration suite would catch a regression there.
  • The parser accepts more than the spec asks for (front matter, entity decoding, `:::` shorthand, `direction`, other shapes imported as rectangles with a warning). The extras are documented and tested, but they add parser surface beyond the agreed subset.
CI details
No CI checks ran on this commit.

BackersAccepted
1 accept · 0 rebuild · 0 not voted · quorum 1 of 1
MaintainerMerge

Accepted by the backers and merged by the maintainer.

Ballots · 1
AcceptJonathan Miller

Automated review cost $0.18, counted as builder cost.

Discussion · 0

No comments yet.