-
Notifications
You must be signed in to change notification settings - Fork 175
WIP: Project state in plugins #2979
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| title: Testing | ||
| --- | ||
|
|
||
| # Testing |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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} <tag>` 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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do like the explicitness of "unstable". We could also use a namespace, e.g.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I won't die on this hill if y'all like |
||
| 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; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<typeof transformMdast>[1], | ||||||||||||
| ) => Promise<void>; | ||||||||||||
|
|
||||||||||||
| /** | ||||||||||||
| * 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 { | ||||||||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here we actually construct this. This function is pretty messy, and the goal is to resolve a clean API to consumers through the plugins. |
||||||||||||
| 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); | ||||||||||||
|
Comment on lines
296
to
+297
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just to make it clear this is document stage (I think it is?) |
||||||||||||
| 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, | ||||||||||||
| }, | ||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Or if it's not easy to provide the AST to transforms, could we add |
||||||||||||
| }); | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| 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) => { | ||||||||||||
|
Comment on lines
+461
to
462
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also not critical - just trying to make the macro-structure of this a bit easier to parse |
||||||||||||
| if (t.stage !== 'project') return; | ||||||||||||
| pipe.use(t.plugin, undefined, pluginUtils); | ||||||||||||
| pipe.use(t.plugin, undefined, { | ||||||||||||
| select, | ||||||||||||
| selectAll, | ||||||||||||
| unstableSession: { | ||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Quick check: if old plugins don't include |
||||||||||||
| page: { slug: mdastPost.slug, frontmatter }, | ||||||||||||
| project: projectSessionProject, | ||||||||||||
| }, | ||||||||||||
| }); | ||||||||||||
| }); | ||||||||||||
| await pipe.run(mdast, vfile); | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if this is worth putting in here, but sometimes it is nice to jump into a specific example?