[{"t":"sqzass","d":"A static site generator written in Rust — fast and deterministic","u":"/","c":"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, Cloudflare Pages, or a plain directory served over HTTP. Its speed claims are measurements with a published method, and its failures are loud: a broken reference stops the build and names the file, the line and the fix."},{"t":"Benchmark","d":"Five generators, one machine, one corpus — and the definitions published","u":"/benchmark/","c":"minimal — a heading and a paragraph blog — six paragraphs, a list, a quote, a link heavy, repeated code — five identical 20-line Rust blocks per page heavy, unique code — five distinct 20-line Rust blocks per page Every number Wall clock, median of three cold runs, and peak RSS. minimal blog heavy · repeat heavy · unique 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 Why heavy comes twice Because code repetition decides rankings, and most benchmarks do not say what theirs is. sqzass highlights each distinct block once per build, so a corpus whose generator repeats the same block is a different measurement from one where every block is unique — the gap between 187 ms and 1,298 ms is that variable. A single \"heavy\" number that does not state its repetition has hidden the thing that produced it. Method Cold. Before every run the output directory and each tool's caches are removed — .jekyll-cache, Astro's cacheDir and dist, Hugo's resources and build lock. Wall clock and peak RSS come from GNU time. Corpus. 1,000 pages plus a section index. The markdown bodies are byte-identical across tools; only the front matter syntax differs. A paragraph is one fixed 76-character sentence repeated four times. Highlighting is on everywhere, at build time, as each tool ships it: syntect + Oniguruma (sqzass), Chroma (Hugo), Giallo (Zola), Rouge (Jekyll), Shiki (Astro). Markup granularity differs — spans on the same heavy page: sqzass 3,205 · Jekyll 1,800 · Zola and Astro 1,700 · Hugo 600. sqzass emits the most detailed markup of the five and the numbers above. Templates. Each tool gets the same minimal no-theme layout: an HTML shell around the rendered content. Astro's time includes Node startup (~1 s). It is also doing more than markdown-to-HTML — it is a component framework. That is the honest caveat; the corpus is plain markdown, which is the workload this page is about."},{"t":"Writing content","d":"Pages, sections, front matter and the URLs they produce","u":"/content/","c":"Everything under content/ is a page. A directory with an _index.md in it is a section, and a section collects the pages beside it. Pages and sections content/ ├── _index.md → / ├── about.md → /about/ └── guide/ ├── _index.md → /guide/ ├── install.md → /guide/install/ └── deep/ ├── _index.md → /guide/deep/ └── dive.md → /guide/deep/dive/ A directory without an _index.md still becomes a section, titled after the directory, so its pages are collected and it appears in navigation — forgetting the file must not make pages vanish from the sidebar. What it does not get is an index page: /guide/ itself 404s until you add one. Add _index.md when the section needs a title of its own, a description, or a body. Why every URL is a directory Pages are written as <path>/index.html, never <path>.html. A host with rewrite rules can serve /about from about.html, but a host without them cannot, and sqzass is built to be correct on the host that gives you nothing. The directory form works everywhere — GitHub Pages, Cloudflare Pages, S3, or python3 -m http.server — because it asks for no cleverness from the server. The cost is that a link to /about (no trailing slash) gets redirected by most servers before it resolves. Link to /about/, or better, use @/ links and let sqzass write the URL. Slugs come from filenames install.md becomes /guide/install/. The title has nothing to do with it. This matters most in Korean. Transliterating a title would be the usual trick, but slug-style crates map Hangul to ASCII one syllable at a time and without context, so two different Korean titles can collapse onto the same path. A filename you chose is unambiguous and it is already unique in its directory. A Korean filename is percent-encoded UTF-8 and left alone. Override it per page with slug in front matter. Two pages claiming the same URL is a build error, not a last-writer-wins race. Ordering Sections sort their pages by weight (ascending), and pages without one fall back to their title. Set sort_by on a section's _index.md, or [nav] sort_by in sqzass.toml to change the default for the whole site. weight Ascending. The default. title Ascending. date Descending — newest first, undated pages last. See Feeds. Drafts draft = true keeps a page out of the build. --drafts on the command line, or [build] drafts = true in the config, puts them back."},{"t":"Front matter","d":"Every field a page can carry, and what it does","u":"/content/front-matter/","s":"Writing content","c":"Front matter is TOML between +++ fences at the top of the file. TOML is the only format — YAML would mean picking a parser, and the obvious Rust one has been archived, so the choice was between an unmaintained dependency and a format that needs none. +++ title = \"Installation\" +++ title is the only required field. Fields Field Type Default title string — Required. description string \"\" Used by templates for <meta name=\"description\"> and in search results. weight integer 0 Sort order within a section. Lower comes first. draft bool false Excluded from the build unless --drafts. date TOML date — Publication date. Feeds and sort_by = \"date\" use it. slug string filename stem The last URL segment. template string — Render with this template instead of the usual one. toc bool false Whether a table of contents should be shown. translation_key string the path minus the language suffix Links this page to its translations. See Languages. aliases array of strings [] Old URLs that should land here. Each one must be a root-absolute path, and each gets a redirect stub in the output. extra table {} Anything you want. Reaches templates as page.extra. toc is the author's intent, not the data: the table of contents is collected for every page regardless, and templates get it as page.toc_entries. That split lets a template show a contents list on long pages only, without you having to strip the data out. Section-only fields These do nothing on an ordinary page, and belong in an _index.md. Field Type Default sort_by \"weight\" | \"title\" | \"date\" site default How this section orders its pages. page_template string — Default template for pages in this section. Extra [extra] is an open table. Nothing in sqzass reads it; templates do. +++ title = \"Release notes\" [extra] version = \"0.2.0\" badge = \"beta\" +++ {% if page.extra.badge %}<span class=\"badge\">{{ page.extra.badge }}</span>{% endif %} Reading a key that does not exist is an error, not an empty string — see Template data for how strict that is and why. Errors point at your file A malformed value is reported with the line number in the source file, counted from the top of the file rather than from the end of the front matter, because the second one sends you to the wrong line."},{"t":"Languages","d":"Two languages from one content tree, with untranslated pages hidden","u":"/content/languages/","s":"Writing content","c":"Declare the languages in sqzass.toml. The default one lives at the root; every other one gets a prefix. default_language = \"en\" [languages.en] name = \"English\" weight = 1 [languages.ko] name = \"한국어\" weight = 2 /start/ is English. /ko/start/ is Korean. A suffix on the filename content/start/ ├── installation.md → /start/installation/ └── installation.ko.md → /ko/start/installation/ The two files sit next to each other, which means ls tells you what is not translated yet. A parallel content.ko/ tree would hide that behind a diff. Untranslated pages are hidden, not duplicated A page with no Korean version does not appear in the Korean navigation at all. The two alternatives are both worse: rendering the English text at a Korean URL creates duplicate content for search engines, and emitting a 404 sends a reader to a dead end from a link the site itself drew. Templates see page.translations, which contains only the languages this page actually exists in — so a language switcher can render exactly the choices that work: {% for t in page.translations %} <a href=\"{{ t.url }}\" hreflang=\"{{ t.code }}\">{{ t.name }}</a> {% endfor %} Empty list, no switcher. There is no state where the button lies. How translations are matched By the path under content/ with the language suffix removed — so start/installation.md and start/installation.ko.md both key on start/installation and are the same page in two languages. The path matters, not just the name: a/notes.md and b/notes.md are two different pages, not translations of each other. Only a suffix that is a language you declared is stripped, so a file named notes.ab.md keys on notes.ab unless ab is in your [languages]. When the filenames have to differ — a localised slug, say — set translation_key in both files to the same value. # content/start/installation.md +++ title = \"Installation\" translation_key = \"install\" +++ # content/start/설치.ko.md +++ title = \"설치\" translation_key = \"install\" +++ UI strings Page text lives in content/. The words the template supplies — \"Skip to content\", \"On this page\", \"Previous\" — live in i18n/<code>.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 reads the language from the page being rendered, so a template never asks which language it is in. It never has to be told, and there is no line where someone can forget to tell it. A key missing from one language is an error, and the message says which languages do have it: 번역 키 'next' 가 i18n/ko.toml 에 없습니다 (en 에는 있습니다) (in page.html:22) Falling back to the default language would put English labels inside a Korean page — visible to every Korean reader and invisible to whoever is maintaining the site, which is the same reason untranslated pages are hidden rather than duplicated. Sites with no i18n/ directory work fine. t is only needed by templates that call it. Korean specifics Two things are handled for you and are worth knowing about, because both are silent when they go wrong. **강조**한다 parses as emphasis. CommonMark's flanking rules were written for languages that put spaces around words, and under them a **bold** run immediately followed by a Korean particle is not emphasis at all. sqzass turns on comrak's cjk_friendly_emphasis, which is why the markdown you would naturally write works. It is a [markdown] key, and turning it off breaks Korean text in a way that looks like your markdown is wrong. Korean headings keep their Hangul ids. ## 설치 becomes id=\"설치\", so anchors and the table of contents work without transliteration."},{"t":"Internal links","d":"Links that point at the source file, and URLs the build writes","u":"/content/links/","s":"Writing content","c":"Write a link to the file and sqzass turns it into that page's URL: See [Installation](@/start/installation.md). The path after @/ is relative to content/, and it points at the markdown file, not the URL. Move the file, rename it, or change its slug, and the link follows. Broken links stop the build An @/ link that resolves to nothing is an error: docs/content/start/first-site.md: 어디도 가리키지 않는 링크가 있습니다: @/start/setup.md This is the point of the syntax. A plain /start/setup/ link that goes nowhere is indistinguishable from one that works until somebody clicks it in production. A reference the build can check is a reference the build does check. They follow the reader's language @/start/installation.md resolves to /start/installation/ for an English reader and /ko/start/installation/ for a Korean one — the same markdown, in both language trees, without a single conditional. If the target has no translation in the current language, the link falls back to the default language rather than breaking. See Languages. Because of that fallback, you write the path once and never write it with a language prefix. @/ko/start/installation.md is not a thing. It happens on the tree Rewriting is done through comrak's URL rewriter, on the AST, before any HTML exists. The shortcut — running a regex over the finished HTML — silently skips any element whose attributes are single-quoted or unquoted, which produces a class of bug you find in production rather than in the build. Images go through the same checker, but not the same syntax. @/ resolves against the table of markdown pages, and an image in static/ is not in it, so ![](@/images/x.png) stops the build. Write the root-absolute path instead — ![](/images/x.png) — and the build verifies the file is there, so a typo in an image path fails the same way a typo in a link does. Generated files are link targets too /sitemap.xml, /robots.txt, /llms.txt, /404.html, /feed-<lang>.xml and /search-<lang>.json are pages as far as the checker is concerned — the build produces them, so a link to one resolves. The exception is anything content-hashed. /css/main.css does not exist after a build; asset(\"css/main.css\") in a template is the way to reach it. Everything else is left alone External links, anchors, mailto: — untouched. sqzass only claims the @/ prefix."},{"t":"Markdown","d":"The extensions that are on, and the ones you can turn off","u":"/content/markdown/","s":"Writing content","c":"CommonMark, via comrak, with a set of extensions on by default. Each is a key under [markdown] in sqzass.toml. [markdown] footnotes = true tables = true tasklist = true strikethrough = true autolink = true alerts = true cjk_friendly_emphasis = true heading_anchors = \"right\" # none | left | right Alerts GitHub's callout syntax, built on blockquotes: > [!NOTE] > The static Linux build ships the pure-Rust regex engine. The static Linux build ships the pure-Rust regex engine. NOTE, TIP, IMPORTANT, WARNING and CAUTION are recognised. Tables | Key | Default | |---|---| | `output_dir` | `public` | Key Default output_dir public Task lists - [x] Search - [ ] Feeds Search Feeds Footnotes Text with a note. Text with a note[^1]. [^1]: The note itself. Headings Every heading gets an id, whether or not anchors are shown, because the table of contents and any deep link you hand out both depend on it. Repeated headings get -1, -2 suffixes, and the anchor and the contents entry are guaranteed to agree — they come from the same counter, in one pass. heading_anchors controls the visible # link: \"right\" (default), \"left\", or \"none\". Two settings that are not keys Both are fixed, and both are deliberate — this section exists so that looking for the option and not finding it ends here rather than in an issue. Raw HTML always passes through. Content is trusted: it is in your repository, written by you, reviewed in the same commit as the code. Sanitising it would be theatre. If sqzass ever ingests untrusted markdown, that becomes a per-source trust level rather than a global switch. Code fences produce <code class=\"language-rust\">, not <pre lang=\"rust\">. The class form is what every client-side highlighter and every copy-button snippet expects. Code Highlighted at build time into CSS classes. See Syntax highlighting. The note itself."},{"t":"Deploying","d":"A directory of files, and the hosts that will serve it","u":"/deploy/","c":"sqzass build -i mysite mysite/public is the site. Copy it somewhere that serves files and you are done — there is no runtime, no server component and no build step left to run. What sqzass does so the host does not have to Every URL is a directory containing index.html, so pretty URLs need no rewrite rules. Cache busting is in the filename, so it needs no cache headers. Nothing in the output depends on host configuration. This is deliberate, and the docs site is hosted on GitHub Pages to keep it honest: Pages offers no custom headers, no redirect rules and no rewrites, so anything that needed them would break here first. The payoff is that moving hosts is a copy. The same directory is correct on Cloudflare Pages, Netlify, S3 behind CloudFront, or nginx. What else is written sitemap.xml lists every page, with <xhtml:link> alternates for pages that exist in more than one language — which is the form Google asks for when a site is bilingual. robots.txt allows everything and points at the sitemap. Neither carries priority or changefreq, because Google confirmed in 2023 that it ignores both. Neither carries lastmod either, and that one is a choice: the honest sources for it are all unreliable here. A file's mtime is its checkout time, so in CI every page would claim to have changed this morning, and it would break the guarantee that two builds produce the same bytes. Git commit times are accurate but need full history, and actions/checkout clones shallow by default — so it would quietly stamp every page with the same date. Google ignores a site's lastmod entirely once it finds it untrustworthy, which makes a wrong one worse than none. llms.txt is a flat list of every page — title, URL and description — in the format proposed at llmstxt.org. A language model asked about your site can read one file instead of crawling the whole thing. It costs nothing to emit because the title, URL and description already exist. Put your own sitemap.xml, robots.txt or llms.txt in static/ and sqzass will not generate that file at all. It does not overwrite yours, and it does not silently ignore it either. Where these guides go Every one of them is the same two facts: run sqzass build, publish public/. The pages differ only in where those facts are written down and what each host calls its preview URL. Sites served under a path https://user.github.io/repo, https://group.gitlab.io/project and https://user.codeberg.page/repo are all project sites, and all of them serve your output under a path rather than at a domain root. Put the whole thing in base_url: base_url = \"https://user.github.io/repo\" sqzass then prefixes every URL it generates — page links, stylesheet hrefs, the search index — while the output directory stays flat, because that directory is the root the host serves. Leaving the path out is the one mistake here that is silent. The build succeeds, the pages are all there, and every link and stylesheet resolves one level too high. static/ is a passthrough Anything in static/ lands in the output with its path and name intact, which is how host-specific files work without sqzass knowing about any host: CNAME GitHub Pages custom domain .domains Codeberg Pages custom domain _headers, _redirects Netlify, Cloudflare Pages .well-known/* Domain verification, security.txt Only CSS and JavaScript get a content hash. A name that is the contract keeps its name. Two files worth knowing about .nojekyll is written into every build. Without it GitHub Pages runs the output through Jekyll, which swallows directories beginning with _. CNAME, if you need one, goes in static/ and is copied through with its name intact — a hashed CNAME is a file GitHub will never look for. What a build emits The complete list, so that \"moving hosts is a copy\" is something you can check rather than take on trust: everything in static/ paths and names intact; CSS and JS get a content hash assets/highlight.<hash>.css unless [highlight] enabled = false asset-manifest.json logical name → written URL, always <path>/index.html one per page search-<lang>.json one per language, unless [search] enabled = false feed-<lang>.xml one per language that has a dated page sitemap.xml, robots.txt unless static/ supplied one with that name llms.txt same terms 404.html when templates/404.html exists alias stubs one per aliases entry .nojekyll always, and it wins over a static/.nojekyll Nothing else, and nothing outside the output directory. Determinism Two builds of the same input produce byte-identical output. That makes the build safe to run in CI as a check, and it means a deploy that changes nothing uploads nothing."},{"t":"Cloudflare Pages","d":"Two settings in the dashboard, and the headers GitHub Pages would not give you","u":"/deploy/cloudflare-pages/","s":"Deploying","c":"In the project's build settings: Framework preset None Build command curl -sSL https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1 && ./sqzass build Build output directory public There is no configuration file to write. This is the host to move to when you need headers sqzass is built so nothing depends on host configuration, and this site is on GitHub Pages specifically to keep that honest. But GitHub Pages serves everything with cache-control: max-age=600 and there is no way to change it. Cloudflare Pages reads a _headers file, so the content-hashed filenames can finally mean what they are for: # static/_headers /css/* Cache-Control: public, max-age=31536000, immutable /js/* Cache-Control: public, max-age=31536000, immutable /* Cache-Control: public, max-age=600 A year is safe for those two directories precisely because their filenames change when their contents change. That is the payoff of hashing the name rather than appending a query string, and it is unavailable on a host that will not let you set the header. Put the file in static/ and it is copied through untouched. Redirects _redirects works the same way, but reach for aliases in front matter first: it lives next to the page that moved, it is checked at build time, and it follows the page to whatever host you use next. Preview deployments $CF_PAGES_URL holds the preview address: ./sqzass build --base-url \"$CF_PAGES_URL\""},{"t":"Codeberg Pages","d":"A pages branch, a .domains file, and Forgejo Actions","u":"/deploy/codeberg-pages/","s":"Deploying","c":"Codeberg serves the pages branch of a repository at https://<user>.codeberg.page/<repo>/, or the whole of a repository named pages at https://<user>.codeberg.page/. Building it in CI # .forgejo/workflows/deploy.yml on: push: branches: [main] jobs: deploy: runs-on: docker container: image: alpine:latest steps: - run: apk add --no-cache git nodejs - uses: actions/checkout@v4 - run: wget -qO- https://github.com/sqzer-x/sqzass/releases/latest/download/sqzass-x86_64-unknown-linux-musl.tar.gz | tar xz --strip-components=1 - run: ./sqzass build - name: Publish to the pages branch run: | cd public git init -q && git add -A git -c user.email=ci -c user.name=ci commit -qm \"Deploy\" git push -f \"https://$GITHUB_ACTOR:${{ secrets.PAGES_TOKEN }}@codeberg.org/$GITHUB_REPOSITORY.git\" HEAD:pages nodejs is there for the Forgejo Actions runner, not for sqzass. The subpath, again Unless the repository is named pages, the site lives under /<repo>/: base_url = \"https://myuser.codeberg.page/myrepo\" The failure mode is the same one GitLab has — a build that succeeds and a site where every link is one level too high. See GitLab Pages for the longer explanation. A custom domain Codeberg reads a .domains file from the root of the served directory. Put it in static/ and it comes through with its name intact, the same way CNAME does for GitHub Pages: # static/.domains example.com www.example.com With a custom domain there is no subpath, so base_url becomes the domain. Path::extension() returns nothing for a name that is all suffix, which is why files like .domains and .nojekyll need handling that files like main.css do not. sqzass copies them through; it does not try to fingerprint a name that is entirely an extension."},{"t":"GitHub Pages","d":"Where this site is, and the host that forced the design","u":"/deploy/github-pages/","s":"Deploying","c":"This site is built and deployed by the workflow below, from the same repository as the tool. CI builds docs/ with the binary it just compiled, so the documentation is a regression test for the generator. The workflow 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 is worth keeping. Cancelling a deploy that is halfway through leaves the site partly updated, which is worse than waiting. The retry is not defensive padding — it is the fix for a failure this site hit. concurrency serialises workflow runs, but a Pages deployment outlives its run: GitHub can still be processing the previous one after the workflow that started it has finished. Push two commits close together and the second lands in that window and dies with a 400, \"due to in progress deployment\". Waiting and trying once more is enough, and it is safe: the deploy fails before it changes anything, so a retry cannot leave the site half updated. A custom domain Put the domain in static/CNAME: sqzass.sqzer.com It is copied to the output with its name intact, and GitHub reads it from there — so the domain survives every deploy without being configured in the repository settings again. Point DNS at <user>.github.io with a CNAME record. If your DNS provider proxies traffic, turn the proxy off for this record. A proxy that terminates TLS itself will stop GitHub from issuing and renewing the certificate, and the failure shows up weeks later as an expired certificate rather than immediately as a broken deploy. Checking it curl -sI https://example.com/ | head -1 # 200, over HTTPS curl -sI https://example.com/nonexistent/ # 404, not 200 The second one matters. A host that answers 200 for a missing page will have search engines index your 404. What you give up No custom headers — GitHub Pages serves everything with max-age=600. No redirect rules. No preview deployments for pull requests. The first two are the reason the output is built the way it is, and the third is sqzass serve. If you later need real header control, the answer is moving to a host that has it, not stacking a CDN in front of one that does not."},{"t":"GitLab Pages","d":"One .gitlab-ci.yml, and the subpath that trips people up","u":"/deploy/gitlab-ci/","s":"Deploying","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 works because the release is statically linked — there is no glibc in that image and nothing here needs one. The job is named pages and the artifact directory is public because GitLab looks for exactly those two names. The subpath Unless you have set a custom domain, a GitLab project site is served at https://<group>.gitlab.io/<project>/. That path has to be in base_url: base_url = \"https://mygroup.gitlab.io/myproject\" sqzass then puts /myproject in front of every URL it generates — links, stylesheet hrefs, the search index location, everything — while the output directory stays flat, because that directory is the root GitLab serves. Getting this wrong is quiet rather than loud. The site builds, the pages exist, and every link and stylesheet resolves one level too high, so you get 404s and an unstyled page from a build that reported success. For a user or group site (<group>.gitlab.io) there is no path, and base_url is just the domain. Merge request previews pages: # … script: - wget -qO- … | tar xz --strip-components=1 - ./sqzass build --base-url \"$CI_PAGES_URL\" $CI_PAGES_URL already contains the project path, so this is also the shortest way to avoid writing the subpath twice."},{"t":"Netlify","d":"netlify.toml, and deploy previews that build with the right base_url","u":"/deploy/netlify/","s":"Deploying","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\" That is the whole configuration. No plugin, no build image, no runtime. Why the binary is downloaded rather than installed Netlify's build image has no Rust toolchain by default, and adding one costs minutes on every build. The Linux release is statically linked, so it runs in any image without matching a glibc version — which is the same property that makes it work in Alpine, in a scratch container, and on a machine older than the one that built it. If you would rather build from source, cargo install --git https://github.com/sqzer-x/sqzass works too and is slower. Deploy previews need their own base_url A preview runs at deploy-preview-42--yoursite.netlify.app, not at your domain. Netlify puts that address in $DEPLOY_PRIME_URL, and --base-url overrides the config for exactly this case — so canonical links, the sitemap and OpenGraph tags describe the preview rather than pointing every crawler at production. Without it the preview still works, because internal links are root-absolute and do not care what the host is called. What breaks is everything absolute: page.permalink, sitemap.xml, and the social tags. Pretty URLs, redirects and headers Nothing to configure. Every page is a directory containing index.html, so Netlify serves /start/ without a rewrite rule, and /404.html is picked up by name if you have a templates/404.html. _redirects and _headers are Netlify's own files and neither is required. If you want them, put them in static/ and they are copied through untouched — static/ is a passthrough, and files whose name is the contract keep their name. static/ ├── _headers └── _redirects What this does not need No netlify-plugin-*, no NODE_VERSION, no functions directory. The output is a directory of files."},{"t":"Vercel","d":"vercel.json, and why none of the framework machinery applies","u":"/deploy/vercel/","s":"Deploying","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 matters. Vercel's detection looks for a package.json and, finding none, will otherwise guess — and a wrong guess produces a build that fails in a way that reads as sqzass's fault. Preview deployments Vercel exposes the preview host as $VERCEL_URL, without a scheme: { \"buildCommand\": \"… && ./sqzass build --base-url \\\"https://$VERCEL_URL\\\"\" } Same reasoning as everywhere else: internal links are root-absolute and work regardless, but canonical, the sitemap and social tags are absolute and would otherwise announce production from a preview. Trailing slashes sqzass writes /start/index.html, so /start/ is the canonical form and every link it generates uses it. Vercel's default trailingSlash behaviour redirects between the two, which costs a hop on links written by hand as /start. Setting \"trailingSlash\": true removes the hop. This is a preference, not a requirement — the site is correct either way. What this does not need No serverless functions, no ISR, no edge config, no image optimisation. sqzass has nothing to run at request time, so the parts of Vercel that distinguish it from a file server are all inert here. That is worth saying plainly: if Vercel is where your team already deploys, this works. If you are choosing a host for a sqzass site, the reason to pick one over another is not going to be its framework support."},{"t":"Features","d":"Highlighting, search, feeds, and the development server","u":"/features/","c":"What sqzass does beyond turning markdown into HTML. Everything here is on by default and configured, if at all, from sqzass.toml. There is no plugin system and no theme system — the things below are the tool, not extensions to it."},{"t":"Development server","d":"Serving from memory, rebuilds on change, and reloads that keep your place","u":"/features/dev-server/","s":"Features","c":"sqzass serve -i docs http://127.0.0.1:3000, rebuilding whenever anything in content/, templates/, static/ or i18n/ changes, or sqzass.toml itself. The output directory is deliberately not watched: a build that triggered on its own output would never stop rebuilding. Flag -i, --input . Site root. -b, --bind 127.0.0.1 Bind address. -p, --port 3000 Port. --drafts Include draft pages. --base-url Override base_url. Nothing is written to disk The build goes into memory and is served from there. public/ is not touched while the server runs. That is not an optimisation. A browser that requests a file mid-rebuild would otherwise get whatever bytes had been written so far, and the resulting half-page is the kind of bug you chase for an hour before realising it was not your code. Serving a build that is complete or not served at all removes the window entirely. Reloading The reload script is injected as the page is served, not written into the build, so the output stays byte-identical to what a production build produces. A change that touched only CSS swaps the stylesheet's href in place instead of reloading — the page does not move, and you keep your scroll position while you nudge a margin. Anything else reloads the page. When a build fails The error is shown in the browser rather than only in the terminal you may not be looking at, and the last good version stays served underneath. Fix the file, save, and the overlay goes away. It is not a production server No caching, no compression, no access control, no TLS. It binds to localhost by default for that reason. --bind 0.0.0.0 will let a phone on your network see the site, which is useful and is as far as it should go."},{"t":"Feeds","d":"One Atom feed per language, from the dates you put in front matter","u":"/features/feeds/","s":"Features","c":"Give a page a date and it enters that language's feed. +++ title = \"Release 0.2.0\" description = \"What changed\" date = 2026-07-26 +++ public/ ├── feed-en.xml └── feed-ko.xml No dates, no feed A language with no dated pages gets no file, and templates get no site.feed to link. An empty feed is worse than an absent one: a subscriber who sees nothing arriving reads it as broken rather than as empty on purpose. This documentation has no dated pages, so this site publishes no feed. That is the feature working. Autodiscovery {% if site.feed %} <link rel=\"alternate\" type=\"application/atom+xml\" title=\"{{ site.title }}\" href=\"{{ site.feed }}\"> {% endif %} site.feed is the current language's feed, or nothing. The if is not defensive — it is the whole rule. Atom, not RSS 2.0 RSS dates are RFC 2822: Tue, 26 Jul 2026 00:00:00 +0000. That format needs a day-of-week we would have to compute and English month names we would have to embed — including in a Korean feed, where Jul is simply wrong. Atom uses RFC 3339, which is the shape a TOML date already has. Less code, one fewer thing to get subtly wrong, and every reader written this century handles Atom. What is in it The 20 most recent dated pages, newest first, each with a title, a permalink, an updated timestamp, and the description as its summary. Twenty is a cap, not a coincidence — a feed that grows without limit eventually becomes a download. Pages that land on the same instant are ordered by title, so they come out in the same order on every build. Since a bare date becomes midnight UTC, in practice that is every pair of posts written on the same day. A date with no time becomes midnight UTC. Atom will not accept a bare date, and a reader that cannot parse a timestamp drops the entry without telling anyone. An offset is carried through as written — 2026-07-26T09:00:00+09:00 stays that, because RFC 3339 accepts any offset and rewriting it as Z would publish a morning in Seoul as an evening in Seoul. Ordering still compares instants rather than the text, so a +09:00 morning sorts before a Z afternoon of the same day. A date that TOML reads as a time with no date — 10:30:00 — is an error rather than a value quietly dropped. You wrote a date; the build should not disagree in silence. Sorting by date # content/posts/_index.md +++ title = \"Posts\" sort_by = \"date\" +++ Newest first, unlike weight and title, which ascend. It is the order anyone reading a dated list expects. Pages without a date go last rather than first, which is what happens if you sort a missing date as zero. Showing the date There is no date filter and no format string. page.date is the parts: page.date.year 2026 page.date.month 7 page.date.day 26 page.date.date 2026-07-26, for <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 %} A format-string filter would mean shipping a date-formatting mini-language and then owning locale rules for every language someone writes in. The parts are data, and a template already knows how to arrange data."},{"t":"Syntax highlighting","d":"Highlighted at build time, as classes, in two themes","u":"/features/highlighting/","s":"Features","c":"Code blocks are highlighted while the site is built. No JavaScript runs in the reader's browser to colour them, so the page is coloured on first paint. [highlight] enabled = true theme_light = \"InspiredGitHub\" theme_dark = \"base16-ocean.dark\" Classes, never inline styles A highlighted block looks like this: <pre class=\"highlight\"><code class=\"language-rust\" data-lang=\"rust\"><span class=\"hl-source hl-rust\">…</span></code></pre> Not style=\"color:#268bd2\". The distinction decides three things. Dark mode is possible at all. Inline colours pin one theme into every document you have ever generated. Changing it means rebuilding the site; following the reader's preference means shipping both themes inside the markup. With classes, the two themes are two blocks of CSS, and switching is a CSS switch. A strict style-src stays available. A Content-Security-Policy that forbids inline styles is off the table the moment your HTML is full of them. The stylesheet is one file. Change a colour and every page changes, without rebuilding a single page of HTML. Two themes, one stylesheet theme_light and theme_dark are both compiled into the generated stylesheet. The dark rules are emitted twice — once under prefers-color-scheme: dark for readers who never touch a toggle, and once under [data-theme=\"dark\"] for sites like this one that offer a switch. Any theme name from syntect's default set works. A name that does not exist is a build error, and the message lists the ones that do. The prefix Classes are prefixed hl-. Without a prefix, syntect emits class names like source, keyword and string — words general enough to collide with your own stylesheet on a site about programming. Marking lines and naming files Options go after the language, space-separated, as key=value: ```rust hl_lines=2-3 name=src/main.rs fn main() { let marked = 2; let also_marked = 3; } ``` And rendered, on this site's stylesheet: fn main() { let marked = 2; let also_marked = 3; } hl_lines takes 1-based line numbers and closed ranges, comma-separated: hl_lines=2-4,7. Each listed line is wrapped in <mark class=\"hl-line\"> — <mark> is visible with no CSS at all, because browsers style it out of the box, and a site restyles it with .highlight mark. Highlighting continues across the boundary: marking the middle line of a block comment keeps it a comment. name labels the block with a file name. It ships as an attribute, not as markup — <code … data-name=\"src/main.rs\"> — and your CSS decides whether to show it: .prose pre code { display: block; width: max-content; min-width: 100%; } .prose pre code[data-name]::before { content: attr(data-name); display: block; } The first rule widens code to the longest line. It is what keeps the label — and hl_lines marks — running to the end of a block that scrolls sideways, instead of stopping at the visible width. Why this syntax and not Zola's rust,hl_lines=2-4: the comma glues the options onto the language token, which then fails syntax lookup and pollutes class=\"language-…\". After the first space the info string is free, and that is where comrak itself splits it. A typo is a build error, not a silent no-op. hl_line=3, linenos=true, hl_lines=9 on a three-line block, a key given twice — each stops the build and names the file; the alternative is a page that ships unhighlighted while looking finished. So does a fence that starts with options instead of a language: = never appears in a language name, so ```hl_lines=2 is an option in the language slot, not an unknown language. The options belong to the highlighter, so with enabled = false they are neither applied nor checked. Line numbers and a copy button are yours Neither is a configuration key, and line_numbers was deleted rather than shipped inert — a setting that does nothing is worse than an absent one, because someone sets it and waits. Ordinary blocks carry no per-line markup to hang a CSS counter on — a wrapper around every line of every block would tax all pages for a feature most blocks never use. A gutter is a few lines of JS over the line count: 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; } A copy button is about ten lines, and the language is already on the element: 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); }); Both live in your static/, both are yours to restyle, and neither adds a key to a configuration file that has to keep meaning the same thing forever. Turning it off enabled = false skips highlighting and emits no stylesheet. Code blocks still get class=\"language-rust\", so a client-side highlighter can pick them up."},{"t":"Search","d":"A substring index, one file per language, and why it is not a word index","u":"/features/search/","s":"Features","c":"Every build writes one index per language: public/ ├── search-en.json └── search-ko.json Each row is a page: its title, description, section, URL and body text. The client fetches the file for the current language the first time someone opens search, and scans it for substrings. Why substrings The usual approach is a word index: split the text into words at build time, look the query's words up at search time. It is smaller and it is faster, and for Korean it is wrong. Korean attaches particles to nouns, so 생성기 appears in running text as 생성기는, 생성기를, 생성기가. A word index can survive that with prefix matching. What it cannot survive is that Korean writes compounds without spaces: 최적화 inside 검색엔진최적화 is not a prefix of anything, and a word index will never return it. Neither will it find 존성 inside 의존성이. The apparent fix is to run a morphological analyser at index time, and it makes things worse. The dictionaries are general-purpose and do not know loanwords, which is most of a technical vocabulary: 템플릿은 comes back as 템플+릿+은, and the word 템플릿 stops matching the pages that are about it. Measured on a 2000-page Korean corpus, recall for 템플릿 fell from 1018 pages to 27. Nor can the browser be taught to compensate. Intl.Segmenter(\"ko\") returns Korean 어절 whole — ICU ships dictionary-based breakers for Chinese, Japanese and Thai, and none for Korean — so an index of morphemes has no way to agree with the query typed against it. Scanning the text for substrings has none of these problems, in any language, and it costs a JSON file. What it costs The index is the body text of every page. For this site that is around 80 KB per language, fetched once, on the first search. It grows linearly with your documentation, and there is a size past which this is the wrong design — but that size is far beyond a documentation site, and reaching it is a better problem than shipping search that cannot find your own words. A site that ships no search UI should not pay even that: [search] enabled = false skips the index entirely. Templates see the same switch as site.search — the current language's index URL, or nothing: {% if site.search %} <button id=\"search-trigger\" data-index=\"{{ site.search }}\">…</button> {% endif %} The same rule as site.feed: the if is not defensive, it is how a theme works on sites that turned search off. This site's search button, the palette and the footer link all sit behind it — and the URL comes from the build, not from the template gluing search- to a language code. Ranking Every term in the query must appear somewhere in a row — an AND, not an OR. A hit in the title outranks one in the description, which outranks one in the body, and a title that starts with the term outranks one that merely contains it. Results are capped at twelve, which is as many as anyone reads. Code blocks are indexed, because in documentation people search for the command they half-remember. The row schema The client is yours to build, so here is what it reads. One row per page: Key t title always present d description omitted when empty u URL always present, and it carries the subpath if you have one s parent section title omitted when empty, and on section index pages c body plain text, code blocks included always present Keys are one character because they repeat on every row. \"title\" instead of \"t\" costs a few tens of kilobytes across a few hundred pages, for a file nobody reads by hand. The client search.js on this site is a couple of hundred lines and has no dependencies. The dialog is a <dialog>, so Escape, the backdrop, the focus trap and returning focus to the trigger are the platform's behaviour rather than code. Open it with Ctrl/⌘ + /. It is example code, not a feature of the tool: sqzass writes the index, and what you build on top of it is yours."},{"t":"Getting started","d":"From installing sqzass to building your first site","u":"/start/","c":"Everything you need to get a site on screen. Every runtime message is in Korean — --help, errors, doctor findings, the dev server's log. There is no locale switch, and the documentation quotes the real strings rather than translating them, so what you read here is what the binary prints. The error identifiers (SQZASS_E_CONTENT) and the doctor check names (untranslated) are ASCII and stable, which is what a script should match on anyway. Where the design differs Markdown is transformed on the tree. Link rewriting, heading anchors and the table of contents are AST operations via comrak. The common shortcut — running regexes over the finished HTML — silently skips any element whose attributes are single-quoted or unquoted, and you find out in production. Templates cannot read a half-written file. templates/ is snapshotted once per build and minijinja's loader resolves include and extends from that snapshot, so a rebuild triggered while you are saving sees one consistent state. Broken references stop the build. An unresolved @/ link, a template that does not exist, two pages claiming the same URL, an unknown asset name — each is an error, not a warning you scroll past. The same input produces the same bytes. Two builds are byte-identical, and CI checks it on every push. Korean was not bolted on afterwards. A page's translations are linked automatically, and a language's navigation contains only pages that exist in it, so an untranslated page cannot leave a dead link behind. Search matches substrings rather than words, which is the only way 최적화 inside 검색엔진최적화 is ever found. Status Early, and honest about it: syntax highlighting, navigation, the table of contents, the asset pipeline, the dev server, search and Atom feeds all work. A theme system does not exist yet."},{"t":"Command line","d":"Commands, flags, exit codes and machine-readable output","u":"/start/cli/","s":"Getting started","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 works on any of them. init Writes a new site into DIR (default .), creating it if needed. Refuses to run where a sqzass.toml already exists. See Your first site. build Flag Default -i, --input . Site root — the directory holding sqzass.toml. -o, --output <input>/public Resolved against your shell's directory, not the site root. --drafts Include pages marked draft = true. --base-url from config Useful for preview deployments. --profile Per-phase timings on stderr; stdout stays as is. The output directory is emptied first, so a page you deleted does not linger as a ghost in the built site. --profile prints one line per phase — discover, assets, feeds, templates, render, search, generate, write — which is how you learn whether a slow build is your content or this tool: discover 750.8µs assets 1.4ms render 198.9ms write 1.3ms serve Serves from memory with live reload on http://127.0.0.1:3000. See Development server. doctor sqzass doctor -i mysite build already refuses anything it cannot resolve — a broken @/ link, a missing template, two pages claiming one URL, a misspelled configuration key. doctor is for what the build accepts and you might not have meant. Check base-url warn base_url is still the placeholder https://example.com. untranslated warn A page exists in some languages and not others. empty-section warn A section has no pages and its index has no body — the navigation entry leads nowhere. A single-page section whose _index.md carries content is fine. description note A page has no description. draft note A page is excluded from the build. unused-template note No page selects this template, and no template names it. --fail-on sets the gate, warn by default, and a gated run exits 7. The default is not note on purpose: notes are things to know, and a pipeline that stops for them gets switched off rather than fixed. sqzass doctor -i mysite --fail-on note # strict sqzass doctor -i mysite --json # every finding as data Exit codes A build either succeeds or tells you which part of your site it could not accept. The code is the answer to \"whose fault is it\" — CI can branch on it without parsing text. Code Identifier 0 Success. 1 SQZASS_E Something unclassified went wrong. 2 Bad command line. This one is clap's, not ours. 3 SQZASS_E_CONFIG sqzass.toml — unreadable, malformed, or a key that does not exist. 4 SQZASS_E_CONTENT Something under content/ — front matter, a missing title, two pages claiming one URL, an unresolved @/ link. 5 SQZASS_E_TEMPLATE templates/, i18n/, or an asset a template asked for and did not get. 6 SQZASS_E_IO Reading or writing failed. 7 doctor found something at or above the --fail-on gate. The identifier is printed with the message, so it can be searched for: error: [SQZASS_E_CONTENT] content/_index.md: 어디도 가리키지 않는 링크가 있습니다: @/nope.md These numbers are a contract. They will not be reassigned to different meanings, because a condition in someone's pipeline should not quietly start testing for something else. --json One JSON object on stdout, and nothing else, so a script reads a single pipe. $ sqzass build -i docs --json {\"ok\":true,\"output\":\"docs/public\",\"pages\":40} Failures go to stdout too, rather than being split across two streams: $ sqzass build -i broken --json {\"code\":4,\"error\":\"[SQZASS_E_CONTENT] …\",\"kind\":\"SQZASS_E_CONTENT\",\"ok\":false} $ echo $? 4 init and doctor have their own shapes: $ 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} For doctor, ok is false whenever anything reached the --fail-on gate, and gated counts those. file is omitted when a finding has none. The check strings are stable — they are what a script should match on, not the message. Without --json, messages go to stderr and results to stdout, which is what a person at a terminal expects."},{"t":"Configuration","d":"Every key in sqzass.toml, and what happens when you misspell one","u":"/start/configuration/","s":"Getting started","c":"sqzass.toml sits at the site root. Two keys are required; everything else has a default and behaves as if you had written the default. title = \"My site\" base_url = \"https://example.com\" A typo is an error 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` A key sqzass does not read is a key that does nothing, and a setting that silently does nothing is the same failure as a broken link: you asked for something, the tool agreed, and nothing happened. You would spend the afternoon wondering why your theme did not change. The same applies in front matter, and the line number points into your file rather than into the front matter block. Site Key Default title — Required. base_url — Required. Used for canonical URLs, the sitemap and robots.txt. description \"\" default_language \"en\" This language lives at the root; others get a URL prefix. [languages.<code>] [languages.en] name = \"English\" weight = 1 [languages.ko] name = \"한국어\" weight = 2 name is what a language switcher shows. weight orders them. Declaring no languages at all is fine — the site is then single-language under default_language. See Languages. [build] Key Default output_dir \"public\" Relative to the site root. drafts false --drafts sets this from the command line. A draft is excluded from the build entirely, not hidden by CSS. See Writing content. [markdown] Key Default footnotes true tables true tasklist true strikethrough true autolink true alerts true GitHub's > [!NOTE] callouts. cjk_friendly_emphasis true Leave this on for Korean. heading_anchors \"right\" none, left or right. cjk_friendly_emphasis is what makes **강조**한다 parse as emphasis. CommonMark's flanking rules assume spaces around words, and without this a bold run followed immediately by a Korean particle is not emphasis at all. Turning it off breaks Korean text in a way that reads like your markdown is wrong. See Markdown. [highlight] Key Default enabled true theme_light \"InspiredGitHub\" theme_dark \"base16-ocean.dark\" A theme name that does not exist is an error, and the message lists the ones that do. See Syntax highlighting. [assets] Key Default source_dir \"static\" fingerprint true Content hash in the filename, for CSS and JS. See Static assets. [nav] Key Default sort_by \"weight\" weight, title or date. A section can override it. How a section overrides it, and why date alone sorts newest first, are in Writing content. [search] Key Default enabled true Emit search-<lang>.json, one per language. The index carries the body text of every page, so it grows with your content — a site with no search UI has no reason to pay for it. Disabled, no index is written and no index URL exists for links to point at. See Search. Overriding from the command line --base-url and --drafts win over the file, which is what makes one configuration serve both a preview deployment and production. sqzass build -i mysite --base-url https://preview.example.com --drafts"},{"t":"Your first site","d":"From an empty directory to a page on screen","u":"/start/first-site/","s":"Getting started","c":"sqzass init mysite sqzass serve -i mysite That is the whole thing. init writes three files, serve puts them on http://127.0.0.1:3000, and there is no fourth step. What it wrote mysite/ ├── sqzass.toml ├── content/ │ └── _index.md └── templates/ └── page.html Three files, because a scaffold you have to delete half of is not a head start. There is no .gitignore you did not ask for, no example blog post, and no directory sqzass expects to own. init refuses to run in a directory that already has a sqzass.toml, so it cannot half-overwrite a site you already have. sqzass.toml Two keys. Everything else has a default, and a key you leave out behaves as if it were set to that default. title = \"mysite\" base_url = \"https://example.com\" content/_index.md The front page. Front matter is TOML between +++ fences, and title is the only field you must supply. +++ title = \"mysite\" +++ Hello. templates/page.html page.content is already HTML, so it needs | safe — templates escape by default, and that default is what keeps a stray < in your prose from becoming markup. <!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> The site.highlight_css line is the stylesheet the build generates from your syntax themes. Leave it out and code blocks come out unstyled. Building it sqzass build -i mysite The output lands in mysite/public. Every page is written as <path>/index.html, never <path>.html, so the URL is /about/ and not /about.html — see Writing content for why that matters on a host with no rewrite rules. A sitemap and a robots.txt come out too. Working on it sqzass serve rebuilds when a file changes. Nothing is written to public/ while it runs, so a rebuilding site never serves a half-written file. The page reloads itself; a change that only touched CSS swaps the stylesheet in place and keeps your scroll position. The dev server is a development tool. It does no caching, no compression and no access control. Serve the built directory with a real server in production. What to add next A second page is a second file. content/about.md becomes /about/, and content/guide/_index.md starts a section that collects everything beside it. Front matter lists the fields a page can carry."},{"t":"Installation","d":"One line, a prebuilt binary, or from source","u":"/start/installation/","s":"Getting started","c":"sqzass is a single binary with no runtime dependencies. No Node, no Python, no system libraries. One line curl -fsSL https://sqzass.sqzer.com/install.sh | sh The script does exactly four things, and you can read it first: detect the platform (Linux on x86_64 or ARM64, or Apple-silicon macOS), download the latest release tarball with its .sha256, verify the checksum, and install the binary into /usr/local/bin — asking for sudo only if that directory is not writable. Set SQZASS_INSTALL_DIR to install somewhere else. It touches nothing else: no shell config, no PATH edits. With Cargo If you already have Rust, one line builds it from source: cargo install --git https://github.com/sqzer-x/sqzass Arch Linux sqzass is in the AUR, built from the release tag: yay -S sqzass # or paru, or makepkg -si on a clone There is no sqzass-bin. Building a Rust tool from source is normal on Arch, and two packages would only raise the question of which one is current. With a coding agent Paste this at an agent and it has enough to finish without guessing: 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 is the long version: the same install commands, the rules that are not guessable from a file tree, and the exit-code table. It exists because an agent asked to \"add a static site generator\" will otherwise reach for cargo install sqzass, which fails — sqzass is installed from git or a release tarball. From source git clone https://github.com/sqzer-x/sqzass cd sqzass cargo build --release The binary lands at target/release/sqzass. Requirements Requirement Version Rust 1.97 or newer A C compiler rustc drives the linker with it; the default build also compiles Oniguruma, the highlighter's regex engine, with it. Everything else — The exact configuration the static release ships — the pure-Rust regex engine, no C source compiled — is a feature flag away: cargo build --release --no-default-features --features pure-rust Prebuilt binaries Attached to each release: two statically linked Linux builds, x86_64-unknown-linux-musl and aarch64-unknown-linux-musl, and an aarch64-apple-darwin one, each with a .sha256 beside it. 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/ The Linux builds are static, so they have no glibc requirement and run on distributions older than the one they were built on — which is also why one tarball covers Debian, Fedora and Arch alike. They are the builds that use the pure-Rust regex engine: everywhere else sqzass highlights with Oniguruma, which is measurably faster on code-heavy sites, but Oniguruma is a C binding and a C binding is exactly what breaks a musl static build. The engines' grammar sets are not identical either: the pure-Rust engine cannot run a few grammars' regexes, so the static artifacts drop seven of them entirely — PowerShell, JavaScript (Babel), Salt State and ARM Assembly among them. A ```powershell or ```jsx fence that a native build highlights in full falls back to plain text there, without an error. Even ```js differs in markup structure: the native build resolves it to the Babel grammar, the static ones to plain JavaScript. Each binary is still fully deterministic on its own. Checking the install sqzass --version Next: Your first site."},{"t":"Templates","d":"Jinja2-compatible templates, a strict data model, and explicit selection","u":"/templates/","c":"Templates live in templates/ and are minijinja — Jinja2 syntax, so {% extends %}, {% block %}, {% include %}, {% macro %} and filters all work the way you expect. templates/ ├── base.html ├── page.html ├── section.html └── partials/ ├── sidebar.html └── toc.html Two things are stricter than you may be used to Undefined access is an error. Rename a front matter key and the build stops with the name of the template and the key, instead of rendering a page with a hole in it. It is not the strictest setting available — {% if optional %} on something that was never defined still works, because a template that cannot ask \"is this here?\" is not usable for a site where pages differ. Everything is escaped by default. page.content is already HTML, so it needs | safe. That is one deliberate | safe in exchange for every other value being safe without you thinking about it. A snapshot per build templates/ is read once at the start of a build, and include/extends resolve against that snapshot. Save a partial while the dev server is watching and the rebuild it triggers sees one consistent set of files — never a half-written one."},{"t":"Static assets","d":"Copying, hashing, and lookup by the original name","u":"/templates/assets/","s":"Templates","c":"Everything under static/ is copied into the output, keeping its path. static/ ├── css/main.css → /css/main.a1b2c3d4.css ├── js/search.js → /js/search.e5f6a7b8.js ├── images/x.png → /images/x.png └── CNAME → /CNAME CSS and JavaScript get a content hash in the filename. Look them up by the name you wrote: <link rel=\"stylesheet\" href=\"{{ asset(\"css/main.css\") }}\"> <script src=\"{{ asset(\"js/search.js\") }}\" defer></script> In the filename, not a query string main.css?v=123 has two problems. Some CDNs and proxies drop the query from the cache key, so the bust does not happen. And the common shape of it — one build stamp on every file — invalidates the whole site when you fix one line of CSS. A per-file content hash invalidates exactly the file that changed, and works the same on every host, including hosts with no cache configuration at all. Why only CSS and JS Images are usually referenced by a literal path — from markdown, from CSS, sometimes from a template you did not write. Renaming them breaks those references without a way to fix them up. And some filenames are the contract. CNAME tells GitHub Pages your domain; robots.txt is looked for by name. CNAME.9f8e7d6c is a file nothing will ever read. Generated assets go through the same path The highlight stylesheet is built from your themes rather than copied, but it is hashed and served identically. Templates reach it as site.highlight_css. Turning it off [assets] source_dir = \"static\" fingerprint = false With fingerprint = false, files keep their names and asset() still works — so you can flip it without editing a template. asset-manifest.json Every build writes one, at the output root, mapping logical names to the URLs they were written to: { \"css/main.css\": \"/css/main.a1b2c3d4.css\", \"CNAME\": \"/CNAME\" } It contains every static file, not only the hashed ones, and it is written even with fingerprint = false. Nothing in sqzass reads it back — it is there so that something outside the build can answer the same question asset() answers inside it: a service worker, a deploy script, a cache warmer. It is safe to ignore. It is not safe to delete in a cleanup step and then wonder why a tool that depended on it stopped working, which is the situation this paragraph exists to prevent. The search index is not hashed /search-en.json and /search-ko.json keep fixed names. They are fetched by script rather than linked from <head>, and their contents come from the rendered pages — which are rendered after assets are resolved, so hashing them would be circular. Ten minutes of a stale index costs a few search hits. See Search."},{"t":"Template data","d":"Everything a template can read","u":"/templates/data/","s":"Templates","c":"Two objects are in scope on every page: site and page. site site.title From sqzass.toml. site.description From sqzass.toml. site.origin Scheme and host only. {{ site.origin }}{{ page.url }} is an absolute URL — page URLs already carry the subpath. site.base_path The path prefix when the site lives under one, else empty. Only for URLs a template writes by hand. site.language The language of the page being rendered. site.sections Top-level sections in this language. site.highlight_css URL of the generated highlight stylesheet, or nothing if highlighting is off. site.search URL of this language's search index, or nothing with [search] enabled = false. site.feed URL of this language's Atom feed, or nothing when no page in it carries a date. See Feeds. site.sections contains only the current language's tree, which is what makes navigation safe: an untranslated page is not in it, so a link to it cannot be drawn. Each section carries title, description, url, weight, pages and subsections, and each entry in pages has title, description, url and weight. page page.title page.description page.url /ko/start/installation/ page.permalink The absolute URL — origin + url. page.content Rendered HTML. Needs | safe. page.weight, page.draft, page.language Front matter, as given. page.toc Whether the author asked for a contents list. page.toc_entries The contents themselves — {level, id, title, children}, nested. page.translations Only languages this page exists in. Empty means no switcher. page.section The section this page belongs to. Nothing at the top level, and nothing on a section index — a section is not inside itself. page.prev, page.next The neighbouring pages within this section. Nothing on a section index, for the same reason. page.children A section's own pages. Empty on ordinary pages. page.is_section page.date The publication date in parts — year, month, day, date, iso — or nothing. See Feeds. page.extra Your [extra] table. page.children is two things On the root _index.md it holds the top-level sections. On any other section it holds that section's own pages, followed by its subsections. Both are what a listing template needs, and neither is obvious from the name. {% for child in page.children %} <a href=\"{{ child.url }}\">{{ child.title }}</a> {%- if child.description %}<p>{{ child.description }}</p>{% endif %} {% endfor %} Ordinary pages have an empty list. page.toc_entries {level, id, title, children}, nested by relative depth — an h2 followed by an h4 nests, without assuming the levels are consecutive. It is collected for every page, whether or not toc = true; the front matter field is the author's intent, and the data is there either way so a template can decide. Rendering it needs a recursive macro: {% 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\") returns the hashed URL that file was written to: <link rel=\"stylesheet\" href=\"{{ asset(\"css/main.css\") }}\"> Asking for a file that was not collected is an error, so a renamed stylesheet fails the build instead of silently 404ing for every visitor. Slashes are not escaped Jinja2 escapes five characters. Some ports escape / as well, which turns every URL on every page into href=\"https:&#x2f;&#x2f;…\". sqzass restores Jinja2's own behaviour, so URLs come out as URLs. Missing keys stop the build undefined value: page.descriptoin Rather than an empty string where your description was. See Templates."},{"t":"Functions and filters","d":"Everything a template can call, and the closing line that nothing else exists","u":"/templates/functions/","s":"Templates","c":"Provided by sqzass Two functions. That is the whole list. asset(path) Returns the URL a static file was written to, hash and subpath included. <link rel=\"stylesheet\" href=\"{{ asset(\"css/main.css\") }}\"> <script src=\"{{ asset(\"js/search.js\") }}\" defer></script> The argument is the logical path under static/, with or without a leading slash. A name that was not collected is a build error listing every name that was — a renamed stylesheet fails the build instead of 404ing for every visitor. t(key) Looks a UI string up in i18n/<language>.toml, for the language of the page being rendered. <a class=\"skip\" href=\"#content\">{{ t(\"skip_to_content\") }}</a> One argument. The language is never passed in — see Languages for why that is deliberate. A key missing from the current language is a build error. Do not name a loop variable t. {% for t in page.translations %} shadows the function inside that block, and the first person to add a label there gets an error a long way from its cause. From minijinja Standard Jinja2 syntax works: {% if %}, {% for %}, {% extends %}, {% block %}, {% include %}, {% macro %}, {% from … import … %}, {% set %}, and the usual filters — safe, escape, length, join, default, upper, lower, replace, trim, first, last, reverse, sort, map, select, selectattr, batch, slice, int, float, abs, round, indent. Our own templates use {% macro %} and {% from \"partials/sidebar.html\" import nav %}, so those two are exercised on every build. Three filters a Jinja2 habit reaches for are not here: tojson, urlencode and striptags. Each is behind a minijinja feature we leave off, and each is a dependency for something already covered — a {% for %} loop, a URL that arrives ready to use, or markup you did not have to strip because you wrote it. Calling one is a build error, not an empty string, so you find out at build time rather than in the page. Nothing else exists There is no url_for, no markdownify, no date, no now(), no env(), no custom tests. Some of those are absences with reasons rather than gaps: now() and env() would break the guarantee that two builds of the same input produce identical bytes, which CI checks on every push. A template that can read the clock cannot be reproducible. url_for would wrap a comparison that is already one line. Page and section URLs arrive ready to use, and they already carry the subpath. Reading a name that does not exist is a build error, not an empty string — so if you call something from another generator by habit, you find out at build time rather than from a page with a hole in it. Marking the current page There is no helper for this, and none is needed. Exact match for a page: <a href=\"{{ p.url }}\"{% if p.url == page.url %} aria-current=\"page\"{% endif %}>{{ p.title }}</a> Ancestor match for a section, using page.section: <a href=\"{{ s.url }}\"{% if page.section and page.section.url == s.url %} aria-current=\"true\"{% endif %}>{{ s.title }}</a> For a prefix test use startingwith, but be careful with the root: every URL starts with /, so the home link must be compared exactly and never as an ancestor."},{"t":"Choosing a template","d":"Four steps, in order, and no cascade","u":"/templates/selection/","s":"Templates","c":"A page is rendered with the first of these that exists: the template named in the page's front matter section.html, if the page is a section index the page_template set on the parent section's _index.md page.html That is the whole rule. Naming a template that does not exist is an error, and the message lists the templates you do have. Why it is four steps and not twenty Hugo resolves templates through a lookup order built from kind × section × type × layout × language × output format. It is more powerful, and it is the single thing Hugo users get lost in most often — there is a decade-old request open asking the tool to at least print which template it picked. Four steps you can hold in your head need no such command. If you cannot tell which template rendered a page, the rule is too complicated. Setting a section's default # content/blog/_index.md +++ title = \"Blog\" page_template = \"post.html\" +++ Every page in content/blog/ now renders with post.html unless it names its own. Note that this reaches direct children only; a subsection sets its own. Extending a base The usual arrangement is one skeleton and thin templates on top: {# 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":"Social cards and structured data","d":"Markup you own, from values that already exist","u":"/templates/social/","s":"Templates","c":"sqzass injects nothing into <head>. There is no injection point and there will not be one — a generator that quietly adds tags is a generator you cannot fully read the output of. Everything below is markup you put in your own base.html, built from values already in the template context. OpenGraph and Twitter Without these, every page of your site renders as a bare URL in Slack, Discord and KakaoTalk. <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, not summary_large_image — the large variant needs an image, and declaring it without one produces an empty box rather than a nicer card. If you have a per-page image, put it in front matter and switch: +++ title = \"Installation\" [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 must be absolute, which is what site.origin is for. Breadcrumbs as JSON-LD This is the one piece of structured data that visibly changes a Google result for a documentation site: the result shows Home › Writing content › Front matter instead of a bare URL. {%- 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 %} Note the {%- if page.section %}: top-level pages have no section, and a breadcrumb with a missing rung is worse than none. This block is inside <script>, where HTML escaping is wrong — a title containing \" produces invalid JSON. Keep titles free of quotes, or drop the structured data rather than shipping JSON that silently fails to parse. This is the one place in a template where our escaping does not protect you. Site-level <script type=\"application/ld+json\"> { \"@context\": \"https://schema.org\", \"@type\": \"WebSite\", \"name\": \"{{ site.title }}\", \"url\": \"{{ site.origin }}{{ site.base_path }}/\" } </script> One block, on every page, is enough. Search engines read it once."}]