diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
@@ -0,0 +1,77 @@
+name: Deploy
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# 배포를 직렬화한다. cancel-in-progress 를 켜면 절반쯤 올라간 배포가 취소되어
+# 사이트가 부분적으로만 갱신된 상태로 남는데, 그건 기다리는 것보다 나쁘다.
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ # 빌드보다 먼저 돈다. 카탈로그의 url 오타와 i18n 키 누락은 sqzass 가
+ # 확인해 주지 않는 유일한 참조라, 여기서 안 잡으면 조용히 배포된다.
+ - name: Check content integrity
+ run: python3 tools/check.py
+
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+
+ # TODO: sqzer-x/sqzass#1 이 머지되면 --branch 를 지운다.
+ # 목록에 날짜를 실어 오는 PageRefCtx.date 가 그 PR 에 있고, Posts 원장이
+ # 그걸 쓴다.
+ - name: Install sqzass
+ run: cargo install --git https://github.com/sqzer-x/sqzass --branch feat/pageref-date --locked
+
+ - uses: actions/configure-pages@v6
+
+ - name: Build
+ run: sqzass build -i .
+
+ # 재현성은 sqzass 가 내세우는 것이므로 이 사이트에서도 확인한다.
+ # 같은 입력을 두 번 빌드해서 바이트가 다르면 배포하지 않는다.
+ - name: Build again and compare
+ run: |
+ mv public /tmp/first
+ sqzass build -i .
+ diff -r /tmp/first public
+
+ - uses: actions/upload-pages-artifact@v5
+ with:
+ path: public
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url || steps.retry.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v5
+ continue-on-error: true
+
+ # concurrency 는 워크플로 실행을 직렬화하지만 Pages 배포는 자기를 띄운
+ # 실행보다 오래 산다. 커밋 두 개를 붙여서 밀면 두 번째가 그 틈에 들어가
+ # "due to in progress deployment" 로 400 을 받는다. 한 번 더 시도하면
+ # 되고, 안전하다 — 배포는 아무것도 바꾸기 전에 실패한다.
+ - name: Wait for the previous deployment to settle
+ if: steps.deployment.outcome == 'failure'
+ run: sleep 90
+
+ - id: retry
+ if: steps.deployment.outcome == 'failure'
+ uses: actions/deploy-pages@v5
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,8 @@
+# 빌드 산출물. sqzass 가 언제든 다시 만든다.
+/public
+
+# 로컬 미리보기용 임시 사본
+/.preview
+
+*.swp
+.DS_Store
diff --git a/README.md b/README.md
@@ -0,0 +1,65 @@
+# sqzer.com
+
+Personal site — projects, technical posts, and an about page. English and Korean.
+
+Built with [sqzass](https://github.com/sqzer-x/sqzass), deployed to GitHub Pages.
+No bundler, no package manager, no `node_modules`.
+
+## Working on it
+
+```bash
+sqzass serve -i . # http://127.0.0.1:3000, live reload
+sqzass build -i . # writes ./public
+python3 tools/check.py
+```
+
+`sqzass` currently needs the `feat/pageref-date` branch — the Posts ledger reads
+`child.date`, which `PageRefCtx` did not carry before
+([sqzer-x/sqzass#1](https://github.com/sqzer-x/sqzass/pull/1)). Once that merges,
+drop `--branch` from `.github/workflows/pages.yml`.
+
+```bash
+cargo install --git https://github.com/sqzer-x/sqzass --branch feat/pageref-date --locked
+```
+
+## Layout
+
+| | |
+|---|---|
+| `content/` | pages. `page.md` is English, `page.ko.md` is Korean |
+| `templates/` | minijinja. `base.html` owns the chrome; each index names its own template |
+| `static/` | one stylesheet, two scripts, one font, the images |
+| `i18n/` | UI strings the templates supply. A key missing from one language is a build error |
+| `tools/check.py` | the two things the build does not check for you |
+
+## Adding things
+
+**A post.** Drop a file in `content/posts/`. `title` and `date` in the front
+matter is enough; the ledger picks up the rest, groups by year, and orders newest
+first. Write `content/posts/<name>.ko.md` beside it for the Korean version — an
+untranslated page is absent from the Korean navigation rather than duplicated.
+
+**A project.** Two edits, and `tools/check.py` fails if you only make one:
+
+1. `content/projects/<name>.md` — the page. Copy the schema from an existing one
+ and keep **every** key: an omitted key is a build error, not a default. `tier`
+ is `"landing"` (hero, facts, features, a terminal session, a call to action) or
+ `"brief"` (hero and prose). There is no middle rung on purpose.
+2. `content/projects/_index.md` — the catalogue entry, under a `[[extra.group]]`.
+ Listings only carry title, description, url, weight and date, so the language
+ badge and the links have to live here.
+
+Inline tables must fit on one line — TOML 1.0 does not let them wrap. Use
+`[[extra.features]]` blocks when the text is too long for that.
+
+**An image.** Put it in `static/images/projects/` and set `logo` on the project.
+Until then the hero and the card render the project's name as the artwork, at the
+same size the image will take, so nothing moves when you add one.
+
+## Deploying
+
+Push to `main`. The workflow checks integrity, builds, builds a second time and
+diffs the two, then deploys. A build that is not reproducible does not ship.
+
+The domain is in `static/CNAME` and is copied to the output with its name intact,
+so it survives every deploy without being set in the repository settings again.
diff --git a/content/_index.ko.md b/content/_index.ko.md
@@ -0,0 +1,14 @@
++++
+title = "홈"
+description = "터미널 도구, 정적 사이트 생성기, 그리고 보안 유틸리티."
+template = "home.html"
+
+[extra]
+hero_kicker = "sqzer-x"
+hero_title = "터미널에서 도는 소프트웨어, 그리고 뒤져 보는 도구들."
+hero_sub = "매일 쓰는 것은 Rust 로, 허락받은 네트워크를 훑는 것은 Python 과 PowerShell 로 만듭니다. 전부 오픈소스입니다."
+hero_actions = [
+ { label = "프로젝트", url = "/ko/projects/", style = "primary" },
+ { label = "글", url = "/ko/posts/", style = "ghost" },
+]
++++
diff --git a/content/_index.md b/content/_index.md
@@ -0,0 +1,16 @@
++++
+title = "Home"
+description = "Terminal tools, static site tooling, and offensive security utilities."
+template = "home.html"
+
+# 히어로 카피. 템플릿은 구조만 알고 문구는 전부 여기에 있다 — 두 언어가 같은
+# 키를 1:1 로 갖는다. 문구를 고치려고 템플릿을 열 일이 없어야 한다.
+[extra]
+hero_kicker = "sqzer-x"
+hero_title = "Software that runs in a terminal, and tools that go looking."
+hero_sub = "Rust for the things I use every day. Python and PowerShell for the ones that map a network I am allowed to be on. All of it open source."
+hero_actions = [
+ { label = "Projects", url = "/projects/", style = "primary" },
+ { label = "Posts", url = "/posts/", style = "ghost" },
+]
++++
diff --git a/content/about/_index.ko.md b/content/about/_index.ko.md
@@ -0,0 +1,45 @@
++++
+title = "소개"
+description = "sqzer.com 뒤에 누가 있고 여기서 무엇을 만드는지."
+weight = 30
+template = "page.html"
+
+# 이름, 소속, 사는 곳, 이력 같은 개인적인 세부는 일부러 비워 두었다.
+# 지어내는 것보다 없는 편이 낫고, 채우는 건 본인만 할 수 있다.
++++
+
+터미널에서 도는 소프트웨어를 만들고, 봐도 되는 네트워크를 들여다보는 도구를
+만듭니다. 이 사이트에 그 둘을 모아 두고, 그걸 만들게 된 문제도 함께 적습니다.
+
+## 여기서 만드는 것
+
+두 갈래인데 사실 그렇게 갈라지지도 않습니다.
+
+하나는 매일 쓰면서 원하는 모양으로는 못 찾은 것들입니다.
+[ommp](@/projects/ommp.md) 는 데몬을 먼저 띄우라고 하지 않고 음악을 재생합니다.
+[sqzass](@/projects/sqzass.md) 는 이 사이트를 만들고, 아무 데도 가지 않는 링크를
+발행하기를 거부합니다. 둘 다 Rust 이고, 둘 다 바이너리 하나이고, 둘 다 기존
+도구가 깔고 있던 전제 하나에 동의하지 않아서 생겼습니다.
+
+다른 하나는 내부망 정찰 도구입니다. 인터넷으로 나가는 길이 없는 곳에서 동작해야
+하는 종류입니다. [uaf](@/projects/uaf.md) 는 무선 AP 를 찾아서 어느 것이 열려
+있는지 말합니다. [udfx](@/projects/udfx.md) 는 평범한 도메인 계정으로 Active
+Directory 에서 DNS 영역을 그대로 읽어 옵니다. [usfx](@/projects/usfx.md) 는 열두
+가지 기법으로 서브도메인을 열거하는데, 어느 것도 외부 서비스를 부르지 않습니다.
+이름이 닮은 것은 우연이 아닙니다. 같은 일을 층만 달리해서 합니다.
+
+## 일하는 방식
+
+작은 도구, 하나에 하나씩, 그리고 움직이는 부품이 적은 쪽을 강하게 선호합니다.
+클라이언트와 서버를 나누느니 바이너리 하나를 내놓겠습니다. 사이트가 깨진 링크를
+안은 채 조용히 배포되느니 빌드가 요란하게 실패하는 편이 낫습니다. 그게
+[sqzass](@/projects/sqzass.md) 의 설계 원칙이고, 이 페이지가 없는 파일로 링크를
+걸 수 없는 이유이기도 합니다.
+
+오래 설치돼 빠르게 돌아야 하는 것은 Rust 로, 남의 환경에서 한 번 실행되고 마는
+것은 이미 그 기계에 있는 것으로 — Python 과 PowerShell 로 만듭니다.
+
+## 다른 곳
+
+더 길고 덜 기술적인 글은 개인 블로그 [blog.sqzer.com](https://blog.sqzer.com) 에
+있습니다. 공개한 것은 전부 [GitHub](https://github.com/sqzer-x) 에 있습니다.
diff --git a/content/about/_index.md b/content/about/_index.md
@@ -0,0 +1,48 @@
++++
+title = "About"
+description = "Who is behind sqzer.com, and what gets built here."
+weight = 30
+template = "page.html"
+
+# 개인적인 세부(이름, 소속, 사는 곳, 이력)는 일부러 비워 두었다. 지어내는 것보다
+# 없는 편이 낫고, 채우는 건 본인만 할 수 있다.
++++
+
+I write software that runs in a terminal, and tools that look at networks I have
+permission to look at. This site collects both, plus notes about the problems
+that made me build them.
+
+## What gets built here
+
+Two kinds of things, and they are not really separate.
+
+The first is software I use every day and could not find in the shape I wanted.
+[ommp](@/projects/ommp.md) plays music without asking me to start a daemon
+first. [sqzass](@/projects/sqzass.md) builds this site, and refuses to publish a
+link that goes nowhere. Both are Rust, both are one binary, and both exist
+because the alternative had one assumption I did not share.
+
+The second is reconnaissance tooling for internal networks — the kind that has
+to work with no route to the internet. [uaf](@/projects/uaf.md) finds wireless
+access points and says which ones are open. [udfx](@/projects/udfx.md) reads the
+DNS zone straight out of Active Directory with a plain domain account.
+[usfx](@/projects/usfx.md) enumerates subdomains with twelve techniques, none of
+which call a service. They are named alike on purpose: same job, different layer.
+
+## How I work
+
+Small tools, one job each, and a strong preference for the version with fewer
+moving parts. I would rather ship one binary than a client and a server. I would
+rather a build fail loudly than a site publish quietly with a broken link — which
+is a whole design philosophy in [sqzass](@/projects/sqzass.md), and the reason
+this page cannot link to a file that is not there.
+
+Rust for the things that have to be fast and stay installed. Python and
+PowerShell for the things that run once, in someone else's environment, with
+whatever is already on the box.
+
+## Elsewhere
+
+Longer and less technical writing lives on my personal blog at
+[blog.sqzer.com](https://blog.sqzer.com). Everything I have published is on
+[GitHub](https://github.com/sqzer-x).
diff --git a/content/posts/_index.ko.md b/content/posts/_index.ko.md
@@ -0,0 +1,8 @@
++++
+title = "글"
+description = "만든 도구와, 그걸 만들게 된 문제에 대한 기록."
+weight = 10
+template = "posts.html"
+page_template = "post.html"
+sort_by = "date"
++++
diff --git a/content/posts/_index.md b/content/posts/_index.md
@@ -0,0 +1,8 @@
++++
+title = "Posts"
+description = "Notes on the tools I build and the problems that made me build them."
+weight = 10
+template = "posts.html"
+page_template = "post.html"
+sort_by = "date"
++++
diff --git a/content/posts/listings-that-cannot-show-their-own-dates.ko.md b/content/posts/listings-that-cannot-show-their-own-dates.ko.md
@@ -0,0 +1,73 @@
++++
+title = "자기 날짜를 못 보여 주는 목록"
+description = "날짜순으로 세워 놓고 그 날짜를 숨기는 생성기. 고치는 데 든 값과, 일부러 빼놓은 것."
+date = 2026-08-03
+toc = false
++++
+
+[sqzass](@/projects/sqzass.md) 는 처음부터 섹션을 날짜순으로 정렬할 수 있었다.
+섹션 인덱스에 `sort_by = "date"` 를 적으면 최신 글이 먼저 나오고 날짜 없는
+페이지가 뒤로 간다. 거기까지는 됐다.
+
+못 하던 것은 그 날짜를 보여 주는 일이었다.
+
+템플릿은 목록을 `page.children` 으로 보는데, 그 안의 항목은 정확히 네 개였다.
+`title`, `description`, `url`, `weight`. 그래서 블로그 인덱스가 완벽한 시간순으로
+정렬돼 있으면서도 그렇다고 말할 방법이 없었다. 순서는 진짜인데 보이지 않았다.
+둘 중 나쁜 쪽만 남은 셈이다. 읽는 사람은 이 목록이 정렬된 것인지 그냥 늘어놓은
+것인지 알 수가 없다.
+
+## 택하지 않은 우회로
+
+뻔한 탈출구는 `[extra]` 다. 템플릿까지 손대지 않고 전달되는 자유 형식 테이블이니,
+거기에 날짜를 적고 URL 로 짝을 지어 템플릿에서 맞추면 된다.
+
+```toml
+[[extra.year]]
+label = "2026"
+posts = [{ url = "/posts/some-post/", date = "08.03" }]
+```
+
+동작한다. 그리고 글 하나를 새로 쓸 때마다 파일 두 개를 고쳐야 하는데, 잊는 쪽은
+언제나 두 번째다. 설명하려는 대상 옆에 손으로 유지하는 대응표는 결국 어긋나고,
+파일 이름을 처음 바꾸는 순간 조용히 깨진다. 항목이 그냥 안 맞게 되고 날짜가
+사라진다.
+
+## 실제로 든 값
+
+구조체 하나에 필드 하나, 그리고 그걸 만드는 자리 여섯 곳.
+
+```rust
+pub struct PageRefCtx {
+ pub title: String,
+ pub description: String,
+ pub url: String,
+ pub weight: i64,
+ pub date: Option<PageDate>,
+}
+```
+
+값은 헬퍼 하나를 거치므로, 목록과 그 목록이 가리키는 페이지가 발행일에 대해
+서로 다른 말을 할 수 없다. 섹션의 날짜는 그 섹션 `_index.md` 에서 오고, 인덱스가
+없는 섹션에는 날짜도 없다.
+
+이게 전부다. 대신하려던 우회로보다 작다.
+
+## 일부러 빼놓은 것
+
+같은 구조체는 필드 하나만 더하면 프로젝트 목록에도 제대로 쓸모가 생긴다.
+`extra` 를 얹으면 모든 목록이 임의의 메타데이터를 실어 올 수 있다. 언어 배지,
+링크, 상태 같은 것들.
+
+넣지 않았고, 이유는 `PageRefCtx` 가 쓰이는 자리에 있다. `page.children` 만이
+아니다. 사이트 전체의 `site.sections` 내비게이션 트리도 이 구조체로 만들어지고,
+그 트리는 언어당 한 번 직렬화해서 모든 페이지가 나눠 쓴다. 여기에 페이지마다의
+`[extra]` 를 넣으면 모든 기능 목록과 모든 코드 예제가 내비게이션 스냅샷에
+실린다. 그걸 들여다보지도 않는 페이지들이 페이지마다 그 값을 치르게 된다.
+
+날짜는 작은 필드 다섯 개다. 열린 테이블은 오늘 얼마나 썼느냐만큼이다.
+
+그래서 이 사이트의 프로젝트 인덱스는 여전히 카탈로그를 한곳에 손으로 두고, 모든
+항목이 실재하는 페이지를 가리키는지 CI 가 검사한다. 방금 날짜에 대해 거부한 것보다
+나쁜 답인데, 여기서는 그게 맞다. 날짜 쪽 수정을 싸게 만든 바로 그 성질이 `extra`
+쪽을 비싸게 만들기 때문이다.
diff --git a/content/posts/listings-that-cannot-show-their-own-dates.md b/content/posts/listings-that-cannot-show-their-own-dates.md
@@ -0,0 +1,76 @@
++++
+title = "Listings that cannot show their own dates"
+description = "A generator that sorts by date and then hides it. What the fix cost, and what I left out."
+date = 2026-08-03
+toc = false
++++
+
+[sqzass](@/projects/sqzass.md) has always been able to sort a section by date.
+Put `sort_by = "date"` on a section index and its pages come out newest first,
+undated ones last. That part worked.
+
+What it could not do was show the date.
+
+Templates see a listing as `page.children`, and every entry in it was exactly
+four fields — `title`, `description`, `url`, `weight`. So a blog index could be
+in perfect chronological order and have no way to say so. The order was real and
+invisible, which is the worst of both: the reader cannot tell whether the list is
+sorted or just arranged.
+
+## The workaround I did not take
+
+The obvious escape hatch is `[extra]`, a free-form table that reaches templates
+untouched. Write the dates there, key them by URL, match them back up in the
+template:
+
+```toml
+[[extra.year]]
+label = "2026"
+posts = [{ url = "/posts/some-post/", date = "08.03" }]
+```
+
+It works. It also means every new post is two edits in two files, and the second
+one is the one you forget. A mapping maintained by hand next to the thing it
+describes is a mapping that drifts, and the first rename breaks it silently —
+the entry just stops matching and the date quietly disappears.
+
+## What it actually cost
+
+One field on one struct, and six places that build it:
+
+```rust
+pub struct PageRefCtx {
+ pub title: String,
+ pub description: String,
+ pub url: String,
+ pub weight: i64,
+ pub date: Option<PageDate>,
+}
+```
+
+The value goes through a single helper, so a listing and the page it links to
+cannot disagree about what day something was published. A section's date comes
+from its own `_index.md`; a section with no index has none.
+
+That is the whole change. It is smaller than the workaround it replaces.
+
+## What I deliberately left out
+
+The same struct is one field away from being genuinely useful for a projects
+index — put `extra` on it and every listing could carry arbitrary metadata:
+language badges, links, status.
+
+I did not add it, and the reason is where `PageRefCtx` is used. It is not only
+`page.children`. It is also what the whole `site.sections` navigation tree is
+made of, and that tree is serialised once per language and shared by every page
+on the site. Putting each page's `[extra]` in it would drag every feature list
+and every code sample into the navigation snapshot — a per-page cost paid by
+pages that never look at it.
+
+A date is five small fields. An open table is however much you wrote today.
+
+So the projects index on this site still keeps its catalogue in one place by
+hand, and a CI step checks that every entry points at a page that exists. That
+is a worse answer than the one I just rejected for dates, and it is the right
+one here, because the thing that made the date fix cheap is exactly what makes
+the `extra` version expensive.
diff --git a/content/posts/what-this-site-is-built-on.ko.md b/content/posts/what-this-site-is-built-on.ko.md
@@ -0,0 +1,41 @@
++++
+title = "이 사이트는 무엇으로 만들어졌나"
+description = "바이너리 하나, 스타일시트 하나, 스크립트 둘, 그리고 생성기 말고는 빌드 단계가 없다."
+date = 2026-08-02
+toc = false
++++
+
+이 사이트는 직접 만든 [sqzass](@/projects/sqzass.md) 가 빌드하고, 바이너리를
+설치해서 실행하는 워크플로가 GitHub Pages 로 배포한다. 번들러도, 패키지
+매니저도, `node_modules` 도 없다.
+
+금욕이 아니다. 저 하나하나가 문장을 쓰는 시점과 그 문장이 화면에 뜨는 시점 사이에서
+깨질 수 있는 물건이라서다.
+
+## 전부
+
+**스타일시트 하나.** 스무 개가 아니라. 이 사이트를 설계하면서 참고한 레퍼런스는
+렌더를 막는 스타일시트를 스무 개 내보내는데, 캐시가 비어 있으면 첫 페인트 전에
+스무 번 왕복한다. 파일 하나면 생성기가 내용 해시를 붙여 주고, 바뀐 그때만 캐시가
+무효가 된다.
+
+**스크립트 둘, 둘 다 있어도 되고 없어도 된다.** 하나는 검색 팔레트를, 하나는 테마
+토글과 스크롤 리빌을 맡는다. JavaScript 를 끄면 팔레트가 없어지고 모든 섹션이
+처음부터 드러나 있다. 읽으러 온 것 중에 이 둘에 기대는 건 없다.
+
+**한글 웹폰트는 안 받는다.** 커버리지가 제대로 있는 한글 웹폰트는 2 MB 쯤 된다.
+시스템 스택 — Apple SD Gothic Neo, Noto Sans KR, 맑은 고딕 — 은 이미 그 기계에
+있고 이미 맞다. 이 사이트가 내려받는 폰트는 JetBrains Mono 뿐인데 라틴 전용
+21 KB 이고, 두 번째 목소리 노릇을 해서 그 값을 한다. 날짜, 배지, 명령, 워드마크가
+그 목소리다.
+
+## 제일 오래 걸린 부분
+
+레이아웃이 아니었다. 날짜였다.
+[다른 글](@/posts/listings-that-cannot-show-their-own-dates.md)에 적어 뒀다.
+
+## 재현되고, 확인한다
+
+같은 입력을 두 번 빌드하면 같은 바이트가 나오고, CI 가 두 번 빌드해서 비교한다.
+결벽처럼 들리는데, 설명이 안 되는 차이를 배포에서 처음 마주치는 날 생각이 달라진다.
+생성기가 결정적인지를 가장 알아야 할 순간에 알게 된다.
diff --git a/content/posts/what-this-site-is-built-on.md b/content/posts/what-this-site-is-built-on.md
@@ -0,0 +1,43 @@
++++
+title = "What this site is built on"
+description = "One binary, one stylesheet, two scripts, and no build step that is not the generator."
+date = 2026-08-02
+toc = false
++++
+
+This site is built by [sqzass](@/projects/sqzass.md), which I wrote, and
+deployed to GitHub Pages from a workflow that installs the binary and runs it.
+There is no bundler, no package manager, and no `node_modules`.
+
+That is not asceticism. It is that every one of those would be a thing that can
+break between me writing a sentence and the sentence appearing.
+
+## The whole stack
+
+**One stylesheet.** Not twenty. The reference I looked at while designing this
+ships twenty separate render-blocking stylesheets, which is twenty requests
+before the first paint on a cold cache. One file, content-hashed by the
+generator, invalidates only when it changes.
+
+**Two scripts, both optional.** One runs the search palette, one handles the
+theme toggle and the scroll reveals. Turn JavaScript off and you lose the
+palette and get every section already revealed. Nothing you came here to read
+depends on either.
+
+**No web font for Korean.** A Hangul webfont with real coverage is around two
+megabytes. The system stack — Apple SD Gothic Neo, Noto Sans KR, Malgun Gothic —
+is already on the machine and already correct. The only font this site downloads
+is JetBrains Mono, which is Latin-only and 21 KB, and it earns that by being the
+second voice: dates, badges, commands, the wordmark.
+
+## The part that took the longest
+
+Not the layout. The dates — see
+[the other post](@/posts/listings-that-cannot-show-their-own-dates.md).
+
+## Reproducible, and checked
+
+Two builds of the same input produce identical bytes, and CI verifies it by
+building twice and diffing. It sounds like a purity exercise until the first
+time a deploy produces a diff you cannot explain, and you find out whether your
+generator is deterministic at the moment you most need it to be.
diff --git a/content/projects/_index.ko.md b/content/projects/_index.ko.md
@@ -0,0 +1,100 @@
++++
+title = "프로젝트"
+description = "터미널 소프트웨어, 보안 도구, 그리고 데스크톱 확장."
+weight = 20
+template = "projects.html"
+page_template = "project.html"
+
+# 카탈로그. 목록은 title·description·url·weight·date 만 실어 오므로 언어 배지와
+# 링크는 여기 있어야 한다. url 은 한국어 트리를 가리켜야 한다.
+[extra]
+
+# 인라인 테이블은 TOML 1.0 에서 한 줄을 넘지 못한다. Rust 쪽 파서는 넘겨 주지만
+# 그건 확장이고, 파서를 바꾸는 날 깨진다.
+[[extra.featured]]
+url = "/ko/projects/sqzass/"
+name = "sqzass"
+lang = "Rust"
+cover = ""
+blurb = "깨진 참조를 발행하지 않는 정적 사이트 생성기."
+site = "https://sqzass.sqzer.com"
+repo = "https://github.com/sqzer-x/sqzass"
+
+[[extra.featured]]
+url = "/ko/projects/ommp/"
+name = "ommp"
+lang = "Rust"
+cover = ""
+blurb = "띄워 둘 데몬도, 끄고 남는 것도 없는 터미널 음악 재생기."
+site = "https://ommp.sqzer.com"
+repo = "https://github.com/sqzer-x/ommp"
+
+[[extra.group]]
+id = "terminal"
+label = "터미널 도구"
+note = ""
+
+ [[extra.group.item]]
+ url = "/ko/projects/sqzass/"
+ name = "sqzass"
+ blurb = "정적 사이트 생성기. 같은 입력을 두 번 빌드하면 바이트까지 같다."
+ lang = "Rust"
+ status = "active"
+ site = "https://sqzass.sqzer.com"
+ repo = "https://github.com/sqzer-x/sqzass"
+
+ [[extra.group.item]]
+ url = "/ko/projects/ommp/"
+ name = "ommp"
+ blurb = "터미널 음악 재생기. MPD 도 데몬도 없이, 실행하면 재생된다."
+ lang = "Rust"
+ status = "active"
+ site = "https://ommp.sqzer.com"
+ repo = "https://github.com/sqzer-x/ommp"
+
+[[extra.group]]
+id = "security"
+label = "보안 도구"
+note = "내부망이 무언가를 숨겨 두는 층마다 하나씩. 셋 다 인터넷 없이 동작한다."
+
+ [[extra.group.item]]
+ url = "/ko/projects/uaf/"
+ name = "uaf"
+ blurb = "Ultimate AP Finder. PCI-DSS 감사를 위한 무선 AP 탐지와 분류."
+ lang = "Python"
+ status = "active"
+ site = ""
+ repo = "https://github.com/sqzer-x/uaf"
+
+ [[extra.group.item]]
+ url = "/ko/projects/udfx/"
+ name = "udfx"
+ blurb = "Ultimate Domain Finder X. Active Directory 의 DNS 레코드를 LDAP 으로 전부 끌어온다."
+ lang = "PowerShell"
+ status = "active"
+ site = ""
+ repo = "https://github.com/sqzer-x/udfx"
+
+ [[extra.group.item]]
+ url = "/ko/projects/usfx/"
+ name = "usfx"
+ blurb = "Ultimate Subdomain Finder X. 열두 가지 열거 기법, 전부 외부를 부르지 않는다."
+ lang = "Python"
+ status = "active"
+ site = ""
+ repo = "https://github.com/sqzer-x/usfx"
+
+[[extra.group]]
+id = "apps"
+label = "앱"
+note = ""
+
+ [[extra.group.item]]
+ url = "/ko/projects/doomsday/"
+ name = "doomsday"
+ blurb = "챙겨야 할 날짜를 세어 주는 GNOME 셸 확장."
+ lang = "JavaScript"
+ status = "active"
+ site = ""
+ repo = "https://github.com/sqzer-x/doomsday"
++++
diff --git a/content/projects/_index.md b/content/projects/_index.md
@@ -0,0 +1,111 @@
++++
+title = "Projects"
+description = "Open-source terminal software, security tooling, and a desktop extension."
+weight = 20
+template = "projects.html"
+page_template = "project.html"
+
+# 카탈로그. 목록(PageRefCtx)은 title·description·url·weight·date 만 실어 오므로
+# 언어 배지와 링크는 여기 있어야 한다.
+#
+# 모든 url 은 content/projects/ 아래 실제 페이지를 가리켜야 한다. [extra] 안의
+# URL 은 빌드가 확인해 주지 않는 유일한 참조라, 그 대응은 CI 가 검사한다.
+#
+# 스키마는 총체적이다. 없는 값은 "" 이고 키를 빼지 않는다 — 정의되지 않은 접근은
+# 빌드 에러라 키 하나가 빠지면 사이트 전체가 안 나온다.
+[extra]
+
+# 큰 카드. 자기 랜딩 페이지를 가진 둘.
+#
+# 인라인 테이블을 쓰지 않는 이유: TOML 1.0 에서 인라인 테이블은 한 줄을 넘지
+# 못한다. Rust 쪽 파서는 넘겨 주지만 그건 확장이고, 파서를 바꾸는 날 깨진다.
+[[extra.featured]]
+url = "/projects/sqzass/"
+name = "sqzass"
+lang = "Rust"
+cover = ""
+blurb = "A static site generator that refuses to publish a broken reference."
+site = "https://sqzass.sqzer.com"
+repo = "https://github.com/sqzer-x/sqzass"
+
+[[extra.featured]]
+url = "/projects/ommp/"
+name = "ommp"
+lang = "Rust"
+cover = ""
+blurb = "A terminal music player with no daemon to start and nothing left running."
+site = "https://ommp.sqzer.com"
+repo = "https://github.com/sqzer-x/ommp"
+
+[[extra.group]]
+id = "terminal"
+label = "Terminal tools"
+note = ""
+
+ [[extra.group.item]]
+ url = "/projects/sqzass/"
+ name = "sqzass"
+ blurb = "Static site generator. Two builds of the same input are byte-identical."
+ lang = "Rust"
+ status = "active"
+ site = "https://sqzass.sqzer.com"
+ repo = "https://github.com/sqzer-x/sqzass"
+
+ [[extra.group.item]]
+ url = "/projects/ommp/"
+ name = "ommp"
+ blurb = "Terminal music player. No MPD, no daemon — run it and play."
+ lang = "Rust"
+ status = "active"
+ site = "https://ommp.sqzer.com"
+ repo = "https://github.com/sqzer-x/ommp"
+
+# uaf / udfx / usfx 는 이름이 우연히 닮은 것이 아니다. 정찰 대상이 AP, 도메인,
+# 서브도메인으로 갈라지는 한 계열이고, 셋 다 인터넷이 없는 망에서 도는 것을
+# 전제로 만들어졌다. 그래서 한 갈래로 묶는다.
+[[extra.group]]
+id = "security"
+label = "Security tools"
+note = "Three finders, one for each layer an internal network hides things at. All of them work with no internet connection."
+
+ [[extra.group.item]]
+ url = "/projects/uaf/"
+ name = "uaf"
+ blurb = "Ultimate AP Finder. Wireless AP discovery and classification for PCI-DSS audits."
+ lang = "Python"
+ status = "active"
+ site = ""
+ repo = "https://github.com/sqzer-x/uaf"
+
+ [[extra.group.item]]
+ url = "/projects/udfx/"
+ name = "udfx"
+ blurb = "Ultimate Domain Finder X. Pulls every DNS record out of Active Directory over LDAP."
+ lang = "PowerShell"
+ status = "active"
+ site = ""
+ repo = "https://github.com/sqzer-x/udfx"
+
+ [[extra.group.item]]
+ url = "/projects/usfx/"
+ name = "usfx"
+ blurb = "Ultimate Subdomain Finder X. Twelve enumeration modules, none of which phone home."
+ lang = "Python"
+ status = "active"
+ site = ""
+ repo = "https://github.com/sqzer-x/usfx"
+
+[[extra.group]]
+id = "apps"
+label = "Apps"
+note = ""
+
+ [[extra.group.item]]
+ url = "/projects/doomsday/"
+ name = "doomsday"
+ blurb = "A GNOME Shell extension that counts down to the dates you care about."
+ lang = "JavaScript"
+ status = "active"
+ site = ""
+ repo = "https://github.com/sqzer-x/doomsday"
++++
diff --git a/content/projects/doomsday.ko.md b/content/projects/doomsday.ko.md
@@ -0,0 +1,52 @@
++++
+title = "doomsday"
+description = "챙겨야 할 날짜를 세어 주는 GNOME 셸 확장."
+weight = 60
+date = 2026-02-09
+
+[extra]
+tier = "brief"
+
+eyebrow = "GNOME 셸 확장 · JavaScript"
+tagline = "카운트다운은 이미 눈이 가는 자리에 있어야 쓸모가 있다. 이건 상단 패널, 시계 옆에 산다."
+lang = "JavaScript"
+license = "GPL-3.0"
+version = ""
+platform = "GNOME 45 · 46 · 47"
+logo = ""
+
+actions = [
+ { label = "소스", url = "https://github.com/sqzer-x/doomsday", style = "ghost" },
+]
+
+facts = [
+ { value = "45–47", label = "GNOME" },
+ { value = "∞", label = "이벤트" },
+ { value = "자정", label = "갱신" },
+ { value = "GPL-3.0", label = "라이선스" },
+]
+
+features_eyebrow = ""
+features_title = ""
+features_intro = ""
+features = []
+
+session_eyebrow = ""
+session_title = ""
+session_shell = ""
+session_caption = ""
+session_lines = []
+
+cta_title = ""
+cta_text = ""
+cta_actions = []
++++
+
+GNOME 상단 패널에 D-Day 를 띄운다. 이벤트는 원하는 만큼 넣고, 한 번 눌러서
+바꾸고, 설정 창을 열지 않고 확장 메뉴에서 바로 고치거나 지운다. 쓰는 물건과 한 번
+설정하고 잊는 물건의 차이가 거기서 갈린다.
+
+날짜는 자정에 넘어가고, 패널에서의 자리는 왼쪽·가운데·오른쪽 중에 고른다.
+
+Extension Manager 로 배포된 zip 을 설치하거나, 레포를 받아서 `./install.sh` 를
+실행하면 된다.
diff --git a/content/projects/doomsday.md b/content/projects/doomsday.md
@@ -0,0 +1,54 @@
++++
+title = "doomsday"
+description = "A GNOME Shell extension that counts down to the dates you care about."
+weight = 60
+date = 2026-02-09
+
+[extra]
+tier = "brief"
+
+eyebrow = "GNOME Shell extension · JavaScript"
+tagline = "A countdown is only useful where you already look. This one lives in the top panel, next to the clock."
+lang = "JavaScript"
+license = "GPL-3.0"
+version = ""
+platform = "GNOME 45 · 46 · 47"
+logo = ""
+
+actions = [
+ { label = "Source", url = "https://github.com/sqzer-x/doomsday", style = "ghost" },
+]
+
+facts = [
+ { value = "45–47", label = "GNOME" },
+ { value = "∞", label = "Events" },
+ { value = "midnight", label = "Refreshes" },
+ { value = "GPL-3.0", label = "License" },
+]
+
+features_eyebrow = ""
+features_title = ""
+features_intro = ""
+features = []
+
+session_eyebrow = ""
+session_title = ""
+session_shell = ""
+session_caption = ""
+session_lines = []
+
+cta_title = ""
+cta_text = ""
+cta_actions = []
++++
+
+D-Day counters in the GNOME top panel. Add as many events as you like, switch
+between them with one click, and edit or delete them from the extension menu
+without opening preferences — which is the difference between a thing you use
+and a thing you configure once and forget.
+
+The count rolls over at midnight, and the panel position is yours to pick: left,
+centre or right.
+
+Install through Extension Manager with the packaged zip, or clone the repository
+and run `./install.sh`.
diff --git a/content/projects/ommp.ko.md b/content/projects/ommp.ko.md
@@ -0,0 +1,84 @@
++++
+title = "ommp"
+description = "Rust 로 만든 터미널 음악 재생기. MPD 도 데몬도 없다."
+weight = 20
+date = 2026-08-01
+
+[extra]
+tier = "landing"
+
+eyebrow = "터미널 음악 재생기 · Rust"
+tagline = "터미널 음악 재생기는 대개 먼저 띄워 둬야 하는 무언가의 클라이언트다. ommp 는 그 전부다. 실행하면 재생되고, 끄면 남는 것이 없다."
+lang = "Rust"
+license = "Apache-2.0"
+version = ""
+platform = "Linux"
+logo = "images/projects/ommp.png"
+
+actions = [
+ { label = "웹사이트", url = "https://ommp.sqzer.com", style = "primary" },
+ { label = "소스", url = "https://github.com/sqzer-x/ommp", style = "ghost" },
+]
+
+facts = [
+ { value = "8", label = "오디오 형식" },
+ { value = "3", label = "이미지 프로토콜" },
+ { value = "0", label = "띄울 데몬" },
+ { value = "Apache-2.0", label = "라이선스" },
+]
+
+features_eyebrow = "기능"
+features_title = "라이브러리, 재생 목록, 그리고 앨범 아트"
+features_intro = "화면은 세 칸이다. 왼쪽이 훑고, 가운데가 줄을 세우고, 오른쪽이 표지와 지금 곡을 보여 준다."
+# 항목은 아래 [[extra.features]] 에 있다. 인라인 테이블은 TOML 1.0 에서
+# 한 줄을 넘지 못하고, 이 설명들은 한 줄에 들어가지 않는다.
+
+session_eyebrow = "설치"
+session_title = "명령 하나, 그다음이 음악"
+session_shell = "zsh"
+session_caption = "인터페이스 아이콘에 Nerd Font 가, 색에 24비트 지원 터미널이 필요하다."
+session_lines = [
+ { kind = "cmd", text = "$ cargo install ommp" },
+ { kind = "comment", text = "# Arch 라면: yay -S ommp" },
+ { kind = "cmd", text = "$ ommp" },
+ { kind = "comment", text = "# ~/Music 을 읽고 하위 디렉터리까지 훑는다. 바뀌면 바로 따라간다" },
+ { kind = "out", text = " Ctrl+S 검색 artist: album: genre: *.flac" },
+ { kind = "out", text = " Ctrl+H 키 목록" },
+]
+
+cta_title = "~/Music 에 음악을 두고 실행하면 됩니다."
+cta_text = "설정할 것이 없다. 앨범 아트는 파일 안의 태그에서 나온다."
+cta_actions = [
+ { label = "ommp.sqzer.com", url = "https://ommp.sqzer.com", style = "primary" },
+ { label = "소스", url = "https://github.com/sqzer-x/ommp", style = "ghost" },
+]
+
+[[extra.features]]
+title = "여덟 가지 형식"
+desc = "FLAC, MP3, M4A, OGG, WAV, Opus, AAC, WMA 를 PulseAudio 나 ALSA 로 낸다."
+
+[[extra.features]]
+title = "앨범 아트를 실제로 그린다"
+desc = "Kitty 그래픽, Sixel, iTerm2 프로토콜 중 되는 것을 시작할 때 알아서 고른다. 아무것도 없으면 블록 문자로 떨어지는데, 그래도 표지는 보인다."
+
+[[extra.features]]
+title = "생각하는 대로 훑는다"
+desc = "아티스트, 앨범, 장르, 형식, 디렉터리, 재생 목록. 위쪽 탭이 왼쪽 칸을 바꾼다."
+
+[[extra.features]]
+title = "필드 검색"
+desc = "Ctrl+S 를 누르면 치는 대로 걸러진다. artist:radiohead, album:ok computer, genre:rock, *.flac."
+
+[[extra.features]]
+title = "기억한다"
+desc = "음량과 재생 목록과 칸 배치가 다시 켜도 그대로고, 파일이 생기거나 사라지면 라이브러리가 따라간다."
+
+[[extra.features]]
+title = "마우스도 쓴다"
+desc = "누르고 굴리고 끌어서 칸 크기를 바꾼다. 키보드로도 전부 되고, Ctrl+H 가 목록을 보여 준다."
+
++++
+
+앨범 아트는 음악 파일의 태그에서 읽으므로 옆에 따로 둘 것이 없다. 터미널이 어떤
+그리기 프로토콜을 지원하는지는 시작할 때 알아낸다. Ghostty, Kitty, WezTerm,
+Konsole 은 실제 이미지가 나오고, foot 과 Sixel 을 켜서 빌드한 xterm 도 그렇다.
diff --git a/content/projects/ommp.md b/content/projects/ommp.md
@@ -0,0 +1,85 @@
++++
+title = "ommp"
+description = "A standalone terminal music player built with Rust. No MPD, no daemon."
+weight = 20
+date = 2026-08-01
+
+[extra]
+tier = "landing"
+
+eyebrow = "Terminal music player · Rust"
+tagline = "Most terminal music players are a client for something else you have to start first. ommp is the whole thing: run it, and it plays. Quit it, and nothing is left running."
+lang = "Rust"
+license = "Apache-2.0"
+version = ""
+platform = "Linux"
+logo = "images/projects/ommp.png"
+
+actions = [
+ { label = "Website", url = "https://ommp.sqzer.com", style = "primary" },
+ { label = "Source", url = "https://github.com/sqzer-x/ommp", style = "ghost" },
+]
+
+facts = [
+ { value = "8", label = "Audio formats" },
+ { value = "3", label = "Image protocols" },
+ { value = "0", label = "Daemons to start" },
+ { value = "Apache-2.0", label = "License" },
+]
+
+features_eyebrow = "Capabilities"
+features_title = "A library browser, a queue, and the artwork"
+features_intro = "Three panels. The left browses, the centre queues, the right shows the cover and the track."
+# 항목은 아래 [[extra.features]] 에 있다. 인라인 테이블은 TOML 1.0 에서
+# 한 줄을 넘지 못하고, 이 설명들은 한 줄에 들어가지 않는다.
+
+session_eyebrow = "Install"
+session_title = "One command, then your music"
+session_shell = "zsh"
+session_caption = "Needs a Nerd Font for the interface icons and a true-colour terminal."
+session_lines = [
+ { kind = "cmd", text = "$ cargo install ommp" },
+ { kind = "comment", text = "# on Arch: yay -S ommp" },
+ { kind = "cmd", text = "$ ommp" },
+ { kind = "comment", text = "# reads ~/Music, scans subdirectories, picks up changes as they happen" },
+ { kind = "out", text = " Ctrl+S search artist: album: genre: *.flac" },
+ { kind = "out", text = " Ctrl+H keybindings" },
+]
+
+cta_title = "Put your music in ~/Music and run it."
+cta_text = "Nothing to configure. Album art comes out of the tags in the files themselves."
+cta_actions = [
+ { label = "ommp.sqzer.com", url = "https://ommp.sqzer.com", style = "primary" },
+ { label = "Source", url = "https://github.com/sqzer-x/ommp", style = "ghost" },
+]
+
+[[extra.features]]
+title = "Eight formats"
+desc = "FLAC, MP3, M4A, OGG, WAV, Opus, AAC and WMA, through PulseAudio or ALSA."
+
+[[extra.features]]
+title = "Album art, actually drawn"
+desc = "Kitty graphics, Sixel or the iTerm2 protocol, detected at startup. Everywhere else it falls back to block characters, which still shows the cover."
+
+[[extra.features]]
+title = "Browse how you think"
+desc = "By artist, album, genre, format, directory or playlist. The tabs across the top switch the left panel."
+
+[[extra.features]]
+title = "Field search"
+desc = "Ctrl+S filters as you type. artist:radiohead, album:ok computer, genre:rock, or *.flac."
+
+[[extra.features]]
+title = "It remembers"
+desc = "Volume, playlists and pane layout survive a restart, and the library re-syncs when files appear or vanish."
+
+[[extra.features]]
+title = "Mouse, if you want it"
+desc = "Click, scroll, and drag the panes to resize. The keyboard does everything too — Ctrl+H lists the bindings."
+
++++
+
+Album art is read from the tags in the audio files, so there is nothing to place
+alongside them. Which drawing protocol your terminal supports is worked out at
+startup — Ghostty, Kitty, WezTerm and Konsole get real images; foot and xterm
+built with Sixel do too.
diff --git a/content/projects/sqzass.ko.md b/content/projects/sqzass.ko.md
@@ -0,0 +1,86 @@
++++
+title = "sqzass"
+description = "Rust 로 만든 정적 사이트 생성기. 이 사이트가 그걸로 만들어졌다."
+weight = 10
+date = 2026-07-31
+
+[extra]
+tier = "landing"
+
+eyebrow = "정적 사이트 생성기 · Rust"
+tagline = "대부분의 생성기는 예쁜 URL 과 리다이렉트, 캐시 헤더를 호스트에 맡긴다. sqzass 는 그 일을 직접 한다. 그래서 같은 출력이 GitHub Pages 에서도, Vercel 에서도, 평범한 HTTP 로 디렉터리를 내주는 서버에서도 똑같이 맞다."
+lang = "Rust"
+license = "MIT"
+version = "0.1.0"
+platform = "Linux · macOS"
+logo = ""
+
+actions = [
+ { label = "문서", url = "https://sqzass.sqzer.com", style = "primary" },
+ { label = "소스", url = "https://github.com/sqzer-x/sqzass", style = "ghost" },
+]
+
+facts = [
+ { value = "25 ms", label = "1,000 페이지" },
+ { value = "0", label = "런타임 의존성" },
+ { value = "0.1.0", label = "버전" },
+ { value = "MIT", label = "라이선스" },
+]
+
+features_eyebrow = "설계"
+features_title = "여섯 개의 결정과 각각의 근거"
+features_intro = "취향이 아니다. 하나하나가 다른 데서 먼저 일어난 사고다."
+# 항목은 아래 [[extra.features]] 에 있다. 인라인 테이블은 TOML 1.0 에서
+# 한 줄을 넘지 못하고, 이 설명들은 한 줄에 들어가지 않는다.
+
+session_eyebrow = "빠른 시작"
+session_title = "파일 세 개, 그다음이 사이트"
+session_shell = "zsh"
+session_caption = "실제 출력이다. 이 레포의 문서 사이트가 60 페이지다."
+session_lines = [
+ { kind = "cmd", text = "$ sqzass init mysite" },
+ { kind = "comment", text = "# sqzass.toml, content/_index.md, templates/page.html 을 쓴다" },
+ { kind = "cmd", text = "$ sqzass serve -i mysite" },
+ { kind = "out", text = " http://127.0.0.1:3000 라이브 리로드" },
+ { kind = "cmd", text = "$ sqzass build -i docs" },
+ { kind = "out", text = " 60 pages → docs/public" },
+]
+
+cta_title = "문서를 읽어 보세요."
+cta_text = "같은 레포의 docs 디렉터리에서 sqzass 로 만들어진다. CI 가 방금 컴파일한 바이너리로 그 문서를 빌드한다."
+cta_actions = [
+ { label = "sqzass.sqzer.com", url = "https://sqzass.sqzer.com", style = "primary" },
+ { label = "소스", url = "https://github.com/sqzer-x/sqzass", style = "ghost" },
+]
+
+[[extra.features]]
+title = "깨진 참조는 빌드를 세운다"
+desc = "풀리지 않는 링크, 없는 템플릿, 같은 URL 을 주장하는 두 페이지, 오타 난 설정 키. 전부 경고가 아니라 에러다."
+
+[[extra.features]]
+title = "마크다운은 AST 에서 다룬다"
+desc = "링크 재작성, 제목 앵커, 목차가 전부 트리 연산이다. 완성된 HTML 에 정규식을 돌리면 작은따옴표로 감싼 속성을 조용히 건너뛴다."
+
+[[extra.features]]
+title = "바이트까지 같은 빌드"
+desc = "같은 입력을 두 번 빌드하면 같은 바이트가 나오고, CI 가 푸시마다 확인한다. 렌더는 병렬이고, 결정성은 일정이 아니라 병합이 만든다."
+
+[[extra.features]]
+title = "한국어는 나중에 얹은 것이 아니다"
+desc = "번역이 원문 옆에 있고, 링크는 읽는 사람의 언어로 풀리며, 검색은 부분 문자열로 맞춘다. 검색엔진최적화 안의 최적화를 찾는 방법은 그것뿐이다."
+
+[[extra.features]]
+title = "인라인 스타일 대신 클래스"
+desc = "구문 강조가 클래스 이름을 낸다. 인라인 색은 테마 하나를 모든 문서에 영원히 박아 넣고, 엄격한 style-src 를 불가능하게 만든다."
+
+[[extra.features]]
+title = "바이너리 하나, 런타임 없음"
+desc = "Node 도 Python 도 시스템 라이브러리도 필요 없다. 내려받은 것이 곧 도는 것이다."
+
++++
+
+지금 보고 있는 사이트가 이걸로 만들어졌다. [문서 사이트][docs] 도 마찬가지인데,
+CI 가 방금 컴파일한 바이너리로 그 문서를 빌드하므로 문서가 생성기의 회귀 테스트
+노릇을 한다.
+
+[docs]: https://sqzass.sqzer.com
diff --git a/content/projects/sqzass.md b/content/projects/sqzass.md
@@ -0,0 +1,87 @@
++++
+title = "sqzass"
+description = "A static site generator written in Rust. This site is built with it."
+weight = 10
+date = 2026-07-31
+
+[extra]
+tier = "landing"
+
+eyebrow = "Static site generator · Rust"
+tagline = "Most generators lean on their host for pretty URLs, redirects and cache headers. sqzass does that work itself, so the same output is correct on GitHub Pages, on Vercel, or in a directory served over plain HTTP."
+lang = "Rust"
+license = "MIT"
+version = "0.1.0"
+platform = "Linux · macOS"
+logo = ""
+
+actions = [
+ { label = "Documentation", url = "https://sqzass.sqzer.com", style = "primary" },
+ { label = "Source", url = "https://github.com/sqzer-x/sqzass", style = "ghost" },
+]
+
+# 별 개수 대신 확인할 수 있는 사실. Rust 도구를 보러 온 사람이 실제로 궁금해하는 것들이다.
+facts = [
+ { value = "25 ms", label = "1,000 pages" },
+ { value = "0", label = "Runtime deps" },
+ { value = "0.1.0", label = "Version" },
+ { value = "MIT", label = "License" },
+]
+
+features_eyebrow = "Design"
+features_title = "Six decisions, and the argument for each"
+features_intro = "None of these are preferences. Each one is a failure that happened somewhere else first."
+# 항목은 아래 [[extra.features]] 에 있다. 인라인 테이블은 TOML 1.0 에서
+# 한 줄을 넘지 못하고, 이 설명들은 한 줄에 들어가지 않는다.
+
+session_eyebrow = "Quickstart"
+session_title = "Three files, then a site"
+session_shell = "zsh"
+session_caption = "Real output. The documentation site in this repository is 60 pages."
+session_lines = [
+ { kind = "cmd", text = "$ sqzass init mysite" },
+ { kind = "comment", text = "# writes sqzass.toml, content/_index.md, templates/page.html" },
+ { kind = "cmd", text = "$ sqzass serve -i mysite" },
+ { kind = "out", text = " http://127.0.0.1:3000 live reload" },
+ { kind = "cmd", text = "$ sqzass build -i docs" },
+ { kind = "out", text = " 60 pages → docs/public" },
+]
+
+cta_title = "Read the documentation."
+cta_text = "Built with sqzass, from the docs directory of the same repository. CI builds it with the binary it just compiled."
+cta_actions = [
+ { label = "sqzass.sqzer.com", url = "https://sqzass.sqzer.com", style = "primary" },
+ { label = "Source", url = "https://github.com/sqzer-x/sqzass", style = "ghost" },
+]
+
+[[extra.features]]
+title = "Broken references stop the build"
+desc = "An unresolved link, a missing template, two pages claiming the same URL, a misspelled config key — every one is an error, not a warning you scroll past."
+
+[[extra.features]]
+title = "Markdown on the AST"
+desc = "Link rewriting, heading anchors and the table of contents are tree operations. A regex over finished HTML silently skips any attribute that is single-quoted."
+
+[[extra.features]]
+title = "Byte-identical builds"
+desc = "Two builds of the same input produce the same bytes, and CI checks it on every push. Rendering is parallel; determinism comes from the merge, not the schedule."
+
+[[extra.features]]
+title = "Korean was not bolted on"
+desc = "Translations sit beside their originals, links resolve to the reader's language, and search matches substrings — the only way 최적화 inside 검색엔진최적화 is ever found."
+
+[[extra.features]]
+title = "Classes, never inline styles"
+desc = "Highlighting emits class names. Inline colours pin one theme into every document forever and put a strict style-src out of reach."
+
+[[extra.features]]
+title = "One binary, no runtime"
+desc = "No Node, no Python, no system libraries. The thing you download is the thing that runs."
+
++++
+
+The site you are reading is built with it. So is [its own documentation][docs] —
+which makes the docs a regression test for the generator, since CI builds them
+with the binary it just compiled.
+
+[docs]: https://sqzass.sqzer.com
diff --git a/content/projects/uaf.ko.md b/content/projects/uaf.ko.md
@@ -0,0 +1,65 @@
++++
+title = "uaf"
+description = "Ultimate AP Finder. PCI-DSS 감사를 위한 무선 AP 탐지와 분류."
+weight = 30
+date = 2026-02-14
+
+[extra]
+tier = "brief"
+
+eyebrow = "무선 AP 감사 · Python"
+tagline = "PCI-DSS 는 무선 점검을 요구한다. 그 요구를 솔직하게 옮기면 이런 질문이 된다. 이 AP 들 중 어느 것이 우리 것이고, 어느 것이 열려 있는가."
+lang = "Python"
+license = ""
+version = ""
+platform = "Linux"
+logo = ""
+
+actions = [
+ { label = "소스", url = "https://github.com/sqzer-x/uaf", style = "ghost" },
+]
+
+facts = [
+ { value = "3", label = "탐지 대역" },
+ { value = "5", label = "보안 분류" },
+ { value = "3.10+", label = "Python" },
+ { value = "root", label = "권한" },
+]
+
+features_eyebrow = ""
+features_title = ""
+features_intro = ""
+features = []
+
+session_eyebrow = ""
+session_title = ""
+session_shell = ""
+session_caption = ""
+session_lines = []
+
+cta_title = ""
+cta_text = ""
+cta_actions = []
++++
+
+스캔 도구는 대개 SSID 목록에서 멈춘다. 감사 보고서에는 그것만으로 부족하다.
+암호화가 WEP 인지 WPA3 인지, SSID 를 숨겼는지, 무선 칩을 누가 만들었는지, 그리고
+열려 있는 것들 뒤에는 실제로 무엇이 있는지가 필요하다.
+
+**수동 스캔.** 2.4, 5, 6 GHz 를 실시간으로 훑는다. 모든 AP 를 보안(WEP, WPA,
+WPA2, WPA3, OPN)과 규격(802.11 a/b/g/n/ac/ax)으로 분류하고, 채널 폭과 숨긴 SSID
+복원과 MAC 제조사 조회까지 붙인다. 열린 네트워크는 눈에 띄게 표시한다. 보고서가
+말하려는 것이 그것이기 때문이다.
+
+**능동 스캔.** 목록에서 의심스러운 개방 AP 를 고르면 접속한 뒤 구조를 그린다.
+DHCP 정보, 다른 단말을 찾는 ARP 스윕, 게이트웨이 포트 스캔. 결과는 화면을 옮기지
+않고 겹쳐 띄운다.
+
+**내보내기.** 능동 스캔 결과까지 포함해 CSV 와 JSON 으로 낸다. 옮겨 적는 대신
+그대로 감사 문서에 들어간다.
+
+`iw` 와 `ip` 가 있는 Linux, root 권한, 모니터 모드가 되는 어댑터가 필요하다.
+
+```bash
+pip install uaf
+```
diff --git a/content/projects/uaf.md b/content/projects/uaf.md
@@ -0,0 +1,66 @@
++++
+title = "uaf"
+description = "Ultimate AP Finder. Wireless AP discovery and classification for PCI-DSS audits."
+weight = 30
+date = 2026-02-14
+
+[extra]
+tier = "brief"
+
+eyebrow = "Wireless AP audit · Python"
+tagline = "PCI-DSS asks for a wireless assessment, and the honest version of that question is: which of these access points are ours, and which of them are open?"
+lang = "Python"
+license = ""
+version = ""
+platform = "Linux"
+logo = ""
+
+actions = [
+ { label = "Source", url = "https://github.com/sqzer-x/uaf", style = "ghost" },
+]
+
+facts = [
+ { value = "3", label = "Bands scanned" },
+ { value = "5", label = "Security classes" },
+ { value = "3.10+", label = "Python" },
+ { value = "root", label = "Privileges" },
+]
+
+features_eyebrow = ""
+features_title = ""
+features_intro = ""
+features = []
+
+session_eyebrow = ""
+session_title = ""
+session_shell = ""
+session_caption = ""
+session_lines = []
+
+cta_title = ""
+cta_text = ""
+cta_actions = []
++++
+
+Scanning tools tend to stop at a list of SSIDs. An audit report needs more than
+that: whether the encryption is WEP or WPA3, whether the SSID is hidden, who
+made the radio, and — for the open ones — what is actually behind them.
+
+**Passive.** Real-time detection across 2.4, 5 and 6 GHz. Every AP is classified
+by security (WEP, WPA, WPA2, WPA3, OPN) and by standard (802.11 a/b/g/n/ac/ax),
+with channel width, hidden-SSID recovery and MAC vendor lookup. Open networks
+are highlighted, because those are the ones the report is about.
+
+**Active.** Pick a suspicious open AP from the list and uaf connects to it, then
+maps what it finds: DHCP details, an ARP sweep for other clients, and a port
+scan of the gateway. Results come back in an overlay rather than a new screen.
+
+**Export.** CSV and JSON, active-scan results included, so the findings go
+straight into the audit document instead of being retyped.
+
+Needs a Linux box with `iw` and `ip`, root, and an adapter that does monitor
+mode.
+
+```bash
+pip install uaf
+```
diff --git a/content/projects/udfx.ko.md b/content/projects/udfx.ko.md
@@ -0,0 +1,58 @@
++++
+title = "udfx"
+description = "Ultimate Domain Finder X. Active Directory 의 DNS 레코드를 LDAP 으로 전부 끌어온다."
+weight = 40
+date = 2026-02-06
+
+[extra]
+tier = "brief"
+
+eyebrow = "Active Directory DNS · PowerShell"
+tagline = "망이 끊긴 윈도우 환경에서 DNS 영역이 곧 지도이고, 그 지도는 이미 Active Directory 가 들고 있다. 이 도구는 그걸 직접 달라고 한다."
+lang = "PowerShell"
+license = ""
+version = ""
+platform = "Windows"
+logo = ""
+
+actions = [
+ { label = "소스", url = "https://github.com/sqzer-x/udfx", style = "ghost" },
+]
+
+facts = [
+ { value = "LDAP", label = "조회 방식" },
+ { value = "5.1+", label = "PowerShell" },
+ { value = "도메인 사용자", label = "권한" },
+ { value = "4", label = "출력 파일" },
+]
+
+features_eyebrow = ""
+features_title = ""
+features_intro = ""
+features = []
+
+session_eyebrow = ""
+session_title = ""
+session_shell = ""
+session_caption = ""
+session_lines = []
+
+cta_title = ""
+cta_text = ""
+cta_actions = []
++++
+
+AD 통합 DNS 에 등록된 모든 도메인과 서브도메인을 LDAP 으로 읽는다. 인터넷도,
+별도 서비스도, 관리자 계정도 필요 없다. 평범한 도메인 사용자면 충분하다는 것이
+핵심이다. 계정 하나를 이미 쥔 공격자가 보는 것이 정확히 이 화면이다.
+
+레코드마다 타입(A, NS, CNAME, MX, SRV, TXT), 값, 영역, 그리고 속한 루트 도메인이
+함께 나오고, 영역은 primary 와 subdomain 과 external 로 갈라진다. 한 번 돌리면
+파일 네 개가 생긴다. 전체 레코드를 CSV 와 JSON 과 텍스트로, 그리고 중복을 없앤
+루트 도메인 목록 하나. 실제로 먼저 여는 것은 보통 마지막 파일이다.
+
+```powershell
+.\Get-ADDnsRecords.ps1 -Domain corp.example.com
+```
+
+도메인에 가입된 시스템에서 PowerShell 5.1 이상이 필요하다.
diff --git a/content/projects/udfx.md b/content/projects/udfx.md
@@ -0,0 +1,60 @@
++++
+title = "udfx"
+description = "Ultimate Domain Finder X. Pulls every DNS record out of Active Directory over LDAP."
+weight = 40
+date = 2026-02-06
+
+[extra]
+tier = "brief"
+
+eyebrow = "Active Directory DNS · PowerShell"
+tagline = "In an air-gapped Windows estate the DNS zone is the map, and Active Directory is already holding it. This asks for it directly."
+lang = "PowerShell"
+license = ""
+version = ""
+platform = "Windows"
+logo = ""
+
+actions = [
+ { label = "Source", url = "https://github.com/sqzer-x/udfx", style = "ghost" },
+]
+
+facts = [
+ { value = "LDAP", label = "Method" },
+ { value = "5.1+", label = "PowerShell" },
+ { value = "Domain user", label = "Privileges" },
+ { value = "4", label = "Output files" },
+]
+
+features_eyebrow = ""
+features_title = ""
+features_intro = ""
+features = []
+
+session_eyebrow = ""
+session_title = ""
+session_shell = ""
+session_caption = ""
+session_lines = []
+
+cta_title = ""
+cta_text = ""
+cta_actions = []
++++
+
+Every domain and subdomain registered in AD-integrated DNS, read over LDAP. No
+internet, no extra service, and no administrator account — a plain domain user
+is enough, which is the point: this is what an attacker who has one already
+sees.
+
+Each record comes out with its type (A, NS, CNAME, MX, SRV, TXT), its value, its
+zone, and the root domain it belongs to, with zones sorted into primary,
+subdomain and external. The run writes four files: the full record set as CSV,
+JSON and text, plus a deduplicated list of root domains, which is usually the
+one you actually open first.
+
+```powershell
+.\Get-ADDnsRecords.ps1 -Domain corp.example.com
+```
+
+Needs PowerShell 5.1 or newer on a domain-joined system.
diff --git a/content/projects/usfx.ko.md b/content/projects/usfx.ko.md
@@ -0,0 +1,59 @@
++++
+title = "usfx"
+description = "Ultimate Subdomain Finder X. 열두 가지 열거 기법, 전부 외부를 부르지 않는다."
+weight = 50
+date = 2026-01-20
+
+[extra]
+tier = "brief"
+
+eyebrow = "서브도메인 탐색 · Python"
+tagline = "이름난 서브도메인 도구는 하나같이 인터넷의 어떤 서비스를 부르는 것으로 시작한다. 내부망에서는 그게 정확히 없는 것이다."
+lang = "Python"
+license = ""
+version = "1.2.0"
+platform = "크로스 플랫폼"
+logo = ""
+
+actions = [
+ { label = "소스", url = "https://github.com/sqzer-x/usfx", style = "ghost" },
+]
+
+facts = [
+ { value = "12", label = "모듈" },
+ { value = "18,000", label = "최대 워드리스트" },
+ { value = "0", label = "외부 서비스" },
+ { value = "1.2.0", label = "버전" },
+]
+
+features_eyebrow = ""
+features_title = ""
+features_intro = ""
+features = []
+
+session_eyebrow = ""
+session_title = ""
+session_shell = ""
+session_caption = ""
+session_lines = []
+
+cta_title = ""
+cta_text = ""
+cta_actions = []
++++
+
+내부 DNS 서버를 알려 주면 거기서부터 시작한다. 기법은 열두 가지이고 전부
+오프라인이다.
+
+DNS 무차별 대입 · 영역 전송(AXFR) · NSEC 과 NSEC3 를 따라가는 DNSSEC 워킹 ·
+MX, NS, TXT, SRV, SOA, CAA 레코드 수집 · 역방향 DNS 스윕 · CNAME 체인 분석 ·
+서브도메인 순열 · 재귀 하위 서브도메인 열거 · 가상 호스트 탐색 · TLS 인증서 SAN
+추출 · 서브도메인 탈취 탐지 · 웹 기술 탐지.
+
+워드리스트는 셋이 함께 들어 있고 각각 약 500, 3,500, 18,000 개다. 출력에는
+파이프라인 모드(`subs`, `web`, `ips`, `json`)가 있어서, 보고서를 파싱하는 대신
+다음에 돌 도구로 그대로 이어진다.
+
+```bash
+pip install usfx
+```
diff --git a/content/projects/usfx.md b/content/projects/usfx.md
@@ -0,0 +1,60 @@
++++
+title = "usfx"
+description = "Ultimate Subdomain Finder X. Twelve enumeration modules, none of which phone home."
+weight = 50
+date = 2026-01-20
+
+[extra]
+tier = "brief"
+
+eyebrow = "Subdomain discovery · Python"
+tagline = "Every well-known subdomain tool starts by querying a service on the internet. On an internal network that is exactly the thing you do not have."
+lang = "Python"
+license = ""
+version = "1.2.0"
+platform = "Cross-platform"
+logo = ""
+
+actions = [
+ { label = "Source", url = "https://github.com/sqzer-x/usfx", style = "ghost" },
+]
+
+facts = [
+ { value = "12", label = "Modules" },
+ { value = "18,000", label = "Largest wordlist" },
+ { value = "0", label = "External services" },
+ { value = "1.2.0", label = "Version" },
+]
+
+features_eyebrow = ""
+features_title = ""
+features_intro = ""
+features = []
+
+session_eyebrow = ""
+session_title = ""
+session_shell = ""
+session_caption = ""
+session_lines = []
+
+cta_title = ""
+cta_text = ""
+cta_actions = []
++++
+
+Point it at an internal DNS server and it works from there. Twelve techniques,
+all of them offline:
+
+DNS brute force · zone transfer (AXFR) · DNSSEC walking over NSEC and NSEC3 ·
+record mining across MX, NS, TXT, SRV, SOA and CAA · reverse DNS sweep · CNAME
+chain analysis · subdomain permutation · recursive sub-subdomain enumeration ·
+virtual host discovery · TLS certificate SAN extraction · subdomain takeover
+detection · web technology detection.
+
+Three wordlists ship with it — roughly 500, 3,500 and 18,000 entries — and the
+output has pipeline modes (`subs`, `web`, `ips`, `json`) so it chains into
+whatever runs next instead of being parsed out of a report.
+
+```bash
+pip install usfx
+```
diff --git a/i18n/en.toml b/i18n/en.toml
@@ -0,0 +1,46 @@
+# 템플릿이 공급하는 문자열. 본문은 content/ 에 있다.
+# 한쪽 언어에 키가 없으면 빌드 에러다 — 기본 언어로 떨어뜨리면 한국어 페이지에
+# 영어 라벨이 섞여 나가고, 그건 읽는 사람에게만 보이고 고치는 사람에게는 안 보인다.
+
+skip_to_content = "Skip to content"
+menu = "Menu"
+close = "Close"
+theme = "Theme"
+search = "Search"
+search_the_site = "Search the site"
+search_hint = "Type to search"
+search_empty = "Nothing matches that."
+search_results = "Results"
+
+all_posts = "All posts"
+all_projects = "All projects"
+writing = "Writing"
+recent = "Recent"
+selected = "Selected"
+
+previous = "Previous"
+next = "Next"
+back = "Back"
+published = "Published"
+updated = "Updated"
+on_this_page = "On this page"
+breadcrumb = "You are here"
+home = "Home"
+
+website = "Website"
+source = "Source"
+docs = "Documentation"
+repository = "Repository"
+status = "Status"
+language = "Language"
+archived = "Archived"
+active = "Active"
+
+copy = "Copy"
+copied = "Copied"
+
+feed = "Feed"
+sitemap = "Sitemap"
+
+not_found_title = "This page does not exist"
+not_found_body = "The address is wrong, or the page moved without leaving a note. Everything on this site is one search away."
diff --git a/i18n/ko.toml b/i18n/ko.toml
@@ -0,0 +1,46 @@
+# 템플릿이 공급하는 문자열. 본문은 content/ 에 있다.
+# 한쪽 언어에 키가 없으면 빌드 에러다 — 기본 언어로 떨어뜨리면 한국어 페이지에
+# 영어 라벨이 섞여 나가고, 그건 읽는 사람에게만 보이고 고치는 사람에게는 안 보인다.
+
+skip_to_content = "본문으로 건너뛰기"
+menu = "메뉴"
+close = "닫기"
+theme = "테마"
+search = "검색"
+search_the_site = "사이트 검색"
+search_hint = "검색어를 입력하세요"
+search_empty = "일치하는 것이 없습니다."
+search_results = "검색 결과"
+
+all_posts = "글 전체"
+all_projects = "프로젝트 전체"
+writing = "글"
+recent = "최근"
+selected = "골라 둔 것"
+
+previous = "이전"
+next = "다음"
+back = "돌아가기"
+published = "발행"
+updated = "갱신"
+on_this_page = "이 페이지"
+breadcrumb = "현재 위치"
+home = "홈"
+
+website = "웹사이트"
+source = "소스"
+docs = "문서"
+repository = "저장소"
+status = "상태"
+language = "언어"
+archived = "보관됨"
+active = "유지 중"
+
+copy = "복사"
+copied = "복사됨"
+
+feed = "피드"
+sitemap = "사이트맵"
+
+not_found_title = "이 페이지는 없습니다"
+not_found_body = "주소가 틀렸거나, 페이지가 옮겨 가면서 안내를 남기지 않았습니다. 이 사이트에 있는 것은 전부 검색 한 번 거리에 있습니다."
diff --git a/sqzass.toml b/sqzass.toml
@@ -0,0 +1,37 @@
+title = "sqzer-x"
+description = "Terminal tools, static site tooling, and offensive security utilities."
+base_url = "https://sqzer.com"
+default_language = "en"
+
+[languages.en]
+name = "English"
+weight = 1
+
+[languages.ko]
+name = "한국어"
+weight = 2
+
+[build]
+output_dir = "public"
+drafts = false
+
+[markdown]
+# 한국어 본문에서 `**강조**한다` 가 강조로 파싱되려면 켜져 있어야 한다.
+cjk_friendly_emphasis = true
+heading_anchors = "right"
+
+# base16-ocean.dark 는 푸른빛 회색 계열이라 --bg(#0b0e12) 위에서 겉돌지 않는다.
+# 라이트는 코드 블록만 종이로 남는 편이 읽기 좋아 InspiredGitHub 를 쓴다.
+[highlight]
+theme_light = "InspiredGitHub"
+theme_dark = "base16-ocean.dark"
+
+[assets]
+source_dir = "static"
+fingerprint = true
+
+[nav]
+sort_by = "weight"
+
+[search]
+enabled = true
diff --git a/static/CNAME b/static/CNAME
@@ -0,0 +1 @@
+sqzer.com
diff --git a/static/css/main.css b/static/css/main.css
@@ -0,0 +1,1276 @@
+/* sqzer.com
+ *
+ * 쿨 그래파이트 필드에 뜻을 가진 액센트 둘. --live 는 지금 살아 있는 것(활성,
+ * 현재 위치, 주 동작)이고 --mark 는 표시하는 것(보관됨, 지표, 눈금)이다. 색이
+ * 취향이 아니라 상태를 말하므로, 어느 쪽인지 몰라서 둘 다 보는 일이 없다.
+ *
+ * 목소리는 둘이다. 본문은 시스템 산세리프 — 한글 웹폰트는 제대로 갖추면 2MB
+ * 라서, 이미 기계에 있는 것을 쓴다. 두 번째 목소리는 JetBrains Mono 이고
+ * 라틴 전용 21KB 다. 날짜, 배지, 명령, 워드마크가 그 목소리를 쓴다.
+ *
+ * 테마는 [data-theme] 이 정한다. prefers-color-scheme 이 아닌 이유는 생성된
+ * 하이라이트 스타일시트가 같은 속성을 보기 때문이다. 둘이 서로 다른 근거로
+ * 결정하면 라이트 OS 를 쓰는 사람이 어두운 페이지에 밝은 코드를 본다.
+ */
+
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("/fonts/jetbrains-mono-400.woff2") format("woff2");
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("/fonts/jetbrains-mono-700.woff2") format("woff2");
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+}
+
+/* ---------- tokens ---------- */
+
+:root {
+ color-scheme: dark;
+
+ --bg-recess: #06080a;
+ --bg: #0b0e12;
+ --bg-raised: #12161c;
+ --bg-hi: #1a1f26;
+
+ --ink: #e8ecf1;
+ --ink-2: #9aa4b0;
+ --ink-3: #6b7580;
+ --ink-4: #333b45;
+
+ --rule: #1a1f27;
+ --rule-hi: #262d37;
+ --rule-str: #3a434f;
+
+ /* 색이 아니라 삼원소다. 토큰 하나로 alpha 0.03 부터 0.75 까지 전부 나온다 —
+ hex 였다면 매번 color-mix 를 거쳐야 했을 값들이다. */
+ --glow: 232, 236, 241;
+
+ --live: #4fb6a5;
+ --mark: #d9a441;
+
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Apple SD Gothic Neo",
+ "Noto Sans KR", "Malgun Gothic", Roboto, sans-serif;
+ --mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas,
+ "Liberation Mono", monospace;
+
+ --radius: 4px;
+ --masthead-h: 3.5rem;
+ --control-h: 2rem;
+ --gutter: clamp(1.25rem, 4vw, 3rem);
+
+ /* 원장의 날짜 거터. 한 곳에만 두면 홈과 목록이 같은 자리에서 정렬된다. */
+ --key-width: 4.5rem;
+
+ /* 한 hover 안에서 잉크는 빠르고 빛은 느리다. 곡선은 둘뿐이고 세 번째를
+ 들이지 않는다 — 속도가 여러 개면 같은 물건이라는 느낌이 사라진다. */
+ --tr-ink: 0.22s ease;
+ --tr-light: 0.5s cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+:root[data-theme="light"] {
+ color-scheme: light;
+
+ --bg-recess: #eceef1;
+ --bg: #f7f8fa;
+ --bg-raised: #ffffff;
+ --bg-hi: #eef1f4;
+
+ --ink: #10141a;
+ --ink-2: #4d5661;
+ --ink-3: #6f7883;
+ --ink-4: #b6bec7;
+
+ --rule: #e2e6ea;
+ --rule-hi: #d3d9df;
+ --rule-str: #b8c0c8;
+
+ --glow: 16, 20, 26;
+
+ /* 니어블랙에서 읽히는 채도가 니어화이트에서는 대비를 잃는다. 둘 다 어둡게. */
+ --live: #197a6c;
+ --mark: #8a5d05;
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+
+/* 루트가 유동이라 모든 rem 이 이미 반응형이다. 이 한 줄이 미디어 쿼리
+ 대부분을 없앤다 — 무릎은 286px 과 1143px 뷰포트에 있다. */
+html {
+ font-size: clamp(16px, 15px + 0.35vw, 19px);
+ -webkit-text-size-adjust: 100%;
+ scroll-behavior: smooth;
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--ink);
+ font-family: var(--sans);
+ font-size: clamp(0.96rem, 0.9rem + 0.2vw, 1.0625rem);
+ line-height: 1.7;
+ /* 한국어는 어절 안에서 줄바꿈하지 않는다. 이게 없으면 좁은 폭에서 단어
+ 한가운데가 잘려 읽기 어려워진다. */
+ word-break: keep-all;
+ overflow-wrap: anywhere;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+}
+
+img { max-width: 100%; height: auto; }
+
+a { color: inherit; text-decoration: none; }
+
+:focus-visible {
+ outline: 2px solid var(--live);
+ outline-offset: 2px;
+ border-radius: 2px;
+}
+
+::selection { background: rgba(var(--glow), 0.18); }
+
+/* 스크롤바도 사이트의 일부다. 코드 블록만 손보고 나머지를 기본값으로 두면
+ 스크롤이 생기는 자리마다 다른 물건이 나온다. */
+* { scrollbar-width: thin; scrollbar-color: var(--rule-hi) transparent; }
+::-webkit-scrollbar { width: 8px; height: 8px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: var(--rule-hi); border-radius: 999px; }
+::-webkit-scrollbar-thumb:hover { background: var(--ink-4); }
+
+.sr-only {
+ position: absolute; width: 1px; height: 1px;
+ padding: 0; margin: -1px; overflow: hidden;
+ clip-path: inset(50%); white-space: nowrap; border: 0;
+}
+
+.skip {
+ position: absolute; left: -9999px; top: 0; z-index: 100;
+ padding: 0.7rem 1.1rem;
+ background: var(--bg-raised);
+ border: 1px solid var(--live);
+ border-radius: var(--radius);
+}
+.skip:focus { left: 0.5rem; top: 0.5rem; }
+
+/* ---------- layout ---------- */
+
+.wrap {
+ width: 100%;
+ max-width: 68rem;
+ margin-inline: auto;
+ padding-inline: var(--gutter);
+}
+/* 읽는 폭. 산문은 좁아야 눈이 줄을 놓치지 않는다. */
+.wrap-read { max-width: 46rem; }
+/* 원장은 날짜 거터가 왼쪽으로 나가 앉을 자리가 필요하다. */
+.wrap-ledger { max-width: 54rem; }
+
+main { display: block; padding-bottom: clamp(4rem, 10vw, 8rem); }
+
+/* ---------- masthead ---------- */
+
+/* 높이를 고정한다. 내용에 따라 늘어나면 페이지마다 상단바가 달라지고, 그게
+ "비율이 이상하다"로 읽힌다. */
+.masthead {
+ position: sticky;
+ top: 0;
+ z-index: 30;
+ height: var(--masthead-h);
+ background: color-mix(in srgb, var(--bg) 86%, transparent);
+ backdrop-filter: blur(10px);
+ border-bottom: 1px solid var(--rule);
+}
+
+.masthead-in {
+ display: flex;
+ align-items: center;
+ gap: clamp(0.6rem, 2vw, 1.75rem);
+ height: 100%;
+ max-width: 68rem;
+ margin-inline: auto;
+ padding-inline: var(--gutter);
+}
+
+.brand {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 0.4rem;
+ flex: none;
+ font-family: var(--mono);
+ font-weight: 700;
+ letter-spacing: -0.03em;
+}
+/* 마크는 그림이 아니라 글자다 — 프롬프트 기호. 로고 파일이 없어도 성립하고
+ currentColor 를 타므로 테마를 따라간다. */
+.brand-mark { color: var(--live); }
+.brand-name { color: var(--ink); }
+
+.nav {
+ display: flex;
+ align-items: center;
+ gap: clamp(0.75rem, 2vw, 1.5rem);
+ margin-right: auto;
+ min-width: 0;
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+.nav::-webkit-scrollbar { display: none; }
+
+.nav a {
+ position: relative;
+ flex: none;
+ padding-block: 0.35rem;
+ font-size: 0.86rem;
+ color: var(--ink-2);
+ transition: color var(--tr-ink);
+}
+.nav a:hover { color: var(--ink); }
+
+/* 현재 위치는 밑줄이 아니라 짧은 눈금이다. 밑줄은 링크의 표시이고 이건
+ 상태의 표시라, 서로 달라야 한다. */
+.nav a[aria-current] { color: var(--ink); }
+.nav a[aria-current]::after {
+ content: "";
+ position: absolute;
+ left: 0; right: 0; bottom: -1px;
+ height: 2px;
+ background: var(--live);
+}
+
+.tools { display: flex; align-items: center; gap: 0.4rem; flex: none; }
+
+/* 크롬 컨트롤은 전부 같은 치수다. 하나만 다르게 생기면 그 하나가 고장난
+ 것처럼 보인다. 터미널에서 모는 도구이므로 키캡처럼 다룬다. */
+.chip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.4rem;
+ height: var(--control-h);
+ padding-inline: 0.55rem;
+ border: 1px solid var(--rule-hi);
+ border-radius: var(--radius);
+ background: transparent;
+ color: var(--ink-2);
+ font: inherit;
+ font-size: 0.78rem;
+ cursor: pointer;
+ transition: color var(--tr-ink), border-color var(--tr-ink), background var(--tr-ink);
+}
+.chip:hover { color: var(--ink); border-color: var(--rule-str); background: var(--bg-raised); }
+.chip-icon { width: var(--control-h); padding-inline: 0; }
+.chip-lang { font-family: var(--mono); }
+.chip kbd {
+ font-family: var(--mono);
+ font-size: 0.68rem;
+ color: var(--ink-3);
+ border: 1px solid var(--rule-hi);
+ border-radius: 3px;
+ padding: 0.05rem 0.28rem;
+}
+
+/* 버튼은 상태가 아니라 동작을 말한다. 다크에서는 해가, 라이트에서는 달이 뜬다. */
+.i-moon { display: none; }
+:root[data-theme="light"] .i-sun { display: none; }
+:root[data-theme="light"] .i-moon { display: inline; }
+
+@media (max-width: 34rem) {
+ .chip kbd { display: none; }
+ .brand-name { display: none; }
+}
+
+/* ---------- type primitives ---------- */
+
+/* 눈금 라벨. 본문보다 작고 mono 이고 넓게 벌어져 있다 — 시각 위계를 의미
+ 위계와 반대로 두면 작은 글자가 오히려 구조로 읽힌다. */
+.eyebrow {
+ margin: 0 0 0.75rem;
+ font-family: var(--mono);
+ font-size: 0.7rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--ink-3);
+}
+
+.page-head { padding-block: clamp(2.5rem, 6vw, 4.5rem) clamp(1.5rem, 3vw, 2.5rem); }
+
+.page-title {
+ margin: 0;
+ font-size: clamp(2rem, 1.5rem + 2.2vw, 3.1rem);
+ font-weight: 800;
+ line-height: 1.1;
+ letter-spacing: -0.03em;
+ text-wrap: balance;
+}
+
+.page-lede {
+ margin: 0.9rem 0 0;
+ max-width: 52ch;
+ font-size: 1.02rem;
+ color: var(--ink-2);
+ text-wrap: pretty;
+}
+
+.page-meta {
+ margin: 1.1rem 0 0;
+ font-family: var(--mono);
+ font-size: 0.78rem;
+ font-variant-numeric: tabular-nums;
+ color: var(--ink-3);
+}
+
+.crumbs { margin-bottom: 0.9rem; font-family: var(--mono); font-size: 0.75rem; }
+.crumbs a { color: var(--ink-3); transition: color var(--tr-ink); }
+.crumbs a:hover { color: var(--live); }
+
+/* ---------- plate: 섹션 머리 ---------- */
+
+/* 라벨 + 오른쪽 끝까지 가는 실선 + 문(door) 링크. 라벨이 본문보다 작다. */
+.plate {
+ display: flex;
+ align-items: center;
+ gap: 0.9rem;
+ margin-bottom: 1.5rem;
+}
+
+.plate-label {
+ margin: 0;
+ flex: none;
+ font-family: var(--mono);
+ font-size: 0.72rem;
+ font-weight: 400;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--ink-2);
+}
+
+/* 스크롤 리빌이 붙는 자리. 빛웅덩이가 아니라 선이 그어진다 — 커서나 스캔
+ 라인처럼 읽히고, 분위기가 아니라 구조를 말한다. */
+.plate-rule {
+ flex: 1 1 auto;
+ height: 1px;
+ background: var(--rule-hi);
+ transform-origin: left center;
+ transition: transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);
+}
+.js [data-reveal] .plate-rule { transform: scaleX(0); }
+.js [data-reveal].is-lit .plate-rule { transform: scaleX(1); }
+
+.plate-count {
+ flex: none;
+ font-family: var(--mono);
+ font-size: 0.72rem;
+ font-variant-numeric: tabular-nums;
+ color: var(--ink-3);
+}
+
+.plate-door {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.78rem;
+ color: var(--ink-2);
+ transition: color var(--tr-ink), gap var(--tr-light);
+}
+.plate-door:hover { color: var(--live); gap: 0.6rem; }
+
+.group-note {
+ margin: -0.6rem 0 1.4rem;
+ max-width: 60ch;
+ font-size: 0.9rem;
+ color: var(--ink-3);
+}
+
+/* ---------- home hero ---------- */
+
+.hero-home { padding-block: clamp(3.5rem, 11vw, 8rem) clamp(2rem, 5vw, 4rem); }
+
+.statement {
+ margin: 0;
+ max-width: 18ch;
+ font-size: clamp(2.4rem, 1.4rem + 4.4vw, 5rem);
+ font-weight: 800;
+ line-height: 1.02;
+ letter-spacing: -0.04em;
+ text-wrap: balance;
+}
+
+.statement-sub {
+ margin: 1.4rem 0 0;
+ max-width: 46ch;
+ font-size: 1.05rem;
+ color: var(--ink-2);
+ text-wrap: pretty;
+}
+
+.home-block { padding-block: clamp(2.5rem, 6vw, 4.5rem); }
+
+/* ---------- feature card (홈 맨 위 글) ---------- */
+
+.feature {
+ display: block;
+ position: relative;
+ padding: clamp(1.4rem, 3vw, 2.2rem);
+ border: 1px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--bg-raised);
+ overflow: hidden;
+ transition: border-color var(--tr-ink);
+}
+/* hover 는 채움이 아니라 빛이다. 왼쪽에서 스며 나오는 방향성 wash. */
+.feature::before {
+ content: "";
+ position: absolute; inset: 0;
+ background: linear-gradient(100deg, rgba(var(--glow), 0.05), transparent 60%);
+ opacity: 0;
+ transition: opacity var(--tr-light);
+ pointer-events: none;
+}
+.feature:hover, .feature:focus-visible { border-color: var(--rule-str); }
+.feature:hover::before, .feature:focus-visible::before { opacity: 1; }
+
+.feature-date {
+ font-family: var(--mono);
+ font-size: 0.75rem;
+ font-variant-numeric: tabular-nums;
+ color: var(--mark);
+}
+
+.feature-title {
+ margin: 0.5rem 0 0;
+ font-size: clamp(1.4rem, 1.1rem + 1vw, 1.9rem);
+ font-weight: 700;
+ line-height: 1.15;
+ letter-spacing: -0.025em;
+ text-wrap: balance;
+}
+
+.feature-desc {
+ margin: 0.6rem 0 0;
+ max-width: 58ch;
+ color: var(--ink-2);
+}
+
+/* ---------- ledger ---------- */
+
+.ledger { list-style: none; margin: 1.5rem 0 0; padding: 0; }
+
+.ledger-year {
+ margin-top: 2rem;
+ padding-bottom: 0.4rem;
+ border-bottom: 1px solid var(--rule);
+ font-family: var(--mono);
+ font-size: 0.72rem;
+ letter-spacing: 0.16em;
+ color: var(--ink-4);
+}
+.ledger-year:first-child { margin-top: 0; }
+
+.ledger-row { border-bottom: 1px solid var(--rule); }
+
+/* 날짜 거터는 고정폭이고 baseline 으로 제목과 묶인다. 크기가 다른 두 글자를
+ 같은 기준선에 앉히는 것이 이 목록의 전부다. */
+.ledger-link {
+ position: relative;
+ display: grid;
+ grid-template-columns: var(--key-width) minmax(0, 1fr);
+ align-items: baseline;
+ gap: 1rem;
+ padding: 0.95rem 0.75rem;
+ margin-inline: -0.75rem;
+ border-radius: var(--radius);
+ transition: background var(--tr-light);
+}
+.ledger-link:hover, .ledger-link:focus-visible { background: rgba(var(--glow), 0.04); }
+
+.ledger-date {
+ font-family: var(--mono);
+ font-size: 0.78rem;
+ font-variant-numeric: tabular-nums;
+ color: var(--ink-3);
+ transition: color var(--tr-ink);
+}
+.ledger-link:hover .ledger-date { color: var(--mark); }
+
+.ledger-body { display: block; min-width: 0; }
+
+.ledger-title {
+ display: block;
+ font-size: 1.02rem;
+ font-weight: 600;
+ letter-spacing: -0.012em;
+ color: var(--ink-2);
+ transition: color var(--tr-ink);
+ text-wrap: balance;
+}
+.ledger-link:hover .ledger-title { color: var(--ink); }
+
+.ledger-desc {
+ display: block;
+ margin-top: 0.15rem;
+ max-width: 58ch;
+ font-size: 0.88rem;
+ color: var(--ink-3);
+}
+
+@media (max-width: 34rem) {
+ .ledger-link { grid-template-columns: 1fr; gap: 0.25rem; }
+}
+
+/* ---------- tags & dots ---------- */
+
+.tag {
+ display: inline-flex;
+ align-items: center;
+ height: 1.45rem;
+ padding-inline: 0.45rem;
+ border: 1px solid var(--rule-hi);
+ border-radius: 3px;
+ font-family: var(--mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.04em;
+ color: var(--ink-3);
+ white-space: nowrap;
+}
+.tag-lang { color: var(--ink-2); }
+
+.dot {
+ width: 6px; height: 6px;
+ border-radius: 50%;
+ background: var(--live);
+ flex: none;
+}
+.is-archived .dot { background: var(--ink-4); }
+
+/* ---------- buttons ---------- */
+
+.btn-row { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 1.6rem; }
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ height: 2.4rem;
+ padding-inline: 1rem;
+ border: 1px solid var(--rule-hi);
+ border-radius: var(--radius);
+ font-size: 0.88rem;
+ font-weight: 500;
+ color: var(--ink);
+ transition: border-color var(--tr-ink), background var(--tr-ink), color var(--tr-ink), gap var(--tr-light);
+}
+/* 화살표가 라벨에서 멀어진다. transform 이 아니라 gap 을 움직이면 글자가
+ 제자리에 있고 간격만 열린다. */
+.btn:hover { gap: 0.7rem; }
+.btn .i-out, .btn .i-arrow { opacity: 0.55; }
+
+.btn-primary {
+ background: var(--live);
+ border-color: var(--live);
+ color: var(--bg);
+ font-weight: 600;
+}
+.btn-primary:hover { filter: brightness(1.1); }
+
+.btn-ghost:hover { border-color: var(--rule-str); background: var(--bg-raised); }
+
+.btn-text { border-color: transparent; padding-inline: 0.3rem; color: var(--ink-2); }
+.btn-text:hover { color: var(--live); }
+
+.back {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ margin-top: 2rem;
+ font-family: var(--mono);
+ font-size: 0.75rem;
+ color: var(--ink-3);
+ transition: color var(--tr-ink), gap var(--tr-light);
+}
+.back:hover { color: var(--ink); gap: 0.6rem; }
+
+/* ---------- bento (featured projects) ---------- */
+
+/* flex 로 두면 셋이든 넷이든 마지막 줄에 하나만 남는 일이 없다. grid 의
+ 고정 열은 개수가 바뀌는 순간 그 문제를 만든다. */
+.bento {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ margin-bottom: clamp(3rem, 7vw, 5rem);
+}
+
+.bento-card {
+ flex: 1 1 20rem;
+ max-width: 34rem;
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--bg-raised);
+ overflow: hidden;
+ transition: border-color var(--tr-ink);
+}
+.bento-card:hover { border-color: var(--rule-str); }
+
+/* 커버가 없을 때 회색 상자를 놓지 않는다. 이름을 크게 깔고 뒤로 물리면
+ 그 자체가 그림이 된다. 나중에 이미지를 넣으면 이게 물러난다. */
+.bento-media {
+ position: relative;
+ aspect-ratio: 16 / 7;
+ display: grid;
+ place-items: center;
+ background: var(--bg-recess);
+ border-bottom: 1px solid var(--rule);
+ overflow: hidden;
+}
+.bento-media img { width: 100%; height: 100%; object-fit: cover; }
+/* 이미지가 없으면 낮춘다. 워드마크 하나를 담자고 16:7 을 비워 두면 카드가
+ 비어 있는 것으로 읽힌다. 이미지가 들어오면 원래 비율로 돌아온다. */
+.bento-media:not(:has(img)) { aspect-ratio: 16 / 4.5; }
+
+.bento-glyph {
+ font-family: var(--mono);
+ font-weight: 700;
+ font-size: clamp(2rem, 5vw, 3.2rem);
+ letter-spacing: -0.05em;
+ color: var(--ink-4);
+ transition: color var(--tr-light);
+}
+.bento-card:hover .bento-glyph { color: var(--ink-3); }
+
+.bento-body {
+ padding: 1.1rem 1.2rem 1.2rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ flex: 1;
+}
+
+.bento-name { margin: 0; font-size: 1.15rem; font-weight: 700; letter-spacing: -0.02em; font-family: var(--mono); }
+.bento-name a { transition: color var(--tr-ink); }
+.bento-name a:hover { color: var(--live); }
+
+.bento-blurb { margin: 0; flex: 1; max-width: 44ch; font-size: 0.9rem; color: var(--ink-2); }
+
+.bento-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-top: 0.35rem;
+}
+.bento-links { display: inline-flex; gap: 0.15rem; }
+.bento-links a, .row-links a {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.8rem; height: 1.8rem;
+ border-radius: 3px;
+ color: var(--ink-3);
+ transition: color var(--tr-ink), background var(--tr-ink);
+}
+.bento-links a:hover, .row-links a:hover { color: var(--ink); background: var(--bg-hi); }
+
+/* ---------- project rows ---------- */
+
+.group { padding-block: clamp(1.75rem, 4vw, 3rem); }
+
+.rows { list-style: none; margin: 0; padding: 0; }
+
+.row {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.85rem 0.75rem;
+ margin-inline: -0.75rem;
+ border-bottom: 1px solid var(--rule);
+ border-radius: var(--radius);
+ transition: background var(--tr-light);
+}
+.row:hover { background: rgba(var(--glow), 0.04); }
+
+/* 행 전체가 링크지만 오른쪽 아이콘들은 따로 눌려야 한다. 늘린 링크를 깔고
+ 상호작용하는 것들만 위로 올린다. */
+.row-main { display: flex; align-items: baseline; gap: 0.85rem; flex: 1; min-width: 0; }
+.row-main::after { content: ""; position: absolute; inset: 0; border-radius: inherit; }
+
+.row-name {
+ flex: none;
+ font-family: var(--mono);
+ font-weight: 700;
+ font-size: 0.95rem;
+ letter-spacing: -0.02em;
+ color: var(--ink-2);
+ transition: color var(--tr-ink);
+}
+.row:hover .row-name { color: var(--ink); }
+
+.row-blurb {
+ min-width: 0;
+ font-size: 0.88rem;
+ color: var(--ink-3);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.row-meta, .row-links {
+ position: relative;
+ z-index: 2;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: none;
+}
+
+@media (max-width: 44rem) {
+ .row { flex-wrap: wrap; }
+ .row-main { flex-basis: 100%; flex-direction: column; align-items: flex-start; gap: 0.2rem; }
+ .row-blurb { white-space: normal; }
+}
+
+/* ---------- project hero ---------- */
+
+.project { padding-bottom: clamp(3rem, 8vw, 6rem); }
+
+.hero {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 18rem;
+ gap: clamp(1.5rem, 4vw, 3.5rem);
+ align-items: center;
+ padding-block: clamp(2rem, 5vw, 3.5rem) clamp(2rem, 5vw, 3rem);
+}
+/* 옆칸이 없으면 한 칸으로. 템플릿이 클래스를 따로 내려보내지 않아도 된다. */
+.hero:not(:has(.hero-aside)) { grid-template-columns: minmax(0, 1fr); }
+
+.hero-main { min-width: 0; }
+
+.hero-name {
+ margin: 0;
+ font-family: var(--mono);
+ font-size: clamp(2.2rem, 1.6rem + 3vw, 4rem);
+ font-weight: 700;
+ line-height: 1.02;
+ letter-spacing: -0.05em;
+}
+
+.hero-tagline {
+ margin: 1rem 0 0;
+ max-width: 46ch;
+ font-size: 1.05rem;
+ color: var(--ink-2);
+ text-wrap: pretty;
+}
+
+.badges { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 1.2rem 0 0; padding: 0; list-style: none; }
+
+/* 로고 유무와 무관하게 같은 규격이다. 나중에 SVG 를 넣어도 레이아웃이 안 움직인다. */
+.hero-aside { display: grid; place-items: center; }
+.medallion {
+ display: grid;
+ place-items: center;
+ width: min(100%, 15rem);
+ aspect-ratio: 1;
+ border: 1px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--bg-raised);
+ overflow: hidden;
+}
+.medallion img { width: 76%; height: 76%; object-fit: contain; }
+.medallion-glyph {
+ font-family: var(--mono);
+ font-weight: 700;
+ font-size: clamp(1.5rem, 4vw, 2.4rem);
+ letter-spacing: -0.05em;
+ color: var(--ink-4);
+}
+
+@media (max-width: 48rem) {
+ .hero { grid-template-columns: minmax(0, 1fr); }
+ .hero-aside { display: none; }
+}
+
+/* ---------- facts ---------- */
+
+/* 별 개수가 있던 자리. 확인할 수 있는 사실만 들어간다. */
+.facts {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
+ gap: 1rem;
+ margin-block: clamp(1.5rem, 4vw, 2.5rem);
+ padding-block: 1.4rem;
+ border-block: 1px solid var(--rule);
+}
+
+.fact { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
+.fact-value {
+ font-family: var(--mono);
+ font-size: clamp(1.3rem, 1.1rem + 0.8vw, 1.7rem);
+ font-weight: 700;
+ letter-spacing: -0.03em;
+ font-variant-numeric: tabular-nums;
+}
+.fact-label {
+ font-size: 0.72rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ink-3);
+}
+
+/* ---------- blocks & lattice ---------- */
+
+.block { padding-block: clamp(2.5rem, 6vw, 4.5rem); }
+.block-head { margin-bottom: 1.8rem; }
+.block-title {
+ margin: 0;
+ font-size: clamp(1.5rem, 1.2rem + 1.2vw, 2.1rem);
+ font-weight: 700;
+ letter-spacing: -0.028em;
+ text-wrap: balance;
+}
+.block-intro { margin: 0.7rem 0 0; max-width: 60ch; color: var(--ink-2); }
+
+/* 선은 음수 마진으로 겹친 1px 이다. 개수와 열수가 무엇이든 맞아 들어가고
+ :nth-child 규칙이 필요 없다. hover 에서 z-index 를 올리는 이유는, 그러지
+ 않으면 이웃에 가려 두 변만 밝아지기 때문이다. */
+/* 18rem 은 임의의 값이 아니다. 이 폭에서 열이 3개가 되고, 특징을 여섯 개로
+ 쓰면 두 줄이 정확히 찬다. 열이 4개면 마지막 줄에 빈 칸 두 개가 테두리만
+ 남기고 서 있는데, 그건 격자가 아니라 덜 채워진 표로 읽힌다. */
+.lattice {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
+ border-top: 1px solid var(--rule);
+ border-left: 1px solid var(--rule);
+}
+
+.cell {
+ position: relative;
+ margin: 0 -1px -1px 0;
+ padding: 1.3rem 1.4rem 1.5rem;
+ border-right: 1px solid var(--rule);
+ border-bottom: 1px solid var(--rule);
+ min-width: 0;
+ transition: background var(--tr-light), border-color var(--tr-ink);
+}
+.cell:hover {
+ z-index: 1;
+ background: rgba(var(--glow), 0.035);
+ border-color: var(--rule-str);
+}
+
+.cell-title {
+ margin: 0;
+ font-size: 0.98rem;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+ transition: color var(--tr-ink);
+}
+.cell-desc { margin: 0.45rem 0 0; font-size: 0.88rem; color: var(--ink-3); text-wrap: pretty; }
+
+.cell-link { display: block; }
+.cell-link:hover .cell-title { color: var(--live); }
+
+/* ---------- terminal ---------- */
+
+.term {
+ border: 1px solid var(--rule-hi);
+ border-radius: var(--radius);
+ overflow: hidden;
+ background: var(--bg-recess);
+}
+
+.term-bar {
+ display: flex;
+ align-items: center;
+ gap: 0.7rem;
+ padding: 0.5rem 0.8rem;
+ border-bottom: 1px solid var(--rule);
+ background: var(--bg-raised);
+}
+.term-dots { display: inline-flex; gap: 0.32rem; }
+.term-dots i { width: 8px; height: 8px; border-radius: 50%; background: var(--rule-str); }
+.term-dots i:first-child { background: var(--ink-4); }
+.term-name { font-family: var(--mono); font-size: 0.7rem; color: var(--ink-3); }
+
+/* 배경이 페이지보다 어둡다 — 페이지 위에 얹힌 판이 아니라 페이지에 뚫린
+ 구멍처럼 읽혀야 한다. */
+.term-body {
+ margin: 0;
+ padding: 1.1rem 1.2rem;
+ overflow-x: auto;
+ font-family: var(--mono);
+ font-size: 0.82rem;
+ line-height: 1.85;
+ white-space: pre;
+}
+.term-body code { font: inherit; background: none; padding: 0; border: 0; }
+.term-body span { display: block; }
+
+.term-cmd { color: var(--ink); }
+.term-comment { color: var(--ink-3); }
+.term-out { color: var(--live); }
+
+.term-caption { margin: 0.7rem 0 0; font-size: 0.78rem; color: var(--ink-3); }
+
+/* ---------- cta ---------- */
+
+.cta {
+ margin-top: clamp(2rem, 5vw, 3.5rem);
+ padding: clamp(1.8rem, 4vw, 3rem);
+ border: 1px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--bg-raised);
+}
+.cta-title {
+ margin: 0;
+ font-size: clamp(1.4rem, 1.1rem + 1.2vw, 2rem);
+ font-weight: 700;
+ letter-spacing: -0.03em;
+ text-wrap: balance;
+}
+.cta-text { margin: 0.7rem 0 0; max-width: 52ch; color: var(--ink-2); }
+.cta .btn-row { margin-top: 1.4rem; }
+
+/* ---------- toc & pager ---------- */
+
+.toc {
+ margin: 2rem 0 0;
+ padding: 1rem 1.2rem;
+ border-left: 2px solid var(--rule-hi);
+ background: var(--bg-raised);
+ border-radius: 0 var(--radius) var(--radius) 0;
+}
+.toc-label {
+ margin: 0 0 0.5rem;
+ font-family: var(--mono);
+ font-size: 0.7rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--ink-3);
+}
+.toc ul { list-style: none; margin: 0; padding: 0; }
+.toc ul ul { padding-left: 1rem; }
+.toc li { margin: 0.25rem 0; font-size: 0.88rem; }
+.toc a { color: var(--ink-2); transition: color var(--tr-ink); }
+.toc a:hover { color: var(--live); }
+
+.pager {
+ display: flex;
+ gap: 0.8rem;
+ margin-top: clamp(3rem, 7vw, 5rem);
+ padding-top: 1.5rem;
+ border-top: 1px solid var(--rule);
+}
+.pager-link {
+ flex: 1;
+ padding: 0.9rem 1rem;
+ border: 1px solid var(--rule);
+ border-radius: var(--radius);
+ transition: border-color var(--tr-ink), background var(--tr-light);
+}
+.pager-link:hover { border-color: var(--rule-str); background: var(--bg-raised); }
+/* 하나만 있을 때 "다음"은 오른쪽에 앉는다 — 읽는 사람이 가는 방향이다. */
+.pager-next { text-align: right; }
+.pager-next:only-child { margin-left: auto; flex: 0 1 22rem; }
+.pager-dir {
+ display: block;
+ font-family: var(--mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--ink-3);
+ margin-bottom: 0.2rem;
+}
+.pager-title { display: block; font-size: 0.92rem; font-weight: 600; color: var(--ink-2); }
+.pager-link:hover .pager-title { color: var(--ink); }
+
+@media (max-width: 40rem) { .pager { flex-direction: column; } }
+
+/* ---------- prose ---------- */
+
+.prose { font-size: 1.02rem; line-height: 1.78; }
+.prose-intro { margin-bottom: 2rem; color: var(--ink-2); }
+
+/* 랜딩과 목록 페이지는 넓은 wrap 을 쓰지만 산문은 거기 따라가면 안 된다.
+ 한 줄이 68rem 이면 눈이 다음 줄의 첫 글자를 못 찾는다. */
+.project .prose, .prose-intro { max-width: 44rem; }
+
+.prose > * + * { margin-top: 1.15rem; }
+.prose p { margin: 0; text-wrap: pretty; }
+
+.prose h2, .prose h3, .prose h4 {
+ margin: 2.6rem 0 0;
+ line-height: 1.2;
+ letter-spacing: -0.025em;
+ text-wrap: balance;
+ scroll-margin-top: calc(var(--masthead-h) + 1rem);
+}
+.prose h2 { font-size: clamp(1.35rem, 1.15rem + 0.8vw, 1.7rem); font-weight: 700; }
+.prose h3 { font-size: 1.15rem; font-weight: 600; }
+.prose h4 { font-size: 1rem; font-weight: 600; }
+.prose h2 + *, .prose h3 + *, .prose h4 + * { margin-top: 0.75rem; }
+
+.prose a { color: var(--live); text-decoration: underline; text-underline-offset: 0.18em; text-decoration-thickness: 1px; }
+.prose a:hover { text-decoration-thickness: 2px; }
+
+.prose ol { margin: 1.15rem 0 0; padding-left: 1.35rem; }
+.prose ul { margin: 1.15rem 0 0; list-style: none; padding-left: 1.1rem; }
+.prose li { margin: 0.35rem 0; }
+/* 불릿 대신 짧은 선. 점보다 조용하고, mono 눈금과 같은 어휘다. */
+.prose ul > li { position: relative; }
+.prose ul > li::before {
+ content: "";
+ position: absolute;
+ left: -1.1rem; top: 0.85em;
+ width: 0.5rem; height: 1px;
+ background: var(--ink-4);
+}
+
+.prose blockquote {
+ margin: 1.8rem 0 0;
+ padding-left: 1.2rem;
+ border-left: 2px solid var(--rule-str);
+ color: var(--ink-2);
+}
+
+.prose code {
+ font-family: var(--mono);
+ font-size: 0.86em;
+ padding: 0.12em 0.35em;
+ border-radius: 3px;
+ background: var(--bg-hi);
+}
+
+.prose pre {
+ margin: 1.5rem 0 0;
+ padding: 1rem 1.1rem;
+ overflow-x: auto;
+ border: 1px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--bg-recess);
+ font-size: 0.84rem;
+ line-height: 1.7;
+}
+.prose pre code { padding: 0; background: none; font-size: inherit; }
+
+/* 복사 버튼은 pre 안에 앉는다. 래퍼를 새로 만들면 .prose 의 형제 간격 규칙이
+ pre 가 아니라 래퍼에 걸려 위아래 여백이 어긋난다.
+ hover 만이 아니라 focus-within 에서도 나온다 — 키보드로는 hover 할 수 없다. */
+.prose pre { position: relative; }
+.copy-btn {
+ position: absolute;
+ top: 0.45rem;
+ right: 0.45rem;
+ padding: 0.15rem 0.45rem;
+ border: 1px solid var(--rule-hi);
+ border-radius: 3px;
+ background: var(--bg-raised);
+ color: var(--ink-3);
+ font-family: var(--mono);
+ font-size: 0.68rem;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity var(--tr-ink), color var(--tr-ink), border-color var(--tr-ink);
+}
+.prose pre:hover .copy-btn,
+.prose pre:focus-within .copy-btn { opacity: 1; }
+.copy-btn:hover { color: var(--ink); border-color: var(--rule-str); }
+.copy-btn.is-done { opacity: 1; color: var(--live); border-color: var(--live); }
+
+.prose table {
+ width: 100%;
+ margin: 1.6rem 0 0;
+ border-collapse: collapse;
+ font-size: 0.92rem;
+ font-variant-numeric: tabular-nums;
+}
+.prose th, .prose td { padding: 0.5rem 0.7rem; text-align: left; border-bottom: 1px solid var(--rule); }
+.prose th {
+ font-size: 0.72rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ink-3);
+ border-bottom-color: var(--rule-hi);
+}
+.prose td { color: var(--ink-2); }
+
+.prose hr { margin: 2.5rem 0 0; border: 0; border-top: 1px solid var(--rule); }
+
+/* 생성기가 제목 오른쪽에 넣는 앵커. 평소에는 없고 그 제목에 다가가면 나온다. */
+.prose .anchor {
+ opacity: 0;
+ color: var(--ink-4);
+ text-decoration: none;
+ transition: opacity var(--tr-ink);
+}
+.prose h2:hover .anchor, .prose h3:hover .anchor, .prose h4:hover .anchor,
+.prose .anchor:focus-visible { opacity: 1; }
+
+/* ---------- search palette ---------- */
+
+.palette {
+ width: min(38rem, calc(100vw - 2rem));
+ margin-top: 12vh;
+ padding: 0;
+ border: 1px solid var(--rule-hi);
+ border-radius: var(--radius);
+ background: var(--bg-raised);
+ color: var(--ink);
+}
+.palette::backdrop { background: rgba(3, 5, 8, 0.62); backdrop-filter: blur(3px); }
+
+.palette-form { margin: 0; }
+
+.palette-head {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.7rem 0.8rem;
+ border-bottom: 1px solid var(--rule);
+ color: var(--ink-3);
+}
+
+.palette-input {
+ flex: 1;
+ min-width: 0;
+ border: 0;
+ background: none;
+ color: var(--ink);
+ font: inherit;
+ font-size: 0.98rem;
+ outline: none;
+}
+
+.palette-close { border: 0; background: none; cursor: pointer; padding: 0; color: var(--ink-3); }
+.palette-close kbd {
+ font-family: var(--mono);
+ font-size: 0.66rem;
+ border: 1px solid var(--rule-hi);
+ border-radius: 3px;
+ padding: 0.1rem 0.3rem;
+}
+
+.palette-results { list-style: none; margin: 0; padding: 0.35rem; max-height: 56vh; overflow-y: auto; }
+.palette-results:empty { display: none; }
+
+.palette-results a { display: block; padding: 0.55rem 0.65rem; border-radius: 3px; }
+.palette-results li[aria-selected="true"] a,
+.palette-results a:hover { background: var(--bg-hi); }
+
+.palette-title { display: block; font-size: 0.92rem; font-weight: 600; }
+.palette-crumb {
+ display: block;
+ margin-top: 0.1rem;
+ font-family: var(--mono);
+ font-size: 0.7rem;
+ color: var(--ink-3);
+}
+.palette-results mark { background: none; color: var(--mark); font-weight: 700; }
+
+.palette-status {
+ margin: 0;
+ padding: 0.5rem 0.8rem;
+ border-top: 1px solid var(--rule);
+ font-family: var(--mono);
+ font-size: 0.7rem;
+ color: var(--ink-3);
+}
+.palette-status:empty { display: none; }
+
+/* ---------- colophon ---------- */
+
+.colophon { border-top: 1px solid var(--rule); padding-block: 2.5rem 3.5rem; }
+.colophon-in {
+ max-width: 68rem;
+ margin-inline: auto;
+ padding-inline: var(--gutter);
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 1rem;
+}
+.colophon-nav { display: flex; flex-wrap: wrap; gap: 1.1rem; }
+.colophon-nav a { font-size: 0.82rem; color: var(--ink-3); transition: color var(--tr-ink); }
+.colophon-nav a:hover { color: var(--ink); }
+.colophon-note { margin: 0; font-family: var(--mono); font-size: 0.75rem; color: var(--ink-4); }
+.colophon-note a { color: var(--ink-3); transition: color var(--tr-ink); }
+.colophon-note a:hover { color: var(--live); }
+.colophon-sep { padding-inline: 0.4rem; }
+
+/* ---------- scroll reveal ---------- */
+
+/* 분위기가 아니라 구조를 드러낸다. 살짝 올라오면서 섹션의 선이 그어진다.
+ 숨기는 쪽을 .js 뒤에 두는 이유는, 스크립트가 없으면 아무것도 숨기지 않기
+ 위해서다 — CSS 로 먼저 숨기면 JS 가 죽었을 때 페이지가 통째로 사라진다. */
+.js [data-reveal] {
+ opacity: 0;
+ transform: translateY(0.6rem);
+ transition: opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1),
+ transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
+}
+.js [data-reveal].is-lit { opacity: 1; transform: none; }
+
+/* ---------- Korean ---------- */
+
+/* 한국어는 라틴 타이포 장치 셋을 깨뜨린다. letter-spacing 은 음절 블록을
+ 벌려 놓고, text-transform 은 아무 일도 하지 않으며, monospace 가 가장
+ 나쁘다 — 한글은 프로포셔널 폰트로 폴백하는데 공백은 여전히 mono 폭이라
+ 어절 사이가 전부 두 배로 벌어진다.
+ 구조는 크기와 굵기와 색으로도 읽히고, 그쪽은 도움이 필요 없다. */
+html[lang="ko"] .eyebrow,
+html[lang="ko"] .plate-label,
+html[lang="ko"] .fact-label,
+html[lang="ko"] .toc-label,
+html[lang="ko"] .pager-dir,
+html[lang="ko"] .prose th,
+html[lang="ko"] .crumbs,
+html[lang="ko"] .palette-crumb,
+html[lang="ko"] .colophon-note,
+html[lang="ko"] .tag {
+ font-family: var(--sans);
+ letter-spacing: 0;
+ text-transform: none;
+}
+
+html[lang="ko"] .statement,
+html[lang="ko"] .page-title,
+html[lang="ko"] .hero-name,
+html[lang="ko"] .block-title,
+html[lang="ko"] .cta-title,
+html[lang="ko"] .feature-title,
+html[lang="ko"] .bento-name {
+ font-family: var(--sans);
+ letter-spacing: -0.02em;
+ font-weight: 700;
+}
+
+/* 날짜와 숫자는 한국어 페이지에서도 mono 로 둔다. 숫자에는 위 문제가 없고,
+ 자릿수가 맞아야 목록이 줄을 이룬다. */
+html[lang="ko"] .ledger-date,
+html[lang="ko"] .feature-date,
+html[lang="ko"] .fact-value,
+html[lang="ko"] .plate-count,
+html[lang="ko"] .brand { font-family: var(--mono); }
+
+/* ---------- reduced motion ---------- */
+
+/* 전환만 끄면 변형된 자리에 그대로 멈춘다. 종점도 같이 중화해야 한다. */
+@media (prefers-reduced-motion: reduce) {
+ html { scroll-behavior: auto; }
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+ .js [data-reveal] { opacity: 1; transform: none; }
+ .js [data-reveal] .plate-rule { transform: scaleX(1); }
+}
diff --git a/static/fonts/jetbrains-mono-400.woff2 b/static/fonts/jetbrains-mono-400.woff2
Binary files differ.
diff --git a/static/fonts/jetbrains-mono-700.woff2 b/static/fonts/jetbrains-mono-700.woff2
Binary files differ.
diff --git a/static/images/projects/ommp.png b/static/images/projects/ommp.png
Binary files differ.
diff --git a/static/js/search.js b/static/js/search.js
@@ -0,0 +1,176 @@
+/* 검색 팔레트. 의존성 없음.
+ *
+ * sqzass 가 언어마다 색인을 하나씩 낸다. 각 행은 페이지 하나이고
+ * `t` 제목, `d` 설명, `u` URL, `s` 갈래 이름, `c` 본문 텍스트다. `d` 와 `s` 는
+ * 비어 있으면 아예 없다 — 그래서 읽을 때마다 있는지 확인한다.
+ *
+ * 단어가 아니라 부분 문자열로 찾는다. 한국어는 명사에 조사가 붙고 띄어쓰기
+ * 없이 합성어를 만들기 때문에, 단어 색인으로는 `검색엔진최적화` 안의 `최적화` 를
+ * 영영 찾지 못한다.
+ *
+ * 다이얼로그는 <dialog> 다. Escape, 백드롭, 포커스 트랩, 닫을 때 트리거로
+ * 포커스 되돌리기가 전부 플랫폼 동작이라 우리가 쓸 코드가 아니다.
+ */
+(function () {
+ "use strict";
+
+ var trigger = document.querySelector(".search-trigger");
+ var dialog = document.getElementById("search-dialog");
+ if (!trigger || !dialog || !dialog.showModal) return;
+
+ var input = dialog.querySelector(".palette-input");
+ var list = dialog.querySelector(".palette-results");
+ var status = dialog.querySelector(".palette-status");
+ var script = document.querySelector("script[data-empty]");
+ var EMPTY = (script && script.dataset.empty) || "No matches.";
+ var HINT = (script && script.dataset.hint) || "";
+
+ var rows = null;
+ var loading = false;
+ var cursor = -1;
+
+ /* 수정자 키를 읽는 사람의 것으로 바꿔 쓴다. 템플릿이 쓴 값은 JS 가 없을 때의 것. */
+ var mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
+ var kbd = trigger.querySelector("kbd");
+ if (kbd) kbd.textContent = mac ? "⌘ K" : "Ctrl K";
+
+ function load() {
+ if (rows || loading) return Promise.resolve();
+ loading = true;
+ status.textContent = "…";
+ return fetch(trigger.dataset.index)
+ .then(function (r) { return r.json(); })
+ .then(function (data) { rows = data; status.textContent = HINT; })
+ .catch(function () { status.textContent = EMPTY; })
+ .then(function () { loading = false; });
+ }
+
+ function open() {
+ load().then(function () { render(input.value); });
+ if (!dialog.open) dialog.showModal();
+ input.focus();
+ input.select();
+ }
+
+ /* 제목에 걸린 것이 설명보다, 설명이 본문보다 위다. 제목이 그 말로 시작하면
+ 한 번 더 올라간다 — 찾던 것을 정확히 친 경우다. */
+ function score(row, terms) {
+ var t = row.t.toLowerCase();
+ var d = (row.d || "").toLowerCase();
+ var c = row.c.toLowerCase();
+ var total = 0;
+ for (var i = 0; i < terms.length; i++) {
+ var q = terms[i];
+ if (t.indexOf(q) === 0) total += 100;
+ else if (t.indexOf(q) > -1) total += 60;
+ else if (d.indexOf(q) > -1) total += 25;
+ else if (c.indexOf(q) > -1) total += 8;
+ else return -1; /* 모든 낱말이 어딘가에는 있어야 한다. OR 이 아니라 AND. */
+ }
+ return total;
+ }
+
+ function mark(text, terms) {
+ var frag = document.createDocumentFragment();
+ var lower = text.toLowerCase();
+ var at = -1, len = 0;
+ for (var i = 0; i < terms.length; i++) {
+ var p = lower.indexOf(terms[i]);
+ if (p > -1 && (at === -1 || p < at)) { at = p; len = terms[i].length; }
+ }
+ if (at === -1) { frag.appendChild(document.createTextNode(text)); return frag; }
+ frag.appendChild(document.createTextNode(text.slice(0, at)));
+ var m = document.createElement("mark");
+ m.textContent = text.slice(at, at + len);
+ frag.appendChild(m);
+ frag.appendChild(document.createTextNode(text.slice(at + len)));
+ return frag;
+ }
+
+ function render(query) {
+ list.textContent = "";
+ cursor = -1;
+ var q = query.trim().toLowerCase();
+ if (!rows || !q) { status.textContent = rows ? HINT : status.textContent; return; }
+
+ var terms = q.split(/\s+/);
+ var hits = [];
+ for (var i = 0; i < rows.length; i++) {
+ var s = score(rows[i], terms);
+ if (s > -1) hits.push({ row: rows[i], score: s });
+ }
+ /* 같은 점수면 제목순. 순서가 매번 달라지면 두 번째 검색에서 눈이 길을 잃는다. */
+ hits.sort(function (a, b) { return b.score - a.score || a.row.t.localeCompare(b.row.t); });
+ hits = hits.slice(0, 12);
+
+ if (!hits.length) { status.textContent = EMPTY; return; }
+ status.textContent = String(hits.length);
+
+ hits.forEach(function (h) {
+ var li = document.createElement("li");
+ var a = document.createElement("a");
+ a.href = h.row.u;
+
+ var title = document.createElement("span");
+ title.className = "palette-title";
+ title.appendChild(mark(h.row.t, terms));
+ a.appendChild(title);
+
+ var sub = h.row.s || h.row.d;
+ if (sub) {
+ var crumb = document.createElement("span");
+ crumb.className = "palette-crumb";
+ crumb.textContent = sub;
+ a.appendChild(crumb);
+ }
+
+ li.appendChild(a);
+ list.appendChild(li);
+ });
+ }
+
+ function move(step) {
+ var items = list.children;
+ if (!items.length) return;
+ if (cursor > -1) items[cursor].removeAttribute("aria-selected");
+ cursor = (cursor + step + items.length) % items.length;
+ items[cursor].setAttribute("aria-selected", "true");
+ items[cursor].scrollIntoView({ block: "nearest" });
+ }
+
+ trigger.addEventListener("click", open);
+
+ document.addEventListener("keydown", function (e) {
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
+ e.preventDefault();
+ open();
+ }
+ });
+
+ input.addEventListener("input", function () { render(input.value); });
+
+ input.addEventListener("keydown", function (e) {
+ if (e.key === "ArrowDown") { e.preventDefault(); move(1); }
+ else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); }
+ else if (e.key === "Enter") {
+ /* 폼 안이라 Enter 는 기본적으로 다이얼로그를 닫는다. 고른 것이 있으면
+ 닫는 대신 거기로 간다. */
+ var pick = cursor > -1 ? list.children[cursor] : list.children[0];
+ if (pick) { e.preventDefault(); pick.querySelector("a").click(); }
+ }
+ });
+
+ /* 닫힐 때 비워 둔다. 다시 열었을 때 지난 검색 결과가 남아 있으면, 방금
+ 친 것에 대한 답으로 읽힌다. */
+ dialog.addEventListener("close", function () {
+ input.value = "";
+ list.textContent = "";
+ status.textContent = "";
+ cursor = -1;
+ });
+
+ /* 백드롭 클릭. <dialog> 는 백드롭도 자기 자신을 target 으로 준다. */
+ dialog.addEventListener("click", function (e) {
+ if (e.target === dialog) dialog.close();
+ });
+})();
diff --git a/static/js/ui.js b/static/js/ui.js
@@ -0,0 +1,81 @@
+/* 테마 토글, 스크롤 리빌, 코드 블록 복사.
+ *
+ * 셋 다 없어도 페이지는 읽힌다. 리빌을 숨기는 CSS 가 `.js` 뒤에 있고 그
+ * 클래스는 <head> 인라인 스크립트가 붙이므로, 이 파일이 못 와도 내용이
+ * 사라지지 않는다.
+ */
+(function () {
+ "use strict";
+
+ var root = document.documentElement;
+ var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+
+ /* ---------- theme ---------- */
+
+ var toggle = document.querySelector(".theme-toggle");
+ if (toggle) {
+ toggle.addEventListener("click", function () {
+ var next = root.dataset.theme === "light" ? "dark" : "light";
+ root.dataset.theme = next;
+ try { localStorage.setItem("theme", next); } catch (e) {}
+ });
+ }
+
+ /* ---------- reveal ---------- */
+
+ /* 한 번 보이면 다시 숨기지 않는다. 위로 스크롤할 때 도로 사라지는 것은
+ 읽던 자리로 돌아가는 사람에게 고장으로 보인다. */
+ var targets = document.querySelectorAll("[data-reveal]");
+ if (reduced || !("IntersectionObserver" in window)) {
+ targets.forEach(function (el) { el.classList.add("is-lit"); });
+ } else {
+ var io = new IntersectionObserver(function (entries) {
+ entries.forEach(function (e) {
+ if (!e.isIntersecting) return;
+ e.target.classList.add("is-lit");
+ io.unobserve(e.target);
+ });
+ }, { rootMargin: "0px 0px -12% 0px", threshold: 0.05 });
+
+ targets.forEach(function (el) {
+ /* 이미 첫 화면에 들어와 있는 것은 관찰을 기다리지 않는다 — 관찰자가
+ 첫 콜백을 내기까지의 한 프레임이 눈에 띈다. */
+ if (el.getBoundingClientRect().top < window.innerHeight * 0.9) {
+ el.classList.add("is-lit");
+ } else {
+ io.observe(el);
+ }
+ });
+ }
+
+ /* ---------- copy ---------- */
+
+ var script = document.querySelector("script[data-copy]");
+ var LABEL = (script && script.dataset.copy) || "Copy";
+ var DONE = (script && script.dataset.copied) || "Copied";
+
+ if (navigator.clipboard) {
+ document.querySelectorAll(".prose pre").forEach(function (pre) {
+ var btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "copy-btn";
+ btn.textContent = LABEL;
+
+ btn.addEventListener("click", function () {
+ /* 버튼 자신의 글자가 pre 안에 있으므로 code 만 읽는다. 안 그러면
+ 복사한 명령 끝에 "Copy" 가 붙어 나간다. */
+ var code = pre.querySelector("code") || pre;
+ navigator.clipboard.writeText(code.innerText).then(function () {
+ btn.textContent = DONE;
+ btn.classList.add("is-done");
+ setTimeout(function () {
+ btn.textContent = LABEL;
+ btn.classList.remove("is-done");
+ }, 1600);
+ });
+ });
+
+ pre.appendChild(btn);
+ });
+ }
+})();
diff --git a/templates/404.html b/templates/404.html
@@ -0,0 +1,21 @@
+{#- 호스트가 이름으로 찾는 404. 페이지에 매이지 않은 템플릿이라 page.title 은
+ "404" 로 고정이고 번역도 자식도 없다 — 여기서 쓸 수 있는 건 t() 와 site 뿐이다.
+
+ 길 잃은 사람에게 필요한 건 사과가 아니라 나가는 길이다. 그래서 갈래 링크를 준다. #}
+{% extends "base.html" %}
+
+{% block content %}
+<div class="wrap wrap-read">
+ <header class="page-head">
+ <p class="eyebrow">404</p>
+ <h1 class="page-title">{{ t("not_found_title") }}</h1>
+ <p class="page-lede">{{ t("not_found_body") }}</p>
+ </header>
+
+ <nav class="btn-row" aria-label="{{ t("menu") }}">
+ {%- for s in site.sections %}
+ <a class="btn btn-ghost" href="{{ s.url }}">{{ s.title }}</a>
+ {%- endfor %}
+ </nav>
+</div>
+{% endblock %}
diff --git a/templates/base.html b/templates/base.html
@@ -0,0 +1,120 @@
+{#- 모든 페이지가 상속하는 골격. 여기는 크롬만 안다 — 본문은 각 템플릿이
+ content 블록에 채우고, 카피는 content/ 와 i18n/ 이 갖는다.
+
+ 내비는 site.sections 를 그대로 돈다. 현재 언어의 트리만 담기므로 번역이
+ 없는 갈래는 애초에 그려지지 않고, 죽은 링크가 생길 수 없다. 그래서 About도
+ 페이지가 아니라 섹션이다 — 세 갈래가 같은 자료에서 나와야 하나만 다르게
+ 동작하는 일이 없다. #}
+{%- set ko = page.language == "ko" %}
+{#- 서브경로에 올라갈 수도 있으므로 손으로 쓰는 링크에는 base_path를 붙인다.
+ 페이지 URL들은 이미 이 접두사를 품고 있어서 손댈 필요가 없다. #}
+{%- set home = site.base_path ~ ("/ko/" if ko else "/") %}
+<!doctype html>
+{#- 기본값을 여기 적는다. prefers-color-scheme 에 맡기지 않는 이유는 생성된
+ 하이라이트 스타일시트가 같은 [data-theme] 을 보기 때문이다. 둘이 서로 다른
+ 근거로 결정하면 라이트 OS 를 쓰는 사람이 어두운 페이지에 밝은 코드를 본다. #}
+<html lang="{{ page.language }}" data-theme="dark">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{% if page.url == home %}{{ site.title }} — {{ site.description }}{% else %}{{ page.title }} · {{ site.title }}{% endif %}</title>
+{%- if page.description %}
+<meta name="description" content="{{ page.description }}">
+{%- endif %}
+<link rel="canonical" href="{{ page.permalink }}">
+{%- if site.feed %}
+<link rel="alternate" type="application/atom+xml" title="{{ site.title }}" href="{{ site.feed }}">
+{%- endif %}
+{%- for lang in page.translations %}
+<link rel="alternate" hreflang="{{ lang.code }}" href="{{ site.origin }}{{ lang.url }}">
+{%- endfor %}
+
+{#- 소셜 카드. sqzass 는 <head> 에 아무것도 넣지 않으므로 전부 우리 마크업이다.
+ summary_large_image 는 이미지가 있을 때만 뜻이 있다 — 없이 선언하면 빈 상자가 나온다. #}
+<meta property="og:type" content="{{ "website" if page.is_section else "article" }}">
+<meta property="og:site_name" content="{{ site.title }}">
+<meta property="og:title" content="{{ page.title }}">
+<meta property="og:url" content="{{ page.permalink }}">
+<meta property="og:locale" content="{{ "ko_KR" if ko else "en_US" }}">
+{%- if page.description %}
+<meta property="og:description" content="{{ page.description }}">
+{%- endif %}
+<meta name="twitter:card" content="summary">
+
+<link rel="stylesheet" href="{{ asset("css/main.css") }}">
+{%- if site.highlight_css %}
+<link rel="stylesheet" href="{{ site.highlight_css }}">
+{%- endif %}
+<script>
+/* 첫 페인트 전에 저장된 테마를 얹는다. 외부 파일이면 늦게 도착해서 반대 테마가
+ 한 번 번쩍인다. 그래서 인라인이다.
+
+ `js` 클래스도 여기서 붙인다. 스크롤 리빌이 이 클래스 뒤에 숨어 있어서,
+ defer 된 파일이 붙이면 내용이 한 번 보였다가 사라졌다가 다시 나타난다. */
+(function(){var d=document.documentElement;d.className+=" js";try{var t=localStorage.getItem("theme");if(t)d.dataset.theme=t}catch(e){}})();
+</script>
+</head>
+<body>
+<a class="skip" href="#content">{{ t("skip_to_content") }}</a>
+
+<header class="masthead">
+ <div class="masthead-in">
+ {#- 로고 이미지는 없다. 워드마크가 곧 마크다 — mono 700 에 음수 트래킹.
+ alt 를 비우지 않는 이유는 이게 이미지가 아니라 텍스트이기 때문이다. #}
+ <a class="brand" href="{{ home }}">
+ <span class="brand-mark" aria-hidden="true">$</span>
+ <span class="brand-name">{{ site.title }}</span>
+ </a>
+
+ <nav class="nav" aria-label="{{ t("menu") }}">
+ {%- for s in site.sections %}
+ <a href="{{ s.url }}"{% if page.url == s.url or (page.section and page.section.url == s.url) %} aria-current="true"{% endif %}>{{ s.title }}</a>
+ {%- endfor %}
+ </nav>
+
+ <div class="tools">
+ {%- if site.search %}
+ <button class="chip search-trigger" type="button" data-index="{{ site.search }}"
+ aria-label="{{ t("search_the_site") }}">
+ <svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
+ {#- 라벨은 search.js 가 읽는 사람의 수정자 키로 바꿔 쓴다. 이건 JS 가 없을 때의 값. #}
+ <kbd>Ctrl K</kbd>
+ </button>
+ {%- endif %}
+ {%- for lang in page.translations %}
+ <a class="chip chip-lang" href="{{ lang.url }}" hreflang="{{ lang.code }}">{{ lang.name }}</a>
+ {%- endfor %}
+ <a class="chip chip-icon" href="https://github.com/sqzer-x" aria-label="GitHub" title="GitHub">
+ <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
+ </a>
+ {#- 지금 테마가 아니라 **누르면 갈 곳**을 보여 준다. 버튼은 상태가 아니라 동작을 말해야 한다. #}
+ <button class="chip chip-icon theme-toggle" type="button" aria-label="{{ t("theme") }}" title="{{ t("theme") }}">
+ <svg class="i-sun" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
+ <circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>
+ </svg>
+ <svg class="i-moon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>
+ </svg>
+ </button>
+ </div>
+ </div>
+</header>
+
+<main id="content">
+{% block content %}{% endblock %}
+</main>
+
+{%- include "partials/footer.html" %}
+
+{%- if site.search %}
+{%- include "partials/search.html" %}
+{%- endif %}
+
+<script src="{{ asset("js/ui.js") }}" defer
+ data-copy="{{ t("copy") }}" data-copied="{{ t("copied") }}"></script>
+{%- if site.search %}
+<script src="{{ asset("js/search.js") }}" defer
+ data-empty="{{ t("search_empty") }}" data-hint="{{ t("search_hint") }}"></script>
+{%- endif %}
+</body>
+</html>
diff --git a/templates/home.html b/templates/home.html
@@ -0,0 +1,93 @@
+{#- 홈. 히어로 → 맨 위 글 한 편 → 최근 글 → 프로젝트.
+
+ 루트의 page.children 은 최상위 섹션들이라 글 목록이 여기 없다. 대신
+ site.sections 를 돌면서 URL 로 갈래를 찾는다. 테스트나 필터를 쓰지 않고
+ 문자열을 그대로 비교하는 이유는, 이 URL 을 만드는 규칙이 이미 home 에
+ 있어서 새 규칙을 하나 더 두지 않아도 되기 때문이다.
+
+ 카피는 전부 [extra] 에 있고 이 파일은 구조만 안다. 두 언어가 같은 키를
+ 1:1 로 갖는다. #}
+{% extends "base.html" %}
+{% from "partials/ledger.html" import ledger %}
+{% from "partials/icons.html" import i_arrow %}
+
+{% block content %}
+{%- set ko = page.language == "ko" %}
+{%- set home = site.base_path ~ ("/ko/" if ko else "/") %}
+
+<section class="hero-home">
+ <div class="wrap">
+ {%- if page.extra.hero_kicker %}
+ <p class="eyebrow" data-reveal>{{ page.extra.hero_kicker }}</p>
+ {%- endif %}
+ <h1 class="statement" data-reveal>{{ page.extra.hero_title }}</h1>
+ {%- if page.extra.hero_sub %}
+ <p class="statement-sub" data-reveal>{{ page.extra.hero_sub }}</p>
+ {%- endif %}
+ {%- if page.extra.hero_actions %}
+ <div class="btn-row" data-reveal>
+ {%- for a in page.extra.hero_actions %}
+ <a class="btn btn-{{ a.style }}" href="{{ a.url }}"><span>{{ a.label }}</span>{{ i_arrow() }}</a>
+ {%- endfor %}
+ </div>
+ {%- endif %}
+ </div>
+</section>
+
+{#- 글. 맨 위 한 편은 크게, 나머지는 원장으로. 같은 자료를 두 가지 크기로
+ 보여 주는 것뿐이라 두 곳을 따로 관리하지 않는다. #}
+{%- for s in site.sections %}
+{%- if s.url == home ~ "posts/" and s.pages %}
+<section class="home-block wrap" data-reveal>
+ <div class="plate">
+ <h2 class="plate-label">{{ t("writing") }}</h2>
+ <span class="plate-rule" aria-hidden="true"></span>
+ <a class="plate-door" href="{{ s.url }}">{{ t("all_posts") }}{{ i_arrow() }}</a>
+ </div>
+
+ {%- for p in s.pages %}
+ {%- if loop.first %}
+ <a class="feature" href="{{ p.url }}">
+ {%- if p.date %}
+ <time class="feature-date" datetime="{{ p.date.date }}">{{ p.date.year }}.{% if p.date.month < 10 %}0{% endif %}{{ p.date.month }}.{% if p.date.day < 10 %}0{% endif %}{{ p.date.day }}</time>
+ {%- endif %}
+ <h3 class="feature-title">{{ p.title }}</h3>
+ {%- if p.description %}
+ <p class="feature-desc">{{ p.description }}</p>
+ {%- endif %}
+ </a>
+ {%- endif %}
+ {%- endfor %}
+
+ {{ ledger(s.pages, 4, false, 1) }}
+</section>
+{%- endif %}
+{%- endfor %}
+
+{#- 프로젝트. 여기서는 이름과 한 줄 설명만 낸다 — 배지와 링크는 목록이 실어
+ 오지 않는 값이라, 홈에 두려면 카탈로그를 한 번 더 적어야 한다. 그건 두
+ 곳이 어긋날 자리를 만드는 일이고, 그 값들이 궁금해진 사람은 어차피
+ Projects 로 간다. #}
+{%- for s in site.sections %}
+{%- if s.url == home ~ "projects/" and s.pages %}
+<section class="home-block wrap" data-reveal>
+ <div class="plate">
+ <h2 class="plate-label">{{ s.title }}</h2>
+ <span class="plate-rule" aria-hidden="true"></span>
+ <a class="plate-door" href="{{ s.url }}">{{ t("all_projects") }}{{ i_arrow() }}</a>
+ </div>
+
+ <div class="lattice">
+ {%- for p in s.pages %}
+ <a class="cell cell-link" href="{{ p.url }}">
+ <h3 class="cell-title">{{ p.title }}</h3>
+ {%- if p.description %}
+ <p class="cell-desc">{{ p.description }}</p>
+ {%- endif %}
+ </a>
+ {%- endfor %}
+ </div>
+</section>
+{%- endif %}
+{%- endfor %}
+{% endblock %}
diff --git a/templates/page.html b/templates/page.html
@@ -0,0 +1,17 @@
+{#- 산문 한 장. About 이 이걸 쓴다. #}
+{% extends "base.html" %}
+
+{% block content %}
+<div class="wrap wrap-read">
+ <header class="page-head" data-reveal>
+ <h1 class="page-title">{{ page.title }}</h1>
+ {%- if page.description %}
+ <p class="page-lede">{{ page.description }}</p>
+ {%- endif %}
+ </header>
+
+ <div class="prose">
+ {{ page.content | safe }}
+ </div>
+</div>
+{% endblock %}
diff --git a/templates/partials/footer.html b/templates/partials/footer.html
@@ -0,0 +1,27 @@
+{#- 푸터. 위 내비와 같은 자료(site.sections)를 쓰므로 갈래가 늘어도 두 곳을
+ 고칠 일이 없다. 콜로폰은 이 사이트가 무엇으로 만들어졌는지 한 줄로 말한다 —
+ 자기 도구로 자기 사이트를 짓는 것 자체가 그 도구에 대한 주장이다. #}
+{%- set ko = page.language == "ko" %}
+{%- set home = site.base_path ~ ("/ko/" if ko else "/") %}
+<footer class="colophon">
+ <div class="colophon-in">
+ <nav class="colophon-nav" aria-label="{{ t("menu") }}">
+ {%- for s in site.sections %}
+ <a href="{{ s.url }}">{{ s.title }}</a>
+ {%- endfor %}
+ {%- if site.feed %}
+ <a href="{{ site.feed }}">{{ t("feed") }}</a>
+ {%- endif %}
+ <a href="{{ site.base_path }}/sitemap.xml">{{ t("sitemap") }}</a>
+ <a href="https://github.com/sqzer-x">GitHub</a>
+ </nav>
+
+ <p class="colophon-note">
+ {#- 링크는 이 사이트를 만든 도구의 랜딩으로 간다. 레포가 아니라 랜딩인 이유는
+ 여기서 궁금해지는 것이 "그게 뭔데"이지 "코드 보여 줘"가 아니기 때문이다. #}
+ <a href="https://sqzass.sqzer.com">sqzass</a>
+ <span class="colophon-sep" aria-hidden="true">·</span>
+ <a href="{{ home }}">{{ site.title }}</a>
+ </p>
+ </div>
+</footer>
diff --git a/templates/partials/icons.html b/templates/partials/icons.html
@@ -0,0 +1,14 @@
+{#- 아이콘은 인라인 SVG 다. 아이콘 폰트나 글리프 문자는 폰트 폴백 스택에 없으면
+ 네모로 나오는데, SVG 는 폰트를 타지 않는다. currentColor 를 쓰므로 테마와
+ 호버 상태를 그대로 따라간다.
+
+ 전부 aria-hidden 이다. 아이콘만 있는 컨트롤에는 부르는 쪽이 aria-label 을 준다 —
+ 이름 없는 버튼은 스크린리더에서 "버튼"이라고만 읽힌다. #}
+
+{% macro i_site() %}<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"/></svg>{% endmacro %}
+
+{% macro i_repo() %}<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false"><path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>{% endmacro %}
+
+{% macro i_out() %}<svg class="i-out" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17 17 7M9 7h8v8"/></svg>{% endmacro %}
+
+{% macro i_arrow() %}<svg class="i-arrow" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h13M13 6l6 6-6 6"/></svg>{% endmacro %}
diff --git a/templates/partials/ledger.html b/templates/partials/ledger.html
@@ -0,0 +1,36 @@
+{#- 원장. 왼쪽 고정폭 거터에 날짜, 오른쪽에 제목과 한 줄 설명.
+ hahwul 은 같은 목록을 세 곳에서 세 번 만들었다. 여기서는 매크로 하나로 두고
+ 홈의 "최근 글"과 Posts 인덱스가 같은 것을 쓴다.
+
+ `limit` 이 0 이면 전부. `skip` 은 앞에서 몇 개를 건너뛸지 — 홈에서 맨 위
+ 글을 큰 카드로 따로 내고 나머지를 이어 붙일 때 쓴다. minijinja 에 slice 가
+ 있지만 0 을 "전부"로 해석해 주지는 않으므로 루프 안에서 센다. 조건이 한 곳에
+ 모여 있는 편이 나중에 읽기 쉽다. #}
+{% macro ledger(children, limit, show_year, skip) %}
+<ol class="ledger">
+ {%- for c in children %}
+ {%- if loop.index > skip and (limit == 0 or loop.index <= skip + limit) %}
+ {#- 연도 구분행. `changed` 는 지난 값을 기억하므로 날짜 없는 항목이 섞여도
+ 기억이 흐트러지지 않는다 — 날짜가 없으면 아예 부르지 않기 때문이다. #}
+ {%- if show_year and c.date and loop.changed(c.date.year) %}
+ <li class="ledger-year"><span aria-hidden="true">{{ c.date.year }}</span></li>
+ {%- endif %}
+ <li class="ledger-row">
+ <a class="ledger-link" href="{{ c.url }}">
+ {%- if c.date %}
+ <time class="ledger-date" datetime="{{ c.date.date }}">{% if c.date.month < 10 %}0{% endif %}{{ c.date.month }}.{% if c.date.day < 10 %}0{% endif %}{{ c.date.day }}</time>
+ {%- else %}
+ <span class="ledger-date" aria-hidden="true">—</span>
+ {%- endif %}
+ <span class="ledger-body">
+ <span class="ledger-title">{{ c.title }}</span>
+ {%- if c.description %}
+ <span class="ledger-desc">{{ c.description }}</span>
+ {%- endif %}
+ </span>
+ </a>
+ </li>
+ {%- endif %}
+ {%- endfor %}
+</ol>
+{% endmacro %}
diff --git a/templates/partials/search.html b/templates/partials/search.html
@@ -0,0 +1,19 @@
+{#- 검색 팔레트. <dialog> 를 쓰므로 Escape, 백드롭 클릭, 포커스 트랩, 닫을 때
+ 트리거로 포커스 되돌리기가 전부 플랫폼 동작이고 우리가 쓸 코드가 아니다.
+
+ 색인 URL은 템플릿이 조립하지 않는다 — 파일 이름은 엔진이 아는 것이라,
+ 손으로 만들면 검색을 끈 사이트에서 죽은 URL 이 된다. site.search 가 준다. #}
+<dialog id="search-dialog" class="palette" aria-label="{{ t("search_the_site") }}">
+ <form method="dialog" class="palette-form">
+ <div class="palette-head">
+ <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
+ <input type="search" class="palette-input" autocomplete="off" spellcheck="false"
+ placeholder="{{ t("search_hint") }}" aria-label="{{ t("search_the_site") }}">
+ <button class="palette-close" type="submit" aria-label="{{ t("close") }}">
+ <kbd>esc</kbd>
+ </button>
+ </div>
+ <ul class="palette-results" aria-label="{{ t("search_results") }}"></ul>
+ <p class="palette-status" role="status"></p>
+ </form>
+</dialog>
diff --git a/templates/partials/toc.html b/templates/partials/toc.html
@@ -0,0 +1,12 @@
+{#- 목차. page.toc_entries 는 항상 채워지고, 보일지 말지는 front matter 의 toc 가
+ 정한다. 데이터와 의사를 갈라 두면 긴 글에서만 목차를 내는 판단을 템플릿이 할 수 있다. #}
+{% macro toc_list(entries) %}
+<ul>
+ {%- for e in entries %}
+ <li>
+ <a href="#{{ e.id }}">{{ e.title }}</a>
+ {%- if e.children %}{{ toc_list(e.children) }}{% endif %}
+ </li>
+ {%- endfor %}
+</ul>
+{% endmacro %}
diff --git a/templates/post.html b/templates/post.html
@@ -0,0 +1,55 @@
+{#- 글 한 편. 날짜는 mono 로 찍는다 — 월 약어를 쓰려면 i18n 키가 언어당 12개
+ 필요한데, 숫자는 두 언어에서 같게 읽히고 tabular-nums 로 자리가 맞는다. #}
+{% extends "base.html" %}
+{% from "partials/toc.html" import toc_list %}
+
+{% block content %}
+<article class="wrap wrap-read">
+ <header class="page-head" data-reveal>
+ {%- if page.section %}
+ <nav class="crumbs" aria-label="{{ t("breadcrumb") }}">
+ <a href="{{ page.section.url }}">{{ page.section.title }}</a>
+ </nav>
+ {%- endif %}
+ <h1 class="page-title">{{ page.title }}</h1>
+ {%- if page.description %}
+ <p class="page-lede">{{ page.description }}</p>
+ {%- endif %}
+ {%- if page.date %}
+ <p class="page-meta">
+ <time datetime="{{ page.date.date }}">{{ page.date.year }}.{% if page.date.month < 10 %}0{% endif %}{{ page.date.month }}.{% if page.date.day < 10 %}0{% endif %}{{ page.date.day }}</time>
+ </p>
+ {%- endif %}
+ </header>
+
+ {%- if page.toc and page.toc_entries %}
+ <nav class="toc" aria-label="{{ t("on_this_page") }}">
+ <p class="toc-label">{{ t("on_this_page") }}</p>
+ {{ toc_list(page.toc_entries) }}
+ </nav>
+ {%- endif %}
+
+ <div class="prose">
+ {{ page.content | safe }}
+ </div>
+
+ {%- if page.prev or page.next %}
+ <nav class="pager" aria-label="{{ t("menu") }}">
+ {#- 이전/다음의 라벨은 방향이 아니라 목적지를 말한다. "다음"만 적힌 링크는
+ 누르기 전에는 어디로 가는지 알 수 없다. #}
+ {%- if page.prev %}
+ <a class="pager-link pager-prev" href="{{ page.prev.url }}">
+ <span class="pager-dir">{{ t("previous") }}</span>
+ <span class="pager-title">{{ page.prev.title }}</span>
+ </a>
+ {%- endif %}
+ {%- if page.next %}
+ <a class="pager-link pager-next" href="{{ page.next.url }}">
+ <span class="pager-dir">{{ t("next") }}</span>
+ <span class="pager-title">{{ page.next.title }}</span>
+ </a>
+ {%- endif %}
+ </nav>
+ {%- endif %}
+</article>
+{% endblock %}
diff --git a/templates/posts.html b/templates/posts.html
@@ -0,0 +1,21 @@
+{#- 글 목록. 섹션이 `sort_by = "date"` 라 최신순이고, 이제 목록 항목이 자기
+ 날짜를 실어 오므로 그 순서의 근거가 화면에 보인다. #}
+{% extends "base.html" %}
+{% from "partials/ledger.html" import ledger %}
+
+{% block content %}
+<div class="wrap wrap-ledger">
+ <header class="page-head" data-reveal>
+ <h1 class="page-title">{{ page.title }}</h1>
+ {%- if page.description %}
+ <p class="page-lede">{{ page.description }}</p>
+ {%- endif %}
+ </header>
+
+ {%- if page.content %}
+ <div class="prose prose-intro">{{ page.content | safe }}</div>
+ {%- endif %}
+
+ {{ ledger(page.children, 0, true, 0) }}
+</div>
+{% endblock %}
diff --git a/templates/project.html b/templates/project.html
@@ -0,0 +1,136 @@
+{#- 프로젝트 한 장. 두 등급뿐이다: 랜딩이거나 브리프거나. 중간 등급을 만들면
+ 어느 프로젝트가 어디에 속하는지 매번 새로 판단해야 한다.
+
+ 등급은 `tier` 가 정하지만 블록의 유무는 데이터가 정한다 — features 가 []
+ 면 그 섹션이 없다. "끈다"는 플래그를 따로 두지 않는 이유는, 빈 배열이 이미
+ 그 뜻이고 플래그와 데이터가 어긋날 자리를 만들지 않기 위해서다.
+
+ 별 개수는 어디에도 없다. 대신 읽는 사람이 확인할 수 있는 사실을 적는다 —
+ 버전, 라이선스, 플랫폼, 의존성 수. 0 stars 를 보여 주는 것보다 낫고,
+ Rust 도구를 보러 온 사람이 실제로 궁금해하는 것이기도 하다. #}
+{% extends "base.html" %}
+{% from "partials/icons.html" import i_site, i_repo, i_out, i_arrow %}
+
+{% block content %}
+{%- set landing = page.extra.tier == "landing" %}
+<div class="{{ "wrap" if landing else "wrap wrap-read" }} project" data-tier="{{ page.extra.tier }}">
+
+ {%- if page.section %}
+ <a class="back" href="{{ page.section.url }}">
+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H6M11 6l-6 6 6 6"/></svg>
+ <span>{{ page.section.title }}</span>
+ </a>
+ {%- endif %}
+
+ <header class="hero" data-reveal>
+ <div class="hero-main">
+ {%- if page.extra.eyebrow %}
+ <p class="eyebrow">{{ page.extra.eyebrow }}</p>
+ {%- endif %}
+ <h1 class="hero-name">{{ page.title }}</h1>
+ {%- if page.extra.tagline %}
+ <p class="hero-tagline">{{ page.extra.tagline }}</p>
+ {%- endif %}
+
+ <ul class="badges">
+ {%- if page.extra.lang %}<li class="tag tag-lang">{{ page.extra.lang }}</li>{% endif %}
+ {%- if page.extra.license %}<li class="tag">{{ page.extra.license }}</li>{% endif %}
+ {%- if page.extra.version %}<li class="tag">{{ page.extra.version }}</li>{% endif %}
+ {%- if page.extra.platform %}<li class="tag">{{ page.extra.platform }}</li>{% endif %}
+ </ul>
+
+ {%- if page.extra.actions %}
+ <div class="btn-row">
+ {%- for a in page.extra.actions %}
+ <a class="btn btn-{{ a.style }}" href="{{ a.url }}"{% if a.url is startingwith "http" %} target="_blank" rel="noopener noreferrer"{% endif %}>
+ <span>{{ a.label }}</span>{{ i_out() }}
+ </a>
+ {%- endfor %}
+ </div>
+ {%- endif %}
+ </div>
+
+ {#- 옆칸. 로고가 있으면 메달리온, 없으면 이름을 크게 깐다. 규격이 같아서
+ 나중에 SVG 를 넣어도 레이아웃이 움직이지 않는다. #}
+ <div class="hero-aside" aria-hidden="true">
+ {%- if page.extra.logo %}
+ <span class="medallion"><img src="{{ asset(page.extra.logo) }}" alt="" loading="eager" decoding="async"></span>
+ {%- else %}
+ <span class="medallion medallion-glyph">{{ page.title }}</span>
+ {%- endif %}
+ </div>
+ </header>
+
+ {%- if page.extra.facts %}
+ <section class="facts" data-reveal>
+ {%- for f in page.extra.facts %}
+ <div class="fact">
+ <span class="fact-value">{{ f.value }}</span>
+ <span class="fact-label">{{ f.label }}</span>
+ </div>
+ {%- endfor %}
+ </section>
+ {%- endif %}
+
+ {%- if page.content %}
+ <div class="prose">{{ page.content | safe }}</div>
+ {%- endif %}
+
+ {%- if page.extra.features %}
+ <section class="block" data-reveal>
+ <header class="block-head">
+ {%- if page.extra.features_eyebrow %}<p class="eyebrow">{{ page.extra.features_eyebrow }}</p>{% endif %}
+ {%- if page.extra.features_title %}<h2 class="block-title">{{ page.extra.features_title }}</h2>{% endif %}
+ {%- if page.extra.features_intro %}<p class="block-intro">{{ page.extra.features_intro }}</p>{% endif %}
+ </header>
+ {#- 격자의 선은 음수 마진으로 겹친 1px 이다. 개수가 몇이든 열이 몇이든
+ 맞아 들어가고 :nth-child 규칙이 필요 없다. #}
+ <div class="lattice">
+ {%- for f in page.extra.features %}
+ <article class="cell">
+ <h3 class="cell-title">{{ f.title }}</h3>
+ <p class="cell-desc">{{ f.desc }}</p>
+ </article>
+ {%- endfor %}
+ </div>
+ </section>
+ {%- endif %}
+
+ {%- if page.extra.session_lines %}
+ <section class="block" data-reveal>
+ <header class="block-head">
+ {%- if page.extra.session_eyebrow %}<p class="eyebrow">{{ page.extra.session_eyebrow }}</p>{% endif %}
+ {%- if page.extra.session_title %}<h2 class="block-title">{{ page.extra.session_title }}</h2>{% endif %}
+ </header>
+ <div class="term">
+ <div class="term-bar">
+ <span class="term-dots" aria-hidden="true"><i></i><i></i><i></i></span>
+ <span class="term-name">{{ page.extra.session_shell }}</span>
+ </div>
+ <pre class="term-body"><code>{%- for l in page.extra.session_lines %}
+<span class="term-{{ l.kind }}">{{ l.text }}</span>
+{%- endfor %}</code></pre>
+ </div>
+ {%- if page.extra.session_caption %}
+ <p class="term-caption">{{ page.extra.session_caption }}</p>
+ {%- endif %}
+ </section>
+ {%- endif %}
+
+ {%- if page.extra.cta_title %}
+ <section class="cta" data-reveal>
+ <h2 class="cta-title">{{ page.extra.cta_title }}</h2>
+ {%- if page.extra.cta_text %}<p class="cta-text">{{ page.extra.cta_text }}</p>{% endif %}
+ {%- if page.extra.cta_actions %}
+ <div class="btn-row">
+ {%- for a in page.extra.cta_actions %}
+ <a class="btn btn-{{ a.style }}" href="{{ a.url }}"{% if a.url is startingwith "http" %} target="_blank" rel="noopener noreferrer"{% endif %}>
+ <span>{{ a.label }}</span>{{ i_arrow() }}
+ </a>
+ {%- endfor %}
+ </div>
+ {%- endif %}
+ </section>
+ {%- endif %}
+</div>
+{% endblock %}
diff --git a/templates/projects.html b/templates/projects.html
@@ -0,0 +1,103 @@
+{#- Projects 인덱스. 이 사이트에서 가장 많은 것을 말하는 페이지다.
+
+ 카탈로그는 이 페이지의 [extra] 에 산다. 목록(PageRefCtx)은 title·description·
+ url·weight·date 만 실어 오므로 언어 배지나 링크는 거기서 나올 수 없다.
+ 대신 모든 url 이 실제 페이지를 가리켜야 하고, 그 대응은 CI 가 검사한다 —
+ [extra] 안의 URL 은 빌드가 확인해 주지 않는 유일한 참조다.
+
+ 스키마는 총체적이다. 없는 값은 "" 나 [] 이고 키를 빼지 않는다. 정의되지 않은
+ 접근이 빌드 에러라, 키가 빠지면 그 프로젝트 한 줄이 아니라 사이트가 안 나온다. #}
+{% extends "base.html" %}
+{% from "partials/icons.html" import i_site, i_repo, i_out %}
+
+{% block content %}
+<div class="wrap">
+ <header class="page-head" data-reveal>
+ <h1 class="page-title">{{ page.title }}</h1>
+ {%- if page.description %}
+ <p class="page-lede">{{ page.description }}</p>
+ {%- endif %}
+ </header>
+
+ {%- if page.content %}
+ <div class="prose prose-intro">{{ page.content | safe }}</div>
+ {%- endif %}
+
+ {#- 골라 둔 것. 큰 카드는 넉 장까지가 한 줄에 맞는다. flex 로 두면 셋이든
+ 넷이든 마지막 줄에 하나만 남는 일이 없다. #}
+ {%- if page.extra.featured %}
+ <section class="bento" aria-label="{{ t("selected") }}">
+ {%- for p in page.extra.featured %}
+ <article class="bento-card" data-reveal>
+ <div class="bento-media">
+ {%- if p.cover %}
+ <img src="{{ asset(p.cover) }}" alt="" loading="lazy" decoding="async">
+ {%- else %}
+ {#- 커버가 없을 때 회색 상자를 놓지 않는다. 이름 자체가 그림이 된다 —
+ mono 로 크게 깔고 뒤로 물린다. 나중에 이미지를 넣으면 이게 물러난다. #}
+ <span class="bento-glyph" aria-hidden="true">{{ p.name }}</span>
+ {%- endif %}
+ </div>
+ <div class="bento-body">
+ <h2 class="bento-name">
+ <a href="{{ p.url }}">{{ p.name }}</a>
+ </h2>
+ <p class="bento-blurb">{{ p.blurb }}</p>
+ <div class="bento-meta">
+ <span class="tag tag-lang">{{ p.lang }}</span>
+ <span class="bento-links">
+ {%- if p.site %}
+ <a href="{{ p.site }}" target="_blank" rel="noopener noreferrer" aria-label="{{ t("website") }}" title="{{ t("website") }}">{{ i_site() }}</a>
+ {%- endif %}
+ {%- if p.repo %}
+ <a href="{{ p.repo }}" target="_blank" rel="noopener noreferrer" aria-label="{{ t("source") }}" title="{{ t("source") }}">{{ i_repo() }}</a>
+ {%- endif %}
+ </span>
+ </div>
+ </div>
+ </article>
+ {%- endfor %}
+ </section>
+ {%- endif %}
+
+ {#- 갈래별 목록. 순서는 TOML 에 적힌 순서 그대로다 — taxonomy 가 없는 대신
+ 분류가 곧 저자의 편집 결정이 된다. #}
+ {%- for g in page.extra.group %}
+ <section class="group" id="{{ g.id }}" data-reveal>
+ <div class="plate">
+ <h2 class="plate-label">{{ g.label }}</h2>
+ <span class="plate-rule" aria-hidden="true"></span>
+ <span class="plate-count">{{ g.item | length }}</span>
+ </div>
+ {%- if g.note %}
+ <p class="group-note">{{ g.note }}</p>
+ {%- endif %}
+
+ <ul class="rows">
+ {%- for p in g.item %}
+ <li class="row is-{{ p.status }}">
+ <a class="row-main" href="{{ p.url }}">
+ <span class="row-name">{{ p.name }}</span>
+ <span class="row-blurb">{{ p.blurb }}</span>
+ </a>
+ <span class="row-meta">
+ <span class="tag tag-lang">{{ p.lang }}</span>
+ {#- 상태는 점 하나다. 색이 뜻을 지므로 라벨은 스크린리더에게만 준다. #}
+ <span class="dot" aria-hidden="true"></span>
+ <span class="sr-only">{{ t("archived") if p.status == "archived" else t("active") }}</span>
+ </span>
+ <span class="row-links">
+ {%- if p.site %}
+ <a href="{{ p.site }}" target="_blank" rel="noopener noreferrer" aria-label="{{ p.name }} — {{ t("website") }}" title="{{ t("website") }}">{{ i_site() }}</a>
+ {%- endif %}
+ {%- if p.repo %}
+ <a href="{{ p.repo }}" target="_blank" rel="noopener noreferrer" aria-label="{{ p.name }} — {{ t("source") }}" title="{{ t("source") }}">{{ i_repo() }}</a>
+ {%- endif %}
+ </span>
+ </li>
+ {%- endfor %}
+ </ul>
+ </section>
+ {%- endfor %}
+</div>
+{% endblock %}
diff --git a/tools/check.py b/tools/check.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""빌드가 잡아 주지 않는 두 가지를 검사한다.
+
+sqzass 는 마크다운의 `@/` 링크와 템플릿 변수와 에셋 이름을 전부 확인하고 틀리면
+빌드를 세운다. 확인하지 않는 것은 `[extra]` 안이다 — 거기는 생성기가 읽지 않는
+자유 테이블이라, 프로젝트 카탈로그의 url 오타는 조용히 배포된다.
+
+i18n 도 마찬가지로 반쪽이다. 템플릿이 실제로 부르는 키가 없으면 빌드가 서지만,
+어느 템플릿도 아직 안 부르는 키가 한쪽 언어에만 있는 것은 그냥 지나간다. 그
+상태는 그 키를 처음 쓰는 날 빌드를 세운다.
+
+ python3 tools/check.py
+"""
+
+import sys
+import tomllib
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+LANGS = {"en": "", "ko": "/ko"} # 코드 → URL 접두사
+fails = []
+
+
+def front_matter(path: Path) -> dict:
+ """`+++` 로 감싼 TOML 머리말만 떼어 읽는다."""
+ text = path.read_text(encoding="utf-8")
+ if not text.startswith("+++"):
+ return {}
+ end = text.find("+++", 3)
+ if end == -1:
+ fails.append(f"{path.relative_to(ROOT)}: 닫히지 않은 +++ 머리말")
+ return {}
+ return tomllib.loads(text[3:end])
+
+
+def catalog_urls(fm: dict) -> set:
+ """카탈로그가 주장하는 프로젝트 URL 전부."""
+ extra = fm.get("extra", {})
+ urls = {item["url"] for item in extra.get("featured", [])}
+ for group in extra.get("group", []):
+ urls |= {item["url"] for item in group.get("item", [])}
+ return urls
+
+
+def check_projects():
+ """카탈로그와 실제 페이지가 양방향으로 일치하는지."""
+ projects = ROOT / "content" / "projects"
+ for lang, prefix in LANGS.items():
+ suffix = ".ko.md" if lang == "ko" else ".md"
+ index = projects / ("_index.ko.md" if lang == "ko" else "_index.md")
+ if not index.exists():
+ fails.append(f"{index.relative_to(ROOT)} 가 없습니다")
+ continue
+
+ claimed = catalog_urls(front_matter(index))
+ actual = {
+ f"{prefix}/projects/{p.name[: -len(suffix)]}/"
+ for p in projects.glob(f"*{suffix}")
+ if not p.name.startswith("_index")
+ # en 패스에서 `foo.ko.md` 가 `foo.ko` 로 잡히지 않게 거른다.
+ and (lang == "ko" or not p.name.endswith(".ko.md"))
+ }
+
+ for url in sorted(claimed - actual):
+ fails.append(f"[{lang}] 카탈로그가 가리키는 페이지가 없습니다: {url}")
+ for url in sorted(actual - claimed):
+ fails.append(f"[{lang}] 페이지가 카탈로그에 없습니다: {url}")
+
+
+def check_i18n():
+ """두 언어의 키 집합이 같은지. 다르면 그 키를 처음 쓰는 날 빌드가 선다."""
+ keys = {}
+ for lang in LANGS:
+ path = ROOT / "i18n" / f"{lang}.toml"
+ if not path.exists():
+ fails.append(f"{path.relative_to(ROOT)} 가 없습니다")
+ return
+ keys[lang] = set(tomllib.loads(path.read_text(encoding="utf-8")))
+
+ a, b = LANGS
+ for key in sorted(keys[a] - keys[b]):
+ fails.append(f"i18n: '{key}' 가 {a} 에만 있습니다")
+ for key in sorted(keys[b] - keys[a]):
+ fails.append(f"i18n: '{key}' 가 {b} 에만 있습니다")
+
+
+check_projects()
+check_i18n()
+
+if fails:
+ print("\n".join(fails), file=sys.stderr)
+ sys.exit(1)
+print("ok")
|