-- allium: 1 -- bDS Static Site Generation -- Scope: core (Wave 4) -- Distilled from: src/main/engine/BlogGenerationEngine.ts, -- PageRenderer.ts, GenerationWorkerPool, RoutePageGenerationService use "./post.allium" as post use "./template.allium" as template use "./metadata.allium" as meta use "./menu.allium" as menu use "./translation.allium" as translation surface GenerationControlSurface { facing _: GenerationOperator provides: GenerateSiteRequested(generation) ValidateSiteRequested(project) ApplyValidationRequested(project_id, sections) @guarantee FullRenderCommands -- Blog > Render Site (Ctrl/Cmd+R) performs a change-aware full render. -- Force Render Site is immediately below it (Ctrl/Cmd+Shift+R) and -- performs the same five sections plus Pagefind with force=true. -- The headless CLI `render [--force]` and TUI `force-render` command -- use the same shared generation behavior. } surface GenerationRuntimeSurface { facing _: GenerationRuntime provides: PageRenderRequested(template, context) GenerationProgressReported(current, total, label) GenerateSiteCompleted(generation) @guarantee ProgressReporting -- Generation, reindex, and site-validation run as background tasks and -- emit progress via the task progress channel (see task.allium -- ReportProgress / ProgressThrottled). -- -- Rendering (both full generation and validation apply) streams one -- message per written page — " /url (n/total)" where verb is -- "Generated" for a full render and "Rewrote" for a validation apply — -- and advances the bar by the number of URLs written. -- -- Route preparation and counting happen in background work before -- section rendering. Validation totals count only prepared URLs -- selected by the validation report, not the whole section. The task -- reports 0/total immediately and advances once per rendered URL. -- -- Full generation and validation apply share the same structuring: a -- task group containing one task per section (Render Site Core, Render -- Single Posts, Render Category Archives, Render Tag Archives, Render -- Date Archives) followed by a final Build Search Index task. The only -- difference is that full generation renders every URL while apply -- renders only the affected URLs. -- -- Multi-phase work (validation) maps each phase onto a fixed fraction -- of the 0.0..1.0 bar. } value GenerationSection { kind: core | single | category | tag | date } value GeneratedFile { relative_path: String content_hash: String } entity SiteGeneration { project_id: String base_url: String language: String -- main language blog_languages: Set max_posts_per_page: Integer pico_theme: String? sections: Set force: Boolean -- Output tracking generated_files: GeneratedFile with project_id = this.project_id } surface GenerationStatusSurface { context generation: SiteGeneration exposes: generation.project_id generation.base_url generation.language generation.blog_languages generation.max_posts_per_page generation.pico_theme generation.sections generation.generated_files.count } invariant GenerationPublishedOnly { -- Generation renders the *published* state of the blog, never draft content. -- -- Post universe: posts that have a published .md file on disk. -- This includes status=published posts and status=draft posts that were -- previously published (they still have a file_path with last-published content). -- Posts that have never been published (no file_path) are excluded entirely. -- -- Content source: always the .md file on disk (the last-published snapshot). -- The DB content field (which holds draft edits) is never read during generation. -- Snapshots set content=nil to ensure file-based resolution. -- -- Contrast with preview (see preview.allium PreviewDraftOverlay): -- Preview includes all drafts and prefers DB content over file content, -- giving the author a live view of unpublished edits. } invariant IncrementalByContentHash { -- For a normal full render, files are only written when content_hash -- changes or the output file is missing. generatedFileHashes tracks -- (projectId, relativePath, contentHash). A matching stored hash and an -- existing output file skip the write without hashing or reading output. } invariant ForcedFullRender { -- force=true renders the same complete section set and Pagefind index as -- normal full render, but ignores stored hashes and atomically rewrites -- every output. Each rewritten output upserts its current hash and time; -- unrelated cache records are not deleted as a shortcut. } rule SelectFullRenderWriteMode { when: GenerateSiteRequested(generation) requires: generation.force ensures: StoredContentHashesIgnored(generation.project_id) } invariant MultiLanguageRoutes { -- Main language: unprefixed routes (/{yyyy}/{mm}/{dd}/{slug}) -- Additional languages: prefixed (/{lang}/{yyyy}/{mm}/{dd}/{slug}) -- Each language subtree gets its own feeds and archives -- Route dates and archive/calendar membership use created_at in local time, -- matching bDS2. -- Posts in the page category additionally render at /{slug} in every tree. } invariant LanguageVariantSelection { -- Every language tree renders each post in its own language when a -- matching variant exists. The main tree prefers the main-language -- translation and otherwise falls back to the original post. An additional -- tree uses the original post when its source language matches that tree, -- otherwise its matching translation, falling back to the original post. -- This applies uniformly to single pages and list pages (home/pagination, -- category/tag/date archives): -- Main tree: a post whose language differs from main_language renders -- its main_language translation when one exists (canonical variant). -- Additional-language trees: posts resolve to that language's -- translation; do_not_translate posts are excluded from those trees. -- Feeds are deliberately narrower: the main feed contains original -- published records, while an additional-language feed contains only -- originals or translations whose language matches that tree. A -- foreign-language HTML fallback is therefore not added to that feed. } invariant CanonicalBaseUrlConfigured { for generation in SiteGenerations: generation.base_url != "" } invariant NamedPicoTheme { for generation in SiteGenerations where generation.pico_theme != null: generation.pico_theme != "" } invariant GeneratedFilesTracked { for generation in SiteGenerations: generation.generated_files.count >= 0 } -- Core section: root pages, sitemap, RSS, Atom, calendar.json rule GenerateCoreSectionPages { when: GenerateSiteRequested(generation) requires: core in generation.sections ensures: FileGenerated("index.html") ensures: FileGenerated("sitemap.xml") -- Single root sitemap with hreflang alternates; no per-language sitemap ensures: FileGenerated("rss.xml") -- bDS2-compatible minimal feed over all published snapshots ensures: FileGenerated("atom.xml") -- Atom feed ensures: FileGenerated("calendar.json") -- created_at counts for all published snapshots, including list-hidden posts ensures: FileGenerated("404.html") -- Not-found page rendered from the not-found template for p in Posts where status = published and p.categories.contains("page"): ensures: FileGenerated(format("{slug}/index.html", slug: p.slug)) for lang in generation.blog_languages - {generation.language}: ensures: FileGenerated(format("{lang}/index.html", lang: lang)) ensures: FileGenerated(format("{lang}/rss.xml", lang: lang)) ensures: FileGenerated(format("{lang}/atom.xml", lang: lang)) ensures: FileGenerated(format("{lang}/404.html", lang: lang)) for p in Posts where status = published and p.categories.contains("page") and not p.do_not_translate: ensures: FileGenerated(format("{lang}/{slug}/index.html", lang: lang, slug: p.slug)) } -- Single section: one HTML page per published post rule GenerateSinglePostPages { when: GenerateSiteRequested(generation) requires: single in generation.sections for p in Posts where status = published: let url = post_canonical_url(p) ensures: FileGenerated(format("{url}/index.html", url: url)) for lang in generation.blog_languages - {generation.language}: if not p.do_not_translate: ensures: FileGenerated(format("{lang}/{url}/index.html", lang: lang, url: url)) } -- Category section: paginated archive per category rule GenerateCategoryPages { when: GenerateSiteRequested(generation) requires: category in generation.sections for cat in generation.categories: let page_count = ceil(posts_in_category(cat).count / generation.max_posts_per_page) ensures: FileGenerated(format("category/{cat}/index.html", cat: cat)) for page in page_range(2, page_count): ensures: FileGenerated(format("category/{cat}/page/{page}/index.html", cat: cat, page: page)) } -- Tag section: paginated archive per tag rule GenerateTagPages { when: GenerateSiteRequested(generation) requires: tag in generation.sections for t in Tags where post_count > 0: let slug = slugify(t.name) let page_count = ceil(posts_with_tag(t).count / generation.max_posts_per_page) ensures: FileGenerated(format("tag/{slug}/index.html", slug: slug)) for page in page_range(2, page_count): ensures: FileGenerated(format("tag/{slug}/page/{page}/index.html", slug: slug, page: page)) } -- Date section: year, month, and day archives rule GenerateDateArchivePages { when: GenerateSiteRequested(generation) requires: date in generation.sections for year in distinct_years(Posts): let yp = ceil(posts_in_year(year).count / generation.max_posts_per_page) ensures: FileGenerated(format("{year}/index.html", year: year)) for page in page_range(2, yp): ensures: FileGenerated(format("{year}/page/{page}/index.html", year: year, page: page)) for month in distinct_months(Posts, year): let mp = ceil(posts_in_month(year, month).count / generation.max_posts_per_page) ensures: FileGenerated(format("{year}/{month}/index.html", year: year, month: month)) for page in page_range(2, mp): ensures: FileGenerated(format("{year}/{month}/page/{page}/index.html", year: year, month: month, page: page)) for day in distinct_days(Posts, year, month): let dp = ceil(posts_in_day(year, month, day).count / generation.max_posts_per_page) ensures: FileGenerated(format("{year}/{month}/{day}/index.html", year: year, month: month, day: day)) for page in page_range(2, dp): ensures: FileGenerated(format("{year}/{month}/{day}/page/{page}/index.html", year: year, month: month, day: day, page: page)) } -- Template rendering context rule RenderPage { when: PageRenderRequested(template, context) -- Template rendering with full context: -- posts, pagination, menus, tags, categories, -- project metadata, i18n translations, theme settings -- Macro expansion: [[slug param1=value1 ...]] in post content -- HTML rewriting for canonical post/media paths ensures: RenderedHtml(template, context, output) } -- Validation rule ValidateSite { when: ValidateSiteRequested(project) -- Matches bDS2's cheap route comparison without rendering page content: -- sitemap.xml is refreshed from the current route set before comparison -- expected: sitemap-eligible routes derived from published snapshots -- actual: non-empty **/index.html files on disk -- missing: expected routes with no non-empty index.html -- extra: index.html routes absent from the expected set, including -- zero-byte files -- stale: existing post routes whose source mtime is more than one second -- newer than both the output mtime and tracked generation time -- Standalone HTML, XML, JSON, assets, Pagefind files, and content hashes are -- not validation comparison inputs. ensures: ValidationReport(missing_pages, extra_pages, stale_pages) } rule ApplyValidation { when: ApplyValidationRequested(project_id, sections) -- Targeted re-rendering for affected report paths. A missing or stale post -- route also re-renders every affected root, category, tag, year, month, -- and day aggregate, including each aggregate's pagination. Applying an -- unchanged post route refreshes its tracked generation time so the -- automatic follow-up validation does not report it stale again. ensures: GenerateSiteRequested(plan_generation(project_id, sections)) -- Observable phases are prepare, affected render sections, optional -- calendar regeneration, search indexing, and final validation. Failure or -- cancellation stops every dependent phase. } -- Day-block grouping for archives invariant ArchiveDayBlocks { -- Archive/list pages group posts by day -- Each day block has a date header and the posts for that day } invariant CategoryListTitleVisibility { -- A post title is hidden on every language's aggregate pages when any of -- the post's categories has show_title disabled. The built-in aside -- category defaults to title-hidden when no explicit setting exists. } -- ============================================================================ -- SEARCH INDEX: PAGEFIND -- ============================================================================ -- Pagefind builds a client-side full-text search index from generated HTML. -- Uses an embedded Pagefind library integration rather than a CLI subprocess. -- Runs as the final step of the generation pipeline, after all HTML is written. rule BuildSearchIndex { when: GenerateSiteCompleted(generation) -- Only runs if any pages were rendered or deleted in this generation pass. -- Separate index per language: -- Main language: source = {html}/, output = {html}/pagefind/ -- Each additional language: source = {html}/{lang}/, output = {html}/{lang}/pagefind/ -- Each index built with force_language set to that language code. for lang in {generation.language} + (generation.blog_languages - {generation.language}): ensures: PagefindIndexBuilt(lang) } invariant PagefindHtmlMarking { -- Single-post templates must include data-pagefind-body attribute -- on the
element to scope indexing to post content only. -- Pagefind ignores elements without this attribute. } invariant PagefindAssets { -- Generated output includes Pagefind UI assets per language: -- {prefix}/pagefind/pagefind-ui.css -- {prefix}/pagefind/pagefind-ui.js -- where prefix is "" for main language, "{lang}" for additional languages. -- Frontend templates reference these via language_prefix variable. -- Assets are bundled locally — no external CDN references. } config { pagefind_threshold: Integer = 0 -- Minimum pages to trigger indexing (0 = always if any rendered/deleted) }