Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions packages/myst-cli/src/process/bookSection.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
65 changes: 65 additions & 0 deletions packages/myst-cli/src/process/mdast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -103,6 +104,63 @@ export type TransformFn = (
opts: Parameters<typeof transformMdast>[1],
) => Promise<void>;

/**
* 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';
Comment thread
mmcky marked this conversation as resolved.
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: {
Expand All @@ -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 {
Expand All @@ -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();
Expand Down Expand Up @@ -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: {} },
Expand Down
59 changes: 53 additions & 6 deletions packages/myst-cli/src/process/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, { section?: BookSection; firstInSection: boolean }> {
const out = new Map<string, { section?: BookSection; firstInSection: boolean }>();
let lastSection: BookSection | undefined;
const sawAnyInSection = new Set<BookSection>();
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', {
Expand Down Expand Up @@ -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,
Expand All @@ -476,6 +517,8 @@ export async function fastProcessFile(
index: project.index,
execute,
offset: level ? level - 1 : undefined,
section: meta?.section,
firstInSection: meta?.firstInSection,
});
}),
);
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
Loading
Loading