clyops-doc 0.6.0 → 0.8.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/README.md +41 -1
- package/dist/cli.js +134 -22
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -1
- package/dist/model.d.ts +30 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +79 -7
- package/dist/render.d.ts +2 -0
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +12 -4
- package/package.json +4 -3
- package/src/cli.ts +124 -22
- package/src/index.ts +37 -1
- package/src/model.ts +98 -9
- package/src/render.ts +13 -4
package/README.md
CHANGED
|
@@ -24,10 +24,47 @@ man -l mytool.1
|
|
|
24
24
|
| `-V, --version` | Version shown in the header |
|
|
25
25
|
| `-d, --date` | Date shown in the header, YYYY-MM-DD (default today, or `SOURCE_DATE_EPOCH` for reproducible builds) |
|
|
26
26
|
| `--schema FILE` | Read the schema from a file instead of stdin |
|
|
27
|
+
| `-r, --root DIR` | Document every tool in a directory (or dispatcher definition), with an index page (needs `--out-dir` or `--prefix`) |
|
|
28
|
+
| `-o, --out-dir DIR` | Write page files into `DIR` (created if missing) instead of to stdout |
|
|
29
|
+
| `-p, --prefix PREFIX` | Install man pages into `PREFIX/share/man/manN`, and warn if `man` won't find them |
|
|
30
|
+
| `--split` | A page per command, git-style (needs `--out-dir` or `--prefix`) |
|
|
31
|
+
| `-x, --execute` | Write the files; without it, `--out-dir` and `--prefix` only list them |
|
|
27
32
|
|
|
28
33
|
Each option can also be set as `CLYOPS_DOC_<OPTION>`. Input that isn't a clyops schema (`"clyops": 1`)
|
|
29
34
|
is rejected with exit status 1.
|
|
30
35
|
|
|
36
|
+
## A page per command
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
mytool --help-json-schema | clyops-doc --split --out-dir man1 # lists the files
|
|
40
|
+
mytool --help-json-schema | clyops-doc --split --out-dir man1 --execute # writes them
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`mytool.1` gets the program's own options and a list of the commands, and each runnable command gets
|
|
44
|
+
its own page, `mytool-db-migrate.1`, with the options it inherits and the nearest declared effects,
|
|
45
|
+
stdin, stdout and roles. They refer to each other under SEE ALSO, as links in Markdown and HTML
|
|
46
|
+
(`.md`, `.html`). A program without commands is one page. Files that already say the same are left
|
|
47
|
+
alone, so a rerun is a no-op. Because it can write files, clyops-doc declares the `idempotent` and
|
|
48
|
+
`destructive` effects, although nothing is written without `--execute`.
|
|
49
|
+
|
|
50
|
+
## A whole directory of tools
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
clyops-doc --root ~/mytool/scripts --name mytool --prefix ~/.local # lists the pages
|
|
54
|
+
clyops-doc --root ~/mytool/scripts --name mytool --prefix ~/.local --execute # installs them
|
|
55
|
+
man mytool-media-to-pcm
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`--root` finds tools the way `clyops-dispatch`, `clyops-api` and `clyops-mcp` do (subdirectories
|
|
59
|
+
become command words, `.clyops` files give descriptions and ignores, nested dispatchers are
|
|
60
|
+
followed), asks each for `--help-json-schema`, and writes a page per tool, or with `--split` per
|
|
61
|
+
command, plus an index page named after the dispatcher (`--name`, default the directory's name)
|
|
62
|
+
that lists them all. Executables that aren't clyops tools, or that fail to give a schema, are
|
|
63
|
+
skipped with a warning.
|
|
64
|
+
|
|
65
|
+
`--prefix` writes into `PREFIX/share/man/man1` (`man8` with `--section 8`) and checks `manpath`
|
|
66
|
+
(or `MANPATH`), saying how to add the directory when `man` won't look there.
|
|
67
|
+
|
|
31
68
|
## What goes where
|
|
32
69
|
|
|
33
70
|
| Section | From the schema |
|
|
@@ -39,8 +76,10 @@ is rejected with exit status 1.
|
|
|
39
76
|
| STREAMS | `stdin`, `stdout` and [roles](https://github.com/wankdanker/clyops/blob/main/spec/SPEC.md#14-roles-on-file-descriptors) with their types |
|
|
40
77
|
| NOTES | effects, option relationships and required commands |
|
|
41
78
|
| ENVIRONMENT | each option's variable, the precedence rule, `CLYOPS_FDS` when there are roles, `CLYOPS_SILENT`, `CLYOPS_MAX_WIDTH` and `NO_COLOR` |
|
|
79
|
+
| FILES | the config file: the option and variable naming it, its default, the line format and prefixes |
|
|
42
80
|
| EXIT STATUS | 0, 1, and the tool's own |
|
|
43
81
|
| EXAMPLES | `epilog` |
|
|
82
|
+
| SEE ALSO | with `--split`, the other pages |
|
|
44
83
|
|
|
45
84
|
Man pages use the plain `man(7)` macros, which groff and mandoc both render; text is escaped so a
|
|
46
85
|
line can't start a request and option dashes are `\-`, so they copy and paste. The HTML page is
|
|
@@ -49,10 +88,11 @@ self-contained, with every value escaped.
|
|
|
49
88
|
## As a library
|
|
50
89
|
|
|
51
90
|
```js
|
|
52
|
-
import { document,
|
|
91
|
+
import { buildDoc, document, pages, renderMarkdown } from 'clyops-doc';
|
|
53
92
|
|
|
54
93
|
const page = document(schema, 'man', { name: 'mytool', version: '1.2.0' });
|
|
55
94
|
const md = renderMarkdown(buildDoc(schema)); // or build the document once and render it several ways
|
|
95
|
+
for (const { file, content } of pages(schema, 'man')) { /* mytool.1, mytool-db-migrate.1, ... */ }
|
|
56
96
|
```
|
|
57
97
|
|
|
58
98
|
ESM only, Node 20+.
|
package/dist/cli.js
CHANGED
|
@@ -1,38 +1,150 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// clyops-doc: a clyops tool's --help-json-schema on stdin, as a man page, Markdown or HTML.
|
|
3
|
-
import { Cli, die } from 'clyops';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
3
|
+
import { Cli, die, info, warn } from 'clyops';
|
|
4
|
+
import { commands, discover, loadSchema } from 'clyops-tools';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { delimiter, join, resolve } from 'node:path';
|
|
8
|
+
import { document, extension, pages } from './index.js';
|
|
9
|
+
import { defaultDate, firstSentence, pageName, toolsetDoc } from './model.js';
|
|
10
|
+
import { render } from './render.js';
|
|
7
11
|
const cli = new Cli({ name: 'clyops-doc' });
|
|
8
12
|
cli.setDescription('Render a clyops tool\'s --help-json-schema as documentation: a man page, Markdown or HTML. It reads the schema on stdin, so it works with any clyops tool, compiled ones included.');
|
|
9
|
-
cli.setEpilog('Examples:\n mytool --help-json-schema | clyops-doc > mytool.1\n mytool --help-json-schema | clyops-doc --format markdown > mytool.md\n clyops-doc --schema schema.json --name "mytool media to-pcm" --format html > to-pcm.html\n clyops-doc --help-json-schema | clyops-doc | man -l -');
|
|
10
|
-
|
|
11
|
-
cli.
|
|
12
|
-
cli.
|
|
13
|
+
cli.setEpilog('Examples:\n mytool --help-json-schema | clyops-doc > mytool.1\n mytool --help-json-schema | clyops-doc --format markdown > mytool.md\n clyops-doc --schema schema.json --name "mytool media to-pcm" --format html > to-pcm.html\n clyops-doc --help-json-schema | clyops-doc | man -l -\n mytool --help-json-schema | clyops-doc --split --out-dir man1 --execute\n clyops-doc --root ~/mytool/scripts --name mytool --prefix ~/.local --execute');
|
|
14
|
+
// It writes files only with --out-dir or --prefix and --execute, and then only its own pages.
|
|
15
|
+
cli.setEffects('idempotent', 'destructive');
|
|
16
|
+
cli.setStdin('The tool\'s --help-json-schema output (unless --schema or --root is given)', 'application/json');
|
|
17
|
+
// One declared type per program: what it writes is text/troff, text/markdown or text/html by --format.
|
|
18
|
+
cli.setStdout('The documentation as --format says: text/troff, text/markdown or text/html; with --out-dir or --prefix, the files written (or, without --execute, that would be), one per line', 'text/plain');
|
|
13
19
|
cli.opt('CLYOPS_DOC_FORMAT', 'format', 'f', 'man', 'Output format', 'Output', 'choice:man,markdown,html');
|
|
14
|
-
cli.opt('CLYOPS_DOC_NAME', 'name', 'n', 'optional', 'How the tool is run (default: the schema\'s name), e.g. "mytool media to-pcm"', 'Output');
|
|
20
|
+
cli.opt('CLYOPS_DOC_NAME', 'name', 'n', 'optional', 'How the tool is run (default: the schema\'s name), e.g. "mytool media to-pcm"; with --root, the dispatcher\'s name (default: the directory\'s)', 'Output');
|
|
15
21
|
cli.opt('CLYOPS_DOC_SECTION', 'section', 's', '1', 'Manual section for the man page header', 'Output', 'regex:^[1-9][a-z]*$');
|
|
16
22
|
cli.opt('CLYOPS_DOC_VERSION', 'version', 'V', 'optional', 'Version shown in the header', 'Output');
|
|
17
23
|
cli.opt('CLYOPS_DOC_DATE', 'date', 'd', 'optional', 'Date shown in the header (default: today, or SOURCE_DATE_EPOCH)', 'Output', 'date:YYYY-MM-DD');
|
|
18
24
|
cli.opt('CLYOPS_DOC_SCHEMA', 'schema', '', 'optional', 'Read the schema from this file instead of stdin', 'Input', 'file:readable');
|
|
25
|
+
cli.opt('CLYOPS_DOC_ROOT', 'root', 'r', 'optional', 'Document every tool in this directory (or dispatcher definition), found as clyops-dispatch, clyops-api and clyops-mcp find them, with an index page', 'Input', 'path');
|
|
26
|
+
cli.opt('CLYOPS_DOC_OUT_DIR', 'out-dir', 'o', 'optional', 'Write page files into this directory (created if missing) instead of to stdout', 'Files', 'path');
|
|
27
|
+
cli.opt('CLYOPS_DOC_PREFIX', 'prefix', 'p', 'optional', 'Install man pages into PREFIX/share/man/manSECTION and check that man finds them', 'Files', 'path');
|
|
28
|
+
cli.opt('CLYOPS_DOC_SPLIT', 'split', '', 'flag', 'A page per command, git-style: mytool.1 lists them, mytool-db-migrate.1 documents one', 'Files');
|
|
29
|
+
cli.opt('CLYOPS_DOC_EXECUTE', 'execute', 'x', 'flag', 'Write the files; without it, only list them', 'Files');
|
|
30
|
+
cli.exclusive('schema', 'root');
|
|
31
|
+
cli.exclusive('out-dir', 'prefix');
|
|
19
32
|
const args = cli.run();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
33
|
+
const format = args.CLYOPS_DOC_FORMAT;
|
|
34
|
+
const opts = {
|
|
35
|
+
name: args.CLYOPS_DOC_NAME ?? undefined,
|
|
36
|
+
section: args.CLYOPS_DOC_SECTION,
|
|
37
|
+
version: args.CLYOPS_DOC_VERSION ?? undefined,
|
|
38
|
+
date: args.CLYOPS_DOC_DATE ?? defaultDate(),
|
|
39
|
+
};
|
|
40
|
+
const root = args.CLYOPS_DOC_ROOT;
|
|
41
|
+
const prefix = args.CLYOPS_DOC_PREFIX;
|
|
42
|
+
const split = args.CLYOPS_DOC_SPLIT;
|
|
43
|
+
const execute = args.CLYOPS_DOC_EXECUTE;
|
|
44
|
+
const manDir = prefix === null ? null : join(prefix, 'share', 'man');
|
|
45
|
+
const outDir = args.CLYOPS_DOC_OUT_DIR ?? (manDir === null ? null : join(manDir, `man${opts.section}`));
|
|
46
|
+
if (prefix !== null && format !== 'man')
|
|
47
|
+
die(1, '--prefix installs man pages; use --out-dir for --format %s', format);
|
|
48
|
+
if (outDir === null) {
|
|
49
|
+
// Several pages, or writing them, need somewhere to go.
|
|
50
|
+
const needs = root !== null ? 'root' : split ? 'split' : execute ? 'execute' : null;
|
|
51
|
+
if (needs)
|
|
52
|
+
die(1, '--%s needs --out-dir or --prefix', needs);
|
|
27
53
|
}
|
|
28
54
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
55
|
+
if (root !== null)
|
|
56
|
+
writePages(await toolsetPages(root));
|
|
57
|
+
else {
|
|
58
|
+
const schema = readSchema();
|
|
59
|
+
if (outDir === null)
|
|
60
|
+
process.stdout.write(document(schema, format, opts));
|
|
61
|
+
else
|
|
62
|
+
writePages(split ? pages(schema, format, opts) : [{ file: `${pageName(opts.name || schema.script)}.${extension(format, opts.section)}`, content: document(schema, format, opts) }]);
|
|
63
|
+
}
|
|
35
64
|
}
|
|
36
65
|
catch (err) {
|
|
37
66
|
die(1, '%s', err.message);
|
|
38
67
|
}
|
|
68
|
+
function readSchema() {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(readFileSync(args.CLYOPS_DOC_SCHEMA ?? 0, 'utf8'));
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
return die(1, 'Could not read the schema: %s', err.message);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// --root: a page (or, with --split, pages) per tool, and an index page named after the dispatcher.
|
|
77
|
+
async function toolsetPages(dir) {
|
|
78
|
+
const group = discover(dir, { name: opts.name });
|
|
79
|
+
const ext = extension(format, opts.section);
|
|
80
|
+
const out = [];
|
|
81
|
+
const tools = [];
|
|
82
|
+
for (const cmd of commands(group)) {
|
|
83
|
+
if (cmd.kind !== 'tool') {
|
|
84
|
+
warn('Skipping %s: not a clyops tool', cmd.file);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
let schema;
|
|
88
|
+
try {
|
|
89
|
+
schema = await loadSchema(cmd.file);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
warn('Skipping %s', err.message);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const name = [group.name, ...cmd.words].join(' ');
|
|
96
|
+
out.push(...(split
|
|
97
|
+
? pages(schema, format, { ...opts, name })
|
|
98
|
+
: [{ file: `${pageName(name)}.${ext}`, content: document(schema, format, { ...opts, name, page: pageName(name) }) }]));
|
|
99
|
+
tools.push({ words: cmd.words, description: firstSentence(schema.description) });
|
|
100
|
+
}
|
|
101
|
+
if (!tools.length)
|
|
102
|
+
throw new Error(`no clyops tools in ${dir}`);
|
|
103
|
+
const all = [{ file: `${pageName(group.name)}.${ext}`, content: render(toolsetDoc(group.name, group.description, tools, opts), format) }, ...out];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
for (const p of all) {
|
|
106
|
+
if (seen.has(p.file))
|
|
107
|
+
throw new Error(`two pages would be named ${p.file}`);
|
|
108
|
+
seen.add(p.file);
|
|
109
|
+
}
|
|
110
|
+
return all;
|
|
111
|
+
}
|
|
112
|
+
// Each page into the directory, leaving files that already say the same alone.
|
|
113
|
+
function writePages(all) {
|
|
114
|
+
const dir = outDir;
|
|
115
|
+
let replaced = 0;
|
|
116
|
+
let unchanged = 0;
|
|
117
|
+
if (execute)
|
|
118
|
+
mkdirSync(dir, { recursive: true });
|
|
119
|
+
for (const page of all) {
|
|
120
|
+
const path = join(dir, page.file);
|
|
121
|
+
const before = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
122
|
+
if (before === page.content)
|
|
123
|
+
unchanged++;
|
|
124
|
+
else {
|
|
125
|
+
if (before !== null)
|
|
126
|
+
replaced++;
|
|
127
|
+
if (execute)
|
|
128
|
+
writeFileSync(path, page.content);
|
|
129
|
+
}
|
|
130
|
+
process.stdout.write(`${path}\n`);
|
|
131
|
+
}
|
|
132
|
+
const counts = `${all.length - unchanged} page(s)${replaced ? ` (replacing ${replaced})` : ''}${unchanged ? `; ${unchanged} already up to date` : ''}`;
|
|
133
|
+
info(execute ? `Wrote ${counts} in ${dir}.` : `Would write ${counts} in ${dir}; add --execute to write them.`);
|
|
134
|
+
if (manDir !== null)
|
|
135
|
+
checkManPath(manDir);
|
|
136
|
+
}
|
|
137
|
+
// --prefix: say so when man won't look in PREFIX/share/man.
|
|
138
|
+
function checkManPath(dir) {
|
|
139
|
+
let paths = process.env.MANPATH ?? '';
|
|
140
|
+
try {
|
|
141
|
+
paths = execFileSync('manpath', [], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).trim();
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// No manpath(1): MANPATH is all there is to go on.
|
|
145
|
+
}
|
|
146
|
+
const want = resolve(dir);
|
|
147
|
+
if (!paths.split(delimiter).some((p) => p && resolve(p) === want)) {
|
|
148
|
+
warn('man won\'t find these pages: %s is not on its search path. Add it with: export MANPATH="%s%s$(manpath)"', want, want, delimiter);
|
|
149
|
+
}
|
|
150
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,4 +5,19 @@ import { type DocOptions } from './model.js';
|
|
|
5
5
|
import { type Format } from './render.js';
|
|
6
6
|
/** A schema's documentation in `format` (default man). Throws for input that isn't a clyops schema. */
|
|
7
7
|
export declare function document(schema: Schema, format?: Format, opts?: DocOptions): string;
|
|
8
|
+
/** One file of a split document. */
|
|
9
|
+
export interface Page {
|
|
10
|
+
/** File name, e.g. `mytool-db-migrate.1`, `mytool.md`. */
|
|
11
|
+
file: string;
|
|
12
|
+
/** The page in its format. */
|
|
13
|
+
content: string;
|
|
14
|
+
}
|
|
15
|
+
/** A page's file extension: the manual section for man, else the format's. */
|
|
16
|
+
export declare function extension(format: Format, section?: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* The documentation as a page per runnable command, git-style: `mytool.1` lists the commands and
|
|
19
|
+
* each has its own, e.g. `mytool-db-migrate.1`, with what it inherits. A program without commands
|
|
20
|
+
* is one page. Throws for input that isn't a clyops schema, or for two commands with one page name.
|
|
21
|
+
*/
|
|
22
|
+
export declare function pages(schema: Schema, format?: Format, opts?: DocOptions): Page[];
|
|
8
23
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAE5B,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAE5B,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,EAA0C,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AACrF,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,aAAa,CAAC;AAElD,uGAAuG;AACvG,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,GAAE,MAAc,EAAE,IAAI,GAAE,UAAe,GAAG,MAAM,CAE9F;AAED,oCAAoC;AACpC,MAAM,WAAW,IAAI;IACnB,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb,8BAA8B;IAC9B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,8EAA8E;AAC9E,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,SAAM,GAAG,MAAM,CAE/D;AAED;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,GAAE,MAAc,EAAE,IAAI,GAAE,UAAe,GAAG,IAAI,EAAE,CAgB3F"}
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,37 @@
|
|
|
1
1
|
// clyops-doc: documentation (man, Markdown, HTML) from a clyops tool's --help-json-schema.
|
|
2
2
|
export * from './model.js';
|
|
3
3
|
export * from './render.js';
|
|
4
|
-
import { buildDoc } from './model.js';
|
|
4
|
+
import { buildDoc, leafSchema, leaves, pageName } from './model.js';
|
|
5
5
|
import { render } from './render.js';
|
|
6
6
|
/** A schema's documentation in `format` (default man). Throws for input that isn't a clyops schema. */
|
|
7
7
|
export function document(schema, format = 'man', opts = {}) {
|
|
8
8
|
return render(buildDoc(schema, opts), format);
|
|
9
9
|
}
|
|
10
|
+
/** A page's file extension: the manual section for man, else the format's. */
|
|
11
|
+
export function extension(format, section = '1') {
|
|
12
|
+
return format === 'man' ? section : format === 'markdown' ? 'md' : 'html';
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The documentation as a page per runnable command, git-style: `mytool.1` lists the commands and
|
|
16
|
+
* each has its own, e.g. `mytool-db-migrate.1`, with what it inherits. A program without commands
|
|
17
|
+
* is one page. Throws for input that isn't a clyops schema, or for two commands with one page name.
|
|
18
|
+
*/
|
|
19
|
+
export function pages(schema, format = 'man', opts = {}) {
|
|
20
|
+
const name = opts.name || schema?.script || '';
|
|
21
|
+
const top = buildDoc(schema, { ...opts, split: true, page: pageName(name) });
|
|
22
|
+
const ext = extension(format, top.section);
|
|
23
|
+
const out = [{ file: `${top.page}.${ext}`, content: render(top, format) }];
|
|
24
|
+
const all = leaves(schema);
|
|
25
|
+
if (all.length === 1 && all[0].words.length === 0)
|
|
26
|
+
return out;
|
|
27
|
+
for (const leaf of all) {
|
|
28
|
+
const page = pageName(name, leaf.words);
|
|
29
|
+
const doc = buildDoc(leafSchema(schema, leaf), { ...opts, split: false, name: [name, ...leaf.words].join(' '), page });
|
|
30
|
+
doc.sections.push({ title: 'SEE ALSO', blocks: [{ kind: 'refs', items: [{ page: pageName(name), section: top.section }] }] });
|
|
31
|
+
const file = `${page}.${ext}`;
|
|
32
|
+
if (out.some((p) => p.file === file))
|
|
33
|
+
throw new Error(`two pages would be named ${file}`);
|
|
34
|
+
out.push({ file, content: render(doc, format) });
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
package/dist/model.d.ts
CHANGED
|
@@ -15,6 +15,12 @@ export type Block = {
|
|
|
15
15
|
} | {
|
|
16
16
|
kind: 'h';
|
|
17
17
|
text: string;
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'refs';
|
|
20
|
+
items: {
|
|
21
|
+
page: string;
|
|
22
|
+
section: string;
|
|
23
|
+
}[];
|
|
18
24
|
};
|
|
19
25
|
/** A top-level section, e.g. OPTIONS. */
|
|
20
26
|
export interface Section {
|
|
@@ -27,6 +33,8 @@ export interface Section {
|
|
|
27
33
|
export interface Doc {
|
|
28
34
|
/** How the program is run, e.g. `mytool` or `mytool media to-pcm`. */
|
|
29
35
|
name: string;
|
|
36
|
+
/** The page's name in the header and NAME line: `name`, or a split page's, e.g. `mytool-db-migrate`. */
|
|
37
|
+
page: string;
|
|
30
38
|
/** Manual section, e.g. `1`. */
|
|
31
39
|
section: string;
|
|
32
40
|
/** Version shown in the header, or empty. */
|
|
@@ -48,6 +56,10 @@ export interface DocOptions {
|
|
|
48
56
|
version?: string;
|
|
49
57
|
/** Date for the header, YYYY-MM-DD (default: today). */
|
|
50
58
|
date?: string;
|
|
59
|
+
/** The page's name (default: `name`); see `Doc.page`. */
|
|
60
|
+
page?: string;
|
|
61
|
+
/** Commands get pages of their own (see `pages`): list them, with a SEE ALSO, instead of documenting each. */
|
|
62
|
+
split?: boolean;
|
|
51
63
|
}
|
|
52
64
|
/** A runnable level: a program without commands, or a leaf command, with what it inherits. */
|
|
53
65
|
export interface Leaf {
|
|
@@ -55,6 +67,8 @@ export interface Leaf {
|
|
|
55
67
|
words: string[];
|
|
56
68
|
/** The level's own description. */
|
|
57
69
|
description: string;
|
|
70
|
+
/** Text after its help, e.g. examples. */
|
|
71
|
+
epilog: string;
|
|
58
72
|
/** Positional arguments. */
|
|
59
73
|
arguments: SchemaArgument[];
|
|
60
74
|
/** Its own options, then those it inherits, nearest first. */
|
|
@@ -96,4 +110,20 @@ export declare function defaultDate(env?: NodeJS.ProcessEnv): string;
|
|
|
96
110
|
* description, options and arguments).
|
|
97
111
|
*/
|
|
98
112
|
export declare function buildDoc(schema: Schema, opts?: DocOptions): Doc;
|
|
113
|
+
/** A page's name: the invocation's words joined by hyphens, e.g. `mytool-db-migrate`. */
|
|
114
|
+
export declare function pageName(name: string, words?: string[]): string;
|
|
115
|
+
/** A runnable command as a program of its own, with everything it inherits, for a page of its own. */
|
|
116
|
+
export declare function leafSchema(schema: Schema, leaf: Leaf): Schema;
|
|
117
|
+
/** One tool of a toolset, for its index page (see `toolsetDoc`). */
|
|
118
|
+
export interface ToolEntry {
|
|
119
|
+
/** The words after the toolset's name, e.g. ['media', 'to-pcm']. */
|
|
120
|
+
words: string[];
|
|
121
|
+
/** The tool's description. */
|
|
122
|
+
description: string;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The index page of a directory of tools (`--root`): what the dispatcher `name` runs, each tool with
|
|
126
|
+
* a reference to its own page.
|
|
127
|
+
*/
|
|
128
|
+
export declare function toolsetDoc(name: string, description: string, tools: ToolEntry[], opts?: DocOptions): Doc;
|
|
99
129
|
//# sourceMappingURL=model.d.ts.map
|
package/dist/model.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,cAAc,
|
|
1
|
+
{"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,cAAc,EAA4B,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAE9H,mGAAmG;AACnG,MAAM,MAAM,KAAK,GACb;IAAoC,IAAI,EAAE,GAAG,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC7D;IAAkD,IAAI,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC7E;IAAqD,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAAE,GAC1G;IAAqD,IAAI,EAAE,GAAG,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9E;IAA4F,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAAE,CAAC;AAE3J,yCAAyC;AACzC,MAAM,WAAW,OAAO;IACtB,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAC;IACd,uCAAuC;IACvC,MAAM,EAAE,KAAK,EAAE,CAAC;CACjB;AAED,6DAA6D;AAC7D,MAAM,WAAW,GAAG;IAClB,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,wGAAwG;IACxG,IAAI,EAAE,MAAM,CAAC;IACb,gCAAgC;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,IAAI,EAAE,MAAM,CAAC;IACb,mDAAmD;IACnD,OAAO,EAAE,MAAM,CAAC;IAChB,8BAA8B;IAC9B,QAAQ,EAAE,OAAO,EAAE,CAAC;CACrB;AAED,uDAAuD;AACvD,MAAM,WAAW,UAAU;IACzB,+DAA+D;IAC/D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oCAAoC;IACpC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wDAAwD;IACxD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8GAA8G;IAC9G,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,8FAA8F;AAC9F,MAAM,WAAW,IAAI;IACnB,+FAA+F;IAC/F,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,mCAAmC;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,0CAA0C;IAC1C,MAAM,EAAE,MAAM,CAAC;IACf,4BAA4B;IAC5B,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,8DAA8D;IAC9D,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,oCAAoC;IACpC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,kCAAkC;IAClC,KAAK,EAAE,YAAY,GAAG,IAAI,CAAC;IAC3B,mCAAmC;IACnC,MAAM,EAAE,YAAY,GAAG,IAAI,CAAC;IAC5B,iFAAiF;IACjF,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,kDAAkD;IAClD,WAAW,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,EAAE,CAAC;IACnD,+CAA+C;IAC/C,gBAAgB,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACnF;AAED,kGAAkG;AAClG,wBAAgB,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,CAiC7C;AAED,0DAA0D;AAC1D,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,GAAG,MAAM,CAIzD;AAED,uEAAuE;AACvE,wBAAgB,WAAW,CAAC,CAAC,EAAE,YAAY,GAAG,MAAM,CAGnD;AAED,qFAAqF;AACrF,wBAAgB,UAAU,CAAC,CAAC,EAAE,YAAY,GAAG,MAAM,CASlD;AAkDD,uFAAuF;AACvF,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIlD;AAED,yEAAyE;AACzE,wBAAgB,WAAW,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAIxE;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,GAAE,UAAe,GAAG,GAAG,CAkFnE;AAqBD,yFAAyF;AACzF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,GAAE,MAAM,EAAO,GAAG,MAAM,CAEnE;AAED,sGAAsG;AACtG,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,GAAG,MAAM,CAQ7D;AAED,oEAAoE;AACpE,MAAM,WAAW,SAAS;IACxB,oEAAoE;IACpE,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,8BAA8B;IAC9B,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE,IAAI,GAAE,UAAe,GAAG,GAAG,CAe5G"}
|
package/dist/model.js
CHANGED
|
@@ -26,6 +26,7 @@ export function leaves(schema) {
|
|
|
26
26
|
out.push({
|
|
27
27
|
words,
|
|
28
28
|
description: node.description,
|
|
29
|
+
epilog: node.epilog,
|
|
29
30
|
arguments: node.arguments,
|
|
30
31
|
options: [...chain].reverse().flatMap((s) => s.options),
|
|
31
32
|
effects: nearest((s) => s.effects, (v) => v.length === 0) ?? [],
|
|
@@ -59,7 +60,7 @@ export function optionText(o) {
|
|
|
59
60
|
if (o.isArray)
|
|
60
61
|
notes.push('repeatable');
|
|
61
62
|
if (o.secret)
|
|
62
|
-
notes.push('secret:
|
|
63
|
+
notes.push('secret: set it in the environment, never on the command line');
|
|
63
64
|
if (o.default && !o.isFlag)
|
|
64
65
|
notes.push(`default: ${o.default}`);
|
|
65
66
|
if (o.validation)
|
|
@@ -100,7 +101,9 @@ function leafParts(leaf) {
|
|
|
100
101
|
...leaf.roles.map((r) => ({ term: `${r.direction === 'in' ? 'input' : 'output'} role ${r.name}`, text: stream(r) })),
|
|
101
102
|
];
|
|
102
103
|
const notes = [
|
|
103
|
-
|
|
104
|
+
leaf.effects.length
|
|
105
|
+
? `Effects: ${leaf.effects.map((e) => `${e} (${EFFECTS[e] ?? e})`).join(', ')}.`
|
|
106
|
+
: 'Effects: not declared, so treat it as possibly destructive.',
|
|
104
107
|
...leaf.constraints.map((c) => CONSTRAINT[c.type]?.(c.options) ?? ''),
|
|
105
108
|
...leaf.requiredCommands.map((r) => `Needs ${r.command} on PATH: ${r.description}${r.installHint ? ` (${r.installHint})` : ''}.`),
|
|
106
109
|
].filter(Boolean);
|
|
@@ -136,7 +139,8 @@ export function buildDoc(schema, opts = {}) {
|
|
|
136
139
|
const program = all.length === 1 && all[0].words.length === 0;
|
|
137
140
|
const summary = firstSentence(schema.description || '');
|
|
138
141
|
const sections = [];
|
|
139
|
-
|
|
142
|
+
const page = opts.page || name;
|
|
143
|
+
sections.push({ title: 'NAME', blocks: [{ kind: 'p', text: summary ? `${page} - ${summary}` : page }] });
|
|
140
144
|
sections.push({ title: 'SYNOPSIS', blocks: [{ kind: 'pre', text: all.map((l) => synopsis(name, l)).join('\n') }] });
|
|
141
145
|
if (schema.description)
|
|
142
146
|
sections.push({ title: 'DESCRIPTION', blocks: schema.description.split(/\n{2,}/).map((text) => ({ kind: 'p', text })) });
|
|
@@ -151,6 +155,16 @@ export function buildDoc(schema, opts = {}) {
|
|
|
151
155
|
if (notes.length)
|
|
152
156
|
sections.push({ title: 'NOTES', blocks: notes });
|
|
153
157
|
}
|
|
158
|
+
else if (opts.split) {
|
|
159
|
+
// The commands have pages of their own: the program's options here, and a list of the commands.
|
|
160
|
+
const own = schema.options.filter((o) => o.name !== 'help').map((o) => ({ term: optionLabel(o), text: optionText(o) }));
|
|
161
|
+
if (own.length)
|
|
162
|
+
sections.push({ title: 'OPTIONS', blocks: [{ kind: 'p', text: 'Accepted by every command.' }, { kind: 'dl', items: own }] });
|
|
163
|
+
sections.push({
|
|
164
|
+
title: 'COMMANDS',
|
|
165
|
+
blocks: [{ kind: 'dl', items: all.map((l) => ({ term: [name, ...l.words].join(' '), text: `${sentence(l.description)}See ${pageName(name, l.words)}(${opts.section || '1'}).` })) }],
|
|
166
|
+
});
|
|
167
|
+
}
|
|
154
168
|
else {
|
|
155
169
|
// One subsection per runnable command, with what it inherits.
|
|
156
170
|
const blocks = [];
|
|
@@ -170,15 +184,17 @@ export function buildDoc(schema, opts = {}) {
|
|
|
170
184
|
}
|
|
171
185
|
// Every option's variable, once, in the order first seen.
|
|
172
186
|
const env = new Map();
|
|
173
|
-
for (const leaf of all)
|
|
187
|
+
for (const leaf of opts.split && !program ? [{ options: schema.options }] : all)
|
|
174
188
|
for (const o of leaf.options)
|
|
175
189
|
if (o.name !== 'help' && !env.has(o.variableName))
|
|
176
190
|
env.set(o.variableName, `Sets --${o.name}.${o.secret ? ' A secret: set it here rather than on the command line.' : ''}`);
|
|
177
|
-
const anyRoles = all.some((l) => l.roles.length);
|
|
191
|
+
const anyRoles = opts.split && !program ? Boolean(schema.roles?.length) : all.some((l) => l.roles.length);
|
|
178
192
|
sections.push({
|
|
179
193
|
title: 'ENVIRONMENT',
|
|
180
194
|
blocks: [
|
|
181
|
-
{ kind: 'p', text:
|
|
195
|
+
{ kind: 'p', text: schema.config
|
|
196
|
+
? 'An option given on the command line takes precedence over the config file, which takes precedence over the environment, which takes precedence over the default.'
|
|
197
|
+
: 'An option given on the command line takes precedence over the environment, which takes precedence over the default.' },
|
|
182
198
|
{
|
|
183
199
|
kind: 'dl',
|
|
184
200
|
items: [
|
|
@@ -191,6 +207,8 @@ export function buildDoc(schema, opts = {}) {
|
|
|
191
207
|
},
|
|
192
208
|
],
|
|
193
209
|
});
|
|
210
|
+
if (schema.config)
|
|
211
|
+
sections.push({ title: 'FILES', blocks: files(schema.config, schema.options) });
|
|
194
212
|
sections.push({
|
|
195
213
|
title: 'EXIT STATUS',
|
|
196
214
|
blocks: [{
|
|
@@ -204,5 +222,59 @@ export function buildDoc(schema, opts = {}) {
|
|
|
204
222
|
});
|
|
205
223
|
if (schema.epilog)
|
|
206
224
|
sections.push({ title: 'EXAMPLES', blocks: [{ kind: 'pre', text: schema.epilog.replace(/\n+$/, '') }] });
|
|
207
|
-
|
|
225
|
+
if (opts.split && !program)
|
|
226
|
+
sections.push({ title: 'SEE ALSO', blocks: [{ kind: 'refs', items: all.map((l) => ({ page: pageName(name, l.words), section: opts.section || '1' })) }] });
|
|
227
|
+
return { name, page, section: opts.section || '1', version: opts.version || '', date: opts.date || defaultDate(), summary, sections };
|
|
228
|
+
}
|
|
229
|
+
// The config file (spec section 4): where it comes from and what a line looks like.
|
|
230
|
+
function files(config, options) {
|
|
231
|
+
const opt = options.find((o) => o.name === config.option);
|
|
232
|
+
const from = [`--${config.option}`, ...(opt?.variableName ? [opt.variableName] : [])].join(' or ');
|
|
233
|
+
const where = opt?.default ? `, ${opt.default} by default` : '';
|
|
234
|
+
const prefixes = config.prefixes.length ? `, on lines starting with ${config.prefixes.join(' or ')}` : '';
|
|
235
|
+
const example = options.find((o) => o.name !== config.option && o.name !== 'help' && !o.isFlag) ?? options.find((o) => o.name !== config.option && o.name !== 'help');
|
|
236
|
+
return [
|
|
237
|
+
{ kind: 'p', text: `The config file is the one named by ${from}${where}, resolved against the current directory. An empty value or disabled skips it; a file that doesn't exist is an error.` },
|
|
238
|
+
{ kind: 'p', text: `It sets options with one key=value per line${prefixes}. The key is an option's long name, with or without the leading --. Blank lines and lines starting with # are ignored, and @include PATH reads another file in place, relative to the one including it. Later lines override earlier ones, and an option given on the command line wins over the file.` },
|
|
239
|
+
...(example ? [{ kind: 'pre', text: `${config.prefixes[0] ?? ''}${example.name}=${example.isFlag ? 'true' : example.default || 'VALUE'}` }] : []),
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
// Text as a sentence followed by a space, ending in a full stop unless it has its own; nothing for none.
|
|
243
|
+
function sentence(text) {
|
|
244
|
+
return text ? `${text}${/[.!?]$/.test(text) ? '' : '.'} ` : '';
|
|
245
|
+
}
|
|
246
|
+
/** A page's name: the invocation's words joined by hyphens, e.g. `mytool-db-migrate`. */
|
|
247
|
+
export function pageName(name, words = []) {
|
|
248
|
+
return [name, ...words].join(' ').trim().split(/\s+/).join('-');
|
|
249
|
+
}
|
|
250
|
+
/** A runnable command as a program of its own, with everything it inherits, for a page of its own. */
|
|
251
|
+
export function leafSchema(schema, leaf) {
|
|
252
|
+
return {
|
|
253
|
+
clyops: 1, script: schema.script, description: leaf.description, epilog: leaf.epilog,
|
|
254
|
+
arguments: leaf.arguments, options: leaf.options, config: schema.config ?? null,
|
|
255
|
+
requiredCommands: leaf.requiredCommands, effects: leaf.effects,
|
|
256
|
+
constraints: leaf.constraints, stdin: leaf.stdin, stdout: leaf.stdout,
|
|
257
|
+
roles: leaf.roles, commands: [],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The index page of a directory of tools (`--root`): what the dispatcher `name` runs, each tool with
|
|
262
|
+
* a reference to its own page.
|
|
263
|
+
*/
|
|
264
|
+
export function toolsetDoc(name, description, tools, opts = {}) {
|
|
265
|
+
const section = opts.section || '1';
|
|
266
|
+
const page = opts.page || pageName(name);
|
|
267
|
+
const summary = firstSentence(description);
|
|
268
|
+
const sections = [
|
|
269
|
+
{ title: 'NAME', blocks: [{ kind: 'p', text: summary ? `${page} - ${summary}` : page }] },
|
|
270
|
+
{ title: 'SYNOPSIS', blocks: [{ kind: 'pre', text: tools.map((t) => `${[name, ...t.words].join(' ')} [ARGUMENTS] [OPTIONS]`).join('\n') }] },
|
|
271
|
+
];
|
|
272
|
+
if (description)
|
|
273
|
+
sections.push({ title: 'DESCRIPTION', blocks: description.split(/\n{2,}/).map((text) => ({ kind: 'p', text })) });
|
|
274
|
+
sections.push({
|
|
275
|
+
title: 'TOOLS',
|
|
276
|
+
blocks: [{ kind: 'dl', items: tools.map((t) => ({ term: [name, ...t.words].join(' '), text: `${sentence(firstSentence(t.description))}See ${pageName(name, t.words)}(${section}).` })) }],
|
|
277
|
+
});
|
|
278
|
+
sections.push({ title: 'SEE ALSO', blocks: [{ kind: 'refs', items: tools.map((t) => ({ page: pageName(name, t.words), section })) }] });
|
|
279
|
+
return { name, page, section, version: opts.version || '', date: opts.date || defaultDate(), summary, sections };
|
|
208
280
|
}
|
package/dist/render.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export declare function renderMan(doc: Doc): string;
|
|
|
10
10
|
* else, so the Markdown stays readable (`DRY_RUN`, `(default: 1)`).
|
|
11
11
|
*/
|
|
12
12
|
export declare function markdownText(text: string): string;
|
|
13
|
+
/** The first line of a Markdown page: a comment, invisible when rendered, saying it is generated. */
|
|
14
|
+
export declare const MARKDOWN_MARKER = "<!-- Generated by clyops-doc from --help-json-schema. Do not edit; regenerate instead. -->";
|
|
13
15
|
/** The document as Markdown. */
|
|
14
16
|
export declare function renderMarkdown(doc: Doc): string;
|
|
15
17
|
/** Text with HTML's special characters escaped. */
|
package/dist/render.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAS,GAAG,EAAE,MAAM,YAAY,CAAC;AAE7C,sBAAsB;AACtB,MAAM,MAAM,MAAM,GAAG,KAAK,GAAG,UAAU,GAAG,MAAM,CAAC;AAMjD,gGAAgG;AAChG,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQ1C;
|
|
1
|
+
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAS,GAAG,EAAE,MAAM,YAAY,CAAC;AAE7C,sBAAsB;AACtB,MAAM,MAAM,MAAM,GAAG,KAAK,GAAG,UAAU,GAAG,MAAM,CAAC;AAMjD,gGAAgG;AAChG,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQ1C;AAsBD,qCAAqC;AACrC,wBAAgB,SAAS,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAgB1C;AAMD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAWjD;AA8BD,qGAAqG;AACrG,eAAO,MAAM,eAAe,+FAA+F,CAAC;AAE5H,gCAAgC;AAChC,wBAAgB,cAAc,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAS/C;AAMD,mDAAmD;AACnD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AA2BD,8CAA8C;AAC9C,wBAAgB,UAAU,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAsB3C;AAED,gCAAgC;AAChC,wBAAgB,MAAM,CAAC,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEvD"}
|
package/dist/render.js
CHANGED
|
@@ -25,11 +25,13 @@ function manBlock(b) {
|
|
|
25
25
|
return ['.PP', '.nf', '.RS 4', troff(b.text), '.RE', '.fi'];
|
|
26
26
|
case 'dl':
|
|
27
27
|
return b.items.flatMap((i) => ['.TP', `\\fB${troff(i.term)}\\fR`, troff(i.text)]);
|
|
28
|
+
case 'refs':
|
|
29
|
+
return ['.PP', ...b.items.map((r, n) => `.BR ${arg(r.page)} ${arg(`(${r.section})${n < b.items.length - 1 ? ',' : ''}`)}`)];
|
|
28
30
|
}
|
|
29
31
|
}
|
|
30
32
|
/** The document as a man(7) page. */
|
|
31
33
|
export function renderMan(doc) {
|
|
32
|
-
const title = doc.
|
|
34
|
+
const title = doc.page.toUpperCase().replace(/\s+/g, '-');
|
|
33
35
|
const lines = [
|
|
34
36
|
`.\\" Generated by clyops-doc from ${doc.name} --help-json-schema.`,
|
|
35
37
|
`.TH ${arg(title)} ${arg(doc.section)} ${arg(doc.date)} ${arg([doc.name, doc.version].filter(Boolean).join(' '))} ${arg('User Commands')}`,
|
|
@@ -38,7 +40,7 @@ export function renderMan(doc) {
|
|
|
38
40
|
lines.push(`.SH ${arg(s.title)}`);
|
|
39
41
|
// NAME is one line, "name \- summary", without a paragraph macro (man(7)).
|
|
40
42
|
if (s.title === 'NAME') {
|
|
41
|
-
lines.push(troff(`${doc.
|
|
43
|
+
lines.push(troff(`${doc.page}${doc.summary ? ` - ${doc.summary}` : ''}`));
|
|
42
44
|
continue;
|
|
43
45
|
}
|
|
44
46
|
for (const b of s.blocks)
|
|
@@ -85,11 +87,15 @@ function markdownBlock(b) {
|
|
|
85
87
|
return [...fenced(b.text), ''];
|
|
86
88
|
case 'dl':
|
|
87
89
|
return [...b.items.map((i) => `- ${code(i.term)}: ${markdownText(i.text)}`), ''];
|
|
90
|
+
case 'refs':
|
|
91
|
+
return [b.items.map((r) => `[${markdownText(`${r.page}(${r.section})`)}](${encodeURI(r.page)}.md)`).join(', '), ''];
|
|
88
92
|
}
|
|
89
93
|
}
|
|
94
|
+
/** The first line of a Markdown page: a comment, invisible when rendered, saying it is generated. */
|
|
95
|
+
export const MARKDOWN_MARKER = '<!-- Generated by clyops-doc from --help-json-schema. Do not edit; regenerate instead. -->';
|
|
90
96
|
/** The document as Markdown. */
|
|
91
97
|
export function renderMarkdown(doc) {
|
|
92
|
-
const lines = [`# ${code(doc.name)}`, ''];
|
|
98
|
+
const lines = [MARKDOWN_MARKER, '', `# ${code(doc.name)}`, ''];
|
|
93
99
|
const header = [doc.version && `Version ${doc.version}`, doc.date].filter(Boolean).join(' · ');
|
|
94
100
|
if (header)
|
|
95
101
|
lines.push(`_${markdownText(header)}_`, '');
|
|
@@ -118,6 +124,8 @@ function htmlBlock(b) {
|
|
|
118
124
|
return `<pre><code>${htmlText(b.text)}</code></pre>`;
|
|
119
125
|
case 'dl':
|
|
120
126
|
return `<dl>\n${b.items.map((i) => `<dt><code>${htmlText(i.term)}</code></dt>\n<dd>${htmlText(i.text)}</dd>`).join('\n')}\n</dl>`;
|
|
127
|
+
case 'refs':
|
|
128
|
+
return `<p>${b.items.map((r) => `<a href="${htmlText(encodeURI(r.page))}.html"><code>${htmlText(r.page)}</code>(${htmlText(r.section)})</a>`).join(', ')}</p>`;
|
|
121
129
|
}
|
|
122
130
|
}
|
|
123
131
|
const STYLE = `body{font:16px/1.55 system-ui,sans-serif;max-width:46rem;margin:2rem auto;padding:0 1rem;color:#1d1f21;background:#fff}
|
|
@@ -139,7 +147,7 @@ export function renderHtml(doc) {
|
|
|
139
147
|
<meta charset="utf-8">
|
|
140
148
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
141
149
|
<meta name="generator" content="clyops-doc">
|
|
142
|
-
<title>${htmlText(doc.
|
|
150
|
+
<title>${htmlText(doc.page)}(${htmlText(doc.section)})</title>
|
|
143
151
|
<style>${STYLE}</style>
|
|
144
152
|
</head>
|
|
145
153
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clyops-doc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Documentation (man pages, Markdown, HTML) from any clyops tool's --help-json-schema.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,8 +36,9 @@
|
|
|
36
36
|
"src"
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"clyops": "0.
|
|
40
|
-
"clyops-schema": "0.
|
|
39
|
+
"clyops": "0.8.0",
|
|
40
|
+
"clyops-schema": "0.8.0",
|
|
41
|
+
"clyops-tools": "0.8.0"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"@types/node": "^22.0.0",
|
package/src/cli.ts
CHANGED
|
@@ -1,40 +1,142 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// clyops-doc: a clyops tool's --help-json-schema on stdin, as a man page, Markdown or HTML.
|
|
3
|
-
import { Cli, die } from 'clyops';
|
|
4
|
-
import {
|
|
3
|
+
import { Cli, die, info, warn } from 'clyops';
|
|
4
|
+
import { commands, discover, loadSchema } from 'clyops-tools';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { delimiter, join, resolve } from 'node:path';
|
|
5
8
|
import type { Schema } from 'clyops-schema';
|
|
6
|
-
import { document } from './index.js';
|
|
7
|
-
import { defaultDate } from './model.js';
|
|
8
|
-
import type
|
|
9
|
+
import { document, extension, pages, type Page } from './index.js';
|
|
10
|
+
import { defaultDate, firstSentence, pageName, toolsetDoc, type ToolEntry } from './model.js';
|
|
11
|
+
import { render, type Format } from './render.js';
|
|
9
12
|
|
|
10
13
|
const cli = new Cli({ name: 'clyops-doc' });
|
|
11
14
|
cli.setDescription('Render a clyops tool\'s --help-json-schema as documentation: a man page, Markdown or HTML. It reads the schema on stdin, so it works with any clyops tool, compiled ones included.');
|
|
12
|
-
cli.setEpilog('Examples:\n mytool --help-json-schema | clyops-doc > mytool.1\n mytool --help-json-schema | clyops-doc --format markdown > mytool.md\n clyops-doc --schema schema.json --name "mytool media to-pcm" --format html > to-pcm.html\n clyops-doc --help-json-schema | clyops-doc | man -l -');
|
|
13
|
-
|
|
14
|
-
cli.
|
|
15
|
-
cli.
|
|
15
|
+
cli.setEpilog('Examples:\n mytool --help-json-schema | clyops-doc > mytool.1\n mytool --help-json-schema | clyops-doc --format markdown > mytool.md\n clyops-doc --schema schema.json --name "mytool media to-pcm" --format html > to-pcm.html\n clyops-doc --help-json-schema | clyops-doc | man -l -\n mytool --help-json-schema | clyops-doc --split --out-dir man1 --execute\n clyops-doc --root ~/mytool/scripts --name mytool --prefix ~/.local --execute');
|
|
16
|
+
// It writes files only with --out-dir or --prefix and --execute, and then only its own pages.
|
|
17
|
+
cli.setEffects('idempotent', 'destructive');
|
|
18
|
+
cli.setStdin('The tool\'s --help-json-schema output (unless --schema or --root is given)', 'application/json');
|
|
19
|
+
// One declared type per program: what it writes is text/troff, text/markdown or text/html by --format.
|
|
20
|
+
cli.setStdout('The documentation as --format says: text/troff, text/markdown or text/html; with --out-dir or --prefix, the files written (or, without --execute, that would be), one per line', 'text/plain');
|
|
16
21
|
cli.opt('CLYOPS_DOC_FORMAT', 'format', 'f', 'man', 'Output format', 'Output', 'choice:man,markdown,html');
|
|
17
|
-
cli.opt('CLYOPS_DOC_NAME', 'name', 'n', 'optional', 'How the tool is run (default: the schema\'s name), e.g. "mytool media to-pcm"', 'Output');
|
|
22
|
+
cli.opt('CLYOPS_DOC_NAME', 'name', 'n', 'optional', 'How the tool is run (default: the schema\'s name), e.g. "mytool media to-pcm"; with --root, the dispatcher\'s name (default: the directory\'s)', 'Output');
|
|
18
23
|
cli.opt('CLYOPS_DOC_SECTION', 'section', 's', '1', 'Manual section for the man page header', 'Output', 'regex:^[1-9][a-z]*$');
|
|
19
24
|
cli.opt('CLYOPS_DOC_VERSION', 'version', 'V', 'optional', 'Version shown in the header', 'Output');
|
|
20
25
|
cli.opt('CLYOPS_DOC_DATE', 'date', 'd', 'optional', 'Date shown in the header (default: today, or SOURCE_DATE_EPOCH)', 'Output', 'date:YYYY-MM-DD');
|
|
21
26
|
cli.opt('CLYOPS_DOC_SCHEMA', 'schema', '', 'optional', 'Read the schema from this file instead of stdin', 'Input', 'file:readable');
|
|
27
|
+
cli.opt('CLYOPS_DOC_ROOT', 'root', 'r', 'optional', 'Document every tool in this directory (or dispatcher definition), found as clyops-dispatch, clyops-api and clyops-mcp find them, with an index page', 'Input', 'path');
|
|
28
|
+
cli.opt('CLYOPS_DOC_OUT_DIR', 'out-dir', 'o', 'optional', 'Write page files into this directory (created if missing) instead of to stdout', 'Files', 'path');
|
|
29
|
+
cli.opt('CLYOPS_DOC_PREFIX', 'prefix', 'p', 'optional', 'Install man pages into PREFIX/share/man/manSECTION and check that man finds them', 'Files', 'path');
|
|
30
|
+
cli.opt('CLYOPS_DOC_SPLIT', 'split', '', 'flag', 'A page per command, git-style: mytool.1 lists them, mytool-db-migrate.1 documents one', 'Files');
|
|
31
|
+
cli.opt('CLYOPS_DOC_EXECUTE', 'execute', 'x', 'flag', 'Write the files; without it, only list them', 'Files');
|
|
32
|
+
cli.exclusive('schema', 'root');
|
|
33
|
+
cli.exclusive('out-dir', 'prefix');
|
|
22
34
|
const args = cli.run();
|
|
23
35
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
36
|
+
const format = args.CLYOPS_DOC_FORMAT as Format;
|
|
37
|
+
const opts = {
|
|
38
|
+
name: (args.CLYOPS_DOC_NAME as string | null) ?? undefined,
|
|
39
|
+
section: args.CLYOPS_DOC_SECTION as string,
|
|
40
|
+
version: (args.CLYOPS_DOC_VERSION as string | null) ?? undefined,
|
|
41
|
+
date: (args.CLYOPS_DOC_DATE as string | null) ?? defaultDate(),
|
|
42
|
+
};
|
|
43
|
+
const root = args.CLYOPS_DOC_ROOT as string | null;
|
|
44
|
+
const prefix = args.CLYOPS_DOC_PREFIX as string | null;
|
|
45
|
+
const split = args.CLYOPS_DOC_SPLIT as boolean;
|
|
46
|
+
const execute = args.CLYOPS_DOC_EXECUTE as boolean;
|
|
47
|
+
const manDir = prefix === null ? null : join(prefix, 'share', 'man');
|
|
48
|
+
const outDir = (args.CLYOPS_DOC_OUT_DIR as string | null) ?? (manDir === null ? null : join(manDir, `man${opts.section}`));
|
|
49
|
+
if (prefix !== null && format !== 'man') die(1, '--prefix installs man pages; use --out-dir for --format %s', format);
|
|
50
|
+
if (outDir === null) {
|
|
51
|
+
// Several pages, or writing them, need somewhere to go.
|
|
52
|
+
const needs = root !== null ? 'root' : split ? 'split' : execute ? 'execute' : null;
|
|
53
|
+
if (needs) die(1, '--%s needs --out-dir or --prefix', needs);
|
|
30
54
|
}
|
|
55
|
+
|
|
31
56
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
57
|
+
if (root !== null) writePages(await toolsetPages(root));
|
|
58
|
+
else {
|
|
59
|
+
const schema = readSchema();
|
|
60
|
+
if (outDir === null) process.stdout.write(document(schema, format, opts));
|
|
61
|
+
else writePages(split ? pages(schema, format, opts) : [{ file: `${pageName(opts.name || schema.script)}.${extension(format, opts.section)}`, content: document(schema, format, opts) }]);
|
|
62
|
+
}
|
|
38
63
|
} catch (err) {
|
|
39
64
|
die(1, '%s', (err as Error).message);
|
|
40
65
|
}
|
|
66
|
+
|
|
67
|
+
function readSchema(): Schema {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(readFileSync((args.CLYOPS_DOC_SCHEMA as string | null) ?? 0, 'utf8')) as Schema;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return die(1, 'Could not read the schema: %s', (err as Error).message);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --root: a page (or, with --split, pages) per tool, and an index page named after the dispatcher.
|
|
76
|
+
async function toolsetPages(dir: string): Promise<Page[]> {
|
|
77
|
+
const group = discover(dir, { name: opts.name });
|
|
78
|
+
const ext = extension(format, opts.section);
|
|
79
|
+
const out: Page[] = [];
|
|
80
|
+
const tools: ToolEntry[] = [];
|
|
81
|
+
for (const cmd of commands(group)) {
|
|
82
|
+
if (cmd.kind !== 'tool') {
|
|
83
|
+
warn('Skipping %s: not a clyops tool', cmd.file);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
let schema: Schema;
|
|
87
|
+
try {
|
|
88
|
+
schema = await loadSchema(cmd.file);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
warn('Skipping %s', (err as Error).message);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const name = [group.name, ...cmd.words].join(' ');
|
|
94
|
+
out.push(...(split
|
|
95
|
+
? pages(schema, format, { ...opts, name })
|
|
96
|
+
: [{ file: `${pageName(name)}.${ext}`, content: document(schema, format, { ...opts, name, page: pageName(name) }) }]));
|
|
97
|
+
tools.push({ words: cmd.words, description: firstSentence(schema.description) });
|
|
98
|
+
}
|
|
99
|
+
if (!tools.length) throw new Error(`no clyops tools in ${dir}`);
|
|
100
|
+
const all = [{ file: `${pageName(group.name)}.${ext}`, content: render(toolsetDoc(group.name, group.description, tools, opts), format) }, ...out];
|
|
101
|
+
const seen = new Set<string>();
|
|
102
|
+
for (const p of all) {
|
|
103
|
+
if (seen.has(p.file)) throw new Error(`two pages would be named ${p.file}`);
|
|
104
|
+
seen.add(p.file);
|
|
105
|
+
}
|
|
106
|
+
return all;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Each page into the directory, leaving files that already say the same alone.
|
|
110
|
+
function writePages(all: Page[]): void {
|
|
111
|
+
const dir = outDir as string;
|
|
112
|
+
let replaced = 0;
|
|
113
|
+
let unchanged = 0;
|
|
114
|
+
if (execute) mkdirSync(dir, { recursive: true });
|
|
115
|
+
for (const page of all) {
|
|
116
|
+
const path = join(dir, page.file);
|
|
117
|
+
const before = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
118
|
+
if (before === page.content) unchanged++;
|
|
119
|
+
else {
|
|
120
|
+
if (before !== null) replaced++;
|
|
121
|
+
if (execute) writeFileSync(path, page.content);
|
|
122
|
+
}
|
|
123
|
+
process.stdout.write(`${path}\n`);
|
|
124
|
+
}
|
|
125
|
+
const counts = `${all.length - unchanged} page(s)${replaced ? ` (replacing ${replaced})` : ''}${unchanged ? `; ${unchanged} already up to date` : ''}`;
|
|
126
|
+
info(execute ? `Wrote ${counts} in ${dir}.` : `Would write ${counts} in ${dir}; add --execute to write them.`);
|
|
127
|
+
if (manDir !== null) checkManPath(manDir);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --prefix: say so when man won't look in PREFIX/share/man.
|
|
131
|
+
function checkManPath(dir: string): void {
|
|
132
|
+
let paths = process.env.MANPATH ?? '';
|
|
133
|
+
try {
|
|
134
|
+
paths = execFileSync('manpath', [], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).trim();
|
|
135
|
+
} catch {
|
|
136
|
+
// No manpath(1): MANPATH is all there is to go on.
|
|
137
|
+
}
|
|
138
|
+
const want = resolve(dir);
|
|
139
|
+
if (!paths.split(delimiter).some((p) => p && resolve(p) === want)) {
|
|
140
|
+
warn('man won\'t find these pages: %s is not on its search path. Add it with: export MANPATH="%s%s$(manpath)"', want, want, delimiter);
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,10 +3,46 @@ export * from './model.js';
|
|
|
3
3
|
export * from './render.js';
|
|
4
4
|
|
|
5
5
|
import type { Schema } from 'clyops-schema';
|
|
6
|
-
import { buildDoc, type DocOptions } from './model.js';
|
|
6
|
+
import { buildDoc, leafSchema, leaves, pageName, type DocOptions } from './model.js';
|
|
7
7
|
import { render, type Format } from './render.js';
|
|
8
8
|
|
|
9
9
|
/** A schema's documentation in `format` (default man). Throws for input that isn't a clyops schema. */
|
|
10
10
|
export function document(schema: Schema, format: Format = 'man', opts: DocOptions = {}): string {
|
|
11
11
|
return render(buildDoc(schema, opts), format);
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
/** One file of a split document. */
|
|
15
|
+
export interface Page {
|
|
16
|
+
/** File name, e.g. `mytool-db-migrate.1`, `mytool.md`. */
|
|
17
|
+
file: string;
|
|
18
|
+
/** The page in its format. */
|
|
19
|
+
content: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A page's file extension: the manual section for man, else the format's. */
|
|
23
|
+
export function extension(format: Format, section = '1'): string {
|
|
24
|
+
return format === 'man' ? section : format === 'markdown' ? 'md' : 'html';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The documentation as a page per runnable command, git-style: `mytool.1` lists the commands and
|
|
29
|
+
* each has its own, e.g. `mytool-db-migrate.1`, with what it inherits. A program without commands
|
|
30
|
+
* is one page. Throws for input that isn't a clyops schema, or for two commands with one page name.
|
|
31
|
+
*/
|
|
32
|
+
export function pages(schema: Schema, format: Format = 'man', opts: DocOptions = {}): Page[] {
|
|
33
|
+
const name = opts.name || (schema as Schema | null)?.script || '';
|
|
34
|
+
const top = buildDoc(schema, { ...opts, split: true, page: pageName(name) });
|
|
35
|
+
const ext = extension(format, top.section);
|
|
36
|
+
const out: Page[] = [{ file: `${top.page}.${ext}`, content: render(top, format) }];
|
|
37
|
+
const all = leaves(schema);
|
|
38
|
+
if (all.length === 1 && all[0].words.length === 0) return out;
|
|
39
|
+
for (const leaf of all) {
|
|
40
|
+
const page = pageName(name, leaf.words);
|
|
41
|
+
const doc = buildDoc(leafSchema(schema, leaf), { ...opts, split: false, name: [name, ...leaf.words].join(' '), page });
|
|
42
|
+
doc.sections.push({ title: 'SEE ALSO', blocks: [{ kind: 'refs', items: [{ page: pageName(name), section: top.section }] }] });
|
|
43
|
+
const file = `${page}.${ext}`;
|
|
44
|
+
if (out.some((p) => p.file === file)) throw new Error(`two pages would be named ${file}`);
|
|
45
|
+
out.push({ file, content: render(doc, format) });
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
package/src/model.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// A clyops schema (spec section 8) as a small document: sections of paragraphs, definition lists
|
|
2
2
|
// and preformatted text, which each output format renders.
|
|
3
3
|
import { describeRule } from 'clyops';
|
|
4
|
-
import type { Schema, SchemaArgument, SchemaBody, SchemaOption, SchemaRole, SchemaStream } from 'clyops-schema';
|
|
4
|
+
import type { Schema, SchemaArgument, SchemaBody, SchemaConfig, SchemaOption, SchemaRole, SchemaStream } from 'clyops-schema';
|
|
5
5
|
|
|
6
6
|
/** One piece of a section. Terms are option names, commands and the like: code in every format. */
|
|
7
7
|
export type Block =
|
|
8
8
|
| { /** A paragraph of plain text. */ kind: 'p'; text: string }
|
|
9
9
|
| { /** Text shown as is, in a fixed-width font. */ kind: 'pre'; text: string }
|
|
10
10
|
| { /** A list of terms, each with its description. */ kind: 'dl'; items: { term: string; text: string }[] }
|
|
11
|
-
| { /** A subsection heading, e.g. one per command. */ kind: 'h'; text: string }
|
|
11
|
+
| { /** A subsection heading, e.g. one per command. */ kind: 'h'; text: string }
|
|
12
|
+
| { /** Other pages, as SEE ALSO lists them: `page(section)`, linked in Markdown and HTML. */ kind: 'refs'; items: { page: string; section: string }[] };
|
|
12
13
|
|
|
13
14
|
/** A top-level section, e.g. OPTIONS. */
|
|
14
15
|
export interface Section {
|
|
@@ -22,6 +23,8 @@ export interface Section {
|
|
|
22
23
|
export interface Doc {
|
|
23
24
|
/** How the program is run, e.g. `mytool` or `mytool media to-pcm`. */
|
|
24
25
|
name: string;
|
|
26
|
+
/** The page's name in the header and NAME line: `name`, or a split page's, e.g. `mytool-db-migrate`. */
|
|
27
|
+
page: string;
|
|
25
28
|
/** Manual section, e.g. `1`. */
|
|
26
29
|
section: string;
|
|
27
30
|
/** Version shown in the header, or empty. */
|
|
@@ -44,6 +47,10 @@ export interface DocOptions {
|
|
|
44
47
|
version?: string;
|
|
45
48
|
/** Date for the header, YYYY-MM-DD (default: today). */
|
|
46
49
|
date?: string;
|
|
50
|
+
/** The page's name (default: `name`); see `Doc.page`. */
|
|
51
|
+
page?: string;
|
|
52
|
+
/** Commands get pages of their own (see `pages`): list them, with a SEE ALSO, instead of documenting each. */
|
|
53
|
+
split?: boolean;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
/** A runnable level: a program without commands, or a leaf command, with what it inherits. */
|
|
@@ -52,6 +59,8 @@ export interface Leaf {
|
|
|
52
59
|
words: string[];
|
|
53
60
|
/** The level's own description. */
|
|
54
61
|
description: string;
|
|
62
|
+
/** Text after its help, e.g. examples. */
|
|
63
|
+
epilog: string;
|
|
55
64
|
/** Positional arguments. */
|
|
56
65
|
arguments: SchemaArgument[];
|
|
57
66
|
/** Its own options, then those it inherits, nearest first. */
|
|
@@ -91,6 +100,7 @@ export function leaves(schema: Schema): Leaf[] {
|
|
|
91
100
|
out.push({
|
|
92
101
|
words,
|
|
93
102
|
description: node.description,
|
|
103
|
+
epilog: node.epilog,
|
|
94
104
|
arguments: node.arguments,
|
|
95
105
|
options: [...chain].reverse().flatMap((s) => s.options),
|
|
96
106
|
effects: nearest((s) => s.effects, (v) => v.length === 0) ?? [],
|
|
@@ -123,7 +133,7 @@ export function optionText(o: SchemaOption): string {
|
|
|
123
133
|
const notes: string[] = [];
|
|
124
134
|
if (o.required) notes.push('required');
|
|
125
135
|
if (o.isArray) notes.push('repeatable');
|
|
126
|
-
if (o.secret) notes.push('secret:
|
|
136
|
+
if (o.secret) notes.push('secret: set it in the environment, never on the command line');
|
|
127
137
|
if (o.default && !o.isFlag) notes.push(`default: ${o.default}`);
|
|
128
138
|
if (o.validation) notes.push(`accepts: ${describeRule(o.validation)}`);
|
|
129
139
|
notes.push(`environment: ${o.variableName}`);
|
|
@@ -164,7 +174,9 @@ function leafParts(leaf: Leaf): { args?: Block; options?: Block; streams?: Block
|
|
|
164
174
|
...leaf.roles.map((r) => ({ term: `${r.direction === 'in' ? 'input' : 'output'} role ${r.name}`, text: stream(r) })),
|
|
165
175
|
];
|
|
166
176
|
const notes = [
|
|
167
|
-
|
|
177
|
+
leaf.effects.length
|
|
178
|
+
? `Effects: ${leaf.effects.map((e) => `${e} (${EFFECTS[e] ?? e})`).join(', ')}.`
|
|
179
|
+
: 'Effects: not declared, so treat it as possibly destructive.',
|
|
168
180
|
...leaf.constraints.map((c) => CONSTRAINT[c.type]?.(c.options) ?? ''),
|
|
169
181
|
...leaf.requiredCommands.map((r) => `Needs ${r.command} on PATH: ${r.description}${r.installHint ? ` (${r.installHint})` : ''}.`),
|
|
170
182
|
].filter(Boolean);
|
|
@@ -204,7 +216,8 @@ export function buildDoc(schema: Schema, opts: DocOptions = {}): Doc {
|
|
|
204
216
|
const summary = firstSentence(schema.description || '');
|
|
205
217
|
const sections: Section[] = [];
|
|
206
218
|
|
|
207
|
-
|
|
219
|
+
const page = opts.page || name;
|
|
220
|
+
sections.push({ title: 'NAME', blocks: [{ kind: 'p', text: summary ? `${page} - ${summary}` : page }] });
|
|
208
221
|
sections.push({ title: 'SYNOPSIS', blocks: [{ kind: 'pre', text: all.map((l) => synopsis(name, l)).join('\n') }] });
|
|
209
222
|
if (schema.description) sections.push({ title: 'DESCRIPTION', blocks: schema.description.split(/\n{2,}/).map((text) => ({ kind: 'p', text })) });
|
|
210
223
|
|
|
@@ -214,6 +227,14 @@ export function buildDoc(schema: Schema, opts: DocOptions = {}): Doc {
|
|
|
214
227
|
if (options) sections.push({ title: 'OPTIONS', blocks: [options] });
|
|
215
228
|
if (streams) sections.push({ title: 'STREAMS', blocks: [streams] });
|
|
216
229
|
if (notes.length) sections.push({ title: 'NOTES', blocks: notes });
|
|
230
|
+
} else if (opts.split) {
|
|
231
|
+
// The commands have pages of their own: the program's options here, and a list of the commands.
|
|
232
|
+
const own = schema.options.filter((o) => o.name !== 'help').map((o) => ({ term: optionLabel(o), text: optionText(o) }));
|
|
233
|
+
if (own.length) sections.push({ title: 'OPTIONS', blocks: [{ kind: 'p', text: 'Accepted by every command.' }, { kind: 'dl', items: own }] });
|
|
234
|
+
sections.push({
|
|
235
|
+
title: 'COMMANDS',
|
|
236
|
+
blocks: [{ kind: 'dl', items: all.map((l) => ({ term: [name, ...l.words].join(' '), text: `${sentence(l.description)}See ${pageName(name, l.words)}(${opts.section || '1'}).` })) }],
|
|
237
|
+
});
|
|
217
238
|
} else {
|
|
218
239
|
// One subsection per runnable command, with what it inherits.
|
|
219
240
|
const blocks: Block[] = [];
|
|
@@ -232,12 +253,14 @@ export function buildDoc(schema: Schema, opts: DocOptions = {}): Doc {
|
|
|
232
253
|
|
|
233
254
|
// Every option's variable, once, in the order first seen.
|
|
234
255
|
const env = new Map<string, string>();
|
|
235
|
-
for (const leaf of all) for (const o of leaf.options) if (o.name !== 'help' && !env.has(o.variableName)) env.set(o.variableName, `Sets --${o.name}.${o.secret ? ' A secret: set it here rather than on the command line.' : ''}`);
|
|
236
|
-
const anyRoles = all.some((l) => l.roles.length);
|
|
256
|
+
for (const leaf of opts.split && !program ? [{ options: schema.options }] : all) for (const o of leaf.options) if (o.name !== 'help' && !env.has(o.variableName)) env.set(o.variableName, `Sets --${o.name}.${o.secret ? ' A secret: set it here rather than on the command line.' : ''}`);
|
|
257
|
+
const anyRoles = opts.split && !program ? Boolean(schema.roles?.length) : all.some((l) => l.roles.length);
|
|
237
258
|
sections.push({
|
|
238
259
|
title: 'ENVIRONMENT',
|
|
239
260
|
blocks: [
|
|
240
|
-
{ kind: 'p', text:
|
|
261
|
+
{ kind: 'p', text: schema.config
|
|
262
|
+
? 'An option given on the command line takes precedence over the config file, which takes precedence over the environment, which takes precedence over the default.'
|
|
263
|
+
: 'An option given on the command line takes precedence over the environment, which takes precedence over the default.' },
|
|
241
264
|
{
|
|
242
265
|
kind: 'dl',
|
|
243
266
|
items: [
|
|
@@ -250,6 +273,7 @@ export function buildDoc(schema: Schema, opts: DocOptions = {}): Doc {
|
|
|
250
273
|
},
|
|
251
274
|
],
|
|
252
275
|
});
|
|
276
|
+
if (schema.config) sections.push({ title: 'FILES', blocks: files(schema.config, schema.options) });
|
|
253
277
|
sections.push({
|
|
254
278
|
title: 'EXIT STATUS',
|
|
255
279
|
blocks: [{
|
|
@@ -262,5 +286,70 @@ export function buildDoc(schema: Schema, opts: DocOptions = {}): Doc {
|
|
|
262
286
|
}],
|
|
263
287
|
});
|
|
264
288
|
if (schema.epilog) sections.push({ title: 'EXAMPLES', blocks: [{ kind: 'pre', text: schema.epilog.replace(/\n+$/, '') }] });
|
|
265
|
-
|
|
289
|
+
if (opts.split && !program) sections.push({ title: 'SEE ALSO', blocks: [{ kind: 'refs', items: all.map((l) => ({ page: pageName(name, l.words), section: opts.section || '1' })) }] });
|
|
290
|
+
return { name, page, section: opts.section || '1', version: opts.version || '', date: opts.date || defaultDate(), summary, sections };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// The config file (spec section 4): where it comes from and what a line looks like.
|
|
294
|
+
function files(config: SchemaConfig, options: SchemaOption[]): Block[] {
|
|
295
|
+
const opt = options.find((o) => o.name === config.option);
|
|
296
|
+
const from = [`--${config.option}`, ...(opt?.variableName ? [opt.variableName] : [])].join(' or ');
|
|
297
|
+
const where = opt?.default ? `, ${opt.default} by default` : '';
|
|
298
|
+
const prefixes = config.prefixes.length ? `, on lines starting with ${config.prefixes.join(' or ')}` : '';
|
|
299
|
+
const example = options.find((o) => o.name !== config.option && o.name !== 'help' && !o.isFlag) ?? options.find((o) => o.name !== config.option && o.name !== 'help');
|
|
300
|
+
return [
|
|
301
|
+
{ kind: 'p', text: `The config file is the one named by ${from}${where}, resolved against the current directory. An empty value or disabled skips it; a file that doesn't exist is an error.` },
|
|
302
|
+
{ kind: 'p', text: `It sets options with one key=value per line${prefixes}. The key is an option's long name, with or without the leading --. Blank lines and lines starting with # are ignored, and @include PATH reads another file in place, relative to the one including it. Later lines override earlier ones, and an option given on the command line wins over the file.` },
|
|
303
|
+
...(example ? [{ kind: 'pre' as const, text: `${config.prefixes[0] ?? ''}${example.name}=${example.isFlag ? 'true' : example.default || 'VALUE'}` }] : []),
|
|
304
|
+
];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Text as a sentence followed by a space, ending in a full stop unless it has its own; nothing for none.
|
|
308
|
+
function sentence(text: string): string {
|
|
309
|
+
return text ? `${text}${/[.!?]$/.test(text) ? '' : '.'} ` : '';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** A page's name: the invocation's words joined by hyphens, e.g. `mytool-db-migrate`. */
|
|
313
|
+
export function pageName(name: string, words: string[] = []): string {
|
|
314
|
+
return [name, ...words].join(' ').trim().split(/\s+/).join('-');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** A runnable command as a program of its own, with everything it inherits, for a page of its own. */
|
|
318
|
+
export function leafSchema(schema: Schema, leaf: Leaf): Schema {
|
|
319
|
+
return {
|
|
320
|
+
clyops: 1, script: schema.script, description: leaf.description, epilog: leaf.epilog,
|
|
321
|
+
arguments: leaf.arguments, options: leaf.options, config: schema.config ?? null,
|
|
322
|
+
requiredCommands: leaf.requiredCommands, effects: leaf.effects as Schema['effects'],
|
|
323
|
+
constraints: leaf.constraints as Schema['constraints'], stdin: leaf.stdin, stdout: leaf.stdout,
|
|
324
|
+
roles: leaf.roles, commands: [],
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** One tool of a toolset, for its index page (see `toolsetDoc`). */
|
|
329
|
+
export interface ToolEntry {
|
|
330
|
+
/** The words after the toolset's name, e.g. ['media', 'to-pcm']. */
|
|
331
|
+
words: string[];
|
|
332
|
+
/** The tool's description. */
|
|
333
|
+
description: string;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The index page of a directory of tools (`--root`): what the dispatcher `name` runs, each tool with
|
|
338
|
+
* a reference to its own page.
|
|
339
|
+
*/
|
|
340
|
+
export function toolsetDoc(name: string, description: string, tools: ToolEntry[], opts: DocOptions = {}): Doc {
|
|
341
|
+
const section = opts.section || '1';
|
|
342
|
+
const page = opts.page || pageName(name);
|
|
343
|
+
const summary = firstSentence(description);
|
|
344
|
+
const sections: Section[] = [
|
|
345
|
+
{ title: 'NAME', blocks: [{ kind: 'p', text: summary ? `${page} - ${summary}` : page }] },
|
|
346
|
+
{ title: 'SYNOPSIS', blocks: [{ kind: 'pre', text: tools.map((t) => `${[name, ...t.words].join(' ')} [ARGUMENTS] [OPTIONS]`).join('\n') }] },
|
|
347
|
+
];
|
|
348
|
+
if (description) sections.push({ title: 'DESCRIPTION', blocks: description.split(/\n{2,}/).map((text) => ({ kind: 'p', text })) });
|
|
349
|
+
sections.push({
|
|
350
|
+
title: 'TOOLS',
|
|
351
|
+
blocks: [{ kind: 'dl', items: tools.map((t) => ({ term: [name, ...t.words].join(' '), text: `${sentence(firstSentence(t.description))}See ${pageName(name, t.words)}(${section}).` })) }],
|
|
352
|
+
});
|
|
353
|
+
sections.push({ title: 'SEE ALSO', blocks: [{ kind: 'refs', items: tools.map((t) => ({ page: pageName(name, t.words), section })) }] });
|
|
354
|
+
return { name, page, section, version: opts.version || '', date: opts.date || defaultDate(), summary, sections };
|
|
266
355
|
}
|
package/src/render.ts
CHANGED
|
@@ -34,12 +34,14 @@ function manBlock(b: Block): string[] {
|
|
|
34
34
|
return ['.PP', '.nf', '.RS 4', troff(b.text), '.RE', '.fi'];
|
|
35
35
|
case 'dl':
|
|
36
36
|
return b.items.flatMap((i) => ['.TP', `\\fB${troff(i.term)}\\fR`, troff(i.text)]);
|
|
37
|
+
case 'refs':
|
|
38
|
+
return ['.PP', ...b.items.map((r, n) => `.BR ${arg(r.page)} ${arg(`(${r.section})${n < b.items.length - 1 ? ',' : ''}`)}`)];
|
|
37
39
|
}
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
/** The document as a man(7) page. */
|
|
41
43
|
export function renderMan(doc: Doc): string {
|
|
42
|
-
const title = doc.
|
|
44
|
+
const title = doc.page.toUpperCase().replace(/\s+/g, '-');
|
|
43
45
|
const lines = [
|
|
44
46
|
`.\\" Generated by clyops-doc from ${doc.name} --help-json-schema.`,
|
|
45
47
|
`.TH ${arg(title)} ${arg(doc.section)} ${arg(doc.date)} ${arg([doc.name, doc.version].filter(Boolean).join(' '))} ${arg('User Commands')}`,
|
|
@@ -48,7 +50,7 @@ export function renderMan(doc: Doc): string {
|
|
|
48
50
|
lines.push(`.SH ${arg(s.title)}`);
|
|
49
51
|
// NAME is one line, "name \- summary", without a paragraph macro (man(7)).
|
|
50
52
|
if (s.title === 'NAME') {
|
|
51
|
-
lines.push(troff(`${doc.
|
|
53
|
+
lines.push(troff(`${doc.page}${doc.summary ? ` - ${doc.summary}` : ''}`));
|
|
52
54
|
continue;
|
|
53
55
|
}
|
|
54
56
|
for (const b of s.blocks) lines.push(...manBlock(b));
|
|
@@ -100,12 +102,17 @@ function markdownBlock(b: Block): string[] {
|
|
|
100
102
|
return [...fenced(b.text), ''];
|
|
101
103
|
case 'dl':
|
|
102
104
|
return [...b.items.map((i) => `- ${code(i.term)}: ${markdownText(i.text)}`), ''];
|
|
105
|
+
case 'refs':
|
|
106
|
+
return [b.items.map((r) => `[${markdownText(`${r.page}(${r.section})`)}](${encodeURI(r.page)}.md)`).join(', '), ''];
|
|
103
107
|
}
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
/** The first line of a Markdown page: a comment, invisible when rendered, saying it is generated. */
|
|
111
|
+
export const MARKDOWN_MARKER = '<!-- Generated by clyops-doc from --help-json-schema. Do not edit; regenerate instead. -->';
|
|
112
|
+
|
|
106
113
|
/** The document as Markdown. */
|
|
107
114
|
export function renderMarkdown(doc: Doc): string {
|
|
108
|
-
const lines = [`# ${code(doc.name)}`, ''];
|
|
115
|
+
const lines = [MARKDOWN_MARKER, '', `# ${code(doc.name)}`, ''];
|
|
109
116
|
const header = [doc.version && `Version ${doc.version}`, doc.date].filter(Boolean).join(' · ');
|
|
110
117
|
if (header) lines.push(`_${markdownText(header)}_`, '');
|
|
111
118
|
for (const s of doc.sections) {
|
|
@@ -136,6 +143,8 @@ function htmlBlock(b: Block): string {
|
|
|
136
143
|
return `<pre><code>${htmlText(b.text)}</code></pre>`;
|
|
137
144
|
case 'dl':
|
|
138
145
|
return `<dl>\n${b.items.map((i) => `<dt><code>${htmlText(i.term)}</code></dt>\n<dd>${htmlText(i.text)}</dd>`).join('\n')}\n</dl>`;
|
|
146
|
+
case 'refs':
|
|
147
|
+
return `<p>${b.items.map((r) => `<a href="${htmlText(encodeURI(r.page))}.html"><code>${htmlText(r.page)}</code>(${htmlText(r.section)})</a>`).join(', ')}</p>`;
|
|
139
148
|
}
|
|
140
149
|
}
|
|
141
150
|
|
|
@@ -159,7 +168,7 @@ export function renderHtml(doc: Doc): string {
|
|
|
159
168
|
<meta charset="utf-8">
|
|
160
169
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
161
170
|
<meta name="generator" content="clyops-doc">
|
|
162
|
-
<title>${htmlText(doc.
|
|
171
|
+
<title>${htmlText(doc.page)}(${htmlText(doc.section)})</title>
|
|
163
172
|
<style>${STYLE}</style>
|
|
164
173
|
</head>
|
|
165
174
|
<body>
|