Skip to content
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
5 changes: 5 additions & 0 deletions examples/plugin-session/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: Testing
---

# Testing
17 changes: 17 additions & 0 deletions examples/plugin-session/myst.yml
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
18 changes: 18 additions & 0 deletions examples/plugin-session/plugin.mjs
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;
17 changes: 17 additions & 0 deletions examples/plugin-tagged/apples.md
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.
Binary file added examples/plugin-tagged/apples.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions examples/plugin-tagged/bananas.md
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.
Binary file added examples/plugin-tagged/bananas.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions examples/plugin-tagged/index.md
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
```
13 changes: 13 additions & 0 deletions examples/plugin-tagged/myst.yml
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
Comment on lines +2 to +11

Copy link
Copy Markdown
Member Author

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?

site:
template: book-theme
18 changes: 18 additions & 0 deletions examples/plugin-tagged/oranges.md
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.
Binary file added examples/plugin-tagged/oranges.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
105 changes: 105 additions & 0 deletions examples/plugin-tagged/plugin.mjs
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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unstableSession feels a little idiosyncratic to me - could we try to think of a more intuitive naming for plugin authors? Based on what's inside, my brain immediately went to something like sessionContext or buildContext or something like that...I feel like the unstable could make sense, but is also a bit verbose and may not be necessary as long as we document this functionality clearly enough (ie, note that it is unstable, and that certain moments in the build chain are more unstable than others)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

utils.hazmat.session.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I won't die on this hill if y'all like unstableFoo :-)

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;
100 changes: 95 additions & 5 deletions packages/myst-cli/src/process/mdast.ts
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';
Expand Down Expand Up @@ -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
Expand All @@ -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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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: {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Load custom transform plugins
const documentSessionProject = getSessionProject(session, projectPath);
/** Document transform stage */
// Load custom transform plugins
const documentSessionProject = getSessionProject(session, projectPath);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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,
},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 parseMyst function to the transforms and then they could parse stuff themselves? (I think this would be useful either way)

});
});

pipe
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const projectSessionProject = getSessionProject(session, projectPath);
session.plugins?.transforms.forEach((t) => {
/** Project transform stage (document stage is above) */
const projectSessionProject = getSessionProject(session, projectPath);
session.plugins?.transforms.forEach((t) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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: {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick check: if old plugins don't include unstableSession will they break until they update? Could we ensure it's optional so this doesn't happen?

page: { slug: mdastPost.slug, frontmatter },
project: projectSessionProject,
},
});
});
await pipe.run(mdast, vfile);

Expand Down
4 changes: 4 additions & 0 deletions packages/myst-common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export type {
ValidatedMystPlugin,
PluginOptions,
PluginUtils,
SessionAPI,
SessionPage,
SessionProject,
SessionSite,
TransformSpec,
FrontmatterPart,
FrontmatterParts,
Expand Down
Loading
Loading