clyops-doc 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +97 -2
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +150 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/model.d.ts +129 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +280 -0
- package/dist/render.d.ts +23 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +165 -0
- package/package.json +50 -3
- package/src/cli.ts +142 -0
- package/src/index.ts +48 -0
- package/src/model.ts +355 -0
- package/src/render.ts +187 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// clyops-doc: a clyops tool's --help-json-schema on stdin, as a man page, Markdown or HTML.
|
|
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 type { Schema } from 'clyops-schema';
|
|
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';
|
|
12
|
+
|
|
13
|
+
const cli = new Cli({ name: 'clyops-doc' });
|
|
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.');
|
|
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');
|
|
21
|
+
cli.opt('CLYOPS_DOC_FORMAT', 'format', 'f', 'man', 'Output format', 'Output', 'choice:man,markdown,html');
|
|
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');
|
|
23
|
+
cli.opt('CLYOPS_DOC_SECTION', 'section', 's', '1', 'Manual section for the man page header', 'Output', 'regex:^[1-9][a-z]*$');
|
|
24
|
+
cli.opt('CLYOPS_DOC_VERSION', 'version', 'V', 'optional', 'Version shown in the header', 'Output');
|
|
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');
|
|
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');
|
|
34
|
+
const args = cli.run();
|
|
35
|
+
|
|
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);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
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
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
die(1, '%s', (err as Error).message);
|
|
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
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// clyops-doc: documentation (man, Markdown, HTML) from a clyops tool's --help-json-schema.
|
|
2
|
+
export * from './model.js';
|
|
3
|
+
export * from './render.js';
|
|
4
|
+
|
|
5
|
+
import type { Schema } from 'clyops-schema';
|
|
6
|
+
import { buildDoc, leafSchema, leaves, pageName, type DocOptions } from './model.js';
|
|
7
|
+
import { render, type Format } from './render.js';
|
|
8
|
+
|
|
9
|
+
/** A schema's documentation in `format` (default man). Throws for input that isn't a clyops schema. */
|
|
10
|
+
export function document(schema: Schema, format: Format = 'man', opts: DocOptions = {}): string {
|
|
11
|
+
return render(buildDoc(schema, opts), format);
|
|
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
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// A clyops schema (spec section 8) as a small document: sections of paragraphs, definition lists
|
|
2
|
+
// and preformatted text, which each output format renders.
|
|
3
|
+
import { describeRule } from 'clyops';
|
|
4
|
+
import type { Schema, SchemaArgument, SchemaBody, SchemaConfig, SchemaOption, SchemaRole, SchemaStream } from 'clyops-schema';
|
|
5
|
+
|
|
6
|
+
/** One piece of a section. Terms are option names, commands and the like: code in every format. */
|
|
7
|
+
export type Block =
|
|
8
|
+
| { /** A paragraph of plain text. */ kind: 'p'; text: string }
|
|
9
|
+
| { /** Text shown as is, in a fixed-width font. */ kind: 'pre'; text: string }
|
|
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 }
|
|
12
|
+
| { /** Other pages, as SEE ALSO lists them: `page(section)`, linked in Markdown and HTML. */ kind: 'refs'; items: { page: string; section: string }[] };
|
|
13
|
+
|
|
14
|
+
/** A top-level section, e.g. OPTIONS. */
|
|
15
|
+
export interface Section {
|
|
16
|
+
/** Upper-case title, as in man pages: NAME, SYNOPSIS, OPTIONS. */
|
|
17
|
+
title: string;
|
|
18
|
+
/** The section's content, in order. */
|
|
19
|
+
blocks: Block[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The document for one program: its header and sections. */
|
|
23
|
+
export interface Doc {
|
|
24
|
+
/** How the program is run, e.g. `mytool` or `mytool media to-pcm`. */
|
|
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;
|
|
28
|
+
/** Manual section, e.g. `1`. */
|
|
29
|
+
section: string;
|
|
30
|
+
/** Version shown in the header, or empty. */
|
|
31
|
+
version: string;
|
|
32
|
+
/** Date shown in the header (YYYY-MM-DD). */
|
|
33
|
+
date: string;
|
|
34
|
+
/** The first line of the program's description. */
|
|
35
|
+
summary: string;
|
|
36
|
+
/** The sections, in order. */
|
|
37
|
+
sections: Section[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What a renderer needs to know beyond the schema. */
|
|
41
|
+
export interface DocOptions {
|
|
42
|
+
/** How the program is run (default: the schema's `script`). */
|
|
43
|
+
name?: string;
|
|
44
|
+
/** Manual section (default `1`). */
|
|
45
|
+
section?: string;
|
|
46
|
+
/** Version for the header. */
|
|
47
|
+
version?: string;
|
|
48
|
+
/** Date for the header, YYYY-MM-DD (default: today). */
|
|
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;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A runnable level: a program without commands, or a leaf command, with what it inherits. */
|
|
57
|
+
export interface Leaf {
|
|
58
|
+
/** The command words after the program name, e.g. ['db', 'migrate']; empty for the program. */
|
|
59
|
+
words: string[];
|
|
60
|
+
/** The level's own description. */
|
|
61
|
+
description: string;
|
|
62
|
+
/** Text after its help, e.g. examples. */
|
|
63
|
+
epilog: string;
|
|
64
|
+
/** Positional arguments. */
|
|
65
|
+
arguments: SchemaArgument[];
|
|
66
|
+
/** Its own options, then those it inherits, nearest first. */
|
|
67
|
+
options: SchemaOption[];
|
|
68
|
+
/** The nearest declared effects. */
|
|
69
|
+
effects: string[];
|
|
70
|
+
/** The nearest declared stdin. */
|
|
71
|
+
stdin: SchemaStream | null;
|
|
72
|
+
/** The nearest declared stdout. */
|
|
73
|
+
stdout: SchemaStream | null;
|
|
74
|
+
/** Its roles (spec section 14), the nearest declaration of each name winning. */
|
|
75
|
+
roles: SchemaRole[];
|
|
76
|
+
/** Option relationships from the program down. */
|
|
77
|
+
constraints: { type: string; options: string[] }[];
|
|
78
|
+
/** Required commands from the program down. */
|
|
79
|
+
requiredCommands: { command: string; description: string; installHint: string }[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Every runnable level of a schema, the way clyops-tools expands commands (spec section 1.7). */
|
|
83
|
+
export function leaves(schema: Schema): Leaf[] {
|
|
84
|
+
const out: Leaf[] = [];
|
|
85
|
+
const visit = (chain: SchemaBody[], words: string[]) => {
|
|
86
|
+
const node = chain[chain.length - 1];
|
|
87
|
+
if (node.commands?.length) {
|
|
88
|
+
for (const child of node.commands) visit([...chain, child], [...words, child.name]);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const nearest = <T>(pick: (s: SchemaBody) => T | null | undefined, empty: (v: T) => boolean = () => false) => {
|
|
92
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
93
|
+
const v = pick(chain[i]);
|
|
94
|
+
if (v !== undefined && v !== null && !empty(v)) return v;
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
};
|
|
98
|
+
const roles = new Map<string, SchemaRole>();
|
|
99
|
+
for (const level of chain) for (const r of level.roles ?? []) roles.set(r.name, r);
|
|
100
|
+
out.push({
|
|
101
|
+
words,
|
|
102
|
+
description: node.description,
|
|
103
|
+
epilog: node.epilog,
|
|
104
|
+
arguments: node.arguments,
|
|
105
|
+
options: [...chain].reverse().flatMap((s) => s.options),
|
|
106
|
+
effects: nearest((s) => s.effects, (v) => v.length === 0) ?? [],
|
|
107
|
+
stdin: nearest((s) => s.stdin) ?? null,
|
|
108
|
+
stdout: nearest((s) => s.stdout) ?? null,
|
|
109
|
+
roles: [...roles.values()],
|
|
110
|
+
constraints: chain.flatMap((s) => s.constraints ?? []),
|
|
111
|
+
requiredCommands: chain.flatMap((s) => s.requiredCommands),
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
visit([schema], []);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The usage line, as help prints it (spec section 7). */
|
|
119
|
+
export function synopsis(name: string, leaf: Leaf): string {
|
|
120
|
+
const parts = [name, ...leaf.words];
|
|
121
|
+
for (const a of leaf.arguments) parts.push(a.isVariadic ? `[<${a.name}...>]` : a.required ? `<${a.name}>` : `[<${a.name}>]`);
|
|
122
|
+
return [...parts, '[OPTIONS]'].join(' ');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** An option's label: `-s, --long=<value>`, or `--long` for a flag. */
|
|
126
|
+
export function optionLabel(o: SchemaOption): string {
|
|
127
|
+
const long = `--${o.name}${o.isFlag ? '' : '=<value>'}`;
|
|
128
|
+
return o.shortName ? `-${o.shortName}, ${long}` : long;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** An option's description with its notes, as help annotates it (spec section 7). */
|
|
132
|
+
export function optionText(o: SchemaOption): string {
|
|
133
|
+
const notes: string[] = [];
|
|
134
|
+
if (o.required) notes.push('required');
|
|
135
|
+
if (o.isArray) notes.push('repeatable');
|
|
136
|
+
if (o.secret) notes.push('secret: set it in the environment, never on the command line');
|
|
137
|
+
if (o.default && !o.isFlag) notes.push(`default: ${o.default}`);
|
|
138
|
+
if (o.validation) notes.push(`accepts: ${describeRule(o.validation)}`);
|
|
139
|
+
notes.push(`environment: ${o.variableName}`);
|
|
140
|
+
return `${o.description}${o.description ? ' ' : ''}(${notes.join('; ')})`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function argumentText(a: SchemaArgument): string {
|
|
144
|
+
const notes: string[] = [];
|
|
145
|
+
if (a.isVariadic) notes.push('any number');
|
|
146
|
+
else if (!a.required) notes.push(a.default ? `default: ${a.default}` : 'optional');
|
|
147
|
+
if (a.validation) notes.push(`accepts: ${describeRule(a.validation)}`);
|
|
148
|
+
return notes.length ? `${a.description}${a.description ? ' ' : ''}(${notes.join('; ')})` : a.description;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function stream(s: SchemaStream): string {
|
|
152
|
+
return [s.description, s.contentType && `(${s.contentType})`].filter(Boolean).join(' ') || 'Declared, without a description.';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const CONSTRAINT = {
|
|
156
|
+
exclusive: (o: string[]) => `At most one of ${o.map((n) => `--${n}`).join(', ')}.`,
|
|
157
|
+
requires: (o: string[]) => `Giving --${o[0]} requires ${o.slice(1).map((n) => `--${n}`).join(', ')}.`,
|
|
158
|
+
oneOf: (o: string[]) => `At least one of ${o.map((n) => `--${n}`).join(', ')}.`,
|
|
159
|
+
} as Record<string, (o: string[]) => string>;
|
|
160
|
+
|
|
161
|
+
const EFFECTS: Record<string, string> = {
|
|
162
|
+
'read-only': 'changes nothing',
|
|
163
|
+
idempotent: 'running it twice is the same as once',
|
|
164
|
+
destructive: 'deletes or overwrites things',
|
|
165
|
+
network: 'talks to the outside world',
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** A leaf's parts: its arguments, options and streams as definition lists, and notes as text. */
|
|
169
|
+
function leafParts(leaf: Leaf): { args?: Block; options?: Block; streams?: Block; notes: Block[] } {
|
|
170
|
+
const dl = (items: { term: string; text: string }[]): Block | undefined => (items.length ? { kind: 'dl', items } : undefined);
|
|
171
|
+
const streams = [
|
|
172
|
+
...(leaf.stdin ? [{ term: 'stdin', text: stream(leaf.stdin) }] : []),
|
|
173
|
+
...(leaf.stdout ? [{ term: 'stdout', text: stream(leaf.stdout) }] : []),
|
|
174
|
+
...leaf.roles.map((r) => ({ term: `${r.direction === 'in' ? 'input' : 'output'} role ${r.name}`, text: stream(r) })),
|
|
175
|
+
];
|
|
176
|
+
const notes = [
|
|
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.',
|
|
180
|
+
...leaf.constraints.map((c) => CONSTRAINT[c.type]?.(c.options) ?? ''),
|
|
181
|
+
...leaf.requiredCommands.map((r) => `Needs ${r.command} on PATH: ${r.description}${r.installHint ? ` (${r.installHint})` : ''}.`),
|
|
182
|
+
].filter(Boolean);
|
|
183
|
+
return {
|
|
184
|
+
args: dl(leaf.arguments.map((a) => ({ term: a.isVariadic ? `<${a.name}...>` : `<${a.name}>`, text: argumentText(a) }))),
|
|
185
|
+
options: dl(leaf.options.filter((o) => o.name !== 'help').map((o) => ({ term: optionLabel(o), text: optionText(o) }))),
|
|
186
|
+
streams: dl(streams),
|
|
187
|
+
notes: notes.map((text) => ({ kind: 'p', text })),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The first sentence of a description (its first line at most), for the NAME line. */
|
|
192
|
+
export function firstSentence(text: string): string {
|
|
193
|
+
const line = text.split('\n')[0].trim();
|
|
194
|
+
const end = line.search(/[.!?](\s|$)/);
|
|
195
|
+
return end < 0 ? line : line.slice(0, end);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Today in UTC, or SOURCE_DATE_EPOCH's date for reproducible builds. */
|
|
199
|
+
export function defaultDate(env: NodeJS.ProcessEnv = process.env): string {
|
|
200
|
+
const epoch = Number(env.SOURCE_DATE_EPOCH);
|
|
201
|
+
const when = Number.isFinite(epoch) && env.SOURCE_DATE_EPOCH ? new Date(epoch * 1000) : new Date();
|
|
202
|
+
return when.toISOString().slice(0, 10);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The document for a schema. Throws for input that isn't a clyops schema (`"clyops": 1`, with
|
|
207
|
+
* description, options and arguments).
|
|
208
|
+
*/
|
|
209
|
+
export function buildDoc(schema: Schema, opts: DocOptions = {}): Doc {
|
|
210
|
+
if (!schema || typeof schema !== 'object' || schema.clyops !== 1 || !Array.isArray(schema.options) || !Array.isArray(schema.arguments)) {
|
|
211
|
+
throw new Error('not a clyops schema (expected "clyops": 1 with options and arguments)');
|
|
212
|
+
}
|
|
213
|
+
const name = opts.name || schema.script;
|
|
214
|
+
const all = leaves(schema);
|
|
215
|
+
const program = all.length === 1 && all[0].words.length === 0;
|
|
216
|
+
const summary = firstSentence(schema.description || '');
|
|
217
|
+
const sections: Section[] = [];
|
|
218
|
+
|
|
219
|
+
const page = opts.page || name;
|
|
220
|
+
sections.push({ title: 'NAME', blocks: [{ kind: 'p', text: summary ? `${page} - ${summary}` : page }] });
|
|
221
|
+
sections.push({ title: 'SYNOPSIS', blocks: [{ kind: 'pre', text: all.map((l) => synopsis(name, l)).join('\n') }] });
|
|
222
|
+
if (schema.description) sections.push({ title: 'DESCRIPTION', blocks: schema.description.split(/\n{2,}/).map((text) => ({ kind: 'p', text })) });
|
|
223
|
+
|
|
224
|
+
if (program) {
|
|
225
|
+
const { args, options, streams, notes } = leafParts(all[0]);
|
|
226
|
+
if (args) sections.push({ title: 'ARGUMENTS', blocks: [args] });
|
|
227
|
+
if (options) sections.push({ title: 'OPTIONS', blocks: [options] });
|
|
228
|
+
if (streams) sections.push({ title: 'STREAMS', blocks: [streams] });
|
|
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
|
+
});
|
|
238
|
+
} else {
|
|
239
|
+
// One subsection per runnable command, with what it inherits.
|
|
240
|
+
const blocks: Block[] = [];
|
|
241
|
+
for (const leaf of all) {
|
|
242
|
+
const { args, options, streams, notes } = leafParts(leaf);
|
|
243
|
+
blocks.push({ kind: 'h', text: [name, ...leaf.words].join(' ') });
|
|
244
|
+
if (leaf.description) blocks.push({ kind: 'p', text: leaf.description });
|
|
245
|
+
blocks.push({ kind: 'pre', text: synopsis(name, leaf) });
|
|
246
|
+
for (const [label, part] of [['Arguments', args], ['Options', options], ['Streams', streams]] as const) {
|
|
247
|
+
if (part) blocks.push({ kind: 'p', text: `${label}:` }, part);
|
|
248
|
+
}
|
|
249
|
+
blocks.push(...notes);
|
|
250
|
+
}
|
|
251
|
+
sections.push({ title: 'COMMANDS', blocks });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Every option's variable, once, in the order first seen.
|
|
255
|
+
const env = new Map<string, string>();
|
|
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);
|
|
258
|
+
sections.push({
|
|
259
|
+
title: 'ENVIRONMENT',
|
|
260
|
+
blocks: [
|
|
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.' },
|
|
264
|
+
{
|
|
265
|
+
kind: 'dl',
|
|
266
|
+
items: [
|
|
267
|
+
...[...env].map(([term, text]) => ({ term, text })),
|
|
268
|
+
...(anyRoles ? [{ term: 'CLYOPS_FDS', text: 'Maps roles to file descriptors, e.g. meta:3,progress:4; set by the program that runs it. Without it, roles are not written.' }] : []),
|
|
269
|
+
{ term: 'CLYOPS_SILENT', text: 'true suppresses informational and warning messages.' },
|
|
270
|
+
{ term: 'CLYOPS_MAX_WIDTH', text: 'The width help is wrapped to (default 100).' },
|
|
271
|
+
{ term: 'NO_COLOR', text: 'Set to turn off colored log levels.' },
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
});
|
|
276
|
+
if (schema.config) sections.push({ title: 'FILES', blocks: files(schema.config, schema.options) });
|
|
277
|
+
sections.push({
|
|
278
|
+
title: 'EXIT STATUS',
|
|
279
|
+
blocks: [{
|
|
280
|
+
kind: 'dl',
|
|
281
|
+
items: [
|
|
282
|
+
{ term: '0', text: 'Success, or help was shown.' },
|
|
283
|
+
{ term: '1', text: 'Invalid arguments, options or input, a missing required command, or a failure the program reports.' },
|
|
284
|
+
{ term: 'other', text: 'Defined by the program.' },
|
|
285
|
+
],
|
|
286
|
+
}],
|
|
287
|
+
});
|
|
288
|
+
if (schema.epilog) sections.push({ title: 'EXAMPLES', blocks: [{ kind: 'pre', text: schema.epilog.replace(/\n+$/, '') }] });
|
|
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 };
|
|
355
|
+
}
|