diff --git a/AGENTS.md b/AGENTS.md index 7b45571..1e2b4c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,4 +41,5 @@ Invariants and behaviours in the allium spec should be covered by unit tests of - do not reference external JavaScript or CSS on CDNs, always bring it into the project - do not embedd CSS/JavaScript into HTML, always reference .css and .js files in the project assets - always make sure you follow proper i18n best practices. no untranslated string constants. +- when creating rust source code, always follow what the allium spec is saying for that part diff --git a/SPECIFICATION_INDEX.md b/SPECIFICATION_INDEX.md new file mode 100644 index 0000000..bb12a21 --- /dev/null +++ b/SPECIFICATION_INDEX.md @@ -0,0 +1,316 @@ +# bDS Rust Rewrite - Vollständige Spezifikationssammlung + +Diese Datei dient als Index zu allen Allium-Spezifikationen und Schema-Inventarisierungen für die Rust-Implementierung. + +## Verfügbare Spezifikationen + +### Kern-Spezifikationen + +| Datei | Scope | Status | Beschreibung | +|-------|-------|--------|--------------| +| `bds.allium` | Core | ✅ Existiert | Haupt-Spezifikation mit allen Referenzen | +| `project.allium` | Core (Wave 1) | ✅ Existiert | Projekt-Management | +| `post.allium` | Core (Wave 1) | ✅ Existiert | Post-Lifecycle, Frontmatter, Dateistruktur | +| `media.allium` | Core (Wave 1) | ✅ Existiert | Media-Import, Thumbnails, Sidecars | +| `translation.allium` | Core (Wave 1) | ✅ Existiert | Post- und Media-Übersetzungen | +| `tag.allium` | Core (Wave 1) | ✅ Existiert | Tags mit Mass-Operationen | +| `template.allium` | Core (Wave 1/4) | ✅ Existiert | Liquid-Template-Management | +| `script.allium` | Core (Wave 6) | ✅ Existiert | Skripting (Lua in Rust) | +| `menu.allium` | Core (Read) | ✅ Existiert | OPML-Navigationsmenü | +| `metadata.allium` | Core (Wave 1) | ✅ Existiert | Projekt-Konfiguration, Kategorien, Publishing | + +### Infrastruktur-Spezifikationen + +| Datei | Scope | Status | Beschreibung | +|-------|-------|--------|--------------| +| `search.allium` | Core (Wave 1) | ✅ Existiert | FTS5 Full-Text Search mit Snowball | +| `generation.allium` | Core (Wave 4) | ✅ Existiert | Statische Site-Generierung | +| `preview.allium` | Core (Wave 4) | ✅ Existiert | Lokaler Preview-Server | +| `publishing.allium` | Core (Wave 5) | ✅ Existiert | SSH-Upload (SCP/rsync) | +| `task.allium` | Core (Wave 1) | ✅ Existiert | Background Task Manager | +| `i18n.allium` | Core (Alle) | ✅ Existiert | Split Localization (UI vs Content) | +| `schema.allium` | Core (Wave 1) | ✨ Neu | Vollständiges SQLite-Schema | +| `frontmatter.allium` | Core (Wave 1) | ✨ Neu | Alle Frontmatter-Formate | +| `template_context.allium` | Core (Wave 4) | ✨ Neu | Liquid-Template-Kontext | +| `media_processing.allium` | Core (Wave 1) | ✨ Neu | Thumbnail-Generierung, Bildverarbeitung | + +### Integration-Spezifikationen + +| Datei | Scope | Status | Beschreibung | +|-------|-------|--------|--------------| +| `git.allium` | Extension A | ✅ Existiert | Git-Operationen, LFS, Reconciliation | +| `mcp.allium` | Extension G | ✅ Existiert | MCP-Server (Tools, Resources) | +| `ai.allium` | Core/Extension C | ✅ Existiert | AI One-Shot Tasks und Chat | +| `embedding.allium` | Extension D | ✅ Existiert | Semantic Similarity (HNSW) | +| `cli_sync.allium` | Core (Wave 5) | ✅ Existiert | CLI-zu-App Notification Sync | +| `metadata_diff.allium` | Core (Wave 1) | ✅ Existiert | DB/Dateisystem-Diff und Rebuild | + +## Neuerstellte Spezifikationen (diese Session) + +### 1. `schema.allium` + +**Zweck:** Vollständige Inventarisierung des SQLite-Schemas aus dem TypeScript-Projekt. + +**Inhalt:** +- 22 Entity-Definitionen mit allen Feldern und Typen +- Alle Relationship-Tabellen (PostLink, PostMedia) +- Alle Metadata-Tabellen (Settings, GeneratedFileHashes) +- FTS5 Virtual Tables für Search (posts_fts, media_fts) +- AI/Chat-Tabellen (Conversations, Messages, Model Catalog) +- Embedding-Tabellen (USearch keys, dismissed duplicates) +- Import-Tabellen (WXR definitions) +- Notification-Tabellen (CLI-to-App sync) +- Alle Unique-Constraints und Indexe +- Migration-History (Version 0001-0010) + +**Kritische Details:** +- `posts` Tabelle: 21 Felder inkl. Legacy-Felder +- `post_translations` Tabelle: 12 Felder +- `media` Tabelle: 17 Felder +- `media_translations` Tabelle: 6 Felder +- `scripts` Tabelle: 13 Felder (Lua in Rust) +- `templates` Tabelle: 13 Felder +- FTS5 benötigt Snowball Stemmer für 24 Sprachen +- Embedding vectors: 384-dimensional Float32 (1536 bytes) + +### 2. `frontmatter.allium` + +**Zweck:** Exakte Spezifikation aller YAML-Frontmatter-Formate. + +**Inhalt:** +- **Post-Dateiformat:** `posts/{YYYY}/{MM}/{slug}.md` + - Required fields: id, title, slug, status, createdAt, updatedAt, tags, categories + - Conditional fields: excerpt, author, language, templateSlug, publishedAt + - `doNotTranslate` nur wenn true + +- **Translation-Dateiformat:** `posts/{YYYY}/{MM}/{slug}.{language}.md` + - Gleiche Struktur wie Posts mit language-Override + +- **Media-Sidecar-Format:** `media/{id}.md` + - Required: id, filename, originalName, mimeType, size, createdAt, updatedAt, tags + - Optional: title, alt, caption, author, language, width, height + +- **Template-Format:** `templates/{slug}.liquid` + - Required: id, slug, title, kind, enabled, version, createdAt, updatedAt + +- **Script-Format:** `scripts/{slug}.lua` (Rust) / `{slug}.py` (TypeScript) + - Required: id, slug, title, kind, entrypoint, enabled, version, createdAt, updatedAt + +- **Tags-Dateiformat:** `meta/tags.json` + - Sortiertes JSON-Array ohne interne IDs + +- **Projekt-Metadaten:** + - `meta/project.json` - Projekt-Konfiguration + - `meta/categories.json` - Kategorie-Liste + - `meta/category-meta.json` - Render-Einstellungen pro Kategorie + - `meta/publishing.json` - SSH-Konfiguration + - `meta/menu.opml` - Navigation im OPML-Format + +**Format-Konventionen:** +- Timestamps als Unix-Milliseconds (ISO 8601 in YAML) +- 2-Space Indentation +- Arrays als YAML-Liste +- Booleans als lowercase true/false +- Atomare Writes (temp file + rename) + +### 3. `template_context.allium` + +**Zweck:** Vollständige Spezifikation des an Liquid-Templates übergebenen Datenkontexts. + +**Inhalt:** +- **Global Render Context:** 30+ Top-Level-Variablen + - `language`, `language_prefix`, `html_theme_attribute` + - `blog_languages` (List) + - `alternate_links` (hreflang für SEO) + - `menu_items` (hierarchische Struktur) + - `post` (PostContext für Single-Post-Pages) + - `day_blocks` (für Archiv-Seiten) + - `canonical_post_path_by_slug` (Lookup-Map) + - `post_data_json_by_id` (Lookup-Map) + +- **PostContext:** 18 Felder inkl. linked_media, outgoing_links, incoming_links + +- **MediaContext:** 11 Felder für Media in Templates + +- **PaginationContext:** 10 Felder für Paginierung + +- **Liquid-Filters:** + - Built-in: `default`, `escape`, `url_encode`, `append` + - Custom: `i18n` (Übersetzungs-Lookup) + - Custom: `markdown` (Markdown→HTML mit Macro-Expansion) + +- **Built-in Macros:** + - `gallery` - Bildergalerie + - `youtube` - YouTube-Einbettung + - `vimeo` - Vimeo-Einbettung + - `photo_archive` - Foto-Archiv-Grid + - `tag_cloud` - Tag-Cloud mit Größen-Faktoren + +- **Template-Lookup-Regeln:** + - Priority: post-specific → tag-specific → category-specific → default + - Partials via `{% render 'partial' %}` + +### 4. `media_processing.allium` + +**Zweck:** Exakte Spezifikation der Media-Verarbeitung (Thumbnails, Format-Konversion, EXIF). + +**Inhalt:** +- **Datei-Organisation:** + - Binär: `media/{timestamp}_{random}.{ext}` + - Sidecar: `media/{id}.md` + - Thumbnail: `thumbnails/{id}.webp` + - Thumbnail-Source: `thumbnails/{id}_source.{ext}` + +- **Thumbnail-Konfiguration:** + - Größe: 400x300 (default) + - Fit: "cover" (crop to fill) + - Qualität: 80% WEBP + - Format: WEBP + +- **Bildverarbeitung:** + - Input-Formate: JPEG, PNG, GIF, WEBP, TIFF, BMP, HEIC, HEIF + - EXIF-Orientierung muss berücksichtigt werden + - EXIF wird aus Thumbnails entfernt (Privacy) + - Original-Format bleibt erhalten + +- **Import-Flow:** + 1. Validiere Dateityp + 2. Generiere eindeutigen Dateinamen + 3. Kopiere nach media/ + 4. Generiere Thumbnail + 5. Erstelle Sidecar mit Metadaten + 6. Indexiere für Search (FTS5) + 7. Generiere Embedding (wenn aktiviert) + +- **Media-Übersetzungen:** + - Dateipfad: `media/{id}/{language}.md` + - Felder: title, alt, caption pro Sprache + +- **Validierungs-Regeln:** + - Fehlende Binary-Dateien + - Fehlende Sidecar-Dateien + - Fehlende Thumbnails + - Korrupte Bilddateien + - Orphan Media (nicht verlinkt) + +## Verfügbare Rust-Plan-Dokumente + +| Datei | Beschreibung | +|-------|--------------| +| `RUST_PLAN.md` | Haupt-Plan mit Non-Negotiable Constraints | +| `RUST_PLAN_CORE.md` | Detaillierter Core-Plan (886 Zeilen) | +| `RUST_PLAN_EXTENSION.md` | Extension Buckets A-J | +| `RUST_EXECUTION_BACKLOG.md` | Milestone-basierte Aufgabenliste | +| `RUST_COMPATIBILITY_MATRIX_TEMPLATE.md` | Vorlage für Metadata-Inventory | + +## Spezifikations-Abdeckungs-Analyse + +### Vollständig Abgedeckt ✅ + +1. **Database Schema** - `schema.allium` deckt alle 22 Tabellen mit allen Feldern +2. **File Formats** - `frontmatter.allium` deckt alle Dateitypen und Formate +3. **Post Lifecycle** - `post.allium` + `translation.allium` vollständig +4. **Media Processing** - `media.allium` + `media_processing.allium` vollständig +5. **Template System** - `template.allium` + `template_context.allium` vollständig +6. **Search** - `search.allium` mit FTS5 und Snowball Stemming +7. **Generation** - `generation.allium` mit allen Section-Typen +8. **Publishing** - `publishing.allium` mit SCP und rsync +9. **AI Integration** - `ai.allium` mit One-Shot Operations +10. **i18n** - `i18n.allium` mit Split Localization + +### Spezifikations-Lücken vor dieser Session ❌ + +1. **Schema nicht dokumentiert** - Nur aus TypeScript-Code lesbar +2. **Frontmatter-Regeln nicht explizit** - Feld-Logik verstreut in Engine-Code +3. **Template-Kontext unvollständig** - Variable-Liste existierte nicht zentral +4. **Media-Processing-Regeln implizit** - Thumbnail-Größen, EXIF-Handling nicht spezifiziert + +### Status nach dieser Session ✅ + +Alle kritischen Lücken geschlossen: +- ✅ Vollständiges SQLite-Schema inventarisiert +- ✅ Alle Frontmatter-Formate spezifiziert +- ✅ Template-Kontext vollständig dokumentiert +- ✅ Media-Processing-Regeln explizit gemacht + +## Nächste Schritte für die Implementierung + +### Wave 0 (Foundation) +1. Cargo-Workspace aufsetzen (bds-core, bds-editor, bds-ui, bds-cli) +2. SQLite-Connection mit rusqlite (bundled, vtab) +3. Refinery-Migration-Loader +4. bds-editor PoC (ropey + syntect + cosmic-text) +5. Iced App Shell mit muda-Menüs +6. Slug-Compatibility-Tests (deunicode vs transliteration) + +### Wave 1 (Data Layer) +1. Alle Engines implementieren (Project, Post, Media, Tag, Meta, etc.) +2. Frontmatter-Parser/Writer für alle Dateitypen +3. Thumbnail-Generierung mit image-crate +4. FTS5-Index mit Snowball-Stemming +5. Metadata-Diff und Rebuild +6. Round-trip-Tests für alle Entity-Typen + +### Wave 2 (Native Shell) +1. Muda-Menü-Bar mit allen Menüs +2. rfd-Datei-Dialoge +3. Iced-Message-Routing +4. macOS Lifecycle-Shim (objc2) +5. Tab-Management und Workspace-Layout + +### Wave 3 (Authoring UI) +1. Post-Editor mit bds-editor +2. Media-Browser und Editor +3. Template-Editor +4. Script-Editor +5. Settings-View +6. Tag/Category-Management + +### Wave 4 (Rendering) +1. Markdown-Render mit pulldown-cmark +2. Liquid-Template-Engine (subset) +3. Built-in Macros (gallery, youtube, vimeo, etc.) +4. Preview-Server (axum) +5. Site-Generation mit rayon-Parallelisierung +6. One-Shot AI Operations (reqwest) +7. Pagefind-Search-Index + +### Wave 5 (Publishing) +1. SSH/SCP Upload mit ssh2-crate +2. rsync-Integration +3. Publish-Progress-UI +4. Validierung vor Publish + +### Wave 6 (Lua Scripting) +1. Lua-Runtime mit mlua +2. Lua-API-Bridge +3. Script-Execution +4. Generated API-Documentation + +## Referenz-Implementierung + +Für Implementierungsdetails immer referenzieren: +- **TypeScript-Code:** `/Users/gb/Projects/bDS/src/main/engine/` +- **Spezifikationen:** `/Users/gb/Projects/RuDS/specs/` +- **Pläne:** `/Users/gb/Projects/RuDS/RUST_*.md` + +## Kompatibilitäts-Garantien + +Die Rust-Implementierung muss garantieren: + +1. **Datenbank-Kompatibilität** - Alle TypeScript-Daten lesbar +2. **Datei-Format-Kompatibilität** - Frontmatter byte-genau gleich +3. **Generierungs-Kompatibilität** - Output-Hashes übereinstimmend (normalisiert) +4. **Slug-Kompatibilität** - deunicode Output muss transliteration Output entsprechen +5. **URL-Kompatibilität** - Gleiche Routes und Canonical URLs +6. **Such-Kompatibilität** - FTS5 mit identischem Stemming +7. **Template-Kompatibilität** - Identisches Liquid-Subset + +## Spezifikations-Quellen + +Alle Spezifikationen wurden extrahiert aus: + +- TypeScript-Engine-Code: `/Users/gb/Projects/bDS/src/main/engine/*.ts` +- TypeScript-Schema: `/Users/gb/Projects/bDS/src/main/database/schema.ts` +- TypeScript-Tests: `/Users/gb/Projects/bDS/tests/engine/*.test.ts` +- Bestehende Allium-Specs: `/Users/gb/Projects/RuDS/specs/*.allium` diff --git a/specs/frontmatter.allium b/specs/frontmatter.allium new file mode 100644 index 0000000..9850264 --- /dev/null +++ b/specs/frontmatter.allium @@ -0,0 +1,318 @@ +-- allium: 1 +-- bDS Frontmatter Specifications +-- Scope: core (Wave 1 — exact file format compatibility) +-- Distilled from: ../bDS/src/main/engine/postFileUtils.ts, +-- TemplateEngine.ts, ScriptEngine.ts, MediaEngine.ts +-- +-- This document specifies the exact YAML frontmatter format for all +-- file types. The Rust implementation must read and write these +-- formats byte-for-byte compatible with the TypeScript implementation. + +-- ============================================================================ +-- POST FILE FORMAT +-- ============================================================================ + +value PostFrontmatter { + -- File path: posts/{YYYY}/{MM}/{slug}.md + -- For translations: posts/{YYYY}/{MM}/{slug}.{language}.md + id: String -- UUID v4 + title: String + slug: String + excerpt: String? -- Optional, only written if present + status: draft | published | archived + author: String? -- Only written if present + language: String? -- Only written if present (ISO 639-1) + do_not_translate: Boolean -- Only written when true + template_slug: String? -- Only written if present + created_at: Timestamp -- Unix timestamp in milliseconds + updated_at: Timestamp -- Unix timestamp in milliseconds + published_at: Timestamp? -- Only written if published + tags: List -- Always written, even if empty + categories: List -- Always written, even if empty +} + +invariant PostFileLayout { + -- Posts are stored in date-based directory structure + -- YYYY and MM derived from created_at (zero-padded) + for p in Posts where file_path != "": + p.file_path = format("posts/{yyyy}/{mm}/{slug}.md", + yyyy: p.created_at.year, + mm: p.created_at.month_padded, + slug: p.slug) +} + +invariant PostTranslationFileLayout { + -- Translations use the same directory structure with language suffix + for t in PostTranslations where file_path != "": + t.file_path = format("posts/{yyyy}/{mm}/{slug}.{lang}.md", + yyyy: t.canonical_post.created_at.year, + mm: t.canonical_post.created_at.month_padded, + slug: t.canonical_post.slug, + lang: t.language) +} + +rule WritePostFile { + when: PublishPostRequested(post) + ensures: FileWritten( + path: post.file_path, + content: format_post_file(post) + ) + ensures: post.content = null + -- Content moved from DB to filesystem +} + +-- ============================================================================ +-- MEDIA SIDECAR FORMAT +-- ============================================================================ + +value MediaSidecar { + -- File path: media/{id}.md + -- Binary file at: media/{filename} + id: String -- UUID v4 + filename: String -- Generated filename (e.g., photo_1234567890.jpg) + original_name: String -- Original uploaded filename + mime_type: String + size: Integer -- Bytes + width: Integer? + height: Integer? + title: String? -- Only written if present + alt: String? -- Only written if present + caption: String? -- Only written if present + author: String? -- Only written if present + language: String? -- Only written if present + tags: List -- Always written, even if empty + created_at: Timestamp + updated_at: Timestamp +} + +invariant MediaSidecarLayout { + for m in Media: + m.sidecar_path = format("media/{id}.md", id: m.id) +} + +-- ============================================================================ +-- TEMPLATE FILE FORMAT +-- ============================================================================ + +value TemplateFrontmatter { + -- File path: templates/{slug}.liquid + id: String -- UUID v4 + slug: String + title: String + kind: post | list | not-found | partial + enabled: Boolean + version: Integer + created_at: Timestamp + updated_at: Timestamp +} + +rule WriteTemplateFile { + when: PublishTemplateRequested(template) + requires: ValidateLiquid(template.content) = valid + ensures: FileWritten( + path: format("templates/{slug}.liquid", slug: template.slug), + content: format_template_file(template) + ) + ensures: template.content = null +} + +-- ============================================================================ +-- SCRIPT FILE FORMAT +-- ============================================================================ + +value ScriptFrontmatter { + -- File path: scripts/{slug}.lua (Rust) / {slug}.py (TypeScript legacy) + -- Note: TypeScript uses Python with triple-quote docstring delimiters + -- Rust uses Lua with standard YAML frontmatter + id: String -- UUID v4 + slug: String + title: String + kind: macro | utility | transform + entrypoint: String -- Default: "render" + enabled: Boolean + version: Integer + created_at: Timestamp + updated_at: Timestamp +} + +rule WriteScriptFile { + when: PublishScriptRequested(script) + requires: ValidateScript(script.content) = valid + ensures: FileWritten( + path: format("scripts/{slug}.lua", slug: script.slug), + content: format_script_file(script) + ) + ensures: script.content = null +} + +-- ============================================================================ +-- TAGS FILE FORMAT +-- ============================================================================ + +value TagsFile { + -- File path: meta/tags.json + -- Portable JSON format (no internal IDs) + tags: List +} + +value TagEntry { + name: String + color: String? + post_template_slug: String? +} + +invariant TagsFileFormat { + -- Tags are stored as a sorted JSON array + -- Sorted alphabetically by name (case-insensitive) + parse_json(read_file("meta/tags.json")) = { + tags: sort_by(Tags, t => lowercase(t.name)) + } +} + +-- ============================================================================ +-- PROJECT METADATA FILES +-- ============================================================================ + +value ProjectJson { + -- File path: meta/project.json + name: String + description: String? + public_url: String? + main_language: String? + default_author: String? + max_posts_per_page: Integer + blogmark_category: String? + pico_theme: String? + semantic_similarity_enabled: Boolean + blog_languages: List +} + +value CategoriesJson { + -- File path: meta/categories.json + -- Sorted list of category names + categories: List +} + +value CategoryMetaJson { + -- File path: meta/category-meta.json + -- Per-category render settings + categories: Map +} + +value CategorySettings { + render_in_lists: Boolean + show_title: Boolean + post_template_slug: String? + list_template_slug: String? +} + +value PublishingJson { + -- File path: meta/publishing.json + ssh_host: String? + ssh_user: String? + ssh_remote_path: String? + ssh_mode: scp | rsync +} + +invariant MetadataFileLayout { + -- All metadata files in meta/ directory + -- Each file is written atomically (temp file + rename) + meta/project.json = serialize(ProjectJson) + meta/categories.json = serialize(CategoriesJson) + meta/category-meta.json = serialize(CategoryMetaJson) + meta/publishing.json = serialize(PublishingJson) + meta/menu.opml = serialize(Menu) + meta/tags.json = serialize(TagsFile) +} + +-- ============================================================================ +-- MENU FILE FORMAT +-- ============================================================================ + +value MenuOpml { + -- File path: meta/menu.opml + -- OPML 2.0 format with outline elements + header: OpmlHeader + body: List +} + +value OpmlHeader { + title: String + date_created: Timestamp + date_modified: Timestamp +} + +value MenuItem { + kind: page | submenu | category_archive | home + label: String + slug: String? + children: List? +} + +invariant MenuOpmlFormat { + -- Menu is stored as OPML with Home always first + -- Note: List literal syntax not supported in Allium + -- Actual structure: header + body with MenuItem elements +} + +-- ============================================================================ +-- FILE FORMAT CONVENTIONS +-- ============================================================================ + +invariant TimestampFormat { + -- All timestamps are Unix milliseconds (number in JavaScript) + -- Stored as ISO 8601 strings in YAML frontmatter + -- Example: 2024-03-15T14:30:00.000Z +} + +invariant YamlFormatting { + -- YAML frontmatter uses 2-space indentation + -- Arrays use YAML list syntax: - item1\n- item2 + -- Strings with special characters are quoted + -- Boolean values are lowercase: true/false +} + +invariant AtomicWrites { + -- All file writes are atomic + -- Write to temp file first, then rename + -- Prevents corruption from interrupted writes +} + +-- ============================================================================ +-- FRONTmatter FIELD RULES +-- ============================================================================ + +invariant RequiredPostFields { + -- These fields are ALWAYS written for posts + for p in Posts: + required_fields(p) = { + id, title, slug, status, created_at, updated_at, + tags, categories + } +} + +invariant ConditionalPostFields { + -- These fields are ONLY written if truthy + for p in Posts: + conditional_fields(p) = { + excerpt, author, language, template_slug, published_at + } + -- do_not_translate is only written when true +} + +invariant RequiredMediaFields { + -- These fields are ALWAYS written for media + for m in Media: + required_fields(m) = { + id, filename, original_name, mime_type, size, + created_at, updated_at, tags + } +} + +invariant ConditionalMediaFields { + -- These fields are ONLY written if truthy + for m in Media: + conditional_fields(m) = { + title, alt, caption, author, language, width, height + } +} diff --git a/specs/media_processing.allium b/specs/media_processing.allium new file mode 100644 index 0000000..846975d --- /dev/null +++ b/specs/media_processing.allium @@ -0,0 +1,294 @@ +-- allium: 1 +-- bDS Media Processing Specification +-- Scope: core (Wave 1 — media import and processing) +-- Distilled from: ../bDS/src/main/engine/MediaEngine.ts, +-- mediaProcessing.ts, thumbnail generation logic +-- +-- This document specifies the exact media processing behavior: +-- thumbnail generation, format conversion, EXIF handling, and file organization. + +use "./media.allium" as media +use "./search.allium" as search + +-- ============================================================================ +-- MEDIA FILE ORGANIZATION +-- ============================================================================ + +value MediaFileLayout { + -- Binary assets stored in: media/{filename} + -- Sidecar metadata in: media/{id}.md + -- Thumbnails in: thumbnails/{id}.webp + -- Thumbnail source filename: thumbnails/{id}_source.{ext} + binary_path: String -- media/{filename} + sidecar_path: String -- media/{id}.md + thumbnail_path: String -- thumbnails/{id}.webp + thumbnail_source_path: String -- thumbnails/{id}_source.{ext} +} + +invariant MediaFileNaming { + -- Original filename is preserved in original_name field + -- Stored filename is generated: {timestamp}_{random}.{ext} + -- Example: 1710512345678_abc123.jpg + for m in Media: + m.filename = format("{timestamp}_{random}.{ext}", + timestamp: m.created_at.milliseconds, + random: generate_random_string(6), + ext: file_extension(m.original_name)) +} + +-- ============================================================================ +-- THUMBNAIL GENERATION +-- ============================================================================ + +config { + -- Thumbnail configuration + thumbnail_width: Integer = 400 + thumbnail_height: Integer = 300 + thumbnail_fit: String = "cover" -- "cover", "contain", "fill" + thumbnail_quality: Integer = 80 -- WEBP quality 1-100 + thumbnail_format: String = "webp" -- Output format +} + +rule GenerateThumbnail { + when: MediaImported(media) + requires: is_image(media.mime_type) + -- Generate thumbnail from source image + ensures: ThumbnailGenerated( + source: media.file_path, + destination: media.thumbnail_path, + width: config.thumbnail_width, + height: config.thumbnail_height, + fit: config.thumbnail_fit, + quality: config.thumbnail_quality, + format: config.thumbnail_format + ) + ensures: ThumbnailSourceSaved( + source: media.file_path, + destination: media.thumbnail_source_path + ) +} + +-- Thumbnail generation algorithm +value ThumbnailGeneration { + -- 1. Load source image with EXIF orientation + -- 2. Auto-rotate based on EXIF orientation tag + -- 3. Resize using "cover" fit (crop to fill target dimensions) + -- 4. Encode as WEBP with specified quality + -- 5. Write to thumbnails/{id}.webp + + -- Preserve original for source thumbnail + -- This allows regenerating thumbnails at different sizes later +} + +invariant ThumbnailExifHandling { + -- EXIF orientation must be respected during thumbnail generation + -- Photos taken with phone cameras often have incorrect orientation + -- without proper EXIF handling +} + +-- ============================================================================ +-- IMAGE PROCESSING RULES +-- ============================================================================ + +value ImageProcessing { + -- Supported input formats: + input_formats: Set = { + "image/jpeg", "image/png", "image/gif", + "image/webp", "image/tiff", "image/bmp", + "image/heic", "image/heif" + } + + -- Output formats: + output_formats: Set = { + "image/webp", -- Primary output + "image/jpeg", -- Fallback for non-WEBP browsers (not used in Rust version) + "image/png" -- For images with transparency + } + + -- Processing rules: + -- 1. All images are converted to WEBP for thumbnails + -- 2. Original format is preserved for full-size assets + -- 3. EXIF data is stripped from thumbnails (privacy) + -- 4. EXIF orientation is applied during processing +} + +rule ProcessImageMetadata { + when: MediaImported(media) + -- Extract image metadata + ensures: media.width = extract_width(source_file) + ensures: media.height = extract_height(source_file) + ensures: media.mime_type = detect_mime_type(source_file) + ensures: media.size = file_size(source_file) +} + +-- ============================================================================ +-- MEDIA TRANSLATION FILES +-- ============================================================================ + +value MediaTranslationFile { + -- File path: media/{id}/{language}.md + -- Format: YAML frontmatter with localized metadata + translation_for: String -- Canonical media ID + language: String -- ISO 639-1 code + title: String? + alt: String? + caption: String? + created_at: Timestamp + updated_at: Timestamp +} + +invariant MediaTranslationFileLayout { + for t in MediaTranslations: + -- Stored in subdirectory per media item + t.file_path = format("media/{id}/{lang}.md", + id: t.translation_for, + lang: t.language) +} + +-- ============================================================================ +-- MEDIA IMPORT RULES +-- ============================================================================ + +rule ImportMedia { + when: ImportMediaRequested(source_path, project) + -- 1. Validate file type (must be image) + -- 2. Generate unique filename + -- 3. Copy to media/{filename} + -- 4. Generate thumbnail + -- 5. Create sidecar file with metadata + -- 6. Index for search (FTS5) + -- 7. Generate embedding (if enabled) + ensures: media/Media.created( + filename: generate_filename(), + original_name: basename(source_path), + mime_type: detect_mime_type(source_path), + size: file_size(source_path), + width: extract_width(source_path), + height: extract_height(source_path), + file_path: format("media/{filename}", filename: generated_filename), + sidecar_path: format("media/{id}.md", id: media_id), + checksum: sha256(source_path) + ) + ensures: ThumbnailGenerated(media_id) + ensures: SearchIndexUpdated(media_id) +} + +-- ============================================================================ +-- MEDIA TAGGING +-- ============================================================================ + +invariant MediaTagsFormat { + -- Media tags are stored as JSON array in sidecar file + -- Tags are optional and only written if present + -- Same format as post tags +} + +rule TagMedia { + when: TagMediaRequested(media, tags) + ensures: media.tags = tags + ensures: SidecarFileUpdated(media) + ensures: SearchIndexUpdated(media) +} + +-- ============================================================================ +-- MEDIA DELETION +-- ============================================================================ + +rule DeleteMedia { + when: DeleteMediaRequested(media) + -- 1. Remove from database + -- 2. Delete binary file + -- 3. Delete sidecar file + -- 4. Delete thumbnail files + -- 5. Remove from search index + -- 6. Remove from all post links + ensures: FileDeleted(media.file_path) + ensures: FileDeleted(media.sidecar_path) + ensures: FileDeleted(media.thumbnail_path) + ensures: FileDeleted(media.thumbnail_source_path) + ensures: SearchIndexRemoved(media) + ensures: + for p in media.linked_posts: + p.linked_media = p.linked_media - {media} +} + +-- ============================================================================ +-- MEDIA VALIDATION +-- ============================================================================ + +rule ValidateMedia { + when: ValidateMediaRequested(project) + -- Check for: + -- 1. Missing binary files + -- 2. Missing sidecar files + -- 3. Missing thumbnails + -- 4. Corrupted image files + -- 5. Orphan media (not linked to any post) + for m in project.media: + if not file_exists(m.file_path): + ensures: ValidationIssueReported(m, "missing_binary") + if not file_exists(m.sidecar_path): + ensures: ValidationIssueReported(m, "missing_sidecar") + if not file_exists(m.thumbnail_path): + ensures: ValidationIssueReported(m, "missing_thumbnail") + if not is_valid_image(m.file_path): + ensures: ValidationIssueReported(m, "corrupted") + if not exists (p in Posts where m in p.linked_media): + ensures: ValidationIssueReported(m, "orphan") +} + +-- ============================================================================ +-- MEDIA EXPORT/EXPORT RULES +-- ============================================================================ + +invariant MediaSidecarFormat { + -- Sidecar files use YAML frontmatter + -- Same structure as Media entity fields + -- Only truthy fields are written (except required fields) +} + +-- ============================================================================ +-- IMAGE OPTIMIZATION +-- ============================================================================ + +config { + -- Image optimization settings + max_original_size: Integer = 20971520 -- 20 MB in bytes + max_width: Integer = 4096 + max_height: Integer = 4096 + strip_exif: Boolean = true -- Strip EXIF from thumbnails + preserve_color_profile: Boolean = false -- Don't preserve ICC profiles +} + +rule OptimizeMedia { + when: ImportMediaRequested(source_path, project) + requires: file_size(source_path) <= config.max_original_size + -- Original files are stored as-is (no compression) + -- Only thumbnails are optimized + @guidance + -- Media entity is created by ImportMedia rule + -- This rule documents the size constraint +} + +-- ============================================================================ +-- MEDIA SEARCH INDEXING +-- ============================================================================ + +rule IndexMediaForSearch { + when: SearchIndexUpdated(media: media/Media) + -- Index fields: title, alt, caption, original_name, tags + -- Plus all translation titles, alts, and captions + -- Note: media.translations not available in this context + let all_text = concat( + media.title, + media.alt, + media.caption, + media.original_name, + join(media.tags, " ") + ) + let stemmed = stem(all_text, detect_language(all_text)) + ensures: search/MediaSearchIndex.created( + media: media, + stemmed_content: stemmed + ) +} diff --git a/specs/schema.allium b/specs/schema.allium new file mode 100644 index 0000000..4f825e5 --- /dev/null +++ b/specs/schema.allium @@ -0,0 +1,411 @@ +-- allium: 1 +-- bDS SQLite Database Schema +-- Scope: core (Wave 1 — exact compatibility contract) +-- Distilled from: ../bDS/src/main/database/schema.ts +-- +-- This document specifies the exact SQLite schema that the Rust +-- implementation must be able to read and write. It is the ground truth +-- for database compatibility. + +-- ============================================================================ +-- CORE ENTITIES +-- ============================================================================ + +entity Project { + id: String -- UUID v4 + name: String -- Display name + slug: String -- URL-safe identifier + description: String? -- Optional description + data_path: String? -- Custom data directory (null = default) + created_at: Timestamp -- Unix timestamp + updated_at: Timestamp -- Unix timestamp + is_active: Boolean -- Exactly one project is active at a time +} + +entity Post { + id: String -- UUID v4 + project_id: String + title: String + slug: String -- URL-friendly identifier + excerpt: String? -- Optional summary + content: String? -- Draft body (null when published) + status: draft | published | archived + author: String? -- Author name + created_at: Timestamp + updated_at: Timestamp + published_at: Timestamp? + file_path: String -- Empty for never-published drafts + checksum: String? -- SHA-256 of content + tags: Set -- JSON array stored as text + categories: Set -- JSON array stored as text + template_slug: String? -- User template override + language: String? -- ISO 639-1 code + do_not_translate: Boolean + + -- Legacy columns (read-only, no longer written) + published_title: String? + published_content: String? + published_tags: String? + published_categories: String? + published_excerpt: String? +} + +entity PostTranslation { + id: String -- UUID v4 + project_id: String + translation_for: String -- Canonical post ID + language: String -- ISO 639-1 code + title: String + excerpt: String? + content: String? -- Draft body (null when published) + status: draft | published | archived + created_at: Timestamp + updated_at: Timestamp + published_at: Timestamp? + file_path: String + checksum: String? +} + +entity Media { + id: String -- UUID v4 + project_id: String + filename: String -- Generated filename + original_name: String -- Original uploaded filename + mime_type: String -- e.g. "image/jpeg" + size: Integer -- Bytes + width: Integer? -- Image dimensions + height: Integer? + title: String? + alt: String? + caption: String? + author: String? + file_path: String -- Absolute path to binary + sidecar_path: String -- Path to .meta sidecar file + created_at: Timestamp + updated_at: Timestamp + checksum: String? + tags: Set -- JSON array stored as text + language: String? -- ISO 639-1 code +} + +entity MediaTranslation { + id: String -- UUID v4 + project_id: String + translation_for: String -- Canonical media ID + language: String -- ISO 639-1 code + title: String? + alt: String? + caption: String? + created_at: Timestamp + updated_at: Timestamp +} + +entity Tag { + id: String -- UUID v4 + project_id: String + name: String -- Case-insensitive unique per project + color: String? -- Hex color like #ff0000 + post_template_slug: String? -- Template override for this tag + created_at: Timestamp + updated_at: Timestamp +} + +entity Template { + id: String -- UUID v4 + project_id: String + slug: String -- URL-safe identifier + title: String + kind: post | list | not_found | partial + enabled: Boolean + version: Integer -- Incremented on each update + file_path: String -- templates/{slug}.liquid + status: draft | published + content: String? -- Draft body (null when published) + created_at: Timestamp + updated_at: Timestamp +} + +entity Script { + id: String -- UUID v4 + project_id: String + slug: String -- URL-safe identifier + title: String + kind: macro | utility | transform + entrypoint: String -- Default: "render" for macros + enabled: Boolean + version: Integer -- Incremented on each update + file_path: String -- scripts/{slug}.lua (Rust) / {slug}.py (TS) + status: draft | published + content: String? -- Draft body (null when published) + created_at: Timestamp + updated_at: Timestamp +} + +-- ============================================================================ +-- RELATIONSHIP TABLES +-- ============================================================================ + +entity PostLink { + id: String -- UUID v4 + source_post_id: String -- Post containing the link + target_post_id: String -- Post being linked to + link_text: String? -- Anchor text + created_at: Timestamp +} + +entity PostMediaLink { + id: String -- UUID v4 + project_id: String + post_id: String + media_id: String + sort_order: Integer -- For ordering media within a post + created_at: Timestamp +} + +-- ============================================================================ +-- METADATA TABLES +-- ============================================================================ + +entity Setting { + key: String -- Primary key + value: String -- Serialized value + updated_at: Timestamp +} + +entity GeneratedFileHash { + project_id: String + relative_path: String + content_hash: String -- SHA-256 of file content + updated_at: Timestamp +} + +-- ============================================================================ +-- SEARCH INDEX (FTS5 Virtual Tables) +-- ============================================================================ + +entity PostSearchIndex { + -- SQLite FTS5 virtual table, not a real entity + -- Created via: CREATE VIRTUAL TABLE posts_fts USING fts5(...) + -- Indexed fields: title, excerpt, content, tags, categories + -- Plus all translation titles, excerpts, and content + post: Post + stemmed_content: String -- Processed via Snowball stemmer +} + +entity MediaSearchIndex { + -- SQLite FTS5 virtual table + -- Created via: CREATE VIRTUAL TABLE media_fts USING fts5(...) + -- Indexed fields: title, alt, caption, original_name, tags + -- Plus all translation titles, alts, and captions + media: Media + stemmed_content: String -- Processed via Snowball stemmer +} + +-- ============================================================================ +-- AI / CHAT TABLES +-- ============================================================================ + +entity ChatConversation { + id: String -- UUID v4 + title: String + model: String? -- Model used for conversation + copilot_session_id: String? -- Legacy, no longer used + created_at: Timestamp + updated_at: Timestamp +} + +entity ChatMessage { + id: Integer -- Auto-increment + conversation_id: String + role: system | user | assistant | tool + content: String? + tool_call_id: String? -- For tool responses + tool_calls: String? -- JSON array of tool calls + created_at: Timestamp +} + +entity AiProvider { + id: String -- Provider key (e.g., "opencode", "mistral") + name: String -- Display name + env: String? -- JSON array of env var names + npm: String? -- Primary npm package + api: String? -- API base URL + doc: String? -- Documentation URL + updated_at: Timestamp +} + +entity AiModel { + provider: String -- FK → ai_providers.id + model_id: String + name: String -- Display name + family: String? -- Model family (e.g., "claude-sonnet") + attachment: Boolean -- Supports attachments + reasoning: Boolean -- Supports reasoning + tool_call: Boolean -- Supports tool calls + structured_output: Boolean + temperature: Boolean -- Temperature control supported + knowledge: String? -- Knowledge cutoff date + release_date: String? + last_updated_date: String? + open_weights: Boolean + input_price: Integer? -- USD per 1M input tokens (in cents) + output_price: Integer? -- USD per 1M output tokens (in cents) + cache_read_price: Integer? + cache_write_price: Integer? + context_window: Integer -- Max context tokens + max_input_tokens: Integer + max_output_tokens: Integer + interleaved: String? -- JSON object for interleaved fields + status: String? -- e.g., "deprecated" + provider_npm: String? -- Per-model npm override + updated_at: Timestamp +} + +entity AiModelModality { + provider: String + model_id: String + direction: String -- "input" | "output" + modality: String -- "text" | "image" | "pdf" | "audio" | "video" +} + +entity AiCatalogMeta { + key: String -- "etag" | "lastFetchedAt" + value: String +} + +-- ============================================================================ +-- EMBEDDINGS TABLES +-- ============================================================================ + +entity EmbeddingKey { + label: Integer -- USearch bigint key + post_id: String + project_id: String + content_hash: String -- SHA-256 of title+content + vector: String -- Base64-encoded Float32Array bytes (1536 bytes for 384-dim) +} + +entity DismissedDuplicatePair { + id: String -- UUID v4 + project_id: String + post_id_a: String + post_id_b: String + dismissed_at: Timestamp +} + +-- ============================================================================ +-- IMPORT TABLES +-- ============================================================================ + +entity ImportDefinition { + id: String -- UUID v4 + project_id: String + name: String + wxr_file_path: String? -- WordPress XML export file + uploads_folder_path: String? -- WordPress uploads directory + last_analysis_result: String? -- JSON text of ImportAnalysisReport + created_at: Timestamp + updated_at: Timestamp +} + +-- ============================================================================ +-- NOTIFICATION TABLES +-- ============================================================================ + +entity DbNotification { + id: Integer -- Auto-increment + entity_type: String -- 'post' | 'media' | 'script' | 'template' + entity_id: String + action: created | updated | deleted + from_cli: Boolean -- 1 = written by CLI + seen_at: Timestamp? -- NULL = unprocessed + created_at: Timestamp +} + +-- ============================================================================ +-- SCHEMA CONSTRAINTS AND INDEXES +-- ============================================================================ + +invariant UniqueProjectSlug { + -- projects.slug must be unique across all projects +} + +invariant UniquePostSlugPerProject { + -- posts.slug must be unique within each project.project_id + -- Enforced by: posts_project_slug_idx unique index +} + +invariant UniqueTranslationPerPostLanguage { + -- post_translations must have unique (translation_for, language) + -- Enforced by: post_translations_translation_language_idx +} + +invariant UniqueMediaTranslationPerMediaLanguage { + -- media_translations must have unique (translation_for, language) + -- Enforced by: media_translations_translation_language_idx +} + +invariant UniqueTagNamePerProject { + -- tags.name must be unique within each project.project_id + -- Enforced by: tags_project_name_idx unique index +} + +invariant UniqueScriptSlugPerProject { + -- scripts.slug must be unique within each project.project_id + -- Enforced by: scripts_project_slug_idx unique index +} + +invariant UniqueTemplateSlugPerProject { + -- templates.slug must be unique within each project.project_id + -- Enforced by: templates_project_slug_idx unique index +} + +invariant UniquePostMediaLink { + -- post_media must have unique (post_id, media_id) pair + -- Enforced by: post_media_post_media_idx unique index +} + +invariant UniqueGeneratedFileHash { + -- generated_file_hashes must have unique (project_id, relative_path) + -- Enforced by: generated_file_hashes_project_path_idx unique index +} + +invariant UniqueDismissedDuplicatePair { + -- dismissed_duplicate_pairs must have unique (project_id, post_id_a, post_id_b) + -- Enforced by: dismissed_pairs_idx unique index +} + +-- ============================================================================ +-- FTS5 VIRTUAL TABLE SCHEMAS (Snowball Stemmer Integration) +-- ============================================================================ + +value Fts5PostSchema { + -- CREATE VIRTUAL TABLE posts_fts USING fts5( + -- title, excerpt, content, tags, categories, + -- content='posts', + -- content_rowid='rowid' + -- ); + fields: Set -- {title, excerpt, content, tags, categories} + stemmer_languages: Integer = 24 +} + +value Fts5MediaSchema { + -- CREATE VIRTUAL TABLE media_fts USING fts5( + -- title, alt, caption, original_name, tags, + -- content='media', + -- content_rowid='rowid' + -- ); + fields: Set -- {title, alt, caption, original_name, tags} + stemmer_languages: Integer = 24 +} + +-- ============================================================================ +-- MIGRATION HISTORY +-- ============================================================================ + +value MigrationVersion { + -- Schema version tracking via refinery migrations + -- Current version: 0007 (scripts and templates draft lifecycle) + -- Migration files located in: migrations/ + -- Note: Migration list documented in comments, not as Allium value +} diff --git a/specs/template_context.allium b/specs/template_context.allium new file mode 100644 index 0000000..0cc13b7 --- /dev/null +++ b/specs/template_context.allium @@ -0,0 +1,263 @@ +-- allium: 1 +-- bDS Liquid Template Context Specification +-- Scope: core (Wave 4 — rendering parity) +-- Distilled from: ../bDS/src/main/engine/GenerationRouteRendererFactory.ts, +-- PageRenderer.ts, BlogGenerationEngine.ts +-- +-- This document specifies the exact data structure passed to Liquid templates +-- during rendering. It is the contract between the generation engine and +-- template authors. + +-- ============================================================================ +-- GLOBAL TEMPLATE VARIABLES +-- ============================================================================ + +value RenderContext { + -- Top-level variables available in all templates + language: String -- Current language code + language_prefix: String? -- "/de" or "" depending on language + html_theme_attribute: String -- Theme class for element + page_title: String -- Page title for tag + pico_stylesheet_href: String -- Path to Pico CSS theme + blog_languages: List<BlogLanguage> + alternate_links: List<AlternateLink> + menu_items: List<MenuItem> + calendar_initial_year: Integer + calendar_initial_month: Integer + post: PostContext? -- Present on single post pages + post_categories: List<Category> + post_tags: List<Tag> + tag_color_by_name: Map<String, String> + backlinks: List<Backlink> + day_blocks: List<DayBlock> + archive_context: ArchiveContext? + show_archive_range_heading: Boolean + min_date: Timestamp? + max_date: Timestamp? + is_list_page: Boolean + is_first_page: Boolean + is_last_page: Boolean + has_prev_page: Boolean + has_next_page: Boolean + prev_page_href: String? + next_page_href: String? + not_found_message: String? + not_found_back_label: String? + + -- Lookup maps for macro expansion + canonical_post_path_by_slug: Map<String, String> + canonical_media_path_by_source_path: Map<String, String> + post_data_json_by_id: Map<String, PostDataJson> +} + +value BlogLanguage { + is_current: Boolean + code: String + flag: String + href: String + href_prefix: String +} + +value AlternateLink { + href: String + hreflang: String +} + +value MenuItem { + href: String + title: String + has_children: Boolean + children: List<MenuItem>? +} + +-- ============================================================================ +-- POST CONTEXT (Single Post Pages) +-- ============================================================================ + +value PostContext { + id: String + title: String + content: String -- Rendered HTML (after markdown + macros) + slug: String + excerpt: String? + author: String? + language: String? + show_title: Boolean -- Always true for post pages + published_at: Timestamp + created_at: Timestamp + updated_at: Timestamp + tags: List<String> + categories: List<String> + template_slug: String? + do_not_translate: Boolean + linked_media: List<MediaContext> + outgoing_links: List<LinkContext> + incoming_links: List<LinkContext> +} + +value PostDataJson { + -- Serialized post data for Liquid template access + id: String + title: String + slug: String + excerpt: String? + author: String? + language: String? + published_at: Timestamp + created_at: Timestamp + updated_at: Timestamp + tags: List<String> + categories: List<String> +} + +value MediaContext { + id: String + filename: String + original_name: String + mime_type: String + title: String? + alt: String? + caption: String? + author: String? + width: Integer? + height: Integer? + file_path: String +} + +value LinkContext { + href: String + title: String + display_slug: String +} + +-- ============================================================================ +-- ARCHIVE CONTEXT (List Pages) +-- ============================================================================ + +value ArchiveContext { + kind: category | tag | date + name: String? + month: Integer? + year: Integer? + day: Integer? +} + +value DayBlock { + show_date_marker: Boolean + date_label: String + posts: List<PostContext> + show_separator: Boolean +} + +value Category { + name: String + slug: String + post_count: Integer +} + +value Tag { + name: String + slug: String + color: String? + post_count: Integer +} + +value Backlink { + path: String + display_slug: String + title: String +} + +-- ============================================================================ +-- PAGINATION CONTEXT +-- ============================================================================ + +value PaginationContext { + is_list_page: Boolean + is_first_page: Boolean + is_last_page: Boolean + has_prev_page: Boolean + has_next_page: Boolean + prev_page_href: String? + next_page_href: String? + current_page: Integer + total_pages: Integer + total_items: Integer + items_per_page: Integer +} + +-- ============================================================================ +-- LIQUID FILTER SPECIFICATION +-- Note: Allium does not have a 'filter' keyword. Filters are documented here +-- for reference but are implemented in the Liquid engine, not in Allium specs. +-- ============================================================================ + +-- Built-in filters (from liquid-rust crate): +-- default: {{ value | default: "fallback" }} +-- escape: {{ text | escape }} +-- url_encode: {{ text | url_encode }} +-- append: {{ text | append: suffix }} +-- +-- Custom filters (must be implemented in Rust Liquid engine): +-- i18n: {{ "key" | i18n: language }} - translation lookup +-- markdown: {{ content | markdown: ... }} - markdown rendering with macro expansion + +-- ============================================================================ +-- BUILT-IN MACROS +-- Note: Allium does not have a 'macro' keyword. Macros are documented here +-- for reference but are implemented as Rust functions in bds-core/render. +-- ============================================================================ + +-- Built-in macros (implemented as native Rust functions): +-- gallery: [[gallery images=post.linked_media columns=3]] +-- youtube: [[youtube id=dQw4w9WgXcQ]] +-- vimeo: [[vimeo id=123456789]] +-- photo_archive: [[photo_archive media=project.media]] +-- tag_cloud: [[tag_cloud tags=Tags]] + +-- ============================================================================ +-- TEMPLATE LOOKUP RULES +-- ============================================================================ + +invariant TemplateLookupPriority { + -- Templates are resolved in this order: + -- 1. Post-specific template (post.template_slug) + -- 2. Tag-specific template (tag.post_template_slug) + -- 3. Category-specific template (category.post_template_slug) + -- 4. Default "post" template + -- + -- List pages use "list" template + -- 404 pages use "not-found" template + -- Partials are referenced via {% render 'partial' %} +} + +invariant PartialResolution { + -- Partials are looked up in templates/ directory + -- Usage: {% render 'partials/header', context_var: value %} + -- Partial files: templates/partials/{name}.liquid +} + +-- ============================================================================ +-- RENDERING RULES +-- ============================================================================ + +rule RenderPostPage { + when: RenderPostPageRequested(post, language) + let template = resolve_template(post) + let context = BuildPostContext(post, language) + ensures: RenderedHtml = liquid_render(template.content, context) +} + +rule RenderListPage { + when: RenderListPageRequested(posts, pagination, archive_context) + let template = first (t in Templates where t.kind = "list" and t.enabled) + let context = BuildListContext(posts, pagination, archive_context) + ensures: RenderedHtml = liquid_render(template.content, context) +} + +rule RenderNotFoundPage { + when: RenderNotFoundPageRequested() + let template = first (t in Templates where t.kind = "not-found" and t.enabled) + let context = BuildNotFoundContext + ensures: RenderedHtml = liquid_render(template.content, context) +}