Skip to content

Commit dd3dec5

Browse files
YoofiTT96rocketstack-mattLeighFinegold
authored
feat(cli): add --export-diagrams flag to render mermaid diagrams via local browser (finos#2634)
* feat(cli): add --export-diagrams flag to render mermaid diagrams via local browser Adds --export-diagrams <svg|png> to docify, which post-processes generated markdown to render mermaid code blocks into SVG/PNG images using a local Chromium-based browser (playwright-core), with chrome/msedge auto-detection and a --browser-path escape hatch. Rendering is opt-in and recoverable on failure -- diagrams remain as mermaid blocks if no browser is found or a diagram fails to render. playwright-core, mermaid, and @mermaid-js/layout-elk are marked external in tsup configs and only loaded lazily when the flag is used. * docs(cli): document --export-diagrams, --browser-path, --diagram-render-timeout Adds the new docify diagram-export options to the CLI README docify help output and usage section, and to the working-with-calm CLI reference docs. * fix(shared): resolve strict TS error in Docifier.renderDiagrams this.exportDiagrams is DiagramExportFormat | undefined, and TS cannot narrow it across the docify -> renderDiagrams call. renderDiagrams is only invoked when exportDiagrams is set, so assert non-null. * fix(shared): center exported diagrams and match export font to IDE rendering Rewrite exported mermaid diagrams as centered <p align="center"><img/></p> blocks instead of plain markdown image references, and ensure the standalone SVG/PNG output uses the same font stack and sizing that calm-widgets/VS Code use for live rendering, fixing visual mismatches and oversized/clipped exports. * fix(shared): address Copilot review feedback on diagram export Use an explicit .md/.mdx regex (MARKDOWN_FILE_REGEX, now shared with processDiagramsInDirectory) to decide whether --output is a file or a directory, instead of path.extname which misclassified directories with a dot in their name (e.g. docs/v1.2) as files. Make MERMAID_BLOCK_REGEX tolerate CRLF line endings around mermaid code fences, and switch the per-diagram mkdirp call to its async form so it no longer blocks the event loop inside the render loop. Also clarifies the MermaidBrowserRendererOptions.browser doc comment: dispose() does close the passed-in browser. * fix(shared): respect --verbose for diagram export logger Docifier.renderDiagrams hardcoded initLogger(false, ...), so its debug-level logs (e.g. MermaidBrowserRenderer cleanup diagnostics) were always suppressed even when --verbose was passed. Align it with the process.env.DEBUG === 'true' pattern used by every other initLogger call in shared/src. * test(shared): extract createMockLogger test helper Dedupe the repeated mock Logger object literal across the docify and template-strategy specs into a shared createMockLogger() helper, and fix the loading-helpers.spec.ts mock from any to the proper Logger type. Also rename PNG_MAGIC_BYTES to PNG_SIGNATURE with a clarifying comment in the diagram-export e2e spec. --------- Co-authored-by: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Co-authored-by: LeighF <167173740+LeighFinegold@users.noreply.github.com>
1 parent 522526b commit dd3dec5

27 files changed

Lines changed: 1766 additions & 44 deletions

calm-plugins/vscode/tsup.config.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ export default defineConfig([
1212
dts: false,
1313
// Bundle runtime dependencies into the extension so the installed VSIX does
1414
// not rely on node_modules being present in the target environment.
15-
// Keep 'vscode' external (provided by the host).
16-
external: ['vscode'],
15+
// Keep 'vscode' external (provided by the host). 'playwright-core' is also
16+
// external: it's only required lazily by Docifier's --export-diagrams path
17+
// (never used by the extension), and bundling it fails (esbuild can't
18+
// resolve playwright-core's chromium-bidi subpath imports).
19+
external: ['vscode', 'playwright-core'],
1720
noExternal: [
1821
'yaml',
1922
'lodash',

calm-server/tsup.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export default defineConfig({
55
format: ['cjs'],
66
sourcemap: false,
77
clean: true,
8-
external: ['canvas', 'fsevents', '@apidevtools/json-schema-ref-parser', /node_modules/, 'ts-node'],
8+
external: ['canvas', 'fsevents', '@apidevtools/json-schema-ref-parser', /node_modules/, 'ts-node', 'playwright-core', 'mermaid', '@mermaid-js/layout-elk'],
99
noExternal: ['@finos/calm-shared', '@finos/calm-widgets', '@finos/calm-models', /tsup/],
1010
bundle: true,
1111
splitting: false,

cli/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,9 @@ Options:
452452
-t, --template <path> Path to a single .hbs or .md template file
453453
-d, --template-dir <path> Path to a directory of .hbs/.md templates
454454
-u, --url-to-local-file-mapping <path> Path to mapping file which maps URLs to local paths.
455+
--export-diagrams <svg|png> Render mermaid diagrams to image files using a local Chromium-based browser (adds roughly 10-40s depending on diagram count).
456+
--browser-path <path> Path to a Chromium-based browser executable, only needed if automatic detection fails.
457+
--diagram-render-timeout <ms> Per-diagram render timeout in milliseconds, only used with --export-diagrams (default: 30000).
455458
-v, --verbose Enable verbose logging. (default: false)
456459
-h, --help display help for command
457460
```
@@ -487,6 +490,32 @@ Sample usage for you to try is as follows (assuming at root of project)
487490
calm docify -a ./cli/test_fixtures/template/model/document-system.json -o ./output/documentation -u ./cli/test_fixtures/template/model/url-to-file-directory.json
488491
```
489492
493+
### Exporting Diagrams as Images
494+
495+
By default, generated documentation contains Mermaid diagrams as ` ```mermaid ` code
496+
blocks. The `--export-diagrams <svg|png>` option renders these diagrams to image
497+
files using a local Chromium-based browser, replacing each code block with a
498+
centered image reference (e.g. `<p align="center"><img src="_diagrams/my-page-1.svg" alt="Diagram 1" /></p>`).
499+
500+
```shell
501+
calm docify -a ./architecture.json -o ./output/documentation --export-diagrams svg
502+
```
503+
504+
This requires Google Chrome or Microsoft Edge to be installed locally (both are
505+
detected automatically). If neither is found, the command prints guidance for
506+
locating another Chromium-based browser, and the documentation is still generated
507+
with the Mermaid code blocks left as-is.
508+
509+
- `--browser-path <path>`: use a specific Chromium-based browser (e.g. Brave,
510+
Vivaldi, Chromium) instead of relying on automatic detection.
511+
- `--diagram-render-timeout <ms>`: per-diagram render timeout, useful for very
512+
large or complex diagrams (default: `30000`).
513+
514+
Rendering adds roughly 10-40 seconds to the command depending on the number of
515+
diagrams. If an individual diagram fails to render (e.g. a timeout or invalid
516+
syntax), it is left as a Mermaid code block and a warning is logged — the rest of
517+
the documentation is unaffected.
518+
490519
### Default options for widgets in templates
491520
492521
Frontmatter can be used in templates to provide default options for any widgets used:

cli/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,14 @@
3939
"dependencies": {
4040
"@apidevtools/json-schema-ref-parser": "^14.0.0",
4141
"@inquirer/prompts": "^7.4.1",
42+
"@mermaid-js/layout-elk": "^0.2.1",
4243
"@stoplight/json": "^3.21.7",
4344
"commander": "^14.0.0",
4445
"copyfiles": "^2.4.1",
4546
"execa": "^9.6.0",
47+
"mermaid": "^11.15.0",
4648
"mkdirp": "^3.0.1",
49+
"playwright-core": "^1.56.0",
4750
"ts-node": "10.9.2"
4851
},
4952
"devDependencies": {

cli/src/cli.spec.ts

Lines changed: 181 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,10 @@ describe('CLI Commands', () => {
368368
'bundle',
369369
undefined,
370370
false,
371-
false
371+
false,
372+
undefined,
373+
undefined,
374+
undefined
372375
);
373376
});
374377

@@ -388,7 +391,10 @@ describe('CLI Commands', () => {
388391
'bundle',
389392
undefined,
390393
true,
391-
false
394+
false,
395+
undefined,
396+
undefined,
397+
undefined
392398
);
393399
});
394400

@@ -408,7 +414,10 @@ describe('CLI Commands', () => {
408414
'template',
409415
'template.hbs',
410416
false,
411-
false
417+
false,
418+
undefined,
419+
undefined,
420+
undefined
412421
);
413422
});
414423

@@ -428,7 +437,10 @@ describe('CLI Commands', () => {
428437
'template-directory',
429438
'templateDir',
430439
false,
431-
false
440+
false,
441+
undefined,
442+
undefined,
443+
undefined
432444
);
433445
});
434446

@@ -468,7 +480,10 @@ describe('CLI Commands', () => {
468480
'bundle',
469481
undefined,
470482
false,
471-
false
483+
false,
484+
undefined,
485+
undefined,
486+
undefined
472487
);
473488
});
474489

@@ -488,7 +503,10 @@ describe('CLI Commands', () => {
488503
'template',
489504
'template.hbs',
490505
false,
491-
false
506+
false,
507+
undefined,
508+
undefined,
509+
undefined
492510
);
493511
});
494512

@@ -514,7 +532,10 @@ describe('CLI Commands', () => {
514532
'bundle',
515533
undefined,
516534
false,
517-
false
535+
false,
536+
undefined,
537+
undefined,
538+
undefined
518539
);
519540
});
520541

@@ -559,6 +580,159 @@ describe('CLI Commands', () => {
559580
exitSpy.mockRestore();
560581
errorSpy.mockRestore();
561582
});
583+
584+
it('should exit if --export-diagrams is combined with --scaffold', async () => {
585+
const exitSpy = vi.spyOn(process, 'exit').mockImplementationOnce(function () {
586+
throw new Error('process.exit called');
587+
});
588+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(function () { });
589+
590+
await expect(program.parseAsync([
591+
'node', 'cli.js', 'docify',
592+
'--architecture', 'model.json',
593+
'--output', 'outDir',
594+
'--scaffold',
595+
'--export-diagrams', 'svg',
596+
])).rejects.toThrow('process.exit called');
597+
598+
expect(errorSpy).toHaveBeenCalledWith('❌ --export-diagrams cannot be combined with --scaffold (scaffold output is unrendered)');
599+
expect(exitSpy).toHaveBeenCalledWith(1);
600+
601+
exitSpy.mockRestore();
602+
errorSpy.mockRestore();
603+
});
604+
605+
it('should pass --export-diagrams through to the Docifier', async () => {
606+
await program.parseAsync([
607+
'node', 'cli.js', 'docify',
608+
'--architecture', 'model.json',
609+
'--output', 'outDir',
610+
'--export-diagrams', 'svg',
611+
]);
612+
613+
expect(docifierConstructorSpy).toHaveBeenCalledWith(
614+
'WEBSITE',
615+
'model.json',
616+
'outDir',
617+
undefined,
618+
'bundle',
619+
undefined,
620+
false,
621+
false,
622+
'svg',
623+
undefined,
624+
undefined
625+
);
626+
});
627+
628+
it('should pass --export-diagrams and --browser-path through to the Docifier', async () => {
629+
await program.parseAsync([
630+
'node', 'cli.js', 'docify',
631+
'--architecture', 'model.json',
632+
'--output', 'outDir',
633+
'--export-diagrams', 'png',
634+
'--browser-path', '/path/to/chrome',
635+
]);
636+
637+
expect(docifierConstructorSpy).toHaveBeenCalledWith(
638+
'WEBSITE',
639+
'model.json',
640+
'outDir',
641+
undefined,
642+
'bundle',
643+
undefined,
644+
false,
645+
false,
646+
'png',
647+
'/path/to/chrome',
648+
undefined
649+
);
650+
});
651+
652+
it('should pass --diagram-render-timeout through to the Docifier', async () => {
653+
await program.parseAsync([
654+
'node', 'cli.js', 'docify',
655+
'--architecture', 'model.json',
656+
'--output', 'outDir',
657+
'--export-diagrams', 'svg',
658+
'--diagram-render-timeout', '30000',
659+
]);
660+
661+
expect(docifierConstructorSpy).toHaveBeenCalledWith(
662+
'WEBSITE',
663+
'model.json',
664+
'outDir',
665+
undefined,
666+
'bundle',
667+
undefined,
668+
false,
669+
false,
670+
'svg',
671+
undefined,
672+
30000
673+
);
674+
});
675+
676+
it('should exit if --diagram-render-timeout is specified without --export-diagrams', async () => {
677+
const exitSpy = vi.spyOn(process, 'exit').mockImplementationOnce(function () {
678+
throw new Error('process.exit called');
679+
});
680+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(function () { });
681+
682+
await expect(program.parseAsync([
683+
'node', 'cli.js', 'docify',
684+
'--architecture', 'model.json',
685+
'--output', 'outDir',
686+
'--diagram-render-timeout', '30000',
687+
])).rejects.toThrow('process.exit called');
688+
689+
expect(errorSpy).toHaveBeenCalledWith('❌ --diagram-render-timeout requires --export-diagrams <svg|png>');
690+
expect(exitSpy).toHaveBeenCalledWith(1);
691+
692+
exitSpy.mockRestore();
693+
errorSpy.mockRestore();
694+
});
695+
696+
it('should exit if --diagram-render-timeout is not a positive number', async () => {
697+
const exitSpy = vi.spyOn(process, 'exit').mockImplementationOnce(function () {
698+
throw new Error('process.exit called');
699+
});
700+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(function () { });
701+
702+
await expect(program.parseAsync([
703+
'node', 'cli.js', 'docify',
704+
'--architecture', 'model.json',
705+
'--output', 'outDir',
706+
'--export-diagrams', 'svg',
707+
'--diagram-render-timeout', '0',
708+
])).rejects.toThrow('process.exit called');
709+
710+
expect(errorSpy).toHaveBeenCalledWith('❌ --diagram-render-timeout must be a positive number of milliseconds');
711+
expect(exitSpy).toHaveBeenCalledWith(1);
712+
713+
exitSpy.mockRestore();
714+
errorSpy.mockRestore();
715+
});
716+
717+
it('should exit if --browser-path is specified without --export-diagrams', async () => {
718+
const exitSpy = vi.spyOn(process, 'exit').mockImplementationOnce(function () {
719+
throw new Error('process.exit called');
720+
});
721+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(function () { });
722+
723+
await expect(program.parseAsync([
724+
'node', 'cli.js', 'docify',
725+
'--architecture', 'model.json',
726+
'--output', 'outDir',
727+
'--browser-path', '/path/to/chrome',
728+
])).rejects.toThrow('process.exit called');
729+
730+
expect(errorSpy).toHaveBeenCalledWith('❌ --browser-path requires --export-diagrams <svg|png>');
731+
expect(exitSpy).toHaveBeenCalledWith(1);
732+
733+
exitSpy.mockRestore();
734+
errorSpy.mockRestore();
735+
});
562736
});
563737

564738
describe('push architecture command', () => {

cli/src/cli.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CALM_META_SCHEMA_DIRECTORY, DocifyMode, initLogger, runGenerate, SchemaDirectory, TemplateProcessingMode, CalmChoice, buildDocumentLoader, DocumentLoader, DocumentLoaderOptions } from '@finos/calm-shared';
1+
import { CALM_META_SCHEMA_DIRECTORY, DocifyMode, DiagramExportFormat, initLogger, runGenerate, SchemaDirectory, TemplateProcessingMode, CalmChoice, buildDocumentLoader, DocumentLoader, DocumentLoaderOptions } from '@finos/calm-shared';
22
import { Option, Command } from 'commander';
33
import { version } from '../package.json';
44
import { promptUserForOptions, loadChoicesFromInput } from './command-helpers/generate-options';
@@ -212,6 +212,14 @@ Validation requires:
212212
.option(URL_MAPPING_OPTION, 'Path to mapping file which maps URLs to local paths')
213213
.option('--scaffold', 'Copy template files without processing (for customization/live docify)', false)
214214
.addOption(new Option('--ants').default(false).hideHelp())
215+
.addOption(new Option('--export-diagrams <format>',
216+
'Render mermaid diagrams to image files using a local Chromium-based browser ' +
217+
'(adds roughly 10-40s depending on diagram count)').choices(['svg', 'png']))
218+
.option('--browser-path <path>',
219+
'Path to a Chromium-based browser executable, only needed if automatic detection fails (run with --export-diagrams to see guidance)')
220+
.option('--diagram-render-timeout <ms>',
221+
'Per-diagram render timeout in milliseconds, only used with --export-diagrams (default: 30000). ' +
222+
'Increase this for large/complex diagrams that time out during rendering.')
215223
.option(VERBOSE_OPTION, 'Enable verbose logging.', false)
216224
.action(async (options) => {
217225
const { Docifier } = await import('@finos/calm-shared');
@@ -232,6 +240,30 @@ Validation requires:
232240
process.exit(1);
233241
}
234242

243+
if (options.exportDiagrams && options.scaffold) {
244+
console.error('❌ --export-diagrams cannot be combined with --scaffold (scaffold output is unrendered)');
245+
process.exit(1);
246+
}
247+
248+
if (options.browserPath && !options.exportDiagrams) {
249+
console.error('❌ --browser-path requires --export-diagrams <svg|png>');
250+
process.exit(1);
251+
}
252+
253+
if (options.diagramRenderTimeout && !options.exportDiagrams) {
254+
console.error('❌ --diagram-render-timeout requires --export-diagrams <svg|png>');
255+
process.exit(1);
256+
}
257+
258+
let diagramRenderTimeoutMs: number | undefined;
259+
if (options.diagramRenderTimeout) {
260+
diagramRenderTimeoutMs = Number(options.diagramRenderTimeout);
261+
if (!Number.isFinite(diagramRenderTimeoutMs) || diagramRenderTimeoutMs <= 0) {
262+
console.error('❌ --diagram-render-timeout must be a positive number of milliseconds');
263+
process.exit(1);
264+
}
265+
}
266+
235267
let docifyMode: DocifyMode = 'WEBSITE';
236268
let templateProcessingMode: TemplateProcessingMode = 'bundle';
237269
let templatePath: string | undefined = undefined;
@@ -256,7 +288,10 @@ Validation requires:
256288
templateProcessingMode,
257289
templatePath,
258290
options.clearOutputDirectory,
259-
options.scaffold
291+
options.scaffold,
292+
options.exportDiagrams as DiagramExportFormat | undefined,
293+
options.browserPath,
294+
diagramRenderTimeoutMs
260295
);
261296

262297
await docifier.docify();

cli/tsup.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export default defineConfig({
77
format: ['cjs'],
88
sourcemap: false,
99
clean: true,
10-
external: ['canvas', 'fsevents', '@apidevtools/json-schema-ref-parser', /node_modules/, 'ts-node'],
10+
external: ['canvas', 'fsevents', '@apidevtools/json-schema-ref-parser', /node_modules/, 'ts-node', 'playwright-core', 'mermaid', '@mermaid-js/layout-elk'],
1111
noExternal: ['@finos/calm-shared', '@finos/calm-widgets','@finos/calm-models', /tsup/],
1212
bundle: true,
1313
splitting: false,

0 commit comments

Comments
 (0)