sqzass.git

sqzass

~ static site generator in Rust
git clone https://github.com/debuman/sqzass.git
commit 7fc540119b8f115a365518eff731759fca56d741
parent 73afe06b646f7007b8caa61cb04174e9c30a05c7
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 25 Jul 2026 22:21:15 +0900

Add sqzass init

Three files: sqzass.toml, content/_index.md, templates/page.html. The same
three the getting-started page describes, so the tutorial and the tool now say
the same thing rather than two versions of it.

It scaffolds no example blog post, no .gitignore you did not ask for and no
directory the tool expects to own. A scaffold you have to delete half of is not
a head start, and every file in it is a file someone has to read before they
can trust what they are looking at.

The template links `site.highlight_css`. Without that line the build still
generates the stylesheet and nothing references it, which leaves a new user
with an unexplained file in their output on their first build — and it hides
the fact that code blocks come out coloured for free.

Running it where a sqzass.toml already exists is an error rather than a merge.
There is no reading of "initialise this" that means "write into the site I am
already working on".

The test builds what init writes. A scaffold that does not build is worse than
no scaffold, and that is exactly the thing that rots quietly as the rest of the
tool moves.

Diffstat:
Mdocs/content/start/first-site.ko.md | 69++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mdocs/content/start/first-site.md | 75+++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Asrc/init.rs | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 1+
Msrc/main.rs | 17+++++++++++++++++
5 files changed, 260 insertion(+), 49 deletion(-)

diff --git a/docs/content/start/first-site.ko.md b/docs/content/start/first-site.ko.md
@@ -1,15 +1,19 @@
 +++
 title = "첫 사이트 만들기"
-description = "sqzass가 빌드하는 가장 작은 사이트를 한 파일씩"
+description = "빈 디렉터리에서 화면에 뜨는 페이지까지"
 weight = 20
 toc = true
 +++
 
-아직 `sqzass init`이 없어서, 사이트는 직접 만드는 파일 세 개로 시작합니다.
-그게 스캐폴드의 전부입니다. sqzass는 숨은 상태도, 자기가 관리한다고 가정하는
-생성 디렉터리도 두지 않습니다.
+```bash
+sqzass init mysite
+sqzass serve -i mysite
+```
+
+이게 전부입니다. `init`이 파일 세 개를 쓰고, `serve`가 그걸
+<http://127.0.0.1:3000>에 띄웁니다. 네 번째 단계는 없습니다.
 
-## 파일 세 개
+## 무엇이 만들어졌나
 
 ```
 mysite/
@@ -20,36 +24,58 @@ mysite/
     └── page.html
 ```
 
-`sqzass.toml`에 반드시 있어야 하는 키는 둘입니다. 나머지는 전부 기본값이 있습니다.
+세 개뿐입니다. 절반을 지워야 하는 스캐폴드는 출발점이 아니니까요. 요청하지 않은
+`.gitignore`도, 예제 블로그 글도, sqzass가 자기 것이라고 여기는 디렉터리도
+만들지 않습니다.
+
+`init`은 이미 `sqzass.toml`이 있는 디렉터리에서는 실행을 거부합니다. 기존 사이트를
+반쯤 덮어쓰는 일이 생길 수 없습니다.
+
+## sqzass.toml
+
+키는 둘입니다. 나머지는 전부 기본값이 있고, 적지 않은 키는 그 기본값으로 동작합니다.
 
 ```toml
-title    = "내 사이트"
+title    = "mysite"
 base_url = "https://example.com"
 ```
 
-`content/_index.md`가 첫 페이지입니다. front matter는 `+++` 펜스 사이의 TOML이고,
-반드시 적어야 하는 필드는 `title` 하나뿐입니다.
+## content/_index.md
+
+첫 페이지입니다. front matter는 `+++` 펜스 사이의 TOML이고, 반드시 적어야 하는
+필드는 `title` 하나뿐입니다.
 
 ```markdown
 +++
-title = "내 사이트"
+title = "mysite"
 +++
 
 안녕하세요.
 ```
 
-`templates/page.html`이 이걸 렌더합니다. `page.content`는 이미 HTML이므로
-`| safe`가 필요합니다. 템플릿은 기본적으로 이스케이프하며, 그 기본값 덕분에
-본문에 섞인 `<` 하나가 마크업으로 둔갑하지 않습니다.
+## templates/page.html
+
+`page.content`는 이미 HTML이므로 `| safe`가 필요합니다. 템플릿은 기본적으로
+이스케이프하며, 그 기본값 덕분에 본문에 섞인 `<` 하나가 마크업으로 둔갑하지
+않습니다.
 
 ```html
 <!doctype html>
 <html lang="{{ page.language }}">
-<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
+<head>
+<meta charset="utf-8">
+<title>{{ page.title }}</title>
+{%- if site.highlight_css %}
+<link rel="stylesheet" href="{{ site.highlight_css }}">
+{%- endif %}
+</head>
 <body>{{ page.content | safe }}</body>
 </html>
 ```
 
+`site.highlight_css` 줄은 빌드가 당신의 구문 테마에서 만들어 주는 스타일시트입니다.
+빼면 코드 블록에 색이 없이 나옵니다.
+
 ## 빌드하기
 
 ```bash
@@ -59,18 +85,15 @@ sqzass build -i mysite
 결과물은 `mysite/public`에 생깁니다. 모든 페이지는 `<경로>.html`이 아니라
 `<경로>/index.html`로 쓰이므로 URL이 `/about.html`이 아니라 `/about/`이 됩니다.
 rewrite 규칙이 없는 호스트에서 왜 이게 중요한지는
-[페이지와 섹션](@/content/_index.md)에 적어 두었습니다.
+[콘텐츠 작성](@/content/_index.md)에 적어 두었습니다. sitemap과 `robots.txt`도
+같이 나옵니다.
 
 ## 작업하기
 
-```bash
-sqzass serve -i mysite
-```
-
-<http://127.0.0.1:3000>에 메모리에서 사이트를 띄우고, 파일이 바뀌면 다시
-빌드합니다. 도는 동안 `public/`에는 아무것도 쓰지 않으므로, 빌드 중인 사이트가
-반쯤 쓰인 파일을 내보내는 일이 없습니다. 페이지는 스스로 새로고침하고,
-CSS만 바뀐 변경은 스타일시트만 갈아 끼워 스크롤 위치를 유지합니다.
+`sqzass serve`는 파일이 바뀌면 다시 빌드합니다. 도는 동안 `public/`에는 아무것도
+쓰지 않으므로, 빌드 중인 사이트가 반쯤 쓰인 파일을 내보내는 일이 없습니다. 페이지는
+스스로 새로고침하고, CSS만 바뀐 변경은 스타일시트만 갈아 끼워 스크롤 위치를
+유지합니다.
 
 > [!NOTE]
 > 개발 서버는 개발용 도구입니다. 캐싱도, 압축도, 접근 제어도 하지 않습니다.
diff --git a/docs/content/start/first-site.md b/docs/content/start/first-site.md
@@ -1,15 +1,19 @@
 +++
 title = "Your first site"
-description = "The smallest site sqzass will build, one file at a time"
+description = "From an empty directory to a page on screen"
 weight = 20
 toc = true
 +++
 
-There is no `sqzass init` yet, so a site starts as three files you make
-yourself. That is the whole scaffold — sqzass has no hidden state and no
-generated directory it expects to own.
+```bash
+sqzass init mysite
+sqzass serve -i mysite
+```
+
+That is the whole thing. `init` writes three files, `serve` puts them on
+<http://127.0.0.1:3000>, and there is no fourth step.
 
-## The three files
+## What it wrote
 
 ```
 mysite/
@@ -20,37 +24,60 @@ mysite/
     └── page.html
 ```
 
-`sqzass.toml` needs two keys. Everything else has a default.
+Three files, because a scaffold you have to delete half of is not a head start.
+There is no `.gitignore` you did not ask for, no example blog post, and no
+directory sqzass expects to own.
+
+`init` refuses to run in a directory that already has a `sqzass.toml`, so it
+cannot half-overwrite a site you already have.
+
+## sqzass.toml
+
+Two keys. Everything else has a default, and a key you leave out behaves as if
+it were set to that default.
 
 ```toml
-title    = "My site"
+title    = "mysite"
 base_url = "https://example.com"
 ```
 
-`content/_index.md` is the front page. Front matter is TOML between `+++`
-fences, and `title` is the only field you must supply.
+## content/_index.md
+
+The front page. Front matter is TOML between `+++` fences, and `title` is the
+only field you must supply.
 
 ```markdown
 +++
-title = "My site"
+title = "mysite"
 +++
 
 Hello.
 ```
 
-`templates/page.html` renders it. `page.content` is already HTML, so it needs
-`| safe` — templates escape by default, and that default is what keeps a stray
-`<` in your prose from becoming markup.
+## templates/page.html
+
+`page.content` is already HTML, so it needs `| safe` — templates escape by
+default, and that default is what keeps a stray `<` in your prose from becoming
+markup.
 
 ```html
 <!doctype html>
 <html lang="{{ page.language }}">
-<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
+<head>
+<meta charset="utf-8">
+<title>{{ page.title }}</title>
+{%- if site.highlight_css %}
+<link rel="stylesheet" href="{{ site.highlight_css }}">
+{%- endif %}
+</head>
 <body>{{ page.content | safe }}</body>
 </html>
 ```
 
-## Build it
+The `site.highlight_css` line is the stylesheet the build generates from your
+syntax themes. Leave it out and code blocks come out unstyled.
+
+## Building it
 
 ```bash
 sqzass build -i mysite
@@ -58,19 +85,15 @@ sqzass build -i mysite
 
 The output lands in `mysite/public`. Every page is written as
 `<path>/index.html`, never `<path>.html`, so the URL is `/about/` and not
-`/about.html` — see [Pages and sections](@/content/_index.md) for why that
-matters on a host with no rewrite rules.
-
-## Work on it
+`/about.html` — see [Writing content](@/content/_index.md) for why that matters
+on a host with no rewrite rules. A sitemap and a `robots.txt` come out too.
 
-```bash
-sqzass serve -i mysite
-```
+## Working on it
 
-This serves the site from memory on <http://127.0.0.1:3000> and rebuilds when
-a file changes. Nothing is written to `public/` while it runs, so a rebuilding
-site never serves a half-written file. The page reloads itself; a change that
-only touched CSS swaps the stylesheet in place and keeps your scroll position.
+`sqzass serve` rebuilds when a file changes. Nothing is written to `public/`
+while it runs, so a rebuilding site never serves a half-written file. The page
+reloads itself; a change that only touched CSS swaps the stylesheet in place
+and keeps your scroll position.
 
 > [!NOTE]
 > The dev server is a development tool. It does no caching, no compression and
diff --git a/src/init.rs b/src/init.rs
@@ -0,0 +1,147 @@
+//! `sqzass init` — 새 사이트 스캐폴드.
+//!
+//! 파일 셋만 만든다. 문서의 "첫 사이트 만들기"가 손으로 만들라고 적어 둔 바로 그
+//! 셋이고, 그래야 튜토리얼과 도구가 같은 것을 말한다.
+//!
+//! 예제 콘텐츠를 잔뜩 깔지 않는 이유는, 스캐폴드가 크면 처음 하는 일이 "만드는 것"이
+//! 아니라 "지우는 것"이 되기 때문이다.
+
+use anyhow::{Context, Result, bail};
+use std::path::Path;
+
+/// 스캐폴드가 만드는 파일들. `(경로, 내용)` — `{title}`만 치환한다.
+const FILES: &[(&str, &str)] = &[
+    (
+        "sqzass.toml",
+        r#"title    = "{title}"
+base_url = "https://example.com"
+
+# 나머지는 전부 기본값이 있다. `sqzass.toml`에 없는 키는 없는 대로 동작한다.
+"#,
+    ),
+    (
+        "content/_index.md",
+        r#"+++
+title = "{title}"
++++
+
+이 파일이 사이트의 첫 페이지입니다. `content/` 아래에 마크다운을 더하면 페이지가
+늘어납니다. `about.md`는 `/about/`이 되고, `guide/_index.md`가 있는 디렉터리는
+섹션이 됩니다.
+
+```bash
+sqzass serve
+```
+"#,
+    ),
+    (
+        "templates/page.html",
+        r#"<!doctype html>
+<html lang="{{ page.language }}">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{ page.title }}</title>
+{# 코드 강조 스타일시트. 빌드가 테마에서 만들어 준다. #}
+{%- if site.highlight_css %}
+<link rel="stylesheet" href="{{ site.highlight_css }}">
+{%- endif %}
+</head>
+<body>
+{# page.content 는 이미 HTML이라 `| safe`가 필요하다. 나머지 값은 전부
+   자동으로 이스케이프된다. #}
+{{ page.content | safe }}
+</body>
+</html>
+"#,
+    ),
+];
+
+/// `dir`에 새 사이트를 만든다.
+///
+/// 이미 `sqzass.toml`이 있으면 아무것도 하지 않는다 — 남의 사이트를 반쯤 덮어쓴
+/// 상태로 만드는 것보다 아무 일도 안 하는 게 낫다.
+pub fn init(dir: &Path) -> Result<Vec<String>> {
+    let config = dir.join("sqzass.toml");
+    if config.exists() {
+        bail!(
+            "{}에 이미 사이트가 있습니다 (sqzass.toml).\n\
+             비어 있는 디렉터리를 지정하거나, 새 디렉터리 이름을 주세요.",
+            dir.display()
+        );
+    }
+
+    // 디렉터리 이름을 제목으로 쓴다. `.`처럼 이름이랄 게 없으면 현재 디렉터리
+    // 이름으로 떨어지고, 그것도 없으면 마지막 폴백을 쓴다.
+    let title = dir
+        .canonicalize()
+        .ok()
+        .as_deref()
+        .and_then(Path::file_name)
+        .or_else(|| dir.file_name())
+        .map(|n| n.to_string_lossy().into_owned())
+        .unwrap_or_else(|| "My site".into());
+
+    let mut written = Vec::new();
+    for (rel, body) in FILES {
+        let path = dir.join(rel);
+        if let Some(parent) = path.parent() {
+            std::fs::create_dir_all(parent)
+                .with_context(|| format!("{}을(를) 만들 수 없습니다", parent.display()))?;
+        }
+        std::fs::write(&path, body.replace("{title}", &title))
+            .with_context(|| format!("{}을(를) 쓸 수 없습니다", path.display()))?;
+        written.push((*rel).to_string());
+    }
+
+    Ok(written)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn tmp(name: &str) -> std::path::PathBuf {
+        let p = std::env::temp_dir().join(format!("sqzass-init-{}-{name}", std::process::id()));
+        let _ = std::fs::remove_dir_all(&p);
+        p
+    }
+
+    #[test]
+    fn scaffolds_a_buildable_site() {
+        let dir = tmp("build");
+        let written = init(&dir).unwrap();
+        assert_eq!(written.len(), 3, "파일 셋: {written:?}");
+
+        // 스캐폴드가 곧바로 빌드되지 않으면 스캐폴드가 아니다.
+        let out = crate::build(&crate::BuildOptions {
+            input: dir.clone(),
+            output: None,
+            drafts: false,
+            base_url: None,
+        })
+        .unwrap();
+        assert_eq!(out.pages_written, 1);
+        assert!(dir.join("public/index.html").is_file());
+
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn refuses_to_overwrite_an_existing_site() {
+        let dir = tmp("existing");
+        init(&dir).unwrap();
+        let err = init(&dir).unwrap_err().to_string();
+        assert!(err.contains("이미 사이트가 있습니다"), "실제: {err}");
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn title_comes_from_the_directory_name() {
+        let dir = tmp("mysite");
+        init(&dir).unwrap();
+        let cfg = std::fs::read_to_string(dir.join("sqzass.toml")).unwrap();
+        assert!(cfg.contains("mysite"), "실제: {cfg}");
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
@@ -4,6 +4,7 @@ pub mod assets;
 pub mod config;
 pub mod content;
 pub mod highlight;
+pub mod init;
 pub mod links;
 pub mod markdown;
 pub mod render;
diff --git a/src/main.rs b/src/main.rs
@@ -23,6 +23,15 @@ enum Command {
     Build(BuildArgs),
     /// 라이브 리로드가 붙은 개발 서버를 띄운다
     Serve(ServeArgs),
+    /// 새 사이트를 만든다
+    Init(InitArgs),
+}
+
+#[derive(clap::Args)]
+struct InitArgs {
+    /// 만들 디렉터리. 없으면 만든다.
+    #[arg(default_value = ".", value_name = "DIR")]
+    dir: PathBuf,
 }
 
 #[derive(clap::Args)]
@@ -92,6 +101,14 @@ fn run() -> Result<()> {
             );
             Ok(())
         }
+        Command::Init(args) => {
+            let written = sqzass::init::init(&args.dir)?;
+            for rel in &written {
+                println!("  {}", display_path(&args.dir.join(rel)));
+            }
+            println!("\nsqzass serve -i {}", display_path(&args.dir));
+            Ok(())
+        }
         Command::Serve(args) => {
             // `build`는 동기 경로라 런타임을 서버에서만 만든다.
             let rt = tokio::runtime::Runtime::new()?;