Architektur
Wie notACMS intern funktioniert — Content-Pipeline, Routing-System und Build-Prozess.
Content Pipeline
Jeder Build folgt einer vierstufigen Pipeline:
1. sass:build → SCSS zu CSS kompilieren
2. asset-map:compile → Assets hashen und kopieren
3. app:build → alle HTML-Seiten aus Content + Templates rendern
4. pagefind → Suchindex erstellen
Schritt 3 wird von BuildStaticSiteCommand in src/Command/ gesteuert, das Content scannt, Templates rendert, responsive Bilder generiert und statisches HTML in public/static/ schreibt. Schritt 4 führt npx pagefind als separaten Prozess aus.
URLs werden aus dem slug-Frontmatter-Feld abgeleitet, nicht aus Verzeichnispfaden. Ein Post in blog/releases/release-1-0-0/en.md mit slug: "blog/release-1-0-0" erzeugt /blog/release-1-0-0/.
Routing-System
Routen werden aus dem Content-Tree generiert. Jedes ContentItem hat eine URL, die durch sein slug-Frontmatter-Feld definiert wird.
Der LocalizedRouteLoader liest local/content/_routes.yaml und registriert Symfony-Routen für jede Locale. Die Default-Locale (EN) hat keine Präfixe. Andere Locales bekommen ein /{locale}/-Präfix, sofern nicht überschrieben.
Beispiel URL-Generierung:
| Content-Datei | Slug-Frontmatter | Locale | Generierte URL |
|---|---|---|---|
pages/about/en.md |
about |
EN | /about/ |
pages/about/de.md |
about |
DE | /de/about/ |
blog/releases/release-1-0-0/en.md |
blog/release-1-0-0 |
EN | /blog/release-1-0-0/ |
blog/releases/release-1-0-0/pl.md |
blog/release-1-0-0 |
PL | /wpisy/release-1-0-0/ |
Core Services
ContentService
Die Haupt-Fassade zum Laden und Abfragen von Content. Delegiert Tree-Aufbau an ContentTreeBuilder und implementiert ContentCacheInterface, sodass invalidateCache($locale) gecachte Trees verwirft, wenn sich Content ändert.
$posts = $contentService->getPosts($locale, 1, 5);
$recent = $contentService->getRecentPosts($locale, 5);
$item = $contentService->findByUrl('/about/', $locale);
ContentItem
Repräsentiert ein einzelnes Content-Element. Exponiert Methoden für Frontmatter-Felder:
$item->title() // string
$item->slug() // string — letztes Segment des Slug-Frontmatter (z.B. "my-post" aus "blog/my-post")
$item->url() // string (voller URL-Pfad, z.B. /blog/my-post/)
$item->date() // ?DateTimeImmutable
$item->updatedDate() // ?DateTimeImmutable
$item->description() // string
$item->tags() // string[]
$item->category() // ?string
$item->image() // ?string
$item->imageAlt() // string (Standardwert: title)
$item->template() // string
$item->isDraft() // bool
$item->isScheduled() // bool
$item->isPinned() // bool
$item->isDynamic() // bool
$item->isFeatured() // bool
$item->hasToc() // bool
$item->series() // ?string
$item->seriesOrder() // ?int
$item->directoryKey() // ?string
$item->relatedSlugs() // string[]
$item->menuWeight() // int
$item->isIndex() // bool
$item->readingTime() // int
$item->wordCount() // int
$item->excerpt() // string
$item->htmlContent // gerendeter HTML-String (public property)
SiteConfigService
Liest local/content/_site.yaml und exponiert Site-weite Einstellungen als Service. Wird in Controller und Twig-Globals via SiteConfigExtension injected.
ContentTree
Interne Repräsentation der Content-Hierarchie für ein einzelnes Locale. Wird für Navigation, Vor-/Zurück-Berechnung und Series-Erkennung verwendet. ContentService umschließt sie als Fassade, daher existieren dieselben Lookup-Namen (findByUrl, findPostBySlug, findScheduledPostBySlug) auf beiden Ebenen: die Service-Variante nimmt $locale entgegen und delegiert an den Tree des entsprechenden Locale.
Wichtige Methoden:
getAdjacentPosts()— gibt das Value ObjectAdjacentPostsmit->prevund->nextzurückgetSeriesPosts()— gibt Posts derselben Series zurück, sortiert nachseries_order
Weitere Services
| Service | Zweck |
|---|---|
ImageResizer |
ImageMagick-Wrapper: resize() erzeugt responsive Varianten; optimize() recomprimiert in-place |
ResponsiveImageService |
Berechnet responsive Variantenbreiten und erstellt srcset-Attributwerte |
SrcsetExtension |
Twig-Filter srcset_media — post-processiert HTML um srcset/sizes-Attribute in <img>-Tags einzufügen |
MediaFileResolver |
Löst Mediendateipfade über Locales hinweg auf |
RelatedPostsService |
Findet verwandte Posts über gemeinsame Tags und manuelles related-Frontmatter |
SessionToggleService |
Generischer session-basierter Boolean-Toggle; gemeinsame Abhängigkeit beider Preview-Services unten |
DraftPreviewService |
Verwaltet Draft-Content-Vorschau mit session-basierten Toggles |
ScheduledPreviewService |
Verwaltet Vorschau für geplante, zukünftige Posts |
TranslationMapBuilder |
Erstellt URL-Übersetzungskarten über Locales für den Sprachwechsler |
ContactFormConfig |
Stellt Kontaktformular-Konfiguration bereit (Empfänger, Turnstile, etc.) |
MarkdownParser |
Konvertiert Markdown zu HTML via CommonMark |
SidebarDataProvider |
Stellt Sidebar-Daten zusammen (Kategorien, Tags, letzte Posts) |
TagTranslationService |
Übersetzt Tag-Slugs zwischen Locales |
Twig-Erweiterungen
Fünf Twig-Erweiterungen stellen Daten für Templates bereit:
| Erweiterung | Typ | Stellt bereit |
|---|---|---|
SiteConfigExtension |
Globals | site_name, site_base_url, site_description, site_social, site_author, site_locales, site_locales_list, site_default_locale, image_variant_widths, new_post_days, coming_soon_reveal_days, meta_description_length |
TranslationMapTwigExtension |
Global | translation_map — {directoryKey: {locale: url}} Zuordnung für Sprachwechsler und hreflang-Tags |
ContentTwigExtension |
Funktion | content_url(directoryKey, locale) — löst URL für ein Content-Element nach Directory-Key auf |
LangSwitcherExtension |
Funktion | lang_switch_urls(otherLocales) — löst Sprachwechsler-URLs pro Locale auf: Translation-Map, Controller-Override, dann Fallbacks (Archiv → paginiert → Blog-Liste → Startseite) |
SrcsetExtension |
Filter | srcset_media — fügt srcset/sizes-Attribute in <img>-Tags ein für responsive Bilder |
Zusätzlich wird cf_analytics_token als Twig-Global in config/packages/twig.yaml registriert, gebunden an die CF_ANALYTICS_TOKEN-Umgebungsvariable.
Request Flow (Live Mode)
Browser → nginx → PHP-FPM → Symfony Kernel → LocaleListener → Router
→ Controller → ContentService → Twig → HTML-Response
→ Twig-Renderer (Template anwenden)
→ HTML-Response
Controller sind dünn. Alle Business-Logik liegt in Services.
Test-Strategie
ddev test # Gesamte Test-Suite ausführen
ddev code-check # PHPStan Level 6 + PHP CS Fixer
- Hunderte von Testmethoden in Integrations- und Unit-Suites.
- PHPStan: Static Analysis auf Level 6. Alle Services sind vollständig getypt.
- PHP CS Fixer: Erzwingt PSR-12 + Symfony Coding Standards.
- Integrationstests: Decken Routing, Content-Loading, Controller, Build-Output, Bildverarbeitung, Preview-Services und Twig-Extensions ab.
- Unit-Tests: Decken
ContentItem,ContentTree, Value Objects,RelatedPostsService,TagTranslationServiceundResponsiveImageServiceab.
Siehe docs/TESTING.md für Details.
Features hinzufügen
notACMS folgt dem Symfony Service-Container-Pattern. Um einen neuen Content-Type oder Build-Schritt hinzuzufügen:
- Interface in
src/Service/erstellen (für Core-Beiträge) oderlocal/src/(für Site-spezifische Erweiterungen) - Implementierungsklasse im selben Verzeichnis erstellen
- Symfony auto-discovered und autowired automatisch — keine manuelle
services.yaml-Registrierung nötig - In den relevanten Controller oder Command injecten
- Templates in
local/templates/überschreiben, falls nötig
Tipp: Alle Services sind zuerst Interfaces. Das macht es einfach, Implementierungen zu tauschen — zum Beispiel den lokalen Filesystem-Loader gegen eine S3-Quelle.
PHP Runtime (Optional)
Standardmäßig ist die gebaute Site rein statisch — Nginx served public/static/ direkt und PHP ist nicht involviert.
Das Produktions-Docker-Setup in local/docker/ enthält einen optionalen PHP-Container. Setze RUNTIME_PHP_ENABLED=true in deiner Produktionsumgebung, um ihn zu starten. Wenn aktiviert, leitet Nginx /api/ (und /{locale}/api/) an den PHP-Container weiter — alles andere wird weiterhin als statische Datei ausgeliefert.
Der Standard-Use-Case ist das Kontaktformular (ContactController via SMTP), das unter /api/contact erreichbar ist.
Hinweis: Lokale Entwicklung (
ddev start) führt die volle Symfony-Anwendung dynamisch aus. Das ist ein Dev-only-Komfort-Feature — es ist nicht Teil der Produktions-PHP-Runtime.
Deployment-Architektur
Static-First Serving
Nginx serviert vorgebaute Dateien direkt aus public/static/. PHP ist nur involviert für:
/api/contact— wennRUNTIME_PHP_ENABLED=true- Entwicklungsmodus (
ddev start)
Produktions-Traffic-Flow:
Browser → Nginx → public/static/ (HTML, CSS, JS)
→ Proxy zum PHP-Container (nur für /api/*)
Build vs Runtime
| Phase | Kommando | Output | Dauer |
|---|---|---|---|
| 1. Kompilieren | sass:build |
public/assets/app-<hash>.css |
~2s |
| 2. Assets | asset-map:compile |
Gehashte Asset-Kopien in public/assets/ |
~1s |
| 3. Build | app:build (BuildStaticSiteCommand) |
public/static/**/*.html |
~5s |
| 4. Index | pagefind |
public/pagefind/ Suchindex |
~3s |
Gesamter erster Deploy: ~2-3 Minuten (Docker-Build) Content-Update: ~10 Sekunden
Container-Profile
| Profil | Container | Use case |
|---|---|---|
| (none) | nur nginx | Statische Sites, kein Kontaktformular |
--profile runtime |
nginx + php | Kontaktformular aktiviert |
Profile werden via RUNTIME_PHP_ENABLED in .env.local gewechselt.