369 lines
16 KiB
Plaintext
369 lines
16 KiB
Plaintext
-- 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)
|
|
}
|