chore: tend to allium spec to align with code
This commit is contained in:
@@ -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 *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
SPECGAPS.md
43
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-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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(_)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user