Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ New table of contents directive #1826

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
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
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
:::
54 changes: 10 additions & 44 deletions packages/myst-cli/src/build/site/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,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';

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 @@ -134,45 +136,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 @@ -195,7 +161,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 @@ -214,7 +180,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 @@ -487,6 +487,7 @@ export async function fastProcessFile(
file: f,
pageReferenceStates,
extraLinkTransformers,
site: true,
});
}),
);
Expand Down Expand Up @@ -601,6 +602,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
Loading