Skip To Article

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.

unsplash.mjs
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;

Program 1:A plugin to add an unsplash directive that includes a beautiful, random picture based on a query string.

This code should be referenced from your myst.yml under the projects.plugins:

myst.yml
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

markup.mjs
const plugin = {
  name: 'Strong to emphasis',
  transforms: [
    {
      name: 'transform-typography',
      doc: 'An example transform that rewrites bold text as text with emphasis.',
      stage: 'document',
      plugin: (_, utils) => (node) => {
        utils.selectAll('strong', node).forEach((strongNode) => {
          const childTextNodes = utils.selectAll('text', strongNode);
          const childText = childTextNodes.map((child) => child.value).join('');
          if (childText === 'special bold text') {
            strongNode['type'] = 'span';
            strongNode['style'] = {
              background: '-webkit-linear-gradient(20deg, #09009f, #E743D9)',
              '-webkit-background-clip': 'text',
              '-webkit-text-fill-color': 'transparent',
            };
          }
        });
      },
    },
  ],
};

export default plugin;

A plugin to add a transform that replaces strong nodes with emphasis nodes.

this code should be referenced from your myst.yml under the projects.plugins:

myst.yml
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:

myst.yml
  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
latex.mjs
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
unsplash.mjs
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
directives.mjs
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
templates.mjs
/**
 * 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;
Footnotes
  1. The format of the Javascript should be an ECMAScript modules, not CommonJS. This means it uses import statements rather than require() and is the most modern style of Javascript.

MyST MarkdownMyST Markdown
Community-driven tools for the future of technical communication and publication, part of Jupyter.