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/dist/model.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
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
|
+
epilog: node.epilog,
|
|
30
|
+
arguments: node.arguments,
|
|
31
|
+
options: [...chain].reverse().flatMap((s) => s.options),
|
|
32
|
+
effects: nearest((s) => s.effects, (v) => v.length === 0) ?? [],
|
|
33
|
+
stdin: nearest((s) => s.stdin) ?? null,
|
|
34
|
+
stdout: nearest((s) => s.stdout) ?? null,
|
|
35
|
+
roles: [...roles.values()],
|
|
36
|
+
constraints: chain.flatMap((s) => s.constraints ?? []),
|
|
37
|
+
requiredCommands: chain.flatMap((s) => s.requiredCommands),
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
visit([schema], []);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** The usage line, as help prints it (spec section 7). */
|
|
44
|
+
export function synopsis(name, leaf) {
|
|
45
|
+
const parts = [name, ...leaf.words];
|
|
46
|
+
for (const a of leaf.arguments)
|
|
47
|
+
parts.push(a.isVariadic ? `[<${a.name}...>]` : a.required ? `<${a.name}>` : `[<${a.name}>]`);
|
|
48
|
+
return [...parts, '[OPTIONS]'].join(' ');
|
|
49
|
+
}
|
|
50
|
+
/** An option's label: `-s, --long=<value>`, or `--long` for a flag. */
|
|
51
|
+
export function optionLabel(o) {
|
|
52
|
+
const long = `--${o.name}${o.isFlag ? '' : '=<value>'}`;
|
|
53
|
+
return o.shortName ? `-${o.shortName}, ${long}` : long;
|
|
54
|
+
}
|
|
55
|
+
/** An option's description with its notes, as help annotates it (spec section 7). */
|
|
56
|
+
export function optionText(o) {
|
|
57
|
+
const notes = [];
|
|
58
|
+
if (o.required)
|
|
59
|
+
notes.push('required');
|
|
60
|
+
if (o.isArray)
|
|
61
|
+
notes.push('repeatable');
|
|
62
|
+
if (o.secret)
|
|
63
|
+
notes.push('secret: set it in the environment, never on the command line');
|
|
64
|
+
if (o.default && !o.isFlag)
|
|
65
|
+
notes.push(`default: ${o.default}`);
|
|
66
|
+
if (o.validation)
|
|
67
|
+
notes.push(`accepts: ${describeRule(o.validation)}`);
|
|
68
|
+
notes.push(`environment: ${o.variableName}`);
|
|
69
|
+
return `${o.description}${o.description ? ' ' : ''}(${notes.join('; ')})`;
|
|
70
|
+
}
|
|
71
|
+
function argumentText(a) {
|
|
72
|
+
const notes = [];
|
|
73
|
+
if (a.isVariadic)
|
|
74
|
+
notes.push('any number');
|
|
75
|
+
else if (!a.required)
|
|
76
|
+
notes.push(a.default ? `default: ${a.default}` : 'optional');
|
|
77
|
+
if (a.validation)
|
|
78
|
+
notes.push(`accepts: ${describeRule(a.validation)}`);
|
|
79
|
+
return notes.length ? `${a.description}${a.description ? ' ' : ''}(${notes.join('; ')})` : a.description;
|
|
80
|
+
}
|
|
81
|
+
function stream(s) {
|
|
82
|
+
return [s.description, s.contentType && `(${s.contentType})`].filter(Boolean).join(' ') || 'Declared, without a description.';
|
|
83
|
+
}
|
|
84
|
+
const CONSTRAINT = {
|
|
85
|
+
exclusive: (o) => `At most one of ${o.map((n) => `--${n}`).join(', ')}.`,
|
|
86
|
+
requires: (o) => `Giving --${o[0]} requires ${o.slice(1).map((n) => `--${n}`).join(', ')}.`,
|
|
87
|
+
oneOf: (o) => `At least one of ${o.map((n) => `--${n}`).join(', ')}.`,
|
|
88
|
+
};
|
|
89
|
+
const EFFECTS = {
|
|
90
|
+
'read-only': 'changes nothing',
|
|
91
|
+
idempotent: 'running it twice is the same as once',
|
|
92
|
+
destructive: 'deletes or overwrites things',
|
|
93
|
+
network: 'talks to the outside world',
|
|
94
|
+
};
|
|
95
|
+
/** A leaf's parts: its arguments, options and streams as definition lists, and notes as text. */
|
|
96
|
+
function leafParts(leaf) {
|
|
97
|
+
const dl = (items) => (items.length ? { kind: 'dl', items } : undefined);
|
|
98
|
+
const streams = [
|
|
99
|
+
...(leaf.stdin ? [{ term: 'stdin', text: stream(leaf.stdin) }] : []),
|
|
100
|
+
...(leaf.stdout ? [{ term: 'stdout', text: stream(leaf.stdout) }] : []),
|
|
101
|
+
...leaf.roles.map((r) => ({ term: `${r.direction === 'in' ? 'input' : 'output'} role ${r.name}`, text: stream(r) })),
|
|
102
|
+
];
|
|
103
|
+
const notes = [
|
|
104
|
+
leaf.effects.length
|
|
105
|
+
? `Effects: ${leaf.effects.map((e) => `${e} (${EFFECTS[e] ?? e})`).join(', ')}.`
|
|
106
|
+
: 'Effects: not declared, so treat it as possibly destructive.',
|
|
107
|
+
...leaf.constraints.map((c) => CONSTRAINT[c.type]?.(c.options) ?? ''),
|
|
108
|
+
...leaf.requiredCommands.map((r) => `Needs ${r.command} on PATH: ${r.description}${r.installHint ? ` (${r.installHint})` : ''}.`),
|
|
109
|
+
].filter(Boolean);
|
|
110
|
+
return {
|
|
111
|
+
args: dl(leaf.arguments.map((a) => ({ term: a.isVariadic ? `<${a.name}...>` : `<${a.name}>`, text: argumentText(a) }))),
|
|
112
|
+
options: dl(leaf.options.filter((o) => o.name !== 'help').map((o) => ({ term: optionLabel(o), text: optionText(o) }))),
|
|
113
|
+
streams: dl(streams),
|
|
114
|
+
notes: notes.map((text) => ({ kind: 'p', text })),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** The first sentence of a description (its first line at most), for the NAME line. */
|
|
118
|
+
export function firstSentence(text) {
|
|
119
|
+
const line = text.split('\n')[0].trim();
|
|
120
|
+
const end = line.search(/[.!?](\s|$)/);
|
|
121
|
+
return end < 0 ? line : line.slice(0, end);
|
|
122
|
+
}
|
|
123
|
+
/** Today in UTC, or SOURCE_DATE_EPOCH's date for reproducible builds. */
|
|
124
|
+
export function defaultDate(env = process.env) {
|
|
125
|
+
const epoch = Number(env.SOURCE_DATE_EPOCH);
|
|
126
|
+
const when = Number.isFinite(epoch) && env.SOURCE_DATE_EPOCH ? new Date(epoch * 1000) : new Date();
|
|
127
|
+
return when.toISOString().slice(0, 10);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The document for a schema. Throws for input that isn't a clyops schema (`"clyops": 1`, with
|
|
131
|
+
* description, options and arguments).
|
|
132
|
+
*/
|
|
133
|
+
export function buildDoc(schema, opts = {}) {
|
|
134
|
+
if (!schema || typeof schema !== 'object' || schema.clyops !== 1 || !Array.isArray(schema.options) || !Array.isArray(schema.arguments)) {
|
|
135
|
+
throw new Error('not a clyops schema (expected "clyops": 1 with options and arguments)');
|
|
136
|
+
}
|
|
137
|
+
const name = opts.name || schema.script;
|
|
138
|
+
const all = leaves(schema);
|
|
139
|
+
const program = all.length === 1 && all[0].words.length === 0;
|
|
140
|
+
const summary = firstSentence(schema.description || '');
|
|
141
|
+
const sections = [];
|
|
142
|
+
const page = opts.page || name;
|
|
143
|
+
sections.push({ title: 'NAME', blocks: [{ kind: 'p', text: summary ? `${page} - ${summary}` : page }] });
|
|
144
|
+
sections.push({ title: 'SYNOPSIS', blocks: [{ kind: 'pre', text: all.map((l) => synopsis(name, l)).join('\n') }] });
|
|
145
|
+
if (schema.description)
|
|
146
|
+
sections.push({ title: 'DESCRIPTION', blocks: schema.description.split(/\n{2,}/).map((text) => ({ kind: 'p', text })) });
|
|
147
|
+
if (program) {
|
|
148
|
+
const { args, options, streams, notes } = leafParts(all[0]);
|
|
149
|
+
if (args)
|
|
150
|
+
sections.push({ title: 'ARGUMENTS', blocks: [args] });
|
|
151
|
+
if (options)
|
|
152
|
+
sections.push({ title: 'OPTIONS', blocks: [options] });
|
|
153
|
+
if (streams)
|
|
154
|
+
sections.push({ title: 'STREAMS', blocks: [streams] });
|
|
155
|
+
if (notes.length)
|
|
156
|
+
sections.push({ title: 'NOTES', blocks: notes });
|
|
157
|
+
}
|
|
158
|
+
else if (opts.split) {
|
|
159
|
+
// The commands have pages of their own: the program's options here, and a list of the commands.
|
|
160
|
+
const own = schema.options.filter((o) => o.name !== 'help').map((o) => ({ term: optionLabel(o), text: optionText(o) }));
|
|
161
|
+
if (own.length)
|
|
162
|
+
sections.push({ title: 'OPTIONS', blocks: [{ kind: 'p', text: 'Accepted by every command.' }, { kind: 'dl', items: own }] });
|
|
163
|
+
sections.push({
|
|
164
|
+
title: 'COMMANDS',
|
|
165
|
+
blocks: [{ kind: 'dl', items: all.map((l) => ({ term: [name, ...l.words].join(' '), text: `${sentence(l.description)}See ${pageName(name, l.words)}(${opts.section || '1'}).` })) }],
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
// One subsection per runnable command, with what it inherits.
|
|
170
|
+
const blocks = [];
|
|
171
|
+
for (const leaf of all) {
|
|
172
|
+
const { args, options, streams, notes } = leafParts(leaf);
|
|
173
|
+
blocks.push({ kind: 'h', text: [name, ...leaf.words].join(' ') });
|
|
174
|
+
if (leaf.description)
|
|
175
|
+
blocks.push({ kind: 'p', text: leaf.description });
|
|
176
|
+
blocks.push({ kind: 'pre', text: synopsis(name, leaf) });
|
|
177
|
+
for (const [label, part] of [['Arguments', args], ['Options', options], ['Streams', streams]]) {
|
|
178
|
+
if (part)
|
|
179
|
+
blocks.push({ kind: 'p', text: `${label}:` }, part);
|
|
180
|
+
}
|
|
181
|
+
blocks.push(...notes);
|
|
182
|
+
}
|
|
183
|
+
sections.push({ title: 'COMMANDS', blocks });
|
|
184
|
+
}
|
|
185
|
+
// Every option's variable, once, in the order first seen.
|
|
186
|
+
const env = new Map();
|
|
187
|
+
for (const leaf of opts.split && !program ? [{ options: schema.options }] : all)
|
|
188
|
+
for (const o of leaf.options)
|
|
189
|
+
if (o.name !== 'help' && !env.has(o.variableName))
|
|
190
|
+
env.set(o.variableName, `Sets --${o.name}.${o.secret ? ' A secret: set it here rather than on the command line.' : ''}`);
|
|
191
|
+
const anyRoles = opts.split && !program ? Boolean(schema.roles?.length) : all.some((l) => l.roles.length);
|
|
192
|
+
sections.push({
|
|
193
|
+
title: 'ENVIRONMENT',
|
|
194
|
+
blocks: [
|
|
195
|
+
{ kind: 'p', text: schema.config
|
|
196
|
+
? 'An option given on the command line takes precedence over the config file, which takes precedence over the environment, which takes precedence over the default.'
|
|
197
|
+
: 'An option given on the command line takes precedence over the environment, which takes precedence over the default.' },
|
|
198
|
+
{
|
|
199
|
+
kind: 'dl',
|
|
200
|
+
items: [
|
|
201
|
+
...[...env].map(([term, text]) => ({ term, text })),
|
|
202
|
+
...(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.' }] : []),
|
|
203
|
+
{ term: 'CLYOPS_SILENT', text: 'true suppresses informational and warning messages.' },
|
|
204
|
+
{ term: 'CLYOPS_MAX_WIDTH', text: 'The width help is wrapped to (default 100).' },
|
|
205
|
+
{ term: 'NO_COLOR', text: 'Set to turn off colored log levels.' },
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
if (schema.config)
|
|
211
|
+
sections.push({ title: 'FILES', blocks: files(schema.config, schema.options) });
|
|
212
|
+
sections.push({
|
|
213
|
+
title: 'EXIT STATUS',
|
|
214
|
+
blocks: [{
|
|
215
|
+
kind: 'dl',
|
|
216
|
+
items: [
|
|
217
|
+
{ term: '0', text: 'Success, or help was shown.' },
|
|
218
|
+
{ term: '1', text: 'Invalid arguments, options or input, a missing required command, or a failure the program reports.' },
|
|
219
|
+
{ term: 'other', text: 'Defined by the program.' },
|
|
220
|
+
],
|
|
221
|
+
}],
|
|
222
|
+
});
|
|
223
|
+
if (schema.epilog)
|
|
224
|
+
sections.push({ title: 'EXAMPLES', blocks: [{ kind: 'pre', text: schema.epilog.replace(/\n+$/, '') }] });
|
|
225
|
+
if (opts.split && !program)
|
|
226
|
+
sections.push({ title: 'SEE ALSO', blocks: [{ kind: 'refs', items: all.map((l) => ({ page: pageName(name, l.words), section: opts.section || '1' })) }] });
|
|
227
|
+
return { name, page, section: opts.section || '1', version: opts.version || '', date: opts.date || defaultDate(), summary, sections };
|
|
228
|
+
}
|
|
229
|
+
// The config file (spec section 4): where it comes from and what a line looks like.
|
|
230
|
+
function files(config, options) {
|
|
231
|
+
const opt = options.find((o) => o.name === config.option);
|
|
232
|
+
const from = [`--${config.option}`, ...(opt?.variableName ? [opt.variableName] : [])].join(' or ');
|
|
233
|
+
const where = opt?.default ? `, ${opt.default} by default` : '';
|
|
234
|
+
const prefixes = config.prefixes.length ? `, on lines starting with ${config.prefixes.join(' or ')}` : '';
|
|
235
|
+
const example = options.find((o) => o.name !== config.option && o.name !== 'help' && !o.isFlag) ?? options.find((o) => o.name !== config.option && o.name !== 'help');
|
|
236
|
+
return [
|
|
237
|
+
{ kind: 'p', text: `The config file is the one named by ${from}${where}, resolved against the current directory. An empty value or disabled skips it; a file that doesn't exist is an error.` },
|
|
238
|
+
{ kind: 'p', text: `It sets options with one key=value per line${prefixes}. The key is an option's long name, with or without the leading --. Blank lines and lines starting with # are ignored, and @include PATH reads another file in place, relative to the one including it. Later lines override earlier ones, and an option given on the command line wins over the file.` },
|
|
239
|
+
...(example ? [{ kind: 'pre', text: `${config.prefixes[0] ?? ''}${example.name}=${example.isFlag ? 'true' : example.default || 'VALUE'}` }] : []),
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
// Text as a sentence followed by a space, ending in a full stop unless it has its own; nothing for none.
|
|
243
|
+
function sentence(text) {
|
|
244
|
+
return text ? `${text}${/[.!?]$/.test(text) ? '' : '.'} ` : '';
|
|
245
|
+
}
|
|
246
|
+
/** A page's name: the invocation's words joined by hyphens, e.g. `mytool-db-migrate`. */
|
|
247
|
+
export function pageName(name, words = []) {
|
|
248
|
+
return [name, ...words].join(' ').trim().split(/\s+/).join('-');
|
|
249
|
+
}
|
|
250
|
+
/** A runnable command as a program of its own, with everything it inherits, for a page of its own. */
|
|
251
|
+
export function leafSchema(schema, leaf) {
|
|
252
|
+
return {
|
|
253
|
+
clyops: 1, script: schema.script, description: leaf.description, epilog: leaf.epilog,
|
|
254
|
+
arguments: leaf.arguments, options: leaf.options, config: schema.config ?? null,
|
|
255
|
+
requiredCommands: leaf.requiredCommands, effects: leaf.effects,
|
|
256
|
+
constraints: leaf.constraints, stdin: leaf.stdin, stdout: leaf.stdout,
|
|
257
|
+
roles: leaf.roles, commands: [],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The index page of a directory of tools (`--root`): what the dispatcher `name` runs, each tool with
|
|
262
|
+
* a reference to its own page.
|
|
263
|
+
*/
|
|
264
|
+
export function toolsetDoc(name, description, tools, opts = {}) {
|
|
265
|
+
const section = opts.section || '1';
|
|
266
|
+
const page = opts.page || pageName(name);
|
|
267
|
+
const summary = firstSentence(description);
|
|
268
|
+
const sections = [
|
|
269
|
+
{ title: 'NAME', blocks: [{ kind: 'p', text: summary ? `${page} - ${summary}` : page }] },
|
|
270
|
+
{ title: 'SYNOPSIS', blocks: [{ kind: 'pre', text: tools.map((t) => `${[name, ...t.words].join(' ')} [ARGUMENTS] [OPTIONS]`).join('\n') }] },
|
|
271
|
+
];
|
|
272
|
+
if (description)
|
|
273
|
+
sections.push({ title: 'DESCRIPTION', blocks: description.split(/\n{2,}/).map((text) => ({ kind: 'p', text })) });
|
|
274
|
+
sections.push({
|
|
275
|
+
title: 'TOOLS',
|
|
276
|
+
blocks: [{ kind: 'dl', items: tools.map((t) => ({ term: [name, ...t.words].join(' '), text: `${sentence(firstSentence(t.description))}See ${pageName(name, t.words)}(${section}).` })) }],
|
|
277
|
+
});
|
|
278
|
+
sections.push({ title: 'SEE ALSO', blocks: [{ kind: 'refs', items: tools.map((t) => ({ page: pageName(name, t.words), section })) }] });
|
|
279
|
+
return { name, page, section, version: opts.version || '', date: opts.date || defaultDate(), summary, sections };
|
|
280
|
+
}
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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 first line of a Markdown page: a comment, invisible when rendered, saying it is generated. */
|
|
14
|
+
export declare const MARKDOWN_MARKER = "<!-- Generated by clyops-doc from --help-json-schema. Do not edit; regenerate instead. -->";
|
|
15
|
+
/** The document as Markdown. */
|
|
16
|
+
export declare function renderMarkdown(doc: Doc): string;
|
|
17
|
+
/** Text with HTML's special characters escaped. */
|
|
18
|
+
export declare function htmlText(text: string): string;
|
|
19
|
+
/** The document as a standalone HTML page. */
|
|
20
|
+
export declare function renderHtml(doc: Doc): string;
|
|
21
|
+
/** The document in `format`. */
|
|
22
|
+
export declare function render(doc: Doc, format: Format): string;
|
|
23
|
+
//# 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;AAsBD,qCAAqC;AACrC,wBAAgB,SAAS,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAgB1C;AAMD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAWjD;AA8BD,qGAAqG;AACrG,eAAO,MAAM,eAAe,+FAA+F,CAAC;AAE5H,gCAAgC;AAChC,wBAAgB,cAAc,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAS/C;AAMD,mDAAmD;AACnD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AA2BD,8CAA8C;AAC9C,wBAAgB,UAAU,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAsB3C;AAED,gCAAgC;AAChC,wBAAgB,MAAM,CAAC,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEvD"}
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
case 'refs':
|
|
29
|
+
return ['.PP', ...b.items.map((r, n) => `.BR ${arg(r.page)} ${arg(`(${r.section})${n < b.items.length - 1 ? ',' : ''}`)}`)];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** The document as a man(7) page. */
|
|
33
|
+
export function renderMan(doc) {
|
|
34
|
+
const title = doc.page.toUpperCase().replace(/\s+/g, '-');
|
|
35
|
+
const lines = [
|
|
36
|
+
`.\\" Generated by clyops-doc from ${doc.name} --help-json-schema.`,
|
|
37
|
+
`.TH ${arg(title)} ${arg(doc.section)} ${arg(doc.date)} ${arg([doc.name, doc.version].filter(Boolean).join(' '))} ${arg('User Commands')}`,
|
|
38
|
+
];
|
|
39
|
+
for (const s of doc.sections) {
|
|
40
|
+
lines.push(`.SH ${arg(s.title)}`);
|
|
41
|
+
// NAME is one line, "name \- summary", without a paragraph macro (man(7)).
|
|
42
|
+
if (s.title === 'NAME') {
|
|
43
|
+
lines.push(troff(`${doc.page}${doc.summary ? ` - ${doc.summary}` : ''}`));
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
for (const b of s.blocks)
|
|
47
|
+
lines.push(...manBlock(b));
|
|
48
|
+
}
|
|
49
|
+
return lines.join('\n') + '\n';
|
|
50
|
+
}
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Markdown (CommonMark)
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
/**
|
|
55
|
+
* Text that renders as itself in CommonMark: characters that can start markup escaped, and nothing
|
|
56
|
+
* else, so the Markdown stays readable (`DRY_RUN`, `(default: 1)`).
|
|
57
|
+
*/
|
|
58
|
+
export function markdownText(text) {
|
|
59
|
+
return text
|
|
60
|
+
.split('\n')
|
|
61
|
+
.map((line) => line
|
|
62
|
+
.replace(/[\\`*[\]<>|]/g, '\\$&')
|
|
63
|
+
// _ only emphasises at a word boundary.
|
|
64
|
+
.replace(/(^|[^A-Za-z0-9])_|_(?=[^A-Za-z0-9]|$)/g, (m) => m.replace('_', '\\_'))
|
|
65
|
+
// A block marker at the start of a line: a heading, quote, list item or rule.
|
|
66
|
+
.replace(/^(\s*)([#>+=-]|\d+[.)])/, '$1\\$2'))
|
|
67
|
+
.join('\n');
|
|
68
|
+
}
|
|
69
|
+
/** Inline code that may itself contain backticks. */
|
|
70
|
+
function code(text) {
|
|
71
|
+
const run = Math.max(0, ...(text.match(/`+/g) ?? []).map((m) => m.length)) + 1;
|
|
72
|
+
const fence = '`'.repeat(run);
|
|
73
|
+
return run > 1 ? `${fence} ${text} ${fence}` : `${fence}${text}${fence}`;
|
|
74
|
+
}
|
|
75
|
+
function fenced(text) {
|
|
76
|
+
const run = Math.max(2, ...(text.match(/`+/g) ?? []).map((m) => m.length)) + 1;
|
|
77
|
+
const fence = '`'.repeat(run);
|
|
78
|
+
return [fence, text, fence];
|
|
79
|
+
}
|
|
80
|
+
function markdownBlock(b) {
|
|
81
|
+
switch (b.kind) {
|
|
82
|
+
case 'p':
|
|
83
|
+
return [markdownText(b.text), ''];
|
|
84
|
+
case 'h':
|
|
85
|
+
return [`### ${code(b.text)}`, ''];
|
|
86
|
+
case 'pre':
|
|
87
|
+
return [...fenced(b.text), ''];
|
|
88
|
+
case 'dl':
|
|
89
|
+
return [...b.items.map((i) => `- ${code(i.term)}: ${markdownText(i.text)}`), ''];
|
|
90
|
+
case 'refs':
|
|
91
|
+
return [b.items.map((r) => `[${markdownText(`${r.page}(${r.section})`)}](${encodeURI(r.page)}.md)`).join(', '), ''];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** The first line of a Markdown page: a comment, invisible when rendered, saying it is generated. */
|
|
95
|
+
export const MARKDOWN_MARKER = '<!-- Generated by clyops-doc from --help-json-schema. Do not edit; regenerate instead. -->';
|
|
96
|
+
/** The document as Markdown. */
|
|
97
|
+
export function renderMarkdown(doc) {
|
|
98
|
+
const lines = [MARKDOWN_MARKER, '', `# ${code(doc.name)}`, ''];
|
|
99
|
+
const header = [doc.version && `Version ${doc.version}`, doc.date].filter(Boolean).join(' · ');
|
|
100
|
+
if (header)
|
|
101
|
+
lines.push(`_${markdownText(header)}_`, '');
|
|
102
|
+
for (const s of doc.sections) {
|
|
103
|
+
lines.push(`## ${s.title}`, '');
|
|
104
|
+
for (const b of s.blocks)
|
|
105
|
+
lines.push(...markdownBlock(b));
|
|
106
|
+
}
|
|
107
|
+
return lines.join('\n').replace(/\n+$/, '\n');
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// HTML: one standalone page, everything escaped.
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
/** Text with HTML's special characters escaped. */
|
|
113
|
+
export function htmlText(text) {
|
|
114
|
+
return text.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
115
|
+
}
|
|
116
|
+
const slug = (text) => text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
117
|
+
function htmlBlock(b) {
|
|
118
|
+
switch (b.kind) {
|
|
119
|
+
case 'p':
|
|
120
|
+
return `<p>${htmlText(b.text)}</p>`;
|
|
121
|
+
case 'h':
|
|
122
|
+
return `<h3 id="${slug(b.text)}"><code>${htmlText(b.text)}</code></h3>`;
|
|
123
|
+
case 'pre':
|
|
124
|
+
return `<pre><code>${htmlText(b.text)}</code></pre>`;
|
|
125
|
+
case 'dl':
|
|
126
|
+
return `<dl>\n${b.items.map((i) => `<dt><code>${htmlText(i.term)}</code></dt>\n<dd>${htmlText(i.text)}</dd>`).join('\n')}\n</dl>`;
|
|
127
|
+
case 'refs':
|
|
128
|
+
return `<p>${b.items.map((r) => `<a href="${htmlText(encodeURI(r.page))}.html"><code>${htmlText(r.page)}</code>(${htmlText(r.section)})</a>`).join(', ')}</p>`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const STYLE = `body{font:16px/1.55 system-ui,sans-serif;max-width:46rem;margin:2rem auto;padding:0 1rem;color:#1d1f21;background:#fff}
|
|
132
|
+
code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}
|
|
133
|
+
pre{background:#f4f5f6;padding:.8rem 1rem;overflow-x:auto;border-radius:4px}
|
|
134
|
+
h2{margin-top:2rem;font-size:1.05rem;letter-spacing:.04em}
|
|
135
|
+
dt{margin-top:.6rem;font-weight:600}dd{margin:.2rem 0 0 1.5rem}
|
|
136
|
+
.meta{color:#5b6168}
|
|
137
|
+
@media (prefers-color-scheme:dark){body{color:#e3e5e8;background:#17191c}pre{background:#23262a}.meta{color:#9aa1a9}}`;
|
|
138
|
+
/** The document as a standalone HTML page. */
|
|
139
|
+
export function renderHtml(doc) {
|
|
140
|
+
const header = [doc.version && `Version ${doc.version}`, doc.date].filter(Boolean).join(' · ');
|
|
141
|
+
const body = doc.sections
|
|
142
|
+
.map((s) => `<section id="${slug(s.title)}">\n<h2>${htmlText(s.title)}</h2>\n${s.blocks.map(htmlBlock).join('\n')}\n</section>`)
|
|
143
|
+
.join('\n');
|
|
144
|
+
return `<!doctype html>
|
|
145
|
+
<html lang="en">
|
|
146
|
+
<head>
|
|
147
|
+
<meta charset="utf-8">
|
|
148
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
149
|
+
<meta name="generator" content="clyops-doc">
|
|
150
|
+
<title>${htmlText(doc.page)}(${htmlText(doc.section)})</title>
|
|
151
|
+
<style>${STYLE}</style>
|
|
152
|
+
</head>
|
|
153
|
+
<body>
|
|
154
|
+
<header><h1><code>${htmlText(doc.name)}</code></h1>${header ? `<p class="meta">${htmlText(header)}</p>` : ''}</header>
|
|
155
|
+
<main>
|
|
156
|
+
${body}
|
|
157
|
+
</main>
|
|
158
|
+
</body>
|
|
159
|
+
</html>
|
|
160
|
+
`;
|
|
161
|
+
}
|
|
162
|
+
/** The document in `format`. */
|
|
163
|
+
export function render(doc, format) {
|
|
164
|
+
return format === 'man' ? renderMan(doc) : format === 'markdown' ? renderMarkdown(doc) : renderHtml(doc);
|
|
165
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clyops-doc",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.7.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.7.0",
|
|
40
|
+
"clyops-schema": "0.7.0",
|
|
41
|
+
"clyops-tools": "0.7.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^22.0.0",
|
|
45
|
+
"typescript": "^5.6.0"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://clyops.io/applications.html",
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
|
|
50
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
51
|
+
"test": "node --test test/*.test.mjs"
|
|
52
|
+
}
|
|
6
53
|
}
|