diff --git a/docs/content/start/cli.ko.md b/docs/content/start/cli.ko.md
@@ -0,0 +1,83 @@
++++
+title = "명령줄"
+description = "명령과 플래그, exit code, 기계가 읽는 출력"
+weight = 40
+toc = true
++++
+
+```
+sqzass init [DIR]
+sqzass build [-i DIR] [-o DIR] [--drafts] [--base-url URL]
+sqzass serve [-i DIR] [-b ADDR] [-p PORT] [--drafts] [--base-url URL]
+```
+
+`--json`은 셋 다에 붙습니다.
+
+## init
+
+`DIR`(기본 `.`)에 새 사이트를 만들고, 필요하면 디렉터리도 만듭니다. 이미
+`sqzass.toml`이 있으면 실행을 거부합니다.
+[첫 사이트 만들기](@/start/first-site.md)를 참고하세요.
+
+## build
+
+| 플래그 | 기본값 | |
+|---|---|---|
+| `-i`, `--input` | `.` | 사이트 루트 — `sqzass.toml`이 있는 디렉터리. |
+| `-o`, `--output` | `<input>/public` | 사이트 루트가 아니라 **셸의 현재 디렉터리** 기준으로 해석합니다. |
+| `--drafts` | | `draft = true`인 페이지도 포함합니다. |
+| `--base-url` | 설정값 | 프리뷰 배포에 씁니다. |
+
+출력 디렉터리는 매번 비우고 시작합니다. 지운 페이지가 빌드된 사이트에 유령으로
+남지 않습니다.
+
+## serve
+
+메모리에서 서빙하며 <http://127.0.0.1:3000>에 라이브 리로드를 붙입니다.
+[개발 서버](@/features/dev-server.md)를 참고하세요.
+
+## exit code
+
+빌드는 성공하거나, 사이트의 **어느 부분을** 받아들일 수 없었는지 알려 줍니다. 코드는
+"누구 잘못이냐"에 대한 답이고, CI는 텍스트를 파싱하지 않고 여기에 조건을 걸면 됩니다.
+
+| 코드 | 식별자 | |
+|---|---|---|
+| `0` | | 성공. |
+| `1` | `SQZASS_E` | 분류되지 않은 실패. |
+| `2` | | 잘못된 명령줄. 이건 우리 것이 아니라 clap의 코드입니다. |
+| `3` | `SQZASS_E_CONFIG` | `sqzass.toml` — 못 읽거나, 형식이 틀렸거나, 없는 키를 썼습니다. |
+| `4` | `SQZASS_E_CONTENT` | `content/` 아래 — front matter, 빠진 title, 같은 URL을 주장하는 두 페이지, 해석 안 되는 `@/` 링크. |
+| `5` | `SQZASS_E_TEMPLATE` | `templates/`, `i18n/`, 또는 템플릿이 요청했는데 없는 에셋. |
+| `6` | `SQZASS_E_IO` | 읽기나 쓰기 실패. |
+
+식별자는 메시지와 함께 찍히므로 그대로 검색할 수 있습니다.
+
+```
+error: [SQZASS_E_CONTENT] content/_index.md: 해석할 수 없는 내부 링크가 있습니다:
+ @/nope.md
+```
+
+이 번호들은 계약입니다. 다른 뜻으로 재사용하지 않습니다 — 남의 파이프라인에 걸린
+조건이 어느 날 조용히 다른 것을 검사하게 되어서는 안 되니까요.
+
+## --json
+
+stdout에 JSON 객체 하나만 냅니다. 스크립트는 파이프 하나만 읽으면 됩니다.
+
+```console
+$ sqzass build -i docs --json
+{"ok":true,"output":"docs/public","pages":40}
+```
+
+실패도 스트림을 나누지 않고 **stdout**으로 갑니다.
+
+```console
+$ sqzass build -i broken --json
+{"code":4,"error":"[SQZASS_E_CONTENT] …","kind":"SQZASS_E_CONTENT","ok":false}
+$ echo $?
+4
+```
+
+`--json` 없이는 메시지가 stderr로, 결과가 stdout으로 갑니다. 터미널 앞의 사람이
+기대하는 방식입니다.
diff --git a/docs/content/start/cli.md b/docs/content/start/cli.md
@@ -0,0 +1,85 @@
++++
+title = "Command line"
+description = "Commands, flags, exit codes and machine-readable output"
+weight = 40
+toc = true
++++
+
+```
+sqzass init [DIR]
+sqzass build [-i DIR] [-o DIR] [--drafts] [--base-url URL]
+sqzass serve [-i DIR] [-b ADDR] [-p PORT] [--drafts] [--base-url URL]
+```
+
+`--json` works on any of them.
+
+## init
+
+Writes a new site into `DIR` (default `.`), creating it if needed. Refuses to
+run where a `sqzass.toml` already exists. See
+[Your first site](@/start/first-site.md).
+
+## build
+
+| Flag | Default | |
+|---|---|---|
+| `-i`, `--input` | `.` | Site root — the directory holding `sqzass.toml`. |
+| `-o`, `--output` | `<input>/public` | Resolved against your shell's directory, not the site root. |
+| `--drafts` | | Include pages marked `draft = true`. |
+| `--base-url` | from config | Useful for preview deployments. |
+
+The output directory is emptied first, so a page you deleted does not linger as
+a ghost in the built site.
+
+## serve
+
+Serves from memory with live reload on <http://127.0.0.1:3000>. See
+[Development server](@/features/dev-server.md).
+
+## Exit codes
+
+A build either succeeds or tells you which part of your site it could not
+accept. The code is the answer to "whose fault is it" — CI can branch on it
+without parsing text.
+
+| Code | Identifier | |
+|---|---|---|
+| `0` | | Success. |
+| `1` | `SQZASS_E` | Something unclassified went wrong. |
+| `2` | | Bad command line. This one is clap's, not ours. |
+| `3` | `SQZASS_E_CONFIG` | `sqzass.toml` — unreadable, malformed, or a key that does not exist. |
+| `4` | `SQZASS_E_CONTENT` | Something under `content/` — front matter, a missing title, two pages claiming one URL, an unresolved `@/` link. |
+| `5` | `SQZASS_E_TEMPLATE` | `templates/`, `i18n/`, or an asset a template asked for and did not get. |
+| `6` | `SQZASS_E_IO` | Reading or writing failed. |
+
+The identifier is printed with the message, so it can be searched for:
+
+```
+error: [SQZASS_E_CONTENT] content/_index.md: 해석할 수 없는 내부 링크가 있습니다:
+ @/nope.md
+```
+
+These numbers are a contract. They will not be reassigned to different meanings,
+because a condition in someone's pipeline should not quietly start testing for
+something else.
+
+## --json
+
+One JSON object on stdout, and nothing else, so a script reads a single pipe.
+
+```console
+$ sqzass build -i docs --json
+{"ok":true,"output":"docs/public","pages":40}
+```
+
+Failures go to **stdout** too, rather than being split across two streams:
+
+```console
+$ sqzass build -i broken --json
+{"code":4,"error":"[SQZASS_E_CONTENT] …","kind":"SQZASS_E_CONTENT","ok":false}
+$ echo $?
+4
+```
+
+Without `--json`, messages go to stderr and results to stdout, which is what a
+person at a terminal expects.
diff --git a/src/error.rs b/src/error.rs
@@ -0,0 +1,158 @@
+//! 에러 분류와 exit code.
+//!
+//! 분류는 에러가 나는 자리마다 붙이지 않고 **파이프라인 단계 경계**에서 한 번씩만
+//! 붙인다. 스무 군데에 종류를 다는 건 스무 번 틀릴 기회이고, 정작 사용자가 알고
+//! 싶은 건 "설정이 문제냐, 콘텐츠가 문제냐, 템플릿이 문제냐"뿐이다.
+//!
+//! exit code는 **CI가 조건을 걸 수 있게** 하려고 나눈다. 사람이 읽는 건 메시지이고,
+//! 코드는 스크립트가 읽는다.
+
+use anyhow::Error;
+
+/// 무엇이 잘못됐는지. 값은 exit code이기도 하므로 **번호를 재사용하지 말 것** —
+/// 기존 스크립트의 조건이 조용히 다른 뜻이 된다.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Kind {
+ /// `sqzass.toml`
+ Config = 3,
+ /// `content/` 아래의 마크다운
+ Content = 4,
+ /// `templates/`, `i18n/`, 에셋 조회
+ Template = 5,
+ /// 읽기/쓰기 실패
+ Io = 6,
+ /// 분류되지 않은 것. 새 단계를 추가하고 태그를 잊으면 여기로 온다.
+ Other = 1,
+}
+
+impl Kind {
+ /// 문서와 검색에 쓰는 안정된 식별자. 메시지에 같이 나간다.
+ pub fn id(self) -> &'static str {
+ match self {
+ Self::Config => "SQZASS_E_CONFIG",
+ Self::Content => "SQZASS_E_CONTENT",
+ Self::Template => "SQZASS_E_TEMPLATE",
+ Self::Io => "SQZASS_E_IO",
+ Self::Other => "SQZASS_E",
+ }
+ }
+
+ pub fn code(self) -> i32 {
+ self as i32
+ }
+
+ /// `.map_err(Kind::Config.tag())` 형태로 단계 경계에 붙인다.
+ pub fn tag(self) -> impl Fn(Error) -> Error {
+ move |e| e.context(self)
+ }
+}
+
+impl std::fmt::Display for Kind {
+ /// anyhow의 컨텍스트 사슬에 그대로 찍히면 사용자에게 잡음이다. 식별자만 낸다.
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(self.id())
+ }
+}
+
+/// 사슬에서 `downcast_ref`로 다시 꺼내려면 이게 있어야 한다. anyhow의 사슬은
+/// `&dyn std::error::Error`를 내주고, 그쪽 downcast만 타입을 되찾을 수 있다.
+impl std::error::Error for Kind {}
+
+/// 모든 분류. 메시지 정리와 테스트가 쓴다.
+const ALL: &[Kind] = &[
+ Kind::Config,
+ Kind::Content,
+ Kind::Template,
+ Kind::Io,
+ Kind::Other,
+];
+
+/// 에러에 붙은 분류를 꺼낸다. 없으면 `Other`.
+///
+/// `chain()`을 훑어 `downcast_ref`를 하면 안 된다 — 사슬이 내주는 건 anyhow가 컨텍스트를
+/// 감싼 내부 타입이고, 거기서는 `Kind`를 되찾을 수 없다. `Error::downcast_ref`는 컨텍스트
+/// 값까지 뒤져 주는 별개의 경로다.
+pub fn kind_of(err: &Error) -> Kind {
+ err.downcast_ref::<Kind>().copied().unwrap_or(Kind::Other)
+}
+
+/// 사람이 읽을 메시지. 분류는 맨 앞에 한 번만 보이고 사슬 중간에서는 지운다.
+pub fn message(err: &Error) -> String {
+ let kind = kind_of(err);
+ let body = err
+ .chain()
+ .map(std::string::ToString::to_string)
+ // 사슬에 낀 분류 계층은 자기 식별자를 Display로 낸다. 바깥 것만이 아니라
+ // 어떤 분류든 걸러낸다 — 하나라도 새면 사용자는 그게 메시지의 일부라고 읽는다.
+ .filter(|s| !ALL.iter().any(|k| k.id() == s))
+ .collect::<Vec<_>>()
+ .join(": ");
+ format!("[{}] {body}", kind.id())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use anyhow::anyhow;
+
+ #[test]
+ fn tags_survive_further_context() {
+ let e = anyhow!("파일이 없습니다");
+ let e = Kind::Config.tag()(e);
+ let e = e.context("설정을 읽는 중");
+ assert_eq!(kind_of(&e), Kind::Config);
+ }
+
+ #[test]
+ fn untagged_errors_are_other() {
+ assert_eq!(kind_of(&anyhow!("무언가")), Kind::Other);
+ assert_eq!(Kind::Other.code(), 1);
+ }
+
+ #[test]
+ fn message_shows_the_id_once_and_keeps_the_chain() {
+ let e = Kind::Content.tag()(anyhow!("front matter 파싱 실패"));
+ let e = e.context("start/install.md");
+ let msg = message(&e);
+ assert!(msg.starts_with("[SQZASS_E_CONTENT] "), "실제: {msg}");
+ assert!(msg.contains("start/install.md"), "실제: {msg}");
+ assert!(msg.contains("front matter 파싱 실패"), "실제: {msg}");
+ // 분류가 사슬 중간에 한 번 더 끼어들면 안 된다.
+ assert_eq!(msg.matches("SQZASS_E_CONTENT").count(), 1, "실제: {msg}");
+ }
+
+ /// 두 번 태그하면 바깥이 이기고 안쪽은 메시지로 샌다. 실제로 한 번 그랬다:
+ /// 깨진 `@/` 링크가 콘텐츠가 아니라 템플릿 에러로 보고됐다.
+ #[test]
+ fn a_nested_tag_never_leaks_into_the_message() {
+ let e = Kind::Content.tag()(anyhow!("깨진 링크"));
+ let e = Kind::Template.tag()(e);
+ let msg = message(&e);
+ assert!(!msg.contains("SQZASS_E_CONTENT"), "안쪽 분류가 샜다: {msg}");
+ assert!(msg.contains("깨진 링크"), "실제: {msg}");
+ }
+
+ /// exit code는 계약이다. 바꾸면 남의 CI 조건이 조용히 뜻을 바꾼다.
+ #[test]
+ fn codes_are_stable_and_distinct() {
+ assert_eq!(
+ ALL.iter().map(|k| k.code()).collect::<Vec<_>>(),
+ vec![3, 4, 5, 6, 1],
+ "exit code가 바뀌었다"
+ );
+ // clap이 사용법 오류에 2를 쓰므로 우리는 2를 쓰지 않는다.
+ assert!(!ALL.iter().any(|k| k.code() == 2));
+ // 식별자도 계약이다. 문서와 사용자 스크립트가 문자열로 잡는다.
+ let ids: Vec<&str> = ALL.iter().map(|k| k.id()).collect();
+ assert_eq!(
+ ids,
+ vec![
+ "SQZASS_E_CONFIG",
+ "SQZASS_E_CONTENT",
+ "SQZASS_E_TEMPLATE",
+ "SQZASS_E_IO",
+ "SQZASS_E"
+ ]
+ );
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
@@ -3,6 +3,7 @@
pub mod assets;
pub mod config;
pub mod content;
+pub mod error;
pub mod highlight;
pub mod i18n;
pub mod init;
@@ -19,6 +20,8 @@ use minijinja::context;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
+use error::Kind;
+
use config::Config;
use content::Page;
use render::{PageCtx, SiteCtx, Templates};
@@ -70,7 +73,7 @@ pub fn build_to_memory(opts: &BuildOptions) -> Result<BuildOutput> {
/// 빌드 경로가 이걸 두 번 부르지 않도록 따로 뺐다. 두 번 부르면 설정 진단이 두 번
/// 찍히고, 사용자는 자기 파일에 문제가 두 개 있다고 읽는다.
fn load_config(opts: &BuildOptions) -> Result<Config> {
- let mut cfg = Config::load(&opts.input)?;
+ let mut cfg = Config::load(&opts.input).map_err(Kind::Config.tag())?;
if let Some(base) = &opts.base_url {
cfg.base_url = base.clone();
}
@@ -81,11 +84,11 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
let root = &opts.input;
let drafts = opts.drafts || cfg.build.drafts;
- let pages = content::discover(root, &cfg, drafts)?;
+ let pages = content::discover(root, &cfg, drafts).map_err(Kind::Content.tag())?;
let site = Site::build(&pages, &cfg);
// 에셋을 먼저 처리해야 템플릿이 해시가 붙은 최종 URL을 조회할 수 있다.
- let mut assets = assets::Assets::collect(root, &cfg.assets)?;
+ let mut assets = assets::Assets::collect(root, &cfg.assets).map_err(Kind::Io.tag())?;
let mut md =
markdown::Renderer::new(&cfg.markdown).with_links(site.link_index(), &cfg.default_language);
@@ -93,7 +96,7 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
md = md.with_highlighter(highlight::Highlighter::new());
// 하이라이트 스타일시트는 테마에서 생성한다. HTML은 클래스만 담고 있으므로
// 이 파일 하나를 바꾸면 사이트 전체의 코드 색이 바뀐다.
- let css = highlight::stylesheet(&cfg.highlight)?;
+ let css = highlight::stylesheet(&cfg.highlight).map_err(Kind::Config.tag())?;
assets.insert(HIGHLIGHT_ASSET, css.into_bytes(), cfg.assets.fingerprint);
assets.url(HIGHLIGHT_ASSET).map(str::to_string)
} else {
@@ -101,9 +104,10 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
};
assets.write_manifest();
- let templates = Templates::load(root)?
+ let templates = Templates::load(root)
+ .map_err(Kind::Template.tag())?
.with_assets(assets.manifest.clone())
- .with_i18n(i18n::load(root)?);
+ .with_i18n(i18n::load(root).map_err(Kind::Template.tag())?);
let mut out = BuildOutput::default();
out.files.extend(assets.files);
@@ -159,7 +163,8 @@ pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
// 출력 디렉터리를 매번 새로 만든다. 지운 페이지가 유령으로 남는 걸 막는다.
if out_dir.exists() {
std::fs::remove_dir_all(&out_dir)
- .with_context(|| format!("{}을(를) 비울 수 없습니다", out_dir.display()))?;
+ .with_context(|| format!("{}을(를) 비울 수 없습니다", out_dir.display()))
+ .map_err(Kind::Io.tag())?;
}
for (rel, bytes) in &output.files {
@@ -169,7 +174,8 @@ pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
.with_context(|| format!("{}을(를) 만들 수 없습니다", parent.display()))?;
}
std::fs::write(&dest, bytes)
- .with_context(|| format!("{}을(를) 쓸 수 없습니다", dest.display()))?;
+ .with_context(|| format!("{}을(를) 쓸 수 없습니다", dest.display()))
+ .map_err(Kind::Io.tag())?;
}
Ok(BuildStats {
@@ -196,7 +202,7 @@ fn render_page(
md: &markdown::Renderer,
highlight_css: Option<&str>,
) -> Result<(String, search::Entry)> {
- let template = select_template(page, site, templates)?;
+ let template = select_template(page, site, templates).map_err(Kind::Template.tag())?;
let site_ctx = SiteCtx {
title: cfg.title.clone(),
@@ -209,12 +215,12 @@ fn render_page(
let rendered = md.render_in(&page.language, &page.body);
if !rendered.unresolved.is_empty() {
- anyhow::bail!(
+ return Err(Kind::Content.tag()(anyhow::anyhow!(
"{}: 해석할 수 없는 내부 링크가 있습니다:\n {}\n\
`@/` 는 content/ 기준 소스 경로를 가리켜야 합니다 (예: `@/start/installation.md`).",
page.source.display(),
rendered.unresolved.join("\n ")
- );
+ )));
}
// 섹션 인덱스는 자기 자신이 섹션이라 라벨과 제목이 겹친다. 그때는 비워 둔다.
@@ -261,7 +267,8 @@ fn render_page(
let html = templates
.render(&template, context! { site => site_ctx, page => page_ctx })
- .with_context(|| format!("{} 렌더 중", page.source.display()))?;
+ .with_context(|| format!("{} 렌더 중", page.source.display()))
+ .map_err(Kind::Template.tag())?;
Ok((html, entry))
}
diff --git a/src/main.rs b/src/main.rs
@@ -2,6 +2,7 @@ use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
+use sqzass::error::{Kind, kind_of, message};
use sqzass::serve::{ServeOptions, serve};
use sqzass::{BuildOptions, build, display_path};
@@ -15,6 +16,13 @@ use sqzass::{BuildOptions, build, display_path};
struct Cli {
#[command(subcommand)]
command: Command,
+
+ /// 결과를 JSON 한 줄로 낸다 (스크립트·CI용)
+ ///
+ /// 서브커맨드 앞뒤 어디에 써도 되도록 global로 둔다. 사람이 기억해야 하는
+ /// 플래그 위치가 하나 줄어든다.
+ #[arg(long, global = true)]
+ json: bool,
}
#[derive(Subcommand)]
@@ -78,14 +86,30 @@ struct BuildArgs {
}
fn main() {
- if let Err(err) = run() {
- eprintln!("error: {err:#}");
- std::process::exit(1);
+ let cli = Cli::parse();
+ let json = cli.json;
+ if let Err(err) = run(cli) {
+ let kind = kind_of(&err);
+ if json {
+ // 실패도 stdout으로 낸다. --json을 켠 쪽은 파이프 하나만 읽는다.
+ println!(
+ "{}",
+ serde_json::json!({
+ "ok": false,
+ "error": message(&err),
+ "kind": kind.id(),
+ "code": kind.code(),
+ })
+ );
+ } else {
+ eprintln!("error: {}", message(&err));
+ }
+ std::process::exit(kind.code());
}
}
-fn run() -> Result<()> {
- let cli = Cli::parse();
+fn run(cli: Cli) -> Result<()> {
+ let json = cli.json;
match cli.command {
Command::Build(args) => {
let stats = build(&BuildOptions {
@@ -94,19 +118,41 @@ fn run() -> Result<()> {
drafts: args.drafts,
base_url: args.base_url,
})?;
- println!(
- "{} pages → {}",
- stats.pages_written,
- display_path(&stats.output_dir)
- );
+ if json {
+ println!(
+ "{}",
+ serde_json::json!({
+ "ok": true,
+ "pages": stats.pages_written,
+ "output": display_path(&stats.output_dir),
+ })
+ );
+ } else {
+ println!(
+ "{} pages → {}",
+ stats.pages_written,
+ display_path(&stats.output_dir)
+ );
+ }
Ok(())
}
Command::Init(args) => {
- let written = sqzass::init::init(&args.dir)?;
- for rel in &written {
- println!(" {}", display_path(&args.dir.join(rel)));
+ let written = sqzass::init::init(&args.dir).map_err(Kind::Io.tag())?;
+ if json {
+ println!(
+ "{}",
+ serde_json::json!({
+ "ok": true,
+ "dir": display_path(&args.dir),
+ "files": written,
+ })
+ );
+ } else {
+ for rel in &written {
+ println!(" {}", display_path(&args.dir.join(rel)));
+ }
+ println!("\nsqzass serve -i {}", display_path(&args.dir));
}
- println!("\nsqzass serve -i {}", display_path(&args.dir));
Ok(())
}
Command::Serve(args) => {
|