@clidoc/docusaurus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ben Houston
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # @clidoc/docusaurus
2
+
3
+ [![npm version](https://img.shields.io/npm/v/%40clidoc%2Fdocusaurus)](https://www.npmjs.com/package/@clidoc/docusaurus)
4
+ [![npm downloads](https://img.shields.io/npm/dw/%40clidoc%2Fdocusaurus)](https://www.npmjs.com/package/@clidoc/docusaurus)
5
+ [![CI](https://github.com/bhouston/clidoc/actions/workflows/ci.yml/badge.svg)](https://github.com/bhouston/clidoc/actions/workflows/ci.yml)
6
+ [![Coverage](https://codecov.io/gh/bhouston/clidoc/graph/badge.svg)](https://codecov.io/gh/bhouston/clidoc)
7
+ [![Documentation](https://img.shields.io/badge/docs-clidoc.dev-blue)](https://clidoc.dev)
8
+
9
+ Generate Docusaurus documentation and sidebar entries from an OpenCLI document.
10
+ The output is ordinary Markdown with CommonMark front matter, so CLI descriptions
11
+ remain plain Markdown even when they contain JSX-like text.
12
+
13
+ Install the publisher alongside Docusaurus and its classic preset:
14
+
15
+ ```sh
16
+ npm install @clidoc/docusaurus @docusaurus/core @docusaurus/preset-classic react react-dom
17
+ ```
18
+
19
+ Export an OpenCLI document from your CLI, for example with
20
+ `mycli docgen --format yaml --output cli.ocs.yaml`.
21
+
22
+ In `docusaurus.config.js`:
23
+
24
+ ```js
25
+ const clidocPlugin = require('@clidoc/docusaurus');
26
+
27
+ module.exports = {
28
+ title: 'My CLI',
29
+ url: 'https://example.com',
30
+ baseUrl: '/',
31
+ markdown: { format: 'md' },
32
+ presets: [['classic', { docs: { routeBasePath: '/', sidebarPath: './sidebars.js' }, blog: false }]],
33
+ plugins: [
34
+ [
35
+ clidocPlugin,
36
+ {
37
+ input: 'cli.ocs.yaml',
38
+ outputDir: 'docs/generated-cli',
39
+ basePath: '/cli',
40
+ },
41
+ ],
42
+ ],
43
+ };
44
+ ```
45
+
46
+ In `sidebars.js`:
47
+
48
+ ```js
49
+ module.exports = { docs: [{ type: 'autogenerated', dirName: '.' }] };
50
+ ```
51
+
52
+ Keep your handwritten pages in `docs/` (for example, `docs/index.md`). The
53
+ generated CLI pages appear beneath `docs/generated-cli/` and are included by
54
+ the autogenerated sidebar.
55
+
56
+ Run `npx docusaurus start` to preview the site.
57
+
58
+ Docusaurus 3 loads site configuration with CommonJS. The package includes a small
59
+ CommonJS entrypoint that imports the ESM generator when the plugin runs.
60
+ `docusaurus.config.js` itself is loaded through jiti, which does not support a
61
+ top-level `await`; load the OpenCLI document from a file path via `input`
62
+ instead of awaiting a parse at the top of the config.
63
+
64
+ Set `input` to a parsed OpenCLI document or a JSON/YAML path relative to the
65
+ site directory. The plugin writes files during initialization, before the docs plugin
66
+ scans content, so the docs plugin's autogenerated sidebar discovers the pages.
67
+ `title` is used as each page's sidebar label. The
68
+ plugin path relies on that autogenerated sidebar and does not return one of
69
+ its own; for a hand-built sidebar array, call `writeDocusaurus()` directly
70
+ from `sidebars.js` instead of using the plugin.
71
+
72
+ `writeDocusaurus(document, {outputDir, basePath})` offers direct generation
73
+ for build scripts. Generated files have readable names based on the executable and
74
+ command, such as `mycli.md` for the landing page and `mycli-validate.md` for a
75
+ command page. Names that contain unsafe characters, exceed the length limit, or
76
+ would otherwise collide retain a readable prefix and add a deterministic digest.
77
+ Page IDs and routes are defined separately in front matter and do not change with
78
+ the filenames.
79
+
80
+ Regeneration removes files listed in `.clidoc-generated.json`. Generation stops
81
+ instead of overwriting a matching filename that is not owned by that manifest.
82
+ On the first run after upgrading from OpenCLI, it also removes old generated
83
+ pages listed in `.opencli-generated.json` and deletes that legacy manifest.
84
+ Other documents in the directory are preserved. Landing-page links to command
85
+ pages point at the generated filenames directly, so routes resolve correctly
86
+ regardless of the docs plugin's `routeBasePath`.
87
+
88
+ Add `docs/generated-cli/` to `.gitignore` if generated pages should stay out of
89
+ version control.
90
+
91
+ ## License
92
+
93
+ MIT. See [LICENSE](../../LICENSE).
94
+
95
+ ## Author
96
+
97
+ [Ben Houston](https://ben3d.ca), Sponsored by [Land of Assets](https://landofassets.com).
@@ -0,0 +1,24 @@
1
+ import { type OpenCliDocument } from '@clidoc/core';
2
+ export interface DocusaurusOptions {
3
+ /** An OpenCLI document or a JSON/YAML filename relative to siteDir. */
4
+ input: OpenCliDocument | string;
5
+ /** Dedicated generated directory consumed by the Docusaurus docs plugin. */
6
+ outputDir: string;
7
+ basePath?: string;
8
+ }
9
+ /** Write CommonMark docs and remove only files recorded as generated by this package. */
10
+ export declare function writeDocusaurus(document: OpenCliDocument, options: {
11
+ outputDir: string;
12
+ basePath?: string;
13
+ }): Promise<{
14
+ type: 'doc';
15
+ id: string;
16
+ label: string;
17
+ }[]>;
18
+ /** Generate before content loading, so plugin-content-docs can discover the files. */
19
+ export default function clidocPlugin(context: {
20
+ siteDir: string;
21
+ }, options: DocusaurusOptions): Promise<{
22
+ name: string;
23
+ }>;
24
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAwB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAE1E,MAAM,WAAW,iBAAiB;IAChC,uEAAuE;IACvE,KAAK,EAAE,eAAe,GAAG,MAAM,CAAC;IAChC,4EAA4E;IAC5E,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAmED,yFAAyF;AACzF,wBAAsB,eAAe,CAAC,QAAQ,EAAE,eAAe,EAAE,OAAO,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE;UA4C5E,KAAK;;;KAC1C;AAED,sFAAsF;AACtF,wBAA8B,YAAY,CAAC,OAAO,EAAE;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,OAAO,EAAE,iBAAiB;;GAUlG"}
package/dist/index.js ADDED
@@ -0,0 +1,120 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ import { generatePages, parse } from '@clidoc/core';
5
+ const manifestName = '.clidoc-generated.json';
6
+ const legacyManifestName = '.opencli-generated.json';
7
+ const legacyFilename = /^(?:clidoc|opencli)-[a-f0-9]{20}\.md$/;
8
+ function filenamePart(value) {
9
+ if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) && value.length <= 48)
10
+ return value;
11
+ const readable = value
12
+ .toLowerCase()
13
+ .normalize('NFKD')
14
+ .replace(/[^a-z0-9]+/g, '-')
15
+ .replace(/^-+|-+$/g, '')
16
+ .slice(0, 32) || 'page';
17
+ return `${readable}-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`;
18
+ }
19
+ function filename(binary, id, title) {
20
+ const prefix = filenamePart(binary);
21
+ if (id === 'index')
22
+ return `${prefix}.md`;
23
+ const command = title.startsWith(`${binary} `) ? title.slice(binary.length + 1) : title;
24
+ const words = command.trim().split(/\s+/);
25
+ const syntax = words.findIndex((word) => /^(?:<|\[|\{|--)/.test(word));
26
+ const staticWords = syntax === -1 ? words : words.slice(0, syntax);
27
+ const staticCommand = staticWords.length ? staticWords.join('-') : command;
28
+ return `${prefix}-${filenamePart(staticCommand)}.md`;
29
+ }
30
+ function filenames(binary, pages) {
31
+ const candidates = pages.map((page) => filename(binary, page.id, page.title));
32
+ const counts = new Map();
33
+ for (const candidate of candidates)
34
+ counts.set(candidate, (counts.get(candidate) ?? 0) + 1);
35
+ return candidates.map((candidate, position) => {
36
+ if (counts.get(candidate) === 1)
37
+ return candidate;
38
+ const stem = candidate.slice(0, -'.md'.length);
39
+ const digest = createHash('sha256').update(pages[position].id).digest('hex').slice(0, 16);
40
+ return `${stem}--${digest}.md`;
41
+ });
42
+ }
43
+ async function previousFiles(outputDir, name) {
44
+ try {
45
+ const value = JSON.parse(await readFile(join(outputDir, name), 'utf8'));
46
+ // Array manifests were written by older releases. Only accept their opaque generated
47
+ // names so a malformed old manifest cannot claim an unrelated Markdown document.
48
+ if (Array.isArray(value))
49
+ return value.filter((entry) => typeof entry === 'string' && legacyFilename.test(entry));
50
+ if (typeof value !== 'object' ||
51
+ value === null ||
52
+ !('version' in value) ||
53
+ value.version !== 1 ||
54
+ !('files' in value) ||
55
+ !Array.isArray(value.files) ||
56
+ !value.files.every((entry) => typeof entry === 'string' && /^[a-z0-9][a-z0-9-]*\.md$/.test(entry) && entry.length <= 120))
57
+ throw new Error('Invalid generated file manifest');
58
+ return value.files;
59
+ }
60
+ catch (error) {
61
+ if (error.code === 'ENOENT')
62
+ return [];
63
+ throw error;
64
+ }
65
+ }
66
+ /** Write CommonMark docs and remove only files recorded as generated by this package. */
67
+ export async function writeDocusaurus(document, options) {
68
+ const pages = generatePages(document, { basePath: options.basePath });
69
+ await mkdir(options.outputDir, { recursive: true });
70
+ const previous = await previousFiles(options.outputDir, manifestName);
71
+ const legacy = await previousFiles(options.outputDir, legacyManifestName);
72
+ const existing = await readdir(options.outputDir);
73
+ const current = filenames(document.info.binary, pages);
74
+ if (new Set(current).size !== current.length)
75
+ throw new Error('Generated Docusaurus filenames collide');
76
+ const owned = new Set([...previous, ...legacy]);
77
+ const unownedCollision = current.find((name) => !owned.has(name) && existing.includes(name));
78
+ if (unownedCollision)
79
+ throw new Error(`Refusing to overwrite unowned file: ${unownedCollision}`);
80
+ // Map each page's route to the generated filename holding it, so the landing page can link by
81
+ // file instead of by route: Docusaurus then resolves the URL itself regardless of routeBasePath.
82
+ const filenameByPath = new Map(pages.map((page, position) => [page.path, current[position]]));
83
+ for (const [position, page] of pages.entries()) {
84
+ const frontmatter = [
85
+ '---',
86
+ 'mdx:',
87
+ ' format: md',
88
+ `id: ${JSON.stringify(page.id)}`,
89
+ `title: ${JSON.stringify(page.title)}`,
90
+ `slug: ${JSON.stringify(page.path)}`,
91
+ `sidebar_position: ${position + 1}`,
92
+ '---',
93
+ '',
94
+ ].join('\n');
95
+ const content = page.id === 'index'
96
+ ? pages
97
+ .slice(1)
98
+ .reduce((text, target) => text.replaceAll(`](${target.path})`, `](./${filenameByPath.get(target.path)})`), page.content)
99
+ : page.content;
100
+ await writeFile(join(options.outputDir, current[position]), frontmatter + content);
101
+ }
102
+ for (const name of [...previous, ...legacy])
103
+ if (!current.includes(name))
104
+ await rm(join(options.outputDir, name), { force: true });
105
+ await writeFile(join(options.outputDir, manifestName), JSON.stringify({ version: 1, files: current }, null, 2) + '\n');
106
+ await rm(join(options.outputDir, legacyManifestName), { force: true });
107
+ return pages.map((page) => ({ type: 'doc', id: page.id, label: page.title }));
108
+ }
109
+ /** Generate before content loading, so plugin-content-docs can discover the files. */
110
+ export default async function clidocPlugin(context, options) {
111
+ const document = typeof options.input === 'string'
112
+ ? parse(await readFile(resolve(context.siteDir, options.input), 'utf8'))
113
+ : options.input;
114
+ await writeDocusaurus(document, {
115
+ outputDir: resolve(context.siteDir, options.outputDir),
116
+ basePath: options.basePath,
117
+ });
118
+ return { name: 'clidoc-docusaurus' };
119
+ }
120
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/index.cjs ADDED
@@ -0,0 +1,5 @@
1
+ // Docusaurus 3 loads site configuration through CommonJS jiti.
2
+ module.exports = async function clidocPlugin(context, options) {
3
+ const { default: plugin } = await import('./dist/index.js');
4
+ return plugin(context, options);
5
+ };
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@clidoc/docusaurus",
3
+ "version": "0.1.0",
4
+ "description": "Publish OpenCLI Markdown in Docusaurus",
5
+ "keywords": [
6
+ "cli",
7
+ "docs",
8
+ "documentation",
9
+ "docusaurus",
10
+ "markdown",
11
+ "opencli"
12
+ ],
13
+ "homepage": "https://clidoc.dev",
14
+ "bugs": {
15
+ "url": "https://github.com/bhouston/clidoc/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Ben Houston <ben@ben3d.ca> (https://ben3d.ca)",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/bhouston/clidoc.git",
22
+ "directory": "packages/docusaurus"
23
+ },
24
+ "files": [
25
+ "index.cjs",
26
+ "dist/**/*.js",
27
+ "dist/**/*.js.map",
28
+ "dist/**/*.d.ts",
29
+ "dist/**/*.d.ts.map",
30
+ "src/**/*.ts",
31
+ "!src/**/*.test.ts",
32
+ "CHANGELOG.md"
33
+ ],
34
+ "type": "module",
35
+ "main": "./dist/index.js",
36
+ "types": "./dist/index.d.ts",
37
+ "exports": {
38
+ ".": {
39
+ "types": "./dist/index.d.ts",
40
+ "import": "./dist/index.js",
41
+ "require": "./index.cjs"
42
+ }
43
+ },
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "registry": "https://registry.npmjs.org/"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc",
50
+ "tsc": "tsc --noEmit"
51
+ },
52
+ "dependencies": {
53
+ "@clidoc/core": "^0.1.0"
54
+ },
55
+ "engines": {
56
+ "node": ">=22.12.0"
57
+ }
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,138 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ import { generatePages, parse, type OpenCliDocument } from '@clidoc/core';
5
+
6
+ export interface DocusaurusOptions {
7
+ /** An OpenCLI document or a JSON/YAML filename relative to siteDir. */
8
+ input: OpenCliDocument | string;
9
+ /** Dedicated generated directory consumed by the Docusaurus docs plugin. */
10
+ outputDir: string;
11
+ basePath?: string;
12
+ }
13
+
14
+ const manifestName = '.clidoc-generated.json';
15
+ const legacyManifestName = '.opencli-generated.json';
16
+ const legacyFilename = /^(?:clidoc|opencli)-[a-f0-9]{20}\.md$/;
17
+
18
+ function filenamePart(value: string): string {
19
+ if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) && value.length <= 48) return value;
20
+ const readable =
21
+ value
22
+ .toLowerCase()
23
+ .normalize('NFKD')
24
+ .replace(/[^a-z0-9]+/g, '-')
25
+ .replace(/^-+|-+$/g, '')
26
+ .slice(0, 32) || 'page';
27
+ return `${readable}-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`;
28
+ }
29
+
30
+ function filename(binary: string, id: string, title: string): string {
31
+ const prefix = filenamePart(binary);
32
+ if (id === 'index') return `${prefix}.md`;
33
+ const command = title.startsWith(`${binary} `) ? title.slice(binary.length + 1) : title;
34
+ const words = command.trim().split(/\s+/);
35
+ const syntax = words.findIndex((word) => /^(?:<|\[|\{|--)/.test(word));
36
+ const staticWords = syntax === -1 ? words : words.slice(0, syntax);
37
+ const staticCommand = staticWords.length ? staticWords.join('-') : command;
38
+ return `${prefix}-${filenamePart(staticCommand)}.md`;
39
+ }
40
+
41
+ function filenames(binary: string, pages: ReturnType<typeof generatePages>): string[] {
42
+ const candidates = pages.map((page) => filename(binary, page.id, page.title));
43
+ const counts = new Map<string, number>();
44
+ for (const candidate of candidates) counts.set(candidate, (counts.get(candidate) ?? 0) + 1);
45
+ return candidates.map((candidate, position) => {
46
+ if (counts.get(candidate) === 1) return candidate;
47
+ const stem = candidate.slice(0, -'.md'.length);
48
+ const digest = createHash('sha256').update(pages[position]!.id).digest('hex').slice(0, 16);
49
+ return `${stem}--${digest}.md`;
50
+ });
51
+ }
52
+
53
+ async function previousFiles(outputDir: string, name: string): Promise<string[]> {
54
+ try {
55
+ const value: unknown = JSON.parse(await readFile(join(outputDir, name), 'utf8'));
56
+ // Array manifests were written by older releases. Only accept their opaque generated
57
+ // names so a malformed old manifest cannot claim an unrelated Markdown document.
58
+ if (Array.isArray(value))
59
+ return value.filter((entry): entry is string => typeof entry === 'string' && legacyFilename.test(entry));
60
+ if (
61
+ typeof value !== 'object' ||
62
+ value === null ||
63
+ !('version' in value) ||
64
+ value.version !== 1 ||
65
+ !('files' in value) ||
66
+ !Array.isArray(value.files) ||
67
+ !value.files.every(
68
+ (entry) => typeof entry === 'string' && /^[a-z0-9][a-z0-9-]*\.md$/.test(entry) && entry.length <= 120,
69
+ )
70
+ )
71
+ throw new Error('Invalid generated file manifest');
72
+ return value.files;
73
+ } catch (error) {
74
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ /** Write CommonMark docs and remove only files recorded as generated by this package. */
80
+ export async function writeDocusaurus(document: OpenCliDocument, options: { outputDir: string; basePath?: string }) {
81
+ const pages = generatePages(document, { basePath: options.basePath });
82
+ await mkdir(options.outputDir, { recursive: true });
83
+ const previous = await previousFiles(options.outputDir, manifestName);
84
+ const legacy = await previousFiles(options.outputDir, legacyManifestName);
85
+ const existing = await readdir(options.outputDir);
86
+ const current = filenames(document.info.binary, pages);
87
+ if (new Set(current).size !== current.length) throw new Error('Generated Docusaurus filenames collide');
88
+ const owned = new Set([...previous, ...legacy]);
89
+ const unownedCollision = current.find((name) => !owned.has(name) && existing.includes(name));
90
+ if (unownedCollision) throw new Error(`Refusing to overwrite unowned file: ${unownedCollision}`);
91
+ // Map each page's route to the generated filename holding it, so the landing page can link by
92
+ // file instead of by route: Docusaurus then resolves the URL itself regardless of routeBasePath.
93
+ const filenameByPath = new Map(pages.map((page, position) => [page.path, current[position]!]));
94
+ for (const [position, page] of pages.entries()) {
95
+ const frontmatter = [
96
+ '---',
97
+ 'mdx:',
98
+ ' format: md',
99
+ `id: ${JSON.stringify(page.id)}`,
100
+ `title: ${JSON.stringify(page.title)}`,
101
+ `slug: ${JSON.stringify(page.path)}`,
102
+ `sidebar_position: ${position + 1}`,
103
+ '---',
104
+ '',
105
+ ].join('\n');
106
+ const content =
107
+ page.id === 'index'
108
+ ? pages
109
+ .slice(1)
110
+ .reduce(
111
+ (text, target) => text.replaceAll(`](${target.path})`, `](./${filenameByPath.get(target.path)})`),
112
+ page.content,
113
+ )
114
+ : page.content;
115
+ await writeFile(join(options.outputDir, current[position]!), frontmatter + content);
116
+ }
117
+ for (const name of [...previous, ...legacy])
118
+ if (!current.includes(name)) await rm(join(options.outputDir, name), { force: true });
119
+ await writeFile(
120
+ join(options.outputDir, manifestName),
121
+ JSON.stringify({ version: 1, files: current }, null, 2) + '\n',
122
+ );
123
+ await rm(join(options.outputDir, legacyManifestName), { force: true });
124
+ return pages.map((page) => ({ type: 'doc' as const, id: page.id, label: page.title }));
125
+ }
126
+
127
+ /** Generate before content loading, so plugin-content-docs can discover the files. */
128
+ export default async function clidocPlugin(context: { siteDir: string }, options: DocusaurusOptions) {
129
+ const document =
130
+ typeof options.input === 'string'
131
+ ? parse(await readFile(resolve(context.siteDir, options.input), 'utf8'))
132
+ : options.input;
133
+ await writeDocusaurus(document, {
134
+ outputDir: resolve(context.siteDir, options.outputDir),
135
+ basePath: options.basePath,
136
+ });
137
+ return { name: 'clidoc-docusaurus' };
138
+ }