[{"excerpt":"Open any page that fails Core Web Vitals and there is a good chance the culprit is a single JPEG. The Largest Contentful Paint element — the thing Google times at 2.5 seconds for a passing grade…","lang":"","locale":"","tags":["performance","images"],"taxonomies":{"tag":["performance","images"]},"text":"Open any page that fails Core Web Vitals and there is a good chance the culprit\nis a single JPEG. The Largest Contentful Paint element — the thing Google times\nat 2.5 seconds for a passing grade, measured at the 75th percentile of real\nvisits — is typically a hero image, a large text block, or a video. On a\nmarketing page it is nearly always the image.\nThe depressing part is how mechanical the fix is. A\n2025 HTTP Archive analysis\nfound that over 60% of image-heavy sites still serve one image size to every\ndevice, meaning phones download files three to eight times larger than their\nscreens can display. That is not a hard engineering problem. It is a build-step\nthat nobody added.\nSo let us add it.\nThe one-liner that does most of the work\n{{ $set := imageSrcSet \u0026quot;hero.jpg\u0026quot; (dict \u0026quot;widths\u0026quot; (slice 480 768 1200 1920) \u0026quot;format\u0026quot; \u0026quot;webp\u0026quot;) }}\n\u0026lt;img src=\u0026quot;{{ $set.Default.URL }}\u0026quot;\n     srcset=\u0026quot;{{ $set.SrcSet }}\u0026quot;\n     sizes=\u0026quot;(min-width: 64rem) 60vw, 100vw\u0026quot;\n     width=\u0026quot;{{ $set.Default.Width }}\u0026quot;\n     height=\u0026quot;{{ $set.Default.Height }}\u0026quot;\n     fetchpriority=\u0026quot;high\u0026quot;\n     alt=\u0026quot;Waterfall in a granite gorge\u0026quot;\u0026gt;\n\nFour variants get generated at build time, in WebP, from one source file. What\neach attribute is actually doing:\nsrcset hands the browser a menu. sizes tells it how wide the image\nwill be rendered at each breakpoint — without that, the browser assumes the\nfull viewport width and quietly picks the largest candidate, which undoes the\nwhole exercise. If your layout is genuinely full-bleed everywhere, SSG's\nimage_sizes_attr config (default 100vw) covers the generated markup;\nanywhere else, write the value yourself, because only you know the layout.\nwidth and height are not decoration. They let the browser reserve the\nbox before the bytes arrive, which is the difference between a CLS of 0.0 and a\nlayout that jumps as the photo lands. The result object hands you the real\npost-resize numbers, so there is no excuse to guess.\nfetchpriority=\u0026quot;high\u0026quot; is the cheapest LCP win available and is worth\nsetting on exactly one image per page — the hero. Add \u0026lt;link rel=\u0026quot;preload\u0026quot;\u0026gt;\nfor it if the image is discovered late, for instance from CSS.\nPick 3–5 widths based on file-size steps rather than the device widths of the\nmonth, and stop at 2× DPR. Chasing 3× triples your build output to serve\ndisplays that will downscale the result anyway.\nCrops are a content decision, not a CSS decision\nobject-fit: cover is fine until the interesting part of the photograph is not\nin the middle. The pipeline offers three answers, in increasing order of\nopinion:\n{{/* fit: largest size inside the box, aspect preserved */}}\n{{ $thumb := imageResize \u0026quot;team.jpg\u0026quot; (dict \u0026quot;width\u0026quot; 480 \u0026quot;height\u0026quot; 320 \u0026quot;mode\u0026quot; \u0026quot;fit\u0026quot;) }}\n\n{{/* fill: exact dimensions, cropped, anchored where you say */}}\n{{ $card := imageResize \u0026quot;team.jpg\u0026quot; (dict \u0026quot;width\u0026quot; 480 \u0026quot;height\u0026quot; 320 \u0026quot;mode\u0026quot; \u0026quot;fill\u0026quot; \u0026quot;anchor\u0026quot; \u0026quot;north\u0026quot;) }}\n\n{{/* focal point: crop stays centred on 0..1 coordinates you choose */}}\n{{ $face := imageCrop \u0026quot;team.jpg\u0026quot; (dict \u0026quot;width\u0026quot; 400 \u0026quot;height\u0026quot; 400 \u0026quot;focusX\u0026quot; 0.32 \u0026quot;focusY\u0026quot; 0.2) }}\n\nfill with \u0026quot;anchor\u0026quot; \u0026quot;north\u0026quot; is the one that saves group photos, because\nfaces live at the top and centre-cropping decapitates people. A focal point is\nwhat you want when the subject is somewhere specific and you can be bothered to\nsay where — frontmatter is a reasonable home for those two numbers.\nFilters compose in declared order, which matters more than it sounds:\n{{ $i := imageFilter \u0026quot;photo.jpg\u0026quot; (slice\n    (dict \u0026quot;name\u0026quot; \u0026quot;grayscale\u0026quot;)\n    (dict \u0026quot;name\u0026quot; \u0026quot;contrast\u0026quot; \u0026quot;amount\u0026quot; 1.1)\n    (dict \u0026quot;name\u0026quot; \u0026quot;sharpen\u0026quot; \u0026quot;amount\u0026quot; 0.3)\n) (dict \u0026quot;format\u0026quot; \u0026quot;webp\u0026quot; \u0026quot;quality\u0026quot; 82) }}\n\nSharpening before downscaling produces crunch; sharpening after produces\ndetail. The pipeline will not reorder your operations to be helpful.\nWhy the filenames look like that\nEvery generated file is named \u0026lt;base\u0026gt;.\u0026lt;hash10\u0026gt;.\u0026lt;ext\u0026gt;, where the hash is\nsha256(source bytes + normalized operations + processor version).\nThis is the boring part that pays rent. The same source with the same\noperations produces the same filename on your laptop, in CI, and after you\ndelete the whole cache — so a CDN can cache it forever, and a rebuild that\nchanged nothing changes nothing. Edit the photograph and the hash moves, which\nis cache invalidation you did not have to think about. Concurrent identical\nrequests are processed once.\nThe flip side is that an unused variant lingers in the cache after you stop\nreferencing it. --images-gc deletes what the finished build no longer\nmentions; --images-gc-dry counts first if you are nervous.\nThe AVIF conversation\nHere is the part where an honest post beats a feature list.\nAVIF now clears 95% browser support and runs 20–30% smaller than WebP,\nwhich makes it the obvious default in 2026 — and SSG does not encode it.\nThere is no portable, CGO-free AVIF encoder in Go, and adding a C dependency\nwould cost the single-static-binary property that makes the tool worth using.\nThat is a real trade-off, not a rounding error. What to do about it:\n\nWebP is not a consolation prize. It sits at 97%+ support with roughly\n25–34% savings over JPEG and decodes fast. Most of the win, none of the\ntoolchain.\nLet the CDN do it. Cloudflare Polish, and the equivalent on every other\nimage CDN, converts on the edge based on the request's Accept header. Your\nbuild stays pure Go; the last hop serves AVIF to browsers that asked for it.\nOr hand-roll \u0026lt;picture\u0026gt; for the one image that matters, with an AVIF\nfile you produced elsewhere and a WebP fallback from the pipeline. One hero\nis worth the manual step; four hundred thumbnails are not.\n\nTwo more constraints worth knowing before they surprise you: WebP encoding\nshells out to cwebp, so a build box without it gets a descriptive error\nrather than a silent JPEG — install webp in CI. And animated GIFs error out\ninstead of being flattened into a still frame, because a silently non-animated\nGIF is a bug you find in production.\nWhat you get for free\nEXIF metadata — including GPS coordinates — is stripped, because outputs are\nre-encoded pixels rather than copied files. Orientation is normalised first, so\nthat upright-on-your-phone, sideways-on-the-web classic does not happen.\nSources above 80 megapixels or 20,000 pixels per side are refused, which is a\ndecompression-bomb guard rather than an opinion about your photography.\nThe hero on this site's homepage is the whole pipeline in three lines: two WebP\nvariants at 1920 and 900 pixels, laid under a gradient scrim so the headline\nkeeps 16.3:1 contrast against a photograph. The logo beside it goes through the\nsame code but comes out as PNG, because it has an alpha channel and PNG needs\nno external encoder.\nNone of this is clever. It is a build step that runs while you are doing\nsomething else, and it is the difference between a 2.5-second LCP and an\napology to your SEO consultant.\n\nSources: Core Web Vitals 2026 thresholds ·\nresponsive images guide with the HTTP Archive figures ·\nAVIF browser support in 2026 ·\nimage optimisation for LCP","title":"Your hero image is your LCP score","translation_key":"","url":"/blog/images-and-lcp/"},{"excerpt":"Most websites do not need a server. They need to answer a question that was already known when the page was written — what does this product cost, what did the release change, how do I install the…","lang":"","locale":"","tags":["introduction","static-sites"],"taxonomies":{"tag":["introduction","static-sites"]},"text":"Most websites do not need a server. They need to answer a question that was\nalready known when the page was written — what does this product cost, what did\nthe release change, how do I install the thing — and then get out of the way.\nSSG exists for that case. You write Markdown, run one command, and get a folder\nof HTML you can put behind any CDN on earth.\nThat is the whole idea, and it is not new. Jekyll did it in 2008. What follows\nis less about the category and more about the specific trade-offs this one\nmakes, because that is the part you cannot tell from a feature list.\nWhat a build actually looks like\nssg my-blog simple example.com --http --watch\n\nThree positional arguments: where the content is, which theme renders it, and\nwhat the canonical domain will be. The last one matters more than it looks —\nit is what makes the sitemap, the feeds, the canonical tags and the Open Graph\nURLs agree with each other instead of quietly disagreeing in three places.\nNothing else is required. No config file, no scaffolding step, no node_modules\nthat weighs more than the site. The simple and krowy themes are compiled\ninto the binary, so a fresh machine with nothing but ssg on it can build a\nsite.\nThen the opt-ins start, and there are a lot of them: WebP conversion,\nresponsive image variants, SCSS, minification with source maps, content-hashed\nasset fingerprinting, Atom feeds, a JSON search index, multilingual output with\nhreflang, dynamic taxonomies, and native deploys to Cloudflare Pages, GitHub\nPages, Netlify, Vercel, FTP or SFTP. Every one of them is off until you ask.\nThat default matters. A generator that turns everything on gives you a build\nyou cannot explain, and eventually a bug you cannot locate.\nWhere the content comes from\nThe obvious answer is Markdown files in a folder, and that is the common case.\nFrontmatter carries the title, date, categories and the rest; a plain .md file\nwith no frontmatter is accepted too and takes its title from its first heading.\nThe less obvious answer is that content does not have to be yours, or local.\nAn SSG build can pull from a remote HTTP API, a read-only SQL query, or a\nWordPress, Drupal or Movable Type database — the last one being how most\nmigrations start. All of it lands in one namespace your templates read from,\nwith one disk cache, one set of retry rules and one error model. The hardened\nHTTP client is not a footnote: it refuses plain http:// unless you say\notherwise, blocks private and loopback addresses at dial time — which is what\nactually defeats DNS rebinding — caps response sizes, and keeps query strings\nout of error messages so a token in a URL does not end up in your CI log.\nSince 1.8.10 the local case got looser as well. A site no longer needs a\ncontent/\u0026lt;source\u0026gt;/ tree at all: content_sources points at any folders of\nMarkdown you already have, which is how this site is built from the\nrepository's own docs/ directory. No copy step, no second source of truth\nthat drifts.\nThe three refusals\nNo client-side framework. The output is HTML, CSS and whatever JavaScript\nyour theme chose to write. If a page needs interactivity, add it. The generator\nwill not add it for you and will not ship a runtime you did not ask for.\nNo silent success. This one is a matter of scars. A build that prints a\nwarning and exits 0 while a content block quietly disappeared is worse than a\nbuild that fails, because the page still renders and looks fine. So\n--check-links=strict fails on a dead internal link, shortcode_errors: strict\nfails on a shortcode that could not render, and an unknown key in your config\nfile is reported by name rather than ignored.\nNo magic version of your content. Directories organise files; they do not\nassign categories. Frontmatter does. A post's URL comes from its date and slug,\nor from a permalink pattern you wrote, and never from a rule you have to\nreverse-engineer.\nWho this is not for\nIf your site's content genuinely changes per request — a dashboard, a\nmarketplace, anything with a session — a static generator is the wrong shape\nand no amount of build-time cleverness fixes that.\nIf you want a theme ecosystem with a thousand entries, Hugo is right there and\nit is excellent. SSG ships three themes and a documented contract for writing\nyour own, which is a different bet: fewer options, less to keep compatible.\nAnd if you are already happy with your current generator, the honest answer is\nthat switching buys you very little. The interesting cases are the ones where\nyou are fighting a plugin chain to do something a single Go binary should have\ndone — migrating 4,000 WordPress posts, or building one site from content that\nlives in four different places.\nWhere to start\nssg --help is exhaustive and grouped. Beyond that, the\ninstallation guide covers Homebrew, Snap, Docker and the raw\nbinaries, and the content guide is the one to read second, because\nalmost every surprising build outcome traces back to a directory-contract rule\nsomeone skipped.\nThe rest of this site is the reference. It is also, as the next post explains,\nbuilt by the tool it documents — which is either elegant or reckless, depending\non the day.","title":"SSG, and the case for boring websites","translation_key":"","url":"/blog/what-ssg-is/"},{"excerpt":"SSG can be configured with command-line flags or a YAML, TOML or JSON file. This guide explains the configuration model and advanced features. The exhaustive, copyable YAML template is…","lang":"","locale":"","tags":null,"taxonomies":{"category":["Documentation"]},"text":"SSG can be configured with command-line flags or a YAML, TOML or JSON file. This\nguide explains the configuration model and advanced features. The exhaustive,\ncopyable YAML template is .ssg.yaml.example.\nLoading configuration\nSelect a file explicitly:\nssg --config path/to/site.yaml\n\nWithout --config, SSG checks the current directory in this order:\n.ssg.yaml  .ssg.yml  .ssg.toml  .ssg.json\nssg.yaml   ssg.yml   ssg.toml   ssg.json\n\nCommand-line flags are parsed after the file and override matching file values.\nThe positional values source, template and domain are read from the file\nwhen all three are present. Otherwise, provide all three positionally —\nsource itself is optional once content_sources is configured.\nTwo diagnostics make a misconfigured file obvious instead of silent:\n\nUnknown keys warn. A YAML key this binary does not know is reported by\nname and ignored. A config written for a newer ssg therefore still builds,\nand the version mismatch is visible rather than looking like a missing value.\nMissing required settings are named. Instead of printing usage alone, ssg\nreports which of source/template/domain is missing, which config file\nit read and what that file provided.\n\nsource: my-blog\ntemplate: simple\ndomain: example.com\n\nssg my-blog simple example.com\n\nMost features are disabled by default. Defaults listed below come from the\ncurrent config.DefaultConfig; omitted strings and booleans otherwise use Go's\nempty value.\nCore and paths\n\n\n\nKey\nDefault\nCLI\nPurpose\n\n\n\n\nsource\nrequired\npositional\nLocal content collection\n\n\ntemplate\nrequired\npositional\nTheme name\n\n\ndomain\nrequired\npositional\nCanonical host without scheme\n\n\ncontent_dir\ncontent\n--content-dir\nParent of local sources\n\n\ncontent_sources\nempty\n--content-source (repeatable)\nExtra Markdown roots merged into the site; see CONTENT.md\n\n\nauto_excerpt\nfalse\n--auto-excerpt\nDerive a missing excerpt from the opening paragraph\n\n\ntemplates_dir\ntemplates\n--templates-dir\nParent of themes\n\n\noutput_dir\noutput\n--output-dir\nGenerated site destination\n\n\nstatic_dir\nstatic\n--static-dir\nVerbatim passthrough files\n\n\ndata_dir\ndata\n--data-dir\nYAML/JSON data for .Data\n\n\npages_path\npages\nconfig only\nPages directory inside a source\n\n\nposts_path\nposts\nconfig only\nPosts directory inside a source\n\n\nquiet\nfalse\n--quiet, -q\nSuppress normal output\n\n\n\noutput_dir is generated state. clean: true deletes its old contents before\nbuilding. See CONTENT.md for the source directory contract.\nTemplate selection\n\n\n\nKey\nDefault\nCLI\nPurpose\n\n\n\n\nengine\nGo behaviour\n--engine\ngo, pongo2, mustache or handlebars\n\n\nonline_theme\nempty\n--online-theme\nGitHub, GitLab or direct ZIP theme URL\n\n\n\nThe template core value names the destination/local theme directory. Engine\naliases accepted by the CLI include jinja2/django for Pongo2 and hbs for\nHandlebars. Non-Go themes must ship their own templates in the chosen syntax.\nSee TEMPLATES.md.\nDevelopment server\n\n\n\nKey\nDefault\nCLI\nPurpose\n\n\n\n\nhttp\nfalse\n--http\nStart the built-in server after building\n\n\nhost\n127.0.0.1\n--host\nBind address\n\n\nport\n8888\n--port\nTCP port\n\n\nwatch\nfalse\n--watch\nRebuild after local file changes\n\n\nwatch_runner\n\u0026quot;\u0026quot;\n--watch-runner\nSpawns a background watch runner process\n\n\nwatch_runner_config\n\u0026quot;\u0026quot;\n--watch-runner-config\nConfig file the runner should use\n\n\nwatch_runner_dir\n\u0026quot;\u0026quot;\n--watch-runner-dir\nDirectory the runner starts in\n\n\nclean\nfalse\n--clean\nRemove previous output before builds\n\n\n\nwatch_runner coordinates background execution of development emulators (like wrangler or workerd). When configured, ssg automatically monitors files for rebuilds and spawns the runner in parallel, piping its output and terminating it on exit. Spelled --wrangler (for npx wrangler dev) or --workerd (for workerd serve) as CLI convenience flags.\nwatch_runner_config points the runner at a config file kept anywhere on disk,\nso a wrangler.toml does not have to sit in the project root next to .ssg.\nThe path is passed as --config \u0026lt;path\u0026gt; to wrangler and to custom runners, and\nas the positional config argument to workerd serve. A missing file is reported\nas a warning; the runner is still started so its own error message is visible.\nwatch_runner_dir starts the runner in another directory — the monorepo case,\nwhere the Worker lives in booking/apps/api/ while content and templates stay\nat the repo root. Without it npx wrangler dev runs where ssg was invoked and\nfails with \u0026quot;Missing entry-point to Worker script or to assets directory\u0026quot;. A\nrelative watch_runner_config is resolved against ssg's working directory\nbefore the runner is started, so both options can be combined safely. A\ndirectory that does not exist aborts the runner (the build itself continues).\n--wrangler-config=FILE, --wrangler-dir=DIR and the --workerd-* pair are\nconvenience spellings: each sets its value and selects that runner (so\n--wrangler is implied), in any flag order. Use --watch-runner-config=FILE /\n--watch-runner-dir=DIR with a custom --watch-runner.\n# Worker in a subdirectory of the same repo (issue #35)\nssg --watch --wrangler-dir=booking/apps/api my-site simple example.com\n\n# wrangler config kept in deploy/, not in the project root\nssg --wrangler-config=deploy/wrangler.toml my-site simple example.com\n\n# equivalent, spelled out\nssg --watch-runner=wrangler --watch-runner-config=deploy/wrangler.toml \\\n    my-site simple example.com\n\nwatch_runner: wrangler\nwatch_runner_dir: booking/apps/api\nwatch_runner_config: booking/apps/api/wrangler.jsonc\n\nPair it with environment variables in external_sources\nto point the same config at the local Worker during development and at the\nproduction API in CI.\nwatch monitors content, templates and data. Touch-only changes whose bytes are\nunchanged do not trigger a rebuild; actual changes still cause a full build.\nUse host: 0.0.0.0 only when the preview must be reachable from other machines.\nPublic TLS and hardening\nhttp: true\nport: 443\ntls_cert: cert.pem\ntls_key: key.pem\nhttp3: true\ngzip: true\nmax_conns: 1024\nmem_limit: 512MiB\n\n\n\n\nKey\nDefault\nCLI\nPurpose\n\n\n\n\ntls_cert\nempty\n--tls-cert\nManual PEM certificate\n\n\ntls_key\nempty\n--tls-key\nManual PEM private key\n\n\ntls_auto\nfalse\n--tls-auto\nObtain certificates with Let's Encrypt\n\n\ntls_domain\nempty\n--tls-domain\nAutocert host names, comma-separated\n\n\nhttp3\nfalse\n--http3\nAdd HTTP/3/QUIC alongside HTTPS\n\n\ngzip\nfalse\n--gzip\nCompress accepted responses\n\n\nmax_conns\n0\n--max-conns\nConnection limit; 0 is unlimited\n\n\nmem_limit\nempty\n--mem-limit\nGo runtime soft memory limit\n\n\n\nTLS enables HTTP/2 automatically through ALPN. HTTP/3 requires TLS and uses the\nsame UDP port. Manual certificate/key configuration takes priority over\nautomatic certificates. Autocert requires a public domain and access to ports\n80/443.\nThe server automatically applies X-Content-Type-Options, X-Frame-Options,\nReferrer-Policy, HSTS under TLS, and cache-control suitable for HTML and\nfingerprinted assets.\nOutput and URLs\n\n\n\nKey\nDefault\nCLI\nPurpose\n\n\n\n\nsitemap_off\nfalse\n--sitemap-off\nDisable sitemap.xml\n\n\nrobots_off\nfalse\n--robots-off\nDisable robots.txt\n\n\npretty_html\nfalse\n--pretty-html\nRemove blank lines from HTML\n\n\nrelative_links\nfalse\n--relative-links\nConvert absolute site links to relative links\n\n\npost_url_format\ndate behaviour\n--post-url-format\ndate or slug\n\n\npage_format\ndirectory behaviour\n--page-format\ndirectory, flat or both\n\n\npermalinks.post\nempty\n--permalink-post\nTokenised post URL pattern\n\n\npermalinks.page\nempty\n--permalink-page\nTokenised page URL pattern\n\n\nrewrite_md_links\nfalse\nconfig only\nRewrite source .md links to final URLs (anchors and query strings are carried over)\n\n\nlink_rewrites\nempty\nconfig only\nMap an href prefix to a replacement, for links to repository files the site never publishes\n\n\npreserve_slug_case\nfalse\nconfig only\nDo not lowercase slugs\n\n\noutputs\nHTML only\n--outputs=html,json\nAdd per-page JSON output\n\n\n\nThe permalinks map contains the optional post and page patterns. Permalink\ntokens are :year, :month, :day, :slug and :category.\nrewrite_md_links turns in-repository links (CONFIGURATION.md,\n./guide.md#section) into the built page URLs, carrying any #anchor or\n?query across. link_rewrites covers the other half of a documentation site:\nlinks to repository files that the site never publishes. It maps an href prefix\nto its replacement, longest match first, so one rule can cover a folder and\nanother override a single file:\nlink_rewrites:\n  \u0026quot;../examples/\u0026quot;: \u0026quot;https://github.com/spagu/ssg/tree/main/examples/\u0026quot;\n  \u0026quot;../.ssg.yaml.example\u0026quot;: \u0026quot;https://github.com/spagu/ssg/blob/main/.ssg.yaml.example\u0026quot;\n\nWith both set, check_links on a documentation site can reach zero warnings.\nFrontmatter link always has higher priority. Detailed URL rules are in\nCONTENT.md.\nMinification and assets\n\n\n\nKey\nDefault\nCLI\nPurpose\n\n\n\n\nminify_all\nfalse\n--minify-all\nEnable HTML, CSS and JS minification\n\n\nminify_html\nfalse\n--minify-html\nMinify HTML only\n\n\nminify_css\nfalse\n--minify-css\nMinify CSS only\n\n\nminify_js\nfalse\n--minify-js\nMinify JavaScript only\n\n\nsourcemap\nfalse\n--sourcemap\nEmit v3 maps for minified CSS/JS\n\n\nfingerprint\nfalse\n--fingerprint\nHash CSS/JS names and rewrite references\n\n\nscss\nfalse\n--scss\nCompile SCSS with Dart Sass\n\n\nsass_binary\nsass on PATH\n--sass-binary\nExplicit Dart Sass executable\n\n\nbundles\nempty\nconfig only\nConcatenate named CSS/JS groups\n\n\n\nExample bundles:\nbundles:\n  app.css: [reset.css, layout.css, theme.css]\n  app.js: [vendor.js, main.js]\n\nBundles are created before minification and fingerprinting. Fingerprinting\nrenames CSS/JS to name.\u0026lt;hash8\u0026gt;.ext, emits assets-manifest.json, and rewrites\nHTML/CSS references in dependency order. Source maps require corresponding CSS\nor JavaScript minification. SCSS is removed from final output after compilation;\nif Dart Sass is missing, the step is skipped with a warning.\nHTML regions can opt out of minification:\n\u0026lt;!-- htmlmin:ignore --\u0026gt;\n\u0026lt;pre\u0026gt;Whitespace is preserved here.\u0026lt;/pre\u0026gt;\n\u0026lt;!-- /htmlmin:ignore --\u0026gt;\n\nImages\n\n\n\nKey\nDefault\nCLI\nPurpose\n\n\n\n\nwebp\nfalse\n--webp\nConvert copied JPG/PNG images to WebP\n\n\nwebp_quality\n60\n--webp-quality\nQuality from 1 to 100\n\n\nwebp_keep_original\nfalse\n--webp-keep-original\nKeep originals next to the .webp files\n\n\nreconvert_images\nfalse\n--reconvert-images\nIgnore existing conversion result\n\n\nimage_sizes\nempty\n--image-sizes\nResponsive widths; no upscaling\n\n\nimage_sizes_attr\n100vw\n--image-sizes-attr\nGenerated HTML sizes value\n\n\n\nWebP encoding requires the optional cwebp executable. Build-time resize,\ncrop, filter and source-set helpers are covered by IMAGES.md.\nBy default WebP conversion replaces each original in the output (the\nhistorical behaviour): logo.png becomes logo.webp and \u0026lt;img src\u0026gt;\nreferences are rewritten. Themes that hardcode extensions outside rewritten\nattributes — favicons, logos, og:image — 404 in that mode. Set\nwebp_keep_original: true to emit the .webp next to the original: rewritten\nreferences serve WebP, hardcoded ones keep working (v1.8.5).\nAuthoring\n\n\n\nKey\nDefault\nCLI\nPurpose\n\n\n\n\nsanitize_html\nfalse\n--sanitize-html\nApply bluemonday's UGC policy to rendered content\n\n\nhighlight\nfalse\n--highlight\nHighlight fenced code with Chroma\n\n\nhighlight_style\ngithub\n--highlight-style\nChroma style name\n\n\ntoc\nfalse\n--toc\nExpose .TOC; [toc] also expands\n\n\ntoc_depth\n3\n--toc-depth\nMaximum TOC heading level\n\n\nmath\nfalse\n--math\nInject KaTeX on pages containing math\n\n\n\nMath detection recognises display $$...$$ and fenced ```math\nblocks (fences are rewritten to display math before rendering, GO-055).\nInline \\(...\\) is not supported — CommonMark backslash-escaping would\nconsume the delimiters. Sanitisation is recommended for untrusted remote\ncontent; it is off for trusted local authoring to avoid changing intentional\nHTML.\nShortcodes\nShortcodes are configured reusable snippets whose template file is required:\nshortcodes:\n  - name: promo\n    template: shortcodes/promo.html\n    type: banner\n    title: Summer offer\n    text: Read the terms before continuing.\n    url: https://example.com/offer\n    logo: /images/offer.png\n    legal: Terms apply.\n    ranking: 4.5\n    tags: [public, featured]\n    data:\n      colour: green\n\nUse {{promo}} in Markdown. The template receives .Name, .Type, .Title,\n.Text, .Url, .Logo, .Legal, .Ranking, .Tags and .Data.\nEnable WordPress-style syntax with:\nshortcode_brackets: true\n\nIt supports attributes and paired content:\n[link url=\u0026quot;https://example.com\u0026quot; label=\u0026quot;Read more\u0026quot;]\n[box type=\u0026quot;warning\u0026quot;]Inner Markdown content[/box]\n\nTemplates read inline values from .Attrs and paired text from\n.InnerContent. Unknown bracket tags remain unchanged.\nSite-wide variables: are reachable as .Vars.key / $.Vars.key, the same\nspelling page templates use. Page context (.Page, .Site, .Posts, …) is\nnot in scope — one shortcode instance may render on many pages. The full\nscope table is in TEMPLATES.md.\n\n\n\nKey\nDefault\nCLI\nPurpose\n\n\n\n\nshortcode_errors\ndrop\n--shortcode-errors\nWhat a shortcode that fails to render leaves in the page\n\n\n\n\ndrop — a warning, and the shortcode is removed from the page (historical\nbehaviour, so existing sites build byte-identically).\nkeep — a warning, and the shortcode's raw source ({{promo}},\n[promo a=\u0026quot;b\u0026quot;]) stays in the page, so the failure is visible rather than\nshipping as a silently missing block.\nstrict — as keep, and the build fails once rendering finishes, listing\nevery shortcode that failed. Recommended in CI.\n\nvariables:\n  stripe_public_key: \u0026quot;pk_test_123\u0026quot;\n\nshortcode_errors: strict\n\nBlog, feeds and search\n\n\n\nKey\nDefault\nCLI\nPurpose\n\n\n\n\npaginate\n0\n--paginate\nPosts per index page; 0 disables\n\n\nfeed\nfalse\n--feed\nRoot and category/tag Atom feeds\n\n\nfeed_items\n20\n--feed-items\nMaximum feed items\n\n\nfeed_full_content\nfalse\nconfig only\nFull rendered body instead of summary\n\n\nsearch_index\nfalse\n--search-index\nEmit search-index.json\n\n\n\nPagination writes page 1 at the site root and pages 2 onward under /page/N/.\nThemes receive .Pager. The search index contains title, URL, tags, excerpt,\nplain text and the per-post taxonomies map, intended for a client-side search\nwidget.\nTaxonomies\ncategory, tag and series are built in. The config-only taxonomies: map\ndeclares additional dynamic taxonomies with per-term archives, metadata files,\noptional per-term feeds and template helpers — the full reference (keys,\nfrontmatter priority, normalization rules, template fallback chains) lives in\nTAXONOMIES.md.\nExternal sources\nThe config-only external_sources: block feeds templates from local files\n(YAML/JSON/TOML/CSV/XML), remote HTTP APIs (hardened client + shared disk\ncache), read-only SQL queries (MySQL/MariaDB/PostgreSQL/SQLite) and CMS\nimports (WordPress, Drupal, Movable Type — merged into the site or exposed as\ndata). Everything lands under .ExternalData; .Data is unchanged. Secrets\ncome exclusively from environment variables. CLI: --offline,\n--refresh-external-sources, --clear-external-cache,\n--external-source=NAME. Full reference:\nEXTERNAL_SOURCES.md.\nServer access control\n\n\n\nKey\nDefault\nCLI\nPurpose\n\n\n\n\nserver_auth\nempty\nconfig only\nbasic or jwt (HS256); empty = open\n\n\nserver_users\nempty\nconfig only\nBasic-auth users as login:$PASS_ENV\n\n\njwt_secret\nempty\nconfig only\nHS256 shared secret, env reference\n\n\nip_allowlist\nempty\nconfig only\nOnly these IPs/CIDRs may connect\n\n\nip_blocklist\nempty\nconfig only\nThese IPs/CIDRs are refused first\n\n\nrate_limit\n0\nconfig only\nRequests/second per client IP\n\n\nrate_burst\n0\nconfig only\nToken-bucket size (default 2×rate)\n\n\n\nThe chain runs blocklist → allowlist → rate limiter → auth, before the file\nserver. Passwords and the JWT secret must reference environment variables;\nX-Forwarded-For is not trusted. SSO and LDAP are deliberately not\nimplemented.\nSEO and validation\n\n\n\nKey\nDefault\nCLI\nPurpose\n\n\n\n\nseo\nfalse\n--seo\nInject missing Open Graph, Twitter and JSON-LD metadata\n\n\ncheck_links\nempty\n`--check-links[=warn\nstrict]`\n\n\nlastmod_from_git\nfalse\n--lastmod-from-git\nUse Git commit dates in sitemap\n\n\n\nSEO injection is non-destructive and skips pages that already provide their own\nOpen Graph tags. The old seo_off/--seo-off setting is a deprecated no-op.\nPlain --check-links selects warning mode; strict mode fails the build.\nData and variables\nFiles below data_dir with .yaml, .yml or .json extensions are loaded by\npath into .Data:\ndata/authors/ada.yaml → .Data.authors.ada\n\nCustom variables are exposed as .Vars and exported to hooks as SSG_*:\nvariables:\n  analytics_id: $ANALYTICS_ID\n  api:\n    endpoint: https://api.example.com\n\nValues beginning with $ resolve from the current process environment. Nested\nkeys are flattened for environment names, for example\nSSG_API_ENDPOINT. Do not commit secrets to configuration files.\nInternationalisation and timezones\nlanguages: [pl, en]\ndefault_language: pl\ntimezone: Europe/Warsaw\nlanguage_timezones:\n  en: America/New_York\n  pl: Europe/Warsaw\n\n\n\n\nKey\nDefault\nCLI\nPurpose\n\n\n\n\nlanguages\nempty\n--languages=pl,en\nEnable multilingual output\n\n\ndefault_language\nempty\n--default-language\nLanguage kept at the root\n\n\n\nFor the opt-in expanded multilingual system, translation dictionaries and\nprefix/fallback policies, see I18N.md.\n| timezone | empty | --timezone | IANA zone for content dates |\n| language_timezones | empty | config only | Per-language zone override |\nNon-default languages are written below /\u0026lt;lang\u0026gt;/. Templates receive .Lang,\n.Languages, .DefaultLanguage, .Translations and .Hreflang. Timezones\naffect permalink calendar tokens and template dates; feeds and sitemap remain UTC.\nBuild hooks\nHooks execute trusted local commands without a shell:\nhooks:\n  pre_build: [./scripts/prepare.sh]\n  post_build: [./scripts/report.sh]\n  post_page: []\n\n\n\n\nPhase\nTiming\nFailure behaviour\n\n\n\n\npre_build\nBefore generation\nFails the build\n\n\npost_page\nAfter each page\nLogged and non-fatal\n\n\npost_build\nAfter generation\nFails the build\n\n\n\nCommands are argv-split, time-limited to 60 seconds, and never loaded from\ncontent. Hooks receive SSG_OUTPUT_DIR, SSG_PHASE, and for page hooks\nSSG_PAGE_PATH, plus exported custom variables.\nMDDB content\nMDDB replaces local Markdown with remote documents:\ntemplate: simple\ndomain: example.com\n\nmddb:\n  enabled: true\n  url: http://localhost:11023\n  protocol: http\n  collection: blog\n  lang: en_US\n  api_key: \u0026quot;\u0026quot;                    # optional; prefer --mddb-key from a secret env value\n  timeout: 30\n  batch_size: 1000\n  watch: true\n  watch_interval: 30\n\n\n\n\nNested key\nDefault\nCLI\n\n\n\n\nmddb.enabled\nfalse\nenabled by --mddb-url\n\n\nmddb.url\nempty\n--mddb-url\n\n\nmddb.protocol\nHTTP behaviour\n`--mddb-protocol=http\n\n\nmddb.collection\nempty\n--mddb-collection\n\n\nmddb.lang\nempty\n--mddb-lang\n\n\nmddb.api_key\nempty\n--mddb-key\n\n\nmddb.timeout\n30\n--mddb-timeout\n\n\nmddb.batch_size\n1000\n--mddb-batch-size\n\n\nmddb.watch\nfalse\n--mddb-watch\n\n\nmddb.watch_interval\n30\n--mddb-watch-interval\n\n\n\nHTTP commonly uses http://localhost:11023; gRPC commonly uses\nlocalhost:11024. MDDB watch polls the collection checksum and rebuilds when it\nchanges. Values beginning with $ are resolved only inside variables, not in\narbitrary configuration fields. In CI, pass an MDDB secret at runtime, for\nexample --mddb-key=\u0026quot;$MDDB_API_KEY\u0026quot;. Use sanitize_html when remote content is\nnot fully trusted.\nArchives and deployment\n\n\n\nKey\nDefault\nCLI\n\n\n\n\nzip\nfalse\n--zip\n\n\ntargz\nfalse\n--targz\n\n\ntarxz\nfalse\n--tarxz\n\n\ndeploy\nempty\n--deploy\n\n\ndeploy_project\nempty\n--deploy-project\n\n\ndeploy_branch\nprovider default\n--deploy-branch\n\n\ndeploy_target\nprovider-specific\n--deploy-target\n\n\n\nDeployment credentials always come from environment variables. Provider details\nand GitHub Action inputs are in DEPLOYMENT.md.\nComplete example\nsource: my-blog\ntemplate: simple\ndomain: example.com\n\ncontent_dir: content\ntemplates_dir: templates\noutput_dir: output\nstatic_dir: static\ndata_dir: data\n\nclean: true\nminify_all: true\nfingerprint: true\nfeed: true\nsearch_index: true\nseo: true\ncheck_links: strict\n\nwebp: true\nwebp_quality: 80\nimage_sizes: [480, 960, 1600]\n\npaginate: 10\noutputs: [html]\n\nBefore relying on a key in automation, compare it with\n.ssg.yaml.example and ssg --help from the installed\nversion.","title":"Configuration reference","translation_key":"","url":"/configuration/"},{"excerpt":"This document is the canonical reference for local Markdown content in SSG. For configuration keys, see CONFIGURATION.md. For values exposed to themes, see TEMPLATES.md.","lang":"","locale":"","tags":null,"taxonomies":{"category":["Documentation"]},"text":"This document is the canonical reference for local Markdown content in SSG. For\nconfiguration keys, see CONFIGURATION.md. For values exposed\nto themes, see TEMPLATES.md.\nContent source\nA normal build selects one source directory:\nssg my-blog simple example.com\n\nWith the default paths, my-blog resolves to content/my-blog/. The source may\nalso come from source: in a configuration file. MDDB is an alternative remote\nsource and is documented in CONFIGURATION.md.\nExtra sources (content_sources)\nContent that already lives elsewhere — a docs/ folder, notes beside the code,\na package's guides in a monorepo — does not have to be copied into the source\ntree. content_sources lists additional flat Markdown roots (loaded\nrecursively) that are merged into the site:\ncontent_sources:\n  - path: docs                  # relative to the working directory, or absolute\n    type: page                  # page (default) | post\n    category: Documentation     # optional; created if metadata.json lacks it\n  - path: ../shared/notes\n    type: post\n\n\nExtra sources join the site before finalize, so they get the same URL,\npermalink, i18n, taxonomy and collision treatment as native content.\ncategory applies only to files whose own frontmatter names no category.\nA directory that does not exist, is a file, or has an unsupported type\nfails the build with a message naming the path; an empty directory warns.\nWith at least one extra source, the primary source becomes optional —\na site can consist of extra sources alone, and then no metadata.json is\nrequired either.\nWatch mode watches these directories too.\n\nCLI: --content-source=DIR, repeatable, path-only (type/category need the\nconfig file):\nssg --content-source=docs ssgtheme example.com --watch\n\nAn unset source with no content_sources is a build error that names the\nmissing settings — and the config loader warns about unknown keys, so a config\nwritten for a newer ssg does not fail silently.\nDirectory contract\ncontent/\n└── my-blog/\n    ├── metadata.json\n    ├── pages/\n    │   ├── about.md\n    │   └── legal/\n    │       └── privacy.md\n    ├── posts/\n    │   ├── general/\n    │   │   └── hello.md\n    │   └── 2026/\n    │       └── 07/\n    │           └── release.md\n    └── media/\n        └── hero.jpg\n\nThe loader follows these rules:\n\nmetadata.json is required for a local source. Unknown JSON fields are\nignored, which allows direct use of larger export metadata files.\nPages are loaded recursively from pages/.\nPosts must be below at least one directory inside posts/. A Markdown file\nplaced directly in posts/ is ignored.\nAfter that first grouping directory, post directories are recursive.\nDirectories organise files only. Post categories come from frontmatter.\nA supported non-Markdown file beside a page or post is a co-located asset.\nIt is copied when that content references its filename (only files directly beside\nthe Markdown file are supported; nested subdirectories next to content files are skipped).\n\nThe pages/ and posts/ names can be changed with pages_path and\nposts_path. The source root can be changed with content_dir.\nmetadata.json\nThe metadata file supplies categories, authors and exported media records:\n{\n  \u0026quot;categories\u0026quot;: [\n    {\n      \u0026quot;id\u0026quot;: 1,\n      \u0026quot;count\u0026quot;: 3,\n      \u0026quot;description\u0026quot;: \u0026quot;General articles\u0026quot;,\n      \u0026quot;link\u0026quot;: \u0026quot;/category/general/\u0026quot;,\n      \u0026quot;name\u0026quot;: \u0026quot;General\u0026quot;,\n      \u0026quot;slug\u0026quot;: \u0026quot;general\u0026quot;,\n      \u0026quot;parent\u0026quot;: 0\n    }\n  ],\n  \u0026quot;users\u0026quot;: [\n    { \u0026quot;id\u0026quot;: 1, \u0026quot;name\u0026quot;: \u0026quot;Ada Lovelace\u0026quot;, \u0026quot;slug\u0026quot;: \u0026quot;ada\u0026quot; }\n  ],\n  \u0026quot;media\u0026quot;: []\n}\n\nOnly the following shapes are consumed:\n\n\n\nCollection\nRecognised fields\n\n\n\n\ncategories\nid, count, description, link, name, slug, parent\n\n\nusers\nid, name, slug\n\n\ntags\nid, name, slug — resolves numeric ids in tags: frontmatter and supplies canonical archive slugs (v1.8.6)\n\n\nmedia\nid, slug, title.rendered, media_type, mime_type, source_url, media_details.width, media_details.height, media_details.file\n\n\n\nAdditional export fields are allowed but are not exposed as site metadata.\nAuthor archives\nEvery author referenced by at least one post gets an automatic archive at\n/author/\u0026lt;slug\u0026gt;/:\n\nThe author frontmatter field accepts an ID (1), a name (Ada Lovelace)\nor a slug (ada); all three resolve through the users block above. An ID\nwith no users entry falls back to author-\u0026lt;id\u0026gt;.\nArchives list posts only — pages never join an author archive, even with\nan author field.\nTemplates: author.html renders the archive (context: .Name, .Posts\nnewest-first, .Kind = author); missing author.html falls back to\ncategory.html. Archives appear in the sitemap.\nThe author archive stays on this fixed pipeline; it is not configurable via\ntaxonomies: (migrating it onto the generic registry is a documented\ndeferred item), and author is a reserved path custom taxonomies cannot\nclaim.\n\nExplicit content wins (v1.8.5). If a page, post or alias already owns an\narchive URL — say a hand-written profile with link: /author/ada/ — the\nauto-generated archive for that URL is skipped with a build warning instead of\nsilently overwriting your page, and the suppressed archive stays out of the\nsitemap and feeds. The same rule protects /category/…, /tag/… and\n/series/… URLs.\nMarkdown and frontmatter\nFor predictable output, authored content should use YAML frontmatter:\n---\ntitle: Understanding WebP\nslug: understanding-webp\nstatus: publish\ntype: post\ndate: 2026-07-14\nmodified: 2026-07-15\ncategories: [Guides]\nauthor: ada\ntags: [images, performance]\nexcerpt: Why WebP can reduce image size.\nfeatured_image: /media/webp-hero.jpg\n---\n\nThe full Markdown article starts here.\n\nFiles with frontmatter are included only when status is exactly publish.\nAny other value, including an omitted status, is treated as a draft.\nA plain .md file without frontmatter is accepted and treated as published.\nFrontmatter is still recommended for anything other than imported plain\ncontent, but two values are inferred so such a file is not blank everywhere it\nis listed:\n\n\n\nValue\nInferred from\nWhen\n\n\n\n\ntitle\nthe document's first # heading (or a Setext Title / ====)\nalways, when frontmatter has no title\n\n\nexcerpt\nthe opening paragraph, capped at 200 characters on a word boundary\nonly with auto_excerpt: true\n\n\n\nThe title fallback is unconditional because an untitled page is broken in every\nlisting, menu and \u0026lt;title\u0026gt;. The excerpt fallback is opt-in because it changes\ncard text, feed summaries and meta descriptions on an existing site. Derivation\nskips headings, fenced code, tables, block quotes, images, list markers and\nLiquid guards ({% … %}), so the excerpt starts at the first real sentence.\nFrontmatter fields\n\n\n\nField\nType\nApplies to\nBehaviour\n\n\n\n\nid\ninteger\nboth\nOptional source identifier\n\n\ntitle\nstring\nboth\nDisplay title\n\n\nslug\nstring\nboth\nURL segment; defaults to the source filename\n\n\nstatus\nstring\nboth\nOnly publish is rendered when frontmatter exists\n\n\ntype\nstring\nboth\nUse page or post; affects URL and template behaviour\n\n\ndate\ndate\npost\nPublication date and default date-based URL\n\n\nmodified\ndate\nboth\nLast modification date\n\n\nlink\nstring\nboth\nExplicit URL path; highest URL precedence\n\n\nauthor\ninteger/string\npost\nAuthor ID, numeric string, name or slug\n\n\ncategories\nlist\npost\nCategory IDs, numeric strings, names or slugs\n\n\ncategory\nstring\npost\nSingle free-form category value exposed to templates\n\n\ntags\nlist\npost\nCreates tag listings at /tag/\u0026lt;slug\u0026gt;/\n\n\nseries\nstring\npost\nCreates a series listing and previous/next navigation\n\n\nexcerpt\nstring\nboth\nListing, feed and metadata summary\n\n\ndescription\nstring\nboth\nSEO description; themes may fall back to excerpt\n\n\nkeywords\nstring\nboth\nSEO keywords\n\n\ncanonical\nstring\nboth\nExplicit canonical value exposed to templates\n\n\naliases\nlist\nboth\nOld paths rendered as redirect stubs\n\n\nfeatured_image\nstring\nboth\nHero/Open Graph image\n\n\nlayout\nstring\npage\nTheme layout; redirect also marks an item for sitemap exclusion\n\n\ntemplate\nstring\npage\nSpecific page template filename override\n\n\nrobots\nstring\nboth\nRobots directive; noindex excludes from sitemap\n\n\nsitemap\nstring\nboth\nno excludes the item from sitemap.xml\n\n\nlang\nstring\nboth\nLanguage used in multilingual output\n\n\n\nUnknown frontmatter fields are retained and flattened into the template's root\ncontext. They never replace standard fields with the same name.\nAuthor and category resolution\nIDs, names and slugs are supported:\nauthor: 1\ncategories: [1, 5]\n\nauthor: Ada Lovelace\ncategories: [Guides, Performance]\n\nauthor: ada\ncategories: [guides, performance]\n\nName and slug matching is case-insensitive. Numeric strings are converted to\nIDs. Values that cannot be resolved through metadata.json are ignored.\nExcerpts and section markers\nSSG supports WordPress-export-style section markers:\n## Excerpt\nA short summary.\n\n## Content\nThe full article.\n\nThe markers must match exactly and are removed from rendered content. Without\nthem, everything after frontmatter becomes content. Markers inside fenced code\nblocks are treated as code. A top-level # Title line in content is discarded\nas an export artifact; use the frontmatter title for the document title.\nSlugs and URLs\nWhen slug is absent, it is derived from the filename without .md and\nlowercased:\nAPI.md → api → /api/\n\nSet preserve_slug_case: true to preserve filename or explicit slug casing.\nDefault URLs are:\n\n\n\nContent\nDefault URL\n\n\n\n\nPage\n/\u0026lt;slug\u0026gt;/\n\n\nPost\n/\u0026lt;year\u0026gt;/\u0026lt;month\u0026gt;/\u0026lt;day\u0026gt;/\u0026lt;slug\u0026gt;/\n\n\n\nURL precedence, from highest to lowest, is:\n\nFrontmatter link\nConfigured permalinks.post or permalinks.page\npost_url_format for posts\nDefault URL\n\nPermalink patterns support :year, :month, :day, :slug and :category:\npermalinks:\n  post: /:year/:month/:slug/\n  page: /:slug/\n\nExpanded paths are sanitised so they cannot escape the output directory.\npage_format controls filesystem form:\n\n\n\nValue\nResult for about\n\n\n\n\ndirectory\nabout/index.html (default)\n\n\nflat\nabout.html\n\n\nboth\nBoth forms\n\n\n\nAliases\nUse aliases to preserve old inbound URLs:\naliases:\n  - /old/permalink/\n  - /2019/legacy-path/\n\nEach safe, non-conflicting alias becomes a noindex HTML redirect stub with a\ncanonical link. Aliases are excluded from the sitemap. A collision with real\ncontent is skipped with a warning.\nRelative Markdown links\nWith rewrite_md_links: true, links to source Markdown files are rewritten to\ntheir generated URLs:\nSee [Authentication](AUTHENTICATION.md) and [API](../reference/API.md).\n\nSSG resolves the source filename and final slug. Unknown .md links remain\nunchanged. The feature is disabled by default for sites that intentionally\npublish raw Markdown.\nAssets and static files\n\nReferenced images, media, archives and documents beside a Markdown file are\ncopied as co-located assets (only files directly beside the Markdown source are supported;\nnested subdirectories next to content files are skipped). Unreferenced siblings are skipped.\ncontent/\u0026lt;source\u0026gt;/media/ contains source media records/files.\nProject-level static/ is copied recursively and verbatim to the output.\nTheme CSS, JavaScript and images are copied from the selected template.\n\nUse static/ for favicons, downloads, manifests and files SSG should not parse.\nSee IMAGES.md for generated image variants and template image\nhelpers.\nData-driven content features\nTags and series\ntags generates /tag/\u0026lt;slug\u0026gt;/ listings. series generates\n/series/\u0026lt;slug\u0026gt;/ and exposes previous/next series values to templates. A theme\nmay provide series.html; otherwise the category template is used.\nMultilingual content\nWhen languages is configured, the default language stays at the site root and\nother languages are emitted under /\u0026lt;lang\u0026gt;/. Set lang on each item to select\nits language. Templates receive language, translation and hreflang context;\nsee TEMPLATES.md.\nDates\ntimezone and language_timezones affect content dates used by templates and\ndate permalink tokens. Feeds and sitemap timestamps remain UTC. With\nlastmod_from_git, sitemap modification dates come from the source file's last\nGit commit and fall back to frontmatter/file dates when unavailable.\nWordPress-compatible media shortcodes\nWhen migrating content from a WordPress site, the content might contain legacy media shortcodes. SSG has native, built-in support for parsing and sanitizing the following bracket shortcodes:\n\n[youtube]VIDEO_ID[/youtube] — Renders the standard YouTube video embed responsive iframe.\n[embed]VIDEO_URL[/embed] — Renders the responsive video player iframe.\n\nThese shortcodes are automatically protected from the HTML sanitizer (sanitize_html: true) via a secure token bypass mechanism, preventing the iframe elements from being stripped out during the build phase while still neutralising other malicious HTML.\nTo strip these shortcodes from your text in lists or feeds, use the stripShortcodes template helper. To extract the YouTube thumbnail image from a post's content, use the thumbnailFromYoutube helper.\nPublication checklist\n\nmetadata.json exists and contains every referenced author/category.\nPosts are not placed directly in posts/.\nFrontmatter content has status: publish.\ntype is correct and posts have a valid date.\nExplicit link and aliases do not collide.\nssg ... --check-links=strict succeeds before deployment.","title":"Content guide","translation_key":"","url":"/content/"},{"excerpt":"SSG can package a generated site, publish it directly, or run as a GitHub Action. Deployment always happens after generation and enabled post-processing, so providers receive the final output tree.","lang":"","locale":"","tags":null,"taxonomies":{"category":["Documentation"]},"text":"SSG can package a generated site, publish it directly, or run as a GitHub\nAction. Deployment always happens after generation and enabled post-processing,\nso providers receive the final output tree.\nProduction build\nA conservative production command is:\nssg my-blog simple example.com \\\n  --clean --minify-all --fingerprint --check-links=strict\n\nReview output/ locally before enabling native deployment. Deployment does not\nreplace validation of provider configuration, redirects, custom domains or DNS.\nArchives\nArchives are written beside the project using the configured domain as their\nbase filename:\n\n\n\nConfiguration\nCLI\nOutput\n\n\n\n\nzip: true\n--zip\n\u0026lt;domain\u0026gt;.zip\n\n\ntargz: true\n--targz\n\u0026lt;domain\u0026gt;.tar.gz\n\n\ntarxz: true\n--tarxz\n\u0026lt;domain\u0026gt;.tar.xz\n\n\n\nMultiple archive formats can be enabled in one build. Archives contain the\noutput tree and can be uploaded manually to any static host.\nNative deployment model\ndeploy: cloudflare\ndeploy_project: my-site\ndeploy_branch: main\ndeploy_target: \u0026quot;\u0026quot;\n\nEquivalent CLI flags are --deploy, --deploy-project, --deploy-branch and\n--deploy-target.\nSecrets are read from environment variables. Do not put tokens, passwords or\nprivate keys in .ssg.yaml, Markdown content, command history or a committed\nworkflow.\n\n\n\nProvider\ndeploy value\nProject\nTarget\nCredentials\n\n\n\n\nCloudflare Pages\ncloudflare\nPages project name\n—\nCLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID\n\n\nGitHub Pages\ngithub-pages\n—\nGit remote; defaults to origin\nGITHUB_TOKEN for HTTPS or normal Git/SSH credentials\n\n\nNetlify\nnetlify\nSite ID\n—\nNETLIFY_AUTH_TOKEN\n\n\nVercel\nvercel\nProject ID/name\n—\nVERCEL_TOKEN; optional VERCEL_ORG_ID\n\n\nFTP\nftp\n—\nftp://[user@]host[:port]/path\nFTP_USERNAME, FTP_PASSWORD\n\n\nSFTP\nsftp\n—\nsftp://[user@]host[:port]/path\nSSH environment described below\n\n\n\nAccepted aliases include cloudflare-pages, github, gh-pages and ssh,\nbut canonical names are recommended in durable configuration.\nCloudflare Pages\nCreate the Pages project first and use an API token with permission to edit it:\nexport CLOUDFLARE_API_TOKEN=...\nexport CLOUDFLARE_ACCOUNT_ID=...\n\nssg my-blog simple example.com \\\n  --deploy=cloudflare \\\n  --deploy-project=my-site\n\n--deploy-branch optionally selects the Pages branch. SSG uses Cloudflare's\nDirect Upload API, hashes the output manifest and uploads the required files; it\ndoes not require Wrangler.\nGenerated _headers and _redirects\nEvery build writes _headers and _redirects into the output directory,\nwhether or not you deploy to Cloudflare Pages — they are inert anywhere else.\nPages applies them verbatim, so their content is the site's security and\ncaching policy, and worth knowing before a page appears to go stale.\nSecurity headers, applied to /*:\n\n\n\nHeader\nValue\n\n\n\n\nX-Content-Type-Options\nnosniff\n\n\nX-Frame-Options\nDENY\n\n\nX-XSS-Protection\n1; mode=block\n\n\nReferrer-Policy\nstrict-origin-when-cross-origin\n\n\nPermissions-Policy\ngeolocation=(), microphone=(), camera=()\n\n\n\nCache policy:\n\n\n\nPath\nCache-Control\n\n\n\n\n/css/*, /js/*, /images/*, /media/*\npublic, max-age=31536000, immutable\n\n\n/*.html and /\npublic, max-age=3600\n\n\n\nThe one-year immutable on assets assumes their filenames change when their\ncontents do. That is exactly what --fingerprint and the image pipeline's\ncontent-addressed names guarantee. Without fingerprinting, a CSS or JS file\nedited in place keeps its name and can be served from a browser cache for a\nyear — enable fingerprint: true for any site that deploys more than once,\nor ship your own _headers.\n_redirects carries the aliases: declared in frontmatter as 301s, plus a\nheader explaining that trailing-slash normalisation is Cloudflare's job.\nOverriding them\nThe generated files are written after static/ is copied, so a _headers\nof your own placed in static/ is overwritten rather than merged. To take\ncontrol of the policy, deploy with a post-build hook that rewrites the file, or\nkeep the generated defaults and layer per-path rules in the Cloudflare\ndashboard.\nA page still shows its old content\nHTML is cached at the edge for an hour. After renaming a page — changing\npost_url_format, adding a permalinks pattern — the old URL keeps\nanswering 200 from cache for up to that hour even though the new deployment\nno longer contains it, and the new URL is live immediately. Check with\ncurl -sI \u0026lt;url\u0026gt; | grep -i age before concluding the deploy failed; purge the\ncache in the Cloudflare dashboard if you cannot wait.\nThis project's own documentation site\nssg.tradik.com is built from this repository by\n.github/workflows/docs-site.yml and is\na working example of everything above:\n\nThe site has no content tree. docs-site.yaml pulls docs/ in through\ncontent_sources, so editing a guide is the whole publishing workflow.\nIt is built with the ssg binary from the commit being deployed, not the\nreleased action, so the site doubles as an integration test of main on real\ncontent — and so it can use configuration keys newer than the last release.\nshortcode_errors: strict and --check-links=strict gate the deploy: an\nunrenderable shortcode or a dead internal link fails the run, and SSG deploys\nonly after a successful generate, so a broken page never reaches the CDN.\ncwebp is installed in the runner because the hero image is encoded as WebP.\n\nSetup is two repository secrets and nothing else:\n\n\n\nSecret / variable\nRequired\nPurpose\n\n\n\n\nCLOUDFLARE_API_TOKEN\nyes\nCloudflare Pages: Edit. Attaching the custom domain additionally needs Zone:DNS:Edit on that zone.\n\n\nCLOUDFLARE_ACCOUNT_ID\nyes\nAccount that owns the project\n\n\nCLOUDFLARE_PAGES_PROJECT\nno\nPages project name (default ssg-docs)\n\n\nDOCS_DOMAIN\nno\nCustom domain (default ssg.tradik.com)\n\n\n\nThe workflow creates the Pages project and attaches the custom domain on its\nfirst run, and leaves both alone afterwards — there is nothing to click in the\ndashboard. Domain attachment is deliberately non-fatal: the deployment is\nalready live on \u0026lt;project\u0026gt;.pages.dev by then, and the usual failure is a token\nwithout Zone:DNS:Edit, which is a permissions decision rather than a build\nproblem. The job summary says which of the two happened.\nA pull request runs the same build with --check-links=strict and stops there:\nnothing is created in Cloudflare and nothing is uploaded, but a dead link or an\nunrenderable shortcode in a new post fails the check before review rather than\nafter the merge. Only a push to the default branch deploys.\nworkflow_dispatch only becomes available once the workflow file is on the\ndefault branch, so the first manual run happens after a merge.\nGitHub Pages\nexport GITHUB_TOKEN=...\n\nssg my-blog simple example.com \\\n  --deploy=github-pages \\\n  --deploy-target=https://github.com/example/site.git \\\n  --deploy-branch=gh-pages\n\nThe target defaults to the current repository's origin, and the branch\ndefaults to gh-pages. SSG creates an isolated Git repository inside the output\ndirectory, commits the generated tree and force-pushes a single commit.\n\ngithub-pages intentionally rewrites the target branch history. Use a branch\ndedicated to generated output, never a source or shared development branch.\n\nFor HTTPS remotes, GITHUB_TOKEN is passed as an HTTP authorization header and\nis not embedded in the remote URL. SSH remotes use the normal Git/SSH setup.\nThe git executable must be available.\nNetlify\nexport NETLIFY_AUTH_TOKEN=...\n\nssg my-blog simple example.com \\\n  --deploy=netlify \\\n  --deploy-project=your-site-id\n\nNETLIFY_SITE_ID may replace --deploy-project. SSG declares a digest manifest\nthrough Netlify's deploy API and uploads only files Netlify reports missing.\nThe Netlify CLI is not required.\nVercel\nexport VERCEL_TOKEN=...\nexport VERCEL_ORG_ID=...\n\nssg my-blog simple example.com \\\n  --deploy=vercel \\\n  --deploy-project=my-project\n\nVERCEL_PROJECT_ID may replace --deploy-project; VERCEL_ORG_ID selects the\nteam scope and is optional for an unscoped account. SSG uploads content-addressed\nfiles and creates a production deployment. The Vercel CLI is not required.\nFTP\nexport FTP_USERNAME=deploy\nexport FTP_PASSWORD=...\n\nssg my-blog simple example.com \\\n  --deploy=ftp \\\n  --deploy-target=ftp://ftp.example.com/public_html\n\nThe username may be included in the target URL; otherwise FTP_USERNAME is\nused, falling back to anonymous. The default port is 21. SSG creates remote\ndirectories where possible and uploads every regular output file.\nFTP does not encrypt credentials or content. Prefer SFTP when the host supports\nit.\nSFTP\nPassword authentication:\nexport SSH_USERNAME=deploy\nexport SSH_PASSWORD=...\n\nssg my-blog simple example.com \\\n  --deploy=sftp \\\n  --deploy-target=sftp://server.example.com/var/www/site\n\nKey authentication:\nexport SSH_KEY_FILE=\u0026quot;$HOME/.ssh/id_ed25519\u0026quot;\nexport SSH_KEY_PASSPHRASE=...\n\nssg my-blog simple example.com \\\n  --deploy=sftp \\\n  --deploy-target=sftp://deploy@server.example.com/var/www/site\n\nSFTP variables:\n\n\n\nVariable\nMeaning\n\n\n\n\nSSH_USERNAME\nUsed when the URL has no username\n\n\nSSH_PASSWORD\nPassword authentication; takes priority over a key\n\n\nSSH_KEY_FILE\nPrivate key; defaults to ~/.ssh/id_rsa\n\n\nSSH_KEY_PASSPHRASE\nOptional encrypted-key passphrase\n\n\nSSH_KNOWN_HOSTS\nHost database; defaults to ~/.ssh/known_hosts\n\n\n\nThe default port is 22. Host keys are always verified; unknown hosts are\nrejected. Add the expected key out of band, for example after independently\nchecking its fingerprint:\nssh-keyscan server.example.com \u0026gt;\u0026gt; ~/.ssh/known_hosts\n\nGitHub Action\nUse the stable major reference to receive compatible v1 updates:\nname: Build site\n\non:\n  push:\n    branches: [main]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@v6\n      - name: Build with SSG\n        id: ssg\n        uses: spagu/ssg@v1\n        with:\n          source: my-blog\n          template: simple\n          domain: example.com\n          clean: \u0026quot;true\u0026quot;\n          minify: \u0026quot;true\u0026quot;\n\nPin a full released tag instead of @v1 when reproducibility is more important\nthan automatic compatible updates. Use @main only to test unreleased changes.\nPin the binary too. The action's version input defaults to latest, so\nevery deploy silently picks up the newest ssg release the moment it ships —\nincluding behaviour changes. For production sites, pin it:\n- uses: spagu/ssg@v1\n  with:\n    version: v1.8.5   # exact ssg release used for the build\n\nSince v1.8.5 the action logs the resolved version on every run (a ::notice::\nwhen latest was used) and exposes it as the version output, so unpinned\nbuilds are at least traceable.\nAction inputs\nThe action intentionally exposes a stable subset of the complete CLI:\n\n\n\nInput\nRequired\nDefault\nMeaning\n\n\n\n\nsource\nyes\n—\nContent source name\n\n\ntemplate\nyes\nsimple\nTheme name\n\n\ndomain\nyes\n—\nCanonical host\n\n\nversion\nno\nlatest\nBinary release to download\n\n\ncontent-dir\nno\ncontent\nContent root\n\n\ntemplates-dir\nno\ntemplates\nTheme root\n\n\noutput-dir\nno\noutput\nGenerated destination\n\n\nwebp\nno\nfalse\nEnable WebP conversion\n\n\nwebp-quality\nno\n60\nWebP quality\n\n\nzip\nno\nfalse\nCreate ZIP archive\n\n\nminify\nno\nfalse\nEnable all minification\n\n\nclean\nno\nfalse\nClean before build\n\n\nengine\nno\ngo\nTemplate engine\n\n\nonline-theme\nno\nempty\nTheme download URL\n\n\ndeploy\nno\nempty\nNative provider\n\n\ndeploy-project\nno\nempty\nProvider project/site\n\n\ndeploy-branch\nno\nempty\nProvider branch\n\n\ndeploy-target\nno\nempty\nGit/FTP/SFTP target\n\n\n\nInputs are passed through environment variables and validated before being\nadded to the command argument array.\nAction outputs\n\n\n\nOutput\nMeaning\n\n\n\n\noutput-path\nGenerated site directory\n\n\nzip-file\nZIP path when zip is enabled\n\n\nzip-size\nZIP size in bytes\n\n\nversion\nResolved ssg version used for the build (GO-052)\n\n\n\nExample artifact upload:\n- uses: actions/upload-pages-artifact@v4\n  with:\n    path: ${{ steps.ssg.outputs.output-path }}\n\nNative deployment from Actions\n- uses: spagu/ssg@v1\n  with:\n    source: my-blog\n    template: simple\n    domain: example.com\n    clean: \u0026quot;true\u0026quot;\n    minify: \u0026quot;true\u0026quot;\n    deploy: cloudflare\n    deploy-project: my-site\n  env:\n    CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n    CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n\nProvider credentials must be repository or environment secrets referenced in\nenv, not action inputs. A complete example is available at\nexamples/workflows/cloudflare-pages.yml.\nDeployment checklist\n\nRun a clean production build.\nRun --check-links=strict.\nInspect generated canonical URLs and redirects.\nConfirm the provider project/site ID.\nStore credentials only in the runtime environment or CI secret store.\nUse a least-privilege token.\nFor GitHub Pages, confirm the target is a disposable generated branch.\nFor SFTP, verify and pin the server host key.\nTest custom domains, HTTPS and cache headers after publishing.","title":"Deployment guide","translation_key":"","url":"/deployment/"},{"excerpt":"One unified system feeds templates with data from outside the content tree: local files, remote HTTP APIs, SQL databases and CMS databases (WordPress, Drupal, Movable Type) — all behind a single…","lang":"","locale":"","tags":null,"taxonomies":{"category":["Documentation"]},"text":"One unified system feeds templates with data from outside the content tree:\nlocal files, remote HTTP APIs, SQL databases and CMS databases (WordPress,\nDrupal, Movable Type) — all behind a single registry, one cache, one secrets\nrule and one error model. The legacy .Data namespace is untouched. A working\nproject lives in examples/external-sources/.\nExternal Source → Connector → Parser/Adapter → Normalizer → Cache\n             → Unified Data Registry → Generator → Templates\n\nConfiguration\nexternal_sources:\n  enabled: true\n\n  cache_dir: .ssg-cache/external-sources\n  offline: false            # serve HTTP sources from the disk cache only\n  refresh: false            # ignore fresh cache entries, re-fetch\n  stale_if_error: true      # fall back to an expired copy when a fetch fails\n  fail_on_cache_miss: true  # offline + no cached copy = build failure\n  max_concurrent_sources: 4\n  allowed_hosts: []         # optional HTTP allowlist: \u0026quot;api.example.com\u0026quot;, \u0026quot;*.example.com\u0026quot;, \u0026quot;127.0.0.1:8787\u0026quot;\n\n  defaults:\n    timeout: 10s\n    cache_ttl: 1h\n    stale_ttl: 24h\n    retries: 2\n    retry_backoff: 500ms\n    max_response_size: 5MB\n    required: true\n    allow_http: false       # applies to every source that does not override it\n    allow_private: false\n\n  sources:\n    local_catalog:\n      type: file\n      path: ./data/catalog.csv\n    products_api:\n      type: http\n      url: https://api.example.com/products\n      format: json\n    products_db:\n      type: sql\n      driver: postgres\n      dsn: \u0026quot;$PRODUCT_DB_DSN\u0026quot;\n      queries:\n        products:\n          sql: SELECT id, name, slug FROM products WHERE published = true\n    wordpress:\n      type: cms\n      adapter: wordpress\n      driver: mysql\n      dsn: \u0026quot;$WORDPRESS_DSN\u0026quot;\n\nSource names must match [a-z][a-z0-9_-]*. Sources load in deterministic\n(name-sorted) order, up to max_concurrent_sources at a time. A required\nsource's failure aborts the build; an optional one (required: false) warns\nand is skipped. Every failure names the source, its type and the stage\n(config, read, fetch, parse, transform, connect, query,\nimport) — and never contains credentials.\nSecrets come only from the environment. Values written as \u0026quot;$NAME\u0026quot;\nresolve from the environment at build time; literal secrets in dsn, auth\nand jwt-style fields are rejected outright, and a referenced-but-unset\nvariable fails the build naming the variable (never a value).\nEnvironment variables in values\nurl, headers and query expand environment references inline, so one\nconfig serves every environment instead of being generated per environment:\naccommodations:\n  type: http\n  url: \u0026quot;$MY_API_BASE/api/accommodations\u0026quot;     # or \u0026quot;${MY_API_BASE}/api/...\u0026quot;\n  headers:\n    Authorization: \u0026quot;Bearer $API_TOKEN\u0026quot;\n\nMY_API_BASE=https://api.example.com ssg …          # production\nMY_API_BASE=http://127.0.0.1:8787   ssg … --wrangler-dir=api   # local Worker\n\nRules:\n\n$NAME and ${NAME} expand; $$ is a literal $. A $ not followed by a\nvariable name ($5, a trailing $) stays literal.\nA variable that is unset or empty counts as missing.\nMissing variable in a required source (the default) fails the build,\nnaming the variable. In an optional source (required: false, or\ndefaults.required: false) the source is skipped with a warning — so a\nshared config can carry an env-driven source nobody else has to set up.\ndsn and auth secret fields keep the stricter whole-value form\n(dsn: \u0026quot;$PRODUCT_DB_DSN\u0026quot;); a literal there is still rejected.\n\nLocal files (type: file)\nFormats: YAML, JSON, TOML, CSV, XML — inferred from the extension or forced\nwith format:. Files are size-capped by max_response_size.\nrates:\n  type: file\n  path: ./data/rates.csv\n  csv:\n    header: true        # rows become {column: value} maps (default)\n    delimiter: \u0026quot;,\u0026quot;\n\nXML maps to nested template-friendly maps: attributes become plain keys,\nrepeated elements collect into lists, text-only elements collapse to strings\nand mixed content keeps its text under #text.\nRemote HTTP (type: http)\nproducts_api:\n  type: http\n  url: https://api.example.com/products\n  format: json          # json | csv | xml (yaml/toml also work)\n  headers:\n    Accept: application/json\n  query:\n    page: \u0026quot;1\u0026quot;\n  auth:\n    type: bearer        # bearer | basic | header\n    token: \u0026quot;$API_TOKEN\u0026quot;\n  timeout: 10s\n  cache_ttl: 1h\n  stale_ttl: 24h\n  retries: 2\n  retry_backoff: 500ms\n\nSecurity, always on:\n\nHTTPS required; plain http:// needs allow_http: true.\nlocalhost and private/link-local IPs are refused at dial time, which\nalso defeats DNS rebinding — opt out with allow_private: true\nfor self-hosted APIs.\nallow_http and allow_private can be set per source or once under\nexternal_sources.defaults; a source may still override either.\nOptional global allowed_hosts allowlist (exact or *.wildcard). An entry\nwithout a port matches the host on any port; an entry with a port\n(127.0.0.1:8787, api.example.com:443) only matches that port, so a\nlocal-dev allowance does not widen to every port on the host.\nRedirects are capped at 5 and every hop is re-validated.\nResponses are size-capped; clearly conflicting Content-Types are rejected.\nError messages carry the URL without its query string.\n\nPagination (GO-062)\nBy default an HTTP source fetches exactly one response. Paginated APIs\n(WordPress REST returns 10 posts per page!) can opt in per source:\nposts_api:\n  type: http\n  url: https://api.example.com/posts\n  format: json            # pagination aggregates pages into one JSON array\n  pagination:\n    mode: page            # page | link\n    param: page           # query parameter for mode=page (default: page)\n    start_page: 1         # first page number (default: 1)\n    per_page: 100         # page-size value; sent only when \u0026gt; 0\n    per_page_param: per_page  # page-size parameter name (default: per_page)\n    max_pages: 10         # hard guard, default 10, max 1000\n\nmode: page increments the query parameter from start_page; mode: link\nfollows the Link: rel=\u0026quot;next\u0026quot; response header (RFC 8288, relative targets\nresolved). Fetching stops on an empty (or non-array) JSON page, a missing\nnext link, or max_pages — hitting the cap prints a warning so truncation is\nnever silent. Pages are aggregated into a single JSON array before transforms;\neach page request reuses the standard auth/retry/size-cap machinery, and the\naggregated result is what lands in the disk cache.\nRetries with linear backoff cover network errors, 429 and 5xx. Successful\npayloads land in the shared disk cache (\u0026lt;hash\u0026gt;.body + \u0026lt;hash\u0026gt;.meta.json,\nsha256-verified; corrupted entries are evicted). Within cache_ttl the cache\nis served without touching the network; after that, a failed refetch can serve\nthe stale copy for stale_ttl (stale_if_error).\nCLI: --offline (cache only), --refresh-external-sources (ignore fresh\ncache), --external-source=NAME (narrow the refresh), --clear-external-cache.\nSQL (type: sql)\nDrivers: mysql, mariadb, postgres, sqlite (pure Go, no cgo). Queries\nlive only in configuration — never in templates — and are statically\nvalidated: a single statement, SELECT (or WITH … SELECT) only, no\npiggybacked statements. Each query runs under the source timeout with a hard\nmax_rows cap (default 10000); exceeding it is an error, not a silent\ntruncation. DSNs are scrubbed from driver errors.\ninventory:\n  type: sql\n  driver: mysql\n  dsn: \u0026quot;$INVENTORY_DSN\u0026quot;\n  queries:\n    products:\n      sql: |\n        SELECT id, name, slug, price\n        FROM products\n        WHERE published = true\n      max_rows: 10000\n\n{{ range .ExternalData.inventory.products }}{{ .name }}: {{ .price }}{{ end }}\n\nOperational rules: use a dedicated read-only database user, and TLS for remote\nconnections (configure both in the DSN).\nCMS adapters (type: cms)\nwordpress:\n  type: cms\n  adapter: wordpress      # wordpress | drupal | movable_type\n  driver: mysql\n  dsn: \u0026quot;$WORDPRESS_DSN\u0026quot;\n  mode: content           # content (default) | data\n\nmode: content merges the import into the site before URL, translation,\ntaxonomy and collision processing — imported posts render, paginate, join\nfeeds, sitemaps and archives exactly like native content. Imported authors\nmerge without overwriting IDs the local metadata already defines. mode: data\nonly exposes the import under .ExternalData.\u0026lt;name\u0026gt; (pages/posts/authors/\ntaxonomies/media/metadata maps) — the data view is available in both modes.\nWordPress\nwp_posts, wp_users, wp_terms/wp_term_taxonomy/wp_term_relationships,\nwp_postmeta and attachments. post and custom post types render as posts;\npage maps to pages. category/post_tag feed the legacy fields; other\ntaxonomies land in the page's taxonomies map, so\ndynamic taxonomies pick them up. User-facing custom fields\n(keys not starting with _) land in .Extra.\nwordpress:\n  table_prefix: wp_\n  post_types: [post, page, guide]\n  statuses: [publish]\n  include_media: true\n  include_custom_fields: true\n  include_taxonomies: true\n\nDrupal (8–11)\nnode_field_data, node__body, users_field_data,\ntaxonomy_term_field_data/taxonomy_index (vocabularies → taxonomies map)\nand path_alias — aliases are preserved as explicit links, so Drupal URLs\nsurvive the migration. With include_fields: true, dynamic node__field_*\ntables are discovered per engine and land in .Extra. Drupal 7 uses a\ndifferent schema and is deferred.\ndrupal:\n  version: 10\n  bundles: [article, page]\n  published_only: true\n  include_fields: true\n\nMovable Type\nmt_entry (released entries and pages), mt_author,\nmt_category/mt_placement, mt_tag/mt_objecttag, mt_asset and —\nopt-in — visible mt_comment rows (GO-058).\nmovable_type:\n  include_entries: true\n  include_pages: true\n  include_assets: true\n  include_comments: true   # visible comments → the page's .Extra[\u0026quot;comments\u0026quot;]\n\nWith include_comments: true each imported entry carries its approved\n(comment_visible = 1) comments as a list of maps (author, email, url,\ncreated_on, text) under .Extra[\u0026quot;comments\u0026quot;], ready for templates. Unapproved\nand spam comments are never imported. Default false — identical behaviour\nto previous releases.\nTemplate API\n{{ .ExternalData.products }}                     — parsed data per source\n{{ .ExternalDataMeta.products.FetchedAt }}       — metadata (index and page contexts)\n{{ getExternal \u0026quot;products\u0026quot; }}                     — helper, works in every context\n{{ getExternalMeta \u0026quot;products\u0026quot; }}                 — Metadata struct\n\nMetadata fields: SourceType, Identifier (always credential-free),\nFetchedAt, FromCache, Stale, Checksum, RecordCount, ContentType.\nTransformations\ntransform:\n  select: data.items    # dot path into the parsed structure\n\nselect unwraps API envelopes before the data reaches templates. There is\ndeliberately no scripting, eval or embedded query runtime.\nMigration notes\n\n.Data (local data/ files) is unchanged; the new system is additive.\nMDDB remains a separate content source; it may implement the same connector\ninterface later.\nBuilds stay deterministic offline: --offline + a warmed cache produces\nbyte-identical output.\n\nTroubleshooting\n\nfailed at fetch … blocked address — the host resolves to a private IP;\nset allow_private: true for self-hosted APIs.\noffline mode and no cached copy — run once online, or set\nfail_on_cache_miss: false to downgrade the miss to a warning.\nmust reference an environment variable — secrets belong in the\nenvironment, not the config file.\nresult exceeds max_rows — raise the query's max_rows or narrow the SQL.\n\nDeferred (phase 7+)\n\nDirect-URL template helpers (getJSON/getCSV/getXML).\nAdapters: Ghost, Strapi, Contentful, Sanity, Notion, Airtable,\nGoogle Sheets, GitHub, GitLab; Drupal 7.\nwatch: true rebuilds on file-source changes.\nExample CMS projects with seed scripts.","title":"External sources","translation_key":"","url":"/external_sources/"},{"excerpt":"i18n is opt-in. Existing sites, including sites using the legacy compact languages option, keep their current behaviour until i18n.enabled is set.","lang":"","locale":"","tags":null,"taxonomies":{"category":["Documentation"]},"text":"i18n is opt-in. Existing sites, including sites using the legacy compact\nlanguages option, keep their current behaviour until i18n.enabled is set.\nConfiguration\nlanguages:\n  - code: pl\n    locale: pl-PL\n    name: Polski\n    timezone: Europe/Warsaw\n  - code: en\n    locale: en-GB\n    name: English\n    timezone: Europe/London\ndefault_language: pl\n\ni18n:\n  enabled: true\n  prefix_default_language: false\n  translations_dir: i18n\n  dictionary_fallback: true\n  missing_translation: warn # error, warn, fallback, empty\n  invalid_language: fail    # fail, warn\n  duplicate_translation: fail # fail, warn\n  fallback_languages:\n    pl: [en]\n    en: []\n\nThe compact form remains valid:\nlanguages: [pl, en]\ndefault_language: pl\nlanguage_timezones:\n  pl: Europe/Warsaw\n\nWith prefix_default_language: false, Polish /o-nas/ and English\n/en/about/ are generated. Setting it to true produces /pl/o-nas/ and\n/en/about/.\nContent translations\nConnect translated files with a stable key; translated slugs may differ:\ntitle: About\nslug: about\nlang: en\ntranslation_key: about\nstatus: publish\n\nWhen lang is absent it resolves to default_language. If\ntranslation_key is absent, SSG derives it deterministically from the source\nfilename. Duplicate (translation_key, lang) pairs, unknown languages,\nfallback cycles, invalid timezones and output collisions are validated before\nrendering.\nTemplates receive .Page.Lang, .Page.Locale, .Page.TranslationKey,\n.Page.Translations, .Site.Language, .Site.Languages,\n.Site.DefaultLanguage, .Site.LanguagePages and .Site.LanguagePosts.\n{{range .Page.Translations}}\n  \u0026lt;a href=\u0026quot;{{.URL}}\u0026quot; hreflang=\u0026quot;{{.Lang}}\u0026quot; {{if .IsCurrent}}aria-current=\u0026quot;page\u0026quot;{{end}}\u0026gt;{{.Lang}}\u0026lt;/a\u0026gt;\n{{end}}\n\nHelpers: hasTranslation, translationURL, languageURL, localizeDate and\nt. Existing helpers such as formatDatePL remain available.\nTranslation dictionaries\nPlace pl.yaml, en.yaml or JSON equivalents in translations_dir:\nnavigation:\n  home: Strona główna\npost:\n  reading_time: \u0026quot;{{count}} minut\u0026quot;\n\nUse {{t \u0026quot;navigation.home\u0026quot;}} or\n{{t \u0026quot;post.reading_time\u0026quot; (dict \u0026quot;count\u0026quot; .ReadingTime)}}. Values are returned\nas ordinary strings and remain subject to html/template escaping. Only named\nplaceholders are substituted; catalog values are never executed as templates.\nGenerated output\nPages, aliases, home pages, pagination, JSON records, Atom feeds and search\nindexes follow the configured prefix rule. Sitemap page entries contain XHTML\nlanguage alternates and x-default; opt-in SEO output includes Open Graph\nlocale metadata and JSON-LD inLanguage.\nInternal Markdown links\nrewrite_md_links is language-aware: a link like installation.md resolves to\nthe active language's translation of that document. An explicit\nlanguage-suffixed link (installation.en.md) keeps the author's choice. When\nthe active-language translation does not exist, the content_fallback chain\n(fallback_languages → default language) applies only when\ni18n.content_fallback: true; otherwise the link is left untouched and a\nwarning names the link and language (once per pair).\nDeferred features\nPlanned follow-ups, not yet implemented: language-scoped taxonomy pages\n(category/tag/author/series listings are still cross-language), a language\nselector and t labels inside the built-in themes (the output \u0026lt;html lang\u0026gt; is\nalready corrected at render time), localized month names in localizeDate,\nand plural rules.\nMigration\nAdd the i18n section only when ready. Start by assigning lang and a shared\ntranslation_key to variants, then add dictionaries and a language selector.\nRun a clean build after enabling i18n so obsolete unprefixed artifacts do not\nremain in the output directory.","title":"Internationalisation (i18n)","translation_key":"","url":"/i18n/"},{"excerpt":"Since v1.8.3. Build-time image manipulation callable from templates and shortcodes: resize, fit, fill-with-crop, explicit/anchor/focal crops, visual filters, format conversion with quality control…","lang":"","locale":"","tags":null,"taxonomies":{"category":["Documentation"]},"text":"Since v1.8.3. Build-time image manipulation callable from templates and\nshortcodes: resize, fit, fill-with-crop, explicit/anchor/focal crops, visual\nfilters, format conversion with quality control, EXIF orientation\nnormalization, responsive srcset sets — all behind a deterministic\ncontent-addressed cache with atomic publishing.\n{{ $image := imageResize \u0026quot;images/hero.jpg\u0026quot; (dict\n  \u0026quot;width\u0026quot; 1200 \u0026quot;height\u0026quot; 630 \u0026quot;mode\u0026quot; \u0026quot;fill\u0026quot; \u0026quot;format\u0026quot; \u0026quot;webp\u0026quot; \u0026quot;quality\u0026quot; 82\n) }}\n\u0026lt;img src=\u0026quot;{{ $image.URL }}\u0026quot; width=\u0026quot;{{ $image.Width }}\u0026quot; height=\u0026quot;{{ $image.Height }}\u0026quot; alt=\u0026quot;\u0026quot;\u0026gt;\n\nSources are looked up in order: assets/ → the static dir → the content source\ndir → the theme dir. Paths are canonicalized; .. traversal, absolute paths and\nsymlink escapes are rejected. Source files are never modified. Variants are\npublished to /\u0026lt;output\u0026gt;/processed_images/\u0026lt;base\u0026gt;.\u0026lt;hash\u0026gt;.\u0026lt;ext\u0026gt; and cached in\n.ssg-cache/images/ — the same request never processes twice, and any change to\nthe source bytes or options changes the hash.\nHelpers\nimageInfo path\nMetadata without processing: .Width .Height .Format .AspectRatio .Orientation .HasAlpha .Animated .FileSize. EXIF-rotated JPEGs report their upright\ndimensions.\nimageResize path dict\n\n\n\nOption\nType\nDefault\nNotes\n\n\n\n\nwidth, height\nint\n0\nper-mode requirements below\n\n\nmode\nstring\nfit\nscale · fit_width · fit_height · fit · fill\n\n\nformat\nstring\nauto\nauto keeps the source format (alpha never silently flattened to JPEG)\n\n\nquality\nint\n82\n1–100 (JPEG/WebP)\n\n\nresample\nstring\nlanczos\nnearest · linear · catmullrom · mitchell · lanczos\n\n\nupscale\nbool\nfalse\ngrowing beyond the source is refused unless set\n\n\nanchor\nstring\ncenter\nused by fill\n\n\nlossless\nbool\nfalse\nreserved for encoders that support it\n\n\n\nUnknown keys are rejected (unknown option \u0026quot;widht\u0026quot;).\n\nscale — exact dimensions, aspect distortion allowed (needs both).\nfit_width / fit_height — one exact dimension, the other calculated.\nfit — largest size fitting inside the box, aspect preserved.\nfill — resize + crop to exact dimensions (anchor or focal point).\n\nimageCrop path dict\nExplicit rectangle (x,y,width,height), anchor crop (anchor, incl.\ncompass aliases north/southeast/…), or focal-point crop (focusX,focusY\n∈ 0..1 — the crop window stays inside the image, centred as close to the focus\nas possible). Out-of-bounds rectangles are clamped.\nimageFilter path filters dict\n{{ $i := imageFilter \u0026quot;photo.jpg\u0026quot; (slice\n  (dict \u0026quot;name\u0026quot; \u0026quot;grayscale\u0026quot;)\n  (dict \u0026quot;name\u0026quot; \u0026quot;contrast\u0026quot; \u0026quot;amount\u0026quot; 1.1)\n  (dict \u0026quot;name\u0026quot; \u0026quot;sharpen\u0026quot; \u0026quot;amount\u0026quot; 0.3)\n) (dict \u0026quot;format\u0026quot; \u0026quot;webp\u0026quot; \u0026quot;quality\u0026quot; 82) }}\n\nFilters run in declared order: grayscale invert sepia (no amount) and\nbrightness −1..1 · contrast 0..2 · saturation 0..2 · gamma 0.1..5 ·\nblur 0..100 · sharpen 0..10 · opacity 0..1.\nimageProcess path ops\nOrdered pipeline of resize / crop / filter / encode dicts (each with\n\u0026quot;op\u0026quot;); a failing operation is identified by index and no partial output is\never published.\nimageSrcSet path dict\n{{ $set := imageSrcSet \u0026quot;hero.jpg\u0026quot; (dict \u0026quot;widths\u0026quot; (slice 480 768 1200) \u0026quot;format\u0026quot; \u0026quot;webp\u0026quot;) }}\n\u0026lt;img src=\u0026quot;{{ $set.Default.URL }}\u0026quot; srcset=\u0026quot;{{ $set.SrcSet }}\u0026quot;\n     width=\u0026quot;{{ $set.Default.Width }}\u0026quot; height=\u0026quot;{{ $set.Default.Height }}\u0026quot; alt=\u0026quot;\u0026quot;\u0026gt;\n\nWidths are sorted and deduplicated; invalid ones dropped; widths above the\nsource are skipped unless upscale; up to 20 variants per source; Default\nis the largest generated variant unless defaultWidth picks another.\nResult object\n.URL .StaticPath .Width .Height .OriginalWidth .OriginalHeight\n.Format .FileSize .CacheKey — no absolute filesystem paths.\nFormats \u0026amp; policies\n\nOutput: jpg/jpeg, png, webp (auto = keep source format).\nWebP encoding uses the optional cwebp tool (same dependency as\n--webp); requesting webp without it is a descriptive error. AVIF is not\nsupported (no portable CGO-free encoder).\nEXIF: orientation is normalized before any geometry; metadata (including\nGPS) is stripped — outputs are re-encoded pixels only.\nAnimated GIFs: processing errors out (animated_policy: error) rather\nthan silently flattening.\nLimits: max source 80 MP / 20 000 px per side, max output 40 MP, max 20\nsrcset variants — descriptive errors, no panics (decompression-bomb guard).\n\nCache \u0026amp; GC\nKey = sha256(source bytes + normalized ops JSON + processor version) → name\n\u0026lt;base\u0026gt;.\u0026lt;hash10\u0026gt;.\u0026lt;ext\u0026gt;. Repeated and clean builds produce identical filenames;\nconcurrent identical requests process once.\nGarbage collection (GO-057): --images-gc (config images_gc: true) deletes\ncache entries the just-finished build no longer references; --images-gc-dry\n(images_gc_dry: true) only reports the file count and bytes that would be\nreclaimed. GC runs after generation and never fails the build — errors are\nreported as warnings.","title":"Image Processing Helpers","translation_key":"","url":"/images/"},{"excerpt":"This document provides installation instructions for SSG on all supported platforms.","lang":"","locale":"","tags":null,"taxonomies":{"category":["Documentation"]},"text":"This document provides installation instructions for SSG on all supported platforms.\nSSG ships as a single static binary of roughly 34 MB (release builds,\nstripped). Since v1.8.4 it embeds pure-Go SQL drivers (MySQL/MariaDB,\nPostgreSQL, SQLite) for external sources, which\naccounts for most of that size; no external libraries or runtimes are needed.\nTable of Contents\n\nQuick Install\nLinux - Debian/Ubuntu (DEB)\nLinux - Fedora/RHEL/CentOS (RPM)\nLinux - Snap (Ubuntu)\nmacOS - Homebrew\nmacOS - Binary\nFreeBSD\nOpenBSD\nWindows\nFrom Source\nVerify Installation\n\n\nQuick Install\nOne-liner (Linux/macOS)\ncurl -sSL https://raw.githubusercontent.com/spagu/ssg/main/install.sh | bash\n\n\nLinux - Debian/Ubuntu (DEB)\nAdd Repository (Recommended)\n# Add GPG key\ncurl -fsSL https://github.com/spagu/ssg/releases/latest/download/ssg-apt.gpg | sudo gpg --dearmor -o /usr/share/keyrings/ssg-keyring.gpg\n\n# Add repository\necho \u0026quot;deb [signed-by=/usr/share/keyrings/ssg-keyring.gpg] https://apt.ssg.dev stable main\u0026quot; | sudo tee /etc/apt/sources.list.d/ssg.list\n\n# Update and install\nsudo apt update\nsudo apt install ssg\n\nManual Download\n# Pick the version you want — see all releases (incl. previous versions):\n# https://github.com/spagu/ssg/releases\nVERSION=1.8.4\n\n# AMD64 (x86_64)\nwget https://github.com/spagu/ssg/releases/download/v${VERSION}/ssg_${VERSION}_amd64.deb\nsudo dpkg -i ssg_${VERSION}_amd64.deb\n\n# ARM64 (aarch64)\nwget https://github.com/spagu/ssg/releases/download/v${VERSION}/ssg_${VERSION}_arm64.deb\nsudo dpkg -i ssg_${VERSION}_arm64.deb\n\n# Install dependencies if needed\nsudo apt install -f\n\nRecommended: Install WebP tools\nsudo apt install webp\n\n\nLinux - Fedora/RHEL/CentOS (RPM)\nAdd Repository (Fedora/RHEL 8+)\n# Add repository\nsudo tee /etc/yum.repos.d/ssg.repo \u0026lt;\u0026lt; 'EOF'\n[ssg]\nname=SSG Repository\nbaseurl=https://rpm.ssg.dev/stable/$basearch\nenabled=1\ngpgcheck=1\ngpgkey=https://github.com/spagu/ssg/releases/latest/download/ssg-rpm.gpg\nEOF\n\n# Install\nsudo dnf install ssg\n\nManual Download\n# Pick the version you want — see all releases (incl. previous versions):\n# https://github.com/spagu/ssg/releases\nVERSION=1.8.4\n\n# AMD64 (x86_64)\nwget https://github.com/spagu/ssg/releases/download/v${VERSION}/ssg-${VERSION}-1.x86_64.rpm\nsudo rpm -i ssg-${VERSION}-1.x86_64.rpm\n\n# ARM64 (aarch64)\nwget https://github.com/spagu/ssg/releases/download/v${VERSION}/ssg-${VERSION}-1.aarch64.rpm\nsudo rpm -i ssg-${VERSION}-1.aarch64.rpm\n\nInstall WebP tools\nsudo dnf install libwebp-tools\n\n\nLinux - Snap (Ubuntu)\nInstall from Snap Store\nsudo snap install static-site-generator\n\nCreate short alias\nsudo snap alias static-site-generator ssg\n\nNow you can use ssg instead of static-site-generator.\n\nmacOS - Homebrew\nTap and Install (Recommended)\n# Add tap\nbrew tap spagu/tap\n\n# Install\nbrew install ssg\n\nOr direct install\nbrew install spagu/tap/ssg\n\nInstall WebP tools\nbrew install webp\n\n\nmacOS - Binary\nDownload and Install\n# Apple Silicon (M1/M2/M3)\ncurl -LO https://github.com/spagu/ssg/releases/latest/download/ssg-darwin-arm64.tar.gz\ntar -xzf ssg-darwin-arm64.tar.gz\nsudo mv ssg /usr/local/bin/\n\n# Intel\ncurl -LO https://github.com/spagu/ssg/releases/latest/download/ssg-darwin-amd64.tar.gz\ntar -xzf ssg-darwin-amd64.tar.gz\nsudo mv ssg /usr/local/bin/\n\n\nFreeBSD\nUsing pkg (when available)\npkg install ssg\n\nFrom Ports\ncd /usr/ports/www/ssg\nmake install clean\n\nManual Download\n# AMD64\nfetch https://github.com/spagu/ssg/releases/latest/download/ssg-freebsd-amd64.tar.gz\ntar -xzf ssg-freebsd-amd64.tar.gz\nmv ssg /usr/local/bin/\n\n# ARM64\nfetch https://github.com/spagu/ssg/releases/latest/download/ssg-freebsd-arm64.tar.gz\ntar -xzf ssg-freebsd-arm64.tar.gz\nmv ssg /usr/local/bin/\n\n\nOpenBSD\nFrom Ports\ncd /usr/ports/www/ssg\nmake install\n\nManual Download\n# AMD64\nftp https://github.com/spagu/ssg/releases/latest/download/ssg-openbsd-amd64.tar.gz\ntar -xzf ssg-openbsd-amd64.tar.gz\ndoas mv ssg /usr/local/bin/\n\n# ARM64\nftp https://github.com/spagu/ssg/releases/latest/download/ssg-openbsd-arm64.tar.gz\ntar -xzf ssg-openbsd-arm64.tar.gz\ndoas mv ssg /usr/local/bin/\n\n\nWindows\nDownload and Install\n\n\nDownload the latest release:\n\nssg-windows-amd64.zip\nssg-windows-arm64.zip\n\n\n\nExtract the ZIP file\n\n\nAdd to PATH:\n# PowerShell (run as Administrator)\n$env:Path += \u0026quot;;C:\\path\\to\\ssg\u0026quot;\n[System.Environment]::SetEnvironmentVariable(\u0026quot;Path\u0026quot;, $env:Path, \u0026quot;Machine\u0026quot;)\n\n\n\nUsing Scoop (Community)\nscoop install ssg\n\n\nFrom Source\nRequirements\n\nGo 1.26.5 or later\nGit\n\nBuild and Install\n# Clone repository\ngit clone https://github.com/spagu/ssg.git\ncd ssg\n\n# Build\ngo build -o ssg ./cmd/ssg\n\n# Install to /usr/local/bin\nsudo mv ssg /usr/local/bin/\n\n# Or use make\nmake build\nsudo make install\n\n\nVerify Installation\nAfter installation, verify SSG is working:\n# Check version\nssg --help\n\n# Quick test\nmkdir -p test-site/{content/my-site,templates/simple}\nssg my-site simple example.com --http --port=3000\n\n\nUninstall\nDebian/Ubuntu\nsudo apt remove ssg\n\nFedora/RHEL\nsudo dnf remove ssg\n\nSnap\nsudo snap remove static-site-generator\n\nHomebrew\nbrew uninstall ssg\nbrew untap spagu/tap\n\nManual (Binary)\nsudo rm /usr/local/bin/ssg\n\n\nTroubleshooting\n\u0026quot;command not found: ssg\u0026quot;\nEnsure /usr/local/bin is in your PATH:\necho $PATH | grep -q \u0026quot;/usr/local/bin\u0026quot; || export PATH=$PATH:/usr/local/bin\n\nWebP conversion fails\nInstall the webp package for your system:\n# Debian/Ubuntu\nsudo apt install webp\n\n# Fedora/RHEL\nsudo dnf install libwebp-tools\n\n# macOS\nbrew install webp\n\n# FreeBSD\npkg install graphics/webp\n\nPermission denied\nMake sure the binary is executable:\nchmod +x /usr/local/bin/ssg\n\n\nSupport\n\nGitHub Issues: https://github.com/spagu/ssg/issues\nDocumentation: https://github.com/spagu/ssg#readme","title":"SSG Installation Guide","translation_key":"","url":"/install/"},{"excerpt":"This document describes the visual design tokens of the built-in themes. All text colors meet WCAG 2.2 AA contrast (≥4.5:1 for normal text) against their intended background (audit FE-002).","lang":"","locale":"","tags":null,"taxonomies":{"category":["Documentation"]},"text":"This document describes the visual design tokens of the built-in themes. All text\ncolors meet WCAG 2.2 AA contrast (≥4.5:1 for normal text) against their intended\nbackground (audit FE-002).\nPrinciples\n\nAccessibility first — WCAG 2.2 AA contrast for body and muted text.\nSystem fonts — themes use a native font stack (no external font CDN, no visitor\nIP leak to third parties; audit FE-003).\nCSS custom properties — every color/spacing token is a --var in :root, so a\ntheme can be re-skinned by overriding variables.\nResponsive \u0026amp; mobile-first — layouts use relative units and flexbox/grid.\n\nThemes\nkrowy — light, natural green\n\n\n\nToken\nValue\nNotes\n\n\n\n\n--color-bg-primary\n#f8faf5\npage background\n\n\n--color-text\ndark green/near-black\nbody text\n\n\n--color-text-muted\n#4a6b4a\nmeta/dates — 5.72:1 on bg-primary (AA)\n\n\n--font-family\n'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif\nsystem stack\n\n\n\nsimple — modern dark\n\n\n\nToken\nValue\nNotes\n\n\n\n\n--color-bg-card\n#222222\ncard background\n\n\n--color-text-muted\n#9a9a9a\nmeta/dates — 5.65:1 on card (AA)\n\n\n--color-accent\n#6366f1\nlinks/buttons; paired with light text on the accent as a button background\n\n\n--font-family\n'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif\nsystem stack\n\n\n\nimd — editorial\n\n\n\nToken\nValue\nNotes\n\n\n\n\n--font-main\n'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif\nsystem fallback\n\n\n--font-head\n'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif\nsystem fallback\n\n\n\nChecking contrast\nWhen changing a text color, verify it against its background:\n\nNormal text (\u0026lt; 18.66px, or \u0026lt; 24px non-bold): ≥ 4.5:1\nLarge text (≥ 24px, or ≥ 18.66px bold): ≥ 3:1\n\nTools: WebAIM Contrast Checker or any\nWCAG contrast calculator. Accent colors used as links rely on additional non-color\naffordances (underline/hover); accent used as a button background must keep its label\ntext at ≥ 4.5:1.\nFonts\nThemes ship no bundled or CDN webfonts. The first family in each stack (Inter,\nManrope, Space Grotesk) is used only if the visitor already has it installed;\notherwise the browser falls back to the platform UI font. This keeps builds\nself-contained and privacy-respecting.","title":"SSG Style \u0026 Color Guidelines","translation_key":"","url":"/styles/"},{"excerpt":"Taxonomies classify posts into browsable archives. category, tag and series are built in and keep their historical URLs, templates and feeds; any number of additional taxonomies can be declared in…","lang":"","locale":"","tags":null,"taxonomies":{"category":["Documentation"]},"text":"Taxonomies classify posts into browsable archives. category, tag and\nseries are built in and keep their historical URLs, templates and feeds; any\nnumber of additional taxonomies can be declared in configuration. A working\nproject lives in examples/dynamic-taxonomies/.\nConfiguration\ntaxonomies:\n  technology:\n    label: Technologies    # plural heading (default: title-cased name)\n    singular: Technology   # singular heading (default: title-cased name)\n    path: technology       # URL segment (default: the taxonomy name)\n    field: technology      # frontmatter field read (default: the taxonomy name)\n    multiple: true         # false = exactly one value per post\n    archive: true          # emit /technology/ + /technology/\u0026lt;term\u0026gt;/\n    feed: false            # true = Atom feed per term (/technology/go/feed.xml)\n    sitemap: true          # include archives in sitemap.xml\n    template: \u0026quot;\u0026quot;           # explicit index template (see fallback chain below)\n    term_template: \u0026quot;\u0026quot;      # explicit term template\n    sort: name             # term ordering: name | count | weight\n    case_sensitive: false  # \u0026quot;Go\u0026quot; and \u0026quot;go\u0026quot; merge into one term by default\n    slugify: true          # URL slugs derived from term names\n    generate_empty: false  # true = archives for zero-post terms from data files\n\nTaxonomy names must match [a-z][a-z0-9_-]*. Every taxonomy needs a unique\npath, and the segments author, page and configured language codes are\nreserved. Overriding category, tag or series adjusts their metadata\n(labels, feed on/off for helpers), but their archives stay on the legacy\npipeline — custom path/template overrides for the built-ins are ignored\nthere (see Deferred).\nAssigning terms in frontmatter\nThree sources are merged per taxonomy, in priority order:\n---\ntitle: Cross-compiling Go and Rust\ntaxonomies:            # 1. the generic map (highest priority)\n  technology: [Go, Rust]\n  platform: [Linux]\ntechnology: [Go]       # 2. the configured direct field\ntags: [tutorial]       # 3. legacy fields (tags/category/categories/series)\n---\n\nMulti-value taxonomies merge and deduplicate across sources. A single-value\ntaxonomy (multiple: false) with two distinct values after deduplication fails\nthe build. Values assigned to tag/series through the generic map are synced\nback onto the legacy fields, so the classic /tag/…/ archives include them.\nTerm identity is normalized: surrounding/inner whitespace collapses and, unless\ncase_sensitive: true, comparison is Unicode-lowercased — Go, go and\nGO are one term whose display name is the first spelling seen. Two distinct\nterms slugifying to the same URL (e.g. C++ and C-- → c) fail the build;\nset an explicit slug in the term metadata to resolve it. Term and index URLs\nare also validated against page, post and alias URLs — collisions fail the\nbuild instead of overwriting output.\nTerm metadata\ndata/taxonomies/\u0026lt;taxonomy\u0026gt;.yaml enriches terms (keys are normalized names):\ngo:\n  name: Go              # display-name override\n  slug: golang          # slug override\n  description: The Go programming language\n  weight: 10            # used by sort: weight (descending)\n  data:                 # free-form, exposed as .Data on the term\n    color: \u0026quot;#00ADD8\u0026quot;\n\nWith generate_empty: true, metadata-only terms get archive pages even before\nany post uses them.\nTemplates\nArchive pages pick the first template that exists in the theme:\n\n\n\nPage\nFallback chain\n\n\n\n\nTaxonomy index (/technology/)\ntemplate: override → taxonomy-\u0026lt;name\u0026gt;.html → taxonomy.html → archive.html → category.html\n\n\nTerm archive (/technology/go/)\nterm_template: override → taxonomy-\u0026lt;name\u0026gt;-term.html → taxonomy-term.html → archive.html → category.html\n\n\n\nThe index context provides .Taxonomy (Name/Label/Singular/Path/URL) and\n.Terms (each Name/Slug/URL/Description/Count/Weight/Data). The term context\nprovides .Taxonomy, .Term, .Posts (newest first), .Pager and — for\ncompatibility with category.html — .Category, .Kind and .Name. With\npaginate set, term archives paginate to /technology/go/page/2/.\nTemplate helpers\n\n\n\nHelper\nExample\nResult\n\n\n\n\ntaxonomies\n{{range taxonomies}}{{.Label}}{{end}}\nevery definition, stable order\n\n\ntaxonomy\n{{with taxonomy \u0026quot;technology\u0026quot;}}{{.URL}}{{end}}\none definition view\n\n\ntaxonomyTerms\n{{range taxonomyTerms \u0026quot;technology\u0026quot;}}…{{end}}\nsorted terms (current language)\n\n\npageTerms\n{{range pageTerms \u0026quot;technology\u0026quot; .Page}}…{{end}}\na page's terms as full views\n\n\ntermURL\n{{termURL \u0026quot;technology\u0026quot; \u0026quot;Go\u0026quot;}}\n/technology/go/\n\n\nhasTerm\n{{if hasTerm \u0026quot;technology\u0026quot; \u0026quot;Go\u0026quot; .Page}}\nnormalized membership test\n\n\npagesByTerm\n{{range pagesByTerm \u0026quot;technology\u0026quot; \u0026quot;Go\u0026quot;}}…{{end}}\nthe term's posts, newest first\n\n\n\nMultilingual builds\nWith i18n.enabled, terms live in per-language buckets and custom archives are\nemitted per language with the usual prefix rules: /technology/… for the\ndefault language and /en/technology/… for others. Feeds, sitemap entries and\nthe helpers all follow the language of the page being rendered.\nFeeds, sitemap and search\n\nfeed: true (plus global feed: true) writes an Atom feed per term.\nsitemap: true (default) adds the taxonomy index and each term archive.\nThe search index and JSON output records carry a taxonomies map\n({\u0026quot;technology\u0026quot;: [\u0026quot;Go\u0026quot;, \u0026quot;Rust\u0026quot;], …}) for client-side filtering.\n\nDeferred features\nNot part of this release; tracked for later:\n\nHierarchical taxonomies (nested terms with rollup counts).\nTerm aliases/redirects and per-language translated term names.\nCustom path/template overrides for the built-in category/tag/series\npipelines (their archives intentionally stay byte-for-byte legacy).\nMigrating the author archive onto the generic registry.","title":"Dynamic taxonomies","translation_key":"","url":"/taxonomies/"},{"excerpt":"This guide describes theme layout, rendering contexts and supported engines. Go template helper signatures are in TEMPLATE_HELPERS.md; image functions are in IMAGES.md.","lang":"","locale":"","tags":null,"taxonomies":{"category":["Documentation"]},"text":"This guide describes theme layout, rendering contexts and supported engines.\nGo template helper signatures are in TEMPLATE_HELPERS.md;\nimage functions are in IMAGES.md.\nSelecting a theme\nThe second positional argument names a directory below templates_dir:\nssg my-blog my-theme example.com\n\nWith default paths, the theme is templates/my-theme/. The built-in simple\nand krowy themes are embedded and scaffolded when first used.\n\n\n\nBundled theme\nFor\n\n\n\n\nsimple\na minimal blog; scaffolded into an empty theme directory\n\n\nkrowy\nthe fuller blog layout used by the examples\n\n\nssgtheme\ndocumentation sites: cards, guide layout, colour-scheme switch, shared chrome in partials/ (README)\n\n\n\nAn online theme can be downloaded before generation:\nssg my-blog bearblog example.com \\\n  --online-theme=https://github.com/janraasch/hugo-bearblog\n\nGitHub, GitLab and direct ZIP URLs are accepted. Hugo archives with a\nlayouts/ structure are converted into SSG's theme layout during extraction.\nDownloaded code and templates should be reviewed before use.\nTheme structure\ntemplates/my-theme/\n├── base.html\n├── index.html\n├── page.html\n├── post.html\n├── category.html\n├── tag.html               # optional; falls back to category.html\n├── author.html            # optional; falls back to category.html\n├── series.html            # optional; falls back to category.html\n├── layouts/\n│   └── landing.html       # optional page layout\n├── partials/              # theme-owned organisation/assets\n├── shortcodes/\n│   └── promo.html\n├── css/\n├── js/\n└── images/\n\nStandard template roles:\n\n\n\nFile\nRenders\n\n\n\n\nindex.html\nHomepage and paginated homepage pages\n\n\npage.html\nNormal pages\n\n\npost.html\nPosts\n\n\ncategory.html\nCategories and fallback for other archives\n\n\ntag.html\nTag archive when present\n\n\nauthor.html\nAuthor archive when present\n\n\n\nDefine names must match file names. Templates are selected by their\ndefine name, not the file name. If your theme wraps templates in\n{{define \u0026quot;…\u0026quot;}} blocks, copying category.html to author.html is not\nenough — the copy still defines category.html. Rename the define to\nauthor.html to activate it. Since v1.8.5 such a \u0026quot;shell\u0026quot; file falls back\ngracefully (never a blank page) and the build prints a warning naming the fix.\nThemes without {{define}} blocks are matched by file name and need no\nrenaming.\n| series.html | Series archive when present |\n| layouts/\u0026lt;name\u0026gt;.html | A page with frontmatter layout: \u0026lt;name\u0026gt; — the file may define either \u0026lt;name\u0026gt;.html or layouts/\u0026lt;name\u0026gt;.html; both resolve |\n| \u0026lt;name\u0026gt;.html | A page with frontmatter template: \u0026lt;name\u0026gt; |\nCustom layout and template selection currently applies to pages. Posts use\npost.html. If a page's selected custom template is absent, SSG falls back to\npage.html.\nFor the Go engine, when the theme root contains no .html files, SSG creates\nthe five standard templates. If any root HTML template exists, the theme is\ntreated as intentional and missing files are not individually scaffolded.\nNon-Go engines never receive generated templates and must ship all templates\nthey need.\nTheme CSS, JavaScript and images are copied to output. Build transforms such as\nSCSS, bundling, minification and fingerprinting run afterward.\nTemplate loading and sharing\nThree directories are parsed, in this order, into one template set:\n\n\n\nParsed\nContents\n\n\n\n\n\u0026lt;theme\u0026gt;/*.html\nthe role templates above\n\n\n\u0026lt;theme\u0026gt;/layouts/*.html\nper-page layouts selected by frontmatter layout:\n\n\n\u0026lt;theme\u0026gt;/partials/*.html\nshared {{define}} blocks, callable from any of the above\n\n\n\nBecause it is one set, a {{define \u0026quot;site-header\u0026quot;}} written in\npartials/chrome.html is callable from index.html, post.html or a layout —\nthat is how a theme keeps its \u0026lt;head\u0026gt;, header and footer in one place instead\nof copying them into every role file:\n{{/* partials/chrome.html */}}\n{{define \u0026quot;site-header\u0026quot;}}\n  \u0026lt;header\u0026gt;…{{ .Domain }}…\u0026lt;/header\u0026gt;\n{{end}}\n\n{{/* page.html */}}\n{{ template \u0026quot;site-header\u0026quot; . }}\n\nPass a computed context with dict when a partial needs values the caller\nknows:\n{{ template \u0026quot;site-head\u0026quot; (dict\n    \u0026quot;Title\u0026quot; (printf \u0026quot;%s — %s\u0026quot; .Page.Title .Domain)\n    \u0026quot;Canonical\u0026quot; (printf \u0026quot;/%s/\u0026quot; .Page.Slug)\n    \u0026quot;Ctx\u0026quot; .) }}\n\nNotes that save a debugging session:\n\nOnly .html files are parsed. Other files under partials/ are neither\nparsed nor copied to the output; public assets belong in css/, js/ or\nimages/, which are the only theme directories copied verbatim.\nA file whose defines do not match its filename renders nothing on its own.\nThat is exactly what makes partials/chrome.html work — and why a\ncategory.html copied to author.html still needs its define renamed (see\nthe warning described above).\nbase.html is part of the scaffolded starter themes and is only used by\na theme that chooses to define and call it; it is not a required file and SSG\nnever invokes it implicitly.\nTemplates are parsed once per build, after content is loaded, so site data is\nfully available to every helper.\n\nThe bundled ssgtheme is the reference implementation of this layout:\npartials/chrome.html holds the head, header and footer; the four role\ntemplates hold only what is unique to them. See\ntemplates/ssgtheme/README.md.\nTemplate engines\n\n\n\nEngine\nConfiguration\nAliases\nSyntax\n\n\n\n\nGo\nengine: go\ndefault\nhtml/template\n\n\nPongo2\nengine: pongo2\njinja2, django\nJinja2/Django-like\n\n\nMustache\nengine: mustache\n—\nLogic-less Mustache\n\n\nHandlebars\nengine: handlebars\nhbs\nHandlebars\n\n\n\nCLI example:\nssg my-blog my-theme example.com --engine=pongo2\n\nSyntax comparison:\n{{ range .Posts }}\n  \u0026lt;h2\u0026gt;{{ .Title }}\u0026lt;/h2\u0026gt;\n{{ end }}\n\n{% for post in Posts %}\n  \u0026lt;h2\u0026gt;{{ post.Title }}\u0026lt;/h2\u0026gt;\n{% endfor %}\n\n{{#Posts}}\n  \u0026lt;h2\u0026gt;{{Title}}\u0026lt;/h2\u0026gt;\n{{/Posts}}\n\n{{#each Posts}}\n  \u0026lt;h2\u0026gt;{{Title}}\u0026lt;/h2\u0026gt;\n{{/each}}\n\nNon-Go engines receive the same data model adapted to their renderer. Their\ntheme files must be written in the selected engine's syntax, and Go template\ninheritance ({{define}}/{{template}}) does not apply. Rendered Markdown\ncontent is provided as HTML for these engines.\nHelper support across engines (GO-054)\nSSG hands every engine the same helper library. Pongo2 exposes helpers as\nfilters ({{ value|helper }}, {{ value|helper:arg }}); Handlebars\nexposes them as helpers ({{helper value}}). Mustache is logic-less and\ncannot call helpers at all. Anything an engine cannot express is reported once\nat build time — never silently ignored.\n\n\n\nHelper group\nGo\nPongo2\nHandlebars\nMustache\n\n\n\n\nClassic (safeHTML, formatDate, stripHTML, default, dict, …)\n✅\n✅¹\n✅\n❌\n\n\nConditionals (in, contains, startsWith, ternary, matches, …)\n✅\n✅\n✅\n❌\n\n\nImage (imageResize, imageSrcSet, imageInfo, …)\n✅\n✅\n✅\n❌\n\n\nExternal sources (getExternal, getExternalMeta)\n✅\n✅\n✅\n❌\n\n\ni18n (t)\n✅\n✅\n✅\n❌\n\n\nCollection (where, filter, sortBy, groupBy, pluck, …)\n✅\n⚠️²\n⚠️²\n❌\n\n\n\n¹ Helpers returning HTML are marked safe automatically; pipe through pongo2's\nown |safe only if you compose further. ² Helpers with more than two arguments\n(pongo2) or more than three (Handlebars), and variadic helpers, cannot be\nadapted — calling one raises a visible error (pongo2) or renders a\n[helper X error: …] marker (Handlebars) plus a build warning, so the failure\nis never silent.\nEngine limitations\n\nMustache: logic-less by design — no helpers, filters, or expressions.\nPrepare any derived values in front matter or switch to Pongo2/Handlebars.\nPongo2: helper results that are already HTML are returned as safe values;\nplain strings are auto-escaped like any pongo2 output.\nPartials/inheritance: alt-engine themes load each template file\nindependently; use that engine's native include mechanism, not Go's\n{{define}}.\n\nRendering contexts\nSSG uses different root contexts for individual content and collection pages.\nRely on the tables below rather than assuming every value exists everywhere.\nHomepage\n\n\n\nValue\nType\nMeaning\n\n\n\n\n.Site\nsite data\nAll pages, posts, categories, media and authors\n\n\n.Posts\nlist\nPosts on the current pagination page\n\n\n.Pages\nlist\nAll pages\n\n\n.Domain\nstring\nCanonical host\n\n\n.Vars\nmap\nCustom configuration variables\n\n\n.Data\nmap\nYAML/JSON data files\n\n\n.Pager\npager\nPagination state\n\n\n\n.Pager contains Current, Total, PerPage, PrevURL and NextURL:\n{{ if gt .Pager.Total 1 }}\n  {{ if .Pager.PrevURL }}\u0026lt;a href=\u0026quot;{{ .Pager.PrevURL }}\u0026quot;\u0026gt;Previous\u0026lt;/a\u0026gt;{{ end }}\n  \u0026lt;span\u0026gt;{{ .Pager.Current }} / {{ .Pager.Total }}\u0026lt;/span\u0026gt;\n  {{ if .Pager.NextURL }}\u0026lt;a href=\u0026quot;{{ .Pager.NextURL }}\u0026quot;\u0026gt;Next\u0026lt;/a\u0026gt;{{ end }}\n{{ end }}\n\nPage and post\nIndividual content fields are flattened at the root:\n\n\n\nValue\nMeaning\n\n\n\n\n.Site, .Domain, .Vars, .Data\nGlobal site/configuration data\n\n\n.ID, .Title, .Slug, .Status, .Type\nIdentity fields\n\n\n.Date, .Modified\nDates converted to the configured content timezone\n\n\n.Content, .Excerpt, .Description, .Keywords\nBody and metadata text\n\n\n.URL, .CanonicalURL, .OutputPath\nComputed destinations\n\n\n.Link, .Canonical, .Robots, .Sitemap\nExplicit URL/SEO values\n\n\n.Author, .Categories, .Category, .Tags\nTaxonomy values\n\n\n.FeaturedImage\nHero/social image\n\n\n.Layout, .Template\nContent template selection fields\n\n\n.WordCount, .ReadingTime\nComputed reading statistics\n\n\n.HasMath, .TOC\nOptional authoring output\n\n\n.Series\nSeries name\n\n\n.SeriesPrevURL, .SeriesPrevTitle\nPrevious series item\n\n\n.SeriesNextURL, .SeriesNextTitle\nNext series item\n\n\n.Lang, .Languages, .DefaultLanguage\nLanguage state\n\n\n.Translations, .Hreflang\nLanguage switching/alternate links\n\n\n\nFor compatibility, the complete model is also available as .Page on pages or\n.Post on posts. Unknown frontmatter keys are flattened into the same root but\ncannot overwrite standard values.\nCategory, tag, author and series archives\n\n\n\nValue\nMeaning\n\n\n\n\n.Site\nComplete site data\n\n\n.Category\nCategory-compatible name/slug object\n\n\n.Kind\ncategory, tag, author or series\n\n\n.Name\nDisplay name\n\n\n.Series\nSeries name for compatibility\n\n\n.Posts\nPosts in the archive\n\n\n.Domain, .Vars, .Data\nGlobal values\n\n\n\nCategory, tag and author posts are newest first. Series posts are oldest first\nto preserve reading order.\nSite data\n.Site contains:\n.Site.Domain\n.Site.Pages\n.Site.Posts\n.Site.Categories   # map keyed by integer ID\n.Site.Media        # map keyed by integer ID\n.Site.Authors      # map keyed by integer ID\n\nExamples:\n{{ range .Site.Pages }}\n  \u0026lt;a href=\u0026quot;{{ .GetURL }}\u0026quot;\u0026gt;{{ .Title }}\u0026lt;/a\u0026gt;\n{{ end }}\n\n{{ with index .Site.Authors .Author }}\n  \u0026lt;a href=\u0026quot;/author/{{ .Slug }}/\u0026quot;\u0026gt;{{ .Name }}\u0026lt;/a\u0026gt;\n{{ end }}\n\nData and variables\ndata/authors/ada.yaml is exposed as .Data.authors.ada. Configuration:\nvariables:\n  analytics_id: G-XXXX\n  api:\n    endpoint: https://api.example.com\n\nbecomes:\n{{ .Vars.analytics_id }}\n{{ .Vars.api.endpoint }}\n\nSee CONFIGURATION.md for environment\nresolution and hook exports.\nGo template helpers\nThe Go engine includes standard html/template operations plus SSG functions.\nA small example:\n{{ $recentGuides := .Site.Posts\n    | where \u0026quot;Type\u0026quot; \u0026quot;post\u0026quot;\n    | sort \u0026quot;Date\u0026quot; \u0026quot;desc\u0026quot;\n    | first 5\n}}\n\n{{ range $recentGuides }}\n  \u0026lt;article\u0026gt;\u0026lt;a href=\u0026quot;{{ .GetURL }}\u0026quot;\u0026gt;{{ .Title }}\u0026lt;/a\u0026gt;\u0026lt;/article\u0026gt;\n{{ end }}\n\nHelper groups include:\n\ncollection operations: where, sort, first, last, groupBy, uniq,\npluck, reverse, limit and offset;\nconditionals: in, notIn, contains, startsWith, matches, isNil,\nisEmpty and ternary;\ncontent helpers: latest, published, byTag, byCategory, byAuthor and\nrelated;\nMarkdown, URL, date and WordPress migration helpers;\nbuild-time image helpers.\n\nInvalid collection helper use fails rendering with a descriptive error. Inputs\nare not mutated. Full signatures and comparison rules are documented in\nTEMPLATE_HELPERS.md.\nImage helpers\nGo templates and shortcodes can inspect and process images:\n{{ $img := imageResize \u0026quot;images/hero.jpg\u0026quot;\n    (dict \u0026quot;width\u0026quot; 1200 \u0026quot;height\u0026quot; 630 \u0026quot;mode\u0026quot; \u0026quot;fill\u0026quot; \u0026quot;format\u0026quot; \u0026quot;webp\u0026quot;) }}\n\u0026lt;img src=\u0026quot;{{ $img.URL }}\u0026quot;\n     width=\u0026quot;{{ $img.Width }}\u0026quot;\n     height=\u0026quot;{{ $img.Height }}\u0026quot;\n     alt=\u0026quot;\u0026quot;\u0026gt;\n\nGenerated variants use a deterministic content-addressed cache below\nprocessed_images/. WebP output requires cwebp. See IMAGES.md.\nShortcode templates\nShortcode template paths are relative to the selected theme. A configured\nshortcodes/promo.html can use:\n\u0026lt;aside class=\u0026quot;promo promo--{{ .Type }}\u0026quot;\u0026gt;\n  {{ if .Logo }}\u0026lt;img src=\u0026quot;{{ .Logo }}\u0026quot; alt=\u0026quot;\u0026quot;\u0026gt;{{ end }}\n  \u0026lt;h2\u0026gt;{{ .Title }}\u0026lt;/h2\u0026gt;\n  \u0026lt;p\u0026gt;{{ .Text }}\u0026lt;/p\u0026gt;\n  \u0026lt;a href=\u0026quot;{{ .Url }}\u0026quot;\u0026gt;Learn more\u0026lt;/a\u0026gt;\n  {{ if .Legal }}\u0026lt;small\u0026gt;{{ .Legal }}\u0026lt;/small\u0026gt;{{ end }}\n\u0026lt;/aside\u0026gt;\n\nWhat is in scope inside a shortcode template\nA shortcode template is executed against the shortcode itself, not against\nthe page — . and $ are the same object. In scope:\n\n\n\nExpression\nSource\n\n\n\n\n.Name .Type .Title .Text .Url .Logo .Legal .Ranking .Tags\nthe shortcodes: entry\n\n\n.Data.key\nthe entry's data: map (values are strings)\n\n\n.Attrs.key, .InnerContent\nthe invocation: [name key=\u0026quot;v\u0026quot;]inner[/name]\n\n\n.Vars.key, $.Vars.key\nsite-wide variables: (same map page templates see)\n\n\n\nNot in scope: .Page, .Site, .Posts, .Categories or anything else\nfrom a page template's context. A shortcode has no page — the same instance may\nrender on many pages — so reaching for page data is a template error.\nA template error does not stop the build by default: the shortcode is dropped\nfrom the page and a warning is printed. Set shortcode_errors (or\n--shortcode-errors=) to change that:\n\n\n\nMode\nResult\n\n\n\n\ndrop (default)\nwarning; the shortcode is removed from the page\n\n\nkeep\nwarning; the shortcode's raw source stays in the page, so the gap is visible\n\n\nstrict\nas keep, and the build fails after rendering\n\n\n\nkeep and strict are the ones to use in CI: a page that quietly lost its\npayment widget still looks fine, whereas one showing [stripe_form] does not.\nThe raw source also survives HTML minification, which an HTML comment marker\nwould not.\nConfiguration and supported forms are documented in\nCONFIGURATION.md.\nCreating a theme\n\nCreate templates/\u0026lt;name\u0026gt;/.\nAdd all five standard templates, even though the Go engine can scaffold an\nentirely empty theme. Explicit files make the theme portable and reviewable.\nStart with index.html, page.html, post.html and category.html using\nonly the documented context for each.\nPut public assets below css/, js/ or images/.\nTest empty collections, content without optional metadata, pagination and a\nproduction build with minification.\nRun strict link validation:\n\nssg my-blog my-theme example.com --clean --check-links=strict\n\nDo not edit generated files in output/; change the theme or source instead.","title":"Templates and themes","translation_key":"","url":"/templates/"},{"excerpt":"Since v1.8.3. SSG's Go template engine ships a set of generic helpers for filtering, sorting, grouping, slicing and testing content — so a theme can build \"recently updated guides\", \"related posts\"…","lang":"","locale":"","tags":null,"taxonomies":{"category":["Documentation"]},"text":"Since v1.8.3. SSG's Go template engine ships a set of generic helpers for\nfiltering, sorting, grouping, slicing and testing content — so a theme can build\n\u0026quot;recently updated guides\u0026quot;, \u0026quot;related posts\u0026quot; or \u0026quot;grouped archives\u0026quot; without any\ncode changes.\nThe collection is always the final argument, so helpers chain naturally in\nGo template pipelines:\n{{ $recentGuides := .Site.Pages\n    | where \u0026quot;Type\u0026quot; \u0026quot;guide\u0026quot;\n    | sort \u0026quot;Modified\u0026quot; \u0026quot;desc\u0026quot;\n    | first 5\n}}\n\n{{ range $recentGuides }}\n  \u0026lt;article\u0026gt;\n    \u0026lt;h2\u0026gt;\u0026lt;a href=\u0026quot;{{ .URL }}\u0026quot;\u0026gt;{{ .Title }}\u0026lt;/a\u0026gt;\u0026lt;/h2\u0026gt;\n    \u0026lt;time\u0026gt;{{ formatDate .Modified }}\u0026lt;/time\u0026gt;\n  \u0026lt;/article\u0026gt;\n{{ end }}\n\nHelpers work on []models.Page and generically on slices of structs,\npointers to structs, string-keyed maps, and primitives (via reflection). They\nnever mutate their input and never panic — invalid usage stops template\nexecution with a descriptive error such as:\nwhere: field \u0026quot;ModifiedAt\u0026quot; does not exist on models.Page\nsort: unsupported direction \u0026quot;newest\u0026quot;; expected \u0026quot;asc\u0026quot; or \u0026quot;desc\u0026quot;\nfirst: count must be greater than or equal to zero\nmatches: invalid regular expression \u0026quot;[\u0026quot;\nfilter: unsupported operator \u0026quot;newest\u0026quot;; expected one of eq, ne, gt, ge, lt, le, …\n\nWhat you do NOT need helpers for\nif, else if, with, range, eq/ne/lt/le/gt/ge, and/or/not are native\nGo template features — use them directly:\n{{ if .Page.HasMath }}math{{ else if eq .Page.Type \u0026quot;guide\u0026quot; }}guide{{ else }}other{{ end }}\n\nNative switch/case does not exist in Go templates and SSG deliberately\ndoes not emulate it — compose if / else if with the conditional helpers below.\nSupported comparison types\nwhere, filter, sort and the equality-based helpers compare: strings,\nbooleans (false \u0026lt; true), all integer/float kinds (compared cross-type,\nso int(5) equals float64(5)), time.Time (and convertible aliases).\nAnything else (slices, maps, structs) errors in ordering contexts; equality\nfalls back to deep comparison.\n\nCollection helpers\nwhere — filter by field equality\n{{ .Site.Pages | where \u0026quot;Type\u0026quot; \u0026quot;guide\u0026quot; }}\n{{ .Site.Pages | where \u0026quot;Status\u0026quot; \u0026quot;publish\u0026quot; }}\n{{ .Site.Pages | where \u0026quot;HasMath\u0026quot; false }}\n\nSignature where(field, expected, collection). Matches struct fields,\npointer-to-struct fields and map keys exactly; preserves input order and element\ntype. A missing field/key is an error (no silent fallback).\nfilter — filter with an operator\n{{ .Site.Pages | filter \u0026quot;Modified\u0026quot; \u0026quot;gt\u0026quot; $cutoff }}\n{{ .Site.Pages | filter \u0026quot;Tags\u0026quot; \u0026quot;contains\u0026quot; \u0026quot;go\u0026quot; }}\n{{ .Site.Pages | filter \u0026quot;Type\u0026quot; \u0026quot;in\u0026quot; (slice \u0026quot;guide\u0026quot; \u0026quot;tutorial\u0026quot;) }}\n\nSignature filter(field, operator, expected, collection). Operators:\neq ne gt ge lt le contains notContains in notIn.\ncontains searches strings (substring) and slices/arrays (element);\nin/notIn test the field value against a provided collection.\nsort — stable sort by field\n{{ .Site.Pages | sort \u0026quot;Modified\u0026quot; \u0026quot;desc\u0026quot; }}\n{{ .Site.Pages | sort \u0026quot;Title\u0026quot; \u0026quot;asc\u0026quot; }}\n\nSignature sort(field, direction, collection); direction is asc or desc.\nStable, non-mutating (returns a sorted copy). Field must exist on every element\nand hold a comparable type.\nfirst / last / limit / offset — pagination\n{{ .Site.Pages | first 5 }}\n{{ .Site.Pages | last 3 }}\n{{ .Site.Pages | sort \u0026quot;Modified\u0026quot; \u0026quot;desc\u0026quot; | limit 5 }}   {{/* limit = first */}}\n{{ .Site.Pages | offset 10 | limit 10 }}               {{/* page 2 of 10 */}}\n\nNegative counts error; counts past the end clamp (offset past the end yields\nan empty collection); inputs are never mutated.\ngroupBy — group by scalar field\n{{ range $category, $pages := (.Site.Pages | groupBy \u0026quot;Category\u0026quot;) }}\n  \u0026lt;h2\u0026gt;{{ $category }}\u0026lt;/h2\u0026gt;\n  {{ range $pages }}…{{ end }}\n{{ end }}\n\nReturns map[key] → slice. Item order inside each group is preserved. Go\ntemplates iterate maps in sorted key order, so output is deterministic.\nKeys must be scalar (string/bool/number/time.Time → RFC 3339); slices, maps\nand other structs error.\nuniq / uniqBy — deduplicate\n{{ .Site.Pages | pluck \u0026quot;Category\u0026quot; | uniq }}   {{/* primitives only */}}\n{{ .Site.Pages | uniqBy \u0026quot;Category\u0026quot; }}         {{/* structs, by field */}}\n\nFirst occurrence wins. uniq on structs/maps errors — use uniqBy.\nreverse — reversed copy\n{{ .Site.Pages | reverse }}\n\nslice — build a list inline\n{{ slice \u0026quot;guide\u0026quot; \u0026quot;tutorial\u0026quot; \u0026quot;docs\u0026quot; }}\n{{ if in .Page.Type (slice \u0026quot;guide\u0026quot; \u0026quot;tutorial\u0026quot;) }}…{{ end }}\n\n\n⚠️ Registering slice overrides Go's builtin slice(value, i, j)\nsub-slicing function. Bundled themes do not use the builtin; if yours does,\nswitch to printf \u0026quot;%.10s\u0026quot; for strings or restructure the data.\n\npluck — extract one field\n{{ $titles := .Site.Pages | pluck \u0026quot;Title\u0026quot; }}\n\nReturns a []any of field values (nil for nil-pointer elements).\nindexBy — build a lookup map\n{{ $bySlug := .Site.Pages | indexBy \u0026quot;Slug\u0026quot; }}\n{{ $page := index $bySlug \u0026quot;getting-started\u0026quot; }}\n\nDuplicate keys and empty keys are errors (no silent overwrites).\n\nConditional helpers\n\n\n\nHelper\nSignature\nExample\n\n\n\n\nin\nin value collection → bool\n{{ if in .Page.Type (slice \u0026quot;guide\u0026quot; \u0026quot;docs\u0026quot;) }}\n\n\nnotIn\nnotIn value collection → bool\n{{ if notIn .Page.Type (slice \u0026quot;draft\u0026quot;) }}\n\n\ncontains\ncontains container value → bool\n{{ if contains .Page.Tags \u0026quot;ssg\u0026quot; }} — string→substring, slice→element, map→key\n\n\nstartsWith\nstartsWith value prefix → bool\n{{ if startsWith .Page.Slug \u0026quot;guide-\u0026quot; }}\n\n\nendsWith\nendsWith value suffix → bool\n{{ if endsWith .Page.SourceFile \u0026quot;.md\u0026quot; }}\n\n\nhasPrefix\nHugo-compatible alias of startsWith (v1.8.5)\n{{ if hasPrefix .Page.Slug \u0026quot;guide-\u0026quot; }}\n\n\nhasSuffix\nHugo-compatible alias of endsWith (v1.8.5)\n{{ if hasSuffix .Page.SourceFile \u0026quot;.md\u0026quot; }}\n\n\nmatches\nmatches pattern value → bool\n{{ if matches `^guide-` .Page.Slug }} — RE2; compiled patterns are cached; invalid patterns error\n\n\nisNil\nisNil value → bool\ntrue for nil interfaces/pointers/maps/slices/funcs/chans; never panics\n\n\nisEmpty\nisEmpty value → bool\nGo template truthiness: nil, \u0026quot;\u0026quot;, 0, false, empty slice/map ⇒ empty. Structs are never empty (zero time.Time included — use .IsZero)\n\n\nternary\nternary cond a b → any\n{{ ternary .Page.HasMath \u0026quot;math\u0026quot; \u0026quot;plain\u0026quot; }} — for values, not control flow\n\n\n\nin takes the value first, collection second (the canonical form above).\nFor pipeline-style membership tests use filter … \u0026quot;in\u0026quot; … instead.\n\nContent helpers (wrappers)\n\n\n\nHelper\nEquivalent to\nExample\n\n\n\n\nlatest field n c\nsort field \u0026quot;desc\u0026quot; | first n\n{{ .Site.Posts | latest \u0026quot;Modified\u0026quot; 5 }}\n\n\npublished c\nwhere \u0026quot;Status\u0026quot; \u0026quot;publish\u0026quot;\n{{ .Site.Pages | published }}\n\n\nbyTag t c\nfilter \u0026quot;Tags\u0026quot; \u0026quot;contains\u0026quot; t\n{{ .Site.Posts | byTag \u0026quot;go\u0026quot; }}\n\n\nbyCategory name c\n(site-aware)\n{{ .Site.Posts | byCategory \u0026quot;guides\u0026quot; }} — matches frontmatter Category or resolved category names/slugs, case-insensitive; []models.Page only\n\n\nbyAuthor a c\n(site-aware)\n{{ .Site.Posts | byAuthor \u0026quot;jan-kowalski\u0026quot; }} — by ID, name or slug; []models.Page only\n\n\nrelated page n c\n(scored)\n{{ .Site.Posts | related .Page 3 }} — ranks by shared tags (3) \u0026gt; shared categories (2) \u0026gt; same author (1), recency breaks ties, excludes the current page, only positive scores\n\n\n\n\nClassic utility helpers\nSSG registers several helper functions in the Go template engine for date formatting, HTML cleaning, metadata lookup, and logic controls.\nHTML and String Utilities\n\nsafeHTML value — Returns template.HTML to prevent the Go template engine from auto-escaping HTML. Necessary when rendering custom templates or shortcode outputs.\n{{ .Content | safeHTML }}\n\n\ndecodeHTML value — Unescapes standard HTML entity sequences (e.g. \u0026amp;amp; becomes \u0026amp;).\n{{ decodeHTML .Title }}\n\n\nstripHTML value — Strips all HTML tags (\u0026lt;...\u0026gt; pattern) from the string.\n{{ .Content | stripHTML }}\n\n\nstripShortcodes value — Strips YouTube and embed WordPress-style bracket shortcodes ([youtube]...[/youtube], [embed]...[/embed]) from the text.\n{{ .Content | stripShortcodes }}\n\n\n\nDate Formatting\n\nformatDate value — Formats a date. If a string is passed, it returns it as-is.\n{{ formatDate .Date }}\n\n\nformatDatePL date — Formats a Go time.Time date using Polish month names (e.g., 14 lipca 2026).\n{{ formatDatePL .Date }}\n\n\n\nTaxonomy and Metadata Lookup\n\ngetCategoryName id — Looks up and returns the name of a category by its integer ID from metadata.json.\n{{ getCategoryName .Category }}\n\n\ngetCategorySlug id — Looks up and returns the slug of a category by its integer ID from metadata.json.\n{{ getCategorySlug .Category }}\n\n\nisValidCategory id — Returns true if the category ID is not 1 (ID 1 is commonly reserved for \u0026quot;Bez kategorii\u0026quot;).\n{{ if isValidCategory .Category }}...{{ end }}\n\n\ngetAuthorName id — Looks up and returns the name of an author by their integer ID from metadata.json.\n{{ getAuthorName .Author }}\n\n\nhasValidCategories page — Returns true if the page or post has categories assigned other than ID 1.\n{{ if hasValidCategories . }}...{{ end }}\n\n\n\nPages and URLs\n\ngetURL page — Helper function that returns the calculated URL path for the page or post. Equivalent to calling .GetURL.\n{{ getURL . }}\n\n\ngetCanonical page — Helper function that returns the full canonical URL for the page or post. Equivalent to calling .GetCanonical .Domain.\n{{ getCanonical . }}\n\n\n\nMiscellaneous Helpers\n\nthumbnailFromYoutube value — Extracts the YouTube video ID from a YouTube WordPress-style shortcode and returns its high-quality video thumbnail URL (https://img.youtube.com/vi/\u0026lt;id\u0026gt;/hqdefault.jpg).\n{{ thumbnailFromYoutube .Content }}\n\n\nrecentPosts n — Returns a list of the first n posts. Safely clamps the count at both ends to prevent slice panics.\n{{ range recentPosts 5 }}...{{ end }}\n\n\ndefault defaultVal val — Returns defaultVal if val is empty, nil, \u0026quot;\u0026quot;, or 0. Otherwise returns val.\n{{ default \u0026quot;No title\u0026quot; .Title }}\n\n\ndict key1 val1 key2 val2 ... — Creates a dictionary (map) from key-value arguments. Useful for passing complex parameters into other helpers like imageResize.\n{{ $image := imageResize \u0026quot;photo.jpg\u0026quot; (dict \u0026quot;width\u0026quot; 300 \u0026quot;height\u0026quot; 200 \u0026quot;mode\u0026quot; \u0026quot;fill\u0026quot;) }}\n\ndict is also how a partial receives a computed context:\n{{ template \u0026quot;site-head\u0026quot; (dict \u0026quot;Title\u0026quot; .Page.Title \u0026quot;Ctx\u0026quot; .) }}\n\n\n\nArithmetic\nGo templates have no arithmetic of their own; these four cover the cases a\ntheme actually needs (column splits, \u0026quot;page N of M\u0026quot;, index offsets).\n\nadd a b, sub a b, mul a b, div a b — Integer operands\ngive an integer result and div divides integrally (div 7 2 → 3); a\nfloat operand anywhere gives a float (div 7.0 2 → 3.5). Dividing by zero\nis a template error rather than infinity, and a non-numeric argument fails\nthe render with a message naming the helper.\n{{ $half := div (add (len .Site.Pages) 1) 2 }}\n{{ range $i, $p := .Site.Pages }}{{ if lt $i $half }}…{{ end }}{{ end }}\n\n\n\n\nAvailability\n\nTheme templates (base/index/post/page/category.html, layouts, partials):\nevery helper above.\nShortcode templates: the safe, deterministic subset — slice, in,\nnotIn, contains, startsWith, endsWith, hasPrefix, hasSuffix,\nmatches, isNil, isEmpty, ternary — plus the image helpers\n(imageResize, imageSrcSet, …) and the read-only external-source helpers\n(getExternal, getExternalMeta). Collection helpers that walk site-wide\ndata stay theme-only.\nAlt engines (pongo2/mustache/handlebars): not applicable — those engines\nship their own filter syntax; these helpers are Go-template only.\n\nLimitations\n\nNo custom predicates/lambdas, no switch/case, no JS expressions (by design).\nsort/filter ordering requires comparable field types (see above).\ngroupBy/indexBy/uniq keys must be scalar.\nuniq across mixed numeric types dedupes by rendered value (1 ≡ uint8(1)).","title":"Template Collection \u0026 Conditional Helpers","translation_key":"","url":"/template_helpers/"},{"excerpt":"Notes on static sites, build pipelines, and the reasoning behind the decisions baked into SSG. Fewer release announcements, more of the thinking that does not fit in a changelog entry.","lang":"","locale":"","tags":null,"text":"Notes on static sites, build pipelines, and the reasoning behind the decisions\nbaked into SSG. Fewer release announcements, more of the thinking that does not\nfit in a changelog entry.","title":"Blog","translation_key":"","url":"/blog/"}]