sqzass.git

sqzass

~ static site generator in Rust
git clone https://github.com/debuman/sqzass.git
commit 9f6a5d12a828e9f2c7b055295651154dbcf2dccd
parent cdb8b01b7803dbc13c30985f1ae632e644c9a0b2
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 25 Jul 2026 22:48:53 +0900

Give failures a code and an identifier, and add --json

Every failure exited 1, so a pipeline could tell that the build broke and
nothing else. Now the exit code answers "whose fault is it": 3 for the config,
4 for content, 5 for templates, 6 for I/O. CI can branch on that without
parsing a message that is not in its language anyway.

The identifier is printed alongside — `[SQZASS_E_CONTENT] …` — so the thing a
person searches for is a stable string rather than a translated sentence. Both
the numbers and the identifiers are a contract; reassigning one would quietly
change what somebody's existing condition tests for, so there is a test that
fails if either moves.

Classification happens at the pipeline stage boundaries, not at each error site.
Twenty tagged call sites is twenty chances to tag the wrong thing, and the only
distinction a reader wants is which part of their site was rejected.

The first version got that wrong in an instructive way. It wrapped the whole
render call in the template kind, which sat outside the content kind already
attached to an unresolved `@/` link — so a broken link reported itself as a
template error, and the inner identifier leaked into the message as if it were
part of the text. Tags are now applied at the precise operation, the message
filters every identifier rather than only the outermost, and a test covers the
nesting case that produced it.

`--json` prints one object on stdout and nothing else, on failure as well as
success, so a script reads a single pipe rather than reassembling two. It is
global, so it works before or after the subcommand.

Diffstat:
Adocs/content/start/cli.ko.md | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/start/cli.md | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/error.rs | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 31+++++++++++++++++++------------
Msrc/main.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
5 files changed, 405 insertion(+), 26 deletion(-)

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) => {