clyops-doc 0.0.0-stage → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dan VerWeire
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,58 @@
1
- # Temporary Holding Version
1
+ # clyops-doc
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Documentation from any [clyops](https://github.com/wankdanker/clyops) tool's `--help-json-schema`: a
4
+ man page, Markdown or a standalone HTML page. It only reads the schema, so it works for a tool in
5
+ any of the nine languages, compiled ones included, and needs nothing from the tool itself.
6
+
7
+ ```sh
8
+ npm install -g clyops-doc
9
+
10
+ mytool --help-json-schema | clyops-doc > mytool.1 # man page (the default)
11
+ mytool --help-json-schema | clyops-doc --format markdown > mytool.md
12
+ mytool --help-json-schema | clyops-doc --format html > mytool.html
13
+ man -l mytool.1
14
+ ```
15
+
16
+ `clyops-doc` is a clyops tool too, so it documents itself:
17
+ `clyops-doc --help-json-schema | clyops-doc | man -l -`.
18
+
19
+ | Option | |
20
+ | --- | --- |
21
+ | `-f, --format` | `man` (default), `markdown` or `html` |
22
+ | `-n, --name` | How the tool is run, default the schema's name; for a dispatcher path, `--name "mytool media to-pcm"` |
23
+ | `-s, --section` | Manual section for the man page header (default `1`) |
24
+ | `-V, --version` | Version shown in the header |
25
+ | `-d, --date` | Date shown in the header, YYYY-MM-DD (default today, or `SOURCE_DATE_EPOCH` for reproducible builds) |
26
+ | `--schema FILE` | Read the schema from a file instead of stdin |
27
+
28
+ Each option can also be set as `CLYOPS_DOC_<OPTION>`. Input that isn't a clyops schema (`"clyops": 1`)
29
+ is rejected with exit status 1.
30
+
31
+ ## What goes where
32
+
33
+ | Section | From the schema |
34
+ | --- | --- |
35
+ | NAME, SYNOPSIS | the name, the first sentence of `description`, and a usage line per command, as help prints it |
36
+ | DESCRIPTION | `description` |
37
+ | ARGUMENTS, OPTIONS | each argument and option with its default, accepted values ([spec §5](https://github.com/wankdanker/clyops/blob/main/spec/SPEC.md#5-validation-rules)), whether it repeats or is a secret, and its environment variable |
38
+ | COMMANDS | for a program with commands, a subsection per command with the options it inherits and the nearest declared effects, stdin, stdout and roles |
39
+ | STREAMS | `stdin`, `stdout` and [roles](https://github.com/wankdanker/clyops/blob/main/spec/SPEC.md#14-roles-on-file-descriptors) with their types |
40
+ | NOTES | effects, option relationships and required commands |
41
+ | ENVIRONMENT | each option's variable, the precedence rule, `CLYOPS_FDS` when there are roles, `CLYOPS_SILENT`, `CLYOPS_MAX_WIDTH` and `NO_COLOR` |
42
+ | EXIT STATUS | 0, 1, and the tool's own |
43
+ | EXAMPLES | `epilog` |
44
+
45
+ Man pages use the plain `man(7)` macros, which groff and mandoc both render; text is escaped so a
46
+ line can't start a request and option dashes are `\-`, so they copy and paste. The HTML page is
47
+ self-contained, with every value escaped.
48
+
49
+ ## As a library
50
+
51
+ ```js
52
+ import { document, buildDoc, renderMarkdown } from 'clyops-doc';
53
+
54
+ const page = document(schema, 'man', { name: 'mytool', version: '1.2.0' });
55
+ const md = renderMarkdown(buildDoc(schema)); // or build the document once and render it several ways
56
+ ```
57
+
58
+ ESM only, Node 20+.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,38 @@
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 } from 'clyops';
4
+ import { readFileSync } from 'node:fs';
5
+ import { document } from './index.js';
6
+ import { defaultDate } from './model.js';
7
+ const cli = new Cli({ name: 'clyops-doc' });
8
+ 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.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');
15
+ cli.opt('CLYOPS_DOC_SECTION', 'section', 's', '1', 'Manual section for the man page header', 'Output', 'regex:^[1-9][a-z]*$');
16
+ cli.opt('CLYOPS_DOC_VERSION', 'version', 'V', 'optional', 'Version shown in the header', 'Output');
17
+ 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
+ cli.opt('CLYOPS_DOC_SCHEMA', 'schema', '', 'optional', 'Read the schema from this file instead of stdin', 'Input', 'file:readable');
19
+ 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);
27
+ }
28
+ 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
+ }));
35
+ }
36
+ catch (err) {
37
+ die(1, '%s', err.message);
38
+ }
@@ -0,0 +1,8 @@
1
+ export * from './model.js';
2
+ export * from './render.js';
3
+ import type { Schema } from 'clyops-schema';
4
+ import { type DocOptions } from './model.js';
5
+ import { type Format } from './render.js';
6
+ /** A schema's documentation in `format` (default man). Throws for input that isn't a clyops schema. */
7
+ export declare function document(schema: Schema, format?: Format, opts?: DocOptions): string;
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
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
+ import { buildDoc } from './model.js';
5
+ import { render } from './render.js';
6
+ /** A schema's documentation in `format` (default man). Throws for input that isn't a clyops schema. */
7
+ export function document(schema, format = 'man', opts = {}) {
8
+ return render(buildDoc(schema, opts), format);
9
+ }
@@ -0,0 +1,99 @@
1
+ import type { Schema, SchemaArgument, SchemaOption, SchemaRole, SchemaStream } from 'clyops-schema';
2
+ /** One piece of a section. Terms are option names, commands and the like: code in every format. */
3
+ export type Block = {
4
+ kind: 'p';
5
+ text: string;
6
+ } | {
7
+ kind: 'pre';
8
+ text: string;
9
+ } | {
10
+ kind: 'dl';
11
+ items: {
12
+ term: string;
13
+ text: string;
14
+ }[];
15
+ } | {
16
+ kind: 'h';
17
+ text: string;
18
+ };
19
+ /** A top-level section, e.g. OPTIONS. */
20
+ export interface Section {
21
+ /** Upper-case title, as in man pages: NAME, SYNOPSIS, OPTIONS. */
22
+ title: string;
23
+ /** The section's content, in order. */
24
+ blocks: Block[];
25
+ }
26
+ /** The document for one program: its header and sections. */
27
+ export interface Doc {
28
+ /** How the program is run, e.g. `mytool` or `mytool media to-pcm`. */
29
+ name: string;
30
+ /** Manual section, e.g. `1`. */
31
+ section: string;
32
+ /** Version shown in the header, or empty. */
33
+ version: string;
34
+ /** Date shown in the header (YYYY-MM-DD). */
35
+ date: string;
36
+ /** The first line of the program's description. */
37
+ summary: string;
38
+ /** The sections, in order. */
39
+ sections: Section[];
40
+ }
41
+ /** What a renderer needs to know beyond the schema. */
42
+ export interface DocOptions {
43
+ /** How the program is run (default: the schema's `script`). */
44
+ name?: string;
45
+ /** Manual section (default `1`). */
46
+ section?: string;
47
+ /** Version for the header. */
48
+ version?: string;
49
+ /** Date for the header, YYYY-MM-DD (default: today). */
50
+ date?: string;
51
+ }
52
+ /** A runnable level: a program without commands, or a leaf command, with what it inherits. */
53
+ export interface Leaf {
54
+ /** The command words after the program name, e.g. ['db', 'migrate']; empty for the program. */
55
+ words: string[];
56
+ /** The level's own description. */
57
+ description: string;
58
+ /** Positional arguments. */
59
+ arguments: SchemaArgument[];
60
+ /** Its own options, then those it inherits, nearest first. */
61
+ options: SchemaOption[];
62
+ /** The nearest declared effects. */
63
+ effects: string[];
64
+ /** The nearest declared stdin. */
65
+ stdin: SchemaStream | null;
66
+ /** The nearest declared stdout. */
67
+ stdout: SchemaStream | null;
68
+ /** Its roles (spec section 14), the nearest declaration of each name winning. */
69
+ roles: SchemaRole[];
70
+ /** Option relationships from the program down. */
71
+ constraints: {
72
+ type: string;
73
+ options: string[];
74
+ }[];
75
+ /** Required commands from the program down. */
76
+ requiredCommands: {
77
+ command: string;
78
+ description: string;
79
+ installHint: string;
80
+ }[];
81
+ }
82
+ /** Every runnable level of a schema, the way clyops-tools expands commands (spec section 1.7). */
83
+ export declare function leaves(schema: Schema): Leaf[];
84
+ /** The usage line, as help prints it (spec section 7). */
85
+ export declare function synopsis(name: string, leaf: Leaf): string;
86
+ /** An option's label: `-s, --long=<value>`, or `--long` for a flag. */
87
+ export declare function optionLabel(o: SchemaOption): string;
88
+ /** An option's description with its notes, as help annotates it (spec section 7). */
89
+ export declare function optionText(o: SchemaOption): string;
90
+ /** The first sentence of a description (its first line at most), for the NAME line. */
91
+ export declare function firstSentence(text: string): string;
92
+ /** Today in UTC, or SOURCE_DATE_EPOCH's date for reproducible builds. */
93
+ export declare function defaultDate(env?: NodeJS.ProcessEnv): string;
94
+ /**
95
+ * The document for a schema. Throws for input that isn't a clyops schema (`"clyops": 1`, with
96
+ * description, options and arguments).
97
+ */
98
+ export declare function buildDoc(schema: Schema, opts?: DocOptions): Doc;
99
+ //# sourceMappingURL=model.d.ts.map
@@ -0,0 +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"}
package/dist/model.js ADDED
@@ -0,0 +1,208 @@
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
+ /** Every runnable level of a schema, the way clyops-tools expands commands (spec section 1.7). */
5
+ export function leaves(schema) {
6
+ const out = [];
7
+ const visit = (chain, words) => {
8
+ const node = chain[chain.length - 1];
9
+ if (node.commands?.length) {
10
+ for (const child of node.commands)
11
+ visit([...chain, child], [...words, child.name]);
12
+ return;
13
+ }
14
+ const nearest = (pick, empty = () => false) => {
15
+ for (let i = chain.length - 1; i >= 0; i--) {
16
+ const v = pick(chain[i]);
17
+ if (v !== undefined && v !== null && !empty(v))
18
+ return v;
19
+ }
20
+ return undefined;
21
+ };
22
+ const roles = new Map();
23
+ for (const level of chain)
24
+ for (const r of level.roles ?? [])
25
+ roles.set(r.name, r);
26
+ out.push({
27
+ words,
28
+ description: node.description,
29
+ arguments: node.arguments,
30
+ options: [...chain].reverse().flatMap((s) => s.options),
31
+ effects: nearest((s) => s.effects, (v) => v.length === 0) ?? [],
32
+ stdin: nearest((s) => s.stdin) ?? null,
33
+ stdout: nearest((s) => s.stdout) ?? null,
34
+ roles: [...roles.values()],
35
+ constraints: chain.flatMap((s) => s.constraints ?? []),
36
+ requiredCommands: chain.flatMap((s) => s.requiredCommands),
37
+ });
38
+ };
39
+ visit([schema], []);
40
+ return out;
41
+ }
42
+ /** The usage line, as help prints it (spec section 7). */
43
+ export function synopsis(name, leaf) {
44
+ const parts = [name, ...leaf.words];
45
+ for (const a of leaf.arguments)
46
+ parts.push(a.isVariadic ? `[<${a.name}...>]` : a.required ? `<${a.name}>` : `[<${a.name}>]`);
47
+ return [...parts, '[OPTIONS]'].join(' ');
48
+ }
49
+ /** An option's label: `-s, --long=<value>`, or `--long` for a flag. */
50
+ export function optionLabel(o) {
51
+ const long = `--${o.name}${o.isFlag ? '' : '=<value>'}`;
52
+ return o.shortName ? `-${o.shortName}, ${long}` : long;
53
+ }
54
+ /** An option's description with its notes, as help annotates it (spec section 7). */
55
+ export function optionText(o) {
56
+ const notes = [];
57
+ if (o.required)
58
+ notes.push('required');
59
+ if (o.isArray)
60
+ notes.push('repeatable');
61
+ if (o.secret)
62
+ notes.push('secret: prefer the environment');
63
+ if (o.default && !o.isFlag)
64
+ notes.push(`default: ${o.default}`);
65
+ if (o.validation)
66
+ notes.push(`accepts: ${describeRule(o.validation)}`);
67
+ notes.push(`environment: ${o.variableName}`);
68
+ return `${o.description}${o.description ? ' ' : ''}(${notes.join('; ')})`;
69
+ }
70
+ function argumentText(a) {
71
+ const notes = [];
72
+ if (a.isVariadic)
73
+ notes.push('any number');
74
+ else if (!a.required)
75
+ notes.push(a.default ? `default: ${a.default}` : 'optional');
76
+ if (a.validation)
77
+ notes.push(`accepts: ${describeRule(a.validation)}`);
78
+ return notes.length ? `${a.description}${a.description ? ' ' : ''}(${notes.join('; ')})` : a.description;
79
+ }
80
+ function stream(s) {
81
+ return [s.description, s.contentType && `(${s.contentType})`].filter(Boolean).join(' ') || 'Declared, without a description.';
82
+ }
83
+ const CONSTRAINT = {
84
+ exclusive: (o) => `At most one of ${o.map((n) => `--${n}`).join(', ')}.`,
85
+ requires: (o) => `Giving --${o[0]} requires ${o.slice(1).map((n) => `--${n}`).join(', ')}.`,
86
+ oneOf: (o) => `At least one of ${o.map((n) => `--${n}`).join(', ')}.`,
87
+ };
88
+ const EFFECTS = {
89
+ 'read-only': 'changes nothing',
90
+ idempotent: 'running it twice is the same as once',
91
+ destructive: 'deletes or overwrites things',
92
+ network: 'talks to the outside world',
93
+ };
94
+ /** A leaf's parts: its arguments, options and streams as definition lists, and notes as text. */
95
+ function leafParts(leaf) {
96
+ const dl = (items) => (items.length ? { kind: 'dl', items } : undefined);
97
+ const streams = [
98
+ ...(leaf.stdin ? [{ term: 'stdin', text: stream(leaf.stdin) }] : []),
99
+ ...(leaf.stdout ? [{ term: 'stdout', text: stream(leaf.stdout) }] : []),
100
+ ...leaf.roles.map((r) => ({ term: `${r.direction === 'in' ? 'input' : 'output'} role ${r.name}`, text: stream(r) })),
101
+ ];
102
+ const notes = [
103
+ ...(leaf.effects.length ? [`Effects: ${leaf.effects.map((e) => `${e} (${EFFECTS[e] ?? e})`).join(', ')}.`] : []),
104
+ ...leaf.constraints.map((c) => CONSTRAINT[c.type]?.(c.options) ?? ''),
105
+ ...leaf.requiredCommands.map((r) => `Needs ${r.command} on PATH: ${r.description}${r.installHint ? ` (${r.installHint})` : ''}.`),
106
+ ].filter(Boolean);
107
+ return {
108
+ args: dl(leaf.arguments.map((a) => ({ term: a.isVariadic ? `<${a.name}...>` : `<${a.name}>`, text: argumentText(a) }))),
109
+ options: dl(leaf.options.filter((o) => o.name !== 'help').map((o) => ({ term: optionLabel(o), text: optionText(o) }))),
110
+ streams: dl(streams),
111
+ notes: notes.map((text) => ({ kind: 'p', text })),
112
+ };
113
+ }
114
+ /** The first sentence of a description (its first line at most), for the NAME line. */
115
+ export function firstSentence(text) {
116
+ const line = text.split('\n')[0].trim();
117
+ const end = line.search(/[.!?](\s|$)/);
118
+ return end < 0 ? line : line.slice(0, end);
119
+ }
120
+ /** Today in UTC, or SOURCE_DATE_EPOCH's date for reproducible builds. */
121
+ export function defaultDate(env = process.env) {
122
+ const epoch = Number(env.SOURCE_DATE_EPOCH);
123
+ const when = Number.isFinite(epoch) && env.SOURCE_DATE_EPOCH ? new Date(epoch * 1000) : new Date();
124
+ return when.toISOString().slice(0, 10);
125
+ }
126
+ /**
127
+ * The document for a schema. Throws for input that isn't a clyops schema (`"clyops": 1`, with
128
+ * description, options and arguments).
129
+ */
130
+ export function buildDoc(schema, opts = {}) {
131
+ if (!schema || typeof schema !== 'object' || schema.clyops !== 1 || !Array.isArray(schema.options) || !Array.isArray(schema.arguments)) {
132
+ throw new Error('not a clyops schema (expected "clyops": 1 with options and arguments)');
133
+ }
134
+ const name = opts.name || schema.script;
135
+ const all = leaves(schema);
136
+ const program = all.length === 1 && all[0].words.length === 0;
137
+ const summary = firstSentence(schema.description || '');
138
+ const sections = [];
139
+ sections.push({ title: 'NAME', blocks: [{ kind: 'p', text: summary ? `${name} - ${summary}` : name }] });
140
+ sections.push({ title: 'SYNOPSIS', blocks: [{ kind: 'pre', text: all.map((l) => synopsis(name, l)).join('\n') }] });
141
+ if (schema.description)
142
+ sections.push({ title: 'DESCRIPTION', blocks: schema.description.split(/\n{2,}/).map((text) => ({ kind: 'p', text })) });
143
+ if (program) {
144
+ const { args, options, streams, notes } = leafParts(all[0]);
145
+ if (args)
146
+ sections.push({ title: 'ARGUMENTS', blocks: [args] });
147
+ if (options)
148
+ sections.push({ title: 'OPTIONS', blocks: [options] });
149
+ if (streams)
150
+ sections.push({ title: 'STREAMS', blocks: [streams] });
151
+ if (notes.length)
152
+ sections.push({ title: 'NOTES', blocks: notes });
153
+ }
154
+ else {
155
+ // One subsection per runnable command, with what it inherits.
156
+ const blocks = [];
157
+ for (const leaf of all) {
158
+ const { args, options, streams, notes } = leafParts(leaf);
159
+ blocks.push({ kind: 'h', text: [name, ...leaf.words].join(' ') });
160
+ if (leaf.description)
161
+ blocks.push({ kind: 'p', text: leaf.description });
162
+ blocks.push({ kind: 'pre', text: synopsis(name, leaf) });
163
+ for (const [label, part] of [['Arguments', args], ['Options', options], ['Streams', streams]]) {
164
+ if (part)
165
+ blocks.push({ kind: 'p', text: `${label}:` }, part);
166
+ }
167
+ blocks.push(...notes);
168
+ }
169
+ sections.push({ title: 'COMMANDS', blocks });
170
+ }
171
+ // Every option's variable, once, in the order first seen.
172
+ const env = new Map();
173
+ for (const leaf of all)
174
+ for (const o of leaf.options)
175
+ if (o.name !== 'help' && !env.has(o.variableName))
176
+ 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);
178
+ sections.push({
179
+ title: 'ENVIRONMENT',
180
+ 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.' },
182
+ {
183
+ kind: 'dl',
184
+ items: [
185
+ ...[...env].map(([term, text]) => ({ term, text })),
186
+ ...(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.' }] : []),
187
+ { term: 'CLYOPS_SILENT', text: 'true suppresses informational and warning messages.' },
188
+ { term: 'CLYOPS_MAX_WIDTH', text: 'The width help is wrapped to (default 100).' },
189
+ { term: 'NO_COLOR', text: 'Set to turn off colored log levels.' },
190
+ ],
191
+ },
192
+ ],
193
+ });
194
+ sections.push({
195
+ title: 'EXIT STATUS',
196
+ blocks: [{
197
+ kind: 'dl',
198
+ items: [
199
+ { term: '0', text: 'Success, or help was shown.' },
200
+ { term: '1', text: 'Invalid arguments, options or input, a missing required command, or a failure the program reports.' },
201
+ { term: 'other', text: 'Defined by the program.' },
202
+ ],
203
+ }],
204
+ });
205
+ if (schema.epilog)
206
+ 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 };
208
+ }
@@ -0,0 +1,21 @@
1
+ import type { Doc } from './model.js';
2
+ /** Output formats. */
3
+ export type Format = 'man' | 'markdown' | 'html';
4
+ /** Text for a troff line: backslashes and dashes escaped, a leading `.` or `'` made literal. */
5
+ export declare function troff(text: string): string;
6
+ /** The document as a man(7) page. */
7
+ export declare function renderMan(doc: Doc): string;
8
+ /**
9
+ * Text that renders as itself in CommonMark: characters that can start markup escaped, and nothing
10
+ * else, so the Markdown stays readable (`DRY_RUN`, `(default: 1)`).
11
+ */
12
+ export declare function markdownText(text: string): string;
13
+ /** The document as Markdown. */
14
+ export declare function renderMarkdown(doc: Doc): string;
15
+ /** Text with HTML's special characters escaped. */
16
+ export declare function htmlText(text: string): string;
17
+ /** The document as a standalone HTML page. */
18
+ export declare function renderHtml(doc: Doc): string;
19
+ /** The document in `format`. */
20
+ export declare function render(doc: Doc, format: Format): string;
21
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +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"}
package/dist/render.js ADDED
@@ -0,0 +1,157 @@
1
+ // ---------------------------------------------------------------------------
2
+ // man(7): plain .TH .SH .SS .PP .TP .nf/.fi macros, which groff and mandoc both render.
3
+ // ---------------------------------------------------------------------------
4
+ /** Text for a troff line: backslashes and dashes escaped, a leading `.` or `'` made literal. */
5
+ export function troff(text) {
6
+ return text
7
+ .split('\n')
8
+ .map((line) => {
9
+ const escaped = line.replace(/\\/g, '\\e').replace(/-/g, '\\-');
10
+ return /^[.']/.test(escaped) ? `\\&${escaped}` : escaped;
11
+ })
12
+ .join('\n');
13
+ }
14
+ /** A macro argument: escaped, and quoted so spaces stay in it. */
15
+ function arg(text) {
16
+ return `"${troff(text).replace(/"/g, '\\(dq')}"`;
17
+ }
18
+ function manBlock(b) {
19
+ switch (b.kind) {
20
+ case 'p':
21
+ return ['.PP', troff(b.text)];
22
+ case 'h':
23
+ return [`.SS ${arg(b.text)}`];
24
+ case 'pre':
25
+ return ['.PP', '.nf', '.RS 4', troff(b.text), '.RE', '.fi'];
26
+ case 'dl':
27
+ return b.items.flatMap((i) => ['.TP', `\\fB${troff(i.term)}\\fR`, troff(i.text)]);
28
+ }
29
+ }
30
+ /** The document as a man(7) page. */
31
+ export function renderMan(doc) {
32
+ const title = doc.name.toUpperCase().replace(/\s+/g, '-');
33
+ const lines = [
34
+ `.\\" Generated by clyops-doc from ${doc.name} --help-json-schema.`,
35
+ `.TH ${arg(title)} ${arg(doc.section)} ${arg(doc.date)} ${arg([doc.name, doc.version].filter(Boolean).join(' '))} ${arg('User Commands')}`,
36
+ ];
37
+ for (const s of doc.sections) {
38
+ lines.push(`.SH ${arg(s.title)}`);
39
+ // NAME is one line, "name \- summary", without a paragraph macro (man(7)).
40
+ if (s.title === 'NAME') {
41
+ lines.push(troff(`${doc.name}${doc.summary ? ` - ${doc.summary}` : ''}`));
42
+ continue;
43
+ }
44
+ for (const b of s.blocks)
45
+ lines.push(...manBlock(b));
46
+ }
47
+ return lines.join('\n') + '\n';
48
+ }
49
+ // ---------------------------------------------------------------------------
50
+ // Markdown (CommonMark)
51
+ // ---------------------------------------------------------------------------
52
+ /**
53
+ * Text that renders as itself in CommonMark: characters that can start markup escaped, and nothing
54
+ * else, so the Markdown stays readable (`DRY_RUN`, `(default: 1)`).
55
+ */
56
+ export function markdownText(text) {
57
+ return text
58
+ .split('\n')
59
+ .map((line) => line
60
+ .replace(/[\\`*[\]<>|]/g, '\\$&')
61
+ // _ only emphasises at a word boundary.
62
+ .replace(/(^|[^A-Za-z0-9])_|_(?=[^A-Za-z0-9]|$)/g, (m) => m.replace('_', '\\_'))
63
+ // A block marker at the start of a line: a heading, quote, list item or rule.
64
+ .replace(/^(\s*)([#>+=-]|\d+[.)])/, '$1\\$2'))
65
+ .join('\n');
66
+ }
67
+ /** Inline code that may itself contain backticks. */
68
+ function code(text) {
69
+ const run = Math.max(0, ...(text.match(/`+/g) ?? []).map((m) => m.length)) + 1;
70
+ const fence = '`'.repeat(run);
71
+ return run > 1 ? `${fence} ${text} ${fence}` : `${fence}${text}${fence}`;
72
+ }
73
+ function fenced(text) {
74
+ const run = Math.max(2, ...(text.match(/`+/g) ?? []).map((m) => m.length)) + 1;
75
+ const fence = '`'.repeat(run);
76
+ return [fence, text, fence];
77
+ }
78
+ function markdownBlock(b) {
79
+ switch (b.kind) {
80
+ case 'p':
81
+ return [markdownText(b.text), ''];
82
+ case 'h':
83
+ return [`### ${code(b.text)}`, ''];
84
+ case 'pre':
85
+ return [...fenced(b.text), ''];
86
+ case 'dl':
87
+ return [...b.items.map((i) => `- ${code(i.term)}: ${markdownText(i.text)}`), ''];
88
+ }
89
+ }
90
+ /** The document as Markdown. */
91
+ export function renderMarkdown(doc) {
92
+ const lines = [`# ${code(doc.name)}`, ''];
93
+ const header = [doc.version && `Version ${doc.version}`, doc.date].filter(Boolean).join(' · ');
94
+ if (header)
95
+ lines.push(`_${markdownText(header)}_`, '');
96
+ for (const s of doc.sections) {
97
+ lines.push(`## ${s.title}`, '');
98
+ for (const b of s.blocks)
99
+ lines.push(...markdownBlock(b));
100
+ }
101
+ return lines.join('\n').replace(/\n+$/, '\n');
102
+ }
103
+ // ---------------------------------------------------------------------------
104
+ // HTML: one standalone page, everything escaped.
105
+ // ---------------------------------------------------------------------------
106
+ /** Text with HTML's special characters escaped. */
107
+ export function htmlText(text) {
108
+ return text.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
109
+ }
110
+ const slug = (text) => text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
111
+ function htmlBlock(b) {
112
+ switch (b.kind) {
113
+ case 'p':
114
+ return `<p>${htmlText(b.text)}</p>`;
115
+ case 'h':
116
+ return `<h3 id="${slug(b.text)}"><code>${htmlText(b.text)}</code></h3>`;
117
+ case 'pre':
118
+ return `<pre><code>${htmlText(b.text)}</code></pre>`;
119
+ case 'dl':
120
+ return `<dl>\n${b.items.map((i) => `<dt><code>${htmlText(i.term)}</code></dt>\n<dd>${htmlText(i.text)}</dd>`).join('\n')}\n</dl>`;
121
+ }
122
+ }
123
+ const STYLE = `body{font:16px/1.55 system-ui,sans-serif;max-width:46rem;margin:2rem auto;padding:0 1rem;color:#1d1f21;background:#fff}
124
+ code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}
125
+ pre{background:#f4f5f6;padding:.8rem 1rem;overflow-x:auto;border-radius:4px}
126
+ h2{margin-top:2rem;font-size:1.05rem;letter-spacing:.04em}
127
+ dt{margin-top:.6rem;font-weight:600}dd{margin:.2rem 0 0 1.5rem}
128
+ .meta{color:#5b6168}
129
+ @media (prefers-color-scheme:dark){body{color:#e3e5e8;background:#17191c}pre{background:#23262a}.meta{color:#9aa1a9}}`;
130
+ /** The document as a standalone HTML page. */
131
+ export function renderHtml(doc) {
132
+ const header = [doc.version && `Version ${doc.version}`, doc.date].filter(Boolean).join(' · ');
133
+ const body = doc.sections
134
+ .map((s) => `<section id="${slug(s.title)}">\n<h2>${htmlText(s.title)}</h2>\n${s.blocks.map(htmlBlock).join('\n')}\n</section>`)
135
+ .join('\n');
136
+ return `<!doctype html>
137
+ <html lang="en">
138
+ <head>
139
+ <meta charset="utf-8">
140
+ <meta name="viewport" content="width=device-width, initial-scale=1">
141
+ <meta name="generator" content="clyops-doc">
142
+ <title>${htmlText(doc.name)}(${htmlText(doc.section)})</title>
143
+ <style>${STYLE}</style>
144
+ </head>
145
+ <body>
146
+ <header><h1><code>${htmlText(doc.name)}</code></h1>${header ? `<p class="meta">${htmlText(header)}</p>` : ''}</header>
147
+ <main>
148
+ ${body}
149
+ </main>
150
+ </body>
151
+ </html>
152
+ `;
153
+ }
154
+ /** The document in `format`. */
155
+ export function render(doc, format) {
156
+ return format === 'man' ? renderMan(doc) : format === 'markdown' ? renderMarkdown(doc) : renderHtml(doc);
157
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "clyops-doc",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.6.0",
4
+ "description": "Documentation (man pages, Markdown, HTML) from any clyops tool's --help-json-schema.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/wankdanker/clyops.git",
9
+ "directory": "apps/doc"
10
+ },
11
+ "keywords": [
12
+ "clyops",
13
+ "man",
14
+ "manpage",
15
+ "documentation",
16
+ "markdown",
17
+ "cli"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "type": "module",
23
+ "bin": {
24
+ "clyops-doc": "./dist/cli.js"
25
+ },
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "default": "./dist/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "src"
37
+ ],
38
+ "dependencies": {
39
+ "clyops": "0.6.0",
40
+ "clyops-schema": "0.6.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^22.0.0",
44
+ "typescript": "^5.6.0"
45
+ },
46
+ "homepage": "https://clyops.io/applications.html",
47
+ "scripts": {
48
+ "build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
49
+ "typecheck": "tsc -p tsconfig.json --noEmit",
50
+ "test": "node --test test/*.test.mjs"
51
+ }
6
52
  }
package/src/cli.ts ADDED
@@ -0,0 +1,40 @@
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 } from 'clyops';
4
+ import { readFileSync } from 'node:fs';
5
+ 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
+
10
+ const cli = new Cli({ name: 'clyops-doc' });
11
+ 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');
16
+ 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');
18
+ cli.opt('CLYOPS_DOC_SECTION', 'section', 's', '1', 'Manual section for the man page header', 'Output', 'regex:^[1-9][a-z]*$');
19
+ cli.opt('CLYOPS_DOC_VERSION', 'version', 'V', 'optional', 'Version shown in the header', 'Output');
20
+ 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
+ cli.opt('CLYOPS_DOC_SCHEMA', 'schema', '', 'optional', 'Read the schema from this file instead of stdin', 'Input', 'file:readable');
22
+ const args = cli.run();
23
+
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);
30
+ }
31
+ 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
+ }));
38
+ } catch (err) {
39
+ die(1, '%s', (err as Error).message);
40
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
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, 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
+ }
package/src/model.ts ADDED
@@ -0,0 +1,266 @@
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, 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
+
13
+ /** A top-level section, e.g. OPTIONS. */
14
+ export interface Section {
15
+ /** Upper-case title, as in man pages: NAME, SYNOPSIS, OPTIONS. */
16
+ title: string;
17
+ /** The section's content, in order. */
18
+ blocks: Block[];
19
+ }
20
+
21
+ /** The document for one program: its header and sections. */
22
+ export interface Doc {
23
+ /** How the program is run, e.g. `mytool` or `mytool media to-pcm`. */
24
+ name: string;
25
+ /** Manual section, e.g. `1`. */
26
+ section: string;
27
+ /** Version shown in the header, or empty. */
28
+ version: string;
29
+ /** Date shown in the header (YYYY-MM-DD). */
30
+ date: string;
31
+ /** The first line of the program's description. */
32
+ summary: string;
33
+ /** The sections, in order. */
34
+ sections: Section[];
35
+ }
36
+
37
+ /** What a renderer needs to know beyond the schema. */
38
+ export interface DocOptions {
39
+ /** How the program is run (default: the schema's `script`). */
40
+ name?: string;
41
+ /** Manual section (default `1`). */
42
+ section?: string;
43
+ /** Version for the header. */
44
+ version?: string;
45
+ /** Date for the header, YYYY-MM-DD (default: today). */
46
+ date?: string;
47
+ }
48
+
49
+ /** A runnable level: a program without commands, or a leaf command, with what it inherits. */
50
+ export interface Leaf {
51
+ /** The command words after the program name, e.g. ['db', 'migrate']; empty for the program. */
52
+ words: string[];
53
+ /** The level's own description. */
54
+ description: string;
55
+ /** Positional arguments. */
56
+ arguments: SchemaArgument[];
57
+ /** Its own options, then those it inherits, nearest first. */
58
+ options: SchemaOption[];
59
+ /** The nearest declared effects. */
60
+ effects: string[];
61
+ /** The nearest declared stdin. */
62
+ stdin: SchemaStream | null;
63
+ /** The nearest declared stdout. */
64
+ stdout: SchemaStream | null;
65
+ /** Its roles (spec section 14), the nearest declaration of each name winning. */
66
+ roles: SchemaRole[];
67
+ /** Option relationships from the program down. */
68
+ constraints: { type: string; options: string[] }[];
69
+ /** Required commands from the program down. */
70
+ requiredCommands: { command: string; description: string; installHint: string }[];
71
+ }
72
+
73
+ /** Every runnable level of a schema, the way clyops-tools expands commands (spec section 1.7). */
74
+ export function leaves(schema: Schema): Leaf[] {
75
+ const out: Leaf[] = [];
76
+ const visit = (chain: SchemaBody[], words: string[]) => {
77
+ const node = chain[chain.length - 1];
78
+ if (node.commands?.length) {
79
+ for (const child of node.commands) visit([...chain, child], [...words, child.name]);
80
+ return;
81
+ }
82
+ const nearest = <T>(pick: (s: SchemaBody) => T | null | undefined, empty: (v: T) => boolean = () => false) => {
83
+ for (let i = chain.length - 1; i >= 0; i--) {
84
+ const v = pick(chain[i]);
85
+ if (v !== undefined && v !== null && !empty(v)) return v;
86
+ }
87
+ return undefined;
88
+ };
89
+ const roles = new Map<string, SchemaRole>();
90
+ for (const level of chain) for (const r of level.roles ?? []) roles.set(r.name, r);
91
+ out.push({
92
+ words,
93
+ description: node.description,
94
+ arguments: node.arguments,
95
+ options: [...chain].reverse().flatMap((s) => s.options),
96
+ effects: nearest((s) => s.effects, (v) => v.length === 0) ?? [],
97
+ stdin: nearest((s) => s.stdin) ?? null,
98
+ stdout: nearest((s) => s.stdout) ?? null,
99
+ roles: [...roles.values()],
100
+ constraints: chain.flatMap((s) => s.constraints ?? []),
101
+ requiredCommands: chain.flatMap((s) => s.requiredCommands),
102
+ });
103
+ };
104
+ visit([schema], []);
105
+ return out;
106
+ }
107
+
108
+ /** The usage line, as help prints it (spec section 7). */
109
+ export function synopsis(name: string, leaf: Leaf): string {
110
+ const parts = [name, ...leaf.words];
111
+ for (const a of leaf.arguments) parts.push(a.isVariadic ? `[<${a.name}...>]` : a.required ? `<${a.name}>` : `[<${a.name}>]`);
112
+ return [...parts, '[OPTIONS]'].join(' ');
113
+ }
114
+
115
+ /** An option's label: `-s, --long=<value>`, or `--long` for a flag. */
116
+ export function optionLabel(o: SchemaOption): string {
117
+ const long = `--${o.name}${o.isFlag ? '' : '=<value>'}`;
118
+ return o.shortName ? `-${o.shortName}, ${long}` : long;
119
+ }
120
+
121
+ /** An option's description with its notes, as help annotates it (spec section 7). */
122
+ export function optionText(o: SchemaOption): string {
123
+ const notes: string[] = [];
124
+ if (o.required) notes.push('required');
125
+ if (o.isArray) notes.push('repeatable');
126
+ if (o.secret) notes.push('secret: prefer the environment');
127
+ if (o.default && !o.isFlag) notes.push(`default: ${o.default}`);
128
+ if (o.validation) notes.push(`accepts: ${describeRule(o.validation)}`);
129
+ notes.push(`environment: ${o.variableName}`);
130
+ return `${o.description}${o.description ? ' ' : ''}(${notes.join('; ')})`;
131
+ }
132
+
133
+ function argumentText(a: SchemaArgument): string {
134
+ const notes: string[] = [];
135
+ if (a.isVariadic) notes.push('any number');
136
+ else if (!a.required) notes.push(a.default ? `default: ${a.default}` : 'optional');
137
+ if (a.validation) notes.push(`accepts: ${describeRule(a.validation)}`);
138
+ return notes.length ? `${a.description}${a.description ? ' ' : ''}(${notes.join('; ')})` : a.description;
139
+ }
140
+
141
+ function stream(s: SchemaStream): string {
142
+ return [s.description, s.contentType && `(${s.contentType})`].filter(Boolean).join(' ') || 'Declared, without a description.';
143
+ }
144
+
145
+ const CONSTRAINT = {
146
+ exclusive: (o: string[]) => `At most one of ${o.map((n) => `--${n}`).join(', ')}.`,
147
+ requires: (o: string[]) => `Giving --${o[0]} requires ${o.slice(1).map((n) => `--${n}`).join(', ')}.`,
148
+ oneOf: (o: string[]) => `At least one of ${o.map((n) => `--${n}`).join(', ')}.`,
149
+ } as Record<string, (o: string[]) => string>;
150
+
151
+ const EFFECTS: Record<string, string> = {
152
+ 'read-only': 'changes nothing',
153
+ idempotent: 'running it twice is the same as once',
154
+ destructive: 'deletes or overwrites things',
155
+ network: 'talks to the outside world',
156
+ };
157
+
158
+ /** A leaf's parts: its arguments, options and streams as definition lists, and notes as text. */
159
+ function leafParts(leaf: Leaf): { args?: Block; options?: Block; streams?: Block; notes: Block[] } {
160
+ const dl = (items: { term: string; text: string }[]): Block | undefined => (items.length ? { kind: 'dl', items } : undefined);
161
+ const streams = [
162
+ ...(leaf.stdin ? [{ term: 'stdin', text: stream(leaf.stdin) }] : []),
163
+ ...(leaf.stdout ? [{ term: 'stdout', text: stream(leaf.stdout) }] : []),
164
+ ...leaf.roles.map((r) => ({ term: `${r.direction === 'in' ? 'input' : 'output'} role ${r.name}`, text: stream(r) })),
165
+ ];
166
+ const notes = [
167
+ ...(leaf.effects.length ? [`Effects: ${leaf.effects.map((e) => `${e} (${EFFECTS[e] ?? e})`).join(', ')}.`] : []),
168
+ ...leaf.constraints.map((c) => CONSTRAINT[c.type]?.(c.options) ?? ''),
169
+ ...leaf.requiredCommands.map((r) => `Needs ${r.command} on PATH: ${r.description}${r.installHint ? ` (${r.installHint})` : ''}.`),
170
+ ].filter(Boolean);
171
+ return {
172
+ args: dl(leaf.arguments.map((a) => ({ term: a.isVariadic ? `<${a.name}...>` : `<${a.name}>`, text: argumentText(a) }))),
173
+ options: dl(leaf.options.filter((o) => o.name !== 'help').map((o) => ({ term: optionLabel(o), text: optionText(o) }))),
174
+ streams: dl(streams),
175
+ notes: notes.map((text) => ({ kind: 'p', text })),
176
+ };
177
+ }
178
+
179
+ /** The first sentence of a description (its first line at most), for the NAME line. */
180
+ export function firstSentence(text: string): string {
181
+ const line = text.split('\n')[0].trim();
182
+ const end = line.search(/[.!?](\s|$)/);
183
+ return end < 0 ? line : line.slice(0, end);
184
+ }
185
+
186
+ /** Today in UTC, or SOURCE_DATE_EPOCH's date for reproducible builds. */
187
+ export function defaultDate(env: NodeJS.ProcessEnv = process.env): string {
188
+ const epoch = Number(env.SOURCE_DATE_EPOCH);
189
+ const when = Number.isFinite(epoch) && env.SOURCE_DATE_EPOCH ? new Date(epoch * 1000) : new Date();
190
+ return when.toISOString().slice(0, 10);
191
+ }
192
+
193
+ /**
194
+ * The document for a schema. Throws for input that isn't a clyops schema (`"clyops": 1`, with
195
+ * description, options and arguments).
196
+ */
197
+ export function buildDoc(schema: Schema, opts: DocOptions = {}): Doc {
198
+ if (!schema || typeof schema !== 'object' || schema.clyops !== 1 || !Array.isArray(schema.options) || !Array.isArray(schema.arguments)) {
199
+ throw new Error('not a clyops schema (expected "clyops": 1 with options and arguments)');
200
+ }
201
+ const name = opts.name || schema.script;
202
+ const all = leaves(schema);
203
+ const program = all.length === 1 && all[0].words.length === 0;
204
+ const summary = firstSentence(schema.description || '');
205
+ const sections: Section[] = [];
206
+
207
+ sections.push({ title: 'NAME', blocks: [{ kind: 'p', text: summary ? `${name} - ${summary}` : name }] });
208
+ sections.push({ title: 'SYNOPSIS', blocks: [{ kind: 'pre', text: all.map((l) => synopsis(name, l)).join('\n') }] });
209
+ if (schema.description) sections.push({ title: 'DESCRIPTION', blocks: schema.description.split(/\n{2,}/).map((text) => ({ kind: 'p', text })) });
210
+
211
+ if (program) {
212
+ const { args, options, streams, notes } = leafParts(all[0]);
213
+ if (args) sections.push({ title: 'ARGUMENTS', blocks: [args] });
214
+ if (options) sections.push({ title: 'OPTIONS', blocks: [options] });
215
+ if (streams) sections.push({ title: 'STREAMS', blocks: [streams] });
216
+ if (notes.length) sections.push({ title: 'NOTES', blocks: notes });
217
+ } else {
218
+ // One subsection per runnable command, with what it inherits.
219
+ const blocks: Block[] = [];
220
+ for (const leaf of all) {
221
+ const { args, options, streams, notes } = leafParts(leaf);
222
+ blocks.push({ kind: 'h', text: [name, ...leaf.words].join(' ') });
223
+ if (leaf.description) blocks.push({ kind: 'p', text: leaf.description });
224
+ blocks.push({ kind: 'pre', text: synopsis(name, leaf) });
225
+ for (const [label, part] of [['Arguments', args], ['Options', options], ['Streams', streams]] as const) {
226
+ if (part) blocks.push({ kind: 'p', text: `${label}:` }, part);
227
+ }
228
+ blocks.push(...notes);
229
+ }
230
+ sections.push({ title: 'COMMANDS', blocks });
231
+ }
232
+
233
+ // Every option's variable, once, in the order first seen.
234
+ 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);
237
+ sections.push({
238
+ title: 'ENVIRONMENT',
239
+ 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.' },
241
+ {
242
+ kind: 'dl',
243
+ items: [
244
+ ...[...env].map(([term, text]) => ({ term, text })),
245
+ ...(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.' }] : []),
246
+ { term: 'CLYOPS_SILENT', text: 'true suppresses informational and warning messages.' },
247
+ { term: 'CLYOPS_MAX_WIDTH', text: 'The width help is wrapped to (default 100).' },
248
+ { term: 'NO_COLOR', text: 'Set to turn off colored log levels.' },
249
+ ],
250
+ },
251
+ ],
252
+ });
253
+ sections.push({
254
+ title: 'EXIT STATUS',
255
+ blocks: [{
256
+ kind: 'dl',
257
+ items: [
258
+ { term: '0', text: 'Success, or help was shown.' },
259
+ { term: '1', text: 'Invalid arguments, options or input, a missing required command, or a failure the program reports.' },
260
+ { term: 'other', text: 'Defined by the program.' },
261
+ ],
262
+ }],
263
+ });
264
+ 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 };
266
+ }
package/src/render.ts ADDED
@@ -0,0 +1,178 @@
1
+ // The document (model.ts) as a man page, Markdown or HTML.
2
+ import type { Block, Doc } from './model.js';
3
+
4
+ /** Output formats. */
5
+ export type Format = 'man' | 'markdown' | 'html';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // man(7): plain .TH .SH .SS .PP .TP .nf/.fi macros, which groff and mandoc both render.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /** Text for a troff line: backslashes and dashes escaped, a leading `.` or `'` made literal. */
12
+ export function troff(text: string): string {
13
+ return text
14
+ .split('\n')
15
+ .map((line) => {
16
+ const escaped = line.replace(/\\/g, '\\e').replace(/-/g, '\\-');
17
+ return /^[.']/.test(escaped) ? `\\&${escaped}` : escaped;
18
+ })
19
+ .join('\n');
20
+ }
21
+
22
+ /** A macro argument: escaped, and quoted so spaces stay in it. */
23
+ function arg(text: string): string {
24
+ return `"${troff(text).replace(/"/g, '\\(dq')}"`;
25
+ }
26
+
27
+ function manBlock(b: Block): string[] {
28
+ switch (b.kind) {
29
+ case 'p':
30
+ return ['.PP', troff(b.text)];
31
+ case 'h':
32
+ return [`.SS ${arg(b.text)}`];
33
+ case 'pre':
34
+ return ['.PP', '.nf', '.RS 4', troff(b.text), '.RE', '.fi'];
35
+ case 'dl':
36
+ return b.items.flatMap((i) => ['.TP', `\\fB${troff(i.term)}\\fR`, troff(i.text)]);
37
+ }
38
+ }
39
+
40
+ /** The document as a man(7) page. */
41
+ export function renderMan(doc: Doc): string {
42
+ const title = doc.name.toUpperCase().replace(/\s+/g, '-');
43
+ const lines = [
44
+ `.\\" Generated by clyops-doc from ${doc.name} --help-json-schema.`,
45
+ `.TH ${arg(title)} ${arg(doc.section)} ${arg(doc.date)} ${arg([doc.name, doc.version].filter(Boolean).join(' '))} ${arg('User Commands')}`,
46
+ ];
47
+ for (const s of doc.sections) {
48
+ lines.push(`.SH ${arg(s.title)}`);
49
+ // NAME is one line, "name \- summary", without a paragraph macro (man(7)).
50
+ if (s.title === 'NAME') {
51
+ lines.push(troff(`${doc.name}${doc.summary ? ` - ${doc.summary}` : ''}`));
52
+ continue;
53
+ }
54
+ for (const b of s.blocks) lines.push(...manBlock(b));
55
+ }
56
+ return lines.join('\n') + '\n';
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Markdown (CommonMark)
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * Text that renders as itself in CommonMark: characters that can start markup escaped, and nothing
65
+ * else, so the Markdown stays readable (`DRY_RUN`, `(default: 1)`).
66
+ */
67
+ export function markdownText(text: string): string {
68
+ return text
69
+ .split('\n')
70
+ .map((line) =>
71
+ line
72
+ .replace(/[\\`*[\]<>|]/g, '\\$&')
73
+ // _ only emphasises at a word boundary.
74
+ .replace(/(^|[^A-Za-z0-9])_|_(?=[^A-Za-z0-9]|$)/g, (m) => m.replace('_', '\\_'))
75
+ // A block marker at the start of a line: a heading, quote, list item or rule.
76
+ .replace(/^(\s*)([#>+=-]|\d+[.)])/, '$1\\$2'))
77
+ .join('\n');
78
+ }
79
+
80
+ /** Inline code that may itself contain backticks. */
81
+ function code(text: string): string {
82
+ const run = Math.max(0, ...(text.match(/`+/g) ?? []).map((m) => m.length)) + 1;
83
+ const fence = '`'.repeat(run);
84
+ return run > 1 ? `${fence} ${text} ${fence}` : `${fence}${text}${fence}`;
85
+ }
86
+
87
+ function fenced(text: string): string[] {
88
+ const run = Math.max(2, ...(text.match(/`+/g) ?? []).map((m) => m.length)) + 1;
89
+ const fence = '`'.repeat(run);
90
+ return [fence, text, fence];
91
+ }
92
+
93
+ function markdownBlock(b: Block): string[] {
94
+ switch (b.kind) {
95
+ case 'p':
96
+ return [markdownText(b.text), ''];
97
+ case 'h':
98
+ return [`### ${code(b.text)}`, ''];
99
+ case 'pre':
100
+ return [...fenced(b.text), ''];
101
+ case 'dl':
102
+ return [...b.items.map((i) => `- ${code(i.term)}: ${markdownText(i.text)}`), ''];
103
+ }
104
+ }
105
+
106
+ /** The document as Markdown. */
107
+ export function renderMarkdown(doc: Doc): string {
108
+ const lines = [`# ${code(doc.name)}`, ''];
109
+ const header = [doc.version && `Version ${doc.version}`, doc.date].filter(Boolean).join(' · ');
110
+ if (header) lines.push(`_${markdownText(header)}_`, '');
111
+ for (const s of doc.sections) {
112
+ lines.push(`## ${s.title}`, '');
113
+ for (const b of s.blocks) lines.push(...markdownBlock(b));
114
+ }
115
+ return lines.join('\n').replace(/\n+$/, '\n');
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // HTML: one standalone page, everything escaped.
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /** Text with HTML's special characters escaped. */
123
+ export function htmlText(text: string): string {
124
+ return text.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] as string);
125
+ }
126
+
127
+ const slug = (text: string) => text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
128
+
129
+ function htmlBlock(b: Block): string {
130
+ switch (b.kind) {
131
+ case 'p':
132
+ return `<p>${htmlText(b.text)}</p>`;
133
+ case 'h':
134
+ return `<h3 id="${slug(b.text)}"><code>${htmlText(b.text)}</code></h3>`;
135
+ case 'pre':
136
+ return `<pre><code>${htmlText(b.text)}</code></pre>`;
137
+ case 'dl':
138
+ return `<dl>\n${b.items.map((i) => `<dt><code>${htmlText(i.term)}</code></dt>\n<dd>${htmlText(i.text)}</dd>`).join('\n')}\n</dl>`;
139
+ }
140
+ }
141
+
142
+ const STYLE = `body{font:16px/1.55 system-ui,sans-serif;max-width:46rem;margin:2rem auto;padding:0 1rem;color:#1d1f21;background:#fff}
143
+ code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}
144
+ pre{background:#f4f5f6;padding:.8rem 1rem;overflow-x:auto;border-radius:4px}
145
+ h2{margin-top:2rem;font-size:1.05rem;letter-spacing:.04em}
146
+ dt{margin-top:.6rem;font-weight:600}dd{margin:.2rem 0 0 1.5rem}
147
+ .meta{color:#5b6168}
148
+ @media (prefers-color-scheme:dark){body{color:#e3e5e8;background:#17191c}pre{background:#23262a}.meta{color:#9aa1a9}}`;
149
+
150
+ /** The document as a standalone HTML page. */
151
+ export function renderHtml(doc: Doc): string {
152
+ const header = [doc.version && `Version ${doc.version}`, doc.date].filter(Boolean).join(' · ');
153
+ const body = doc.sections
154
+ .map((s) => `<section id="${slug(s.title)}">\n<h2>${htmlText(s.title)}</h2>\n${s.blocks.map(htmlBlock).join('\n')}\n</section>`)
155
+ .join('\n');
156
+ return `<!doctype html>
157
+ <html lang="en">
158
+ <head>
159
+ <meta charset="utf-8">
160
+ <meta name="viewport" content="width=device-width, initial-scale=1">
161
+ <meta name="generator" content="clyops-doc">
162
+ <title>${htmlText(doc.name)}(${htmlText(doc.section)})</title>
163
+ <style>${STYLE}</style>
164
+ </head>
165
+ <body>
166
+ <header><h1><code>${htmlText(doc.name)}</code></h1>${header ? `<p class="meta">${htmlText(header)}</p>` : ''}</header>
167
+ <main>
168
+ ${body}
169
+ </main>
170
+ </body>
171
+ </html>
172
+ `;
173
+ }
174
+
175
+ /** The document in `format`. */
176
+ export function render(doc: Doc, format: Format): string {
177
+ return format === 'man' ? renderMan(doc) : format === 'markdown' ? renderMarkdown(doc) : renderHtml(doc);
178
+ }