From fd46e3dc5e64d4ad1f647d4a44b954ddf7b53067 Mon Sep 17 00:00:00 2001 From: choldgraf Date: Sat, 11 May 2024 11:18:54 -0700 Subject: [PATCH 1/9] Add theme options to docs --- docs/requirements.txt | 4 ++++ docs/website-templates.md | 48 ++++++++++++++++++++++++--------------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index cbb5ce1eb..d92f55042 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -5,3 +5,7 @@ pandas matplotlib numpy vega_datasets + +# For parsing YAML +pyyaml +requests diff --git a/docs/website-templates.md b/docs/website-templates.md index f658bceb1..fda3309f1 100644 --- a/docs/website-templates.md +++ b/docs/website-templates.md @@ -45,6 +45,7 @@ The frontmatter that is displayed at the top of the article is the contents of y ## Site Options There are a number of common options between the site templates. These should be placed in the `site.options` in your `myst.yml`. +For example: ```{code-block} yaml :filename: myst.yml @@ -54,21 +55,32 @@ site: logo: my-site-logo.svg ``` -```{list-table} Site Options -:header-rows: 1 -:label: tbl:site-options -* - option - - description -* - `favicon` - - a file - Local path to favicon image -* - `logo` - - a file - Local path to logo image -* - `logo_dark` - - a file - Local path to logo image to be used in dark mode only -* - `logo_text` - - a string - Short text to display next to logo at the top of all pages -* - `analytics_google` - - a string - Google analytics key, see [](./analytics.md) -* - `analytics_plausible` - - a string - Plausible analytics key, see [](./analytics.md) -``` +Below is a table of options for each theme. + +```{code-cell} python +:tags: remove-input +import requests +import yaml +from IPython.display import display, Markdown, HTML +import pandas as pd + +# URL of the remote YAML file +urls = ["https://github.com/executablebooks/myst-theme/raw/main/themes/book/template.yml", + "https://github.com/executablebooks/myst-theme/raw/main/themes/article/template.yml" + ] +for url in urls: + # Send a GET request to download the YAML file + response = requests.get(url) + + # Check if the request was successful + if response.status_code == 200: + # Parse the YAML content into a Python dictionary + data = yaml.safe_load(response.text) + df = pd.DataFrame(data["options"]) + + display(Markdown(f"{data['title']}")) + display(HTML(df[["id", "description", "type"]].to_html(index=False))) + + else: + print(f"Failed to fetch YAML file: {response.status_code}") +``` \ No newline at end of file From 33686086c868d6311c80ae2bf64617781868a4a6 Mon Sep 17 00:00:00 2001 From: choldgraf Date: Sat, 11 May 2024 11:21:35 -0700 Subject: [PATCH 2/9] Comments --- docs/website-templates.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/website-templates.md b/docs/website-templates.md index fda3309f1..7037c0feb 100644 --- a/docs/website-templates.md +++ b/docs/website-templates.md @@ -57,6 +57,9 @@ site: Below is a table of options for each theme. +% TODO: Parse the output as markdown when this is resolved: +% ref: https://github.com/executablebooks/mystmd/issues/1026 +% TODO: Figure out how to attach a label to each of these tables. ```{code-cell} python :tags: remove-input import requests From b03aa025d0c54890ca42352255dea8ef1121f495 Mon Sep 17 00:00:00 2001 From: choldgraf Date: Sat, 11 May 2024 11:52:24 -0700 Subject: [PATCH 3/9] Use function --- docs/website-templates.md | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/docs/website-templates.md b/docs/website-templates.md index 7037c0feb..74d4530f5 100644 --- a/docs/website-templates.md +++ b/docs/website-templates.md @@ -68,10 +68,7 @@ from IPython.display import display, Markdown, HTML import pandas as pd # URL of the remote YAML file -urls = ["https://github.com/executablebooks/myst-theme/raw/main/themes/book/template.yml", - "https://github.com/executablebooks/myst-theme/raw/main/themes/article/template.yml" - ] -for url in urls: +def display_options(url): # Send a GET request to download the YAML file response = requests.get(url) @@ -80,10 +77,29 @@ for url in urls: # Parse the YAML content into a Python dictionary data = yaml.safe_load(response.text) df = pd.DataFrame(data["options"]) - - display(Markdown(f"{data['title']}")) - display(HTML(df[["id", "description", "type"]].to_html(index=False))) - + df = df.rename(columns={"id": "name"}) + display(HTML(df[["name", "description", "type"]].to_html(index=False))) else: print(f"Failed to fetch YAML file: {response.status_code}") -``` \ No newline at end of file +``` + +### Book theme + + +```{code-cell} python +:tags: remove-input +url = "https://github.com/executablebooks/myst-theme/raw/main/themes/book/template.yml" +display_options(url) +``` + +[Source file](https://github.com/executablebooks/myst-theme/raw/main/themes/book/template.yml) + +### Article theme + +```{code-cell} python +:tags: remove-input +url = "https://github.com/executablebooks/myst-theme/raw/main/themes/article/template.yml" +display_options(url) +``` + +[Source file](https://github.com/executablebooks/myst-theme/raw/main/themes/article/template.yml) \ No newline at end of file From c2c3c334b371bc03352dc822910fc08a897f61be Mon Sep 17 00:00:00 2001 From: choldgraf Date: Sat, 11 May 2024 14:20:38 -0700 Subject: [PATCH 4/9] Document actions and nav --- docs/website-templates.md | 70 ++++++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/docs/website-templates.md b/docs/website-templates.md index 74d4530f5..d43ce7b70 100644 --- a/docs/website-templates.md +++ b/docs/website-templates.md @@ -1,9 +1,19 @@ --- -title: Website Templates +title: Website Themes & Templates description: There are two templates for MyST websites, a `book-theme`, based loosely on JupyterBook, and an `article-theme` that is designed for scientific documents with supporting notebooks. --- -There are currently two templates for MyST websites, a `book-theme`, which is the default and is based loosely on JupyterBook and an `article-theme` that is designed for scientific documents with supporting notebooks. The documentation for this site is using the `book-theme`, for a demonstration of the `article-theme`, you can see [an article on finite volume](https://simpeg.xyz/tle-finitevolume). +Web templates allow MyST to render documents as HTML-based sites. +These provide different reading experiences that are designed for different types of MyST documents. +They are defined via the same templating system used for [static document exporting](./documents-exports.md), and a base set of web themes can be found in the [`executablebooks/myst-themes` repository](https://github.com/executablebooks/myst-theme/tree/main/themes). + +:::{tip} Themes and templates mean the same thing +For the remainder of this page, assume that "theme" and "template" mean the same thing. +::: + +## Themes bundled with MyST + +There are two templates for MyST websites, a `book-theme`, which is the default and is based loosely on JupyterBook and an `article-theme` that is designed for scientific documents with supporting notebooks. The documentation for this site uses the `book-theme`. For a demonstration of the `article-theme`, you can see [an article on finite volume](https://simpeg.xyz/tle-finitevolume). :::::{tab-set} ::::{tab} Article Theme @@ -19,9 +29,15 @@ Example of a site using the `book-theme`, ([online](https://mystmd.org), [source :::: ::::: -## Changing Site Templates +### Article Theme + +The article theme is centered around a single document with supporting content, which is how many scientific articles are structured today: a narrative article with associated computational notebooks to reproduce a figure, document data-cleaning steps, or provide interactive visualization. These are listed as "supporting documents" in this theme and can be pulled in as normal with your [](./table-of-contents.md). For information on how to import your figures into your article, see [](./reuse-jupyter-outputs.md). + +The frontmatter that is displayed at the top of the article is the contents of your project, including a project [thumbnail and banner](#thumbnail-and-banner). The affiliations for your authors, their ORCID, email, etc. are available by clicking directly on the author name. + +## Change Site Templates -To change your website template from the default (`book-theme`), use the `site: template:` property: +To manually specify your website template, use the `site.template` property: ```{code} yaml :filename: myst.yml @@ -34,12 +50,50 @@ site: template: article-theme ``` -### Article Theme +(site-navigation)= +## Site navigation -The article theme is centered around a single document with supporting content, which is how many scientific articles are structured today: a narrative article with associated computational notebooks to reproduce a figure, document data-cleaning steps, or provide interactive visualization. These are listed as "supporting documents" in this theme and can be pulled in as normal with your [](./table-of-contents.md). For information on how to import your figures into your article, see [](./reuse-jupyter-outputs.md). +In addition to [your MyST document's Table of Contents](./table-of-contents.md), you may specify a top-level navigation for your MyST site. +These links are displayed across all pages of your site, and are useful for quickly jumping to sections of your site, or for external links. -The frontmatter that is displayed at the top of the article is the contents of your project, including a project [thumbnail and banner](#thumbnail-and-banner). The affiliations for your authors, their ORCID, email, etc. are available by clicking directly on the author name. +Specify top-level navigation with the `site.nav` option, and provide a collection of navigation links similar to [how the Table of Contents is structured](./table-of-contents.md). For example: +```{code-block} yaml +:filename: myst.yml + +site: + nav: + # A top-level dropdown + - title: Dropdown links + children: + - title: Page one + url: https://mystmd.org + - title: Page two + url: https://mystmd.org/guide + # A top-level link + - title: A standalone link + url: https://jupyter.org +``` + +% TODO: Clarify why some things have their own section (nav: and actions:) while +% others are nested under site.options. +## Action buttons + +Action buttons provide a more noticeable button that invites users to click on them. +They are located in the top-right of the page. + +Add action buttons to your site header with the `site.actions` option. For example: + +```{code-block} yaml +:filename: myst.yml + +site: + actions: + - title: Button text + url: https://mystmd.org + - title: Second button text + url: https://mystmd.org/guide +``` (site-options)= ## Site Options @@ -55,7 +109,7 @@ site: logo: my-site-logo.svg ``` -Below is a table of options for each theme. +Below is a table of options for each theme bundled with MyST. % TODO: Parse the output as markdown when this is resolved: % ref: https://github.com/executablebooks/mystmd/issues/1026 From b34d6801871a33b6a5cf195129eca72ce655d6b9 Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Mon, 13 May 2024 20:31:55 +0100 Subject: [PATCH 5/9] docs: add template directive --- docs/myst.yml | 1 + docs/requirements.txt | 4 - docs/templates.mjs | 189 ++++++++++++++++++++++++++++++++++++++ docs/website-templates.md | 40 +------- 4 files changed, 194 insertions(+), 40 deletions(-) create mode 100644 docs/templates.mjs diff --git a/docs/myst.yml b/docs/myst.yml index f4eb9f5e1..84bbb3c28 100644 --- a/docs/myst.yml +++ b/docs/myst.yml @@ -49,6 +49,7 @@ project: - directives.mjs - unsplash.mjs - latex.mjs + - templates.mjs error_rules: - rule: link-resolves severity: ignore diff --git a/docs/requirements.txt b/docs/requirements.txt index d92f55042..cbb5ce1eb 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -5,7 +5,3 @@ pandas matplotlib numpy vega_datasets - -# For parsing YAML -pyyaml -requests diff --git a/docs/templates.mjs b/docs/templates.mjs new file mode 100644 index 000000000..2e7f4cf59 --- /dev/null +++ b/docs/templates.mjs @@ -0,0 +1,189 @@ +import { u } from 'unist-builder'; +import { mystParse } from 'myst-parser'; + +/** + * Create a documentation section for a directive + * + * @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, vfile) { + return [ + u( + 'myst-template-ref', + { + template: data.arg, + kind: data.options?.kind ?? 'site', + fullTitle: data.options?.['fullTitle'] ?? false, + headingDepth: data.options?.['heading-depth'] ?? 2, + }, + [], + ), + ]; + }, +}; + +let _promise = undefined; + +/** + * @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 slugify(id) { + return id.replaceAll('/', '-'); +} + +function createOption(template, option) { + if (!option) return []; + 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', ')'), + ] + : []), + ]), + ]; + + if (option.description) { + def.push( + u( + 'definitionDescription', + option.description ? mystParse(option.description).children : [u('text', 'No description')], + ), + ); + } + return def; +} + +async function loadFromTemplateMeta(url) { + const response = await fetch(url); + return await response.json(); +} + +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))); +} + +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(); +} + +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_-]+)$/; + +function templateTransform(opts, utils) { + return async (mdast) => { + if (_promise === undefined) { + _promise = loadTemplates(); + } + + let templates; + try { + templates = await _promise; + } catch (err) { + throw new Error('Error loading template information from https://api.mystmd.org'); + } + + utils.selectAll('myst-template-ref', mdast).forEach((node) => { + 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 find template with name ${templateName}`); + } + + // Match the name + const template = templates.find((template) => template.id === resolvedTemplateName); + const slug = slugify(template.id); + + const [_, kind, namespace, name, ...rest] = template.id.match(FULL_TEMPLATE_REGEX); + const title = node.fullTitle ? template.id : name; + const heading = u('heading', { depth: node.headingDepth, identifier: `template-${slug}` }, [ + u('inlineCode', title), + u('text', ' template'), + ]); + + 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')]), + ]); + const doc = template.description ? mystParse(template.description).children : []; + const link = { + type: 'link', + url: template.links.source, + children: [ + { + type: 'text', + value: 'Source', + }, + ], + }; + node.children = [heading, ...doc, list, link]; + }); + }; +} + +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; diff --git a/docs/website-templates.md b/docs/website-templates.md index d43ce7b70..18afa19c6 100644 --- a/docs/website-templates.md +++ b/docs/website-templates.md @@ -114,46 +114,14 @@ Below is a table of options for each theme bundled with MyST. % TODO: Parse the output as markdown when this is resolved: % ref: https://github.com/executablebooks/mystmd/issues/1026 % TODO: Figure out how to attach a label to each of these tables. -```{code-cell} python -:tags: remove-input -import requests -import yaml -from IPython.display import display, Markdown, HTML -import pandas as pd - -# URL of the remote YAML file -def display_options(url): - # Send a GET request to download the YAML file - response = requests.get(url) - - # Check if the request was successful - if response.status_code == 200: - # Parse the YAML content into a Python dictionary - data = yaml.safe_load(response.text) - df = pd.DataFrame(data["options"]) - df = df.rename(columns={"id": "name"}) - display(HTML(df[["name", "description", "type"]].to_html(index=False))) - else: - print(f"Failed to fetch YAML file: {response.status_code}") -``` - -### Book theme -```{code-cell} python -:tags: remove-input -url = "https://github.com/executablebooks/myst-theme/raw/main/themes/book/template.yml" -display_options(url) +```{myst:template} book-theme +:heading-depth: 3 ``` -[Source file](https://github.com/executablebooks/myst-theme/raw/main/themes/book/template.yml) - -### Article theme -```{code-cell} python -:tags: remove-input -url = "https://github.com/executablebooks/myst-theme/raw/main/themes/article/template.yml" -display_options(url) +```{myst:template} article-theme +:heading-depth: 3 ``` -[Source file](https://github.com/executablebooks/myst-theme/raw/main/themes/article/template.yml) \ No newline at end of file From 9f34ed9f890b5cce70f09e53b4c269ec49b643e3 Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Tue, 14 May 2024 07:16:06 +0100 Subject: [PATCH 6/9] refactor: drop unused code --- docs/templates.mjs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/docs/templates.mjs b/docs/templates.mjs index 2e7f4cf59..3f49dfc20 100644 --- a/docs/templates.mjs +++ b/docs/templates.mjs @@ -41,16 +41,6 @@ const mystTemplate = { let _promise = undefined; -/** - * @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 slugify(id) { return id.replaceAll('/', '-'); From 32d47b130fe17ceed42d9619e585f465409c8be7 Mon Sep 17 00:00:00 2001 From: choldgraf Date: Tue, 14 May 2024 15:34:52 -0700 Subject: [PATCH 7/9] Document plugins --- docs/plugins.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/plugins.md b/docs/plugins.md index 438fd9f3a..8cbc0214c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -78,3 +78,37 @@ You can now use the directive, for example: 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](https://www.npmjs.com/package/myst-common), [github](https://github.com/executablebooks/mystmd/tree/main/packages/myst-common)) with the [`DirectiveSpec`](https://github.com/executablebooks/mystmd/blob/9965925030c3fab6f34c20d11eeee7ffdafa73df/packages/myst-common/src/types.ts#L68-L77) and [`RoleSpec`](https://github.com/executablebooks/mystmd/blob/9965925030c3fab6f34c20d11eeee7ffdafa73df/packages/myst-common/src/types.ts#L79-L85) being the main types to implement. + +## 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](myst.yml) with syntax like below: + + +```{literalinclude} myst.yml +:start-at: plugins +:end-before: error_rules +``` + +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. + +::::{dropdown} Plugin: Latex rendering +```{literalinclude} latex.mjs +``` +:::: + +::::{dropdown} Plugin: Display an image +```{literalinclude} unsplash.mjs +``` +:::: + +::::{dropdown} Plugin: Custom directive for documenting roles and directives +```{literalinclude} directives.mjs +``` +:::: + +::::{dropdown} Plugin: Render web template options as a table +```{literalinclude} templates.mjs +``` +:::: From acfeaf26236be89305844436b923153cd8e1a0d3 Mon Sep 17 00:00:00 2001 From: choldgraf Date: Wed, 15 May 2024 06:24:15 -0700 Subject: [PATCH 8/9] Document theme options a bit more --- docs/website-templates.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/website-templates.md b/docs/website-templates.md index 18afa19c6..a824c1678 100644 --- a/docs/website-templates.md +++ b/docs/website-templates.md @@ -94,13 +94,18 @@ site: - title: Second button text url: https://mystmd.org/guide ``` + (site-options)= ## Site Options -There are a number of common options between the site templates. These should be placed in the `site.options` in your `myst.yml`. +Site options allow you to configure a theme's behavior.[^opts] +These should be placed in the `site.options` in your `myst.yml`. For example: +[^opts]: They are generally unique to the theme (and thus in a dediated `site.options` key rather than a top-level option in `site`). + + ```{code-block} yaml :filename: myst.yml site: @@ -111,11 +116,6 @@ site: Below is a table of options for each theme bundled with MyST. -% TODO: Parse the output as markdown when this is resolved: -% ref: https://github.com/executablebooks/mystmd/issues/1026 -% TODO: Figure out how to attach a label to each of these tables. - - ```{myst:template} book-theme :heading-depth: 3 ``` @@ -125,3 +125,14 @@ Below is a table of options for each theme bundled with MyST. :heading-depth: 3 ``` +## Other top-level site configuration + +There are some other top-level site configuration options not documented here. +You can find them in the following two files. + +% TODO: Add proper documentation for these +% ref: https://github.com/executablebooks/mystmd/issues/1211 +https://github.com/executablebooks/mystmd/blob/8e7ac4ae05d833140181ed69aa1e354face7caa0/packages/myst-frontmatter/src/site/types.ts#L57-L83 + + +https://github.com/executablebooks/mystmd/blob/main/packages/myst-config/src/site/types.ts?rgh-link-date=2024-05-15T06%3A31%3A26Z#L26-L33 From 96d8b0867f3e22d2ff138520ccaad592a342343e Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Wed, 15 May 2024 15:18:47 +0100 Subject: [PATCH 9/9] docs: add helpful comments on the template plugin --- docs/templates.mjs | 133 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 105 insertions(+), 28 deletions(-) diff --git a/docs/templates.mjs b/docs/templates.mjs index 3f49dfc20..5a4f4f0ca 100644 --- a/docs/templates.mjs +++ b/docs/templates.mjs @@ -1,8 +1,24 @@ +/** + * 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'; /** - * Create a documentation section for a directive + * @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} */ @@ -23,31 +39,45 @@ const mystTemplate = { type: String, required: true, }, - run(data, vfile) { - return [ - u( - 'myst-template-ref', - { - template: data.arg, - kind: data.options?.kind ?? 'site', - fullTitle: data.options?.['fullTitle'] ?? false, - headingDepth: data.options?.['heading-depth'] ?? 2, - }, - [], - ), - ]; + 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 []; + 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)]), @@ -61,28 +91,42 @@ function createOption(template, option) { ]), ]; - if (option.description) { - def.push( - u( - 'definitionDescription', - option.description ? mystParse(option.description).children : [u('text', 'No description')], - ), - ); - } + // 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/`); @@ -91,16 +135,31 @@ async function loadTemplates() { 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; @@ -108,7 +167,11 @@ function templateTransform(opts, utils) { 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) { @@ -118,20 +181,30 @@ function templateTransform(opts, utils) { } else if (templateName.match(FULL_TEMPLATE_REGEX)) { resolvedTemplateName = templateName; } else { - throw new Error(`Could not find template with name ${templateName}`); + throw new Error(`Could not determine full name for template: ${templateName}`); } - // Match the name + // Let's now find the template information for the requested template name const template = templates.find((template) => template.id === resolvedTemplateName); - const slug = slugify(template.id); + 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(); @@ -143,7 +216,8 @@ function templateTransform(opts, utils) { ? u('definitionDescription', [u('definitionList', options)]) : u('definitionDescription', [u('text', 'No options')]), ]); - const doc = template.description ? mystParse(template.description).children : []; + + // Add a footer that links to the template source const link = { type: 'link', url: template.links.source, @@ -154,11 +228,14 @@ function templateTransform(opts, utils) { }, ], }; + + // 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',