sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git
commit c9549468fe65caa16869ac4f0744a266cf66807c
parent 715d0e3d69d101c6823871577a6f4793fb3cdbb8
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 25 Jul 2026 23:49:16 +0900

Document the five hosts we already worked on

deploy/_index.md claimed the same directory is correct on Cloudflare Pages,
Netlify, S3 and nginx, and then documented one host. A reader evaluating sqzass
for Netlify had to take that on faith, and `ls docs/content/deploy/` said
GitHub Pages was the only supported target. The claim was true; the evidence
was missing, and a claim without evidence reads as marketing.

Netlify, Vercel, Cloudflare Pages, GitLab Pages and Codeberg Pages, in both
languages. No code changed, because none was needed — every one of them is
"run sqzass build, publish public/", and the pages differ only in which file
that goes in and what each host calls its preview URL.

Two things they gave the docs that GitHub Pages could not.

The subpath is now written down where people meet it. GitLab and Codeberg
project sites are served under /<project>/, as are GitHub project pages, and
omitting it from base_url is the only mistake in this whole area that is
silent — the build succeeds, the pages exist, and every link resolves one level
too high. It is called out on each of those three pages and in the index.

And Cloudflare Pages is where content-hashed filenames finally pay off, because
it reads `_headers` and GitHub Pages pins everything to max-age=600 with no way
to change it. A year on /css/* is safe precisely because the name changes when
the bytes do — which is the argument for hashing the name instead of appending
a query string, stated somewhere a reader can act on it.

The static/ passthrough is documented for the same reason: CNAME, .domains,
_headers and .well-known all work today, and none of it was written down, so
the honest reading was that they did not.

Diffstat:
Mdocs/content/deploy/_index.ko.md | 46++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/content/deploy/_index.md | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/deploy/cloudflare-pages.ko.md | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/deploy/cloudflare-pages.md | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/deploy/codeberg-pages.ko.md | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/deploy/codeberg-pages.md | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/deploy/gitlab-ci.ko.md | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/deploy/gitlab-ci.md | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/deploy/netlify.ko.md | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/deploy/netlify.md | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/deploy/vercel.ko.md | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/content/deploy/vercel.md | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/doctor.rs | 3+++
13 files changed, 679 insertion(+), 0 deletion(-)

diff --git a/docs/content/deploy/_index.ko.md b/docs/content/deploy/_index.ko.md
@@ -43,6 +43,52 @@ rewrite도 없으므로, 그런 것에 기대는 게 생기면 여기서 가장
 `sitemap.xml`이나 `robots.txt`를 직접 `static/`에 넣으면 sqzass는 그 파일을 아예
 만들지 않습니다. 당신 것을 덮어쓰지도, 조용히 무시하지도 않습니다.
 
+## 각 안내서
+
+| | |
+|---|---|
+| [GitHub Pages](@/deploy/github-pages.md) | 이 사이트가 있는 곳이자, 설계를 강제한 호스트 |
+| [Netlify](@/deploy/netlify.md) | `netlify.toml`, 배포 프리뷰 |
+| [Vercel](@/deploy/vercel.md) | `vercel.json`, 프레임워크 기능이 무의미한 이유 |
+| [Cloudflare Pages](@/deploy/cloudflare-pages.md) | 대시보드 두 칸과 `_headers` — 캐시 제어가 필요해지면 옮겨 갈 곳 |
+| [GitLab Pages](@/deploy/gitlab-ci.md) | `.gitlab-ci.yml`, 프로젝트 서브경로 |
+| [Codeberg Pages](@/deploy/codeberg-pages.md) | `pages` 브랜치, `.domains`, Forgejo Actions |
+
+전부 같은 사실 두 개입니다. `sqzass build`를 돌리고 `public/`을 발행한다. 문서가
+갈리는 건 그 두 사실을 어디에 적느냐와, 각 호스트가 프리뷰 URL을 뭐라고 부르느냐뿐입니다.
+
+## 경로 아래에 놓이는 사이트
+
+`https://user.github.io/repo`, `https://group.gitlab.io/project`,
+`https://user.codeberg.page/repo` — 전부 프로젝트 사이트이고, 전부 결과물을 도메인
+루트가 아니라 경로 아래에 서빙합니다. `base_url`에 그 전체를 적으세요.
+
+```toml
+base_url = "https://user.github.io/repo"
+```
+
+그러면 sqzass가 생성하는 모든 URL에 접두사가 붙습니다 — 페이지 링크도, 스타일시트
+href도, 검색 색인도. 출력 디렉터리는 평평하게 남는데, 그 디렉터리가 곧 호스트가
+서빙하는 루트이기 때문입니다.
+
+경로를 빠뜨리는 건 여기서 유일하게 **조용한** 실수입니다. 빌드는 성공하고 페이지도
+다 있는데, 모든 링크와 스타일시트가 한 단계 위를 가리킵니다.
+
+## static/ 은 통과 경로입니다
+
+`static/` 안의 것은 경로와 이름이 그대로 유지된 채 출력에 놓입니다. sqzass가 어떤
+호스트도 알지 못한 채로 호스트별 파일이 동작하는 방식입니다.
+
+| | |
+|---|---|
+| `CNAME` | GitHub Pages 커스텀 도메인 |
+| `.domains` | Codeberg Pages 커스텀 도메인 |
+| `_headers`, `_redirects` | Netlify, Cloudflare Pages |
+| `.well-known/*` | 도메인 소유 검증, `security.txt` |
+
+콘텐츠 해시가 붙는 건 CSS와 JavaScript뿐입니다. 이름 자체가 계약인 파일은 이름을
+유지합니다.
+
 ## 알아 둘 파일 둘
 
 `.nojekyll`은 빌드마다 출력에 들어갑니다. 없으면 GitHub Pages가 출력을 Jekyll로 한 번
diff --git a/docs/content/deploy/_index.md b/docs/content/deploy/_index.md
@@ -46,6 +46,55 @@ Put your own `sitemap.xml` or `robots.txt` in `static/` and sqzass will not
 generate that file at all. It does not overwrite yours, and it does not
 silently ignore it either.
 
+## Where these guides go
+
+| | |
+|---|---|
+| [GitHub Pages](@/deploy/github-pages.md) | Where this site is, and the host that forced the design |
+| [Netlify](@/deploy/netlify.md) | `netlify.toml`, deploy previews |
+| [Vercel](@/deploy/vercel.md) | `vercel.json`, and why the framework machinery is inert |
+| [Cloudflare Pages](@/deploy/cloudflare-pages.md) | Two dashboard fields, and `_headers` — the host to move to when you need cache control |
+| [GitLab Pages](@/deploy/gitlab-ci.md) | `.gitlab-ci.yml`, and the project subpath |
+| [Codeberg Pages](@/deploy/codeberg-pages.md) | A `pages` branch, `.domains`, Forgejo Actions |
+
+Every one of them is the same two facts: run `sqzass build`, publish `public/`.
+The pages differ only in where those facts are written down and what each host
+calls its preview URL.
+
+## Sites served under a path
+
+`https://user.github.io/repo`, `https://group.gitlab.io/project` and
+`https://user.codeberg.page/repo` are all project sites, and all of them serve
+your output under a path rather than at a domain root. Put the whole thing in
+`base_url`:
+
+```toml
+base_url = "https://user.github.io/repo"
+```
+
+sqzass then prefixes every URL it generates — page links, stylesheet hrefs, the
+search index — while the output directory stays flat, because that directory is
+the root the host serves.
+
+Leaving the path out is the one mistake here that is silent. The build succeeds,
+the pages are all there, and every link and stylesheet resolves one level too
+high.
+
+## static/ is a passthrough
+
+Anything in `static/` lands in the output with its path and name intact, which
+is how host-specific files work without sqzass knowing about any host:
+
+| | |
+|---|---|
+| `CNAME` | GitHub Pages custom domain |
+| `.domains` | Codeberg Pages custom domain |
+| `_headers`, `_redirects` | Netlify, Cloudflare Pages |
+| `.well-known/*` | Domain verification, `security.txt` |
+
+Only CSS and JavaScript get a content hash. A name that is the contract keeps
+its name.
+
 ## Two files worth knowing about
 
 `.nojekyll` is written into every build. Without it GitHub Pages runs the
diff --git a/docs/content/deploy/cloudflare-pages.ko.md b/docs/content/deploy/cloudflare-pages.ko.md
@@ -0,0 +1,54 @@
++++
+title = "Cloudflare Pages"
+description = "대시보드 설정 두 개, 그리고 GitHub Pages가 못 주던 헤더"
+weight = 40
+toc = true
++++
+
+프로젝트 빌드 설정에서:
+
+| | |
+|---|---|
+| 프레임워크 프리셋 | 없음 |
+| 빌드 명령 | `curl -sSL https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz \| tar xz --strip-components=1 && ./sqzass build` |
+| 빌드 출력 디렉터리 | `public` |
+
+작성할 설정 파일이 없습니다.
+
+## 헤더가 필요해지면 옮겨 갈 곳이 여기입니다
+
+sqzass는 호스트 설정에 아무것도 기대지 않도록 만들었고, 이 사이트를 GitHub Pages에
+둔 것도 그 약속을 정직하게 지키기 위해서입니다. 다만 GitHub Pages는 전부
+`cache-control: max-age=600`으로 내보내고 그걸 바꿀 방법이 없습니다.
+
+Cloudflare Pages는 `_headers` 파일을 읽으므로, 콘텐츠 해시가 붙은 파일명이 드디어
+제 뜻대로 쓰입니다.
+
+```
+# static/_headers
+/css/*
+  Cache-Control: public, max-age=31536000, immutable
+/js/*
+  Cache-Control: public, max-age=31536000, immutable
+/*
+  Cache-Control: public, max-age=600
+```
+
+이 두 디렉터리에 1년을 걸어도 안전한 이유는, 내용이 바뀌면 파일명이 바뀌기
+때문입니다. 쿼리 문자열이 아니라 이름에 해시를 넣은 것의 보상이고, 헤더를 못 바꾸는
+호스트에서는 쓸 수 없는 보상입니다.
+
+파일을 `static/`에 두면 그대로 복사됩니다.
+
+## 리다이렉트
+
+`_redirects`도 같은 방식으로 동작하지만, front matter의 `aliases`를 먼저 보세요.
+옮겨진 페이지 바로 옆에 있고, 빌드가 검사하며, 다음에 어떤 호스트로 가든 따라옵니다.
+
+## 프리뷰 배포
+
+`$CF_PAGES_URL`에 프리뷰 주소가 들어 있습니다.
+
+```bash
+./sqzass build --base-url "$CF_PAGES_URL"
+```
diff --git a/docs/content/deploy/cloudflare-pages.md b/docs/content/deploy/cloudflare-pages.md
@@ -0,0 +1,56 @@
++++
+title = "Cloudflare Pages"
+description = "Two settings in the dashboard, and the headers GitHub Pages would not give you"
+weight = 40
+toc = true
++++
+
+In the project's build settings:
+
+| | |
+|---|---|
+| Framework preset | None |
+| Build command | `curl -sSL https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz \| tar xz --strip-components=1 && ./sqzass build` |
+| Build output directory | `public` |
+
+There is no configuration file to write.
+
+## This is the host to move to when you need headers
+
+sqzass is built so nothing depends on host configuration, and this site is on
+GitHub Pages specifically to keep that honest. But GitHub Pages serves
+everything with `cache-control: max-age=600` and there is no way to change it.
+
+Cloudflare Pages reads a `_headers` file, so the content-hashed filenames can
+finally mean what they are for:
+
+```
+# static/_headers
+/css/*
+  Cache-Control: public, max-age=31536000, immutable
+/js/*
+  Cache-Control: public, max-age=31536000, immutable
+/*
+  Cache-Control: public, max-age=600
+```
+
+A year is safe for those two directories precisely because their filenames
+change when their contents change. That is the payoff of hashing the name
+rather than appending a query string, and it is unavailable on a host that will
+not let you set the header.
+
+Put the file in `static/` and it is copied through untouched.
+
+## Redirects
+
+`_redirects` works the same way, but reach for `aliases` in front matter first:
+it lives next to the page that moved, it is checked at build time, and it
+follows the page to whatever host you use next.
+
+## Preview deployments
+
+`$CF_PAGES_URL` holds the preview address:
+
+```bash
+./sqzass build --base-url "$CF_PAGES_URL"
+```
diff --git a/docs/content/deploy/codeberg-pages.ko.md b/docs/content/deploy/codeberg-pages.ko.md
@@ -0,0 +1,68 @@
++++
+title = "Codeberg Pages"
+description = "pages 브랜치, .domains 파일, 그리고 Forgejo Actions"
+weight = 60
+toc = true
++++
+
+Codeberg는 저장소의 `pages` 브랜치를 `https://<사용자>.codeberg.page/<저장소>/`에
+서빙합니다. 저장소 이름이 `pages`면 통째로 `https://<사용자>.codeberg.page/`가
+됩니다.
+
+## CI에서 빌드하기
+
+```yaml
+# .forgejo/workflows/deploy.yml
+on:
+  push:
+    branches: [main]
+
+jobs:
+  deploy:
+    runs-on: docker
+    container:
+      image: alpine:latest
+    steps:
+      - run: apk add --no-cache git nodejs
+      - uses: actions/checkout@v4
+      - run: wget -qO- https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1
+      - run: ./sqzass build
+      - name: pages 브랜치로 발행
+        run: |
+          cd public
+          git init -q && git add -A
+          git -c user.email=ci -c user.name=ci commit -qm "Deploy"
+          git push -f "https://$GITHUB_ACTOR:${{ secrets.PAGES_TOKEN }}@codeberg.org/$GITHUB_REPOSITORY.git" HEAD:pages
+```
+
+`nodejs`는 Forgejo Actions 러너에 필요한 것이지 sqzass에 필요한 게 아닙니다.
+
+## 여기도 서브경로
+
+저장소 이름이 `pages`가 아니라면 사이트는 `/<저장소>/` 아래에 놓입니다.
+
+```toml
+base_url = "https://myuser.codeberg.page/myrepo"
+```
+
+실패 방식은 GitLab과 같습니다. 빌드는 성공하고 모든 링크가 한 단계 위를 가리킵니다.
+자세한 설명은 [GitLab Pages](@/deploy/gitlab-ci.md)에 적어 두었습니다.
+
+## 커스텀 도메인
+
+Codeberg는 서빙되는 디렉터리 루트의 `.domains` 파일을 읽습니다. `static/`에 두면
+GitHub Pages의 `CNAME`과 마찬가지로 이름이 유지된 채 그대로 나갑니다.
+
+```
+# static/.domains
+example.com
+www.example.com
+```
+
+커스텀 도메인을 쓰면 서브경로가 없으므로 `base_url`은 도메인만 남습니다.
+
+> [!NOTE]
+> `Path::extension()`은 이름 전체가 확장자처럼 생긴 파일에 대해 아무것도 돌려주지
+> 않습니다. `.domains`나 `.nojekyll` 같은 파일이 `main.css`와 다른 취급을 받아야
+> 하는 이유입니다. sqzass는 이런 파일을 그대로 통과시키고, 이름 전체가 확장자인
+> 것에 해시를 붙이려 하지 않습니다.
diff --git a/docs/content/deploy/codeberg-pages.md b/docs/content/deploy/codeberg-pages.md
@@ -0,0 +1,70 @@
++++
+title = "Codeberg Pages"
+description = "A pages branch, a .domains file, and Forgejo Actions"
+weight = 60
+toc = true
++++
+
+Codeberg serves the `pages` branch of a repository at
+`https://<user>.codeberg.page/<repo>/`, or the whole of a repository named
+`pages` at `https://<user>.codeberg.page/`.
+
+## Building it in CI
+
+```yaml
+# .forgejo/workflows/deploy.yml
+on:
+  push:
+    branches: [main]
+
+jobs:
+  deploy:
+    runs-on: docker
+    container:
+      image: alpine:latest
+    steps:
+      - run: apk add --no-cache git nodejs
+      - uses: actions/checkout@v4
+      - run: wget -qO- https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1
+      - run: ./sqzass build
+      - name: Publish to the pages branch
+        run: |
+          cd public
+          git init -q && git add -A
+          git -c user.email=ci -c user.name=ci commit -qm "Deploy"
+          git push -f "https://$GITHUB_ACTOR:${{ secrets.PAGES_TOKEN }}@codeberg.org/$GITHUB_REPOSITORY.git" HEAD:pages
+```
+
+`nodejs` is there for the Forgejo Actions runner, not for sqzass.
+
+## The subpath, again
+
+Unless the repository is named `pages`, the site lives under `/<repo>/`:
+
+```toml
+base_url = "https://myuser.codeberg.page/myrepo"
+```
+
+The failure mode is the same one GitLab has — a build that succeeds and a site
+where every link is one level too high. See
+[GitLab Pages](@/deploy/gitlab-ci.md) for the longer explanation.
+
+## A custom domain
+
+Codeberg reads a `.domains` file from the root of the served directory. Put it
+in `static/` and it comes through with its name intact, the same way `CNAME`
+does for GitHub Pages:
+
+```
+# static/.domains
+example.com
+www.example.com
+```
+
+With a custom domain there is no subpath, so `base_url` becomes the domain.
+
+> [!NOTE]
+> `Path::extension()` returns nothing for a name that is all suffix, which is
+> why files like `.domains` and `.nojekyll` need handling that files like
+> `main.css` do not. sqzass copies them through; it does not try to fingerprint
+> a name that is entirely an extension.
diff --git a/docs/content/deploy/gitlab-ci.ko.md b/docs/content/deploy/gitlab-ci.ko.md
@@ -0,0 +1,56 @@
++++
+title = "GitLab Pages"
+description = ".gitlab-ci.yml 하나, 그리고 사람들이 걸려 넘어지는 서브경로"
+weight = 50
+toc = true
++++
+
+```yaml
+# .gitlab-ci.yml
+pages:
+  image: alpine:latest
+  script:
+    - wget -qO- https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1
+    - ./sqzass build
+  artifacts:
+    paths: [public]
+  rules:
+    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+```
+
+`alpine`이 되는 건 릴리스가 정적 링크이기 때문입니다. 그 이미지엔 glibc가 없고,
+여기엔 glibc가 필요한 게 없습니다. 잡 이름이 `pages`이고 아티팩트 디렉터리가
+`public`인 건 GitLab이 정확히 그 두 이름을 찾기 때문입니다.
+
+## 서브경로
+
+커스텀 도메인을 걸지 않았다면 GitLab 프로젝트 사이트는
+`https://<그룹>.gitlab.io/<프로젝트>/`에서 서빙됩니다. 그 경로가 `base_url`에
+들어가야 합니다.
+
+```toml
+base_url = "https://mygroup.gitlab.io/myproject"
+```
+
+그러면 sqzass가 생성하는 모든 URL 앞에 `/myproject`가 붙습니다 — 링크도,
+스타일시트 href도, 검색 색인 위치도 전부. 출력 디렉터리는 그대로 평평하게 남는데,
+그 디렉터리가 곧 GitLab이 서빙하는 루트이기 때문입니다.
+
+이걸 틀리면 요란하지 않고 조용합니다. 빌드는 되고 페이지도 생기는데, 모든 링크와
+스타일시트가 한 단계 위를 가리켜서 404와 스타일 없는 페이지가 나옵니다. 빌드는
+성공했다고 보고한 채로요.
+
+사용자/그룹 사이트(`<그룹>.gitlab.io`)에는 경로가 없으므로 `base_url`은 도메인뿐입니다.
+
+## 머지 리퀘스트 프리뷰
+
+```yaml
+pages:
+  # …
+  script:
+    - wget -qO- … | tar xz --strip-components=1
+    - ./sqzass build --base-url "$CI_PAGES_URL"
+```
+
+`$CI_PAGES_URL`에 이미 프로젝트 경로가 들어 있으므로, 서브경로를 두 번 적지 않는
+가장 짧은 방법이기도 합니다.
diff --git a/docs/content/deploy/gitlab-ci.md b/docs/content/deploy/gitlab-ci.md
@@ -0,0 +1,56 @@
++++
+title = "GitLab Pages"
+description = "One .gitlab-ci.yml, and the subpath that trips people up"
+weight = 50
+toc = true
++++
+
+```yaml
+# .gitlab-ci.yml
+pages:
+  image: alpine:latest
+  script:
+    - wget -qO- https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1
+    - ./sqzass build
+  artifacts:
+    paths: [public]
+  rules:
+    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+```
+
+`alpine` works because the release is statically linked — there is no glibc in
+that image and nothing here needs one. The job is named `pages` and the artifact
+directory is `public` because GitLab looks for exactly those two names.
+
+## The subpath
+
+Unless you have set a custom domain, a GitLab project site is served at
+`https://<group>.gitlab.io/<project>/`. That path has to be in `base_url`:
+
+```toml
+base_url = "https://mygroup.gitlab.io/myproject"
+```
+
+sqzass then puts `/myproject` in front of every URL it generates — links,
+stylesheet hrefs, the search index location, everything — while the output
+directory stays flat, because that directory *is* the root GitLab serves.
+
+Getting this wrong is quiet rather than loud. The site builds, the pages exist,
+and every link and stylesheet resolves one level too high, so you get 404s and
+an unstyled page from a build that reported success.
+
+For a user or group site (`<group>.gitlab.io`) there is no path, and `base_url`
+is just the domain.
+
+## Merge request previews
+
+```yaml
+pages:
+  # …
+  script:
+    - wget -qO- … | tar xz --strip-components=1
+    - ./sqzass build --base-url "$CI_PAGES_URL"
+```
+
+`$CI_PAGES_URL` already contains the project path, so this is also the shortest
+way to avoid writing the subpath twice.
diff --git a/docs/content/deploy/netlify.ko.md b/docs/content/deploy/netlify.ko.md
@@ -0,0 +1,60 @@
++++
+title = "Netlify"
+description = "netlify.toml, 그리고 base_url이 맞는 배포 프리뷰"
+weight = 20
+toc = true
++++
+
+```toml
+# netlify.toml
+[build]
+  command   = "curl -sSL https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1 && ./sqzass build"
+  publish   = "public"
+
+[context.deploy-preview]
+  command   = "curl -sSL https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1 && ./sqzass build --base-url $DEPLOY_PRIME_URL"
+```
+
+설정은 이게 전부입니다. 플러그인도, 빌드 이미지도, 런타임도 없습니다.
+
+## 왜 설치가 아니라 바이너리를 받나
+
+Netlify 빌드 이미지에는 Rust 툴체인이 기본으로 없고, 넣으면 빌드마다 몇 분이
+더 듭니다. 리눅스 릴리스는 정적 링크라 glibc 버전을 맞추지 않고도 어떤 이미지에서든
+돕니다. Alpine에서도, scratch 컨테이너에서도, 빌드한 머신보다 오래된 머신에서도
+도는 것과 같은 성질입니다.
+
+소스에서 빌드하고 싶으면 `cargo install --git
+https://github.com/sqzer-x/sqzass` 도 됩니다. 다만 느립니다.
+
+## 배포 프리뷰에는 자기 base_url이 필요합니다
+
+프리뷰는 당신의 도메인이 아니라 `deploy-preview-42--yoursite.netlify.app`에서
+돕니다. Netlify가 그 주소를 `$DEPLOY_PRIME_URL`에 넣어 주고, `--base-url`이
+바로 이 경우를 위해 설정을 덮어씁니다. 그래야 canonical과 sitemap, OpenGraph 태그가
+프로덕션이 아니라 프리뷰를 가리킵니다.
+
+이걸 안 해도 프리뷰는 **동작합니다.** 내부 링크가 루트 절대 경로라서 호스트 이름을
+신경 쓰지 않기 때문입니다. 깨지는 건 절대 URL 쪽입니다 — `page.permalink`,
+`sitemap.xml`, 소셜 태그.
+
+## 예쁜 URL·리다이렉트·헤더
+
+설정할 게 없습니다. 모든 페이지가 `index.html`을 담은 디렉터리라서 Netlify가
+rewrite 규칙 없이 `/start/`를 서빙하고, `templates/404.html`이 있으면 `/404.html`을
+이름으로 찾아 씁니다.
+
+`_redirects`와 `_headers`는 Netlify 고유 파일이고 둘 다 필수가 아닙니다. 쓰고 싶으면
+`static/`에 두면 그대로 복사됩니다 — `static/`은 통과 경로이고, 이름 자체가 계약인
+파일은 이름이 유지됩니다.
+
+```
+static/
+├── _headers
+└── _redirects
+```
+
+## 필요 없는 것
+
+`netlify-plugin-*`도, `NODE_VERSION`도, `functions` 디렉터리도 없습니다. 결과물은
+파일이 든 디렉터리입니다.
diff --git a/docs/content/deploy/netlify.md b/docs/content/deploy/netlify.md
@@ -0,0 +1,61 @@
++++
+title = "Netlify"
+description = "netlify.toml, and deploy previews that build with the right base_url"
+weight = 20
+toc = true
++++
+
+```toml
+# netlify.toml
+[build]
+  command   = "curl -sSL https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1 && ./sqzass build"
+  publish   = "public"
+
+[context.deploy-preview]
+  command   = "curl -sSL https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1 && ./sqzass build --base-url $DEPLOY_PRIME_URL"
+```
+
+That is the whole configuration. No plugin, no build image, no runtime.
+
+## Why the binary is downloaded rather than installed
+
+Netlify's build image has no Rust toolchain by default, and adding one costs
+minutes on every build. The Linux release is statically linked, so it runs in
+any image without matching a glibc version — which is the same property that
+makes it work in Alpine, in a scratch container, and on a machine older than the
+one that built it.
+
+If you would rather build from source, `cargo install --git
+https://github.com/sqzer-x/sqzass` works too and is slower.
+
+## Deploy previews need their own base_url
+
+A preview runs at `deploy-preview-42--yoursite.netlify.app`, not at your domain.
+Netlify puts that address in `$DEPLOY_PRIME_URL`, and `--base-url` overrides the
+config for exactly this case — so canonical links, the sitemap and OpenGraph
+tags describe the preview rather than pointing every crawler at production.
+
+Without it the preview still *works*, because internal links are root-absolute
+and do not care what the host is called. What breaks is everything absolute:
+`page.permalink`, `sitemap.xml`, and the social tags.
+
+## Pretty URLs, redirects and headers
+
+Nothing to configure. Every page is a directory containing `index.html`, so
+Netlify serves `/start/` without a rewrite rule, and `/404.html` is picked up by
+name if you have a `templates/404.html`.
+
+`_redirects` and `_headers` are Netlify's own files and neither is required.
+If you want them, put them in `static/` and they are copied through untouched —
+`static/` is a passthrough, and files whose name is the contract keep their name.
+
+```
+static/
+├── _headers
+└── _redirects
+```
+
+## What this does not need
+
+No `netlify-plugin-*`, no `NODE_VERSION`, no `functions` directory. The output
+is a directory of files.
diff --git a/docs/content/deploy/vercel.ko.md b/docs/content/deploy/vercel.ko.md
@@ -0,0 +1,49 @@
++++
+title = "Vercel"
+description = "vercel.json, 그리고 프레임워크 기능들이 여기서 무의미한 이유"
+weight = 30
+toc = true
++++
+
+```json
+{
+  "buildCommand": "curl -sSL https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1 && ./sqzass build",
+  "outputDirectory": "public",
+  "framework": null
+}
+```
+
+`"framework": null`이 중요합니다. Vercel의 감지는 `package.json`을 찾는데, 없으면
+추측을 합니다. 잘못 추측하면 빌드가 실패하고, 그 실패가 sqzass 탓처럼 읽힙니다.
+
+## 프리뷰 배포
+
+Vercel은 프리뷰 호스트를 스킴 없이 `$VERCEL_URL`로 줍니다.
+
+```json
+{
+  "buildCommand": "… && ./sqzass build --base-url \"https://$VERCEL_URL\""
+}
+```
+
+이유는 다른 호스트와 같습니다. 내부 링크는 루트 절대 경로라 어디서든 동작하지만,
+canonical·sitemap·소셜 태그는 절대 URL이라 프리뷰에서 프로덕션을 가리키게 됩니다.
+
+## 끝 슬래시
+
+sqzass는 `/start/index.html`을 쓰므로 `/start/`가 정본이고, 생성하는 모든 링크가
+그 형태입니다. Vercel의 기본 `trailingSlash` 동작은 둘 사이를 리다이렉트하므로
+손으로 `/start`라고 쓴 링크에는 한 번의 왕복이 더 듭니다. `"trailingSlash": true`로
+두면 그 왕복이 없어집니다.
+
+필수가 아니라 취향입니다. 어느 쪽이든 사이트는 옳게 동작합니다.
+
+## 필요 없는 것
+
+서버리스 함수도, ISR도, 엣지 설정도, 이미지 최적화도 없습니다. sqzass는 요청 시점에
+실행할 게 없으므로, Vercel을 파일 서버와 구별되게 하는 부분이 전부 여기서는 놀고
+있습니다.
+
+이건 분명히 말해 둘 만합니다. 팀이 이미 Vercel을 쓰고 있다면 잘 됩니다. sqzass
+사이트를 위해 호스트를 **고르는** 중이라면, 프레임워크 지원이 선택 기준이 되지는
+않을 겁니다.
diff --git a/docs/content/deploy/vercel.md b/docs/content/deploy/vercel.md
@@ -0,0 +1,51 @@
++++
+title = "Vercel"
+description = "vercel.json, and why none of the framework machinery applies"
+weight = 30
+toc = true
++++
+
+```json
+{
+  "buildCommand": "curl -sSL https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1 && ./sqzass build",
+  "outputDirectory": "public",
+  "framework": null
+}
+```
+
+`"framework": null` matters. Vercel's detection looks for a `package.json` and,
+finding none, will otherwise guess — and a wrong guess produces a build that
+fails in a way that reads as sqzass's fault.
+
+## Preview deployments
+
+Vercel exposes the preview host as `$VERCEL_URL`, without a scheme:
+
+```json
+{
+  "buildCommand": "… && ./sqzass build --base-url \"https://$VERCEL_URL\""
+}
+```
+
+Same reasoning as everywhere else: internal links are root-absolute and work
+regardless, but canonical, the sitemap and social tags are absolute and would
+otherwise announce production from a preview.
+
+## Trailing slashes
+
+sqzass writes `/start/index.html`, so `/start/` is the canonical form and every
+link it generates uses it. Vercel's default `trailingSlash` behaviour redirects
+between the two, which costs a hop on links written by hand as `/start`. Setting
+`"trailingSlash": true` removes the hop.
+
+This is a preference, not a requirement — the site is correct either way.
+
+## What this does not need
+
+No serverless functions, no ISR, no edge config, no image optimisation.
+sqzass has nothing to run at request time, so the parts of Vercel that
+distinguish it from a file server are all inert here.
+
+That is worth saying plainly: if Vercel is where your team already deploys,
+this works. If you are choosing a host for a sqzass site, the reason to pick one
+over another is not going to be its framework support.
diff --git a/src/doctor.rs b/src/doctor.rs
@@ -199,6 +199,9 @@ fn unused_templates(
 ) -> Result<()> {
     let templates = Templates::load(root)?;
     let mut used: BTreeSet<String> = BTreeSet::new();
+    // 페이지가 고르는 게 아니라 빌드가 직접 렌더하는 템플릿. 이걸 빼면 린트가
+    // 쓰이고 있는 파일을 지우라고 권하게 된다.
+    used.insert(crate::emit::NOT_FOUND_PATH.to_string());
     for page in pages {
         if let Ok(name) = crate::select_template(page, site, &templates) {
             used.insert(name);