@clidoc/vitepress 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,81 @@
1
+ # @clidoc/vitepress
2
+
3
+ [![npm version](https://img.shields.io/npm/v/%40clidoc%2Fvitepress)](https://www.npmjs.com/package/@clidoc/vitepress)
4
+ [![npm downloads](https://img.shields.io/npm/dw/%40clidoc%2Fvitepress)](https://www.npmjs.com/package/@clidoc/vitepress)
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 VitePress pages and sidebar entries from an OpenCLI document.
10
+
11
+ Install the publisher, parser, and VitePress:
12
+
13
+ ```sh
14
+ npm install -D @clidoc/vitepress @clidoc/core vitepress
15
+ ```
16
+
17
+ Export an OpenCLI document from your CLI, for example with
18
+ `mycli docgen --format yaml --output cli.ocs.yaml`.
19
+
20
+ In `docs/.vitepress/config.mts`, with `cli.ocs.yaml` at the project root:
21
+
22
+ ```ts
23
+ import { defineConfig } from 'vitepress';
24
+ import { parse } from '@clidoc/core';
25
+ import { writeVitePress } from '@clidoc/vitepress';
26
+ import { readFile } from 'node:fs/promises';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const document = parse(await readFile(new URL('../../cli.ocs.yaml', import.meta.url), 'utf8'));
30
+ const cliSidebar = await writeVitePress(document, {
31
+ outputDir: fileURLToPath(new URL('..', import.meta.url)),
32
+ basePath: '/cli',
33
+ });
34
+ export default defineConfig({
35
+ themeConfig: { sidebar: [{ text: 'CLI', items: cliSidebar }] },
36
+ });
37
+ ```
38
+
39
+ Keep handwritten pages in `docs/`. With `basePath: '/cli'`, add only the
40
+ generated paths to `.gitignore`:
41
+
42
+ ```gitignore
43
+ docs/cli.md
44
+ docs/cli/commands/
45
+ docs/.clidoc-generated.json
46
+ ```
47
+
48
+ Run `npx vitepress dev docs` to preview the site.
49
+
50
+ The output contains ordinary Markdown. VitePress's `v-pre` custom container
51
+ keeps CLI-authored Vue expressions literal while still rendering headings,
52
+ links, tables, and fenced code. `basePath` determines both routes and file
53
+ locations below `outputDir`. Regeneration removes only files recorded in
54
+ `.clidoc-generated.json`, leaving hand-written pages intact. On the first run
55
+ after upgrading from OpenCLI, it also removes stale pages listed in
56
+ `.opencli-generated.json` and deletes that legacy manifest.
57
+
58
+ `<` and `>` outside fenced code blocks and inline code spans are escaped to
59
+ entities, since `v-pre` stops Vue mustache interpolation but not its SFC
60
+ parser's tag scanning; as a result, HTML written in a description renders as
61
+ literal text rather than as markup on VitePress.
62
+
63
+ Command pages use the synopsis generated by `@clidoc/core`. Required values use
64
+ `<value>`, optional elements use `[element]`, and repeatable elements use `...`.
65
+ The synopsis includes visible command flags and inherited global flags, and its
66
+ angle-bracket placeholders remain literal inside the fenced code block.
67
+
68
+ Generated command page filenames and sidebar links are stable and based on the
69
+ shared command routes. An ordinary binary-prefixed command such as
70
+ `mycli deploy` produces `cli/commands/deploy.md` and links to
71
+ `/cli/commands/deploy` when `basePath` is `/cli`. Names outside the readable
72
+ route form use a normalized prefix plus a deterministic hash suffix. This keeps
73
+ their URLs stable when unrelated commands are added while avoiding collisions.
74
+
75
+ ## License
76
+
77
+ MIT. See [LICENSE](../../LICENSE).
78
+
79
+ ## Author
80
+
81
+ [Ben Houston](https://ben3d.ca), Sponsored by [Land of Assets](https://landofassets.com).
@@ -0,0 +1,12 @@
1
+ import { type OpenCliDocument } from '@clidoc/core';
2
+ export interface VitePressOptions {
3
+ /** Site source root. Generated pages live below basePath. */
4
+ outputDir: string;
5
+ basePath?: string;
6
+ }
7
+ /** Use in an async VitePress config and assign the result to themeConfig.sidebar. */
8
+ export declare function writeVitePress(document: OpenCliDocument, options: VitePressOptions): Promise<{
9
+ text: string;
10
+ link: string;
11
+ }[]>;
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAiB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAEnE,MAAM,WAAW,gBAAgB;IAC/B,6DAA6D;IAC7D,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AA4DD,qFAAqF;AACrF,wBAAsB,cAAc,CAAC,QAAQ,EAAE,eAAe,EAAE,OAAO,EAAE,gBAAgB;;;KAqBxF"}
package/dist/index.js ADDED
@@ -0,0 +1,86 @@
1
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, relative, resolve, sep } from 'node:path';
3
+ import { generatePages } from '@clidoc/core';
4
+ /**
5
+ * VitePress's `v-pre` container stops Vue mustache interpolation but not Vue's
6
+ * SFC template parser, which still tries to parse tag-shaped text like
7
+ * `use <profile> to override`. Escape `<`/`>` outside fenced code blocks and
8
+ * inline code spans so the build never sees an unbalanced "tag".
9
+ */
10
+ function escapeAngleBrackets(content) {
11
+ const lines = content.split('\n');
12
+ let fenceCharacter = '';
13
+ let fenceLength = 0;
14
+ return lines
15
+ .map((line) => {
16
+ if (fenceLength) {
17
+ const closing = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
18
+ if (closing?.[1]?.[0] === fenceCharacter && closing[1].length >= fenceLength) {
19
+ fenceCharacter = '';
20
+ fenceLength = 0;
21
+ }
22
+ return line;
23
+ }
24
+ const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
25
+ if (opening?.[1] && (opening[1][0] === '~' || !opening[2]?.includes('`'))) {
26
+ fenceCharacter = opening[1][0];
27
+ fenceLength = opening[1].length;
28
+ return line;
29
+ }
30
+ // Split on inline code spans (backtick runs) and only escape outside them.
31
+ return line
32
+ .split(/(`+.*?`+)/)
33
+ .map((part, index) => (index % 2 === 0 ? part.replace(/</g, '&lt;').replace(/>/g, '&gt;') : part))
34
+ .join('');
35
+ })
36
+ .join('\n');
37
+ }
38
+ const manifestName = '.clidoc-generated.json';
39
+ const legacyManifestName = '.opencli-generated.json';
40
+ function ownedPath(outputDir, name) {
41
+ const full = resolve(outputDir, name);
42
+ const rel = relative(outputDir, full);
43
+ if (!rel || rel.startsWith(`..${sep}`) || rel === '..' || rel.startsWith(sep)) {
44
+ throw new Error(`Generated filename escapes output directory: ${name}`);
45
+ }
46
+ return full;
47
+ }
48
+ async function previousFiles(outputDir, name) {
49
+ try {
50
+ const value = JSON.parse(await readFile(join(outputDir, name), 'utf8'));
51
+ if (!Array.isArray(value))
52
+ throw new Error('Invalid generated file manifest');
53
+ return value.filter((entry) => typeof entry === 'string' && entry.endsWith('.md') && !entry.includes('..'));
54
+ }
55
+ catch (error) {
56
+ if (error.code === 'ENOENT')
57
+ return [];
58
+ throw error;
59
+ }
60
+ }
61
+ /** Use in an async VitePress config and assign the result to themeConfig.sidebar. */
62
+ export async function writeVitePress(document, options) {
63
+ const pages = generatePages(document, { basePath: options.basePath });
64
+ const outputDir = resolve(options.outputDir);
65
+ await mkdir(outputDir, { recursive: true });
66
+ const previous = await previousFiles(outputDir, manifestName);
67
+ const legacy = await previousFiles(outputDir, legacyManifestName);
68
+ const current = pages.map((page) => `${page.path.replace(/^\//, '') || 'index'}.md`);
69
+ if (new Set(current).size !== current.length)
70
+ throw new Error('Generated VitePress filenames collide');
71
+ for (const [index, page] of pages.entries()) {
72
+ const filename = ownedPath(outputDir, current[index]);
73
+ await mkdir(dirname(filename), { recursive: true });
74
+ // VitePress's v-pre container keeps Vue expressions literal while parsing Markdown;
75
+ // escaping angle brackets keeps Vue's SFC parser from choking on tag-shaped prose.
76
+ const content = escapeAngleBrackets(page.content.trimEnd());
77
+ await writeFile(filename, `::: v-pre\n\n${content}\n\n:::\n`);
78
+ }
79
+ for (const name of [...previous, ...legacy])
80
+ if (!current.includes(name))
81
+ await rm(ownedPath(outputDir, name), { force: true });
82
+ await writeFile(join(outputDir, manifestName), JSON.stringify(current, null, 2) + '\n');
83
+ await rm(join(outputDir, legacyManifestName), { force: true });
84
+ return pages.map((page) => ({ text: page.title, link: page.path }));
85
+ }
86
+ //# 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/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@clidoc/vitepress",
3
+ "version": "0.1.0",
4
+ "description": "Publish OpenCLI Markdown in VitePress",
5
+ "keywords": [
6
+ "cli",
7
+ "docs",
8
+ "documentation",
9
+ "markdown",
10
+ "opencli",
11
+ "vitepress"
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/vitepress"
23
+ },
24
+ "files": [
25
+ "dist/**/*.js",
26
+ "dist/**/*.js.map",
27
+ "dist/**/*.d.ts",
28
+ "dist/**/*.d.ts.map",
29
+ "src/**/*.ts",
30
+ "!src/**/*.test.ts",
31
+ "CHANGELOG.md"
32
+ ],
33
+ "type": "module",
34
+ "main": "./dist/index.js",
35
+ "types": "./dist/index.d.ts",
36
+ "exports": {
37
+ ".": {
38
+ "types": "./dist/index.d.ts",
39
+ "import": "./dist/index.js"
40
+ }
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "registry": "https://registry.npmjs.org/"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc",
48
+ "tsc": "tsc --noEmit"
49
+ },
50
+ "dependencies": {
51
+ "@clidoc/core": "^0.1.0"
52
+ },
53
+ "engines": {
54
+ "node": ">=22.12.0"
55
+ }
56
+ }
package/src/index.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, relative, resolve, sep } from 'node:path';
3
+ import { generatePages, type OpenCliDocument } from '@clidoc/core';
4
+
5
+ export interface VitePressOptions {
6
+ /** Site source root. Generated pages live below basePath. */
7
+ outputDir: string;
8
+ basePath?: string;
9
+ }
10
+
11
+ /**
12
+ * VitePress's `v-pre` container stops Vue mustache interpolation but not Vue's
13
+ * SFC template parser, which still tries to parse tag-shaped text like
14
+ * `use <profile> to override`. Escape `<`/`>` outside fenced code blocks and
15
+ * inline code spans so the build never sees an unbalanced "tag".
16
+ */
17
+ function escapeAngleBrackets(content: string): string {
18
+ const lines = content.split('\n');
19
+ let fenceCharacter = '';
20
+ let fenceLength = 0;
21
+ return lines
22
+ .map((line) => {
23
+ if (fenceLength) {
24
+ const closing = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
25
+ if (closing?.[1]?.[0] === fenceCharacter && closing[1].length >= fenceLength) {
26
+ fenceCharacter = '';
27
+ fenceLength = 0;
28
+ }
29
+ return line;
30
+ }
31
+ const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
32
+ if (opening?.[1] && (opening[1][0] === '~' || !opening[2]?.includes('`'))) {
33
+ fenceCharacter = opening[1][0]!;
34
+ fenceLength = opening[1].length;
35
+ return line;
36
+ }
37
+ // Split on inline code spans (backtick runs) and only escape outside them.
38
+ return line
39
+ .split(/(`+.*?`+)/)
40
+ .map((part, index) => (index % 2 === 0 ? part.replace(/</g, '&lt;').replace(/>/g, '&gt;') : part))
41
+ .join('');
42
+ })
43
+ .join('\n');
44
+ }
45
+
46
+ const manifestName = '.clidoc-generated.json';
47
+ const legacyManifestName = '.opencli-generated.json';
48
+ function ownedPath(outputDir: string, name: string): string {
49
+ const full = resolve(outputDir, name);
50
+ const rel = relative(outputDir, full);
51
+ if (!rel || rel.startsWith(`..${sep}`) || rel === '..' || rel.startsWith(sep)) {
52
+ throw new Error(`Generated filename escapes output directory: ${name}`);
53
+ }
54
+ return full;
55
+ }
56
+ async function previousFiles(outputDir: string, name: string): Promise<string[]> {
57
+ try {
58
+ const value: unknown = JSON.parse(await readFile(join(outputDir, name), 'utf8'));
59
+ if (!Array.isArray(value)) throw new Error('Invalid generated file manifest');
60
+ return value.filter(
61
+ (entry): entry is string => typeof entry === 'string' && entry.endsWith('.md') && !entry.includes('..'),
62
+ );
63
+ } catch (error) {
64
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ /** Use in an async VitePress config and assign the result to themeConfig.sidebar. */
70
+ export async function writeVitePress(document: OpenCliDocument, options: VitePressOptions) {
71
+ const pages = generatePages(document, { basePath: options.basePath });
72
+ const outputDir = resolve(options.outputDir);
73
+ await mkdir(outputDir, { recursive: true });
74
+ const previous = await previousFiles(outputDir, manifestName);
75
+ const legacy = await previousFiles(outputDir, legacyManifestName);
76
+ const current = pages.map((page) => `${page.path.replace(/^\//, '') || 'index'}.md`);
77
+ if (new Set(current).size !== current.length) throw new Error('Generated VitePress filenames collide');
78
+ for (const [index, page] of pages.entries()) {
79
+ const filename = ownedPath(outputDir, current[index]!);
80
+ await mkdir(dirname(filename), { recursive: true });
81
+ // VitePress's v-pre container keeps Vue expressions literal while parsing Markdown;
82
+ // escaping angle brackets keeps Vue's SFC parser from choking on tag-shaped prose.
83
+ const content = escapeAngleBrackets(page.content.trimEnd());
84
+ await writeFile(filename, `::: v-pre\n\n${content}\n\n:::\n`);
85
+ }
86
+ for (const name of [...previous, ...legacy])
87
+ if (!current.includes(name)) await rm(ownedPath(outputDir, name), { force: true });
88
+ await writeFile(join(outputDir, manifestName), JSON.stringify(current, null, 2) + '\n');
89
+ await rm(join(outputDir, legacyManifestName), { force: true });
90
+ return pages.map((page) => ({ text: page.title, link: page.path }));
91
+ }