diff --git a/.changeset/brave-dragons-pump.md b/.changeset/brave-dragons-pump.md new file mode 100644 index 000000000..ac3276d79 --- /dev/null +++ b/.changeset/brave-dragons-pump.md @@ -0,0 +1,7 @@ +--- +'myst-directives': patch +'myst-transforms': patch +'myst-cli': patch +--- + +New TOC directive diff --git a/docs/directives.md b/docs/directives.md index 8d532d9f3..54bc11d09 100644 --- a/docs/directives.md +++ b/docs/directives.md @@ -62,3 +62,6 @@ description: A full list of the directives included in MyST Markdown by default. :::{myst:directive} table ::: + +:::{myst:directive} toc +::: diff --git a/packages/myst-cli/src/build/site/manifest.ts b/packages/myst-cli/src/build/site/manifest.ts index d60b091e3..868680cbe 100644 --- a/packages/myst-cli/src/build/site/manifest.ts +++ b/packages/myst-cli/src/build/site/manifest.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { SPEC_VERSION } from '../../spec-version.js'; import { hashAndCopyStaticFile } from 'myst-cli-utils'; import { RuleId, TemplateOptionType } from 'myst-common'; import type { SiteAction, SiteExport, SiteManifest } from 'myst-config'; @@ -18,17 +19,18 @@ import type { RootState } from '../../store/index.js'; import { selectors } from '../../store/index.js'; import { transformBanner, transformThumbnail } from '../../transforms/images.js'; import { addWarningForFile } from '../../utils/addWarningForFile.js'; -import { fileTitle } from '../../utils/fileInfo.js'; import { resolveFrontmatterParts } from '../../utils/resolveFrontmatterParts.js'; import version from '../../version.js'; import { getSiteTemplate } from './template.js'; import { collectExportOptions } from '../utils/collectExportOptions.js'; import { filterPages } from '../../project/load.js'; import { getRawFrontmatterFromFile } from '../../process/file.js'; -import { castSession } from '../../session/cache.js'; -import { SPEC_VERSION } from '../../spec-version.js'; - -type ManifestProject = Required['projects'][0]; +import type { ManifestProject } from '../utils/projectManifest.js'; +import { + indexFrontmatterFromProject, + manifestPagesFromProject, + manifestTitleFromProject, +} from '../utils/projectManifest.js'; export async function resolvePageExports(session: ISession, file: string): Promise { const exports = ( @@ -135,45 +137,9 @@ export async function localToManifestProject( const proj = selectors.selectLocalProject(state, projectPath); if (!proj) return null; // Update all of the page title to the frontmatter title - const { index, file: indexFile } = proj; + const { index } = proj; const projectFileInfo = selectors.selectFileInfo(state, proj.file); - const projectTitle = projConfig?.title || projectFileInfo.title || proj.index; - const cache = castSession(session); - const pages = await Promise.all( - proj.pages.map(async (page) => { - if ('file' in page) { - const fileInfo = selectors.selectFileInfo(state, page.file); - const title = fileInfo.title || fileTitle(page.file); - const short_title = fileInfo.short_title ?? undefined; - const description = fileInfo.description ?? ''; - const thumbnail = fileInfo.thumbnail ?? ''; - const thumbnailOptimized = fileInfo.thumbnailOptimized ?? ''; - const banner = fileInfo.banner ?? ''; - const bannerOptimized = fileInfo.bannerOptimized ?? ''; - const date = fileInfo.date ?? ''; - const tags = fileInfo.tags ?? []; - const { slug, level, file } = page; - const { frontmatter } = cache.$getMdast(file)?.post ?? {}; - const projectPage: ManifestProject['pages'][0] = { - slug, - title, - short_title, - description, - date, - thumbnail, - thumbnailOptimized, - banner, - bannerOptimized, - tags, - level, - enumerator: frontmatter?.enumerator, - }; - return projectPage; - } - return { ...page }; - }), - ); - + const pages = await manifestPagesFromProject(session, projectPath); const projFrontmatter = projConfig ? filterKeys(projConfig, PROJECT_FRONTMATTER_KEYS) : {}; const projConfigFile = selectors.selectLocalConfigFile(state, projectPath); const exports = projConfigFile ? await resolvePageExports(session, projConfigFile) : []; @@ -196,7 +162,7 @@ export async function localToManifestProject( session.publicPath(), { altOutputFolder: '/', webp: true }, ); - const { frontmatter } = cache.$getMdast(indexFile)?.post ?? {}; + const frontmatter = indexFrontmatterFromProject(session, projectPath); return { ...projFrontmatter, // TODO: a null in the project frontmatter should not fall back to index page @@ -215,7 +181,7 @@ export async function localToManifestProject( downloads, parts, bibliography: projFrontmatter.bibliography || [], - title: projectTitle || 'Untitled', + title: manifestTitleFromProject(session, projectPath), slug: projectSlug, index, enumerator: frontmatter?.enumerator, diff --git a/packages/myst-cli/src/build/utils/projectManifest.ts b/packages/myst-cli/src/build/utils/projectManifest.ts new file mode 100644 index 000000000..c06c15417 --- /dev/null +++ b/packages/myst-cli/src/build/utils/projectManifest.ts @@ -0,0 +1,73 @@ +import type { SiteManifest } from 'myst-config'; +import { castSession } from '../../session/cache.js'; +import type { ISession } from '../../session/types.js'; +import { selectors } from '../../store/index.js'; +import { fileTitle } from '../../utils/fileInfo.js'; +import type { PageFrontmatter } from 'myst-frontmatter'; + +export type ManifestProject = Required['projects'][0]; + +export async function manifestPagesFromProject(session: ISession, projectPath: string) { + const state = session.store.getState(); + const proj = selectors.selectLocalProject(state, projectPath); + if (!proj) return []; + const cache = castSession(session); + const pages = await Promise.all( + proj.pages.map(async (page) => { + if ('file' in page) { + const fileInfo = selectors.selectFileInfo(state, page.file); + const title = fileInfo.title || fileTitle(page.file); + const short_title = fileInfo.short_title ?? undefined; + const description = fileInfo.description ?? ''; + const thumbnail = fileInfo.thumbnail ?? ''; + const thumbnailOptimized = fileInfo.thumbnailOptimized ?? ''; + const banner = fileInfo.banner ?? ''; + const bannerOptimized = fileInfo.bannerOptimized ?? ''; + const date = fileInfo.date ?? ''; + const tags = fileInfo.tags ?? []; + const { slug, level, file } = page; + const { frontmatter } = cache.$getMdast(file)?.post ?? {}; + const projectPage: ManifestProject['pages'][0] = { + slug, + title, + short_title, + description, + date, + thumbnail, + thumbnailOptimized, + banner, + bannerOptimized, + tags, + level, + enumerator: frontmatter?.enumerator, + }; + return projectPage; + } + return { ...page }; + }), + ); + return pages; +} + +export function manifestTitleFromProject(session: ISession, projectPath: string) { + const state = session.store.getState(); + const projConfig = selectors.selectLocalProjectConfig(state, projectPath); + if (projConfig?.title) return projConfig.title; + const proj = selectors.selectLocalProject(state, projectPath); + if (!proj) return 'Untitled'; + const projectFileInfo = selectors.selectFileInfo(session.store.getState(), proj.file); + return projectFileInfo.title || proj.index || 'Untitled'; +} + +export function indexFrontmatterFromProject( + session: ISession, + projectPath: string, +): PageFrontmatter { + const state = session.store.getState(); + const cache = castSession(session); + const proj = selectors.selectLocalProject(state, projectPath); + if (!proj) return {}; + const { file } = proj; + const { frontmatter } = cache.$getMdast(file)?.post ?? {}; + return frontmatter ?? {}; +} diff --git a/packages/myst-cli/src/process/mdast.ts b/packages/myst-cli/src/process/mdast.ts index 39093be02..e06b70aab 100644 --- a/packages/myst-cli/src/process/mdast.ts +++ b/packages/myst-cli/src/process/mdast.ts @@ -29,6 +29,7 @@ import { inlineMathSimplificationPlugin, checkLinkTextTransform, indexIdentifierPlugin, + buildTocTransform, } from 'myst-transforms'; import { unified } from 'unified'; import { select, selectAll } from 'unist-util-select'; @@ -76,6 +77,11 @@ import { kernelExecutionTransform, LocalDiskCache } from 'myst-execute'; import type { IOutput } from '@jupyterlab/nbformat'; import { rawDirectiveTransform } from '../transforms/raw.js'; import { addEditUrl } from '../utils/addEditUrl.js'; +import { + indexFrontmatterFromProject, + manifestPagesFromProject, + manifestTitleFromProject, +} from '../build/utils/projectManifest.js'; const LINKS_SELECTOR = 'link,card,linkBlock'; @@ -289,11 +295,13 @@ export async function postProcessMdast( checkLinks, pageReferenceStates, extraLinkTransformers, + site, }: { file: string; checkLinks?: boolean; pageReferenceStates: ReferenceState[]; extraLinkTransformers?: LinkTransformer[]; + site?: boolean; }, ) { const toc = tic(); @@ -306,6 +314,28 @@ export async function postProcessMdast( const { mdast, dependencies, frontmatter } = mdastPost; const state = new MultiPageReferenceResolver(pageReferenceStates, file, vfile); const externalReferences = Object.values(cache.$externalReferences); + const storeState = session.store.getState(); + const projectPath = selectors.selectCurrentProjectPath(storeState); + const siteConfig = selectors.selectCurrentSiteConfig(storeState); + const projectSlug = siteConfig?.projects?.find((proj) => proj.path === projectPath)?.slug; + if (site) { + buildTocTransform( + mdast, + vfile, + projectPath + ? [ + { + title: manifestTitleFromProject(session, projectPath), + level: 1, + slug: '', + enumerator: indexFrontmatterFromProject(session, projectPath).enumerator, + }, + ...(await manifestPagesFromProject(session, projectPath)), + ] + : undefined, + projectSlug, + ); + } // NOTE: This is doing things in place, we should potentially make this a different state? const transformers = [ ...(extraLinkTransformers || []), diff --git a/packages/myst-cli/src/process/site.ts b/packages/myst-cli/src/process/site.ts index 9620d6695..4c80fedb9 100644 --- a/packages/myst-cli/src/process/site.ts +++ b/packages/myst-cli/src/process/site.ts @@ -489,6 +489,7 @@ export async function fastProcessFile( file: f, pageReferenceStates, extraLinkTransformers, + site: true, }); }), ); @@ -603,6 +604,7 @@ export async function processProject( checkLinks: checkLinks || strict, pageReferenceStates, extraLinkTransformers, + site: true, }), ), ); diff --git a/packages/myst-directives/src/index.ts b/packages/myst-directives/src/index.ts index f3766426d..5a153e543 100644 --- a/packages/myst-directives/src/index.ts +++ b/packages/myst-directives/src/index.ts @@ -18,6 +18,7 @@ import { mystdemoDirective } from './mystdemo.js'; import { blockquoteDirective } from './blockquote.js'; import { rawDirective, rawLatexDirective, rawTypstDirective } from './raw.js'; import { divDirective } from './div.js'; +import { tocDirective } from './toc.js'; export const defaultDirectives = [ admonitionDirective, @@ -46,6 +47,7 @@ export const defaultDirectives = [ rawLatexDirective, rawTypstDirective, divDirective, + tocDirective, ]; export * from './utils.js'; @@ -68,3 +70,4 @@ export { mystdemoDirective } from './mystdemo.js'; export { blockquoteDirective } from './blockquote.js'; export { rawDirective, rawLatexDirective, rawTypstDirective } from './raw.js'; export { divDirective } from './div.js'; +export { tocDirective } from './toc.js'; diff --git a/packages/myst-directives/src/indices.ts b/packages/myst-directives/src/indices.ts index 7ec2ac225..6994f8375 100644 --- a/packages/myst-directives/src/indices.ts +++ b/packages/myst-directives/src/indices.ts @@ -95,7 +95,8 @@ export const genIndexDirective: DirectiveSpec = { } else { children.push({ type: 'heading', - depth: 1, + depth: 2, + enumerated: false, children: parsedArg, }); } diff --git a/packages/myst-directives/src/toc.ts b/packages/myst-directives/src/toc.ts new file mode 100644 index 000000000..7591f2905 --- /dev/null +++ b/packages/myst-directives/src/toc.ts @@ -0,0 +1,61 @@ +import type { VFile } from 'vfile'; +import { type DirectiveSpec, type DirectiveData, type GenericNode, fileError } from 'myst-common'; +import { addCommonDirectiveOptions, commonDirectiveOptions } from './utils.js'; + +const CONTEXTS = ['project', 'page', 'section']; + +export const tocDirective: DirectiveSpec = { + name: 'toc', + doc: 'Inserts table of contents in the page. This may be for the project (each page has an entry), the current page (each heading has an entry), or the current section (only headings in the section have an entry).', + alias: ['tableofcontents', 'table-of-contents', 'toctree', 'contents'], + arg: { + type: 'myst', + doc: 'Heading to be included with table of contents', + }, + options: { + context: { + type: String, + doc: 'Table of Contents context; one of project, page, or section', + alias: ['kind'], + }, + depth: { + type: Number, + doc: 'Number of levels to include in Table of Contents; by default, all levels will be included', + alias: ['maxdepth'], + }, + ...commonDirectiveOptions('toc'), + }, + run(data: DirectiveData, vfile: VFile): GenericNode[] { + let context = data.options?.context + ? (data.options.context as string) + : data.name === 'contents' + ? 'section' + : 'project'; + if (!CONTEXTS.includes(context)) { + fileError(vfile, `Unknown context for ${data.name} directive: ${context}`); + context = 'project'; + } + let depth = data.options?.depth as number | undefined; + if (depth != null && depth < 1) { + fileError(vfile, `Table of Contents 'depth' must be a number greater than 0`); + depth = undefined; + } + const children: GenericNode[] = []; + if (data.arg) { + const parsedArg = data.arg as GenericNode[]; + if (parsedArg[0]?.type === 'heading') { + children.push(...parsedArg); + } else { + children.push({ + type: 'heading', + depth: 2, + enumerated: false, + children: parsedArg, + }); + } + } + const toc = { type: 'toc', kind: context, depth, children }; + addCommonDirectiveOptions(data, toc); + return [toc]; + }, +}; diff --git a/packages/myst-transforms/src/enumerate.ts b/packages/myst-transforms/src/enumerate.ts index 1405500e1..07721afc2 100644 --- a/packages/myst-transforms/src/enumerate.ts +++ b/packages/myst-transforms/src/enumerate.ts @@ -735,7 +735,8 @@ export function addContainerCaptionNumbersTransform( * Raise a warning if `target` linked by `node` has an implicit reference */ function implicitTargetWarning(target: Target, node: GenericNode, opts: StateResolverOptions) { - if ((target.node as GenericNode).implicit && opts.state.vfile) { + // suppressImplicitWarning is used, for example, in the table of contents directive + if ((target.node as GenericNode).implicit && opts.state.vfile && !node.suppressImplicitWarning) { fileWarn( opts.state.vfile, `Linking "${target.node.identifier}" to an implicit ${target.kind} reference, best practice is to create an explicit reference.`, @@ -747,6 +748,7 @@ function implicitTargetWarning(target: Target, node: GenericNode, opts: StateRes }, ); } + delete node.suppressImplicitWarning; } export const resolveReferenceLinksTransform = (tree: GenericParent, opts: StateResolverOptions) => { diff --git a/packages/myst-transforms/src/index.ts b/packages/myst-transforms/src/index.ts index 0d3f40d02..17e57b6f9 100644 --- a/packages/myst-transforms/src/index.ts +++ b/packages/myst-transforms/src/index.ts @@ -59,6 +59,7 @@ export { abbreviationPlugin, abbreviationTransform } from './abbreviations.js'; export { includeDirectivePlugin, includeDirectiveTransform } from './include.js'; export { containerChildrenPlugin, containerChildrenTransform } from './containers.js'; export { headingDepthPlugin, headingDepthTransform } from './headings.js'; +export { buildTocTransform } from './toc.js'; // Enumeration export type { IReferenceStateResolver, ReferenceKind, TargetCounts } from './enumerate.js'; diff --git a/packages/myst-transforms/src/toc.spec.ts b/packages/myst-transforms/src/toc.spec.ts new file mode 100644 index 000000000..b043fe9f4 --- /dev/null +++ b/packages/myst-transforms/src/toc.spec.ts @@ -0,0 +1,413 @@ +import { describe, expect, test } from 'vitest'; +import { buildTocTransform } from './toc'; +import { VFile } from 'vfile'; +import { toText } from 'myst-common'; + +describe('Test toc transformation', () => { + test('Project Toc - basic', () => { + const vfile = new VFile(); + const mdast = { + type: 'root', + children: [{ type: 'toc', kind: 'project', children: [] }], + } as any; + buildTocTransform(mdast, vfile, [ + { title: 'One', level: 1, slug: '' }, + { title: 'Two', level: 1, slug: 'two' }, + { title: 'Three', level: 2, slug: 'three' }, + { title: 'Four', level: 1, slug: 'four' }, + ]); + expect(mdast.children[0].type).toBe('block'); + expect(mdast.children[0].data.part).toBe('toc:project'); + expect(mdast.children[0].children[0].type).toBe('list'); + expect(mdast.children[0].children[0].children.length).toBe(3); + expect(mdast.children[0].children[0].children[0].children.length).toBe(1); + expect(mdast.children[0].children[0].children[1].children.length).toBe(2); + expect(mdast.children[0].children[0].children[0].children[0].url).toBe('/'); + expect(mdast.children[0].children[0].children[1].children[0].url).toBe('/two'); + expect(toText(mdast.children[0].children[0].children[1].children[0])).toBe('Two'); + }); + test('Project Toc - with project slug and enumerators', () => { + const vfile = new VFile(); + const mdast = { + type: 'root', + children: [{ type: 'toc', kind: 'project', children: [] }], + } as any; + buildTocTransform( + mdast, + vfile, + [ + { title: 'One', level: 1, slug: '', enumerator: '1.1' }, + { title: 'Two', level: 1, slug: 'two', enumerator: '1.2' }, + { title: 'Three', level: 2, slug: 'three', enumerator: '1.2.1' }, + { title: 'Four', level: 1, slug: 'four', enumerator: '1.3' }, + ], + 'slug', + ); + expect(mdast.children[0].type).toBe('block'); + expect(mdast.children[0].data.part).toBe('toc:project'); + expect(mdast.children[0].children[0].type).toBe('list'); + expect(mdast.children[0].children[0].children.length).toBe(3); + expect(mdast.children[0].children[0].children[0].children.length).toBe(1); + expect(mdast.children[0].children[0].children[1].children.length).toBe(2); + expect(mdast.children[0].children[0].children[0].children[0].url).toBe('/slug/'); + expect(mdast.children[0].children[0].children[1].children[0].url).toBe('/slug/two'); + expect(toText(mdast.children[0].children[0].children[1].children[0])).toBe('1.2 Two'); + }); + test('Project Toc - no links', () => { + const vfile = new VFile(); + const mdast = { + type: 'root', + children: [{ type: 'toc', kind: 'project', children: [] }], + } as any; + buildTocTransform(mdast, vfile, [ + { title: 'One', level: 1 }, + { title: 'Two', level: 1 }, + { title: 'Three', level: 2 }, + { title: 'Four', level: 1 }, + ]); + expect(mdast.children[0].type).toBe('block'); + expect(mdast.children[0].data.part).toBe('toc:project'); + expect(mdast.children[0].children[0].type).toBe('list'); + expect(mdast.children[0].children[0].children.length).toBe(3); + expect(mdast.children[0].children[0].children[0].children.length).toBe(1); + expect(mdast.children[0].children[0].children[1].children.length).toBe(2); + expect(mdast.children[0].children[0].children[0].children[0].url).toBeUndefined(); + expect(mdast.children[0].children[0].children[1].children[0].value).toBe('Two'); + }); + test('Project Toc - heading depth', () => { + const vfile = new VFile(); + const mdast = { + type: 'root', + children: [{ type: 'toc', kind: 'project', depth: 1, children: [] }], + } as any; + buildTocTransform( + mdast, + vfile, + [ + { title: 'One', level: 1, slug: '', enumerator: '1.1' }, + { title: 'Two', level: 1, slug: 'two', enumerator: '1.2' }, + { title: 'Three', level: 2, slug: 'three', enumerator: '1.2.1' }, + { title: 'Four', level: 1, slug: 'four', enumerator: '1.3' }, + ], + 'slug', + ); + expect(mdast.children[0].type).toBe('block'); + expect(mdast.children[0].data.part).toBe('toc:project'); + expect(mdast.children[0].children[0].type).toBe('list'); + expect(mdast.children[0].children[0].children.length).toBe(3); + expect(mdast.children[0].children[0].children[0].children.length).toBe(1); + expect(mdast.children[0].children[0].children[1].children.length).toBe(1); + expect(mdast.children[0].children[0].children[0].children[0].url).toBe('/slug/'); + expect(mdast.children[0].children[0].children[1].children[0].url).toBe('/slug/two'); + expect(toText(mdast.children[0].children[0].children[1].children[0])).toBe('1.2 Two'); + }); + test('Page Toc - basic', () => { + const vfile = new VFile(); + const mdast = { + type: 'root', + children: [ + { + type: 'heading', + children: [{ type: 'text', value: 'One' }], + depth: 1, + identifier: 'one', + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Two' }], + depth: 1, + identifier: 'two', + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Three' }], + depth: 2, + identifier: 'three', + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Four' }], + depth: 1, + identifier: 'four', + }, + { type: 'toc', kind: 'page', children: [] }, + ], + } as any; + buildTocTransform(mdast, vfile); + expect(mdast.children[4].type).toBe('block'); + expect(mdast.children[4].data.part).toBe('toc:page'); + expect(mdast.children[4].children[0].type).toBe('list'); + expect(mdast.children[4].children[0].children.length).toBe(3); + expect(mdast.children[4].children[0].children[0].children.length).toBe(1); + expect(mdast.children[4].children[0].children[1].children.length).toBe(2); + expect(mdast.children[4].children[0].children[0].children[0].url).toBe('#one'); + expect(mdast.children[4].children[0].children[1].children[0].url).toBe('#two'); + expect(toText(mdast.children[4].children[0].children[1].children[0])).toBe('Two'); + }); + test('Page Toc - with enumerators', () => { + const vfile = new VFile(); + const mdast = { + type: 'root', + children: [ + { + type: 'heading', + children: [{ type: 'text', value: 'One' }], + depth: 1, + identifier: 'one', + enumerator: '1.1', + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Two' }], + depth: 1, + identifier: 'two', + enumerator: '1.2', + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Three' }], + depth: 2, + identifier: 'three', + enumerator: '1.2.1', + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Four' }], + depth: 1, + identifier: 'four', + enumerator: '1.3', + }, + { type: 'toc', kind: 'page', children: [] }, + ], + } as any; + buildTocTransform(mdast, vfile); + expect(mdast.children[4].type).toBe('block'); + expect(mdast.children[4].data.part).toBe('toc:page'); + expect(mdast.children[4].children[0].type).toBe('list'); + expect(mdast.children[4].children[0].children.length).toBe(3); + expect(mdast.children[4].children[0].children[0].children.length).toBe(1); + expect(mdast.children[4].children[0].children[1].children.length).toBe(2); + expect(mdast.children[4].children[0].children[0].children[0].url).toBe('#one'); + expect(mdast.children[4].children[0].children[1].children[0].url).toBe('#two'); + expect(toText(mdast.children[4].children[0].children[1].children[0])).toBe('1.2 Two'); + }); + test('Page Toc - no identifiers', () => { + const vfile = new VFile(); + const mdast = { + type: 'root', + children: [ + { + type: 'heading', + children: [{ type: 'text', value: 'One' }], + depth: 1, + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Two' }], + depth: 1, + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Three' }], + depth: 2, + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Four' }], + depth: 1, + }, + { type: 'toc', kind: 'page', children: [] }, + ], + } as any; + buildTocTransform(mdast, vfile); + expect(mdast.children[4].type).toBe('block'); + expect(mdast.children[4].data.part).toBe('toc:page'); + expect(mdast.children[4].children[0].type).toBe('list'); + expect(mdast.children[4].children[0].children.length).toBe(3); + expect(mdast.children[4].children[0].children[0].children.length).toBe(1); + expect(mdast.children[4].children[0].children[1].children.length).toBe(2); + expect(mdast.children[4].children[0].children[0].children[0].url).toBeUndefined(); + expect(toText(mdast.children[4].children[0].children[1].children[0])).toBe('Two'); + }); + test('Page Toc - heading depth', () => { + const vfile = new VFile(); + const mdast = { + type: 'root', + children: [ + { + type: 'heading', + children: [{ type: 'text', value: 'One' }], + depth: 1, + identifier: 'one', + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Two' }], + depth: 1, + identifier: 'two', + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Three' }], + depth: 2, + identifier: 'three', + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Four' }], + depth: 1, + identifier: 'four', + }, + { type: 'toc', kind: 'page', depth: 1, children: [] }, + ], + } as any; + buildTocTransform(mdast, vfile); + expect(mdast.children[4].type).toBe('block'); + expect(mdast.children[4].data.part).toBe('toc:page'); + expect(mdast.children[4].children[0].type).toBe('list'); + expect(mdast.children[4].children[0].children.length).toBe(3); + expect(mdast.children[4].children[0].children[0].children.length).toBe(1); + expect(mdast.children[4].children[0].children[1].children.length).toBe(1); + expect(mdast.children[4].children[0].children[0].children[0].url).toBe('#one'); + expect(mdast.children[4].children[0].children[1].children[0].url).toBe('#two'); + expect(toText(mdast.children[4].children[0].children[1].children[0])).toBe('Two'); + }); + test('Section Toc - basic', () => { + const vfile = new VFile(); + const mdast = { + type: 'root', + children: [ + { + type: 'heading', + children: [{ type: 'text', value: 'One' }], + depth: 1, + identifier: 'one', + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Two' }], + depth: 1, + identifier: 'two', + }, + { type: 'toc', kind: 'section', children: [] }, + { + type: 'heading', + children: [{ type: 'text', value: 'Three' }], + depth: 2, + identifier: 'three', + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Four' }], + depth: 1, + identifier: 'four', + }, + ], + } as any; + buildTocTransform(mdast, vfile); + expect(mdast.children[2].type).toBe('block'); + expect(mdast.children[2].data.part).toBe('toc:section'); + expect(mdast.children[2].children[0].type).toBe('list'); + expect(mdast.children[2].children[0].children.length).toBe(1); + expect(mdast.children[2].children[0].children[0].children.length).toBe(1); + expect(mdast.children[2].children[0].children[0].children[0].url).toBe('#three'); + expect(toText(mdast.children[2].children[0].children[0].children[0])).toBe('Three'); + }); + test('Section Toc - nested', () => { + const vfile = new VFile(); + const mdast = { + type: 'root', + children: [ + { + type: 'heading', + children: [{ type: 'text', value: 'One' }], + depth: 1, + identifier: 'one', + }, + { type: 'toc', kind: 'section', children: [] }, + { + type: 'heading', + children: [{ type: 'text', value: 'Two' }], + depth: 1, + identifier: 'two', + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Three' }], + depth: 2, + identifier: 'three', + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Four' }], + depth: 1, + identifier: 'four', + }, + ], + } as any; + buildTocTransform(mdast, vfile); + expect(mdast.children[1].type).toBe('block'); + expect(mdast.children[1].data.part).toBe('toc:section'); + expect(mdast.children[1].children[0].type).toBe('list'); + expect(mdast.children[1].children[0].children.length).toBe(2); + expect(mdast.children[1].children[0].children[0].children.length).toBe(2); + expect(mdast.children[1].children[0].children[0].children[0].url).toBe('#two'); + expect(toText(mdast.children[1].children[0].children[0].children[0])).toBe('Two'); + expect(mdast.children[1].children[0].children[1].children[0].url).toBe('#four'); + }); + test('Section Toc - with heading', () => { + const vfile = new VFile(); + const mdast = { + type: 'root', + children: [ + { + type: 'heading', + children: [{ type: 'text', value: 'One' }], + depth: 1, + identifier: 'one', + }, + { + type: 'toc', + kind: 'section', + children: [ + { + type: 'heading', + children: [{ type: 'text', value: 'My ToC' }], + depth: 2, + identifier: 'my-toc', + }, + ], + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Two' }], + depth: 1, + identifier: 'two', + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Three' }], + depth: 2, + identifier: 'three', + }, + { + type: 'heading', + children: [{ type: 'text', value: 'Four' }], + depth: 1, + identifier: 'four', + }, + ], + } as any; + buildTocTransform(mdast, vfile); + expect(mdast.children[1].type).toBe('block'); + expect(mdast.children[1].data.part).toBe('toc:section'); + expect(mdast.children[1].children[0].type).toBe('heading'); + expect(toText(mdast.children[1].children[0])).toBe('My ToC'); + expect(mdast.children[1].children[1].type).toBe('list'); + expect(mdast.children[1].children[1].children.length).toBe(2); + expect(mdast.children[1].children[1].children[0].children.length).toBe(2); + expect(mdast.children[1].children[1].children[0].children[0].url).toBe('#two'); + expect(toText(mdast.children[1].children[1].children[0].children[0])).toBe('Two'); + expect(mdast.children[1].children[1].children[1].children[0].url).toBe('#four'); + }); +}); diff --git a/packages/myst-transforms/src/toc.ts b/packages/myst-transforms/src/toc.ts new file mode 100644 index 000000000..1823a8863 --- /dev/null +++ b/packages/myst-transforms/src/toc.ts @@ -0,0 +1,177 @@ +import { fileError, fileWarn, toText, type GenericNode, type GenericParent } from 'myst-common'; +import type { List, Text } from 'myst-spec'; +import type { Heading, Link, ListItem } from 'myst-spec-ext'; +import { selectAll } from 'unist-util-select'; +import type { VFile } from 'vfile'; + +type ProjectPage = { + title: string; + level: number; + slug?: string; + enumerator?: string; +}; + +function listFromPages(pages: ProjectPage[], projectSlug?: string): List { + if (pages.length === 0) return { type: 'list', children: [] }; + let ignore = false; + const level = pages[0].level; + const children = pages + .map((page, index) => { + if (ignore) return undefined; + if (page.level < level) ignore = true; + if (page.level !== level) return undefined; + return listItemFromPages(pages.slice(index), projectSlug); + }) + .filter((item): item is ListItem => !!item); + return { type: 'list', children }; +} + +function listItemFromPages(pages: ProjectPage[], projectSlug?: string) { + if (pages.length === 0) return; + const { title, slug, enumerator, level } = pages[0]; + const text: Text = { + type: 'text', + value: `${enumerator ? `${enumerator} ` : ''}${title}`, + }; + const child: Text | Link = + slug != null + ? ({ + type: 'link', + url: `${projectSlug ? `/${projectSlug}` : ''}/${slug}`, + internal: true, + children: [text], + } as Link) + : text; + const item: ListItem = { + type: 'listItem', + children: [child], + }; + if (pages[1] && pages[1].level > level) { + item.children.push(listFromPages(pages.slice(1), projectSlug)); + } + return item; +} + +function listFromHeadings(headings: Heading[]): List { + if (headings.length === 0) return { type: 'list', children: [] }; + let ignore = false; + const depth = headings[0].depth; + const children = headings + .map((heading, index) => { + if (ignore) return undefined; + if (heading.depth < depth) ignore = true; + if (heading.depth !== depth) return undefined; + return listItemFromHeadings(headings.slice(index)); + }) + .filter((item): item is ListItem => !!item); + return { type: 'list', children }; +} + +function listItemFromHeadings(headings: Heading[]) { + if (headings.length === 0) return; + const { children, enumerator, depth, identifier } = headings[0]; + const text: Text = { + type: 'text', + value: `${enumerator ? `${enumerator} ` : ''}${toText(children)}`, + }; + const child: Text | Link = identifier + ? ({ + type: 'link', + url: `#${identifier}`, + internal: true, + children: [text], + suppressImplicitWarning: true, + } as Link) + : text; + const item: ListItem = { + type: 'listItem', + children: [child], + }; + if (headings[1] && headings[1].depth > depth) { + item.children.push(listFromHeadings(headings.slice(1))); + } + return item; +} + +export function buildTocTransform( + mdast: GenericParent, + vfile: VFile, + pages?: ProjectPage[], + projectSlug?: string, +) { + const tocHeadings = selectAll('toc > heading', mdast); + const tocsAndHeadings = selectAll('toc,heading', mdast).filter((item) => { + // Do not include toc headings anywhere in this transform + return !tocHeadings.includes(item); + }) as GenericNode[]; + if (!tocsAndHeadings.find((node) => node.type === 'toc')) return; + const projectTocs = tocsAndHeadings.filter( + (node) => node.type === 'toc' && node.kind === 'project', + ); + const pageTocs = tocsAndHeadings.filter((node) => node.type === 'toc' && node.kind === 'page'); + const sectionTocs = tocsAndHeadings.filter( + (node) => node.type === 'toc' && node.kind === 'section', + ); + if (projectTocs.length) { + if (!pages) { + fileError(vfile, `Pages not available to build Table of Contents`); + } else { + if (pages[0].level !== 1) { + fileWarn(vfile, `First page of Table of Contents must be level 1`); + } + projectTocs.forEach((toc) => { + const filteredPages = toc.depth ? pages.filter((page) => page.level <= toc.depth) : pages; + toc.type = 'block'; + delete toc.kind; + toc.data = { part: 'toc:project' }; + if (!toc.children) toc.children = []; + toc.children.push(listFromPages(filteredPages, projectSlug)); + }); + } + } + if (pageTocs.length) { + const headings = tocsAndHeadings.filter((node) => node.type === 'heading') as Heading[]; + if (headings.length === 0) { + fileWarn(vfile, `No page headings found for Table of Contents`); + } else { + if (Math.min(...headings.map((h) => h.depth)) !== headings[0].depth) { + fileWarn(vfile, 'Page heading levels do not start with highest level'); + } + pageTocs.forEach((toc) => { + const filteredHeadings = toc.depth + ? headings.filter((heading) => heading.depth - headings[0].depth < toc.depth) + : headings; + toc.type = 'block'; + delete toc.kind; + toc.data = { part: 'toc:page' }; + if (!toc.children) toc.children = []; + toc.children.push(listFromHeadings(filteredHeadings)); + }); + } + } + if (sectionTocs.length) { + tocsAndHeadings.forEach((toc, index) => { + if (toc.type !== 'toc' || toc.kind !== 'section') return; + const headings = tocsAndHeadings + .slice(index + 1) + .filter((h) => h.type === 'heading') as Heading[]; + if (headings.length === 0) { + fileWarn(vfile, `No section headings found for Table of Contents`); + } else { + const filteredHeadings = toc.depth + ? headings.filter((heading) => heading.depth - headings[0].depth < toc.depth) + : headings; + toc.type = 'block'; + delete toc.kind; + toc.data = { part: 'toc:section' }; + if (!toc.children) toc.children = []; + const nextSection = filteredHeadings.findIndex((h) => h.depth < filteredHeadings[0].depth); + toc.children.push( + listFromHeadings( + nextSection === -1 ? filteredHeadings : filteredHeadings.slice(0, nextSection), + ), + ); + } + }); + } +}