SSG Pipeline
Introduction
Running bun ssg executes scripts/ssg.ts - the static site generator. The SSG script runs in a series of phases, each with a specific responsibility. Understanding this pipeline helps you debug SSG issues and know when each piece of configuration is consumed.
Quick Reference
bun scripts/ssg.ts [--public ./src/public] [--dist ./dist] [--base-url /] [--site-url https://example.com] [--verbose]
| Option | Default | Description |
|---|---|---|
--public <dir> | ./src/public | Source directory with .ree templates and .md files |
--dist <dir> | ./dist | Output directory for static HTML |
--base-url <url> | / | Base URL for the site |
--site-url <url> | $SITE_URL or empty | Full site URL for hreflang links (required for multi-language SEO) |
--verbose | off | Log each rendered file |
--help | - | Print usage and exit |
If --site-url is not provided, hreflang alternate links are skipped. Google requires absolute URLs for hreflang, so the SSG script emits a warning when it's missing.
Phase 1: Validate Redirects
Input: config/redirects.ts
The script first schema-validates the redirects array. This is done before any rendering work so config errors fail fast rather than after a long execution.
Validation checks:
redirectsis an array- Each entry has a
from(string, starts with/, no file extensions in last segment) andto(non-empty string) status(optional) is301or302- No duplicate
frompaths
If validation fails, the script exits immediately with a clear error message pointing to the specific entry.
Phase 2: Clear Output Directory
The dist/ directory is deleted and recreated. This ensures stale files from previous builds don't linger and accidentally get deployed.
Phase 3: Load Translations
Input: src/public/ directory - walks all subdirectories for {lang}.json files
The load_all_translations() function:
- Walks the entire
src/public/directory tree - Discovers every
{lang}.jsonfile - Groups by language code, keyed by directory namespace
- Runs cross-language fallback - missing keys in one language inherit from any language that has them
The loader also derives:
language_self_names- each language's own name from its translation filelanguage_urls- URL prefixes per language (empty string for default,/{lang}for others)
Phase 4: Collect Files
The generator scans src/public/ and separates files into three categories:
.reetemplates - files that the template engine compiles and renders.mdmarkdown files - files rendered viaBun.markdown.html()withprocess_docs_markdownpost-processing- Static assets - everything else (images, fonts, CSS, JS, PDFs) - copied verbatim
The collect_page_files() helper:
- Skips top-level
layout.reeand*.layout.reefiles (used only via{#layout()}) - Collapses language-variant siblings into one canonical entry (e.g.,
about.en.ree+about.sl.ree→about.ree)
Phase 5: Load Dynamic Data
Input: .ts files with the same base name as .ree templates
For every .ree template, the generator checks for a sibling .ts file with the same name (e.g., index.ree → index.ts). If found and it exports load_template_data(), the function is called and its return value is stored for that template.
The convention is documented in Project Structure. On Windows, the import uses pathToFileURL() to ensure compatibility with Bun's module resolution.
Phase 6: Generate Route Map
Input: Translations + collected template files
build_static_route_map() walks each canonical path segment-by-segment and substitutes route_name from translations where present. The result is a map of:
canonical_path → { language → localized_path }
This is used:
- To generate correct per-language URLs for rendered pages
- To generate hreflang alternate links
- By the
localized_path()helper in templates - By the
localized_path_for_lang()helper for language switchers
Phase 7: Render Templates
For each .ree file, the script renders it for every configured language:
- Merge global translations with route-specific translations
- Resolve the localized path for this language
- Generate hreflang links (only when
--site-urlis set) - Merge data from the data loader (
load_template_data()) - Call the template engine to compile and render
- Write the output to
dist/as{localized_path}/index.html
Special cases:
- Default language at root:
/index.html,/about/index.html - Other languages with prefix:
/en/index.html,/en/about/index.html - Home page (
/): alwaysindex.htmlor{lang}/index.html
Phase 8: Generate Sidebar Navigation
The generator scans markdown index.md files for the has_sidebar: true frontmatter flag. When found:
- Lists all
.mdfiles in that folder - Sorts them alphabetically (use ordering prefixes like
01_,02_to control order) - Resolves language-specific titles
- Excludes pages with
skip-navigation: true - Builds a per-language sidebar link list
The sidebar data is passed to the markdown layout template as props.sidebar.
Phase 9: Render Markdown
Input: .md files in the public directory
For each collected .md file, the script renders it for every language:
- Resolves language-specific variant (
file.{lang}.md→file.{default}.md→file.md) - Parses frontmatter for layout selection, metadata, and visibility flags (
draft,published,published_at) - Evaluates content visibility -
draft: true,published: false, or apublished_at/datein the future means the page is rendered (reachable by URL) but excluded from listings, feeds, sitemaps, and markednoindex - Renders markdown body via
Bun.markdown.html()with options for tables, strikethrough, tasklists, autolinks, and heading IDs - Post-processes with
process_docs_markdown(raw_html, markdown_styles)- syntax highlighting, external link handling, and injection of the project's Tailwind classes fromsrc/lib/markdown_styles.ts - Resolves the layout template (from frontmatter or default
"layout") - Renders the layout with the processed HTML as
props.body - Writes to
dist/following the same path rules as templates
See Content Visibility for the full visibility model.
Phase 9½: Render Paginated Routes
Input: config/pagination.ts
Routes listed in the pagination config get additional numbered page output. For each paginated route, the generator:
- Collects records - either from markdown files in the route's directory (via
lib/collect_records.ts) or from the route'sload_records(lang)data loader - Evaluates content visibility for each record - drafts and future-dated pages are filtered from the record list
- Groups records into pages according to
per_page - Builds
PaginationDataview-models (page numbers, URLs, prev/next links, window segments) - Renders each page's
index.reewithprops.records(this page's records),props.pagination(the view-model), andprops.pagination_variant(the configured"full"/"simple"variant) - Writes output as
/route/,/route/2/,/route/3/, etc.
Records are language-aware - each language collects its own set of markdown files and renders independently.
Phase 10: Copy Static Files
Every non-template, non-translation file from src/public/ is copied verbatim to dist/ preserving the directory structure. This includes:
- CSS files (
src/public/css/style.css→dist/css/style.css) - Images, fonts, PDFs
- Client-side JavaScript files
- Root-level files (
favicon.ico,robots.txt,sitemap.xml)
Phase 11: Emit Redirects
Output: dist/_redirects + HTML stub files
This is the second phase of redirect processing (Phase 1 validated the schema; Phase 2 now checks for collisions):
- Collision checks: Verifies no
frompath collides with a generated page route or a static asset - Target validation: For internal
totargets, verifies the file exists indist/ _redirectsfile: Writes Cloudflare-compatible redirect rules - two lines per entry (with and without trailing slash)- HTML stubs: For each redirect, creates
dist/{from}/index.htmlwith a meta-refresh tag. This provides redirect behavior for non-Cloudflare hosts and local preview
External URL targets are not fetched or validated during static site generation.
Error Handling
The SSG script fails fast - if any phase encounters an error, the script stops immediately and exits with a non-zero status code. Common error scenarios and what they look like:
Template compilation errors
When a .ree template has a syntax error (unclosed {#each}, invalid expression, malformed layout tag), the template engine throws with a clear error message at compile time. The script log shows the template name, the line number if possible, and the specific syntax error:
✗ SSG failed
Template compilation failed in "about/index.ree":
Unclosed block(s): each
The full generated JavaScript that failed to compile is also available in the error output - you can inspect it to see exactly what the engine produced and find the mismatch. This is primarily useful when debugging complex nested blocks.
Redirect validation errors
If config/redirects.ts has a malformed entry - a from path that doesn't start with /, a duplicate redirect, or a to target that's empty - Phase 1 catches it before any rendering starts:
✗ Redirect validation failed:
Entry #3: "from" path must start with "/" (got "old-page")
Fix the config/redirects.ts entry and re-run SSG.
Missing data loader errors
A .ts data loader that throws during static site generation (a missing import, a network error in load_template_data()) causes the script to fail when it tries to import and execute the module:
✗ Failed to load data for "index.ree":
Cannot find module '$config/some_file' imported from 'src/public/index.ts'
Check the import path in the .ts file - relative imports should work from the src/public/ directory.
Missing translation errors
Missing translations don't cause SSG failures - the fallback merge fills missing keys from any language that has them. A warning is logged during Phase 3 for each missing file, but the script continues:
⚠ Translation file not found: src/public/de.json (will fall back to other languages)
Exit codes
| Code | Meaning |
|---|---|
0 | SSG succeeded |
1 | SSG failed - check the error message above |
In CI/CD pipelines, a non-zero exit code stops the deployment automatically.
Utility Functions
The SSG scripts and the shared lib/static_site.ts module export several helper functions that are useful for custom SSG extensions, custom data loaders, or understanding how the pipeline works:
walk_dir(root)
Recursively walks a directory and returns all file paths relative to the root:
import { walk_dir } from "$lib/static_site";
const files = walk_dir("./src/public");
// ["index.ree", "about/index.ree", "blog/post.md", "css/style.css", ...]
template_to_canonical(rel_path)
Converts a template file path to its canonical route, stripping ordering prefixes (\d+_) from each segment:
import { template_to_canonical } from "$lib/static_site";
template_to_canonical("index.ree"); // "/"
template_to_canonical("about/index.ree"); // "/about"
template_to_canonical("docs/05_auth.md"); // "/docs/auth"
template_to_canonical("blog/029_post-title.md"); // "/blog/post-title"
This is how the SSG pipeline derives the output URL path from a file's position in the source tree. The ordering prefixes (01_, 05_, 029_) are stripped so they don't appear in the final URL.
path_to_namespace(rel_path)
Converts a template path to a translation namespace (dot-separated, with index stripped):
import { path_to_namespace } from "$lib/static_site";
path_to_namespace("blog/post.ree"); // "blog.post"
path_to_namespace("blog/index.ree"); // "blog"
path_to_namespace("index.ree"); // ""
This is used to find the right translation keys for a page. The namespace blog.post maps to translations[lang]["blog"]["post"] in the translation tree.
file_mtime_iso_date(file_path)
Returns the file's modification time as an ISO date string (YYYY-MM-DD). Used by the sitemap generator for <lastmod> when no frontmatter override is supplied:
import { file_mtime_iso_date } from "$lib/static_site";
file_mtime_iso_date("./src/public/blog/post.md");
// → "2026-05-15"
read_frontmatter(file_path)
Reads a file from disk and returns its parsed frontmatter. Returns an empty object if the file doesn't exist or has no frontmatter block:
import { read_frontmatter } from "$lib/static_site";
const fm = read_frontmatter("./src/public/blog/post.md");
// { title: "Post Title", date: "2026-05-15", author: "Alice" }
This is useful in custom SSG scripts or data loaders that need to inspect page metadata without re-parsing the file yourself.
collect_page_files(public_dir, languages, extensions?)
Collects all renderable page files, skipping layout files and collapsing language variants into canonical entries:
import { collect_page_files } from "$lib/static_site";
const pages = collect_page_files("./src/public", ["en", "sl"], ["ree", "md"]);
// ["index.ree", "about/index.ree", "about/index.en.ree" collapses to "about/index.ree", ...]
This is what Phase 4 of the SSG pipeline uses to discover every page that needs rendering. The extensions parameter defaults to ["ree", "md"].
build_static_route_map(translations, page_files, languages)
Builds a map of canonical paths → (language → localized path), walking the translation tree segment-by-segment and substituting route_name where present:
import { build_static_route_map } from "$lib/static_site";
const route_map = build_static_route_map(translations, page_files, languages);
// Map { "/about" => Map { "en" => "/about", "sl" => "/o-nas" } }
This is the data structure that drives localized URL generation and hreflang links in Phase 6.
Output Structure
The final dist/ directory:
dist/
├── index.html ← Default language homepage
├── about/
│ └── index.html ← Default language /about/
├── en/
│ ├── index.html ← English homepage
│ └── about/
│ └── index.html ← English /about/
├── css/
│ └── style.css ← Compiled Tailwind CSS
├── _redirects ← Redirect rules (Cloudflare format)
├── favicon.ico
├── sitemap.xml ← Generated sitemap
└── ... ← Other static assets
Each page is rendered as index.html inside a directory named after the URL path - the standard pattern for static hosting.