diff --git a/.gitignore b/.gitignore index 86cb67592d..ab7d5f7fc1 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ yalc.lock # vim swap files *.swp + +# local logs +logs \ No newline at end of file diff --git a/docs/directives.md b/docs/directives.md index a3fc16c7db..1438124a52 100644 --- a/docs/directives.md +++ b/docs/directives.md @@ -6,6 +6,9 @@ label: directives_list To learn more about the syntax and usage of the directives, please refer to the [](#syntax:directives) section of the documentation. +:::{myst:directive} abbreviations +::: + :::{myst:directive} admonition ::: diff --git a/docs/glossaries-and-terms.md b/docs/glossaries-and-terms.md index 0afd315dc5..ce5d6413d6 100644 --- a/docs/glossaries-and-terms.md +++ b/docs/glossaries-and-terms.md @@ -228,3 +228,23 @@ abbreviations: We use ML to parse HTML. ``` + +### Generate a list of abbreviations + +The `{abbreviations}` directive displays a definition list of known abbreviates. The entries are sorted alphabetically by abbreviation key. + +In this context `known abbreviations` means that abbreviations are pulled from the projects `yml` and the frontmatter from available pages at build time. + +Example snippet: + +::::{dropdown} Show abbreviations used in these docs +:::{abbreviations} +::: +:::: + +:::{note} Order of abbreviations +Page-level abbreviates to will overwrite project-level abbreviations. +::: + +Entries with `null` values are not included in the generated list. + diff --git a/packages/myst-cli/src/process/mdast.ts b/packages/myst-cli/src/process/mdast.ts index 24602edd2f..db180515e8 100644 --- a/packages/myst-cli/src/process/mdast.ts +++ b/packages/myst-cli/src/process/mdast.ts @@ -30,6 +30,7 @@ import { checkLinkTextTransform, indexIdentifierPlugin, buildTocTransform, + abbreviationsListTransform, } from 'myst-transforms'; import { unified } from 'unified'; import { select, selectAll } from 'unist-util-select'; @@ -98,6 +99,16 @@ const htmlHandlers = { }, }; +function collectAbbreviations(session: ISession, pageReferenceStates: ReferenceState[]) { + const cache = castSession(session); + const abbreviations: Record = {}; + pageReferenceStates.forEach((state) => { + const pageAbbreviations = cache.$getMdast(state.filePath)?.post?.frontmatter?.abbreviations; + if (pageAbbreviations) Object.assign(abbreviations, pageAbbreviations); + }); + return abbreviations; +} + export type TransformFn = ( session: ISession, opts: Parameters[1], @@ -344,6 +355,9 @@ export async function postProcessMdast( projectSlug, mdastPost.slug, ); + abbreviationsListTransform(mdast, { + abbreviations: collectAbbreviations(session, pageReferenceStates), + }); } // NOTE: This is doing things in place, we should potentially make this a different state? const transformers = [ diff --git a/packages/myst-directives/src/abbreviations.spec.ts b/packages/myst-directives/src/abbreviations.spec.ts new file mode 100644 index 0000000000..c0c8ef9902 --- /dev/null +++ b/packages/myst-directives/src/abbreviations.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'vitest'; +import type { DirectiveData } from 'myst-common'; +import { abbreviationsDirective } from './abbreviations.js'; +import { defaultDirectives } from './index.js'; + +function run(data: Partial = {}) { + return abbreviationsDirective.run!({ + name: 'abbreviations', + node: {} as any, + options: {}, + ...data, + }); +} + +function log(arg: any) { + console.dir(arg, { depth: null }); + return arg +} +describe('abbreviations directive', () => { + test('creates a placeholder node', () => { + expect(run()).toEqual([{ type: 'abbreviations', children: [] }]); + }); + + test('wraps argument content in a heading', () => { + expect(run({ arg: [{ type: 'text', value: 'Abbreviations' }] })).toEqual([ + { + type: 'abbreviations', + children: [ + { + type: 'heading', + depth: 2, + enumerated: false, + children: [{ type: 'text', value: 'Abbreviations' }], + }, + ], + }, + ]); + }); + + test('preserves heading arguments', () => { + const heading = { + type: 'heading', + depth: 3, + children: [{ type: 'text', value: 'Terms' }], + }; + expect(run({ arg: [heading] })).toEqual([{ type: 'abbreviations', children: [heading] }]); + }); + + test('preserves common directive options', () => { + expect( + run({ + options: { + class: 'compact', + label: 'abbreviations-list', + }, + }), + ).toEqual([ + { + type: 'abbreviations', + children: [], + class: 'compact', + label: 'abbreviations-list', + identifier: 'abbreviations-list', + }, + ]); + }); + + test('is registered by default', () => { + expect(defaultDirectives).toContain(abbreviationsDirective); + }); +}); diff --git a/packages/myst-directives/src/abbreviations.ts b/packages/myst-directives/src/abbreviations.ts new file mode 100644 index 0000000000..47f02e0786 --- /dev/null +++ b/packages/myst-directives/src/abbreviations.ts @@ -0,0 +1,33 @@ +import type { DirectiveData, DirectiveSpec, GenericNode } from 'myst-common'; +import { addCommonDirectiveOptions, commonDirectiveOptions } from './utils.js'; + +export const abbreviationsDirective: DirectiveSpec = { + name: 'abbreviations', + doc: 'Inserts an alphabetized list of known abbreviations, collected the pages and project.', + arg: { + type: 'myst', + doc: 'Heading to be included with the abbreviations list', + }, + options: { + ...commonDirectiveOptions('abbreviations'), + }, + run(data: DirectiveData): GenericNode[] { + const children: GenericNode[] = []; + if (data.arg) { + const parsedArg = data.arg as GenericNode[]; + if (parsedArg[0]?.type === 'heading') { + children.push(...parsedArg); + } else { + children.push({ + type: 'heading', + depth: 2, + enumerated: false, + children: parsedArg, + }); + } + } + const abbreviations = { type: 'abbreviations', children }; + addCommonDirectiveOptions(data, abbreviations); + return [abbreviations]; + }, +}; diff --git a/packages/myst-directives/src/index.ts b/packages/myst-directives/src/index.ts index 28ae60aa5e..5de36011b4 100644 --- a/packages/myst-directives/src/index.ts +++ b/packages/myst-directives/src/index.ts @@ -10,6 +10,7 @@ import { includeDirective } from './include.js'; import { indexDirective, genIndexDirective } from './indices.js'; import { csvTableDirective, tableDirective, listTableDirective } from './table.js'; import { asideDirective } from './aside.js'; +import { abbreviationsDirective } from './abbreviations.js'; import { glossaryDirective } from './glossary.js'; import { mathDirective } from './math.js'; import { mdastDirective } from './mdast.js'; @@ -39,6 +40,7 @@ export const defaultDirectives = [ tableDirective, listTableDirective, asideDirective, + abbreviationsDirective, glossaryDirective, mathDirective, mdastDirective, @@ -65,6 +67,7 @@ export { includeDirective } from './include.js'; export { indexDirective, genIndexDirective } from './indices.js'; export { csvTableDirective, listTableDirective, tableDirective } from './table.js'; export { asideDirective } from './aside.js'; +export { abbreviationsDirective } from './abbreviations.js'; export { mathDirective } from './math.js'; export { mdastDirective } from './mdast.js'; export { mermaidDirective } from './mermaid.js'; diff --git a/packages/myst-transforms/src/abbreviations.ts b/packages/myst-transforms/src/abbreviations.ts index 6aec1dab0e..55133aca73 100644 --- a/packages/myst-transforms/src/abbreviations.ts +++ b/packages/myst-transforms/src/abbreviations.ts @@ -1,5 +1,5 @@ import type { Plugin } from 'unified'; -import type { GenericParent } from 'myst-common'; +import type { GenericNode, GenericParent } from 'myst-common'; import { toText } from 'myst-common'; import { selectAll } from 'unist-util-select'; import type { Abbreviation, Text } from 'myst-spec'; @@ -80,3 +80,44 @@ export const abbreviationPlugin: Plugin<[Options], GenericParent, GenericParent> (opts) => (tree) => { abbreviationTransform(tree, opts); }; + +export function abbreviationListChildren(abbreviations?: Record) { + // turns an abbreviations object into a clean, sorted list of abbreviation entries. + const entries = Object.entries(abbreviations ?? {}) + .filter((entry): entry is [string, string] => !!entry[1]) // Keeps only entries where the value exists + .sort(([a], [b]) => a.localeCompare(b)); // Sort alphabetically by abbreviation key. + + if (!entries.length) return []; + + return [ + { + type: 'definitionList', + children: entries + .map(([abbr, title]) => [ + { + type: 'definitionTerm', + children: [{ type: 'text', value: abbr }], + }, + { + type: 'definitionDescription', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: title }], + }, + ], + }, + ]) + .flat(), + }, + ]; +} + +export function abbreviationsListTransform(mdast: GenericParent, opts?: Options) { + const nodes = selectAll('abbreviations', mdast) as GenericNode[]; + nodes.forEach((node) => { + node.type = 'block'; + node.data = { ...(node.data ?? {}), part: 'abbreviations' }; + node.children = [...(node.children ?? []), ...abbreviationListChildren(opts?.abbreviations)]; + }); +} diff --git a/packages/myst-transforms/src/index.ts b/packages/myst-transforms/src/index.ts index ce91632c47..2481492a19 100644 --- a/packages/myst-transforms/src/index.ts +++ b/packages/myst-transforms/src/index.ts @@ -58,7 +58,11 @@ export { } from './targets.js'; export { joinGatesPlugin, joinGatesTransform } from './joinGates.js'; export { glossaryPlugin, glossaryTransform } from './glossary.js'; -export { abbreviationPlugin, abbreviationTransform } from './abbreviations.js'; +export { + abbreviationPlugin, + abbreviationTransform, + abbreviationsListTransform, +} from './abbreviations.js'; export { includeDirectivePlugin, includeDirectiveTransform } from './include.js'; export { containerChildrenPlugin, containerChildrenTransform } from './containers.js'; export { headingDepthPlugin, headingDepthTransform } from './headings.js'; diff --git a/packages/myst-transforms/tests/abbreviations-list.spec.ts b/packages/myst-transforms/tests/abbreviations-list.spec.ts new file mode 100644 index 0000000000..8580ed19f1 --- /dev/null +++ b/packages/myst-transforms/tests/abbreviations-list.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import yaml from 'js-yaml'; +import { abbreviationsListTransform } from '../src'; +import { abbreviationListChildren } from '../src/abbreviations'; + +type TestFile = { + cases: TestCase[]; +}; +type TestCase = { + title: string; + before: any; + after: any; + opts?: { + abbreviations?: Record; + }; +}; + +const fixtures = path.join('tests', 'abbreviations-list.yml'); +const testYaml = fs.readFileSync(fixtures).toString(); +const cases = (yaml.load(testYaml) as TestFile).cases; + +describe('abbreviationListChildren', () => { + test('returns no children without abbreviations', () => { + expect(abbreviationListChildren()).toEqual([]); + expect(abbreviationListChildren({})).toEqual([]); + }); + + test('creates a sorted definition list and omits null values', () => { + expect( + abbreviationListChildren({ + MyST: 'Markedly Structured Text', + SHRILL: null, + API: 'Application Programming Interface', + }), + ).toEqual([ + { + type: 'definitionList', + children: [ + { + type: 'definitionTerm', + children: [{ type: 'text', value: 'API' }], + }, + { + type: 'definitionDescription', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'Application Programming Interface' }], + }, + ], + }, + { + type: 'definitionTerm', + children: [{ type: 'text', value: 'MyST' }], + }, + { + type: 'definitionDescription', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'Markedly Structured Text' }], + }, + ], + }, + ], + }, + ]); + }); +}); + +describe('abbreviations list', () => { + test.each(cases.map((c): [string, TestCase] => [c.title, c]))( + '%s', + (_, { before, after, opts }) => { + abbreviationsListTransform(before, opts); + expect(yaml.dump(before)).toEqual(yaml.dump(after)); + }, + ); +}); diff --git a/packages/myst-transforms/tests/abbreviations-list.yml b/packages/myst-transforms/tests/abbreviations-list.yml new file mode 100644 index 0000000000..f2f4cea07a --- /dev/null +++ b/packages/myst-transforms/tests/abbreviations-list.yml @@ -0,0 +1,158 @@ +cases: + - title: simple generated list + opts: + abbreviations: + MyST: Markedly Structured Text + API: Application Programming Interface + before: + type: root + children: + - type: abbreviations + children: [] + after: + type: root + children: + - type: block + children: + - type: definitionList + children: + - type: definitionTerm + children: + - type: text + value: API + - type: definitionDescription + children: + - type: paragraph + children: + - type: text + value: Application Programming Interface + - type: definitionTerm + children: + - type: text + value: MyST + - type: definitionDescription + children: + - type: paragraph + children: + - type: text + value: Markedly Structured Text + data: + part: abbreviations + - title: null abbreviations omitted + opts: + abbreviations: + HR: Heart Rate + SHRILL: null + before: + type: root + children: + - type: abbreviations + children: [] + after: + type: root + children: + - type: block + children: + - type: definitionList + children: + - type: definitionTerm + children: + - type: text + value: HR + - type: definitionDescription + children: + - type: paragraph + children: + - type: text + value: Heart Rate + data: + part: abbreviations + - title: heading preserved + opts: + abbreviations: + API: Application Programming Interface + before: + type: root + children: + - type: abbreviations + children: + - type: heading + depth: 2 + enumerated: false + children: + - type: text + value: Abbreviations + after: + type: root + children: + - type: block + children: + - type: heading + depth: 2 + enumerated: false + children: + - type: text + value: Abbreviations + - type: definitionList + children: + - type: definitionTerm + children: + - type: text + value: API + - type: definitionDescription + children: + - type: paragraph + children: + - type: text + value: Application Programming Interface + data: + part: abbreviations + - title: no abbreviations + opts: + abbreviations: {} + before: + type: root + children: + - type: abbreviations + children: [] + after: + type: root + children: + - type: block + children: [] + data: + part: abbreviations + - title: common options stay on wrapper + opts: + abbreviations: + API: Application Programming Interface + before: + type: root + children: + - type: abbreviations + label: abbreviations-list + identifier: abbreviations-list + class: compact + children: [] + after: + type: root + children: + - type: block + label: abbreviations-list + identifier: abbreviations-list + class: compact + children: + - type: definitionList + children: + - type: definitionTerm + children: + - type: text + value: API + - type: definitionDescription + children: + - type: paragraph + children: + - type: text + value: Application Programming Interface + data: + part: abbreviations diff --git a/packages/mystmd/tests/abbreviations-directive/README.md b/packages/mystmd/tests/abbreviations-directive/README.md new file mode 100644 index 0000000000..84b4e8b053 --- /dev/null +++ b/packages/mystmd/tests/abbreviations-directive/README.md @@ -0,0 +1,77 @@ +# Abbreviations Directive Sample + +This fixture exercises the `{abbreviations}` directive during local development. + +Current content files: + +- `index.md`: directive on the landing page, plus inline abbreviation text. +- `page-1.md`: page-level `AST` abbreviation and directive options. +- `page-2.md`: page-level `GPU` abbreviation without a directive. +- `page-3.md`: multiple page-level abbreviations without a directive. + +`README.md` is intentionally not listed in `project.toc`, so it is not built as a page. + +The current `myst.yml` was generated with: + +```sh +bun ../../dist/myst.cjs init --project --site --write-toc +``` + +Review `myst.yml` after regenerating it. The generated file may need manual edits for: + +- `project.abbreviations`, if you want project-level definitions such as `API`, `CLI`, or `MyST`. +- `project.toc`, if you want to keep `README.md` excluded or reorder pages. +- `site.template`, if the generated template differs from the fixture expectation. + +Expected behavior: + +- Pages with `{abbreviations}` render a generated definition list. +- The generated list includes non-null abbreviations collected from the project pages. +- Null-valued abbreviations are omitted. +- `page-1.md` preserves the directive `label` and `class` metadata on the generated wrapper block. + +## Local Testing + +From the repo root, rebuild local packages: + +```sh +cd mystmd +bun run build -- --force +``` + +Run the local built CLI against the sample fixture: + +```sh +cd packages/mystmd/tests/abbreviations-directive +bun ../../dist/myst.cjs build --ci +``` + +Inspect generated page JSON: + +```sh +ls _build/site/content +rg '"part": "abbreviations"|definitionList|AST|GPU|Algo|SA' _build/site/content +``` + +Validate the placeholder-to-definition-list transform directly: + +```sh +cd mystmd/packages/myst-transforms +bun test tests/abbreviations-list.spec.ts +``` + +Run the existing inline abbreviation regression: + +```sh +cd mystmd/packages/myst-transforms +bun test tests/abbreviations.spec.ts +``` + +Run the focused end-to-end fixture test: + +```sh +cd mystmd +bun test packages/mystmd/tests/endToEnd.spec.ts -t "Abbreviations directive site build" +``` + +The focused end-to-end case should stay aligned with `project.toc`; it checks that site JSON is generated for `index.md`, `page-1.md`, `page-2.md`, and `page-3.md`. diff --git a/packages/mystmd/tests/abbreviations-directive/index.md b/packages/mystmd/tests/abbreviations-directive/index.md new file mode 100644 index 0000000000..9efea51efc --- /dev/null +++ b/packages/mystmd/tests/abbreviations-directive/index.md @@ -0,0 +1,10 @@ +# Abbreviations Directive (index.md) + +This page uses API, CLI, and MyST so the existing inline abbreviation transform +can still be checked alongside the new directive. + +```{abbreviations} Abbreviations +``` + +SHRILL is configured with a null expansion and should not appear in the +generated abbreviation list. diff --git a/packages/mystmd/tests/abbreviations-directive/myst.yml b/packages/mystmd/tests/abbreviations-directive/myst.yml new file mode 100644 index 0000000000..4b37e11b43 --- /dev/null +++ b/packages/mystmd/tests/abbreviations-directive/myst.yml @@ -0,0 +1,26 @@ +# See docs at: https://mystmd.org/guide/frontmatter +version: 1 +project: + id: 8e548486-6c71-442a-8e4e-33347b477f09 + title: Abbreviations Directive Sample + abbreviations: + API: Application Programming Interface + CLI: Command Line Interface + MyST: Markedly Structured Text + # description: + # keywords: [] + # authors: [] + github: https://github.com/jupyter-book/mystmd + # To autogenerate a Table of Contents, run "myst init --write-toc" + toc: + # Auto-generated by `myst init --write-toc` + - file: index.md + - file: page-1.md + - file: page-2.md + - file: page-3.md + +site: + template: book-theme + # options: + # favicon: favicon.ico + # logo: site_logo.png diff --git a/packages/mystmd/tests/abbreviations-directive/page-1.md b/packages/mystmd/tests/abbreviations-directive/page-1.md new file mode 100644 index 0000000000..8a35d2062f --- /dev/null +++ b/packages/mystmd/tests/abbreviations-directive/page-1.md @@ -0,0 +1,16 @@ +--- +abbreviations: + AST: Abstract Syntax Tree +--- + +# Page-Level Abbreviations + +This page uses AST plus project abbreviations like API and MyST. + +```{abbreviations} New Abbrevs +:label: abbreviations-list +:class: compact +``` + +I expect to see something related to text like API or CLI from the project. +Here's some text with GPU when it was defined in [](:page-2) \ No newline at end of file diff --git a/packages/mystmd/tests/abbreviations-directive/page-2.md b/packages/mystmd/tests/abbreviations-directive/page-2.md new file mode 100644 index 0000000000..2394a91346 --- /dev/null +++ b/packages/mystmd/tests/abbreviations-directive/page-2.md @@ -0,0 +1,10 @@ +--- +abbreviations: + GPU: Graphics Processing Unit +--- + +# Page 2 + +This is the second page on the micro-site + +Here's some text with GPU. \ No newline at end of file diff --git a/packages/mystmd/tests/abbreviations-directive/page-3.md b/packages/mystmd/tests/abbreviations-directive/page-3.md new file mode 100644 index 0000000000..6ca9d2b009 --- /dev/null +++ b/packages/mystmd/tests/abbreviations-directive/page-3.md @@ -0,0 +1,10 @@ +--- +abbreviations: + Fl: Fruity Loops + Algo: Algorithm + SA: South Africa +--- + +# Page 3 + +This is random content for page 3 and here we talk about Fl, Algo and SA. diff --git a/packages/mystmd/tests/exports.yml b/packages/mystmd/tests/exports.yml index 7180065189..f706701810 100644 --- a/packages/mystmd/tests/exports.yml +++ b/packages/mystmd/tests/exports.yml @@ -199,6 +199,14 @@ cases: content: outputs/basic-site-config.json - path: basic-site/_build/site/myst.xref.json content: outputs/basic-site-myst.xref.json + - title: Abbreviations directive site build + cwd: abbreviations-directive + command: myst build + outputs: + - path: abbreviations-directive/_build/site/content/index.json + - path: abbreviations-directive/_build/site/content/page-1.json + - path: abbreviations-directive/_build/site/content/page-2.json + - path: abbreviations-directive/_build/site/content/page-3.json - title: Alternate config file cwd: alternate-config command: myst --config foo.yml build