Heading attribute blocks: {#id .class .unnumbered} for per-heading unnumbered control - #89
Merged
Merged
Conversation
…ings Wires the existing inline-attribute tokenizer (upstream jupyter-book#1822, previously used only by roles and directives) to headings via a markdown-it core rule. `.unnumbered` / `{-}` / `enumerated=false` map to `enumerated: false`, which the enumerate transform already honors: the heading keeps its target and TOC entry, renders no number, and does not advance the counter — LaTeX `\section*` semantics. `#id` becomes the heading label/identifier (equivalent to a preceding `(id)=` target) and `.class` lands on the node's class. Conservative by construction: a trailing brace block parses as attributes only when its entire content is recognized vocabulary, so prose like `## The set {1, 2, 3}` and escaped `\{` stay literal text. Opt out with `extensions.headingAttributes: false`. Fixes #68. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds pandoc-compatible trailing heading attribute blocks (e.g. ## Title {#id .class .unnumbered}) to the MyST markdown-it tokenizer and wires the resulting metadata into myst-parser so headings can carry per-heading IDs/classes and opt out of numbering without advancing the counter.
Changes:
- Introduces a new
headingAttributesPlugininmarkdown-it-mystthat strips trailing{...}blocks from heading text and attacheslabel,class, andenumeratedmetadata ontoheading_opentokens. - Wires the plugin into
myst-parserbehind a newextensions.headingAttributesoption (defaulttrue) and maps token metadata into heading node fields. - Adds unit tests (both tokenizer- and parser-level), documentation, and a changeset for a minor release.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/myst-parser/tests/headingAttributes.spec.ts | Adds parser-level tests asserting heading attribute parsing and opt-out behavior. |
| packages/myst-parser/src/tokensToMyst.ts | Maps heading token meta (enumerated/label/class) into MDAST heading node attributes. |
| packages/myst-parser/src/plugins.ts | Re-exports the new headingAttributesPlugin from markdown-it-myst. |
| packages/myst-parser/src/myst.ts | Enables the extension by default and registers the tokenizer plugin when enabled. |
| packages/myst-parser/src/fromMarkdown.ts | Extends the public options type with headingAttributes?: boolean. |
| packages/markdown-it-myst/src/index.ts | Exports headingAttributesPlugin from the package entrypoint. |
| packages/markdown-it-myst/src/headingAttributes.ts | Implements heading attribute parsing and core-rule integration. |
| packages/markdown-it-myst/src/headingAttributes.spec.ts | Adds tokenizer-level tests for parsing and non-matching behavior. |
| docs/typography.md | Documents heading attribute blocks and supported vocabulary. |
| .changeset/heading-attributes.md | Declares minor bumps and describes the new feature and opt-out option. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
mmcky
added a commit
that referenced
this pull request
Aug 14, 2026
…ta (#91) * chore(quantecon): track PR #89 (heading-attributes) in fork trackers VERSION.yml gains feature 15 (merge_sha a40f0cb, tag null until the next qe-v cut); UPSTREAM-PRS.yml gains a standalone heading-attributes candidate — it completes upstream's inline-attribute mechanism (their jupyter-book#1822) and touches no fork-modified code paths, so the cherry-pick should be clean. Downstream adoption is tracked in QuantEcon/claude-latex-to-myst#160 and needs a qe-v10 tag first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(quantecon): cut qe-v10 metadata (heading-attributes) qe_version -> qe-v10; feature 15 (a40f0cb) tagged qe-v10. Tag to be cut on this PR's merge commit per the VERSION.yml procedure, so the tagged tree is self-consistent. qe-v10 = qe-v9 + heading attribute blocks + the qe-version.ts untracking housekeeping (#90, no feature row). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 14, 2026
mmcky
added a commit
to QuantEcon/claude-latex-to-myst
that referenced
this pull request
Aug 15, 2026
#209) * fix: emit `.unnumbered` so a starred \section* isn't numbered (#160A) `\section*` is unnumbered in LaTeX, and pandoc records that as a `.unnumbered` class on the heading. `convert_section_labels` read only the slug out of the attribute block and discarded the rest, so book-mode numbering numbered the heading anyway — and, worse, advanced the counter, renumbering its numbered siblings. dp2's `Summary` took §1.5 and pushed `Chapter Notes` to §1.6, against the PDF's §1.5. The class is now re-emitted as a bare `{.unnumbered}` block, which the renderer reads as `enumerated: false`: no number, no counter advance, target and TOC entry kept, so cross-references still resolve — rendering the section title, the only honest rendering for a section with no number. Only `.unnumbered` is re-emitted. The block is all-or-nothing at the renderer (one unrecognized token and it leaves literal braces in the title), so the emitted vocabulary is kept to what we rely on. The slug is not moved into the block either: the `(label)=` target line stays, since emitting both makes mystmd warn that one label replaced the other. The fix rides the #194 suppression branch, not the anchor branch — all 25 affected headings have a pandoc-derived slug, so re-emitting only where an anchor is promoted would have fixed none of them. Also hardens `add_frontmatter`'s `bare_h1` title-equality test to tolerate a trailing block. Unreachable today (a depth-1 anchor is never suppressed, so such a heading always arrives as a `(label)=` pair), but this change is what lets an H1 carry a block at all, and the mismatch would resurrect #3's duplicate-heading regression. Requires the `qe-v10` renderer floor (heading attribute blocks, from the ask in QuantEcon/mystmd#68 — shipped as QuantEcon/mystmd#89), so `MYSTMD_REF` and the floor statements in README, docs/getting-started.md and convert.sh move in the same commit. Unlike the `qe-v9` coupling from #186, which silently forfeits its fix on an older renderer, this one corrupts pages: without a heading-attribute parser the `{.unnumbered}` renders as literal text in the title and pollutes its slug. Effect, isolated against main: exactly 25 heading lines change across dp1/dp2/deep-learning (0/1/24), no other line touched. In the built Deep-Learning AST the headings carrying an enumerator drop 234 → 210 while the 24 newly-unnumbered ones carry none. All three render gates pass with zero new build warnings; 942 tests pass. Lesson 059; golden case `starred_section_unnumbered`. Closes #160 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: warn on a stale renderer, and narrow the bare_h1 brace guard Review follow-ups on the #160A change. **convert.sh now checks the renderer version, not just its presence.** The whole argument for shipping the `MYSTMD_REF` bump in the same commit is that this coupling is the unforgiving kind — a stale renderer corrupts headings rather than forfeiting a fix. But the only place that said so was the branch taken when `myst` is ABSENT, i.e. the one case where nothing is built and no damage is possible. Stage 7 now parses the `(qe-vN)` tag out of `myst --version` and warns when it is older than qe-v10, or when there is no fork tag at all (upstream mystmd). A warning, not a hard failure: this is a smoke stage, and a maintainer on an unreleased build from source shouldn't be blocked by it. **`bare_h1`'s optional block is matched literally.** The previous `\{[^}\n]*\}` accepted any brace group, and that branch *deletes* the heading on a match — so `# Preface {see note}` would have read as the configured title `Preface` and dropped a heading the author wrote. Only the exact `{.unnumbered}` we emit is absorbed now. **Lesson 059 named the wrong mechanism for the leading space.** It credited `convert_pandoc_spans`, whose patterns are literal on the class name and so can never match `{.unnumbered}`. The real reason is better: the renderer requires the `{` to be preceded by whitespace, so `## Title{.unnumbered}` is not an attribute block at all — the braces stay in the title *and* the heading is still numbered. Verified against a real build and pinned by a test. Also: the floor is now stated in the wrapper template copied into book repos (the file a consumer actually invokes), the golden-tier count in README is corrected to 57, and both `derived_heading_anchor_suppressed` (058, which #205 never registered) and `starred_section_unnumbered` (059) are added to LESSON_COVERAGE.md and the `test_golden_tex_seeded` guard. Output-neutral: all three fixture books remain byte-identical to their snapshots; 943 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: pin that an author's own brace group survives the bare_h1 guard Adversarial verification reproduced the shape through the real pipeline: \chapter{Preface \{see note\}} reaches add_frontmatter as '# Preface {see note}' — pandoc omits the {#id} block because its derived id is redundant, so nothing distinguishes it from a bare H1. Under the permissive guard that briefly shipped, the heading was deleted outright. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: correct what the qe-v10 heading-attribute parser actually rejects Lesson 059, the CHANGELOG entry and the transform docstring all justified "emit only .unnumbered" with a safety argument that is false: they claimed an unfamiliar class would make the parser abandon the block and leave literal braces in the title. Probed against the real renderer. An unknown class is ACCEPTED and carried as an inert attribute — '## H {.myclass}' yields class="myclass" and is still numbered, and '.unlisted' behaves the same. What the parser rejects is a token of an unrecognized KIND: '{not-a-class}' and '{foo=bar}' are left as literal title text. So the real argument is narrower and honest: nothing downstream gives another class meaning, and pandoc attaches nothing else here anyway (always exactly {#slug} or {#slug .unnumbered} across all three books). Assembling the block from a fixed vocabulary rather than passing pandoc's through is what keeps the genuine reject case unreachable. Docs only; no behaviour change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mmcky
added a commit
to mmcky/Deep_Learning_for_Solving_And_Estimating_Dynamic_Economic_Models
that referenced
this pull request
Aug 16, 2026
…commit (#22) * mystmd: R28 — bump converter to 182214f and CI to qe-v10 in the same commit Both pins move here, and this time the coupling is the unforgiving kind. Since QuantEcon/claude-latex-to-myst#209 the converter emits `{.unnumbered}` on starred sections, which needs the heading attribute blocks the QuantEcon fork shipped in QuantEcon/mystmd#89, released as qe-v10. R26's qe-v9 coupling failed silently on a stale renderer — it merely forfeited per-row equation numbering. This one corrupts pages: without the parser the block renders as literal braces in the heading title and pollutes its auto-slug. So converter and renderer move in one commit. This is the half of #19 that R27 deliberately left blocked, waiting on QuantEcon/claude-latex-to-myst#160. That has landed, so the round is now executable and #19 is done. `\section*` is unnumbered in LaTeX. Pandoc records that as a `.unnumbered` class, the converter now re-emits it as a bare `{.unnumbered}` block, and the renderer reads that as `enumerated: false` — no number, no counter advance, target and TOC entry kept. The output diff is 24 changed lines and nothing else: 12 `## Exercises`, 11 `## Further Reading`, 1 `### Validation Protocol`. That is exactly the source population — the .tex holds precisely 12, 11 and 1 of those starred commands and no other starred sectioning command — so coverage is complete and nothing was over-reached. The 4 `\chapter*` frontmatter pages stay absorbed into their page `title:`. Validation built the pre-bump markdown under qe-v10 first, so renderer effects are separated from converter effects. The renderer bump on its own produced zero new warnings and zero AST deltas, so everything below is attributable to the converter. The qe-v10 gate was checked against a real build rather than assumed: 0 headings carry a literal brace in the title and 0 slugs contain "unnumbered". The rendered markup is the proof — an unnumbered heading emits `<span class="heading-text">Exercises</span>` with no number span, while its numbered sibling still carries `<span class="mr-3">3.6</span>`. No counter drift, which was the real risk. The failure that upstream change corrected in dp2 was an unnumbered heading still advancing the counter and renumbering its siblings. That does not happen here: all 213 other headings are byte-identical, enumerator for enumerator. Eleven chapters put their starred sections last so nothing follows them, and ch03 — the one mid-chapter case, where Validation Protocol held 3.6.1 — is the sole subsection of its parent, so again nothing follows. Build stays 2 warnings / 0 errors, the by-design kept-`\paragraph` pair. Headings 237 to 237, enumerated 234 to 210, 24 now unnumbered. Byte-identical throughout: 813 cross-references with 0 unresolved, equation enumerators per chapter and entry for entry (so R26's 272/272 match with the printed PDF carries forward), 93 figures, 46 tables, 74 proofs, 140 lists. Deep links preserved — id="exercises" x12, id="further-reading" x11, id="validation-protocol" x1, each with its TOC anchor. Class L intact at 10 markers. 0 escaped roles, 0 marker leaks, 0 KaTeX errors, 0 TikZ churn, and a second convert run is idempotent. Also syncs the local wrapper with the upstream template's renderer-floor note. The tool's own `--build` stage now parses `myst --version` and warns below qe-v10, where previously it only complained when myst was missing entirely — the one case where nothing is built and no damage is possible. Noted upstream with no local action: QuantEcon/claude-latex-to-myst#210, where qe-v10 consumes a trailing brace group in a heading title when it parses as attributes. Latent here, 0 occurrences book-wide. Closes #19 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * mystmd: R28 review follow-ups — make the renderer check version-agnostic Two documentation fixes from Copilot's review of the R28 PR. The renderer check no longer pins a numeric version. The requirement is qe-v10 or later, so the thing to read is the parenthesised fork tag at the end of `myst --version`, not the `v1.10.1` in front of it — upstream npm mystmd prints no tag at all, and the numeric version will move. Both lines happen to read v1.10.1 today, which is now stated as an aside rather than as the test. The tool's own check was already version-agnostic (it extracts qe-vN and compares numerically); this only brings the prose in line. Also disambiguates the coupling sentence: "(which converter generates the markdown)" reads for a moment as if `.tool-version` did the generating. Now "(which selects the converter that generates the markdown)", keeping the parallel with the renderer clause. The same numeric-version wording in convert.sh is deliberately left alone — that comment block is byte-identical to the upstream template in QuantEcon/claude-latex-to-myst, and editing it here would show up as drift on the next sync. It belongs upstream if anywhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements per-heading unnumbered control (#68) as pandoc-compatible heading attribute blocks, per the routing decision in #88.
What this does
## Further Reading {.unnumbered}now parses the trailing brace block as attributes instead of literal heading text. The recognized vocabulary is an id (#sec-intro→ headinglabel/identifier, equivalent to a preceding(id)=target), classes (.fancy→ nodeclass), and numbering control:.unnumbered, the pandoc shorthand{-}, or an explicitenumerated=true|false. The block is stripped from the heading text, so the TOC entry, target, andhtml_idall survive — the two information-destroying fallbacks recorded in #68 (attribute text leaking into the heading, or demotion to bold run-in text) are both gone.Why it is small
Both halves already existed; this PR is the connector #68 anticipated. The tokenizer is upstream's own
inlineAttributes.ts(jupyter-book#1822), previously wired only to roles and directives — a new markdown-it core rule applies it toheading_opentokens between block and inline parsing, so ATX and setext headings both work, including inside directive bodies. On the transforms side nothing changes:shouldEnumerateNodealready givesnode.enumeratedthe final word and gates the counter increment on it, so an unnumbered heading renders no number and does not advance the counter — exact\section*semantics, verified end-to-end (Alpha → 1,Further Reading {.unnumbered} → no number,Beta → 2).Safety against false positives
A trailing brace block only parses as attributes when its entire content is recognized vocabulary. Prose like
## The set {1, 2, 3},## Heading {}, unknown keys ({foo=bar}), double ids, and escaped\{all stay literal text, byte-for-byte. The extension can be disabled withextensions.headingAttributes: false. The full CommonMark conformance suite (1474 cases) passes unchanged, along with the rest of the myst-parser and markdown-it-myst suites (2918 + 77 tests, 22 new).Upstream story
Standalone
UPSTREAM-PRS.ymlcandidate (tracker update to follow post-merge, withVERSION.yml): this completes upstream's inline-attribute mechanism rather than inventing fork syntax, uses pandoc's own heading-attribute notation so LaTeX-converted content arrives already carrying the marker, and touches no fork-modified code paths —enumerate.tsis untouched.Downstream: unblocks QuantEcon/claude-latex-to-myst#160 (24 starred headings in the Deep Learning book,
\section*{Summary}drift in book-dp2 — see the #88 survey).🤖 Generated with Claude Code