@deepnote/convert 1.4.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1485 @@
1
+ import fs from "node:fs/promises";
2
+ import { basename, dirname, extname, join } from "node:path";
3
+ import { createMarkdown, createPythonCode, deepnoteBlockSchema, deserializeDeepnoteFile, environmentSchema, executionSchema } from "@deepnote/blocks";
4
+ import { v4 } from "uuid";
5
+ import { parse, stringify } from "yaml";
6
+
7
+ //#region src/utils.ts
8
+ /**
9
+ * Creates a sorting key for Deepnote blocks.
10
+ * Uses a base-36 encoding to generate compact, sortable keys.
11
+ *
12
+ * @param index - The zero-based index of the block
13
+ * @returns A sortable string key
14
+ */
15
+ function createSortingKey(index) {
16
+ const maxLength = 6;
17
+ const chars = "0123456789abcdefghijklmnopqrstuvwxyz";
18
+ const base = 36;
19
+ if (index < 0) throw new Error("Index must be non-negative");
20
+ let result = "";
21
+ let num = index + 1;
22
+ let iterations = 0;
23
+ while (num > 0 && iterations < maxLength) {
24
+ num--;
25
+ result = chars[num % base] + result;
26
+ num = Math.floor(num / base);
27
+ iterations++;
28
+ }
29
+ if (num > 0) throw new Error(`Index ${index} exceeds maximum key length of ${maxLength}`);
30
+ return result;
31
+ }
32
+ /**
33
+ * Sanitizes a filename by removing invalid characters and replacing spaces.
34
+ *
35
+ * @param name - The original filename
36
+ * @returns A sanitized filename safe for all platforms
37
+ */
38
+ function sanitizeFileName(name) {
39
+ return name.replace(/[<>:"/\\|?*]/g, "_").replace(/\s+/g, "_");
40
+ }
41
+ /**
42
+ * Deepnote block types that should be converted to code cells.
43
+ * Unknown types default to markdown (less lossy).
44
+ */
45
+ const CODE_BLOCK_TYPES = [
46
+ "big-number",
47
+ "button",
48
+ "code",
49
+ "notebook-function",
50
+ "sql",
51
+ "visualization"
52
+ ];
53
+ /**
54
+ * Checks if a Deepnote block type should be converted to a markdown cell.
55
+ * Uses a whitelist of code types and defaults unknown types to markdown (less lossy).
56
+ *
57
+ * @param blockType - The type of the Deepnote block
58
+ * @returns true if the block should be treated as markdown
59
+ */
60
+ function isMarkdownBlockType(blockType) {
61
+ if (blockType.startsWith("input-")) return false;
62
+ if (CODE_BLOCK_TYPES.includes(blockType)) return false;
63
+ return true;
64
+ }
65
+ /**
66
+ * Sorts object keys alphabetically while preserving the type.
67
+ * Useful for producing stable, predictable output in serialized formats like YAML.
68
+ *
69
+ * @param obj - The object whose keys should be sorted
70
+ * @returns A new object with the same values but keys in alphabetical order
71
+ */
72
+ function sortKeysAlphabetically(obj) {
73
+ const sorted = {};
74
+ for (const key of Object.keys(obj).sort()) sorted[key] = obj[key];
75
+ return sorted;
76
+ }
77
+
78
+ //#endregion
79
+ //#region src/deepnote-to-jupyter.ts
80
+ /**
81
+ * Converts an array of Deepnote blocks into a single Jupyter Notebook.
82
+ * This is the lowest-level conversion function, suitable for use in Deepnote Cloud.
83
+ *
84
+ * @param blocks - Array of DeepnoteBlock objects to convert
85
+ * @param options - Notebook metadata options
86
+ * @returns A JupyterNotebook object
87
+ *
88
+ * @example
89
+ * ```typescript
90
+ * import { convertBlocksToJupyterNotebook } from '@deepnote/convert'
91
+ *
92
+ * const notebook = convertBlocksToJupyterNotebook(blocks, {
93
+ * notebookId: 'abc123',
94
+ * notebookName: 'My Notebook',
95
+ * executionMode: 'block'
96
+ * })
97
+ * ```
98
+ */
99
+ function convertBlocksToJupyterNotebook(blocks, options) {
100
+ return {
101
+ cells: blocks.map((block) => convertBlockToCell$3(block)),
102
+ metadata: {
103
+ deepnote_notebook_id: options.notebookId,
104
+ deepnote_notebook_name: options.notebookName,
105
+ deepnote_execution_mode: options.executionMode,
106
+ deepnote_is_module: options.isModule,
107
+ deepnote_working_directory: options.workingDirectory,
108
+ deepnote_environment: options.environment,
109
+ deepnote_execution: options.execution
110
+ },
111
+ nbformat: 4,
112
+ nbformat_minor: 0
113
+ };
114
+ }
115
+ /**
116
+ * Converts a Deepnote project into Jupyter Notebook objects.
117
+ * This is a pure conversion function that doesn't perform any file I/O.
118
+ * Each notebook in the Deepnote project is converted to a separate Jupyter notebook.
119
+ *
120
+ * @param deepnoteFile - The deserialized Deepnote project file
121
+ * @returns Array of objects containing filename and corresponding Jupyter notebook
122
+ *
123
+ * @example
124
+ * ```typescript
125
+ * import { deserializeDeepnoteFile } from '@deepnote/blocks'
126
+ * import { convertDeepnoteToJupyterNotebooks } from '@deepnote/convert'
127
+ *
128
+ * const yamlContent = await fs.readFile('project.deepnote', 'utf-8')
129
+ * const deepnoteFile = deserializeDeepnoteFile(yamlContent)
130
+ * const notebooks = convertDeepnoteToJupyterNotebooks(deepnoteFile)
131
+ *
132
+ * for (const { filename, notebook } of notebooks) {
133
+ * console.log(`${filename}: ${notebook.cells.length} cells`)
134
+ * }
135
+ * ```
136
+ */
137
+ function convertDeepnoteToJupyterNotebooks(deepnoteFile) {
138
+ return deepnoteFile.project.notebooks.map((notebook) => {
139
+ const jupyterNotebook = convertNotebookToJupyter(deepnoteFile, notebook);
140
+ return {
141
+ filename: `${sanitizeFileName(notebook.name)}.ipynb`,
142
+ notebook: jupyterNotebook
143
+ };
144
+ });
145
+ }
146
+ /**
147
+ * Converts a Deepnote project file into separate Jupyter Notebook (.ipynb) files.
148
+ * Each notebook in the Deepnote project becomes a separate .ipynb file.
149
+ */
150
+ async function convertDeepnoteFileToJupyterFiles(deepnoteFilePath, options) {
151
+ const notebooks = convertDeepnoteToJupyterNotebooks(deserializeDeepnoteFile(await fs.readFile(deepnoteFilePath, "utf-8")));
152
+ await fs.mkdir(options.outputDir, { recursive: true });
153
+ for (const { filename, notebook } of notebooks) {
154
+ const filePath = join(options.outputDir, filename);
155
+ await fs.writeFile(filePath, JSON.stringify(notebook, null, 2), "utf-8");
156
+ }
157
+ }
158
+ function convertBlockToCell$3(block) {
159
+ const content = block.content || "";
160
+ const jupyterCellType = convertBlockTypeToJupyter(block.type);
161
+ const executionStartedAt = "executionStartedAt" in block ? block.executionStartedAt : void 0;
162
+ const executionFinishedAt = "executionFinishedAt" in block ? block.executionFinishedAt : void 0;
163
+ const executionCount = "executionCount" in block ? block.executionCount : null;
164
+ const outputs = "outputs" in block ? block.outputs : void 0;
165
+ const metadata = {
166
+ ...block.metadata || {},
167
+ cell_id: block.id,
168
+ deepnote_block_group: block.blockGroup,
169
+ deepnote_cell_type: block.type,
170
+ deepnote_sorting_key: block.sortingKey,
171
+ deepnote_content_hash: block.contentHash,
172
+ deepnote_execution_started_at: executionStartedAt,
173
+ deepnote_execution_finished_at: executionFinishedAt
174
+ };
175
+ metadata.deepnote_source = content;
176
+ return {
177
+ block_group: block.blockGroup,
178
+ cell_type: jupyterCellType,
179
+ execution_count: executionCount ?? null,
180
+ metadata,
181
+ outputs,
182
+ source: getSourceForBlock(block, jupyterCellType, content)
183
+ };
184
+ }
185
+ function getSourceForBlock(block, jupyterCellType, content) {
186
+ if (jupyterCellType === "markdown") return createMarkdown(block);
187
+ if (block.type === "code") return content;
188
+ return createPythonCode(block);
189
+ }
190
+ function convertBlockTypeToJupyter(blockType) {
191
+ return isMarkdownBlockType(blockType) ? "markdown" : "code";
192
+ }
193
+ function convertNotebookToJupyter(deepnoteFile, notebook) {
194
+ return convertBlocksToJupyterNotebook(notebook.blocks, {
195
+ notebookId: notebook.id,
196
+ notebookName: notebook.name,
197
+ executionMode: notebook.executionMode,
198
+ isModule: notebook.isModule,
199
+ workingDirectory: notebook.workingDirectory,
200
+ environment: deepnoteFile.environment,
201
+ execution: deepnoteFile.execution
202
+ });
203
+ }
204
+
205
+ //#endregion
206
+ //#region src/deepnote-to-marimo.ts
207
+ /** Current Marimo version for generated files */
208
+ const MARIMO_VERSION = "0.10.0";
209
+ /**
210
+ * Converts an array of Deepnote blocks into a Marimo app.
211
+ * This is the lowest-level conversion function.
212
+ *
213
+ * @param blocks - Array of DeepnoteBlock objects to convert
214
+ * @param notebookName - Name of the notebook (used for app title)
215
+ * @returns A MarimoApp object
216
+ */
217
+ function convertBlocksToMarimoApp(blocks, notebookName) {
218
+ return {
219
+ generatedWith: MARIMO_VERSION,
220
+ width: "medium",
221
+ title: notebookName,
222
+ cells: blocks.map((block) => convertBlockToCell$2(block))
223
+ };
224
+ }
225
+ /**
226
+ * Converts a Deepnote project into Marimo app objects.
227
+ * This is a pure conversion function that doesn't perform any file I/O.
228
+ * Each notebook in the Deepnote project is converted to a separate Marimo app.
229
+ *
230
+ * @param deepnoteFile - The deserialized Deepnote project file
231
+ * @returns Array of objects containing filename and corresponding Marimo app
232
+ */
233
+ function convertDeepnoteToMarimoApps(deepnoteFile) {
234
+ return deepnoteFile.project.notebooks.map((notebook) => {
235
+ const app = convertBlocksToMarimoApp(notebook.blocks, notebook.name);
236
+ return {
237
+ filename: `${sanitizeFileName(notebook.name)}.py`,
238
+ app
239
+ };
240
+ });
241
+ }
242
+ /**
243
+ * Serializes a Marimo app to a Python file string.
244
+ *
245
+ * @param app - The Marimo app to serialize
246
+ * @returns The serialized Python code string
247
+ */
248
+ function serializeMarimoFormat(app) {
249
+ const lines = [];
250
+ const hasMarkdownOrSqlCells = app.cells.some((cell) => cell.cellType === "markdown" || cell.cellType === "sql");
251
+ if (hasMarkdownOrSqlCells) lines.push("import marimo as mo");
252
+ else lines.push("import marimo");
253
+ lines.push("");
254
+ lines.push(`__generated_with = "${app.generatedWith || MARIMO_VERSION}"`);
255
+ const appOptions = [];
256
+ if (app.width) appOptions.push(`width="${app.width}"`);
257
+ if (app.title) appOptions.push(`title="${escapeString(app.title)}"`);
258
+ const marimoRef = hasMarkdownOrSqlCells ? "mo" : "marimo";
259
+ const optionsStr = appOptions.length > 0 ? appOptions.join(", ") : "";
260
+ lines.push(`app = ${marimoRef}.App(${optionsStr})`);
261
+ lines.push("");
262
+ lines.push("");
263
+ for (const cell of app.cells) {
264
+ const decoratorOptions = [];
265
+ if (cell.hidden) decoratorOptions.push("hide_code=True");
266
+ if (cell.disabled) decoratorOptions.push("disabled=True");
267
+ const decoratorStr = decoratorOptions.length > 0 ? `@app.cell(${decoratorOptions.join(", ")})` : "@app.cell";
268
+ lines.push(decoratorStr);
269
+ const funcName = cell.functionName || "__";
270
+ const params = cell.dependencies?.join(", ") || "";
271
+ lines.push(`def ${funcName}(${params}):`);
272
+ if (cell.cellType === "markdown") {
273
+ const escaped = escapeTripleQuote(cell.content);
274
+ lines.push(` mo.md(r"""`);
275
+ for (const contentLine of escaped.split("\n")) if (contentLine.trim() === "") lines.push("");
276
+ else lines.push(` ${contentLine}`);
277
+ lines.push(` """)`);
278
+ lines.push(" return");
279
+ } else if (cell.cellType === "sql") {
280
+ const escaped = escapeTripleQuote(cell.content);
281
+ const varName = cell.exports && cell.exports.length > 0 ? cell.exports[0] : "df";
282
+ const engineParam = cell.dependencies?.includes("engine") ? ", engine=engine" : "";
283
+ lines.push(` ${varName} = mo.sql(`);
284
+ lines.push(` f"""`);
285
+ for (const contentLine of escaped.split("\n")) if (contentLine === "" || contentLine.trim() === "") lines.push("");
286
+ else lines.push(` ${contentLine}`);
287
+ lines.push(` """${engineParam}`);
288
+ lines.push(` )`);
289
+ if (cell.exports && cell.exports.length > 0) lines.push(` return ${cell.exports.join(", ")},`);
290
+ else lines.push(" return");
291
+ } else {
292
+ const contentLines = cell.content.split("\n");
293
+ for (const contentLine of contentLines) if (contentLine.trim() === "") lines.push("");
294
+ else lines.push(` ${contentLine}`);
295
+ if (cell.exports && cell.exports.length > 0) lines.push(` return ${cell.exports.join(", ")},`);
296
+ else lines.push(" return");
297
+ }
298
+ lines.push("");
299
+ lines.push("");
300
+ }
301
+ lines.push("if __name__ == \"__main__\":");
302
+ lines.push(" app.run()");
303
+ lines.push("");
304
+ return lines.join("\n");
305
+ }
306
+ /**
307
+ * Converts a Deepnote project file into separate Marimo (.py) files.
308
+ * Each notebook in the Deepnote project becomes a separate .py file.
309
+ */
310
+ async function convertDeepnoteFileToMarimoFiles(deepnoteFilePath, options) {
311
+ const apps = convertDeepnoteToMarimoApps(deserializeDeepnoteFile(await fs.readFile(deepnoteFilePath, "utf-8")));
312
+ await fs.mkdir(options.outputDir, { recursive: true });
313
+ for (const { filename, app } of apps) {
314
+ const filePath = join(options.outputDir, filename);
315
+ try {
316
+ const content = serializeMarimoFormat(app);
317
+ await fs.writeFile(filePath, content, "utf-8");
318
+ } catch (err) {
319
+ const errorMessage = err instanceof Error ? err.message : String(err);
320
+ throw new Error(`Failed to write ${filename}: ${errorMessage}`, { cause: err });
321
+ }
322
+ }
323
+ }
324
+ function convertBlockToCell$2(block) {
325
+ const isMarkdown = isMarkdownBlockType(block.type);
326
+ const isSql = block.type === "sql";
327
+ const metadata = block.metadata || {};
328
+ let content;
329
+ let cellType;
330
+ if (isMarkdown) {
331
+ cellType = "markdown";
332
+ try {
333
+ content = createMarkdown(block);
334
+ } catch {
335
+ content = block.content || "";
336
+ }
337
+ } else if (isSql) {
338
+ cellType = "sql";
339
+ content = block.content || "";
340
+ } else if (block.type === "code") {
341
+ cellType = "code";
342
+ content = block.content || "";
343
+ } else {
344
+ cellType = "code";
345
+ try {
346
+ content = createPythonCode(block);
347
+ } catch {
348
+ content = block.content || "";
349
+ }
350
+ }
351
+ const dependencies = metadata.marimo_dependencies;
352
+ const exports = metadata.marimo_exports;
353
+ const hidden = metadata.is_code_hidden;
354
+ const disabled = metadata.marimo_disabled;
355
+ const functionName = metadata.marimo_function_name;
356
+ return {
357
+ cellType,
358
+ content,
359
+ ...functionName ? { functionName } : {},
360
+ ...dependencies && dependencies.length > 0 ? { dependencies } : {},
361
+ ...exports && exports.length > 0 ? { exports } : {},
362
+ ...hidden ? { hidden } : {},
363
+ ...disabled ? { disabled } : {}
364
+ };
365
+ }
366
+ /**
367
+ * Escapes a string for use in a Python double-quoted string literal.
368
+ * Handles backslashes, quotes, control characters, and non-printable characters.
369
+ */
370
+ function escapeString(str) {
371
+ return str.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/[\x08]/g, "\\b").replace(/[\x0C]/g, "\\f").replace(/[\x00-\x07\x0B\x0E-\x1F\x7F-\x9F]/g, (char) => {
372
+ return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`;
373
+ });
374
+ }
375
+ /**
376
+ * Escapes content for use in a Python raw triple-quoted string (r""").
377
+ * Raw strings don't interpret escape sequences, so we can't use backslashes to escape.
378
+ * When we encounter triple quotes, we break the raw string and concatenate with a regular string.
379
+ */
380
+ function escapeTripleQuote(str) {
381
+ return str.replace(/"""/g, "\"\"\"+'\"'+r\"\"\"");
382
+ }
383
+
384
+ //#endregion
385
+ //#region src/deepnote-to-percent.ts
386
+ /**
387
+ * Converts an array of Deepnote blocks into a percent format notebook.
388
+ * This is the lowest-level conversion function.
389
+ *
390
+ * @param blocks - Array of DeepnoteBlock objects to convert
391
+ * @returns A PercentNotebook object
392
+ */
393
+ function convertBlocksToPercentNotebook(blocks) {
394
+ return { cells: blocks.map((block) => convertBlockToCell$1(block)) };
395
+ }
396
+ /**
397
+ * Converts a Deepnote project into percent format notebook objects.
398
+ * This is a pure conversion function that doesn't perform any file I/O.
399
+ * Each notebook in the Deepnote project is converted to a separate percent notebook.
400
+ *
401
+ * @param deepnoteFile - The deserialized Deepnote project file
402
+ * @returns Array of objects containing filename and corresponding percent notebook
403
+ */
404
+ function convertDeepnoteToPercentNotebooks(deepnoteFile) {
405
+ return deepnoteFile.project.notebooks.map((notebook) => {
406
+ const percentNotebook = convertBlocksToPercentNotebook(notebook.blocks);
407
+ return {
408
+ filename: `${sanitizeFileName(notebook.name)}.py`,
409
+ notebook: percentNotebook
410
+ };
411
+ });
412
+ }
413
+ /**
414
+ * Serializes a percent format notebook to a string.
415
+ *
416
+ * @param notebook - The percent notebook to serialize
417
+ * @returns The serialized percent format string
418
+ */
419
+ function serializePercentFormat(notebook) {
420
+ const lines = [];
421
+ for (const cell of notebook.cells) {
422
+ let marker = "# %%";
423
+ if (cell.cellType === "markdown") marker += " [markdown]";
424
+ else if (cell.cellType === "raw") marker += " [raw]";
425
+ if (cell.title) marker += ` ${cell.title}`;
426
+ if (cell.tags && cell.tags.length > 0) {
427
+ const tagsStr = cell.tags.map((t) => JSON.stringify(t)).join(", ");
428
+ marker += ` tags=[${tagsStr}]`;
429
+ }
430
+ lines.push(marker);
431
+ if (cell.cellType === "markdown") {
432
+ const contentLines = cell.content.split("\n");
433
+ for (const contentLine of contentLines) if (contentLine === "") lines.push("#");
434
+ else lines.push(`# ${contentLine}`);
435
+ } else lines.push(cell.content);
436
+ lines.push("");
437
+ }
438
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
439
+ return `${lines.join("\n")}\n`;
440
+ }
441
+ /**
442
+ * Converts a Deepnote project file into separate percent format (.py) files.
443
+ * Each notebook in the Deepnote project becomes a separate .py file.
444
+ */
445
+ async function convertDeepnoteFileToPercentFiles(deepnoteFilePath, options) {
446
+ const notebooks = convertDeepnoteToPercentNotebooks(deserializeDeepnoteFile(await fs.readFile(deepnoteFilePath, "utf-8")));
447
+ await fs.mkdir(options.outputDir, { recursive: true });
448
+ for (const { filename, notebook } of notebooks) {
449
+ const filePath = join(options.outputDir, filename);
450
+ const content = serializePercentFormat(notebook);
451
+ await fs.writeFile(filePath, content, "utf-8");
452
+ }
453
+ }
454
+ function convertBlockToCell$1(block) {
455
+ const isMarkdown = isMarkdownBlockType(block.type);
456
+ let content;
457
+ if (isMarkdown) try {
458
+ content = createMarkdown(block);
459
+ } catch {
460
+ content = block.content || "";
461
+ }
462
+ else if (block.type === "code") content = block.content || "";
463
+ else try {
464
+ content = createPythonCode(block);
465
+ } catch {
466
+ content = block.content || "";
467
+ }
468
+ const title = block.metadata?.title;
469
+ const tags = block.metadata?.tags;
470
+ return {
471
+ cellType: block.metadata?.percent_cell_type === "raw" ? "raw" : isMarkdown ? "markdown" : "code",
472
+ content,
473
+ ...title ? { title } : {},
474
+ ...tags && tags.length > 0 ? { tags } : {}
475
+ };
476
+ }
477
+
478
+ //#endregion
479
+ //#region src/deepnote-to-quarto.ts
480
+ /**
481
+ * Converts an array of Deepnote blocks into a Quarto document.
482
+ * This is the lowest-level conversion function.
483
+ *
484
+ * @param blocks - Array of DeepnoteBlock objects to convert
485
+ * @param notebookName - Name of the notebook (used for document title)
486
+ * @returns A QuartoDocument object
487
+ */
488
+ function convertBlocksToQuartoDocument(blocks, notebookName) {
489
+ const cells = blocks.map((block) => convertBlockToCell(block));
490
+ return {
491
+ frontmatter: {
492
+ title: notebookName,
493
+ jupyter: "python3"
494
+ },
495
+ cells
496
+ };
497
+ }
498
+ /**
499
+ * Converts a Deepnote project into Quarto document objects.
500
+ * This is a pure conversion function that doesn't perform any file I/O.
501
+ * Each notebook in the Deepnote project is converted to a separate Quarto document.
502
+ *
503
+ * @param deepnoteFile - The deserialized Deepnote project file
504
+ * @returns Array of objects containing filename and corresponding Quarto document
505
+ */
506
+ function convertDeepnoteToQuartoDocuments(deepnoteFile) {
507
+ return deepnoteFile.project.notebooks.map((notebook) => {
508
+ const document = convertBlocksToQuartoDocument(notebook.blocks, notebook.name);
509
+ return {
510
+ filename: `${sanitizeFileName(notebook.name)}.qmd`,
511
+ document
512
+ };
513
+ });
514
+ }
515
+ /**
516
+ * Serializes a Quarto document to a string.
517
+ *
518
+ * @param document - The Quarto document to serialize
519
+ * @returns The serialized Quarto format string
520
+ */
521
+ function serializeQuartoFormat(document) {
522
+ const lines = [];
523
+ if (document.frontmatter && Object.keys(document.frontmatter).length > 0) {
524
+ lines.push("---");
525
+ for (const [key, value] of Object.entries(document.frontmatter)) if (typeof value === "string") if (value.includes(":") || value.includes("#") || value.includes("'") || value.includes("\"")) lines.push(`${key}: "${value.replace(/"/g, "\\\"")}"`);
526
+ else lines.push(`${key}: ${value}`);
527
+ else if (typeof value === "boolean") lines.push(`${key}: ${value}`);
528
+ else if (value !== void 0) lines.push(`${key}: ${JSON.stringify(value)}`);
529
+ lines.push("---");
530
+ lines.push("");
531
+ }
532
+ let previousCellType = null;
533
+ for (const cell of document.cells) {
534
+ if (cell.cellType === "markdown") {
535
+ if (previousCellType === "markdown") {
536
+ lines.push("<!-- cell -->");
537
+ lines.push("");
538
+ }
539
+ lines.push(cell.content);
540
+ lines.push("");
541
+ } else {
542
+ const language = cell.language || "python";
543
+ lines.push(`\`\`\`{${language}}`);
544
+ if (cell.options) {
545
+ const optionLines = serializeQuartoCellOptions(cell.options);
546
+ lines.push(...optionLines);
547
+ }
548
+ lines.push(cell.content);
549
+ lines.push("```");
550
+ lines.push("");
551
+ }
552
+ previousCellType = cell.cellType;
553
+ }
554
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
555
+ return `${lines.join("\n")}\n`;
556
+ }
557
+ /**
558
+ * Serializes Quarto cell options to #| format lines.
559
+ */
560
+ function serializeQuartoCellOptions(options) {
561
+ const lines = [];
562
+ if (options.label) lines.push(`#| label: ${options.label}`);
563
+ if (options.echo !== void 0) lines.push(`#| echo: ${options.echo}`);
564
+ if (options.eval !== void 0) lines.push(`#| eval: ${options.eval}`);
565
+ if (options.output !== void 0) lines.push(`#| output: ${options.output}`);
566
+ if (options.figCap) lines.push(`#| fig-cap: "${options.figCap}"`);
567
+ if (options.figWidth !== void 0) lines.push(`#| fig-width: ${options.figWidth}`);
568
+ if (options.figHeight !== void 0) lines.push(`#| fig-height: ${options.figHeight}`);
569
+ if (options.tblCap) lines.push(`#| tbl-cap: "${options.tblCap}"`);
570
+ if (options.warning !== void 0) lines.push(`#| warning: ${options.warning}`);
571
+ if (options.message !== void 0) lines.push(`#| message: ${options.message}`);
572
+ if (options.raw) for (const [key, value] of Object.entries(options.raw)) if (typeof value === "string") lines.push(`#| ${key}: "${value}"`);
573
+ else lines.push(`#| ${key}: ${value}`);
574
+ return lines;
575
+ }
576
+ /**
577
+ * Converts a Deepnote project file into separate Quarto (.qmd) files.
578
+ * Each notebook in the Deepnote project becomes a separate .qmd file.
579
+ */
580
+ async function convertDeepnoteFileToQuartoFiles(deepnoteFilePath, options) {
581
+ const documents = convertDeepnoteToQuartoDocuments(deserializeDeepnoteFile(await fs.readFile(deepnoteFilePath, "utf-8")));
582
+ await fs.mkdir(options.outputDir, { recursive: true });
583
+ for (const { filename, document } of documents) {
584
+ const filePath = join(options.outputDir, filename);
585
+ const content = serializeQuartoFormat(document);
586
+ await fs.writeFile(filePath, content, "utf-8");
587
+ }
588
+ }
589
+ function convertBlockToCell(block) {
590
+ const isMarkdown = isMarkdownBlockType(block.type);
591
+ let content;
592
+ if (isMarkdown) try {
593
+ content = createMarkdown(block);
594
+ } catch {
595
+ content = block.content || "";
596
+ }
597
+ else if (block.type === "code") content = block.content || "";
598
+ else try {
599
+ content = createPythonCode(block);
600
+ } catch {
601
+ content = block.content || "";
602
+ }
603
+ let options;
604
+ const metadata = block.metadata || {};
605
+ if (metadata.quarto_label || metadata.is_code_hidden || metadata.quarto_fig_cap || metadata.quarto_tbl_cap) {
606
+ options = {};
607
+ if (metadata.quarto_label) options.label = metadata.quarto_label;
608
+ if (metadata.is_code_hidden) options.echo = false;
609
+ if (metadata.quarto_fig_cap) options.figCap = metadata.quarto_fig_cap;
610
+ if (metadata.quarto_tbl_cap) options.tblCap = metadata.quarto_tbl_cap;
611
+ if (metadata.quarto_options) options.raw = metadata.quarto_options;
612
+ }
613
+ const cell = {
614
+ cellType: isMarkdown ? "markdown" : "code",
615
+ content,
616
+ ...options ? { options } : {}
617
+ };
618
+ if (metadata.language && metadata.language !== "python") cell.language = metadata.language;
619
+ return cell;
620
+ }
621
+
622
+ //#endregion
623
+ //#region src/format-detection.ts
624
+ /** Check if file content is Marimo format */
625
+ function isMarimoContent(content) {
626
+ return /^import marimo\b/m.test(content) && /@app\.cell\b/.test(content) && !/^\s*['"]{3}[\s\S]*?import marimo/m.test(content);
627
+ }
628
+ /** Check if file content is percent format */
629
+ function isPercentContent(content) {
630
+ return /^# %%/m.test(content) && !/^\s*['"]{3}[\s\S]*?# %%/m.test(content);
631
+ }
632
+ /**
633
+ * Detects the notebook format from filename and optionally content.
634
+ * For .py files, content is required to distinguish between Marimo and Percent formats.
635
+ */
636
+ function detectFormat(filename, content) {
637
+ const lowercaseFilename = filename.toLowerCase();
638
+ if (lowercaseFilename.endsWith(".ipynb")) return "jupyter";
639
+ if (lowercaseFilename.endsWith(".deepnote")) return "deepnote";
640
+ if (lowercaseFilename.endsWith(".qmd")) return "quarto";
641
+ if (lowercaseFilename.endsWith(".py")) {
642
+ if (!content) throw new Error("Content is required to detect format for .py files");
643
+ if (isMarimoContent(content)) return "marimo";
644
+ if (isPercentContent(content)) return "percent";
645
+ throw new Error("Unsupported Python file format. File must be percent format (# %%) or Marimo (@app.cell).");
646
+ }
647
+ throw new Error(`Unsupported file format: ${filename}`);
648
+ }
649
+
650
+ //#endregion
651
+ //#region src/jupyter-to-deepnote.ts
652
+ /**
653
+ * Converts a single Jupyter Notebook into an array of Deepnote blocks.
654
+ * This is the lowest-level conversion function, suitable for use in Deepnote Cloud.
655
+ *
656
+ * @param notebook - The Jupyter notebook object to convert
657
+ * @param options - Optional conversion options including custom ID generator
658
+ * @returns Array of DeepnoteBlock objects
659
+ *
660
+ * @example
661
+ * ```typescript
662
+ * import { convertJupyterNotebookToBlocks } from '@deepnote/convert'
663
+ *
664
+ * const notebook = JSON.parse(ipynbContent)
665
+ * const blocks = convertJupyterNotebookToBlocks(notebook, {
666
+ * idGenerator: () => myCustomIdGenerator()
667
+ * })
668
+ * ```
669
+ */
670
+ function convertJupyterNotebookToBlocks(notebook, options) {
671
+ const idGenerator = options?.idGenerator ?? v4;
672
+ return notebook.cells.map((cell, index) => convertCellToBlock$3(cell, index, idGenerator));
673
+ }
674
+ /**
675
+ * Converts Jupyter Notebook objects into a Deepnote project file.
676
+ * This is a pure conversion function that doesn't perform any file I/O.
677
+ *
678
+ * @param notebooks - Array of Jupyter notebooks with filenames
679
+ * @param options - Conversion options including project name
680
+ * @returns A DeepnoteFile object
681
+ */
682
+ function convertJupyterNotebooksToDeepnote(notebooks, options) {
683
+ let environment;
684
+ let execution;
685
+ for (const { notebook } of notebooks) {
686
+ if (!environment && notebook.metadata?.deepnote_environment) {
687
+ const parsed = environmentSchema.safeParse(notebook.metadata.deepnote_environment);
688
+ if (parsed.success) environment = parsed.data;
689
+ }
690
+ if (!execution && notebook.metadata?.deepnote_execution) {
691
+ const parsed = executionSchema.safeParse(notebook.metadata.deepnote_execution);
692
+ if (parsed.success) execution = parsed.data;
693
+ }
694
+ }
695
+ const firstNotebookId = notebooks.length > 0 ? notebooks[0].notebook.metadata?.deepnote_notebook_id ?? v4() : void 0;
696
+ const deepnoteFile = {
697
+ environment,
698
+ execution,
699
+ metadata: { createdAt: (/* @__PURE__ */ new Date()).toISOString() },
700
+ project: {
701
+ id: v4(),
702
+ initNotebookId: firstNotebookId,
703
+ integrations: [],
704
+ name: options.projectName,
705
+ notebooks: [],
706
+ settings: {}
707
+ },
708
+ version: "1.0.0"
709
+ };
710
+ for (let i = 0; i < notebooks.length; i++) {
711
+ const { filename, notebook } = notebooks[i];
712
+ const filenameWithoutExt = basename(filename, extname(filename)) || "Untitled notebook";
713
+ const blocks = convertJupyterNotebookToBlocks(notebook);
714
+ const notebookId = notebook.metadata?.deepnote_notebook_id;
715
+ const notebookName = notebook.metadata?.deepnote_notebook_name;
716
+ const executionMode = notebook.metadata?.deepnote_execution_mode;
717
+ const isModule = notebook.metadata?.deepnote_is_module;
718
+ const workingDirectory = notebook.metadata?.deepnote_working_directory;
719
+ const resolvedNotebookId = i === 0 && firstNotebookId ? firstNotebookId : notebookId ?? v4();
720
+ deepnoteFile.project.notebooks.push({
721
+ blocks,
722
+ executionMode: executionMode ?? "block",
723
+ id: resolvedNotebookId,
724
+ isModule: isModule ?? false,
725
+ name: notebookName ?? filenameWithoutExt,
726
+ workingDirectory
727
+ });
728
+ }
729
+ return deepnoteFile;
730
+ }
731
+ /**
732
+ * Converts multiple Jupyter Notebook (.ipynb) files into a single Deepnote project file.
733
+ */
734
+ async function convertIpynbFilesToDeepnoteFile(inputFilePaths, options) {
735
+ const notebooks = [];
736
+ for (const filePath of inputFilePaths) {
737
+ const notebook = await parseIpynbFile(filePath);
738
+ notebooks.push({
739
+ filename: basename(filePath),
740
+ notebook
741
+ });
742
+ }
743
+ const yamlContent = stringify(convertJupyterNotebooksToDeepnote(notebooks, { projectName: options.projectName }));
744
+ const parentDir = dirname(options.outputPath);
745
+ await fs.mkdir(parentDir, { recursive: true });
746
+ await fs.writeFile(options.outputPath, yamlContent, "utf-8");
747
+ }
748
+ async function parseIpynbFile(filePath) {
749
+ let ipynbJson;
750
+ try {
751
+ ipynbJson = await fs.readFile(filePath, "utf-8");
752
+ } catch (error) {
753
+ const message = error instanceof Error ? error.message : String(error);
754
+ throw new Error(`Failed to read ${filePath}: ${message}`);
755
+ }
756
+ try {
757
+ return JSON.parse(ipynbJson);
758
+ } catch (error) {
759
+ const message = error instanceof Error ? error.message : String(error);
760
+ throw new Error(`Failed to parse ${filePath}: invalid JSON - ${message}`);
761
+ }
762
+ }
763
+ function convertCellToBlock$3(cell, index, idGenerator) {
764
+ let source = Array.isArray(cell.source) ? cell.source.join("") : cell.source;
765
+ const cellId = cell.metadata?.cell_id;
766
+ const deepnoteCellType = cell.metadata?.deepnote_cell_type;
767
+ const sortingKey = cell.metadata?.deepnote_sorting_key;
768
+ const contentHash = cell.metadata?.deepnote_content_hash;
769
+ const executionStartedAt = cell.metadata?.deepnote_execution_started_at;
770
+ const executionFinishedAt = cell.metadata?.deepnote_execution_finished_at;
771
+ const blockGroup = cell.metadata?.deepnote_block_group ?? cell.block_group ?? idGenerator();
772
+ const deepnoteSource = cell.metadata?.deepnote_source;
773
+ if (deepnoteSource !== void 0) source = deepnoteSource;
774
+ const blockType = deepnoteCellType ?? (cell.cell_type === "code" ? "code" : "markdown");
775
+ const originalMetadata = { ...cell.metadata };
776
+ delete originalMetadata.cell_id;
777
+ delete originalMetadata.deepnote_cell_type;
778
+ delete originalMetadata.deepnote_block_group;
779
+ delete originalMetadata.deepnote_sorting_key;
780
+ delete originalMetadata.deepnote_source;
781
+ delete originalMetadata.deepnote_content_hash;
782
+ delete originalMetadata.deepnote_execution_started_at;
783
+ delete originalMetadata.deepnote_execution_finished_at;
784
+ delete cell.block_group;
785
+ const executionCount = cell.execution_count ?? void 0;
786
+ const hasExecutionCount = executionCount !== void 0;
787
+ const hasOutputs = cell.cell_type === "code" && cell.outputs !== void 0;
788
+ return sortKeysAlphabetically(deepnoteBlockSchema.parse({
789
+ blockGroup,
790
+ content: source,
791
+ ...contentHash ? { contentHash } : {},
792
+ ...hasExecutionCount ? { executionCount } : {},
793
+ ...executionFinishedAt ? { executionFinishedAt } : {},
794
+ ...executionStartedAt ? { executionStartedAt } : {},
795
+ id: cellId ?? idGenerator(),
796
+ metadata: originalMetadata,
797
+ ...hasOutputs ? { outputs: cell.outputs } : {},
798
+ sortingKey: sortingKey ?? createSortingKey(index),
799
+ type: blockType
800
+ }));
801
+ }
802
+
803
+ //#endregion
804
+ //#region src/marimo-to-deepnote.ts
805
+ /**
806
+ * Splits a string on commas that are at the top level (not inside parentheses, brackets, braces, or string literals).
807
+ * This handles cases like "func(a, b), other" and 'return "a,b", x' correctly.
808
+ * Supports single quotes, double quotes, and backticks, with proper escape handling.
809
+ */
810
+ function splitOnTopLevelCommas(str) {
811
+ const results = [];
812
+ let current = "";
813
+ let depth = 0;
814
+ let inString = null;
815
+ let escaped = false;
816
+ for (const char of str) {
817
+ if (escaped) {
818
+ current += char;
819
+ escaped = false;
820
+ continue;
821
+ }
822
+ if (char === "\\") {
823
+ current += char;
824
+ escaped = true;
825
+ continue;
826
+ }
827
+ if (char === "\"" || char === "'" || char === "`") {
828
+ if (inString === null) inString = char;
829
+ else if (inString === char) inString = null;
830
+ current += char;
831
+ continue;
832
+ }
833
+ if (inString !== null) {
834
+ current += char;
835
+ continue;
836
+ }
837
+ if (char === "(" || char === "[" || char === "{") {
838
+ depth++;
839
+ current += char;
840
+ } else if (char === ")" || char === "]" || char === "}") {
841
+ depth--;
842
+ current += char;
843
+ } else if (char === "," && depth === 0) {
844
+ results.push(current);
845
+ current = "";
846
+ } else current += char;
847
+ }
848
+ if (current) results.push(current);
849
+ return results;
850
+ }
851
+ /**
852
+ * Parses a Marimo Python file into a MarimoApp structure.
853
+ *
854
+ * @param content - The raw content of the .py file
855
+ * @returns A MarimoApp object
856
+ *
857
+ * @example
858
+ * ```typescript
859
+ * const content = `import marimo
860
+ *
861
+ * app = marimo.App()
862
+ *
863
+ * @app.cell
864
+ * def __():
865
+ * print("hello")
866
+ * return
867
+ * `
868
+ * const app = parseMarimoFormat(content)
869
+ * ```
870
+ */
871
+ function parseMarimoFormat(content) {
872
+ const cells = [];
873
+ const generatedWith = /__generated_with\s*=\s*["']([^"']+)["']/.exec(content)?.[1];
874
+ const width = /(?:marimo|mo)\.App\([^)]*width\s*=\s*["']([^"']+)["']/.exec(content)?.[1];
875
+ const title = /(?:marimo|mo)\.App\([^)]*title\s*=\s*["']([^"']+)["']/.exec(content)?.[1];
876
+ const cellRegex = /@app\.cell(?:\(([^)]*)\))?\s*\n\s*def\s+(\w+)\s*\(([^)]*)\)\s*(?:->.*?)?\s*:\s*\n([\s\S]*?)(?=@app\.cell|if\s+__name__|$)/g;
877
+ let match = cellRegex.exec(content);
878
+ while (match !== null) {
879
+ const decoratorArgs = match[1] || "";
880
+ const functionName = match[2];
881
+ const params = match[3].trim();
882
+ let body = match[4];
883
+ const dependencies = params ? params.split(",").map((p) => p.trim()).filter((p) => p.length > 0) : void 0;
884
+ const hidden = /hide_code\s*=\s*True/.test(decoratorArgs);
885
+ const disabled = /disabled\s*=\s*True/.test(decoratorArgs);
886
+ const lines = body.split("\n");
887
+ const firstNonEmptyLine = lines.find((l) => l.trim().length > 0);
888
+ const baseIndent = (firstNonEmptyLine ? /^(\s*)/.exec(firstNonEmptyLine) : null)?.[1] || "";
889
+ let exports;
890
+ for (let i = lines.length - 1; i >= 0; i--) {
891
+ const line = lines[i];
892
+ if (line.startsWith(baseIndent) && !line.startsWith(`${baseIndent} `) && !line.startsWith(`${baseIndent}\t`)) {
893
+ const lineContent = line.slice(baseIndent.length);
894
+ const returnMatch = /^return\s*([^\n]*?)(?:,\s*)?$/.exec(lineContent);
895
+ if (returnMatch) {
896
+ const returnVal = returnMatch[1].trim();
897
+ if (returnVal && returnVal !== "None" && returnVal !== "") {
898
+ exports = splitOnTopLevelCommas(returnVal.replace(/^\(|\)$/g, "").replace(/,\s*$/, "")).map((e) => e.trim()).filter((e) => e.length > 0 && e !== "None");
899
+ if (exports.length === 0) exports = void 0;
900
+ }
901
+ break;
902
+ }
903
+ }
904
+ }
905
+ const filteredLines = lines.filter((line) => {
906
+ const normalizedLine = line.replace(/\t/g, " ");
907
+ const normalizedBaseIndent = baseIndent.replace(/\t/g, " ");
908
+ if (normalizedLine.startsWith(normalizedBaseIndent) && !normalizedLine.startsWith(`${normalizedBaseIndent} `) && !normalizedLine.startsWith(`${normalizedBaseIndent}\t`)) {
909
+ const lineContent = normalizedLine.slice(normalizedBaseIndent.length);
910
+ if (/^return\s*(?:[^\n]*)?(?:,\s*)?$/.test(lineContent)) return false;
911
+ }
912
+ return true;
913
+ });
914
+ let processedBody;
915
+ if (baseIndent.length > 0) processedBody = filteredLines.map((l) => l.startsWith(baseIndent) ? l.slice(baseIndent.length) : l).join("\n");
916
+ else processedBody = filteredLines.join("\n");
917
+ body = processedBody.trim();
918
+ const isMarkdown = /^\s*mo\.md\s*\(/.test(body) || /^\s*marimo\.md\s*\(/.test(body);
919
+ const isSql = /^\s*(?:\w+\s*=\s*)?(?:mo|marimo)\.sql\s*\(/.test(body);
920
+ if (isMarkdown) {
921
+ const mdMatch = /(?:mo|marimo)\.md\s*\(\s*(?:(?:r|f|rf|fr)?(?:"""([\s\S]*?)"""|'''([\s\S]*?)'''|"([^"]*)"|'([^']*)'))\s*\)/.exec(body);
922
+ if (mdMatch) {
923
+ const mdContent = mdMatch[1] || mdMatch[2] || mdMatch[3] || mdMatch[4] || "";
924
+ cells.push({
925
+ cellType: "markdown",
926
+ content: mdContent.trim(),
927
+ functionName,
928
+ ...dependencies && dependencies.length > 0 ? { dependencies } : {},
929
+ ...hidden ? { hidden } : {},
930
+ ...disabled ? { disabled } : {}
931
+ });
932
+ }
933
+ } else if (isSql) {
934
+ const sqlMatch = /(?:mo|marimo)\.sql\s*\(\s*(?:f)?(?:"""([\s\S]*?)"""|'''([\s\S]*?)'''|"([^"]*)"|'([^']*)')\s*(?:,[\s\S]*)?\)/.exec(body);
935
+ if (sqlMatch) {
936
+ const sqlQuery = sqlMatch[1] || sqlMatch[2] || sqlMatch[3] || sqlMatch[4] || "";
937
+ cells.push({
938
+ cellType: "sql",
939
+ content: sqlQuery.trim(),
940
+ functionName,
941
+ ...dependencies && dependencies.length > 0 ? { dependencies } : {},
942
+ ...exports && exports.length > 0 ? { exports } : {},
943
+ ...hidden ? { hidden } : {},
944
+ ...disabled ? { disabled } : {}
945
+ });
946
+ }
947
+ } else cells.push({
948
+ cellType: "code",
949
+ content: body,
950
+ functionName,
951
+ ...dependencies && dependencies.length > 0 ? { dependencies } : {},
952
+ ...exports && exports.length > 0 ? { exports } : {},
953
+ ...hidden ? { hidden } : {},
954
+ ...disabled ? { disabled } : {}
955
+ });
956
+ match = cellRegex.exec(content);
957
+ }
958
+ return {
959
+ ...generatedWith ? { generatedWith } : {},
960
+ ...width ? { width } : {},
961
+ ...title ? { title } : {},
962
+ cells
963
+ };
964
+ }
965
+ /**
966
+ * Converts a single Marimo app into an array of Deepnote blocks.
967
+ * This is the lowest-level conversion function.
968
+ *
969
+ * @param app - The Marimo app object to convert
970
+ * @param options - Optional conversion options including custom ID generator
971
+ * @returns Array of DeepnoteBlock objects
972
+ */
973
+ function convertMarimoAppToBlocks(app, options) {
974
+ const idGenerator = options?.idGenerator ?? v4;
975
+ return app.cells.map((cell, index) => convertCellToBlock$2(cell, index, idGenerator));
976
+ }
977
+ /**
978
+ * Converts Marimo app objects into a Deepnote project file.
979
+ * This is a pure conversion function that doesn't perform any file I/O.
980
+ *
981
+ * @param apps - Array of Marimo apps with filenames
982
+ * @param options - Conversion options including project name and optional ID generator
983
+ * @returns A DeepnoteFile object
984
+ */
985
+ function convertMarimoAppsToDeepnote(apps, options) {
986
+ const idGenerator = options.idGenerator ?? v4;
987
+ const firstNotebookId = apps.length > 0 ? idGenerator() : void 0;
988
+ const deepnoteFile = {
989
+ metadata: { createdAt: (/* @__PURE__ */ new Date()).toISOString() },
990
+ project: {
991
+ id: idGenerator(),
992
+ initNotebookId: firstNotebookId,
993
+ integrations: [],
994
+ name: options.projectName,
995
+ notebooks: [],
996
+ settings: {}
997
+ },
998
+ version: "1.0.0"
999
+ };
1000
+ for (let i = 0; i < apps.length; i++) {
1001
+ const { filename, app } = apps[i];
1002
+ const filenameWithoutExt = basename(filename, extname(filename)) || "Untitled notebook";
1003
+ const notebookName = app.title || filenameWithoutExt;
1004
+ const blocks = convertMarimoAppToBlocks(app, { idGenerator });
1005
+ const notebookId = i === 0 && firstNotebookId ? firstNotebookId : idGenerator();
1006
+ deepnoteFile.project.notebooks.push({
1007
+ blocks,
1008
+ executionMode: "block",
1009
+ id: notebookId,
1010
+ isModule: false,
1011
+ name: notebookName
1012
+ });
1013
+ }
1014
+ return deepnoteFile;
1015
+ }
1016
+ /**
1017
+ * Converts multiple Marimo (.py) files into a single Deepnote project file.
1018
+ */
1019
+ async function convertMarimoFilesToDeepnoteFile(inputFilePaths, options) {
1020
+ const apps = [];
1021
+ for (const filePath of inputFilePaths) try {
1022
+ const app = parseMarimoFormat(await fs.readFile(filePath, "utf-8"));
1023
+ apps.push({
1024
+ filename: basename(filePath),
1025
+ app
1026
+ });
1027
+ } catch (err) {
1028
+ const errorMessage = err instanceof Error ? err.message : String(err);
1029
+ const errorStack = err instanceof Error ? err.stack : void 0;
1030
+ throw new Error(`Failed to read or parse file ${basename(filePath)}: ${errorMessage}`, { cause: errorStack ? {
1031
+ originalError: err,
1032
+ stack: errorStack
1033
+ } : err });
1034
+ }
1035
+ const yamlContent = stringify(convertMarimoAppsToDeepnote(apps, { projectName: options.projectName }));
1036
+ const parentDir = dirname(options.outputPath);
1037
+ await fs.mkdir(parentDir, { recursive: true });
1038
+ await fs.writeFile(options.outputPath, yamlContent, "utf-8");
1039
+ }
1040
+ function convertCellToBlock$2(cell, index, idGenerator) {
1041
+ let blockType;
1042
+ if (cell.cellType === "markdown") blockType = "markdown";
1043
+ else if (cell.cellType === "sql") blockType = "sql";
1044
+ else blockType = "code";
1045
+ const metadata = {};
1046
+ if (cell.dependencies && cell.dependencies.length > 0) metadata.marimo_dependencies = cell.dependencies;
1047
+ if (cell.exports && cell.exports.length > 0) metadata.marimo_exports = cell.exports;
1048
+ if (cell.hidden) metadata.is_code_hidden = true;
1049
+ if (cell.disabled) metadata.marimo_disabled = true;
1050
+ if (cell.functionName && cell.functionName !== "__") metadata.marimo_function_name = cell.functionName;
1051
+ if (cell.cellType === "sql" && cell.exports && cell.exports.length > 0) metadata.deepnote_variable_name = cell.exports[0];
1052
+ return {
1053
+ blockGroup: idGenerator(),
1054
+ content: cell.content,
1055
+ id: idGenerator(),
1056
+ metadata: Object.keys(metadata).length > 0 ? metadata : {},
1057
+ sortingKey: createSortingKey(index),
1058
+ type: blockType
1059
+ };
1060
+ }
1061
+
1062
+ //#endregion
1063
+ //#region src/percent-to-deepnote.ts
1064
+ /**
1065
+ * Parses a percent format Python file into a PercentNotebook structure.
1066
+ *
1067
+ * @param content - The raw content of the .py file
1068
+ * @returns A PercentNotebook object
1069
+ *
1070
+ * @example
1071
+ * ```typescript
1072
+ * const content = `# %% [markdown]
1073
+ * # # My Title
1074
+ *
1075
+ * # %%
1076
+ * print("hello")
1077
+ * `
1078
+ * const notebook = parsePercentFormat(content)
1079
+ * ```
1080
+ */
1081
+ function parsePercentFormat(content) {
1082
+ const cells = [];
1083
+ const lines = content.split("\n");
1084
+ if (lines.length === 0 || lines.length === 1 && lines[0] === "") return { cells: [] };
1085
+ const cellMarkerRegex = /^# %%\s*(?:\[(\w+)\])?\s*(.*)$/;
1086
+ let currentCell = null;
1087
+ let currentContent = [];
1088
+ function finalizeCell() {
1089
+ if (currentCell) {
1090
+ if (currentCell.cellType === "markdown") currentCell.content = currentContent.map((line) => {
1091
+ if (line.startsWith("# ")) return line.slice(2);
1092
+ if (line === "#") return "";
1093
+ return line;
1094
+ }).join("\n").trim();
1095
+ else currentCell.content = currentContent.join("\n").trim();
1096
+ cells.push(currentCell);
1097
+ }
1098
+ currentContent = [];
1099
+ }
1100
+ for (const line of lines) {
1101
+ const match = cellMarkerRegex.exec(line);
1102
+ if (match) {
1103
+ finalizeCell();
1104
+ const cellTypeStr = match[1]?.toLowerCase() || "code";
1105
+ const rest = match[2]?.trim() || "";
1106
+ let cellType = "code";
1107
+ if (cellTypeStr === "markdown" || cellTypeStr === "md") cellType = "markdown";
1108
+ else if (cellTypeStr === "raw") cellType = "raw";
1109
+ let title;
1110
+ let tags;
1111
+ const tagsMatch = /tags\s*=\s*\[([^\]]*)\]/.exec(rest);
1112
+ if (tagsMatch) {
1113
+ tags = tagsMatch[1].split(",").map((t) => t.trim().replace(/^["']|["']$/g, "")).filter((t) => t.length > 0);
1114
+ const titlePart = rest.replace(/tags\s*=\s*\[[^\]]*\]/, "").trim();
1115
+ if (titlePart) title = titlePart;
1116
+ } else if (rest) title = rest;
1117
+ currentCell = {
1118
+ cellType,
1119
+ content: "",
1120
+ ...title ? { title } : {},
1121
+ ...tags && tags.length > 0 ? { tags } : {}
1122
+ };
1123
+ } else if (currentCell) currentContent.push(line);
1124
+ else if (line.trim() !== "" || currentContent.length > 0) {
1125
+ currentCell = {
1126
+ cellType: "code",
1127
+ content: ""
1128
+ };
1129
+ currentContent.push(line);
1130
+ }
1131
+ }
1132
+ finalizeCell();
1133
+ return { cells };
1134
+ }
1135
+ /**
1136
+ * Converts a single percent format notebook into an array of Deepnote blocks.
1137
+ * This is the lowest-level conversion function.
1138
+ *
1139
+ * @param notebook - The percent notebook object to convert
1140
+ * @param options - Optional conversion options including custom ID generator
1141
+ * @returns Array of DeepnoteBlock objects
1142
+ */
1143
+ function convertPercentNotebookToBlocks(notebook, options) {
1144
+ const idGenerator = options?.idGenerator ?? v4;
1145
+ return notebook.cells.map((cell, index) => convertCellToBlock$1(cell, index, idGenerator));
1146
+ }
1147
+ /**
1148
+ * Converts percent format notebook objects into a Deepnote project file.
1149
+ * This is a pure conversion function that doesn't perform any file I/O.
1150
+ *
1151
+ * @param notebooks - Array of percent notebooks with filenames
1152
+ * @param options - Conversion options including project name
1153
+ * @returns A DeepnoteFile object
1154
+ */
1155
+ function convertPercentNotebooksToDeepnote(notebooks, options) {
1156
+ const firstNotebookId = notebooks.length > 0 ? v4() : void 0;
1157
+ const deepnoteFile = {
1158
+ metadata: { createdAt: (/* @__PURE__ */ new Date()).toISOString() },
1159
+ project: {
1160
+ id: v4(),
1161
+ initNotebookId: firstNotebookId,
1162
+ integrations: [],
1163
+ name: options.projectName,
1164
+ notebooks: [],
1165
+ settings: {}
1166
+ },
1167
+ version: "1.0.0"
1168
+ };
1169
+ for (let i = 0; i < notebooks.length; i++) {
1170
+ const { filename, notebook } = notebooks[i];
1171
+ const filenameWithoutExt = basename(filename, extname(filename)) || "Untitled notebook";
1172
+ const blocks = convertPercentNotebookToBlocks(notebook);
1173
+ const notebookId = i === 0 && firstNotebookId ? firstNotebookId : v4();
1174
+ deepnoteFile.project.notebooks.push({
1175
+ blocks,
1176
+ executionMode: "block",
1177
+ id: notebookId,
1178
+ isModule: false,
1179
+ name: filenameWithoutExt
1180
+ });
1181
+ }
1182
+ return deepnoteFile;
1183
+ }
1184
+ /**
1185
+ * Converts multiple percent format (.py) files into a single Deepnote project file.
1186
+ */
1187
+ async function convertPercentFilesToDeepnoteFile(inputFilePaths, options) {
1188
+ const notebooks = [];
1189
+ for (const filePath of inputFilePaths) {
1190
+ const notebook = parsePercentFormat(await fs.readFile(filePath, "utf-8"));
1191
+ notebooks.push({
1192
+ filename: basename(filePath),
1193
+ notebook
1194
+ });
1195
+ }
1196
+ const yamlContent = stringify(convertPercentNotebooksToDeepnote(notebooks, { projectName: options.projectName }));
1197
+ const parentDir = dirname(options.outputPath);
1198
+ await fs.mkdir(parentDir, { recursive: true });
1199
+ await fs.writeFile(options.outputPath, yamlContent, "utf-8");
1200
+ }
1201
+ function convertCellToBlock$1(cell, index, idGenerator) {
1202
+ const blockType = cell.cellType === "markdown" || cell.cellType === "raw" ? "markdown" : "code";
1203
+ const metadata = {};
1204
+ if (cell.cellType === "raw") metadata.percent_cell_type = "raw";
1205
+ if (cell.title) metadata.title = cell.title;
1206
+ if (cell.tags && cell.tags.length > 0) metadata.tags = cell.tags;
1207
+ return {
1208
+ blockGroup: idGenerator(),
1209
+ content: cell.content,
1210
+ id: idGenerator(),
1211
+ metadata: Object.keys(metadata).length > 0 ? metadata : {},
1212
+ sortingKey: createSortingKey(index),
1213
+ type: blockType
1214
+ };
1215
+ }
1216
+
1217
+ //#endregion
1218
+ //#region src/quarto-to-deepnote.ts
1219
+ /**
1220
+ * Parses a Quarto (.qmd) file into a QuartoDocument structure.
1221
+ *
1222
+ * @param content - The raw content of the .qmd file
1223
+ * @returns A QuartoDocument object
1224
+ *
1225
+ * @example
1226
+ * ```typescript
1227
+ * const content = `---
1228
+ * title: "My Document"
1229
+ * ---
1230
+ *
1231
+ * # Introduction
1232
+ *
1233
+ * \`\`\`{python}
1234
+ * print("hello")
1235
+ * \`\`\`
1236
+ * `
1237
+ * const doc = parseQuartoFormat(content)
1238
+ * ```
1239
+ */
1240
+ function parseQuartoFormat(content) {
1241
+ const cells = [];
1242
+ let frontmatter;
1243
+ let mainContent = content;
1244
+ const frontmatterMatch = /^---\r?\n([\s\S]*?)---\r?\n?/.exec(content);
1245
+ if (frontmatterMatch) {
1246
+ frontmatter = parseYamlFrontmatter(frontmatterMatch[1]);
1247
+ mainContent = content.slice(frontmatterMatch[0].length);
1248
+ }
1249
+ const codeChunkRegex = /```\{([\w-]+)\}\r?\n([\s\S]*?)```/g;
1250
+ let lastIndex = 0;
1251
+ let match = codeChunkRegex.exec(mainContent);
1252
+ while (match !== null) {
1253
+ const markdownBefore = mainContent.slice(lastIndex, match.index).trim();
1254
+ if (markdownBefore) addMarkdownCells(cells, markdownBefore);
1255
+ const language = match[1];
1256
+ let codeContent = match[2];
1257
+ const options = parseQuartoCellOptions(codeContent);
1258
+ if (options) codeContent = codeContent.split("\n").filter((line) => !line.trimStart().startsWith("#|")).join("\n").trim();
1259
+ else codeContent = codeContent.trim();
1260
+ cells.push({
1261
+ cellType: "code",
1262
+ content: codeContent,
1263
+ language,
1264
+ ...options ? { options } : {}
1265
+ });
1266
+ lastIndex = match.index + match[0].length;
1267
+ match = codeChunkRegex.exec(mainContent);
1268
+ }
1269
+ const markdownAfter = mainContent.slice(lastIndex).trim();
1270
+ if (markdownAfter) addMarkdownCells(cells, markdownAfter);
1271
+ return {
1272
+ ...frontmatter ? { frontmatter } : {},
1273
+ cells
1274
+ };
1275
+ }
1276
+ /**
1277
+ * Splits markdown content at cell delimiter comments and adds each part as a separate cell.
1278
+ * The delimiter `<!-- cell -->` is used to preserve cell boundaries during roundtrip conversion.
1279
+ */
1280
+ function addMarkdownCells(cells, content) {
1281
+ const parts = content.split(/\s*<!--\s*cell\s*-->\s*/);
1282
+ for (const part of parts) {
1283
+ const trimmed = part.trim();
1284
+ if (trimmed) cells.push({
1285
+ cellType: "markdown",
1286
+ content: trimmed
1287
+ });
1288
+ }
1289
+ }
1290
+ /**
1291
+ * Parses YAML frontmatter string into a QuartoFrontmatter object.
1292
+ * Uses the yaml package to properly handle nested objects, arrays, and all YAML features.
1293
+ */
1294
+ function parseYamlFrontmatter(yamlString) {
1295
+ if (!yamlString || yamlString.trim() === "") return {};
1296
+ try {
1297
+ const parsed = parse(yamlString);
1298
+ if (parsed === null || parsed === void 0) return {};
1299
+ if (typeof parsed !== "object" || Array.isArray(parsed)) return {};
1300
+ return parsed;
1301
+ } catch {
1302
+ return {};
1303
+ }
1304
+ }
1305
+ /**
1306
+ * Parses Quarto cell options from #| lines.
1307
+ */
1308
+ function parseQuartoCellOptions(content) {
1309
+ const optionLines = content.split("\n").filter((line) => line.trimStart().startsWith("#|"));
1310
+ if (optionLines.length === 0) return;
1311
+ const options = {};
1312
+ const raw = {};
1313
+ for (const line of optionLines) {
1314
+ const match = /^#\|\s*(\S+):\s*(.*)$/.exec(line.trimStart());
1315
+ if (match) {
1316
+ const key = match[1];
1317
+ let value = match[2].trim();
1318
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
1319
+ else if (value === "true") value = true;
1320
+ else if (value === "false") value = false;
1321
+ else {
1322
+ const num = Number(value);
1323
+ if (!Number.isNaN(num) && Number.isFinite(num)) value = num;
1324
+ }
1325
+ switch (key) {
1326
+ case "label":
1327
+ if (typeof value === "string") options.label = value;
1328
+ else raw[key] = value;
1329
+ break;
1330
+ case "echo":
1331
+ if (typeof value === "boolean") options.echo = value;
1332
+ else if (value === "true" || value === "false") options.echo = value === "true";
1333
+ else raw[key] = value;
1334
+ break;
1335
+ case "eval":
1336
+ if (typeof value === "boolean") options.eval = value;
1337
+ else if (value === "true" || value === "false") options.eval = value === "true";
1338
+ else raw[key] = value;
1339
+ break;
1340
+ case "output":
1341
+ if (typeof value === "boolean") options.output = value;
1342
+ else if (value === "true" || value === "false") options.output = value === "true";
1343
+ else raw[key] = value;
1344
+ break;
1345
+ case "fig-cap":
1346
+ if (typeof value === "string") options.figCap = value;
1347
+ else raw[key] = value;
1348
+ break;
1349
+ case "fig-width":
1350
+ if (typeof value === "number" && Number.isFinite(value)) options.figWidth = value;
1351
+ else if (typeof value === "string") {
1352
+ const num = Number(value);
1353
+ if (!Number.isNaN(num) && Number.isFinite(num)) options.figWidth = num;
1354
+ else raw[key] = value;
1355
+ } else raw[key] = value;
1356
+ break;
1357
+ case "fig-height":
1358
+ if (typeof value === "number" && Number.isFinite(value)) options.figHeight = value;
1359
+ else if (typeof value === "string") {
1360
+ const num = Number(value);
1361
+ if (!Number.isNaN(num) && Number.isFinite(num)) options.figHeight = num;
1362
+ else raw[key] = value;
1363
+ } else raw[key] = value;
1364
+ break;
1365
+ case "tbl-cap":
1366
+ if (typeof value === "string") options.tblCap = value;
1367
+ else raw[key] = value;
1368
+ break;
1369
+ case "warning":
1370
+ if (typeof value === "boolean") options.warning = value;
1371
+ else if (value === "true" || value === "false") options.warning = value === "true";
1372
+ else raw[key] = value;
1373
+ break;
1374
+ case "message":
1375
+ if (typeof value === "boolean") options.message = value;
1376
+ else if (value === "true" || value === "false") options.message = value === "true";
1377
+ else raw[key] = value;
1378
+ break;
1379
+ default: raw[key] = value;
1380
+ }
1381
+ }
1382
+ }
1383
+ if (Object.keys(raw).length > 0) options.raw = raw;
1384
+ return Object.keys(options).length > 0 ? options : void 0;
1385
+ }
1386
+ /**
1387
+ * Converts a single Quarto document into an array of Deepnote blocks.
1388
+ * This is the lowest-level conversion function.
1389
+ *
1390
+ * @param document - The Quarto document object to convert
1391
+ * @param options - Optional conversion options including custom ID generator
1392
+ * @returns Array of DeepnoteBlock objects
1393
+ */
1394
+ function convertQuartoDocumentToBlocks(document, options) {
1395
+ const idGenerator = options?.idGenerator ?? v4;
1396
+ const blocks = [];
1397
+ if (document.frontmatter?.title) blocks.push({
1398
+ blockGroup: idGenerator(),
1399
+ content: `# ${document.frontmatter.title}`,
1400
+ id: idGenerator(),
1401
+ metadata: {},
1402
+ sortingKey: createSortingKey(blocks.length),
1403
+ type: "markdown"
1404
+ });
1405
+ for (const cell of document.cells) blocks.push(convertCellToBlock(cell, blocks.length, idGenerator));
1406
+ return blocks;
1407
+ }
1408
+ /**
1409
+ * Converts Quarto document objects into a Deepnote project file.
1410
+ * This is a pure conversion function that doesn't perform any file I/O.
1411
+ *
1412
+ * @param documents - Array of Quarto documents with filenames
1413
+ * @param options - Conversion options including project name
1414
+ * @returns A DeepnoteFile object
1415
+ */
1416
+ function convertQuartoDocumentsToDeepnote(documents, options) {
1417
+ const firstNotebookId = documents.length > 0 ? v4() : void 0;
1418
+ const deepnoteFile = {
1419
+ metadata: { createdAt: (/* @__PURE__ */ new Date()).toISOString() },
1420
+ project: {
1421
+ id: v4(),
1422
+ initNotebookId: firstNotebookId,
1423
+ integrations: [],
1424
+ name: options.projectName,
1425
+ notebooks: [],
1426
+ settings: {}
1427
+ },
1428
+ version: "1.0.0"
1429
+ };
1430
+ for (let i = 0; i < documents.length; i++) {
1431
+ const { filename, document } = documents[i];
1432
+ const filenameWithoutExt = basename(filename, extname(filename)) || "Untitled notebook";
1433
+ const notebookName = document.frontmatter?.title || filenameWithoutExt;
1434
+ const blocks = convertQuartoDocumentToBlocks(document);
1435
+ const notebookId = i === 0 && firstNotebookId ? firstNotebookId : v4();
1436
+ deepnoteFile.project.notebooks.push({
1437
+ blocks,
1438
+ executionMode: "block",
1439
+ id: notebookId,
1440
+ isModule: false,
1441
+ name: typeof notebookName === "string" ? notebookName : filenameWithoutExt
1442
+ });
1443
+ }
1444
+ return deepnoteFile;
1445
+ }
1446
+ /**
1447
+ * Converts multiple Quarto (.qmd) files into a single Deepnote project file.
1448
+ */
1449
+ async function convertQuartoFilesToDeepnoteFile(inputFilePaths, options) {
1450
+ const documents = [];
1451
+ for (const filePath of inputFilePaths) {
1452
+ const document = parseQuartoFormat(await fs.readFile(filePath, "utf-8"));
1453
+ documents.push({
1454
+ filename: basename(filePath),
1455
+ document
1456
+ });
1457
+ }
1458
+ const yamlContent = stringify(convertQuartoDocumentsToDeepnote(documents, { projectName: options.projectName }));
1459
+ const parentDir = dirname(options.outputPath);
1460
+ await fs.mkdir(parentDir, { recursive: true });
1461
+ await fs.writeFile(options.outputPath, yamlContent, "utf-8");
1462
+ }
1463
+ function convertCellToBlock(cell, index, idGenerator) {
1464
+ const blockType = cell.cellType === "markdown" ? "markdown" : "code";
1465
+ const metadata = {};
1466
+ if (cell.options) {
1467
+ if (cell.options.label) metadata.quarto_label = cell.options.label;
1468
+ if (cell.options.echo === false) metadata.is_code_hidden = true;
1469
+ if (cell.options.figCap) metadata.quarto_fig_cap = cell.options.figCap;
1470
+ if (cell.options.tblCap) metadata.quarto_tbl_cap = cell.options.tblCap;
1471
+ if (cell.options.raw) metadata.quarto_options = cell.options.raw;
1472
+ }
1473
+ if (cell.language && cell.language !== "python") metadata.language = cell.language;
1474
+ return {
1475
+ blockGroup: idGenerator(),
1476
+ content: cell.content,
1477
+ id: idGenerator(),
1478
+ metadata: Object.keys(metadata).length > 0 ? metadata : {},
1479
+ sortingKey: createSortingKey(index),
1480
+ type: blockType
1481
+ };
1482
+ }
1483
+
1484
+ //#endregion
1485
+ export { serializeMarimoFormat as A, convertBlocksToPercentNotebook as C, convertBlocksToMarimoApp as D, serializePercentFormat as E, convertDeepnoteFileToJupyterFiles as M, convertDeepnoteToJupyterNotebooks as N, convertDeepnoteFileToMarimoFiles as O, serializeQuartoFormat as S, convertDeepnoteToPercentNotebooks as T, isMarimoContent as _, convertPercentFilesToDeepnoteFile as a, convertDeepnoteFileToQuartoFiles as b, parsePercentFormat as c, convertMarimoFilesToDeepnoteFile as d, parseMarimoFormat as f, detectFormat as g, convertJupyterNotebooksToDeepnote as h, parseQuartoFormat as i, convertBlocksToJupyterNotebook as j, convertDeepnoteToMarimoApps as k, convertMarimoAppToBlocks as l, convertJupyterNotebookToBlocks as m, convertQuartoDocumentsToDeepnote as n, convertPercentNotebookToBlocks as o, convertIpynbFilesToDeepnoteFile as p, convertQuartoFilesToDeepnoteFile as r, convertPercentNotebooksToDeepnote as s, convertQuartoDocumentToBlocks as t, convertMarimoAppsToDeepnote as u, isPercentContent as v, convertDeepnoteFileToPercentFiles as w, convertDeepnoteToQuartoDocuments as x, convertBlocksToQuartoDocument as y };