Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0b8268a
📓 Add `ipynb` as export format
agoose77 May 23, 2025
3d20828
fix some tests and comment the failing ones to fix in upcoming commits
kp992 Mar 12, 2025
09f1a54
fix tests
kp992 Mar 14, 2025
ab8f6ef
fix failure in myst-cli
kp992 Mar 14, 2025
137f9a8
update the tests and keep the split lines logic
kp992 Apr 3, 2025
d889e7a
Revert merging md blocks and update 2 sample test cases
kp992 Apr 25, 2025
e72ff5c
Add ipynb in validators (#2159)
kp992 Jul 10, 2025
4a989de
fix: myst-to-ipynb bug fixes for kernelspec, markers, and metadata
mmcky Feb 25, 2026
44571dc
feat(myst-to-ipynb): add CommonMark serialization mode
mmcky Feb 25, 2026
925a194
test: expand myst-to-ipynb test suite (30 cases)
mmcky Feb 25, 2026
51d6524
fix: strip identifier/label from nodes, drop mystTarget/comment, filt…
mmcky Feb 25, 2026
f6a8586
feat: add image node handler to CommonMark transform
mmcky Feb 25, 2026
85b6fcc
feat: add image attachment embedding option for ipynb export
mmcky Feb 25, 2026
34a59d6
fix: lint formatting + add ipynb export documentation
mmcky Feb 25, 2026
fe2c295
fix: break circular dependency between attachments.ts and index.ts
mmcky Feb 25, 2026
27c2c15
fix: strip leading slash from image URLs + fix misleading comment
mmcky Feb 25, 2026
c5eea1c
fix: unwrap resolved include directives in CommonMark transform
mmcky Feb 26, 2026
6837df2
fix: lift code-cell blocks from gated exercise/solution nodes in ipyn…
mmcky Feb 26, 2026
c0c4e33
fix: handle real AST structure where exercise/solution share a block
mmcky Feb 26, 2026
033cd77
fix: serialize epigraph/pull-quote/blockquote containers in CommonMar…
mmcky Feb 26, 2026
6d6ba98
debug: add crossReference empty-URL instrumentation and node.url fall…
mmcky Feb 27, 2026
bff99f8
fix: use html_id as fallback for crossReference URLs in CommonMark ex…
mmcky Feb 27, 2026
89a57e5
refactor(myst-to-md): remove MYST_DEBUG_XREF instrumentation
mmcky Feb 27, 2026
59267a8
style: fix prettier formatting in myst-to-ipynb and myst-to-md
mmcky Feb 27, 2026
7ec2d37
Fix image attachment regex for escaped markdown characters
mmcky Feb 27, 2026
65e71ce
Fix prettier formatting
mmcky Feb 27, 2026
43edd9e
Fix lint errors: remove shadowed variable and useless escape
mmcky Feb 27, 2026
0a5563d
Merge branch 'main' into myst-to-ipynb
mmcky Mar 10, 2026
b76866e
Merge branch 'main' into myst-to-ipynb
mmcky Mar 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
["myst-common", "myst-config", "myst-frontmatter", "myst-spec-ext"],
["myst-to-jats", "jats-to-myst"],
["myst-to-tex", "tex-to-myst"],
["myst-to-md", "myst-to-ipynb"],
["myst-parser", "myst-roles", "myst-directives", "myst-to-html"],
["mystmd", "myst-cli", "myst-migrate"]
],
Expand Down
7 changes: 7 additions & 0 deletions .changeset/witty-tigers-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"myst-frontmatter": patch
"myst-to-ipynb": patch
"myst-cli": patch
---

Add ipynb as export format
117 changes: 117 additions & 0 deletions docs/creating-notebooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
---
title: Creating Jupyter Notebooks
description: Export MyST documents to Jupyter Notebook (.ipynb) format with optional CommonMark markdown and embedded images.
---

You can export MyST documents to Jupyter Notebook (`.ipynb`) format using `myst build`. The exported notebooks can use either MyST markdown (for use with [jupyterlab-myst](https://github.com/jupyter-book/jupyterlab-myst)) or plain CommonMark markdown compatible with vanilla Jupyter Notebook, JupyterLab, and Google Colab.

## Basic usage

Add an `exports` entry with `format: ipynb` to your page frontmatter:

```{code-block} yaml
:filename: my-document.md
---
exports:
- format: ipynb
output: exports/my-document.ipynb
---
```

Build the notebook with:

```bash
myst build my-document.md --ipynb
```

Or build all ipynb exports in the project:

```bash
myst build --ipynb
```

## CommonMark markdown

By default, exported notebooks use MyST markdown in their cells. If you need compatibility with environments that don't support MyST (vanilla Jupyter, Colab, etc.), set `markdown: commonmark`:

```{code-block} yaml
:filename: my-document.md
---
exports:
- format: ipynb
markdown: commonmark
output: exports/my-document.ipynb
---
```

With `markdown: commonmark`, MyST-specific syntax is converted to plain CommonMark equivalents:

```{list-table} CommonMark conversions
:header-rows: 1
- * MyST syntax
* CommonMark output
- * `:::{note}` admonitions
* `> **Note**` blockquotes
- * `` {math}`E=mc^2` `` roles
* `$E=mc^2$` dollar math
- * `$$` math blocks
* `$$...$$` (preserved)
- * `:::{exercise}` directives
* **Exercise N** bold headers
- * `:::{proof:theorem}` directives
* **Theorem N** bold headers
- * Figures with captions
* `![alt](url)` with italic caption
- * Tab sets
* Bold tab titles with content
- * `{image}` directives
* `![alt](url)` images
- * `(label)=` targets
* Dropped (no CommonMark equivalent)
- * `% comments`
* Dropped
```

## Embedding images as cell attachments

By default, images in exported notebooks reference external files. To create fully self-contained notebooks with images embedded as base64 cell attachments, set `images: attachment`:

```{code-block} yaml
:filename: my-document.md
---
exports:
- format: ipynb
markdown: commonmark
images: attachment
output: exports/my-document.ipynb
---
```

With `images: attachment`:
- Local images are read from disk and base64-encoded
- Image references become `![alt](attachment:filename.png)`
- Each cell includes an `attachments` field with the image data
- Remote images (http/https URLs) are left as references

This is useful for distributing notebooks, uploading to Google Colab, or sharing via email where external image files may not be available.

## Export options

```{list-table} ipynb export options
:header-rows: 1
- * Option
* Values
* Description
- * `format`
* `ipynb`
* Required — specifies notebook export
- * `output`
* string
* Output filename or folder
- * `markdown`
* `myst` (default), `commonmark`
* Markdown format for notebook cells
- * `images`
* `reference` (default), `attachment`
* How to handle images — references or embedded attachments
```
7 changes: 6 additions & 1 deletion docs/documents-exports.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Exporting overview
description: Create an export for PDF, LaTeX, Typst, Docx, JATS, or CITATION.cff in your page or project frontmatter, and use `myst build` to build the export.
description: Create an export for PDF, LaTeX, Typst, Docx, JATS, Jupyter Notebook (ipynb), or CITATION.cff in your page or project frontmatter, and use `myst build` to build the export.
---

You can export MyST content into one or more static documents, and optionally bundle them with a MyST website. This section gives an overview of the Exporting process and major configuration options.
Expand Down Expand Up @@ -29,6 +29,8 @@ Below are supported export types and links to documentation for further reading:
* [](./creating-citation-cff.md)
- * `MyST Markdown`
* [](#export:myst)
- * `Jupyter Notebook`
* [](./creating-notebooks.md)
```

## Where to configure options for exports
Expand Down Expand Up @@ -127,6 +129,9 @@ You can configure the CLI command in a number of ways:
`myst build --pdf --docx`
: Build `pdf` (LaTeX or Typst) exports and `docx` in the project

`myst build --ipynb`
: Build `ipynb` (Jupyter Notebook) exports in the project

`myst build my-paper.md`
: Build all exports in a specific page

Expand Down
2 changes: 1 addition & 1 deletion docs/frontmatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ For usage information, see [](./documents-exports.md).
* - `id`
- a string - a local identifier that can be used to reference the export
* - `format`
- one of `pdf` (built with $\LaTeX$ or Typst, depending on the template), `tex` (raw $\LaTeX$ files), `pdf+tex` (both PDF and raw $\LaTeX$ files) `typst` (raw Typst files and built PDF file), `docx`, `md`, `jats`, or `meca`
- one of `pdf` (built with $\LaTeX$ or Typst, depending on the template), `tex` (raw $\LaTeX$ files), `pdf+tex` (both PDF and raw $\LaTeX$ files) `typst` (raw Typst files and built PDF file), `docx`, `md`, `jats`, `meca`, or `ipynb`
* - `template`
- a string - name of an existing [MyST template](https://github.com/myst-templates) or a local path to a template folder. Templates are only available for `pdf`, `tex`, `typst`, and `docx` formats.
* - `output`
Expand Down
1 change: 1 addition & 0 deletions docs/myst.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ project:
- file: creating-word-documents.md
- file: creating-jats-xml.md
- file: creating-citation-cff.md
- file: creating-notebooks.md
- file: plugins.md
children:
- file: javascript-plugins.md
Expand Down
1 change: 1 addition & 0 deletions packages/myst-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"myst-spec-ext": "^1.9.5",
"myst-templates": "^1.0.27",
"myst-to-docx": "^1.0.16",
"myst-to-ipynb": "^1.0.15",
"myst-to-jats": "^1.0.35",
"myst-to-md": "^1.0.16",
"myst-to-tex": "^1.0.45",
Expand Down
1 change: 1 addition & 0 deletions packages/myst-cli/src/build/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ describe('get export formats', () => {
ExportFormats.tex,
ExportFormats.xml,
ExportFormats.md,
ExportFormats.ipynb,
ExportFormats.meca,
ExportFormats.cff,
]);
Expand Down
15 changes: 10 additions & 5 deletions packages/myst-cli/src/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type FormatBuildOpts = {
typst?: boolean;
xml?: boolean;
md?: boolean;
ipynb?: boolean;
meca?: boolean;
cff?: boolean;
html?: boolean;
Expand All @@ -37,8 +38,8 @@ type FormatBuildOpts = {
export type BuildOpts = FormatBuildOpts & CollectionOptions & RunExportOptions & StartOptions;

export function hasAnyExplicitExportFormat(opts: BuildOpts): boolean {
const { docx, pdf, tex, typst, xml, md, meca, cff } = opts;
return docx || pdf || tex || typst || xml || md || meca || cff || false;
const { docx, pdf, tex, typst, xml, md, ipynb, meca, cff } = opts;
return docx || pdf || tex || typst || xml || md || ipynb || meca || cff || false;
}

/**
Expand All @@ -50,12 +51,13 @@ export function hasAnyExplicitExportFormat(opts: BuildOpts): boolean {
* @param opts.typst
* @param opts.xml
* @param opts.md
* @param opts.ipynb
* @param opts.meca
* @param opts.all all exports requested with --all option
* @param opts.explicit explicit input file was provided
*/
export function getAllowedExportFormats(opts: FormatBuildOpts & { explicit?: boolean }) {
const { docx, pdf, tex, typst, xml, md, meca, cff, all, explicit } = opts;
const { docx, pdf, tex, typst, xml, md, ipynb, meca, cff, all, explicit } = opts;
const formats = [];
const any = hasAnyExplicitExportFormat(opts);
const override = all || (!any && explicit);
Expand All @@ -69,6 +71,7 @@ export function getAllowedExportFormats(opts: FormatBuildOpts & { explicit?: boo
if (typst || override) formats.push(ExportFormats.typst);
if (xml || override) formats.push(ExportFormats.xml);
if (md || override) formats.push(ExportFormats.md);
if (ipynb || override) formats.push(ExportFormats.ipynb);
if (meca || override) formats.push(ExportFormats.meca);
if (cff || override) formats.push(ExportFormats.cff);
return [...new Set(formats)];
Expand All @@ -78,14 +81,15 @@ export function getAllowedExportFormats(opts: FormatBuildOpts & { explicit?: boo
* Return requested formats from CLI options
*/
export function getRequestedExportFormats(opts: FormatBuildOpts) {
const { docx, pdf, tex, typst, xml, md, meca, cff } = opts;
const { docx, pdf, tex, typst, xml, md, ipynb, meca, cff } = opts;
const formats = [];
if (docx) formats.push(ExportFormats.docx);
if (pdf) formats.push(ExportFormats.pdf);
if (tex) formats.push(ExportFormats.tex);
if (typst) formats.push(ExportFormats.typst);
if (xml) formats.push(ExportFormats.xml);
if (md) formats.push(ExportFormats.md);
if (ipynb) formats.push(ExportFormats.ipynb);
if (meca) formats.push(ExportFormats.meca);
if (cff) formats.push(ExportFormats.cff);
return formats;
Expand Down Expand Up @@ -239,7 +243,8 @@ export async function build(session: ISession, files: string[], opts: BuildOpts)
// Print out the kinds that are filtered
const kinds = Object.entries(opts)
.filter(
([k, v]) => ['docx', 'pdf', 'tex', 'typst', 'xml', 'md', 'meca', 'cff'].includes(k) && v,
([k, v]) =>
['docx', 'pdf', 'tex', 'typst', 'xml', 'md', 'ipynb', 'meca', 'cff'].includes(k) && v,
)
.map(([k]) => k);
session.log.info(
Expand Down
123 changes: 123 additions & 0 deletions packages/myst-cli/src/build/ipynb/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import fs from 'node:fs';
import path from 'node:path';
import mime from 'mime-types';
import { tic, writeFileToFolder } from 'myst-cli-utils';
import { FRONTMATTER_ALIASES, PAGE_FRONTMATTER_KEYS } from 'myst-frontmatter';
import { writeIpynb } from 'myst-to-ipynb';
import type { IpynbOptions, ImageData } from 'myst-to-ipynb';
import { filterKeys } from 'simple-validators';
import { selectAll } from 'unist-util-select';
import { VFile } from 'vfile';
import { finalizeMdast } from '../../process/mdast.js';
import type { ISession } from '../../session/types.js';
import { logMessagesFromVFile } from '../../utils/logging.js';
import { KNOWN_IMAGE_EXTENSIONS } from '../../utils/resolveExtension.js';
import type { ExportWithOutput, ExportFnOptions } from '../types.js';
import { cleanOutput } from '../utils/cleanOutput.js';
import { getFileContent } from '../utils/getFileContent.js';
import { getSourceFolder } from '../../transforms/links.js';

export async function runIpynbExport(
session: ISession,
sourceFile: string,
exportOptions: ExportWithOutput,
opts?: ExportFnOptions,
) {
const toc = tic();
const { output, articles } = exportOptions;
const { clean, projectPath, extraLinkTransformers, execute } = opts ?? {};
// At this point, export options are resolved to contain one-and-only-one article
const article = articles[0];
if (!article?.file) return { tempFolders: [] };
if (clean) cleanOutput(session, output);
const [{ mdast, frontmatter }] = await getFileContent(session, [article.file], {
projectPath,
imageExtensions: KNOWN_IMAGE_EXTENSIONS,
extraLinkTransformers,
preFrontmatters: [
filterKeys(article, [...PAGE_FRONTMATTER_KEYS, ...Object.keys(FRONTMATTER_ALIASES)]),
],
execute,
});
await finalizeMdast(session, mdast, frontmatter, article.file, {
imageWriteFolder: path.join(path.dirname(output), 'files'),
imageAltOutputFolder: 'files/',
imageExtensions: KNOWN_IMAGE_EXTENSIONS,
simplifyFigures: false,
useExistingImages: true,
});
const vfile = new VFile();
vfile.path = output;
// Build ipynb options from export config
const ipynbOpts: IpynbOptions = {};
if ((exportOptions as any).markdown === 'commonmark') {
ipynbOpts.markdown = 'commonmark';
}
if ((exportOptions as any).images === 'attachment') {
ipynbOpts.images = 'attachment';
// Collect image data from the AST — read files and base64-encode
ipynbOpts.imageData = collectImageData(session, mdast, article.file);
}
const mdOut = writeIpynb(vfile, mdast as any, frontmatter, ipynbOpts);
logMessagesFromVFile(session, mdOut);
session.log.info(toc(`📓 Exported IPYNB in %s, copying to ${output}`));
writeFileToFolder(output, mdOut.result as string);
return { tempFolders: [] };
}

/**
* Collect base64-encoded image data from the mdast tree (Phase 1 of attachment embedding).
*
* Walks all image nodes via `selectAll('image', mdast)`, resolves their
* filesystem paths using `getSourceFolder` (handles both absolute `/_static/...`
* and relative paths), reads the files, and base64-encodes them into a map.
*
* The returned `Record<url, ImageData>` is passed to `writeIpynb` as
* `options.imageData`. Phase 2 (in `embedImagesAsAttachments`) then rewrites
* the serialized markdown to use `attachment:` references.
*
* Remote URLs (http/https) and data URIs are skipped — only local files are embedded.
*/
function collectImageData(
session: ISession,
mdast: any,
sourceFile: string,
): Record<string, ImageData> {
const imageData: Record<string, ImageData> = {};
const imageNodes = selectAll('image', mdast) as any[];
const sourcePath = session.sourcePath();

for (const img of imageNodes) {
const url = img.url ?? img.urlSource;
if (
!url ||
url.startsWith('http://') ||
url.startsWith('https://') ||
url.startsWith('data:')
) {
continue;
}
if (imageData[url]) continue; // already processed

const sourceFolder = getSourceFolder(url, sourceFile, sourcePath);
const relativeUrl = url.replace(/^[/\\]+/, '');
const filePath = path.join(sourceFolder, relativeUrl);

try {
if (!fs.existsSync(filePath)) {
session.log.debug(`Image not found for attachment embedding: ${filePath}`);
continue;
}
const buffer = fs.readFileSync(filePath);
const mimeType = (mime.lookup(filePath) || 'application/octet-stream') as string;
imageData[url] = {
mime: mimeType,
data: buffer.toString('base64'),
};
} catch (err) {
session.log.debug(`Failed to read image for attachment: ${filePath}`);
}
}

return imageData;
}
1 change: 1 addition & 0 deletions packages/myst-cli/src/build/utils/collectExportOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ export function resolveArticles(
export const ALLOWED_EXTENSIONS: Record<ExportFormats, string[]> = {
[ExportFormats.docx]: ['.doc', '.docx'],
[ExportFormats.md]: ['.md'],
[ExportFormats.ipynb]: ['.ipynb'],
[ExportFormats.meca]: ['.zip', '.meca'],
[ExportFormats.pdf]: ['.pdf'],
[ExportFormats.pdftex]: ['.pdf', '.tex', '.zip'],
Expand Down
Loading
Loading