Skip to content

Commit 7c1aa47

Browse files
committed
📓 Add ipynb as export format
1 parent 7d68c88 commit 7c1aa47

20 files changed

+629
-5
lines changed

.changeset/config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
["myst-common", "myst-config", "myst-frontmatter", "myst-spec-ext"],
77
["myst-to-jats", "jats-to-myst"],
88
["myst-to-tex", "tex-to-myst"],
9+
["myst-to-md", "myst-to-ipynb"],
910
["myst-parser", "myst-roles", "myst-directives", "myst-to-html"],
1011
["mystmd", "myst-cli", "myst-migrate"]
1112
],

.changeset/witty-tigers-hunt.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"myst-frontmatter": patch
3+
"myst-to-ipynb": patch
4+
"myst-cli": patch
5+
---
6+
7+
Add ipynb as export format

package-lock.json

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/myst-cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"myst-spec": "^0.0.5",
8686
"myst-spec-ext": "^1.7.10",
8787
"myst-templates": "^1.0.23",
88+
"myst-to-ipynb": "^1.0.15",
8889
"myst-to-docx": "^1.0.14",
8990
"myst-to-jats": "^1.0.33",
9091
"myst-to-md": "^1.0.15",

packages/myst-cli/src/build/build.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type FormatBuildOpts = {
2626
typst?: boolean;
2727
xml?: boolean;
2828
md?: boolean;
29+
ipynb?: boolean;
2930
meca?: boolean;
3031
cff?: boolean;
3132
html?: boolean;
@@ -37,8 +38,8 @@ type FormatBuildOpts = {
3738
export type BuildOpts = FormatBuildOpts & CollectionOptions & RunExportOptions & StartOptions;
3839

3940
export function hasAnyExplicitExportFormat(opts: BuildOpts): boolean {
40-
const { docx, pdf, tex, typst, xml, md, meca, cff } = opts;
41-
return docx || pdf || tex || typst || xml || md || meca || cff || false;
41+
const { docx, pdf, tex, typst, xml, md, ipynb, meca, cff } = opts;
42+
return docx || pdf || tex || typst || xml || md || ipynb || meca || cff || false;
4243
}
4344

4445
/**
@@ -50,12 +51,13 @@ export function hasAnyExplicitExportFormat(opts: BuildOpts): boolean {
5051
* @param opts.typst
5152
* @param opts.xml
5253
* @param opts.md
54+
* @param opts.ipynb
5355
* @param opts.meca
5456
* @param opts.all all exports requested with --all option
5557
* @param opts.explicit explicit input file was provided
5658
*/
5759
export function getAllowedExportFormats(opts: FormatBuildOpts & { explicit?: boolean }) {
58-
const { docx, pdf, tex, typst, xml, md, meca, cff, all, explicit } = opts;
60+
const { docx, pdf, tex, typst, xml, md, ipynb, meca, cff, all, explicit } = opts;
5961
const formats = [];
6062
const any = hasAnyExplicitExportFormat(opts);
6163
const override = all || (!any && explicit);
@@ -69,6 +71,7 @@ export function getAllowedExportFormats(opts: FormatBuildOpts & { explicit?: boo
6971
if (typst || override) formats.push(ExportFormats.typst);
7072
if (xml || override) formats.push(ExportFormats.xml);
7173
if (md || override) formats.push(ExportFormats.md);
74+
if (ipynb || override) formats.push(ExportFormats.ipynb);
7275
if (meca || override) formats.push(ExportFormats.meca);
7376
if (cff || override) formats.push(ExportFormats.cff);
7477
return [...new Set(formats)];
@@ -78,14 +81,15 @@ export function getAllowedExportFormats(opts: FormatBuildOpts & { explicit?: boo
7881
* Return requested formats from CLI options
7982
*/
8083
export function getRequestedExportFormats(opts: FormatBuildOpts) {
81-
const { docx, pdf, tex, typst, xml, md, meca, cff } = opts;
84+
const { docx, pdf, tex, typst, xml, md, ipynb, meca, cff } = opts;
8285
const formats = [];
8386
if (docx) formats.push(ExportFormats.docx);
8487
if (pdf) formats.push(ExportFormats.pdf);
8588
if (tex) formats.push(ExportFormats.tex);
8689
if (typst) formats.push(ExportFormats.typst);
8790
if (xml) formats.push(ExportFormats.xml);
8891
if (md) formats.push(ExportFormats.md);
92+
if (ipynb) formats.push(ExportFormats.ipynb);
8993
if (meca) formats.push(ExportFormats.meca);
9094
if (cff) formats.push(ExportFormats.cff);
9195
return formats;
@@ -239,7 +243,8 @@ export async function build(session: ISession, files: string[], opts: BuildOpts)
239243
// Print out the kinds that are filtered
240244
const kinds = Object.entries(opts)
241245
.filter(
242-
([k, v]) => ['docx', 'pdf', 'tex', 'typst', 'xml', 'md', 'meca', 'cff'].includes(k) && v,
246+
([k, v]) =>
247+
['docx', 'pdf', 'tex', 'typst', 'xml', 'md', 'ipynb', 'meca', 'cff'].includes(k) && v,
243248
)
244249
.map(([k]) => k);
245250
session.log.info(
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import path from 'node:path';
2+
import { tic, writeFileToFolder } from 'myst-cli-utils';
3+
import { FRONTMATTER_ALIASES, PAGE_FRONTMATTER_KEYS } from 'myst-frontmatter';
4+
import { writeIpynb } from 'myst-to-ipynb';
5+
import { filterKeys } from 'simple-validators';
6+
import { VFile } from 'vfile';
7+
import { finalizeMdast } from '../../process/mdast.js';
8+
import type { ISession } from '../../session/types.js';
9+
import { logMessagesFromVFile } from '../../utils/logging.js';
10+
import { KNOWN_IMAGE_EXTENSIONS } from '../../utils/resolveExtension.js';
11+
import type { ExportWithOutput, ExportFnOptions } from '../types.js';
12+
import { cleanOutput } from '../utils/cleanOutput.js';
13+
import { getFileContent } from '../utils/getFileContent.js';
14+
15+
export async function runIpynbExport(
16+
session: ISession,
17+
sourceFile: string,
18+
exportOptions: ExportWithOutput,
19+
opts?: ExportFnOptions,
20+
) {
21+
const toc = tic();
22+
const { output, articles } = exportOptions;
23+
const { clean, projectPath, extraLinkTransformers, execute } = opts ?? {};
24+
// At this point, export options are resolved to contain one-and-only-one article
25+
const article = articles[0];
26+
if (!article?.file) return { tempFolders: [] };
27+
if (clean) cleanOutput(session, output);
28+
const [{ mdast, frontmatter }] = await getFileContent(session, [article.file], {
29+
projectPath,
30+
imageExtensions: KNOWN_IMAGE_EXTENSIONS,
31+
extraLinkTransformers,
32+
preFrontmatters: [
33+
filterKeys(article, [...PAGE_FRONTMATTER_KEYS, ...Object.keys(FRONTMATTER_ALIASES)]),
34+
],
35+
execute,
36+
});
37+
await finalizeMdast(session, mdast, frontmatter, article.file, {
38+
imageWriteFolder: path.join(path.dirname(output), 'files'),
39+
imageAltOutputFolder: 'files/',
40+
imageExtensions: KNOWN_IMAGE_EXTENSIONS,
41+
simplifyFigures: false,
42+
useExistingImages: true,
43+
});
44+
const vfile = new VFile();
45+
vfile.path = output;
46+
const mdOut = writeIpynb(vfile, mdast as any, frontmatter);
47+
logMessagesFromVFile(session, mdOut);
48+
session.log.info(toc(`📑 Exported MD in %s, copying to ${output}`));
49+
writeFileToFolder(output, mdOut.result as string);
50+
return { tempFolders: [] };
51+
}

packages/myst-cli/src/build/utils/collectExportOptions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ export function resolveArticles(
271271
export const ALLOWED_EXTENSIONS: Record<ExportFormats, string[]> = {
272272
[ExportFormats.docx]: ['.doc', '.docx'],
273273
[ExportFormats.md]: ['.md'],
274+
[ExportFormats.ipynb]: ['.ipynb'],
274275
[ExportFormats.meca]: ['.zip', '.meca'],
275276
[ExportFormats.pdf]: ['.pdf'],
276277
[ExportFormats.pdftex]: ['.pdf', '.tex', '.zip'],

packages/myst-cli/src/build/utils/localArticleExport.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { texExportOptionsFromPdf } from '../pdf/single.js';
2020
import { createPdfGivenTexExport } from '../pdf/create.js';
2121
import { runMecaExport } from '../meca/index.js';
2222
import { runMdExport } from '../md/index.js';
23+
import { runIpynbExport } from '../ipynb/index.js';
2324
import { selectors, watch as watchReducer } from '../../store/index.js';
2425
import { runCffExport } from '../cff.js';
2526

@@ -113,6 +114,8 @@ async function _localArticleExport(
113114
exportFn = runJatsExport;
114115
} else if (format === ExportFormats.md) {
115116
exportFn = runMdExport;
117+
} else if (format === ExportFormats.ipynb) {
118+
exportFn = runIpynbExport;
116119
} else if (format === ExportFormats.meca) {
117120
exportFn = runMecaExport;
118121
} else if (format === ExportFormats.cff) {

packages/myst-cli/src/cli/build.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
makeMaxSizeWebpOption,
2121
makeDOIBibOption,
2222
makeCffOption,
23+
makeIpynbOption,
2324
} from './options.js';
2425
import { readableName } from '../utils/whiteLabelling.js';
2526

@@ -33,6 +34,7 @@ export function makeBuildCommand() {
3334
.addOption(makeTypstOption('Build Typst outputs'))
3435
.addOption(makeDocxOption('Build Docx output'))
3536
.addOption(makeMdOption('Build MD output'))
37+
.addOption(makeIpynbOption('Build IPYNB output'))
3638
.addOption(makeJatsOption('Build JATS xml output'))
3739
.addOption(makeMecaOptions('Build MECA zip output'))
3840
.addOption(makeCffOption('Build CFF output'))

packages/myst-cli/src/cli/options.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ export function makeMdOption(description: string) {
2828
return new Option('--md', description).default(false);
2929
}
3030

31+
export function makeIpynbOption(description: string) {
32+
return new Option('--ipynb', description).default(false);
33+
}
34+
3135
export function makeJatsOption(description: string) {
3236
return new Option('--jats, --xml', description).default(false);
3337
}

packages/myst-frontmatter/src/exports/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export enum ExportFormats {
88
docx = 'docx',
99
xml = 'xml',
1010
md = 'md',
11+
ipynb = 'ipynb',
1112
meca = 'meca',
1213
cff = 'cff',
1314
}

packages/myst-to-ipynb/.eslintrc.cjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
module.exports = {
2+
root: true,
3+
extends: ['curvenote'],
4+
};

packages/myst-to-ipynb/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# myst-to-ipynb

packages/myst-to-ipynb/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# myst-to-ipynb
2+
3+
Convert a MyST AST to ipynb notebook.

packages/myst-to-ipynb/package.json

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"name": "myst-to-ipynb",
3+
"version": "1.0.15",
4+
"description": "Export from MyST mdast to ipynb",
5+
"author": "Rowan Cockett <[email protected]>",
6+
"homepage": "https://github.com/jupyter-book/mystmd/tree/main/packages/myst-to-md",
7+
"license": "MIT",
8+
"type": "module",
9+
"exports": "./dist/index.js",
10+
"types": "./dist/index.d.ts",
11+
"files": [
12+
"src",
13+
"dist"
14+
],
15+
"keywords": [
16+
"myst-plugin",
17+
"markdown"
18+
],
19+
"publishConfig": {
20+
"access": "public"
21+
},
22+
"repository": {
23+
"type": "git",
24+
"url": "git+https://github.com/jupyter-book/mystmd.git"
25+
},
26+
"scripts": {
27+
"clean": "rimraf dist",
28+
"lint": "eslint \"src/**/*.ts\" -c .eslintrc.cjs --max-warnings 1",
29+
"lint:format": "prettier --check src/*.ts src/**/*.ts",
30+
"test": "vitest run",
31+
"test:watch": "vitest watch",
32+
"build:esm": "tsc",
33+
"build": "npm-run-all -l clean -p build:esm"
34+
},
35+
"bugs": {
36+
"url": "https://github.com/jupyter-book/mystmd/issues"
37+
},
38+
"dependencies": {
39+
"js-yaml": "^4.1.0",
40+
"mdast-util-gfm-footnote": "^1.0.2",
41+
"mdast-util-gfm-table": "^1.0.7",
42+
"mdast-util-to-markdown": "^1.5.0",
43+
"myst-common": "^1.7.6",
44+
"myst-frontmatter": "^1.7.6",
45+
"myst-to-md": "^1.0.15",
46+
"unist-util-select": "^4.0.3",
47+
"vfile": "^5.3.7",
48+
"vfile-reporter": "^7.0.4"
49+
}
50+
}

packages/myst-to-ipynb/src/index.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { Root } from 'myst-spec';
2+
import type { Block, Code } from 'myst-spec-ext';
3+
import type { Plugin } from 'unified';
4+
import type { VFile } from 'vfile';
5+
import type { PageFrontmatter } from 'myst-frontmatter';
6+
import { writeMd } from 'myst-to-md';
7+
import { select } from 'unist-util-select';
8+
9+
function sourceToStringList(src: string): string[] {
10+
const lines = src.split('\n').map((s) => `${s}\n`);
11+
lines[lines.length - 1] = lines[lines.length - 1].trimEnd();
12+
return lines;
13+
}
14+
15+
export function writeIpynb(file: VFile, node: Root, frontmatter?: PageFrontmatter) {
16+
const cells = (node.children as Block[]).map((block: Block) => {
17+
if (block.type === 'block' && block.kind === 'notebook-code') {
18+
const code = select('code', block) as Code;
19+
return {
20+
cell_type: 'code',
21+
execution_count: null,
22+
metadata: {},
23+
outputs: [],
24+
source: sourceToStringList(code.value),
25+
};
26+
}
27+
const md = writeMd(file, { type: 'root', children: block.children as any }).result as string;
28+
return {
29+
cell_type: 'markdown',
30+
metadata: {},
31+
source: sourceToStringList(md),
32+
};
33+
});
34+
const ipynb = {
35+
cells,
36+
metadata: {
37+
language_info: {
38+
name: 'python',
39+
},
40+
},
41+
nbformat: 4,
42+
nbformat_minor: 2,
43+
};
44+
file.result = JSON.stringify(ipynb, null, 2);
45+
return file;
46+
}
47+
48+
const plugin: Plugin<[PageFrontmatter?], Root, VFile> = function (frontmatter?) {
49+
this.Compiler = (node, file) => {
50+
return writeIpynb(file, node, frontmatter);
51+
};
52+
53+
return (node: Root) => {
54+
// Preprocess
55+
return node;
56+
};
57+
};
58+
59+
export default plugin;

0 commit comments

Comments
 (0)