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/render.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
case 'refs':
|
|
38
|
+
return ['.PP', ...b.items.map((r, n) => `.BR ${arg(r.page)} ${arg(`(${r.section})${n < b.items.length - 1 ? ',' : ''}`)}`)];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The document as a man(7) page. */
|
|
43
|
+
export function renderMan(doc: Doc): string {
|
|
44
|
+
const title = doc.page.toUpperCase().replace(/\s+/g, '-');
|
|
45
|
+
const lines = [
|
|
46
|
+
`.\\" Generated by clyops-doc from ${doc.name} --help-json-schema.`,
|
|
47
|
+
`.TH ${arg(title)} ${arg(doc.section)} ${arg(doc.date)} ${arg([doc.name, doc.version].filter(Boolean).join(' '))} ${arg('User Commands')}`,
|
|
48
|
+
];
|
|
49
|
+
for (const s of doc.sections) {
|
|
50
|
+
lines.push(`.SH ${arg(s.title)}`);
|
|
51
|
+
// NAME is one line, "name \- summary", without a paragraph macro (man(7)).
|
|
52
|
+
if (s.title === 'NAME') {
|
|
53
|
+
lines.push(troff(`${doc.page}${doc.summary ? ` - ${doc.summary}` : ''}`));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
for (const b of s.blocks) lines.push(...manBlock(b));
|
|
57
|
+
}
|
|
58
|
+
return lines.join('\n') + '\n';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Markdown (CommonMark)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Text that renders as itself in CommonMark: characters that can start markup escaped, and nothing
|
|
67
|
+
* else, so the Markdown stays readable (`DRY_RUN`, `(default: 1)`).
|
|
68
|
+
*/
|
|
69
|
+
export function markdownText(text: string): string {
|
|
70
|
+
return text
|
|
71
|
+
.split('\n')
|
|
72
|
+
.map((line) =>
|
|
73
|
+
line
|
|
74
|
+
.replace(/[\\`*[\]<>|]/g, '\\$&')
|
|
75
|
+
// _ only emphasises at a word boundary.
|
|
76
|
+
.replace(/(^|[^A-Za-z0-9])_|_(?=[^A-Za-z0-9]|$)/g, (m) => m.replace('_', '\\_'))
|
|
77
|
+
// A block marker at the start of a line: a heading, quote, list item or rule.
|
|
78
|
+
.replace(/^(\s*)([#>+=-]|\d+[.)])/, '$1\\$2'))
|
|
79
|
+
.join('\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Inline code that may itself contain backticks. */
|
|
83
|
+
function code(text: string): string {
|
|
84
|
+
const run = Math.max(0, ...(text.match(/`+/g) ?? []).map((m) => m.length)) + 1;
|
|
85
|
+
const fence = '`'.repeat(run);
|
|
86
|
+
return run > 1 ? `${fence} ${text} ${fence}` : `${fence}${text}${fence}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function fenced(text: string): string[] {
|
|
90
|
+
const run = Math.max(2, ...(text.match(/`+/g) ?? []).map((m) => m.length)) + 1;
|
|
91
|
+
const fence = '`'.repeat(run);
|
|
92
|
+
return [fence, text, fence];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function markdownBlock(b: Block): string[] {
|
|
96
|
+
switch (b.kind) {
|
|
97
|
+
case 'p':
|
|
98
|
+
return [markdownText(b.text), ''];
|
|
99
|
+
case 'h':
|
|
100
|
+
return [`### ${code(b.text)}`, ''];
|
|
101
|
+
case 'pre':
|
|
102
|
+
return [...fenced(b.text), ''];
|
|
103
|
+
case 'dl':
|
|
104
|
+
return [...b.items.map((i) => `- ${code(i.term)}: ${markdownText(i.text)}`), ''];
|
|
105
|
+
case 'refs':
|
|
106
|
+
return [b.items.map((r) => `[${markdownText(`${r.page}(${r.section})`)}](${encodeURI(r.page)}.md)`).join(', '), ''];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The first line of a Markdown page: a comment, invisible when rendered, saying it is generated. */
|
|
111
|
+
export const MARKDOWN_MARKER = '<!-- Generated by clyops-doc from --help-json-schema. Do not edit; regenerate instead. -->';
|
|
112
|
+
|
|
113
|
+
/** The document as Markdown. */
|
|
114
|
+
export function renderMarkdown(doc: Doc): string {
|
|
115
|
+
const lines = [MARKDOWN_MARKER, '', `# ${code(doc.name)}`, ''];
|
|
116
|
+
const header = [doc.version && `Version ${doc.version}`, doc.date].filter(Boolean).join(' · ');
|
|
117
|
+
if (header) lines.push(`_${markdownText(header)}_`, '');
|
|
118
|
+
for (const s of doc.sections) {
|
|
119
|
+
lines.push(`## ${s.title}`, '');
|
|
120
|
+
for (const b of s.blocks) lines.push(...markdownBlock(b));
|
|
121
|
+
}
|
|
122
|
+
return lines.join('\n').replace(/\n+$/, '\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// HTML: one standalone page, everything escaped.
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/** Text with HTML's special characters escaped. */
|
|
130
|
+
export function htmlText(text: string): string {
|
|
131
|
+
return text.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] as string);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const slug = (text: string) => text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
135
|
+
|
|
136
|
+
function htmlBlock(b: Block): string {
|
|
137
|
+
switch (b.kind) {
|
|
138
|
+
case 'p':
|
|
139
|
+
return `<p>${htmlText(b.text)}</p>`;
|
|
140
|
+
case 'h':
|
|
141
|
+
return `<h3 id="${slug(b.text)}"><code>${htmlText(b.text)}</code></h3>`;
|
|
142
|
+
case 'pre':
|
|
143
|
+
return `<pre><code>${htmlText(b.text)}</code></pre>`;
|
|
144
|
+
case 'dl':
|
|
145
|
+
return `<dl>\n${b.items.map((i) => `<dt><code>${htmlText(i.term)}</code></dt>\n<dd>${htmlText(i.text)}</dd>`).join('\n')}\n</dl>`;
|
|
146
|
+
case 'refs':
|
|
147
|
+
return `<p>${b.items.map((r) => `<a href="${htmlText(encodeURI(r.page))}.html"><code>${htmlText(r.page)}</code>(${htmlText(r.section)})</a>`).join(', ')}</p>`;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const STYLE = `body{font:16px/1.55 system-ui,sans-serif;max-width:46rem;margin:2rem auto;padding:0 1rem;color:#1d1f21;background:#fff}
|
|
152
|
+
code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}
|
|
153
|
+
pre{background:#f4f5f6;padding:.8rem 1rem;overflow-x:auto;border-radius:4px}
|
|
154
|
+
h2{margin-top:2rem;font-size:1.05rem;letter-spacing:.04em}
|
|
155
|
+
dt{margin-top:.6rem;font-weight:600}dd{margin:.2rem 0 0 1.5rem}
|
|
156
|
+
.meta{color:#5b6168}
|
|
157
|
+
@media (prefers-color-scheme:dark){body{color:#e3e5e8;background:#17191c}pre{background:#23262a}.meta{color:#9aa1a9}}`;
|
|
158
|
+
|
|
159
|
+
/** The document as a standalone HTML page. */
|
|
160
|
+
export function renderHtml(doc: Doc): string {
|
|
161
|
+
const header = [doc.version && `Version ${doc.version}`, doc.date].filter(Boolean).join(' · ');
|
|
162
|
+
const body = doc.sections
|
|
163
|
+
.map((s) => `<section id="${slug(s.title)}">\n<h2>${htmlText(s.title)}</h2>\n${s.blocks.map(htmlBlock).join('\n')}\n</section>`)
|
|
164
|
+
.join('\n');
|
|
165
|
+
return `<!doctype html>
|
|
166
|
+
<html lang="en">
|
|
167
|
+
<head>
|
|
168
|
+
<meta charset="utf-8">
|
|
169
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
170
|
+
<meta name="generator" content="clyops-doc">
|
|
171
|
+
<title>${htmlText(doc.page)}(${htmlText(doc.section)})</title>
|
|
172
|
+
<style>${STYLE}</style>
|
|
173
|
+
</head>
|
|
174
|
+
<body>
|
|
175
|
+
<header><h1><code>${htmlText(doc.name)}</code></h1>${header ? `<p class="meta">${htmlText(header)}</p>` : ''}</header>
|
|
176
|
+
<main>
|
|
177
|
+
${body}
|
|
178
|
+
</main>
|
|
179
|
+
</body>
|
|
180
|
+
</html>
|
|
181
|
+
`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The document in `format`. */
|
|
185
|
+
export function render(doc: Doc, format: Format): string {
|
|
186
|
+
return format === 'man' ? renderMan(doc) : format === 'markdown' ? renderMarkdown(doc) : renderHtml(doc);
|
|
187
|
+
}
|