1use clap::Command;
40use std::fmt::Write as _;
41
42#[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
64fn 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#[must_use]
89pub fn render(app: &Command, version: &str, date: &str) -> String {
90 let mut out = String::with_capacity(8192);
91
92 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 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
166fn 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 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
224fn literal(out: &mut String, text: &str) {
227 let _ = writeln!(out, ".nf\n.RS 4\n{}\n.RE\n.fi", escape_text(text));
228}
229
230mod 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 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 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 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(¯o_name),
367 "line {}: unrecognised roff macro {macro_name:?}\n {line}",
368 n + 1
369 );
370 }
371 }
372}