sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git
commit a9bae04df9fddcb340455735eff71223c92e98a7
parent 0f378562efa1880f57c9960971f3ec6a6af21421
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Tue, 28 Jul 2026 15:41:36 +0900

build --profile 로 페이즈별 시간을 stderr 에 낸다

느린 빌드가 콘텐츠 탓인지 이 도구 탓인지 구분할 수단이 없었다. discover /
assets / feeds / templates / render / search / generate / write 여덟
구간의 벽시계를 한 줄씩 찍는다.

stderr 전용이다 — stdout의 "N pages → …" 계약과 --json 출력, 빌드
산출물은 건드리지 않으므로 결정성과 무관하다. 실패하는 빌드에서도 찍힌
구간까지는 그대로 나오고, 에러 태그와 종료 코드는 변하지 않는다.

Diffstat:
Mdocs/content/start/cli.ko.md | 14+++++++++++++-
Mdocs/content/start/cli.md | 14+++++++++++++-
Msrc/init.rs | 1+
Msrc/lib.rs | 36++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 6++++++
Msrc/serve.rs | 1+
6 files changed, 70 insertion(+), 2 deletion(-)

diff --git a/docs/content/start/cli.ko.md b/docs/content/start/cli.ko.md
@@ -7,7 +7,7 @@ toc = true
 
 ```
 sqzass init  [DIR]
-sqzass build [-i DIR] [-o DIR] [--drafts] [--base-url URL]
+sqzass build [-i DIR] [-o DIR] [--drafts] [--base-url URL] [--profile]
 sqzass serve [-i DIR] [-b ADDR] [-p PORT] [--drafts] [--base-url URL]
 sqzass doctor [-i DIR] [--fail-on note|warn] [--drafts]
 ```
@@ -28,10 +28,22 @@ sqzass doctor [-i DIR] [--fail-on note|warn] [--drafts]
 | `-o`, `--output` | `<input>/public` | 사이트 루트가 아니라 **셸의 현재 디렉터리** 기준으로 해석합니다. |
 | `--drafts` | | `draft = true`인 페이지도 포함합니다. |
 | `--base-url` | 설정값 | 프리뷰 배포에 씁니다. |
+| `--profile` | | 페이즈별 소요 시간을 stderr로 냅니다. stdout은 그대로입니다. |
 
 출력 디렉터리는 매번 비우고 시작합니다. 지운 페이지가 빌드된 사이트에 유령으로
 남지 않습니다.
 
+`--profile`은 페이즈마다 한 줄씩 찍습니다 — discover, assets, feeds, templates,
+render, search, generate, write. 느린 빌드의 원인이 콘텐츠인지 이 도구인지를
+여기서 알 수 있습니다.
+
+```
+ discover  750.8µs
+   assets  1.4ms
+   render  198.9ms
+    write  1.3ms
+```
+
 ## serve
 
 메모리에서 서빙하며 <http://127.0.0.1:3000>에 라이브 리로드를 붙입니다.
diff --git a/docs/content/start/cli.md b/docs/content/start/cli.md
@@ -7,7 +7,7 @@ toc = true
 
 ```
 sqzass init  [DIR]
-sqzass build [-i DIR] [-o DIR] [--drafts] [--base-url URL]
+sqzass build [-i DIR] [-o DIR] [--drafts] [--base-url URL] [--profile]
 sqzass serve [-i DIR] [-b ADDR] [-p PORT] [--drafts] [--base-url URL]
 sqzass doctor [-i DIR] [--fail-on note|warn] [--drafts]
 ```
@@ -28,10 +28,22 @@ run where a `sqzass.toml` already exists. See
 | `-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. |
+| `--profile` | | Per-phase timings on stderr; stdout stays as is. |
 
 The output directory is emptied first, so a page you deleted does not linger as
 a ghost in the built site.
 
+`--profile` prints one line per phase — discover, assets, feeds, templates,
+render, search, generate, write — which is how you learn whether a slow build
+is your content or this tool:
+
+```
+ discover  750.8µs
+   assets  1.4ms
+   render  198.9ms
+    write  1.3ms
+```
+
 ## serve
 
 Serves from memory with live reload on <http://127.0.0.1:3000>. See
diff --git a/src/init.rs b/src/init.rs
@@ -119,6 +119,7 @@ mod tests {
             output: None,
             drafts: false,
             base_url: None,
+            profile: false,
         })
         .unwrap();
         assert_eq!(out.pages_written, 1);
diff --git a/src/lib.rs b/src/lib.rs
@@ -39,6 +39,32 @@ pub struct BuildOptions {
     pub drafts: bool,
     /// 설정의 `base_url`을 덮어쓴다.
     pub base_url: Option<String>,
+    /// 페이즈별 소요 시간을 stderr로 낸다 (`build --profile`).
+    pub profile: bool,
+}
+
+/// `--profile`의 페이즈 시계. stderr로만 말한다 — stdout의 "N pages → …" 계약과
+/// 빌드 산출물은 건드리지 않으므로 결정성과 무관하다.
+struct Profiler {
+    enabled: bool,
+    last: std::time::Instant,
+}
+
+impl Profiler {
+    fn new(enabled: bool) -> Self {
+        Self {
+            enabled,
+            last: std::time::Instant::now(),
+        }
+    }
+
+    /// 직전 구간의 경과를 찍고 시계를 되돌린다.
+    fn lap(&mut self, name: &str) {
+        if self.enabled {
+            eprintln!("{name:>9}  {:.1?}", self.last.elapsed());
+        }
+        self.last = std::time::Instant::now();
+    }
 }
 
 /// 생성된 하이라이트 스타일시트의 논리 경로. 다른 에셋과 같은 규칙으로 해시가 붙는다.
@@ -86,9 +112,11 @@ fn load_config(opts: &BuildOptions) -> Result<Config> {
 fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
     let root = &opts.input;
     let drafts = opts.drafts || cfg.build.drafts;
+    let mut prof = Profiler::new(opts.profile);
 
     let pages = content::discover(root, &cfg, drafts).map_err(Kind::Content.tag())?;
     let site = Site::build(&pages, &cfg);
+    prof.lap("discover");
 
     // 에셋을 먼저 처리해야 템플릿이 해시가 붙은 최종 URL을 조회할 수 있다.
     let mut assets =
@@ -106,6 +134,7 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
     } else {
         None
     };
+    prof.lap("assets");
 
     // 피드를 먼저 만든다. 페이지가 <head>에 자동 발견 링크를 넣으려면 자기 언어에
     // 피드가 있는지를 렌더 시점에 알아야 한다.
@@ -134,6 +163,7 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
             }
         }
     }
+    prof.lap("feeds");
 
     // 사이트가 실제로 내보내는 URL 전체. 이게 있어야 `[설치](/start/install/)` 같은
     // 평범한 절대 경로 링크도 빌드가 검증할 수 있다 — 지금까지는 `@/`만 봤다.
@@ -194,6 +224,7 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
             (lang, minijinja::Value::from_serialize(&ctx))
         })
         .collect();
+    prof.lap("templates");
 
     let mut out = BuildOutput {
         pages: pages.len(),
@@ -227,12 +258,14 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
         );
         index.entry(page.language.clone()).or_default().push(entry);
     }
+    prof.lap("render");
 
     for (language, entries) in index {
         let json = serde_json::to_vec(&entries)
             .with_context(|| format!("{language} 검색 색인을 만들 수 없습니다"))?;
         out.files.insert(search::path(&language), json);
     }
+    prof.lap("search");
 
     // 옛 URL을 새 URL로 보내는 스텁. GitHub Pages에는 리다이렉트 규칙이 없으므로
     // 이걸 빌드가 만들지 않으면 아무도 만들어 주지 않는다.
@@ -299,6 +332,7 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
     out.files.insert(".nojekyll".into(), Vec::new());
 
     check_output_collisions(&out).map_err(Kind::Content.tag())?;
+    prof.lap("generate");
 
     Ok(out)
 }
@@ -317,6 +351,7 @@ pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
     let cfg = load_config(opts)?;
     let out_dir = resolve_output_dir(opts, &cfg);
     let output = render_site(opts, cfg)?;
+    let mut prof = Profiler::new(opts.profile);
 
     // 출력 디렉터리를 매번 새로 만든다. 지운 페이지가 유령으로 남는 걸 막는다.
     if out_dir.exists() {
@@ -335,6 +370,7 @@ pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
             .with_context(|| format!("{}을(를) 쓸 수 없습니다", dest.display()))
             .map_err(Kind::Io.tag())?;
     }
+    prof.lap("write");
 
     Ok(BuildStats {
         pages_written: output.pages,
diff --git a/src/main.rs b/src/main.rs
@@ -103,6 +103,10 @@ struct BuildArgs {
     /// 설정의 base_url을 덮어쓴다
     #[arg(long, value_name = "URL")]
     base_url: Option<String>,
+
+    /// 페이즈별 소요 시간을 stderr로 낸다
+    #[arg(long)]
+    profile: bool,
 }
 
 fn main() {
@@ -137,6 +141,7 @@ fn run(cli: Cli) -> Result<()> {
                 output: args.output,
                 drafts: args.drafts,
                 base_url: args.base_url,
+                profile: args.profile,
             })?;
             if json {
                 println!(
@@ -181,6 +186,7 @@ fn run(cli: Cli) -> Result<()> {
                 output: None,
                 drafts: args.drafts,
                 base_url: None,
+                profile: false,
             })?;
             let gated = findings
                 .iter()
diff --git a/src/serve.rs b/src/serve.rs
@@ -70,6 +70,7 @@ pub async fn serve(opts: ServeOptions) -> Result<()> {
         output: None,
         drafts: opts.drafts,
         base_url: opts.base_url.clone(),
+        profile: false,
     };
 
     let initial = build_to_memory(&build_opts)