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()?;
|