src/main.rs (7083B)
1 use anyhow::Result;
2 use clap::{Parser, Subcommand};
3 use std::path::PathBuf;
4
5 use sqzass::error::{Kind, kind_of, message};
6 use sqzass::serve::{ServeOptions, serve};
7 use sqzass::{BuildOptions, build, display_path};
8
9 #[derive(Parser)]
10 #[command(
11 name = "sqzass",
12 version,
13 about = "A static site generator written in Rust",
14 disable_help_subcommand = true
15 )]
16 struct Cli {
17 #[command(subcommand)]
18 command: Command,
19
20 /// 결과를 JSON 한 줄로 낸다 (스크립트·CI용)
21 ///
22 /// 서브커맨드 앞뒤 어디에 써도 되도록 global로 둔다. 사람이 기억해야 하는
23 /// 플래그 위치가 하나 줄어든다.
24 #[arg(long, global = true)]
25 json: bool,
26 }
27
28 #[derive(Subcommand)]
29 enum Command {
30 /// 사이트를 빌드한다
31 Build(BuildArgs),
32 /// 라이브 리로드가 붙은 개발 서버를 띄운다
33 Serve(ServeArgs),
34 /// 새 사이트를 만든다
35 Init(InitArgs),
36 /// 빌드는 통과하지만 알아 둘 만한 것들을 찾는다
37 Doctor(DoctorArgs),
38 }
39
40 #[derive(clap::Args)]
41 struct DoctorArgs {
42 /// 사이트 루트 (sqzass.toml이 있는 디렉터리)
43 #[arg(short, long, default_value = ".", value_name = "DIR")]
44 input: PathBuf,
45
46 /// 이 심각도 이상이 하나라도 있으면 실패로 끝낸다 (note | warn)
47 ///
48 /// 기본이 warn인 이유: note는 "알아 두라"는 말이고, 그것 때문에 파이프라인이
49 /// 멈추면 사람들은 doctor를 끄지 검사를 고치지 않는다.
50 #[arg(long, default_value = "warn", value_name = "SEVERITY")]
51 fail_on: sqzass::doctor::Severity,
52
53 /// 드래프트 페이지도 포함해서 본다
54 #[arg(long)]
55 drafts: bool,
56 }
57
58 #[derive(clap::Args)]
59 struct InitArgs {
60 /// 만들 디렉터리. 없으면 만든다.
61 #[arg(default_value = ".", value_name = "DIR")]
62 dir: PathBuf,
63 }
64
65 #[derive(clap::Args)]
66 struct ServeArgs {
67 /// 사이트 루트 (sqzass.toml이 있는 디렉터리)
68 #[arg(short, long, default_value = ".", value_name = "DIR")]
69 input: PathBuf,
70
71 /// 바인드 주소
72 #[arg(short, long, default_value = "127.0.0.1", value_name = "ADDR")]
73 bind: std::net::IpAddr,
74
75 /// 포트
76 #[arg(short, long, default_value_t = 3000)]
77 port: u16,
78
79 /// 드래프트 페이지도 포함한다
80 #[arg(long)]
81 drafts: bool,
82
83 /// 설정의 base_url을 덮어쓴다
84 #[arg(long, value_name = "URL")]
85 base_url: Option<String>,
86 }
87
88 #[derive(clap::Args)]
89 struct BuildArgs {
90 /// 사이트 루트 (sqzass.toml이 있는 디렉터리)
91 #[arg(short, long, default_value = ".", value_name = "DIR")]
92 input: PathBuf,
93
94 /// 출력 디렉터리. 생략하면 <input>/public.
95 /// 지정하면 셸의 현재 디렉터리 기준으로 해석한다.
96 #[arg(short, long, value_name = "DIR")]
97 output: Option<PathBuf>,
98
99 /// 드래프트 페이지도 포함한다
100 #[arg(long)]
101 drafts: bool,
102
103 /// 설정의 base_url을 덮어쓴다
104 #[arg(long, value_name = "URL")]
105 base_url: Option<String>,
106
107 /// 페이즈별 소요 시간을 stderr로 낸다
108 #[arg(long)]
109 profile: bool,
110 }
111
112 fn main() {
113 let cli = Cli::parse();
114 let json = cli.json;
115 if let Err(err) = run(cli) {
116 let kind = kind_of(&err);
117 if json {
118 // 실패도 stdout으로 낸다. --json을 켠 쪽은 파이프 하나만 읽는다.
119 println!(
120 "{}",
121 serde_json::json!({
122 "ok": false,
123 "error": message(&err),
124 "kind": kind.id(),
125 "code": kind.code(),
126 })
127 );
128 } else {
129 eprintln!("error: {}", message(&err));
130 }
131 std::process::exit(kind.code());
132 }
133 }
134
135 fn run(cli: Cli) -> Result<()> {
136 let json = cli.json;
137 match cli.command {
138 Command::Build(args) => {
139 let stats = build(&BuildOptions {
140 input: args.input,
141 output: args.output,
142 drafts: args.drafts,
143 base_url: args.base_url,
144 profile: args.profile,
145 })?;
146 if json {
147 println!(
148 "{}",
149 serde_json::json!({
150 "ok": true,
151 "pages": stats.pages_written,
152 "output": display_path(&stats.output_dir),
153 })
154 );
155 } else {
156 println!(
157 "{} pages → {}",
158 stats.pages_written,
159 display_path(&stats.output_dir)
160 );
161 }
162 Ok(())
163 }
164 Command::Init(args) => {
165 let written = sqzass::init::init(&args.dir).map_err(Kind::Io.tag())?;
166 if json {
167 println!(
168 "{}",
169 serde_json::json!({
170 "ok": true,
171 "dir": display_path(&args.dir),
172 "files": written,
173 })
174 );
175 } else {
176 for rel in &written {
177 println!(" {}", display_path(&args.dir.join(rel)));
178 }
179 println!("\nsqzass serve -i {}", display_path(&args.dir));
180 }
181 Ok(())
182 }
183 Command::Doctor(args) => {
184 let findings = sqzass::diagnose(&BuildOptions {
185 input: args.input,
186 output: None,
187 drafts: args.drafts,
188 base_url: None,
189 profile: false,
190 })?;
191 let gated = findings
192 .iter()
193 .filter(|f| f.severity >= args.fail_on)
194 .count();
195
196 if json {
197 println!(
198 "{}",
199 serde_json::json!({
200 "ok": gated == 0,
201 "findings": findings,
202 "gated": gated,
203 })
204 );
205 } else if findings.is_empty() {
206 println!("지적할 것이 없습니다");
207 } else {
208 for f in &findings {
209 println!("{f}");
210 }
211 println!(
212 "\n{}건 중 {gated}건이 {} 이상입니다",
213 findings.len(),
214 args.fail_on
215 );
216 }
217
218 if gated > 0 {
219 std::process::exit(sqzass::doctor::FINDINGS_EXIT_CODE);
220 }
221 Ok(())
222 }
223 Command::Serve(args) => {
224 // `build`는 동기 경로라 런타임을 서버에서만 만든다.
225 let rt = tokio::runtime::Runtime::new()?;
226 rt.block_on(serve(ServeOptions {
227 input: args.input,
228 bind: args.bind,
229 port: args.port,
230 drafts: args.drafts,
231 base_url: args.base_url,
232 }))
233 }
234 }
235 }