B1-5..B1-20: distill remaining code behaviors into specs (rendering.allium, post/media/task/generation/editor specs)

This commit is contained in:
2026-05-30 14:33:19 +02:00
parent dfb2f8870b
commit 723a7ec1f7
11 changed files with 354 additions and 22 deletions

View File

@@ -20,6 +20,7 @@ use "./metadata.allium" as metadata -- Project config, categories, publi
-- Infrastructure
use "./search.allium" as search -- FTS5 full-text search with Snowball stemming
use "./rendering.allium" as rendering -- Template render assigns, filters, macros (preview + generation)
use "./generation.allium" as generation -- Static site generation (sections, routes, hashing)
use "./preview.allium" as preview -- Local HTTP preview server
use "./publishing.allium" as publishing -- SSH upload (SCP / rsync)

View File

@@ -130,9 +130,15 @@ surface MediaEditorSurface {
@guarantee TranslationsSection
-- Shown only when language is set.
-- List of existing translations: flag emoji + language name + title.
-- Per-translation actions: click to edit inline, refresh button, delete button.
-- Per-translation actions: click to edit (opens modal), refresh button, delete button.
-- "No translations" message when list is empty.
@guarantee TranslationEditModal
-- Editing a translation opens a modal dialog ("Edit Translation"), not
-- an inline form. Hidden language field plus Title, Alt Text, and
-- Caption (textarea) inputs. Footer: Cancel and Save buttons.
-- Save persists the translation; Cancel/close discards.
@guarantee LinkedPostsSection
-- "Link to Post" button opens inline post picker overlay.
-- List of currently linked posts with document icon. Click navigates to post tab.
@@ -214,8 +220,9 @@ rule MediaLinkToPost {
rule MediaTranslationEdit {
when: MediaTranslationEditClicked(media_id, language)
-- Loads translation fields inline (title, alt, caption) for that language
-- Edit in place, save persists to translated sidecar {path}.{lang}.meta
-- Opens the "Edit Translation" modal pre-filled with the translation's
-- title, alt, and caption for that language
-- Save persists to DB + translated sidecar {path}.{lang}.meta; Cancel discards
}
rule MediaTranslationRefresh {

View File

@@ -19,6 +19,7 @@ value ScriptEditorView {
entrypoint: String -- select: discovered Lua functions available as entrypoints
enabled: Boolean -- checkbox
content: String -- code editor content
can_publish: Boolean -- true while status = draft
created_at: String -- locale-formatted date
updated_at: String -- locale-formatted date
}
@@ -37,13 +38,16 @@ surface ScriptEditorSurface {
provides:
ScriptSaveRequested(editor.script_id)
ScriptPublishRequested(editor.script_id)
when editor.can_publish
ScriptRunRequested(editor.script_id)
ScriptCheckSyntaxRequested(editor.script_id)
ScriptDeleteRequested(editor.script_id)
@guarantee HeaderLayout
-- Header bar with script title tab.
-- Actions (right side): Save button, Run button,
-- Actions (right side): Save button, Publish button (shown only when
-- can_publish, i.e. status = draft), Run button,
-- Check Syntax button, Delete button (danger style).
@guarantee MetadataRow
@@ -72,6 +76,15 @@ rule ScriptSave {
-- Entrypoint list re-discovered from Lua source after save
}
rule ScriptPublish {
when: ScriptPublishRequested(script_id)
-- Only offered while the script is a draft (can_publish gate on the button)
-- Validates Lua syntax first (publish gate); invalid source blocks publish
-- Saves current draft fields, then publishes (status -> published)
-- Writes the published script file to disk
-- See script.allium PublishScript
}
rule ScriptCheckSyntax {
when: ScriptCheckSyntaxRequested(script_id)
-- Validates Lua syntax without saving

View File

@@ -18,6 +18,7 @@ value TemplateEditorView {
kind: String -- select: post | list | not_found | partial
enabled: Boolean -- checkbox
content: String -- code editor content
can_publish: Boolean -- true while status = draft
created_at: String -- locale-formatted date
updated_at: String -- locale-formatted date
}
@@ -35,12 +36,15 @@ surface TemplateEditorSurface {
provides:
TemplateSaveRequested(editor.template_id)
TemplatePublishRequested(editor.template_id)
when editor.can_publish
TemplateValidateRequested(editor.template_id)
TemplateDeleteRequested(editor.template_id)
@guarantee HeaderLayout
-- Header bar with template title tab.
-- Actions (right side): Save button, Validate button,
-- Actions (right side): Save button, Publish button (shown only when
-- can_publish, i.e. status = draft), Validate button,
-- Delete button (danger style).
@guarantee MetadataRow
@@ -67,6 +71,15 @@ rule TemplateSave {
-- See engine_side_effects.allium UpdateTemplateSideEffects
}
rule TemplatePublish {
when: TemplatePublishRequested(template_id)
-- Only offered while the template is a draft (can_publish gate on the button)
-- Validates Liquid first (publish gate); invalid source blocks publish
-- Saves current draft fields, then publishes (status -> published)
-- Writes the published .liquid file to disk
-- See template.allium PublishTemplate
}
rule TemplateValidate {
when: TemplateValidateRequested(template_id)
-- Validates Liquid syntax without saving

View File

@@ -121,10 +121,13 @@ rule GenerateCoreSectionPages {
-- Atom feed
ensures: FileGenerated("calendar.json")
-- Post dates for calendar widget
ensures: FileGenerated("404.html")
-- Not-found page rendered from the not-found template
for lang in generation.blog_languages - {generation.language}:
ensures: FileGenerated(format("{lang}/index.html", lang: lang))
ensures: FileGenerated(format("{lang}/feed.xml", lang: lang))
ensures: FileGenerated(format("{lang}/atom.xml", lang: lang))
ensures: FileGenerated(format("{lang}/404.html", lang: lang))
}
-- Single section: one HTML page per published post

View File

@@ -11,6 +11,7 @@ surface MediaControlSurface {
provides:
ImportMediaRequested(source_path, project, metadata)
UpdateMediaRequested(media, changes)
ReplaceMediaFileRequested(media, new_source_path)
DeleteMediaRequested(media)
UpsertMediaTranslationRequested(media, language, title, alt, caption)
RebuildMediaFromFilesRequested(project)
@@ -158,6 +159,28 @@ rule UpdateMedia {
ensures: SearchIndexUpdated(media)
}
rule ReplaceMediaFile {
when: ReplaceMediaFileRequested(media, new_source_path)
-- Replaces the binary at media.file_path with the new source file,
-- keeping the same path/id. Sidecar metadata (title/alt/etc.) is preserved.
let checksum = md5(read(new_source_path))
-- Identical content (checksum = media.checksum): no-op, nothing rewritten.
-- Otherwise the old file is backed up to {path}.bak (restored on failure,
-- removed on success) and the row is updated from the new file:
if checksum != media.checksum:
ensures: media.checksum = checksum
ensures: media.size = file_size(new_source_path)
ensures: media.updated_at = now
ensures: MediaDimensionsUpdated(media)
-- width/height re-read from the new image
ensures: SidecarWritten(media)
if media.is_image:
ensures: ThumbnailsRegenerated(media)
-- Synchronous (awaited), not fire-and-forget
ensures: SearchIndexUpdated(media)
-- See engine_side_effects.allium ReplaceMediaFileSideEffects
}
rule DeleteMedia {
when: DeleteMediaRequested(media)
ensures: not exists media

View File

@@ -14,6 +14,7 @@ surface MetadataMaintenanceSurface {
provides:
MetadataDiffRequested(project)
RebuildFromFilesystemRequested(project, entity_type)
RepairMetadataDiffItemRequested(project, direction, item)
}
value DiffField {
@@ -23,7 +24,8 @@ value DiffField {
}
value DiffReport {
entity_type: String -- post, media, script, template
entity_type: String -- post, post_translation, media,
-- media_translation, script, template, embedding
entity_id: String
differences: List<DiffField>
}
@@ -66,7 +68,26 @@ rule RunMetadataDiff {
if matching.count = 0:
ensures: OrphanReport.created(file_path: file)
-- Same pattern for media sidecar files, scripts, templates
-- Same pattern for media sidecars (media), media translation sidecars
-- (media_translation), scripts, templates, and embeddings.
-- Embedding diffs compare the stored content_hash against the live post
-- content to detect vectors that need recomputation.
}
rule RepairMetadataDiffItem {
when: RepairMetadataDiffItemRequested(project, direction, item)
-- Resolves a single diff in one direction.
-- direction = file_to_db (filesystem wins) | db_to_file (database wins)
-- Dispatched per item.entity_type:
-- project | categories | category_meta | publishing -> project metadata sync/flush
-- post -> sync_post_from_file / rewrite_published_post
-- post_translation -> sync_post_translation_from_file / rewrite_published_post_translation
-- media -> sync_media_from_sidecar / sync_media_sidecar
-- media_translation -> sync_media_translation_from_sidecar / sync_media_translation_sidecar
-- script -> sync_script_from_file / sync_published_script_file
-- template -> sync_template_from_file / sync_published_template_file
-- embedding -> sync_post (file_to_db) / refresh_snapshot (db_to_file)
-- Unknown entity_type or direction -> unsupported error.
}
rule RebuildFromFilesystem {

View File

@@ -55,6 +55,7 @@ surface PostControlSurface {
PublishPostRequested(post)
DeletePostRequested(post)
ArchivePostRequested(post)
DiscardPostChangesRequested(post)
}
surface PostFilePathSurface {
@@ -100,6 +101,15 @@ entity Post {
updated_at: Timestamp
published_at: Timestamp?
-- Published snapshot: copy of title/content/tags/categories/excerpt as of
-- the last publish. Used by changes_affect_published_content to decide when
-- an edit reopens a published post to draft (see ReopenPublishedPost).
published_title: String?
published_content: String?
published_tags: String?
published_categories: String?
published_excerpt: String?
-- Relationships
translations: PostTranslation with canonical_post = this
linked_media: PostMediaLink with post = this
@@ -218,6 +228,20 @@ rule ArchivePost {
ensures: post.status = archived
}
rule DiscardPostChanges {
when: DiscardPostChangesRequested(post)
requires: post.file_path != ""
-- Only posts with a published file on disk can be discarded;
-- a never-published draft has no file version to restore.
-- Re-reads the published .md file and upserts the DB record from it,
-- discarding unsaved draft edits.
ensures: post.content = null
ensures: post.status = published
ensures: PostLinksUpdated(post)
ensures: SearchIndexUpdated(post)
-- See engine_side_effects.allium DiscardPostChangesSideEffects
}
-- File format axioms
invariant FrontmatterRoundtrip {

207
specs/rendering.allium Normal file
View File

@@ -0,0 +1,207 @@
-- allium: 1
-- bDS Rendering Subsystem
-- Scope: core — template rendering shared by preview and generation
-- Distilled from: lib/bds/rendering/{filters,labels,links_and_languages,
-- metadata,post_rendering,list_archive}.ex
-- The rendering subsystem turns a post/list/not-found record plus project
-- metadata into the assigns consumed by a Liquid template. It is shared by the
-- preview server (on-demand) and static site generation (published .md files).
-- Rendering language is the CONTENT language (post/project), never the UI locale.
use "./template.allium" as template
use "./template_context.allium" as template_context
use "./i18n.allium" as i18n
use "./post.allium" as post
-- ─── Custom Liquid filters ───────────────────────────────────
-- The three custom filters available in the Liquid subset (see
-- template.allium LiquidFilterSubset). Applied during template rendering.
surface RenderFilterSurface {
facing _: TemplateRenderer
provides:
I18nFilterApplied(value, language)
MarkdownFilterApplied(value, post_id, language)
SlugifyFilterApplied(value)
}
rule I18nFilter {
when: I18nFilterApplied(value, language)
-- {{ "Key" | i18n }} -> localized "render" domain string for `language`.
-- Empty/blank input returns empty string.
ensures: result = lgettext(language, "render", trim(value))
}
rule SlugifyFilter {
when: SlugifyFilterApplied(value)
-- {{ title | slugify }} -> URL-friendly slug (same Slug.slugify as posts).
ensures: result = slugify(value)
}
rule MarkdownFilter {
when: MarkdownFilterApplied(value, post_id, language)
-- {{ body | markdown }} pipeline, in order:
-- 1. Expand built-in [[...]] macros (see ExpandBuiltinMacros)
-- 2. Convert markdown to HTML (Earmark; errors degrade to partial HTML)
-- 3. Rewrite href/src URLs to canonical post/media paths (see RewriteUrls)
ensures: result = rewritten_html
}
-- ─── Built-in macros ─────────────────────────────────────────
-- Macros use double-bracket syntax [[name param="value" ...]], expanded in
-- markdown before HTML conversion. NOT Liquid tags. Each renders a bundled
-- macro template (macros/{name}) in an isolated Liquid subscope.
value BuiltinMacro {
name: String -- youtube | vimeo | gallery | photo_archive | tag_cloud
params: Map<String, String>
}
surface BuiltinMacroSurface {
context macro: BuiltinMacro
exposes:
macro.name
macro.params
}
rule ExpandBuiltinMacros {
when: MacroEncountered(macro, language, post_id)
-- Unknown macro names are left verbatim in the source.
if macro.name = "youtube" or macro.name = "vimeo":
-- Embeds video by `id`; localized default title when title param absent.
ensures: MacroTemplateRendered(format("macros/{name}", name: macro.name))
if macro.name = "gallery":
-- Image gallery from the post's linked image media (ordered by sort).
-- `columns` clamped to 1..6 (default 3). Empty when no post_id/images.
ensures: MacroTemplateRendered("macros/gallery")
if macro.name = "photo_archive":
-- Month-grouped image archive for the project (optional year/month filter).
ensures: MacroTemplateRendered("macros/photo-archive")
if macro.name = "tag_cloud":
-- Weighted tag cloud from post tag counts; per-tag colours from Tag rows.
ensures: MacroTemplateRendered("macros/tag-cloud")
}
invariant MacroIsolation {
-- Macro templates render in an isolated Liquid subscope so macro-local
-- assigns never leak into the surrounding template context.
}
-- ─── URL rewriting ───────────────────────────────────────────
rule RewriteUrls {
when: RenderedHtmlProduced(html, canonical_post_paths, canonical_media_paths)
-- Rewrites href= and src= attribute values in rendered HTML.
-- External/special URLs (scheme:, //, #) are left untouched.
-- Internal post references (/post/{slug}, /posts/{slug}, dated paths) map to
-- the post's canonical dated path; query/fragment suffixes are preserved.
-- Internal /media/YYYY/MM/{file} references map to canonical media paths.
ensures: AttributesRewritten(html)
}
-- ─── Links and languages ─────────────────────────────────────
value LinkContext {
href: String
title: String
display_slug: String
language: String
}
rule ResolveLanguagePrefix {
when: LanguagePrefixRequested(language, main_language)
-- "" for the main language (and nil/blank); "/{language}" otherwise.
if language = main_language:
ensures: prefix = ""
else:
ensures: prefix = format("/{lang}", lang: language)
}
rule CollectLinkContexts {
when: LinkContextsRequested(project, post_id, direction)
-- direction = incoming (backlinks) | outgoing.
-- One LinkContext per linked post that still exists; missing targets dropped.
-- href = canonical post path, language normalized to main when unset.
ensures: List<LinkContext>
}
-- ─── Render labels ───────────────────────────────────────────
value RenderLabels {
-- Localized strings for rendered/preview output, resolved in the "render"
-- gettext domain for the CONTENT language (not the UI locale). Includes
-- taxonomy, backlinks, archive, pagination, calendar, search, not-found,
-- and macro fallback labels. Month names resolved 1..12 per language.
}
invariant LabelsUseContentLanguage {
-- RenderLabels and the i18n filter resolve against the content/render
-- language, consistent with i18n.allium's split-localization rule.
}
-- ─── Post render assigns ─────────────────────────────────────
-- The full assigns map for a single post template, assembled from the post
-- record (Post or PostTranslation) and project metadata.
value PostRenderAssigns {
language: String
language_prefix: String
page_title: String?
pico_stylesheet_href: String
blog_languages: List<i18n/RenderLanguage>
alternate_links: List<AlternateLink> -- hreflang alternates for translations
menu_items: List<template_context/MenuItem>
post_categories: List<String>
post_tags: List<String>
tag_color_by_name: Map<String, String?>
backlinks: List<LinkContext>
canonical_post_path_by_slug: Map<String, String>
canonical_media_path_by_source_path: Map<String, String>
post_data_json_by_id: String -- PostData JSON for client widgets
post: template_context/PostContext -- includes incoming/outgoing links
labels: RenderLabels
calendar_initial_year: Integer?
calendar_initial_month: Integer?
}
value AlternateLink {
language: String
href: String
}
rule BuildPostAssigns {
when: PostAssignsRequested(project, assigns)
-- Loads the post/translation record, renders its markdown body (macros +
-- HTML + URL rewrite), collects incoming/outgoing links, and resolves all
-- metadata-derived assigns (menu, languages, alternates, tag colours,
-- calendar bounds, labels) for the post's language.
ensures: PostRenderAssigns
}
rule BuildNotFoundAssigns {
when: NotFoundAssignsRequested(project, assigns)
-- Assigns for the 404 page: page_title defaults to "404", no alternates,
-- but shares language/menu/blog_languages/stylesheet/labels with posts.
-- Consumed by the not-found template (see generation.allium 404.html).
ensures: NotFoundRenderAssigns
}
rule BuildListAssigns {
when: ListAssignsRequested(project, assigns)
-- Assigns for list/archive pages (home, category, tag, date archives):
-- paginated post list plus shared metadata-derived assigns.
ensures: ListRenderAssigns
}
invariant SharedRenderPathForPreviewAndGeneration {
-- Preview and generation produce identical HTML for the same input because
-- both build assigns through this subsystem and render via the same Liquid
-- subset. They differ only in content SOURCE (see preview.allium
-- PreviewDraftOverlay and generation.allium GenerationPublishedOnly).
}

View File

@@ -36,6 +36,7 @@ surface TaskRuntimeSurface {
TaskWorkCompleted(task)
TaskWorkFailed(task, error_message)
ProgressReported(task, value, message)
FinishedTaskEvictionDue()
}
surface TaskSurface {
@@ -54,6 +55,8 @@ surface TaskSurface {
config {
max_concurrent: Integer = 3
progress_throttle: Duration = 250.milliseconds
finished_task_ttl: Duration = 1.hour
recent_finished_limit: Integer = 10
}
invariant MaxConcurrency {
@@ -112,6 +115,23 @@ invariant ProgressThrottled {
-- At most one progress event per 250ms per task
}
invariant FinishedTaskRetention {
-- The status snapshot surfaces only the most recent finished tasks:
-- completed/failed/cancelled tasks beyond config.recent_finished_limit
-- (newest first) are not shown.
let finished = Tasks where status in {completed, failed, cancelled}
-- At most config.recent_finished_limit finished tasks are reported.
}
rule EvictFinishedTasks {
when: FinishedTaskEvictionDue()
-- Periodic sweep (every config.finished_task_ttl). A finished task whose
-- finished_at is older than config.finished_task_ttl is dropped from state.
for task in Tasks where status in {completed, failed, cancelled}:
if now - task.finished_at >= config.finished_task_ttl:
ensures: not exists task
}
-- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts)
rule RegisterExternalTask {
when: RegisterExternalTaskRequested(name)