-
Notifications
You must be signed in to change notification settings - Fork 128
🧜♀️ Create images of mermaid diagrams for static exports #2132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| 'myst-common': patch | ||
| --- | ||
|
|
||
| Added `options?: PluginOptions` to `TransformSpec` type. Enables passing configuration options to transform plugins. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| --- | ||
| 'myst-cli': patch | ||
| --- | ||
|
|
||
| Integrated transform loading from options with proper plugin utilities. | ||
|
|
||
| Added mermaid transform import and configuration | ||
| Integrated mermaid transform into Typst build pipeline. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| 'myst-spec-ext': patch | ||
| --- | ||
|
|
||
| Added `label`, `identifier`, and `html_id` to `Image` type |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,3 +24,32 @@ flowchart LR | |
| :::{note} | ||
| Both GitHub and JupyterLab ([#101](https://github.com/jupyter/enhancement-proposals/pull/101)) support the translation of a code-block ` ```mermaid ` to a mermaid diagram directly, this can also be used by default in MyST. | ||
| ::: | ||
|
|
||
| ## Rendering for Static Exports | ||
|
|
||
| MyST supports static rendering of Mermaid diagrams for static export formats (PDF, Word, LaTeX, Typst). This feature converts Mermaid syntax to base64-encoded SVG or PNG images during the build process, ensuring consistent rendering across all static output formats. | ||
|
|
||
| :::{important} Prerequisites | ||
| To use static Mermaid rendering, you need the Mermaid CLI installed: | ||
|
|
||
| ```bash | ||
| npm install -g @mermaid-js/mermaid-cli | ||
| ``` | ||
|
|
||
| ::: | ||
|
|
||
| :::{note .dropdown} For CI Environments | ||
|
|
||
| The Mermaid CLI uses Puppeteer which may require special configuration in CI environments. MyST automatically handles this by detecting the `CI` environment variable or you can manually control it: | ||
|
|
||
| ```bash | ||
| # Automatic detection (recommended for CI) | ||
| CI=true npm run build | ||
|
|
||
| # Manual control | ||
| MERMAID_NO_SANDBOX=true npm run build | ||
| ``` | ||
|
Comment on lines
+45
to
+51
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CI was way harder to get going than I thought. Should be automatic now though!
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice! |
||
|
|
||
| This automatically creates a Puppeteer configuration file with `--no-sandbox` flag to resolve sandbox issues in Linux CI environments. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, this answers part of my question from above. Does generating the config equate to disabling the sandbox? If so, maybe something like:
|
||
|
|
||
| ::: | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,57 +4,53 @@ import { TemplateKind } from 'myst-common'; | |
| import MystTemplate, { downloadTemplate, resolveInputs, Session } from 'myst-templates'; | ||
| import { renderTemplate } from '../src'; | ||
|
|
||
| describe( | ||
| 'Download Template', | ||
| () => { | ||
| it('Download default template', async () => { | ||
| const session = new Session(); | ||
| const inputs = resolveInputs(session, { buildDir: '_build', kind: TemplateKind.tex }); | ||
| await downloadTemplate(session, { | ||
| templatePath: inputs.templatePath, | ||
| templateUrl: inputs.templateUrl as string, | ||
| }); | ||
| expect(fs.existsSync('_build/templates/tex/myst/plain_latex/template.zip')).toBe(true); | ||
| expect(fs.existsSync('_build/templates/tex/myst/plain_latex/template.yml')).toBe(true); | ||
| expect(fs.existsSync('_build/templates/tex/myst/plain_latex/template.tex')).toBe(true); | ||
| describe('Download Template', { timeout: 15000 }, () => { | ||
| it('Download default template', async () => { | ||
|
Comment on lines
+7
to
+8
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is just putting the timeout as the second arg. |
||
| const session = new Session(); | ||
| const inputs = resolveInputs(session, { buildDir: '_build', kind: TemplateKind.tex }); | ||
| await downloadTemplate(session, { | ||
| templatePath: inputs.templatePath, | ||
| templateUrl: inputs.templateUrl as string, | ||
| }); | ||
| it('Bad template paths to throw', async () => { | ||
| const jtex = new MystTemplate(new Session(), { | ||
| template: 'not-there', | ||
| kind: TemplateKind.tex, | ||
| }); | ||
| expect(() => jtex.prepare({} as any)).toThrow(/does not exist/); | ||
| expect(fs.existsSync('_build/templates/tex/myst/plain_latex/template.zip')).toBe(true); | ||
| expect(fs.existsSync('_build/templates/tex/myst/plain_latex/template.yml')).toBe(true); | ||
| expect(fs.existsSync('_build/templates/tex/myst/plain_latex/template.tex')).toBe(true); | ||
| }); | ||
| it('Bad template paths to throw', async () => { | ||
| const jtex = new MystTemplate(new Session(), { | ||
| template: 'not-there', | ||
| kind: TemplateKind.tex, | ||
| }); | ||
| it('Render out the template', async () => { | ||
| const jtex = new MystTemplate(new Session(), { | ||
| kind: TemplateKind.tex, | ||
| template: `${__dirname}/example`, | ||
| }); | ||
| renderTemplate(jtex, { | ||
| contentOrPath: `${__dirname}/test.tex`, | ||
| outputPath: '_build/out/article.tex', | ||
| frontmatter: { | ||
| title: 'test', | ||
| description: 'test', | ||
| date: new Date(2022, 6, 22).toISOString(), | ||
| authors: [ | ||
| { name: 'Rowan Cockett', affiliations: ['Curvenote'] }, | ||
| { name: 'Steve Purves', affiliations: ['Curvenote'], orcid: '0000' }, | ||
| ], | ||
| }, | ||
| options: { | ||
| keywords: '', | ||
| }, | ||
| parts: { | ||
| abstract: 'My abstract!', | ||
| }, | ||
| }); | ||
| expect(fs.existsSync('_build/out/article.tex')).toBe(true); | ||
| const content = fs.readFileSync('_build/out/article.tex').toString(); | ||
| expect(content.includes('Volcanic Archipelago')).toBe(true); | ||
| expect(content.includes('Rowan Cockett')).toBe(true); | ||
| expect(content.includes('My abstract!')).toBe(true); | ||
| expect(() => jtex.prepare({} as any)).toThrow(/does not exist/); | ||
| }); | ||
| it('Render out the template', async () => { | ||
| const jtex = new MystTemplate(new Session(), { | ||
| kind: TemplateKind.tex, | ||
| template: `${__dirname}/example`, | ||
| }); | ||
| }, | ||
| { timeout: 15000 }, | ||
| ); | ||
| renderTemplate(jtex, { | ||
| contentOrPath: `${__dirname}/test.tex`, | ||
| outputPath: '_build/out/article.tex', | ||
| frontmatter: { | ||
| title: 'test', | ||
| description: 'test', | ||
| date: new Date(2022, 6, 22).toISOString(), | ||
| authors: [ | ||
| { name: 'Rowan Cockett', affiliations: ['Curvenote'] }, | ||
| { name: 'Steve Purves', affiliations: ['Curvenote'], orcid: '0000' }, | ||
| ], | ||
| }, | ||
| options: { | ||
| keywords: '', | ||
| }, | ||
| parts: { | ||
| abstract: 'My abstract!', | ||
| }, | ||
| }); | ||
| expect(fs.existsSync('_build/out/article.tex')).toBe(true); | ||
| const content = fs.readFileSync('_build/out/article.tex').toString(); | ||
| expect(content.includes('Volcanic Archipelago')).toBe(true); | ||
| expect(content.includes('Rowan Cockett')).toBe(true); | ||
| expect(content.includes('My abstract!')).toBe(true); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,6 +43,7 @@ | |
| }, | ||
| "dependencies": { | ||
| "@jupyterlab/services": "^7.3.0", | ||
| "@mermaid-js/mermaid-cli": "^10.9.1", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this dependency is only here to install If so, I think we should remove it as a dependency for all users. We already have installation instructions in the error message when mermaid conversion fails.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How are dependencies determined for the overall mystmd package? Presumably, you could have |
||
| "@reduxjs/toolkit": "^2.1.0", | ||
| "adm-zip": "^0.5.10", | ||
| "boxen": "^7.1.1", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,7 @@ import { cleanOutput } from '../utils/cleanOutput.js'; | |
| import { getFileContent } from '../utils/getFileContent.js'; | ||
| import { createFooter } from './footers.js'; | ||
| import { createArticleTitle, createReferenceTitle } from './titles.js'; | ||
| import { createMermaidImageMystPlugin } from '../../transforms/mermaid.js'; | ||
|
|
||
| const DOCX_IMAGE_EXTENSIONS = [ImageExtensions.png, ImageExtensions.jpg, ImageExtensions.jpeg]; | ||
|
|
||
|
|
@@ -98,6 +99,7 @@ export async function runWordExport( | |
| projectPath, | ||
| imageExtensions: DOCX_IMAGE_EXTENSIONS, | ||
| extraLinkTransformers, | ||
| transforms: [createMermaidImageMystPlugin], | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Injecting this as The current pattern for "we need to transform something so it works in a static export" is put it in Using a transform instead is maybe (probably...?) better: the interface is cleaner and follows existing plugin work, and we are not just dumping more random stuff in the Probably fine to leave as-is, and maybe start moving the |
||
| preFrontmatters: [ | ||
| filterKeys(article, [...PAGE_FRONTMATTER_KEYS, ...Object.keys(FRONTMATTER_ALIASES)]), | ||
| ], | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,12 @@ | ||
| import path from 'node:path'; | ||
| import { tic } from 'myst-cli-utils'; | ||
| import type { GenericParent, IExpressionResult, PluginUtils, References } from 'myst-common'; | ||
| import type { | ||
| GenericParent, | ||
| IExpressionResult, | ||
| PluginUtils, | ||
| References, | ||
| TransformSpec, | ||
| } from 'myst-common'; | ||
| import { fileError, fileWarn, RuleId, slugToUrl } from 'myst-common'; | ||
| import type { PageFrontmatter } from 'myst-frontmatter'; | ||
| import { SourceFileKind } from 'myst-spec-ext'; | ||
|
|
@@ -112,6 +118,8 @@ export async function transformMdast( | |
| imageExtensions?: ImageExtensions[]; | ||
| watchMode?: boolean; | ||
| execute?: boolean; | ||
| transforms?: TransformSpec[]; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This small change is actually a pretty substantial change to the API - It's a completely new way to specify transforms within the code. I think it is non-controversial and for-the-better: matching how external plugins are defined and executed is great. But worth noting this alone is a new feature, entirely independent of the mermaid stuff. |
||
| /** @deprecated Use `session.plugins?.transforms` instead */ | ||
| extraTransforms?: TransformFn[]; | ||
| minifyMaxCharacters?: number; | ||
| index?: string; | ||
|
|
@@ -125,6 +133,7 @@ export async function transformMdast( | |
| pageSlug, | ||
| projectSlug, | ||
| imageExtensions, | ||
| transforms, | ||
| extraTransforms, | ||
| watchMode = false, | ||
| minifyMaxCharacters, | ||
|
|
@@ -204,10 +213,15 @@ export async function transformMdast( | |
| }) | ||
| .use(inlineMathSimplificationPlugin, { replaceSymbol: false }) | ||
| .use(mathPlugin, { macros: frontmatter.math }); | ||
| // Load transform functions from options (we always run all of them) | ||
| transforms?.forEach((t) => { | ||
| pipe.use(t.plugin, t.options, pluginUtils); | ||
| }); | ||
|
|
||
| // Load custom transform plugins | ||
| session.plugins?.transforms.forEach((t) => { | ||
| if (t.stage !== 'document') return; | ||
| pipe.use(t.plugin, undefined, pluginUtils); | ||
| pipe.use(t.plugin, t.options, pluginUtils); | ||
| }); | ||
|
|
||
| pipe | ||
|
|
@@ -361,7 +375,7 @@ export async function postProcessMdast( | |
| const pipe = unified(); | ||
| session.plugins?.transforms.forEach((t) => { | ||
| if (t.stage !== 'project') return; | ||
| pipe.use(t.plugin, undefined, pluginUtils); | ||
| pipe.use(t.plugin, t.options, pluginUtils); | ||
| }); | ||
| await pipe.run(mdast, vfile); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is being handled here?
The example is a bit confusing, because
CIis already set on both GH and GitLab, so user would never set it themselves.