JavaScript plugins are native MyST plugins, which are loaded as modules into the MyST engine. Transforms defined in these modules have access to helpful AST manipulation routines made available by MyST. Edits to JavaScript plugins have no effect during execution of a MyST build, instead the build must be restarted.
Defining a new directive¶
To create a plugin, you will need a single Javascript file[1] that exports one or more of the objects above. For example, a simple directive that pulls a random image from Unsplash can be created with a single file that exports an unsplash
directive.
This code should be referenced from your myst.yml
under the projects.plugins
:
project:
plugins:
- unsplash.mjs
Then start or build your document using myst start
or myst build
, and you will see that the plugin is loaded.
myst start
...
🔌 Unsplash Images (unsplash.mjs) loaded: 1 directive
...
You can now use the directive, for example:
:::{unsplash} misty,mountains
:::
If you change the source code you will have to stop and re-start the server to see the results.
The types are defined in myst-common
(npm, github) with the DirectiveSpec
and RoleSpec
being the main types to implement.
Implementing a custom transform¶
Directives can be used to extend MyST with rich structured content. However, sometimes we want to modify the existing behavior of MyST. One of the ways to do this is by writing a custom transform. In this section, we’ll implement a transform that replaces bold text with emphasis.
First, let’s define the transform
this code should be referenced from your myst.yml
under the projects.plugins
:
project:
plugins:
- type: javascript
path: markup.mjs
then start or build your document using myst start
or myst build
, and you will see that the plugin is loaded.
myst start
...
🔌 Strong to emphasis (markup.mjs) loaded: 1 directive
...
you can now use the directive, for example:
I am **special bold text**, whilst I am **normal bold text**
I am special bold text, whilst I am normal bold text
Examples of plugins¶
The documentation you’re reading now defines several of its own plugins to extend MyST functionality. These are all registered in the documentation’s myst.yml configuration with syntax like below:
plugins:
- directives.mjs
- unsplash.mjs
- latex.mjs
- templates.mjs
- type: executable
path: unsplash.py
- type: executable
path: markup.py
- type: javascript
path: markup.mjs
Each plugin is defined as a .mjs
file in the same folder as the documentation’s MyST content.
Below is the contents of each file for reference.
Plugin: Latex rendering
import { DEFAULT_HANDLERS } from 'tex-to-myst';
const latexDirective = {
name: 'myst:tex-list',
run() {
const keys = Object.keys(DEFAULT_HANDLERS);
const macros = keys
.filter((k) => k.startsWith('macro_'))
.map((k) => ({
type: 'listItem',
children: [{ type: 'inlineCode', value: `\\${k.replace('macro_', '')}` }],
}));
const environments = keys
.filter((k) => k.startsWith('env_'))
.map((k) => ({
type: 'listItem',
children: [{ type: 'inlineCode', value: `\\begin{${k.replace('env_', '')}}` }],
}));
return [
{
type: 'details',
children: [
{ type: 'summary', children: [{ type: 'text', value: 'LaTeX Macros' }] },
{ type: 'list', children: macros },
],
},
{
type: 'details',
children: [
{ type: 'summary', children: [{ type: 'text', value: 'LaTeX Environments' }] },
{ type: 'list', children: environments },
],
},
];
},
};
const plugin = { name: 'LaTeX List', directives: [latexDirective] };
export default plugin;
Plugin: Display an image
const unsplashDirective = {
name: 'unsplash',
doc: 'An example directive for showing a nice random image at a custom size.',
alias: ['random-pic'],
arg: { type: String, doc: 'The kinds of images to search for, e.g., `fruit`' },
options: {
size: { type: String, doc: 'Size of the image, for example, `500x200`.' },
},
run(data) {
const query = data.arg;
const size = data.options.size || '500x200';
const url = `https://source.unsplash.com/random/${size}/?${query}`;
const img = { type: 'image', url };
return [img];
},
};
const plugin = { name: 'Unsplash Images', directives: [unsplashDirective] };
export default plugin;
Plugin: Custom directive for documenting roles and directives
import { u } from 'unist-builder';
import { mystParse } from 'myst-parser';
import { defaultDirectives } from 'myst-directives';
import { defaultRoles } from 'myst-roles';
import { fileError } from 'myst-common';
/**
* @param {import('myst-common').OptionDefinition} option
*/
function type2string(option) {
if (option.type === 'string' || option.type === String) return 'string';
if (option.type === 'number' || option.type === Number) return 'number';
if (option.type === 'boolean' || option.type === Boolean) return 'boolean';
if (option.type === 'parsed' || option.type === 'myst') return 'parsed';
return '';
}
function createOption(directive, optName, option) {
if (!option) return [];
const optType = type2string(option);
const def = [
u('definitionTerm', { identifier: `directive-${directive.name}-${optName}` }, [
u('strong', [
u(
'text',
optName === 'arg'
? 'Directive Argument'
: optName === 'body'
? 'Directive Body'
: optName,
),
]),
...(optType
? [
u('text', ' ('),
u('emphasis', [u('text', `${optType}${option.required ? ', required' : ''}`)]),
u('text', ')'),
]
: []),
]),
u(
'definitionDescription',
option.doc ? mystParse(option.doc).children : [u('text', 'No description')],
),
];
if (option.alias && option.alias.length > 0) {
def.push(
u('definitionDescription', [
u('strong', [u('text', 'Alias')]),
u('text', ': '),
...option.alias
.map((a, i) => {
const c = [u('inlineCode', a)];
if (i < option.alias.length - 1) c.push(u('text', ', '));
return c;
})
.flat(),
]),
);
}
return def;
}
/**
* Create a documentation section for a directive
*
* @type {import('myst-common').DirectiveSpec}
*/
const mystDirective = {
name: 'myst:directive',
arg: {
type: String,
required: true,
},
run(data, vfile) {
const name = data.arg;
const directive = defaultDirectives.find((d) => d.name === name);
if (!directive) {
fileError(vfile, `myst:directive: Unknown myst directive "${name}"`);
return [];
}
const heading = u('heading', { depth: 2, identifier: `directive-${name}` }, [
u('inlineCode', name),
u('text', ' directive'),
]);
const doc = directive.doc ? mystParse(directive.doc).children : [];
let alias = [];
if (directive.alias && directive.alias.length > 0) {
alias = [
u('paragraph', [
u('strong', [u('text', 'Alias')]),
u('text', ': '),
...directive.alias
.map((a, i) => {
const c = [u('inlineCode', a)];
if (i < directive.alias.length - 1) c.push(u('text', ', '));
return c;
})
.flat(),
]),
];
}
const options = Object.entries(directive.options ?? {})
.map(([optName, option]) => createOption(directive, optName, option))
.flat();
const list = u('definitionList', [
...createOption(directive, 'arg', directive.arg),
...createOption(directive, 'body', directive.body),
u('definitionTerm', { identifier: `directive-${directive.name}-opts` }, [
u('strong', [u('text', 'Options')]),
]),
options.length > 0
? u('definitionDescription', [u('definitionList', options)])
: u('definitionDescription', [u('text', 'No options')]),
]);
return [heading, u('div', [...doc, ...alias]), list];
},
};
/**
* Create a documentation section for a directive
*
* @type {import('myst-common').DirectiveSpec}
*/
const mystRole = {
name: 'myst:role',
arg: {
type: String,
required: true,
},
run(data, vfile) {
const name = data.arg;
const role = defaultRoles.find((d) => d.name === name);
if (!role) {
fileError(vfile, `myst:role: Unknown myst role "${name}"`);
return [];
}
const heading = u('heading', { depth: 2, identifier: `role-${name}` }, [
u('inlineCode', name),
u('text', ' role'),
]);
const doc = role.doc ? mystParse(role.doc).children : [];
let alias = [];
if (role.alias && role.alias.length > 0) {
alias = [
u('paragraph', [
u('strong', [u('text', 'Alias')]),
u('text', ': '),
...role.alias
.map((a, i) => {
const c = [u('inlineCode', a)];
if (i < role.alias.length - 1) c.push(u('text', ', '));
return c;
})
.flat(),
]),
];
}
return [heading, u('div', [...doc, ...alias])];
},
};
const REF_PATTERN = /^(.+?)<([^<>]+)>$/; // e.g. 'Labeled Reference <ref>'
/**
* Create a documentation section for a directive
*
* @type {import('myst-common').RoleSpec}
*/
const mystDirectiveRole = {
name: 'myst:directive',
body: {
type: String,
required: true,
},
run(data) {
const match = REF_PATTERN.exec(data.body);
const [, modified, rawLabel] = match ?? [];
const label = rawLabel ?? data.body;
const [name, opt] = label?.split('.') ?? [];
const directive = defaultDirectives.find((d) => d.name === name || d.alias?.includes(name));
const identifier = opt
? `directive-${directive?.name ?? name}-${opt}`
: `directive-${directive?.name ?? name}`;
var textToDisplay = modified?.trim() || name;
if (opt) {
textToDisplay = `${textToDisplay}.${opt}`;
}
return [
u('crossReference', { identifier }, [u('inlineCode', `{${textToDisplay}}`)]),
];
},
};
/**
* Create a documentation section for a directive
*
* @type {import('myst-common').RoleSpec}
*/
const mystRoleRole = {
name: 'myst:role',
body: {
type: String,
required: true,
},
run(data) {
const match = REF_PATTERN.exec(data.body);
const [, modified, rawLabel] = match ?? [];
const label = rawLabel ?? data.body;
const [name, opt] = label?.split('.') ?? [];
const role = defaultRoles.find((d) => d.name === name || d.alias?.includes(name));
const identifier = opt ? `role-${role?.name ?? name}-${opt}` : `role-${role?.name ?? name}`;
var textToDisplay = modified?.trim() || name;
if (opt) {
textToDisplay = `${textToDisplay}.${opt}`;
}
return [
u('crossReference', { identifier }, [u('inlineCode', `{${textToDisplay}}`)]),
];
},
};
/**
* @type {import('myst-common').MystPlugin}
*/
const plugin = {
name: 'MyST Documentation Plugins',
author: 'Rowan Cockett',
license: 'MIT',
directives: [mystDirective, mystRole],
roles: [mystDirectiveRole, mystRoleRole],
};
export default plugin;
Plugin: Render web template options as a table
/**
* Example of a MyST plugin that retrieves MyST template option information
* and displays it as a definition list
*/
import { u } from 'unist-builder';
import { mystParse } from 'myst-parser';
/**
* @typedef MySTTemplateRef
* @type {object}
* @property {string} template - a partial or fully resolved template name
* @property {string} kind - the kind of template, e.g. 'site'
* @property {boolean} fullTitle - show the full template title, or just the name
* @property {number} headingDepth - depth of the generated heading (e.g. 1 for h1)
*/
/**
* Create a documentation section for a template
*
* This directive simply passes-through the options into an AST node,
* because we can't (shouldn't) perform any async / blocking work here.
*
* @type {import('myst-common').DirectiveSpec}
*/
const mystTemplate = {
name: 'myst:template',
options: {
kind: {
type: String,
},
'full-title': {
type: Boolean,
},
'heading-depth': {
type: Number,
},
},
arg: {
type: String,
required: true,
},
run(data) {
/** @type {MySTTemplateRef} */
const templateRef = u(
'myst-template-ref',
{
template: data.arg,
kind: data.options?.kind ?? 'site',
fullTitle: data.options?.['fullTitle'] ?? false,
headingDepth: data.options?.['heading-depth'] ?? 2,
},
[],
);
return [templateRef];
},
};
let _promise = undefined;
/**
* Determine a URL-friendly slug for a given template ID.
*
* @param id - template ID
*/
function slugify(id) {
return id.replaceAll('/', '-');
}
/**
* Return the MyST AST for a given template option declaration.
*
* @param template - template declaration
* @param option - option declaration
*/
function createOption(template, option) {
if (!option) {
return [];
}
// Build a definitionTerm for the given template option
const def = [
u('definitionTerm', { identifier: `template-${slugify(template.id)}-${slugify(option.id)}` }, [
u('strong', [u('text', option.id)]),
...(option.type
? [
u('text', ' ('),
u('emphasis', [u('text', `${option.type}${option.required ? ', required' : ''}`)]),
u('text', ')'),
]
: []),
]),
];
// Build a definitionDescription for the given template option, falling back on default text if
// no description is defined.
def.push(
u(
'definitionDescription',
// Parse the description as MyST (if given)
option.description ? mystParse(option.description).children : [u('text', 'No description')],
),
);
return def;
}
/**
* Load a MyST Template e.g. https://api.mystmd.org/templates/site/myst/book-theme
*
* @param url - url to MyST template
*/
async function loadFromTemplateMeta(url) {
const response = await fetch(url);
return await response.json();
}
/**
* Load a list of MyST templates with a given kind, e.g. https://api.mystmd.org/templates/site/
*
* @param url - url to MyST templates
*/
async function loadByTemplateKind(url) {
const response = await fetch(url);
const { items } = await response.json();
return await Promise.all(items.map((item) => loadFromTemplateMeta(item.links.self)));
}
/**
* Load a list of all MyST templates given by api.mystmd.org
*/
async function loadTemplates() {
// Load top-level list of templates
const response = await fetch(`https://api.mystmd.org/templates/`);
const { links } = await response.json();
// Load all the top-level kinds
return (await Promise.all(Object.values(links).map(loadByTemplateKind))).flat();
}
// Define some regular expressions to identify partial template names (e.g. book-theme)
// vs full names (e.g. site/myst/book-theme)
const PARTIAL_TEMPLATE_REGEX = /^[a-zA-Z0-9_-]+$/;
const TEMPLATE_REGEX = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/;
const FULL_TEMPLATE_REGEX = /^(site|tex|typst|docx)\/([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_-]+)$/;
/**
* MyST transform to fetch information about MyST templates from api.mystmd.org, and
* populate the children of myst-template-ref nodes using this data
*
* @param opts - (empty) options populated by the caller (MyST)
* @param utils - helpful utility functions
*/
function templateTransform(opts, utils) {
return async (mdast) => {
// This function is called during processing of all documents, with multiple invocations
// potentially running concurrently. To avoid fetching the templates for each call,
// we first create the promise (but _do not await it_) so that other invocations
// can await the result.
if (_promise === undefined) {
_promise = loadTemplates();
}
// Now we await the list of templates. After this promise has been resolved, this will
// happen instantly
let templates;
try {
templates = await _promise;
} catch (err) {
throw new Error('Error loading template information from https://api.mystmd.org');
}
// Using unist-util-select, a utility for walking unist (to which MyST confirms) graphs.
// We are looking for nodes of type `myst-template-ref`, which are created by our directive above
utils.selectAll('myst-template-ref', mdast).forEach((node) => {
// Figure out whether the caller gave a full template or partial template name.
// If the name is partial, we will try to resolve it into a full name.
const templateName = node.template;
let resolvedTemplateName;
if (templateName.match(PARTIAL_TEMPLATE_REGEX) && node.kind !== undefined) {
resolvedTemplateName = `${node.kind}/myst/${templateName}`;
} else if (templateName.match(TEMPLATE_REGEX) && node.kind !== undefined) {
resolvedTemplateName = `${node.kind}/${templateName}`;
} else if (templateName.match(FULL_TEMPLATE_REGEX)) {
resolvedTemplateName = templateName;
} else {
throw new Error(`Could not determine full name for template: ${templateName}`);
}
// Let's now find the template information for the requested template name
const template = templates.find((template) => template.id === resolvedTemplateName);
if (template === undefined) {
throw new Error(`Could not find template ${templateName}`);
}
// Parse the template name into useful parts
const [_, kind, namespace, name, ...rest] = template.id.match(FULL_TEMPLATE_REGEX);
// Build the title node
const title = node.fullTitle ? template.id : name;
const slug = slugify(template.id);
const heading = u('heading', { depth: node.headingDepth, identifier: `template-${slug}` }, [
u('inlineCode', title),
u('text', ' template'),
]);
// Parse the template description
const doc = template.description ? mystParse(template.description).children : [];
// Build a definitionList of template options
const options = (template.options ?? {})
.map((option) => createOption(template, option))
.flat();
const list = u('definitionList', [
u('definitionTerm', { identifier: `template-${slug}-opts` }, [
u('strong', [u('text', 'Options')]),
]),
options.length > 0
? u('definitionDescription', [u('definitionList', options)])
: u('definitionDescription', [u('text', 'No options')]),
]);
// Add a footer that links to the template source
const link = {
type: 'link',
url: template.links.source,
children: [
{
type: 'text',
value: 'Source',
},
],
};
// Update the `myst-template-ref` children with our generated nodes
node.children = [heading, ...doc, list, link];
});
};
}
// Declare a transform plugin
const mystTemplateTransform = {
plugin: templateTransform,
stage: 'document',
};
/**
* @type {import('myst-common').MystPlugin}
*/
const plugin = {
name: 'MyST Template Documentation Plugins',
author: 'Angus Hollands',
license: 'MIT',
directives: [mystTemplate],
roles: [],
transforms: [mystTemplateTransform],
};
export default plugin;
The format of the Javascript should be an ECMAScript modules, not CommonJS. This means it uses
import
statements rather thanrequire()
and is the most modern style of Javascript.