Skip to content

Commit

Permalink
✨ New table of contents directive (#1826)
Browse files Browse the repository at this point in the history
  • Loading branch information
fwkoch authored Feb 28, 2025
1 parent ffdccaf commit 0052853
Show file tree
Hide file tree
Showing 13 changed files with 786 additions and 47 deletions.
7 changes: 7 additions & 0 deletions .changeset/brave-dragons-pump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'myst-directives': patch
'myst-transforms': patch
'myst-cli': patch
---

New TOC directive
3 changes: 3 additions & 0 deletions docs/directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,6 @@ description: A full list of the directives included in MyST Markdown by default.

:::{myst:directive} table
:::

:::{myst:directive} toc
:::
56 changes: 11 additions & 45 deletions packages/myst-cli/src/build/site/manifest.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<SiteManifest>['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<SiteExport[]> {
const exports = (
Expand Down Expand Up @@ -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) : [];
Expand All @@ -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
Expand All @@ -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,
Expand Down
73 changes: 73 additions & 0 deletions packages/myst-cli/src/build/utils/projectManifest.ts
Original file line number Diff line number Diff line change
@@ -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<SiteManifest>['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 ?? {};
}
30 changes: 30 additions & 0 deletions packages/myst-cli/src/process/mdast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
inlineMathSimplificationPlugin,
checkLinkTextTransform,
indexIdentifierPlugin,
buildTocTransform,
} from 'myst-transforms';
import { unified } from 'unified';
import { select, selectAll } from 'unist-util-select';
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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();
Expand All @@ -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 || []),
Expand Down
2 changes: 2 additions & 0 deletions packages/myst-cli/src/process/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ export async function fastProcessFile(
file: f,
pageReferenceStates,
extraLinkTransformers,
site: true,
});
}),
);
Expand Down Expand Up @@ -603,6 +604,7 @@ export async function processProject(
checkLinks: checkLinks || strict,
pageReferenceStates,
extraLinkTransformers,
site: true,
}),
),
);
Expand Down
3 changes: 3 additions & 0 deletions packages/myst-directives/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -46,6 +47,7 @@ export const defaultDirectives = [
rawLatexDirective,
rawTypstDirective,
divDirective,
tocDirective,
];

export * from './utils.js';
Expand All @@ -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';
3 changes: 2 additions & 1 deletion packages/myst-directives/src/indices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ export const genIndexDirective: DirectiveSpec = {
} else {
children.push({
type: 'heading',
depth: 1,
depth: 2,
enumerated: false,
children: parsedArg,
});
}
Expand Down
61 changes: 61 additions & 0 deletions packages/myst-directives/src/toc.ts
Original file line number Diff line number Diff line change
@@ -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];
},
};
4 changes: 3 additions & 1 deletion packages/myst-transforms/src/enumerate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
Expand All @@ -747,6 +748,7 @@ function implicitTargetWarning(target: Target, node: GenericNode, opts: StateRes
},
);
}
delete node.suppressImplicitWarning;
}

export const resolveReferenceLinksTransform = (tree: GenericParent, opts: StateResolverOptions) => {
Expand Down
1 change: 1 addition & 0 deletions packages/myst-transforms/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading

0 comments on commit 0052853

Please # to comment.