null

Wie ich einen statischen Site-Generator von Grund auf gebaut habe

Ein Custom-Bytecode-Compiler, ein generischer REST-Adapter und die Frage, ob man simdjson-Objekte wirklich durch die gesamte Architektur schleifen sollte.

Was ist Guss?

Guss ist kein Wrapper um Hugo oder Jekyll. Es ist ein statischer Site-Generator, den ich komplett in C++23 selbst implementiert habe. Der Name kommt vom deutschen Wort der Guss — und das beschreibt das Konzept ganz gut: Rohe CMS-Daten werden wie geschmolzenes Metall in eine Form gegossen und als statisches HTML ausgehärtet.

Die Webseite, auf der dieser Post zu lesen ist, wurde übrigens selbst mit Guss gebaut. Das Theme pinguin ist ein vollständiges Guss-Template und die gesamte Seite entstand durch einen einzigen guss build-Aufruf gegen die Ghost-API.

Das Projekt löst zwei Probleme, die mich bei bestehenden Tools immer gestört haben.

Erstens die CMS-Abhängigkeit: Die meisten Static-Site-Generatoren setzen entweder auf Markdown-Dateien oder haben harte Integrationen für ein bestimmtes CMS. Guss spricht mit jeder HTTP-JSON-API. Ghost, WordPress, Strapi, ein selbst geschriebenes Backend — alles, was JSON zurückgibt, funktioniert. Der Adapter ist vollständig konfigurationsgetrieben.

Zweitens externe Template-Engines: Ich wollte keine Abhängigkeit auf inja, nlohmann/json oder ähnliche Bibliotheken im Render-Pfad. Also habe ich eine eigene bytecode-kompilierte Template-Engine geschrieben, die Jinja2-Syntax versteht.

Das Ergebnis ist ein Build, der 76 Items, 33 Tags, 40 Posts und alle Archive in 506 ms schreibt, davon der größte Teil Netzwerklatenz beim Fetchen:

[info] Phase 1: Fetching content from rest_api
[info] RestCmsAdapter: fetched 4 collections
[info]   tags: 33 items
[info]   authors: 1 items
[info]   pages: 2 items
[info]   posts: 40 items
[info] Phase 3: Rendering templates...
[info] Build complete!
[info]   Duration: 506ms

Die Architektur auf einen Blick

Der Dependency-Graph teilt sich in klar abgegrenzte Layer auf:

CLI  →  Builder (Pipeline)
              |
         Adapters  →  REST / Markdown
              |
          Core (Value, Config, Permalink)
              |
         Render (Lexer → Parser → AST → Compiler → Runtime)

Jeder Layer darf nur nach unten zeigen. Der render-Layer weiß nichts von HTTP. Der adapter-Layer weiß nichts vom Compiler. Das klingt trivial, aber es hat mich mehrfach davor bewahrt, Abkürzungen zu nehmen, die langfristig Schmerzen verursacht hätten.

Die vier Build-Phasen der Pipeline sind:

Phase 1 — Fetch: Der Adapter holt Inhalte aus der Quelle und konvertiert alles zu Value-Objekten. Das Ergebnis ist eine CollectionMap, eine flache unordered_map<string, vector<RenderItem>>, dazu ein Value mit Site-Metadaten.

Phase 2 — Prepare: Die Pipeline expandiert Permalink-Muster zu Output-Pfaden, löst Markdown zu HTML auf, generiert Archiv-Seiten und paginierte Chunks und serialisiert die geteilten Site-Daten einmalig in einen SharedSiteData-Block.

Phase 3 — Render: OpenMP-Parallelloop. Jeder Thread baut seinen eigenen Context mit einem Shared-Pointer auf SharedSiteData (ein atomarer Increment pro Seite, keine Locks) und den seitenspezifischen Value-Daten. Die Bytecode-Engine rendert in einen String.

Phase 4 — Write: HTML auf die Disk schreiben, Theme-Assets kopieren, optional sitemap.xml und rss.xml erzeugen.


Die zentrale Datenstruktur: Value

Bevor ich auf den Compiler eingehe, muss ich über Value sprechen, das Rückgrat des gesamten Systems.

Der Grundgedanke lautet: everything is a Value. Es gibt keine Post-, Page- oder Author-Structs. In dem Moment, in dem Daten die Adapter-Boundary überqueren, werden sie zu einem Value. Die Template-Engine weiß nicht, was ein Post ist. Die Pipeline weiß nicht, was Ghost ist. Alles wird durch Konfiguration gesteuert.

Value ist ein diskriminierter Union-Typ über std::variant:

std::variant<
    NullTag,
    std::string_view,             // zero-copy view in Adapter-owned Memory
    std::string,                  // owned string, z.B. Filter-Output
    bool,
    int64_t,
    uint64_t,
    double,
    std::shared_ptr<ValueMap>,    // key→Value-Objekte, O(1) Copy
    std::shared_ptr<ValueArray>   // indexierte Arrays, O(1) Copy
> data_;

Zwei Designentscheidungen sind hier besonders erwähnenswert.

std::string_view neben std::string: Adapter-Code kann Strings zero-copy einbringen, solange der Besitzer länger lebt. Für Filter-Output oder konstruierte Werte wird std::string benutzt. as_string() abstrahiert das weg, der Aufrufer sieht immer eine std::string_view.

shared_ptr für Map und Array: Das Kopieren eines Value, der ein großes Objekt umhüllt, kostet genau einen atomaren Increment. Die Template-Engine kopiert Value-Objekte ständig beim Iterieren von Arrays oder Zugriffen auf verschachtelte Felder. Ohne shared_ptr wäre das prohibitiv teuer.

ValueMap und ValueArray sind im Header nur forward-declared. Ihre vollständige Definition lebt in value.cpp, wo auch der Destruktor von Value definiert ist. Würde der Destruktor inlining-fähig im Header stehen, müsste shared_ptr<ValueMap> dort vollständig sein und man bräuchte zirkuläre Abhängigkeiten. Der User-Declared-Destructor bricht diesen Kreis.


Der simdjson-Trade-off

Das ist die Designfrage, die ich am längsten abgewogen habe.

Die verlockende Option: simdjson direkt durchreichen

simdjson ist bemerkenswert schnell. Es parst JSON nahezu auf Speicherbandbreiten-Geschwindigkeit durch SIMD-Instruktionen, weil es on-demand vorgeht. simdjson::ondemand::value ist intern nur ein Pointer in den geparsten Puffer. Würde ich dieses Objekt direkt bis in den Template-Renderer durchreichen, hätte ich keinerlei Allokationskosten für den gesamten Datenpfad.

Das scheitert allerdings an mehreren fundamentalen Einschränkungen.

Lifetime-Bindung: Ein ondemand::value ist an seinen ondemand::parser und das padded_string gebunden. Sobald der HTTP-Response-Buffer freigegeben wird, ist jeder Zeiger dangling. Die Render-Phase läuft zeitlich nach der Fetch-Phase, der Buffer existiert dann längst nicht mehr.

Keine Copy-Semantik: simdjson On-Demand-Objekte können nicht in std::vector gespeichert oder als Funktionsargumente weitergereicht werden, ohne die interne Traversierungs-State-Machine zu beschädigen.

Verletzung der Layer-Grenzen: Der render-Layer würde eine harte Abhängigkeit auf simdjson bekommen. Der Header value.hpp macht das explizit: "No simdjson types appear anywhere in this header or its implementation. simdjson is used exclusively in the adapter layer."

Thread-Safety: Nach dem Load ist der Template-Cache read-only und render() ist sicher für parallele Aufrufe. Mit direkten simdjson-Referenzen wäre das unmöglich.

Die gewählte Lösung: from_simdjson() als harte Boundary

Die Lösung ist eine einzelne Funktion am Boundary des Adapter-Layers:

render::Value from_simdjson(simdjson::ondemand::value val) {
    switch (val.type()) {
        case json_type::object: {
            std::unordered_map<std::string, Value> m;
            for (auto field : val.get_object())
                m[std::string(field.unescaped_key())] = from_simdjson(field.value());
            return Value(std::move(m));
        }
        case json_type::array: {
            std::vector<Value> arr;
            for (auto elem : val.get_array())
                arr.push_back(from_simdjson(elem.value()));
            return Value(std::move(arr));
        }
        case json_type::number: {
            auto nt = val.get_number_type().value();
            if (nt == number_type::signed_integer)   return Value(val.get_int64().value());
            if (nt == number_type::unsigned_integer) return Value(val.get_uint64().value());
            return Value(val.get_double().value());
        }
        case json_type::string:
            return Value(std::string(val.get_string().value()));
        case json_type::boolean:
            return Value(val.get_bool().value());
        default:
            return Value{};
    }
}

Beim ersten Zugriff auf ein API-Objekt entstehen Allokationen: ein shared_ptr<ValueMap> für jedes Objekt, std::string für jeden Key. Das klingt teuer, ist aber ein klarer Pay Once, Use Many-Trade-off. Die Konvertierung passiert einmalig pro Fetch-Zyklus. Danach werden die allokierten Value-Bäume beliebig oft von verschiedenen Templates und über den gesamten Pagination-Loop hinweg wiederverwendet, jedes Mal mit O(1)-Copy-Kosten über den shared_ptr. simdjson bleibt vollständig auf den Adapter-Layer beschränkt und der Rest des Systems ist JSON-agnostisch.


Die vollständige Compiler-Pipeline

Jetzt zum Herzstück. Wie wird ein Template-String wie

{% for post in posts %}
  <h2>{{ post.title | upper }}</h2>
  <p>{{ post.reading_minutes }} min read</p>
{% endfor %}

zu ausführbarem Bytecode? Es gibt fünf Stufen.


Stufe 1: Lexer

Der Lexer läuft in zwei alternierenden Modi.

Text-Modus: Zeichen werden verbatim akkumuliert, bis ein Jinja2-Delimiter auftaucht ({{, {% oder {#). Eine einzelne Text-Token deckt die gesamte Spanne ab, leere Spans werden still verworfen.

Tag-Modus: Nach dem Delimiter werden Whitespace übersprungen und Tokens produziert, bis der schließende Delimiter konsumiert ist (}}, %}, #}). Whitespace-Trimming-Modifier ({%-, -%}) streifen anliegenden Whitespace vom benachbarten Text-Token, analog zu Jinja2. Comments ({# … #}) werden vollständig konsumiert, ohne dass ein Token entsteht.

Das wichtigste Designprinzip: Alle string_view-Felder in Tokens zeigen direkt in den Original-Source-Buffer. Es entstehen keine Allokationen pro Token. Der Source-Buffer muss so lange leben wie der Token-Vektor — und damit auch wie der AST, dessen String-Daten ebenfalls als string_view in denselben Buffer zeigen.

struct Token {
    TokenType        type;
    std::string_view value;  // zero-allocation, Slice in den Source-Buffer
    uint32_t         line;
    uint32_t         col;
};

Stufe 2: Parser

Der Parser ist ein klassischer rekursiver Abstieg über den Token-Vektor. Er konsumiert den flachen Token-Stream und baut den AST auf.

Auf Statement-Ebene dispatcht parse_node() auf Basis des aktuellen Tokens auf parse_for(), parse_if(), parse_block(), parse_extends() oder parse_include(). Text-Tokens werden direkt zu TextNode, {{ expr }}-Tags zu ExprNode.

Auf Expression-Ebene gibt es neun Precedence-Level nach klassischem Muster:

parse_expr()
  parse_or()               →  or   (linksassoziativ)
    parse_and()            →  and  (linksassoziativ)
      parse_not()          →  not  (Präfix-Unär)
        parse_comparison() →  ==, !=, <, >, <=, >=
          parse_additive()       →  +, -
            parse_multiplicative() →  *, /, %
              parse_unary()    →  unäres -
                parse_primary()  →  Variable, Literal, (Klammern)
                  parse_filter_chain()  →  | filter(args)

Eine Besonderheit ist span_tokens(). Bei gepunkteten Pfaden wie post.author.name entstehen drei separate Identifier-Tokens. Statt sie zu einem neuen std::string zu konkatenieren, konstruiert der Parser einen einzigen std::string_view vom Anfang des ersten bis zum Ende des letzten Tokens über Zeigerarithmetik. Das ist valide, weil alle Views in denselben zusammenhängenden Source-Buffer zeigen.

Das Ergebnis ist ein ast::Template:

struct Template {
    std::string source;   // besitzt den rohen Source-Text
    std::optional<std::string> parent;
    std::vector<Node> nodes;
    std::unordered_map<std::string, BlockNode*> blocks;  // O(1)-Lookup für Vererbung
};

Die BlockNode*-Pointer in der blocks-Map zeigen auf Knoten innerhalb von nodes und bleiben stabil, weil alle Knoten via unique_ptr heap-allokiert sind.


Stufe 3: AST

Der AST besteht aus zwei Schichten.

Expressions über den Expr-Variant: Variable (Pfad-Lookup), StringLit/IntLit/FloatLit/BoolLit (Literale), Filter (Filter-Anwendung mit expliziten Argumenten), UnaryOp, BinaryOp und SuperNode für {{ super() }}.

Statements über den Node-Variant: TextNode, ExprNode, ForNode, IfNode, BlockNode, ExtendsNode, IncludeNode und SetNode.

Alle Knoten leben in std::unique_ptr innerhalb der std::variant-Aliase Expr und Node. Rekursive Strukturen wie BinaryOp (dessen Operanden wiederum Expr sind) oder ForNode::body (ein vector<Node>) sind dadurch möglich, ohne dass die Typen zum Zeitpunkt der Variant-Definition vollständig sein müssen.


Stufe 4: Compiler

Der Compiler macht einen Single-Pass über den AST und emittiert eine flache Instruction-Sequenz in CompiledTemplate. Das ist der Teil, den ich am aufwendigsten designed habe.

Das Instruction-Set

enum class Op : uint8_t {
    EmitText,    // Literal-Text ausgeben (Index in strings[])
    Resolve,     // Variable auflösen, auf Stack pushen (Index in paths[])
    Push,        // Literal-Konstante pushen (Index in constants[])
    Emit,        // TOS poppen, HTML-escapen, ausgeben
    EmitRaw,     // TOS poppen, ohne Escaping ausgeben (für | safe)
    Filter,      // Filter anwenden: (name_idx << 8) | arg_count
    BinaryOp,    // Zwei Werte poppen, Ergebnis pushen
    UnaryOp,     // Einen Wert poppen, Ergebnis pushen
    JumpIfFalse, // Bedingter Sprung (relativer Offset)
    Jump,        // Unbedingter Sprung (relativer Offset)
    ForBegin,    // Iterator initialisieren: (iterable_idx << 16) | var_idx
    ForNext,     // Iterator voranschreiten oder Sprung bei Erschöpfung
    ForEnd,      // No-op-Marker
    BlockCall,   // Block aufrufen: (skip_dist << 16) | block_idx
    BlockEnd,    // No-op-Marker
    Include,     // Template includen (Index in include_names[])
    Set,         // Stack-Top in Context schreiben (Index in paths[])
    Super,       // Parent-Block rendern und auf Stack pushen
    Return,      // Ausführung beenden
};

Alle Operanden sind int32_t. Verschiedene Instructions nutzen Bit-Packing: Filter kodiert (name_idx << 8) | arg_count in einem Operanden, ForBegin kodiert (iterable_idx << 16) | var_idx, BlockCall kodiert (skip_dist << 16) | block_idx. Das hält die Instruction-Struct bei 5 Bytes und macht den Instruction-Stream cache-freundlich.

Interning-Tabellen

Der Compiler schreibt keine Strings direkt in die Instructions. Stattdessen werden alle Strings in Parallel-Tabellen interniert:

Tabelle Verwendet von
strings[] EmitText
paths[] Resolve, ForBegin, Set
constants[] Push
filter_names[] Filter
include_names[] Include
blocks[] BlockCall

Instructions tragen nur den Index. Der Hot-Path berührt damit ausschließlich Integers.

Back-Patching für Control Flow

Forward-Sprünge sind das klassische Compiler-Problem: Beim Emittieren von JumpIfFalse ist das Ziel noch unbekannt. Die Lösung ist Back-Patching.

// compile_if:
size_t false_jump = emit(Op::JumpIfFalse, 0);  // Placeholder-Operand
compile_nodes(branch.body);
size_t end_jump   = emit(Op::Jump, 0);          // Placeholder-Operand
// Jetzt ist das Ziel bekannt:
patch(false_jump, static_cast<int32_t>(out_.code.size()) - false_jump);

patch() schreibt den relativen Offset nachträglich in den bereits emittierten Instruction-Slot zurück. emit() gibt den Index der emittierten Instruction zurück, genau damit dieser spätere patch()-Aufruf möglich ist.

Für For-Schleifen wird der Loop-Top-Index vor dem Body-Emit gespeichert:

size_t loop_top       = out_.code.size();
size_t exhausted_jump = emit(Op::ForNext, 0);        // Vorwärts-Placeholder
compile_nodes(node.body);
emit(Op::Jump,                                       // Rückwärtssprung zum Loop-Top
     static_cast<int32_t>(loop_top) - static_cast<int32_t>(out_.code.size()));
patch(exhausted_jump, ...);                          // Vorwärts-Patch bei Erschöpfung

BlockCall und Template-Vererbung

BlockCall ist das interessanteste Opcode. Für {% block content %}…{% endblock %} emittiert der Compiler drei Dinge: erstens einen BlockCall mit einem initialen Placeholder-Operanden, dann die kompilierten Nodes des Default-Body, schließlich BlockEnd als Marker. Sobald BlockEnd emittiert ist, wird der BlockCall-Operand per Back-Patch mit (skip_dist << 16) | block_idx befüllt. skip_dist gibt an, um wie viele Instructions der Executor springen soll, falls ein Child-Override vorhanden ist, um den Default-Body zu überspringen.

verify_stack_depths() — Sicherheit ohne Runtime-Overhead

Nach dem Kompilieren simuliert der Compiler in einem linearen Pass den Value-Stack und den Loop-Stack über den erzeugten Bytecode:

void Compiler::verify_stack_depths() {
    int sp  = 0;  // Value-Stack-Pointer
    int lsp = 0;  // Loop-Stack-Pointer
    for (const Instruction& instr : out_.code) {
        switch (instr.op) {
            case Op::Resolve: case Op::Push: case Op::Super: ++sp;  break;
            case Op::Emit:    case Op::EmitRaw: case Op::JumpIfFalse: --sp; break;
            case Op::BinaryOp:  sp -= 1; break;
            case Op::Filter:    sp -= static_cast<int>(instr.operand & 0xFF); break;
            case Op::ForBegin:  ++lsp; break;
            case Op::ForEnd:    --lsp; break;
            case Op::Set:       --sp;  break;
        }
        if (sp  > static_cast<int>(MAX_VALUE_STACK_SIZE)) throw ...;
        if (sp  < 0)                                       throw ...;
        if (lsp > static_cast<int>(MAX_LOOP_STACK_SIZE))  throw ...;
    }
}

Wenn ein Template komplex genug wäre, um beim Ausführen einen Stack-Overflow zu provozieren, wird die Exception beim Laden geworfen. Der Hot-Path braucht damit keinerlei Bounds-Checks. MAX_VALUE_STACK_SIZE ist 64, MAX_LOOP_STACK_SIZE ist 16.


Stufe 5: Runtime

Der Runtime besitzt den Template-Cache (unordered_map<string, CompiledTemplate>), die Filter-Registry mit direktem Index-Lookup und die geordneten Search-Paths für Template-Dateien.

Load-Flow

load(name)
  → resolve_path()         Datei über Search-Paths finden
  → Datei lesen, source in ast::Template::source speichern
  → Lexer::tokenize()      Token-Vektor erzeugen
  → Parser::parse()        AST bauen
  → Compiler::compile()    Bytecode erzeugen
  → verify_stack_depths()  Statische Sicherheitsüberprüfung
  → resolve_filter_ids()   Filter-Namen zu direkten Indizes auflösen
  → cache_[name] = ...     Ergebnis cachen

Alles läuft innerhalb eines einzigen try/catch-Blocks. Fehler werden als error::Result<> zurückgegeben, keine Exception propagiert nach außen.

Template-Vererbung: build_override_map()

Wenn child.html ein {% extends "base.html" %} enthält, traversiert build_override_map() die parent_name-Kette bis zur Wurzel und baut für jeden Block-Namen einen Override-Vektor:

override_map["content"] = [
    &base_compiled_tpl,    // Default-Body (Index 0)
    &child_compiled_tpl    // Override (tiefste Ebene zuletzt)
]

Beim Ausführen eines BlockCall schaut der Executor in diese Map: Gibt es einen Override, wird der tiefste ausgeführt und der Default-Body übersprungen. {{ super() }} über Op::Super rendert den nächsten Eintrag in der Kette, vollständig rekursiv bis zum Basis-Template.

Execute — der Hot-Path

Der Value-Stack und der Loop-Stack sind stack-allokierte C-Arrays fester Größe:

Value vstack[MAX_VALUE_STACK_SIZE];   // kein Heap-Touch im Loop
int   lstack[MAX_LOOP_STACK_SIZE][2]; // [iterator_pos, iterable_size]

Der Output-Buffer ist pre-reserved auf 32 KiB (WRITE_BUFFER_SIZE = 0x8000), um Reallokatierungen für typische Seitengrößen zu vermeiden.

Die Sprung-Konvention verdient eine Erklärung: Der Executor inkrementiert pc vor dem Lesen des Operanden. Daher ist der gespeicherte Offset target_index - instruction_index, nicht target_index - (instruction_index + 1). Der Executor berechnet pc_new = pc + operand - 1. Das ist keine Konventions-Inkonsistenz, sondern die direkte Konsequenz des Pre-Increments.


Cross-References: Wie Taxonomy-Seiten ihre Posts bekommen

Eine Besonderheit, die ich gerne noch erwähnen möchte, ist das Cross-Reference-System. Tag-Seiten müssen wissen, welche Posts zu ihnen gehören. Das ist ein klassisches N:M-Problem zwischen Collections.

Die Konfiguration sieht so aus:

cross_references:
  tags:
    from: posts
    via: "tags.slug"

Das bedeutet: Für jedes Item in der tags-Collection suche alle Items in posts, bei denen der Dot-Path tags.slug mit dem Slug des aktuellen Tags übereinstimmt. "tags.slug" ist dabei eine Array-Projektion: Der Adapter traversiert für jeden Post das tags-Array und sammelt das slug-Feld jedes Elements. Das Ergebnis wird als Root-Level-Variable posts in den Template-Context injiziert, sodass ein Tag-Template einfach {% for post in posts %} schreiben kann.


Alles zusammen

Der vollständige Datenfluss eines Build-Vorgangs:

Config (guss.yaml)
        |
RestCmsAdapter::fetch_all()
  → HTTP-Request
  → simdjson parsen
  → from_simdjson() → Value-Baum
  → enrich_item() (permalink, year/month/day, field_map)
  → cross_references auflösen
        |
Builder::Pipeline
  → render_item() für jeden Post/Page/Tag/Author (parallel via OpenMP)
        |
Runtime::render(template_name, ctx)
  → load() (Lexer → Parser → Compiler, danach gecacht)
  → build_override_map() (Vererbungskette auflösen)
  → execute() (Bytecode gegen Value-Context)
        |
HTML-Datei auf Disk

Der Context ist das Bindeglied zwischen Adapter und Renderer. Er trägt den Value-Baum des aktuellen Items sowie globale Werte wie site, pagination und loop. Variable-Auflösung über Op::Resolve traversiert diesen Baum über den internierten Pfad-String.


Fazit

Was ich an diesem Design am meisten schätze, ist die Schärfe der Layer-Grenzen. Der Renderer weiß nichts von HTTP. Der Compiler weiß nichts von der Runtime. Der Lexer weiß nichts vom Parser. Jeder Layer hat eine einzige Verantwortung und eine klar definierte Interface.

Das Bytecode-Design mit verify_stack_depths() war eine der besten Entscheidungen des Projekts. Der Compiler übernimmt die Verantwortung für die Korrektheit des erzeugten Codes, damit der Hot-Path maximal clean bleiben kann. Und from_simdjson() als harte Boundary zeigt gut, dass ein einmaliger Allokations-Preis, sauber an der richtigen Stelle bezahlt, die gesamte restliche Architektur dramatisch vereinfachen kann.

Den Sourcecode gibt es auf git.michm.de/manne/guss.