From fbed3d4482250b793d8759922c037e59390c3e23 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 14 May 2026 10:49:06 +1000 Subject: [PATCH 01/10] =?UTF-8?q?Add=20schema=20for=20book-style=20numberi?= =?UTF-8?q?ng=20(PR=20#1,=20=C2=A73.5(1))?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/numbering/numbering.yml | 75 +++++++++++++++++++ .../myst-frontmatter/src/numbering/types.ts | 16 ++++ .../src/numbering/validators.ts | 50 ++++++++++++- 3 files changed, 139 insertions(+), 2 deletions(-) diff --git a/packages/myst-frontmatter/src/numbering/numbering.yml b/packages/myst-frontmatter/src/numbering/numbering.yml index 5b051b0f1b..4bbc62a00e 100644 --- a/packages/myst-frontmatter/src/numbering/numbering.yml +++ b/packages/myst-frontmatter/src/numbering/numbering.yml @@ -283,3 +283,78 @@ cases: figure: enumerator: '{number}' enabled: true + - title: format validates + raw: + numbering: + chapters: + format: arabic + appendices: + format: Alph + parts: + format: Roman + normalized: + numbering: + chapters: + enabled: true + format: arabic + appendices: + enabled: true + format: Alph + parts: + enabled: true + format: Roman + - title: invalid format warns + raw: + numbering: + chapters: + format: bogus + normalized: {} + warnings: 1 + - title: label validates + raw: + numbering: + chapters: + label: Chapter %s + appendices: + label: Appendix %s + normalized: + numbering: + chapters: + enabled: true + label: Chapter %s + appendices: + enabled: true + label: Appendix %s + - title: reset_on_part validates + raw: + numbering: + chapters: + reset_on_part: true + normalized: + numbering: + chapters: + enabled: true + reset_on_part: true + - title: book flag coerces from boolean + raw: + numbering: + book: true + normalized: + numbering: + book: + enabled: true + - title: book flag composes with chapter config + raw: + numbering: + book: true + chapters: + label: Chapter %s + format: arabic + normalized: + numbering: + book: + enabled: true + chapters: + enabled: true + label: Chapter %s + format: arabic diff --git a/packages/myst-frontmatter/src/numbering/types.ts b/packages/myst-frontmatter/src/numbering/types.ts index c2adbd273f..4652205402 100644 --- a/packages/myst-frontmatter/src/numbering/types.ts +++ b/packages/myst-frontmatter/src/numbering/types.ts @@ -1,3 +1,5 @@ +export type CounterFormat = 'arabic' | 'alph' | 'Alph' | 'roman' | 'Roman'; + export type NumberingItem = { enabled?: boolean; start?: number; @@ -5,9 +7,20 @@ export type NumberingItem = { template?: string; continue?: boolean; offset?: number; // only applies to title + 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` is the opt-in flag for book-style numbering (PR #1, §3.2(0)). + * It rides as a `NumberingItem`-shaped entry so the existing kind map stays + * typed cleanly: `numbering: { book: true }` coerces to + * `numbering.book = { enabled: true }` via `validateNumberingItem`, and + * consumers test `numbering.book?.enabled === true` to gate book behaviour. + */ export type Numbering = { + book?: NumberingItem; enumerator?: NumberingItem; // start, enabled, continue, and template ignored all?: NumberingItem; // start, template, enumerator ignored title?: NumberingItem; // start, continue, and template ignored @@ -17,6 +30,9 @@ export type Numbering = { subequation?: NumberingItem; table?: NumberingItem; code?: NumberingItem; + parts?: NumberingItem; + chapters?: NumberingItem; + appendices?: NumberingItem; heading_1?: NumberingItem; heading_2?: NumberingItem; heading_3?: NumberingItem; diff --git a/packages/myst-frontmatter/src/numbering/validators.ts b/packages/myst-frontmatter/src/numbering/validators.ts index 060df95421..a70846a064 100644 --- a/packages/myst-frontmatter/src/numbering/validators.ts +++ b/packages/myst-frontmatter/src/numbering/validators.ts @@ -9,22 +9,36 @@ import { validateString, validationWarning, } from 'simple-validators'; -import type { Numbering, NumberingItem } from './types.js'; +import type { CounterFormat, Numbering, NumberingItem } from './types.js'; export const NUMBERING_OPTIONS = ['enumerator', 'all', 'headings', 'title']; const HEADING_KEYS = ['heading_1', 'heading_2', 'heading_3', 'heading_4', 'heading_5', 'heading_6']; +const BOOK_SECTION_KEYS = ['parts', 'chapters', 'appendices']; export const NUMBERING_KEYS = [ + 'book', 'figure', 'subfigure', 'equation', 'subequation', 'table', 'code', + ...BOOK_SECTION_KEYS, ...HEADING_KEYS, ]; -const NUMBERING_ITEM_KEYS = ['enabled', 'start', 'enumerator', 'template', 'continue']; +const NUMBERING_ITEM_KEYS = [ + 'enabled', + 'start', + 'enumerator', + 'template', + 'continue', + 'format', + 'label', + 'reset_on_part', +]; + +const COUNTER_FORMATS: CounterFormat[] = ['arabic', 'alph', 'Alph', 'roman', 'Roman']; const CONTINUE_STRINGS = ['continue', 'next']; @@ -126,6 +140,38 @@ export function validateNumberingItem( output.enabled = output.enabled ?? true; } } + if (defined(value.format)) { + const formatOpts = incrementOptions('format', opts); + const formatStr = validateString(value.format, formatOpts); + if (defined(formatStr)) { + if ((COUNTER_FORMATS as string[]).includes(formatStr)) { + output.format = formatStr as CounterFormat; + output.enabled = output.enabled ?? true; + } else { + validationWarning( + `must be one of: ${COUNTER_FORMATS.join(', ')} (got "${formatStr}")`, + formatOpts, + ); + } + } + } + if (defined(value.label)) { + const label = validateString(value.label, incrementOptions('label', opts)); + if (defined(label)) { + output.label = label; + output.enabled = output.enabled ?? true; + } + } + if (defined(value.reset_on_part)) { + const resetOnPart = validateBoolean( + value.reset_on_part, + incrementOptions('reset_on_part', opts), + ); + if (defined(resetOnPart)) { + output.reset_on_part = resetOnPart; + output.enabled = output.enabled ?? true; + } + } if (Object.keys(output).length === 0) return undefined; return output; } From d39288c5e8a12e350cb216e66c0b9da2433809a2 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 14 May 2026 10:53:21 +1000 Subject: [PATCH 02/10] =?UTF-8?q?Add=20formatCounter=20for=20arabic/alph/A?= =?UTF-8?q?lph/roman/Roman=20(PR=20#1,=20=C2=A73.5(2))?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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)). --- .../myst-transforms/src/enumerate.spec.ts | 47 ++++++++++ packages/myst-transforms/src/enumerate.ts | 85 +++++++++++++++++-- packages/myst-transforms/src/index.ts | 2 + 3 files changed, 125 insertions(+), 9 deletions(-) diff --git a/packages/myst-transforms/src/enumerate.spec.ts b/packages/myst-transforms/src/enumerate.spec.ts index 95e3b62c91..eb06b102e2 100644 --- a/packages/myst-transforms/src/enumerate.spec.ts +++ b/packages/myst-transforms/src/enumerate.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'; import { ReferenceState, enumerateTargetsTransform, + formatCounter, formatHeadingEnumerator, incrementHeadingCounts, initializeTargetCounts, @@ -9,6 +10,52 @@ import { import { u } from 'unist-builder'; import { VFile } from 'vfile'; +describe('formatCounter', () => { + test.each([ + [1, undefined, '1'], + [1, 'arabic', '1'], + [27, 'arabic', '27'], + [1, 'alph', 'a'], + [26, 'alph', 'z'], + [27, 'alph', 'aa'], + [28, 'alph', 'ab'], + [52, 'alph', 'az'], + [53, 'alph', 'ba'], + [1, 'Alph', 'A'], + [27, 'Alph', 'AA'], + [1, 'roman', 'i'], + [4, 'roman', 'iv'], + [9, 'roman', 'ix'], + [40, 'roman', 'xl'], + [90, 'roman', 'xc'], + [400, 'roman', 'cd'], + [900, 'roman', 'cm'], + [1994, 'roman', 'mcmxciv'], + [1, 'Roman', 'I'], + [4, 'Roman', 'IV'], + [1994, 'Roman', 'MCMXCIV'], + [0, 'Alph', '0'], // non-positive passes through + [-1, 'Roman', '-1'], + ] as const)('formatCounter(%s, %s) → %s', (n, fmt, expected) => { + expect(formatCounter(n as number, fmt as any)).toBe(expected); + }); +}); + +describe('formatHeadingEnumerator with formats', () => { + test('Alph at depth 1 renders as letter', () => { + expect(formatHeadingEnumerator([1, 0, 0, 0, 0, 0], undefined, ['Alph'])).toBe('A'); + }); + test('Alph chapter prefix on a sub-heading', () => { + expect(formatHeadingEnumerator([2, 3, 0, 0, 0, 0], undefined, ['Alph'])).toBe('B.3'); + }); + test('Roman at depth 1, arabic sub-headings', () => { + expect(formatHeadingEnumerator([3, 2, 1, 0, 0, 0], undefined, ['Roman'])).toBe('III.2.1'); + }); + test('no formats array preserves today\'s arabic behaviour', () => { + expect(formatHeadingEnumerator([1, 2, 0, 0, 0, 0])).toBe('1.2'); + }); +}); + describe('Heading counts and formatting', () => { test.each([ [2, [0, 0, 0, null, 0, 0], [0, 1, 0, null, 0, 0]], diff --git a/packages/myst-transforms/src/enumerate.ts b/packages/myst-transforms/src/enumerate.ts index 9c8ed0a451..65f15a1bbd 100644 --- a/packages/myst-transforms/src/enumerate.ts +++ b/packages/myst-transforms/src/enumerate.ts @@ -23,7 +23,7 @@ import { import type { LinkTransformer } from './links/types.js'; import { updateLinkTextIfEmpty } from './links/utils.js'; import { fillNumbering } from 'myst-frontmatter'; -import type { PageFrontmatter, Numbering } from 'myst-frontmatter'; +import type { CounterFormat, PageFrontmatter, Numbering } from 'myst-frontmatter'; const TRANSFORM_NAME = 'myst-transforms:enumerate'; @@ -247,23 +247,87 @@ export function incrementHeadingCounts( }); } +/** + * Format a positive integer counter as arabic / alph / Alph / roman / Roman. + * + * - `arabic` (default): 1, 2, 3, … + * - `alph` / `Alph`: a, b, c, … z, aa, ab, … (excel-style overflow) + * - `roman` / `Roman`: i, ii, iii, iv, v, … (zero is empty) + * + * Non-positive values are returned as `String(value)` unchanged so callers + * can pass 0 / negative sentinels without surprise. + */ +export function formatCounter(value: number, format?: CounterFormat): string { + if (!format || format === 'arabic') return String(value); + if (value <= 0) return String(value); + if (format === 'alph' || format === 'Alph') { + let n = value; + let out = ''; + while (n > 0) { + const rem = (n - 1) % 26; + out = String.fromCharCode('a'.charCodeAt(0) + rem) + out; + n = Math.floor((n - 1) / 26); + } + return format === 'Alph' ? out.toUpperCase() : out; + } + // roman / Roman + const romans: [number, string][] = [ + [1000, 'm'], + [900, 'cm'], + [500, 'd'], + [400, 'cd'], + [100, 'c'], + [90, 'xc'], + [50, 'l'], + [40, 'xl'], + [10, 'x'], + [9, 'ix'], + [5, 'v'], + [4, 'iv'], + [1, 'i'], + ]; + let n = value; + let out = ''; + for (const [num, sym] of romans) { + while (n >= num) { + out += sym; + n -= num; + } + } + return format === 'Roman' ? out.toUpperCase() : out; +} + /** * Return dot-delimited header numbering based on heading counts * * counts is a list of 6 counts, corresponding to 6 heading depths * * Leading zeros are kept, trailing zeros are removed, nulls are ignored. + * + * Optional `formats` is a parallel list of per-depth counter formats; when + * provided, each depth's count is rendered via `formatCounter`. Heading + * depths without a format default to arabic, matching today's behaviour. */ -export function formatHeadingEnumerator(counts: (number | null)[], prefix?: string): string { - counts = counts.filter((d) => d !== null); - while (counts && counts[counts.length - 1] === 0) { - counts.pop(); +export function formatHeadingEnumerator( + counts: (number | null)[], + prefix?: string, + formats?: (CounterFormat | undefined)[], +): string { + const pairs = counts + .map((c, i) => [c, formats?.[i]] as const) + .filter(([c]) => c !== null) as [number, CounterFormat | undefined][]; + while (pairs.length && pairs[pairs.length - 1][0] === 0) { + pairs.pop(); } - const enumerator = counts.join('.'); + const enumerator = pairs.map(([c, fmt]) => formatCounter(c, fmt)).join('.'); const out = prefix ? prefix.replace(/%s/g, String(enumerator)) : String(enumerator); return out; } +function headingFormats(numbering: Numbering): (CounterFormat | undefined)[] { + return [1, 2, 3, 4, 5, 6].map((d) => numbering[`heading_${d}`]?.format); +} + export function initializeTargetCounts( numbering: Numbering, previousCounts?: TargetCounts, @@ -366,6 +430,7 @@ export class ReferenceState implements IReferenceStateResolver { this.enumerator = formatHeadingEnumerator( this.targetCounts.heading, this.numbering.title?.enumerator ?? this.numbering.enumerator?.enumerator, + headingFormats(this.numbering), ); } this.identifiers = opts?.identifiers ?? []; @@ -434,6 +499,7 @@ export class ReferenceState implements IReferenceStateResolver { this.numbering[ `heading_${node.depth - (this.numbering?.title?.enabled ? 0 : 1) + this.offset}` ]?.enumerator ?? this.numbering.enumerator?.enumerator, + headingFormats(this.numbering), ); node.enumerator = enumerator; return enumerator; @@ -441,6 +507,7 @@ export class ReferenceState implements IReferenceStateResolver { const countKind = kind === TargetKind.subequation ? TargetKind.equation : kind; // Ensure target kind is instantiated this.targetCounts[countKind] ??= { main: 0, sub: 0 }; + const kindFormat = this.numbering[countKind]?.format; if (node.subcontainer || kind === TargetKind.subequation) { this.targetCounts[countKind].sub += 1; // Will restart counting if there are more than 26 subequations/figures @@ -449,13 +516,13 @@ export class ReferenceState implements IReferenceStateResolver { ); if (node.subcontainer) { node.parentEnumerator = this.resolveEnumerator( - this.targetCounts[countKind].main, + formatCounter(this.targetCounts[countKind].main, kindFormat), this.numbering[countKind]?.enumerator, ); enumerator = letter; } else { enumerator = this.resolveEnumerator( - this.targetCounts[countKind].main + letter, + formatCounter(this.targetCounts[countKind].main, kindFormat) + letter, this.numbering[countKind]?.enumerator, ); } @@ -463,7 +530,7 @@ export class ReferenceState implements IReferenceStateResolver { this.targetCounts[kind].main += 1; this.targetCounts[kind].sub = 0; enumerator = this.resolveEnumerator( - this.targetCounts[kind].main, + formatCounter(this.targetCounts[kind].main, kindFormat), this.numbering[kind]?.enumerator, ); } diff --git a/packages/myst-transforms/src/index.ts b/packages/myst-transforms/src/index.ts index ce91632c47..2fa0d54552 100644 --- a/packages/myst-transforms/src/index.ts +++ b/packages/myst-transforms/src/index.ts @@ -75,6 +75,8 @@ export { resolveReferencesPlugin, ReferenceState, MultiPageReferenceResolver, + formatCounter, + formatHeadingEnumerator, } from './enumerate.js'; // Composite plugins From 1c3251a69f862e1cac33ed9d4406d9346c143824 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 14 May 2026 10:59:56 +1000 Subject: [PATCH 03/10] =?UTF-8?q?Render=20heading=20cross-refs=20via=20lab?= =?UTF-8?q?el=20/=20fall=20back=20to=20title=20(PR=20#1,=20=C2=A73.5(5))?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../myst-transforms/src/enumerate.spec.ts | 102 ++++++++++++++++++ packages/myst-transforms/src/enumerate.ts | 18 +++- 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/packages/myst-transforms/src/enumerate.spec.ts b/packages/myst-transforms/src/enumerate.spec.ts index eb06b102e2..424b98a77e 100644 --- a/packages/myst-transforms/src/enumerate.spec.ts +++ b/packages/myst-transforms/src/enumerate.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest'; import { + addChildrenFromTargetNode, ReferenceState, enumerateTargetsTransform, formatCounter, @@ -9,6 +10,7 @@ import { } from './enumerate'; import { u } from 'unist-builder'; import { VFile } from 'vfile'; +import { toText } from 'myst-common'; describe('formatCounter', () => { test.each([ @@ -172,6 +174,106 @@ describe('enumeration', () => { expect(state.getTarget('fig:2')?.node.enumerator).toBe('A.2'); }); }); +describe('Heading cross-ref rendering (§3.2(h))', () => { + test('label takes precedence over template for numbered heading', () => { + const heading = u('heading', { + identifier: 'ch1', + depth: 1, + enumerator: '1', + }, [u('text', 'Introduction')]); + const ref: any = { type: 'crossReference', identifier: 'ch1' }; + addChildrenFromTargetNode( + ref, + heading as any, + { + title: { enabled: true }, + heading_1: { enabled: true, template: 'Section %s', label: 'Chapter %s' }, + }, + new VFile(), + ); + expect(toText(ref.children)).toBe('Chapter 1'); + }); + + test('falls back to template when label is absent', () => { + const heading = u('heading', { + identifier: 'h1', + depth: 1, + enumerator: '1', + }, [u('text', 'Introduction')]); + const ref: any = { type: 'crossReference', identifier: 'h1' }; + addChildrenFromTargetNode( + ref, + heading as any, + { + title: { enabled: true }, + heading_1: { enabled: true, template: 'Section %s' }, + }, + new VFile(), + ); + expect(toText(ref.children)).toBe('Section 1'); + }); + + test('unnumbered heading falls back to title (#12 fix)', () => { + // Heading has no enumerator — even though numbering.heading_1 has a + // template, the cross-ref must render the heading text, not + // "Chapter ??". + const heading = u('heading', { identifier: 'preface', depth: 1 }, [u('text', 'Preface')]); + const ref: any = { type: 'crossReference', identifier: 'preface' }; + addChildrenFromTargetNode( + ref, + heading as any, + { + title: { enabled: true }, + heading_1: { enabled: true, template: 'Chapter %s', label: 'Chapter %s' }, + }, + new VFile(), + ); + expect(toText(ref.children)).toBe('Preface'); + }); + + test('explicit link text wins', () => { + const heading = u('heading', { + identifier: 'ch1', + depth: 1, + enumerator: '1', + }, [u('text', 'Introduction')]); + const ref: any = { + type: 'crossReference', + identifier: 'ch1', + children: [u('text', 'the intro')], + }; + addChildrenFromTargetNode( + ref, + heading as any, + { + title: { enabled: true }, + heading_1: { enabled: true, label: 'Chapter %s' }, + }, + new VFile(), + ); + expect(toText(ref.children)).toBe('the intro'); + }); + + test('label with Alph-formatted enumerator (appendix-style)', () => { + const heading = u('heading', { + identifier: 'app-a', + depth: 1, + enumerator: 'A', + }, [u('text', 'Proofs')]); + const ref: any = { type: 'crossReference', identifier: 'app-a' }; + addChildrenFromTargetNode( + ref, + heading as any, + { + title: { enabled: true }, + heading_1: { enabled: true, label: 'Appendix %s' }, + }, + new VFile(), + ); + expect(toText(ref.children)).toBe('Appendix A'); + }); +}); + describe('initializeTargetCounts', () => { test('no inputs initializes heading', () => { expect(initializeTargetCounts({})).toEqual({ heading: [0, 0, 0, 0, 0, 0] }); diff --git a/packages/myst-transforms/src/enumerate.ts b/packages/myst-transforms/src/enumerate.ts index 65f15a1bbd..851b268ff7 100644 --- a/packages/myst-transforms/src/enumerate.ts +++ b/packages/myst-transforms/src/enumerate.ts @@ -90,9 +90,12 @@ function getReferenceTemplate( let template: string | undefined; if (numbered) { if (kind === TargetKind.heading && node.type === 'heading') { - template = - numbering[`heading_${node.depth - (numbering?.title?.enabled ? 0 : 1) + (offset ?? 0)}`] - ?.template; + // §3.2(h): for heading-type targets, `label` takes precedence over + // `template`. This is what makes `[](#ch1)` render "Chapter 1" rather + // than "Section 1" when a project sets `numbering.heading_1.label`. + const item = + numbering[`heading_${node.depth - (numbering?.title?.enabled ? 0 : 1) + (offset ?? 0)}`]; + template = item?.label ?? item?.template; } else if (node.subcontainer) { template = numbering.subfigure?.template; } else { @@ -597,7 +600,14 @@ export function addChildrenFromTargetNode( const kind = kindFromNode(targetNode); const noNodeChildren = !node.children?.length; if (kind === TargetKind.heading) { - const numberHeading = shouldEnumerateNode(targetNode, TargetKind.heading, numbering); + // §3.4(8) / #12 fix: a heading that nominally has numbering enabled but + // never received an enumerator (e.g. on a page with page-level + // `numbering: false`, or under frontmatter:/backmatter: in book mode) + // must fall through to the title-only template. Otherwise `%s` in the + // heading template substitutes against UNKNOWN_REFERENCE_ENUMERATOR and + // renders "Chapter ??". + const numberHeading = + shouldEnumerateNode(targetNode, TargetKind.heading, numbering) && !!targetNode.enumerator; const template = getReferenceTemplate( { node: targetNode, kind }, numbering, From 88ffc86a02f9daa1423b2a8db6a0a8d148cfaebc Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 14 May 2026 11:38:18 +1000 Subject: [PATCH 04/10] =?UTF-8?q?Apply=20label=20rendering=20to=20file-tar?= =?UTF-8?q?get=20cross-refs=20(PR=20#1,=20=C2=A73.5(5))?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- packages/myst-transforms/src/enumerate.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/myst-transforms/src/enumerate.ts b/packages/myst-transforms/src/enumerate.ts index 851b268ff7..9d7e45cc29 100644 --- a/packages/myst-transforms/src/enumerate.ts +++ b/packages/myst-transforms/src/enumerate.ts @@ -567,14 +567,24 @@ export class ReferenceState implements IReferenceStateResolver { resolveReferenceContent(node: ResolvableCrossReference) { const fileTarget = this.getFileTarget(node.identifier); if (fileTarget) { - const { url, title, dataUrl } = fileTarget; + const { url, title, dataUrl, enumerator } = fileTarget; if (url) { const nodeAsLink = node as unknown as Link; nodeAsLink.type = 'link'; nodeAsLink.url = url; nodeAsLink.internal = true; if (dataUrl) nodeAsLink.dataUrl = dataUrl; - updateLinkTextIfEmpty(nodeAsLink, title ?? url); + // §3.2(h): file-targets are the page's H1 (the title heading), so + // apply the same label > template > title fallback used for inline + // headings. The page's `heading_1` numbering item carries the book + // mode's "Chapter %s" / "Appendix %s" label. + let text: string | undefined; + if (enumerator) { + const item = fileTarget.numbering?.heading_1; + const template = item?.label ?? item?.template; + if (template) text = template.replace(/%s/g, enumerator); + } + updateLinkTextIfEmpty(nodeAsLink, text ?? title ?? url); } return; } From 61f74f4b0ef666d5b221c8eb315c780001b69436 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 14 May 2026 12:16:37 +1000 Subject: [PATCH 05/10] =?UTF-8?q?Inject=20per-section=20numbering=20defaul?= =?UTF-8?q?ts=20for=20tagged=20TOC=20subtrees=20(PR=20#1,=20=C2=A73.5(3))?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../myst-cli/src/process/bookSection.spec.ts | 77 +++++++++++++++++++ packages/myst-cli/src/process/mdast.ts | 51 ++++++++++++ packages/myst-cli/src/process/site.ts | 59 ++++++++++++-- packages/myst-cli/src/project/fromTOC.ts | 38 +++++++-- packages/myst-cli/src/project/types.ts | 6 ++ packages/myst-toc/src/toc.ts | 15 +++- packages/myst-toc/src/types.ts | 14 ++++ packages/myst-toc/tests/examples.spec.ts | 31 ++++++++ 8 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 packages/myst-cli/src/process/bookSection.spec.ts diff --git a/packages/myst-cli/src/process/bookSection.spec.ts b/packages/myst-cli/src/process/bookSection.spec.ts new file mode 100644 index 0000000000..2321192a22 --- /dev/null +++ b/packages/myst-cli/src/process/bookSection.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'vitest'; +import type { PageFrontmatter } from 'myst-frontmatter'; +import { injectBookSectionDefaults } from './mdast.js'; + +describe('injectBookSectionDefaults', () => { + test('no-op when book mode is off', () => { + const fm: PageFrontmatter = { numbering: {} }; + injectBookSectionDefaults(fm, 'chapters', false); + expect(fm.numbering).toEqual({}); + }); + + test('no-op when section is undefined', () => { + const fm: PageFrontmatter = { numbering: { book: { enabled: true } } }; + injectBookSectionDefaults(fm, undefined, false); + expect(fm.numbering).toEqual({ book: { enabled: true } }); + }); + + test('chapters section seeds heading_1 with the Chapter label', () => { + const fm: PageFrontmatter = { numbering: { book: { enabled: true } } }; + injectBookSectionDefaults(fm, 'chapters', false); + expect(fm.numbering?.heading_1).toEqual({ enabled: true, label: 'Chapter %s' }); + }); + + test('first chapter gets start: 1', () => { + const fm: PageFrontmatter = { numbering: { book: { enabled: true } } }; + injectBookSectionDefaults(fm, 'chapters', true); + expect(fm.numbering?.heading_1).toEqual({ start: 1, enabled: true, label: 'Chapter %s' }); + }); + + test('appendices section seeds Alph format + Appendix label', () => { + const fm: PageFrontmatter = { numbering: { book: { enabled: true } } }; + injectBookSectionDefaults(fm, 'appendices', false); + expect(fm.numbering?.heading_1).toEqual({ + enabled: true, + format: 'Alph', + label: 'Appendix %s', + }); + }); + + test('first appendix gets start: 1 (counter reset on section transition)', () => { + const fm: PageFrontmatter = { numbering: { book: { enabled: true } } }; + injectBookSectionDefaults(fm, 'appendices', true); + expect(fm.numbering?.heading_1).toEqual({ + start: 1, + enabled: true, + format: 'Alph', + label: 'Appendix %s', + }); + }); + + test('frontmatter section disables heading_1 (skip-semantic)', () => { + const fm: PageFrontmatter = { numbering: { book: { enabled: true } } }; + injectBookSectionDefaults(fm, 'frontmatter', true); + expect(fm.numbering?.heading_1?.enabled).toBe(false); + }); + + test('backmatter section disables heading_1', () => { + const fm: PageFrontmatter = { numbering: { book: { enabled: true } } }; + injectBookSectionDefaults(fm, 'backmatter', false); + expect(fm.numbering?.heading_1?.enabled).toBe(false); + }); + + test('does not clobber explicit author settings', () => { + const fm: PageFrontmatter = { + numbering: { + book: { enabled: true }, + heading_1: { label: 'Section %s', format: 'roman' }, + }, + }; + injectBookSectionDefaults(fm, 'chapters', false); + // author-set label and format are preserved + expect(fm.numbering?.heading_1?.label).toBe('Section %s'); + expect(fm.numbering?.heading_1?.format).toBe('roman'); + // enabled is filled in (the default kicks in only when unset) + expect(fm.numbering?.heading_1?.enabled).toBe(true); + }); +}); diff --git a/packages/myst-cli/src/process/mdast.ts b/packages/myst-cli/src/process/mdast.ts index 24602edd2f..0b5fccbcea 100644 --- a/packages/myst-cli/src/process/mdast.ts +++ b/packages/myst-cli/src/process/mdast.ts @@ -103,6 +103,50 @@ export type TransformFn = ( opts: Parameters[1], ) => Promise; +/** + * Per-section defaults injected into page frontmatter when + * `numbering.book.enabled === true` and the page carries a `section:` tag + * from the TOC (§3.5(3)). + * + * Each entry seeds `heading_1` with sensible defaults that the author can + * override at any layer above (page frontmatter, project numbering). + * Defaults are applied with `??=`-style precedence — never clobbering an + * explicit setting. + */ +export function injectBookSectionDefaults( + frontmatter: PageFrontmatter, + section?: import('myst-toc').BookSection, + firstInSection?: boolean, +) { + if (!section) return; + if (!frontmatter.numbering?.book?.enabled) return; + const numbering = frontmatter.numbering; + numbering.heading_1 ??= {}; + const h1 = numbering.heading_1; + // `firstInSection` resets the heading_1 counter at section transitions + // (chapters → appendices) so the first appendix renders "A" rather than + // continuing the chapter sequence. Subsequent appendix pages have no + // `start` and continue naturally. + if (firstInSection && h1.start == null) h1.start = 1; + switch (section) { + case 'chapters': + h1.enabled ??= true; + h1.label ??= 'Chapter %s'; + // arabic is the default — no need to set `format`. + break; + case 'appendices': + h1.enabled ??= true; + h1.format ??= 'Alph'; + h1.label ??= 'Appendix %s'; + break; + case 'frontmatter': + case 'backmatter': + // Skip-semantic: do not advance the title counter (§3.4(1)). + h1.enabled = false; + break; + } +} + export async function transformMdast( session: ISession, opts: { @@ -118,6 +162,10 @@ export async function transformMdast( index?: string; titleDepth?: number; offset?: number; + /** Book section the page belongs to — set by the TOC walker. */ + section?: import('myst-toc').BookSection; + /** True when this page is the first one in its book section. */ + firstInSection?: boolean; }, ) { const { @@ -132,6 +180,8 @@ export async function transformMdast( index, titleDepth, // Related to title set in markdown, rather than frontmatter offset, // Related to multi-page nesting + section, + firstInSection, execute, } = opts; const toc = tic(); @@ -172,6 +222,7 @@ export async function transformMdast( if (!frontmatter.numbering.title) frontmatter.numbering.title = {}; if (frontmatter.numbering.title.offset == null) frontmatter.numbering.title.offset = offset; } + injectBookSectionDefaults(frontmatter, section, firstInSection); await addEditUrl(session, frontmatter, file); const references: References = { cite: { order: [], data: {} }, diff --git a/packages/myst-cli/src/process/site.ts b/packages/myst-cli/src/process/site.ts index 9d4a6f92a8..06bea8a137 100644 --- a/packages/myst-cli/src/process/site.ts +++ b/packages/myst-cli/src/process/site.ts @@ -28,7 +28,8 @@ import { writeRemoteDOIBibtex } from '../build/utils/bibtex.js'; import { MYST_DOI_BIB_FILE } from '../cli/options.js'; import { filterPages, loadProjectFromDisk } from '../project/load.js'; import { DEFAULT_INDEX_FILENAMES } from '../project/fromTOC.js'; -import type { LocalProject, LocalProjectPage } from '../project/types.js'; +import type { LocalProject, LocalProjectFolder, LocalProjectPage } from '../project/types.js'; +import type { BookSection } from 'myst-toc'; import { castSession } from '../session/cache.js'; import type { ISession } from '../session/types.js'; import { selectors } from '../store/index.js'; @@ -384,6 +385,43 @@ export function selectPageReferenceStates( return pageReferenceStates; } +/** + * Walk a TOC-ordered page list and assign each file a `section` (inherited + * from its enclosing subtree's `section:` field) plus a `firstInSection` + * flag for the first page of each contiguous run. The flag is what + * `injectBookSectionDefaults` uses to reset the heading_1 counter at + * section transitions (so the first appendix renders "A" rather than + * continuing the chapter sequence). + */ +function computeSectionMetadata( + pages: (LocalProjectPage | LocalProjectFolder | { file: string })[], +): Map { + const out = new Map(); + let lastSection: BookSection | undefined; + let sawAnyInSection = new Set(); + for (const page of pages) { + const file = (page as { file?: string }).file; + if (!file) { + // A folder entry (no file). Folders don't reset state; the section + // is propagated by descendants instead. + continue; + } + const section = (page as LocalProjectPage).section; + let firstInSection = false; + if (section) { + if (section !== lastSection && !sawAnyInSection.has(section)) { + firstInSection = true; + sawAnyInSection.add(section); + } + lastSection = section; + } else { + lastSection = undefined; + } + out.set(file, { section, firstInSection }); + } + return out; +} + async function resolvePageSource(session: ISession, file: string) { const fileHash = hashAndCopyStaticFile(session, file, session.publicPath(), (m: string) => { addWarningForFile(session, file, m, 'error', { @@ -462,9 +500,12 @@ export async function fastProcessFile( const state = session.store.getState(); const fileParts = selectors.selectFileParts(state, file); const projectParts = selectors.selectProjectParts(state, projectPath); + const sectionMeta = computeSectionMetadata(pages); await Promise.all( [file, ...fileParts].map(async (f) => { - const level = pages.find((page) => page.file === file)?.level; + const page = pages.find((p) => p.file === file); + const level = page?.level; + const meta = sectionMeta.get(file); return transformMdast(session, { file: f, imageExtensions: imageExtensions ?? WEB_IMAGE_EXTENSIONS, @@ -476,6 +517,8 @@ export async function fastProcessFile( index: project.index, execute, offset: level ? level - 1 : undefined, + section: meta?.section, + firstInSection: meta?.firstInSection, }); }), ); @@ -578,11 +621,13 @@ export async function processProject( ...pages, ...projectParts, ]; + const sectionMeta = computeSectionMetadata(pages); const usedImageExtensions = imageExtensions ?? WEB_IMAGE_EXTENSIONS; // Transform all pages await Promise.all( - pagesToTransform.map((page) => - transformMdast(session, { + pagesToTransform.map((page) => { + const meta = sectionMeta.get(page.file); + return transformMdast(session, { file: page.file, projectPath: project.path, projectSlug: siteProject.slug, @@ -593,8 +638,10 @@ export async function processProject( extraTransforms, index: project.index, offset: page.level ? page.level - 1 : undefined, - }), - ), + section: meta?.section, + firstInSection: meta?.firstInSection, + }); + }), ); const pageReferenceStates = selectPageReferenceStates(session, pagesToTransform); diff --git a/packages/myst-cli/src/project/fromTOC.ts b/packages/myst-cli/src/project/fromTOC.ts index 6ad01d00d6..d81133fd98 100644 --- a/packages/myst-cli/src/project/fromTOC.ts +++ b/packages/myst-cli/src/project/fromTOC.ts @@ -28,6 +28,7 @@ import type { URLEntry, FileParentEntry, URLParentEntry, + BookSection, } from 'myst-toc'; import { isFile, isPattern, isURL } from 'myst-toc'; import { globSync } from 'glob'; @@ -168,10 +169,15 @@ function pagesFromEntries( level: PageLevels = 1, pageSlugs: PageSlugs, opts?: SlugOptions, + inheritedSection?: BookSection, ): (LocalProjectFolder | LocalProjectPage)[] { const configFile = selectors.selectLocalConfigFile(session.store.getState(), path); for (const entry of entries) { let entryLevel = level; + // A subtree with `section:` tags its descendants; siblings without it + // keep the inherited value (so a top-level "Appendices" subtree wraps + // every descendant page even if intermediate parents omit `section:`). + const childSection: BookSection | undefined = entry.section ?? inheritedSection; if (isFile(entry)) { // Level must be "chapter" (0) or "section" (1-6) for files entryLevel = level < 0 ? 0 : level; @@ -184,7 +190,13 @@ function pagesFromEntries( }); if (resolvedFile && fs.existsSync(resolvedFile) && !isDirectory(resolvedFile)) { const { slug } = fileInfo(resolvedFile, pageSlugs, { ...opts, session }); - pages.push({ file: resolvedFile, level: entryLevel, slug, ...leftover }); + pages.push({ + file: resolvedFile, + level: entryLevel, + slug, + ...leftover, + ...(childSection ? { section: childSection } : {}), + }); } } else if (isURL(entry)) { pages.push({ @@ -194,9 +206,23 @@ function pagesFromEntries( open_in_same_tab: entry.open_in_same_tab, }); } else { - // Parent Entry - may be a "part" with level -1 - entryLevel = level < -1 ? -1 : level; - pages.push({ level: entryLevel, title: entry.title }); + // ParentEntry. A `section:`-tagged subtree is a *logical* group, not + // a structural one: it doesn't emit a folder entry and doesn't bump + // the heading depth for its children. Without this, the section + // subtree would behave like a part header — ch1's H1 would land at + // heading_2 instead of heading_1 and book-section defaults wouldn't + // line up with what authors actually wrote on the page. + if (entry.section) { + // childSection is already entry.section (set above); fall through + // to recurse with the same level. + } else { + // Parent Entry - may be a "part" with level -1 + entryLevel = level < -1 ? -1 : level; + pages.push({ + level: entryLevel, + title: entry.title, + }); + } } // Do we have any children? @@ -207,9 +233,11 @@ function pagesFromEntries( path, parentEntry.children as EntryWithoutPattern[], pages, - nextLevel(entryLevel), + // section subtree → children stay at the parent's level + (entry as { section?: BookSection }).section ? entryLevel : nextLevel(entryLevel), pageSlugs, opts, + childSection, ); } } diff --git a/packages/myst-cli/src/project/types.ts b/packages/myst-cli/src/project/types.ts index 398ef67f8f..f9f8279f62 100644 --- a/packages/myst-cli/src/project/types.ts +++ b/packages/myst-cli/src/project/types.ts @@ -11,9 +11,13 @@ export type PageSlugs = Record; */ export type PageLevels = -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6; +import type { BookSection } from 'myst-toc'; + export type LocalProjectFolder = { title: string; level: PageLevels; + /** Inherited from the TOC subtree's `section:` for book-style numbering */ + section?: BookSection; }; export type LocalProjectPage = { @@ -24,6 +28,8 @@ export type LocalProjectPage = { title?: string; /** Flag to mark if the page is implied from a TOC pattern or folder structure */ implicit?: boolean; + /** Inherited from the TOC subtree's `section:` for book-style numbering */ + section?: BookSection; }; export type ExternalURL = { diff --git a/packages/myst-toc/src/toc.ts b/packages/myst-toc/src/toc.ts index 815132d3d3..2f85b6e72f 100644 --- a/packages/myst-toc/src/toc.ts +++ b/packages/myst-toc/src/toc.ts @@ -23,8 +23,9 @@ import { validateChoice, } from 'simple-validators'; -const COMMON_ENTRY_KEYS = ['title', 'hidden']; -// const COMMON_ENTRY_KEYS = ['title', 'hidden', 'numbering', 'id', 'class']; +const BOOK_SECTION_CHOICES = ['frontmatter', 'chapters', 'appendices', 'backmatter']; +const COMMON_ENTRY_KEYS = ['title', 'hidden', 'section']; +// const COMMON_ENTRY_KEYS = ['title', 'hidden', 'section', 'numbering', 'id', 'class']; function validateCommonEntry(entry: Record, opts: ValidationOptions): CommonEntry { const output: CommonEntry = {}; @@ -36,6 +37,16 @@ function validateCommonEntry(entry: Record, opts: ValidationOptions output.hidden = validateBoolean(entry.hidden, incrementOptions('hidden', opts)); } + if (defined(entry.section)) { + const section = validateChoice(entry.section, { + ...incrementOptions('section', opts), + choices: BOOK_SECTION_CHOICES, + }); + if (section !== undefined) { + output.section = section as CommonEntry['section']; + } + } + // if (defined(entry.numbering)) { // output.numbering = validateString(entry.numbering, incrementOptions('numbering', opts)); // } diff --git a/packages/myst-toc/src/types.ts b/packages/myst-toc/src/types.ts index 835fe0dffc..0e6fe2ab2a 100644 --- a/packages/myst-toc/src/types.ts +++ b/packages/myst-toc/src/types.ts @@ -1,3 +1,11 @@ +/** + * Named book sections for `section:` on a ParentEntry. Pages under a + * `section`-tagged subtree inherit the section's numbering defaults + * (chapters → arabic, appendices → Alph, etc.) once `numbering.book: true` + * is set on the project. + */ +export type BookSection = 'frontmatter' | 'chapters' | 'appendices' | 'backmatter'; + /** * Common attributes for all TOC items * Should be taken as a Partial<> @@ -5,6 +13,12 @@ export type CommonEntry = { title?: string; hidden?: boolean; + /** + * Book-style section tag. When set on a ParentEntry, every descendant + * page is treated as belonging to that named section for numbering + * purposes. Only meaningful when project `numbering.book: true`. + */ + section?: BookSection; // numbering?: string; // id?: string; // class?: string; diff --git a/packages/myst-toc/tests/examples.spec.ts b/packages/myst-toc/tests/examples.spec.ts index 6392de7eef..72e75ec1c0 100644 --- a/packages/myst-toc/tests/examples.spec.ts +++ b/packages/myst-toc/tests/examples.spec.ts @@ -151,3 +151,34 @@ describe.each([ } }); }); + +describe('book section field', () => { + test.each(['frontmatter', 'chapters', 'appendices', 'backmatter'])( + 'section: %s parses on a ParentEntry', + (section) => { + const input = [ + { + title: 'Group', + section, + children: [{ file: 'ch1.md' }], + }, + ]; + const toc = validateTOC(input, opts); + expect(opts.messages.errors).toBeUndefined(); + expect(toc).toStrictEqual(input); + }, + ); + + test('section: on a FileEntry parses', () => { + const input = [{ file: 'ch1.md', section: 'chapters' }]; + const toc = validateTOC(input, opts); + expect(opts.messages.errors).toBeUndefined(); + expect(toc).toStrictEqual(input); + }); + + test('invalid section value errors', () => { + const input = [{ title: 'g', section: 'bogus', children: [{ file: 'a.md' }] }]; + validateTOC(input, opts); + expect(opts.messages.errors?.length).toBe(1); + }); +}); From ccff61a12872c0bd75e6375f69228c69fc1e34b5 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 14 May 2026 12:30:03 +1000 Subject: [PATCH 06/10] =?UTF-8?q?Auto-prefix=20figures/equations/tables/pr?= =?UTF-8?q?oofs=20with=20chapter=20enumerator=20(PR=20#1,=20=C2=A73.5(4))?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../myst-transforms/src/enumerate.spec.ts | 234 ++++++++++++++++++ packages/myst-transforms/src/enumerate.ts | 56 ++++- 2 files changed, 287 insertions(+), 3 deletions(-) diff --git a/packages/myst-transforms/src/enumerate.spec.ts b/packages/myst-transforms/src/enumerate.spec.ts index 424b98a77e..ec17a837be 100644 --- a/packages/myst-transforms/src/enumerate.spec.ts +++ b/packages/myst-transforms/src/enumerate.spec.ts @@ -174,6 +174,142 @@ describe('enumeration', () => { expect(state.getTarget('fig:2')?.node.enumerator).toBe('A.2'); }); }); +describe('Book-mode auto-prefix (§3.4(6,7))', () => { + test('figure picks up chapter prefix when book mode is on', () => { + const tree = u('root', [ + u('container', { kind: 'figure', identifier: 'fig1' }), + u('container', { kind: 'figure', identifier: 'fig2' }), + ]); + const state = new ReferenceState('ch1.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true, label: 'Chapter %s' }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.enumerator).toBe('1'); + expect(state.getTarget('fig1')?.node.enumerator).toBe('1.1'); + expect(state.getTarget('fig2')?.node.enumerator).toBe('1.2'); + }); + + test('appendix Alph prefix flows to figures', () => { + const tree = u('root', [u('container', { kind: 'figure', identifier: 'fa' })]); + const state = new ReferenceState('app-a.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true, format: 'Alph', label: 'Appendix %s' }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.enumerator).toBe('A'); + expect(state.getTarget('fa')?.node.enumerator).toBe('A.1'); + }); + + test('continue: true opts out of prefix and keeps counter flat', () => { + const tree = u('root', [ + u('container', { kind: 'figure', identifier: 'figc' }), + ]); + const state = new ReferenceState('ch1.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true }, + figure: { continue: true }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('figc')?.node.enumerator).toBe('1'); + }); + + test('no auto-prefix when book mode is off', () => { + const tree = u('root', [u('container', { kind: 'figure', identifier: 'fx' })]); + const state = new ReferenceState('p.md', { + frontmatter: { + numbering: { + // book flag not set → today's behaviour preserved + title: { enabled: true }, + heading_1: { enabled: true }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('fx')?.node.enumerator).toBe('1'); + }); + + test('unnumbered page (no enumerator) → no prefix even in book mode', () => { + // mimics a frontmatter/backmatter page where heading_1.enabled is false + const tree = u('root', [u('container', { kind: 'figure', identifier: 'fz' })]); + const state = new ReferenceState('preface.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: false }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.enumerator).toBeUndefined(); + expect(state.getTarget('fz')?.node.enumerator).toBe('1'); + }); + + test('equation and table also pick up the prefix', () => { + const tree = u('root', [ + u('math', { identifier: 'eq1' }), + u('container', { kind: 'table', identifier: 't1' }), + ]); + const state = new ReferenceState('ch1.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('eq1')?.node.enumerator).toBe('1.1'); + expect(state.getTarget('t1')?.node.enumerator).toBe('1.1'); + }); + + test('subfigures inherit the chapter-prefixed parent enumerator', () => { + const tree = u('root', [ + u('container', { kind: 'figure', identifier: 'fig-p' }, [ + u('container', { kind: 'figure', subcontainer: true, identifier: 'fig-p-a' }), + u('container', { kind: 'figure', subcontainer: true, identifier: 'fig-p-b' }), + ]), + ]); + const state = new ReferenceState('ch2.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true, start: 2 }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.enumerator).toBe('2'); + expect(state.getTarget('fig-p')?.node.enumerator).toBe('2.1'); + expect(state.getTarget('fig-p-a')?.node.parentEnumerator).toBe('2.1'); + }); +}); + describe('Heading cross-ref rendering (§3.2(h))', () => { test('label takes precedence over template for numbered heading', () => { const heading = u('heading', { @@ -274,6 +410,104 @@ describe('Heading cross-ref rendering (§3.2(h))', () => { }); }); +describe('Book-mode auto-prefix for figures and equations (§3.2(e))', () => { + function pageState(opts: { + enumerator?: string; + book?: boolean; + figureContinue?: boolean; + }) { + return new ReferenceState('p.md', { + frontmatter: { + numbering: { + ...(opts.book ? { book: { enabled: true } } : {}), + title: { enabled: true }, + heading_1: { enabled: true }, + figure: { + enabled: true, + ...(opts.figureContinue ? { continue: true } : {}), + }, + }, + // Force the constructor to NOT auto-enumerate the title, so we can + // set this.enumerator explicitly for the test. + content_includes_title: true, + } as any, + vfile: new VFile(), + }); + } + + test('book mode prefixes figure with page enumerator', () => { + const state = pageState({ book: true }); + (state as any).enumerator = '3'; + const tree = u('root', [ + u('container', { kind: 'figure', identifier: 'f1' }), + u('container', { kind: 'figure', identifier: 'f2' }), + ]); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('f1')?.node.enumerator).toBe('3.1'); + expect(state.getTarget('f2')?.node.enumerator).toBe('3.2'); + }); + + test('appendix-style Alph enumerator prefixes correctly', () => { + const state = pageState({ book: true }); + (state as any).enumerator = 'A'; + const tree = u('root', [ + u('container', { kind: 'figure', identifier: 'fA1' }), + u('container', { kind: 'figure', identifier: 'fA2' }), + ]); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('fA1')?.node.enumerator).toBe('A.1'); + expect(state.getTarget('fA2')?.node.enumerator).toBe('A.2'); + }); + + test('no book mode → no prefix (today\'s behavior preserved)', () => { + const state = pageState({ book: false }); + (state as any).enumerator = '3'; + const tree = u('root', [u('container', { kind: 'figure', identifier: 'f1' })]); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('f1')?.node.enumerator).toBe('1'); + }); + + test('figure.continue: true opts out of prefix (flat counter)', () => { + const state = pageState({ book: true, figureContinue: true }); + (state as any).enumerator = '3'; + const tree = u('root', [u('container', { kind: 'figure', identifier: 'f1' })]); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('f1')?.node.enumerator).toBe('1'); + }); + + test('frontmatter page (no enumerator) keeps flat global counter', () => { + const state = pageState({ book: true }); + // no this.enumerator set + const tree = u('root', [u('container', { kind: 'figure', identifier: 'f1' })]); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('f1')?.node.enumerator).toBe('1'); + }); + + test('book mode prefixes proof:theorem and exercise', () => { + const state = new ReferenceState('p.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true }, + 'proof:theorem': { enabled: true }, + exercise: { enabled: true }, + }, + content_includes_title: true, + } as any, + vfile: new VFile(), + }); + (state as any).enumerator = '6'; + const tree = u('root', [ + u('proof', { kind: 'theorem', identifier: 'thm1' }), + u('exercise', { identifier: 'ex1' }), + ]); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('thm1')?.node.enumerator).toBe('6.1'); + expect(state.getTarget('ex1')?.node.enumerator).toBe('6.1'); + }); +}); + describe('initializeTargetCounts', () => { test('no inputs initializes heading', () => { expect(initializeTargetCounts({})).toEqual({ heading: [0, 0, 0, 0, 0, 0] }); diff --git a/packages/myst-transforms/src/enumerate.ts b/packages/myst-transforms/src/enumerate.ts index 9d7e45cc29..b4e2a5cc53 100644 --- a/packages/myst-transforms/src/enumerate.ts +++ b/packages/myst-transforms/src/enumerate.ts @@ -27,6 +27,41 @@ import type { CounterFormat, PageFrontmatter, Numbering } from 'myst-frontmatter const TRANSFORM_NAME = 'myst-transforms:enumerate'; +/** + * Kinds that get the chapter/appendix enumerator prepended when the page is + * inside a book section (§3.4(6)). Each kind keeps its own counter; only the + * leading prefix changes per-page. Authors opt out per-kind via + * `numbering..continue: true` (§3.4(7)), which both keeps the counter + * flat across pages and drops the prefix. + * + * Proof family kinds (`proof:theorem`, `proof:lemma`, …) are matched by + * prefix so adding a new proof kind upstream doesn't require touching this + * list. + */ +const AUTO_PREFIX_KINDS = new Set([ + 'figure', + 'subfigure', + 'equation', + 'subequation', + 'table', + 'exercise', +]); + +/** + * Pure kind matcher — does this kind belong to the family that gets the + * chapter/appendix prefix when book mode is on? The caller also checks + * `numbering.book.enabled`, `numbering[kind].continue`, and the + * page-side enumerator before applying the prefix. + */ +function shouldAutoPrefix(kind: string): boolean { + if (AUTO_PREFIX_KINDS.has(kind)) return true; + // Cover both the `proof` directive (`type: proof`) and the legacy + // `prf:*` naming so future renames don't break book mode. + if (kind.startsWith('proof:') || kind.startsWith('prf:')) return true; + if (kind === 'proof') return true; + return false; +} + const DEFAULT_NUMBERING: Numbering = { equation: { enabled: true, template: '(%s)' }, subequation: { enabled: true, template: '(%s)' }, @@ -511,6 +546,21 @@ export class ReferenceState implements IReferenceStateResolver { // Ensure target kind is instantiated this.targetCounts[countKind] ??= { main: 0, sub: 0 }; const kindFormat = this.numbering[countKind]?.format; + // §3.2(e) auto-prefix: in book mode, prepend the active chapter or + // appendix enumerator (this.enumerator — the page's H1 number / letter) + // so figures render "3.1", "A.2", etc. Pages in front/back matter have + // no this.enumerator, so the flat global counter is used automatically. + // 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 = + this.enumerator && + this.numbering.book?.enabled && + !continueKind && + shouldAutoPrefix(countKind) + ? `${this.enumerator}.` + : ''; if (node.subcontainer || kind === TargetKind.subequation) { this.targetCounts[countKind].sub += 1; // Will restart counting if there are more than 26 subequations/figures @@ -519,13 +569,13 @@ export class ReferenceState implements IReferenceStateResolver { ); if (node.subcontainer) { node.parentEnumerator = this.resolveEnumerator( - formatCounter(this.targetCounts[countKind].main, kindFormat), + autoPrefix + formatCounter(this.targetCounts[countKind].main, kindFormat), this.numbering[countKind]?.enumerator, ); enumerator = letter; } else { enumerator = this.resolveEnumerator( - formatCounter(this.targetCounts[countKind].main, kindFormat) + letter, + autoPrefix + formatCounter(this.targetCounts[countKind].main, kindFormat) + letter, this.numbering[countKind]?.enumerator, ); } @@ -533,7 +583,7 @@ export class ReferenceState implements IReferenceStateResolver { this.targetCounts[kind].main += 1; this.targetCounts[kind].sub = 0; enumerator = this.resolveEnumerator( - formatCounter(this.targetCounts[kind].main, kindFormat), + autoPrefix + formatCounter(this.targetCounts[kind].main, kindFormat), this.numbering[kind]?.enumerator, ); } From 8409c8cba1923bfa725087033a06f5c097448403 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 14 May 2026 12:36:02 +1000 Subject: [PATCH 07/10] Test proof:* / exercise auto-prefix and render-only format override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../myst-transforms/src/enumerate.spec.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/myst-transforms/src/enumerate.spec.ts b/packages/myst-transforms/src/enumerate.spec.ts index ec17a837be..086667ad10 100644 --- a/packages/myst-transforms/src/enumerate.spec.ts +++ b/packages/myst-transforms/src/enumerate.spec.ts @@ -286,6 +286,86 @@ describe('Book-mode auto-prefix (§3.4(6,7))', () => { expect(state.getTarget('t1')?.node.enumerator).toBe('1.1'); }); + test('proof:* and exercise pick up the chapter prefix', () => { + // §3.5(6): the auto-prefix matcher uses a `proof:` prefix test so any + // proof-family kind (theorem/lemma/proposition/…) and exercise pick up + // the chapter prefix without each kind needing to be enumerated. + const tree = u('root', [ + u('proof', { kind: 'theorem', identifier: 'thm:1' }), + u('proof', { kind: 'lemma', identifier: 'lem:1' }), + u('exercise', { identifier: 'ex:1', enumerated: true }), + ]); + const state = new ReferenceState('ch3.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true }, // enable every kind incl. proof:*/exercise + title: { enabled: true }, + heading_1: { enabled: true, start: 3 }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.enumerator).toBe('3'); + expect(state.getTarget('thm:1')?.node.enumerator).toBe('3.1'); + expect(state.getTarget('lem:1')?.node.enumerator).toBe('3.1'); + // Each proof-family kind keeps its own counter — theorem and lemma both + // start at 3.1 in the same chapter; only the chapter prefix is shared. + expect(state.getTarget('ex:1')?.node.enumerator).toBe('3.1'); + }); + + test('page-level format override is render-only (§3.4(9))', () => { + // A chapter page sets `format: Roman` in its frontmatter. Only that + // page's rendered enumerator changes; the underlying counter + // sequence stays 1, 2, 3, 4 so siblings render arithmetic-naturally. + // Modelled here via the `previousCounts` chain across three pages. + const ch1 = new ReferenceState('ch1.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true, label: 'Chapter %s' }, + }, + }, + vfile: new VFile(), + }); + expect(ch1.enumerator).toBe('1'); + + const ch2 = new ReferenceState('ch2.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + title: { enabled: true }, + // page-level override flips this chapter's rendering only + heading_1: { enabled: true, label: 'Chapter %s', format: 'Roman' }, + }, + }, + previousCounts: ch1.targetCounts, + vfile: new VFile(), + }); + expect(ch2.enumerator).toBe('II'); // rendered as Roman + // The underlying count is still 2 (not converted) — confirm by reading + // targetCounts.heading[0] directly. The Roman is purely a render + // detail at formatHeadingEnumerator time. + expect(ch2.targetCounts.heading[0]).toBe(2); + + const ch3 = new ReferenceState('ch3.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true, label: 'Chapter %s' }, + }, + }, + previousCounts: ch2.targetCounts, + vfile: new VFile(), + }); + // ch3 picks up where ch2 left off arithmetically — "3", not "4" — so + // the page-level Roman override did not disturb the sequence. + expect(ch3.enumerator).toBe('3'); + }); + test('subfigures inherit the chapter-prefixed parent enumerator', () => { const tree = u('root', [ u('container', { kind: 'figure', identifier: 'fig-p' }, [ From db7ddea954a9c940d16bff040e6dc60539c72714 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 14 May 2026 12:59:32 +1000 Subject: [PATCH 08/10] 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). --- .../myst-transforms/src/enumerate.spec.ts | 70 +++++++++++-------- packages/myst-transforms/src/enumerate.ts | 10 +-- 2 files changed, 45 insertions(+), 35 deletions(-) diff --git a/packages/myst-transforms/src/enumerate.spec.ts b/packages/myst-transforms/src/enumerate.spec.ts index 086667ad10..f825c8a7b6 100644 --- a/packages/myst-transforms/src/enumerate.spec.ts +++ b/packages/myst-transforms/src/enumerate.spec.ts @@ -53,7 +53,7 @@ describe('formatHeadingEnumerator with formats', () => { test('Roman at depth 1, arabic sub-headings', () => { expect(formatHeadingEnumerator([3, 2, 1, 0, 0, 0], undefined, ['Roman'])).toBe('III.2.1'); }); - test('no formats array preserves today\'s arabic behaviour', () => { + test("no formats array preserves today's arabic behaviour", () => { expect(formatHeadingEnumerator([1, 2, 0, 0, 0, 0])).toBe('1.2'); }); }); @@ -214,9 +214,7 @@ describe('Book-mode auto-prefix (§3.4(6,7))', () => { }); test('continue: true opts out of prefix and keeps counter flat', () => { - const tree = u('root', [ - u('container', { kind: 'figure', identifier: 'figc' }), - ]); + const tree = u('root', [u('container', { kind: 'figure', identifier: 'figc' })]); const state = new ReferenceState('ch1.md', { frontmatter: { numbering: { @@ -392,11 +390,15 @@ describe('Book-mode auto-prefix (§3.4(6,7))', () => { describe('Heading cross-ref rendering (§3.2(h))', () => { test('label takes precedence over template for numbered heading', () => { - const heading = u('heading', { - identifier: 'ch1', - depth: 1, - enumerator: '1', - }, [u('text', 'Introduction')]); + const heading = u( + 'heading', + { + identifier: 'ch1', + depth: 1, + enumerator: '1', + }, + [u('text', 'Introduction')], + ); const ref: any = { type: 'crossReference', identifier: 'ch1' }; addChildrenFromTargetNode( ref, @@ -411,11 +413,15 @@ describe('Heading cross-ref rendering (§3.2(h))', () => { }); test('falls back to template when label is absent', () => { - const heading = u('heading', { - identifier: 'h1', - depth: 1, - enumerator: '1', - }, [u('text', 'Introduction')]); + const heading = u( + 'heading', + { + identifier: 'h1', + depth: 1, + enumerator: '1', + }, + [u('text', 'Introduction')], + ); const ref: any = { type: 'crossReference', identifier: 'h1' }; addChildrenFromTargetNode( ref, @@ -448,11 +454,15 @@ describe('Heading cross-ref rendering (§3.2(h))', () => { }); test('explicit link text wins', () => { - const heading = u('heading', { - identifier: 'ch1', - depth: 1, - enumerator: '1', - }, [u('text', 'Introduction')]); + const heading = u( + 'heading', + { + identifier: 'ch1', + depth: 1, + enumerator: '1', + }, + [u('text', 'Introduction')], + ); const ref: any = { type: 'crossReference', identifier: 'ch1', @@ -471,11 +481,15 @@ describe('Heading cross-ref rendering (§3.2(h))', () => { }); test('label with Alph-formatted enumerator (appendix-style)', () => { - const heading = u('heading', { - identifier: 'app-a', - depth: 1, - enumerator: 'A', - }, [u('text', 'Proofs')]); + const heading = u( + 'heading', + { + identifier: 'app-a', + depth: 1, + enumerator: 'A', + }, + [u('text', 'Proofs')], + ); const ref: any = { type: 'crossReference', identifier: 'app-a' }; addChildrenFromTargetNode( ref, @@ -491,11 +505,7 @@ describe('Heading cross-ref rendering (§3.2(h))', () => { }); describe('Book-mode auto-prefix for figures and equations (§3.2(e))', () => { - function pageState(opts: { - enumerator?: string; - book?: boolean; - figureContinue?: boolean; - }) { + function pageState(opts: { enumerator?: string; book?: boolean; figureContinue?: boolean }) { return new ReferenceState('p.md', { frontmatter: { numbering: { @@ -539,7 +549,7 @@ describe('Book-mode auto-prefix for figures and equations (§3.2(e))', () => { expect(state.getTarget('fA2')?.node.enumerator).toBe('A.2'); }); - test('no book mode → no prefix (today\'s behavior preserved)', () => { + test("no book mode → no prefix (today's behavior preserved)", () => { const state = pageState({ book: false }); (state as any).enumerator = '3'; const tree = u('root', [u('container', { kind: 'figure', identifier: 'f1' })]); diff --git a/packages/myst-transforms/src/enumerate.ts b/packages/myst-transforms/src/enumerate.ts index b4e2a5cc53..a01fdc30cd 100644 --- a/packages/myst-transforms/src/enumerate.ts +++ b/packages/myst-transforms/src/enumerate.ts @@ -351,9 +351,10 @@ export function formatHeadingEnumerator( prefix?: string, formats?: (CounterFormat | undefined)[], ): string { - const pairs = counts - .map((c, i) => [c, formats?.[i]] as const) - .filter(([c]) => c !== null) as [number, CounterFormat | undefined][]; + const pairs = counts.map((c, i) => [c, formats?.[i]] as const).filter(([c]) => c !== null) as [ + number, + CounterFormat | undefined, + ][]; while (pairs.length && pairs[pairs.length - 1][0] === 0) { pairs.pop(); } @@ -552,8 +553,7 @@ export class ReferenceState implements IReferenceStateResolver { // no this.enumerator, so the flat global counter is used automatically. // 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 continueKind = this.numbering[countKind]?.continue || this.numbering.all?.continue; const autoPrefix = this.enumerator && this.numbering.book?.enabled && From 8b476e65ccaaf008ff48d1be98bb3648ea9713fe Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 14 May 2026 13:06:02 +1000 Subject: [PATCH 09/10] Fix ESLint errors flagged by CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three errors surfaced by the `lint` workflow's `eslint` job (the `lint:format` job, which is prettier-based, went green after db7ddea9): - 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). --- packages/myst-cli/src/process/mdast.ts | 5 +++-- packages/myst-cli/src/process/site.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/myst-cli/src/process/mdast.ts b/packages/myst-cli/src/process/mdast.ts index 0b5fccbcea..0742681a66 100644 --- a/packages/myst-cli/src/process/mdast.ts +++ b/packages/myst-cli/src/process/mdast.ts @@ -76,6 +76,7 @@ import { bibFilesInDir, selectFile } from './file.js'; import { parseMyst } from './myst.js'; import { kernelExecutionTransform, LocalDiskCache } from 'myst-execute'; import type { IOutput } from '@jupyterlab/nbformat'; +import type { BookSection } from 'myst-toc'; import { rawDirectiveTransform } from '../transforms/raw.js'; import { addEditUrl } from '../utils/addEditUrl.js'; import { @@ -115,7 +116,7 @@ export type TransformFn = ( */ export function injectBookSectionDefaults( frontmatter: PageFrontmatter, - section?: import('myst-toc').BookSection, + section?: BookSection, firstInSection?: boolean, ) { if (!section) return; @@ -163,7 +164,7 @@ export async function transformMdast( titleDepth?: number; offset?: number; /** Book section the page belongs to — set by the TOC walker. */ - section?: import('myst-toc').BookSection; + section?: BookSection; /** True when this page is the first one in its book section. */ firstInSection?: boolean; }, diff --git a/packages/myst-cli/src/process/site.ts b/packages/myst-cli/src/process/site.ts index 06bea8a137..ba89fdcf3c 100644 --- a/packages/myst-cli/src/process/site.ts +++ b/packages/myst-cli/src/process/site.ts @@ -398,7 +398,7 @@ function computeSectionMetadata( ): Map { const out = new Map(); let lastSection: BookSection | undefined; - let sawAnyInSection = new Set(); + const sawAnyInSection = new Set(); for (const page of pages) { const file = (page as { file?: string }).file; if (!file) { From ca72cbd00ddb6b0633c02f9b2ad7ba80eceb3c0d Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 14 May 2026 13:54:34 +1000 Subject: [PATCH 10/10] Address Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.
> 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). --- .../myst-cli/src/process/bookSection.spec.ts | 42 +++++++++++++++++++ packages/myst-cli/src/process/mdast.ts | 13 ++++++ packages/myst-cli/src/project/fromTOC.ts | 9 +++- .../myst-transforms/src/enumerate.spec.ts | 30 +++++++++++++ packages/myst-transforms/src/enumerate.ts | 26 ++++++++---- 5 files changed, 111 insertions(+), 9 deletions(-) diff --git a/packages/myst-cli/src/process/bookSection.spec.ts b/packages/myst-cli/src/process/bookSection.spec.ts index 2321192a22..f211225703 100644 --- a/packages/myst-cli/src/process/bookSection.spec.ts +++ b/packages/myst-cli/src/process/bookSection.spec.ts @@ -60,6 +60,48 @@ describe('injectBookSectionDefaults', () => { expect(fm.numbering?.heading_1?.enabled).toBe(false); }); + test('numbering.chapters.label flows into heading_1', () => { + // Copilot review #2: a project setting `numbering.chapters.label` + // should reach pages in `section: chapters` instead of being + // silently ignored in favour of the hardcoded "Chapter %s". + const fm: PageFrontmatter = { + numbering: { + book: { enabled: true }, + chapters: { label: 'Module %s' }, + }, + }; + injectBookSectionDefaults(fm, 'chapters', false); + expect(fm.numbering?.heading_1?.label).toBe('Module %s'); + }); + + test('numbering.appendices.format flows into heading_1', () => { + const fm: PageFrontmatter = { + numbering: { + book: { enabled: true }, + appendices: { format: 'roman' }, + }, + }; + injectBookSectionDefaults(fm, 'appendices', false); + // section config beats the hardcoded `Alph` default + expect(fm.numbering?.heading_1?.format).toBe('roman'); + // hardcoded label still fills in because section didn't set one + expect(fm.numbering?.heading_1?.label).toBe('Appendix %s'); + }); + + test('page heading_1 beats section config beats hardcoded default', () => { + const fm: PageFrontmatter = { + numbering: { + book: { enabled: true }, + chapters: { label: 'Module %s', format: 'roman' }, + // page-level wins for label; format comes from section config + heading_1: { label: 'Lesson %s' }, + }, + }; + injectBookSectionDefaults(fm, 'chapters', false); + expect(fm.numbering?.heading_1?.label).toBe('Lesson %s'); // page wins + expect(fm.numbering?.heading_1?.format).toBe('roman'); // section wins over hardcoded + }); + test('does not clobber explicit author settings', () => { const fm: PageFrontmatter = { numbering: { diff --git a/packages/myst-cli/src/process/mdast.ts b/packages/myst-cli/src/process/mdast.ts index 0742681a66..36c1c23ea4 100644 --- a/packages/myst-cli/src/process/mdast.ts +++ b/packages/myst-cli/src/process/mdast.ts @@ -129,6 +129,19 @@ export function injectBookSectionDefaults( // continuing the chapter sequence. Subsequent appendix pages have no // `start` and continue naturally. if (firstInSection && h1.start == null) h1.start = 1; + // Author-supplied per-section config (e.g. `numbering.chapters.label` + // or `numbering.appendices.format`) sits between explicit page + // frontmatter and the hardcoded defaults. Each field is filled with + // `??=` so the page-level value wins, then the section config, then + // the hardcoded fallback. Only applies when the section's matching + // kind block is one of the well-known book section keys. + const sectionConfig: { label?: string; format?: string; enabled?: boolean } | undefined = + section === 'chapters' || section === 'appendices' ? numbering[section] : undefined; + if (sectionConfig) { + h1.label ??= sectionConfig.label; + h1.format ??= sectionConfig.format as typeof h1.format; + h1.enabled ??= sectionConfig.enabled; + } switch (section) { case 'chapters': h1.enabled ??= true; diff --git a/packages/myst-cli/src/project/fromTOC.ts b/packages/myst-cli/src/project/fromTOC.ts index d81133fd98..63f0204009 100644 --- a/packages/myst-cli/src/project/fromTOC.ts +++ b/packages/myst-cli/src/project/fromTOC.ts @@ -228,13 +228,18 @@ function pagesFromEntries( // Do we have any children? const parentEntry = entry as Partial; if (parentEntry.children) { + // Only a *section-only* ParentEntry (no `file:`) keeps its children + // at the same level — that's the "logical wrapper" intent. A + // section-tagged FileEntry with children is still a structural + // parent, so its children move to the next level (e.g. ch1.md's + // sub-page becomes heading_2). + const isSectionGroup = !isFile(entry) && (entry as { section?: BookSection }).section; pagesFromEntries( session, path, parentEntry.children as EntryWithoutPattern[], pages, - // section subtree → children stay at the parent's level - (entry as { section?: BookSection }).section ? entryLevel : nextLevel(entryLevel), + isSectionGroup ? entryLevel : nextLevel(entryLevel), pageSlugs, opts, childSection, diff --git a/packages/myst-transforms/src/enumerate.spec.ts b/packages/myst-transforms/src/enumerate.spec.ts index f825c8a7b6..c51e1c85dd 100644 --- a/packages/myst-transforms/src/enumerate.spec.ts +++ b/packages/myst-transforms/src/enumerate.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import { addChildrenFromTargetNode, + MultiPageReferenceResolver, ReferenceState, enumerateTargetsTransform, formatCounter, @@ -502,6 +503,35 @@ describe('Heading cross-ref rendering (§3.2(h))', () => { ); expect(toText(ref.children)).toBe('Appendix A'); }); + + test('file-target on nested page uses heading_${offset+1}, not heading_1', () => { + // Copilot review #1: a nested TOC page has offset>0; its title + // enumerator was generated at heading_${offset+1}. The file-target + // label rendering must read the same depth, otherwise a sub-section + // under a book chapter renders as "Chapter 1.1" (using heading_1's + // book label) instead of "Section 1.1". + const filePage = new ReferenceState('nested.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + title: { enabled: true, offset: 1 }, + heading_1: { enabled: true, label: 'Chapter %s' }, + heading_2: { enabled: true, label: 'Section %s' }, + }, + }, + identifiers: ['nested-page'], + vfile: new VFile(), + }); + filePage.url = '/nested'; + // Force a deterministic enumerator (mimicking previousCounts having + // already filled heading_1 = 1 from the parent chapter, so the + // sub-page becomes heading_2 == 1 → "1.1"). + filePage.enumerator = '1.1'; + const resolver = new MultiPageReferenceResolver([filePage], 'caller.md'); + const ref: any = { type: 'crossReference', identifier: 'nested-page' }; + resolver.resolveReferenceContent(ref); + expect(toText(ref.children)).toBe('Section 1.1'); + }); }); describe('Book-mode auto-prefix for figures and equations (§3.2(e))', () => { diff --git a/packages/myst-transforms/src/enumerate.ts b/packages/myst-transforms/src/enumerate.ts index a01fdc30cd..5dce75ce7e 100644 --- a/packages/myst-transforms/src/enumerate.ts +++ b/packages/myst-transforms/src/enumerate.ts @@ -1,7 +1,15 @@ import type { Plugin } from 'unified'; import { VFile } from 'vfile'; import type { CrossReference, Paragraph } from 'myst-spec'; -import type { Cite, Container, Heading, Math, MathGroup, Link, IndexEntry } from 'myst-spec-ext'; +import type { + Cite, + Container, + Heading, + Math as MathNode, + MathGroup, + Link, + IndexEntry, +} from 'myst-spec-ext'; import type { PhrasingContent } from 'mdast'; import { visit } from 'unist-util-visit'; import { select, selectAll } from 'unist-util-select'; @@ -147,7 +155,7 @@ export enum ReferenceKind { eq = 'eq', } -type TargetNodes = (Container | Math | MathGroup | Heading) & { +type TargetNodes = (Container | MathNode | MathGroup | Heading) & { html_id: string; subcontainer?: boolean; parentEnumerator?: string; @@ -624,13 +632,17 @@ export class ReferenceState implements IReferenceStateResolver { nodeAsLink.url = url; nodeAsLink.internal = true; if (dataUrl) nodeAsLink.dataUrl = dataUrl; - // §3.2(h): file-targets are the page's H1 (the title heading), so - // apply the same label > template > title fallback used for inline - // headings. The page's `heading_1` numbering item carries the book - // mode's "Chapter %s" / "Appendix %s" label. + // §3.2(h): file-targets are the page's title heading, so apply the + // same label > template > title fallback used for inline headings. + // Use the page's offset to pick the same heading numbering item that + // was used when its enumerator was generated — a nested TOC page + // with offset=1 should read `heading_2`, not `heading_1`. Otherwise + // a sub-section under a book chapter would render as e.g. + // "Chapter 1.1" instead of "Section 1.1". let text: string | undefined; if (enumerator) { - const item = fileTarget.numbering?.heading_1; + const depth = (fileTarget.offset ?? 0) + 1; + const item = fileTarget.numbering?.[`heading_${depth}`]; const template = item?.label ?? item?.template; if (template) text = template.replace(/%s/g, enumerator); }