@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 +21 -0
- package/README.md +81 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +86 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
- package/src/index.ts +91 -0
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
|
+
[](https://www.npmjs.com/package/@clidoc/vitepress)
|
|
4
|
+
[](https://www.npmjs.com/package/@clidoc/vitepress)
|
|
5
|
+
[](https://github.com/bhouston/clidoc/actions/workflows/ci.yml)
|
|
6
|
+
[](https://codecov.io/gh/bhouston/clidoc)
|
|
7
|
+
[](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).
|
package/dist/index.d.ts
ADDED
|
@@ -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, '<').replace(/>/g, '>') : 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, '<').replace(/>/g, '>') : 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
|
+
}
|