-- 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 — "<verb> /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<String>
    max_posts_per_page: Integer
    pico_theme: String?
    sections: Set<GenerationSection>
    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 <article> 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)
}
