Skip to content

Commit cdf549e

Browse files
committed
feat(cli): support mermaid files within markdown
Support mermaid diagrams that are embedded within CommonMark `.md` markdown files. We're using [remark](https://github.com/remarkjs/remark) to parse the markdown into a [markdown AST](https://github.com/syntax-tree/mdast). From this, we process every code block that has `lang=mermaid`, then turn the mdAST back to a string. This means that we also automatically format/prettify the user's markdown input, which is potentially unideal.
1 parent 834c3e6 commit cdf549e

8 files changed

+598
-5
lines changed

packages/cli/README.md

+4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ mermaid-chart --help
1717
`@mermaidchart/cli` allows you to easily sync local diagrams with your diagrams
1818
on https://mermaidchart.com.
1919

20+
These local diagrams can either be stored in `.mmd` or `.mermaid` files, or
21+
the can be stored within ```` ```mermaid```` code blocks within `.md` markdown
22+
files.
23+
2024
### `login`
2125

2226
Firstly, go to https://www.mermaidchart.com/app/user/settings and generate an

packages/cli/package.json

+6-1
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,13 @@
4141
"@tsconfig/strictest": "^2.0.2",
4242
"@types/iarna__toml": "^2.0.5",
4343
"@types/js-yaml": "^4.0.9",
44+
"@types/mdast": "^4.0.3",
4445
"@types/node": "^18.18.11",
4546
"@typescript-eslint/eslint-plugin": "^6.11.0",
4647
"@typescript-eslint/parser": "^6.11.0",
4748
"eslint": "^8.54.0",
4849
"typescript": "^5.2.2",
50+
"vfile": "^6.0.1",
4951
"vitest": "^0.34.6"
5052
},
5153
"dependencies": {
@@ -56,6 +58,9 @@
5658
"@inquirer/select": "^1.3.1",
5759
"@mermaidchart/sdk": "workspace:^",
5860
"commander": "^11.1.0",
59-
"js-yaml": "^4.1.0"
61+
"js-yaml": "^4.1.0",
62+
"remark": "^15.0.1",
63+
"to-vfile": "^8.0.0",
64+
"unist-util-visit": "^5.0.0"
6065
}
6166
}

packages/cli/src/commander.test.ts

+67
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import type { MCDocument, MCProject, MCUser } from '@mermaidchart/sdk/dist/types
1111

1212
/** Config file with auth_key setup */
1313
const CONFIG_AUTHED = 'test/fixtures/config-authed.toml';
14+
/** Markdown file that has every Mermaid diagrams already linked */
15+
const LINKED_MARKDOWN_FILE = 'test/fixtures/linked-markdown-file.md';
16+
/** Markdown file that has some unlinked Mermaid diagrams */
17+
const UNLINKED_MARKDOWN_FILE = 'test/fixtures/unlinked-markdown-file.md';
1418

1519
type Optional<T> = T | undefined;
1620
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -269,16 +273,42 @@ describe('link', () => {
269273
);
270274
});
271275
}
276+
277+
it('should link diagrams in a markdown file', async () => {
278+
const unlinkedMarkdownFile = 'test/output/markdown-file.md';
279+
await copyFile(UNLINKED_MARKDOWN_FILE, unlinkedMarkdownFile);
280+
281+
const { program } = mockedProgram();
282+
283+
vi.mock('@inquirer/confirm');
284+
vi.mock('@inquirer/select');
285+
vi.mocked(confirm).mockResolvedValue(true);
286+
vi.mocked(select).mockResolvedValueOnce(mockedProjects[0].id);
287+
288+
vi.mocked(MermaidChart.prototype.createDocument)
289+
.mockResolvedValueOnce(mockedEmptyDiagram)
290+
.mockResolvedValueOnce({ ...mockedEmptyDiagram, documentID: 'second-id' });
291+
await program.parseAsync(['--config', CONFIG_AUTHED, 'link', unlinkedMarkdownFile], {
292+
from: 'user',
293+
});
294+
295+
const file = await readFile(unlinkedMarkdownFile, { encoding: 'utf8' });
296+
297+
expect(file).toMatch(`id: ${mockedEmptyDiagram.documentID}\n`);
298+
expect(file).toMatch(`id: second-id\n`);
299+
});
272300
});
273301

274302
describe('pull', () => {
275303
const diagram = 'test/output/connected-diagram.mmd';
276304
const diagram2 = 'test/output/connected-diagram-2.mmd';
305+
const linkedMarkdownFile = 'test/output/linked-markdown-file.md';
277306

278307
beforeEach(async () => {
279308
await Promise.all([
280309
copyFile('test/fixtures/connected-diagram.mmd', diagram),
281310
copyFile('test/fixtures/connected-diagram.mmd', diagram2),
311+
copyFile(LINKED_MARKDOWN_FILE, linkedMarkdownFile),
282312
]);
283313
});
284314

@@ -327,16 +357,41 @@ title: My cool flowchart
327357
expect(diagramContents).toContain("flowchart TD\n A[I've been updated!]");
328358
}
329359
});
360+
361+
it('should pull documents from within markdown file', async () => {
362+
const { program } = mockedProgram();
363+
364+
vi.mocked(MermaidChart.prototype.getDocument)
365+
.mockResolvedValueOnce({
366+
...mockedEmptyDiagram,
367+
code: "flowchart TD\n A[I've been updated!]",
368+
})
369+
.mockResolvedValueOnce({
370+
...mockedEmptyDiagram,
371+
code: 'pie\n "Flowchart" : 2',
372+
});
373+
374+
await program.parseAsync(['--config', CONFIG_AUTHED, 'pull', linkedMarkdownFile], {
375+
from: 'user',
376+
});
377+
378+
const file = await readFile(linkedMarkdownFile, { encoding: 'utf8' });
379+
380+
expect(file).toMatch("flowchart TD\n A[I've been updated!]");
381+
expect(file).toMatch('pie\n "Flowchart" : 2');
382+
});
330383
});
331384

332385
describe('push', () => {
333386
const diagram = 'test/output/connected-diagram.mmd';
334387
const diagram2 = 'test/output/connected-diagram-2.mmd';
388+
const linkedMarkdownFile = 'test/output/linked-markdown-file.md';
335389

336390
beforeEach(async () => {
337391
await Promise.all([
338392
copyFile('test/fixtures/connected-diagram.mmd', diagram),
339393
copyFile('test/fixtures/connected-diagram.mmd', diagram2),
394+
copyFile(LINKED_MARKDOWN_FILE, linkedMarkdownFile),
340395
]);
341396
});
342397

@@ -368,4 +423,16 @@ describe('push', () => {
368423
}),
369424
);
370425
});
426+
427+
it('should push documents from within markdown file', async () => {
428+
const { program } = mockedProgram();
429+
430+
vi.mocked(MermaidChart.prototype.getDocument).mockResolvedValue(mockedEmptyDiagram);
431+
432+
await program.parseAsync(['--config', CONFIG_AUTHED, 'push', linkedMarkdownFile], {
433+
from: 'user',
434+
});
435+
436+
expect(vi.mocked(MermaidChart.prototype.setDocument)).toHaveBeenCalledTimes(2);
437+
});
371438
});

packages/cli/src/commander.ts

+23-3
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import confirm from '@inquirer/confirm';
1111
import input from '@inquirer/input';
1212
import select, { Separator } from '@inquirer/select';
1313
import { type Config, defaultConfigPath, readConfig, writeConfig } from './config.js';
14-
import { link, type LinkOptions, pull, push } from './methods.js';
14+
import { type Cache, link, type LinkOptions, pull, push } from './methods.js';
15+
import { processMarkdown } from './remark.js';
1516

1617
/**
1718
* Global configuration option for the root Commander Command.
@@ -157,6 +158,10 @@ function logout() {
157158
});
158159
}
159160

161+
function isMarkdownFile(path: string): path is `${string}.${'md' | 'markdown'}` {
162+
return /\.(md|markdown)$/.test(path);
163+
}
164+
160165
function linkCmd() {
161166
return createCommand('link')
162167
.description('Link the given Mermaid diagrams to Mermaid Chart')
@@ -200,9 +205,13 @@ function linkCmd() {
200205
return projectId;
201206
};
202207

203-
const linkCache = {};
208+
const linkCache: Cache = {};
204209

205210
for (const path of paths) {
211+
if (isMarkdownFile(path)) {
212+
await processMarkdown(path, { command: 'link', client, cache: linkCache, getProjectId });
213+
continue;
214+
}
206215
const existingFile = await readFile(path, { encoding: 'utf8' });
207216

208217
const linkedDiagram = await link(existingFile, client, {
@@ -220,12 +229,18 @@ function pullCmd() {
220229
return createCommand('pull')
221230
.description('Pulls documents from Mermaid Chart')
222231
.addArgument(new Argument('<path...>', 'The paths of the files to pull.'))
223-
.option('--check', 'Check whether the local files would be overwrited')
232+
.option('--check', 'Check whether the local files would be overwrited', false)
224233
.action(async (paths, options, command) => {
225234
const optsWithGlobals = command.optsWithGlobals<CommonOptions>();
226235
const client = await createClient(optsWithGlobals);
236+
227237
await Promise.all(
228238
paths.map(async (path) => {
239+
if (isMarkdownFile(path)) {
240+
await processMarkdown(path, { command: 'pull', client, check: options['check'] });
241+
return;
242+
}
243+
229244
const text = await readFile(path, { encoding: 'utf8' });
230245

231246
const newFile = await pull(text, client, { title: path });
@@ -255,6 +270,11 @@ function pushCmd() {
255270
const client = await createClient(optsWithGlobals);
256271
await Promise.all(
257272
paths.map(async (path) => {
273+
if (isMarkdownFile(path)) {
274+
await processMarkdown(path, { command: 'push', client });
275+
return;
276+
}
277+
258278
const text = await readFile(path, { encoding: 'utf8' });
259279

260280
await push(text, client, { title: path });

packages/cli/src/remark.ts

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { Root, Code } from 'mdast';
2+
import type { VFile } from 'vfile';
3+
import { visit } from 'unist-util-visit';
4+
import type { MermaidChart } from '@mermaidchart/sdk';
5+
import { type LinkOptions, link, pull, push } from './methods.js';
6+
7+
import { remark } from 'remark';
8+
import { read, write } from 'to-vfile';
9+
interface MCPluginCommonOptions {
10+
/** Authenticated client */
11+
client: MermaidChart;
12+
}
13+
14+
type MCPluginLinkOptions = MCPluginCommonOptions & {
15+
command: 'link';
16+
} & Pick<LinkOptions, 'cache' | 'getProjectId'>;
17+
18+
interface MCPluginPullOptions extends MCPluginCommonOptions {
19+
command: 'pull';
20+
check: boolean;
21+
}
22+
23+
interface MCPluginPushOptions extends MCPluginCommonOptions {
24+
command: 'push';
25+
}
26+
27+
export type MCPluginOptions = MCPluginLinkOptions | MCPluginPullOptions | MCPluginPushOptions;
28+
29+
/**
30+
* UnifiedJS plugin for syncing mermaid diagrams in a markdown file with
31+
* MermaidChart.com
32+
*
33+
* @param options - Options.
34+
*/
35+
export function plugin({ client, ...options }: MCPluginOptions) {
36+
return async function (tree: Root, file: VFile) {
37+
const mermaidNodes: Code[] = [];
38+
visit(tree, (node) => {
39+
if (node.type === 'code' && node?.lang === 'mermaid') {
40+
mermaidNodes.push(node);
41+
}
42+
});
43+
44+
if (mermaidNodes.length == 0) {
45+
console.log(`○ - ${file.basename} ignored, as it has no mermaid diagrams`);
46+
}
47+
48+
for (const node of mermaidNodes) {
49+
const title = `${file.basename}:${node.position?.start.line}`;
50+
switch (options.command) {
51+
case 'link':
52+
node.value = await link(node.value, client, {
53+
cache: options.cache,
54+
title: ``,
55+
getProjectId: options.getProjectId,
56+
});
57+
break;
58+
case 'pull': {
59+
const newValue = await pull(node.value, client, { title });
60+
61+
if (node.value === newValue) {
62+
console.log(`✅ - ${title} is up to date`);
63+
} else {
64+
if (options['check']) {
65+
console.log(`❌ - ${title} would be updated`);
66+
process.exitCode = 1;
67+
} else {
68+
node.value = newValue;
69+
console.log(`✅ - ${title} will be updated`);
70+
}
71+
}
72+
break;
73+
}
74+
case 'push':
75+
await push(node.value, client, { title });
76+
}
77+
}
78+
};
79+
}
80+
81+
/**
82+
* Read, process, and potentially update the given markdown file.
83+
*
84+
* @param inputFile - The file to read from, and potentially write to.
85+
* @param options - Options to pass to {@link plugin}.
86+
*/
87+
export async function processMarkdown(inputFile: string, options: MCPluginOptions) {
88+
const inVFile = await read(inputFile);
89+
const outVFile = await remark().use(plugin, options).process(inVFile);
90+
91+
switch (options.command) {
92+
case 'link':
93+
case 'pull':
94+
await write(outVFile);
95+
break;
96+
case 'push':
97+
// no need to write-back any data to the file
98+
}
99+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Test markdown file with linked diagrams
2+
3+
Here is a markdown comment: <!-- Hello World -->
4+
5+
This is a flowchart diagram
6+
7+
```mermaid
8+
---
9+
id: xxxxxxx-flowchart
10+
---
11+
flowchart
12+
A[Hello World]
13+
```
14+
15+
While this is a pie diagram
16+
17+
```mermaid
18+
---
19+
id: xxxxxxx-pie
20+
---
21+
pie
22+
"Flowchart" : 1
23+
"Pie" : 1
24+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Test markdown file
2+
3+
Here is a markdown comment: <!-- Hello World -->
4+
5+
This is a flowchart diagram
6+
7+
```mermaid
8+
flowchart
9+
A[Hello World]
10+
```
11+
12+
While this is a pie diagram
13+
14+
```mermaid
15+
pie
16+
"Flowchart" : 1
17+
"Pie" : 1
18+
```

0 commit comments

Comments
 (0)