Skip to content

CommonMark ipynb export + image attachment embedding#1

Closed
mmcky wants to merge 30 commits into
mainfrom
myst-to-ipynb
Closed

CommonMark ipynb export + image attachment embedding#1
mmcky wants to merge 30 commits into
mainfrom
myst-to-ipynb

Conversation

@mmcky
Copy link
Copy Markdown

@mmcky mmcky commented Feb 25, 2026

Summary

Adds myst build --ipynb support that produces notebooks with plain CommonMark markdown cells — compatible with vanilla Jupyter Notebook, JupyterLab (without jupyterlab-myst), and Google Colab. Also adds an option to embed images as base64 cell attachments for fully self-contained notebooks.


What's Changed

Bug fixes (built on top of upstream PR jupyter-book#1882)

  • Kernelspec from frontmatterfrontmatter parameter was accepted but never used; now populates metadata.kernelspec and metadata.language_info correctly
  • Language detection — no longer hardcoded to "python"; derives from frontmatter kernelspec
  • Log message — says "Exported IPYNB" instead of "Exported MD"
  • +++ markers — stripped globally from markdown cells (was only matching start of string)
  • Package homepage — corrected URL from myst-to-md to myst-to-ipynb

Additionally fixes two issues discovered during real-world validation against QuantEcon lectures:

  • Epigraph/pull-quote directives silently dropped during export (#7)
  • Cross-references with empty URLs in resolved nodes (#8)

CommonMark serialization mode

New markdown: commonmark option in export config triggers an AST pre-transform that converts MyST-specific nodes to CommonMark equivalents before writeMd serialization.

exports:
  - format: ipynb
    markdown: commonmark

18 directive/role mappings implemented:

MyST Node CommonMark Output
math block $$...$$
inlineMath $...$
admonition > **Title** ... blockquote
exercise **Exercise N** ...
solution **Solution** ... (or dropped via dropSolutions)
proof/theorem/lemma **Theorem N (Title)** ...
tabSet Bold tab titles + content
container (figure) ![alt](url) + italic caption
container (table) Bold caption + GFM table
card / grid / details / aside Appropriate blockquote/bold fallbacks
mystDirective / mystRole Unwrapped children
mystTarget / comment Dropped
code blocks Stripped MyST-specific attributes
Node identifier/label Stripped to prevent (id)= prefixes

Key design: uses { type: 'html' } AST nodes for math to prevent mdast-util-to-markdown from escaping LaTeX special characters.

Image attachment embedding

New images: attachment option embeds images as base64 cell attachments for self-contained notebooks.

exports:
  - format: ipynb
    markdown: commonmark
    images: attachment

Two-phase hybrid architecture:

  1. Phase 1 (myst-cli): collectImageData() walks AST image nodes, resolves filesystem paths, reads & base64-encodes into Record<url, ImageData>
  2. Phase 2 (myst-to-ipynb): embedImagesAsAttachments() post-serialization regex rewrites ![alt](url)![alt](attachment:name) with cell attachments field

Validated on 24 QuantEcon lectures: 50 images across 12 notebooks embedded, 0 external references remaining.

Epigraph / pull-quote serialization (fixes #7)

The myst-to-md container handler only handled figure, table, and code kinds — quote containers (produced by {epigraph} and {pull-quote} directives) were silently dropped.

Fix: Added kind === 'quote' branch that:

  • Finds the blockquote child and serializes it via the default blockquote handler
  • Appends optional caption as > — Attribution line

Cross-reference URL fallback (fixes #8)

When MyST resolves same-page cross-references (e.g., {ref}, {eq}), the addChildrenFromTargetNode transform sets html_id on the node but doesn't always propagate identifier or label. The myst-to-md serializer was only checking urlSource → #label → #identifier, resulting in empty [text]() links.

Fix: Extended the URL fallback chain to: urlSource → #label → #identifier → #html_id → url → ''

Note: A third case was identified where {ref} roles with multi-line bodies fail to parse entirely. This is an upstream parser limitation filed as jupyter-book/mystmd#2724.

Exercise / solution code cell lifting

Exercises and solutions can contain code-cell nodes that should become executable notebook cells. These were being serialized as markdown instead of being lifted to top-level code cells.

Fix: liftCodeCellsFromGatedNodes() pre-processes the AST to extract code cells from exercise/solution containers, splitting surrounding markdown content into separate cells. Handles both direct gated nodes and blocks wrapping gated nodes.

Documentation

  • New docs/creating-notebooks.md — comprehensive ipynb export guide
  • Updated docs/documents-exports.md — added ipynb to format table
  • Updated docs/frontmatter.md — added ipynb to format values
  • Updated docs/myst.yml — TOC entry for creating-notebooks
  • Updated packages/myst-to-ipynb/README.md — features and usage

Test suite — 154 tests passing

myst-to-ipynb (55 tests)

File Tests Coverage
basic.yml 13 Core: styles, headings, code, links, images, block markers
frontmatter.yml 4 Kernelspec: Python, Julia, Python3, R
commonmark.yml 18 All directive/role mappings, identifier stripping
attachments.yml 5 Integration: single/multi image, dedup, no-match, no-data
attachments.spec.ts 7 Unit: basename, embed/skip/dedup logic
run.spec.ts 8 Exercise/solution code cell lifting

myst-to-md (99 tests, 6 new)

File New Tests Coverage
directives.yml 3 Epigraph, epigraph with attribution, pull-quote
references.yml 3 URL fallback for remote refs, html_id heading, html_id equation

Files Changed (36 files, +3122 / -11)

New files

  • packages/myst-to-ipynb/src/commonmark.ts — AST pre-transform (~500 lines, 18 node types)
  • packages/myst-to-ipynb/src/attachments.ts — Post-serialization image embedding (~100 lines)
  • packages/myst-to-ipynb/src/types.ts — Shared ImageData interface
  • packages/myst-to-ipynb/tests/commonmark.yml — 18 CommonMark mode tests
  • packages/myst-to-ipynb/tests/attachments.yml — 5 attachment integration tests
  • packages/myst-to-ipynb/tests/attachments.spec.ts — 7 attachment unit tests
  • docs/creating-notebooks.md — New documentation page

Modified files

  • packages/myst-to-ipynb/src/index.tsIpynbOptions, attachment wiring, empty cell filter, exercise lifting
  • packages/myst-to-ipynb/tests/run.spec.ts — Test runner supporting frontmatter + options in YAML
  • packages/myst-to-ipynb/tests/basic.yml — Expanded to 13 tests
  • packages/myst-to-ipynb/tests/frontmatter.yml — 4 kernelspec tests
  • packages/myst-to-ipynb/package.json — Fixed homepage URL
  • packages/myst-to-ipynb/README.md — Updated documentation
  • packages/myst-cli/src/build/ipynb/index.tscollectImageData(), options passthrough, log fix
  • packages/myst-to-md/src/directives.ts — Epigraph/pull-quote container handler
  • packages/myst-to-md/src/references.ts — Cross-reference URL fallback chain with html_id
  • packages/myst-to-md/tests/directives.yml — 3 epigraph/pull-quote tests
  • packages/myst-to-md/tests/references.yml — 3 cross-reference fallback tests
  • docs/documents-exports.md — ipynb in format table
  • docs/frontmatter.md — ipynb in format values
  • docs/myst.yml — TOC entry

Tracking issue: QuantEcon/mystmd#2 (full PLAN)
Related: QuantEcon/meta#292 · jupyter-book/mystmd#1882
Real-world validation: QuantEcon/lecture-python-programming.myst#363 — 24 lectures, 0 MyST syntax leaks


@mmcky mmcky requested a review from Copilot February 25, 2026 04:28
@mmcky mmcky marked this pull request as ready for review February 25, 2026 04:33
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class .ipynb export support to the MyST toolchain, including CommonMark-friendly notebook markdown output and optional image embedding via Jupyter cell attachments.

Changes:

  • Introduces myst-to-ipynb package to serialize MyST mdast into Jupyter notebook JSON, with optional CommonMark transforms.
  • Adds image attachment embedding support (collect image bytes in myst-cli, rewrite image references + add attachments in myst-to-ipynb).
  • Wires ipynb into myst-frontmatter export types/validation, myst-cli build/export flows + --ipynb CLI option, and adds docs.

Reviewed changes

Copilot reviewed 32 out of 32 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/myst-to-ipynb/tsconfig.json Adds TS build configuration for the new package.
packages/myst-to-ipynb/package.json Defines new package metadata, deps, and build/test scripts.
packages/myst-to-ipynb/README.md Documents notebook export features and frontmatter usage.
packages/myst-to-ipynb/CHANGELOG.md Adds initial changelog stub for the package.
packages/myst-to-ipynb/.eslintrc.cjs Enables linting via shared config.
packages/myst-to-ipynb/src/index.ts Implements mdast → ipynb conversion, CommonMark option, and attachment embedding hook.
packages/myst-to-ipynb/src/commonmark.ts Adds AST transforms to convert MyST constructs into CommonMark-compatible mdast.
packages/myst-to-ipynb/src/attachments.ts Adds markdown post-processing to rewrite images into attachment: references + build attachments dict.
packages/myst-to-ipynb/src/types.ts Defines ImageData shape for attachment embedding.
packages/myst-to-ipynb/tests/run.spec.ts Snapshot-style YAML-driven test runner for mdast→ipynb serialization.
packages/myst-to-ipynb/tests/basic.yml Adds coverage for baseline markdown/cell behaviors.
packages/myst-to-ipynb/tests/commonmark.yml Adds coverage for CommonMark conversion behaviors.
packages/myst-to-ipynb/tests/frontmatter.yml Adds coverage for kernelspec/frontmatter→notebook metadata.
packages/myst-to-ipynb/tests/attachments.yml Adds end-to-end coverage for attachment embedding behavior in produced notebooks.
packages/myst-to-ipynb/tests/attachments.spec.ts Unit tests for embedImagesAsAttachments.
packages/myst-to-ipynb/tests/example.ipynb Adds an example notebook fixture.
packages/myst-frontmatter/src/exports/types.ts Adds ipynb to ExportFormats.
packages/myst-frontmatter/src/exports/validators.ts Recognizes .ipynb extension as ipynb export format.
packages/myst-cli/package.json Adds dependency on myst-to-ipynb.
packages/myst-cli/src/cli/options.ts Adds --ipynb CLI option helper.
packages/myst-cli/src/cli/build.ts Exposes --ipynb on myst build.
packages/myst-cli/src/build/build.ts Includes ipynb in format selection logic.
packages/myst-cli/src/build/build.spec.ts Updates format-selection tests to include ipynb.
packages/myst-cli/src/build/utils/collectExportOptions.ts Allows .ipynb as a valid output extension.
packages/myst-cli/src/build/utils/localArticleExport.ts Routes ExportFormats.ipynb to the new export runner.
packages/myst-cli/src/build/ipynb/index.ts Implements runIpynbExport and image-data collection for attachments.
docs/myst.yml Adds notebooks page to docs navigation.
docs/frontmatter.md Documents ipynb as a supported export format.
docs/documents-exports.md Adds ipynb to export overview + CLI examples.
docs/creating-notebooks.md New documentation page for notebook exporting.
.changeset/witty-tigers-hunt.md Declares patch releases for affected packages.
.changeset/config.json Groups versioning for myst-to-ipynb alongside related packages.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/myst-cli/src/build/ipynb/index.ts Outdated
Comment thread packages/myst-cli/src/build/ipynb/index.ts
Comment thread packages/myst-to-ipynb/src/attachments.ts Outdated
Comment thread packages/myst-to-ipynb/src/attachments.ts Outdated
agoose77 and others added 21 commits February 27, 2026 14:36
- Use frontmatter kernelspec to populate notebook metadata (name,
  display_name, language) instead of ignoring the frontmatter parameter
- Derive language_info.name from frontmatter instead of hardcoding 'python'
- Strip leading +++ block markers from markdown cells (MyST-specific
  separators that have no meaning in notebooks)
- Fix log message from 'Exported MD' to 'Exported IPYNB'
- Fix package.json homepage URL to point to myst-to-ipynb (not myst-to-md)

Ref: QuantEcon/meta#292
Add AST pre-transform that converts MyST-specific nodes to CommonMark
equivalents before markdown serialization, producing notebooks compatible
with vanilla Jupyter Notebook and Google Colab.

New option: markdown: 'commonmark' (default: 'myst')

Transforms implemented:
- math block directive to $$ delimiters
- inline math role to $ delimiters
- admonition to blockquote with bold title
- exercise to bold header with content
- solution to bold header with content (or dropped via option)
- proof/theorem/lemma to bold header with content
- tab-set to bold tab titles with tab content
- figure to image + italic caption
- table container to bold caption + table
- card/grid to unwrapped content
- details to blockquote with summary title
- aside/sidebar to blockquote
- mystDirective/mystRole to unwrapped content or plain text

Uses html-type AST nodes for math content to prevent the markdown
serializer from escaping LaTeX special characters (underscores, etc).

CLI wiring: reads 'markdown: commonmark' from export config in myst.yml.

Ref: QuantEcon/meta#292
- Rewrite basic.yml with 13 proper YAML-object test cases (was 2 active)
- Add frontmatter.yml with 4 kernelspec/metadata test cases
- Add commonmark.yml with 13 CommonMark-mode test cases covering:
  inline math, math blocks, admonitions, exercises, theorems,
  tabSets, solutions (kept/dropped), underscore preservation
- Update run.spec.ts to support frontmatter and options fields in
  YAML test cases, enabling CommonMark and metadata tests

Ref: QuantEcon/meta#292
…er empty cells

Real-world validation with QuantEcon lecture content revealed:
- myst-to-md labelWrapper was adding (identifier)= prefixes to headings,
  paragraphs, blockquotes, and lists with identifier/label properties
- mystTarget nodes need to be dropped in CommonMark mode
- comment nodes (% syntax) need to be dropped in CommonMark mode
- code blocks with extra MyST attributes rendered as code-block directives
- +++ block markers appearing mid-cell (not just leading)
- Empty markdown cells from dropped nodes should be filtered out

Changes:
- commonmark.ts: strip identifier/label from all transformed children,
  add mystTarget and comment handlers, add code handler
- index.ts: filter empty markdown cells, fix stripBlockMarkers /gm regex
- commonmark.yml: add 5 new tests, update solution-dropped test
Standalone {image} directives with class/width/align properties were
being serialized as ```{image} directives by myst-to-md. Added
transformImage handler that strips directive-specific properties so
they render as plain ![alt](url) markdown syntax.

Found during full-project validation against lecture-python-programming.myst
(24 lectures, all clean after this fix).
Add 'images: attachment' option that embeds local images as base64
cell attachments in exported notebooks, producing self-contained
.ipynb files that don't depend on external image files.

Architecture (two-phase hybrid):
- Phase 1 (myst-cli): collectImageData() walks AST image nodes,
  resolves filesystem paths, reads files, and base64-encodes them
- Phase 2 (myst-to-ipynb): embedImagesAsAttachments() rewrites
  serialized markdown image refs to attachment: references

Usage in frontmatter:
  exports:
    - format: ipynb
      images: attachment

New files:
- packages/myst-to-ipynb/src/attachments.ts
- packages/myst-to-ipynb/tests/attachments.spec.ts (7 tests)
- packages/myst-to-ipynb/tests/attachments.yml (5 tests)

47/47 tests passing.
- Fix prettier formatting in commonmark.ts, index.ts, and myst-cli ipynb/index.ts
- Add docs/creating-notebooks.md with full ipynb export documentation
  (CommonMark markdown, image attachments, export options table)
- Add ipynb to export format table in docs/documents-exports.md
- Add --ipynb CLI example to docs/documents-exports.md
- Add ipynb to format list in docs/frontmatter.md
- Add creating-notebooks.md to docs/myst.yml TOC
- Update packages/myst-to-ipynb/README.md with features and usage
Move ImageData interface to shared types.ts so attachments.ts and
index.ts no longer import from each other. Fixes madge lint:circular
check.
Address Copilot review comments:
- Strip leading '/' from image URLs before path.join in collectImageData()
  so project-root URLs like '/_static/img/foo.png' resolve correctly.
- Fix misleading 'reverse order' comment in embedImagesAsAttachments().
Include nodes that have been resolved by includeDirectiveTransform
retain type 'include', causing myst-to-md to serialize them back as
```{include} directive syntax. Add an 'include' case to
transformNode() that unwraps resolved children into the parent,
so the included content (e.g. admonitions) is emitted as plain
CommonMark in notebook cells.
…b export

When gated syntax ({exercise-start}/{exercise-end}, {solution-start}/
{solution-end}) is used, joinGatesTransform nests all content between
the gates, including {code-cell} blocks, as children of the
exercise/solution node. During ipynb export these were absorbed into a
single markdown cell, silently dropping executable code cells.

Add liftCodeCellsFromGatedNodes() preprocessing step in writeIpynb that
detects exercise/solution nodes containing code-cell blocks and splits
them into alternating top-level markdown and code cells, preserving
document order. When dropSolutions is true, solution nodes are left
intact for transformToCommonMark to drop entirely.

Also fix stripBlockMarkers regex to handle +++ at end-of-string without
trailing newline, preventing empty markdown cells.

Closes #5
The previous fix (05bdc24) assumed exercise/solution nodes would be the
sole child of a block. In reality, blockNestingTransform groups all
consecutive non-block siblings into a single wrapper block, so the AST is:

  root > block { para, exercise {...}, solution {..., block{code}}, para }

The fix now scans inside each block's children for exercise/solution
nodes containing code-cell blocks, and splits the block accordingly.
Extracted helper functions for clarity:

- isGatedNodeWithCodeCells: identifies target nodes
- liftFromExerciseSolution: splits a single node's children
- splitBlockWithGatedNodes: processes a block with mixed children

Added tests for the shared-block structure (exercise + solution + other
content in the same block) and for dropSolutions with shared blocks.

Refs #5, #6
…k/ipynb export

The container handler in myst-to-md only handled figure, table, and code
kinds. Containers with kind 'quote' (produced by the epigraph, pull-quote,
and blockquote directives) fell through and returned empty string, silently
dropping all content during ipynb export.

Add a 'quote' branch that serializes the blockquote child as a standard
markdown blockquote, with optional attribution rendered as an em-dash line.

Closes #7
…back

Add MYST_DEBUG_XREF env var to dump full AST node details when a
crossReference resolves with an empty URL during CommonMark serialization.
This helps diagnose #8 where some {ref} roles produce
[text]() links in ipynb export.

Also add a defensive fallback to use node.url (set by
MultiPageReferenceResolver for cross-page refs) when urlSource, label,
and identifier are all missing. This prevents empty URLs for resolved
remote references without changing behaviour for any existing case.
…port

The reference resolver (addChildrenFromTargetNode) marks crossReferences as
resolved and sets html_id + kind, but for same-page targets the identifier
and label fields end up undefined. The CommonMark serializer then generates
empty URLs like [Section 7]().

Add html_id to the URL fallback chain:
  urlSource → #label → #identifier → #html_id → url → ''

This fixes all 23 unique empty-URL crossReferences found in the QuantEcon
lectures (headings, equations, exercises, code blocks, paragraphs).

Closes #8
The ad-hoc debug logging served its purpose for diagnosing the html_id
fallback issue and is no longer needed. A system-wide debug infrastructure
should be designed separately.
- Update regex to handle escaped brackets in alt text and escaped
  parentheses in URLs produced by mdast-util-to-markdown
- Unescape URLs before looking up in imageData dictionary
- Refactor to single-pass replacement using md.replace(regex, callback)
- Add tests for escaped parentheses in URLs and escaped brackets in alt text
@mmcky mmcky closed this May 7, 2026
@mmcky mmcky deleted the myst-to-ipynb branch May 7, 2026 00:21
mmcky added a commit that referenced this pull request May 14, 2026
Three substantive findings on the PR, plus one type-import lint
fixup discovered along the way.

**#1 — File-target labels for nested pages (enumerate.ts:633)**

The file-target branch of `resolveReferenceContent` unconditionally
read `numbering.heading_1` when applying the chapter/appendix label.
A nested TOC page (offset > 0) has its title enumerator generated at
`heading_${offset + 1}`, so reading heading_1 produced
"Chapter 1.1" where the page is actually a heading_2 subsection.
Use the page's offset to pick the right depth. Adds a regression
test using `MultiPageReferenceResolver` with an offset=1 file
target.

**#2 — `numbering.chapters` / `numbering.appendices` were inert**

PR #1 accepted these schema keys but `injectBookSectionDefaults`
never read them — so setting `numbering.chapters.label: "Module %s"`
or `numbering.appendices.format: roman` had no effect. The keys
were live config that did nothing.

Now the injection merges the section's block into `heading_1`
between page frontmatter and the hardcoded defaults. Precedence:
  page heading_1  >  numbering.<section>  >  hardcoded fallback
All via `??=` so explicit author values always win. Adds three spec
cases pinning the precedence.

**#3 — section-tagged `FileEntry` should still bump children's level**

The same-level recursion fired for any entry with `section:`,
including a `FileEntry` like `{ file: ch1.md, section: chapters,
children: [...] }`. Only section-only `ParentEntry` groups are
meant to be logical wrappers; a section-tagged file is still a
structural parent and its sub-pages should land at the next level.

Narrowed the condition with an `isFile` check.

**Drive-by — `Math` type import shadowed the JS global**

CI surfaced `consistent-type-imports: Type import "Math" is used by
decorator metadata`. The type-only import of `Math` from
myst-spec-ext shadowed `Math.floor` (used in `formatCounter`).
Renamed to `MathNode` and updated the one type reference.

All four touched packages green: myst-frontmatter 499/499,
myst-transforms 346/346 (+1 nested-file-target), myst-toc 34/34,
myst-cli 276/276 (+3 chapters/appendices precedence cases).
mmcky added a commit that referenced this pull request May 14, 2026
…22)

* Add schema for book-style numbering (PR #1, §3.5(1))

Extends `NumberingItem` with `format`, `label`, and `reset_on_part`,
and adds `parts`, `chapters`, `appendices` as well-known kinds on
`Numbering`. Introduces the `book` opt-in flag (typed as a
`NumberingItem` for index-signature compatibility; consumers check
`numbering.book?.enabled === true`).

The new fields are purely additive at this commit — no consumer wires
them up yet. Validators normalize and golden-test all the new shapes.

* Add formatCounter for arabic/alph/Alph/roman/Roman (PR #1, §3.5(2))

Pure helper that renders an integer counter under any of the five
formats from §3.2(b). Wired into:

- formatHeadingEnumerator: new optional per-depth `formats` array,
  so the chapter/appendix prefix of a sub-heading (e.g. "A.1",
  "III.2.1") renders correctly. Omitted formats default to arabic
  so today's behaviour is preserved.
- ReferenceState.incrementCount: per-kind `format` on figure /
  equation / table / etc. is applied when stringifying the main
  counter (and the subcontainer parent enumerator).

Render-only — `format` does not touch counter state (§3.4(9)).

* Render heading cross-refs via label / fall back to title (PR #1, §3.5(5))

Implements §3.2(h)'s heading cross-ref policy: when [](#target)
resolves to a heading-type target and link text is omitted, prefer
`numbering.heading_N.label` over `template`, with the heading text as
the unnumbered fallback.

- §3.2(h) label precedence: `label` wins over `template` for heading
  cross-refs, so `[](#ch1)` renders "Chapter 1" rather than
  "Section 1" once a project sets `numbering.heading_1.label:
  "Chapter %s"`. `template` continues to drive cross-refs when no
  label is set, so existing projects see no change.
- #12 fix: a heading whose numbering is nominally enabled but which
  never received an enumerator (e.g. on a page with page-level
  `numbering: false`, or anything that would land in front/back
  matter once book mode is wired) now falls through to the heading
  text instead of substituting `%s` against UNKNOWN_REFERENCE_ENUMERATOR
  and rendering "Chapter ??".

Scope-limited to heading-type targets. Figures, equations, tables,
and other kinds keep today's labelling — extending the policy to
non-heading kinds is PR #2 (§6).

* Apply label rendering to file-target cross-refs (PR #1, §3.5(5))

The cross-ref to a page's H1 (e.g. \`[](#ch1)\` where \`ch1\` is the
file slug) resolves through the file-target path, not the
heading-target path. Without this change, that path always rendered
the page title — so a book with \`numbering.heading_1.label: 'Chapter
%s'\` still showed "Introduction" instead of "Chapter 1" for
\`[](#ch1)\`. Authors had to invent extra anchors to get label
rendering.

Extend \`ReferenceState.resolveReferenceContent\` so the file-target
branch applies the same \`label > template > title\` policy as
inline headings, keyed off the page's \`heading_1\` numbering item
and the page-level \`enumerator\` that the constructor already
computes. Title remains the fallback when no label/template is set
or the page is unnumbered (#12 case).

* Inject per-section numbering defaults for tagged TOC subtrees (PR #1, §3.5(3))

When a ParentEntry in the TOC carries `section: chapters | appendices
| frontmatter | backmatter` (new field on myst-toc CommonEntry), each
descendant page inherits that section. With project
`numbering.book: true`, the section drives sensible heading_1
defaults: arabic + "Chapter %s" for chapters, Alph + "Appendix %s"
for appendices, and `enabled: false` (skip-semantic) for front/back
matter. The first page of each section gets `start: 1` so the first
appendix renders "A" rather than continuing the chapter sequence.

This is a deviation from PLAN.md §3.2(a)'s literal YAML, which puts
named sections as top-level keys under `toc: { format: jb-book }`.
That form is the legacy Sphinx `_toc.yml` surface (used only by
`myst upgrade`); MyST's `myst.yml toc:` is `MySTEntry[]`. Adding the
named keys at the myst.yml level needs a format-discriminated union
on the toc validator, which is invasive enough that PR #1 ships the
section-tagged primitive instead. A later PR can layer the named-key
form on top.

Authors now write:

  numbering:
    book: true
  toc:
    - file: index
    - title: Appendices
      section: appendices
      children:
        - file: app-a

The section subtree is logical, not structural — no folder is
emitted, no level bump — so app-a's H1 stays at heading_1 instead of
becoming heading_2.

* Auto-prefix figures/equations/tables/proofs with chapter enumerator (PR #1, §3.5(4))

When `numbering.book.enabled` is true and the page itself is
numbered (heading_1 ticks, so `state.enumerator` is set), every
auto-prefixed kind picks up that enumerator as a leading prefix:

  ch1.md (heading_1 → "1"):    Figure 1.1, 1.2; (1.1), (1.2); Table 1.1
  app-a.md (heading_1 → "A"):  Figure A.1, A.2; Theorem A.1; Exercise A.2

Each kind keeps its own per-page counter (today's reset-per-page
behaviour), so figures/equations restart at the chapter/appendix
boundary naturally. Pages without an enumerator (front-/backmatter,
explicit `numbering: false`) get the flat global counter — no prefix.

Authors opt out per-kind via the existing `continue: true` field
(§3.4(6)): `numbering.figure.continue: true` keeps the figure
counter flat across the whole book and drops the prefix.

Auto-prefix kinds: figure, subfigure, equation, subequation, table,
exercise, plus all `proof:*` / `prf:*` (theorem, lemma, proposition,
…). The matcher uses a prefix test on `proof:` so new proof-family
kinds added upstream are picked up automatically.

* Test proof:* / exercise auto-prefix and render-only format override

Closes the remaining test gaps in PR #1:

- **§3.5(6) integration**: confirm a `proof` node with `kind:
  theorem` (which renders as kind "proof:theorem") and an
  `exercise` both pick up the chapter prefix in book mode. Asserts
  each proof-family kind keeps its own counter and only the chapter
  enumerator is shared — what the matcher claims, now exercised.

- **§3.4(9) regression**: a chapter page sets
  `heading_1.format: Roman` in its frontmatter. The page renders
  "II" but the underlying counter stays 2, so the next chapter
  (without the override) continues at "3" rather than restarting or
  re-formatting. Uses the `previousCounts` chain across three
  ReferenceState instances to model the multi-page flow.

* Apply prettier formatting to enumerate.ts and enumerate.spec.ts

CI `lint:format` job flagged these two files. Pure prettier --write
output, no semantic change. Tests stay green (345/345).

* Fix ESLint errors flagged by CI

Three errors surfaced by the `lint` workflow's `eslint` job (the
`lint:format` job, which is prettier-based, went green after
db7ddea):

- mdast.ts: inline `import('myst-toc').BookSection` annotations
  violate `@typescript-eslint/consistent-type-imports`. Hoist to a
  top-level `import type { BookSection } from 'myst-toc'` and use
  the bare name in the two annotations.
- site.ts: `sawAnyInSection` is only `.add()`-ed, never reassigned,
  so `prefer-const` flags it. Change `let` → `const`.

273/273 myst-cli tests still pass. Local `npm run lint` now reports
0 errors (12 pre-existing warnings unrelated to this PR).

* Address Copilot review feedback

Three substantive findings on the PR, plus one type-import lint
fixup discovered along the way.

**#1 — File-target labels for nested pages (enumerate.ts:633)**

The file-target branch of `resolveReferenceContent` unconditionally
read `numbering.heading_1` when applying the chapter/appendix label.
A nested TOC page (offset > 0) has its title enumerator generated at
`heading_${offset + 1}`, so reading heading_1 produced
"Chapter 1.1" where the page is actually a heading_2 subsection.
Use the page's offset to pick the right depth. Adds a regression
test using `MultiPageReferenceResolver` with an offset=1 file
target.

**#2 — `numbering.chapters` / `numbering.appendices` were inert**

PR #1 accepted these schema keys but `injectBookSectionDefaults`
never read them — so setting `numbering.chapters.label: "Module %s"`
or `numbering.appendices.format: roman` had no effect. The keys
were live config that did nothing.

Now the injection merges the section's block into `heading_1`
between page frontmatter and the hardcoded defaults. Precedence:
  page heading_1  >  numbering.<section>  >  hardcoded fallback
All via `??=` so explicit author values always win. Adds three spec
cases pinning the precedence.

**#3 — section-tagged `FileEntry` should still bump children's level**

The same-level recursion fired for any entry with `section:`,
including a `FileEntry` like `{ file: ch1.md, section: chapters,
children: [...] }`. Only section-only `ParentEntry` groups are
meant to be logical wrappers; a section-tagged file is still a
structural parent and its sub-pages should land at the next level.

Narrowed the condition with an `isFile` check.

**Drive-by — `Math` type import shadowed the JS global**

CI surfaced `consistent-type-imports: Type import "Math" is used by
decorator metadata`. The type-only import of `Math` from
myst-spec-ext shadowed `Math.floor` (used in `formatCounter`).
Renamed to `MathNode` and updated the one type reference.

All four touched packages green: myst-frontmatter 499/499,
myst-transforms 346/346 (+1 nested-file-target), myst-toc 34/34,
myst-cli 276/276 (+3 chapters/appendices precedence cases).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cross-references resolve with empty URLs in ipynb export Epigraph directive content silently dropped in ipynb export

4 participants