diff --git a/examples/plugin-session/index.md b/examples/plugin-session/index.md new file mode 100644 index 000000000..821c851cc --- /dev/null +++ b/examples/plugin-session/index.md @@ -0,0 +1,5 @@ +--- +title: Testing +--- + +# Testing diff --git a/examples/plugin-session/myst.yml b/examples/plugin-session/myst.yml new file mode 100644 index 000000000..0490629ec --- /dev/null +++ b/examples/plugin-session/myst.yml @@ -0,0 +1,17 @@ +# See docs at: https://mystmd.org/guide/frontmatter +version: 1 +project: + id: 70921a1f-f53a-440c-9f03-9023b8e9b815 + # title: + # description: + # keywords: [] + # authors: [] + github: https://github.com/jupyter-book/mystmd + # To autogenerate a Table of Contents, run "myst init --write-toc" +plugins: + - plugin.mjs +site: + template: book-theme + # options: + # favicon: favicon.ico + # logo: site_logo.png diff --git a/examples/plugin-session/plugin.mjs b/examples/plugin-session/plugin.mjs new file mode 100644 index 000000000..be4d3a03b --- /dev/null +++ b/examples/plugin-session/plugin.mjs @@ -0,0 +1,18 @@ +function sessionTransform(opts, utils) { + return async (mdast) => { + console.log('hello', utils.unstableSession); + }; +} + +// Declare a transform plugin +const sessionTransformPlugin = { + plugin: sessionTransform, + stage: 'document', +}; + +const plugin = { + name: 'Session Transform Plugin', + transforms: [sessionTransformPlugin], +}; + +export default plugin; diff --git a/examples/plugin-tagged/apples.md b/examples/plugin-tagged/apples.md new file mode 100644 index 000000000..1f1ce2beb --- /dev/null +++ b/examples/plugin-tagged/apples.md @@ -0,0 +1,17 @@ +--- +title: Apples +description: A crisp and classic orchard fruit. +date: 2024-01-15 +doi: 10.5555/apples.2024 +thumbnail: apples.png +authors: + - name: Johnny Appleseed + affiliations: + - Orchard Institute +tags: + - fruit +--- + +# Apples + +Apples are a fruit. diff --git a/examples/plugin-tagged/apples.png b/examples/plugin-tagged/apples.png new file mode 100644 index 000000000..4dc3fd6c7 Binary files /dev/null and b/examples/plugin-tagged/apples.png differ diff --git a/examples/plugin-tagged/bananas.md b/examples/plugin-tagged/bananas.md new file mode 100644 index 000000000..81574196a --- /dev/null +++ b/examples/plugin-tagged/bananas.md @@ -0,0 +1,16 @@ +--- +title: Bananas +description: A soft and sweet tropical favorite. +date: 2024-06-08 +thumbnail: bananas.png +authors: + - name: Bunch Plantain + affiliations: + - Tropical Fruit Lab +tags: + - fruit +--- + +# Bananas + +Bananas are a fruit. diff --git a/examples/plugin-tagged/bananas.png b/examples/plugin-tagged/bananas.png new file mode 100644 index 000000000..58f65fc27 Binary files /dev/null and b/examples/plugin-tagged/bananas.png differ diff --git a/examples/plugin-tagged/index.md b/examples/plugin-tagged/index.md new file mode 100644 index 000000000..beafafdd8 --- /dev/null +++ b/examples/plugin-tagged/index.md @@ -0,0 +1,24 @@ +--- +title: Tagged Pages +--- + +# Tagged Pages + +This example demonstrates a directive transform plugin that lists every page in +the project carrying a given tag. The plugin reads the project-wide page list +from the (unstable) session API. + +## Fruit pages + +```{tagged} fruit +``` + +## Citrus pages + +```{tagged} citrus +``` + +## Pages that do not exist + +```{tagged} vegetable +``` diff --git a/examples/plugin-tagged/myst.yml b/examples/plugin-tagged/myst.yml new file mode 100644 index 000000000..e9a84cd82 --- /dev/null +++ b/examples/plugin-tagged/myst.yml @@ -0,0 +1,13 @@ +# See docs at: https://mystmd.org/guide/frontmatter +version: 1 +project: + title: Tagged Pages Plugin Example + plugins: + - plugin.mjs + toc: + - file: index.md + - file: apples.md + - file: oranges.md + - file: bananas.md +site: + template: book-theme diff --git a/examples/plugin-tagged/oranges.md b/examples/plugin-tagged/oranges.md new file mode 100644 index 000000000..9e3c814b7 --- /dev/null +++ b/examples/plugin-tagged/oranges.md @@ -0,0 +1,18 @@ +--- +title: Oranges +description: A juicy citrus packed with vitamin C. +date: 2024-03-22 +doi: 10.5555/oranges.2024 +thumbnail: oranges.png +authors: + - name: Clementine Citrus + affiliations: + - Citrus Research Center +tags: + - fruit + - citrus +--- + +# Oranges + +Oranges are a citrus fruit. diff --git a/examples/plugin-tagged/oranges.png b/examples/plugin-tagged/oranges.png new file mode 100644 index 000000000..34b157e69 Binary files /dev/null and b/examples/plugin-tagged/oranges.png differ diff --git a/examples/plugin-tagged/plugin.mjs b/examples/plugin-tagged/plugin.mjs new file mode 100644 index 000000000..aff8f09cf --- /dev/null +++ b/examples/plugin-tagged/plugin.mjs @@ -0,0 +1,105 @@ +// A node type used as a placeholder by the directive, later replaced by the +// project-stage transform once project-wide page information is available. +const PAGE_LIST_NODE = 'taggedPageList'; + +/** + * `{tagged} ` directive + * + * Emits a placeholder node carrying the requested tag. The actual cards are + * filled in by the transform below, which has access to the full project via + * the (unstable) session API. + */ +const taggedDirective = { + name: 'tagged', + doc: 'Show a grid of cards for every page in the project that has the given tag.', + arg: { + type: String, + required: true, + doc: 'The tag to filter project pages by.', + }, + run(data) { + return [{ type: PAGE_LIST_NODE, tag: `${data.arg}`.trim() }]; + }, +}; + +/** Build a card node for a single page. */ +function makeCard(page) { + const children = []; + + // Thumbnail (rendered at the top of the card via the header slot) + const thumbnail = page.thumbnailOptimized ?? page.thumbnail; + if (thumbnail) { + children.push({ + type: 'header', + children: [{ type: 'image', url: thumbnail, alt: page.title ?? '' }], + }); + } + + // Title + const title = page.title ?? page.slug ?? page.filename ?? 'Untitled'; + children.push({ type: 'cardTitle', children: [{ type: 'text', value: title }] }); + + // Subtitle (body) + const subtitle = page.short_title ?? page.description; + if (subtitle) { + children.push({ type: 'paragraph', children: [{ type: 'text', value: subtitle }] }); + } + + // Author names (footer) + const authorNames = (page.authors ?? []) + .map((author) => author?.name) + .filter(Boolean) + .join(', '); + if (authorNames) { + children.push({ + type: 'footer', + children: [{ type: 'emphasis', children: [{ type: 'text', value: `by ${authorNames}` }] }], + }); + } + + return { type: 'card', url: page.url, children }; +} + +/** + * Project-stage transform that replaces each placeholder node with a grid of + * cards for every page in the project carrying the requested tag. + */ +function taggedTransform(opts, utils) { + return async (mdast) => { + const { selectAll, unstableSession } = utils; + const placeholders = selectAll(PAGE_LIST_NODE, mdast); + if (placeholders.length === 0) return; + const pages = unstableSession?.project?.pages ?? []; + placeholders.forEach((node) => { + const { tag } = node; + const matching = pages.filter((page) => (page.tags ?? []).includes(tag)); + delete node.tag; + if (matching.length === 0) { + node.type = 'paragraph'; + node.children = [{ type: 'text', value: `No pages tagged "${tag}".` }]; + return; + } + // Mutate the placeholder in place into a grid of cards + node.type = 'grid'; + node.kind = 'listing'; + node.columns = [1, 2, 2, 3]; + node.children = matching.map((page) => makeCard(page)); + }); + }; +} + +const taggedTransformPlugin = { + name: 'Tagged pages cards', + // 'project' stage runs after all pages are processed, so every page's tags, + // frontmatter, and resolved url are available on the session. + stage: 'project', + plugin: taggedTransform, +}; + +const plugin = { + name: 'Tagged Pages Plugin', + directives: [taggedDirective], + transforms: [taggedTransformPlugin], +}; + +export default plugin; diff --git a/packages/myst-cli/src/process/mdast.ts b/packages/myst-cli/src/process/mdast.ts index fcd71cba4..aefdb4a47 100644 --- a/packages/myst-cli/src/process/mdast.ts +++ b/packages/myst-cli/src/process/mdast.ts @@ -1,6 +1,12 @@ import path from 'node:path'; import { tic } from 'myst-cli-utils'; -import type { GenericParent, PluginUtils, References } from 'myst-common'; +import type { + GenericParent, + References, + SessionPage, + SessionProject, + SessionSite, +} from 'myst-common'; import { fileError, fileWarn, RuleId, slugToUrl } from 'myst-common'; import type { PageFrontmatter } from 'myst-frontmatter'; import { SourceFileKind } from 'myst-spec-ext'; @@ -91,8 +97,6 @@ import { const LINKS_SELECTOR = 'link,card,linkBlock'; -const pluginUtils: PluginUtils = { select, selectAll }; - const htmlHandlers = { comment(h: any, node: any) { // Prevents HTML comments from showing up as text in web @@ -108,6 +112,76 @@ export type TransformFn = ( opts: Parameters[1], ) => Promise; +/** + * Build the project/site information exposed to plugins via the unstable session API. + * + * This gathers the list of all pages in the project (with their tags, slugs, and + * resolved urls) along with the current site configuration so that plugins, e.g. a + * directive that lists pages with a given tag, can operate on project-wide data. + * + * Page tags and urls are populated during `transformMdast`; for `project` stage + * transforms (running in `postProcessMdast`) all pages have been processed so this + * data is complete. + */ +function getSessionProject(session: ISession, projectPath?: string): SessionProject | undefined { + const state = session.store.getState(); + const cache = castSession(session); + const siteConfig = selectors.selectCurrentSiteConfig(state); + const resolvedProjectPath = projectPath ?? selectors.selectCurrentProjectPath(state); + if (!resolvedProjectPath) return undefined; + const proj = selectors.selectLocalProject(state, resolvedProjectPath); + if (!proj) return undefined; + const projectSlug = siteConfig?.projects?.find((p) => p.path === resolvedProjectPath)?.slug; + const pages: SessionProject['pages'] = []; + // The full processed page frontmatter is available once a page has been through + // `transformMdast` (i.e. complete for `project` stage transforms). `selectFileInfo` + // is used as a fallback for the basics (e.g. during `document` stage transforms). + const pageFromFile = (file: string, slug?: string, level?: number): SessionPage => { + const fileInfo = selectors.selectFileInfo(state, file); + const frontmatter = cache.$getMdast(file)?.post?.frontmatter ?? {}; + return { + title: fileInfo.title ?? undefined, + short_title: fileInfo.short_title ?? undefined, + description: fileInfo.description ?? undefined, + tags: fileInfo.tags ?? undefined, + date: fileInfo.date ?? undefined, + ...frontmatter, + slug, + url: fileInfo.url ?? undefined, + file, + filename: path.basename(file), + level, + }; + }; + // The project index page is tracked separately from the rest of the pages + pages.push(pageFromFile(proj.file, proj.index, 1)); + proj.pages.forEach((tocEntry) => { + if ('file' in tocEntry) { + pages.push(pageFromFile(tocEntry.file, tocEntry.slug, tocEntry.level)); + } else if ('url' in tocEntry) { + pages.push({ title: tocEntry.title, url: tocEntry.url, level: tocEntry.level }); + } + }); + const site: SessionSite | undefined = siteConfig + ? { + title: siteConfig.title, + description: siteConfig.description, + options: siteConfig.options, + nav: siteConfig.nav, + actions: siteConfig.actions, + domains: siteConfig.domains, + template: siteConfig.template, + } + : undefined; + return { + slug: projectSlug, + index: proj.index, + title: manifestTitleFromProject(session, resolvedProjectPath), + pages, + site, + }; +} + export async function transformMdast( session: ISession, opts: { @@ -220,9 +294,17 @@ export async function transformMdast( .use(inlineMathSimplificationPlugin, { replaceSymbol: false }) .use(mathPlugin, { macros: frontmatter.math }); // Load custom transform plugins + const documentSessionProject = getSessionProject(session, projectPath); session.plugins?.transforms.forEach((t) => { if (t.stage !== 'document') return; - pipe.use(t.plugin, undefined, pluginUtils); + pipe.use(t.plugin, undefined, { + select, + selectAll, + unstableSession: { + page: { slug: pageSlug, frontmatter }, + project: documentSessionProject, + }, + }); }); pipe @@ -376,9 +458,17 @@ export async function postProcessMdast( await transformMystXRefs(session, vfile, mdast, frontmatter); await embedTransform(session, mdast, file, dependencies, state); const pipe = unified(); + const projectSessionProject = getSessionProject(session, projectPath); session.plugins?.transforms.forEach((t) => { if (t.stage !== 'project') return; - pipe.use(t.plugin, undefined, pluginUtils); + pipe.use(t.plugin, undefined, { + select, + selectAll, + unstableSession: { + page: { slug: mdastPost.slug, frontmatter }, + project: projectSessionProject, + }, + }); }); await pipe.run(mdast, vfile); diff --git a/packages/myst-common/src/index.ts b/packages/myst-common/src/index.ts index e2147d0e8..4ec3191ef 100644 --- a/packages/myst-common/src/index.ts +++ b/packages/myst-common/src/index.ts @@ -51,6 +51,10 @@ export type { ValidatedMystPlugin, PluginOptions, PluginUtils, + SessionAPI, + SessionPage, + SessionProject, + SessionSite, TransformSpec, FrontmatterPart, FrontmatterParts, diff --git a/packages/myst-common/src/types.ts b/packages/myst-common/src/types.ts index e6643de66..d29beef7d 100644 --- a/packages/myst-common/src/types.ts +++ b/packages/myst-common/src/types.ts @@ -115,7 +115,62 @@ export type RoleSpec = { type Select = (selector: string, tree?: GenericParent) => GenericNode | null; type SelectAll = (selector: string, tree?: GenericParent) => GenericNode[] | null; -export type PluginUtils = { select: Select; selectAll: SelectAll }; +/** + * Page information exposed to plugins via the unstable session API. + * + * This is the full page frontmatter (title, description, tags, date, authors, + * affiliations, contributors, doi, etc.) extended with the page's location + * within the site. + */ +export type SessionPage = PageFrontmatter & { + slug?: string; + /** Resolved url for the page within the site, e.g. `/my-page`. */ + url?: string; + /** Full path to the source file on disk. */ + file?: string; + /** Source file name, e.g. `my-page.md`. */ + filename?: string; + /** Table of contents depth/level for the page. */ + level?: number; +}; + +/** Minimal site configuration exposed to plugins via the unstable session API. */ +export type SessionSite = { + title?: string; + description?: string; + options?: Record; + nav?: any[]; + actions?: any[]; + domains?: string[]; + template?: string; +}; + +/** Minimal project information exposed to plugins via the unstable session API. */ +export type SessionProject = { + slug?: string; + index?: string; + title?: string; + pages: SessionPage[]; + /** The current site configuration that this project belongs to. */ + site?: SessionSite; +}; + +export type SessionAPI = { + page: { + slug?: string; + frontmatter: PageFrontmatter; + }; + /** + * The current project, including the list of all pages and their tags, as + * well as the site configuration nested under `project.site`. + * + * This is only fully populated for `project` stage transforms; during + * `document` stage transforms other pages may not have been processed yet. + */ + project?: SessionProject; +}; + +export type PluginUtils = { select: Select; selectAll: SelectAll; unstableSession: SessionAPI }; export type PluginOptions = Record; export type TransformSpec = {