Skip to main content

ssg/cmd/
man.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Man-page generation from the live clap definition.
5//!
6//! # Why this is written here rather than taken from a crate
7//!
8//! The obvious options were `clap_mangen`, `help2man`, or the `roff` crate
9//! that `clap_mangen` is built on. All three were considered:
10//!
11//! * `help2man` builds one page from one `--help`. This CLI has eight
12//!   subcommands, so it would need eight invocations plus `--include` files
13//!   of hand-written prose — reintroducing the second source of truth it was
14//!   meant to avoid, and adding a perl build dependency.
15//! * `clap_mangen` cannot express the prose a good page needs: a real
16//!   DESCRIPTION, worked EXAMPLES, EXIT STATUS.
17//! * `roff` is small (456 lines, no runtime dependencies, no `unsafe`, no
18//!   I/O) and would have served, but it is unaudited against this
19//!   repository's `cargo vet` policy, whose exemption ratchet forbids adding
20//!   an unreviewed crate. We use a narrow subset of roff — `.TH`, `.SH`,
21//!   `.TP`, `.B`, `.nf` — so emitting it directly costs less than the
22//!   supply-chain review it avoids.
23//!
24//! # What cannot drift
25//!
26//! SYNOPSIS and OPTIONS are walked out of [`crate::cmd::Cli`]'s own
27//! `clap::Command`, so a flag that exists in the parser appears in the page
28//! by construction. Only the prose sections are written by hand, and
29//! `tests/man_page.rs` asserts that every flag and subcommand the parser
30//! defines is present in the rendered output — so prose cannot fall behind
31//! the parser either.
32//!
33//! # Escaping
34//!
35//! A leading `.` or `'` on a line is a roff control line, and `\` and `-`
36//! are meaningful mid-text. [`escape_text`] neutralises all four. This is
37//! the same set the `roff` crate handles, and for the same reason.
38
39use clap::Command;
40use std::fmt::Write as _;
41
42/// Escapes text for inclusion in a roff text line.
43///
44/// Neutralises the four sequences a roff processor would otherwise act on:
45/// a backslash, a hyphen (which becomes a typographic dash), and a leading
46/// period or apostrophe on a line, either of which starts a control line and
47/// would silently swallow the rest of it.
48///
49/// # Examples
50///
51/// ```
52/// use ssg::cmd::man::escape_text;
53/// assert_eq!(escape_text("a-b"), r"a\-b");
54/// assert_eq!(escape_text("x\n.y"), "x\n\\&.y");
55/// ```
56#[must_use]
57pub fn escape_text(s: &str) -> String {
58    s.replace('\\', r"\\")
59        .replace('-', r"\-")
60        .replace("\n.", "\n\\&.")
61        .replace("\n'", "\n\\&'")
62}
63
64/// Quotes a roff macro argument when it contains whitespace.
65fn quote_arg(s: &str) -> String {
66    if s.contains(char::is_whitespace) {
67        format!("\"{}\"", s.replace('"', "'"))
68    } else {
69        s.to_owned()
70    }
71}
72
73/// Renders the full `ssg.1` man page.
74///
75/// `version` and `date` are passed in rather than read from the environment
76/// so the output is a pure function of its inputs — the determinism gate
77/// compares builds across machines, and a page carrying today's date would
78/// differ on every run.
79///
80/// # Examples
81///
82/// ```
83/// use ssg::cmd::{man, Cli};
84/// let page = man::render(&Cli::subcommand_app(), "0.0.58", "2026-09-02");
85/// assert!(page.starts_with(".TH "));
86/// assert!(page.contains(".SH NAME"));
87/// ```
88#[must_use]
89pub fn render(app: &Command, version: &str, date: &str) -> String {
90    let mut out = String::with_capacity(8192);
91
92    // .TH title section date source manual
93    let _ = writeln!(
94        out,
95        ".TH {} 1 {} {} {}",
96        quote_arg("SSG"),
97        quote_arg(date),
98        quote_arg(&format!("ssg {version}")),
99        quote_arg("User Commands"),
100    );
101
102    section(&mut out, "NAME");
103    let _ = writeln!(
104        out,
105        "ssg \\- {}",
106        escape_text(
107            app.get_about()
108                .map_or_else(
109                    || "static site generator".into(),
110                    ToString::to_string,
111                )
112                .as_str()
113        )
114    );
115
116    section(&mut out, "SYNOPSIS");
117    let _ = writeln!(out, "\\fBssg\\fR [\\fIOPTIONS\\fR]");
118    for sub in app.get_subcommands() {
119        let _ = writeln!(
120            out,
121            ".br\n\\fBssg {}\\fR [\\fIOPTIONS\\fR]",
122            escape_text(sub.get_name())
123        );
124    }
125
126    section(&mut out, "DESCRIPTION");
127    for para in prose::DESCRIPTION {
128        paragraph(&mut out, para);
129    }
130
131    // Top-level options, then one subsection per subcommand. Walking the
132    // parser means a flag cannot be missing from the page.
133    section(&mut out, "OPTIONS");
134    write_args(&mut out, app);
135
136    for sub in app.get_subcommands() {
137        subsection(&mut out, &format!("ssg {}", sub.get_name()));
138        if let Some(about) = sub.get_about() {
139            paragraph(&mut out, &about.to_string());
140        }
141        write_args(&mut out, sub);
142    }
143
144    section(&mut out, "EXIT STATUS");
145    for (code, meaning) in prose::EXIT_STATUS {
146        tagged(&mut out, code, meaning);
147    }
148
149    section(&mut out, "ENVIRONMENT");
150    for (var, meaning) in prose::ENVIRONMENT {
151        tagged(&mut out, var, meaning);
152    }
153
154    section(&mut out, "EXAMPLES");
155    for (caption, cmd) in prose::EXAMPLES {
156        paragraph(&mut out, caption);
157        literal(&mut out, cmd);
158    }
159
160    section(&mut out, "SEE ALSO");
161    paragraph(&mut out, prose::SEE_ALSO);
162
163    out
164}
165
166/// Writes every argument of `cmd` as a `.TP` tagged paragraph.
167fn write_args(out: &mut String, cmd: &Command) {
168    for arg in cmd.get_arguments() {
169        if arg.is_hide_set() {
170            continue;
171        }
172        let mut tag = String::new();
173        if let Some(short) = arg.get_short() {
174            let _ = write!(tag, "\\fB\\-{short}\\fR");
175        }
176        if let Some(long) = arg.get_long() {
177            if !tag.is_empty() {
178                tag.push_str(", ");
179            }
180            let _ = write!(tag, "\\fB\\-\\-{}\\fR", escape_text(long));
181        }
182        if tag.is_empty() {
183            // A positional argument.
184            let _ =
185                write!(tag, "\\fI{}\\fR", escape_text(arg.get_id().as_str()));
186        }
187        if let Some(names) = arg.get_value_names() {
188            for n in names {
189                let _ = write!(tag, " \\fI{}\\fR", escape_text(n));
190            }
191        }
192
193        let help = arg
194            .get_long_help()
195            .or_else(|| arg.get_help())
196            .map(|h| h.to_string())
197            .unwrap_or_default();
198
199        let _ = writeln!(out, ".TP\n{tag}\n{}", escape_text(&help));
200    }
201}
202
203fn section(out: &mut String, name: &str) {
204    let _ = writeln!(out, ".SH {}", quote_arg(name));
205}
206
207fn subsection(out: &mut String, name: &str) {
208    let _ = writeln!(out, ".SS {}", quote_arg(name));
209}
210
211fn paragraph(out: &mut String, text: &str) {
212    let _ = writeln!(out, ".PP\n{}", escape_text(text));
213}
214
215fn tagged(out: &mut String, tag: &str, text: &str) {
216    let _ = writeln!(
217        out,
218        ".TP\n\\fB{}\\fR\n{}",
219        escape_text(tag),
220        escape_text(text)
221    );
222}
223
224/// An unfilled, indented block — used for command examples, where roff must
225/// not reflow the text.
226fn literal(out: &mut String, text: &str) {
227    let _ = writeln!(out, ".nf\n.RS 4\n{}\n.RE\n.fi", escape_text(text));
228}
229
230/// The hand-written half of the page.
231///
232/// Everything here is prose a parser cannot know: why a flag exists, what a
233/// workflow looks like, what an exit code means. The generated half covers
234/// what the parser does know, so these two never restate each other.
235mod prose {
236    pub(super) const DESCRIPTION: &[&str] = &[
237        "ssg compiles a directory of Markdown content and templates into a \
238         static website. Unlike generators that render and stop, it runs its \
239         accessibility, security and metadata checks during the build: a page \
240         that fails is reported with its file and line, rather than \
241         discovered after deployment.",
242        "Configuration is read from ssg.toml or config.toml in the working \
243         directory, or from the path given to --config. When no configuration \
244         is found the built-in defaults are used and a warning naming the \
245         fallback canonical host is printed, because that host ends up in \
246         every canonical URL, sitemap entry and JSON-LD identifier the build \
247         emits.",
248    ];
249
250    pub(super) const EXIT_STATUS: &[(&str, &str)] = &[
251        ("0", "The build completed and every enabled gate passed."),
252        (
253            "1",
254            "The build failed, or a gate reported a violation at or above the \
255             configured --fail-on severity.",
256        ),
257        ("101", "An internal error. Please report this with the input that caused it."),
258    ];
259
260    pub(super) const ENVIRONMENT: &[(&str, &str)] = &[
261        (
262            "SSG_CONFIG",
263            "Path to a configuration file. Consulted only when neither \
264             --config nor a configuration file in the working directory is \
265             found, so a stale value cannot override a project's own file.",
266        ),
267        (
268            "STRICT_A11Y",
269            "When set, accessibility violations fail the build instead of \
270             warning.",
271        ),
272        (
273            "SSG_REQUIRE_EXAMPLES",
274            "Used by this project's own CI. Makes the example-output gates \
275             fail rather than skip when they find nothing to inspect.",
276        ),
277    ];
278
279    pub(super) const EXAMPLES: &[(&str, &str)] = &[
280        (
281            "Build the site described by ssg.toml in the current directory:",
282            "ssg build",
283        ),
284        ("Serve the site with live reload while editing:", "ssg dev"),
285        (
286            "Run every validator without writing any output:",
287            "ssg check",
288        ),
289        (
290            "Report the plugin pipeline, including the deploy stage for a \
291             chosen target:",
292            "ssg plugins list --target netlify",
293        ),
294    ];
295
296    pub(super) const SEE_ALSO: &str =
297        "Full documentation at https://static-site-generator.com. Source, \
298         issue tracker and the architecture decision records at \
299         https://github.com/sebastienrousseau/static-site-generator.";
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::cmd::Cli;
306
307    #[test]
308    fn escapes_roff_control_sequences() {
309        assert_eq!(escape_text("a-b"), r"a\-b");
310        assert_eq!(escape_text(r"a\b"), r"a\\b");
311        // A leading period on a line would otherwise start a control line and
312        // swallow the rest of it.
313        assert_eq!(escape_text("x\n.SH EVIL"), "x\n\\&.SH EVIL");
314        assert_eq!(escape_text("x\n'tis"), "x\n\\&'tis");
315    }
316
317    #[test]
318    fn quotes_only_arguments_containing_whitespace() {
319        assert_eq!(quote_arg("SSG"), "SSG");
320        assert_eq!(quote_arg("User Commands"), "\"User Commands\"");
321    }
322
323    #[test]
324    fn render_is_deterministic() {
325        // The determinism gate compares whole trees across machines, so the
326        // page must not depend on the clock.
327        let app = Cli::subcommand_app();
328        let a = render(&app, "0.0.58", "2026-09-02");
329        let b = render(&app, "0.0.58", "2026-09-02");
330        assert_eq!(a, b);
331    }
332
333    #[test]
334    fn page_has_the_mandatory_sections() {
335        let page = render(&Cli::subcommand_app(), "0.0.58", "2026-09-02");
336        for s in [
337            ".TH ",
338            ".SH NAME",
339            ".SH SYNOPSIS",
340            ".SH DESCRIPTION",
341            ".SH OPTIONS",
342            ".SH \"EXIT STATUS\"",
343            ".SH ENVIRONMENT",
344            ".SH EXAMPLES",
345            ".SH \"SEE ALSO\"",
346        ] {
347            assert!(page.contains(s), "missing section {s}\n{page}");
348        }
349    }
350
351    #[test]
352    fn every_line_is_valid_roff_structure() {
353        // A line starting with `.` must be a macro we actually emit; anything
354        // else means escaping failed and prose is being read as markup.
355        let page = render(&Cli::subcommand_app(), "0.0.58", "2026-09-02");
356        let known = [
357            ".TH", ".SH", ".SS", ".PP", ".TP", ".br", ".nf", ".fi", ".RS",
358            ".RE",
359        ];
360        for (n, line) in page.lines().enumerate() {
361            if !line.starts_with('.') {
362                continue;
363            }
364            let macro_name = line.split_whitespace().next().unwrap_or_default();
365            assert!(
366                known.contains(&macro_name),
367                "line {}: unrecognised roff macro {macro_name:?}\n  {line}",
368                n + 1
369            );
370        }
371    }
372}