Skip to content

Commit 3f3daf3

Browse files
authored
Merge pull request #16 from Mermaid-Chart/feat/support-mermaid-in-markdown-files
feat(cli): support mermaid diagrams within GFM (github flavored markdown)
2 parents 9384306 + 1ee1ab1 commit 3f3daf3

11 files changed

+980
-15
lines changed

packages/cli/README.md

+4
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ npx @mermaidchart/cli --help
2929
`@mermaidchart/cli` allows you to easily sync local diagrams with your diagrams
3030
on https://mermaidchart.com.
3131

32+
These local diagrams can either be stored in `.mmd` or `.mermaid` files, or
33+
they can be stored within ```` ```mermaid```` code blocks within `.md`
34+
[GFM](https://github.github.com/gfm/) markdown files.
35+
3236
### `login`
3337

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

packages/cli/package.json

+8-1
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,14 @@
4242
"@tsconfig/strictest": "^2.0.2",
4343
"@types/iarna__toml": "^2.0.5",
4444
"@types/js-yaml": "^4.0.9",
45+
"@types/mdast": "^4.0.3",
4546
"@types/node": "^18.18.11",
4647
"@typescript-eslint/eslint-plugin": "^6.11.0",
4748
"@typescript-eslint/parser": "^6.11.0",
4849
"eslint": "^8.54.0",
4950
"tsx": "^3.12.8",
5051
"typescript": "^5.2.2",
52+
"vfile": "^6.0.1",
5153
"vitest": "^0.34.6"
5254
},
5355
"dependencies": {
@@ -58,6 +60,11 @@
5860
"@inquirer/select": "^1.3.1",
5961
"@mermaidchart/sdk": "workspace:^",
6062
"commander": "^11.1.0",
61-
"js-yaml": "^4.1.0"
63+
"js-yaml": "^4.1.0",
64+
"remark": "^15.0.1",
65+
"remark-frontmatter": "^5.0.0",
66+
"remark-gfm": "^4.0.0",
67+
"to-vfile": "^8.0.0",
68+
"unist-util-visit": "^5.0.0"
6269
}
6370
}

packages/cli/src/commander.test.ts

+126
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ 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 linked and some unlinked Mermaid diagrams */
17+
const PARTIALLY_LINKED_MARKDOWN_FILE = 'test/fixtures/partially-linked-markdown-file.md';
18+
/** Markdown file that has unlinked Mermaid diagrams */
19+
const UNLINKED_MARKDOWN_FILE = 'test/fixtures/unlinked-markdown-file.md';
20+
/** Markdown file that has non-standard Markdown features like YAML frontmatter */
21+
const UNUSUAL_MARKDOWN_FILE = 'test/fixtures/unusual-markdown-file.md';
1422

1523
type Optional<T> = T | undefined;
1624
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -269,16 +277,97 @@ describe('link', () => {
269277
);
270278
});
271279
}
280+
281+
it('should link diagrams in a markdown file', async () => {
282+
const unlinkedMarkdownFile = 'test/output/markdown-file.md';
283+
await copyFile(UNLINKED_MARKDOWN_FILE, unlinkedMarkdownFile);
284+
285+
const { program } = mockedProgram();
286+
287+
vi.mock('@inquirer/confirm');
288+
vi.mock('@inquirer/select');
289+
vi.mocked(confirm).mockResolvedValue(true);
290+
vi.mocked(select).mockResolvedValueOnce(mockedProjects[0].id);
291+
292+
vi.mocked(MermaidChart.prototype.createDocument)
293+
.mockResolvedValueOnce(mockedEmptyDiagram)
294+
.mockResolvedValueOnce({ ...mockedEmptyDiagram, documentID: 'second-id' });
295+
await program.parseAsync(['--config', CONFIG_AUTHED, 'link', unlinkedMarkdownFile], {
296+
from: 'user',
297+
});
298+
299+
const file = await readFile(unlinkedMarkdownFile, { encoding: 'utf8' });
300+
301+
expect(file).toMatch(`id: ${mockedEmptyDiagram.documentID}\n`);
302+
expect(file).toMatch(`id: second-id\n`);
303+
});
304+
305+
it('should link diagrams in partially linked markdown file', async () => {
306+
const partiallyLinkedMarkdownFile = 'test/output/partially-linked-markdown-file.md';
307+
await copyFile(PARTIALLY_LINKED_MARKDOWN_FILE, partiallyLinkedMarkdownFile);
308+
309+
const { program } = mockedProgram();
310+
311+
vi.mock('@inquirer/confirm');
312+
vi.mock('@inquirer/select');
313+
vi.mocked(confirm).mockResolvedValue(true);
314+
vi.mocked(select).mockResolvedValueOnce(mockedProjects[0].id);
315+
316+
vi.mocked(MermaidChart.prototype.createDocument).mockResolvedValueOnce({
317+
...mockedEmptyDiagram,
318+
documentID: 'second-id',
319+
});
320+
await program.parseAsync(['--config', CONFIG_AUTHED, 'link', partiallyLinkedMarkdownFile], {
321+
from: 'user',
322+
});
323+
324+
const file = await readFile(partiallyLinkedMarkdownFile, { encoding: 'utf8' });
325+
326+
expect(file).toMatch(`id: second-id\n`);
327+
});
328+
329+
it('should handle unusual markdown formatting', async () => {
330+
const unusualMarkdownFile = 'test/output/unusual-markdown-file.md';
331+
await copyFile(UNUSUAL_MARKDOWN_FILE, unusualMarkdownFile);
332+
333+
const { program } = mockedProgram();
334+
335+
vi.mock('@inquirer/confirm');
336+
vi.mock('@inquirer/select');
337+
vi.mocked(confirm).mockResolvedValue(true);
338+
vi.mocked(select).mockResolvedValueOnce(mockedProjects[0].id);
339+
340+
vi.mocked(MermaidChart.prototype.createDocument).mockResolvedValueOnce({
341+
...mockedEmptyDiagram,
342+
documentID: 'my-mocked-diagram-id',
343+
});
344+
await program.parseAsync(['--config', CONFIG_AUTHED, 'link', unusualMarkdownFile], {
345+
from: 'user',
346+
});
347+
348+
const file = await readFile(unusualMarkdownFile, { encoding: 'utf8' });
349+
350+
const idLineRegex = /^.*id: my-mocked-diagram-id\n/gm;
351+
352+
expect(file).toMatch(idLineRegex);
353+
// other than the added `id: xxxx` field, everything else should be identical,
354+
// although in practice, we'd expect some formatting changes
355+
expect(file.replace(idLineRegex, '')).toStrictEqual(
356+
await readFile(UNUSUAL_MARKDOWN_FILE, { encoding: 'utf8' }),
357+
);
358+
});
272359
});
273360

274361
describe('pull', () => {
275362
const diagram = 'test/output/connected-diagram.mmd';
276363
const diagram2 = 'test/output/connected-diagram-2.mmd';
364+
const linkedMarkdownFile = 'test/output/linked-markdown-file.md';
277365

278366
beforeEach(async () => {
279367
await Promise.all([
280368
copyFile('test/fixtures/connected-diagram.mmd', diagram),
281369
copyFile('test/fixtures/connected-diagram.mmd', diagram2),
370+
copyFile(LINKED_MARKDOWN_FILE, linkedMarkdownFile),
282371
]);
283372
});
284373

@@ -327,16 +416,41 @@ title: My cool flowchart
327416
expect(diagramContents).toContain("flowchart TD\n A[I've been updated!]");
328417
}
329418
});
419+
420+
it('should pull documents from within markdown file', async () => {
421+
const { program } = mockedProgram();
422+
423+
vi.mocked(MermaidChart.prototype.getDocument)
424+
.mockResolvedValueOnce({
425+
...mockedEmptyDiagram,
426+
code: "flowchart TD\n A[I've been updated!]",
427+
})
428+
.mockResolvedValueOnce({
429+
...mockedEmptyDiagram,
430+
code: 'pie\n "Flowchart" : 2',
431+
});
432+
433+
await program.parseAsync(['--config', CONFIG_AUTHED, 'pull', linkedMarkdownFile], {
434+
from: 'user',
435+
});
436+
437+
const file = await readFile(linkedMarkdownFile, { encoding: 'utf8' });
438+
439+
expect(file).toMatch("flowchart TD\n A[I've been updated!]");
440+
expect(file).toMatch('pie\n "Flowchart" : 2');
441+
});
330442
});
331443

332444
describe('push', () => {
333445
const diagram = 'test/output/connected-diagram.mmd';
334446
const diagram2 = 'test/output/connected-diagram-2.mmd';
447+
const linkedMarkdownFile = 'test/output/linked-markdown-file.md';
335448

336449
beforeEach(async () => {
337450
await Promise.all([
338451
copyFile('test/fixtures/connected-diagram.mmd', diagram),
339452
copyFile('test/fixtures/connected-diagram.mmd', diagram2),
453+
copyFile(LINKED_MARKDOWN_FILE, linkedMarkdownFile),
340454
]);
341455
});
342456

@@ -368,4 +482,16 @@ describe('push', () => {
368482
}),
369483
);
370484
});
485+
486+
it('should push documents from within markdown file', async () => {
487+
const { program } = mockedProgram();
488+
489+
vi.mocked(MermaidChart.prototype.getDocument).mockResolvedValue(mockedEmptyDiagram);
490+
491+
await program.parseAsync(['--config', CONFIG_AUTHED, 'push', linkedMarkdownFile], {
492+
from: 'user',
493+
});
494+
495+
expect(vi.mocked(MermaidChart.prototype.setDocument)).toHaveBeenCalledTimes(2);
496+
});
371497
});

packages/cli/src/commander.ts

+24-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,15 +205,20 @@ 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, {
209218
cache: linkCache,
210219
title: path,
211220
getProjectId,
221+
ignoreAlreadyLinked: false,
212222
});
213223

214224
await writeFile(path, linkedDiagram, { encoding: 'utf8' });
@@ -220,12 +230,18 @@ function pullCmd() {
220230
return createCommand('pull')
221231
.description('Pulls documents from Mermaid Chart')
222232
.addArgument(new Argument('<path...>', 'The paths of the files to pull.'))
223-
.option('--check', 'Check whether the local files would be overwrited')
233+
.option('--check', 'Check whether the local files would be overwrited', false)
224234
.action(async (paths, options, command) => {
225235
const optsWithGlobals = command.optsWithGlobals<CommonOptions>();
226236
const client = await createClient(optsWithGlobals);
237+
227238
await Promise.all(
228239
paths.map(async (path) => {
240+
if (isMarkdownFile(path)) {
241+
await processMarkdown(path, { command: 'pull', client, check: options['check'] });
242+
return;
243+
}
244+
229245
const text = await readFile(path, { encoding: 'utf8' });
230246

231247
const newFile = await pull(text, client, { title: path });
@@ -255,6 +271,11 @@ function pushCmd() {
255271
const client = await createClient(optsWithGlobals);
256272
await Promise.all(
257273
paths.map(async (path) => {
274+
if (isMarkdownFile(path)) {
275+
await processMarkdown(path, { command: 'push', client });
276+
return;
277+
}
278+
258279
const text = await readFile(path, { encoding: 'utf8' });
259280

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

packages/cli/src/methods.ts

+18-9
Original file line numberDiff line numberDiff line change
@@ -39,28 +39,37 @@ interface CommonOptions {
3939
export interface LinkOptions extends CommonOptions {
4040
/** Function that asks the user which project id they want to upload a diagram to */
4141
getProjectId: (cache: LinkOptions['cache'], documentTitle: string) => Promise<string>;
42-
// cache to be shared between link calls. This object may be modified between calls.
42+
/** cache to be shared between link calls. This object may be modified between calls. */
4343
cache: Cache;
44+
/** If `true`, ignore diagrams that are already linked. */
45+
ignoreAlreadyLinked: boolean;
4446
}
4547

4648
/**
4749
* Creates a new diagram on MermaidChart.com for the given local diagram.
4850
*
4951
* @returns The diagram with an added `id: xxxx` field.
5052
*/
51-
export async function link(diagram: string, client: MermaidChart, options: LinkOptions) {
53+
export async function link(
54+
diagram: string,
55+
client: MermaidChart,
56+
{ title, getProjectId, cache, ignoreAlreadyLinked }: LinkOptions,
57+
) {
5258
const frontmatter = extractFrontMatter(diagram);
5359

5460
if (frontmatter.metadata.id) {
55-
throw new CommanderError(
56-
/*exitCode=*/ 1,
57-
'EALREADY_LINKED',
58-
'This document already has an `id` field',
59-
);
61+
if (ignoreAlreadyLinked) {
62+
console.log(`○ - ${title} is already linked`);
63+
return diagram; // no change required
64+
} else {
65+
throw new CommanderError(
66+
/*exitCode=*/ 1,
67+
'EALREADY_LINKED',
68+
'This document already has an `id` field',
69+
);
70+
}
6071
}
6172

62-
const { title, getProjectId, cache } = options;
63-
6473
const projectId = await getProjectId(cache, title);
6574

6675
const createdDocument = await client.createDocument(projectId);

0 commit comments

Comments
 (0)