diff --git a/SPECGAPS.md b/SPECGAPS.md index c99c7a5..08dc76b 100644 --- a/SPECGAPS.md +++ b/SPECGAPS.md @@ -64,22 +64,22 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update | ~~B1-2~~ | ~~Auto-translation system (AutoTranslation.maybe_schedule, media cascade, batch fill)~~ | `lib/bds/posts/auto_translation.ex` | **Resolved:** distilled into translation.allium — added `AutoTranslationControlSurface` (PostSavedForAutoTranslation reactive + FillMissingTranslationsRequested batch triggers), three rules (`ScheduleAutoTranslation` draft-per-missing-language + media cascade, `AutoTranslatePost` upsert/auto-publish primitive, `AutoTranslateMediaCascade` linked-media per-language tasks, `FillMissingTranslations` published-only batch emitting `ProgressReported` + `FillMissingTranslationsCompleted`), three invariants (`AutoTranslationGatedByEndpoint`, `AutoTranslationSkipsDoNotTranslate`, `AutoTranslationOnlyMissingLanguages`), and `auto_translation_task_group_name` config; `allium check` passes | | ~~B1-3~~ | ~~3 extra settings sections (Technology, MCP, Data Maintenance)~~ | `lib/bds/desktop/shell_live/settings_editor/` | **Resolved:** distilled into editor_settings.allium — added `SettingsTechnologySection` (semantic_similarity toggle + read-only scripting-runtime note, saved with project metadata), `SettingsDataSection` (rebuild_targets), and `technology_section`/`data_section` on `SettingsView`; reconciled `SettingsMCPSection`/`MCPAgentRow` to code (dropped non-existent status badge; added `is_supported`/`config_path`; only Claude Code + GitHub Copilot supported); updated TechnologySection/MCPSection/DataMaintenanceSection guarantees (7 rebuild buttons incl. Rebuild Embedding Index) and SettingsRebuild rule entity_type (+embedding); `allium check` passes | | ~~B1-4~~ | ~~Style/Theme as separate tab (`:style`), not settings section~~ | `lib/bds/desktop/shell_live/settings_editor/style_editor.ex` | **Resolved:** editor_settings.allium Style view section now frames it as its own `style` singleton tab (cross-ref tabs.allium), explicitly NOT a SettingsView collapsible section; added `SeparateTab` guarantee (requires active project), documented 20 named Pico themes and the theme display-name transform ("-"→" ", capitalise); `allium check` passes | -| B1-5 | `published_*` snapshot fields on Post for diffing | `lib/bds/posts/post.ex:61-65` | Add to post.allium entity | -| B1-6 | Full rendering subsystem (Liquex, Filters, Labels, LinksAndLanguages, PostRendering) | `lib/bds/rendering/` | Distill into spec | -| B1-7 | 404.html generation | `lib/bds/generation/outputs.ex:344-345` | Add to generation.allium | +| ~~B1-5~~ | ~~`published_*` snapshot fields on Post for diffing~~ | `lib/bds/posts/post.ex:61-65` | **Resolved:** added published_title/content/tags/categories/excerpt snapshot fields to the Post entity in post.allium, noting their role in changes_affect_published_content / ReopenPublishedPost (schema.allium already listed them) | +| ~~B1-6~~ | ~~Full rendering subsystem (Liquex, Filters, Labels, LinksAndLanguages, PostRendering)~~ | `lib/bds/rendering/` | **Resolved:** new `rendering.allium` distills the shared render subsystem — 3 custom filters (i18n/markdown/slugify), markdown→HTML + URL rewriting, built-in `[[...]]` macros (youtube/vimeo/gallery/photo_archive/tag_cloud) with isolation invariant, links/languages (LinkContext, language prefix, backlinks), RenderLabels (content-language gettext + months), and post/list/not-found assign builders; added `SharedRenderPathForPreviewAndGeneration` invariant and wired into bds.allium | +| ~~B1-7~~ | ~~404.html generation~~ | `lib/bds/generation/outputs.ex:344-345` | **Resolved:** added `FileGenerated("404.html")` (+ `{lang}/404.html` per blog language) to GenerateCoreSectionPages in generation.allium | | B1-8 | ~~`linkedPostIds` in media sidecar~~ | `lib/bds/media/sidecars.ex:42` | **Resolved:** added to MediaSidecar value in frontmatter.allium (with A2-15) | | B1-9 | ~~`projectId` in template/script frontmatter~~ | `templates.ex:337`, `scripts.ex:268` | **Resolved:** added projectId to TemplateFrontmatter and ScriptFrontmatter in frontmatter.allium (with A2-15) | -| B1-10 | Media translation editing modal | `media_editor.html.heex:275-303` | Add to editor_media.allium | -| B1-11 | Menu editor drag-drop, indent/unindent/move | `lib/bds/desktop/menu_editor/tree_ops.ex` | Add to editor_misc.allium | -| B1-12 | `:language_picker` overlay with flag emojis | `shell_overlay.html.heex:116-139` | Add to modals.allium | -| B1-13 | `:confirm_dialog` generic confirmation | `shell_overlay.html.heex:171-187` | Add to modals.allium | -| B1-14 | Publish actions for scripts and templates | `script_editor.html.heex:10-12`, `template_editor.html.heex:10-12` | Add to editor_script.allium, editor_template.allium | -| B1-15 | `:import` as full editor tab | `lib/bds/ui/import_editor.ex` | Add to tabs.allium | -| B1-16 | `:documentation`/`:api_documentation` tab types | `lib/bds/desktop/misc_editor/` | Add to tabs.allium | -| B1-17 | Metadata diff covers embedding, media_translation, post_translation as entity types | `lib/bds/maintenance/repair.ex` | Add to metadata_diff.allium | -| B1-18 | Finished task TTL eviction (1h, keep last 10) | `lib/bds/tasks.ex:365-386` | Add to task.allium | -| B1-19 | `discard_post_changes/1` | `lib/bds/posts.ex:201-227` | Add to post.allium | -| B1-20 | `replace_media_file/2` with checksum/backup | `lib/bds/media.ex:288-337` | Add to media.allium | +| ~~B1-10~~ | ~~Media translation editing modal~~ | `media_editor.html.heex:275-303` | **Resolved:** editor_media.allium corrected — MediaTranslationEdit opens an "Edit Translation" modal (not inline); added `TranslationEditModal` guarantee (language hidden field + Title/Alt/Caption inputs, Cancel/Save footer) | +| ~~B1-11~~ | ~~Menu editor drag-drop, indent/unindent/move~~ | `lib/bds/desktop/menu_editor/tree_ops.ex` | **Resolved:** already covered in editor_misc.allium — MenuMoveItem rule (up/down/indent/unindent), DragDrop + MoveDirections guarantees, HomeItemProtection (D1-18); verified against tree_ops.ex | +| ~~B1-12~~ | ~~`:language_picker` overlay with flag emojis~~ | `shell_overlay.html.heex:116-139` | **Resolved:** already in modals.allium — LanguagePickerModal/LanguageTarget (flag emoji, name, existing-translation status badge); verified matches overlay code | +| ~~B1-13~~ | ~~`:confirm_dialog` generic confirmation~~ | `shell_overlay.html.heex:171-187` | **Resolved:** already in modals.allium — ConfirmDialog (title/message, Cancel/Confirm); verified matches overlay code | +| ~~B1-14~~ | ~~Publish actions for scripts and templates~~ | `script_editor.html.heex:10-12`, `template_editor.html.heex:10-12` | **Resolved:** added `can_publish` field, Publish button in HeaderLayout (shown only when draft), ScriptPublishRequested/TemplatePublishRequested surface events and ScriptPublish/TemplatePublish rules (validate-then-publish) to editor_script.allium + editor_template.allium | +| ~~B1-15~~ | ~~`:import` as full editor tab~~ | `lib/bds/ui/import_editor.ex` | **Resolved:** already in tabs.allium (import singleton/pinned tab + editor route) and fully detailed as ImportAnalysisView in editor_misc.allium | +| ~~B1-16~~ | ~~`:documentation`/`:api_documentation` tab types~~ | `lib/bds/desktop/misc_editor/` | **Resolved:** already in tabs.allium (both listed as singleton tabs + editor views: DOCUMENTATION.md / API.md) with DocumentationSurface in editor_misc.allium | +| ~~B1-17~~ | ~~Metadata diff covers embedding, media_translation, post_translation as entity types~~ | `lib/bds/maintenance/repair.ex` | **Resolved:** metadata_diff.allium DiffReport entity_type list expanded to all 7 types; RunMetadataDiff notes media/media_translation/script/template/embedding (content_hash) coverage; added `RepairMetadataDiffItem` rule + surface event documenting the file_to_db/db_to_file dispatch per entity type | +| ~~B1-18~~ | ~~Finished task TTL eviction (1h, keep last 10)~~ | `lib/bds/tasks.ex:365-386` | **Resolved:** added `finished_task_ttl` (1h) + `recent_finished_limit` (10) config, `FinishedTaskRetention` invariant, `EvictFinishedTasks` rule and `FinishedTaskEvictionDue` runtime event to task.allium | +| ~~B1-19~~ | ~~`discard_post_changes/1`~~ | `lib/bds/posts.ex:201-227` | **Resolved:** added `DiscardPostChangesRequested` surface event + `DiscardPostChanges` rule (requires file_path, restores published file, content=null/status=published, re-syncs links+FTS) to post.allium | +| ~~B1-20~~ | ~~`replace_media_file/2` with checksum/backup~~ | `lib/bds/media.ex:288-337` | **Resolved:** added `ReplaceMediaFileRequested` surface event + `ReplaceMediaFile` rule (md5 no-op skip, .bak backup/restore, updates checksum/size/dimensions, synchronous thumbnail regen, FTS re-sync) to media.allium | ### B2. Lower Priority (implementation detail or minor) @@ -193,7 +193,7 @@ All reconciled to follow code. Specs must be self-consistent and match code. 1c. ~~**A1-17**~~ — blogmark deep-link handler resolved: `BDS.Desktop.DeepLink` receives OS `bds2://` URL events and `BDS.Blogmark` parses them, runs the transform pipeline, and creates+opens a draft post (macOS `Info.plist` scheme registration documented, pending an app-bundle pipeline) 2. **D1-1 through D1-18** — untested invariants/guarantees 3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code) -4. **B1-1 through B1-6** — major code behaviors missing from spec (B1-1, B1-2, B1-3, B1-4 resolved) +4. ~~**B1-1 through B1-20**~~ — all resolved: chat inline surfaces, auto-translation, settings sections, style tab, published snapshot fields, rendering subsystem (new rendering.allium), 404.html, media translation modal, menu ops, language picker + confirm dialog, script/template publish actions, import + documentation tabs, metadata-diff entity types, task TTL eviction, discard-post-changes, replace-media-file 5. **A2-1 through A2-17** — spec drift (code is normative, update spec) 6. **D2-1 through D2-17** — untested rules 7. **D3-1 through D3-11** — partial test coverage diff --git a/specs/bds.allium b/specs/bds.allium index cc12b21..d83ee30 100644 --- a/specs/bds.allium +++ b/specs/bds.allium @@ -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) diff --git a/specs/editor_media.allium b/specs/editor_media.allium index ae2cdd1..e51c80d 100644 --- a/specs/editor_media.allium +++ b/specs/editor_media.allium @@ -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 { diff --git a/specs/editor_script.allium b/specs/editor_script.allium index 347fdfd..01196d8 100644 --- a/specs/editor_script.allium +++ b/specs/editor_script.allium @@ -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 diff --git a/specs/editor_template.allium b/specs/editor_template.allium index b661f30..5fc8141 100644 --- a/specs/editor_template.allium +++ b/specs/editor_template.allium @@ -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 diff --git a/specs/generation.allium b/specs/generation.allium index 4bf9a30..a860c44 100644 --- a/specs/generation.allium +++ b/specs/generation.allium @@ -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 diff --git a/specs/media.allium b/specs/media.allium index 5489526..56f378f 100644 --- a/specs/media.allium +++ b/specs/media.allium @@ -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 diff --git a/specs/metadata_diff.allium b/specs/metadata_diff.allium index 33a1e6b..080c71a 100644 --- a/specs/metadata_diff.allium +++ b/specs/metadata_diff.allium @@ -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 } @@ -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 { diff --git a/specs/post.allium b/specs/post.allium index ba0b767..c61e07d 100644 --- a/specs/post.allium +++ b/specs/post.allium @@ -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 { diff --git a/specs/rendering.allium b/specs/rendering.allium new file mode 100644 index 0000000..69c7f29 --- /dev/null +++ b/specs/rendering.allium @@ -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 +} + +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 +} + +-- ─── 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 + alternate_links: List -- hreflang alternates for translations + menu_items: List + post_categories: List + post_tags: List + tag_color_by_name: Map + backlinks: List + canonical_post_path_by_slug: Map + canonical_media_path_by_source_path: Map + 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). +} diff --git a/specs/task.allium b/specs/task.allium index f22256f..1c9d96a 100644 --- a/specs/task.allium +++ b/specs/task.allium @@ -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)