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]
OptionDefaultDescription
--public <dir>./src/publicSource directory with .ree templates and .md files
--dist <dir>./distOutput directory for static HTML
--base-url <url>/Base URL for the site
--site-url <url>$SITE_URL or emptyFull site URL for hreflang links (required for multi-language SEO)
--verboseoffLog 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:

  • redirects is an array
  • Each entry has a from (string, starts with /, no file extensions in last segment) and to (non-empty string)
  • status (optional) is 301 or 302
  • No duplicate from paths

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:

  1. Walks the entire src/public/ directory tree
  2. Discovers every {lang}.json file
  3. Groups by language code, keyed by directory namespace
  4. 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 file
  • language_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:

  1. .ree templates - files that the template engine compiles and renders
  2. .md markdown files - files rendered via Bun.markdown.html() with process_docs_markdown post-processing
  3. Static assets - everything else (images, fonts, CSS, JS, PDFs) - copied verbatim

The collect_page_files() helper:

  • Skips top-level layout.ree and *.layout.ree files (used only via {#layout()})
  • Collapses language-variant siblings into one canonical entry (e.g., about.en.ree + about.sl.reeabout.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.reeindex.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:

  1. Merge global translations with route-specific translations
  2. Resolve the localized path for this language
  3. Generate hreflang links (only when --site-url is set)
  4. Merge data from the data loader (load_template_data())
  5. Call the template engine to compile and render
  6. 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 (/): always index.html or {lang}/index.html

Phase 8: Generate Sidebar Navigation

The generator scans markdown index.md files for the has_sidebar: true frontmatter flag. When found:

  1. Lists all .md files in that folder
  2. Sorts them alphabetically (use ordering prefixes like 01_, 02_ to control order)
  3. Resolves language-specific titles
  4. Excludes pages with skip-navigation: true
  5. 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:

  1. Resolves language-specific variant (file.{lang}.mdfile.{default}.mdfile.md)
  2. Parses frontmatter for layout selection, metadata, and visibility flags (draft, published, published_at)
  3. Evaluates content visibility - draft: true, published: false, or a published_at/date in the future means the page is rendered (reachable by URL) but excluded from listings, feeds, sitemaps, and marked noindex
  4. Renders markdown body via Bun.markdown.html() with options for tables, strikethrough, tasklists, autolinks, and heading IDs
  5. Post-processes with process_docs_markdown(raw_html, markdown_styles) - syntax highlighting, external link handling, and injection of the project's Tailwind classes from src/lib/markdown_styles.ts
  6. Resolves the layout template (from frontmatter or default "layout")
  7. Renders the layout with the processed HTML as props.body
  8. 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:

  1. Collects records - either from markdown files in the route's directory (via lib/collect_records.ts) or from the route's load_records(lang) data loader
  2. Evaluates content visibility for each record - drafts and future-dated pages are filtered from the record list
  3. Groups records into pages according to per_page
  4. Builds PaginationData view-models (page numbers, URLs, prev/next links, window segments)
  5. Renders each page's index.ree with props.records (this page's records), props.pagination (the view-model), and props.pagination_variant (the configured "full"/"simple" variant)
  6. 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.cssdist/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):

  1. Collision checks: Verifies no from path collides with a generated page route or a static asset
  2. Target validation: For internal to targets, verifies the file exists in dist/
  3. _redirects file: Writes Cloudflare-compatible redirect rules - two lines per entry (with and without trailing slash)
  4. HTML stubs: For each redirect, creates dist/{from}/index.html with 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

CodeMeaning
0SSG succeeded
1SSG 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.