From add51d79eb26c4ffc1b016ad9e8a0b562f3b91e3 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Fri, 15 May 2026 09:09:46 +1000 Subject: [PATCH 1/2] feat(book): section-level scope for proof:* / figure / equation auto-prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `scope` option to NumberingItem so authors can opt kinds into LaTeX `\newtheorem{...}[section]`-style numbering. With `numbering.proof.scope: section` and `numbering.book: true`, theorems render as `5.1.1`, `5.1.2`, `5.2.1` (chapter.section.counter) and the per-kind counter resets on each new heading_2. Pre-section proofs render the literal `5.0.1`, matching the LaTeX PDF rather than the trailing-zero-stripped `5.1` (which would collide with later `5.1.x` numbers). Resolution order for a kind's scope: per-kind override → `proof` umbrella (for proof family) → `numbering.all.scope` → `chapter` (today's default). Applies to every auto-prefix kind — figure, equation, table, exercise, proof:*. Accepted values: `chapter` / `section` / `subsection` / `heading_1`..`heading_6`. Closes QuantEcon/mystmd#27. Co-Authored-By: Claude Opus 4.7 --- .changeset/book-proof-scope.md | 6 + .../src/numbering/numbering.yml | 37 +++ .../myst-frontmatter/src/numbering/types.ts | 20 ++ .../src/numbering/validators.ts | 31 +++ .../myst-transforms/src/enumerate.spec.ts | 227 ++++++++++++++++++ packages/myst-transforms/src/enumerate.ts | 123 +++++++++- 6 files changed, 441 insertions(+), 3 deletions(-) create mode 100644 .changeset/book-proof-scope.md diff --git a/.changeset/book-proof-scope.md b/.changeset/book-proof-scope.md new file mode 100644 index 0000000000..a297c60a50 --- /dev/null +++ b/.changeset/book-proof-scope.md @@ -0,0 +1,6 @@ +--- +"myst-frontmatter": patch +"myst-transforms": patch +--- + +Book mode: `scope` option on `NumberingItem` opts kinds into a section-level (LaTeX `\newtheorem{...}[section]`) auto-prefix. With `numbering.proof.scope: section`, theorems/lemmas render as `5.1.1, 5.1.2, 5.2.1` (chapter.section.counter) and reset on each new heading_2. Accepted values: `chapter`/`heading_1` (default — today's behaviour), `section`/`heading_2`, `subsection`/`heading_3`, `heading_4`..`heading_6`. Per-kind `scope` (`numbering.proof:theorem.scope`) overrides the `proof` umbrella, which overrides `numbering.all.scope`. Applies to every auto-prefix kind (figure, equation, table, exercise, proof:*). Closes QuantEcon/mystmd#27. diff --git a/packages/myst-frontmatter/src/numbering/numbering.yml b/packages/myst-frontmatter/src/numbering/numbering.yml index 4bbc62a00e..1a1af235c2 100644 --- a/packages/myst-frontmatter/src/numbering/numbering.yml +++ b/packages/myst-frontmatter/src/numbering/numbering.yml @@ -358,3 +358,40 @@ cases: enabled: true label: Chapter %s format: arabic + - title: scope normalizes section/chapter/subsection aliases + raw: + numbering: + proof: + scope: section + figure: + scope: chapter + equation: + scope: subsection + normalized: + numbering: + proof: + enabled: true + scope: heading_2 + figure: + enabled: true + scope: heading_1 + equation: + enabled: true + scope: heading_3 + - title: scope accepts heading_N directly + raw: + numbering: + proof: + scope: heading_2 + normalized: + numbering: + proof: + enabled: true + scope: heading_2 + - title: invalid scope warns + raw: + numbering: + proof: + scope: bogus + normalized: {} + warnings: 1 diff --git a/packages/myst-frontmatter/src/numbering/types.ts b/packages/myst-frontmatter/src/numbering/types.ts index 4652205402..cae15b5391 100644 --- a/packages/myst-frontmatter/src/numbering/types.ts +++ b/packages/myst-frontmatter/src/numbering/types.ts @@ -10,6 +10,26 @@ export type NumberingItem = { format?: CounterFormat; // counter rendering format (arabic/alph/Alph/roman/Roman) label?: string; // cross-reference template, distinct from `template` reset_on_part?: boolean; // chapters: restart counter at each part (only meaningful on `chapters`) + /** + * Book-mode auto-prefix depth (#27). For kinds that pick up the + * chapter/appendix prefix in `numbering.book: true` mode (figure, + * equation, table, exercise, proof:*), `scope` controls which heading + * depth contributes to the prefix and at which boundary the kind's + * counter resets. Accepted values: + * + * - `chapter` (default) / `heading_1` — current behaviour: prefix is the + * page's chapter enumerator, counter resets on chapter boundary. + * Renders e.g. `Theorem 5.1, 5.2`. + * - `section` / `heading_2` — LaTeX `\newtheorem{...}[section]` parity: + * prefix is `chapter.section`, counter resets on each new heading_2. + * Renders e.g. `Theorem 5.1.1, 5.1.2, 5.2.1`. + * - `subsection` / `heading_3` … `heading_6` — deeper variants. + * + * For `proof:*` kinds, a `scope` set on the umbrella `proof` key applies + * to every proof-family kind; per-kind `scope` (`numbering.proof:theorem.scope`) + * wins. `numbering.all.scope` is the project-wide default. + */ + scope?: string; }; /** diff --git a/packages/myst-frontmatter/src/numbering/validators.ts b/packages/myst-frontmatter/src/numbering/validators.ts index a70846a064..9e933592a2 100644 --- a/packages/myst-frontmatter/src/numbering/validators.ts +++ b/packages/myst-frontmatter/src/numbering/validators.ts @@ -36,12 +36,27 @@ const NUMBERING_ITEM_KEYS = [ 'format', 'label', 'reset_on_part', + 'scope', ]; const COUNTER_FORMATS: CounterFormat[] = ['arabic', 'alph', 'Alph', 'roman', 'Roman']; const CONTINUE_STRINGS = ['continue', 'next']; +const SCOPE_ALIASES: Record = { + chapter: 'heading_1', + section: 'heading_2', + subsection: 'heading_3', + subsubsection: 'heading_4', + heading_1: 'heading_1', + heading_2: 'heading_2', + heading_3: 'heading_3', + heading_4: 'heading_4', + heading_5: 'heading_5', + heading_6: 'heading_6', +}; +const SCOPE_VALUES = Object.keys(SCOPE_ALIASES); + export const NUMBERING_ALIAS = { sections: 'headings', h1: 'heading_1', @@ -172,6 +187,22 @@ export function validateNumberingItem( output.enabled = output.enabled ?? true; } } + if (defined(value.scope)) { + const scopeOpts = incrementOptions('scope', opts); + const scopeStr = validateString(value.scope, scopeOpts); + if (defined(scopeStr)) { + const normalized = SCOPE_ALIASES[scopeStr]; + if (normalized) { + output.scope = normalized; + output.enabled = output.enabled ?? true; + } else { + validationWarning( + `must be one of: ${SCOPE_VALUES.join(', ')} (got "${scopeStr}")`, + scopeOpts, + ); + } + } + } if (Object.keys(output).length === 0) return undefined; return output; } diff --git a/packages/myst-transforms/src/enumerate.spec.ts b/packages/myst-transforms/src/enumerate.spec.ts index c51e1c85dd..8c93a69dd5 100644 --- a/packages/myst-transforms/src/enumerate.spec.ts +++ b/packages/myst-transforms/src/enumerate.spec.ts @@ -365,6 +365,233 @@ describe('Book-mode auto-prefix (§3.4(6,7))', () => { expect(ch3.enumerator).toBe('3'); }); + // --------------------------------------------------------------------- + // #27: section-scoped auto-prefix (LaTeX `\newtheorem{...}[section]`). + // --------------------------------------------------------------------- + + test('proof:* picks up section prefix when scope=section (#27)', () => { + // chapter 1 with two sections; each section has two theorems. + // Expected: 1.1.1, 1.1.2, 1.2.1, 1.2.2 (LaTeX `[section]` parity). + const tree = u('root', [ + u('heading', { identifier: 's1', depth: 2 }), + u('proof', { kind: 'theorem', identifier: 'thm:1' }), + u('proof', { kind: 'theorem', identifier: 'thm:2' }), + u('heading', { identifier: 's2', depth: 2 }), + u('proof', { kind: 'theorem', identifier: 'thm:3' }), + u('proof', { kind: 'theorem', identifier: 'thm:4' }), + ]); + const state = new ReferenceState('ch1.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true }, + heading_2: { enabled: true }, + proof: { scope: 'section' }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('thm:1')?.node.enumerator).toBe('1.1.1'); + expect(state.getTarget('thm:2')?.node.enumerator).toBe('1.1.2'); + expect(state.getTarget('thm:3')?.node.enumerator).toBe('1.2.1'); + expect(state.getTarget('thm:4')?.node.enumerator).toBe('1.2.2'); + }); + + test('per-kind scope wins over proof umbrella (#27)', () => { + // umbrella sets section-scoped for all proofs, but lemma overrides + // back to chapter scope — theorem stays section-scoped. + const tree = u('root', [ + u('heading', { identifier: 's1', depth: 2 }), + u('proof', { kind: 'theorem', identifier: 'thm:1' }), + u('proof', { kind: 'lemma', identifier: 'lem:1' }), + u('heading', { identifier: 's2', depth: 2 }), + u('proof', { kind: 'theorem', identifier: 'thm:2' }), + u('proof', { kind: 'lemma', identifier: 'lem:2' }), + ]); + const state = new ReferenceState('ch1.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true }, + heading_2: { enabled: true }, + proof: { scope: 'section' }, + 'proof:lemma': { scope: 'chapter' }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('thm:1')?.node.enumerator).toBe('1.1.1'); + expect(state.getTarget('thm:2')?.node.enumerator).toBe('1.2.1'); + // lemma keeps the chapter-only prefix and counter doesn't reset + expect(state.getTarget('lem:1')?.node.enumerator).toBe('1.1'); + expect(state.getTarget('lem:2')?.node.enumerator).toBe('1.2'); + }); + + test('proof:* before first heading_2 renders literal 5.0.1 (#27)', () => { + // LaTeX `\newtheorem{theorem}[section]` literally prints section=0 + // when no section has been started yet. Match the PDF: render as + // `5.0.1`, not the trailing-zero-stripped `5.1` (which would also + // collide with later `5.1.x` numbers). + const tree = u('root', [ + u('proof', { kind: 'theorem', identifier: 'thm:pre' }), + u('heading', { identifier: 's1', depth: 2 }), + u('proof', { kind: 'theorem', identifier: 'thm:post' }), + ]); + const state = new ReferenceState('ch5.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true, start: 5 }, + heading_2: { enabled: true }, + proof: { scope: 'section' }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('thm:pre')?.node.enumerator).toBe('5.0.1'); + expect(state.getTarget('thm:post')?.node.enumerator).toBe('5.1.1'); + }); + + test('section scope under an appendix uses Alph chapter (#27)', () => { + // appendix A: heading_1 format=Alph; heading_2 stays arabic. + // Expected `A.1.1`, `A.1.2` for two theorems in §A.1. + const tree = u('root', [ + u('heading', { identifier: 'sa', depth: 2 }), + u('proof', { kind: 'theorem', identifier: 'thm:a1' }), + u('proof', { kind: 'theorem', identifier: 'thm:a2' }), + ]); + const state = new ReferenceState('app-a.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true, format: 'Alph' }, + heading_2: { enabled: true }, + proof: { scope: 'section' }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.enumerator).toBe('A'); + expect(state.getTarget('thm:a1')?.node.enumerator).toBe('A.1.1'); + expect(state.getTarget('thm:a2')?.node.enumerator).toBe('A.1.2'); + }); + + test('scope accepts heading_2 alias (#27)', () => { + const tree = u('root', [ + u('heading', { identifier: 's1', depth: 2 }), + u('proof', { kind: 'theorem', identifier: 'thm:1' }), + ]); + const state = new ReferenceState('ch1.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true }, + heading_2: { enabled: true }, + 'proof:theorem': { scope: 'heading_2' }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('thm:1')?.node.enumerator).toBe('1.1.1'); + }); + + test('scope=subsection (heading_3) prefixes with chapter.section.subsection (#27)', () => { + const tree = u('root', [ + u('heading', { identifier: 's1', depth: 2 }), + u('heading', { identifier: 'ss1', depth: 3 }), + u('proof', { kind: 'theorem', identifier: 'thm:1' }), + u('proof', { kind: 'theorem', identifier: 'thm:2' }), + u('heading', { identifier: 'ss2', depth: 3 }), + u('proof', { kind: 'theorem', identifier: 'thm:3' }), + ]); + const state = new ReferenceState('ch1.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true }, + heading_2: { enabled: true }, + heading_3: { enabled: true }, + proof: { scope: 'subsection' }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('thm:1')?.node.enumerator).toBe('1.1.1.1'); + expect(state.getTarget('thm:2')?.node.enumerator).toBe('1.1.1.2'); + // counter resets on heading_3 boundary, not heading_2 + expect(state.getTarget('thm:3')?.node.enumerator).toBe('1.1.2.1'); + }); + + test('continue: true on a kind still wins over scope (#27)', () => { + // `continue: true` keeps the counter globally flat and drops the + // prefix entirely, even when scope is set. Matches §3.4(6) opt-out. + const tree = u('root', [ + u('heading', { identifier: 's1', depth: 2 }), + u('proof', { kind: 'theorem', identifier: 'thm:1' }), + u('proof', { kind: 'theorem', identifier: 'thm:2' }), + ]); + const state = new ReferenceState('ch1.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true }, + heading_2: { enabled: true }, + 'proof:theorem': { scope: 'section', continue: true }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('thm:1')?.node.enumerator).toBe('1'); + expect(state.getTarget('thm:2')?.node.enumerator).toBe('2'); + }); + + test('figures also accept scope (#27)', () => { + // Confirms scope generalises to every auto-prefix kind, not just + // proof:* — `numbering.all.scope` applies to figure too. + const tree = u('root', [ + u('heading', { identifier: 's1', depth: 2 }), + u('container', { kind: 'figure', identifier: 'fig:1' }), + u('heading', { identifier: 's2', depth: 2 }), + u('container', { kind: 'figure', identifier: 'fig:2' }), + ]); + const state = new ReferenceState('ch3.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true, scope: 'section' }, + title: { enabled: true }, + heading_1: { enabled: true, start: 3 }, + heading_2: { enabled: true }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('fig:1')?.node.enumerator).toBe('3.1.1'); + expect(state.getTarget('fig:2')?.node.enumerator).toBe('3.2.1'); + }); + test('subfigures inherit the chapter-prefixed parent enumerator', () => { const tree = u('root', [ u('container', { kind: 'figure', identifier: 'fig-p' }, [ diff --git a/packages/myst-transforms/src/enumerate.ts b/packages/myst-transforms/src/enumerate.ts index 5dce75ce7e..8d5c11305c 100644 --- a/packages/myst-transforms/src/enumerate.ts +++ b/packages/myst-transforms/src/enumerate.ts @@ -70,6 +70,92 @@ function shouldAutoPrefix(kind: string): boolean { return false; } +/** + * Is this kind part of the proof family (for umbrella scope resolution)? + * Used so `numbering.proof.scope` applies to every `proof:*` / `prf:*` kind. + */ +function isProofFamilyKind(kind: string): boolean { + return kind === 'proof' || kind.startsWith('proof:') || kind.startsWith('prf:'); +} + +/** + * Map a scope alias (`chapter` / `section` / `subsection` / `heading_N`) + * to its heading depth. Returns undefined for unrecognised values so the + * caller can fall through to the next candidate. Mirrors the alias map + * in `myst-frontmatter/src/numbering/validators.ts`; kept in sync because + * `ReferenceState`'s constructor accepts pre-validated *and* raw + * frontmatter (no separate validator pass), so we normalise here too. + */ +function scopeAliasToDepth(scope: string): number | undefined { + switch (scope) { + case 'chapter': + case 'heading_1': + return 1; + case 'section': + case 'heading_2': + return 2; + case 'subsection': + case 'heading_3': + return 3; + case 'subsubsection': + case 'heading_4': + return 4; + case 'heading_5': + return 5; + case 'heading_6': + return 6; + default: + return undefined; + } +} + +/** + * Resolve a kind's effective auto-prefix scope depth (#27). + * + * Lookup order, most-specific first: + * 1. `numbering[kind].scope` — e.g. `numbering['proof:theorem'].scope` + * 2. `numbering.proof.scope` — umbrella default for proof family + * 3. `numbering.all.scope` — project-wide default + * 4. `chapter` (depth 1) — current MyST behaviour + * + * Returns the heading depth (1 = chapter / heading_1, 2 = section / + * heading_2, …). + */ +function effectiveScopeDepth(numbering: Numbering, kind: string): number { + const candidates = [numbering[kind]?.scope]; + if (isProofFamilyKind(kind)) candidates.push(numbering.proof?.scope); + candidates.push(numbering.all?.scope); + for (const scope of candidates) { + if (!scope) continue; + const depth = scopeAliasToDepth(scope); + if (depth) return depth; + } + return 1; +} + +/** + * Format a heading-prefix string at a fixed scope depth, preserving zero + * counts (matches LaTeX `\thechapter.\thesection.…` literal output). + * + * Unlike `formatHeadingEnumerator`, this does NOT strip trailing zeros — + * so a section-scoped proof appearing before the first `## Section` in + * chapter 5 renders as `5.0.1`, not `5.1`. The literal form avoids + * ambiguity with later `5.1.x` numbers and matches the PDF. + */ +function formatHeadingPrefix( + counts: (number | null)[], + scopeDepth: number, + formats?: (CounterFormat | undefined)[], +): string { + const parts: string[] = []; + for (let i = 0; i < scopeDepth; i++) { + const count = counts[i]; + if (count === null) continue; + parts.push(formatCounter(count ?? 0, formats?.[i])); + } + return parts.join('.'); +} + const DEFAULT_NUMBERING: Numbering = { equation: { enabled: true, template: '(%s)' }, subequation: { enabled: true, template: '(%s)' }, @@ -448,6 +534,15 @@ export class ReferenceState implements IReferenceStateResolver { identifiers: string[]; enumerator?: string; offset: number; + /** + * Per-kind last-seen scope-key (#27). When a scoped kind (e.g. + * `proof:theorem` with `scope: section`) is incremented, the prefix + * derived from the current heading counts is recorded here; if the + * next increment of the same kind sees a different prefix, the main + * counter resets. Empty on fresh pages — counter reset across pages + * is governed by `targetCounts` carry-over, not this map. + */ + lastScopeKeyByKind: Record; constructor( filePath: string, @@ -487,6 +582,7 @@ export class ReferenceState implements IReferenceStateResolver { this.url = opts?.url; this.dataUrl = opts?.dataUrl; this.title = opts?.frontmatter?.title; + this.lastScopeKeyByKind = {}; } addTarget(node: TargetNodes, hidden?: boolean) { @@ -562,13 +658,34 @@ export class ReferenceState implements IReferenceStateResolver { // Per-kind `continue: true` (§3.4(6)) opts out and keeps the counter // flat across pages. const continueKind = this.numbering[countKind]?.continue || this.numbering.all?.continue; - const autoPrefix = + let autoPrefix = ''; + if ( this.enumerator && this.numbering.book?.enabled && !continueKind && shouldAutoPrefix(countKind) - ? `${this.enumerator}.` - : ''; + ) { + // #27: scope > 1 means "go deeper than the chapter prefix" — e.g. + // `Theorem 5.1.2` from chapter 5, section 1, second theorem. The + // counter must also reset on each scope boundary (e.g. new heading_2), + // so we track the last-seen scope key per kind and reset main/sub + // when it changes. Scope == 1 keeps today's behaviour exactly. + const scopeDepth = effectiveScopeDepth(this.numbering, countKind); + if (scopeDepth > 1) { + const scopePrefix = formatHeadingPrefix( + this.targetCounts.heading, + scopeDepth, + headingFormats(this.numbering), + ); + autoPrefix = scopePrefix ? `${scopePrefix}.` : ''; + if (this.lastScopeKeyByKind[countKind] !== scopePrefix) { + this.targetCounts[countKind] = { main: 0, sub: 0 }; + this.lastScopeKeyByKind[countKind] = scopePrefix; + } + } else { + autoPrefix = `${this.enumerator}.`; + } + } if (node.subcontainer || kind === TargetKind.subequation) { this.targetCounts[countKind].sub += 1; // Will restart counting if there are more than 26 subequations/figures From 00ca91abc607012b968f909e68664ca721bd0726 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Fri, 15 May 2026 09:35:27 +1000 Subject: [PATCH 2/2] fix(book): address Copilot PR feedback on #27 scope feature - Scope-change reset no longer fires on first encounter of a kind, so `numbering[kind].start` seeded by initializeTargetCounts survives. Regression test confirms `figure: { start: 5, scope: section }` now renders `1.1.5`, `1.1.6`, `1.2.1` instead of `1.1.1, 1.1.2, 1.2.1`. - Hoist `SCOPE_ALIASES` + `scopeAliasToDepth` into `myst-frontmatter/numbering/validators.ts` and consume from `myst-transforms/enumerate.ts`. Single source of truth for accepted scope spellings; the validator-side normalisation and the enumerate- side depth lookup no longer drift. - Tighten `NumberingItem.scope` from `string` to a `NumberingScope` string-literal union so invalid spellings fail at compile time. Co-Authored-By: Claude Opus 4.7 --- .../myst-frontmatter/src/numbering/types.ts | 21 ++++++++- .../src/numbering/validators.ts | 27 ++++++++++-- .../myst-transforms/src/enumerate.spec.ts | 36 ++++++++++++++++ packages/myst-transforms/src/enumerate.ts | 43 ++++--------------- 4 files changed, 88 insertions(+), 39 deletions(-) diff --git a/packages/myst-frontmatter/src/numbering/types.ts b/packages/myst-frontmatter/src/numbering/types.ts index cae15b5391..afca12e095 100644 --- a/packages/myst-frontmatter/src/numbering/types.ts +++ b/packages/myst-frontmatter/src/numbering/types.ts @@ -1,5 +1,24 @@ export type CounterFormat = 'arabic' | 'alph' | 'Alph' | 'roman' | 'Roman'; +/** + * Accepted values for `numbering..scope` (#27). The validator + * normalises every spelling to `heading_N`; the alias forms are kept in + * the union so authors can write the LaTeX-familiar names directly. + * Source of truth for the alias map lives in + * `myst-frontmatter/src/numbering/validators.ts` (`SCOPE_ALIASES`). + */ +export type NumberingScope = + | 'chapter' + | 'section' + | 'subsection' + | 'subsubsection' + | 'heading_1' + | 'heading_2' + | 'heading_3' + | 'heading_4' + | 'heading_5' + | 'heading_6'; + export type NumberingItem = { enabled?: boolean; start?: number; @@ -29,7 +48,7 @@ export type NumberingItem = { * to every proof-family kind; per-kind `scope` (`numbering.proof:theorem.scope`) * wins. `numbering.all.scope` is the project-wide default. */ - scope?: string; + scope?: NumberingScope; }; /** diff --git a/packages/myst-frontmatter/src/numbering/validators.ts b/packages/myst-frontmatter/src/numbering/validators.ts index 9e933592a2..95e24831a8 100644 --- a/packages/myst-frontmatter/src/numbering/validators.ts +++ b/packages/myst-frontmatter/src/numbering/validators.ts @@ -9,7 +9,7 @@ import { validateString, validationWarning, } from 'simple-validators'; -import type { CounterFormat, Numbering, NumberingItem } from './types.js'; +import type { CounterFormat, Numbering, NumberingItem, NumberingScope } from './types.js'; export const NUMBERING_OPTIONS = ['enumerator', 'all', 'headings', 'title']; @@ -43,7 +43,15 @@ const COUNTER_FORMATS: CounterFormat[] = ['arabic', 'alph', 'Alph', 'roman', 'Ro const CONTINUE_STRINGS = ['continue', 'next']; -const SCOPE_ALIASES: Record = { +/** + * Single source of truth for `numbering..scope` aliases (#27). + * Maps every accepted spelling to its canonical `heading_N` form. + * Both the validator (which normalises to `heading_N`) and consumers + * that need the depth integer (e.g. `myst-transforms/src/enumerate.ts`'s + * `effectiveScopeDepth`) import this and `scopeAliasToDepth` rather + * than maintaining parallel switch statements. + */ +export const SCOPE_ALIASES: Record = { chapter: 'heading_1', section: 'heading_2', subsection: 'heading_3', @@ -55,7 +63,18 @@ const SCOPE_ALIASES: Record = { heading_5: 'heading_5', heading_6: 'heading_6', }; -const SCOPE_VALUES = Object.keys(SCOPE_ALIASES); +export const SCOPE_VALUES = Object.keys(SCOPE_ALIASES); + +/** + * Resolve a scope alias (`chapter`/`section`/`heading_N`/…) to its + * heading depth (1-based). Returns `undefined` for unrecognised values + * so the caller can fall through to the next candidate. + */ +export function scopeAliasToDepth(scope: string): number | undefined { + const canonical = SCOPE_ALIASES[scope]; + if (!canonical) return undefined; + return Number(canonical.slice('heading_'.length)); +} export const NUMBERING_ALIAS = { sections: 'headings', @@ -193,7 +212,7 @@ export function validateNumberingItem( if (defined(scopeStr)) { const normalized = SCOPE_ALIASES[scopeStr]; if (normalized) { - output.scope = normalized; + output.scope = normalized as NumberingScope; output.enabled = output.enabled ?? true; } else { validationWarning( diff --git a/packages/myst-transforms/src/enumerate.spec.ts b/packages/myst-transforms/src/enumerate.spec.ts index 8c93a69dd5..9988dac63b 100644 --- a/packages/myst-transforms/src/enumerate.spec.ts +++ b/packages/myst-transforms/src/enumerate.spec.ts @@ -566,6 +566,42 @@ describe('Book-mode auto-prefix (§3.4(6,7))', () => { expect(state.getTarget('thm:2')?.node.enumerator).toBe('2'); }); + test('scope respects `start` on first encounter (#27)', () => { + // Regression: the scope-change reset used to fire on first encounter + // (because `lastScopeKeyByKind[kind]` started undefined), wiping the + // `start` offset seeded by initializeTargetCounts. Confirm that + // `figure: { start: 5, scope: section }` renders the first figure + // in §1.1 as `1.1.5`, not `1.1.1`, and that subsequent sections + // still reset back to 1. + const tree = u('root', [ + u('heading', { identifier: 's1', depth: 2 }), + u('container', { kind: 'figure', identifier: 'fig:1' }), + u('container', { kind: 'figure', identifier: 'fig:2' }), + u('heading', { identifier: 's2', depth: 2 }), + u('container', { kind: 'figure', identifier: 'fig:3' }), + ]); + const state = new ReferenceState('ch1.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true }, + heading_2: { enabled: true }, + figure: { start: 5, scope: 'section' }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('fig:1')?.node.enumerator).toBe('1.1.5'); + expect(state.getTarget('fig:2')?.node.enumerator).toBe('1.1.6'); + // crossing into §1.2 resets the counter (start applies only at + // page-init time, not on every scope boundary — same as today's + // chapter-scoped behaviour). + expect(state.getTarget('fig:3')?.node.enumerator).toBe('1.2.1'); + }); + test('figures also accept scope (#27)', () => { // Confirms scope generalises to every auto-prefix kind, not just // proof:* — `numbering.all.scope` applies to figure too. diff --git a/packages/myst-transforms/src/enumerate.ts b/packages/myst-transforms/src/enumerate.ts index 8d5c11305c..7ed242b705 100644 --- a/packages/myst-transforms/src/enumerate.ts +++ b/packages/myst-transforms/src/enumerate.ts @@ -30,7 +30,7 @@ import { } from 'myst-common'; import type { LinkTransformer } from './links/types.js'; import { updateLinkTextIfEmpty } from './links/utils.js'; -import { fillNumbering } from 'myst-frontmatter'; +import { fillNumbering, scopeAliasToDepth } from 'myst-frontmatter'; import type { CounterFormat, PageFrontmatter, Numbering } from 'myst-frontmatter'; const TRANSFORM_NAME = 'myst-transforms:enumerate'; @@ -78,37 +78,6 @@ function isProofFamilyKind(kind: string): boolean { return kind === 'proof' || kind.startsWith('proof:') || kind.startsWith('prf:'); } -/** - * Map a scope alias (`chapter` / `section` / `subsection` / `heading_N`) - * to its heading depth. Returns undefined for unrecognised values so the - * caller can fall through to the next candidate. Mirrors the alias map - * in `myst-frontmatter/src/numbering/validators.ts`; kept in sync because - * `ReferenceState`'s constructor accepts pre-validated *and* raw - * frontmatter (no separate validator pass), so we normalise here too. - */ -function scopeAliasToDepth(scope: string): number | undefined { - switch (scope) { - case 'chapter': - case 'heading_1': - return 1; - case 'section': - case 'heading_2': - return 2; - case 'subsection': - case 'heading_3': - return 3; - case 'subsubsection': - case 'heading_4': - return 4; - case 'heading_5': - return 5; - case 'heading_6': - return 6; - default: - return undefined; - } -} - /** * Resolve a kind's effective auto-prefix scope depth (#27). * @@ -678,10 +647,16 @@ export class ReferenceState implements IReferenceStateResolver { headingFormats(this.numbering), ); autoPrefix = scopePrefix ? `${scopePrefix}.` : ''; - if (this.lastScopeKeyByKind[countKind] !== scopePrefix) { + // Reset only on a real scope *change* — i.e. we've already seen + // this kind at a different scope key. Resetting on *first* + // encounter would clobber `numbering[kind].start` seeded by + // `initializeTargetCounts`, so e.g. `figure: { start: 5, scope: + // section }` would silently render `5.1.1` instead of `5.1.5`. + const prevScopeKey = this.lastScopeKeyByKind[countKind]; + if (prevScopeKey !== undefined && prevScopeKey !== scopePrefix) { this.targetCounts[countKind] = { main: 0, sub: 0 }; - this.lastScopeKeyByKind[countKind] = scopePrefix; } + this.lastScopeKeyByKind[countKind] = scopePrefix; } else { autoPrefix = `${this.enumerator}.`; }