From 51bcb5435ea616fcce13972b0932b5c441191dd3 Mon Sep 17 00:00:00 2001 From: kp992 Date: Sun, 13 Jul 2025 18:49:27 -0700 Subject: [PATCH 1/4] Add exercise and solution node in md format --- packages/myst-to-md/src/directives.ts | 2 ++ packages/myst-to-md/src/index.ts | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/packages/myst-to-md/src/directives.ts b/packages/myst-to-md/src/directives.ts index 93558289b..de75e6a87 100644 --- a/packages/myst-to-md/src/directives.ts +++ b/packages/myst-to-md/src/directives.ts @@ -408,6 +408,8 @@ export const directiveHandlers: Record = { include: writeStaticDirective('include', { argsKey: 'file' }), mermaid: writeStaticDirective('mermaid'), mystDirective, + exercise: writeStaticDirective('exercise'), + solution: writeStaticDirective('solution'), }; export const directiveValidators: Record = { diff --git a/packages/myst-to-md/src/index.ts b/packages/myst-to-md/src/index.ts index 97e785259..93655f466 100644 --- a/packages/myst-to-md/src/index.ts +++ b/packages/myst-to-md/src/index.ts @@ -6,6 +6,7 @@ import { gfmTableToMarkdown } from 'mdast-util-gfm-table'; import type { Options } from 'mdast-util-to-markdown'; import { defaultHandlers, toMarkdown } from 'mdast-util-to-markdown'; import { directiveHandlers, directiveValidators } from './directives.js'; +import { exerciseDirectives } from 'myst-ext-exercise'; import { miscHandlers, miscValidators } from './misc.js'; import { referenceHandlers } from './references.js'; import { roleHandlers } from './roles.js'; @@ -22,9 +23,15 @@ export function writeMd(file: VFile, node: Root, frontmatter?: PageFrontmatter) ...referenceHandlers, ...miscHandlers, }; + const exerciseDirectivesNames = exerciseDirectives + .flatMap(({ name, alias }) => [ + ...(name && name.trim() !== '' ? [name] : []), + ...(alias?.filter(a => a && a.trim() !== '') || []) + ]); const handlerKeys = [ ...Object.keys(handlers), ...Object.keys(defaultHandlers), + ...exerciseDirectivesNames, ...FOOTNOTE_HANDLER_KEYS, ...TABLE_HANDLER_KEYS, ]; From 264f2970ef246dccca6dc4ee67da7b68b4cdba7e Mon Sep 17 00:00:00 2001 From: kp992 Date: Sun, 3 Aug 2025 10:47:57 -0700 Subject: [PATCH 2/4] Update exercise directive handlers --- packages/myst-to-md/src/directives.ts | 118 ++++++++++++++++++++------ packages/myst-to-md/src/index.ts | 9 +- 2 files changed, 97 insertions(+), 30 deletions(-) diff --git a/packages/myst-to-md/src/directives.ts b/packages/myst-to-md/src/directives.ts index de75e6a87..1f290acdb 100644 --- a/packages/myst-to-md/src/directives.ts +++ b/packages/myst-to-md/src/directives.ts @@ -82,6 +82,97 @@ function mystDirective(node: any, _: Parent, state: NestedState, info: Info): st return writeStaticDirective(node.name, { argsKey: 'args' })(node, _, state, info); } +/** + * Handler for any directive with children nodes + * + * This adds multiple backticks in cases where directives are nested + */ +function writeFlowDirective(name: string, args?: string, options?: DirectiveOptions) { + return (node: any, _: Parent, state: NestedState, info: Info): string => { + incrementNestedLevel('directive', state); + const optionsLines = optionsFromNode(node, options); + const content = state.containerFlow(node, info); + const nesting = popNestedLevel('directive', state); + const marker = directiveChar.repeat(nesting + 3); + if (optionsLines.length && content) optionsLines.push(''); + const directiveLines = [ + `${marker}{${name}}${args ? ' ' : ''}${args ? args : ''}`, + ...optionsLines, + ]; + if (content) directiveLines.push(content); + directiveLines.push(marker); + return directiveLines.join('\n'); + }; +} + +/** + * Handler for exercise nodes + */ +function exercise(node: any, _: Parent, state: NestedState, info: Info): string { + const admonitionTitle = select('admonitionTitle', node); + const args = admonitionTitle ? state.containerPhrasing(admonitionTitle as any, info) : ''; + + // Handle gate property for exercise-start/exercise-end + if (node.gate === 'start') { + const nodeCopy = { + ...node, + children: node.children.filter((n: GenericNode) => n.type !== 'admonitionTitle'), + }; + const options = { + keys: ['label', 'class', 'hidden', 'enumerated', 'nonumber'], + aliases: { + enumerated: 'enumerated', + nonumber: 'nonumber', + }, + }; + return writeFlowDirective('exercise-start', args, options)(nodeCopy, _, state, info); + } else if (node.gate === 'end') { + return '```{exercise-end}\n```'; + } + + const nodeCopy = { + ...node, + children: node.children.filter((n: GenericNode) => n.type !== 'admonitionTitle'), + }; + const options = { + keys: ['label', 'class', 'hidden', 'enumerated', 'nonumber'], + aliases: { + enumerated: 'enumerated', + nonumber: 'nonumber', + }, + }; + return writeFlowDirective('exercise', args, options)(nodeCopy, _, state, info); +} + +/** + * Handler for solution nodes + */ +function solution(node: any, _: Parent, state: NestedState, info: Info): string { + // Handle gate property for solution-start/solution-end + const args = ''; + if (node.gate === 'start') { + const nodeCopy = { + ...node, + children: node.children.filter((n: GenericNode) => n.type !== 'admonitionTitle'), + }; + const options = { + keys: ['class', 'hidden'], + }; + return writeFlowDirective('solution-start', args, options)(nodeCopy, _, state, info); + } else if (node.gate === 'end') { + return '```{solution-end}\n```'; + } + + const nodeCopy = { + ...node, + children: node.children.filter((n: GenericNode) => n.type !== 'admonitionTitle'), + }; + const options = { + keys: ['class', 'hidden'], + }; + return writeFlowDirective('solution', args, options)(nodeCopy, _, state, info); +} + const CODE_BLOCK_KEYS = [ 'class', 'emphasizeLines', @@ -142,29 +233,6 @@ function image(node: any, _: Parent, state: NestedState, info: Info): string { return writeStaticDirective('image', options)(node, _, state, info); } -/** - * Handler for any directive with children nodes - * - * This adds multiple backticks in cases where directives are nested - */ -function writeFlowDirective(name: string, args?: string, options?: DirectiveOptions) { - return (node: any, _: Parent, state: NestedState, info: Info): string => { - incrementNestedLevel('directive', state); - const optionsLines = optionsFromNode(node, options); - const content = state.containerFlow(node, info); - const nesting = popNestedLevel('directive', state); - const marker = directiveChar.repeat(nesting + 3); - if (optionsLines.length && content) optionsLines.push(''); - const directiveLines = [ - `${marker}{${name}}${args ? ' ' : ''}${args ? args : ''}`, - ...optionsLines, - ]; - if (content) directiveLines.push(content); - directiveLines.push(marker); - return directiveLines.join('\n'); - }; -} - function containerValidator(node: any, file: VFile) { const { kind } = node; if (kind === 'figure' && !select('image', node)) { @@ -397,6 +465,8 @@ export const directiveHandlers: Record = { iframe, aside, card, + exercise, + solution, grid: writeFlowDirective('grid', undefined, { keys: ['columns'], transforms: { columns: (val) => val.join(' ') }, @@ -408,8 +478,6 @@ export const directiveHandlers: Record = { include: writeStaticDirective('include', { argsKey: 'file' }), mermaid: writeStaticDirective('mermaid'), mystDirective, - exercise: writeStaticDirective('exercise'), - solution: writeStaticDirective('solution'), }; export const directiveValidators: Record = { diff --git a/packages/myst-to-md/src/index.ts b/packages/myst-to-md/src/index.ts index 93655f466..dffb1519a 100644 --- a/packages/myst-to-md/src/index.ts +++ b/packages/myst-to-md/src/index.ts @@ -23,11 +23,10 @@ export function writeMd(file: VFile, node: Root, frontmatter?: PageFrontmatter) ...referenceHandlers, ...miscHandlers, }; - const exerciseDirectivesNames = exerciseDirectives - .flatMap(({ name, alias }) => [ - ...(name && name.trim() !== '' ? [name] : []), - ...(alias?.filter(a => a && a.trim() !== '') || []) - ]); + const exerciseDirectivesNames = exerciseDirectives.flatMap(({ name, alias }) => [ + ...(name && name.trim() !== '' ? [name] : []), + ...(alias?.filter((a) => a && a.trim() !== '') || []), + ]); const handlerKeys = [ ...Object.keys(handlers), ...Object.keys(defaultHandlers), From 3dec8a4b9d11775bc52684bbf92e174eab1948a3 Mon Sep 17 00:00:00 2001 From: kp992 Date: Sun, 3 Aug 2025 10:48:01 -0700 Subject: [PATCH 3/4] add tests --- packages/myst-to-md/tests/exercises.yml | 160 ++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 packages/myst-to-md/tests/exercises.yml diff --git a/packages/myst-to-md/tests/exercises.yml b/packages/myst-to-md/tests/exercises.yml new file mode 100644 index 000000000..b8ca3f411 --- /dev/null +++ b/packages/myst-to-md/tests/exercises.yml @@ -0,0 +1,160 @@ +title: myst-to-md exercise directives +cases: + - title: exercise directive - basic + mdast: + type: root + children: + - type: exercise + label: ex-1 + identifier: ex-1 + enumerated: true + children: + - type: admonitionTitle + children: + - type: text + value: Plot a function + - type: paragraph + children: + - type: text + value: Plot the function f(x) = cos(x) + markdown: |- + :::{exercise} Plot a function + :label: ex-1 + :enumerated: + + Plot the function f(x) = cos(x) + ::: + - title: exercise directive - no title + mdast: + type: root + children: + - type: exercise + label: ex-2 + identifier: ex-2 + enumerated: true + children: + - type: paragraph + children: + - type: text + value: Plot the function f(x) = sin(x) + markdown: |- + :::{exercise} + :label: ex-2 + :enumerated: + + Plot the function f(x) = sin(x) + ::: + - title: exercise-start directive + mdast: + type: root + children: + - type: exercise + label: ex-3 + identifier: ex-3 + enumerated: true + gate: start + children: + - type: admonitionTitle + children: + - type: text + value: Complex Exercise + - type: paragraph + children: + - type: text + value: This is a complex exercise with multiple parts. + markdown: |- + :::{exercise-start} Complex Exercise + :label: ex-3 + :enumerated: + + This is a complex exercise with multiple parts. + ::: + - title: exercise-end directive + mdast: + type: root + children: + - type: exercise + gate: end + markdown: |- + ```{exercise-end} + ``` + - title: solution directive - basic + mdast: + type: root + children: + - type: solution + hidden: false + children: + - type: admonitionTitle + children: + - type: text + value: 'Solution to ' + - type: crossReference + label: ex-1 + identifier: ex-1 + - type: paragraph + children: + - type: text + value: Here is the solution + markdown: |- + :::{solution} + Here is the solution + ::: + - title: solution-start directive with class + mdast: + type: root + children: + - type: solution + gate: start + class: dropdown + children: + - type: admonitionTitle + children: + - type: text + value: 'Solution to ' + - type: crossReference + label: mpl_ex1 + identifier: mpl_ex1 + - type: paragraph + children: + - type: text + value: Here's one solution + markdown: |- + :::{solution-start} + :class: dropdown + + Here's one solution + ::: + - title: solution-end directive + mdast: + type: root + children: + - type: solution + gate: end + markdown: |- + ```{solution-end} + ``` + - title: exercise with options + mdast: + type: root + children: + - type: exercise + label: ex-4 + identifier: ex-4 + enumerated: false + nonumber: true + hidden: false + class: my-exercise + children: + - type: paragraph + children: + - type: text + value: Exercise content here + markdown: |- + :::{exercise} + :label: ex-4 + :class: my-exercise + :nonumber: + + Exercise content here + ::: From d9e208df54f9b9c59dad45e6cb5788a3b0167253 Mon Sep 17 00:00:00 2001 From: kp992 Date: Sun, 3 Aug 2025 11:24:07 -0700 Subject: [PATCH 4/4] update dependencies --- packages/myst-to-md/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/myst-to-md/package.json b/packages/myst-to-md/package.json index c63065445..a3745fd9f 100644 --- a/packages/myst-to-md/package.json +++ b/packages/myst-to-md/package.json @@ -44,6 +44,7 @@ "myst-frontmatter": "^1.7.6", "unist-util-select": "^4.0.3", "vfile": "^5.3.7", - "vfile-reporter": "^7.0.4" + "vfile-reporter": "^7.0.4", + "myst-ext-exercise": "^1.0.9" } }