[{"t":"sqzass","d":"Rust로 만든 정적 사이트 생성기 — 빠르고 결정적입니다","u":"/ko/","c":"대부분의 생성기는 예쁜 URL, 리다이렉트, 캐시 헤더 같은 일을 호스트에 맡깁니다. sqzass는 이걸 직접 처리합니다. 그래서 GitHub Pages에 올리든, Cloudflare Pages에 올리든, 그냥 디렉터리째 서빙하든 같은 결과물이 그대로 동작합니다. 속도는 형용사가 아니라 측정값으로 말합니다. 문제가 생기면 조용히 넘어가는 대신 빌드를 멈추고, 어느 파일 몇째 줄이 왜 문제인지 짚어 줍니다."},{"t":"벤치마크","d":"생성기 다섯, 머신 하나, 코퍼스 하나 — 그리고 정의는 전부 공개","u":"/ko/benchmark/","c":"minimal — 제목 하나와 문단 하나 blog — 문단 여섯, 목록, 인용, 링크 heavy·반복 — 페이지마다 동일한 20줄 Rust 블록 5개 heavy·고유 — 페이지마다 전부 다른 20줄 Rust 블록 5개 전체 수치 벽시계 시간(콜드 3회 중앙값)과 peak RSS입니다. minimal blog heavy·반복 heavy·고유 sqzass 0.1.0 18 ms · 15 MB 25 ms · 24 MB 187 ms · 229 MB 1,298 ms · 439 MB Hugo 0.164.0 93 ms · 123 MB 112 ms · 140 MB 3,559 ms · 411 MB 5,912 ms · 421 MB Zola 0.22.1 141 ms · 136 MB 172 ms · 142 MB 5,084 ms · 269 MB 6,789 ms · 279 MB Jekyll 4.4.1 442 ms · 56 MB 752 ms · 62 MB 10,850 ms · 174 MB 11,456 ms · 182 MB Astro 5.18.2 4,672 ms · 555 MB 6,699 ms · 604 MB 22,090 ms · 2,578 MB 23,958 ms · 2,607 MB heavy가 둘인 이유 코드 반복도가 순위를 가르는데, 대부분의 벤치마크는 자기 코퍼스의 반복도를 말하지 않기 때문입니다. sqzass는 서로 다른 블록만 빌드에서 한 번씩 구문 강조하므로, 코퍼스 생성기가 같은 블록을 반복해 넣은 코퍼스와 블록이 전부 다른 코퍼스는 서로 다른 측정입니다 — 187 ms와 1,298 ms의 간격이 바로 그 변수입니다. 반복도를 밝히지 않은 단일 \"heavy\" 수치는 결과를 만든 변수를 숨긴 것입니다. 방법 콜드. 매 회 출력 디렉터리와 도구별 캐시(.jekyll-cache, Astro의 cacheDir와 dist, Hugo의 resources와 빌드 락)를 지웠습니다. 벽시계 시간과 peak RSS는 GNU time으로 쟀습니다. 코퍼스. 1,000페이지 + 섹션 인덱스. 마크다운 본문은 도구 간에 바이트 단위로 같고 front matter 문법만 다릅니다. 문단은 76자 문장 하나를 네 번 반복한 고정 텍스트입니다. 구문 강조는 다섯 도구 모두 빌드 시점에, 각자 싣는 그대로 켰습니다: syntect+Oniguruma(sqzass), Chroma(Hugo), Giallo(Zola), Rouge(Jekyll), Shiki(Astro). 마크업 세밀도는 도구마다 다릅니다. 같은 heavy 페이지의 span 수는 sqzass 3,205 · Jekyll 1,800 · Zola·Astro 1,700 · Hugo 600. sqzass는 다섯 중 가장 세밀한 마크업을 뽑아내면서도 위의 시간을 기록합니다. 템플릿. 다섯 도구 모두 테마 없는 동일한 최소 레이아웃을 씁니다. 렌더된 본문을 감싸는 HTML 셸 하나입니다. Astro의 시간에는 Node 기동(~1초)이 포함됩니다. Astro는 마크다운→HTML 이상의 일을 하는 컴포넌트 프레임워크이기도 합니다. 그 점은 숨기지 않고 밝혀 둡니다. 다만 코퍼스는 순수 마크다운이고, 이 페이지가 재는 것도 그 워크로드입니다."},{"t":"콘텐츠 작성","d":"페이지와 섹션, front matter, 그리고 거기서 나오는 URL","u":"/ko/content/","c":"content/ 아래의 모든 파일이 페이지입니다. _index.md가 들어 있는 디렉터리는 섹션이 되고, 섹션은 그 옆의 페이지들을 묶습니다. 페이지와 섹션 content/ ├── _index.md → / ├── about.md → /about/ └── guide/ ├── _index.md → /guide/ ├── install.md → /guide/install/ └── deep/ ├── _index.md → /guide/deep/ └── dive.md → /guide/deep/dive/ _index.md가 없는 디렉터리도 섹션이 됩니다. 제목은 디렉터리 이름이 되고, 그 안의 페이지들이 묶이고, 내비게이션에도 나타납니다 — 파일 하나 깜빡했다고 페이지가 사이드바에서 사라지면 안 되니까요. 다만 인덱스 페이지는 생기지 않아서 /guide/ 자체는 하나 만들기 전까지 404입니다. 섹션에 고유한 제목이나 설명, 본문을 주고 싶을 때 _index.md를 둡니다. 모든 URL이 디렉터리인 이유 페이지는 <경로>.html이 아니라 <경로>/index.html로 쓰입니다. rewrite 규칙이 있는 호스트라면 about.html을 /about으로 내줄 수 있지만, 규칙이 없는 호스트는 못 합니다. sqzass는 아무것도 해 주지 않는 호스트에서 옳게 도는 것을 기준으로 만들었습니다. 디렉터리 형태는 서버에게 아무 재주도 요구하지 않기 때문에 GitHub Pages든 Cloudflare Pages든 S3든 python3 -m http.server든 똑같이 동작합니다. 대가는 있습니다. 슬래시 없는 /about 링크는 대부분의 서버가 리다이렉트를 한 번 거친 뒤에야 도착합니다. /about/으로 쓰거나, 더 나은 방법으로는 @/ 링크를 써서 URL을 sqzass가 쓰게 하세요. 슬러그는 제목이 아니라 파일명에서 나옵니다 install.md는 /guide/install/이 됩니다. 제목은 여기에 관여하지 않습니다. 이 규칙이 가장 중요해지는 곳이 한국어입니다. 흔한 방법은 제목을 로마자로 옮기는 것인데, slug 계열 크레이트는 한글을 문맥 없이 한 음절씩 ASCII로 매핑하기 때문에 서로 다른 한국어 제목이 같은 경로로 겹칠 수 있습니다. 직접 정한 파일명은 모호하지 않고, 같은 디렉터리 안에서 이미 유일합니다. 한국어 파일명은 퍼센트 인코딩된 UTF-8 그대로 둡니다. 페이지마다 바꾸고 싶으면 front matter의 slug로 덮어쓰면 됩니다. 두 페이지가 같은 URL을 주장하면 빌드 에러입니다. 나중에 쓴 쪽이 이긴다는 식으로 처리하지 않습니다. 정렬 섹션은 자식 페이지를 weight 오름차순으로 정렬하고, weight가 없으면 제목으로 넘어갑니다. 섹션의 _index.md에 sort_by를 주거나, sqzass.toml의 [nav] sort_by로 사이트 전체 기본값을 바꿀 수 있습니다. weight 오름차순. 기본값. title 오름차순. date 내림차순 — 최신이 먼저, 날짜 없는 페이지는 뒤. 피드 참고. 드래프트 draft = true인 페이지는 빌드에서 빠집니다. 명령줄의 --drafts나 설정의 [build] drafts = true로 다시 넣을 수 있습니다."},{"t":"Front matter","d":"페이지가 가질 수 있는 모든 필드와 그 역할","u":"/ko/content/front-matter/","s":"콘텐츠 작성","c":"front matter는 파일 맨 위 +++ 펜스 사이에 쓰는 TOML입니다. 형식은 TOML 하나뿐인데, YAML을 쓰려면 파서를 골라야 하고 러스트의 대표 크레이트가 이미 아카이브된 상태라 선택지가 \"관리되지 않는 의존성\"과 \"의존성이 필요 없는 형식\" 둘이었기 때문입니다. +++ title = \"설치\" +++ 반드시 있어야 하는 필드는 title 하나입니다. 필드 필드 타입 기본값 title 문자열 — 필수. description 문자열 \"\" 템플릿의 <meta name=\"description\">과 검색 결과에 쓰입니다. weight 정수 0 섹션 안에서의 정렬 순서. 작을수록 앞. draft bool false --drafts가 없으면 빌드에서 빠집니다. date TOML 날짜 — 발행 날짜. 피드와 sort_by = \"date\"가 씁니다. slug 문자열 파일명 stem URL의 마지막 세그먼트. template 문자열 — 평소 쓰던 것 대신 이 템플릿으로 렌더합니다. toc bool false 목차를 보여줄지 여부. translation_key 문자열 언어 접미사를 뗀 경로 이 페이지와 번역본을 연결합니다. 언어를 참고하세요. aliases 문자열 배열 [] 이 페이지로 와야 하는 옛 URL들. 각 항목은 루트 절대 경로여야 하고, 항목마다 리다이렉트 스텁이 하나씩 나옵니다. extra 테이블 {} 무엇이든. 템플릿에 page.extra로 갑니다. toc는 데이터가 아니라 저자의 의사입니다. 목차 자체는 모든 페이지에서 수집해 page.toc_entries로 템플릿에 넘어갑니다. 이렇게 나눠 두면 긴 글에만 목차를 띄우는 템플릿을 만들 때 데이터를 따로 걷어낼 필요가 없습니다. 섹션 전용 필드 일반 페이지에서는 아무 일도 하지 않습니다. _index.md에 씁니다. 필드 타입 기본값 sort_by \"weight\" | \"title\" | \"date\" 사이트 기본값 이 섹션이 자식 페이지를 정렬하는 기준. page_template 문자열 — 이 섹션 페이지들의 기본 템플릿. extra [extra]는 열린 테이블입니다. sqzass는 여기를 읽지 않고, 템플릿이 읽습니다. +++ title = \"릴리스 노트\" [extra] version = \"0.2.0\" badge = \"beta\" +++ {% if page.extra.badge %}<span class=\"badge\">{{ page.extra.badge }}</span>{% endif %} 없는 키를 읽으면 빈 문자열이 아니라 에러입니다. 얼마나 엄격한지와 그 이유는 템플릿 데이터에 적어 두었습니다. 에러는 소스 파일을 가리킵니다 값이 잘못되면 소스 파일의 줄 번호로 보고합니다. front matter가 끝난 지점부터가 아니라 파일 맨 위부터 센 줄 번호입니다. 후자는 엉뚱한 줄을 가리키니까요."},{"t":"언어","d":"하나의 콘텐츠 트리로 두 언어를, 미번역 페이지는 감춘 채","u":"/ko/content/languages/","s":"콘텐츠 작성","c":"언어는 sqzass.toml에 선언합니다. 기본 언어가 루트를 쓰고, 나머지는 접두사를 갖습니다. default_language = \"en\" [languages.en] name = \"English\" weight = 1 [languages.ko] name = \"한국어\" weight = 2 /start/가 영어, /ko/start/가 한국어입니다. 파일명 접미사 content/start/ ├── installation.md → /start/installation/ └── installation.ko.md → /ko/start/installation/ 두 파일이 나란히 놓이므로 ls 한 번이면 무엇이 아직 번역되지 않았는지 보입니다. content.ko/ 같은 병렬 트리를 두면 그게 diff 뒤로 숨습니다. 미번역 페이지는 복제하지 않고 감춥니다 한국어판이 없는 페이지는 한국어 내비게이션에 아예 나타나지 않습니다. 나머지 두 선택지는 둘 다 더 나쁩니다. 한국어 URL에 영어 본문을 렌더하면 검색 엔진 입장에서 중복 콘텐츠가 되고, 404를 내면 사이트가 스스로 그린 링크로 독자를 막다른 길에 보내는 셈입니다. 템플릿에는 page.translations가 넘어가는데, 여기엔 이 페이지가 실제로 존재하는 언어만 담깁니다. 그래서 언어 전환 UI는 동작하는 선택지만 그릴 수 있습니다. {% for t in page.translations %} <a href=\"{{ t.url }}\" hreflang=\"{{ t.code }}\">{{ t.name }}</a> {% endfor %} 목록이 비면 전환 버튼도 없습니다. 버튼이 거짓말을 하는 상태가 생기지 않습니다. 번역을 짝짓는 방법 content/ 기준 경로에서 언어 접미사를 뗀 값으로 짝짓습니다. 그래서 start/installation.md와 start/installation.ko.md는 둘 다 start/installation을 키로 갖는 한 페이지의 두 언어판입니다. 이름만이 아니라 경로가 중요합니다. a/notes.md와 b/notes.md는 서로의 번역이 아니라 다른 두 페이지입니다. 그리고 선언한 언어인 접미사만 떼므로, notes.ab.md라는 파일은 ab가 [languages]에 없는 한 notes.ab를 키로 갖습니다. 파일명이 달라야 할 때는 — 슬러그를 현지화하는 경우 등 — 양쪽 파일에 같은 translation_key를 주면 됩니다. # content/start/installation.md +++ title = \"Installation\" translation_key = \"install\" +++ # content/start/설치.ko.md +++ title = \"설치\" translation_key = \"install\" +++ UI 문자열 페이지의 글은 content/에 있습니다. 템플릿이 넣는 말은 i18n/<코드>.toml에 둡니다 — \"본문으로 건너뛰기\", \"이 페이지\", \"이전\" 같은 것들입니다. # i18n/en.toml home = \"Home\" on_this_page = \"On this page\" # i18n/ko.toml home = \"홈\" on_this_page = \"이 페이지\" <a href=\"{{ site.base_path }}/\">{{ t(\"home\") }}</a> t는 지금 렌더 중인 페이지에서 언어를 읽습니다. 그래서 템플릿은 자기가 어느 언어인지 묻지 않아도 되고, 알려 주는 걸 깜빡할 수 있는 자리도 생기지 않습니다. 어느 한 언어에 키가 없으면 에러이고, 어느 언어에는 있는지도 알려 줍니다. 번역 키 'next' 가 i18n/ko.toml 에 없습니다 (en 에는 있습니다) (in page.html:22) 기본 언어로 떨어뜨리면 한국어 페이지 안에 영어 라벨이 들어갑니다. 한국어 독자에게는 전부 보이고 사이트를 관리하는 사람에게는 보이지 않는데, 미번역 페이지를 복제하지 않고 감추는 것과 정확히 같은 이유입니다. i18n/ 디렉터리가 없는 사이트도 잘 돕니다. t는 그걸 부르는 템플릿에만 필요합니다. 한국어에서 알아 둘 것 알아서 처리되지만 알고는 있어야 하는 게 둘 있습니다. 둘 다 어긋났을 때 조용하기 때문입니다. **강조**한다가 강조로 파싱됩니다. CommonMark의 flanking 규칙은 단어 사이에 공백을 두는 언어를 전제로 쓰였고, 그 규칙대로면 **굵게** 바로 뒤에 조사가 붙은 형태는 강조가 아예 아닙니다. sqzass는 comrak의 cjk_friendly_emphasis를 켜 두기 때문에 한국어로 자연스럽게 쓴 마크다운이 그대로 동작합니다. [markdown]의 키이며, 이걸 끄면 한국어 본문이 \"내 마크다운이 틀렸나\" 싶은 모양으로 깨집니다. 한국어 제목은 한글 id를 유지합니다. ## 설치는 id=\"설치\"가 되므로 로마자 변환 없이 앵커와 목차가 동작합니다."},{"t":"내부 링크","d":"소스 파일을 가리키는 링크와, 빌드가 대신 쓰는 URL","u":"/ko/content/links/","s":"콘텐츠 작성","c":"링크를 파일로 쓰면 sqzass가 그 페이지의 URL로 바꿔 줍니다. [설치](@/start/installation.md) 문서를 보세요. @/ 뒤의 경로는 content/ 기준이고, URL이 아니라 마크다운 파일을 가리킵니다. 파일을 옮기든 이름을 바꾸든 slug를 바꾸든 링크가 따라옵니다. 깨진 링크는 빌드를 멈춥니다 아무것도 가리키지 못하는 @/ 링크는 에러입니다. docs/content/start/first-site.md: 어디도 가리키지 않는 링크가 있습니다: @/start/setup.md 이게 이 문법의 존재 이유입니다. 아무 데도 안 가는 /start/setup/ 링크는 누군가 프로덕션에서 눌러 보기 전까지 멀쩡한 링크와 구분되지 않습니다. 빌드가 검사할 수 있는 참조라면 빌드가 검사합니다. 읽는 사람의 언어를 따라갑니다 @/start/installation.md는 영어 독자에게는 /start/installation/으로, 한국어 독자에게는 /ko/start/installation/으로 풀립니다. 같은 마크다운이 양쪽 언어 트리에서, 조건문 하나 없이 동작합니다. 대상 페이지에 해당 언어 번역이 없으면 링크가 깨지는 대신 기본 언어로 넘어갑니다. 언어를 참고하세요. 그 폴백이 있기 때문에 경로는 한 번만 쓰고, 언어 접두사는 절대 붙이지 않습니다. @/ko/start/installation.md 같은 건 없습니다. 트리에서 일어납니다 재작성은 comrak의 URL 재작성기로, HTML이 만들어지기 전에 AST 단계에서 일어납니다. 흔한 지름길인 \"완성된 HTML에 정규식 돌리기\"는 속성을 홑따옴표로 쓰거나 따옴표를 빼면 조용히 처리에서 빠집니다. 빌드가 아니라 프로덕션에서 발견되는 종류의 버그입니다. 이미지도 같은 검사기를 지나지만 문법은 다릅니다. @/는 마크다운 페이지 표를 뒤지는데 static/의 이미지는 그 표에 없으므로 ![](@/images/x.png)는 빌드를 멈춥니다. 루트 절대 경로로 씁니다 — ![](/images/x.png). 그러면 빌드가 파일이 있는지 확인하므로, 이미지 경로의 오타도 링크의 오타와 똑같이 빌드를 멈춥니다. 생성된 파일도 링크 대상입니다 /sitemap.xml, /robots.txt, /llms.txt, /404.html, /feed-<언어>.xml, /search-<언어>.json은 검사기 입장에서 페이지와 같습니다. 빌드가 만들어 내므로 그것을 가리키는 링크는 해석됩니다. 예외는 콘텐츠 해시가 붙는 것들입니다. 빌드 뒤에 /css/main.css는 존재하지 않으므로, 템플릿에서 asset(\"css/main.css\")로 가야 합니다. 나머지는 건드리지 않습니다 외부 링크, 앵커, mailto: — 그대로 둡니다. sqzass가 가져가는 건 @/ 접두사뿐입니다."},{"t":"마크다운","d":"기본으로 켜져 있는 확장과, 끌 수 있는 것들","u":"/ko/content/markdown/","s":"콘텐츠 작성","c":"comrak으로 렌더하는 CommonMark이고, 확장 몇 가지가 기본으로 켜져 있습니다. 각각이 sqzass.toml의 [markdown] 아래 키입니다. [markdown] footnotes = true tables = true tasklist = true strikethrough = true autolink = true alerts = true cjk_friendly_emphasis = true heading_anchors = \"right\" # none | left | right 콜아웃 인용문 위에 얹힌 GitHub의 콜아웃 문법입니다. 설정 키는 alerts입니다. > [!NOTE] > 정적 리눅스 빌드는 순수 Rust 정규식 엔진을 싣습니다. 정적 리눅스 빌드는 순수 Rust 정규식 엔진을 싣습니다. NOTE, TIP, IMPORTANT, WARNING, CAUTION을 인식합니다. 표 | 키 | 기본값 | |---|---| | `output_dir` | `public` | 키 기본값 output_dir public 체크리스트 - [x] 검색 - [ ] 피드 검색 피드 각주 각주가 붙은 문장입니다. 각주가 붙은 문장입니다[^1]. [^1]: 각주 본문입니다. 제목 모든 제목에 id가 붙습니다. 앵커를 보여주든 말든 붙는데, 목차와 남에게 건네는 딥링크가 둘 다 여기에 기대기 때문입니다. 같은 제목이 반복되면 -1, -2가 붙고, 앵커와 목차 항목은 반드시 일치합니다. 한 번의 순회에서 같은 카운터로 만들어지니까요. heading_anchors는 눈에 보이는 # 링크를 정합니다. \"right\"(기본), \"left\", \"none\" 중 하나입니다. 키가 아닌 설정 둘 둘 다 고정이고, 둘 다 의도한 것입니다. 옵션을 찾다가 없어서 이슈를 쓰기 전에 여기서 끝나라고 있는 절입니다. 원시 HTML은 항상 통과합니다. 콘텐츠는 믿을 수 있습니다. 저장소 안에 있고, 직접 썼고, 코드와 같은 커밋에서 리뷰됩니다. 이걸 새니타이즈하는 건 시늉입니다. sqzass가 언젠가 믿을 수 없는 마크다운을 받게 되면, 그건 전역 스위치가 아니라 소스별 신뢰 등급이 될 것입니다. 코드 펜스는 <pre lang=\"rust\">가 아니라 <code class=\"language-rust\">를 만듭니다. 클라이언트 하이라이터와 복사 버튼 스니펫이 기대하는 형태가 클래스 쪽입니다. 코드 빌드 시점에 CSS 클래스로 강조됩니다. 구문 강조를 참고하세요. 각주 본문입니다."},{"t":"배포","d":"디렉터리 하나로 나오는 결과물과, 그걸 받아 주는 호스트들","u":"/ko/deploy/","c":"sqzass build -i mysite mysite/public이 사이트입니다. 파일을 서빙하는 곳에 복사하면 끝입니다. 런타임도, 서버 컴포넌트도, 남아 있는 빌드 단계도 없습니다. 호스트가 하지 않아도 되도록 sqzass가 하는 일 모든 URL이 index.html을 담은 디렉터리라서 예쁜 URL에 rewrite 규칙이 필요 없습니다. 캐시 무효화가 파일명에 들어 있어서 캐시 헤더가 필요 없습니다. 출력물 중 어느 것도 호스트 설정에 기대지 않습니다. 의도한 것이고, 이 문서 사이트를 GitHub Pages에 올려 둔 것도 그걸 정직하게 지키기 위해서입니다. Pages에는 커스텀 헤더도, 리다이렉트 규칙도, rewrite도 없으므로, 그런 것에 기대는 게 생기면 여기서 가장 먼저 깨집니다. 보상은 호스트를 옮기는 일이 복사가 된다는 점입니다. 같은 디렉터리가 Cloudflare Pages 에서도, Netlify에서도, CloudFront 뒤의 S3에서도, nginx에서도 그대로 맞습니다. 그 밖에 만들어지는 것 sitemap.xml에 모든 페이지가 들어가고, 두 개 이상의 언어에 존재하는 페이지에는 <xhtml:link> 대체 링크가 붙습니다. 이중 언어 사이트에 대해 구글이 요구하는 형태입니다. robots.txt는 전부 허용하고 sitemap을 가리킵니다. 둘 다 priority와 changefreq를 담지 않습니다. 구글이 2023년에 둘 다 무시한다고 확인했기 때문입니다. lastmod도 담지 않는데, 이건 선택입니다. 여기서 쓸 수 있는 출처가 전부 믿을 만하지 않습니다. 파일 mtime은 체크아웃 시각이라 CI에서는 모든 페이지가 \"오늘 아침에 바뀌었다\"고 주장하게 되고, 두 번 빌드하면 같은 바이트가 나온다는 보장도 깨집니다. git 커밋 시각은 정확하지만 전체 이력이 필요한데 actions/checkout은 기본이 얕은 클론이라, 조용히 모든 페이지에 같은 날짜를 찍게 됩니다. 구글은 한 번 믿을 수 없다고 판단하면 그 사이트의 lastmod를 통째로 무시합니다. 틀린 값이 없는 것보다 나쁘다는 뜻입니다. llms.txt는 모든 페이지의 제목·URL·설명을 담은 평평한 목록으로, llmstxt.org가 제안한 형식입니다. 이 사이트에 대해 질문받은 언어 모델이 전체를 훑는 대신 파일 하나를 읽으면 됩니다. 제목·URL·설명이 이미 있으니 만드는 데 드는 게 없습니다. sitemap.xml이나 robots.txt, llms.txt를 직접 static/에 넣으면 sqzass는 그 파일을 아예 만들지 않습니다. 직접 넣은 것을 덮어쓰지도, 조용히 무시하지도 않습니다. 각 안내서 전부 같은 사실 두 개입니다. sqzass build를 돌리고 public/을 발행한다. 문서가 갈리는 건 그 두 사실을 어디에 적느냐와, 각 호스트가 프리뷰 URL을 뭐라고 부르느냐뿐입니다. 경로 아래에 놓이는 사이트 https://user.github.io/repo, https://group.gitlab.io/project, https://user.codeberg.page/repo — 전부 프로젝트 사이트이고, 전부 결과물을 도메인 루트가 아니라 경로 아래에 서빙합니다. base_url에 그 전체를 적으세요. base_url = \"https://user.github.io/repo\" 그러면 sqzass가 생성하는 모든 URL에 접두사가 붙습니다 — 페이지 링크도, 스타일시트 href도, 검색 색인도. 출력 디렉터리는 평평하게 남는데, 그 디렉터리가 곧 호스트가 서빙하는 루트이기 때문입니다. 경로를 빠뜨리는 건 여기서 유일하게 조용한 실수입니다. 빌드는 성공하고 페이지도 다 있는데, 모든 링크와 스타일시트가 한 단계 위를 가리킵니다. static/은 통과 경로입니다 static/ 안의 것은 경로와 이름이 그대로 유지된 채 출력에 놓입니다. sqzass가 어떤 호스트도 알지 못한 채로 호스트별 파일이 동작하는 방식입니다. CNAME GitHub Pages 커스텀 도메인 .domains Codeberg Pages 커스텀 도메인 _headers, _redirects Netlify, Cloudflare Pages .well-known/* 도메인 소유 검증, security.txt 콘텐츠 해시가 붙는 건 CSS와 JavaScript뿐입니다. 이름 자체가 계약인 파일은 이름을 유지합니다. 알아 둘 파일 둘 .nojekyll은 빌드마다 출력에 들어갑니다. 없으면 GitHub Pages가 출력을 Jekyll로 한 번 더 굴려서 _로 시작하는 디렉터리를 통째로 삼킵니다. CNAME이 필요하면 static/에 두면 이름 그대로 복사됩니다. 해시가 붙은 CNAME은 GitHub이 영영 찾지 않을 파일입니다. 빌드가 내보내는 것 전체 목록입니다. \"호스트를 옮기는 일이 복사\"라는 말을 믿는 대신 확인할 수 있으라고 적어 둡니다. static/의 전부 경로와 이름 그대로. CSS와 JS에만 콘텐츠 해시 assets/highlight.<해시>.css [highlight] enabled = false가 아니면 asset-manifest.json 논리 이름 → 실제 URL, 항상 <경로>/index.html 페이지마다 하나 search-<언어>.json [search] enabled = false가 아니면 언어마다 하나 feed-<언어>.xml 날짜 있는 페이지가 있는 언어마다 하나 sitemap.xml, robots.txt 같은 이름을 static/에 두지 않았다면 llms.txt 같은 조건 404.html templates/404.html이 있을 때 alias 스텁 aliases 항목마다 하나 .nojekyll 항상. static/.nojekyll보다 이쪽이 이깁니다 그 밖엔 없고, 출력 디렉터리 밖으로는 아무것도 나가지 않습니다. 결정성 같은 입력을 두 번 빌드하면 바이트까지 같은 출력이 나옵니다. 그래서 이 빌드를 CI에서 검사로 돌려도 되고, 바뀐 게 없는 배포는 아무것도 올리지 않습니다."},{"t":"Cloudflare Pages","d":"대시보드 설정 두 개, 그리고 GitHub Pages가 못 주던 헤더","u":"/ko/deploy/cloudflare-pages/","s":"배포","c":"프로젝트 빌드 설정에서: 프레임워크 프리셋 없음 빌드 명령 curl -sSL https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1 && ./sqzass build 빌드 출력 디렉터리 public 작성할 설정 파일이 없습니다. 헤더가 필요해지면 옮겨 갈 곳이 여기입니다 sqzass는 호스트 설정에 아무것도 기대지 않도록 만들었고, 이 사이트를 GitHub Pages에 둔 것도 그 약속을 정직하게 지키기 위해서입니다. 다만 GitHub Pages는 전부 cache-control: max-age=600으로 내보내고 그걸 바꿀 방법이 없습니다. Cloudflare Pages는 _headers 파일을 읽으므로, 콘텐츠 해시가 붙은 파일명이 드디어 제 뜻대로 쓰입니다. # static/_headers /css/* Cache-Control: public, max-age=31536000, immutable /js/* Cache-Control: public, max-age=31536000, immutable /* Cache-Control: public, max-age=600 이 두 디렉터리에 1년을 걸어도 안전한 이유는, 내용이 바뀌면 파일명이 바뀌기 때문입니다. 쿼리 문자열이 아니라 이름에 해시를 넣은 것의 보상이고, 헤더를 못 바꾸는 호스트에서는 쓸 수 없는 보상입니다. 파일을 static/에 두면 그대로 복사됩니다. 리다이렉트 _redirects도 같은 방식으로 동작하지만, front matter의 aliases를 먼저 보세요. 옮겨진 페이지 바로 옆에 있고, 빌드가 검사하며, 다음에 어떤 호스트로 가든 따라옵니다. 프리뷰 배포 $CF_PAGES_URL에 프리뷰 주소가 들어 있습니다. ./sqzass build --base-url \"$CF_PAGES_URL\""},{"t":"Codeberg Pages","d":"pages 브랜치, .domains 파일, 그리고 Forgejo Actions","u":"/ko/deploy/codeberg-pages/","s":"배포","c":"Codeberg는 저장소의 pages 브랜치를 https://<사용자>.codeberg.page/<저장소>/에 서빙합니다. 저장소 이름이 pages면 통째로 https://<사용자>.codeberg.page/가 됩니다. CI에서 빌드하기 # .forgejo/workflows/deploy.yml on: push: branches: [main] jobs: deploy: runs-on: docker container: image: alpine:latest steps: - run: apk add --no-cache git nodejs - uses: actions/checkout@v4 - run: wget -qO- https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1 - run: ./sqzass build - name: pages 브랜치로 발행 run: | cd public git init -q && git add -A git -c user.email=ci -c user.name=ci commit -qm \"Deploy\" git push -f \"https://$GITHUB_ACTOR:${{ secrets.PAGES_TOKEN }}@codeberg.org/$GITHUB_REPOSITORY.git\" HEAD:pages nodejs는 Forgejo Actions 러너에 필요한 것이지 sqzass에 필요한 게 아닙니다. 여기도 서브경로 저장소 이름이 pages가 아니라면 사이트는 /<저장소>/ 아래에 놓입니다. base_url = \"https://myuser.codeberg.page/myrepo\" 실패 방식은 GitLab과 같습니다. 빌드는 성공하고 모든 링크가 한 단계 위를 가리킵니다. 자세한 설명은 GitLab Pages에 적어 두었습니다. 커스텀 도메인 Codeberg는 서빙되는 디렉터리 루트의 .domains 파일을 읽습니다. static/에 두면 GitHub Pages의 CNAME과 마찬가지로 이름이 유지된 채 그대로 나갑니다. # static/.domains example.com www.example.com 커스텀 도메인을 쓰면 서브경로가 없으므로 base_url은 도메인만 남습니다. Path::extension()은 이름 전체가 확장자처럼 생긴 파일에 대해 아무것도 돌려주지 않습니다. .domains나 .nojekyll 같은 파일이 main.css와 다른 취급을 받아야 하는 이유입니다. sqzass는 이런 파일을 그대로 통과시키고, 이름 전체가 확장자인 것에 해시를 붙이려 하지 않습니다."},{"t":"GitHub Pages","d":"이 사이트가 있는 곳이자, 설계를 강제한 호스트","u":"/ko/deploy/github-pages/","s":"배포","c":"이 사이트는 도구와 같은 저장소에서 아래 워크플로로 빌드·배포됩니다. CI가 방금 컴파일한 바이너리로 docs/를 빌드하므로, 이 문서가 곧 생성기의 회귀 테스트가 됩니다. 워크플로 name: Deploy docs on: push: branches: [main] workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: pages cancel-in-progress: false jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - uses: actions/configure-pages@v6 - run: cargo run --quiet -- build -i docs - uses: actions/upload-pages-artifact@v5 with: path: docs/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 - 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 cancel-in-progress: false는 그대로 두는 게 좋습니다. 진행 중인 배포를 취소하면 사이트가 반쯤 갱신된 상태로 남는데, 그건 기다리는 것보다 나쁩니다. 재시도는 혹시 몰라 넣은 게 아니라 이 사이트가 실제로 겪은 실패의 해결책입니다. concurrency는 워크플로 실행을 직렬화하는데, Pages 배포는 그보다 오래 삽니다. 앞선 실행이 끝난 뒤에도 GitHub 쪽에서 처리 중일 수 있고, 커밋을 연달아 밀면 두 번째가 그 창에 들어가 400 \"due to in progress deployment\"로 죽습니다. 기다렸다 한 번 더 시도하면 됩니다. 안전하기도 합니다 — 배포가 무언가를 바꾸기 전에 실패하는 것이라, 재시도가 사이트를 반쯤 갱신된 상태로 만들지 않습니다. 커스텀 도메인 도메인을 static/CNAME에 넣습니다. sqzass.sqzer.com 이름 그대로 출력에 복사되고 GitHub이 거기서 읽습니다. 그래서 배포할 때마다 저장소 설정에서 도메인을 다시 넣지 않아도 살아남습니다. DNS는 CNAME 레코드로 <사용자>.github.io를 가리키게 합니다. DNS 제공자가 트래픽을 프록시한다면 이 레코드에서는 프록시를 끄세요. 프록시가 TLS를 자기가 종료하면 GitHub이 인증서를 발급·갱신하지 못하는데, 이 실패는 배포가 깨지는 형태로 바로 드러나지 않고 몇 주 뒤 인증서 만료로 나타납니다. 확인하기 curl -sI https://example.com/ | head -1 # HTTPS로 200 curl -sI https://example.com/nonexistent/ # 200이 아니라 404 두 번째가 중요합니다. 없는 페이지에 200을 돌려주는 호스트에서는 검색 엔진이 그 404 페이지를 색인하게 됩니다. 감수하는 것 커스텀 헤더가 없습니다. GitHub Pages는 전부 max-age=600으로 내보냅니다. 리다이렉트 규칙도 없습니다. 풀 리퀘스트 프리뷰 배포도 없습니다. 앞의 둘은 출력물을 지금처럼 만든 이유 그 자체이고, 세 번째는 sqzass serve가 대신합니다. 나중에 정말로 헤더 제어가 필요해지면, 답은 헤더가 없는 호스트 앞에 CDN을 얹는 게 아니라 헤더가 있는 호스트로 옮기는 것입니다."},{"t":"GitLab Pages","d":".gitlab-ci.yml 하나, 그리고 사람들이 걸려 넘어지는 서브경로","u":"/ko/deploy/gitlab-ci/","s":"배포","c":"# .gitlab-ci.yml pages: image: alpine:latest script: - wget -qO- https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1 - ./sqzass build artifacts: paths: [public] rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH alpine이 되는 건 릴리스가 정적 링크이기 때문입니다. 그 이미지엔 glibc가 없고, 여기엔 glibc가 필요한 게 없습니다. 잡 이름이 pages이고 아티팩트 디렉터리가 public인 건 GitLab이 정확히 그 두 이름을 찾기 때문입니다. 서브경로 커스텀 도메인을 걸지 않았다면 GitLab 프로젝트 사이트는 https://<그룹>.gitlab.io/<프로젝트>/에서 서빙됩니다. 그 경로가 base_url에 들어가야 합니다. base_url = \"https://mygroup.gitlab.io/myproject\" 그러면 sqzass가 생성하는 모든 URL 앞에 /myproject가 붙습니다 — 링크도, 스타일시트 href도, 검색 색인 위치도 전부. 출력 디렉터리는 그대로 평평하게 남는데, 그 디렉터리가 곧 GitLab이 서빙하는 루트이기 때문입니다. 이걸 틀리면 요란하지 않고 조용합니다. 빌드는 되고 페이지도 생기는데, 모든 링크와 스타일시트가 한 단계 위를 가리켜서 404와 스타일 없는 페이지가 나옵니다. 빌드는 성공했다고 보고한 채로요. 사용자/그룹 사이트(<그룹>.gitlab.io)에는 경로가 없으므로 base_url은 도메인뿐입니다. 머지 리퀘스트 프리뷰 pages: # … script: - wget -qO- … | tar xz --strip-components=1 - ./sqzass build --base-url \"$CI_PAGES_URL\" $CI_PAGES_URL에 이미 프로젝트 경로가 들어 있으므로, 서브경로를 두 번 적지 않는 가장 짧은 방법이기도 합니다."},{"t":"Netlify","d":"netlify.toml, 그리고 base_url이 맞는 배포 프리뷰","u":"/ko/deploy/netlify/","s":"배포","c":"# netlify.toml [build] command = \"curl -sSL https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1 && ./sqzass build\" publish = \"public\" [context.deploy-preview] command = \"curl -sSL https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1 && ./sqzass build --base-url $DEPLOY_PRIME_URL\" 설정은 이게 전부입니다. 플러그인도, 빌드 이미지도, 런타임도 없습니다. 왜 설치가 아니라 바이너리를 받나 Netlify 빌드 이미지에는 Rust 툴체인이 기본으로 없고, 넣으면 빌드마다 몇 분이 더 듭니다. 리눅스 릴리스는 정적 링크라 glibc 버전을 맞추지 않고도 어떤 이미지에서든 돕니다. Alpine에서도, scratch 컨테이너에서도, 빌드한 머신보다 오래된 머신에서도 도는 것과 같은 성질입니다. 소스에서 빌드하고 싶으면 cargo install --git https://github.com/sqzer-x/sqzass도 됩니다. 다만 느립니다. 배포 프리뷰에는 자기 base_url이 필요합니다 프리뷰는 본 도메인이 아니라 deploy-preview-42--yoursite.netlify.app에서 돕니다. Netlify가 그 주소를 $DEPLOY_PRIME_URL에 넣어 주고, --base-url이 바로 이 경우를 위해 설정을 덮어씁니다. 그래야 canonical과 sitemap, OpenGraph 태그가 프로덕션이 아니라 프리뷰를 가리킵니다. 이걸 안 해도 프리뷰는 동작합니다. 내부 링크가 루트 절대 경로라서 호스트 이름을 신경 쓰지 않기 때문입니다. 깨지는 건 절대 URL 쪽입니다 — page.permalink, sitemap.xml, 소셜 태그. 예쁜 URL·리다이렉트·헤더 설정할 게 없습니다. 모든 페이지가 index.html을 담은 디렉터리라서 Netlify가 rewrite 규칙 없이 /start/를 서빙하고, templates/404.html이 있으면 /404.html을 이름으로 찾아 씁니다. _redirects와 _headers는 Netlify 고유 파일이고 둘 다 필수가 아닙니다. 쓰고 싶으면 static/에 두면 그대로 복사됩니다 — static/은 통과 경로이고, 이름 자체가 계약인 파일은 이름이 유지됩니다. static/ ├── _headers └── _redirects 필요 없는 것 netlify-plugin-*도, NODE_VERSION도, functions 디렉터리도 없습니다. 결과물은 파일이 든 디렉터리입니다."},{"t":"Vercel","d":"vercel.json, 그리고 프레임워크 기능들이 여기서 무의미한 이유","u":"/ko/deploy/vercel/","s":"배포","c":"{ \"buildCommand\": \"curl -sSL https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1 && ./sqzass build\", \"outputDirectory\": \"public\", \"framework\": null } \"framework\": null이 중요합니다. Vercel의 감지는 package.json을 찾는데, 없으면 추측을 합니다. 잘못 추측하면 빌드가 실패하고, 그 실패가 sqzass 탓처럼 읽힙니다. 프리뷰 배포 Vercel은 프리뷰 호스트를 스킴 없이 $VERCEL_URL로 줍니다. { \"buildCommand\": \"… && ./sqzass build --base-url \\\"https://$VERCEL_URL\\\"\" } 이유는 다른 호스트와 같습니다. 내부 링크는 루트 절대 경로라 어디서든 동작하지만, canonical·sitemap·소셜 태그는 절대 URL이라 프리뷰에서 프로덕션을 가리키게 됩니다. 끝 슬래시 sqzass는 /start/index.html을 쓰므로 /start/가 정본이고, 생성하는 모든 링크가 그 형태입니다. Vercel의 기본 trailingSlash 동작은 둘 사이를 리다이렉트하므로 손으로 /start라고 쓴 링크에는 한 번의 왕복이 더 듭니다. \"trailingSlash\": true로 두면 그 왕복이 없어집니다. 필수가 아니라 취향입니다. 어느 쪽이든 사이트는 옳게 동작합니다. 필요 없는 것 서버리스 함수도, ISR도, 엣지 설정도, 이미지 최적화도 없습니다. sqzass는 요청 시점에 실행할 게 없으므로, Vercel을 파일 서버와 구별되게 하는 부분이 전부 여기서는 놀고 있습니다. 이건 분명히 말해 둘 만합니다. 팀이 이미 Vercel을 쓰고 있다면 잘 됩니다. sqzass 사이트를 위해 호스트를 고르는 중이라면, 프레임워크 지원이 선택 기준이 되지는 않을 겁니다."},{"t":"기능","d":"구문 강조, 검색, 피드, 개발 서버","u":"/ko/features/","c":"마크다운을 HTML로 바꾸는 것 너머에서 sqzass가 하는 일들입니다. 여기 있는 것들은 전부 기본으로 켜져 있고, 설정이 있다면 sqzass.toml에서 합니다. 플러그인 시스템도 테마 시스템도 없습니다. 아래 것들은 이 도구에 붙는 확장이 아니라 이 도구 자체입니다."},{"t":"개발 서버","d":"메모리 서빙, 변경 시 리빌드, 보던 자리를 지키는 새로고침","u":"/ko/features/dev-server/","s":"기능","c":"sqzass serve -i docs http://127.0.0.1:3000에 뜨고, content/·templates/·static/·i18n/ 안의 무엇이든, 또는 sqzass.toml 자체가 바뀔 때마다 다시 빌드합니다. 출력 디렉터리는 일부러 보지 않습니다. 자기가 쓴 것을 보고 다시 빌드하면 끝나지 않습니다. 플래그 -i, --input . 사이트 루트. -b, --bind 127.0.0.1 바인드 주소. -p, --port 3000 포트. --drafts 드래프트 페이지도 포함. --base-url base_url 덮어쓰기. 디스크에는 아무것도 쓰지 않습니다 빌드 결과가 메모리로 들어가고 거기서 서빙됩니다. 서버가 도는 동안 public/은 건드리지 않습니다. 최적화가 아닙니다. 리빌드 중에 브라우저가 파일을 요청하면 그 시점까지 쓰인 바이트가 그대로 나가고, 그렇게 나온 반쪽짜리 페이지는 \"내 코드 문제가 아니었구나\"를 깨닫기까지 한 시간을 쓰게 되는 종류의 버그입니다. 완성된 빌드를 서빙하거나 아예 서빙하지 않으면 그 틈이 통째로 사라집니다. 새로고침 리로드 스크립트는 빌드 산출물에 쓰이는 게 아니라 서빙하는 시점에 주입됩니다. 그래서 출력물은 프로덕션 빌드와 바이트 단위로 동일하게 유지됩니다. CSS만 바뀐 변경은 페이지를 새로고침하는 대신 스타일시트의 href를 그 자리에서 갈아 끼웁니다. 페이지가 움직이지 않으니 여백을 조금씩 밀어 보는 동안 스크롤 위치를 잃지 않습니다. 그 밖의 변경은 페이지를 새로고침합니다. 빌드가 실패하면 보고 있지도 않을 터미널에만 찍는 대신 브라우저에 에러를 띄우고, 그 아래에는 마지막 성공 버전이 계속 서빙됩니다. 파일을 고쳐 저장하면 오버레이가 사라집니다. 프로덕션 서버가 아닙니다 캐싱도, 압축도, 접근 제어도, TLS도 없습니다. 그래서 기본 바인드가 localhost입니다. --bind 0.0.0.0을 주면 같은 네트워크의 휴대폰에서 사이트를 볼 수 있고, 유용하며, 딱 거기까지입니다."},{"t":"피드","d":"front matter에 적은 날짜로 만들어지는 언어별 Atom 피드","u":"/ko/features/feeds/","s":"기능","c":"페이지에 date를 주면 그 언어의 피드에 들어갑니다. +++ title = \"0.2.0 릴리스\" description = \"무엇이 바뀌었나\" date = 2026-07-26 +++ public/ ├── feed-en.xml └── feed-ko.xml 날짜가 없으면 피드도 없습니다 날짜 있는 페이지가 하나도 없는 언어에는 파일을 만들지 않고, 템플릿에도 링크할 site.feed가 오지 않습니다. 빈 피드는 없는 것보다 나쁩니다. 구독자는 아무것도 오지 않는 것을 \"비어 있구나\"가 아니라 \"고장났구나\"로 읽습니다. 이 문서에는 날짜 있는 페이지가 없어서 이 사이트는 피드를 발행하지 않습니다. 그게 기능이 제대로 도는 모습입니다. 자동 발견 {% if site.feed %} <link rel=\"alternate\" type=\"application/atom+xml\" title=\"{{ site.title }}\" href=\"{{ site.feed }}\"> {% endif %} site.feed는 현재 언어의 피드이거나 없습니다. 이 if는 방어가 아니라 규칙 그 자체입니다. RSS 2.0이 아니라 Atom RSS의 날짜는 RFC 2822입니다: Tue, 26 Jul 2026 00:00:00 +0000. 이 형식은 우리가 직접 계산해야 하는 요일과 직접 박아 넣어야 하는 영어 월 이름을 요구합니다. 한국어 피드에도 그대로 들어가는데, 거기서 Jul은 그냥 틀린 표기입니다. Atom은 RFC 3339를 쓰고, 그건 TOML 날짜가 이미 갖고 있는 모양입니다. 코드가 줄고, 미묘하게 틀릴 자리가 하나 줄고, 이번 세기에 만들어진 리더는 전부 Atom을 읽습니다. 무엇이 들어가나 날짜가 있는 최근 20개, 최신순. 각 항목에 제목·퍼머링크·updated 시각, 그리고 description이 요약으로 들어갑니다. 20은 우연이 아니라 상한입니다 — 끝없이 자라는 피드는 언젠가 읽는 것이 아니라 내려받는 것이 됩니다. 같은 시각에 놓인 페이지는 제목순입니다. 그래야 빌드할 때마다 같은 순서로 나옵니다. 날짜만 적으면 자정 UTC가 되므로, 실제로는 같은 날 쓴 글끼리가 모두 여기 해당합니다. 시각이 없는 날짜는 자정 UTC가 됩니다. Atom은 날짜만으로는 받지 않고, 시각을 파싱 못 한 리더는 그 항목을 아무 말 없이 버립니다. 오프셋은 쓴 그대로 실립니다. 2026-07-26T09:00:00+09:00은 그대로 나갑니다. RFC 3339가 어떤 오프셋도 받고, 이걸 Z로 고쳐 적으면 서울의 아침을 서울의 저녁이라고 발행하는 셈이기 때문입니다. 정렬은 표기가 아니라 순간을 비교하므로, 같은 날의 +09:00 아침이 Z 오후보다 앞섭니다. TOML이 시각으로만 읽는 date(10:30:00)는 조용히 버리지 않고 에러입니다. 날짜를 적었는데 빌드가 말없이 다르게 생각해서는 안 됩니다. 날짜순 정렬 # content/posts/_index.md +++ title = \"글\" sort_by = \"date\" +++ 최신이 먼저입니다. 오름차순인 weight·title과 다릅니다. 날짜가 붙은 목록을 보는 사람이 기대하는 순서니까요. 날짜가 없는 페이지는 앞이 아니라 뒤로 갑니다. 없는 날짜를 0으로 두고 정렬하면 앞으로 올라오는데, 그건 아무도 원하지 않습니다. 날짜 보여주기 날짜 필터도, 형식 문자열도 없습니다. page.date는 조각입니다. page.date.year 2026 page.date.month 7 page.date.day 26 page.date.date 2026-07-26, <time datetime>에 그대로 page.date.iso 2026-07-26T00:00:00Z {% if page.date %} <time datetime=\"{{ page.date.date }}\">{{ page.date.year }}년 {{ page.date.month }}월 {{ page.date.day }}일</time> {% endif %} 형식 문자열 필터를 만들면 날짜 서식 미니 언어를 하나 더 배포하게 되고, 그다음엔 사람들이 쓰는 모든 언어의 로케일 규칙을 우리가 떠안게 됩니다. 조각은 데이터이고, 템플릿은 데이터를 배열할 줄 이미 압니다."},{"t":"구문 강조","d":"빌드 시점에, 클래스로, 두 테마를 함께","u":"/ko/features/highlighting/","s":"기능","c":"코드 블록은 사이트를 빌드하는 동안 강조됩니다. 색을 입히려고 독자의 브라우저에서 JavaScript가 도는 일이 없으므로, 페이지는 첫 페인트부터 색이 입혀져 있습니다. [highlight] enabled = true theme_light = \"InspiredGitHub\" theme_dark = \"base16-ocean.dark\" 인라인 스타일이 아니라 클래스 강조된 블록은 이렇게 생겼습니다. <pre class=\"highlight\"><code class=\"language-rust\" data-lang=\"rust\"><span class=\"hl-source hl-rust\">…</span></code></pre> style=\"color:#268bd2\"가 아닙니다. 이 차이가 세 가지를 결정합니다. 다크 모드가 가능해집니다. 인라인 색상은 지금까지 만든 모든 문서에 테마 하나를 박아 넣습니다. 테마를 바꾸려면 사이트를 다시 빌드해야 하고, 독자의 설정을 따르려면 두 테마를 마크업 안에 같이 실어 보내야 합니다. 클래스라면 두 테마는 CSS 두 덩어리일 뿐이고, 전환은 CSS 전환입니다. 엄격한 style-src를 쓸 수 있습니다. 인라인 스타일을 금지하는 CSP는 HTML이 인라인 스타일로 가득 찬 순간 선택지에서 사라집니다. 스타일시트가 파일 하나입니다. 색 하나를 바꾸면 HTML 페이지를 하나도 다시 만들지 않고 모든 페이지가 바뀝니다. 두 테마, 하나의 스타일시트 theme_light와 theme_dark가 둘 다 생성된 스타일시트에 들어갑니다. 다크 규칙은 두 번 나갑니다. 토글을 쓰지 않는 독자를 위해 prefers-color-scheme: dark 아래 한 번, 이 사이트처럼 전환 버튼을 제공하는 경우를 위해 [data-theme=\"dark\"] 아래 한 번입니다. syntect 기본 세트의 테마 이름이면 무엇이든 됩니다. 없는 이름을 쓰면 빌드 에러이고, 메시지에 쓸 수 있는 이름들이 같이 나옵니다. 접두사 클래스에는 hl- 접두사가 붙습니다. 접두사가 없으면 syntect는 source, keyword, string 같은 이름을 내보내는데, 프로그래밍을 다루는 사이트에서 사이트 스타일시트와 부딪히기 딱 좋은 일반적인 단어들입니다. 줄 표시와 파일명 옵션은 언어 뒤에, 공백으로 구분해, key=value로 적습니다. ```rust hl_lines=2-3 name=src/main.rs fn main() { let marked = 2; let also_marked = 3; } ``` 이 사이트의 스타일시트로 렌더하면 이렇게 됩니다. fn main() { let marked = 2; let also_marked = 3; } hl_lines는 1부터 세는 줄 번호와 닫힌 구간을 쉼표로 나열합니다: hl_lines=2-4,7. 지목된 줄은 <mark class=\"hl-line\">로 감싸집니다. <mark>는 브라우저가 기본으로 칠해 주므로 CSS가 하나도 없어도 표시가 보이고, 사이트는 .highlight mark로 다시 꾸미면 됩니다. 구문 강조는 줄 경계를 건너 이어집니다. 여러 줄 주석의 가운데 줄만 표시해도 그 줄은 여전히 주석입니다. name은 블록에 파일명을 답니다. 마크업이 아니라 속성으로 나가므로 — <code … data-name=\"src/main.rs\"> — 보여줄지는 CSS가 정합니다. .prose pre code { display: block; width: max-content; min-width: 100%; } .prose pre code[data-name]::before { content: attr(data-name); display: block; } 첫 규칙은 code를 가장 긴 줄까지 넓힙니다. 블록이 옆으로 스크롤될 때 라벨과 hl_lines 표시가 보이는 폭에서 끊기지 않고 줄 끝까지 닿는 것은 이 규칙 덕입니다. Zola식 rust,hl_lines=2-4가 아닌 이유: 쉼표는 옵션을 언어 토큰에 붙여 버려서 문법 조회가 실패하고 class=\"language-…\"가 오염됩니다. 첫 공백 뒤에는 무엇이 와도 되고, comrak도 바로 거기서 정보 문자열을 가릅니다. 오타는 조용한 무시가 아니라 빌드 에러입니다. hl_line=3도, linenos=true도, 세 줄짜리 블록의 hl_lines=9도, 두 번 적은 키도 전부 파일명과 함께 빌드를 멈춥니다 — 그 반대는 멀쩡해 보이는 채로 강조가 빠진 페이지가 배포되는 것입니다. 언어 없이 옵션부터 시작한 펜스도 마찬가지입니다. 언어 이름에 =가 들어가는 일은 없으므로, ```hl_lines=2는 모르는 언어가 아니라 언어 자리에 온 옵션입니다. 옵션은 하이라이터의 일부이므로 enabled = false면 적용도 검사도 되지 않습니다. 줄 번호와 복사 버튼은 직접 만듭니다 둘 다 설정 키가 아니고, line_numbers는 아무 일도 안 하는 채로 남기느니 삭제했습니다. 아무것도 하지 않는 설정은 없는 것보다 나쁩니다. 누군가 그걸 켜 놓고 기다리게 되니까요. 보통의 블록에는 CSS 카운터를 얹을 줄 단위 마크업이 없습니다 — 모든 블록의 모든 줄에 래퍼를 감으면, 대부분의 블록이 안 쓰는 기능의 값을 모든 페이지가 치르게 됩니다. 줄 번호 거터는 줄 수를 세는 JS 몇 줄입니다. document.querySelectorAll(\".prose pre > code\").forEach(function (code) { var lines = code.textContent.split(\"\\n\").length - 1; var gutter = document.createElement(\"span\"); gutter.className = \"linenos\"; for (var i = 1; i <= lines; i++) gutter.textContent += i + \"\\n\"; code.parentElement.prepend(gutter); }); .prose pre { display: flex; gap: 1em; } .prose pre .linenos { text-align: right; color: var(--ink-3); user-select: none; } 복사 버튼은 열 줄 남짓이고, 언어는 이미 엘리먼트에 붙어 있습니다. document.querySelectorAll(\".prose pre\").forEach(function (pre) { var b = document.createElement(\"button\"); b.textContent = \"copy\"; b.addEventListener(\"click\", function () { navigator.clipboard.writeText(pre.textContent); }); pre.appendChild(b); }); 둘 다 static/에 있고, 얼마든지 다시 꾸밀 수 있으며, 영원히 같은 뜻을 유지해야 하는 설정 파일에 키를 하나도 더하지 않습니다. 끄기 enabled = false면 강조를 건너뛰고 스타일시트도 만들지 않습니다. 코드 블록에는 여전히 class=\"language-rust\"가 붙으므로 클라이언트 쪽 하이라이터가 받아 쓸 수 있습니다."},{"t":"검색","d":"부분 문자열 색인, 언어마다 한 파일, 그리고 단어 색인이 아닌 이유","u":"/ko/features/search/","s":"기능","c":"빌드할 때마다 언어마다 색인 하나를 씁니다. public/ ├── search-en.json └── search-ko.json 각 행이 페이지 하나입니다. 제목, 설명, 섹션, URL, 본문 평문이 들어갑니다. 클라이언트는 누군가 검색을 처음 열 때 현재 언어의 파일을 받아 부분 문자열로 훑습니다. 왜 부분 문자열인가 흔한 방식은 단어 색인입니다. 빌드 때 본문을 단어로 쪼개 두고, 검색 때 질의의 단어를 찾습니다. 더 작고 더 빠릅니다. 그리고 한국어에서는 틀립니다. 한국어는 명사에 조사가 붙기 때문에 생성기가 본문에서는 생성기는, 생성기를, 생성기가로 나타납니다. 단어 색인도 접두 매칭이면 여기까지는 버팁니다. 버티지 못하는 건 한국어가 합성어를 붙여 쓴다는 사실입니다. 검색엔진최적화 안의 최적화는 그 무엇의 접두사도 아니고, 단어 색인은 이걸 영영 돌려주지 않습니다. 의존성이 안의 존성도 마찬가지입니다. 여기서 떠오르는 해법은 색인할 때 형태소 분석기를 돌리는 것인데, 오히려 더 나빠집니다. 사전은 범용 국어사전이라 외래어를 모르고, 기술 문서의 어휘는 대부분 외래어입니다. 템플릿은이 템플+릿+은으로 돌아오고, 템플릿이라는 단어가 정작 템플릿을 다루는 페이지들과 매칭되지 않게 됩니다. 한국어 2000페이지 코퍼스에서 실측했을 때 템플릿의 recall이 1018페이지에서 27페이지로 떨어졌습니다. 브라우저가 이걸 보정해 줄 수도 없습니다. Intl.Segmenter(\"ko\")는 한국어 어절을 통째로 돌려줍니다. ICU는 중국어·일본어·태국어용 사전 분절기를 싣고 한국어용은 싣지 않기 때문입니다. 그러니 형태소로 쪼갠 색인은 통째로 들어오는 질의와 맞을 길이 없습니다. 본문을 부분 문자열로 훑으면 이런 문제가 어떤 언어에서도 생기지 않습니다. 대가는 JSON 파일 하나입니다. 대가 색인은 모든 페이지의 본문입니다. 이 사이트 기준으로 언어당 80KB쯤이고, 첫 검색 때 한 번 받습니다. 문서가 늘면 선형으로 커지고, 어느 크기를 넘으면 이 설계가 틀린 선택이 되는 지점이 분명히 있습니다. 다만 그 지점은 문서 사이트보다 훨씬 뒤에 있고, 거기 도달하는 건 자기 문서의 단어를 못 찾는 검색을 배포하는 것보다 나은 문제입니다. 검색 UI가 없는 사이트라면 그 80KB도 낼 이유가 없습니다. [search] enabled = false 색인 생성을 통째로 건너뜁니다. 템플릿에는 같은 스위치가 site.search로 옵니다 — 현재 언어의 색인 URL이거나, 꺼져 있으면 없습니다: {% if site.search %} <button id=\"search-trigger\" data-index=\"{{ site.search }}\">…</button> {% endif %} site.feed와 같은 규칙입니다. 이 if는 방어가 아니라 검색을 끈 사이트에서도 테마가 동작하게 하는 방법 그 자체입니다. 이 사이트의 검색 버튼과 팔레트, 푸터 링크가 전부 이 뒤에 있습니다 — 그리고 URL은 템플릿이 search-에 언어 코드를 이어 붙여 만드는 게 아니라 빌드가 줍니다. 순위 질의의 모든 단어가 그 행 어딘가에 나타나야 합니다. OR가 아니라 AND입니다. 제목에서 맞은 것이 설명에서 맞은 것보다, 설명이 본문보다 앞섭니다. 제목이 그 단어로 시작하면 단순히 포함하는 것보다 앞섭니다. 결과는 12개에서 자릅니다. 사람이 읽는 건 그 정도까지입니다. 코드 블록도 색인합니다. 문서에서 사람들은 어렴풋이 기억나는 명령어를 검색하니까요. 행 스키마 클라이언트는 직접 만드는 것이니, 무엇을 읽게 되는지 적어 둡니다. 페이지 하나가 한 행입니다. 키 t 제목 항상 있음 d 설명 비어 있으면 생략 u URL 항상 있음. 서브경로가 있으면 그것까지 포함 s 소속 섹션 제목 비어 있거나 자기가 섹션이면 생략 c 본문 평문, 코드 블록 포함 항상 있음 키가 한 글자인 건 행마다 반복되기 때문입니다. 수백 페이지에서 \"t\" 대신 \"title\"을 쓰면 수십 KB가 더 드는데, 사람이 손으로 읽을 파일도 아닙니다. 클라이언트 이 사이트의 search.js는 200줄 남짓이고 의존성이 없습니다. 대화상자는 <dialog>라서 Esc, 백드롭, 포커스 트랩, 닫을 때 트리거로 포커스를 되돌리는 것까지 전부 코드가 아니라 플랫폼의 동작입니다. Ctrl/⌘ + /로 엽니다. 이건 도구의 기능이 아니라 예제 코드입니다. sqzass는 색인을 쓰고, 그 위에 무엇을 만들지는 직접 정합니다."},{"t":"시작하기","d":"설치부터 첫 사이트를 빌드하기까지","u":"/ko/start/","c":"사이트를 화면에 띄우는 데 필요한 것들입니다. 실행 중 나오는 메시지는 전부 한국어입니다 — --help, 에러, doctor 지적, 개발 서버 로그. 로케일 전환은 없고, 문서도 번역하는 대신 실제 문자열을 그대로 인용합니다. 영문 문서에 한국어 에러가 그대로 실려 있는 것도 그래서입니다. 에러 식별자(SQZASS_E_CONTENT)와 doctor 검사 이름(untranslated)은 ASCII이고 안정적입니다 — 스크립트가 잡아야 할 것도 그쪽입니다. 설계가 다른 지점 마크다운을 트리에서 변환합니다. 링크 재작성, 제목 앵커, 목차가 모두 comrak의 AST 연산입니다. 흔한 지름길인 \"완성된 HTML에 정규식 돌리기\"는 속성이 홑따옴표이거나 따옴표가 없는 요소를 조용히 건너뛰고, 그 사실은 프로덕션에서 알게 됩니다. 템플릿이 반쯤 쓰인 파일을 읽을 수 없습니다. templates/를 빌드마다 한 번 스냅샷으로 뜨고 minijinja의 로더가 include와 extends를 그 스냅샷에서 해석합니다. 저장하는 도중에 리빌드가 걸려도 항상 일관된 상태를 봅니다. 깨진 참조는 빌드를 멈춥니다. 해석되지 않는 @/ 링크, 없는 템플릿, 같은 URL을 주장하는 두 페이지, 없는 에셋 이름 — 전부 경고가 아니라 에러입니다. 같은 입력은 같은 바이트를 냅니다. 두 번 빌드하면 바이트까지 동일하고, CI가 push마다 확인합니다. 한국어를 나중에 얹지 않았습니다. 번역은 자동으로 연결되고, 각 언어의 내비게이션에는 그 언어에 실제로 있는 페이지만 담깁니다. 미번역 페이지가 죽은 링크를 남길 수 없습니다. 검색은 형태소 분석 대신 부분 문자열로 찾기 때문에 검색엔진최적화 안의 최적화도 걸립니다. 상태 초기 단계이고, 그 점을 숨기지 않습니다. 구문 강조, 내비게이션, 목차, 에셋 파이프라인, 개발 서버, 검색, Atom 피드는 동작합니다. 테마 시스템은 아직 없습니다."},{"t":"명령줄","d":"명령과 플래그, exit code, 기계가 읽는 출력","u":"/ko/start/cli/","s":"시작하기","c":"sqzass init [DIR] sqzass build [-i DIR] [-o DIR] [--drafts] [--base-url URL] [--profile] sqzass serve [-i DIR] [-b ADDR] [-p PORT] [--drafts] [--base-url URL] sqzass doctor [-i DIR] [--fail-on note|warn] [--drafts] --json은 어느 명령에나 붙습니다. init DIR(기본 .)에 새 사이트를 만들고, 필요하면 디렉터리도 만듭니다. 이미 sqzass.toml이 있으면 실행을 거부합니다. 첫 사이트 만들기를 참고하세요. build 플래그 기본값 -i, --input . 사이트 루트 — sqzass.toml이 있는 디렉터리. -o, --output <input>/public 사이트 루트가 아니라 셸의 현재 디렉터리 기준으로 해석합니다. --drafts draft = true인 페이지도 포함합니다. --base-url 설정값 프리뷰 배포에 씁니다. --profile 페이즈별 소요 시간을 stderr로 냅니다. stdout은 그대로입니다. 출력 디렉터리는 매번 비우고 시작합니다. 지운 페이지가 빌드된 사이트에 유령으로 남지 않습니다. --profile은 페이즈마다 한 줄씩 찍습니다 — discover, assets, feeds, templates, render, search, generate, write. 느린 빌드의 원인이 콘텐츠인지 이 도구인지를 여기서 알 수 있습니다. discover 750.8µs assets 1.4ms render 198.9ms write 1.3ms serve 메모리에서 서빙하며 http://127.0.0.1:3000에 라이브 리로드를 붙입니다. 개발 서버를 참고하세요. doctor sqzass doctor -i mysite build는 해석하지 못하는 것을 이미 거부합니다 — 깨진 @/ 링크, 없는 템플릿, 같은 URL을 주장하는 두 페이지, 오타 난 설정 키. doctor는 빌드가 받아들이지만 의도하지 않았을 수도 있는 것을 봅니다. 검사 base-url warn base_url이 아직 자리표시자 https://example.com입니다. untranslated warn 어떤 언어에는 있고 어떤 언어에는 없는 페이지입니다. empty-section warn 섹션에 페이지도 _index.md 본문도 없어서 내비게이션 항목이 빈 곳으로 갑니다. 본문이 있는 단일 페이지 섹션은 괜찮습니다. description note description이 없는 페이지입니다. draft note 빌드에서 빠지는 페이지입니다. unused-template note 어떤 페이지도 고르지 않고, 어떤 템플릿도 이름으로 부르지 않습니다. --fail-on으로 게이트를 정하고, 기본은 warn이며, 걸리면 7로 끝납니다. 기본을 note로 두지 않은 건 의도적입니다. note는 \"알아 두라\"는 말이고, 그것 때문에 파이프라인이 멈추면 사람들은 검사를 고치는 대신 doctor를 꺼 버립니다. sqzass doctor -i mysite --fail-on note # 엄격하게 sqzass doctor -i mysite --json # 모든 지적을 데이터로 exit code 빌드는 성공하거나, 사이트의 어느 부분을 받아들일 수 없었는지 알려 줍니다. 코드는 \"누구 잘못이냐\"에 대한 답이고, CI는 텍스트를 파싱하지 않고 여기에 조건을 걸면 됩니다. 코드 식별자 0 성공. 1 SQZASS_E 분류되지 않은 실패. 2 잘못된 명령줄. 이건 우리 것이 아니라 clap의 코드입니다. 3 SQZASS_E_CONFIG sqzass.toml — 못 읽거나, 형식이 틀렸거나, 없는 키를 썼습니다. 4 SQZASS_E_CONTENT content/ 아래 — front matter, 빠진 title, 같은 URL을 주장하는 두 페이지, 해석 안 되는 @/ 링크. 5 SQZASS_E_TEMPLATE templates/, i18n/, 또는 템플릿이 요청했는데 없는 에셋. 6 SQZASS_E_IO 읽기나 쓰기 실패. 7 doctor가 --fail-on 기준 이상을 찾았습니다. 식별자는 메시지와 함께 찍히므로 그대로 검색할 수 있습니다. error: [SQZASS_E_CONTENT] content/_index.md: 어디도 가리키지 않는 링크가 있습니다: @/nope.md 이 번호들은 계약입니다. 다른 뜻으로 재사용하지 않습니다 — 남의 파이프라인에 걸린 조건이 어느 날 조용히 다른 것을 검사하게 되어서는 안 되니까요. --json stdout에 JSON 객체 하나만 냅니다. 스크립트는 파이프 하나만 읽으면 됩니다. $ sqzass build -i docs --json {\"ok\":true,\"output\":\"docs/public\",\"pages\":40} 실패도 스트림을 나누지 않고 stdout으로 갑니다. $ sqzass build -i broken --json {\"code\":4,\"error\":\"[SQZASS_E_CONTENT] …\",\"kind\":\"SQZASS_E_CONTENT\",\"ok\":false} $ echo $? 4 init과 doctor는 각자의 모양이 있습니다. $ sqzass init mysite --json {\"dir\":\"mysite\",\"files\":[\"sqzass.toml\",\"content/_index.md\",\"templates/page.html\"],\"ok\":true} $ sqzass doctor -i mysite --json {\"findings\":[{\"check\":\"base-url\",\"file\":\"sqzass.toml\",\"message\":\"…\",\"severity\":\"warn\"}],\"gated\":1,\"ok\":false} doctor의 ok는 --fail-on 기준에 걸린 게 하나라도 있으면 false이고, gated가 그 개수입니다. file은 해당 없는 지적에서는 생략됩니다. check 문자열은 안정적입니다 — 스크립트가 잡아야 할 것은 메시지가 아니라 이쪽입니다. --json 없이는 메시지가 stderr로, 결과가 stdout으로 갑니다. 터미널 앞의 사람이 기대하는 방식입니다."},{"t":"설정","d":"sqzass.toml의 모든 키와, 오타를 냈을 때 벌어지는 일","u":"/ko/start/configuration/","s":"시작하기","c":"sqzass.toml은 사이트 루트에 둡니다. 반드시 있어야 하는 키는 둘이고, 나머지는 전부 기본값이 있어 적지 않으면 그 기본값으로 동작합니다. title = \"내 사이트\" base_url = \"https://example.com\" 오타는 에러입니다 error: [SQZASS_E_CONFIG] sqzass.toml 파싱 실패: TOML parse error at line 8, column 1 | 8 | theme_ligth = \"InspiredGitHub\" | ^^^^^^^^^^^ unknown field `theme_ligth`, expected one of `enabled`, `theme_light`, `theme_dark` sqzass가 읽지 않는 키는 아무 일도 하지 않는 키이고, 조용히 아무 일도 하지 않는 설정은 깨진 링크와 같은 종류의 실패입니다. 무언가를 요청했고, 도구는 알겠다고 했고, 아무 일도 일어나지 않았습니다. 테마가 왜 안 바뀌는지 찾느라 오후를 쓰게 됩니다. front matter도 마찬가지이고, 에러가 가리키는 줄 번호는 front matter 블록 기준이 아니라 파일 기준입니다. 사이트 키 기본값 title — 필수. base_url — 필수. canonical URL, sitemap, robots.txt가 씁니다. description \"\" default_language \"en\" 이 언어가 루트를 쓰고, 나머지는 URL 접두사를 갖습니다. [languages.<코드>] [languages.en] name = \"English\" weight = 1 [languages.ko] name = \"한국어\" weight = 2 name은 언어 전환 UI에 보이는 이름이고, weight는 순서입니다. 언어를 아예 선언하지 않아도 됩니다. 그러면 default_language 하나로 도는 단일 언어 사이트가 됩니다. 언어를 참고하세요. [build] 키 기본값 output_dir \"public\" 사이트 루트 기준. drafts false 명령줄의 --drafts가 이걸 켭니다. 드래프트는 CSS로 감추는 게 아니라 빌드에서 통째로 빠집니다. 콘텐츠 작성을 참고하세요. [markdown] 키 기본값 footnotes true tables true tasklist true strikethrough true autolink true alerts true GitHub의 > [!NOTE] 콜아웃. cjk_friendly_emphasis true 한국어라면 켜 두세요. heading_anchors \"right\" none, left, right. cjk_friendly_emphasis가 **강조**한다를 강조로 파싱되게 하는 키입니다. CommonMark의 flanking 규칙은 단어 사이에 공백을 두는 언어를 전제로 하고, 이게 없으면 굵게 표시한 부분 바로 뒤에 조사가 붙은 형태는 강조가 아예 아닙니다. 끄면 한국어 본문이 \"내 마크다운이 틀렸나\" 싶은 모양으로 깨집니다. 마크다운을 참고하세요. [highlight] 키 기본값 enabled true theme_light \"InspiredGitHub\" theme_dark \"base16-ocean.dark\" 없는 테마 이름을 쓰면 에러이고, 메시지에 쓸 수 있는 이름들이 같이 나옵니다. 구문 강조를 참고하세요. [assets] 키 기본값 source_dir \"static\" fingerprint true CSS와 JS의 파일명에 콘텐츠 해시를 붙입니다. 정적 에셋을 참고하세요. [nav] 키 기본값 sort_by \"weight\" weight, title, date. 섹션이 각자 덮어쓸 수 있습니다. 섹션마다 덮어쓰는 방법과 date만 내림차순인 이유는 콘텐츠 작성에 적어 두었습니다. [search] 키 기본값 enabled true 언어별 search-<lang>.json을 만듭니다. 색인은 모든 페이지의 본문을 담아 콘텐츠와 함께 커집니다 — 검색 UI가 없는 사이트가 그 값을 치를 이유가 없습니다. 끄면 색인이 만들어지지 않고, 링크가 가리킬 색인 URL도 존재하지 않게 됩니다. 검색을 참고하세요. 명령줄에서 덮어쓰기 --base-url과 --drafts는 파일보다 우선합니다. 설정 하나로 프리뷰 배포와 프로덕션을 함께 다룰 수 있는 이유입니다. sqzass build -i mysite --base-url https://preview.example.com --drafts"},{"t":"첫 사이트 만들기","d":"빈 디렉터리에서 화면에 뜨는 페이지까지","u":"/ko/start/first-site/","s":"시작하기","c":"sqzass init mysite sqzass serve -i mysite 이게 전부입니다. init이 파일 세 개를 쓰고, serve가 그걸 http://127.0.0.1:3000에 띄웁니다. 네 번째 단계는 없습니다. 무엇이 만들어졌나 mysite/ ├── sqzass.toml ├── content/ │ └── _index.md └── templates/ └── page.html 세 개뿐입니다. 절반을 지워야 하는 스캐폴드는 출발점이 아니니까요. 요청하지 않은 .gitignore도, 예제 블로그 글도, sqzass가 자기 것이라고 여기는 디렉터리도 만들지 않습니다. init은 이미 sqzass.toml이 있는 디렉터리에서는 실행을 거부합니다. 기존 사이트를 반쯤 덮어쓰는 일이 생길 수 없습니다. sqzass.toml 키는 둘입니다. 나머지는 전부 기본값이 있고, 적지 않은 키는 그 기본값으로 동작합니다. title = \"mysite\" base_url = \"https://example.com\" content/_index.md 첫 페이지입니다. front matter는 +++ 펜스 사이의 TOML이고, 반드시 적어야 하는 필드는 title 하나뿐입니다. +++ title = \"mysite\" +++ 안녕하세요. templates/page.html page.content는 이미 HTML이므로 | safe가 필요합니다. 템플릿은 기본적으로 이스케이프하며, 그 기본값 덕분에 본문에 섞인 < 하나가 마크업으로 둔갑하지 않습니다. <!doctype html> <html lang=\"{{ page.language }}\"> <head> <meta charset=\"utf-8\"> <title>{{ page.title }}</title> {%- if site.highlight_css %} <link rel=\"stylesheet\" href=\"{{ site.highlight_css }}\"> {%- endif %} </head> <body>{{ page.content | safe }}</body> </html> site.highlight_css 줄은 빌드가 구문 테마에서 만들어 주는 스타일시트입니다. 빼면 코드 블록에 색이 없이 나옵니다. 빌드하기 sqzass build -i mysite 결과물은 mysite/public에 생깁니다. 모든 페이지는 <경로>.html이 아니라 <경로>/index.html로 쓰이므로 URL이 /about.html이 아니라 /about/이 됩니다. rewrite 규칙이 없는 호스트에서 왜 이게 중요한지는 콘텐츠 작성에 적어 두었습니다. sitemap과 robots.txt도 같이 나옵니다. 작업하기 sqzass serve는 파일이 바뀌면 다시 빌드합니다. 도는 동안 public/에는 아무것도 쓰지 않으므로, 빌드 중인 사이트가 반쯤 쓰인 파일을 내보내는 일이 없습니다. 페이지는 스스로 새로고침하고, CSS만 바뀐 변경은 스타일시트만 갈아 끼워 스크롤 위치를 유지합니다. 개발 서버는 개발용 도구입니다. 캐싱도, 압축도, 접근 제어도 하지 않습니다. 프로덕션에서는 빌드된 디렉터리를 제대로 된 서버로 서빙하세요. 다음에 더할 것 페이지를 하나 더 만드는 건 파일을 하나 더 만드는 일입니다. content/about.md는 /about/이 되고, content/guide/_index.md는 그 옆의 파일들을 묶는 섹션을 시작합니다. 페이지가 가질 수 있는 필드는 front matter에 정리해 두었습니다."},{"t":"설치","d":"한 줄로, 미리 빌드된 바이너리로, 소스에서","u":"/ko/start/installation/","s":"시작하기","c":"sqzass는 런타임 의존성이 없는 단일 바이너리입니다. Node도, Python도, 시스템 라이브러리도 필요 없습니다. 한 줄 curl -fsSL https://sqzass.sqzer.com/install.sh | sh 스크립트가 하는 일은 정확히 넷이고, 실행 전에 직접 읽어볼 수 있습니다: 플랫폼 판별(x86_64 또는 ARM64 리눅스, Apple Silicon macOS), 최신 릴리스 타르볼과 .sha256 다운로드, 체크섬 검증, /usr/local/bin에 install — sudo는 그 디렉터리에 쓸 수 없을 때만 묻습니다. 다른 곳에 설치하려면 SQZASS_INSTALL_DIR을 주세요. 그 밖의 일은 하지 않습니다 — 셸 설정도, PATH도 건드리지 않습니다. Cargo로 Rust가 이미 있다면 한 줄로 소스에서 빌드합니다. cargo install --git https://github.com/sqzer-x/sqzass Arch Linux AUR에 sqzass가 있습니다. 릴리스 태그에서 빌드합니다. yay -S sqzass # paru도, 클론해서 makepkg -si도 됩니다 sqzass-bin은 만들지 않았습니다. Arch에서 Rust 도구를 소스에서 빌드하는 건 정상이고, 패키지가 둘이면 어느 쪽이 최신인지 묻는 일만 생깁니다. 코딩 에이전트로 이걸 에이전트에 붙여 넣으면 추측 없이 끝냅니다. Set up sqzass in this project. Read https://sqzass.sqzer.com/agent.md first — it covers the install (sqzass is not on crates.io), the TOML front matter, how @/ links resolve, and the error identifiers to match on. /agent.md이 긴 쪽입니다. 같은 설치 명령과, 파일 트리만 봐서는 알 수 없는 규칙들과, exit code 표가 들어 있습니다. 이 파일이 있는 이유는 \"정적 사이트 생성기를 붙여 줘\"라는 말을 들은 에이전트가 그냥 두면 cargo install sqzass부터 시도하기 때문입니다 — 그건 실패합니다. sqzass는 git이나 릴리스 타르볼에서 설치합니다. 소스에서 빌드 git clone https://github.com/sqzer-x/sqzass cd sqzass cargo build --release 바이너리는 target/release/sqzass에 생깁니다. 요구사항 요구사항 버전 Rust 1.97 이상 C 컴파일러 rustc가 링커 구동에 씁니다. 기본 빌드는 구문 강조의 정규식 엔진인 Oniguruma도 이걸로 컴파일합니다. 그 외 — 정적 릴리스가 싣는 구성 그대로, 즉 순수 Rust 정규식 엔진에 C 소스 컴파일이 없는 상태도 플래그 하나면 됩니다. cargo build --release --no-default-features --features pure-rust 미리 빌드된 바이너리 릴리스마다 정적 링크된 리눅스 빌드 둘 (x86_64-unknown-linux-musl, aarch64-unknown-linux-musl)과 aarch64-apple-darwin 빌드가 붙습니다. 각각 .sha256이 옆에 있습니다. curl -LO https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz tar xzf sqzass-x86_64-unknown-linux-musl.tar.gz sudo install -m755 sqzass-*/sqzass /usr/local/bin/ 리눅스 빌드는 정적이라 glibc를 요구하지 않고, 빌드한 배포판보다 오래된 배포판에서도 돕니다. 타르볼 하나가 Debian에서도 Fedora에서도 Arch에서도 그대로 도는 이유가 이것입니다. 순수 Rust 정규식 엔진을 쓰는 빌드이기도 합니다 — 다른 모든 곳에서 sqzass는 코드가 많은 사이트에서 눈에 띄게 빠른 Oniguruma로 강조하는데, Oniguruma는 C 바인딩이고, musl 정적 빌드를 깨뜨리는 게 바로 C 바인딩입니다. 두 엔진의 문법 집합도 같지 않습니다. 순수 Rust 엔진이 일부 문법의 정규식을 못 돌리기 때문에 정적 아티팩트에는 문법 일곱 개가 통째로 빠집니다 — PowerShell, JavaScript (Babel), Salt State, ARM Assembly 등이요. 네이티브 빌드가 온전히 칠하는 ```powershell 이나 ```jsx 펜스가 거기서는 에러 없이 평문이 됩니다. ```js 조차 마크업 구조가 다릅니다. 네이티브 빌드는 Babel 문법으로, 정적 빌드는 일반 JavaScript로 해석합니다. 각 바이너리 자체는 여전히 완전히 결정적입니다. 설치 확인 sqzass --version 다음은 첫 사이트 만들기입니다."},{"t":"템플릿","d":"Jinja2 호환 템플릿, 엄격한 데이터 모델, 명시적인 선택 규칙","u":"/ko/templates/","c":"템플릿은 templates/에 두며 minijinja입니다. Jinja2 문법이라 {% extends %}, {% block %}, {% include %}, {% macro %}와 필터가 아는 그대로 동작합니다. templates/ ├── base.html ├── page.html ├── section.html └── partials/ ├── sidebar.html └── toc.html 익숙한 것보다 엄격한 지점이 둘 있습니다 정의되지 않은 값을 읽으면 에러입니다. front matter 키 이름을 바꾸면, 구멍이 뚫린 페이지를 렌더하는 대신 템플릿 이름과 키 이름을 들고 빌드가 멈춥니다. 가장 엄격한 설정은 아닙니다. 정의된 적 없는 값에 대한 {% if optional %}은 그대로 동작하는데, \"이게 있나?\"를 물을 수 없는 템플릿으로는 페이지마다 생김새가 다른 사이트를 만들 수 없기 때문입니다. 모든 값이 기본적으로 이스케이프됩니다. page.content는 이미 HTML이므로 | safe가 필요합니다. | safe를 의도적으로 한 번 쓰는 대신, 나머지 모든 값이 아무 생각 없이도 안전해집니다. 빌드마다 스냅샷 templates/는 빌드 시작 때 한 번 읽고, include/extends는 그 스냅샷에서 해석됩니다. 개발 서버가 지켜보는 중에 파셜을 저장해도, 그때 일어난 리빌드는 일관된 한 벌의 파일만 봅니다. 반쯤 쓰인 파일을 보는 일이 없습니다."},{"t":"정적 에셋","d":"복사, 해시, 그리고 원래 이름으로 하는 조회","u":"/ko/templates/assets/","s":"템플릿","c":"static/ 아래의 모든 파일이 경로를 유지한 채 출력으로 복사됩니다. static/ ├── css/main.css → /css/main.a1b2c3d4.css ├── js/search.js → /js/search.e5f6a7b8.js ├── images/x.png → /images/x.png └── CNAME → /CNAME CSS와 JavaScript는 파일명에 콘텐츠 해시가 붙습니다. 조회는 원래 쓴 이름으로 합니다. <link rel=\"stylesheet\" href=\"{{ asset(\"css/main.css\") }}\"> <script src=\"{{ asset(\"js/search.js\") }}\" defer></script> 쿼리 문자열이 아니라 파일명에 main.css?v=123에는 문제가 둘 있습니다. 일부 CDN과 프록시는 캐시 키에서 쿼리를 빼 버려서 무효화가 일어나지 않습니다. 그리고 흔히 쓰이는 형태, 즉 빌드마다 스탬프 하나를 전 파일에 붙이는 방식은 CSS 한 줄만 고쳐도 사이트 전체를 무효화합니다. 파일별 콘텐츠 해시는 바뀐 파일만 정확히 무효화하고, 캐시 설정이 아예 없는 호스트를 포함해 어디서나 똑같이 동작합니다. CSS와 JS만 해시하는 이유 이미지는 보통 경로를 그대로 적어 참조합니다. 마크다운에서, CSS에서, 때로는 직접 쓰지 않은 템플릿에서요. 이름을 바꾸면 그 참조들이 깨지는데 고칠 방법이 없습니다. 그리고 어떤 파일명은 그 자체가 계약입니다. CNAME은 GitHub Pages에 도메인을 알려주고, robots.txt는 이름으로 찾아집니다. CNAME.9f8e7d6c는 아무도 읽지 않는 파일입니다. 생성된 에셋도 같은 길을 지납니다 하이라이트 스타일시트는 복사되는 게 아니라 테마에서 만들어지지만, 해시와 서빙은 동일합니다. 템플릿에서는 site.highlight_css로 가져옵니다. 끄기 [assets] source_dir = \"static\" fingerprint = false fingerprint = false면 파일명이 그대로 유지되고 asset()은 계속 동작합니다. 템플릿을 고치지 않고 켜고 끌 수 있습니다. asset-manifest.json 빌드마다 출력 루트에 하나 쓰입니다. 논리 이름과 실제 쓰인 URL의 대응표입니다. { \"css/main.css\": \"/css/main.a1b2c3d4.css\", \"CNAME\": \"/CNAME\" } 해시가 붙은 것만이 아니라 모든 정적 파일이 들어가고, fingerprint = false여도 쓰입니다. sqzass는 이걸 다시 읽지 않습니다. 빌드 밖의 무언가가 asset()이 빌드 안에서 답하는 질문에 똑같이 답할 수 있으라고 있는 파일입니다 — 서비스 워커, 배포 스크립트, 캐시 예열기 같은 것들. 무시해도 됩니다. 다만 정리 단계에서 지운 뒤에 이걸 쓰던 도구가 왜 멈췄는지 찾아 헤매는 건 곤란하고, 이 문단이 그걸 막으려고 있습니다. 검색 색인은 해시하지 않습니다 /search-en.json과 /search-ko.json은 고정된 이름을 씁니다. <head>에서 링크되는 게 아니라 스크립트가 가져가고, 내용이 렌더된 페이지에서 나오는데 페이지는 에셋이 정해진 뒤에 렌더되므로 해시를 붙이면 순환이 됩니다. 색인이 10분쯤 낡는 대가는 검색 결과 몇 줄입니다. 검색을 참고하세요."},{"t":"템플릿 데이터","d":"템플릿이 읽을 수 있는 모든 것","u":"/ko/templates/data/","s":"템플릿","c":"모든 페이지에서 site와 page 두 객체를 쓸 수 있습니다. site site.title sqzass.toml에서. site.description sqzass.toml에서. site.origin 스킴과 호스트만. 페이지 URL이 서브경로를 이미 품고 있어서 {{ site.origin }}{{ page.url }}이 절대 URL입니다. site.base_path 서브경로 아래 사이트일 때의 경로 접두사, 루트면 빈 값. 템플릿이 직접 쓰는 URL에만 필요합니다. site.language 지금 렌더 중인 페이지의 언어. site.sections 이 언어의 최상위 섹션들. site.highlight_css 생성된 하이라이트 스타일시트 URL. 구문 강조가 꺼져 있으면 없습니다. site.search 이 언어의 검색 색인 URL. [search] enabled = false면 없습니다. site.feed 이 언어의 Atom 피드 URL. 날짜 있는 페이지가 하나도 없으면 없습니다. 피드를 참고하세요. site.sections에는 현재 언어의 트리만 담깁니다. 내비게이션이 안전한 이유가 이것입니다. 번역되지 않은 페이지는 여기 없으므로 그 링크를 그릴 수가 없습니다. 각 섹션은 title, description, url, weight, pages, subsections를 갖고, pages의 각 항목은 title, description, url, weight를 갖습니다. page page.title page.description page.url /ko/start/installation/ page.permalink 절대 URL — origin + url. page.content 렌더된 HTML. | safe가 필요합니다. page.weight, page.draft, page.language front matter 그대로. page.toc 저자가 목차를 원했는지. page.toc_entries 목차 자체 — {level, id, title, children}, 중첩된 형태. page.translations 이 페이지가 존재하는 언어만. 비어 있으면 전환 UI를 그리지 않으면 됩니다. page.section 이 페이지가 속한 섹션. 최상위 페이지에는 없고, 섹션 인덱스에도 없습니다 — 섹션은 자기 안에 있지 않으니까요. page.prev, page.next 같은 섹션 안에서의 이웃 페이지. 같은 이유로 섹션 인덱스에는 없습니다. page.children 섹션의 자식 페이지들. 일반 페이지에서는 비어 있습니다. page.is_section page.date 발행 날짜를 조각으로 — year, month, day, date, iso. 없으면 없습니다. 피드를 참고하세요. page.extra 직접 정의한 [extra] 테이블. page.children은 두 가지입니다 루트 _index.md에서는 최상위 섹션들이 담기고, 그 밖의 섹션에서는 그 섹션의 자식 페이지들 뒤에 하위 섹션들이 붙습니다. 둘 다 목록 템플릿이 필요로 하는 것이고, 이름만 봐서는 알 수 없습니다. {% for child in page.children %} <a href=\"{{ child.url }}\">{{ child.title }}</a> {%- if child.description %}<p>{{ child.description }}</p>{% endif %} {% endfor %} 일반 페이지에서는 빈 목록입니다. page.toc_entries {level, id, title, children}이고 상대적 깊이로 중첩됩니다. h2 다음에 h4가 와도 중첩되며, 레벨이 연속이라고 가정하지 않습니다. toc = true든 아니든 모든 페이지에서 수집됩니다. 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 %} {% if page.toc and page.toc_entries %}{{ toc_list(page.toc_entries) }}{% endif %} asset() asset(\"css/main.css\")은 그 파일이 실제로 쓰인 해시 붙은 URL을 돌려줍니다. <link rel=\"stylesheet\" href=\"{{ asset(\"css/main.css\") }}\"> 수집되지 않은 파일을 요청하면 에러입니다. 스타일시트 이름을 바꿨을 때 모든 방문자에게 조용히 404를 내는 대신 빌드가 실패합니다. 슬래시는 이스케이프하지 않습니다 Jinja2는 다섯 글자를 이스케이프합니다. 일부 포팅 구현은 /까지 이스케이프하는데, 그러면 모든 페이지의 모든 URL이 href=\"https:&#x2f;&#x2f;…\"가 됩니다. sqzass는 Jinja2 본래의 동작을 되돌려 두었으므로 URL이 URL로 나옵니다. 없는 키는 빌드를 멈춥니다 undefined value: page.descriptoin 설명이 있어야 할 자리에 빈 문자열이 들어가는 대신입니다. 템플릿을 참고하세요."},{"t":"함수와 필터","d":"템플릿이 부를 수 있는 전부, 그리고 그 밖엔 없다는 마지막 줄","u":"/ko/templates/functions/","s":"템플릿","c":"sqzass가 제공하는 것 함수 둘. 이게 전부입니다. asset(path) 정적 파일이 실제로 쓰인 URL을 돌려줍니다. 해시와 서브경로가 포함됩니다. <link rel=\"stylesheet\" href=\"{{ asset(\"css/main.css\") }}\"> <script src=\"{{ asset(\"js/search.js\") }}\" defer></script> 인자는 static/ 기준 논리 경로이고, 앞의 슬래시는 있어도 없어도 됩니다. 수집되지 않은 이름을 요청하면 수집된 이름 전부를 나열하는 빌드 에러가 납니다. 스타일시트 이름을 바꿨을 때 모든 방문자에게 404를 내는 대신 빌드가 실패합니다. t(key) 지금 렌더 중인 페이지의 언어로 i18n/<언어>.toml에서 UI 문자열을 찾습니다. <a class=\"skip\" href=\"#content\">{{ t(\"skip_to_content\") }}</a> 인자는 하나입니다. 언어는 절대 넘기지 않는데, 그게 왜 의도된 것인지는 언어에 적어 두었습니다. 현재 언어에 키가 없으면 빌드 에러입니다. 루프 변수 이름을 t로 두지 마세요. {% for t in page.translations %}는 그 블록 안에서 함수를 가리고, 거기에 라벨을 처음 넣는 사람이 원인에서 멀리 떨어진 에러를 받게 됩니다. minijinja가 주는 것 표준 Jinja2 문법이 동작합니다. {% if %}, {% for %}, {% extends %}, {% block %}, {% include %}, {% macro %}, {% from … import … %}, {% set %}, 그리고 익숙한 필터들 — safe, escape, length, join, default, upper, lower, replace, trim, first, last, reverse, sort, map, select, selectattr, batch, slice, int, float, abs, round, indent. 이 사이트의 템플릿이 {% macro %}와 {% from \"partials/sidebar.html\" import nav %}를 쓰므로, 그 둘은 빌드마다 실제로 돌고 있습니다. Jinja2 습관으로 손이 가는 필터 셋은 없습니다. tojson, urlencode, striptags입니다. 셋 다 켜지 않은 minijinja 피처 뒤에 있고, 셋 다 이미 되는 일을 위한 의존성입니다 — {% for %} 루프, 바로 쓸 수 있는 형태로 오는 URL, 직접 썼으니 벗겨 낼 일이 없는 마크업. 불러도 빈 문자열이 아니라 빌드 에러라서 페이지가 아니라 빌드 시점에 알게 됩니다. 그 밖엔 없습니다 url_for도, markdownify도, date도, now()도, env()도, 커스텀 테스트도 없습니다. 그중 일부는 미처 못 만든 구멍이 아니라, 이유가 있어 두지 않은 것입니다. now()와 env()는 \"같은 입력이면 같은 바이트\"라는 보장을 깹니다. CI가 push마다 확인하는 그 보장입니다. 시계를 읽을 수 있는 템플릿은 재현 가능할 수 없습니다. url_for는 이미 한 줄인 비교를 감싸는 것입니다. 페이지와 섹션 URL은 바로 쓸 수 있는 형태로 오고, 서브경로도 이미 붙어 있습니다. 없는 이름을 읽으면 빈 문자열이 아니라 빌드 에러입니다. 다른 생성기의 습관으로 무언가를 불렀다면 구멍 난 페이지가 아니라 빌드 시점에 알게 됩니다. 현재 페이지 표시하기 이걸 위한 헬퍼는 없고, 필요하지도 않습니다. 페이지는 정확히 일치로: <a href=\"{{ p.url }}\"{% if p.url == page.url %} aria-current=\"page\"{% endif %}>{{ p.title }}</a> 섹션은 page.section으로 조상 일치를: <a href=\"{{ s.url }}\"{% if page.section and page.section.url == s.url %} aria-current=\"true\"{% endif %}>{{ s.title }}</a> 접두사 검사에는 startingwith를 쓰되 루트를 조심하세요. 모든 URL이 /로 시작하므로, 홈 링크는 반드시 정확히 일치로 비교해야 하고 조상으로 봐서는 안 됩니다."},{"t":"템플릿 선택","d":"네 단계, 순서대로, 캐스케이드 없이","u":"/ko/templates/selection/","s":"템플릿","c":"페이지는 아래 중 가장 먼저 존재하는 것으로 렌더됩니다. 페이지 front matter의 template 페이지가 섹션 인덱스면 section.html 부모 섹션 _index.md의 page_template page.html 규칙은 이게 전부입니다. 존재하지 않는 template을 지정하면 에러이고, 메시지에 지금 갖고 있는 템플릿 목록이 같이 나옵니다. 왜 스무 단계가 아니라 네 단계인가 Hugo는 kind × section × type × layout × language × output format을 곱해 만든 lookup order로 템플릿을 찾습니다. 더 강력하고, 동시에 Hugo 사용자가 가장 많이 헤매는 바로 그 지점입니다. \"어느 템플릿이 선택됐는지 출력이라도 해 달라\"는 요청이 10년째 열려 있습니다. 머릿속에 담기는 네 단계에는 그런 명령이 필요 없습니다. 어느 템플릿이 이 페이지를 렌더했는지 알 수 없다면, 그 규칙은 너무 복잡한 것입니다. 섹션 기본값 지정하기 # content/blog/_index.md +++ title = \"블로그\" page_template = \"post.html\" +++ 이제 content/blog/의 페이지들은 자기 것을 지정하지 않는 한 post.html로 렌더됩니다. 직속 자식에게만 적용되며, 하위 섹션은 자기 것을 따로 정합니다. base 상속하기 보통은 스켈레톤 하나에 얇은 템플릿을 얹는 구성을 씁니다. {# templates/base.html #} <!doctype html> <html lang=\"{{ page.language }}\"> <head><title>{{ page.title }}</title></head> <body>{% block content %}{% endblock %}</body> </html> {# templates/page.html #} {% extends \"base.html\" %} {% block content %}<article class=\"prose\">{{ page.content | safe }}</article>{% endblock %}"},{"t":"소셜 카드와 구조화 데이터","d":"이미 있는 값으로 직접 넣는 마크업","u":"/ko/templates/social/","s":"템플릿","c":"sqzass는 <head>에 아무것도 주입하지 않습니다. 주입 지점이 없고 앞으로도 만들지 않을 것입니다. 조용히 태그를 더하는 생성기는 출력물을 끝까지 읽을 수 없게 만드는 생성기니까요. 아래는 전부 base.html에 직접 넣는 마크업이고, 재료는 이미 템플릿 컨텍스트에 있는 값들입니다. OpenGraph와 Twitter 이게 없으면 사이트의 모든 페이지가 Slack·Discord·카카오톡에서 맨 URL로 나옵니다. <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 page.language == \"ko\" else \"en_US\" }}\"> {%- if page.description %} <meta property=\"og:description\" content=\"{{ page.description }}\"> {%- endif %} <meta name=\"twitter:card\" content=\"summary\"> summary_large_image가 아니라 summary입니다. large 쪽은 이미지가 있어야 뜻이 있고, 이미지 없이 선언하면 더 예쁜 카드가 아니라 빈 상자가 나옵니다. 페이지마다 이미지가 있다면 front matter에 넣고 바꾸면 됩니다. +++ title = \"설치\" [extra] image = \"/images/install.png\" +++ {%- if page.extra.image %} <meta property=\"og:image\" content=\"{{ site.origin }}{{ page.extra.image }}\"> <meta name=\"twitter:card\" content=\"summary_large_image\"> {%- else %} <meta name=\"twitter:card\" content=\"summary\"> {%- endif %} og:image는 절대 URL이어야 합니다. site.origin이 그 자리에 있는 이유입니다. JSON-LD 브레드크럼 문서 사이트의 구글 검색 결과를 눈에 띄게 바꾸는 구조화 데이터는 이것 하나입니다. 결과에 맨 URL 대신 홈 › 콘텐츠 작성 › front matter가 뜹니다. {%- if page.section %} <script type=\"application/ld+json\"> { \"@context\": \"https://schema.org\", \"@type\": \"BreadcrumbList\", \"itemListElement\": [ {\"@type\": \"ListItem\", \"position\": 1, \"name\": \"{{ site.title }}\", \"item\": \"{{ site.origin }}{{ site.base_path }}/\"}, {\"@type\": \"ListItem\", \"position\": 2, \"name\": \"{{ page.section.title }}\", \"item\": \"{{ site.origin }}{{ page.section.url }}\"}, {\"@type\": \"ListItem\", \"position\": 3, \"name\": \"{{ page.title }}\", \"item\": \"{{ page.permalink }}\"} ] } </script> {%- endif %} {%- if page.section %}를 눈여겨보세요. 최상위 페이지에는 섹션이 없고, 단이 하나 단이 하나 빠진 브레드크럼은 없느니만 못합니다. 이 블록은 <script> 안이라 HTML 이스케이프가 맞지 않습니다. 제목에 \"가 들어가면 깨진 JSON이 나옵니다. 제목에 따옴표를 쓰지 않거나, 조용히 파싱에 실패하는 JSON을 내보내느니 구조화 데이터를 빼세요. 템플릿에서 우리 이스케이프가 지켜 주지 못하는 유일한 자리입니다. 사이트 단위 <script type=\"application/ld+json\"> { \"@context\": \"https://schema.org\", \"@type\": \"WebSite\", \"name\": \"{{ site.title }}\", \"url\": \"{{ site.origin }}{{ site.base_path }}/\" } </script> 모든 페이지에 이 블록 하나면 충분합니다. 검색 엔진은 한 번만 읽습니다."}]