chore: tend to allium spec to align with code

This commit is contained in:
2026-05-28 13:36:55 +02:00
parent b09b14cc03
commit 1914b05f39
15 changed files with 295 additions and 176 deletions

View File

@@ -15,7 +15,8 @@
"mcp__Claude_in_Chrome__navigate", "mcp__Claude_in_Chrome__navigate",
"mcp__Claude_in_Chrome__computer", "mcp__Claude_in_Chrome__computer",
"mcp__Claude_in_Chrome__browser_batch", "mcp__Claude_in_Chrome__browser_batch",
"mcp__Claude_in_Chrome__javascript_tool" "mcp__Claude_in_Chrome__javascript_tool",
"Bash(allium check *)"
] ]
} }
} }

View File

@@ -22,28 +22,29 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
| A1-10 | Template file written on create | engine_side_effects.allium:151-153 | Draft templates have `file_path=""` | Fix code: write template file on create | | A1-10 | Template file written on create | engine_side_effects.allium:151-153 | Draft templates have `file_path=""` | Fix code: write template file on create |
| A1-11 | Graceful shutdown with inflight request tracking | preview.allium:47-48 | Kills acceptor process, no inflight tracking | Fix code: track inflight requests, drain before shutdown | | A1-11 | Graceful shutdown with inflight request tracking | preview.allium:47-48 | Kills acceptor process, no inflight tracking | Fix code: track inflight requests, drain before shutdown |
| A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI | | A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI |
| A1-13 | Git sidebar shows only "Working tree" placeholder | sidebar_views.allium:651-770 | `sidebar.ex:782-798` returns single entity_list item; `BDS.Git` has full status/diff/commit/history/fetch/pull/push/prune_lfs but sidebar doesn't use it | Fix code: wire sidebar `git_view/0` to `BDS.Git` — render branch, ahead/behind, status file list, commit input, history entries, action buttons per spec |
### A2. Spec Should Update (code is normative) ### A2. Spec Should Update (code is normative)
| ID | Gap | Spec | Code | Path | | ID | Gap | Spec | Code | Path |
|---|---|---|---|---| |---|---|---|---|---|
| A2-1 | WYSIWYG/visual editor mode (3 modes) | editor_post.allium:159-164 | Only markdown+preview; visual normalizes to markdown | Drop from spec or mark future | | A2-1 | ~~WYSIWYG/visual editor mode (3 modes)~~ | editor_post.allium:159-164 | Only markdown+preview; visual normalizes to markdown | **Resolved:** spec updated to 2 modes (markdown/preview), visual/WYSIWYG dropped |
| A2-2 | Template/Script are global entities | template.allium, script.allium | Both have `project_id`, per-project uniqueness | Update spec to per-project scoping | | A2-2 | ~~Template/Script are global entities~~ | template.allium, script.allium | Both have `project_id`, per-project uniqueness | **Resolved:** spec updated — added `project_id` to entities, scoped uniqueness invariants and create rules per project |
| A2-3 | TagsFile uses `{tags: [...]}` wrapper | frontmatter.allium:255-273 | Code writes bare array `[...]` | Update spec | | A2-3 | ~~TagsFile uses `{tags: [...]}` wrapper~~ | frontmatter.allium:255-273 | Code writes bare array `[...]` | **Resolved:** spec updated — removed wrapper object, TagEntry is now the top-level value, bare array in invariant, camelCase keys |
| A2-4 | Sidecar is "YAML-like, not gray-matter" | frontmatter.allium:174 | Code wraps with `---` delimiters | Update spec to gray-matter style | | A2-4 | ~~Sidecar is "YAML-like, not gray-matter"~~ | frontmatter.allium:174 | Code wraps with `---` delimiters | **Resolved:** spec updated — format comment now says gray-matter style with --- delimiters |
| A2-5 | Translation frontmatter omits status/timestamps | frontmatter.allium:107-117 | Code writes status, createdAt, updatedAt, publishedAt | Update spec to match written fields | | A2-5 | ~~Translation frontmatter omits status/timestamps~~ | frontmatter.allium:107-117 | Code writes status, createdAt, updatedAt, publishedAt | **Resolved:** spec updated — TranslationFrontmatter now includes status, created_at, updated_at, published_at; TranslationFilesInheritCanonicalMetadata renamed to TranslationFrontmatterRoundtrip; translation.allium invariant updated to TranslationFilesCarryFullMetadata |
| A2-6 | Search index has single `stemmed_content` | search.allium:40-54 | FTS5 per-field stemmed columns | Update spec to per-field model | | A2-6 | ~~Search index has single `stemmed_content`~~ | search.allium:40-54 | FTS5 per-field stemmed columns | **Resolved:** spec updated — PostSearchIndex has title/excerpt/content/tags/categories; MediaSearchIndex has title/alt/caption/original_name/tags; SearchMedia now accepts filters; index rules use delete-and-reinsert with per-field stemming |
| A2-7 | Tag archives are single-page | generation.allium:142-147 | Code paginates | Update spec | | A2-7 | ~~Tag archives are single-page~~ | generation.allium:142-147 | Code paginates | **Resolved:** spec updated — GenerateTagPages now paginated like categories, using max_posts_per_page |
| A2-8 | Date archives year+month only | generation.allium:151-159 | Code also generates day-level | Update spec | | A2-8 | ~~Date archives year+month only~~ | generation.allium:151-159 | Code also generates day-level | **Resolved:** spec updated — GenerateDateArchivePages now includes day-level archives, all three levels paginated |
| A2-9 | Menu is DB entity | menu.allium:20-26 | Purely file-based OPML, no DB table | Update spec to file-only model | | A2-9 | ~~Menu is DB entity~~ | menu.allium:20-26 | Purely file-based OPML, no DB table | **Resolved:** spec updated — `entity Menu` changed to `value Menu`, file-only model with OPML persistence, added LoadMenu/SyncMenuFromFilesystem rules |
| A2-10 | Panel tabs: problems, terminal | layout.allium:235-240 | `[:tasks, :output, :post_links, :git_log]` | Update spec | | A2-10 | ~~Panel tabs: problems, terminal~~ | layout.allium:235-240 | `[:tasks, :output, :post_links, :git_log]` | **Resolved:** spec already lists tasks/output/post_links/git_log with availability and fallback rules matching code |
| A2-11 | Git sidebar: commit input, history, push/pull | sidebar_views.allium | Only "Working tree" item | Mark as partial/TODO in spec | | A2-11 | ~~Git sidebar: commit input, history, push/pull~~ | sidebar_views.allium | Only "Working tree" item | **Moved to A1-13:** backend code exists in BDS.Git, sidebar must wire it up |
| A2-12 | Slug timestamp fallback after 999 | post.allium:21 | Unbounded numeric suffix | Update spec or fix code | | A2-12 | ~~Slug timestamp fallback after 999~~ | post.allium:21 | Unbounded numeric suffix | **Resolved:** spec updated — uniqueness comment now says unbounded numeric suffix, no 999 cap or timestamp fallback |
| A2-13 | Thumbnail generation is async | engine_side_effects.allium:117 | Synchronous | Update spec or fix code | | A2-13 | ~~Thumbnail generation is async~~ | engine_side_effects.allium:117 | Synchronous | **Resolved:** spec updated — import thumbnail generation now says synchronous (awaited, logged on error), matching code; summary table changed from `async` to `sync` |
| A2-14 | AiModelModality: :video vs :file/:tool | schema.allium:291 | Code has `:file`, `:tool` instead of `:video` | Update spec to :file/:tool | | A2-14 | ~~AiModelModality: :video vs :file/:tool~~ | schema.allium:291 | Code has `:file`, `:tool` instead of `:video` | **Resolved:** spec updated — modality enum now lists "text" \| "image" \| "audio" \| "file" \| "tool", matching code |
| A2-15 | JSON key convention: snake_case vs camelCase | frontmatter.allium values | Code uses camelCase for all metadata JSON | Update spec to camelCase | | A2-15 | ~~JSON key convention: snake_case vs camelCase~~ | frontmatter.allium values | Code uses camelCase for all metadata JSON | **Resolved:** all value types in frontmatter.allium updated to camelCase field names; added CamelCaseKeys invariant; surfaces updated; also added linkedPostIds to MediaSidecar (C-2) and projectId to TemplateFrontmatter/ScriptFrontmatter (B1-9) |
| A2-16 | Snowball stemmer language list | search.allium:26-31 | Library determines which have algorithms vs passthrough | Update spec: don't enumerate; just say "Snowball stemmers via library" | | A2-16 | ~~Snowball stemmer language list~~ | search.allium:26-31 | Library determines which have algorithms vs passthrough | **Resolved:** spec updated — StemmerLanguage comment now says "Snowball stemmers via library (Stemex); languages with algorithm get real stemming, others pass through" |
| A2-17 | `provider_package_ref` on AiModel | schema.allium:282 | Not in code; legacy field not needed | Drop from spec | | A2-17 | ~~`provider_package_ref` on AiModel~~ | schema.allium:282 | Not in code; legacy field not needed | **Resolved:** dropped from AiModel entity and AiModelRecordSurface in schema.allium; DB column retained (migration artifact) |
--- ---
@@ -60,8 +61,8 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
| 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` | Add to post.allium entity |
| 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/` | Distill into spec |
| 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` | Add to generation.allium |
| B1-8 | `linkedPostIds` in media sidecar | `lib/bds/media/sidecars.ex:42` | Add to frontmatter.allium MediaSidecar | | 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` | Add to frontmatter.allium | | 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` | 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-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-12 | `:language_picker` overlay with flag emojis | `shell_overlay.html.heex:116-139` | Add to modals.allium |
@@ -97,8 +98,8 @@ All reconciled to follow code. Specs must be self-consistent and match code.
| ID | Conflict | Resolution | Path | | ID | Conflict | Resolution | Path |
|---|---|---|---| |---|---|---|---|
| C-1 | schema.allium ChatMessage has no cache tokens; ai.allium ChatMessage has `cache_read_tokens`/`cache_write_tokens` | Code has cache tokens → align schema.allium with ai.allium | Update schema.allium | | C-1 | schema.allium ChatMessage has no cache tokens; ai.allium ChatMessage has `cache_read_tokens`/`cache_write_tokens` | Code has cache tokens → align schema.allium with ai.allium | Update schema.allium |
| C-2 | media.allium SidecarFile mentions `linkedPostIds`; frontmatter.allium MediaSidecar does NOT list it | Code writes `linkedPostIds` → add to frontmatter.allium | Update frontmatter.allium | | C-2 | ~~media.allium SidecarFile mentions `linkedPostIds`; frontmatter.allium MediaSidecar does NOT list it~~ | Code writes `linkedPostIds` → add to frontmatter.allium | **Resolved:** linkedPostIds added to MediaSidecar in frontmatter.allium (with A2-15) |
| C-3 | translation.allium says status/timestamps omitted; frontmatter.allium TranslationFrontmatter defines only 5 fields; code writes 8+ fields | Code writes status/timestamps → update both specs to match code | Update translation.allium + frontmatter.allium | | C-3 | ~~translation.allium says status/timestamps omitted; frontmatter.allium TranslationFrontmatter defines only 5 fields; code writes 8+ fields~~ | Code writes status/timestamps → update both specs to match code | **Resolved:** both specs updated (see A2-5) |
--- ---

View File

@@ -19,7 +19,7 @@ value PostEditorView {
metadata: PostEditorMetadata metadata: PostEditorMetadata
metadata_expanded: Boolean -- starts expanded when title is empty metadata_expanded: Boolean -- starts expanded when title is empty
excerpt_expanded: Boolean excerpt_expanded: Boolean
editor_mode: String -- visual | markdown | preview editor_mode: String -- markdown | preview
footer: PostEditorFooter footer: PostEditorFooter
} }
@@ -152,15 +152,15 @@ surface PostEditorSurface {
-- Collapsible section with textarea (4 rows). -- Collapsible section with textarea (4 rows).
@guarantee EditorBodyToolbar @guarantee EditorBodyToolbar
-- Toolbar: "Content" label, mode toggle (Visual/Markdown/Preview), -- Toolbar: "Content" label, mode toggle (Markdown/Preview),
-- action buttons (markdown mode only): Gallery (with media count), -- action buttons (markdown mode only): Gallery (with media count),
-- Insert Post Link, Insert Media. -- Insert Post Link, Insert Media.
@guarantee EditorModes @guarantee EditorModes
-- Visual: rich-text WYSIWYG editor.
-- Markdown: code editor with markdown-with-macros language, -- Markdown: code editor with markdown-with-macros language,
-- highlighting [[macro ...]] syntax. Word wrap on, minimap off, 14px font. -- highlighting [[macro ...]] syntax. Word wrap on, minimap off, 14px font.
-- Preview: iframe showing rendered preview. -- Preview: iframe showing rendered preview.
-- Note: visual/WYSIWYG mode not implemented; "visual" normalizes to markdown.
@guarantee DragDropImages @guarantee DragDropImages
-- Drop image file onto editor area triggers import chain. -- Drop image file onto editor area triggers import chain.
@@ -193,8 +193,8 @@ invariant PostDirtyTracking {
} }
invariant PostEditorModePersistence { invariant PostEditorModePersistence {
-- Editor mode (visual/markdown/preview) persists per session. -- Editor mode (markdown/preview) persists per session.
-- Default mode comes from editor settings. -- Default mode comes from editor settings (markdown).
} }
-- ─── Post editor actions ──────────────────────────────────── -- ─── Post editor actions ────────────────────────────────────

View File

@@ -33,7 +33,7 @@ value SettingsProjectSection {
} }
value SettingsEditorSection { value SettingsEditorSection {
default_mode: String -- select: wysiwyg | markdown | preview default_mode: String -- select: markdown | preview
diff_view_style: String -- select: inline | side-by-side diff_view_style: String -- select: inline | side-by-side
wrap_long_lines: Boolean -- checkbox wrap_long_lines: Boolean -- checkbox
hide_unchanged_regions: Boolean -- checkbox hide_unchanged_regions: Boolean -- checkbox
@@ -138,7 +138,7 @@ surface SettingsViewSurface {
-- Bookmarklet uses project's publicUrl to construct POST endpoint. -- Bookmarklet uses project's publicUrl to construct POST endpoint.
@guarantee EditorSection @guarantee EditorSection
-- Section 2: Default Editor Mode (select: WYSIWYG/Markdown/Preview), -- Section 2: Default Editor Mode (select: Markdown/Preview),
-- Diff View Style (select: Inline/Side-by-side), -- Diff View Style (select: Inline/Side-by-side),
-- Wrap Long Lines (checkbox), Hide Unchanged Regions (checkbox). -- Wrap Long Lines (checkbox), Hide Unchanged Regions (checkbox).

View File

@@ -114,7 +114,7 @@ rule ImportMediaSideEffects {
if media.is_image: if media.is_image:
ensures: ThumbnailsGenerated(media) ensures: ThumbnailsGenerated(media)
-- small=150px, medium=400px, large=800px, ai=448x448 -- small=150px, medium=400px, large=800px, ai=448x448
-- Asynchronous, emits thumbnailsGenerated on completion -- Synchronous (awaited), logged on error
ensures: FTSIndexUpdated(media) ensures: FTSIndexUpdated(media)
} }
@@ -299,7 +299,7 @@ rule DeleteMediaTranslationSideEffects {
-- updatePost | rename only* | yes | if Δ | no | no | yes | no -- updatePost | rename only* | yes | if Δ | no | no | yes | no
-- publishPost | .md + trans | yes | yes | no | no | yes | no -- publishPost | .md + trans | yes | yes | no | no | yes | no
-- deletePost | delete .md | del | del | no | Δ media | del | no -- deletePost | delete .md | del | del | no | Δ media | del | no
-- importMedia | copy file | yes | no | async | write | no | no -- importMedia | copy file | yes | no | sync | write | no | no
-- updateMedia | no | yes | no | no | rewrite | no | no -- updateMedia | no | yes | no | no | rewrite | no | no
-- replaceMediaFile | overwrite | no | no | regen | no | no | no -- replaceMediaFile | overwrite | no | no | regen | no | no | no
-- deleteMedia | delete all | del | no | del | del all | no | no -- deleteMedia | delete all | del | no | del | del all | no | no

View File

@@ -25,7 +25,7 @@ surface PostFrontmatterSurface {
frontmatter.title frontmatter.title
frontmatter.slug frontmatter.slug
frontmatter.status frontmatter.status
frontmatter.published_at frontmatter.publishedAt
frontmatter.tags frontmatter.tags
frontmatter.categories frontmatter.categories
} }
@@ -35,11 +35,11 @@ surface MediaSidecarSurface {
exposes: exposes:
sidecar.id sidecar.id
sidecar.original_name sidecar.originalName
sidecar.mime_type sidecar.mimeType
sidecar.width sidecar.width
sidecar.height sidecar.height
sidecar.updated_at sidecar.updatedAt
} }
surface TemplateFrontmatterSurface { surface TemplateFrontmatterSurface {
@@ -70,8 +70,8 @@ surface MenuOpmlSurface {
exposes: exposes:
document.header.title document.header.title
document.header.date_created document.header.dateCreated
document.header.date_modified document.header.dateModified
for item in document.body: for item in document.body:
item.kind item.kind
item.label item.label
@@ -88,6 +88,7 @@ config {
value PostFrontmatter { value PostFrontmatter {
-- File path: posts/{YYYY}/{MM}/{slug}.md -- File path: posts/{YYYY}/{MM}/{slug}.md
-- All keys serialized as camelCase in YAML frontmatter
id: String -- UUID v4 id: String -- UUID v4
title: String title: String
slug: String slug: String
@@ -95,25 +96,29 @@ value PostFrontmatter {
status: draft | published | archived status: draft | published | archived
author: String? -- Only written if present author: String? -- Only written if present
language: String? -- Only written if present (ISO 639-1) language: String? -- Only written if present (ISO 639-1)
do_not_translate: Boolean -- Only written when true doNotTranslate: Boolean -- Only written when true
template_slug: String? -- Only written if present templateSlug: String? -- Only written if present
created_at: Timestamp -- Unix timestamp in milliseconds createdAt: Timestamp -- Unix timestamp in milliseconds
updated_at: Timestamp -- Unix timestamp in milliseconds updatedAt: Timestamp -- Unix timestamp in milliseconds
published_at: Timestamp? -- Only written if published publishedAt: Timestamp? -- Only written if published
tags: List<String> -- Always written, even if empty tags: List<String> -- Always written, even if empty
categories: List<String> -- Always written, even if empty categories: List<String> -- Always written, even if empty
} }
value TranslationFrontmatter { value TranslationFrontmatter {
-- File path: posts/{YYYY}/{MM}/{slug}.{language}.md -- File path: posts/{YYYY}/{MM}/{slug}.{language}.md
-- Translation files only store language-specific metadata. -- Translation files carry their own publication state and timestamps
-- Shared publication state and timestamps are inherited from the -- so that each translation can be rebuilt independently.
-- canonical post file and are not duplicated here. -- All keys serialized as camelCase in YAML frontmatter
id: String -- UUID v4 id: String -- UUID v4
translation_for: String -- Canonical post UUID translationFor: String -- Canonical post UUID
language: String -- ISO 639-1 language code language: String -- ISO 639-1 language code
title: String -- Translated title title: String -- Translated title
excerpt: String? -- Only written when the translated excerpt differs excerpt: String? -- Only written when the translated excerpt differs
status: draft | published
createdAt: Timestamp -- Unix timestamp in milliseconds
updatedAt: Timestamp -- Unix timestamp in milliseconds
publishedAt: Timestamp -- Canonical post's publishedAt at time of publish
} }
surface TranslationFrontmatterSurface { surface TranslationFrontmatterSurface {
@@ -121,10 +126,14 @@ surface TranslationFrontmatterSurface {
exposes: exposes:
frontmatter.id frontmatter.id
frontmatter.translation_for frontmatter.translationFor
frontmatter.language frontmatter.language
frontmatter.title frontmatter.title
frontmatter.excerpt when frontmatter.excerpt != null frontmatter.excerpt when frontmatter.excerpt != null
frontmatter.status
frontmatter.createdAt
frontmatter.updatedAt
frontmatter.publishedAt
} }
invariant PostFileLayout { invariant PostFileLayout {
@@ -147,9 +156,10 @@ invariant PostTranslationFileLayout {
lang: t.language) lang: t.language)
} }
invariant TranslationFilesInheritCanonicalMetadata { invariant TranslationFrontmatterRoundtrip {
-- Missing status and timestamp fields in translation files are expected. -- Translation files carry status and timestamps explicitly.
-- Rebuild and metadata diff must resolve those values from the canonical post. -- On rebuild, these fields are read back directly; fallback to canonical
-- post values applies only when fields are absent (legacy files).
for t in PostTranslations where file_path != "": for t in PostTranslations where file_path != "":
parse_frontmatter(read_file(t.file_path)) = translation_frontmatter_fields(t) parse_frontmatter(read_file(t.file_path)) = translation_frontmatter_fields(t)
} }
@@ -171,11 +181,12 @@ rule WritePostFile {
value MediaSidecar { value MediaSidecar {
-- File path: {binary_path}.meta (e.g., media/2024/03/a1b2c3d4.jpg.meta) -- File path: {binary_path}.meta (e.g., media/2024/03/a1b2c3d4.jpg.meta)
-- Binary file at: media/{YYYY}/{MM}/{uuid}.{ext} -- Binary file at: media/{YYYY}/{MM}/{uuid}.{ext}
-- Format: YAML-like key-value (hand-built, not gray-matter frontmatter) -- Format: YAML-like key-value wrapped in --- delimiters (gray-matter style, hand-built serializer)
-- Note: 'filename' is NOT written to sidecar — it is implicit from the binary path -- Note: 'filename' is NOT written to sidecar — it is implicit from the binary path
-- All keys serialized as camelCase
id: String -- UUID v4 id: String -- UUID v4
original_name: String -- Original uploaded filename originalName: String -- Original uploaded filename
mime_type: String mimeType: String
size: Integer -- Bytes size: Integer -- Bytes
width: Integer? width: Integer?
height: Integer? height: Integer?
@@ -185,8 +196,9 @@ value MediaSidecar {
author: String? -- Only written if present author: String? -- Only written if present
language: String? -- Only written if present language: String? -- Only written if present
tags: List<String> -- Always written, even if empty tags: List<String> -- Always written, even if empty
created_at: Timestamp linkedPostIds: List<String> -- UUIDs of posts that reference this media
updated_at: Timestamp createdAt: Timestamp
updatedAt: Timestamp
} }
invariant MediaSidecarLayout { invariant MediaSidecarLayout {
@@ -200,14 +212,16 @@ invariant MediaSidecarLayout {
value TemplateFrontmatter { value TemplateFrontmatter {
-- File path: templates/{slug}.liquid -- File path: templates/{slug}.liquid
-- All keys serialized as camelCase in YAML frontmatter
id: String -- UUID v4 id: String -- UUID v4
projectId: String -- Scoped to project
slug: String slug: String
title: String title: String
kind: post | list | not_found | partial kind: post | list | not_found | partial
enabled: Boolean enabled: Boolean
version: Integer version: Integer
created_at: Timestamp createdAt: Timestamp
updated_at: Timestamp updatedAt: Timestamp
} }
rule WriteTemplateFile { rule WriteTemplateFile {
@@ -227,15 +241,17 @@ rule WriteTemplateFile {
value ScriptFrontmatter { value ScriptFrontmatter {
-- File path: scripts/{slug}.{extension} -- File path: scripts/{slug}.{extension}
-- YAML frontmatter delimited by --- markers -- YAML frontmatter delimited by --- markers
-- All keys serialized as camelCase in YAML frontmatter
id: String -- UUID v4 id: String -- UUID v4
projectId: String -- Scoped to project
slug: String slug: String
title: String title: String
kind: macro | utility | transform kind: macro | utility | transform
entrypoint: String -- Default: "render" for macros, "main" otherwise entrypoint: String -- Default: "render" for macros, "main" otherwise
enabled: Boolean enabled: Boolean
version: Integer version: Integer
created_at: Timestamp createdAt: Timestamp
updated_at: Timestamp updatedAt: Timestamp
} }
rule WriteScriptFile { rule WriteScriptFile {
@@ -252,24 +268,20 @@ rule WriteScriptFile {
-- TAGS FILE FORMAT -- TAGS FILE FORMAT
-- ============================================================================ -- ============================================================================
value TagsFile {
-- File path: meta/tags.json
-- Portable JSON format (no internal IDs)
tags: List<TagEntry>
}
value TagEntry { value TagEntry {
-- File path: meta/tags.json
-- Stored as a bare JSON array (no wrapper object)
-- Portable JSON format (no internal IDs), camelCase keys
name: String name: String
color: String? color: String?
post_template_slug: String? postTemplateSlug: String?
} }
invariant TagsFileFormat { invariant TagsFileFormat {
-- Tags are stored as a sorted JSON array -- Tags are stored as a bare sorted JSON array
-- Sorted alphabetically by name (case-insensitive) -- Sorted alphabetically by name (case-insensitive)
parse_json(read_file("meta/tags.json")) = { parse_json(read_file("meta/tags.json")) =
tags: sort_by(Tags, t => lowercase(t.name)) sort_by(tags, t => lowercase(t.name))
}
} }
-- ============================================================================ -- ============================================================================
@@ -278,16 +290,17 @@ invariant TagsFileFormat {
value ProjectJson { value ProjectJson {
-- File path: meta/project.json -- File path: meta/project.json
-- All keys serialized as camelCase
name: String name: String
description: String? description: String?
public_url: String? publicUrl: String?
main_language: String? mainLanguage: String?
default_author: String? defaultAuthor: String?
max_posts_per_page: Integer maxPostsPerPage: Integer
blogmark_category: String? blogmarkCategory: String?
pico_theme: String? picoTheme: String?
semantic_similarity_enabled: Boolean semanticSimilarityEnabled: Boolean
blog_languages: List<String> blogLanguages: List<String>
} }
value CategoriesJson { value CategoriesJson {
@@ -303,18 +316,19 @@ value CategoryMetaJson {
} }
value CategorySettings { value CategorySettings {
render_in_lists: Boolean renderInLists: Boolean
show_title: Boolean showTitle: Boolean
post_template_slug: String? postTemplateSlug: String?
list_template_slug: String? listTemplateSlug: String?
} }
value PublishingJson { value PublishingJson {
-- File path: meta/publishing.json -- File path: meta/publishing.json
ssh_host: String? -- All keys serialized as camelCase
ssh_user: String? sshHost: String?
ssh_remote_path: String? sshUser: String?
ssh_mode: scp | rsync sshRemotePath: String?
sshMode: scp | rsync
} }
invariant MetadataFileLayout { invariant MetadataFileLayout {
@@ -325,7 +339,7 @@ invariant MetadataFileLayout {
meta/category-meta.json = serialize(CategoryMetaJson) meta/category-meta.json = serialize(CategoryMetaJson)
meta/publishing.json = serialize(PublishingJson) meta/publishing.json = serialize(PublishingJson)
meta/menu.opml = serialize(Menu) meta/menu.opml = serialize(Menu)
meta/tags.json = serialize(TagsFile) meta/tags.json = serialize(List<TagEntry>)
} }
-- ============================================================================ -- ============================================================================
@@ -341,8 +355,8 @@ value MenuOpml {
value OpmlHeader { value OpmlHeader {
title: String title: String
date_created: Timestamp dateCreated: Timestamp
date_modified: Timestamp dateModified: Timestamp
} }
value MenuItem { value MenuItem {
@@ -376,6 +390,11 @@ invariant YamlFormatting {
-- Boolean values are lowercase: true/false -- Boolean values are lowercase: true/false
} }
invariant CamelCaseKeys {
-- All serialized keys in YAML frontmatter and JSON metadata use camelCase.
-- Entity/DB fields use snake_case internally; the mapping happens at serialization.
}
invariant AtomicWrites { invariant AtomicWrites {
-- All file writes are atomic -- All file writes are atomic
-- Write to temp file first, then rename -- Write to temp file first, then rename
@@ -390,7 +409,7 @@ invariant RequiredPostFields {
-- These fields are ALWAYS written for posts -- These fields are ALWAYS written for posts
for p in Posts: for p in Posts:
required_fields(p) = { required_fields(p) = {
id, title, slug, status, created_at, updated_at, id, title, slug, status, createdAt, updatedAt,
tags, categories tags, categories
} }
} }
@@ -399,9 +418,9 @@ invariant ConditionalPostFields {
-- These fields are ONLY written if truthy -- These fields are ONLY written if truthy
for p in Posts: for p in Posts:
conditional_fields(p) = { conditional_fields(p) = {
excerpt, author, language, template_slug, published_at excerpt, author, language, templateSlug, publishedAt
} }
-- do_not_translate is only written when true -- doNotTranslate is only written when true
} }
invariant RequiredMediaFields { invariant RequiredMediaFields {
@@ -409,8 +428,8 @@ invariant RequiredMediaFields {
-- Note: 'filename' is NOT a sidecar field — it is the binary path itself -- Note: 'filename' is NOT a sidecar field — it is the binary path itself
for m in Media: for m in Media:
required_fields(m) = { required_fields(m) = {
id, original_name, mime_type, size, id, originalName, mimeType, size,
created_at, updated_at, tags createdAt, updatedAt, tags
} }
} }

View File

@@ -143,19 +143,39 @@ rule GenerateTagPages {
when: GenerateSiteRequested(generation) when: GenerateSiteRequested(generation)
requires: tag in generation.sections requires: tag in generation.sections
for t in Tags where post_count > 0: for t in Tags where post_count > 0:
ensures: FileGenerated(format("tag/{slug}/index.html", slug: slugify(t.name))) let slug = slugify(t.name)
let page_count = ceil(posts_with_tag(t).count / generation.max_posts_per_page)
ensures: FileGenerated(format("tag/{slug}/index.html", slug: slug))
for page in page_range(2, page_count):
ensures: FileGenerated(format("tag/{slug}/page/{page}/index.html",
slug: slug, page: page))
} }
-- Date section: year and month archives -- Date section: year, month, and day archives
rule GenerateDateArchivePages { rule GenerateDateArchivePages {
when: GenerateSiteRequested(generation) when: GenerateSiteRequested(generation)
requires: date in generation.sections requires: date in generation.sections
for year in distinct_years(Posts): for year in distinct_years(Posts):
let yp = ceil(posts_in_year(year).count / generation.max_posts_per_page)
ensures: FileGenerated(format("{year}/index.html", year: year)) ensures: FileGenerated(format("{year}/index.html", year: year))
for page in page_range(2, yp):
ensures: FileGenerated(format("{year}/page/{page}/index.html",
year: year, page: page))
for month in distinct_months(Posts, year): for month in distinct_months(Posts, year):
let mp = ceil(posts_in_month(year, month).count / generation.max_posts_per_page)
ensures: FileGenerated(format("{year}/{month}/index.html", ensures: FileGenerated(format("{year}/{month}/index.html",
year: year, month: month)) year: year, month: month))
for page in page_range(2, mp):
ensures: FileGenerated(format("{year}/{month}/page/{page}/index.html",
year: year, month: month, page: page))
for day in distinct_days(Posts, year, month):
let dp = ceil(posts_in_day(year, month, day).count / generation.max_posts_per_page)
ensures: FileGenerated(format("{year}/{month}/{day}/index.html",
year: year, month: month, day: day))
for page in page_range(2, dp):
ensures: FileGenerated(format("{year}/{month}/{day}/page/{page}/index.html",
year: year, month: month, day: day, page: page))
} }
-- Template rendering context -- Template rendering context

View File

@@ -1,28 +1,30 @@
-- allium: 1 -- allium: 1
-- bDS Navigation Menu -- bDS Navigation Menu
-- Scope: core (read for rendering), extension Bucket F (menu editor UI) -- Scope: core (read for rendering), extension Bucket F (menu editor UI)
-- Distilled from: src/main/engine/MenuEngine.ts -- File-only model: no DB table. Loaded from meta/menu.opml into a
-- transient value, mutated in memory, written back to OPML on save.
surface MenuManagementSurface { surface MenuManagementSurface {
facing _: MenuOperator facing _: MenuOperator
provides: provides:
UpdateMenuRequested(menu, items) MenuLoadRequested(project_id)
UpdateMenuRequested(items)
SyncMenuFromFilesystemRequested(project_id)
} }
value MenuItem { value MenuItem {
kind: page | submenu | category_archive | home kind: page | submenu | category_archive | home
label: String label: String
slug: String? slug: String? -- pageSlug for page/home, categoryName for category_archive
children: List<MenuItem>? -- only for submenu kind children: List<MenuItem>? -- present only for submenu kind
} }
entity Menu { value Menu {
items: List<MenuItem> items: List<MenuItem>
-- Derived -- Derived
home_items: items where kind = home home_entry: items.first -- always home after normalization
home_entry: home_items.first
} }
surface MenuSurface { surface MenuSurface {
@@ -30,27 +32,42 @@ surface MenuSurface {
exposes: exposes:
menu.items.count menu.items.count
menu.home_items.count
menu.home_entry.label menu.home_entry.label
} }
invariant HomeAlwaysPresent { invariant HomeAlwaysFirst {
-- The menu always has a Home entry, extracted and prepended -- Normalization guarantees home is always the first item.
-- UpdateMenu strips any home entries from input, then prepends one.
for menu in Menus: for menu in Menus:
menu.items.first.kind = home menu.items.first.kind = home
} }
invariant MenuPersistedAsOpml { invariant MenuPersistedAsOpml {
-- meta/menu.opml is the canonical storage format -- meta/menu.opml is the sole persistent store (no DB table).
-- Uses OPML with outline elements for each item -- OPML outline attributes: text (label), type (kind),
-- pageSlug (slug for page/home), categoryName (slug for category_archive).
-- Nested <outline> elements represent submenu children.
parse_opml(read_file("meta/menu.opml")) = menu.items parse_opml(read_file("meta/menu.opml")) = menu.items
} }
rule UpdateMenu { rule LoadMenu {
when: UpdateMenuRequested(menu, items) when: MenuLoadRequested(project_id)
-- Normalizes Home entry: extracts from items, prepends -- Reads meta/menu.opml; if file missing, returns default (home-only) menu.
let without_home = items where kind != home -- Normalizes: strips home entries from body, prepends canonical home.
let home = MenuItem{kind: home, label: "Home"} ensures: MenuLoaded(project_id, normalize(parse_opml_or_empty(project_id)))
ensures: menu.items = build_menu_items(home, without_home) }
ensures: MenuFileWritten(menu)
rule UpdateMenu {
when: UpdateMenuRequested(items)
-- Normalizes Home entry: strips all home items, prepends canonical home.
-- Writes normalized menu back to meta/menu.opml.
let without_home = items where kind != home
ensures: MenuFileWritten(normalize(without_home))
}
rule SyncMenuFromFilesystem {
when: SyncMenuFromFilesystemRequested(project_id)
-- Reloads menu from OPML, normalizes, writes back (round-trip repair).
ensures: MenuLoaded(project_id, _)
ensures: MenuFileWritten(_)
} }

View File

@@ -18,7 +18,7 @@ value Slug {
-- replace [^a-z0-9]+ with hyphens, strip leading/trailing hyphens -- replace [^a-z0-9]+ with hyphens, strip leading/trailing hyphens
-- Transliteration scope: only German (ä/ö/ü/ß/ÄÖÜ) and English letters used. -- Transliteration scope: only German (ä/ö/ü/ß/ÄÖÜ) and English letters used.
-- Verify transliteration matches the established bDS behaviour for this set. -- Verify transliteration matches the established bDS behaviour for this set.
-- Uniqueness: tries base, then {slug}-2 .. {slug}-999, then {slug}-{timestamp} -- Uniqueness: tries base, then {slug}-2, {slug}-3, … (unbounded numeric suffix)
} }
value PostFilePath { value PostFilePath {

View File

@@ -279,7 +279,6 @@ entity AiModel {
max_output_tokens: Integer max_output_tokens: Integer
interleaved: String? -- interleaved capability descriptor interleaved: String? -- interleaved capability descriptor
status: String? -- active | deprecated | preview status: String? -- active | deprecated | preview
provider_package_ref: String? -- provider-specific legacy package reference
updated_at: Timestamp updated_at: Timestamp
} }
@@ -288,7 +287,7 @@ entity AiModelModality {
provider: AiProvider provider: AiProvider
model_id: String model_id: String
direction: String -- "input" | "output" direction: String -- "input" | "output"
modality: String -- "text" | "image" | "audio" | "video" modality: String -- "text" | "image" | "audio" | "file" | "tool"
} }
entity AiCatalogMeta { entity AiCatalogMeta {
@@ -552,7 +551,6 @@ surface AiModelRecordSurface {
model.max_output_tokens model.max_output_tokens
model.interleaved when model.interleaved != null model.interleaved when model.interleaved != null
model.status when model.status != null model.status when model.status != null
model.provider_package_ref when model.provider_package_ref != null
model.updated_at model.updated_at
} }

View File

@@ -20,6 +20,7 @@ enum ScriptStatus {
} }
entity Script { entity Script {
project_id: String
slug: String slug: String
title: String title: String
kind: macro | utility | transform kind: macro | utility | transform
@@ -63,8 +64,8 @@ surface ScriptManagementSurface {
facing _: ScriptOperator facing _: ScriptOperator
provides: provides:
CreateScriptRequested(title, kind, content, entrypoint) CreateScriptRequested(project, title, kind, content, entrypoint)
CreateAndPublishScriptRequested(title, kind, content, entrypoint) CreateAndPublishScriptRequested(project, title, kind, content, entrypoint)
UpdateScriptRequested(script, changes) UpdateScriptRequested(script, changes)
PublishScriptRequested(script) PublishScriptRequested(script)
DeleteScriptRequested(script) DeleteScriptRequested(script)
@@ -113,9 +114,10 @@ surface ScriptRuntimeSurface {
} }
invariant UniqueScriptSlug { invariant UniqueScriptSlug {
-- Slug uniqueness is scoped per project, not globally.
for a in Scripts: for a in Scripts:
for b in Scripts: for b in Scripts:
a != b implies a.slug != b.slug a != b and a.project_id = b.project_id implies a.slug != b.slug
} }
invariant ScriptFileLayout { invariant ScriptFileLayout {
@@ -125,11 +127,12 @@ invariant ScriptFileLayout {
-- Script files use standard --- YAML frontmatter -- Script files use standard --- YAML frontmatter
rule CreateScript { rule CreateScript {
when: CreateScriptRequested(title, kind, content, entrypoint) when: CreateScriptRequested(project, title, kind, content, entrypoint)
let slug = slugify(title) let slug = slugify(title)
-- Creates a draft script: content stored in DB, no file written yet -- Creates a draft script: content stored in DB, no file written yet
ensures: ensures:
let new_script = Script.created( let new_script = Script.created(
project_id: project.id,
slug: slug, slug: slug,
title: title, title: title,
kind: kind, kind: kind,
@@ -160,11 +163,12 @@ rule ReopenPublishedScript {
rule CreateAndPublishScript { rule CreateAndPublishScript {
-- Alternative creation path: create + immediately publish (file written) -- Alternative creation path: create + immediately publish (file written)
-- Some implementations may expose this as a single user action -- Some implementations may expose this as a single user action
when: CreateAndPublishScriptRequested(title, kind, content, entrypoint) when: CreateAndPublishScriptRequested(project, title, kind, content, entrypoint)
let slug = slugify(title) let slug = slugify(title)
requires: ValidateScript(content) = valid requires: ValidateScript(content) = valid
ensures: ensures:
let new_script = Script.created( let new_script = Script.created(
project_id: project.id,
slug: slug, slug: slug,
title: title, title: title,
kind: kind, kind: kind,
@@ -234,7 +238,7 @@ rule ExecuteTransform {
-- Execution uses the same managed job host API contract as other batch -- Execution uses the same managed job host API contract as other batch
-- scripts and may report progress while mass-processing remote or local -- scripts and may report progress while mass-processing remote or local
-- content. -- content.
let transforms = Scripts where kind = transform and enabled = true let transforms = Scripts where project_id = data.project_id and kind = transform and enabled = true
for t in ordered_by(transforms, s => s.updated_at, s => s.slug, s => s.id): for t in ordered_by(transforms, s => s.updated_at, s => s.slug, s => s.id):
requires: t.entrypoint != "" requires: t.entrypoint != ""
ensures: TransformApplied(t, data) ensures: TransformApplied(t, data)

View File

@@ -12,7 +12,7 @@ surface SearchControlSurface {
provides: provides:
SearchPostsRequested(query, filters) SearchPostsRequested(query, filters)
SearchMediaRequested(query) SearchMediaRequested(query, filters)
} }
surface SearchIndexRuntimeSurface { surface SearchIndexRuntimeSurface {
@@ -24,8 +24,9 @@ surface SearchIndexRuntimeSurface {
} }
value StemmerLanguage { value StemmerLanguage {
-- Snowball stemmers for 24 languages -- Snowball stemmers via library (Stemex)
-- ISO 639-1 to Snowball mapping -- Languages with a Snowball algorithm get real stemming;
-- others pass through unstemmed
-- Applied to both indexing and query processing -- Applied to both indexing and query processing
code: String code: String
} }
@@ -38,19 +39,29 @@ surface StemmerLanguageSurface {
} }
entity PostSearchIndex { entity PostSearchIndex {
-- Full-text index projection -- FTS5 virtual table with per-field stemmed columns
-- Indexed fields: title, excerpt, content, tags, categories -- Each field is stemmed independently; translations are
-- Plus all translation titles, excerpts, and content -- stemmed with their own language stemmer and appended
-- to the corresponding field
post: post/Post post: post/Post
stemmed_content: String title: String
excerpt: String
content: String
tags: String
categories: String
} }
entity MediaSearchIndex { entity MediaSearchIndex {
-- Full-text index projection -- FTS5 virtual table with per-field stemmed columns
-- Indexed fields: title, alt, caption, original_name, tags -- Each field is stemmed independently; translations are
-- Plus all translation titles, alts, and captions -- stemmed with their own language stemmer and appended
-- to the corresponding field
media: media/Media media: media/Media
stemmed_content: String title: String
alt: String
caption: String
original_name: String
tags: String
} }
invariant CrossLanguageStemming { invariant CrossLanguageStemming {
@@ -77,42 +88,80 @@ rule SearchPosts {
} }
rule SearchMedia { rule SearchMedia {
when: SearchMediaRequested(query) when: SearchMediaRequested(query, filters)
-- Full-text search with optional filters:
-- language, tags, year, month, date range (from/to)
-- Returns paginated results with total count
let stemmed_query = stem(query, detect_language(query)) let stemmed_query = stem(query, detect_language(query))
let matched = search_fts(MediaSearchIndex, stemmed_query) let matched = search_fts(MediaSearchIndex, stemmed_query, filters)
ensures: SearchResults( ensures: SearchResults(
media: matched media: matched,
total: matched.count,
offset: filters.offset,
limit: filters.limit
) )
} }
rule IndexPost { rule IndexPost {
when: SearchIndexUpdated(post) when: SearchIndexUpdated(post)
-- Stems: title + excerpt + content + tags + categories -- Delete-and-reinsert: no in-place update for FTS5 rows
-- Plus all translations' title + excerpt + content -- Each field is stemmed per-language; translations are stemmed
let all_text = concat_post_text(post) -- with their own language stemmer and joined into the same field
-- Concatenates: post.title, post.excerpt, post.content, let lang = post.language
-- join(post.tags, " "), join(post.categories, " "), let translations = post.translations
-- and all translations' title, excerpt, content let title = join_stemmed(
let index_entry = PostSearchIndex{post: post} stem(post.title, lang),
ensures: for t in translations: stem(t.title, t.language)
if exists index_entry: )
index_entry.stemmed_content = stem(all_text) let excerpt = join_stemmed(
else: stem(post.excerpt, lang),
PostSearchIndex.created(post: post, stemmed_content: stem(all_text)) for t in translations: stem(t.excerpt, t.language)
)
let content = join_stemmed(
stem(post.content, lang),
for t in translations: stem(t.content, t.language)
)
let tags = stem(join(post.tags, " "), lang)
let categories = stem(join(post.categories, " "), lang)
ensures: not exists PostSearchIndex{post: post}
ensures: PostSearchIndex.created(
post: post,
title: title,
excerpt: excerpt,
content: content,
tags: tags,
categories: categories
)
} }
rule IndexMedia { rule IndexMedia {
when: SearchIndexUpdated(media) when: SearchIndexUpdated(media)
-- Stems: title + alt + caption + original_name + tags -- Delete-and-reinsert: no in-place update for FTS5 rows
-- Plus all translations' title, alt, caption -- Each field is stemmed per-language; translations are stemmed
let all_text = concat_media_text(media) -- with their own language stemmer and joined into the same field
-- Concatenates: media.title, media.alt, media.caption, let lang = media.language
-- media.original_name, join(media.tags, " "), let translations = media.translations
-- and all translations' title, alt, caption let title = join_stemmed(
let index_entry = MediaSearchIndex{media: media} stem(media.title, lang),
ensures: for t in translations: stem(t.title, t.language)
if exists index_entry: )
index_entry.stemmed_content = stem(all_text) let alt = join_stemmed(
else: stem(media.alt, lang),
MediaSearchIndex.created(media: media, stemmed_content: stem(all_text)) for t in translations: stem(t.alt, t.language)
)
let caption = join_stemmed(
stem(media.caption, lang),
for t in translations: stem(t.caption, t.language)
)
let original_name = stem(media.original_name, lang)
let tags = stem(join(media.tags, " "), lang)
ensures: not exists MediaSearchIndex{media: media}
ensures: MediaSearchIndex.created(
media: media,
title: title,
alt: alt,
caption: caption,
original_name: original_name,
tags: tags
)
} }

View File

@@ -652,6 +652,9 @@ rule ImportListClick {
-- Full git interface, not the SidebarEntityList pattern. -- Full git interface, not the SidebarEntityList pattern.
-- Three possible states: loading, not_a_repo, active_repo. -- Three possible states: loading, not_a_repo, active_repo.
-- Backend: BDS.Git provides status, diff, commit_all, history,
-- fetch, pull, push, prune_lfs_cache, remote_state, initialize_repo.
-- The sidebar must surface these capabilities directly.
-- State: not_a_repo -- State: not_a_repo
-- Remote URL text input + "Initialize Git" button. -- Remote URL text input + "Initialize Git" button.

View File

@@ -10,6 +10,7 @@ enum TemplateStatus {
} }
entity Template { entity Template {
project_id: String
slug: String slug: String
title: String title: String
kind: post | list | not_found | partial kind: post | list | not_found | partial
@@ -23,8 +24,8 @@ entity Template {
-- Derived -- Derived
content_location: if status = published: file_path else: content content_location: if status = published: file_path else: content
referencing_posts: Posts where template_slug = this.slug referencing_posts: Posts where template_slug = this.slug and project_id = this.project_id
referencing_tags: Tags where post_template_slug = this.slug referencing_tags: Tags where post_template_slug = this.slug and project_id = this.project_id
transitions status { transitions status {
draft -> published draft -> published
@@ -36,8 +37,8 @@ surface TemplateManagementSurface {
facing _: TemplateOperator facing _: TemplateOperator
provides: provides:
CreateTemplateRequested(title, kind, content) CreateTemplateRequested(project, title, kind, content)
CreateAndPublishTemplateRequested(title, kind, content) CreateAndPublishTemplateRequested(project, title, kind, content)
UpdateTemplateRequested(template, changes) UpdateTemplateRequested(template, changes)
PublishTemplateRequested(template) PublishTemplateRequested(template)
DeleteTemplateRequested(template) DeleteTemplateRequested(template)
@@ -45,9 +46,10 @@ surface TemplateManagementSurface {
} }
invariant UniqueTemplateSlug { invariant UniqueTemplateSlug {
-- Slug uniqueness is scoped per project, not globally.
for a in Templates: for a in Templates:
for b in Templates: for b in Templates:
a != b implies a.slug != b.slug a != b and a.project_id = b.project_id implies a.slug != b.slug
} }
invariant TemplateFrontmatter { invariant TemplateFrontmatter {
@@ -85,11 +87,12 @@ invariant RebuildTemplatesIndexesOnlyProjectTemplates {
} }
rule CreateTemplate { rule CreateTemplate {
when: CreateTemplateRequested(title, kind, content) when: CreateTemplateRequested(project, title, kind, content)
let slug = slugify(title) let slug = slugify(title)
-- Creates a draft template: content stored in DB, no file written yet -- Creates a draft template: content stored in DB, no file written yet
ensures: ensures:
let new_template = Template.created( let new_template = Template.created(
project_id: project.id,
slug: slug, slug: slug,
title: title, title: title,
kind: kind, kind: kind,
@@ -105,11 +108,12 @@ rule CreateTemplate {
rule CreateAndPublishTemplate { rule CreateAndPublishTemplate {
-- Alternative creation path: create + immediately publish (file written) -- Alternative creation path: create + immediately publish (file written)
-- Some implementations may expose this as a single user action -- Some implementations may expose this as a single user action
when: CreateAndPublishTemplateRequested(title, kind, content) when: CreateAndPublishTemplateRequested(project, title, kind, content)
let slug = slugify(title) let slug = slugify(title)
requires: ValidateLiquid(content) = valid requires: ValidateLiquid(content) = valid
ensures: ensures:
let new_template = Template.created( let new_template = Template.created(
project_id: project.id,
slug: slug, slug: slug,
title: title, title: title,
kind: kind, kind: kind,

View File

@@ -64,13 +64,16 @@ entity PostTranslation {
} }
} }
invariant TranslationFilesStoreOnlyLanguageSpecificMetadata { invariant TranslationFilesCarryFullMetadata {
-- Translation markdown files persist only fields that differ by language. -- Translation markdown files include status and timestamps alongside
-- Shared metadata such as publication status and timestamps belongs to the -- language-specific fields. This allows each translation to be rebuilt
-- canonical post file and is inherited from the canonical post when -- independently. On rebuild, missing fields fall back to canonical post
-- rebuilding or diffing translation files. -- values for compatibility with legacy files.
for t in PostTranslations where file_path != "": for t in PostTranslations where file_path != "":
translation_file(t).omits_shared_metadata = true translation_file(t).has_fields(
id, translation_for, language, title, excerpt?,
status, created_at, updated_at, published_at
)
} }
surface PostTranslationSurface { surface PostTranslationSurface {