B1-5..B1-20: distill remaining code behaviors into specs (rendering.allium, post/media/task/generation/editor specs)
This commit is contained in:
30
SPECGAPS.md
30
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-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-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-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-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/` | Distill into spec |
|
| ~~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` | Add to generation.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-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-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-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` | Add to editor_misc.allium |
|
| ~~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` | Add to modals.allium |
|
| ~~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` | Add to modals.allium |
|
| ~~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` | Add to editor_script.allium, editor_template.allium |
|
| ~~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` | Add to tabs.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/` | Add to tabs.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` | Add to metadata_diff.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` | Add to task.allium |
|
| ~~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` | Add to post.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` | Add to media.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)
|
### 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)
|
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
|
2. **D1-1 through D1-18** — untested invariants/guarantees
|
||||||
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
|
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)
|
5. **A2-1 through A2-17** — spec drift (code is normative, update spec)
|
||||||
6. **D2-1 through D2-17** — untested rules
|
6. **D2-1 through D2-17** — untested rules
|
||||||
7. **D3-1 through D3-11** — partial test coverage
|
7. **D3-1 through D3-11** — partial test coverage
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use "./metadata.allium" as metadata -- Project config, categories, publi
|
|||||||
|
|
||||||
-- Infrastructure
|
-- Infrastructure
|
||||||
use "./search.allium" as search -- FTS5 full-text search with Snowball stemming
|
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 "./generation.allium" as generation -- Static site generation (sections, routes, hashing)
|
||||||
use "./preview.allium" as preview -- Local HTTP preview server
|
use "./preview.allium" as preview -- Local HTTP preview server
|
||||||
use "./publishing.allium" as publishing -- SSH upload (SCP / rsync)
|
use "./publishing.allium" as publishing -- SSH upload (SCP / rsync)
|
||||||
|
|||||||
@@ -130,9 +130,15 @@ surface MediaEditorSurface {
|
|||||||
@guarantee TranslationsSection
|
@guarantee TranslationsSection
|
||||||
-- Shown only when language is set.
|
-- Shown only when language is set.
|
||||||
-- List of existing translations: flag emoji + language name + title.
|
-- 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.
|
-- "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
|
@guarantee LinkedPostsSection
|
||||||
-- "Link to Post" button opens inline post picker overlay.
|
-- "Link to Post" button opens inline post picker overlay.
|
||||||
-- List of currently linked posts with document icon. Click navigates to post tab.
|
-- List of currently linked posts with document icon. Click navigates to post tab.
|
||||||
@@ -214,8 +220,9 @@ rule MediaLinkToPost {
|
|||||||
|
|
||||||
rule MediaTranslationEdit {
|
rule MediaTranslationEdit {
|
||||||
when: MediaTranslationEditClicked(media_id, language)
|
when: MediaTranslationEditClicked(media_id, language)
|
||||||
-- Loads translation fields inline (title, alt, caption) for that language
|
-- Opens the "Edit Translation" modal pre-filled with the translation's
|
||||||
-- Edit in place, save persists to translated sidecar {path}.{lang}.meta
|
-- title, alt, and caption for that language
|
||||||
|
-- Save persists to DB + translated sidecar {path}.{lang}.meta; Cancel discards
|
||||||
}
|
}
|
||||||
|
|
||||||
rule MediaTranslationRefresh {
|
rule MediaTranslationRefresh {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ value ScriptEditorView {
|
|||||||
entrypoint: String -- select: discovered Lua functions available as entrypoints
|
entrypoint: String -- select: discovered Lua functions available as entrypoints
|
||||||
enabled: Boolean -- checkbox
|
enabled: Boolean -- checkbox
|
||||||
content: String -- code editor content
|
content: String -- code editor content
|
||||||
|
can_publish: Boolean -- true while status = draft
|
||||||
created_at: String -- locale-formatted date
|
created_at: String -- locale-formatted date
|
||||||
updated_at: String -- locale-formatted date
|
updated_at: String -- locale-formatted date
|
||||||
}
|
}
|
||||||
@@ -37,13 +38,16 @@ surface ScriptEditorSurface {
|
|||||||
|
|
||||||
provides:
|
provides:
|
||||||
ScriptSaveRequested(editor.script_id)
|
ScriptSaveRequested(editor.script_id)
|
||||||
|
ScriptPublishRequested(editor.script_id)
|
||||||
|
when editor.can_publish
|
||||||
ScriptRunRequested(editor.script_id)
|
ScriptRunRequested(editor.script_id)
|
||||||
ScriptCheckSyntaxRequested(editor.script_id)
|
ScriptCheckSyntaxRequested(editor.script_id)
|
||||||
ScriptDeleteRequested(editor.script_id)
|
ScriptDeleteRequested(editor.script_id)
|
||||||
|
|
||||||
@guarantee HeaderLayout
|
@guarantee HeaderLayout
|
||||||
-- Header bar with script title tab.
|
-- 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).
|
-- Check Syntax button, Delete button (danger style).
|
||||||
|
|
||||||
@guarantee MetadataRow
|
@guarantee MetadataRow
|
||||||
@@ -72,6 +76,15 @@ rule ScriptSave {
|
|||||||
-- Entrypoint list re-discovered from Lua source after save
|
-- 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 {
|
rule ScriptCheckSyntax {
|
||||||
when: ScriptCheckSyntaxRequested(script_id)
|
when: ScriptCheckSyntaxRequested(script_id)
|
||||||
-- Validates Lua syntax without saving
|
-- Validates Lua syntax without saving
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ value TemplateEditorView {
|
|||||||
kind: String -- select: post | list | not_found | partial
|
kind: String -- select: post | list | not_found | partial
|
||||||
enabled: Boolean -- checkbox
|
enabled: Boolean -- checkbox
|
||||||
content: String -- code editor content
|
content: String -- code editor content
|
||||||
|
can_publish: Boolean -- true while status = draft
|
||||||
created_at: String -- locale-formatted date
|
created_at: String -- locale-formatted date
|
||||||
updated_at: String -- locale-formatted date
|
updated_at: String -- locale-formatted date
|
||||||
}
|
}
|
||||||
@@ -35,12 +36,15 @@ surface TemplateEditorSurface {
|
|||||||
|
|
||||||
provides:
|
provides:
|
||||||
TemplateSaveRequested(editor.template_id)
|
TemplateSaveRequested(editor.template_id)
|
||||||
|
TemplatePublishRequested(editor.template_id)
|
||||||
|
when editor.can_publish
|
||||||
TemplateValidateRequested(editor.template_id)
|
TemplateValidateRequested(editor.template_id)
|
||||||
TemplateDeleteRequested(editor.template_id)
|
TemplateDeleteRequested(editor.template_id)
|
||||||
|
|
||||||
@guarantee HeaderLayout
|
@guarantee HeaderLayout
|
||||||
-- Header bar with template title tab.
|
-- 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).
|
-- Delete button (danger style).
|
||||||
|
|
||||||
@guarantee MetadataRow
|
@guarantee MetadataRow
|
||||||
@@ -67,6 +71,15 @@ rule TemplateSave {
|
|||||||
-- See engine_side_effects.allium UpdateTemplateSideEffects
|
-- 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 {
|
rule TemplateValidate {
|
||||||
when: TemplateValidateRequested(template_id)
|
when: TemplateValidateRequested(template_id)
|
||||||
-- Validates Liquid syntax without saving
|
-- Validates Liquid syntax without saving
|
||||||
|
|||||||
@@ -121,10 +121,13 @@ rule GenerateCoreSectionPages {
|
|||||||
-- Atom feed
|
-- Atom feed
|
||||||
ensures: FileGenerated("calendar.json")
|
ensures: FileGenerated("calendar.json")
|
||||||
-- Post dates for calendar widget
|
-- 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}:
|
for lang in generation.blog_languages - {generation.language}:
|
||||||
ensures: FileGenerated(format("{lang}/index.html", lang: lang))
|
ensures: FileGenerated(format("{lang}/index.html", lang: lang))
|
||||||
ensures: FileGenerated(format("{lang}/feed.xml", lang: lang))
|
ensures: FileGenerated(format("{lang}/feed.xml", lang: lang))
|
||||||
ensures: FileGenerated(format("{lang}/atom.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
|
-- Single section: one HTML page per published post
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ surface MediaControlSurface {
|
|||||||
provides:
|
provides:
|
||||||
ImportMediaRequested(source_path, project, metadata)
|
ImportMediaRequested(source_path, project, metadata)
|
||||||
UpdateMediaRequested(media, changes)
|
UpdateMediaRequested(media, changes)
|
||||||
|
ReplaceMediaFileRequested(media, new_source_path)
|
||||||
DeleteMediaRequested(media)
|
DeleteMediaRequested(media)
|
||||||
UpsertMediaTranslationRequested(media, language, title, alt, caption)
|
UpsertMediaTranslationRequested(media, language, title, alt, caption)
|
||||||
RebuildMediaFromFilesRequested(project)
|
RebuildMediaFromFilesRequested(project)
|
||||||
@@ -158,6 +159,28 @@ rule UpdateMedia {
|
|||||||
ensures: SearchIndexUpdated(media)
|
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 {
|
rule DeleteMedia {
|
||||||
when: DeleteMediaRequested(media)
|
when: DeleteMediaRequested(media)
|
||||||
ensures: not exists media
|
ensures: not exists media
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ surface MetadataMaintenanceSurface {
|
|||||||
provides:
|
provides:
|
||||||
MetadataDiffRequested(project)
|
MetadataDiffRequested(project)
|
||||||
RebuildFromFilesystemRequested(project, entity_type)
|
RebuildFromFilesystemRequested(project, entity_type)
|
||||||
|
RepairMetadataDiffItemRequested(project, direction, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
value DiffField {
|
value DiffField {
|
||||||
@@ -23,7 +24,8 @@ value DiffField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
value DiffReport {
|
value DiffReport {
|
||||||
entity_type: String -- post, media, script, template
|
entity_type: String -- post, post_translation, media,
|
||||||
|
-- media_translation, script, template, embedding
|
||||||
entity_id: String
|
entity_id: String
|
||||||
differences: List<DiffField>
|
differences: List<DiffField>
|
||||||
}
|
}
|
||||||
@@ -66,7 +68,26 @@ rule RunMetadataDiff {
|
|||||||
if matching.count = 0:
|
if matching.count = 0:
|
||||||
ensures: OrphanReport.created(file_path: file)
|
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 {
|
rule RebuildFromFilesystem {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ surface PostControlSurface {
|
|||||||
PublishPostRequested(post)
|
PublishPostRequested(post)
|
||||||
DeletePostRequested(post)
|
DeletePostRequested(post)
|
||||||
ArchivePostRequested(post)
|
ArchivePostRequested(post)
|
||||||
|
DiscardPostChangesRequested(post)
|
||||||
}
|
}
|
||||||
|
|
||||||
surface PostFilePathSurface {
|
surface PostFilePathSurface {
|
||||||
@@ -100,6 +101,15 @@ entity Post {
|
|||||||
updated_at: Timestamp
|
updated_at: Timestamp
|
||||||
published_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
|
-- Relationships
|
||||||
translations: PostTranslation with canonical_post = this
|
translations: PostTranslation with canonical_post = this
|
||||||
linked_media: PostMediaLink with post = this
|
linked_media: PostMediaLink with post = this
|
||||||
@@ -218,6 +228,20 @@ rule ArchivePost {
|
|||||||
ensures: post.status = archived
|
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
|
-- File format axioms
|
||||||
|
|
||||||
invariant FrontmatterRoundtrip {
|
invariant FrontmatterRoundtrip {
|
||||||
|
|||||||
207
specs/rendering.allium
Normal file
207
specs/rendering.allium
Normal 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).
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ surface TaskRuntimeSurface {
|
|||||||
TaskWorkCompleted(task)
|
TaskWorkCompleted(task)
|
||||||
TaskWorkFailed(task, error_message)
|
TaskWorkFailed(task, error_message)
|
||||||
ProgressReported(task, value, message)
|
ProgressReported(task, value, message)
|
||||||
|
FinishedTaskEvictionDue()
|
||||||
}
|
}
|
||||||
|
|
||||||
surface TaskSurface {
|
surface TaskSurface {
|
||||||
@@ -54,6 +55,8 @@ surface TaskSurface {
|
|||||||
config {
|
config {
|
||||||
max_concurrent: Integer = 3
|
max_concurrent: Integer = 3
|
||||||
progress_throttle: Duration = 250.milliseconds
|
progress_throttle: Duration = 250.milliseconds
|
||||||
|
finished_task_ttl: Duration = 1.hour
|
||||||
|
recent_finished_limit: Integer = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant MaxConcurrency {
|
invariant MaxConcurrency {
|
||||||
@@ -112,6 +115,23 @@ invariant ProgressThrottled {
|
|||||||
-- At most one progress event per 250ms per task
|
-- 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)
|
-- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts)
|
||||||
rule RegisterExternalTask {
|
rule RegisterExternalTask {
|
||||||
when: RegisterExternalTaskRequested(name)
|
when: RegisterExternalTaskRequested(name)
|
||||||
|
|||||||
Reference in New Issue
Block a user