clyops-doc 0.6.0 → 0.7.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 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, buildDoc, renderMarkdown } from 'clyops-doc';
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 { readFileSync } from 'node:fs';
5
- import { document } from './index.js';
6
- import { defaultDate } from './model.js';
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
- cli.setEffects('read-only');
11
- cli.setStdin('The tool\'s --help-json-schema output (unless --schema is given)', 'application/json');
12
- cli.setStdout('The documentation, in the format --format names', 'text/plain');
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
- let schema;
21
- try {
22
- const text = readFileSync(args.CLYOPS_DOC_SCHEMA ?? 0, 'utf8');
23
- schema = JSON.parse(text);
24
- }
25
- catch (err) {
26
- die(1, 'Could not read the schema: %s', err.message);
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
- process.stdout.write(document(schema, args.CLYOPS_DOC_FORMAT, {
30
- name: args.CLYOPS_DOC_NAME ?? undefined,
31
- section: args.CLYOPS_DOC_SECTION,
32
- version: args.CLYOPS_DOC_VERSION ?? undefined,
33
- date: args.CLYOPS_DOC_DATE ?? defaultDate(),
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
@@ -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,EAAY,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AACvD,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"}
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
@@ -1 +1 @@
1
- {"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,cAAc,EAAc,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAEhH,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,CAAC;AAEnF,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,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;CACf;AAED,8FAA8F;AAC9F,MAAM,WAAW,IAAI;IACnB,+FAA+F;IAC/F,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,mCAAmC;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,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,CAgC7C;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;AAgDD,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,CAqEnE"}
1
+ {"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"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"}
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: prefer the environment');
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
- ...(leaf.effects.length ? [`Effects: ${leaf.effects.map((e) => `${e} (${EFFECTS[e] ?? e})`).join(', ')}.`] : []),
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
- sections.push({ title: 'NAME', blocks: [{ kind: 'p', text: summary ? `${name} - ${summary}` : name }] });
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: '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.' },
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
- return { name, section: opts.section || '1', version: opts.version || '', date: opts.date || defaultDate(), summary, sections };
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. */
@@ -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;AAoBD,qCAAqC;AACrC,wBAAgB,SAAS,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAgB1C;AAMD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAWjD;AA4BD,gCAAgC;AAChC,wBAAgB,cAAc,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAS/C;AAMD,mDAAmD;AACnD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAyBD,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"}
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.name.toUpperCase().replace(/\s+/g, '-');
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.name}${doc.summary ? ` - ${doc.summary}` : ''}`));
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.name)}(${htmlText(doc.section)})</title>
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.6.0",
3
+ "version": "0.7.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.6.0",
40
- "clyops-schema": "0.6.0"
39
+ "clyops": "0.7.0",
40
+ "clyops-schema": "0.7.0",
41
+ "clyops-tools": "0.7.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 { readFileSync } from 'node:fs';
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 { Format } from './render.js';
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
- cli.setEffects('read-only');
14
- cli.setStdin('The tool\'s --help-json-schema output (unless --schema is given)', 'application/json');
15
- cli.setStdout('The documentation, in the format --format names', 'text/plain');
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
- let schema: Schema;
25
- try {
26
- const text = readFileSync((args.CLYOPS_DOC_SCHEMA as string | null) ?? 0, 'utf8');
27
- schema = JSON.parse(text) as Schema;
28
- } catch (err) {
29
- die(1, 'Could not read the schema: %s', (err as Error).message);
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
- process.stdout.write(document(schema, args.CLYOPS_DOC_FORMAT as Format, {
33
- name: (args.CLYOPS_DOC_NAME as string | null) ?? undefined,
34
- section: args.CLYOPS_DOC_SECTION as string,
35
- version: (args.CLYOPS_DOC_VERSION as string | null) ?? undefined,
36
- date: (args.CLYOPS_DOC_DATE as string | null) ?? defaultDate(),
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: prefer the environment');
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
- ...(leaf.effects.length ? [`Effects: ${leaf.effects.map((e) => `${e} (${EFFECTS[e] ?? e})`).join(', ')}.`] : []),
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
- sections.push({ title: 'NAME', blocks: [{ kind: 'p', text: summary ? `${name} - ${summary}` : name }] });
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: '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.' },
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
- return { name, section: opts.section || '1', version: opts.version || '', date: opts.date || defaultDate(), summary, sections };
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.name.toUpperCase().replace(/\s+/g, '-');
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.name}${doc.summary ? ` - ${doc.summary}` : ''}`));
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.name)}(${htmlText(doc.section)})</title>
171
+ <title>${htmlText(doc.page)}(${htmlText(doc.section)})</title>
163
172
  <style>${STYLE}</style>
164
173
  </head>
165
174
  <body>