From 1914b05f39d5b6451fc7a3c6bb919c37eb470cf6 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 28 May 2026 13:36:55 +0200 Subject: [PATCH] chore: tend to allium spec to align with code --- .claude/settings.local.json | 3 +- SPECGAPS.md | 43 ++++----- specs/editor_post.allium | 10 +-- specs/editor_settings.allium | 4 +- specs/engine_side_effects.allium | 4 +- specs/frontmatter.allium | 145 +++++++++++++++++-------------- specs/generation.allium | 24 ++++- specs/menu.allium | 55 ++++++++---- specs/post.allium | 2 +- specs/schema.allium | 4 +- specs/script.allium | 16 ++-- specs/search.allium | 125 ++++++++++++++++++-------- specs/sidebar_views.allium | 3 + specs/template.allium | 18 ++-- specs/translation.allium | 15 ++-- 15 files changed, 295 insertions(+), 176 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 98499d1..98f7e99 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,8 @@ "mcp__Claude_in_Chrome__navigate", "mcp__Claude_in_Chrome__computer", "mcp__Claude_in_Chrome__browser_batch", - "mcp__Claude_in_Chrome__javascript_tool" + "mcp__Claude_in_Chrome__javascript_tool", + "Bash(allium check *)" ] } } diff --git a/SPECGAPS.md b/SPECGAPS.md index ea809b7..bd9f44d 100644 --- a/SPECGAPS.md +++ b/SPECGAPS.md @@ -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-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-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) | 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-2 | Template/Script are global entities | template.allium, script.allium | Both have `project_id`, per-project uniqueness | Update spec to per-project scoping | -| A2-3 | TagsFile uses `{tags: [...]}` wrapper | frontmatter.allium:255-273 | Code writes bare array `[...]` | Update spec | -| A2-4 | Sidecar is "YAML-like, not gray-matter" | frontmatter.allium:174 | Code wraps with `---` delimiters | Update spec to gray-matter style | -| A2-5 | Translation frontmatter omits status/timestamps | frontmatter.allium:107-117 | Code writes status, createdAt, updatedAt, publishedAt | Update spec to match written fields | -| A2-6 | Search index has single `stemmed_content` | search.allium:40-54 | FTS5 per-field stemmed columns | Update spec to per-field model | -| A2-7 | Tag archives are single-page | generation.allium:142-147 | Code paginates | Update spec | -| A2-8 | Date archives year+month only | generation.allium:151-159 | Code also generates day-level | Update spec | -| A2-9 | Menu is DB entity | menu.allium:20-26 | Purely file-based OPML, no DB table | Update spec to file-only model | -| A2-10 | Panel tabs: problems, terminal | layout.allium:235-240 | `[:tasks, :output, :post_links, :git_log]` | Update spec | -| A2-11 | Git sidebar: commit input, history, push/pull | sidebar_views.allium | Only "Working tree" item | Mark as partial/TODO in spec | -| A2-12 | Slug timestamp fallback after 999 | post.allium:21 | Unbounded numeric suffix | Update spec or fix code | -| A2-13 | Thumbnail generation is async | engine_side_effects.allium:117 | Synchronous | Update spec or fix code | -| A2-14 | AiModelModality: :video vs :file/:tool | schema.allium:291 | Code has `:file`, `:tool` instead of `:video` | Update spec to :file/:tool | -| A2-15 | JSON key convention: snake_case vs camelCase | frontmatter.allium values | Code uses camelCase for all metadata JSON | Update spec to camelCase | -| 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-17 | `provider_package_ref` on AiModel | schema.allium:282 | Not in code; legacy field not needed | Drop from spec | +| 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 | **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 `[...]` | **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 | **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 | **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 | **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 | **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 | **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 | **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]` | **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 | **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 | **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 | **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` | **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 | **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 | **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 | **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-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-8 | `linkedPostIds` in media sidecar | `lib/bds/media/sidecars.ex:42` | Add to frontmatter.allium MediaSidecar | -| B1-9 | `projectId` in template/script frontmatter | `templates.ex:337`, `scripts.ex:268` | Add to frontmatter.allium | +| B1-8 | ~~`linkedPostIds` in media sidecar~~ | `lib/bds/media/sidecars.ex:42` | **Resolved:** added to MediaSidecar value in frontmatter.allium (with A2-15) | +| B1-9 | ~~`projectId` in template/script frontmatter~~ | `templates.ex:337`, `scripts.ex:268` | **Resolved:** added projectId to TemplateFrontmatter and ScriptFrontmatter in frontmatter.allium (with A2-15) | | B1-10 | Media translation editing modal | `media_editor.html.heex:275-303` | Add to editor_media.allium | | B1-11 | Menu editor drag-drop, indent/unindent/move | `lib/bds/desktop/menu_editor/tree_ops.ex` | Add to editor_misc.allium | | B1-12 | `:language_picker` overlay with flag emojis | `shell_overlay.html.heex:116-139` | Add to modals.allium | @@ -97,8 +98,8 @@ All reconciled to follow code. Specs must be self-consistent and match code. | 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-2 | media.allium SidecarFile mentions `linkedPostIds`; frontmatter.allium MediaSidecar does NOT list it | Code writes `linkedPostIds` → add to frontmatter.allium | Update 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 | Update translation.allium + 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 | **Resolved:** both specs updated (see A2-5) | --- diff --git a/specs/editor_post.allium b/specs/editor_post.allium index d1d2ae2..1d78aa1 100644 --- a/specs/editor_post.allium +++ b/specs/editor_post.allium @@ -19,7 +19,7 @@ value PostEditorView { metadata: PostEditorMetadata metadata_expanded: Boolean -- starts expanded when title is empty excerpt_expanded: Boolean - editor_mode: String -- visual | markdown | preview + editor_mode: String -- markdown | preview footer: PostEditorFooter } @@ -152,15 +152,15 @@ surface PostEditorSurface { -- Collapsible section with textarea (4 rows). @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), -- Insert Post Link, Insert Media. @guarantee EditorModes - -- Visual: rich-text WYSIWYG editor. -- Markdown: code editor with markdown-with-macros language, -- highlighting [[macro ...]] syntax. Word wrap on, minimap off, 14px font. -- Preview: iframe showing rendered preview. + -- Note: visual/WYSIWYG mode not implemented; "visual" normalizes to markdown. @guarantee DragDropImages -- Drop image file onto editor area triggers import chain. @@ -193,8 +193,8 @@ invariant PostDirtyTracking { } invariant PostEditorModePersistence { - -- Editor mode (visual/markdown/preview) persists per session. - -- Default mode comes from editor settings. + -- Editor mode (markdown/preview) persists per session. + -- Default mode comes from editor settings (markdown). } -- ─── Post editor actions ──────────────────────────────────── diff --git a/specs/editor_settings.allium b/specs/editor_settings.allium index b6f1b13..b810d15 100644 --- a/specs/editor_settings.allium +++ b/specs/editor_settings.allium @@ -33,7 +33,7 @@ value SettingsProjectSection { } value SettingsEditorSection { - default_mode: String -- select: wysiwyg | markdown | preview + default_mode: String -- select: markdown | preview diff_view_style: String -- select: inline | side-by-side wrap_long_lines: Boolean -- checkbox hide_unchanged_regions: Boolean -- checkbox @@ -138,7 +138,7 @@ surface SettingsViewSurface { -- Bookmarklet uses project's publicUrl to construct POST endpoint. @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), -- Wrap Long Lines (checkbox), Hide Unchanged Regions (checkbox). diff --git a/specs/engine_side_effects.allium b/specs/engine_side_effects.allium index e3bc34f..e9f212a 100644 --- a/specs/engine_side_effects.allium +++ b/specs/engine_side_effects.allium @@ -114,7 +114,7 @@ rule ImportMediaSideEffects { if media.is_image: ensures: ThumbnailsGenerated(media) -- small=150px, medium=400px, large=800px, ai=448x448 - -- Asynchronous, emits thumbnailsGenerated on completion + -- Synchronous (awaited), logged on error ensures: FTSIndexUpdated(media) } @@ -299,7 +299,7 @@ rule DeleteMediaTranslationSideEffects { -- updatePost | rename only* | yes | if Δ | no | no | yes | no -- publishPost | .md + trans | yes | yes | no | no | yes | 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 -- replaceMediaFile | overwrite | no | no | regen | no | no | no -- deleteMedia | delete all | del | no | del | del all | no | no diff --git a/specs/frontmatter.allium b/specs/frontmatter.allium index 740839d..32967df 100644 --- a/specs/frontmatter.allium +++ b/specs/frontmatter.allium @@ -25,7 +25,7 @@ surface PostFrontmatterSurface { frontmatter.title frontmatter.slug frontmatter.status - frontmatter.published_at + frontmatter.publishedAt frontmatter.tags frontmatter.categories } @@ -35,11 +35,11 @@ surface MediaSidecarSurface { exposes: sidecar.id - sidecar.original_name - sidecar.mime_type + sidecar.originalName + sidecar.mimeType sidecar.width sidecar.height - sidecar.updated_at + sidecar.updatedAt } surface TemplateFrontmatterSurface { @@ -70,8 +70,8 @@ surface MenuOpmlSurface { exposes: document.header.title - document.header.date_created - document.header.date_modified + document.header.dateCreated + document.header.dateModified for item in document.body: item.kind item.label @@ -88,6 +88,7 @@ config { value PostFrontmatter { -- File path: posts/{YYYY}/{MM}/{slug}.md + -- All keys serialized as camelCase in YAML frontmatter id: String -- UUID v4 title: String slug: String @@ -95,25 +96,29 @@ value PostFrontmatter { status: draft | published | archived author: String? -- Only written if present language: String? -- Only written if present (ISO 639-1) - do_not_translate: Boolean -- Only written when true - template_slug: String? -- Only written if present - created_at: Timestamp -- Unix timestamp in milliseconds - updated_at: Timestamp -- Unix timestamp in milliseconds - published_at: Timestamp? -- Only written if published + doNotTranslate: Boolean -- Only written when true + templateSlug: String? -- Only written if present + createdAt: Timestamp -- Unix timestamp in milliseconds + updatedAt: Timestamp -- Unix timestamp in milliseconds + publishedAt: Timestamp? -- Only written if published tags: List -- Always written, even if empty categories: List -- Always written, even if empty } value TranslationFrontmatter { -- File path: posts/{YYYY}/{MM}/{slug}.{language}.md - -- Translation files only store language-specific metadata. - -- Shared publication state and timestamps are inherited from the - -- canonical post file and are not duplicated here. + -- Translation files carry their own publication state and timestamps + -- so that each translation can be rebuilt independently. + -- All keys serialized as camelCase in YAML frontmatter id: String -- UUID v4 - translation_for: String -- Canonical post UUID + translationFor: String -- Canonical post UUID language: String -- ISO 639-1 language code title: String -- Translated title 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 { @@ -121,10 +126,14 @@ surface TranslationFrontmatterSurface { exposes: frontmatter.id - frontmatter.translation_for + frontmatter.translationFor frontmatter.language frontmatter.title frontmatter.excerpt when frontmatter.excerpt != null + frontmatter.status + frontmatter.createdAt + frontmatter.updatedAt + frontmatter.publishedAt } invariant PostFileLayout { @@ -147,9 +156,10 @@ invariant PostTranslationFileLayout { lang: t.language) } -invariant TranslationFilesInheritCanonicalMetadata { - -- Missing status and timestamp fields in translation files are expected. - -- Rebuild and metadata diff must resolve those values from the canonical post. +invariant TranslationFrontmatterRoundtrip { + -- Translation files carry status and timestamps explicitly. + -- 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 != "": parse_frontmatter(read_file(t.file_path)) = translation_frontmatter_fields(t) } @@ -171,11 +181,12 @@ rule WritePostFile { value MediaSidecar { -- File path: {binary_path}.meta (e.g., media/2024/03/a1b2c3d4.jpg.meta) -- 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 + -- All keys serialized as camelCase id: String -- UUID v4 - original_name: String -- Original uploaded filename - mime_type: String + originalName: String -- Original uploaded filename + mimeType: String size: Integer -- Bytes width: Integer? height: Integer? @@ -185,8 +196,9 @@ value MediaSidecar { author: String? -- Only written if present language: String? -- Only written if present tags: List -- Always written, even if empty - created_at: Timestamp - updated_at: Timestamp + linkedPostIds: List -- UUIDs of posts that reference this media + createdAt: Timestamp + updatedAt: Timestamp } invariant MediaSidecarLayout { @@ -200,14 +212,16 @@ invariant MediaSidecarLayout { value TemplateFrontmatter { -- File path: templates/{slug}.liquid + -- All keys serialized as camelCase in YAML frontmatter id: String -- UUID v4 + projectId: String -- Scoped to project slug: String title: String kind: post | list | not_found | partial enabled: Boolean version: Integer - created_at: Timestamp - updated_at: Timestamp + createdAt: Timestamp + updatedAt: Timestamp } rule WriteTemplateFile { @@ -227,15 +241,17 @@ rule WriteTemplateFile { value ScriptFrontmatter { -- File path: scripts/{slug}.{extension} -- YAML frontmatter delimited by --- markers + -- All keys serialized as camelCase in YAML frontmatter id: String -- UUID v4 + projectId: String -- Scoped to project slug: String title: String kind: macro | utility | transform entrypoint: String -- Default: "render" for macros, "main" otherwise enabled: Boolean version: Integer - created_at: Timestamp - updated_at: Timestamp + createdAt: Timestamp + updatedAt: Timestamp } rule WriteScriptFile { @@ -252,24 +268,20 @@ rule WriteScriptFile { -- TAGS FILE FORMAT -- ============================================================================ -value TagsFile { - -- File path: meta/tags.json - -- Portable JSON format (no internal IDs) - tags: List -} - 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 color: String? - post_template_slug: String? + postTemplateSlug: String? } 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) - parse_json(read_file("meta/tags.json")) = { - tags: sort_by(Tags, t => lowercase(t.name)) - } + parse_json(read_file("meta/tags.json")) = + sort_by(tags, t => lowercase(t.name)) } -- ============================================================================ @@ -278,16 +290,17 @@ invariant TagsFileFormat { value ProjectJson { -- File path: meta/project.json + -- All keys serialized as camelCase name: String description: String? - public_url: String? - main_language: String? - default_author: String? - max_posts_per_page: Integer - blogmark_category: String? - pico_theme: String? - semantic_similarity_enabled: Boolean - blog_languages: List + publicUrl: String? + mainLanguage: String? + defaultAuthor: String? + maxPostsPerPage: Integer + blogmarkCategory: String? + picoTheme: String? + semanticSimilarityEnabled: Boolean + blogLanguages: List } value CategoriesJson { @@ -303,18 +316,19 @@ value CategoryMetaJson { } value CategorySettings { - render_in_lists: Boolean - show_title: Boolean - post_template_slug: String? - list_template_slug: String? + renderInLists: Boolean + showTitle: Boolean + postTemplateSlug: String? + listTemplateSlug: String? } value PublishingJson { -- File path: meta/publishing.json - ssh_host: String? - ssh_user: String? - ssh_remote_path: String? - ssh_mode: scp | rsync + -- All keys serialized as camelCase + sshHost: String? + sshUser: String? + sshRemotePath: String? + sshMode: scp | rsync } invariant MetadataFileLayout { @@ -325,7 +339,7 @@ invariant MetadataFileLayout { meta/category-meta.json = serialize(CategoryMetaJson) meta/publishing.json = serialize(PublishingJson) meta/menu.opml = serialize(Menu) - meta/tags.json = serialize(TagsFile) + meta/tags.json = serialize(List) } -- ============================================================================ @@ -341,8 +355,8 @@ value MenuOpml { value OpmlHeader { title: String - date_created: Timestamp - date_modified: Timestamp + dateCreated: Timestamp + dateModified: Timestamp } value MenuItem { @@ -376,6 +390,11 @@ invariant YamlFormatting { -- 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 { -- All file writes are atomic -- Write to temp file first, then rename @@ -390,7 +409,7 @@ invariant RequiredPostFields { -- These fields are ALWAYS written for posts for p in Posts: required_fields(p) = { - id, title, slug, status, created_at, updated_at, + id, title, slug, status, createdAt, updatedAt, tags, categories } } @@ -399,9 +418,9 @@ invariant ConditionalPostFields { -- These fields are ONLY written if truthy for p in Posts: 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 { @@ -409,8 +428,8 @@ invariant RequiredMediaFields { -- Note: 'filename' is NOT a sidecar field — it is the binary path itself for m in Media: required_fields(m) = { - id, original_name, mime_type, size, - created_at, updated_at, tags + id, originalName, mimeType, size, + createdAt, updatedAt, tags } } diff --git a/specs/generation.allium b/specs/generation.allium index 781a872..cdb1f1b 100644 --- a/specs/generation.allium +++ b/specs/generation.allium @@ -143,19 +143,39 @@ rule GenerateTagPages { when: GenerateSiteRequested(generation) requires: tag in generation.sections 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 { when: GenerateSiteRequested(generation) requires: date in generation.sections for year in distinct_years(Posts): + let yp = ceil(posts_in_year(year).count / generation.max_posts_per_page) ensures: FileGenerated(format("{year}/index.html", year: year)) + for page in page_range(2, yp): + ensures: FileGenerated(format("{year}/page/{page}/index.html", + year: year, page: page)) for month in distinct_months(Posts, year): + let mp = ceil(posts_in_month(year, month).count / generation.max_posts_per_page) ensures: FileGenerated(format("{year}/{month}/index.html", year: year, month: month)) + for page in page_range(2, mp): + ensures: FileGenerated(format("{year}/{month}/page/{page}/index.html", + year: year, month: month, page: page)) + for day in distinct_days(Posts, year, month): + let dp = ceil(posts_in_day(year, month, day).count / generation.max_posts_per_page) + ensures: FileGenerated(format("{year}/{month}/{day}/index.html", + year: year, month: month, day: day)) + for page in page_range(2, dp): + ensures: FileGenerated(format("{year}/{month}/{day}/page/{page}/index.html", + year: year, month: month, day: day, page: page)) } -- Template rendering context diff --git a/specs/menu.allium b/specs/menu.allium index e072955..1a9363d 100644 --- a/specs/menu.allium +++ b/specs/menu.allium @@ -1,28 +1,30 @@ -- allium: 1 -- bDS Navigation Menu -- 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 { facing _: MenuOperator provides: - UpdateMenuRequested(menu, items) + MenuLoadRequested(project_id) + UpdateMenuRequested(items) + SyncMenuFromFilesystemRequested(project_id) } value MenuItem { kind: page | submenu | category_archive | home label: String - slug: String? - children: List? -- only for submenu kind + slug: String? -- pageSlug for page/home, categoryName for category_archive + children: List? -- present only for submenu kind } -entity Menu { +value Menu { items: List -- Derived - home_items: items where kind = home - home_entry: home_items.first + home_entry: items.first -- always home after normalization } surface MenuSurface { @@ -30,27 +32,42 @@ surface MenuSurface { exposes: menu.items.count - menu.home_items.count menu.home_entry.label } -invariant HomeAlwaysPresent { - -- The menu always has a Home entry, extracted and prepended +invariant HomeAlwaysFirst { + -- Normalization guarantees home is always the first item. + -- UpdateMenu strips any home entries from input, then prepends one. for menu in Menus: menu.items.first.kind = home } invariant MenuPersistedAsOpml { - -- meta/menu.opml is the canonical storage format - -- Uses OPML with outline elements for each item + -- meta/menu.opml is the sole persistent store (no DB table). + -- OPML outline attributes: text (label), type (kind), + -- pageSlug (slug for page/home), categoryName (slug for category_archive). + -- Nested elements represent submenu children. parse_opml(read_file("meta/menu.opml")) = menu.items } -rule UpdateMenu { - when: UpdateMenuRequested(menu, items) - -- Normalizes Home entry: extracts from items, prepends - let without_home = items where kind != home - let home = MenuItem{kind: home, label: "Home"} - ensures: menu.items = build_menu_items(home, without_home) - ensures: MenuFileWritten(menu) +rule LoadMenu { + when: MenuLoadRequested(project_id) + -- Reads meta/menu.opml; if file missing, returns default (home-only) menu. + -- Normalizes: strips home entries from body, prepends canonical home. + ensures: MenuLoaded(project_id, normalize(parse_opml_or_empty(project_id))) +} + +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(_) } diff --git a/specs/post.allium b/specs/post.allium index cc9e5e3..ba0b767 100644 --- a/specs/post.allium +++ b/specs/post.allium @@ -18,7 +18,7 @@ value Slug { -- replace [^a-z0-9]+ with hyphens, strip leading/trailing hyphens -- Transliteration scope: only German (ä/ö/ü/ß/ÄÖÜ) and English letters used. -- 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 { diff --git a/specs/schema.allium b/specs/schema.allium index d177dec..2449dcb 100644 --- a/specs/schema.allium +++ b/specs/schema.allium @@ -279,7 +279,6 @@ entity AiModel { max_output_tokens: Integer interleaved: String? -- interleaved capability descriptor status: String? -- active | deprecated | preview - provider_package_ref: String? -- provider-specific legacy package reference updated_at: Timestamp } @@ -288,7 +287,7 @@ entity AiModelModality { provider: AiProvider model_id: String direction: String -- "input" | "output" - modality: String -- "text" | "image" | "audio" | "video" + modality: String -- "text" | "image" | "audio" | "file" | "tool" } entity AiCatalogMeta { @@ -552,7 +551,6 @@ surface AiModelRecordSurface { model.max_output_tokens model.interleaved when model.interleaved != null model.status when model.status != null - model.provider_package_ref when model.provider_package_ref != null model.updated_at } diff --git a/specs/script.allium b/specs/script.allium index e32687c..bb585c5 100644 --- a/specs/script.allium +++ b/specs/script.allium @@ -20,6 +20,7 @@ enum ScriptStatus { } entity Script { + project_id: String slug: String title: String kind: macro | utility | transform @@ -63,8 +64,8 @@ surface ScriptManagementSurface { facing _: ScriptOperator provides: - CreateScriptRequested(title, kind, content, entrypoint) - CreateAndPublishScriptRequested(title, kind, content, entrypoint) + CreateScriptRequested(project, title, kind, content, entrypoint) + CreateAndPublishScriptRequested(project, title, kind, content, entrypoint) UpdateScriptRequested(script, changes) PublishScriptRequested(script) DeleteScriptRequested(script) @@ -113,9 +114,10 @@ surface ScriptRuntimeSurface { } invariant UniqueScriptSlug { + -- Slug uniqueness is scoped per project, not globally. for a 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 { @@ -125,11 +127,12 @@ invariant ScriptFileLayout { -- Script files use standard --- YAML frontmatter rule CreateScript { - when: CreateScriptRequested(title, kind, content, entrypoint) + when: CreateScriptRequested(project, title, kind, content, entrypoint) let slug = slugify(title) -- Creates a draft script: content stored in DB, no file written yet ensures: let new_script = Script.created( + project_id: project.id, slug: slug, title: title, kind: kind, @@ -160,11 +163,12 @@ rule ReopenPublishedScript { rule CreateAndPublishScript { -- Alternative creation path: create + immediately publish (file written) -- 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) requires: ValidateScript(content) = valid ensures: let new_script = Script.created( + project_id: project.id, slug: slug, title: title, kind: kind, @@ -234,7 +238,7 @@ rule ExecuteTransform { -- Execution uses the same managed job host API contract as other batch -- scripts and may report progress while mass-processing remote or local -- 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): requires: t.entrypoint != "" ensures: TransformApplied(t, data) diff --git a/specs/search.allium b/specs/search.allium index ba4f4e1..81da2dd 100644 --- a/specs/search.allium +++ b/specs/search.allium @@ -12,7 +12,7 @@ surface SearchControlSurface { provides: SearchPostsRequested(query, filters) - SearchMediaRequested(query) + SearchMediaRequested(query, filters) } surface SearchIndexRuntimeSurface { @@ -24,8 +24,9 @@ surface SearchIndexRuntimeSurface { } value StemmerLanguage { - -- Snowball stemmers for 24 languages - -- ISO 639-1 to Snowball mapping + -- Snowball stemmers via library (Stemex) + -- Languages with a Snowball algorithm get real stemming; + -- others pass through unstemmed -- Applied to both indexing and query processing code: String } @@ -38,19 +39,29 @@ surface StemmerLanguageSurface { } entity PostSearchIndex { - -- Full-text index projection - -- Indexed fields: title, excerpt, content, tags, categories - -- Plus all translation titles, excerpts, and content + -- FTS5 virtual table with per-field stemmed columns + -- Each field is stemmed independently; translations are + -- stemmed with their own language stemmer and appended + -- to the corresponding field post: post/Post - stemmed_content: String + title: String + excerpt: String + content: String + tags: String + categories: String } entity MediaSearchIndex { - -- Full-text index projection - -- Indexed fields: title, alt, caption, original_name, tags - -- Plus all translation titles, alts, and captions + -- FTS5 virtual table with per-field stemmed columns + -- Each field is stemmed independently; translations are + -- stemmed with their own language stemmer and appended + -- to the corresponding field media: media/Media - stemmed_content: String + title: String + alt: String + caption: String + original_name: String + tags: String } invariant CrossLanguageStemming { @@ -77,42 +88,80 @@ rule SearchPosts { } 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 matched = search_fts(MediaSearchIndex, stemmed_query) + let matched = search_fts(MediaSearchIndex, stemmed_query, filters) ensures: SearchResults( - media: matched + media: matched, + total: matched.count, + offset: filters.offset, + limit: filters.limit ) } rule IndexPost { when: SearchIndexUpdated(post) - -- Stems: title + excerpt + content + tags + categories - -- Plus all translations' title + excerpt + content - let all_text = concat_post_text(post) - -- Concatenates: post.title, post.excerpt, post.content, - -- join(post.tags, " "), join(post.categories, " "), - -- and all translations' title, excerpt, content - let index_entry = PostSearchIndex{post: post} - ensures: - if exists index_entry: - index_entry.stemmed_content = stem(all_text) - else: - PostSearchIndex.created(post: post, stemmed_content: stem(all_text)) + -- Delete-and-reinsert: no in-place update for FTS5 rows + -- Each field is stemmed per-language; translations are stemmed + -- with their own language stemmer and joined into the same field + let lang = post.language + let translations = post.translations + let title = join_stemmed( + stem(post.title, lang), + for t in translations: stem(t.title, t.language) + ) + let excerpt = join_stemmed( + stem(post.excerpt, lang), + 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 { when: SearchIndexUpdated(media) - -- Stems: title + alt + caption + original_name + tags - -- Plus all translations' title, alt, caption - let all_text = concat_media_text(media) - -- Concatenates: media.title, media.alt, media.caption, - -- media.original_name, join(media.tags, " "), - -- and all translations' title, alt, caption - let index_entry = MediaSearchIndex{media: media} - ensures: - if exists index_entry: - index_entry.stemmed_content = stem(all_text) - else: - MediaSearchIndex.created(media: media, stemmed_content: stem(all_text)) + -- Delete-and-reinsert: no in-place update for FTS5 rows + -- Each field is stemmed per-language; translations are stemmed + -- with their own language stemmer and joined into the same field + let lang = media.language + let translations = media.translations + let title = join_stemmed( + stem(media.title, lang), + for t in translations: stem(t.title, t.language) + ) + let alt = join_stemmed( + stem(media.alt, lang), + 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 + ) } diff --git a/specs/sidebar_views.allium b/specs/sidebar_views.allium index b160460..00df891 100644 --- a/specs/sidebar_views.allium +++ b/specs/sidebar_views.allium @@ -652,6 +652,9 @@ rule ImportListClick { -- Full git interface, not the SidebarEntityList pattern. -- 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 -- Remote URL text input + "Initialize Git" button. diff --git a/specs/template.allium b/specs/template.allium index a51284b..fc1a80a 100644 --- a/specs/template.allium +++ b/specs/template.allium @@ -10,6 +10,7 @@ enum TemplateStatus { } entity Template { + project_id: String slug: String title: String kind: post | list | not_found | partial @@ -23,8 +24,8 @@ entity Template { -- Derived content_location: if status = published: file_path else: content - referencing_posts: Posts where template_slug = this.slug - referencing_tags: Tags where post_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 and project_id = this.project_id transitions status { draft -> published @@ -36,8 +37,8 @@ surface TemplateManagementSurface { facing _: TemplateOperator provides: - CreateTemplateRequested(title, kind, content) - CreateAndPublishTemplateRequested(title, kind, content) + CreateTemplateRequested(project, title, kind, content) + CreateAndPublishTemplateRequested(project, title, kind, content) UpdateTemplateRequested(template, changes) PublishTemplateRequested(template) DeleteTemplateRequested(template) @@ -45,9 +46,10 @@ surface TemplateManagementSurface { } invariant UniqueTemplateSlug { + -- Slug uniqueness is scoped per project, not globally. for a 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 { @@ -85,11 +87,12 @@ invariant RebuildTemplatesIndexesOnlyProjectTemplates { } rule CreateTemplate { - when: CreateTemplateRequested(title, kind, content) + when: CreateTemplateRequested(project, title, kind, content) let slug = slugify(title) -- Creates a draft template: content stored in DB, no file written yet ensures: let new_template = Template.created( + project_id: project.id, slug: slug, title: title, kind: kind, @@ -105,11 +108,12 @@ rule CreateTemplate { rule CreateAndPublishTemplate { -- Alternative creation path: create + immediately publish (file written) -- 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) requires: ValidateLiquid(content) = valid ensures: let new_template = Template.created( + project_id: project.id, slug: slug, title: title, kind: kind, diff --git a/specs/translation.allium b/specs/translation.allium index f8ebad1..a4a168f 100644 --- a/specs/translation.allium +++ b/specs/translation.allium @@ -64,13 +64,16 @@ entity PostTranslation { } } -invariant TranslationFilesStoreOnlyLanguageSpecificMetadata { - -- Translation markdown files persist only fields that differ by language. - -- Shared metadata such as publication status and timestamps belongs to the - -- canonical post file and is inherited from the canonical post when - -- rebuilding or diffing translation files. +invariant TranslationFilesCarryFullMetadata { + -- Translation markdown files include status and timestamps alongside + -- language-specific fields. This allows each translation to be rebuilt + -- independently. On rebuild, missing fields fall back to canonical post + -- values for compatibility with legacy files. 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 {