Skip to content

Commit a4c9684

Browse files
ovflowdavivkeller
andauthored
feat: add a markdown option for remark, rehype, and recma plugins (#1157)
* refactor(core): move the type annotations plugin to `src/plugins` It is a remark plugin, with its mdast and hast handlers, rather than a utility. `#plugins/*` imports it. Assisted-by: Claude Opus 5.5 <noreply@anthropic.com> * refactor(react): move the `jsx-ast` plugins out of `utils` They are the remark and rehype plugins of `jsx-ast`, rather than utilities. Assisted-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(core): add Markdown pipelines and the `markdown` option Generators declare the unified plugins they process Markdown with as `markdown`, taking the configured plugins in place of `'...'`. The `markdown` option adds plugins globally and per generator, resolved from the file declaring them so that worker threads can import them. Each thread loads the pipeline of the generators it runs, and `getProcessor(name)` gives their processor. Assisted-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(core): add the Shiki rehype plugin Highlights code with Shiki as a rehype plugin, configurable with more languages, aliases, themes, and transformers. It loads through `load`, as creating its highlighter is asynchronous, and creates the Shiki instance on first use, so threads that never highlight code skip it. Assisted-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(core): use Markdown pipelines in `ast`, `metadata`, and `json` The configured remark plugins run on each whole document once `ast` parses it, and `metadata` and `json` parse and serialise Markdown with their syntax. Assisted-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(react): use the Markdown pipeline in `jsx-ast` The pages run the configured remark, rehype, and recma plugins, the rehype ones before code is highlighted. Types and signatures are highlighted by the Shiki plugin of the pipeline. Assisted-by: Claude Opus 5.5 <noreply@anthropic.com> * refactor(legacy): use Markdown pipelines in the legacy generators The legacy generators declare their pipelines, taking no configured plugins, so their output stays the same. The processors of `@doc-kit/core/utils/remark.mjs` go, and the Shiki step of `utils/highlighter.mjs` moves to `legacy-html`, its last user. Assisted-by: Claude Opus 5.5 <noreply@anthropic.com> * docs: document Markdown plugins Documents the `markdown` option, the Shiki plugin options, and how generators declare their pipelines. The changeset also lists the `@doc-kit/core` modules that are removed or moved. Assisted-by: Claude Opus 5.5 <noreply@anthropic.com> * chore: dry * refactor(core): split up `Promise.all` calls and ternaries Builds each list of promises before awaiting it, imports the Shiki themes in a step of their own, and replaces the ternaries resolving and configuring Markdown plugins with `if` statements, so each step reads on its own. Assisted-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: avivkeller <me@aviv.sh>
1 parent c50e75f commit a4c9684

77 files changed

Lines changed: 2068 additions & 367 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/markdown-plugins.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@doc-kit/core': minor
3+
'@doc-kit/generator-react': minor
4+
'@node-core/doc-kit-legacy': patch
5+
---
6+
7+
Add a `markdown` option to add remark, rehype, and recma plugins to the generators processing Markdown, or configure the ones they use, such as Shiki. The `@doc-kit/core` modules the generators' pipelines replace are removed (`utils/remark.mjs`, `utils/remark-shiki.mjs`, and `utils/highlighter.mjs`), and `utils/type-annotations` moves to `plugins/type-annotations`

‎docs/configuration.md‎

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,11 @@ Everything under the `global` key applies to every generator:
123123
a URL or path to parse, or a pre-parsed array. **Default:** `[]`
124124
(single-version output).
125125
- `index` {string|URL|Array} Index URL.
126+
- `markdown` {Object} [Markdown plugins](#markdown-plugins) for every
127+
generator processing Markdown.
126128

127129
A generator's own section (e.g., `html`, `legacy-json`) can override any of
128-
these for that generator alone.
130+
these for that generator alone, except `markdown`, which it adds to.
129131

130132
## Execution options
131133

@@ -155,6 +157,96 @@ export default {
155157
};
156158
```
157159

160+
## Markdown plugins
161+
162+
`doc-kit` processes Markdown with [unified](https://unifiedjs.com/).
163+
`global.markdown` adds plugins to every generator processing Markdown, and a
164+
generator's own `markdown` adds plugins to it alone, after the global ones.
165+
166+
```mjs displayName="doc-kit.config.mjs"
167+
export default {
168+
global: {
169+
markdown: {
170+
remarkPlugins: ['remark-math'],
171+
},
172+
},
173+
174+
// The generator rendering the site's pages
175+
'jsx-ast': {
176+
markdown: {
177+
rehypePlugins: [
178+
'rehype-katex',
179+
['./plugins/rehype-diagrams.mjs', { theme: 'neutral' }],
180+
],
181+
},
182+
},
183+
};
184+
```
185+
186+
Each plugin is a module specifier (a package name, or a path relative to the
187+
configuration file), alone or in a `[specifier, options]` pair. The module
188+
default-exports the plugin, a list of plugins, or a unified preset. Markdown is
189+
processed in worker threads, which import the plugins themselves, so options
190+
must be serializable: configure a plugin taking functions in a module of your
191+
own.
192+
193+
- `remarkPlugins` {Array} The global ones run on each document once the `ast`
194+
generator parses it, so every output sees their changes. A generator
195+
rendering Markdown, such as `jsx-ast`, runs its own on what it renders.
196+
- `rehypePlugins` {Array} Run on the HTML of the generators rendering
197+
Markdown, such as `jsx-ast`, before code is highlighted.
198+
- `recmaPlugins` {Array} Run on the JavaScript `jsx-ast` compiles the pages to.
199+
200+
A generator only takes the plugins its pipeline has a place for: `jsx-ast`
201+
takes all three kinds, `ast`, `metadata`, and `json` take remark plugins, and
202+
generators such as `html` take none. Plugins listed for a generator that
203+
doesn't take them are ignored with a warning.
204+
205+
A generator runs each plugin once: listing one it already has, its own or a
206+
global one, configures it instead, merging the options (arrays add up, and
207+
objects merge).
208+
209+
The plugins configured for `jsx-ast` don't apply to the fragments it renders
210+
on their own: types, parameter descriptions, and change history notes.
211+
212+
### Code highlighting
213+
214+
`jsx-ast` highlights code with the `@doc-kit/core/plugins/shiki/rehype.mjs`
215+
plugin, which runs [Shiki](https://shiki.style/) with every language it
216+
bundles. To configure it, list it with options in
217+
`jsx-ast.markdown.rehypePlugins` (with pnpm, add `@doc-kit/core` to your
218+
dependencies, so your configuration file can resolve it):
219+
220+
- `langs` {Array} More languages: grammars, or modules default-exporting them.
221+
- `langAlias` {Object} Aliases of languages, such as `{ conf: 'ini' }`.
222+
- `themes` {Object} The `light` and `dark` themes: names of themes Shiki
223+
bundles, modules default-exporting themes, or themes.
224+
- `transformers` {Array} Modules default-exporting
225+
[Shiki transformers](https://shiki.style/guide/transformers), or lists of
226+
them.
227+
228+
These modules are paths relative to the working directory, or URLs, such as
229+
the ones `import.meta.resolve()` gives:
230+
231+
```mjs displayName="doc-kit.config.mjs"
232+
export default {
233+
'jsx-ast': {
234+
markdown: {
235+
rehypePlugins: [
236+
[
237+
'@doc-kit/core/plugins/shiki/rehype.mjs',
238+
{
239+
langAlias: { conf: 'ini' },
240+
themes: { light: 'github-light', dark: 'github-dark' },
241+
transformers: [import.meta.resolve('./shiki-transformers.mjs')],
242+
},
243+
],
244+
],
245+
},
246+
},
247+
};
248+
```
249+
158250
## Configuration Merging
159251

160252
Configurations are merged in the following order (higher sources take

‎docs/creating-generators.md‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,64 @@ Use a dependent when a generator transforms an intermediate representation
468468
format of its own. See the [`section-pages`](./generators/section-pages.md) generator for
469469
a worked example.
470470

471+
## Markdown pipelines
472+
473+
A generator processing Markdown declares its [unified](https://unifiedjs.com/)
474+
pipeline as `markdown`, listing plugins as the
475+
[`markdown` option](./configuration.md#markdown-plugins) does, with paths
476+
relative to its module. The rehype list starts with `remark-rehype`, and the
477+
recma list with `rehype-recma`. Each list takes the configured plugins in place
478+
of its `'...'`, and a list without one takes none. As every thread running the
479+
generator imports it, options may be anything, functions included.
480+
481+
```mjs displayName="index.mjs"
482+
export default {
483+
name: 'my-generator',
484+
485+
dependsOn: '@doc-kit/core/metadata',
486+
487+
markdown: {
488+
remarkPlugins: ['remark-parse', 'remark-gfm', '...'],
489+
rehypePlugins: [
490+
['remark-rehype', { allowDangerousHtml: true }],
491+
'...',
492+
['rehype-stringify', { allowDangerousHtml: true }],
493+
],
494+
},
495+
496+
generate,
497+
};
498+
```
499+
500+
`getProcessor(name)` gives the generator's processor, on each thread it runs
501+
on:
502+
503+
```mjs displayName="generate.mjs"
504+
import { getProcessor } from '@doc-kit/core/utils/markdown/processor.mjs';
505+
506+
export async function generate(input) {
507+
const processor = getProcessor('my-generator');
508+
509+
return Promise.all(
510+
input.map(async ({ content }) =>
511+
processor.stringify(await processor.run(content))
512+
)
513+
);
514+
}
515+
```
516+
517+
`getProcessor(name, { mdx: true })` returns an MDX processor (plugins can
518+
check `this.data('mdx')`), and `getProcessor(name, { configured: false })` one
519+
without the configured plugins. A generator rendering Markdown (with rehype or
520+
recma plugins) only takes its own remark plugins, as the global ones already
521+
ran when `ast` parsed the Markdown.
522+
523+
A plugin needing asynchronous setup can export an async `load(options)`
524+
returning the plugin, instead of a default export. The Shiki plugin does, and
525+
`getHighlighter(name)` (from `@doc-kit/core/plugins/shiki/highlighter.mjs`)
526+
returns the highlighter of a generator's Shiki plugin, to highlight code as its
527+
pipeline does.
528+
471529
## File Output
472530

473531
### Writing Output Files

‎packages/core/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"./src/parsers/*",
4040
"./src/parsers/*.d.ts"
4141
],
42+
"#plugins/*": "./src/plugins/*",
4243
"#utils/*": "./src/utils/*"
4344
},
4445
"files": [
@@ -60,7 +61,6 @@
6061
"dedent": "^1.7.2",
6162
"github-slugger": "^2.0.0",
6263
"glob-parent": "^6.0.2",
63-
"hastscript": "^9.0.1",
6464
"mdast-util-slice-markdown": "^2.0.1",
6565
"mdast-util-to-string": "^4.0.0",
6666
"piscina": "^5.3.2",

‎packages/core/src/__tests__/generators.test.mjs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,17 @@ mock.module('../threading/parallel.mjs', {
150150
},
151151
});
152152

153+
// The Markdown pipelines loaded, with the generators that had run by then
154+
const loadedPipelines = [];
155+
156+
mock.module('../utils/markdown/plugins.mjs', {
157+
exports: {
158+
loadMarkdownPlugins: async ({ name }, markdown) => {
159+
loadedPipelines.push({ name, markdown, ran: Object.keys(runs) });
160+
},
161+
},
162+
});
163+
153164
const createGenerator = (await import('../generators.mjs')).default;
154165

155166
describe('createGenerator orchestration', () => {
@@ -215,4 +226,23 @@ describe('createGenerator orchestration', () => {
215226
{ all: { d: [{ meta: 1 }, { spliced: true }] } },
216227
]);
217228
});
229+
230+
it('loads the Markdown pipeline of each generator as it starts', async () => {
231+
const { runGenerators } = createGenerator();
232+
const markdown = { remarkPlugins: ['file:///plugin.mjs'] };
233+
234+
loadedPipelines.length = 0;
235+
236+
await runGenerators({
237+
target: ['gen-a'],
238+
threads: 1,
239+
'gen-a': { markdown },
240+
});
241+
242+
assert.deepStrictEqual(loadedPipelines, [
243+
{ name: 'ast', markdown: undefined, ran: [] },
244+
{ name: 'metadata', markdown: undefined, ran: ['ast'] },
245+
{ name: 'gen-a', markdown, ran: ['ast', 'metadata'] },
246+
]);
247+
});
218248
});

‎packages/core/src/generators.mjs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { resolvePipeline } from './generators/pipeline.mjs';
99
import logger from './logger/index.mjs';
1010
import createWorkerPool from './threading/index.mjs';
1111
import createParallelWorker from './threading/parallel.mjs';
12+
import { loadMarkdownPlugins } from './utils/markdown/plugins.mjs';
1213
import { isAsyncIterable } from './utils/misc.mjs';
1314

1415
const generatorsLogger = logger.child('generators');
@@ -78,6 +79,9 @@ const createGenerator = () => {
7879

7980
generatorsLogger.debug(`Starting "${name}"`);
8081

82+
// Load its Markdown pipeline, for what it processes on this thread
83+
await loadMarkdownPlugins(generator, configuration[name]?.markdown);
84+
8185
// Create parallel worker for streaming generators
8286
const worker = hasParallelProcessor
8387
? createParallelWorker(specifier, generator, pool, configuration)

‎packages/core/src/generators/ast/__tests__/generate.test.mjs‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,19 @@ import { mkdtemp, writeFile, rm } from 'node:fs/promises';
33
import { tmpdir } from 'node:os';
44
import { join, sep } from 'node:path';
55
import { after, before, describe, it } from 'node:test';
6+
import { pathToFileURL } from 'node:url';
7+
8+
import { loadGenerator } from '#generators/loader.mjs';
9+
import { loadMarkdownPlugins } from '#utils/markdown/plugins.mjs';
610

711
import { STABILITY_INDEX_URL } from '../constants.mjs';
812
import { processChunk } from '../generate.mjs';
913

14+
const ast = await loadGenerator(import.meta.resolve('../index.mjs'));
15+
16+
// Files are parsed with the pipeline of `ast`
17+
await loadMarkdownPlugins(ast);
18+
1019
let dir;
1120

1221
const toPosixPath = value => value.split(sep).join('/');
@@ -157,6 +166,42 @@ describe('processChunk', () => {
157166
});
158167
});
159168

169+
describe('remark plugins', () => {
170+
before(async () => {
171+
// Records the file it runs on, and the headings of its document
172+
await writeFile(
173+
join(dir, 'recorder.mjs'),
174+
`export default () => (tree, file) => {
175+
const headings = tree.children.filter(node => node.type === 'heading');
176+
177+
tree.data = { recorded: file.path + ':' + headings.length };
178+
};`
179+
);
180+
181+
await loadMarkdownPlugins(ast, {
182+
remarkPlugins: [pathToFileURL(join(dir, 'recorder.mjs')).href],
183+
});
184+
});
185+
186+
after(() => loadMarkdownPlugins(ast));
187+
188+
it('run on each whole document, knowing its file', async () => {
189+
const tuple = await file('plugged.md', '# A\n\n## B\n\n## C\n');
190+
191+
const { tree } = await process(tuple);
192+
193+
assert.strictEqual(tree.data.recorded, `${tuple[0]}:3`);
194+
});
195+
196+
it('run on MDX documents too', async () => {
197+
const tuple = await file('plugged.mdx', '# A\n\n<B />\n');
198+
199+
const { tree } = await process(tuple);
200+
201+
assert.strictEqual(tree.data.recorded, `${tuple[0]}:1`);
202+
});
203+
});
204+
160205
describe('chunking', () => {
161206
it('only processes the requested indices', async () => {
162207
const a = await file('a.md', '# A\n');

‎packages/core/src/generators/ast/generate.mjs‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { parse as parseYaml } from 'yaml';
99

1010
import getConfig from '#utils/configuration/index.mjs';
1111
import { withExt } from '#utils/file.mjs';
12+
import { getProcessor } from '#utils/markdown/processor.mjs';
1213
import { QUERIES } from '#utils/queries/index.mjs';
13-
import { getRemark as remark, getRemarkMdx } from '#utils/remark.mjs';
1414

1515
import { STABILITY_INDEX_URL } from './constants.mjs';
1616

@@ -67,6 +67,8 @@ export async function processChunk(inputSlice, itemIndices) {
6767
// The path is the relative path minus the extension
6868
const relativePath = sep + withExt(relative(parent, path));
6969

70+
const processor = getProcessor('ast', { mdx });
71+
7072
let tree;
7173

7274
if (mdx) {
@@ -79,7 +81,7 @@ export async function processChunk(inputSlice, itemIndices) {
7981
''
8082
);
8183

82-
tree = getRemarkMdx().parse(source);
84+
tree = processor.parse(source);
8385

8486
if (frontmatter) {
8587
tree.children.unshift({
@@ -93,9 +95,11 @@ export async function processChunk(inputSlice, itemIndices) {
9395
(_, yaml) => `<!-- YAML\n${yaml}\n-->`
9496
);
9597

96-
tree = remark().parse(value);
98+
tree = processor.parse(value);
9799
}
98100

101+
tree = await processor.run(tree, { path });
102+
99103
results.push({ tree, path: relativePath, mdx });
100104
}
101105

‎packages/core/src/generators/ast/index.mjs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ export default {
1515

1616
hasParallelProcessor: true,
1717

18+
markdown: {
19+
remarkPlugins: [
20+
'remark-parse',
21+
'#plugins/type-annotations/remark.mjs',
22+
'remark-gfm',
23+
'...',
24+
],
25+
},
26+
1827
generate,
1928
processChunk,
2029
};

0 commit comments

Comments
 (0)