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..f211225703 --- /dev/null +++ b/packages/myst-cli/src/process/bookSection.spec.ts @@ -0,0 +1,119 @@ +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('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: { + 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..36c1c23ea4 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 { @@ -103,6 +104,63 @@ 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?: 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; + // 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; + 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 +176,10 @@ export async function transformMdast( index?: string; titleDepth?: number; offset?: number; + /** Book section the page belongs to — set by the TOC walker. */ + section?: BookSection; + /** True when this page is the first one in its book section. */ + firstInSection?: boolean; }, ) { const { @@ -132,6 +194,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 +236,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..ba89fdcf3c 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; + const 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..63f0204009 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,22 +206,43 @@ 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? 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, - nextLevel(entryLevel), + isSectionGroup ? 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-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; } 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); + }); +}); diff --git a/packages/myst-transforms/src/enumerate.spec.ts b/packages/myst-transforms/src/enumerate.spec.ts index 95e3b62c91..c51e1c85dd 100644 --- a/packages/myst-transforms/src/enumerate.spec.ts +++ b/packages/myst-transforms/src/enumerate.spec.ts @@ -1,13 +1,63 @@ import { describe, expect, test } from 'vitest'; import { + addChildrenFromTargetNode, + MultiPageReferenceResolver, ReferenceState, enumerateTargetsTransform, + formatCounter, formatHeadingEnumerator, incrementHeadingCounts, initializeTargetCounts, } from './enumerate'; import { u } from 'unist-builder'; import { VFile } from 'vfile'; +import { toText } from 'myst-common'; + +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([ @@ -125,6 +175,459 @@ 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('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' }, [ + 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', + { + 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'); + }); + + 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))', () => { + 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 9c8ed0a451..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'; @@ -23,10 +31,45 @@ 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'; +/** + * 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)' }, @@ -90,9 +133,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 { @@ -109,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; @@ -247,23 +293,88 @@ 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 +477,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 +546,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 +554,21 @@ 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; + // §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 @@ -449,13 +577,13 @@ export class ReferenceState implements IReferenceStateResolver { ); if (node.subcontainer) { node.parentEnumerator = this.resolveEnumerator( - this.targetCounts[countKind].main, + autoPrefix + formatCounter(this.targetCounts[countKind].main, kindFormat), this.numbering[countKind]?.enumerator, ); enumerator = letter; } else { enumerator = this.resolveEnumerator( - this.targetCounts[countKind].main + letter, + autoPrefix + formatCounter(this.targetCounts[countKind].main, kindFormat) + letter, this.numbering[countKind]?.enumerator, ); } @@ -463,7 +591,7 @@ export class ReferenceState implements IReferenceStateResolver { this.targetCounts[kind].main += 1; this.targetCounts[kind].sub = 0; enumerator = this.resolveEnumerator( - this.targetCounts[kind].main, + autoPrefix + formatCounter(this.targetCounts[kind].main, kindFormat), this.numbering[kind]?.enumerator, ); } @@ -497,14 +625,28 @@ 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 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 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); + } + updateLinkTextIfEmpty(nodeAsLink, text ?? title ?? url); } return; } @@ -530,7 +672,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, 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