diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 120455a..6e2ea72 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -92,6 +92,68 @@ plt.show() \`\`\` ` +const VARIABLES_MYST_SAMPLE = `--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 +kernelspec: + display_name: C++17 + language: C++17 + name: xcpp17 +learning: + objectives: + apply: [variable, "d\\xE9claration de variable", affectation] + prerequisites: + apply: [valeur, "op\\xE9ration", expression, "expression bool\\xE9enne", type, entier, + "r\\xE9el", "caract\\xE8re", "bool\\xE9en"] +--- + ++++ {"nbgrader": {"grade": false, "grade_id": "cell-b78562ee2ff6d72c", "locked": true, "schema_version": 3, "solution": false}} + +# TP : variables et affectations + +Dans la feuille précédente, nous avons effectué des calculs et observé +les résultats (type, valeur). Pour écrire des programmes, nous aurons +besoin de **stocker les résultats intermédiaires dans des variables** +pour en réutiliser les valeurs. + +## Exercice 1 + +- Exécutez la cellule suivante : + +\`\`\`{code-cell} +int a; +a = 3; +\`\`\` + ++++ {"nbgrader": {"grade": false, "grade_id": "cell-77b23801c7675aeb", "locked": true, "schema_version": 3, "solution": false}} + +Une fois que la variable \`a\` a été ***déclarée*** (\`int a;\`) et qu'on +lui a ***affecté*** une valeur (\`a = 3\`), on peut afficher ou +réutiliser cette valeur : + +\`\`\`{code-cell} +--- +nbgrader: + grade: false + grade_id: cell-cb019a2142f5c180 + locked: true + schema_version: 3 + solution: false + task: false +--- +a +\`\`\` + +\`\`\`{code-cell} +:locked: false + +a + 1 +\`\`\` +` + const SG_SAMPLE = `# --- # kernelspec: {"display_name": "Python 3", "name": "python3"} # language_info: {"name": "python"} @@ -172,6 +234,7 @@ const INPUT_LANGUAGE: Record = { py: 'python', md: 'markdown', myst: 'markdown', + myst_vars: 'markdown', sg: 'python', } @@ -179,10 +242,23 @@ const INPUT_LANGUAGE: Record = { // App // --------------------------------------------------------------------------- -type Format = 'py' | 'md' | 'myst' | 'sg' +type Format = 'py' | 'md' | 'myst' | 'myst_vars' | 'sg' -const SAMPLES: Record = { py: PY_SAMPLE, md: MD_SAMPLE, myst: MYST_SAMPLE, sg: SG_SAMPLE } -const LABELS: Record = { py: '.py percent', md: '.md classic', myst: '.md MyST', sg: 'sphinx-gallery' } +const SAMPLES: Record = { + py: PY_SAMPLE, + md: MD_SAMPLE, + myst: MYST_SAMPLE, + myst_vars: VARIABLES_MYST_SAMPLE, + sg: SG_SAMPLE +} + +const LABELS: Record = { + py: '.py percent', + md: '.md classic', + myst: '.md MyST (simple)', + myst_vars: '.md MyST (variables)', + sg: 'sphinx-gallery' +} export default function App() { const [format, setFormat] = useState('py') diff --git a/src/parseClassicMd.ts b/src/parseClassicMd.ts index 7b05864..106d4bf 100644 --- a/src/parseClassicMd.ts +++ b/src/parseClassicMd.ts @@ -1,5 +1,5 @@ import { codeCell, markdownCell, makeNotebook, type Cell, type Notebook } from "./notebook.js"; -import { parseFrontMatter } from "./utils.js"; +import { parseYAMLBlock } from "./utils.js"; // --------------------------------------------------------------------------- // Classic markdown parser @@ -40,7 +40,7 @@ export function parseClassicMd(text: string): Notebook { i++; } i++; // skip closing --- - notebookMeta = parseFrontMatter(fmLines); + notebookMeta = parseYAMLBlock(fmLines); } while (i < lines.length) { diff --git a/src/parseMystMd.ts b/src/parseMystMd.ts index 95b901c..8a05a16 100644 --- a/src/parseMystMd.ts +++ b/src/parseMystMd.ts @@ -6,7 +6,7 @@ import { type Cell, type Notebook, } from "./notebook"; -import { parseFrontMatter } from "./utils"; +import { parseYAMLBlock } from "./utils"; // --------------------------------------------------------------------------- // MyST notebook format parser @@ -54,28 +54,6 @@ function substituteInlineRoles(line: string): string { return line; } -/** Minimal YAML/JSON option parser for cell metadata. - * Handles flat key:value and key: [flow, list] only. */ -function parseOptions(lines: string[]): Record { - const result: Record = {}; - for (const line of lines) { - const m = line.match(/^([\w-]+):\s*(.*)/); - if (!m) continue; - const key = m[1]; - const val = m[2].trim(); - if (val.startsWith("[") || val.startsWith("{")) { - try { - result[key] = JSON.parse(val); - } catch { - result[key] = val; - } - } else { - result[key] = val; - } - } - return result; -} - function stripTrailingBlank(lines: string[]): string[] { let end = lines.length; while (end > 0 && lines[end - 1].trim() === "") end--; @@ -109,7 +87,7 @@ export function parseMystMd(text: string): Notebook { i++; } i++; // skip closing --- - notebookMeta = parseFrontMatter(fmLines); + notebookMeta = parseYAMLBlock(fmLines); } while (i < lines.length) { @@ -182,7 +160,7 @@ export function parseMystMd(text: string): Notebook { } } - const cellMeta = parseOptions(optionLines); + const cellMeta = parseYAMLBlock(optionLines); // Skip blank line after options if (i < lines.length && lines[i].trim() === "") i++; diff --git a/src/parsePy.ts b/src/parsePy.ts index e872612..c696ee8 100644 --- a/src/parsePy.ts +++ b/src/parsePy.ts @@ -6,7 +6,7 @@ import { type Cell, type Notebook, } from "./notebook"; -import { parseFrontMatter } from "./utils"; +import { parseYAMLBlock } from "./utils"; // --------------------------------------------------------------------------- // Percent format parser @@ -37,22 +37,47 @@ function parseCellHeader(rest: string): CellHeader { s = s.replace(TYPE_TAG_RE, "").trim(); } - const metadata: Record = {}; + let metadata: Record = {}; - // Parse tags="[...]" or tags=['...'] - const tagsMatch = s.match(/\btags\s*=\s*(['"])\[(.+?)\]\1/); - if (tagsMatch) { + const firstCurly = s.indexOf("{"); + const firstEqual = s.indexOf("="); + + if (firstCurly >= 0 && (firstEqual < 0 || firstCurly < firstEqual)) { + const jsonStr = s.slice(firstCurly); + const titleStr = s.slice(0, firstCurly).trim(); try { - metadata.tags = JSON.parse("[" + tagsMatch[2] + "]"); + metadata = JSON.parse(jsonStr); } catch { - // ignore malformed tags + // ignore + } + if (titleStr) { + metadata.name = titleStr; + } + } else { + const kvRegex = /\b([\w-]+)=("[^"]*"|'[^']*'|\[[^\]]*\]|\S+)/g; + const matches = Array.from(s.matchAll(kvRegex)); + let remaining = s; + for (const match of matches) { + const key = match[1]; + const valStr = match[2]; + remaining = remaining.replace(match[0], ""); + + let parsedVal: unknown; + const strippedVal = valStr.replace(/^['"]|['"]$/g, ""); + try { + const normVal = strippedVal.replace(/'/g, '"'); + parsedVal = JSON.parse(normVal); + } catch { + parsedVal = strippedVal; + } + metadata[key] = parsedVal; + } + const nameStr = remaining.replace(/\s+/g, " ").trim(); + if (nameStr) { + metadata.name = nameStr; } - s = s.replace(tagsMatch[0], "").trim(); } - // Remaining text is the cell title/name - if (s) metadata.name = s; - return { cellType, metadata }; } @@ -99,7 +124,7 @@ export function parsePy(text: string): Notebook { i++; } i++; // skip closing # --- - notebookMeta = parseFrontMatter(fmLines); + notebookMeta = parseYAMLBlock(fmLines); } // Find all delimiter positions diff --git a/src/parseSphinxGallery.ts b/src/parseSphinxGallery.ts index 6c1d220..9cdb924 100644 --- a/src/parseSphinxGallery.ts +++ b/src/parseSphinxGallery.ts @@ -1,5 +1,5 @@ import { codeCell, markdownCell, makeNotebook, type Cell, type Notebook } from "./notebook"; -import { parseFrontMatter } from "./utils"; +import { parseYAMLBlock } from "./utils"; // --------------------------------------------------------------------------- // RST helpers @@ -132,7 +132,7 @@ export function parseSphinxGallery(text: string): Notebook { i++; } i++; // skip closing # --- - notebookMeta = parseFrontMatter(fmLines); + notebookMeta = parseYAMLBlock(fmLines); } // Skip any leading empty lines before checking for docstring diff --git a/src/toMystMd.ts b/src/toMystMd.ts index 3dc0baf..987f719 100644 --- a/src/toMystMd.ts +++ b/src/toMystMd.ts @@ -1,5 +1,5 @@ import type { Notebook } from "./notebook"; -import { stringifyYAML } from "./utils"; +import { stringifyYAML, pythonStyleJSON, cleanCellMetadata } from "./utils"; // --------------------------------------------------------------------------- // Notebook → MyST Markdown serializer @@ -9,10 +9,35 @@ function joinSource(source: string[]): string { return source.join(""); } -function serializeOptions(meta: Record): string[] { - return Object.entries(meta).map(([key, val]) => - typeof val === "string" ? `:${key}: ${val}` : `:${key}: ${JSON.stringify(val)}`, - ); +/** Check if metadata has nested objects (not simple). */ +function isSimpleMetadata(meta: Record): boolean { + for (const val of Object.values(meta)) { + if (val && typeof val === "object" && !Array.isArray(val)) { + return false; + } + } + return true; +} + +/** Serialize flat keys to MyST compact colon shorthand. */ +function serializeCompactOptions(meta: Record): string[] { + return Object.entries(meta).map(([key, val]) => { + if (Array.isArray(val)) { + const items = val + .map((item) => { + if (typeof item === "string" && /^[a-zA-Z0-9_-]+$/.test(item)) { + return item; + } + return JSON.stringify(item); + }) + .join(", "); + return `:${key}: [${items}]`; + } + if (typeof val === "string") { + return `:${key}: ${val}`; + } + return `:${key}: ${JSON.stringify(val)}`; + }); } /** @@ -32,10 +57,11 @@ export function toMystMd(notebook: Notebook): string { } for (const cell of notebook.cells) { + const cleanMeta = cleanCellMetadata(cell.metadata); if (cell.cell_type === "markdown") { - const hasMeta = Object.keys(cell.metadata).length > 0; + const hasMeta = Object.keys(cleanMeta).length > 0; if (parts.length > 0 || hasMeta) { - const metaStr = hasMeta ? ` ${JSON.stringify(cell.metadata)}` : ""; + const metaStr = hasMeta ? ` ${pythonStyleJSON(cleanMeta)}` : ""; parts.push(`+++${metaStr}`); } const src = joinSource(cell.source); @@ -43,9 +69,21 @@ export function toMystMd(notebook: Notebook): string { } else { const directive = cell.cell_type === "code" ? "code-cell" : "raw-cell"; const lines: string[] = [`\`\`\`{${directive}}`]; - const optionLines = serializeOptions(cell.metadata); - lines.push(...optionLines); - if (optionLines.length > 0) lines.push(""); + + const hasMeta = Object.keys(cleanMeta).length > 0; + if (hasMeta) { + if (isSimpleMetadata(cleanMeta)) { + const optionLines = serializeCompactOptions(cleanMeta); + lines.push(...optionLines); + lines.push(""); + } else { + const yamlStr = stringifyYAML(cleanMeta); + lines.push("---"); + lines.push(yamlStr); + lines.push("---"); + } + } + const src = joinSource(cell.source); if (src) lines.push(src); lines.push("```"); diff --git a/src/toPy.ts b/src/toPy.ts index f2ccddd..6f553a6 100644 --- a/src/toPy.ts +++ b/src/toPy.ts @@ -1,5 +1,5 @@ import type { Notebook } from "./notebook"; -import { stringifyYAML } from "./utils"; +import { stringifyYAML, pythonStyleJSON, cleanCellMetadata } from "./utils"; // --------------------------------------------------------------------------- // Notebook → Python percent format serializer @@ -17,14 +17,56 @@ function commentLines(text: string): string { .join("\n"); } +function isSimpleMetadata(meta: Record): boolean { + for (const val of Object.values(meta)) { + if (val && typeof val === "object" && !Array.isArray(val)) { + return false; + } + } + return true; +} + function buildDelimiter(cellType: string, meta: Record): string { const parts: string[] = ["# %%"]; if (cellType === "markdown") parts.push("[markdown]"); else if (cellType === "raw") parts.push("[raw]"); - if (typeof meta.name === "string" && meta.name) parts.push(meta.name); - if (Array.isArray(meta.tags) && meta.tags.length > 0) { - parts.push(`tags='${JSON.stringify(meta.tags)}'`); + + const cleanMeta = { ...meta }; + delete cleanMeta.cell_marker; + + let cellName = ""; + if (typeof cleanMeta.name === "string" && cleanMeta.name) { + cellName = cleanMeta.name; + delete cleanMeta.name; + } else if (typeof cleanMeta.title === "string" && cleanMeta.title) { + cellName = cleanMeta.title; + delete cleanMeta.title; } + + if (cellName) { + parts.push(cellName); + } + + if (Object.keys(cleanMeta).length > 0) { + if (isSimpleMetadata(cleanMeta)) { + const optStr = Object.entries(cleanMeta) + .map(([key, val]) => { + if (typeof val === "string") { + return `${key}=${JSON.stringify(val)}`; + } + if (Array.isArray(val)) { + const items = val.map((item) => JSON.stringify(item)).join(", "); + return `${key}=[${items}]`; + } + return `${key}=${JSON.stringify(val)}`; + }) + .join(" "); + parts.push(optStr); + } else { + parts.push(pythonStyleJSON(cleanMeta)); + } + } + return parts.join(" "); } @@ -49,7 +91,8 @@ export function toPy(notebook: Notebook): string { } for (const cell of notebook.cells) { - const delimiter = buildDelimiter(cell.cell_type, cell.metadata); + const cleanMeta = cleanCellMetadata(cell.metadata); + const delimiter = buildDelimiter(cell.cell_type, cleanMeta); const src = joinSource(cell.source); if (cell.cell_type === "code") { diff --git a/src/utils.ts b/src/utils.ts index 1d8f17a..afca03e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,32 +5,112 @@ type YAMLObject = Record; /** - * Parse a multiline YAML frontmatter string array into a nested object structure. - * Uses jupyter namespace for compatibility with Jupytext metadata format. + * Merges multi-line flow arrays in YAML lines by counting brackets. */ -export function parseFrontMatter(yamlLines: string[]): YAMLObject { +function preprocessYamlLines(yamlLines: string[]): string[] { + const merged: string[] = []; + let currentLine = ""; + + for (const rawLine of yamlLines) { + if (currentLine === "") { + currentLine = rawLine; + } else { + currentLine += "\n" + rawLine; + } + + let openBrackets = 0; + let closeBrackets = 0; + let inDoubleQuotes = false; + let inSingleQuotes = false; + + for (let c = 0; c < currentLine.length; c++) { + const char = currentLine[c]; + const prevChar = currentLine[c - 1] ?? ""; + if (char === '"' && prevChar !== "\\" && !inSingleQuotes) { + inDoubleQuotes = !inDoubleQuotes; + } else if (char === "'" && prevChar !== "\\" && !inDoubleQuotes) { + inSingleQuotes = !inSingleQuotes; + } else if (!inDoubleQuotes && !inSingleQuotes) { + if (char === "[") { + openBrackets++; + } else if (char === "]") { + closeBrackets++; + } + } + } + + if (openBrackets === closeBrackets) { + merged.push(currentLine); + currentLine = ""; + } + } + + if (currentLine !== "") { + merged.push(currentLine); + } + return merged; +} + +/** + * Decodes double-quoted strings with PyYAML-style hex escapes (\xHH) into Unicode. + */ +function parseDoubleQuotedString(s: string): string { + const converted = s.replace(/\\x([0-9a-fA-F]{2})/g, "\\u00$1"); + try { + return JSON.parse(converted); + } catch { + return s.slice(1, -1); + } +} + +/** + * Encodes non-ASCII characters inside double-quoted strings into PyYAML hex escapes. + */ +function escapeNonAsciiYAML(str: string): string { + let result = ""; + for (let i = 0; i < str.length; i++) { + const char = str[i]; + const code = char.charCodeAt(0); + if (code > 127) { + if (code <= 255) { + result += "\\x" + code.toString(16).toUpperCase(); + } else { + result += "\\u" + code.toString(16).padStart(4, "0").toUpperCase(); + } + } else { + if (char === '"' || char === "\\") { + result += "\\" + char; + } else { + result += char; + } + } + } + return `"${result}"`; +} + +/** + * Parse a multiline YAML block (or frontmatter) into a nested object structure. + */ +export function parseYAMLBlock(yamlLines: string[]): YAMLObject { const root: YAMLObject = {}; - // Stack contains objects and their indentation level const stack: { indent: number; obj: YAMLObject }[] = [{ indent: -1, obj: root }]; + const processedLines = preprocessYamlLines(yamlLines); - for (const rawLine of yamlLines) { + for (const rawLine of processedLines) { const trimmed = rawLine.trimEnd(); if (!trimmed || trimmed.trim().startsWith("#")) { - continue; // skip blank lines and comments + continue; } - // Calculate indentation level const indent = rawLine.length - rawLine.trimStart().length; - // Pop from stack until we find the parent (indentation < current indent) while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { stack.pop(); } const currentParent = stack[stack.length - 1].obj; - // Match the first colon as key-value separator, allowing dots/quotes/spaces in keys - const match = trimmed.trim().match(/^([^:]+):\s*(.*)$/); + const match = trimmed.trim().match(/^([^:]+):\s*(.*)$/s); if (!match) { continue; } @@ -48,16 +128,24 @@ export function parseFrontMatter(yamlLines: string[]): YAMLObject { (valueStr.startsWith('"') && valueStr.endsWith('"')) || (valueStr.startsWith("'") && valueStr.endsWith("'")) ) { - parsedVal = valueStr.slice(1, -1); + parsedVal = parseDoubleQuotedString(valueStr); } else if (valueStr.startsWith("[") && valueStr.endsWith("]")) { try { parsedVal = JSON.parse(valueStr); } catch { - // Flow array fallback: split by comma and clean quotes parsedVal = valueStr .slice(1, -1) .split(",") - .map((s) => s.trim().replace(/^['"]|['"]$/g, "")); + .map((s) => s.trim()) + .map((s) => { + if ( + (s.startsWith('"') && s.endsWith('"')) || + (s.startsWith("'") && s.endsWith("'")) + ) { + return parseDoubleQuotedString(s); + } + return s; + }); } } else { try { @@ -70,7 +158,6 @@ export function parseFrontMatter(yamlLines: string[]): YAMLObject { } } - // Return the nested `jupyter` namespace directly if present, otherwise the root object if (root.jupyter && typeof root.jupyter === "object") { return root.jupyter as YAMLObject; } @@ -90,7 +177,17 @@ export function stringifyYAML(obj: YAMLObject, depth = 0): string { continue; } if (Array.isArray(val)) { - const items = val.map((item) => JSON.stringify(item)).join(", "); + const items = val + .map((item) => { + if (typeof item === "string") { + if (/^[a-zA-Z0-9_-]+$/.test(item)) { + return item; + } + return escapeNonAsciiYAML(item); + } + return JSON.stringify(item); + }) + .join(", "); lines.push(`${indent}${key}: [${items}]`); } else if (val && typeof val === "object") { lines.push(`${indent}${key}:`); @@ -99,19 +196,68 @@ export function stringifyYAML(obj: YAMLObject, depth = 0): string { lines.push(nested); } } else if (typeof val === "string") { - // If it contains YAML special characters, or looks like a number/bool/null, serialise as JSON string (quoted) const isNumeric = !isNaN(Number(val)) && !isNaN(parseFloat(val)); const isBoolOrNull = val === "true" || val === "false" || val === "null"; - if (/[:#[\]{}|>&*?]/g.test(val) || val.trim() !== val || isNumeric || isBoolOrNull) { + // eslint-disable-next-line no-control-regex + const hasNonAscii = /[^\x00-\x7F]/.test(val); + if (hasNonAscii) { + lines.push(`${indent}${key}: ${escapeNonAsciiYAML(val)}`); + } else if (/[:#[\]{}|>&*?]/g.test(val) || val.trim() !== val || isNumeric || isBoolOrNull) { lines.push(`${indent}${key}: ${JSON.stringify(val)}`); } else { lines.push(`${indent}${key}: ${val}`); } } else { - // Boolean, number lines.push(`${indent}${key}: ${val}`); } } return lines.join("\n"); } + +/** + * Format JSON objects with spaces after colons and commas to match Python's formatting. + */ +export function pythonStyleJSON(val: unknown): string { + if (val === null) { + return "null"; + } + if (typeof val === "string") { + return JSON.stringify(val); + } + if (typeof val === "number" || typeof val === "boolean") { + return String(val); + } + if (Array.isArray(val)) { + return "[" + val.map(pythonStyleJSON).join(", ") + "]"; + } + if (typeof val === "object") { + const entries = Object.entries(val as Record) + .map(([k, v]) => `${JSON.stringify(k)}: ${pythonStyleJSON(v)}`) + .join(", "); + return "{" + entries + "}"; + } + return ""; +} + +/** + * Filter out transient cell metadata keys + */ +export function cleanCellMetadata(meta: Record): Record { + const clean = { ...meta }; + const keysToFilter = [ + "trusted", + "collapsed", + "scrolled", + "autoscroll", + "ExecuteTime", + "execution", + "heading_collapsed", + "jp-MarkdownHeadingCollapsed", + "user_expressions", + ]; + for (const key of keysToFilter) { + delete clean[key]; + } + return clean; +} diff --git a/test/roundtrip.test.ts b/test/roundtrip.test.ts new file mode 100644 index 0000000..a5060db --- /dev/null +++ b/test/roundtrip.test.ts @@ -0,0 +1,120 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { parseMystMd } from "../src/parseMystMd.js"; +import { toMystMd } from "../src/toMystMd.js"; +import { parsePy } from "../src/parsePy.js"; +import { toPy } from "../src/toPy.js"; +import { parseClassicMd } from "../src/parseClassicMd.js"; +import { toClassicMd } from "../src/toClassicMd.js"; +import { parseSphinxGallery } from "../src/parseSphinxGallery.js"; +import { toSphinxGallery } from "../src/toSphinxGallery.js"; +import { makeNotebook, codeCell, markdownCell } from "../src/notebook.js"; + +const VARIABLES_MYST_SAMPLE = `--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 +kernelspec: + display_name: C++17 + language: C++17 + name: xcpp17 +learning: + objectives: + apply: [variable, "d\\xE9claration de variable", affectation] + prerequisites: + apply: [valeur, "op\\xE9ration", expression, "expression bool\\xE9enne", type, entier, + "r\\xE9el", "caract\\xE8re", "bool\\xE9en"] +--- + ++++ {"nbgrader": {"grade": false, "grade_id": "cell-b78562ee2ff6d72c", "locked": true, "schema_version": 3, "solution": false}} + +# TP : variables et affectations + +Dans la feuille précédente, nous avons effectué des calculs et observé +les résultats (type, valeur). Pour écrire des programmes, nous aurons +besoin de **stocker les résultats intermédiaires dans des variables** +pour en réutiliser les valeurs. + +## Exercice 1 + +- Exécutez la cellule suivante : + +\`\`\`{code-cell} +int a; +a = 3; +\`\`\` + ++++ {"nbgrader": {"grade": false, "grade_id": "cell-77b23801c7675aeb", "locked": true, "schema_version": 3, "solution": false}} + +Une fois que la variable \`a\` a été ***déclarée*** (\`int a;\`) et qu'on +lui a ***affecté*** une valeur (\`a = 3\`), on peut afficher ou +réutiliser cette valeur : + +\`\`\`{code-cell} +--- +nbgrader: + grade: false + grade_id: cell-cb019a2142f5c180 + locked: true + schema_version: 3 + solution: false + task: false +--- +a +\`\`\` + +\`\`\`{code-cell} +:locked: false + +a + 1 +\`\`\` +`; + +describe("Roundtrip and Idempotency Tests", () => { + test("02-variables.md is fully idempotent (MyST roundtrip)", () => { + const nb = parseMystMd(VARIABLES_MYST_SAMPLE); + const firstWrite = toMystMd(nb); + const nb2 = parseMystMd(firstWrite); + const secondWrite = toMystMd(nb2); + assert.equal(secondWrite, firstWrite); + }); + + test("Percent format roundtrip (with nested cell metadata)", () => { + const nb = makeNotebook([ + codeCell("a = 1", { + nbgrader: { + grade: false, + grade_id: "cell-cb019a2142f5c180", + locked: true, + } + }) + ]); + const serialized = toPy(nb); + const nb2 = parsePy(serialized); + assert.deepEqual(nb2.cells[0].metadata.nbgrader, nb.cells[0].metadata.nbgrader); + }); + + test("Classic MD roundtrip", () => { + const nb = makeNotebook([ + markdownCell("# Heading\n\nProse text."), + codeCell("print('hello')") + ]); + const serialized = toClassicMd(nb); + const nb2 = parseClassicMd(serialized); + assert.equal(nb2.cells.length, 2); + assert.equal(nb2.cells[0].cell_type, "markdown"); + assert.equal(nb2.cells[1].cell_type, "code"); + }); + + test("Sphinx Gallery roundtrip", () => { + const nb = makeNotebook([ + markdownCell("Docstring.\n\nDescription."), + codeCell("x = 1") + ]); + const serialized = toSphinxGallery(nb); + const nb2 = parseSphinxGallery(serialized); + assert.equal(nb2.cells.length, 2); + }); +}); diff --git a/test/serialize.test.ts b/test/serialize.test.ts index f910fe1..92523b7 100644 --- a/test/serialize.test.ts +++ b/test/serialize.test.ts @@ -56,7 +56,7 @@ describe("toMystMd", () => { test("markdown cell with metadata → +++ {json}", () => { const nb = makeNotebook([markdownCell("Content.", { tags: ["foo"] })]); const out = toMystMd(nb); - assert.ok(out.includes('+++ {"tags":["foo"]}')); + assert.ok(out.includes('+++ {"tags": ["foo"]}')); }); test("notebook metadata → YAML front matter", () => { @@ -138,6 +138,43 @@ describe("toMystMd", () => { assert.deepEqual(nb2.metadata.language_info, language_info); }); + test("round-trip: complex nested cell metadata in YAML block", () => { + const nb = makeNotebook([ + codeCell("a", { + nbgrader: { + grade: false, + grade_id: "cell-cb019a2142f5c180", + locked: true, + schema_version: 3, + solution: false, + task: false + } + }) + ]); + const serialized = toMystMd(nb); + assert.ok(serialized.includes("---")); + assert.ok(serialized.includes("nbgrader:")); + const nb2 = parseMystMd(serialized); + assert.deepEqual(nb2.cells[0].metadata.nbgrader, nb.cells[0].metadata.nbgrader); + }); + + test("cleanCellMetadata strips transient keys but preserves nbgrader and locked in toMystMd", () => { + const nb = makeNotebook([ + codeCell("a", { + trusted: true, + collapsed: false, + locked: true, + nbgrader: { grade: false } + }) + ]); + const serialized = toMystMd(nb); + assert.ok(!serialized.includes("trusted"), "trusted must be stripped"); + assert.ok(!serialized.includes("collapsed"), "collapsed must be stripped"); + assert.ok(serialized.includes("locked"), "locked must be preserved"); + assert.ok(serialized.includes("nbgrader"), "nbgrader must be preserved"); + }); + + test("empty notebook → just a newline", () => { const nb = makeNotebook([]); assert.equal(toMystMd(nb), "\n"); @@ -255,6 +292,44 @@ describe("toPy", () => { assert.equal(nb2.cells[2].cell_type, "markdown"); assert.equal(nb2.cells[3].cell_type, "code"); }); + + test("round-trip: complex nested cell metadata in Percent format", () => { + const nb = makeNotebook([ + codeCell("a", { + nbgrader: { + grade: false, + grade_id: "cell-cb019a2142f5c180", + locked: true, + } + }), + codeCell("b", { + tags: ["hide-input"], + name: "cell-name" + }) + ]); + const serialized = toPy(nb); + const nb2 = parsePy(serialized); + assert.deepEqual(nb2.cells[0].metadata.nbgrader, nb.cells[0].metadata.nbgrader); + assert.deepEqual(nb2.cells[1].metadata.tags, ["hide-input"]); + assert.deepEqual(nb2.cells[1].metadata.name, "cell-name"); + }); + + test("cleanCellMetadata strips transient keys but preserves nbgrader and tags in toPy", () => { + const nb = makeNotebook([ + codeCell("a", { + trusted: true, + collapsed: false, + tags: ["tag1"], + nbgrader: { grade: false } + }) + ]); + const serialized = toPy(nb); + assert.ok(!serialized.includes("trusted"), "trusted must be stripped"); + assert.ok(!serialized.includes("collapsed"), "collapsed must be stripped"); + assert.ok(serialized.includes("tags"), "tags must be preserved"); + assert.ok(serialized.includes("nbgrader"), "nbgrader must be preserved"); + }); + }); // ---------------------------------------------------------------------------