Skip to content

Heading attribute blocks: {#id .class .unnumbered} for per-heading unnumbered control - #89

Merged
mmcky merged 1 commit into
mainfrom
feature/heading-attributes
Aug 14, 2026
Merged

Heading attribute blocks: {#id .class .unnumbered} for per-heading unnumbered control#89
mmcky merged 1 commit into
mainfrom
feature/heading-attributes

Conversation

@mmcky

@mmcky mmcky commented Aug 14, 2026

Copy link
Copy Markdown

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 → heading label/identifier, equivalent to a preceding (id)= target), classes (.fancy → node class), and numbering control: .unnumbered, the pandoc shorthand {-}, or an explicit enumerated=true|false. The block is stripped from the heading text, so the TOC entry, target, and html_id all 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 to heading_open tokens between block and inline parsing, so ATX and setext headings both work, including inside directive bodies. On the transforms side nothing changes: shouldEnumerateNode already gives node.enumerated the 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 with extensions.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.yml candidate (tracker update to follow post-merge, with VERSION.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.ts is 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

…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>
Copilot AI lite review requested due to automatic review settings August 14, 2026 00:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 headingAttributesPlugin in markdown-it-myst that strips trailing {...} blocks from heading text and attaches label, class, and enumerated metadata onto heading_open tokens.
  • Wires the plugin into myst-parser behind a new extensions.headingAttributes option (default true) 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.

Comment thread packages/markdown-it-myst/src/headingAttributes.ts
@mmcky
mmcky merged commit a40f0cb into main Aug 14, 2026
7 checks passed
@mmcky
mmcky deleted the feature/heading-attributes branch August 14, 2026 01:02
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants