127
specs/action_patterns.allium
Normal file
127
specs/action_patterns.allium
Normal file
@@ -0,0 +1,127 @@
|
||||
-- allium: 1
|
||||
-- bDS Action Patterns and Chains
|
||||
-- Scope: cross-cutting (all waves)
|
||||
-- Distilled from: PostEditor.tsx, MediaEditor.tsx, appStore.ts
|
||||
|
||||
-- Cross-cutting patterns for AI operations, auto-translation,
|
||||
-- drag-and-drop chains, and confirmation dialogs.
|
||||
|
||||
use "./post.allium" as post
|
||||
use "./media.allium" as media
|
||||
|
||||
-- ─── External surfaces ──────────────────────────────────────
|
||||
|
||||
surface UserAction {
|
||||
provides: AISuggestionRequested(entity_type, entity_id)
|
||||
provides: PostSaved(post_id)
|
||||
provides: PostAutoTranslateCompleted(post_id, language)
|
||||
}
|
||||
|
||||
-- ─── AI operation gating ───────────────────────────────
|
||||
|
||||
invariant AIOperationGating {
|
||||
-- All AI operations route through the active endpoint for the current mode.
|
||||
-- See ai.allium AirplaneModeGating for endpoint selection logic.
|
||||
-- If airplane_mode: use airplane endpoint (local model, e.g. Ollama/LM Studio)
|
||||
-- If online: use online endpoint (cloud provider)
|
||||
-- If active endpoint not configured: show toast, abort operation
|
||||
-- Two model categories:
|
||||
-- Title model: text analysis (suggestions, translation, language detect)
|
||||
-- Image model: vision-capable (image analysis only)
|
||||
-- Model selection: per-operation default from settings,
|
||||
-- overrideable per-conversation in chat panel only
|
||||
}
|
||||
|
||||
-- ─── AI suggestions pattern ────────────────────────────
|
||||
|
||||
-- Shared flow used by PostAIAnalysis and MediaAIImageAnalysis.
|
||||
-- See modals.allium AISuggestionsModal value type.
|
||||
|
||||
rule AISuggestionFlow {
|
||||
when: AISuggestionRequested(entity_type, entity_id)
|
||||
-- 1. Check AIOperationGating (abort if offline and no local model)
|
||||
-- 2. Send request to appropriate model:
|
||||
-- Posts: title model, input = title + excerpt + first 2000 chars
|
||||
-- Media: image model, input = 448x448 AI thumbnail JPEG
|
||||
-- 3. Parse response into per-field suggestions
|
||||
-- 4. Open AISuggestionsModal:
|
||||
-- Each field: label, current value, suggested value, accept checkbox
|
||||
-- Accept checkboxes default to true
|
||||
-- Special case: post slug locked (no checkbox) if ever published
|
||||
-- 5. On Confirm: apply only accepted fields to entity
|
||||
-- Posts: triggers auto-save (3s timer reset)
|
||||
-- Media: triggers explicit save
|
||||
-- 6. On Cancel: discard all suggestions, no changes
|
||||
}
|
||||
|
||||
-- ─── Auto-translation chain ────────────────────────────
|
||||
|
||||
rule AutoTranslationChain {
|
||||
when: PostSaved(post_id)
|
||||
-- Gate: AIOperationGating + post.doNotTranslate must be false
|
||||
-- Triggered after any post save (auto-save, manual Ctrl+S, or unmount)
|
||||
-- For each configured blogLanguage missing a translation for this post:
|
||||
-- 1. Enqueue background task: translate metadata (title, excerpt)
|
||||
-- via title model
|
||||
-- 2. Enqueue background task: translate content (full markdown)
|
||||
-- via title model
|
||||
-- 3. Create/update translation record in DB
|
||||
-- Tasks: sequential per language, parallel across languages
|
||||
-- Progress visible in Tasks panel
|
||||
}
|
||||
|
||||
rule MediaMetadataTranslationCascade {
|
||||
when: PostAutoTranslateCompleted(post_id, language)
|
||||
-- After a post translation completes for a given language:
|
||||
-- For each media item linked to this post:
|
||||
-- If media has source language set
|
||||
-- and no translation exists for {language}:
|
||||
-- Enqueue background task: translate media metadata
|
||||
-- (title, alt, caption) via title model
|
||||
-- Creates translated sidecar file: {path}.{lang}.meta
|
||||
}
|
||||
|
||||
-- ─── Drag-and-drop image chain ─────────────────────────
|
||||
|
||||
-- Full chain when image file is dropped on post editor body.
|
||||
-- See editor_post.allium PostDragDropImage for trigger rule.
|
||||
|
||||
-- Synchronous steps (user waits):
|
||||
-- 1. importMedia(file) -> new media record + file copy + base sidecar
|
||||
-- 2. generateThumbnails(media) -> async start (small/medium/large/ai)
|
||||
-- 3. linkMediaToPost(media, post) -> update sidecar linkedPostIds
|
||||
-- 4. insertMarkdownImage(cursor) -> insert  at cursor
|
||||
|
||||
-- Background steps (non-blocking, results auto-applied):
|
||||
-- 5. If AI available: aiImageAnalysis(media)
|
||||
-- Uses image model on 448x448 AI thumbnail
|
||||
-- Results auto-applied to media metadata (NO modal for drag-drop,
|
||||
-- unlike manual Quick Action which shows AISuggestionsModal)
|
||||
-- Triggers sidecar rewrite
|
||||
-- 6. If auto-translate enabled (post.doNotTranslate=false):
|
||||
-- For each blogLanguage: translateMediaMetadata(media, lang)
|
||||
-- Creates translated sidecar files
|
||||
|
||||
-- ─── Confirmation dialog patterns ──────────────────────
|
||||
|
||||
-- Four distinct patterns used across the application:
|
||||
|
||||
-- Pattern 1: System confirm dialog
|
||||
-- Simple yes/no system dialog, no custom UI
|
||||
-- Used by: PostDelete, PostDiscard, TemplateDelete (when references exist)
|
||||
|
||||
-- Pattern 2: ConfirmDeleteModal (custom modal with reference info)
|
||||
-- Shows entity name, reference counts, linked entity list
|
||||
-- Two buttons: Cancel, Delete (destructive red style)
|
||||
-- Used by: MediaDelete (shows linked posts), TagDelete (shows post count)
|
||||
|
||||
-- Pattern 3: ConfirmDialog (custom modal for non-delete confirmations)
|
||||
-- Shows description of action and consequences
|
||||
-- Two buttons: Cancel, Confirm
|
||||
-- Used by: TagMerge ("Merge N tags into {target}? Cannot be undone.")
|
||||
|
||||
-- Pattern 4: No confirmation (immediate execution)
|
||||
-- Action executes on click, no dialog
|
||||
-- Used by: all Rebuild operations, ScriptDelete, MenuItemDelete,
|
||||
-- MetadataDiff per-field sync, ImportExecute, MediaTranslationDelete,
|
||||
-- MediaUnlink
|
||||
217
specs/ai.allium
Normal file
217
specs/ai.allium
Normal file
@@ -0,0 +1,217 @@
|
||||
-- allium: 1
|
||||
-- bDS AI Integration
|
||||
-- Scope: core (one-shot operations), extension Bucket C (chat + streaming)
|
||||
-- Distilled from: src/main/engine/ChatEngine.ts, ai/providers.ts,
|
||||
-- ai/chat.ts, ai/tasks.ts, SecureKeyStore.ts
|
||||
-- The rewrite models AI access as two configurable OpenAI-compatible
|
||||
-- endpoints (online + airplane mode) instead of a fixed named-provider set.
|
||||
|
||||
use "./post.allium" as post
|
||||
use "./media.allium" as media
|
||||
|
||||
entity AiEndpoint {
|
||||
kind: online | airplane
|
||||
url: String
|
||||
api_key: String? -- encrypted via SecureKeyStore; null for local models
|
||||
model: String
|
||||
-- online: cloud provider (OpenAI, Anthropic-via-proxy, etc.)
|
||||
-- airplane: local model (Ollama, LM Studio, etc.)
|
||||
}
|
||||
|
||||
surface AiEndpointSurface {
|
||||
context endpoint: AiEndpoint
|
||||
|
||||
exposes:
|
||||
endpoint.kind
|
||||
endpoint.url
|
||||
endpoint.api_key when endpoint.api_key != null
|
||||
endpoint.model
|
||||
}
|
||||
|
||||
entity SecureKeyStore {
|
||||
-- Encrypts API keys using the host operating system's secure storage.
|
||||
-- Stored in application settings in encrypted form.
|
||||
-- No plain-text fallback
|
||||
}
|
||||
|
||||
surface SecureKeyStoreSurface {
|
||||
context _: SecureKeyStore
|
||||
}
|
||||
|
||||
entity ChatConversation {
|
||||
title: String
|
||||
model: String
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
|
||||
messages: ChatMessage with conversation = this
|
||||
}
|
||||
|
||||
surface ChatConversationSurface {
|
||||
context conversation: ChatConversation
|
||||
|
||||
exposes:
|
||||
conversation.title
|
||||
conversation.model
|
||||
conversation.created_at
|
||||
conversation.updated_at
|
||||
conversation.messages.count
|
||||
}
|
||||
|
||||
entity ChatMessage {
|
||||
conversation: ChatConversation
|
||||
role: system | user | assistant | tool
|
||||
content: String
|
||||
token_usage_input: Integer?
|
||||
token_usage_output: Integer?
|
||||
created_at: Timestamp
|
||||
}
|
||||
|
||||
surface ChatMessageSurface {
|
||||
context message: ChatMessage
|
||||
|
||||
exposes:
|
||||
message.conversation
|
||||
message.role
|
||||
message.content
|
||||
message.token_usage_input when message.token_usage_input != null
|
||||
message.token_usage_output when message.token_usage_output != null
|
||||
message.created_at
|
||||
}
|
||||
|
||||
surface OneShotAiSurface {
|
||||
facing _: AiOperator
|
||||
|
||||
provides:
|
||||
AnalyzeTaxonomyRequested(post)
|
||||
AnalyzeImageRequested(media)
|
||||
AnalyzePostRequested(post)
|
||||
DetectLanguageRequested(text)
|
||||
TranslatePostRequested(post, target_language)
|
||||
TranslateMediaRequested(media, target_language)
|
||||
}
|
||||
|
||||
surface AiChatSurface {
|
||||
facing _: ChatOperator
|
||||
|
||||
provides:
|
||||
StartChatRequested(model)
|
||||
SendChatMessageRequested(conversation, content)
|
||||
RefreshModelCatalogRequested(endpoint)
|
||||
}
|
||||
|
||||
-- One-shot AI tasks (core scope, no streaming)
|
||||
-- All use OpenAI Chat Completions wire format.
|
||||
-- Endpoint routing: see AirplaneModeGating invariant below.
|
||||
-- When no endpoint configured for current mode: disable AI, show toast.
|
||||
|
||||
rule AnalyzeTaxonomy {
|
||||
when: AnalyzeTaxonomyRequested(post)
|
||||
requires: active_endpoint_configured
|
||||
-- Suggests tags and categories for a post
|
||||
ensures: TaxonomySuggestion(tags, categories)
|
||||
}
|
||||
|
||||
rule AnalyzeImage {
|
||||
when: AnalyzeImageRequested(media)
|
||||
requires: active_endpoint_configured
|
||||
requires: is_image(media.mime_type)
|
||||
-- Vision model generates alt text and caption
|
||||
ensures: ImageAnalysisResult(alt, caption)
|
||||
}
|
||||
|
||||
rule AnalyzePost {
|
||||
when: AnalyzePostRequested(post)
|
||||
requires: active_endpoint_configured
|
||||
-- Generates title, excerpt, slug suggestions
|
||||
ensures: PostAnalysisResult(title, excerpt, slug)
|
||||
}
|
||||
|
||||
rule DetectLanguage {
|
||||
when: DetectLanguageRequested(text)
|
||||
requires: active_endpoint_configured
|
||||
ensures: LanguageDetectionResult(language_code)
|
||||
}
|
||||
|
||||
rule TranslatePost {
|
||||
when: TranslatePostRequested(post, target_language)
|
||||
requires: active_endpoint_configured
|
||||
-- Translates title, excerpt, content to target language
|
||||
ensures: TranslationResult(title, excerpt, content)
|
||||
}
|
||||
|
||||
rule TranslateMedia {
|
||||
when: TranslateMediaRequested(media, target_language)
|
||||
requires: active_endpoint_configured
|
||||
-- Translates title, alt, caption to target language
|
||||
ensures: MediaTranslationResult(title, alt, caption)
|
||||
}
|
||||
|
||||
-- Chat (extension Bucket C scope, with streaming and tool use)
|
||||
|
||||
rule StartChat {
|
||||
when: StartChatRequested(model)
|
||||
ensures: ChatConversation.created(
|
||||
title: generated_chat_title(model),
|
||||
model: model,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
)
|
||||
}
|
||||
|
||||
rule SendChatMessage {
|
||||
when: SendChatMessageRequested(conversation, content)
|
||||
requires: active_endpoint_configured
|
||||
ensures: ChatMessage.created(
|
||||
conversation: conversation,
|
||||
role: user,
|
||||
content: content,
|
||||
token_usage_input: null,
|
||||
token_usage_output: null,
|
||||
created_at: now
|
||||
)
|
||||
ensures: conversation.updated_at = now
|
||||
ensures: AiStreamingResponse(conversation)
|
||||
-- Streaming response with bounded tool-call loop.
|
||||
-- Blog data tools for post/media querying during chat.
|
||||
-- Token usage tracking (input, output, cache read/write).
|
||||
}
|
||||
|
||||
-- Model catalog
|
||||
|
||||
rule RefreshModelCatalog {
|
||||
when: RefreshModelCatalogRequested(endpoint)
|
||||
-- Queries the endpoint's model list API
|
||||
-- 5-minute cache TTL
|
||||
ensures: ModelCatalogUpdated(endpoint)
|
||||
}
|
||||
|
||||
invariant AirplaneModeGating {
|
||||
-- Endpoint routing based on airplane (offline) mode:
|
||||
-- airplane_mode = true -> use airplane endpoint (local model)
|
||||
-- airplane_mode = false -> use online endpoint (cloud provider)
|
||||
-- active_endpoint_configured = true iff the endpoint for the
|
||||
-- current mode has a non-empty url (and api_key for online).
|
||||
-- When active endpoint is not configured: AI is unavailable,
|
||||
-- show toast "AI unavailable — configure {online|airplane} endpoint in Settings"
|
||||
}
|
||||
|
||||
invariant TwoEndpointModel {
|
||||
-- Two configurable OpenAI-compatible endpoints:
|
||||
-- online: for cloud providers (requires API key)
|
||||
-- airplane: for local models (no API key required)
|
||||
-- Both use the OpenAI Chat Completions wire format.
|
||||
-- Endpoint selection is configurable rather than tied to hard-coded providers.
|
||||
}
|
||||
|
||||
invariant AiSpecPartitioning {
|
||||
-- This file covers two distinct but related AI contracts:
|
||||
-- 1. Core one-shot operations (taxonomy, vision, translation, language detection)
|
||||
-- 2. Extension chat/model-catalog behaviour
|
||||
-- Both share the same endpoint routing and airplane-mode gating rules.
|
||||
}
|
||||
|
||||
invariant SecureKeyStorage {
|
||||
-- API keys are never stored in plain text
|
||||
-- Always encrypted via host secure storage before persistence
|
||||
}
|
||||
84
specs/bds.allium
Normal file
84
specs/bds.allium
Normal file
@@ -0,0 +1,84 @@
|
||||
-- allium: 1
|
||||
-- bDS (Blogging Desktop Server) — Axiom Specification
|
||||
-- Distilled from the existing bDS application at ../bDS/
|
||||
-- This is the behavioural baseline for the rewrite effort.
|
||||
|
||||
-- An offline-first desktop application for blog authoring with
|
||||
-- static site generation, SSH publishing, AI integration, and
|
||||
-- external tool integration via MCP.
|
||||
|
||||
-- Core domain
|
||||
use "./project.allium" as project -- Multi-project management
|
||||
use "./post.allium" as post -- Post lifecycle, frontmatter, file layout
|
||||
use "./media.allium" as media -- Media import, thumbnails, sidecars
|
||||
use "./translation.allium" as translation -- Post and media translations
|
||||
use "./tag.allium" as tag -- Tags with mass operations
|
||||
use "./template.allium" as template -- Liquid template management
|
||||
use "./script.allium" as script -- Scripting (macros, utilities, transforms)
|
||||
use "./menu.allium" as menu -- OPML navigation menu
|
||||
use "./metadata.allium" as metadata -- Project config, categories, publishing prefs
|
||||
|
||||
-- Infrastructure
|
||||
use "./search.allium" as search -- FTS5 full-text search with Snowball stemming
|
||||
use "./generation.allium" as generation -- Static site generation (sections, routes, hashing)
|
||||
use "./preview.allium" as preview -- Local HTTP preview server
|
||||
use "./publishing.allium" as publishing -- SSH upload (SCP / rsync)
|
||||
use "./task.allium" as task -- Background task manager
|
||||
use "./i18n.allium" as i18n -- Split localization (UI vs content)
|
||||
|
||||
-- UI
|
||||
use "./layout.allium" as layout -- App shell, activity bar, status bar, panels
|
||||
use "./tabs.allium" as tabs -- Tab system, editor routing, preview/pin
|
||||
use "./sidebar_views.allium" as sidebar -- 10 sidebar views, content, behaviour
|
||||
use "./modals.allium" as modals -- Shared modals (AI suggestions, confirm, gallery)
|
||||
use "./editor_post.allium" as editor_post -- Post editor view and actions
|
||||
use "./editor_media.allium" as editor_media -- Media editor view and actions
|
||||
use "./editor_settings.allium" as editor_settings -- Settings + style views
|
||||
use "./editor_tags.allium" as editor_tags -- Tags view and colour picker
|
||||
use "./editor_chat.allium" as editor_chat -- Chat panel and model selector
|
||||
use "./editor_script.allium" as editor_script -- Script editor
|
||||
use "./editor_template.allium" as editor_template -- Template editor
|
||||
use "./editor_misc.allium" as editor_misc -- Dashboard, menu, metadata diff, git diff, etc.
|
||||
|
||||
-- Flows and side-effects
|
||||
use "./ui_data_flow.allium" as ui_data_flow -- Sidebar/editor/tab reactive coordination
|
||||
use "./engine_side_effects.allium" as engine_side_effects -- CRUD side-effect chains
|
||||
use "./action_patterns.allium" as action_patterns -- AI gating, translation chains, confirmations
|
||||
|
||||
-- Integration
|
||||
use "./git.allium" as git -- Git operations, LFS, reconciliation
|
||||
use "./mcp.allium" as mcp -- MCP server (tools, resources, proposals)
|
||||
use "./ai.allium" as ai -- AI one-shot tasks and chat
|
||||
use "./embedding.allium" as embedding -- Semantic similarity (HNSW vectors)
|
||||
use "./cli_sync.allium" as cli_sync -- CLI-to-app notification sync
|
||||
use "./metadata_diff.allium" as metadata_diff -- DB/filesystem diff and rebuild
|
||||
|
||||
-- Compatibility contract
|
||||
--
|
||||
-- MUST stay identical:
|
||||
-- persistence semantics, post markdown frontmatter,
|
||||
-- translation file naming, media sidecars, thumbnail conventions,
|
||||
-- template file formats, menu OPML, generated routes/feeds/sitemaps,
|
||||
-- full-text search behaviour, slug generation, metadata diff,
|
||||
-- rebuild-from-filesystem
|
||||
--
|
||||
-- MAY change intentionally:
|
||||
-- implementation language, desktop container, UI framework,
|
||||
-- editor implementation, internal process model, runtime libraries
|
||||
|
||||
-- Resolved questions:
|
||||
--
|
||||
-- 1. Slug generation scope: only German and English letters are used.
|
||||
-- Verify transliteration preserves the established bDS behaviour for
|
||||
-- ä/ö/ü/ß/ÄÖÜ.
|
||||
--
|
||||
-- 2. Liquid subset: see template.allium for the exact subset.
|
||||
-- Only 5 tags, 4 standard filters, 2 custom filters, 5 operators.
|
||||
-- .size is property access on arrays, NOT a pipe filter.
|
||||
--
|
||||
-- 3. Macro calling convention: [[macroslug param1=value1 ...]]
|
||||
-- Double-bracket syntax, not Liquid tags. Identical to current app.
|
||||
--
|
||||
-- 4. AI provider model: the rewrite uses two configurable OpenAI-compatible
|
||||
-- endpoints (online + airplane mode) rather than a fixed named-provider set.
|
||||
-- See ai.allium for details.
|
||||
68
specs/cli_sync.allium
Normal file
68
specs/cli_sync.allium
Normal file
@@ -0,0 +1,68 @@
|
||||
-- allium: 1
|
||||
-- bDS CLI / App Notification Sync
|
||||
-- Scope: extension (Bucket G — MCP + Automation)
|
||||
-- Distilled from: src/main/engine/CliNotifier.ts, NotificationWatcher.ts
|
||||
|
||||
entity DbNotification {
|
||||
entity_type: String -- post, media, script, template
|
||||
entity_id: String
|
||||
action: created | updated | deleted
|
||||
from_cli: Boolean
|
||||
seen_at: Timestamp?
|
||||
created_at: Timestamp
|
||||
|
||||
-- Derived
|
||||
is_processed: seen_at != null
|
||||
}
|
||||
|
||||
surface CliSyncRuntimeSurface {
|
||||
facing _: CliSyncRuntime
|
||||
|
||||
provides:
|
||||
CliMutationPerformed(entity_type, entity_id, action)
|
||||
DbFileChangeDetected()
|
||||
}
|
||||
|
||||
rule CliWriteNotification {
|
||||
when: CliMutationPerformed(entity_type, entity_id, action)
|
||||
-- CLI inserts notification row; app watches for it
|
||||
ensures: DbNotification.created(
|
||||
entity_type: entity_type,
|
||||
entity_id: entity_id,
|
||||
action: action,
|
||||
from_cli: true,
|
||||
seen_at: null
|
||||
)
|
||||
}
|
||||
|
||||
rule AppWatchNotifications {
|
||||
when: DbFileChangeDetected()
|
||||
-- Watches the persisted notification store for external mutations
|
||||
-- Debounced at 100ms
|
||||
let unseen = DbNotifications where seen_at = null and from_cli = true
|
||||
for n in unseen:
|
||||
ensures: n.seen_at = now
|
||||
ensures: EngineCacheInvalidated(n.entity_type)
|
||||
ensures: EntityChangedEvent(n.entity_type, n.entity_id, n.action)
|
||||
-- IPC event to renderer for UI refresh
|
||||
}
|
||||
|
||||
rule PruneProcessedNotifications {
|
||||
when: n: DbNotification.created_at + 1.hour <= now
|
||||
requires: n.is_processed
|
||||
-- Processed rows: prune after 1 hour
|
||||
ensures: not exists n
|
||||
}
|
||||
|
||||
rule PruneUnprocessedNotifications {
|
||||
when: n: DbNotification.created_at + 24.hours <= now
|
||||
requires: not n.is_processed
|
||||
-- Unprocessed rows: prune after 24 hours
|
||||
ensures: not exists n
|
||||
}
|
||||
|
||||
invariant AppNoopNotifier {
|
||||
-- The desktop application uses a no-op notifier for its own writes
|
||||
-- It already knows about its own mutations
|
||||
-- Only CLI writes produce notification rows
|
||||
}
|
||||
144
specs/editor_chat.allium
Normal file
144
specs/editor_chat.allium
Normal file
@@ -0,0 +1,144 @@
|
||||
-- allium: 1
|
||||
-- bDS Chat Panel
|
||||
-- Scope: UI content area — AI chat surface
|
||||
-- Distilled from: ChatPanel.tsx
|
||||
|
||||
-- Describes the layout and behaviour of the chat panel.
|
||||
|
||||
use "./i18n.allium" as i18n
|
||||
|
||||
-- ─── Chat panel ───────────────────────────────────────────────
|
||||
|
||||
value ChatPanelView {
|
||||
conversation_id: String?
|
||||
needs_api_key: Boolean
|
||||
title: String -- conversation title or "New Chat"
|
||||
selected_model_id: String?
|
||||
messages: List<ChatMessage>
|
||||
is_streaming: Boolean
|
||||
input_text: String
|
||||
}
|
||||
|
||||
value ChatMessage {
|
||||
role: String -- user | assistant | system
|
||||
content: String -- user: plain text; assistant: GFM markdown
|
||||
tool_markers: List<ToolMarker>
|
||||
is_streaming: Boolean -- true while accumulating
|
||||
}
|
||||
|
||||
value ToolMarker {
|
||||
tool_name: String
|
||||
args_preview: String -- string args truncated to config.chat_tool_args_max_length
|
||||
is_complete: Boolean -- checkmark when done, dot when in-progress
|
||||
}
|
||||
|
||||
value ModelSelectorDropdown {
|
||||
groups: List<ModelProviderGroup>
|
||||
selected_model_id: String?
|
||||
}
|
||||
|
||||
surface ModelSelectorDropdownSurface {
|
||||
context dropdown: ModelSelectorDropdown
|
||||
|
||||
exposes:
|
||||
dropdown.selected_model_id when dropdown.selected_model_id != null
|
||||
for group in dropdown.groups:
|
||||
group.provider_name
|
||||
for model in group.models:
|
||||
model.model_id
|
||||
model.display_name
|
||||
model.context_window
|
||||
model.max_output_tokens
|
||||
}
|
||||
|
||||
value ModelProviderGroup {
|
||||
provider_name: String -- e.g. "OpenAI", "Ollama", "LM Studio"
|
||||
models: List<ModelEntry>
|
||||
}
|
||||
|
||||
value ModelEntry {
|
||||
model_id: String
|
||||
display_name: String
|
||||
context_window: Integer
|
||||
max_output_tokens: Integer
|
||||
}
|
||||
|
||||
config {
|
||||
chat_tool_args_max_length: Integer = 30
|
||||
chat_input_max_height: Integer = 200
|
||||
}
|
||||
|
||||
surface ChatPanelSurface {
|
||||
context panel: ChatPanelView
|
||||
|
||||
exposes:
|
||||
panel.needs_api_key
|
||||
panel.title
|
||||
panel.selected_model_id
|
||||
panel.is_streaming
|
||||
panel.input_text
|
||||
for msg in panel.messages:
|
||||
msg.role
|
||||
msg.content
|
||||
msg.is_streaming
|
||||
for tm in msg.tool_markers:
|
||||
tm.tool_name
|
||||
tm.args_preview
|
||||
tm.is_complete
|
||||
|
||||
provides:
|
||||
ChatSendMessage(panel.conversation_id, panel.input_text)
|
||||
when panel.input_text != "" and not panel.is_streaming
|
||||
ChatAbortStreaming(panel.conversation_id)
|
||||
when panel.is_streaming
|
||||
ChatSelectModel(panel.conversation_id, model_id)
|
||||
ChatOpenSettings()
|
||||
when panel.needs_api_key
|
||||
|
||||
@guarantee ApiKeyRequiredScreen
|
||||
-- Shown when needs_api_key is true.
|
||||
-- Key icon, title, description text, "Open Settings" button.
|
||||
-- No chat functionality available until API key is set.
|
||||
|
||||
@guarantee HeaderLayout
|
||||
-- Left: conversation title (or "New Chat"), CSS ellipsis on overflow.
|
||||
-- Right: model selector button opening dropdown.
|
||||
|
||||
@guarantee ModelSelectorDropdown
|
||||
-- Dropdown groups models by provider (section headers).
|
||||
-- Each entry: model display name.
|
||||
-- Expandable details: context window, max output tokens.
|
||||
-- Selection is per-conversation override, persisted with conversation.
|
||||
-- Changing model mid-conversation applies to subsequent messages only.
|
||||
|
||||
@guarantee WelcomeScreen
|
||||
-- Shown when no messages and not streaming.
|
||||
-- Robot icon, title, description, 5 tip bullet points.
|
||||
|
||||
@guarantee MessageRendering
|
||||
-- User messages: plain text.
|
||||
-- Assistant messages: rendered as GFM Markdown.
|
||||
-- External images blocked (CSP), shown as links.
|
||||
-- Tool markers: checkmark (done) or dot (in-progress) icon,
|
||||
-- tool name, args truncated to config.chat_tool_args_max_length for strings.
|
||||
-- Streaming: accumulating markdown + tool markers, thinking dots animation.
|
||||
|
||||
@guarantee AutoScroll
|
||||
-- Message area auto-scrolls to bottom on new messages.
|
||||
|
||||
@guarantee InputArea
|
||||
-- Abort/Stop button: square stop icon, visible only during streaming.
|
||||
-- Auto-growing textarea: max config.chat_input_max_height px.
|
||||
-- Enter sends message. Shift+Enter inserts newline.
|
||||
-- Send button: up-arrow icon, disabled when input is empty or streaming.
|
||||
|
||||
@guarantee AssistantActionDispatch
|
||||
-- Assistant tool calls can trigger navigation actions:
|
||||
-- open_post(id), open_media(id), open_settings(), etc.
|
||||
-- Actions dispatched through store, same as user clicks.
|
||||
-- Navigation actions open tabs with pin intent.
|
||||
|
||||
@guarantee TokenTracking
|
||||
-- Token usage tracked per conversation.
|
||||
-- Displayed in status bar, not in the chat panel itself.
|
||||
}
|
||||
234
specs/editor_media.allium
Normal file
234
specs/editor_media.allium
Normal file
@@ -0,0 +1,234 @@
|
||||
-- allium: 1
|
||||
-- bDS Media Editor View
|
||||
-- Scope: UI content area — media editing surface
|
||||
-- Distilled from: MediaEditor.tsx
|
||||
|
||||
-- Describes the layout and behaviour of the media editor rendered in
|
||||
-- the main content area when a media tab is active.
|
||||
|
||||
use "./media.allium" as media
|
||||
use "./i18n.allium" as i18n
|
||||
|
||||
-- ─── Media editor ─────────────────────────────────────────────
|
||||
|
||||
value MediaEditorView {
|
||||
media_id: String
|
||||
is_image: Boolean
|
||||
file_name: String -- originalName, read-only
|
||||
mime_type: String -- read-only
|
||||
file_size: String -- formatted, read-only
|
||||
dimensions: String? -- "W x H" if width/height exist, read-only
|
||||
title: String? -- editable text input
|
||||
alt_text: String? -- editable text input
|
||||
caption: String? -- editable textarea (3 rows)
|
||||
tags: String? -- comma-separated text input
|
||||
author: String? -- editable text input
|
||||
language: String? -- select from supported languages
|
||||
translations: List<MediaTranslationItem>
|
||||
linked_posts: List<LinkedPostItem>
|
||||
}
|
||||
|
||||
value MediaTranslationItem {
|
||||
language: String
|
||||
flag_emoji: String
|
||||
title: String?
|
||||
alt_text: String?
|
||||
caption: String?
|
||||
}
|
||||
|
||||
value LinkedPostItem {
|
||||
post_id: String
|
||||
title: String
|
||||
}
|
||||
|
||||
value PostPickerOverlay {
|
||||
search_query: String
|
||||
results: List<PostPickerResult>
|
||||
overflow_count: Integer? -- shown as "and N more" if > 0
|
||||
}
|
||||
|
||||
surface PostPickerOverlaySurface {
|
||||
context overlay: PostPickerOverlay
|
||||
|
||||
exposes:
|
||||
overlay.search_query
|
||||
for result in overlay.results:
|
||||
result.post_id
|
||||
result.title
|
||||
overlay.overflow_count when overlay.overflow_count != null
|
||||
}
|
||||
|
||||
value PostPickerResult {
|
||||
post_id: String
|
||||
title: String
|
||||
}
|
||||
|
||||
config {
|
||||
media_post_picker_max_results: Integer = 10
|
||||
}
|
||||
|
||||
surface MediaEditorSurface {
|
||||
context editor: MediaEditorView
|
||||
|
||||
exposes:
|
||||
editor.file_name
|
||||
editor.mime_type
|
||||
editor.file_size
|
||||
editor.dimensions when editor.dimensions != null
|
||||
editor.is_image
|
||||
editor.title
|
||||
editor.alt_text
|
||||
editor.caption
|
||||
editor.tags
|
||||
editor.author
|
||||
editor.language
|
||||
for t in editor.translations:
|
||||
t.language
|
||||
t.flag_emoji
|
||||
t.title
|
||||
for lp in editor.linked_posts:
|
||||
lp.post_id
|
||||
lp.title
|
||||
|
||||
provides:
|
||||
MediaAIImageAnalysisRequested(editor.media_id)
|
||||
when editor.is_image
|
||||
MediaDetectLanguageRequested(editor.media_id)
|
||||
MediaTranslateMetadataRequested(editor.media_id, target_language)
|
||||
MediaReplaceFileRequested(editor.media_id)
|
||||
MediaSaveRequested(editor.media_id)
|
||||
MediaDeleteRequested(editor.media_id)
|
||||
MediaLinkToPostRequested(editor.media_id)
|
||||
MediaTranslationEditClicked(editor.media_id, language)
|
||||
MediaTranslationRefreshClicked(editor.media_id, language)
|
||||
MediaTranslationDeleteClicked(editor.media_id, language)
|
||||
MediaUnlinkPostRequested(editor.media_id, post_id)
|
||||
|
||||
@guarantee HeaderLayout
|
||||
-- Header bar with media display name.
|
||||
-- Actions (right side): Quick Actions dropdown, Replace File button,
|
||||
-- Save button, Delete button (danger style).
|
||||
|
||||
@guarantee QuickActionsDropdown
|
||||
-- Dropdown menu in header with three entries:
|
||||
-- AI Image Analysis (robot icon) — only shown for image/* MIME types.
|
||||
-- Detect Language (magnifier icon).
|
||||
-- Translate Metadata (globe icon).
|
||||
|
||||
@guarantee PreviewArea
|
||||
-- Images: rendered via bds-media:// protocol with cache-busting timestamp.
|
||||
-- Non-images: SVG file icon placeholder + original filename text.
|
||||
|
||||
@guarantee MetadataForm
|
||||
-- Form fields in order:
|
||||
-- File Name (disabled input), MIME Type (disabled input),
|
||||
-- Size + Dimensions row (disabled inputs),
|
||||
-- Title (text input), Alt Text (text input), Caption (textarea, 3 rows),
|
||||
-- Tags (comma-separated text input), Author (text input),
|
||||
-- Language (select from supported languages).
|
||||
|
||||
@guarantee TranslationsSection
|
||||
-- Shown only when language is set.
|
||||
-- List of existing translations: flag emoji + language name + title.
|
||||
-- Per-translation actions: click to edit inline, refresh button, delete button.
|
||||
-- "No translations" message when list is empty.
|
||||
|
||||
@guarantee LinkedPostsSection
|
||||
-- "Link to Post" button opens inline post picker overlay.
|
||||
-- List of currently linked posts with document icon. Click navigates to post tab.
|
||||
-- Per-post unlink button (×).
|
||||
-- "Not linked to any posts" message when list is empty.
|
||||
|
||||
@guarantee PostPickerOverlay
|
||||
-- Inline overlay positioned near "Link to Post" button (not a modal).
|
||||
-- Search input filtering unlinked posts by title.
|
||||
-- Up to config.media_post_picker_max_results results displayed.
|
||||
-- "and N more" text when total exceeds limit.
|
||||
-- Click result: links media to selected post, closes overlay.
|
||||
|
||||
@guarantee NoAutoSave
|
||||
-- Unlike the post editor, media editor requires explicit Save button.
|
||||
}
|
||||
|
||||
-- ─── Media editor actions ─────────────────────────────────────
|
||||
|
||||
rule MediaAIImageAnalysis {
|
||||
when: MediaAIImageAnalysisRequested(media_id)
|
||||
-- Gate: airplane mode check (see action_patterns.allium AIOperationGating)
|
||||
-- Only available for image/* MIME types (button hidden for non-images)
|
||||
-- Uses image analysis model (vision-capable, not title model)
|
||||
-- Input: AI-optimized JPEG thumbnail (448x448, generated on import)
|
||||
-- Response: suggested title, alt text, caption
|
||||
-- Opens AISuggestionsModal with 3 fields (title, alt, caption)
|
||||
-- On confirm: applies checked fields, triggers explicit save
|
||||
}
|
||||
|
||||
rule MediaDetectLanguage {
|
||||
when: MediaDetectLanguageRequested(media_id)
|
||||
-- Gate: airplane mode check
|
||||
-- Input: concatenation of title + alt + caption text
|
||||
-- Response: detected language code
|
||||
-- Immediately persists to media record (no modal, no confirmation)
|
||||
-- Triggers sidecar rewrite
|
||||
}
|
||||
|
||||
rule MediaTranslateMetadata {
|
||||
when: MediaTranslateMetadataRequested(media_id, target_language)
|
||||
-- Gate: airplane mode check
|
||||
-- Opens language picker modal (same pattern as post translate)
|
||||
-- Two-step process:
|
||||
-- 1. If source language not set: detect it first (auto-persist)
|
||||
-- 2. Translate title, alt, caption to target language via title model
|
||||
-- Creates/updates media translation record
|
||||
-- Writes translated sidecar file: {path}.{lang}.meta
|
||||
}
|
||||
|
||||
rule MediaReplaceFile {
|
||||
when: MediaReplaceFileRequested(media_id)
|
||||
-- Opens native file dialog (no MIME type filter)
|
||||
-- Copies selected file over existing media file path
|
||||
-- If image: regenerates thumbnails synchronously (awaited)
|
||||
-- Preview area updates with cache-busting timestamp query param
|
||||
}
|
||||
|
||||
rule MediaDeleteAction {
|
||||
when: MediaDeleteRequested(media_id)
|
||||
-- Opens ConfirmDeleteModal (custom modal, not native dialog)
|
||||
-- Shows: media display name, linked posts count and list
|
||||
-- Two buttons: Cancel, Delete (destructive red style)
|
||||
-- On confirm: deletes file, sidecar, thumbnails, all translations,
|
||||
-- post-media links, FTS index entry
|
||||
-- Closes media tab, sidebar removes item
|
||||
-- See engine_side_effects.allium DeleteMediaSideEffects
|
||||
}
|
||||
|
||||
rule MediaLinkToPost {
|
||||
when: MediaLinkToPostRequested(media_id)
|
||||
-- Opens inline post picker overlay (not a modal, positioned near button)
|
||||
-- Search input filtering unlinked posts by title
|
||||
-- Up to config.media_post_picker_max_results results shown
|
||||
-- "and N more" text if results exceed limit
|
||||
-- Click links media to selected post (updates sidecar linkedPostIds)
|
||||
-- Linked posts list refreshes immediately
|
||||
}
|
||||
|
||||
rule MediaTranslationEdit {
|
||||
when: MediaTranslationEditClicked(media_id, language)
|
||||
-- Loads translation fields inline (title, alt, caption) for that language
|
||||
-- Edit in place, save persists to translated sidecar {path}.{lang}.meta
|
||||
}
|
||||
|
||||
rule MediaTranslationRefresh {
|
||||
when: MediaTranslationRefreshClicked(media_id, language)
|
||||
-- Gate: airplane mode check
|
||||
-- Re-translates from source language to target via title model
|
||||
-- Overwrites existing translation fields
|
||||
-- Rewrites translated sidecar file
|
||||
}
|
||||
|
||||
rule MediaTranslationDelete {
|
||||
when: MediaTranslationDeleteClicked(media_id, language)
|
||||
-- Deletes translation record from DB
|
||||
-- Deletes translated sidecar file: {path}.{lang}.meta
|
||||
-- No confirmation dialog
|
||||
}
|
||||
786
specs/editor_misc.allium
Normal file
786
specs/editor_misc.allium
Normal file
@@ -0,0 +1,786 @@
|
||||
-- allium: 1
|
||||
-- bDS Miscellaneous Editor Views
|
||||
-- Scope: UI content area — dashboard, menu editor, metadata diff, git diff,
|
||||
-- documentation, validation, find duplicates, import analysis
|
||||
-- Distilled from: Editor.tsx (Dashboard), MenuEditorView.tsx,
|
||||
-- MetadataDiffPanel.tsx, ImportAnalysisView.tsx
|
||||
|
||||
-- Describes the layout and behaviour of smaller editor views that don't
|
||||
-- warrant their own spec file.
|
||||
|
||||
use "./tabs.allium" as tabs
|
||||
use "./i18n.allium" as i18n
|
||||
use "./generation.allium" as generation
|
||||
|
||||
-- ─── Dashboard (no tab active) ───────────────────────────────
|
||||
|
||||
-- Shown as default/welcome view when no entity tab is active.
|
||||
|
||||
value Dashboard {
|
||||
title: String
|
||||
subtitle: String
|
||||
stats: DashboardStats
|
||||
timeline: DashboardTimeline
|
||||
tag_cloud: DashboardTagCloud
|
||||
category_cloud: DashboardCategoryCloud
|
||||
recent_posts: List<DashboardRecentPost>
|
||||
}
|
||||
|
||||
value DashboardStats {
|
||||
total_posts: Integer
|
||||
published_count: Integer
|
||||
draft_count: Integer
|
||||
archived_count: Integer -- shown only if > 0
|
||||
media_count: Integer
|
||||
image_count: Integer
|
||||
total_media_size: String -- formatted B/KB/MB/GB
|
||||
tag_count: Integer
|
||||
category_count: Integer
|
||||
}
|
||||
|
||||
value DashboardTimeline {
|
||||
months: List<DashboardTimelineMonth>
|
||||
}
|
||||
|
||||
value DashboardTimelineMonth {
|
||||
label: String -- month abbreviation
|
||||
year: Integer
|
||||
count: Integer
|
||||
}
|
||||
|
||||
value DashboardTagCloud {
|
||||
tags: List<DashboardTag>
|
||||
overflow_count: Integer? -- "and N more" when > config.dashboard_max_tags
|
||||
}
|
||||
|
||||
value DashboardTag {
|
||||
name: String
|
||||
count: Integer
|
||||
color: String?
|
||||
}
|
||||
|
||||
value DashboardCategoryCloud {
|
||||
categories: List<DashboardCategory>
|
||||
}
|
||||
|
||||
value DashboardCategory {
|
||||
name: String
|
||||
count: Integer
|
||||
}
|
||||
|
||||
value DashboardRecentPost {
|
||||
post_id: String
|
||||
title: String -- "Untitled" as fallback
|
||||
status: String -- draft | published
|
||||
date: String -- locale-formatted
|
||||
}
|
||||
|
||||
config {
|
||||
dashboard_max_tags: Integer = 40
|
||||
dashboard_tag_min_font: Integer = 11
|
||||
dashboard_tag_max_font: Integer = 22
|
||||
dashboard_recent_count: Integer = 5
|
||||
dashboard_timeline_months: Integer = 12
|
||||
}
|
||||
|
||||
surface DashboardSurface {
|
||||
context dash: Dashboard
|
||||
|
||||
exposes:
|
||||
dash.title
|
||||
dash.subtitle
|
||||
dash.stats.total_posts
|
||||
dash.stats.published_count
|
||||
dash.stats.draft_count
|
||||
dash.stats.archived_count when dash.stats.archived_count > 0
|
||||
dash.stats.media_count
|
||||
dash.stats.image_count
|
||||
dash.stats.total_media_size
|
||||
dash.stats.tag_count
|
||||
dash.stats.category_count
|
||||
for m in dash.timeline.months:
|
||||
m.label
|
||||
m.year
|
||||
m.count
|
||||
for t in dash.tag_cloud.tags:
|
||||
t.name
|
||||
t.count
|
||||
t.color
|
||||
dash.tag_cloud.overflow_count when dash.tag_cloud.overflow_count != null
|
||||
for c in dash.category_cloud.categories:
|
||||
c.name
|
||||
c.count
|
||||
for rp in dash.recent_posts:
|
||||
rp.title
|
||||
rp.status
|
||||
rp.date
|
||||
|
||||
provides:
|
||||
DashboardRecentPostClicked(post_id, single)
|
||||
DashboardRecentPostClicked(post_id, double)
|
||||
|
||||
@guarantee StatCards
|
||||
-- Three stat cards side by side.
|
||||
-- Posts card: total number, breakdown tags (published/drafts/archived if > 0).
|
||||
-- Media card: count, images count, total size (formatted bytes).
|
||||
-- Tags card: count, categories count.
|
||||
|
||||
@guarantee TimelineChart
|
||||
-- Bar chart of posts over last config.dashboard_timeline_months months that have data.
|
||||
-- Each bar: count label on top, month abbreviation + year below.
|
||||
-- Bar height proportional to max count.
|
||||
|
||||
@guarantee TagCloud
|
||||
-- Up to config.dashboard_max_tags tags, sorted alphabetically.
|
||||
-- Font size scaled config.dashboard_tag_min_font to config.dashboard_tag_max_font px
|
||||
-- based on post count.
|
||||
-- Tags with colours get coloured background with contrast text.
|
||||
-- Hover title shows post count.
|
||||
-- "and N more" text when overflow_count > 0.
|
||||
|
||||
@guarantee CategoryCloud
|
||||
-- All categories as badge-like tags.
|
||||
-- Each shows category name + count.
|
||||
|
||||
@guarantee RecentPosts
|
||||
-- Last config.dashboard_recent_count posts by updatedAt descending.
|
||||
-- Each row: title (or "Untitled"), status badge (draft/published), date.
|
||||
-- Single-click: preview tab. Double-click: pin tab.
|
||||
}
|
||||
|
||||
-- ─── Menu editor view ────────────────────────────────────────
|
||||
|
||||
-- Visual editor for the OPML navigation menu (meta/menu.opml).
|
||||
-- See menu.allium for data model.
|
||||
|
||||
value MenuEditorView {
|
||||
items: List<MenuTreeItem>
|
||||
}
|
||||
|
||||
value MenuTreeItem {
|
||||
item_id: String
|
||||
kind: String -- home | page | category_archive | submenu
|
||||
label: String
|
||||
children: List<MenuTreeItem>
|
||||
is_home: Boolean -- true for the home item (protected)
|
||||
}
|
||||
|
||||
surface MenuEditorSurface {
|
||||
context menu: MenuEditorView
|
||||
|
||||
exposes:
|
||||
for item in menu.items:
|
||||
item.kind
|
||||
item.label
|
||||
item.children
|
||||
item.is_home
|
||||
|
||||
provides:
|
||||
MenuItemAdded(kind, data)
|
||||
MenuSaveRequested()
|
||||
MenuItemDeleted(item_id)
|
||||
when not item.is_home
|
||||
MenuItemMoved(item_id, direction)
|
||||
when not item.is_home
|
||||
|
||||
@guarantee HeaderLayout
|
||||
-- Title + description text.
|
||||
|
||||
@guarantee Toolbar
|
||||
-- 8 icon buttons with tooltips:
|
||||
-- Add Entry (+), Save (floppy), Add Category Archive,
|
||||
-- Move Up, Move Down, Indent, Unindent, Delete.
|
||||
|
||||
@guarantee TreeView
|
||||
-- Drag-and-drop tree with items showing:
|
||||
-- Drag handle, kind icon (home/page/category-archive/submenu SVGs),
|
||||
-- title, selected row highlighting.
|
||||
-- Nested items indented to show hierarchy.
|
||||
|
||||
@guarantee InlineEditing
|
||||
-- When creating page item: inline PageInput with search
|
||||
-- (filters posts in "page" category).
|
||||
-- When creating category archive: inline CategoryInput with search/create.
|
||||
-- Escape cancels, selection confirms.
|
||||
|
||||
@guarantee HomeItemProtection
|
||||
-- Home item cannot be moved, reordered, or deleted.
|
||||
-- Maximum one home item allowed.
|
||||
|
||||
@guarantee DragDrop
|
||||
-- Drag handle on each item.
|
||||
-- Auto-expand collapsed submenus on hover (config.menu_drag_expand_delay ms delay).
|
||||
-- Drop indicators show target position and nesting level.
|
||||
|
||||
@guarantee MoveDirections
|
||||
-- Up/Down: reorder within same nesting level.
|
||||
-- Indent: nest under previous sibling (becomes child).
|
||||
-- Unindent: move to parent's level (becomes next sibling of parent).
|
||||
}
|
||||
|
||||
config {
|
||||
menu_drag_expand_delay: Integer = 450
|
||||
}
|
||||
|
||||
rule MenuAddItem {
|
||||
when: MenuItemAdded(kind, data)
|
||||
-- kind = page: opens lazy-loaded page picker (posts with "page" category)
|
||||
-- kind = category_archive: opens lazy-loaded category picker
|
||||
-- kind = submenu: creates empty container node for nesting children
|
||||
-- kind = home: always available, maximum one allowed
|
||||
ensures: MenuTreeUpdated()
|
||||
}
|
||||
|
||||
rule MenuSave {
|
||||
when: MenuSaveRequested()
|
||||
-- Serializes tree to OPML 2.0, writes meta/menu.opml
|
||||
ensures: MenuFileWritten()
|
||||
}
|
||||
|
||||
rule MenuMoveItem {
|
||||
when: MenuItemMoved(item_id, direction)
|
||||
-- direction = up | down | indent | unindent
|
||||
requires: not is_home_item(item_id)
|
||||
ensures: MenuTreeUpdated()
|
||||
}
|
||||
|
||||
rule MenuDeleteItem {
|
||||
when: MenuItemDeleted(item_id)
|
||||
requires: not is_home_item(item_id)
|
||||
-- Removes item and all children, no confirmation dialog
|
||||
ensures: MenuTreeUpdated()
|
||||
}
|
||||
|
||||
-- ─── Metadata diff view ──────────────────────────────────────
|
||||
|
||||
-- Shows DB vs filesystem differences for all entity types.
|
||||
-- See metadata_diff.allium for diff field definitions.
|
||||
|
||||
value MetadataDiffView {
|
||||
is_scanning: Boolean
|
||||
active_entity_tab: String -- posts | media | scripts | templates
|
||||
diff_stats: MetadataDiffStats
|
||||
field_summaries: List<MetadataDiffFieldSummary>
|
||||
items: List<MetadataDiffItem>
|
||||
orphan_files: List<MetadataDiffOrphanFile>
|
||||
}
|
||||
|
||||
value MetadataDiffStats {
|
||||
total_posts: Integer
|
||||
published_posts: Integer
|
||||
draft_posts: Integer
|
||||
media_files: Integer
|
||||
scripts: Integer
|
||||
templates: Integer
|
||||
}
|
||||
|
||||
value MetadataDiffFieldSummary {
|
||||
field_name: String
|
||||
diff_count: Integer
|
||||
}
|
||||
|
||||
value MetadataDiffItem {
|
||||
entity_name: String
|
||||
entity_type: String
|
||||
file_missing: Boolean -- badge shown when file not found
|
||||
field_diffs: List<MetadataDiffField>
|
||||
}
|
||||
|
||||
value MetadataDiffField {
|
||||
field_name: String
|
||||
db_value: String?
|
||||
file_value: String?
|
||||
}
|
||||
|
||||
value MetadataDiffOrphanFile {
|
||||
file_path: String
|
||||
entity_type: String
|
||||
}
|
||||
|
||||
surface MetadataDiffSurface {
|
||||
context diff: MetadataDiffView
|
||||
|
||||
exposes:
|
||||
diff.is_scanning
|
||||
diff.active_entity_tab
|
||||
diff.diff_stats
|
||||
for fs in diff.field_summaries:
|
||||
fs.field_name
|
||||
fs.diff_count
|
||||
for item in diff.items:
|
||||
item.entity_name
|
||||
item.file_missing
|
||||
for fd in item.field_diffs:
|
||||
fd.field_name
|
||||
fd.db_value
|
||||
fd.file_value
|
||||
for orphan in diff.orphan_files:
|
||||
orphan.file_path
|
||||
|
||||
provides:
|
||||
MetadataDiffScanRequested()
|
||||
MetadataDiffSyncFieldToFile(entity_name, field_name)
|
||||
MetadataDiffSyncFieldToDb(entity_name, field_name)
|
||||
MetadataDiffSyncAllFieldToFile(field_name)
|
||||
MetadataDiffSyncAllFieldToDb(field_name)
|
||||
MetadataDiffImportOrphan(file_path)
|
||||
|
||||
@guarantee HeaderLayout
|
||||
-- Title + description text.
|
||||
-- Stats row: 6 stat items (total posts, published, drafts, media, scripts, templates).
|
||||
|
||||
@guarantee ScanAction
|
||||
-- Scan/Rescan button at top.
|
||||
-- Progress bar + message during scan.
|
||||
|
||||
@guarantee EntityTabs
|
||||
-- Tabs: Posts, Media, Scripts, Templates — each with badge count of diffs.
|
||||
|
||||
@guarantee FieldSummaryPills
|
||||
-- Clickable filter pills per field, each with count.
|
||||
-- Two bulk sync buttons per pill: DB→File and File→DB (syncs all items for that field).
|
||||
|
||||
@guarantee DiffItemCards
|
||||
-- Per-item card: header (entity label + file-missing badge),
|
||||
-- field rows (field name, DB value, file value),
|
||||
-- two sync buttons per field (DB→File, File→DB).
|
||||
-- Button disappears after successful sync (field now matches).
|
||||
|
||||
@guarantee OrphanFilesSection
|
||||
-- Files on disk with no matching DB record shown at bottom of each entity tab.
|
||||
-- "Import" button creates DB record from file metadata.
|
||||
|
||||
@guarantee NoConfirmation
|
||||
-- Individual field syncs require no confirmation dialog.
|
||||
}
|
||||
|
||||
-- ─── Git diff view ────────────────────────────────────────────
|
||||
|
||||
-- Renders diff for a file (working tree vs HEAD) or a commit.
|
||||
-- File diff: id = "git-diff:{filePath}"
|
||||
-- Commit diff: id = "git-diff:commit:{commitHash}"
|
||||
|
||||
value GitDiffView {
|
||||
diff_id: String
|
||||
diff_type: String -- file | commit
|
||||
display_mode: String -- inline | side_by_side (from editor settings)
|
||||
}
|
||||
|
||||
surface GitDiffSurface {
|
||||
context diff: GitDiffView
|
||||
|
||||
exposes:
|
||||
diff.diff_id
|
||||
diff.diff_type
|
||||
diff.display_mode
|
||||
|
||||
@guarantee DiffDisplayModes
|
||||
-- Supports inline and side-by-side diff display modes.
|
||||
-- Mode comes from editor settings (Diff View Style).
|
||||
|
||||
@guarantee ReadOnly
|
||||
-- No actions beyond viewing — changes are managed via git sidebar.
|
||||
}
|
||||
|
||||
-- ─── Documentation views ─────────────────────────────────────
|
||||
|
||||
-- documentation: renders DOCUMENTATION.md as styled HTML
|
||||
-- api_documentation: renders API.md as styled HTML
|
||||
|
||||
surface DocumentationSurface {
|
||||
@guarantee MarkdownRendering
|
||||
-- Renders markdown file as styled HTML.
|
||||
-- No edit actions. Read-only view.
|
||||
}
|
||||
|
||||
-- ─── Site validation view ───────────────────────────────────
|
||||
|
||||
value SiteValidationReport {
|
||||
project_id: String
|
||||
expected_url_count: Integer
|
||||
existing_html_count: Integer
|
||||
missing_url_paths: List<String> -- in sitemap, no HTML on disk
|
||||
extra_url_paths: List<String> -- HTML on disk, not in sitemap
|
||||
updated_post_url_paths: List<String> -- source .md newer than HTML
|
||||
affected_sections: Set<generation/GenerationSection>
|
||||
}
|
||||
|
||||
surface SiteValidationSurface {
|
||||
context report: SiteValidationReport
|
||||
|
||||
exposes:
|
||||
report.project_id
|
||||
report.expected_url_count
|
||||
report.existing_html_count
|
||||
report.missing_url_paths
|
||||
report.extra_url_paths
|
||||
report.updated_post_url_paths
|
||||
report.affected_sections
|
||||
|
||||
provides:
|
||||
SiteValidationScanRequested()
|
||||
SiteValidationApplyRequested(report)
|
||||
when report.missing_url_paths.count > 0
|
||||
or report.extra_url_paths.count > 0
|
||||
or report.updated_post_url_paths.count > 0
|
||||
|
||||
@guarantee SummaryLine
|
||||
-- "Expected URLs: N — Existing HTML URLs: N — Missing: N — Extra: N — Updated: N"
|
||||
|
||||
@guarantee UrlSections
|
||||
-- Three sections: Missing URLs, Extra URLs, Updated URLs.
|
||||
-- Each section: heading + list of URL paths, or "None found".
|
||||
|
||||
@guarantee ApplyAction
|
||||
-- Apply button disabled when nothing to fix.
|
||||
-- On apply: renders missing, deletes extra, re-renders updated.
|
||||
-- Toast: "Validation applied: N rendered, N deleted".
|
||||
}
|
||||
|
||||
rule SiteValidationScan {
|
||||
when: SiteValidationScanRequested()
|
||||
-- Parses <loc> entries from sitemap.xml into expected URL set
|
||||
-- Scans HTML output dir for index.html files (zero-byte = missing)
|
||||
-- Compares source .md mtime against generated HTML mtime
|
||||
ensures: SiteValidationReport
|
||||
}
|
||||
|
||||
rule SiteValidationApply {
|
||||
when: SiteValidationApplyRequested(report)
|
||||
-- Classifies affected paths into generation sections (core, single, category, tag, date)
|
||||
-- Renders only affected sections in parallel
|
||||
-- Deletes extra HTML files, removes empty directories
|
||||
-- Regenerates calendar if anything changed
|
||||
-- Rebuilds search index if anything rendered or deleted
|
||||
ensures: ApplyValidationRequested(report.project_id, report.affected_sections)
|
||||
}
|
||||
|
||||
-- ─── Translation validation view ───────────────────────────
|
||||
|
||||
value TranslationValidationReport {
|
||||
checked_database_row_count: Integer
|
||||
checked_filesystem_file_count: Integer
|
||||
invalid_database_rows: List<TranslationValidationIssue>
|
||||
invalid_filesystem_files: List<TranslationValidationIssue>
|
||||
}
|
||||
|
||||
value TranslationValidationIssue {
|
||||
issue: String
|
||||
-- Issue kinds:
|
||||
-- missing_source_post: translationFor points to nonexistent post
|
||||
-- same_language_as_canonical: translation language matches source post language
|
||||
-- do_not_translate_has_translations: source post is doNotTranslate but has translations
|
||||
-- content_in_database: published translation still has content in DB (should be on disk)
|
||||
translation_id: String?
|
||||
translation_for: String
|
||||
canonical_language: String?
|
||||
translation_language: String
|
||||
title: String?
|
||||
file_path: String?
|
||||
}
|
||||
|
||||
surface TranslationValidationSurface {
|
||||
context report: TranslationValidationReport
|
||||
|
||||
exposes:
|
||||
report.checked_database_row_count
|
||||
report.checked_filesystem_file_count
|
||||
for issue in report.invalid_database_rows:
|
||||
issue.issue
|
||||
issue.translation_id
|
||||
issue.translation_for
|
||||
issue.canonical_language
|
||||
issue.translation_language
|
||||
issue.title
|
||||
issue.file_path
|
||||
for issue in report.invalid_filesystem_files:
|
||||
issue.issue
|
||||
issue.file_path
|
||||
|
||||
provides:
|
||||
TranslationValidationScanRequested()
|
||||
TranslationValidationFixRequested(report)
|
||||
when report.invalid_database_rows.count > 0
|
||||
or report.invalid_filesystem_files.count > 0
|
||||
|
||||
@guarantee SummaryLine
|
||||
-- "Checked DB rows: N — Checked files: N — Invalid DB rows: N — Invalid files: N"
|
||||
|
||||
@guarantee IssueSections
|
||||
-- Two sections: Database Issues, Filesystem Issues.
|
||||
-- Each issue rendered as card with coloured left border.
|
||||
-- Card shows: issue label, source post ID, translation ID, title, languages, file path.
|
||||
|
||||
@guarantee IssueTypes
|
||||
-- same_language_as_canonical: translation language matches source.
|
||||
-- do_not_translate_has_translations: source is doNotTranslate.
|
||||
-- content_in_database: published translation has content in DB.
|
||||
-- missing_source_post: translationFor references nonexistent post.
|
||||
|
||||
@guarantee FixAction
|
||||
-- Revalidate button + Fix button (disabled when no issues).
|
||||
-- Fix: content_in_database -> flush to .md file, set content null.
|
||||
-- Other issues -> delete DB row or .md file.
|
||||
-- After fix: automatically re-validates.
|
||||
-- Toast: "Deleted N DB rows and N files, flushed N translations to disk".
|
||||
}
|
||||
|
||||
rule TranslationValidationScan {
|
||||
when: TranslationValidationScanRequested()
|
||||
-- Database pass: checks all translation rows for integrity issues
|
||||
-- Filesystem pass: scans posts/ for translation .md files, checks frontmatter
|
||||
ensures: TranslationValidationReport
|
||||
}
|
||||
|
||||
rule TranslationValidationFix {
|
||||
when: TranslationValidationFixRequested(report)
|
||||
-- content_in_database: flushes content to .md file, sets content = null in DB
|
||||
-- missing_source_post | same_language_as_canonical | do_not_translate:
|
||||
-- DB issues: deletes translation row
|
||||
-- Filesystem issues: deletes the .md file
|
||||
-- After fix: automatically re-validates
|
||||
ensures: TranslationValidationScan
|
||||
}
|
||||
|
||||
-- ─── Find duplicates view ──────────────────────────────────
|
||||
|
||||
value DuplicateSearchResult {
|
||||
pairs: List<DuplicatePair>
|
||||
has_more: Boolean -- pagination with config.duplicate_page_size per page
|
||||
}
|
||||
|
||||
value DuplicatePair {
|
||||
post_id_a: String
|
||||
title_a: String
|
||||
post_id_b: String
|
||||
title_b: String
|
||||
similarity: Decimal -- 0.0 to 1.0
|
||||
exact_match: Boolean -- true if titles + content identical
|
||||
}
|
||||
|
||||
config {
|
||||
duplicate_similarity_threshold: Decimal = 0.92
|
||||
duplicate_page_size: Integer = 500
|
||||
duplicate_neighbor_count: Integer = 21
|
||||
}
|
||||
|
||||
surface DuplicatesSurface {
|
||||
context result: DuplicateSearchResult
|
||||
|
||||
exposes:
|
||||
for pair in result.pairs:
|
||||
pair.title_a
|
||||
pair.title_b
|
||||
pair.similarity
|
||||
pair.exact_match
|
||||
result.has_more
|
||||
|
||||
provides:
|
||||
DuplicateSearchRequested()
|
||||
DuplicatePairDismissed(post_id_a, post_id_b)
|
||||
DuplicatePairsBatchDismissed(pair_ids)
|
||||
DuplicatePostClicked(post_id)
|
||||
DuplicateShowMoreRequested()
|
||||
when result.has_more
|
||||
|
||||
@guarantee SemanticSimilarityGate
|
||||
-- Requires semanticSimilarityEnabled in project metadata.
|
||||
-- If disabled: shows "Semantic similarity is not enabled" message.
|
||||
-- No search functionality available when disabled.
|
||||
|
||||
@guarantee ActionsBar
|
||||
-- Refresh button, Check All, Uncheck All,
|
||||
-- Dismiss Checked (with count), disabled when none checked.
|
||||
|
||||
@guarantee PairRows
|
||||
-- Each row: checkbox, post A title (clickable -> opens tab),
|
||||
-- arrow, post B title (clickable -> opens tab),
|
||||
-- similarity badge (percentage or "Exact Match"),
|
||||
-- Dismiss button.
|
||||
-- Exact matches styled distinctly from similarity matches.
|
||||
|
||||
@guarantee Pagination
|
||||
-- "Show More" button when has_more is true.
|
||||
-- config.duplicate_page_size pairs per page.
|
||||
|
||||
@guarantee BatchDismiss
|
||||
-- Batch insert in chunks of 100.
|
||||
-- Dismissed pairs excluded from future searches.
|
||||
}
|
||||
|
||||
rule DuplicateSearch {
|
||||
when: DuplicateSearchRequested()
|
||||
requires: semantic_similarity_enabled
|
||||
-- Loads USearch vector index for project
|
||||
-- For each indexed post: search config.duplicate_neighbor_count nearest neighbors
|
||||
-- similarity = max(0, 1 - distance)
|
||||
-- Filter: similarity >= config.duplicate_similarity_threshold, exclude dismissed
|
||||
-- For 100% embedding similarity: load post bodies, compare title+content
|
||||
-- If identical: exact_match = true
|
||||
-- Sort: exact matches first, then descending similarity
|
||||
ensures: DuplicateSearchResult
|
||||
}
|
||||
|
||||
rule DuplicateDismiss {
|
||||
when: DuplicatePairDismissed(post_id_a, post_id_b)
|
||||
-- Inserts into dismissed_duplicate_pairs with canonical ID ordering
|
||||
-- Excluded from future searches
|
||||
ensures: PairDismissed(post_id_a, post_id_b)
|
||||
}
|
||||
|
||||
rule DuplicateBatchDismiss {
|
||||
when: DuplicatePairsBatchDismissed(pair_ids)
|
||||
-- Batch insert in chunks of 100
|
||||
ensures: for pair in pair_ids: PairDismissed(pair)
|
||||
}
|
||||
|
||||
-- ─── Import analysis view ───────────────────────────────────
|
||||
|
||||
-- Editor for WXR (WordPress eXtended RSS) import definitions.
|
||||
-- Keyed by import definition ID. Opened as always-pinned tab.
|
||||
|
||||
value ImportAnalysisView {
|
||||
definition_id: String
|
||||
definition_name: String -- editable name input
|
||||
uploads_folder_path: String? -- path display + Browse button
|
||||
wxr_file_path: String? -- path display + Select & Analyze button
|
||||
is_loading: Boolean
|
||||
report: ImportAnalysisReport?
|
||||
}
|
||||
|
||||
value ImportAnalysisReport {
|
||||
site_info: ImportSiteInfo
|
||||
post_stats: ImportEntityStats
|
||||
page_stats: ImportEntityStats
|
||||
media_stats: ImportMediaStats
|
||||
category_stats: ImportTaxonomyStats
|
||||
tag_stats: ImportTaxonomyStats
|
||||
date_distribution: List<ImportYearDistribution>
|
||||
conflicts: List<ImportConflict>
|
||||
macros: List<ImportMacro>
|
||||
}
|
||||
|
||||
value ImportSiteInfo {
|
||||
title: String
|
||||
url: String
|
||||
language: String
|
||||
source_file: String
|
||||
}
|
||||
|
||||
value ImportEntityStats {
|
||||
new_count: Integer
|
||||
update_count: Integer
|
||||
conflict_count: Integer
|
||||
duplicate_count: Integer
|
||||
}
|
||||
|
||||
value ImportMediaStats {
|
||||
new_count: Integer
|
||||
update_count: Integer
|
||||
conflict_count: Integer
|
||||
duplicate_count: Integer
|
||||
missing_count: Integer
|
||||
}
|
||||
|
||||
value ImportTaxonomyStats {
|
||||
existing_count: Integer
|
||||
mapped_count: Integer
|
||||
new_count: Integer
|
||||
}
|
||||
|
||||
value ImportYearDistribution {
|
||||
year: Integer
|
||||
post_count: Integer
|
||||
media_count: Integer
|
||||
}
|
||||
|
||||
value ImportConflict {
|
||||
item_type: String -- post | page | media
|
||||
item_name: String
|
||||
resolution: String -- import | skip | merge
|
||||
}
|
||||
|
||||
value ImportMacro {
|
||||
name: String
|
||||
usage_count: Integer
|
||||
parameters: List<String>
|
||||
validation_status: String -- valid | invalid | unknown
|
||||
}
|
||||
|
||||
surface ImportAnalysisSurface {
|
||||
context analysis: ImportAnalysisView
|
||||
|
||||
exposes:
|
||||
analysis.definition_name
|
||||
analysis.uploads_folder_path
|
||||
analysis.wxr_file_path
|
||||
analysis.is_loading
|
||||
analysis.report when analysis.report != null
|
||||
|
||||
provides:
|
||||
ImportAnalyzeRequested(analysis.definition_id, file_path)
|
||||
when analysis.wxr_file_path != null
|
||||
ImportExecuteRequested(analysis.definition_id)
|
||||
when analysis.report != null
|
||||
ImportConflictResolutionChanged(item_name, resolution)
|
||||
ImportTaxonomyMappingChanged(source_term, target_term)
|
||||
ImportAITaxonomyAnalysisRequested(analysis.definition_id)
|
||||
|
||||
@guarantee FileSelectors
|
||||
-- Uploads folder: path display + Browse button (native folder dialog).
|
||||
-- WXR file: path display + Select & Analyze button (native file dialog).
|
||||
|
||||
@guarantee LoadingState
|
||||
-- Spinner + progress step + detail text during analysis.
|
||||
|
||||
@guarantee SiteInfoCard
|
||||
-- Shows: site title, URL, language, source file path.
|
||||
|
||||
@guarantee StatCards
|
||||
-- Posts (new/update/conflict/duplicate), Pages (same),
|
||||
-- Media (new/update/conflict/duplicate/missing),
|
||||
-- Categories (existing/mapped/new), Tags (existing/mapped/new).
|
||||
|
||||
@guarantee DateDistribution
|
||||
-- Year-by-year bar charts for posts + media.
|
||||
|
||||
@guarantee ConflictsSection
|
||||
-- Collapsible. Per-item dropdown: Import/Skip/Merge.
|
||||
-- Default: Import for new items, Skip for existing matches.
|
||||
|
||||
@guarantee TaxonomySection
|
||||
-- Collapsible. Category + tag pills.
|
||||
-- Click pill to map to existing term. Inline edit with suggestion dropdown.
|
||||
-- AI analyze button (with model selector dropdown).
|
||||
-- Gate: airplane mode check for AI taxonomy analysis.
|
||||
|
||||
@guarantee MacrosSection
|
||||
-- Collapsible. Discovered macros with usage counts, parameters,
|
||||
-- validation status (valid/invalid/unknown badges).
|
||||
|
||||
@guarantee ExecuteAction
|
||||
-- Execute button shows importable counts (tags, posts, media, pages).
|
||||
-- Disabled if nothing to import.
|
||||
-- Progress bar during execution (current/total, phase, detail, ETA).
|
||||
-- No confirmation dialog — executes immediately.
|
||||
|
||||
@guarantee CreatedEntitiesRefresh
|
||||
-- Created entities appear in sidebar immediately after execution.
|
||||
}
|
||||
|
||||
rule ImportSelectAndAnalyze {
|
||||
when: ImportAnalyzeRequested(definition_id, file_path)
|
||||
-- Parses WXR XML file
|
||||
-- Extracts: posts, pages, media, tags, categories, authors
|
||||
-- Shows summary counts per entity type
|
||||
-- Identifies conflicts: duplicate slugs, existing categories/tags
|
||||
}
|
||||
|
||||
rule ImportExecute {
|
||||
when: ImportExecuteRequested(definition_id)
|
||||
-- No confirmation dialog — executes immediately
|
||||
-- Processes items per conflict resolution settings
|
||||
-- Creates posts, media, tags, categories as needed
|
||||
-- Summary with counts shown in import view on completion
|
||||
-- Created entities appear in sidebar immediately (store updated)
|
||||
}
|
||||
317
specs/editor_post.allium
Normal file
317
specs/editor_post.allium
Normal file
@@ -0,0 +1,317 @@
|
||||
-- allium: 1
|
||||
-- bDS Post Editor View
|
||||
-- Scope: UI content area — post editing surface
|
||||
-- Distilled from: PostEditor.tsx
|
||||
|
||||
-- Describes the layout and behaviour of the post editor rendered in
|
||||
-- the main content area when a post tab is active.
|
||||
-- Tab routing is in tabs.allium. Sidebar navigation is in sidebar_views.allium.
|
||||
|
||||
use "./tabs.allium" as tabs
|
||||
use "./post.allium" as post
|
||||
use "./i18n.allium" as i18n
|
||||
|
||||
-- ─── Post editor ──────────────────────────────────────────────
|
||||
|
||||
value PostEditorView {
|
||||
post_id: String
|
||||
header: PostEditorHeader
|
||||
metadata: PostEditorMetadata
|
||||
metadata_expanded: Boolean -- starts expanded when title is empty
|
||||
excerpt_expanded: Boolean
|
||||
editor_mode: String -- visual | markdown | preview
|
||||
footer: PostEditorFooter
|
||||
}
|
||||
|
||||
value PostEditorHeader {
|
||||
title: String -- post title or "Untitled"
|
||||
is_dirty: Boolean
|
||||
status: String -- draft | published | archived
|
||||
is_auto_saving: Boolean
|
||||
}
|
||||
|
||||
value PostEditorMetadata {
|
||||
title: String -- editable text input
|
||||
tags: List<String> -- autocomplete chip input
|
||||
author: String? -- text input
|
||||
language: String? -- select from supported languages
|
||||
do_not_translate: Boolean -- checkbox
|
||||
slug: String -- read-only text input
|
||||
categories: List<String> -- chip input
|
||||
template_slug: String? -- select (shown only when templates exist)
|
||||
post_links: PostLinksPanel
|
||||
linked_media: List<LinkedMediaItem>
|
||||
}
|
||||
|
||||
value PostLinksPanel {
|
||||
backlinks: List<PostLinkReference> -- posts linking to this post
|
||||
outlinks: List<PostLinkReference> -- posts this post links to
|
||||
}
|
||||
|
||||
value PostLinkReference {
|
||||
post_id: String
|
||||
title: String
|
||||
}
|
||||
|
||||
value LinkedMediaItem {
|
||||
media_id: String
|
||||
has_thumbnail: Boolean
|
||||
name: String
|
||||
sort_order: Integer
|
||||
}
|
||||
|
||||
value PostEditorFooter {
|
||||
created_at: String -- locale-formatted date
|
||||
updated_at: String -- locale-formatted date
|
||||
published_at: String? -- locale-formatted date, only when post was published
|
||||
}
|
||||
|
||||
value TranslationFlag {
|
||||
language: String
|
||||
flag_emoji: String
|
||||
status: String -- draft | published
|
||||
is_active: Boolean -- true when this language is currently being edited
|
||||
}
|
||||
|
||||
surface TranslationFlagSurface {
|
||||
context flag: TranslationFlag
|
||||
|
||||
exposes:
|
||||
flag.language
|
||||
flag.flag_emoji
|
||||
flag.status
|
||||
flag.is_active
|
||||
}
|
||||
|
||||
surface PostEditorSurface {
|
||||
context editor: PostEditorView
|
||||
|
||||
exposes:
|
||||
editor.header.title
|
||||
editor.header.is_dirty
|
||||
editor.header.status
|
||||
editor.header.is_auto_saving
|
||||
editor.metadata_expanded
|
||||
editor.excerpt_expanded
|
||||
editor.editor_mode
|
||||
editor.footer.created_at
|
||||
editor.footer.updated_at
|
||||
editor.footer.published_at when editor.footer.published_at != null
|
||||
|
||||
provides:
|
||||
PostAIAnalysisRequested(editor.post_id)
|
||||
PostTranslateRequested(editor.post_id, target_language)
|
||||
PostSaved(editor.post_id)
|
||||
PostPublishRequested(editor.post_id)
|
||||
when editor.header.status = draft
|
||||
PostDiscardRequested(editor.post_id)
|
||||
when editor.header.status = draft
|
||||
PostDeleteRequested(editor.post_id)
|
||||
PostInsertLinkRequested(editor.post_id)
|
||||
when editor.editor_mode = markdown
|
||||
PostInsertMediaRequested(editor.post_id)
|
||||
when editor.editor_mode = markdown
|
||||
PostGalleryRequested(editor.post_id)
|
||||
ImageDroppedOnEditor(editor.post_id, file_path)
|
||||
PostLanguageDetectRequested(editor.post_id)
|
||||
|
||||
@guarantee HeaderLayout
|
||||
-- Header bar with two areas.
|
||||
-- Left: title text with dirty indicator dot (●) when is_dirty is true.
|
||||
-- Right: status badge, auto-save indicator (when saving),
|
||||
-- Quick Actions dropdown, Publish button (only when draft, success style),
|
||||
-- Discard button (only when draft), Delete button.
|
||||
|
||||
@guarantee QuickActionsDropdown
|
||||
-- Dropdown menu in header with two entries:
|
||||
-- AI Analysis (robot icon) — suggests title, excerpt, slug.
|
||||
-- Translate Post (globe icon) — opens translation modal.
|
||||
|
||||
@guarantee MetadataSection
|
||||
-- Collapsible section. Starts expanded when title is empty.
|
||||
-- Two-column layout.
|
||||
-- Left column: Title, Tags, Author, Language + detect button,
|
||||
-- Do Not Translate checkbox, Slug (read-only), Categories,
|
||||
-- Template (select, only when templates exist), PostLinks.
|
||||
-- Right column: LinkedMediaPanel.
|
||||
|
||||
@guarantee TagAutocomplete
|
||||
-- Tag input with autocomplete.
|
||||
-- Standard: prefix match on existing tag names (case-insensitive).
|
||||
-- When semanticSimilarityEnabled: also suggests tags from 10 similar posts,
|
||||
-- weighted by similarity, top 5 shown.
|
||||
-- Merged results: prefix matches first, then embedding suggestions.
|
||||
|
||||
@guarantee TranslationFlagsBar
|
||||
-- Row of flag emoji buttons inline with metadata toggle.
|
||||
-- One flag per language: canonical language + each translation.
|
||||
-- Each flag shows status (draft/published) via CSS class.
|
||||
-- Active flag highlighted. Click switches editor to that language's draft.
|
||||
|
||||
@guarantee ExcerptSection
|
||||
-- Collapsible section with textarea (4 rows).
|
||||
|
||||
@guarantee EditorBodyToolbar
|
||||
-- Toolbar: "Content" label, mode toggle (Visual/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.
|
||||
|
||||
@guarantee DragDropImages
|
||||
-- Drop image file onto editor area triggers import chain.
|
||||
|
||||
@guarantee FooterLayout
|
||||
-- Three date stamps: Created, Updated, Published (only when published_at exists).
|
||||
-- Locale-formatted dates.
|
||||
|
||||
@guarantee LinkedMediaPanel
|
||||
-- Right column of metadata showing media items linked to this post.
|
||||
-- Actions: Import & Link (native file dialog), Link Existing (media picker),
|
||||
-- Unlink (no confirmation), Reorder (drag handle), Click (opens media tab).
|
||||
}
|
||||
|
||||
config {
|
||||
post_auto_save_delay: Integer = 3000
|
||||
post_content_sample_length: Integer = 2000
|
||||
}
|
||||
|
||||
invariant PostAutoSave {
|
||||
-- Auto-saves after config.post_auto_save_delay ms of idle in the editor.
|
||||
-- Also auto-saves on unmount/tab switch.
|
||||
-- Ctrl/Cmd+S triggers immediate save.
|
||||
-- Saves: title, content, excerpt, tags, categories, templateSlug, language.
|
||||
}
|
||||
|
||||
invariant PostDirtyTracking {
|
||||
-- Compares canonical draft + translation drafts against saved state.
|
||||
-- Dirty indicator shown in header (●) and tab bar.
|
||||
}
|
||||
|
||||
invariant PostEditorModePersistence {
|
||||
-- Editor mode (visual/markdown/preview) persists per session.
|
||||
-- Default mode comes from editor settings.
|
||||
}
|
||||
|
||||
-- ─── Post editor actions ────────────────────────────────────
|
||||
|
||||
rule PostAIAnalysis {
|
||||
when: PostAIAnalysisRequested(post_id)
|
||||
-- Gate: airplane mode check (see action_patterns.allium AIOperationGating)
|
||||
-- Uses title model (not default chat model)
|
||||
-- Input: post title + excerpt + content (first config.post_content_sample_length chars)
|
||||
-- Response: suggested title, excerpt, slug
|
||||
-- Opens AISuggestionsModal with 3 fields:
|
||||
-- Each field: current value, suggested value, accept checkbox
|
||||
-- Slug field locked (no accept checkbox) if post was ever published
|
||||
-- On confirm: applies only checked fields, triggers auto-save
|
||||
}
|
||||
|
||||
rule PostTranslateAction {
|
||||
when: PostTranslateRequested(post_id, target_language)
|
||||
-- Gate: airplane mode check
|
||||
-- Opens language picker modal:
|
||||
-- Available target languages from project blogLanguages
|
||||
-- Existing translations shown with status badge (draft/published)
|
||||
-- Two sequential AI calls via title model:
|
||||
-- 1. Translate metadata (title, excerpt) to target language
|
||||
-- 2. Translate content (full markdown body) to target language
|
||||
-- Creates/updates translation record in DB
|
||||
-- If source post is published: transitions source to draft
|
||||
-- (copies file content back to DB so it can be edited)
|
||||
}
|
||||
|
||||
rule PostAutoTranslateOnSave {
|
||||
when: PostSaved(post_id)
|
||||
-- Gate: airplane mode check + auto_translate not disabled (doNotTranslate=false)
|
||||
-- For each configured blog language missing a translation:
|
||||
-- Enqueue background translation task (title model)
|
||||
-- Each task: translate metadata + content, create translation record
|
||||
-- Cascades to linked media: for each linked media item,
|
||||
-- translate media metadata for missing languages
|
||||
-- See action_patterns.allium AutoTranslationChain for full chain
|
||||
}
|
||||
|
||||
rule PostPublishAction {
|
||||
when: PostPublishRequested(post_id)
|
||||
-- Implicit save first (awaited) if post is dirty
|
||||
-- Then calls engine publish (see engine_side_effects.allium PublishPostSideEffects)
|
||||
-- Also publishes all translations whose source language is published
|
||||
-- UI updates: status badge -> published, sidebar section move
|
||||
}
|
||||
|
||||
rule PostDiscardChanges {
|
||||
when: PostDiscardRequested(post_id)
|
||||
-- Only available for published posts with pending draft changes
|
||||
-- System confirm dialog: "Discard changes to this post?"
|
||||
-- On confirm: reads published version from .md file,
|
||||
-- restores DB to published state (content=null, status=published)
|
||||
-- Editor reloads with restored content
|
||||
}
|
||||
|
||||
rule PostDeleteAction {
|
||||
when: PostDeleteRequested(post_id)
|
||||
-- System confirm dialog: "Delete this post?"
|
||||
-- If published: also deletes .md file and all translation files
|
||||
-- If never published: only deletes DB record
|
||||
-- Removes from DB, closes tab, sidebar removes item
|
||||
-- See engine_side_effects.allium DeletePostSideEffects
|
||||
}
|
||||
|
||||
rule PostInsertLink {
|
||||
when: PostInsertLinkRequested(post_id)
|
||||
-- Keyboard shortcut: Ctrl/Cmd+K
|
||||
-- Opens InsertPostLinkModal with two tabs: Internal, External
|
||||
-- Internal tab:
|
||||
-- Search input (debounced, queries post titles)
|
||||
-- Results list: title + status badge (draft/published)
|
||||
-- If semantic similarity enabled: results ranked by similarity
|
||||
-- Click inserts markdown link: [title](/YYYY/MM/DD/slug)
|
||||
-- "Create Post" option at bottom of search results:
|
||||
-- Creates new post with search query as title
|
||||
-- Inserts link to newly created post
|
||||
-- External tab:
|
||||
-- URL input + optional display text input
|
||||
-- Inserts: [text](url) or bare url if no display text
|
||||
}
|
||||
|
||||
rule PostInsertMedia {
|
||||
when: PostInsertMediaRequested(post_id)
|
||||
-- Opens InsertMediaModal (media search variant)
|
||||
-- Search input, grid of media items with bds-thumb:// thumbnails
|
||||
-- Click inserts markdown:
|
||||
-- Images: 
|
||||
-- Non-images: [originalName](bds-media://id)
|
||||
}
|
||||
|
||||
rule PostGalleryAction {
|
||||
when: PostGalleryRequested(post_id)
|
||||
-- Opens gallery overlay showing all media linked to this post
|
||||
-- Image grid with bds-thumb:// thumbnails
|
||||
-- Click on image opens lightbox (full-size bds-media:// preview)
|
||||
-- Lightbox: left/right arrow navigation, close button, ESC to close
|
||||
}
|
||||
|
||||
rule PostDragDropImage {
|
||||
when: ImageDroppedOnEditor(post_id, file_path)
|
||||
-- Chain of operations (see action_patterns.allium DragDropImageChain):
|
||||
-- 1. Import media file -> media record + file copy + sidecar
|
||||
-- 2. Generate thumbnails (async: small/medium/large/ai)
|
||||
-- 3. Link media to post (update sidecar linkedPostIds)
|
||||
-- 4. Insert markdown image at cursor: 
|
||||
-- 5. If AI available: AI image analysis (async, auto-applied, no modal)
|
||||
-- 6. If auto-translate enabled: cascade translate media metadata
|
||||
-- Steps 1-4 synchronous. Steps 5-6 background tasks.
|
||||
}
|
||||
|
||||
rule PostLanguageDetect {
|
||||
when: PostLanguageDetectRequested(post_id)
|
||||
-- Gate: airplane mode check
|
||||
-- Sends content sample to title model
|
||||
-- Auto-sets post language field (no modal)
|
||||
-- Triggers auto-save
|
||||
}
|
||||
96
specs/editor_script.allium
Normal file
96
specs/editor_script.allium
Normal file
@@ -0,0 +1,96 @@
|
||||
-- allium: 1
|
||||
-- bDS Script Editor View
|
||||
-- Scope: UI content area — script editing surface
|
||||
-- Distilled from: ScriptEditor.tsx
|
||||
|
||||
-- Describes the layout and behaviour of the script editor.
|
||||
-- See script.allium for entity model.
|
||||
|
||||
use "./script.allium" as script
|
||||
use "./i18n.allium" as i18n
|
||||
|
||||
-- ─── Script editor view ──────────────────────────────────────
|
||||
|
||||
value ScriptEditorView {
|
||||
script_id: String
|
||||
title: String -- editable text input
|
||||
slug: String -- editable text input, auto-generated from title
|
||||
kind: String -- select: utility | macro | transform
|
||||
entrypoint: String -- select: dynamically discovered functions, "main" always first
|
||||
enabled: Boolean -- checkbox
|
||||
content: String -- code editor content
|
||||
created_at: String -- locale-formatted date
|
||||
updated_at: String -- locale-formatted date
|
||||
}
|
||||
|
||||
surface ScriptEditorSurface {
|
||||
context editor: ScriptEditorView
|
||||
|
||||
exposes:
|
||||
editor.title
|
||||
editor.slug
|
||||
editor.kind
|
||||
editor.entrypoint
|
||||
editor.enabled
|
||||
editor.created_at
|
||||
editor.updated_at
|
||||
|
||||
provides:
|
||||
ScriptSaveRequested(editor.script_id)
|
||||
ScriptRunRequested(editor.script_id)
|
||||
ScriptCheckSyntaxRequested(editor.script_id)
|
||||
ScriptDeleteRequested(editor.script_id)
|
||||
|
||||
@guarantee HeaderLayout
|
||||
-- Header bar with script title tab.
|
||||
-- Actions (right side): Save button, Run button,
|
||||
-- Check Syntax button, Delete button (danger style).
|
||||
|
||||
@guarantee MetadataRow
|
||||
-- Two rows of metadata fields above the editor.
|
||||
-- Row 1: Title (text input), Slug (text input, auto-generated from title).
|
||||
-- Row 2: Kind (select: utility/macro/transform),
|
||||
-- Entrypoint (select: dynamically discovered functions with "main" always first),
|
||||
-- Enabled (checkbox).
|
||||
|
||||
@guarantee EditorBody
|
||||
-- Code editor with syntax highlighting for the configured scripting language.
|
||||
-- Toolbar: "Content" label.
|
||||
-- Syntax errors shown as inline markers in editor gutter.
|
||||
|
||||
@guarantee FooterLayout
|
||||
-- Created date and Updated date, locale-formatted.
|
||||
}
|
||||
|
||||
-- ─── Script editor actions ──────────────────────────────────
|
||||
|
||||
rule ScriptSave {
|
||||
when: ScriptSaveRequested(script_id)
|
||||
-- Syntax check first using the configured scripting runtime semantics
|
||||
-- If syntax error: blocks save, shows error inline in editor gutter
|
||||
-- If valid: bumps version, saves to DB + rewrites the published script file
|
||||
-- Entrypoint list re-discovered from AST after save
|
||||
}
|
||||
|
||||
rule ScriptCheckSyntax {
|
||||
when: ScriptCheckSyntaxRequested(script_id)
|
||||
-- Validates script syntax without saving
|
||||
-- Shows errors inline in editor gutter
|
||||
-- Success shown as toast or status indicator
|
||||
}
|
||||
|
||||
rule ScriptRun {
|
||||
when: ScriptRunRequested(script_id)
|
||||
-- Executes script in the configured runtime
|
||||
-- Calls configured entrypoint function (default: main)
|
||||
-- stdout/stderr directed to Output panel tab
|
||||
-- Output panel auto-opens if not already visible
|
||||
-- Errors shown in Output panel with line numbers
|
||||
}
|
||||
|
||||
rule ScriptDelete {
|
||||
when: ScriptDeleteRequested(script_id)
|
||||
-- No confirmation dialog
|
||||
-- Deletes DB record + published script file on disk
|
||||
-- Closes script tab, sidebar removes item
|
||||
}
|
||||
271
specs/editor_settings.allium
Normal file
271
specs/editor_settings.allium
Normal file
@@ -0,0 +1,271 @@
|
||||
-- allium: 1
|
||||
-- bDS Settings and Style Views
|
||||
-- Scope: UI content area — settings + style editing surfaces
|
||||
-- Distilled from: SettingsView.tsx, StyleView.tsx
|
||||
|
||||
-- Describes the layout and behaviour of the settings and style views.
|
||||
|
||||
use "./i18n.allium" as i18n
|
||||
|
||||
-- ─── Settings view ────────────────────────────────────────────
|
||||
|
||||
value SettingsView {
|
||||
search_query: String? -- filters sections by keyword match
|
||||
active_sections: List<String> -- visible sections after search filter
|
||||
project_section: SettingsProjectSection?
|
||||
editor_section: SettingsEditorSection?
|
||||
categories: List<SettingsCategoryRow>
|
||||
ai_section: SettingsAISection?
|
||||
publishing_section: SettingsPublishingSection?
|
||||
mcp_section: SettingsMCPSection?
|
||||
}
|
||||
|
||||
value SettingsProjectSection {
|
||||
project_name: String -- text input
|
||||
project_description: String -- textarea (3 rows)
|
||||
data_path: String -- text input + Browse button + Reset button
|
||||
public_url: String -- url input
|
||||
main_language: String -- select
|
||||
blog_languages: List<String> -- checkboxes (main language disabled)
|
||||
default_author: String -- text input
|
||||
max_posts_per_page: Integer -- number input (1-500, default 50)
|
||||
blogmark_category: String -- select from categories
|
||||
}
|
||||
|
||||
value SettingsEditorSection {
|
||||
default_mode: String -- select: wysiwyg | markdown | preview
|
||||
diff_view_style: String -- select: inline | side-by-side
|
||||
wrap_long_lines: Boolean -- checkbox
|
||||
hide_unchanged_regions: Boolean -- checkbox
|
||||
}
|
||||
|
||||
value SettingsCategoryRow {
|
||||
name: String -- read-only for protected categories
|
||||
title: String -- editable
|
||||
render_in_lists: Boolean -- checkbox
|
||||
show_titles: Boolean -- checkbox
|
||||
post_template_slug: String? -- select
|
||||
list_template_slug: String? -- select
|
||||
is_protected: Boolean -- true for: article, aside, page, picture
|
||||
}
|
||||
|
||||
value SettingsAISection {
|
||||
anthropic_api_key: String? -- masked + Change/Save
|
||||
mistral_api_key: String? -- masked + Change/Save
|
||||
ollama_enabled: Boolean -- toggle, shows model capabilities table (tools/vision)
|
||||
lm_studio_enabled: Boolean -- toggle, shows model capabilities table
|
||||
offline_mode: Boolean -- toggle, switches between online and airplane endpoint
|
||||
default_model: String? -- select from active endpoint models
|
||||
title_model: String? -- select
|
||||
image_analysis_model: String? -- select (vision-capable only)
|
||||
system_prompt: String -- textarea (12 rows) + Save + Reset to Default
|
||||
}
|
||||
|
||||
value SettingsPublishingSection {
|
||||
ssh_mode: String -- select: scp | rsync
|
||||
ssh_host: String -- text input
|
||||
ssh_username: String -- text input
|
||||
ssh_remote_path: String -- text input
|
||||
}
|
||||
|
||||
value SettingsMCPSection {
|
||||
status: String -- port number or "Not running"
|
||||
agents: List<MCPAgentRow>
|
||||
}
|
||||
|
||||
value MCPAgentRow {
|
||||
agent_name: String -- Claude Code, Claude Desktop, GitHub Copilot, etc.
|
||||
is_installed: Boolean -- Add/Remove toggle
|
||||
}
|
||||
|
||||
invariant SettingsProtectedCategories {
|
||||
-- Protected categories (article, aside, page, picture) cannot be deleted.
|
||||
-- Their Remove button is disabled.
|
||||
}
|
||||
|
||||
invariant SettingsMCPAgents {
|
||||
-- MCP section has exactly 7 agent rows in this order:
|
||||
-- Claude Code, Claude Desktop, GitHub Copilot, Gemini CLI,
|
||||
-- OpenCode, Mistral Vibe, OpenAI Codex.
|
||||
}
|
||||
|
||||
config {
|
||||
settings_max_posts_per_page: Integer = 500
|
||||
settings_default_posts_per_page: Integer = 50
|
||||
settings_system_prompt_rows: Integer = 12
|
||||
}
|
||||
|
||||
surface SettingsViewSurface {
|
||||
context settings: SettingsView
|
||||
|
||||
exposes:
|
||||
settings.search_query
|
||||
settings.active_sections
|
||||
|
||||
provides:
|
||||
SettingsSearchChanged(query)
|
||||
SettingsProjectSaved(project_data)
|
||||
SettingsEditorSaved(editor_data)
|
||||
SettingsCategoryAdded(category_name)
|
||||
SettingsCategoryUpdated(category_row)
|
||||
SettingsCategoryRemoved(category_name)
|
||||
SettingsCategoriesResetToDefaults()
|
||||
SettingsAIApiKeySaved(provider, key)
|
||||
SettingsAIModelRefreshRequested(endpoint)
|
||||
SettingsAISystemPromptSaved(prompt)
|
||||
SettingsAISystemPromptReset()
|
||||
SettingsPublishingSaved(publishing_data)
|
||||
SettingsPublishingCleared()
|
||||
SettingsMCPAgentToggled(agent_name)
|
||||
SettingsRebuildRequested(entity_type)
|
||||
SettingsRegenerateThumbnailsRequested()
|
||||
SettingsOpenDataFolderRequested()
|
||||
StyleThemeSelected(theme_name)
|
||||
StyleApplyRequested(theme_name)
|
||||
|
||||
@guarantee SearchBar
|
||||
-- Search input in header filters sections by keyword match.
|
||||
-- "No results" message with clear button when no sections match.
|
||||
|
||||
@guarantee ProjectSection
|
||||
-- Section 1: Project Name, Description (textarea 3 rows), Data Path (text + Browse + Reset),
|
||||
-- Public URL, Main Language (select), Blog Languages (checkbox grid, main disabled),
|
||||
-- Default Author, Max Posts Per Page (number 1-500),
|
||||
-- Blogmark Category (select), Blogmark Bookmarklet (copy button), Save button.
|
||||
|
||||
@guarantee BookmarkletCopy
|
||||
-- Copy button copies bookmarklet JavaScript to clipboard.
|
||||
-- Bookmarklet uses project's publicUrl to construct POST endpoint.
|
||||
|
||||
@guarantee EditorSection
|
||||
-- Section 2: Default Editor Mode (select: WYSIWYG/Markdown/Preview),
|
||||
-- Diff View Style (select: Inline/Side-by-side),
|
||||
-- Wrap Long Lines (checkbox), Hide Unchanged Regions (checkbox).
|
||||
|
||||
@guarantee ContentCategoriesSection
|
||||
-- Section 3: Table with columns: Category, Title, Render in Lists,
|
||||
-- Show Titles, Post Template, List Template, Remove.
|
||||
-- Protected categories (article, aside, page, picture) cannot be deleted.
|
||||
-- Add Category: text input + Add button, validates non-empty and unique.
|
||||
-- "Reset to Defaults" restores default categories set.
|
||||
|
||||
@guarantee AISection
|
||||
-- Section 4: Anthropic API key, Mistral API key (both masked + Change/Save),
|
||||
-- Ollama toggle (with model capabilities table: tools/vision),
|
||||
-- LM Studio toggle (with capabilities table),
|
||||
-- Offline mode toggle (with dedicated model selectors for chat/title/image),
|
||||
-- Default model (with catalog info: max output, context window),
|
||||
-- Title model, Image analysis model,
|
||||
-- System prompt (textarea config.settings_system_prompt_rows rows + Save + Reset).
|
||||
|
||||
@guarantee AIApiKeyValidation
|
||||
-- On Save: test API call to endpoint before persisting.
|
||||
-- On failure: toast error message, key not saved.
|
||||
-- On success: key encrypted via SecureKeyStore, masked display shown.
|
||||
|
||||
@guarantee AIModelRefresh
|
||||
-- Refresh Models button per endpoint fetches model list from URL.
|
||||
-- Model selector populated from fetched list.
|
||||
-- Per-model info: max output tokens, context window (when available).
|
||||
|
||||
@guarantee TechnologySection
|
||||
-- Section 5: scripting capabilities are configured at the application level;
|
||||
-- this section does not expose implementation-specific runtime choices.
|
||||
-- Semantic Similarity toggle.
|
||||
|
||||
@guarantee PublishingSection
|
||||
-- Section 6: SSH Mode (scp/rsync), Host, Username, Remote Path.
|
||||
-- Save + Clear buttons.
|
||||
|
||||
@guarantee MCPSection
|
||||
-- Section 7: Status badge (port or "Not running").
|
||||
-- 7 agent rows with Add/Remove toggle each.
|
||||
|
||||
@guarantee DataMaintenanceSection
|
||||
-- Section 8: 6 rebuild buttons (Posts from Files, Media from Files,
|
||||
-- Scripts from Files, Templates from Files, Links,
|
||||
-- Regenerate Missing Thumbnails).
|
||||
-- Open Data Folder button.
|
||||
-- Each rebuild executes immediately (no confirmation).
|
||||
-- Runs as background task with progress in Tasks panel.
|
||||
|
||||
@guarantee CollapsibleSections
|
||||
-- All 8 sections are collapsible.
|
||||
-- Section visibility respects search filter.
|
||||
}
|
||||
|
||||
-- ─── Settings view actions ──────────────────────────────────
|
||||
|
||||
rule SettingsRebuild {
|
||||
when: SettingsRebuildRequested(entity_type)
|
||||
-- entity_type: posts | media | scripts | templates | links | thumbnails
|
||||
-- Executes immediately (no confirmation dialog)
|
||||
-- Runs as background task with progress visible in Tasks panel
|
||||
-- On completion: wholesale replaces store data for that entity type
|
||||
-- Sidebar and editors re-render with fresh data
|
||||
}
|
||||
|
||||
-- ─── Style view ───────────────────────────────────────────────
|
||||
|
||||
value StyleView {
|
||||
themes: List<StyleTheme>
|
||||
selected_theme: String?
|
||||
applied_theme: String?
|
||||
preview_mode: String -- auto | light | dark
|
||||
}
|
||||
|
||||
value StyleTheme {
|
||||
name: String
|
||||
accent_color: String -- hex
|
||||
light_bg_color: String -- hex
|
||||
dark_bg_color: String -- hex
|
||||
}
|
||||
|
||||
surface StyleViewSurface {
|
||||
context style: StyleView
|
||||
|
||||
exposes:
|
||||
for t in style.themes:
|
||||
t.name
|
||||
t.accent_color
|
||||
t.light_bg_color
|
||||
t.dark_bg_color
|
||||
style.selected_theme
|
||||
style.applied_theme
|
||||
style.preview_mode
|
||||
|
||||
provides:
|
||||
StyleThemeSelected(theme_name)
|
||||
StyleApplyRequested(style.selected_theme)
|
||||
when style.selected_theme != style.applied_theme
|
||||
StylePreviewModeChanged(mode)
|
||||
|
||||
@guarantee ThemePicker
|
||||
-- Grid of theme buttons (one per Pico CSS theme).
|
||||
-- Each button: swatch with 3 colour tones (accent, light bg, dark bg) + theme name.
|
||||
-- Selected theme highlighted with aria-pressed.
|
||||
|
||||
@guarantee ControlsRow
|
||||
-- Preview Mode dropdown (Auto/Light/Dark).
|
||||
-- Apply Theme button, disabled when selected_theme matches applied_theme.
|
||||
|
||||
@guarantee LivePreview
|
||||
-- Iframe at 127.0.0.1:4123/__style-preview with theme and mode query params.
|
||||
-- Updates live on selection or preview mode change.
|
||||
|
||||
@guarantee PreviewModeLocal
|
||||
-- Preview mode dropdown controls iframe query param only.
|
||||
-- Does not persist — local UI state only.
|
||||
}
|
||||
|
||||
rule StyleThemeSelect {
|
||||
when: StyleThemeSelected(theme_name)
|
||||
-- Updates iframe preview immediately (query param change)
|
||||
-- Does NOT persist until Apply is clicked
|
||||
}
|
||||
|
||||
rule StyleApplyTheme {
|
||||
when: StyleApplyRequested(theme_name)
|
||||
-- Persists picoTheme to project metadata (meta/project.json)
|
||||
-- See engine_side_effects.allium UpdateProjectMetadataSideEffects
|
||||
}
|
||||
118
specs/editor_tags.allium
Normal file
118
specs/editor_tags.allium
Normal file
@@ -0,0 +1,118 @@
|
||||
-- allium: 1
|
||||
-- bDS Tags View
|
||||
-- Scope: UI content area — tag management surface
|
||||
-- Distilled from: TagsView.tsx
|
||||
|
||||
-- Describes the layout and behaviour of the tags view.
|
||||
|
||||
use "./tag.allium" as tag
|
||||
use "./i18n.allium" as i18n
|
||||
|
||||
-- ─── Tags view ────────────────────────────────────────────────
|
||||
|
||||
value TagsView {
|
||||
cloud_tags: List<TagCloudItem>
|
||||
selected_tags: List<String> -- multi-select from cloud
|
||||
edit_form: TagEditForm? -- populated when exactly 1 tag selected
|
||||
merge_source_tags: List<String> -- 2+ selected tags for merge
|
||||
merge_target: String? -- target tag for merge
|
||||
}
|
||||
|
||||
value TagCloudItem {
|
||||
name: String
|
||||
count: Integer
|
||||
color: String?
|
||||
}
|
||||
|
||||
value TagEditForm {
|
||||
name: String -- editable text input
|
||||
color: String? -- colour picker value
|
||||
post_template_slug: String? -- select from available templates
|
||||
}
|
||||
|
||||
value ColourPickerPopover {
|
||||
presets: List<String> -- 17 hex colour values
|
||||
custom_hex: String?
|
||||
selected: String?
|
||||
}
|
||||
|
||||
surface ColourPickerPopoverSurface {
|
||||
context popover: ColourPickerPopover
|
||||
|
||||
exposes:
|
||||
popover.presets
|
||||
popover.custom_hex when popover.custom_hex != null
|
||||
popover.selected when popover.selected != null
|
||||
}
|
||||
|
||||
config {
|
||||
colour_picker_preset_count: Integer = 17
|
||||
tag_cloud_min_font: String = "0.85rem"
|
||||
tag_cloud_max_font: String = "1.8rem"
|
||||
}
|
||||
|
||||
surface TagsViewSurface {
|
||||
context view: TagsView
|
||||
|
||||
exposes:
|
||||
for t in view.cloud_tags:
|
||||
t.name
|
||||
t.count
|
||||
t.color
|
||||
view.selected_tags
|
||||
view.edit_form when view.edit_form != null
|
||||
view.merge_target when view.merge_target != null
|
||||
|
||||
provides:
|
||||
TagCloudItemClicked(tag_name)
|
||||
TagCreateRequested(name, color)
|
||||
TagSaveRequested(name, color, post_template_slug)
|
||||
when view.edit_form != null
|
||||
TagDeleteRequested(tag_name)
|
||||
when view.selected_tags.count = 1
|
||||
TagMergeRequested(source_tags, target_tag)
|
||||
when view.selected_tags.count >= 2
|
||||
TagSyncRequested()
|
||||
|
||||
@guarantee CloudSection
|
||||
-- Tag cloud visualisation (section #1).
|
||||
-- Tags sized by post count (config.tag_cloud_min_font to config.tag_cloud_max_font).
|
||||
-- Tags with colours get coloured background with contrast text.
|
||||
-- Multi-select: click to toggle selection. Selection count shown with clear button.
|
||||
-- Click selects tag for editing in Manage section.
|
||||
|
||||
@guarantee ManageSection
|
||||
-- Create/Edit section (section #2).
|
||||
-- Create form: name input + colour picker + Create button.
|
||||
-- Edit form (when exactly 1 tag selected): name input, colour picker,
|
||||
-- post template select dropdown (optional), Save/Cancel buttons.
|
||||
-- Delete button visible when exactly 1 tag selected.
|
||||
|
||||
@guarantee ColourPicker
|
||||
-- Inline popover positioned below tag colour field.
|
||||
-- Grid of config.colour_picker_preset_count preset colour swatches.
|
||||
-- Below grid: custom hex input field (#RRGGBB).
|
||||
-- Selection is immediate — no confirm/cancel buttons.
|
||||
-- Choosing a colour updates the form's colour field live.
|
||||
|
||||
@guarantee MergeSection
|
||||
-- Merge section (section #3). Requires 2+ selected tags.
|
||||
-- Target tag select dropdown (single select from selected tags).
|
||||
-- Merge button opens ConfirmDialog:
|
||||
-- "Merge N tags into {target}? This cannot be undone."
|
||||
-- Shows preview of "tags to delete" (all selected except target).
|
||||
|
||||
@guarantee SyncSection
|
||||
-- Sync/Discover section (section #4).
|
||||
-- "Discover" button rewrites tags.json from current DB state.
|
||||
|
||||
@guarantee DeleteConfirmation
|
||||
-- Delete opens ConfirmDialog showing post count:
|
||||
-- "This tag is used in N posts. Delete anyway?"
|
||||
-- On confirm: background task removes tag from all posts,
|
||||
-- rewrites published .md files, deletes tag, writes tags.json.
|
||||
|
||||
@guarantee MergeExecution
|
||||
-- On merge confirm: background task updates all affected posts,
|
||||
-- rewrites published .md files, deletes source tags, writes tags.json.
|
||||
}
|
||||
87
specs/editor_template.allium
Normal file
87
specs/editor_template.allium
Normal file
@@ -0,0 +1,87 @@
|
||||
-- allium: 1
|
||||
-- bDS Template Editor View
|
||||
-- Scope: UI content area — template editing surface
|
||||
-- Distilled from: TemplateEditor.tsx
|
||||
|
||||
-- Describes the layout and behaviour of the template editor.
|
||||
-- See template.allium for entity model and Liquid subset.
|
||||
|
||||
use "./template.allium" as template
|
||||
use "./i18n.allium" as i18n
|
||||
|
||||
-- ─── Template editor view ─────────────────────────────────────
|
||||
|
||||
value TemplateEditorView {
|
||||
template_id: String
|
||||
title: String -- editable text input
|
||||
slug: String -- editable text input, auto-generated from title
|
||||
kind: String -- select: post | list | not_found | partial
|
||||
enabled: Boolean -- checkbox
|
||||
content: String -- code editor content
|
||||
created_at: String -- locale-formatted date
|
||||
updated_at: String -- locale-formatted date
|
||||
}
|
||||
|
||||
surface TemplateEditorSurface {
|
||||
context editor: TemplateEditorView
|
||||
|
||||
exposes:
|
||||
editor.title
|
||||
editor.slug
|
||||
editor.kind
|
||||
editor.enabled
|
||||
editor.created_at
|
||||
editor.updated_at
|
||||
|
||||
provides:
|
||||
TemplateSaveRequested(editor.template_id)
|
||||
TemplateValidateRequested(editor.template_id)
|
||||
TemplateDeleteRequested(editor.template_id)
|
||||
|
||||
@guarantee HeaderLayout
|
||||
-- Header bar with template title tab.
|
||||
-- Actions (right side): Save button, Validate button,
|
||||
-- Delete button (danger style).
|
||||
|
||||
@guarantee MetadataRow
|
||||
-- Two rows of metadata fields above the editor.
|
||||
-- Row 1: Title (text input), Slug (text input, auto-generated from title).
|
||||
-- Row 2: Kind (select: post/list/not-found/partial), Enabled (checkbox).
|
||||
|
||||
@guarantee EditorBody
|
||||
-- Code editor with HTML/Liquid syntax highlighting.
|
||||
-- Toolbar: "Content" label.
|
||||
-- Syntax errors shown inline in editor on validation.
|
||||
|
||||
@guarantee FooterLayout
|
||||
-- Created date and Updated date, locale-formatted.
|
||||
}
|
||||
|
||||
-- ─── Template editor actions ────────────────────────────────
|
||||
|
||||
rule TemplateSave {
|
||||
when: TemplateSaveRequested(template_id)
|
||||
-- Liquid validation first (parse check for syntax errors)
|
||||
-- If invalid: blocks save, shows error inline in editor
|
||||
-- If valid: bumps version, saves to DB + rewrites .liquid file
|
||||
-- See engine_side_effects.allium UpdateTemplateSideEffects
|
||||
}
|
||||
|
||||
rule TemplateValidate {
|
||||
when: TemplateValidateRequested(template_id)
|
||||
-- Validates Liquid syntax without saving
|
||||
-- Shows errors inline in editor
|
||||
-- Success shown as toast or status indicator
|
||||
}
|
||||
|
||||
rule TemplateDelete {
|
||||
when: TemplateDeleteRequested(template_id)
|
||||
-- Checks for references: posts using this template, tags with postTemplateSlug
|
||||
-- If references exist: system confirm dialog
|
||||
-- "This template is used by N posts and M tags. Force delete?"
|
||||
-- Force delete: nulls templateSlug on referencing posts,
|
||||
-- nulls postTemplateSlug on referencing tags
|
||||
-- If no references: deletes without confirmation
|
||||
-- Deletes DB record + .liquid file on disk
|
||||
-- Closes template tab, sidebar removes item
|
||||
}
|
||||
226
specs/embedding.allium
Normal file
226
specs/embedding.allium
Normal file
@@ -0,0 +1,226 @@
|
||||
-- allium: 1
|
||||
-- bDS Semantic Similarity / Embeddings
|
||||
-- Scope: extension (Bucket D — Embeddings + Duplicate Detection)
|
||||
-- Distilled from: src/main/engine/EmbeddingEngine.ts
|
||||
|
||||
-- Local embedding model for semantic similarity. Runs entirely on-device,
|
||||
-- independent of AI endpoints. Gated by semanticSimilarityEnabled project setting.
|
||||
|
||||
use "./post.allium" as post
|
||||
use "./tag.allium" as tag
|
||||
|
||||
surface EmbeddingModelSurface {
|
||||
context model: EmbeddingModel
|
||||
|
||||
exposes:
|
||||
model.model_id
|
||||
model.dimensions
|
||||
}
|
||||
|
||||
surface EmbeddingRuntimeSurface {
|
||||
facing _: EmbeddingRuntime
|
||||
|
||||
provides:
|
||||
PostCreated(post)
|
||||
PostUpdated(post)
|
||||
PostDeleted(post)
|
||||
}
|
||||
|
||||
surface EmbeddingControlSurface {
|
||||
facing _: EmbeddingOperator
|
||||
|
||||
provides:
|
||||
ReindexAllRequested(project)
|
||||
IndexUnindexedRequested(project)
|
||||
FindSimilarRequested(post, limit)
|
||||
ComputeSimilaritiesRequested(source_post, target_post_ids)
|
||||
SuggestTagsRequested(post, input_text)
|
||||
FindDuplicatesRequested(project)
|
||||
DismissDuplicatePairRequested(post_a, post_b)
|
||||
}
|
||||
|
||||
-- ─── Model ──────────────────────────────────────────────────
|
||||
|
||||
value EmbeddingModel {
|
||||
-- multilingual-e5-small: 384-dimensional sentence embeddings
|
||||
-- Model files are obtained from an external model source and cached locally
|
||||
-- Downloaded on first use, cached in app data directory
|
||||
-- Lazy-loaded: pipeline created on first embedding request, not at startup
|
||||
-- Text preprocessing: prefix all input with "query: " (e5 convention)
|
||||
-- Pooling: mean pooling + L2 normalization
|
||||
model_id: String -- "Xenova/multilingual-e5-small"
|
||||
dimensions: Integer -- 384
|
||||
}
|
||||
|
||||
value EmbeddingVector {
|
||||
dimensions: Integer -- 384 (multilingual-e5-small)
|
||||
values: List<Decimal>
|
||||
}
|
||||
|
||||
-- ─── Entities ───────────────────────────────────────────────
|
||||
|
||||
entity EmbeddingKey {
|
||||
label: Integer -- HNSW label for USearch
|
||||
post: post/Post
|
||||
content_hash: String -- SHA-256 of "{title}\n\n{content}"
|
||||
vector: EmbeddingVector
|
||||
}
|
||||
|
||||
entity DismissedDuplicatePair {
|
||||
post_a: post/Post
|
||||
post_b: post/Post
|
||||
-- IDs stored in canonical order (sorted) for dedup
|
||||
}
|
||||
|
||||
-- ─── USearch HNSW Index ─────────────────────────────────────
|
||||
|
||||
config {
|
||||
model_id: String = "Xenova/multilingual-e5-small"
|
||||
embedding_dimensions: Integer = 384
|
||||
hnsw_metric: String = "cosine"
|
||||
hnsw_connectivity: Integer = 16 -- M parameter
|
||||
hnsw_expansion_add: Integer = 128 -- efConstruction
|
||||
hnsw_expansion_search: Integer = 64 -- efSearch
|
||||
debounce_persist: Duration = 5.seconds
|
||||
-- Index file: {userData}/projects/{projectId}/embeddings.usearch
|
||||
-- Key mapping is persisted alongside the embedding records
|
||||
}
|
||||
|
||||
-- ─── Gating ─────────────────────────────────────────────────
|
||||
|
||||
invariant SemanticSimilarityGate {
|
||||
-- All embedding operations are gated by semanticSimilarityEnabled in project metadata.
|
||||
-- When disabled: no posts are indexed, queries return empty results.
|
||||
-- When toggled on: triggers IndexUnindexed to backfill all posts.
|
||||
-- When toggled off: index remains on disk but is not queried.
|
||||
}
|
||||
|
||||
-- ─── Event-driven indexing ──────────────────────────────────
|
||||
|
||||
-- Post lifecycle events trigger embedding updates automatically.
|
||||
-- See engine_side_effects.allium for the trigger points.
|
||||
|
||||
rule EmbedPost {
|
||||
when: PostCreated(post) or PostUpdated(post)
|
||||
requires: semantic_similarity_enabled
|
||||
let hash = sha256(format("{title}\n\n{content}", title: post.title, content: post.content))
|
||||
let existing = EmbeddingKey{post: post}
|
||||
if not exists existing or existing.content_hash != hash:
|
||||
-- Compute embedding vector via local model
|
||||
-- Upsert into USearch index + embedding_keys DB table
|
||||
-- Debounced index save (5s)
|
||||
ensures: EmbeddingKeyUpdated(post)
|
||||
}
|
||||
|
||||
rule RemovePostEmbedding {
|
||||
when: PostDeleted(post)
|
||||
requires: semantic_similarity_enabled
|
||||
ensures: EmbeddingKeyRemoved(post)
|
||||
}
|
||||
|
||||
-- ─── Batch indexing ─────────────────────────────────────────
|
||||
|
||||
rule ReindexAll {
|
||||
when: ReindexAllRequested(project)
|
||||
requires: semantic_similarity_enabled
|
||||
-- Re-embeds all posts, rebuilds HNSW index from scratch
|
||||
for p in project.posts:
|
||||
ensures: EmbeddingKeyUpdated(p)
|
||||
ensures: HnswIndexRebuilt(project)
|
||||
}
|
||||
|
||||
rule IndexUnindexed {
|
||||
when: IndexUnindexedRequested(project)
|
||||
requires: semantic_similarity_enabled
|
||||
-- Triggered at app startup (if enabled) and when setting toggled on
|
||||
-- Only embeds posts without existing embeddings or with changed content_hash
|
||||
-- Runs as background task with progress reporting
|
||||
for p in project.posts:
|
||||
let existing = EmbeddingKey{post: p}
|
||||
if not exists existing or existing.content_hash != p.checksum:
|
||||
ensures: EmbeddingKeyUpdated(p)
|
||||
}
|
||||
|
||||
-- ─── Query operations ───────────────────────────────────────
|
||||
|
||||
rule FindSimilar {
|
||||
when: FindSimilarRequested(post, limit)
|
||||
requires: semantic_similarity_enabled
|
||||
-- HNSW approximate nearest neighbor search via USearch
|
||||
-- Searches index for (limit + 1) neighbors, excludes self
|
||||
-- Converts USearch cosine distance to similarity: max(0, 1 - distance)
|
||||
-- Returns ranked list sorted by descending similarity
|
||||
ensures: SimilarPostsResult(post, ranked_matches)
|
||||
}
|
||||
|
||||
rule ComputeSimilarities {
|
||||
when: ComputeSimilaritiesRequested(source_post, target_post_ids)
|
||||
requires: semantic_similarity_enabled
|
||||
-- Exact pairwise cosine similarity between source vector and each target vector
|
||||
-- Uses in-memory vector cache, NOT USearch search
|
||||
-- Returns map of post_id -> similarity score
|
||||
-- Used by InsertPostLinkModal to rank FTS search results
|
||||
ensures: SimilarityScoresResult(source_post, scores)
|
||||
}
|
||||
|
||||
rule SuggestTags {
|
||||
when: SuggestTagsRequested(post, input_text)
|
||||
requires: semantic_similarity_enabled
|
||||
-- 1. Find 10 most similar posts via HNSW search
|
||||
-- 2. Collect all tags from those posts
|
||||
-- 3. Weight tags by similarity score of the post they came from
|
||||
-- 4. Return top 5 tags by weighted score
|
||||
-- Used by tag input component for autocomplete suggestions
|
||||
ensures: TagSuggestionResult(post, suggested_tags)
|
||||
}
|
||||
|
||||
rule FindDuplicates {
|
||||
when: FindDuplicatesRequested(project)
|
||||
requires: semantic_similarity_enabled
|
||||
-- Finds near-duplicate post pairs above similarity threshold
|
||||
-- For each indexed post: search 21 nearest neighbors
|
||||
-- Pairs above 0.92 threshold kept, dismissed pairs excluded
|
||||
-- At 100% embedding similarity: loads post bodies for exact match check
|
||||
-- Results sorted: exact matches first, then descending similarity
|
||||
let all_pairs = compute_all_similarities(project)
|
||||
let above_threshold = filter_above_threshold(all_pairs)
|
||||
let pairs = exclude_dismissed(above_threshold, DismissedDuplicatePairs)
|
||||
ensures: DuplicateReport(pairs)
|
||||
}
|
||||
|
||||
rule DismissDuplicatePair {
|
||||
when: DismissDuplicatePairRequested(post_a, post_b)
|
||||
-- Stores with canonical ID ordering for consistent dedup
|
||||
ensures: DismissedDuplicatePair.created(post_a: post_a, post_b: post_b)
|
||||
}
|
||||
|
||||
-- ─── Invariants ─────────────────────────────────────────────
|
||||
|
||||
invariant ContentHashSkipsUnchanged {
|
||||
-- If a post's content_hash matches the stored embedding's content_hash,
|
||||
-- the post is not re-embedded. This makes bulk re-indexing efficient.
|
||||
}
|
||||
|
||||
invariant DebouncedPersistence {
|
||||
-- USearch index persistence is debounced at 5 seconds
|
||||
-- Prevents excessive disk I/O during bulk operations
|
||||
-- Index also force-saved on project switch and app shutdown
|
||||
}
|
||||
|
||||
invariant VectorCacheInDb {
|
||||
-- Vector cache persisted as BLOB in embedding_keys table
|
||||
-- Float32Array, 384 dimensions per vector (1536 bytes)
|
||||
-- Enables instant reload without re-embedding
|
||||
}
|
||||
|
||||
invariant ModelCaching {
|
||||
-- Model files (~100 MB) downloaded from Hugging Face Hub on first use
|
||||
-- Cached in app data directory, persists across sessions
|
||||
-- Model pipeline stays loaded across project switches (one model, many indexes)
|
||||
}
|
||||
|
||||
invariant ProjectIsolation {
|
||||
-- Each project has its own USearch index file and embedding_keys rows
|
||||
-- On project switch: save current index, load new project's index
|
||||
-- Model pipeline shared across projects (not reloaded)
|
||||
}
|
||||
314
specs/engine_side_effects.allium
Normal file
314
specs/engine_side_effects.allium
Normal file
@@ -0,0 +1,314 @@
|
||||
-- allium: 1
|
||||
-- bDS Engine-Level Save Side-Effects
|
||||
-- Scope: cross-cutting (all waves)
|
||||
-- Distilled from: PostEngine.ts, MediaEngine.ts, TemplateEngine.ts,
|
||||
-- ScriptEngine.ts, MetaEngine.ts, TagEngine.ts
|
||||
|
||||
-- When an entity is saved/published/deleted in the engine layer, a chain
|
||||
-- of automatic side-effects fires. These are NOT UI-level concerns —
|
||||
-- they happen in the backend regardless of which UI triggered them.
|
||||
|
||||
use "./post.allium" as post
|
||||
use "./media.allium" as media
|
||||
|
||||
-- ─── External surfaces ──────────────────────────────────────
|
||||
|
||||
-- Engine-level events emitted after backend operations complete.
|
||||
-- These are NOT direct user actions — they fire as side-effects
|
||||
-- of user operations processed by the engine layer.
|
||||
|
||||
surface Engine {
|
||||
provides: PostCreated(post)
|
||||
provides: PostUpdated(post, changes)
|
||||
provides: PostPublished(post)
|
||||
provides: PostDeleted(post)
|
||||
provides: PostChangesDiscarded(post)
|
||||
provides: MediaImported(media)
|
||||
provides: MediaUpdated(media, changes)
|
||||
provides: MediaFileReplaced(media, new_file)
|
||||
provides: MediaDeleted(media)
|
||||
provides: TemplateCreated(template)
|
||||
provides: TemplateUpdated(template, changes)
|
||||
provides: TemplatePublished(template)
|
||||
provides: TemplateDeleted(template, force)
|
||||
provides: TagDeleted(tag)
|
||||
provides: TagRenamed(old_name, new_name)
|
||||
provides: TagsMerged(source_tags, target_tag)
|
||||
provides: ProjectMetadataUpdated(metadata)
|
||||
provides: CategoryAdded(name)
|
||||
provides: CategoryRemoved(name)
|
||||
provides: PublishingPreferencesUpdated(prefs)
|
||||
provides: PostTranslationUpserted(translation, source_post)
|
||||
provides: MediaTranslationUpserted(translation, media)
|
||||
provides: MediaTranslationDeleted(media, language)
|
||||
}
|
||||
|
||||
-- ─── Post operations ─────────────────────────────────────
|
||||
|
||||
rule CreatePostSideEffects {
|
||||
when: PostCreated(post)
|
||||
ensures: FTSIndexUpdated(post)
|
||||
ensures: EmbeddingUpdated(post)
|
||||
-- No file written (draft lives in DB)
|
||||
}
|
||||
|
||||
rule UpdatePostSideEffects {
|
||||
when: PostUpdated(post, changes)
|
||||
-- If post is published and content/metadata changes:
|
||||
-- auto-transition status back to draft
|
||||
-- If slug changed and file exists: rename .md file
|
||||
-- If templateSlug changed on published post: rewrite .md frontmatter
|
||||
ensures: FTSIndexUpdated(post)
|
||||
if changes.content:
|
||||
ensures: PostLinksUpdated(post)
|
||||
-- Parses markdown/HTML links, resolves slugs to post IDs,
|
||||
-- replaces outgoing link rows
|
||||
ensures: EmbeddingUpdated(post)
|
||||
}
|
||||
|
||||
rule PublishPostSideEffects {
|
||||
when: PostPublished(post)
|
||||
ensures: PostFileWritten(post)
|
||||
-- posts/YYYY/MM/{slug}.md with YAML frontmatter
|
||||
if old_file_path != new_file_path:
|
||||
ensures: OldPostFileDeleted(old_file_path)
|
||||
ensures: post.content = null
|
||||
-- Content cleared from DB; lives in filesystem only
|
||||
ensures: FTSIndexUpdated(post)
|
||||
ensures: PostLinksUpdated(post)
|
||||
-- Also publishes all translations:
|
||||
for t in post.translations:
|
||||
ensures: TranslationFileWritten(t)
|
||||
ensures: t.content = null
|
||||
ensures: EmbeddingUpdated(post)
|
||||
}
|
||||
|
||||
rule DeletePostSideEffects {
|
||||
when: PostDeleted(post)
|
||||
if post.file_path != "":
|
||||
ensures: PostFileDeleted(post.file_path)
|
||||
ensures: PostLinksDeleted(post)
|
||||
-- Deletes both source and target link rows
|
||||
for media_link in post.linked_media:
|
||||
ensures: MediaSidecarUpdated(media_link.media_id)
|
||||
-- Removes post from media sidecar's linkedPostIds
|
||||
ensures: FTSIndexDeleted(post)
|
||||
ensures: EmbeddingRemoved(post)
|
||||
}
|
||||
|
||||
rule DiscardPostChangesSideEffects {
|
||||
when: PostChangesDiscarded(post)
|
||||
-- Reads published version from file, restores DB metadata,
|
||||
-- sets content=null, status=published
|
||||
ensures: FTSIndexUpdated(post)
|
||||
}
|
||||
|
||||
-- ─── Media operations ────────────────────────────────────
|
||||
|
||||
rule ImportMediaSideEffects {
|
||||
when: MediaImported(media)
|
||||
ensures: MediaFileWritten(media)
|
||||
-- media/YYYY/MM/{uuid}.{ext}
|
||||
ensures: SidecarFileWritten(media)
|
||||
-- {path}.meta with YAML-like metadata
|
||||
if media.is_image:
|
||||
ensures: ThumbnailsGenerated(media)
|
||||
-- small=150px, medium=400px, large=800px, ai=448x448
|
||||
-- Asynchronous, emits thumbnailsGenerated on completion
|
||||
ensures: FTSIndexUpdated(media)
|
||||
}
|
||||
|
||||
rule UpdateMediaSideEffects {
|
||||
when: MediaUpdated(media, changes)
|
||||
ensures: SidecarFileRewritten(media)
|
||||
-- Preserves fields caller didn't set (linkedPostIds, author)
|
||||
ensures: FTSIndexUpdated(media)
|
||||
}
|
||||
|
||||
rule ReplaceMediaFileSideEffects {
|
||||
when: MediaFileReplaced(media, new_file)
|
||||
-- Copies new file over existing path
|
||||
if media.is_image:
|
||||
ensures: ThumbnailsRegenerated(media)
|
||||
-- Synchronous (awaited), not fire-and-forget
|
||||
}
|
||||
|
||||
rule DeleteMediaSideEffects {
|
||||
when: MediaDeleted(media)
|
||||
ensures: MediaFileDeleted(media)
|
||||
ensures: SidecarFileDeleted(media)
|
||||
ensures: ThumbnailsDeleted(media)
|
||||
ensures: PostMediaLinksDeleted(media)
|
||||
ensures: MediaTranslationsDeleted(media)
|
||||
-- Also deletes all translated sidecar files: {path}.{lang}.meta
|
||||
ensures: FTSIndexDeleted(media)
|
||||
}
|
||||
|
||||
-- ─── Template operations ─────────────────────────────────
|
||||
|
||||
rule CreateTemplateSideEffects {
|
||||
when: TemplateCreated(template)
|
||||
ensures: TemplateFileWritten(template)
|
||||
-- templates/{slug}.liquid with YAML frontmatter
|
||||
}
|
||||
|
||||
rule UpdateTemplateSideEffects {
|
||||
when: TemplateUpdated(template, changes)
|
||||
ensures: template.version = template.version + 1
|
||||
-- DB-first update, then filesystem; rollback DB on filesystem failure
|
||||
if changes.slug:
|
||||
ensures: TemplateFileRenamed(template)
|
||||
ensures: CascadeSlugUpdate(template)
|
||||
-- Updates posts.templateSlug and tags.postTemplateSlug
|
||||
ensures: TemplateFileRewritten(template)
|
||||
}
|
||||
|
||||
rule PublishTemplateSideEffects {
|
||||
when: TemplatePublished(template)
|
||||
ensures: TemplateFileWritten(template)
|
||||
ensures: template.content = null
|
||||
-- Content cleared from DB
|
||||
}
|
||||
|
||||
rule DeleteTemplateSideEffects {
|
||||
when: TemplateDeleted(template, force)
|
||||
if has_references and not force:
|
||||
-- Return without deleting, report reference counts
|
||||
ensures: nothing
|
||||
if force:
|
||||
ensures: ReferencingPostsCleared(template)
|
||||
ensures: ReferencingTagsCleared(template)
|
||||
-- Nulls out templateSlug on posts, postTemplateSlug on tags
|
||||
ensures: TemplateFileDeleted(template)
|
||||
}
|
||||
|
||||
-- ─── Script operations ───────────────────────────────────
|
||||
|
||||
-- Same pattern as templates:
|
||||
-- Create: write published script file, insert DB
|
||||
-- Update: bump version, rewrite file, update DB
|
||||
-- Publish: write file, clear DB content
|
||||
-- Delete: delete file, delete DB row
|
||||
|
||||
-- ─── Tag operations ──────────────────────────────────────
|
||||
|
||||
rule DeleteTagSideEffects {
|
||||
when: TagDeleted(tag)
|
||||
-- Background task:
|
||||
-- For each post containing this tag:
|
||||
-- Remove tag from post's tags array in DB
|
||||
-- If published: rewrite .md file (syncPublishedPostFile)
|
||||
ensures: TagsJsonWritten()
|
||||
-- meta/tags.json updated
|
||||
}
|
||||
|
||||
rule RenameTagSideEffects {
|
||||
when: TagRenamed(old_name, new_name)
|
||||
-- Background task:
|
||||
-- For each post containing old_name:
|
||||
-- Replace old_name with new_name in tags array
|
||||
-- If published: rewrite .md file
|
||||
ensures: TagsJsonWritten()
|
||||
}
|
||||
|
||||
rule MergeTagsSideEffects {
|
||||
when: TagsMerged(source_tags, target_tag)
|
||||
-- Background task:
|
||||
-- For each source tag, for each post containing it:
|
||||
-- Replace source with target (dedup), update DB
|
||||
-- If published: rewrite .md file
|
||||
-- Delete all source tag rows
|
||||
ensures: TagsJsonWritten()
|
||||
}
|
||||
|
||||
-- ─── Settings/Metadata operations ────────────────────────
|
||||
|
||||
rule UpdateProjectMetadataSideEffects {
|
||||
when: ProjectMetadataUpdated(metadata)
|
||||
ensures: ProjectJsonWritten()
|
||||
-- meta/project.json (atomic write)
|
||||
ensures: CategoryMetaJsonWritten()
|
||||
-- meta/category-meta.json (atomic write)
|
||||
}
|
||||
|
||||
rule AddCategorySideEffects {
|
||||
when: CategoryAdded(name)
|
||||
ensures: ProjectJsonWritten()
|
||||
ensures: CategoryMetaJsonWritten()
|
||||
ensures: CategoriesJsonWritten()
|
||||
-- meta/categories.json
|
||||
}
|
||||
|
||||
rule RemoveCategorySideEffects {
|
||||
when: CategoryRemoved(name)
|
||||
ensures: ProjectJsonWritten()
|
||||
ensures: CategoryMetaJsonWritten()
|
||||
ensures: CategoriesJsonWritten()
|
||||
}
|
||||
|
||||
rule UpdatePublishingPreferencesSideEffects {
|
||||
when: PublishingPreferencesUpdated(prefs)
|
||||
ensures: PublishingJsonWritten()
|
||||
-- meta/publishing.json (atomic write)
|
||||
}
|
||||
|
||||
-- ─── Translation operations ──────────────────────────────
|
||||
|
||||
rule UpsertPostTranslationSideEffects {
|
||||
when: PostTranslationUpserted(translation, source_post)
|
||||
-- If source is published and this is a manual edit (not auto-publish):
|
||||
-- transition source post to draft (copies content from file to DB)
|
||||
if source_post.status = published and translation.is_manual_edit:
|
||||
ensures: source_post.status = draft
|
||||
ensures: source_post.content = read_file(source_post.file_path)
|
||||
-- If both translation and source are published:
|
||||
-- write translation file, clear translation content from DB
|
||||
if translation.status = published and source_post.status = published:
|
||||
ensures: TranslationFileWritten(translation)
|
||||
ensures: translation.content = null
|
||||
ensures: FTSIndexUpdated(source_post)
|
||||
-- FTS includes all translation content for the source post
|
||||
}
|
||||
|
||||
rule UpsertMediaTranslationSideEffects {
|
||||
when: MediaTranslationUpserted(translation, media)
|
||||
ensures: TranslatedSidecarWritten(media, translation.language)
|
||||
-- {path}.{lang}.meta
|
||||
}
|
||||
|
||||
rule DeleteMediaTranslationSideEffects {
|
||||
when: MediaTranslationDeleted(media, language)
|
||||
ensures: TranslatedSidecarDeleted(media, language)
|
||||
}
|
||||
|
||||
-- ─── CLI/MCP notification sync ───────────────────────────
|
||||
|
||||
-- When MCP CLI makes mutations, it writes to db_notifications table.
|
||||
-- NotificationWatcher polls DB file (chokidar, 100ms debounce):
|
||||
-- Reads unseen CLI notifications
|
||||
-- Calls engine.invalidate(entityId) if needed
|
||||
-- Sends entity:changed IPC event to renderer
|
||||
-- Marks rows as seen
|
||||
-- Prunes: >1h processed, >24h unprocessed
|
||||
|
||||
-- ─── Side-effect summary table ───────────────────────────
|
||||
|
||||
-- Operation | File Write | FTS | Links | Thumbs | Sidecar | Embed | JSON Meta
|
||||
-- -------------------|--------------|------|-------|--------|---------|-------|----------
|
||||
-- createPost | no (draft) | yes | no | no | no | yes | no
|
||||
-- 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
|
||||
-- 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
|
||||
-- createTemplate | .liquid | no | no | no | no | no | no
|
||||
-- updateTemplate | rewrite | no | no | no | no | no | no
|
||||
-- deleteTemplate | delete+casc | no | no | no | no | no | no
|
||||
-- deleteTag | sync posts | no | no | no | no | no | tags.json
|
||||
-- renameTag | sync posts | no | no | no | no | no | tags.json
|
||||
-- mergeTags | sync posts | no | no | no | no | no | tags.json
|
||||
-- updateMetadata | no | no | no | no | no | no | *.json
|
||||
-- addCategory | no | no | no | no | no | no | *.json
|
||||
-- * updatePost rewrites file only when templateSlug changes on published post
|
||||
394
specs/frontmatter.allium
Normal file
394
specs/frontmatter.allium
Normal file
@@ -0,0 +1,394 @@
|
||||
-- allium: 1
|
||||
-- bDS Frontmatter Specifications
|
||||
-- Scope: core (Wave 1 — exact file format compatibility)
|
||||
-- Distilled from: ../bDS/src/main/engine/postFileUtils.ts,
|
||||
-- TemplateEngine.ts, ScriptEngine.ts, MediaEngine.ts
|
||||
--
|
||||
-- This document specifies the exact YAML frontmatter format for all
|
||||
-- file types. The rewrite must read and write these formats compatibly
|
||||
-- with existing bDS content.
|
||||
|
||||
surface FrontmatterPersistenceSurface {
|
||||
facing _: ContentPersistenceRuntime
|
||||
|
||||
provides:
|
||||
PublishPostRequested(post)
|
||||
PublishTemplateRequested(template)
|
||||
PublishScriptRequested(script)
|
||||
}
|
||||
|
||||
surface PostFrontmatterSurface {
|
||||
context frontmatter: PostFrontmatter
|
||||
|
||||
exposes:
|
||||
frontmatter.id
|
||||
frontmatter.title
|
||||
frontmatter.slug
|
||||
frontmatter.status
|
||||
frontmatter.published_at
|
||||
frontmatter.tags
|
||||
frontmatter.categories
|
||||
}
|
||||
|
||||
surface MediaSidecarSurface {
|
||||
context sidecar: MediaSidecar
|
||||
|
||||
exposes:
|
||||
sidecar.id
|
||||
sidecar.original_name
|
||||
sidecar.mime_type
|
||||
sidecar.width
|
||||
sidecar.height
|
||||
sidecar.updated_at
|
||||
}
|
||||
|
||||
surface TemplateFrontmatterSurface {
|
||||
context frontmatter: TemplateFrontmatter
|
||||
|
||||
exposes:
|
||||
frontmatter.id
|
||||
frontmatter.slug
|
||||
frontmatter.kind
|
||||
frontmatter.enabled
|
||||
frontmatter.version
|
||||
}
|
||||
|
||||
surface ScriptFrontmatterSurface {
|
||||
context frontmatter: ScriptFrontmatter
|
||||
|
||||
exposes:
|
||||
frontmatter.id
|
||||
frontmatter.slug
|
||||
frontmatter.kind
|
||||
frontmatter.entrypoint
|
||||
frontmatter.enabled
|
||||
frontmatter.version
|
||||
}
|
||||
|
||||
surface MenuOpmlSurface {
|
||||
context document: MenuOpml
|
||||
|
||||
exposes:
|
||||
document.header.title
|
||||
document.header.date_created
|
||||
document.header.date_modified
|
||||
for item in document.body:
|
||||
item.kind
|
||||
item.label
|
||||
item.slug
|
||||
}
|
||||
|
||||
config {
|
||||
script_extension: String = "script"
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- POST FILE FORMAT
|
||||
-- ============================================================================
|
||||
|
||||
value PostFrontmatter {
|
||||
-- File path: posts/{YYYY}/{MM}/{slug}.md
|
||||
-- For translations: posts/{YYYY}/{MM}/{slug}.{language}.md
|
||||
id: String -- UUID v4
|
||||
title: String
|
||||
slug: String
|
||||
excerpt: String? -- Optional, only written if present
|
||||
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
|
||||
tags: List<String> -- Always written, even if empty
|
||||
categories: List<String> -- Always written, even if empty
|
||||
}
|
||||
|
||||
invariant PostFileLayout {
|
||||
-- Posts are stored in date-based directory structure
|
||||
-- YYYY and MM derived from created_at (zero-padded)
|
||||
for p in Posts where file_path != "":
|
||||
p.file_path = format("posts/{yyyy}/{mm}/{slug}.md",
|
||||
yyyy: p.created_at.year,
|
||||
mm: p.created_at.month_padded,
|
||||
slug: p.slug)
|
||||
}
|
||||
|
||||
invariant PostTranslationFileLayout {
|
||||
-- Translations use the same directory structure with language suffix
|
||||
for t in PostTranslations where file_path != "":
|
||||
t.file_path = format("posts/{yyyy}/{mm}/{slug}.{lang}.md",
|
||||
yyyy: t.canonical_post.created_at.year,
|
||||
mm: t.canonical_post.created_at.month_padded,
|
||||
slug: t.canonical_post.slug,
|
||||
lang: t.language)
|
||||
}
|
||||
|
||||
rule WritePostFile {
|
||||
when: PublishPostRequested(post)
|
||||
ensures: FileWritten(
|
||||
path: post.file_path,
|
||||
content: format_post_file(post)
|
||||
)
|
||||
ensures: post.content = null
|
||||
-- Content moved from DB to filesystem
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- MEDIA SIDECAR FORMAT
|
||||
-- ============================================================================
|
||||
|
||||
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)
|
||||
-- Note: 'filename' is NOT written to sidecar — it is implicit from the binary path
|
||||
id: String -- UUID v4
|
||||
original_name: String -- Original uploaded filename
|
||||
mime_type: String
|
||||
size: Integer -- Bytes
|
||||
width: Integer?
|
||||
height: Integer?
|
||||
title: String? -- Only written if present
|
||||
alt: String? -- Only written if present
|
||||
caption: String? -- Only written if present
|
||||
author: String? -- Only written if present
|
||||
language: String? -- Only written if present
|
||||
tags: List<String> -- Always written, even if empty
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
}
|
||||
|
||||
invariant MediaSidecarLayout {
|
||||
for m in Media:
|
||||
m.sidecar_path = format("{binary_path}.meta", binary_path: m.file_path)
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- TEMPLATE FILE FORMAT
|
||||
-- ============================================================================
|
||||
|
||||
value TemplateFrontmatter {
|
||||
-- File path: templates/{slug}.liquid
|
||||
id: String -- UUID v4
|
||||
slug: String
|
||||
title: String
|
||||
kind: post | list | not_found | partial
|
||||
enabled: Boolean
|
||||
version: Integer
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
}
|
||||
|
||||
rule WriteTemplateFile {
|
||||
when: PublishTemplateRequested(template)
|
||||
requires: ValidateLiquid(template.content) = valid
|
||||
ensures: FileWritten(
|
||||
path: format("templates/{slug}.liquid", slug: template.slug),
|
||||
content: format_template_file(template)
|
||||
)
|
||||
ensures: template.content = null
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- SCRIPT FILE FORMAT
|
||||
-- ============================================================================
|
||||
|
||||
value ScriptFrontmatter {
|
||||
-- File path: scripts/{slug}.{extension}
|
||||
-- YAML frontmatter delimited by --- markers
|
||||
id: String -- UUID v4
|
||||
slug: String
|
||||
title: String
|
||||
kind: macro | utility | transform
|
||||
entrypoint: String -- Default: "render"
|
||||
enabled: Boolean
|
||||
version: Integer
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
}
|
||||
|
||||
rule WriteScriptFile {
|
||||
when: PublishScriptRequested(script)
|
||||
requires: ValidateScript(script.content) = valid
|
||||
ensures: FileWritten(
|
||||
path: format("scripts/{slug}.{extension}", slug: script.slug, extension: config.script_extension),
|
||||
content: format_script_file(script)
|
||||
)
|
||||
ensures: script.content = null
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- TAGS FILE FORMAT
|
||||
-- ============================================================================
|
||||
|
||||
value TagsFile {
|
||||
-- File path: meta/tags.json
|
||||
-- Portable JSON format (no internal IDs)
|
||||
tags: List<TagEntry>
|
||||
}
|
||||
|
||||
value TagEntry {
|
||||
name: String
|
||||
color: String?
|
||||
post_template_slug: String?
|
||||
}
|
||||
|
||||
invariant TagsFileFormat {
|
||||
-- Tags are stored as a sorted JSON array
|
||||
-- Sorted alphabetically by name (case-insensitive)
|
||||
parse_json(read_file("meta/tags.json")) = {
|
||||
tags: sort_by(Tags, t => lowercase(t.name))
|
||||
}
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- PROJECT METADATA FILES
|
||||
-- ============================================================================
|
||||
|
||||
value ProjectJson {
|
||||
-- File path: meta/project.json
|
||||
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<String>
|
||||
}
|
||||
|
||||
value CategoriesJson {
|
||||
-- File path: meta/categories.json
|
||||
-- Sorted list of category names
|
||||
categories: List<String>
|
||||
}
|
||||
|
||||
value CategoryMetaJson {
|
||||
-- File path: meta/category-meta.json
|
||||
-- Per-category render settings
|
||||
categories: Map<String, CategorySettings>
|
||||
}
|
||||
|
||||
value CategorySettings {
|
||||
render_in_lists: Boolean
|
||||
show_title: Boolean
|
||||
post_template_slug: String?
|
||||
list_template_slug: String?
|
||||
}
|
||||
|
||||
value PublishingJson {
|
||||
-- File path: meta/publishing.json
|
||||
ssh_host: String?
|
||||
ssh_user: String?
|
||||
ssh_remote_path: String?
|
||||
ssh_mode: scp | rsync
|
||||
}
|
||||
|
||||
invariant MetadataFileLayout {
|
||||
-- All metadata files in meta/ directory
|
||||
-- Each file is written atomically (temp file + rename)
|
||||
meta/project.json = serialize(ProjectJson)
|
||||
meta/categories.json = serialize(CategoriesJson)
|
||||
meta/category-meta.json = serialize(CategoryMetaJson)
|
||||
meta/publishing.json = serialize(PublishingJson)
|
||||
meta/menu.opml = serialize(Menu)
|
||||
meta/tags.json = serialize(TagsFile)
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- MENU FILE FORMAT
|
||||
-- ============================================================================
|
||||
|
||||
value MenuOpml {
|
||||
-- File path: meta/menu.opml
|
||||
-- OPML 2.0 format with outline elements
|
||||
header: OpmlHeader
|
||||
body: List<MenuItem>
|
||||
}
|
||||
|
||||
value OpmlHeader {
|
||||
title: String
|
||||
date_created: Timestamp
|
||||
date_modified: Timestamp
|
||||
}
|
||||
|
||||
value MenuItem {
|
||||
kind: page | submenu | category_archive | home
|
||||
label: String
|
||||
slug: String?
|
||||
children: List<MenuItem>?
|
||||
}
|
||||
|
||||
invariant MenuOpmlFormat {
|
||||
-- Menu is stored as OPML with Home always first
|
||||
-- Note: List literal syntax not supported in Allium
|
||||
-- Actual structure: header + body with MenuItem elements
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- FILE FORMAT CONVENTIONS
|
||||
-- ============================================================================
|
||||
|
||||
invariant TimestampFormat {
|
||||
-- Database: Unix milliseconds stored as INTEGER columns
|
||||
-- YAML frontmatter: ISO 8601 strings (e.g. 2024-03-15T14:30:00.000Z)
|
||||
-- Conversion on read: parse ISO 8601 → Unix ms
|
||||
-- Conversion on write: Unix ms → ISO 8601
|
||||
}
|
||||
|
||||
invariant YamlFormatting {
|
||||
-- YAML frontmatter uses 2-space indentation
|
||||
-- Arrays use YAML list syntax: - item1\n- item2
|
||||
-- Strings with special characters are quoted
|
||||
-- Boolean values are lowercase: true/false
|
||||
}
|
||||
|
||||
invariant AtomicWrites {
|
||||
-- All file writes are atomic
|
||||
-- Write to temp file first, then rename
|
||||
-- Prevents corruption from interrupted writes
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- FRONTmatter FIELD RULES
|
||||
-- ============================================================================
|
||||
|
||||
invariant RequiredPostFields {
|
||||
-- These fields are ALWAYS written for posts
|
||||
for p in Posts:
|
||||
required_fields(p) = {
|
||||
id, title, slug, status, created_at, updated_at,
|
||||
tags, categories
|
||||
}
|
||||
}
|
||||
|
||||
invariant ConditionalPostFields {
|
||||
-- These fields are ONLY written if truthy
|
||||
for p in Posts:
|
||||
conditional_fields(p) = {
|
||||
excerpt, author, language, template_slug, published_at
|
||||
}
|
||||
-- do_not_translate is only written when true
|
||||
}
|
||||
|
||||
invariant RequiredMediaFields {
|
||||
-- These fields are ALWAYS written for media sidecars
|
||||
-- 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
|
||||
}
|
||||
}
|
||||
|
||||
invariant ConditionalMediaFields {
|
||||
-- These fields are ONLY written if truthy
|
||||
for m in Media:
|
||||
conditional_fields(m) = {
|
||||
title, alt, caption, author, language, width, height
|
||||
}
|
||||
}
|
||||
231
specs/generation.allium
Normal file
231
specs/generation.allium
Normal file
@@ -0,0 +1,231 @@
|
||||
-- allium: 1
|
||||
-- bDS Static Site Generation
|
||||
-- Scope: core (Wave 4)
|
||||
-- Distilled from: src/main/engine/BlogGenerationEngine.ts,
|
||||
-- PageRenderer.ts, GenerationWorkerPool, RoutePageGenerationService
|
||||
|
||||
use "./post.allium" as post
|
||||
use "./template.allium" as template
|
||||
use "./metadata.allium" as meta
|
||||
use "./menu.allium" as menu
|
||||
use "./translation.allium" as translation
|
||||
|
||||
surface GenerationControlSurface {
|
||||
facing _: GenerationOperator
|
||||
|
||||
provides:
|
||||
GenerateSiteRequested(generation)
|
||||
ValidateSiteRequested(project)
|
||||
ApplyValidationRequested(project_id, sections)
|
||||
}
|
||||
|
||||
surface GenerationRuntimeSurface {
|
||||
facing _: GenerationRuntime
|
||||
|
||||
provides:
|
||||
PageRenderRequested(template, context)
|
||||
GenerateSiteCompleted(generation)
|
||||
}
|
||||
|
||||
value GenerationSection {
|
||||
kind: core | single | category | tag | date
|
||||
}
|
||||
|
||||
value GeneratedFile {
|
||||
relative_path: String
|
||||
content_hash: String
|
||||
}
|
||||
|
||||
entity SiteGeneration {
|
||||
project_id: String
|
||||
base_url: String
|
||||
language: String -- main language
|
||||
blog_languages: Set<String>
|
||||
max_posts_per_page: Integer
|
||||
pico_theme: String?
|
||||
sections: Set<GenerationSection>
|
||||
|
||||
-- Output tracking
|
||||
generated_files: GeneratedFile with project_id = this.project_id
|
||||
}
|
||||
|
||||
surface GenerationStatusSurface {
|
||||
context generation: SiteGeneration
|
||||
|
||||
exposes:
|
||||
generation.project_id
|
||||
generation.base_url
|
||||
generation.language
|
||||
generation.blog_languages
|
||||
generation.max_posts_per_page
|
||||
generation.pico_theme
|
||||
generation.sections
|
||||
generation.generated_files.count
|
||||
}
|
||||
|
||||
invariant IncrementalByContentHash {
|
||||
-- Files are only written when content_hash changes
|
||||
-- generatedFileHashes table tracks (projectId, relativePath, contentHash)
|
||||
-- A file with unchanged hash is skipped on regeneration
|
||||
}
|
||||
|
||||
invariant MultiLanguageRoutes {
|
||||
-- Main language: flat routes (/{yyyy}/{mm}/{dd}/{slug})
|
||||
-- Additional languages: prefixed (/{lang}/{yyyy}/{mm}/{dd}/{slug})
|
||||
-- Each language subtree gets its own feeds and archives
|
||||
}
|
||||
|
||||
invariant CanonicalBaseUrlConfigured {
|
||||
for generation in SiteGenerations:
|
||||
generation.base_url != ""
|
||||
}
|
||||
|
||||
invariant NamedPicoTheme {
|
||||
for generation in SiteGenerations where generation.pico_theme != null:
|
||||
generation.pico_theme != ""
|
||||
}
|
||||
|
||||
invariant GeneratedFilesTracked {
|
||||
for generation in SiteGenerations:
|
||||
generation.generated_files.count >= 0
|
||||
}
|
||||
|
||||
-- Core section: root pages, sitemap, RSS, Atom, calendar.json
|
||||
|
||||
rule GenerateCoreSectionPages {
|
||||
when: GenerateSiteRequested(generation)
|
||||
requires: core in generation.sections
|
||||
ensures: FileGenerated("index.html")
|
||||
ensures: FileGenerated("sitemap.xml")
|
||||
-- Multi-language sitemap with hreflang alternates
|
||||
ensures: FileGenerated("feed.xml")
|
||||
-- RSS 2.0 feed
|
||||
ensures: FileGenerated("atom.xml")
|
||||
-- Atom feed
|
||||
ensures: FileGenerated("calendar.json")
|
||||
-- Post dates for calendar widget
|
||||
for lang in generation.blog_languages - {generation.language}:
|
||||
ensures: FileGenerated(format("{lang}/index.html", lang: lang))
|
||||
ensures: FileGenerated(format("{lang}/feed.xml", lang: lang))
|
||||
ensures: FileGenerated(format("{lang}/atom.xml", lang: lang))
|
||||
}
|
||||
|
||||
-- Single section: one HTML page per published post
|
||||
|
||||
rule GenerateSinglePostPages {
|
||||
when: GenerateSiteRequested(generation)
|
||||
requires: single in generation.sections
|
||||
for p in Posts where status = published:
|
||||
let url = post_canonical_url(p)
|
||||
ensures: FileGenerated(format("{url}/index.html", url: url))
|
||||
for lang in generation.blog_languages - {generation.language}:
|
||||
if p.translations.any(t => t.language.code = lang):
|
||||
ensures: FileGenerated(format("{lang}/{url}/index.html",
|
||||
lang: lang, url: url))
|
||||
}
|
||||
|
||||
-- Category section: paginated archive per category
|
||||
|
||||
rule GenerateCategoryPages {
|
||||
when: GenerateSiteRequested(generation)
|
||||
requires: category in generation.sections
|
||||
for cat in generation.categories:
|
||||
let page_count = ceil(posts_in_category(cat).count / generation.max_posts_per_page)
|
||||
ensures: FileGenerated(format("category/{cat}/index.html", cat: cat))
|
||||
for page in page_range(2, page_count):
|
||||
ensures: FileGenerated(format("category/{cat}/page/{page}/index.html",
|
||||
cat: cat, page: page))
|
||||
}
|
||||
|
||||
-- Tag section: paginated archive per tag
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
-- Date section: year and month archives
|
||||
|
||||
rule GenerateDateArchivePages {
|
||||
when: GenerateSiteRequested(generation)
|
||||
requires: date in generation.sections
|
||||
for year in distinct_years(Posts):
|
||||
ensures: FileGenerated(format("{year}/index.html", year: year))
|
||||
for month in distinct_months(Posts, year):
|
||||
ensures: FileGenerated(format("{year}/{month}/index.html",
|
||||
year: year, month: month))
|
||||
}
|
||||
|
||||
-- Template rendering context
|
||||
|
||||
rule RenderPage {
|
||||
when: PageRenderRequested(template, context)
|
||||
-- Template rendering with full context:
|
||||
-- posts, pagination, menus, tags, categories,
|
||||
-- project metadata, i18n translations, theme settings
|
||||
-- Macro expansion: [[slug param1=value1 ...]] in post content
|
||||
-- HTML rewriting for canonical post/media paths
|
||||
ensures: RenderedHtml(template, context, output)
|
||||
}
|
||||
|
||||
-- Validation
|
||||
|
||||
rule ValidateSite {
|
||||
when: ValidateSiteRequested(project)
|
||||
-- Compares sitemap URLs to HTML files on disk
|
||||
-- Detects: missing pages, extra (stale) pages, sitemap/file mismatches
|
||||
ensures: ValidationReport(missing_pages, extra_pages, stale_pages)
|
||||
}
|
||||
|
||||
rule ApplyValidation {
|
||||
when: ApplyValidationRequested(project_id, sections)
|
||||
-- Targeted re-rendering for affected sections only
|
||||
ensures: GenerateSiteRequested(plan_generation(project_id, sections))
|
||||
}
|
||||
|
||||
-- Day-block grouping for archives
|
||||
invariant ArchiveDayBlocks {
|
||||
-- Archive/list pages group posts by day
|
||||
-- Each day block has a date header and the posts for that day
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- SEARCH INDEX: PAGEFIND
|
||||
-- ============================================================================
|
||||
|
||||
-- Pagefind builds a client-side full-text search index from generated HTML.
|
||||
-- Uses an embedded Pagefind library integration rather than a CLI subprocess.
|
||||
-- Runs as the final step of the generation pipeline, after all HTML is written.
|
||||
|
||||
rule BuildSearchIndex {
|
||||
when: GenerateSiteCompleted(generation)
|
||||
-- Only runs if any pages were rendered or deleted in this generation pass.
|
||||
-- Separate index per language:
|
||||
-- Main language: source = {html}/, output = {html}/pagefind/
|
||||
-- Each additional language: source = {html}/{lang}/, output = {html}/{lang}/pagefind/
|
||||
-- Each index built with force_language set to that language code.
|
||||
for lang in {generation.language} + (generation.blog_languages - {generation.language}):
|
||||
ensures: PagefindIndexBuilt(lang)
|
||||
}
|
||||
|
||||
invariant PagefindHtmlMarking {
|
||||
-- Single-post templates must include data-pagefind-body attribute
|
||||
-- on the <article> element to scope indexing to post content only.
|
||||
-- Pagefind ignores elements without this attribute.
|
||||
}
|
||||
|
||||
invariant PagefindAssets {
|
||||
-- Generated output includes Pagefind UI assets per language:
|
||||
-- {prefix}/pagefind/pagefind-ui.css
|
||||
-- {prefix}/pagefind/pagefind-ui.js
|
||||
-- where prefix is "" for main language, "{lang}" for additional languages.
|
||||
-- Frontend templates reference these via language_prefix variable.
|
||||
-- Assets are bundled locally — no external CDN references.
|
||||
}
|
||||
|
||||
config {
|
||||
pagefind_threshold: Integer = 0
|
||||
-- Minimum pages to trigger indexing (0 = always if any rendered/deleted)
|
||||
}
|
||||
166
specs/git.allium
Normal file
166
specs/git.allium
Normal file
@@ -0,0 +1,166 @@
|
||||
-- allium: 1
|
||||
-- bDS Git Integration
|
||||
-- Scope: extension (Bucket A — Git + Validation)
|
||||
-- Distilled from: src/main/engine/GitEngine.ts
|
||||
|
||||
use "./post.allium" as post
|
||||
use "./script.allium" as script
|
||||
use "./template.allium" as template
|
||||
|
||||
value GitProvider {
|
||||
kind: github | gitlab | gitea_forgejo
|
||||
-- Detected from remote URL patterns
|
||||
}
|
||||
|
||||
value GitSyncStatus {
|
||||
-- Per-commit: local_only | remote_only | both
|
||||
kind: local_only | remote_only | both
|
||||
}
|
||||
|
||||
surface GitSyncStatusSurface {
|
||||
context status: GitSyncStatus
|
||||
|
||||
exposes:
|
||||
status.kind
|
||||
}
|
||||
|
||||
entity GitRepository {
|
||||
is_initialized: Boolean
|
||||
remote_url: String?
|
||||
provider: GitProvider?
|
||||
current_branch: String?
|
||||
has_lfs: Boolean
|
||||
}
|
||||
|
||||
surface GitRepositorySurface {
|
||||
context repo: GitRepository
|
||||
|
||||
exposes:
|
||||
repo.is_initialized
|
||||
repo.remote_url when repo.remote_url != null
|
||||
repo.provider when repo.provider != null
|
||||
repo.current_branch when repo.current_branch != null
|
||||
repo.has_lfs
|
||||
}
|
||||
|
||||
surface GitControlSurface {
|
||||
facing _: GitOperator
|
||||
|
||||
provides:
|
||||
InitializeRepoRequested(project)
|
||||
GitStatusRequested(project)
|
||||
GitDiffRequested(project)
|
||||
GitHistoryRequested(project, branch)
|
||||
GitFetchRequested(project)
|
||||
GitPullRequested(project)
|
||||
GitPushRequested(project)
|
||||
GitCommitAllRequested(project, message)
|
||||
GitReconcileRequested(project, old_commit, new_commit)
|
||||
PruneLfsCacheRequested(project, retain_recent)
|
||||
}
|
||||
|
||||
rule InitializeRepo {
|
||||
when: InitializeRepoRequested(project)
|
||||
ensures: GitRepository.created(
|
||||
is_initialized: true,
|
||||
remote_url: null,
|
||||
provider: null,
|
||||
current_branch: "master",
|
||||
has_lfs: true
|
||||
)
|
||||
ensures: GitignoreCreated(project)
|
||||
-- .gitignore manages generated artifacts, cached assets, dependency directories, etc.
|
||||
ensures: LfsTrackingConfigured(project)
|
||||
-- Git LFS auto-tracks image patterns (*.jpg, *.png, *.gif, etc.)
|
||||
}
|
||||
|
||||
rule GetStatus {
|
||||
when: GitStatusRequested(project)
|
||||
-- Returns file-level status: added, modified, deleted, renamed, untracked
|
||||
ensures: GitStatusReport(files)
|
||||
}
|
||||
|
||||
rule GetDiff {
|
||||
when: GitDiffRequested(project)
|
||||
ensures: GitDiffReport(staged_diff, unstaged_diff)
|
||||
}
|
||||
|
||||
rule GetHistory {
|
||||
when: GitHistoryRequested(project, branch)
|
||||
-- Returns commit history with sync status per commit
|
||||
ensures: GitHistoryReport(commits)
|
||||
@guidance
|
||||
-- Each commit annotated with: local_only, remote_only, or both
|
||||
-- This drives the "push needed" / "pull needed" indicators
|
||||
}
|
||||
|
||||
rule Fetch {
|
||||
when: GitFetchRequested(project)
|
||||
ensures: RemoteRefsUpdated(project)
|
||||
}
|
||||
|
||||
rule Pull {
|
||||
when: GitPullRequested(project)
|
||||
ensures: LocalBranchUpdated(project)
|
||||
ensures: GitReconcileRequested(project, previous_head(project), current_head(project))
|
||||
-- After pull, detect changed files and reconcile DB
|
||||
}
|
||||
|
||||
rule Push {
|
||||
when: GitPushRequested(project)
|
||||
ensures: RemoteBranchUpdated(project)
|
||||
}
|
||||
|
||||
rule CommitAll {
|
||||
when: GitCommitAllRequested(project, message)
|
||||
ensures: AllChangesStaged(project)
|
||||
ensures: CommitCreated(project, message)
|
||||
}
|
||||
|
||||
-- Git reconciliation: sync DB from filesystem changes
|
||||
|
||||
rule ReconcileFromGit {
|
||||
when: GitReconcileRequested(project, old_commit, new_commit)
|
||||
-- Detect changed files between commits for posts, scripts, templates
|
||||
let post_changes = changed_post_files(old_commit, new_commit)
|
||||
let script_changes = changed_script_files(old_commit, new_commit)
|
||||
let template_changes = changed_template_files(old_commit, new_commit)
|
||||
|
||||
for added in post_changes.added:
|
||||
ensures: post/Post.created(parse_post_file(added))
|
||||
for modified in post_changes.modified:
|
||||
ensures: PostUpdatedFromFile(modified)
|
||||
for deleted in post_changes.deleted:
|
||||
ensures: PostDeletedByPath(deleted)
|
||||
for renamed in post_changes.renamed:
|
||||
ensures: PostFileRenamed(renamed.old, renamed.new)
|
||||
|
||||
-- Same pattern for scripts and templates
|
||||
for added in script_changes.added:
|
||||
ensures: script/Script.created(parse_script_file(added))
|
||||
for added in template_changes.added:
|
||||
ensures: template/Template.created(parse_template_file(added))
|
||||
|
||||
ensures: EntityChangedEventsEmitted(project)
|
||||
}
|
||||
|
||||
invariant NonInteractiveGit {
|
||||
-- All git operations run non-interactively:
|
||||
-- GIT_TERMINAL_PROMPT=0
|
||||
-- GCM_INTERACTIVE=never
|
||||
-- ssh -oBatchMode=yes
|
||||
-- No password prompts ever surface to the user
|
||||
}
|
||||
|
||||
invariant StructuredAuthErrors {
|
||||
-- Auth failures produce structured guidance:
|
||||
-- per platform (macOS/Windows/Linux)
|
||||
-- per provider (GitHub/GitLab/Gitea)
|
||||
-- Instead of raw git error messages
|
||||
}
|
||||
|
||||
rule PruneLfsCache {
|
||||
when: PruneLfsCacheRequested(project, retain_recent)
|
||||
-- Prunes LFS cache with configurable recent commit retention
|
||||
ensures: LfsCachePruned(project)
|
||||
}
|
||||
54
specs/i18n.allium
Normal file
54
specs/i18n.allium
Normal file
@@ -0,0 +1,54 @@
|
||||
-- allium: 1
|
||||
-- bDS Internationalization
|
||||
-- Scope: core (Wave 0 onward — split localization is mandatory)
|
||||
-- Distilled from: src/main/shared/i18n.ts, i18n/locales/*.json
|
||||
|
||||
value SupportedLanguage {
|
||||
code: String
|
||||
-- en, de, fr, it, es
|
||||
flag: String
|
||||
-- en=GB, de=DE, fr=FR, it=IT, es=ES
|
||||
}
|
||||
|
||||
config {
|
||||
supported_languages: Set<SupportedLanguage> = {
|
||||
SupportedLanguage(code: "en", flag: "GB"),
|
||||
SupportedLanguage(code: "de", flag: "DE"),
|
||||
SupportedLanguage(code: "fr", flag: "FR"),
|
||||
SupportedLanguage(code: "it", flag: "IT"),
|
||||
SupportedLanguage(code: "es", flag: "ES")
|
||||
}
|
||||
default_language: String = "en"
|
||||
}
|
||||
|
||||
invariant SplitLocalization {
|
||||
-- Two independent locale scopes:
|
||||
-- 1. UI locale: follows OS system locale
|
||||
-- 2. Content/render locale: follows project settings (mainLanguage)
|
||||
-- These are resolved independently and may differ
|
||||
}
|
||||
|
||||
invariant LanguageNormalization {
|
||||
-- Input language codes are normalized:
|
||||
-- Take base language code (split on '-'): "en-US" -> "en"
|
||||
-- Fall back to "en" if unrecognized
|
||||
}
|
||||
|
||||
invariant MenuTranslations {
|
||||
-- Menu item labels are separately translatable
|
||||
-- translateMenu() uses UI locale (system/OS locale), NOT render locale
|
||||
-- This follows the OS convention: menus match the system language
|
||||
}
|
||||
|
||||
invariant RenderTranslations {
|
||||
-- Template rendering i18n strings (date formats, archive labels,
|
||||
-- "older posts", "newer posts", etc.) come from locale JSON files
|
||||
-- translateRender() and getRenderTranslations() provide these
|
||||
}
|
||||
|
||||
-- Stemmer language support for search (broader than UI languages)
|
||||
invariant SnowballStemmerCoverage {
|
||||
-- 24 languages supported for FTS5 search stemming
|
||||
-- ISO 639-1 mapped to Snowball stemmer names
|
||||
-- All 5 UI languages are a subset of stemmer languages
|
||||
}
|
||||
291
specs/layout.allium
Normal file
291
specs/layout.allium
Normal file
@@ -0,0 +1,291 @@
|
||||
-- allium: 1
|
||||
-- bDS Application Layout
|
||||
-- Scope: UI shell (all waves)
|
||||
-- Distilled from: src/renderer/App.tsx, ActivityBar.tsx, StatusBar.tsx,
|
||||
-- Panel.tsx, WindowTitleBar.tsx, ResizablePanel, Sidebar.tsx
|
||||
|
||||
-- The top-level visual structure of the application window.
|
||||
-- Describes the shell (regions, toggle behaviour, resize constraints)
|
||||
-- but NOT the content of each region (see tabs.allium, sidebar_views.allium).
|
||||
|
||||
use "./i18n.allium" as i18n
|
||||
use "./task.allium" as task
|
||||
|
||||
surface LayoutControlSurface {
|
||||
facing _: LayoutOperator
|
||||
|
||||
provides:
|
||||
ToggleSidebarRequested()
|
||||
TogglePanelRequested()
|
||||
ToggleAssistantSidebarRequested()
|
||||
ActivityClicked(activity_id)
|
||||
}
|
||||
|
||||
surface LayoutRuntimeSurface {
|
||||
facing _: LayoutRuntime
|
||||
|
||||
provides:
|
||||
GitBadgePollTick(badge)
|
||||
ClearGitBadgeTick(badge)
|
||||
}
|
||||
|
||||
-- ─── Window shell ─────────────────────────────────────────────
|
||||
|
||||
-- +------------------------------------------------------------+
|
||||
-- | WindowTitleBar |
|
||||
-- +----+--------+----------------------------+-----------------+
|
||||
-- | A | Side- | TabBar | Assistant |
|
||||
-- | c | bar |----------------------------| Sidebar |
|
||||
-- | t | (resz) | Editor (routed by tab) | |
|
||||
-- | i | |----------------------------+ |
|
||||
-- | v | | Panel (bottom) | |
|
||||
-- | i | | | |
|
||||
-- | t | | | |
|
||||
-- | y | | | |
|
||||
-- +----+--------+----------------------------+-----------------+
|
||||
-- | StatusBar |
|
||||
-- +------------------------------------------------------------+
|
||||
|
||||
value AppShell {
|
||||
title_bar: WindowTitleBar
|
||||
activity_bar: ActivityBar
|
||||
sidebar: ResizableRegion
|
||||
content_area: ContentArea
|
||||
assistant_sidebar: ResizableRegion
|
||||
status_bar: StatusBar
|
||||
}
|
||||
|
||||
surface AppShellSurface {
|
||||
context shell: AppShell
|
||||
|
||||
exposes:
|
||||
shell.title_bar.title
|
||||
shell.sidebar.visible
|
||||
shell.sidebar.width
|
||||
shell.content_area.panel.visible
|
||||
shell.assistant_sidebar.visible
|
||||
}
|
||||
|
||||
value ContentArea {
|
||||
-- tab_bar: see tabs.allium
|
||||
-- editor: routed by active tab; see tabs.allium
|
||||
panel: Panel
|
||||
}
|
||||
|
||||
-- ─── Resizable regions ────────────────────────────────────────
|
||||
|
||||
config {
|
||||
sidebar_initial_width: Integer = 280
|
||||
sidebar_min_width: Integer = 200
|
||||
sidebar_max_width: Integer = 500
|
||||
assistant_initial_width: Integer = 360
|
||||
assistant_min_width: Integer = 280
|
||||
assistant_max_width: Integer = 640
|
||||
}
|
||||
|
||||
value ResizableRegion {
|
||||
visible: Boolean
|
||||
width: Integer
|
||||
min_width: Integer
|
||||
max_width: Integer
|
||||
}
|
||||
|
||||
-- ─── Toggle state ─────────────────────────────────────────────
|
||||
|
||||
value ShellVisibility {
|
||||
sidebar_visible: Boolean
|
||||
panel_visible: Boolean
|
||||
assistant_sidebar_visible: Boolean
|
||||
}
|
||||
|
||||
surface ShellVisibilitySurface {
|
||||
context visibility: ShellVisibility
|
||||
|
||||
exposes:
|
||||
visibility.sidebar_visible
|
||||
visibility.panel_visible
|
||||
visibility.assistant_sidebar_visible
|
||||
}
|
||||
|
||||
rule ToggleSidebar {
|
||||
when: ToggleSidebarRequested()
|
||||
ensures: sidebar_visible = not sidebar_visible
|
||||
}
|
||||
|
||||
rule TogglePanel {
|
||||
when: TogglePanelRequested()
|
||||
ensures: panel_visible = not panel_visible
|
||||
}
|
||||
|
||||
rule ToggleAssistantSidebar {
|
||||
when: ToggleAssistantSidebarRequested()
|
||||
ensures: assistant_sidebar_visible = not assistant_sidebar_visible
|
||||
}
|
||||
|
||||
-- ─── Window title bar ─────────────────────────────────────────
|
||||
|
||||
value WindowTitleBar {
|
||||
-- Platform-adaptive
|
||||
-- menu_bar: rendered only on non-Mac platforms (6 groups: App, File, Edit, View, Window, Help)
|
||||
-- macOS: native menu bar (same 6 groups)
|
||||
-- Keyboard: Alt opens mnemonics, Alt+letter opens group
|
||||
-- Arrow keys navigate groups and items, Enter/Space activates
|
||||
-- View group hides devTools toggle when not in dev mode
|
||||
title: String -- document.title, fallback "Blogging Desktop Server"
|
||||
-- Three toggle buttons (all platforms): sidebar, panel, assistant
|
||||
}
|
||||
|
||||
-- ─── Activity bar ─────────────────────────────────────────────
|
||||
|
||||
-- Narrow vertical icon strip at the far-left edge of the window.
|
||||
-- Two groups: top (content views) and bottom (tools).
|
||||
|
||||
value ActivityBar {
|
||||
top_group: List<ActivityButton>
|
||||
bottom_group: List<ActivityButton>
|
||||
}
|
||||
|
||||
value ActivityButton {
|
||||
id: String -- matches SidebarView name
|
||||
label_key: String -- i18n key for tooltip
|
||||
badge: Badge? -- only git has a badge
|
||||
active: Boolean -- highlighted when this view is showing
|
||||
}
|
||||
|
||||
value Badge {
|
||||
count: Integer
|
||||
display: String -- count capped at "99+"
|
||||
}
|
||||
|
||||
-- Exhaustive activity list with preserved order
|
||||
-- Top group (content views):
|
||||
-- 1. posts i18n:activity.posts
|
||||
-- 2. pages i18n:activity.pages
|
||||
-- 3. media i18n:activity.media
|
||||
-- 4. scripts i18n:activity.scripts
|
||||
-- 5. templates i18n:activity.templates
|
||||
-- 6. tags i18n:activity.tags
|
||||
-- 7. chat i18n:activity.aiAssistant
|
||||
-- 8. import i18n:activity.import
|
||||
-- Bottom group (tools):
|
||||
-- 9. git i18n:activity.sourceControl (badge: pending pull count)
|
||||
-- 10. settings i18n:common.settings
|
||||
|
||||
-- Each activity ID maps 1:1 to a SidebarView of the same name.
|
||||
|
||||
-- ─── Activity click behaviour ─────────────────────────────────
|
||||
|
||||
-- All activities share the same toggle-sidebar strategy.
|
||||
-- The sidebar shows the view that matches the clicked activity.
|
||||
|
||||
rule ActivityClick {
|
||||
when: ActivityClicked(activity_id)
|
||||
let target_view = activity_id
|
||||
if active_view = target_view:
|
||||
ensures: ToggleSidebarRequested()
|
||||
-- If already on this view, toggle sidebar open/closed
|
||||
else:
|
||||
ensures: active_view = target_view
|
||||
if not sidebar_visible:
|
||||
ensures: ToggleSidebarRequested()
|
||||
-- Switch view; open sidebar if hidden
|
||||
}
|
||||
|
||||
invariant ActivityActiveHighlight {
|
||||
-- An activity button shows active state iff its view is the
|
||||
-- current active_view AND the sidebar is visible
|
||||
for btn in ActivityBar.all_buttons:
|
||||
btn.active = (active_view = btn.id and sidebar_visible)
|
||||
}
|
||||
|
||||
-- ─── Git badge ────────────────────────────────────────────────
|
||||
|
||||
-- Only the git activity button carries a badge.
|
||||
-- Badge shows remote "behind" count, polled every 30 seconds.
|
||||
|
||||
config {
|
||||
git_badge_poll_interval: Integer = 30
|
||||
-- seconds between badge refresh polls
|
||||
}
|
||||
|
||||
rule RefreshGitBadge {
|
||||
when: GitBadgePollTick(badge)
|
||||
requires: online and active_project != null
|
||||
let repo_state = git.getRepoState()
|
||||
requires: repo_state.is_repo and repo_state.has_remote
|
||||
ensures: git.fetch()
|
||||
let remote_state = git.getRemoteState()
|
||||
ensures: badge.count = max(0, remote_state.behind)
|
||||
}
|
||||
|
||||
rule ClearGitBadge {
|
||||
when: ClearGitBadgeTick(badge)
|
||||
requires: not online or active_project = null or not is_repo or not has_remote
|
||||
ensures: badge.count = 0
|
||||
}
|
||||
|
||||
-- ─── Bottom panel ─────────────────────────────────────────────
|
||||
|
||||
value Panel {
|
||||
visible: Boolean
|
||||
active_tab: String -- tasks | output | post_links | git_log
|
||||
}
|
||||
|
||||
-- Panel tab availability depends on active editor tab
|
||||
invariant PanelTabAvailability {
|
||||
-- tasks: always available
|
||||
-- output: always available
|
||||
-- post_links: only when active editor tab is a post
|
||||
-- git_log: only when active editor tab is a post or media
|
||||
}
|
||||
|
||||
invariant PanelTabFallback {
|
||||
-- If active panel tab becomes unavailable, fall back to tasks
|
||||
-- post_links unavailable when no post tab is active
|
||||
-- git_log unavailable when neither post nor media tab is active
|
||||
}
|
||||
|
||||
-- Tasks tab: last 10 tasks, newest first, with progress/cancel.
|
||||
-- Tasks with shared group_id are collapsible groups showing aggregate progress.
|
||||
-- Output tab: log entries with copy-all button.
|
||||
-- Post Links tab: backlinks (posts linking here) + outlinks (posts linked from here).
|
||||
-- Each entry clickable, opens linked post as pinned tab.
|
||||
-- Git Log tab: file-level git history for active post/media (up to 50 entries).
|
||||
-- For posts: path = posts/YYYY/MM/{slug}.md
|
||||
-- For media: path relative to project root
|
||||
|
||||
-- ─── Status bar ───────────────────────────────────────────────
|
||||
|
||||
value StatusBar {
|
||||
left: StatusBarLeft
|
||||
right: StatusBarRight
|
||||
}
|
||||
|
||||
value StatusBarLeft {
|
||||
-- Project selector dropdown to switch active project
|
||||
running_task_message: String? -- spinner + message when tasks running
|
||||
running_task_overflow: Integer? -- "+N more" count when multiple running
|
||||
}
|
||||
|
||||
value StatusBarRight {
|
||||
-- In display order (left to right):
|
||||
post_status: String? -- draft|published|archived dot, when post tab active
|
||||
post_count: String -- "{count} posts"
|
||||
media_count: String -- "{count} media"
|
||||
token_usage: TokenUsage? -- shown only when active tab is chat
|
||||
theme_badge: String -- pico theme name
|
||||
offline_mode: Boolean -- airplane icon toggle, keyboard accessible
|
||||
ui_language: String -- dropdown: en, de, fr, it, es
|
||||
brand: String -- "bDS"
|
||||
}
|
||||
|
||||
value TokenUsage {
|
||||
input_tokens: Integer
|
||||
output_tokens: Integer
|
||||
cache_read_tokens: Integer
|
||||
}
|
||||
|
||||
-- ─── Keyboard shortcuts (global) ──────────────────────────────
|
||||
|
||||
-- Ctrl/Cmd+B: toggle sidebar
|
||||
-- Ctrl/Cmd+W: close active tab (see tabs.allium)
|
||||
392
specs/mcp.allium
Normal file
392
specs/mcp.allium
Normal file
@@ -0,0 +1,392 @@
|
||||
-- allium: 1
|
||||
-- bDS MCP Server (Model Context Protocol)
|
||||
-- Scope: extension (Bucket G — MCP + Automation)
|
||||
-- Distilled from: src/main/engine/MCPServer.ts, ProposalStore, MCPAgentConfigEngine.ts
|
||||
|
||||
use "./post.allium" as post
|
||||
use "./media.allium" as media
|
||||
use "./script.allium" as script
|
||||
use "./template.allium" as template
|
||||
|
||||
entity McpServer {
|
||||
transport: http | stdio
|
||||
host: String -- 127.0.0.1 for HTTP
|
||||
port: Integer -- 4124 for HTTP
|
||||
is_running: Boolean
|
||||
}
|
||||
|
||||
surface McpServerSurface {
|
||||
context server: McpServer
|
||||
|
||||
exposes:
|
||||
server.transport
|
||||
server.host
|
||||
server.port
|
||||
server.is_running
|
||||
}
|
||||
|
||||
entity Proposal {
|
||||
kind: draft_post | propose_script | propose_template | propose_media_metadata | propose_post_metadata
|
||||
status: pending | accepted | discarded | expired
|
||||
entity_id: String
|
||||
data: String
|
||||
created_at: Timestamp
|
||||
expires_at: Timestamp
|
||||
draft_post: post/Post?
|
||||
proposed_script: script/Script?
|
||||
proposed_template: template/Template?
|
||||
target_media: media/Media?
|
||||
target_post: post/Post?
|
||||
|
||||
-- Derived
|
||||
is_expired: expires_at <= now
|
||||
|
||||
transitions status {
|
||||
pending -> accepted
|
||||
pending -> discarded
|
||||
pending -> expired
|
||||
}
|
||||
}
|
||||
|
||||
surface ProposalSurface {
|
||||
context proposal: Proposal
|
||||
|
||||
exposes:
|
||||
proposal.kind
|
||||
proposal.status
|
||||
proposal.entity_id
|
||||
proposal.data
|
||||
proposal.created_at
|
||||
proposal.expires_at
|
||||
proposal.draft_post when proposal.draft_post != null
|
||||
proposal.proposed_script when proposal.proposed_script != null
|
||||
proposal.proposed_template when proposal.proposed_template != null
|
||||
proposal.target_media when proposal.target_media != null
|
||||
proposal.target_post when proposal.target_post != null
|
||||
proposal.is_expired
|
||||
}
|
||||
|
||||
config {
|
||||
http_port: Integer = 4124
|
||||
proposal_ttl_app: Duration = 30.minutes
|
||||
proposal_ttl_cli: Duration = 8.hours
|
||||
}
|
||||
|
||||
surface McpAutomationSurface {
|
||||
facing _: McpClient
|
||||
|
||||
provides:
|
||||
McpToolInvoked("check_term", term)
|
||||
McpToolInvoked("search_posts", params)
|
||||
McpToolInvoked("count_posts", params)
|
||||
McpToolInvoked("read_post_by_slug", slug, language)
|
||||
McpToolInvoked("draft_post", params)
|
||||
McpToolInvoked("propose_script", params)
|
||||
McpToolInvoked("propose_template", params)
|
||||
McpToolInvoked("propose_media_metadata", params)
|
||||
McpToolInvoked("propose_post_metadata", params)
|
||||
AcceptProposalRequested(proposal)
|
||||
DiscardProposalRequested(proposal)
|
||||
InstallAgentConfigRequested(agent_kind)
|
||||
UninstallAgentConfigRequested(agent_kind)
|
||||
}
|
||||
|
||||
invariant LocalhostOnlyHttp {
|
||||
-- HTTP transport binds to 127.0.0.1 only
|
||||
-- Origin validation: localhost only
|
||||
-- CORS headers present
|
||||
}
|
||||
|
||||
invariant StatelessHttpHandling {
|
||||
-- Each HTTP request creates a fresh McpServer instance
|
||||
-- No session state between requests
|
||||
}
|
||||
|
||||
-- Read-only resources (bds:// scheme)
|
||||
|
||||
surface PostsResource {
|
||||
facing viewer: McpClient
|
||||
context posts: Posts
|
||||
exposes:
|
||||
for p in posts:
|
||||
p.id
|
||||
p.title
|
||||
p.slug
|
||||
p.status
|
||||
p.tags
|
||||
p.categories
|
||||
p.created_at
|
||||
p.backlinks
|
||||
p.outlinks
|
||||
@guidance
|
||||
-- Paginated: 50 per page, base64url cursor
|
||||
-- bds://posts, bds://posts?cursor={cursor}
|
||||
}
|
||||
|
||||
surface MediaResource {
|
||||
facing viewer: McpClient
|
||||
context media_items: Media
|
||||
exposes:
|
||||
for m in media_items:
|
||||
m.id
|
||||
m.filename
|
||||
m.title
|
||||
m.alt
|
||||
m.caption
|
||||
m.tags
|
||||
@guidance
|
||||
-- bds://media, bds://media?cursor={cursor}
|
||||
}
|
||||
|
||||
surface TagsResource {
|
||||
facing viewer: McpClient
|
||||
context tags: Tags
|
||||
exposes:
|
||||
for t in tags:
|
||||
t.name
|
||||
t.color
|
||||
t.post_count
|
||||
@guidance
|
||||
-- bds://tags
|
||||
}
|
||||
|
||||
surface CategoriesResource {
|
||||
facing viewer: McpClient
|
||||
context categories: Categories
|
||||
exposes:
|
||||
for c in categories:
|
||||
c.name
|
||||
c.post_count
|
||||
@guidance
|
||||
-- bds://categories
|
||||
}
|
||||
|
||||
-- Read-only tools
|
||||
|
||||
rule CheckTerm {
|
||||
when: McpToolInvoked("check_term", term)
|
||||
-- Disambiguates a term as category, tag, or both
|
||||
-- Returns post counts for each
|
||||
let is_category = is_category_term(term)
|
||||
let is_tag = is_tag_term(term)
|
||||
ensures: TermCheckResult(
|
||||
is_category: is_category,
|
||||
category_post_count: if is_category: category_post_count(term) else: 0,
|
||||
is_tag: is_tag,
|
||||
tag_post_count: if is_tag: tag_post_count(term) else: 0
|
||||
)
|
||||
}
|
||||
|
||||
rule SearchPosts {
|
||||
when: McpToolInvoked("search_posts", params)
|
||||
-- Full-text + filtered search with pagination envelope
|
||||
-- Params: query, category, tags[], language, missingTranslationLanguage,
|
||||
-- year, month, status, offset, limit
|
||||
-- Returns: { total, offset, limit, hasMore, posts[] }
|
||||
-- Each post includes backlinks[] and linksTo[]
|
||||
ensures: SearchEnvelope(results)
|
||||
}
|
||||
|
||||
rule CountPosts {
|
||||
when: McpToolInvoked("count_posts", params)
|
||||
-- Grouped counts by: year, month, tag, category, status
|
||||
-- Params: groupBy[], optional filters
|
||||
ensures: GroupedCounts(results)
|
||||
}
|
||||
|
||||
rule ReadPostBySlug {
|
||||
when: McpToolInvoked("read_post_by_slug", slug, language)
|
||||
-- Full post content by slug
|
||||
-- Optional language parameter for translation view
|
||||
ensures: FullPostContent(post)
|
||||
}
|
||||
|
||||
-- Write tools (proposal-based)
|
||||
|
||||
rule DraftPost {
|
||||
when: McpToolInvoked("draft_post", params)
|
||||
-- Creates a draft post in DB
|
||||
-- Returns proposalId for accept/discard lifecycle
|
||||
ensures:
|
||||
let new_post = post/Post.created(
|
||||
title: params.title,
|
||||
content: params.content,
|
||||
status: draft
|
||||
)
|
||||
let proposal = Proposal.created(
|
||||
kind: draft_post,
|
||||
entity_id: new_post.id,
|
||||
data: "",
|
||||
created_at: now,
|
||||
expires_at: now + config.proposal_ttl_app,
|
||||
draft_post: new_post,
|
||||
proposed_script: null,
|
||||
proposed_template: null,
|
||||
target_media: null,
|
||||
target_post: null,
|
||||
status: pending
|
||||
)
|
||||
proposal.status = pending
|
||||
}
|
||||
|
||||
rule ProposeScript {
|
||||
when: McpToolInvoked("propose_script", params)
|
||||
requires: ValidateScript(params.content) = valid
|
||||
ensures:
|
||||
let new_script = script/Script.created(
|
||||
title: params.title,
|
||||
kind: params.kind,
|
||||
content: params.content,
|
||||
status: draft
|
||||
)
|
||||
let proposal = Proposal.created(
|
||||
kind: propose_script,
|
||||
entity_id: new_script.id,
|
||||
data: "",
|
||||
created_at: now,
|
||||
expires_at: now + config.proposal_ttl_app,
|
||||
draft_post: null,
|
||||
proposed_script: new_script,
|
||||
proposed_template: null,
|
||||
target_media: null,
|
||||
target_post: null,
|
||||
status: pending
|
||||
)
|
||||
proposal.status = pending
|
||||
}
|
||||
|
||||
rule ProposeTemplate {
|
||||
when: McpToolInvoked("propose_template", params)
|
||||
requires: ValidateLiquid(params.content) = valid
|
||||
ensures:
|
||||
let new_template = template/Template.created(
|
||||
title: params.title,
|
||||
kind: params.kind,
|
||||
content: params.content,
|
||||
status: draft
|
||||
)
|
||||
let proposal = Proposal.created(
|
||||
kind: propose_template,
|
||||
entity_id: new_template.id,
|
||||
data: "",
|
||||
created_at: now,
|
||||
expires_at: now + config.proposal_ttl_app,
|
||||
draft_post: null,
|
||||
proposed_script: null,
|
||||
proposed_template: new_template,
|
||||
target_media: null,
|
||||
target_post: null,
|
||||
status: pending
|
||||
)
|
||||
proposal.status = pending
|
||||
}
|
||||
|
||||
rule ProposeMediaMetadata {
|
||||
when: McpToolInvoked("propose_media_metadata", params)
|
||||
ensures:
|
||||
let proposal = Proposal.created(
|
||||
kind: propose_media_metadata,
|
||||
entity_id: params.media_id,
|
||||
data: serialize(params),
|
||||
created_at: now,
|
||||
expires_at: now + config.proposal_ttl_app,
|
||||
draft_post: null,
|
||||
proposed_script: null,
|
||||
proposed_template: null,
|
||||
target_media: params.media,
|
||||
target_post: null,
|
||||
status: pending
|
||||
)
|
||||
proposal.status = pending
|
||||
}
|
||||
|
||||
rule ProposePostMetadata {
|
||||
when: McpToolInvoked("propose_post_metadata", params)
|
||||
ensures:
|
||||
let proposal = Proposal.created(
|
||||
kind: propose_post_metadata,
|
||||
entity_id: params.post_id,
|
||||
data: serialize(params),
|
||||
created_at: now,
|
||||
expires_at: now + config.proposal_ttl_app,
|
||||
draft_post: null,
|
||||
proposed_script: null,
|
||||
proposed_template: null,
|
||||
target_media: null,
|
||||
target_post: params.post,
|
||||
status: pending
|
||||
)
|
||||
proposal.status = pending
|
||||
}
|
||||
|
||||
-- Proposal lifecycle
|
||||
|
||||
rule AcceptProposal {
|
||||
when: AcceptProposalRequested(proposal)
|
||||
requires: not proposal.is_expired
|
||||
ensures:
|
||||
if proposal.kind = draft_post:
|
||||
post/PublishPostRequested(proposal.draft_post)
|
||||
if proposal.kind = propose_script:
|
||||
script/PublishScriptRequested(proposal.proposed_script)
|
||||
if proposal.kind = propose_template:
|
||||
template/PublishTemplateRequested(proposal.proposed_template)
|
||||
if proposal.kind = propose_media_metadata:
|
||||
media/UpdateMediaRequested(proposal.target_media, deserialize_media_changes(proposal.data))
|
||||
if proposal.kind = propose_post_metadata:
|
||||
post/UpdatePostRequested(proposal.target_post, deserialize_post_changes(proposal.data))
|
||||
proposal.status = accepted
|
||||
not exists proposal
|
||||
}
|
||||
|
||||
rule DiscardProposal {
|
||||
when: DiscardProposalRequested(proposal)
|
||||
ensures:
|
||||
if proposal.kind = draft_post:
|
||||
post/DeletePostRequested(proposal.draft_post)
|
||||
if proposal.kind = propose_script:
|
||||
script/DeleteScriptRequested(proposal.proposed_script)
|
||||
if proposal.kind = propose_template:
|
||||
template/DeleteTemplateRequested(proposal.proposed_template)
|
||||
proposal.status = discarded
|
||||
not exists proposal
|
||||
}
|
||||
|
||||
rule ExpireProposal {
|
||||
when: proposal: Proposal.is_expired becomes true
|
||||
-- On expiry: clean up draft DB rows
|
||||
ensures: proposal.status = expired
|
||||
ensures: DiscardProposalRequested(proposal)
|
||||
}
|
||||
|
||||
-- Agent configuration
|
||||
|
||||
value McpAgentKind {
|
||||
-- Supported: claude_code, claude_desktop, github_copilot,
|
||||
-- gemini_cli, opencode, mistral_vibe, openai_codex
|
||||
kind: String
|
||||
}
|
||||
|
||||
surface McpAgentKindSurface {
|
||||
context agent_kind: McpAgentKind
|
||||
|
||||
exposes:
|
||||
agent_kind.kind
|
||||
}
|
||||
|
||||
rule InstallAgentConfig {
|
||||
when: InstallAgentConfigRequested(agent_kind)
|
||||
-- Writes stdio MCP server config into the agent's config file
|
||||
ensures: AgentConfigInstalled(agent_kind)
|
||||
}
|
||||
|
||||
rule UninstallAgentConfig {
|
||||
when: UninstallAgentConfigRequested(agent_kind)
|
||||
ensures: AgentConfigRemoved(agent_kind)
|
||||
}
|
||||
|
||||
invariant ProposalPayloadEncoding {
|
||||
-- Proposal.data stores a serialized payload for metadata proposals.
|
||||
-- draft_post / propose_script / propose_template proposals keep the
|
||||
-- created entity reference directly on the proposal record.
|
||||
}
|
||||
198
specs/media.allium
Normal file
198
specs/media.allium
Normal file
@@ -0,0 +1,198 @@
|
||||
-- allium: 1
|
||||
-- bDS Media Lifecycle
|
||||
-- Scope: core (Wave 1)
|
||||
-- Distilled from: src/main/engine/MediaEngine.ts, schema.ts
|
||||
|
||||
use "./project.allium" as project
|
||||
|
||||
surface MediaControlSurface {
|
||||
facing _: MediaOperator
|
||||
|
||||
provides:
|
||||
ImportMediaRequested(project, source_file)
|
||||
UpdateMediaRequested(media, changes)
|
||||
DeleteMediaRequested(media)
|
||||
UpsertMediaTranslationRequested(media, language, title, alt, caption)
|
||||
RebuildMediaFromFilesRequested(project)
|
||||
}
|
||||
|
||||
value ThumbnailSet {
|
||||
small: String -- 150px width (binary path)
|
||||
medium: String -- 400px width (binary path)
|
||||
large: String -- 800px width (binary path)
|
||||
ai: String -- 448x448 JPEG for vision models (binary path)
|
||||
}
|
||||
|
||||
value SidecarFile {
|
||||
-- {media_file}.meta (YAML-like key-value format)
|
||||
-- Fields: title, alt, caption, author, tags, language, linkedPostIds
|
||||
-- Translations: {media_file}.{lang}.meta
|
||||
path: String
|
||||
}
|
||||
|
||||
surface SidecarFileSurface {
|
||||
context sidecar: SidecarFile
|
||||
|
||||
exposes:
|
||||
sidecar.path
|
||||
}
|
||||
|
||||
entity Media {
|
||||
project: project/Project
|
||||
filename: String
|
||||
original_name: String
|
||||
mime_type: String
|
||||
size: Integer
|
||||
width: Integer?
|
||||
height: Integer?
|
||||
title: String?
|
||||
alt: String?
|
||||
caption: String?
|
||||
author: String?
|
||||
language: String?
|
||||
file_path: String
|
||||
sidecar_path: String
|
||||
checksum: String?
|
||||
tags: List<String>
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
|
||||
-- Relationships
|
||||
translations: MediaTranslation with media = this
|
||||
linked_posts: PostMediaLink with media_id = this.id
|
||||
|
||||
-- Derived
|
||||
available_languages: translations -> language
|
||||
thumbnails: ThumbnailSet
|
||||
}
|
||||
|
||||
surface MediaSurface {
|
||||
context media: Media
|
||||
|
||||
exposes:
|
||||
media.project
|
||||
media.filename
|
||||
media.original_name
|
||||
media.mime_type
|
||||
media.size
|
||||
media.width when media.width != null
|
||||
media.height when media.height != null
|
||||
media.title when media.title != null
|
||||
media.alt when media.alt != null
|
||||
media.caption when media.caption != null
|
||||
media.author when media.author != null
|
||||
media.language when media.language != null
|
||||
media.file_path
|
||||
media.sidecar_path
|
||||
media.checksum when media.checksum != null
|
||||
media.tags
|
||||
media.created_at
|
||||
media.updated_at
|
||||
media.translations.count
|
||||
media.linked_posts.count
|
||||
media.available_languages
|
||||
media.thumbnails.small
|
||||
media.thumbnails.medium
|
||||
media.thumbnails.large
|
||||
media.thumbnails.ai
|
||||
}
|
||||
|
||||
entity MediaTranslation {
|
||||
media: Media
|
||||
language: String
|
||||
title: String?
|
||||
alt: String?
|
||||
caption: String?
|
||||
}
|
||||
|
||||
invariant UniqueMediaTranslation {
|
||||
for a in MediaTranslations:
|
||||
for b in MediaTranslations:
|
||||
(a != b and a.media = b.media) implies a.language != b.language
|
||||
}
|
||||
|
||||
invariant DateBasedMediaLayout {
|
||||
for m in Media:
|
||||
m.file_path = format("media/{yyyy}/{mm}/{uuid}.{ext}",
|
||||
yyyy: m.created_at.year,
|
||||
mm: m.created_at.month_padded,
|
||||
uuid: stem(m.filename),
|
||||
ext: extension(m.filename))
|
||||
}
|
||||
|
||||
rule ImportMedia {
|
||||
when: ImportMediaRequested(project, source_file)
|
||||
let uuid_name = generate_uuid() + extension(source_file)
|
||||
let dest = format("media/{yyyy}/{mm}/{uuid_name}",
|
||||
yyyy: now.year, mm: now.month_padded)
|
||||
ensures: Media.created(
|
||||
project: project,
|
||||
filename: uuid_name,
|
||||
original_name: source_file.name,
|
||||
mime_type: detect_mime(source_file),
|
||||
size: source_file.size,
|
||||
width: detect_width(source_file),
|
||||
height: detect_height(source_file),
|
||||
file_path: dest,
|
||||
tags: {}
|
||||
)
|
||||
ensures: FileCopied(source_file, dest)
|
||||
ensures: SidecarWritten(media)
|
||||
ensures: ThumbnailsGenerated(media)
|
||||
ensures: SearchIndexUpdated(media)
|
||||
}
|
||||
|
||||
rule UpdateMedia {
|
||||
when: UpdateMediaRequested(media, changes)
|
||||
ensures: MediaFieldsUpdated(media, changes)
|
||||
ensures: media.updated_at = now
|
||||
ensures: SidecarWritten(media)
|
||||
-- Metadata changes flush to .meta sidecar
|
||||
ensures: SearchIndexUpdated(media)
|
||||
}
|
||||
|
||||
rule DeleteMedia {
|
||||
when: DeleteMediaRequested(media)
|
||||
ensures: not exists media
|
||||
ensures: MediaFileDeleted(media)
|
||||
ensures: SidecarDeleted(media)
|
||||
ensures: ThumbnailsDeleted(media)
|
||||
ensures:
|
||||
for t in media.translations:
|
||||
not exists t
|
||||
ensures: SearchIndexUpdated(media)
|
||||
}
|
||||
|
||||
rule UpsertMediaTranslation {
|
||||
when: UpsertMediaTranslationRequested(media, language, title, alt, caption)
|
||||
ensures: MediaTranslation.created(
|
||||
media: media,
|
||||
language: language,
|
||||
title: title,
|
||||
alt: alt,
|
||||
caption: caption
|
||||
)
|
||||
ensures: TranslationSidecarWritten(media, language)
|
||||
-- Writes {file}.{lang}.meta
|
||||
}
|
||||
|
||||
rule RebuildMediaFromFiles {
|
||||
when: RebuildMediaFromFilesRequested(project)
|
||||
-- Scans media directory for .meta sidecars, reimports to DB
|
||||
for sidecar in scan_directory(project.effective_data_dir + "/media", "*.meta"):
|
||||
let parsed = parse_sidecar(sidecar)
|
||||
ensures: Media.created(parsed)
|
||||
-- or updated if already exists
|
||||
@guidance
|
||||
-- This is the filesystem-to-DB reconciliation path
|
||||
-- Used after git pull or manual file changes
|
||||
}
|
||||
|
||||
invariant SidecarRoundtrip {
|
||||
-- Sidecar files faithfully represent DB metadata
|
||||
for m in Media:
|
||||
parse_sidecar(m.sidecar_path).title = m.title
|
||||
parse_sidecar(m.sidecar_path).alt = m.alt
|
||||
parse_sidecar(m.sidecar_path).caption = m.caption
|
||||
parse_sidecar(m.sidecar_path).tags = m.tags
|
||||
}
|
||||
374
specs/media_processing.allium
Normal file
374
specs/media_processing.allium
Normal file
@@ -0,0 +1,374 @@
|
||||
-- allium: 1
|
||||
-- bDS Media Processing Specification
|
||||
-- Scope: core (Wave 1 — media import and processing)
|
||||
-- Distilled from: ../bDS/src/main/engine/MediaEngine.ts,
|
||||
-- mediaProcessing.ts, thumbnail generation logic
|
||||
--
|
||||
-- This document specifies the exact media processing behavior:
|
||||
-- thumbnail generation, format conversion, EXIF handling, and file organization.
|
||||
|
||||
use "./media.allium" as media
|
||||
use "./search.allium" as search
|
||||
|
||||
surface MediaProcessingControlSurface {
|
||||
facing _: MediaProcessingOperator
|
||||
|
||||
provides:
|
||||
ImportMediaRequested(source_path, project)
|
||||
TagMediaRequested(media, tags)
|
||||
DeleteMediaRequested(media)
|
||||
ValidateMediaRequested(project)
|
||||
}
|
||||
|
||||
surface MediaProcessingRuntimeSurface {
|
||||
facing _: MediaProcessingRuntime
|
||||
|
||||
provides:
|
||||
MediaImported(media)
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- MEDIA FILE ORGANIZATION
|
||||
-- ============================================================================
|
||||
|
||||
value MediaFileLayout {
|
||||
-- Binary assets stored in: media/{YYYY}/{MM}/{uuid}.{ext}
|
||||
-- Sidecar metadata in: {binary_path}.meta
|
||||
-- Thumbnails in: thumbnails/{id[0:2]}/{id}-{size}.webp
|
||||
-- (ai thumbnail is JPEG: thumbnails/{id[0:2]}/{id}-ai.jpg)
|
||||
binary_path: String -- media/{YYYY}/{MM}/{uuid}.{ext}
|
||||
sidecar_path: String -- {binary_path}.meta
|
||||
thumbnail_small: String -- thumbnails/{prefix}/{id}-small.webp
|
||||
thumbnail_medium: String -- thumbnails/{prefix}/{id}-medium.webp
|
||||
thumbnail_large: String -- thumbnails/{prefix}/{id}-large.webp
|
||||
thumbnail_ai: String -- thumbnails/{prefix}/{id}-ai.jpg
|
||||
}
|
||||
|
||||
surface MediaFileLayoutSurface {
|
||||
context layout: MediaFileLayout
|
||||
|
||||
exposes:
|
||||
layout.binary_path
|
||||
layout.sidecar_path
|
||||
layout.thumbnail_small
|
||||
layout.thumbnail_medium
|
||||
layout.thumbnail_large
|
||||
layout.thumbnail_ai
|
||||
}
|
||||
|
||||
invariant MediaFileNaming {
|
||||
-- Original filename is preserved in original_name field
|
||||
-- Stored filename uses UUID v4: {uuid}.{ext}
|
||||
-- Example: a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg
|
||||
for m in Media:
|
||||
m.filename = format("{uuid}.{ext}",
|
||||
uuid: generate_uuid_v4(),
|
||||
ext: file_extension(m.original_name))
|
||||
}
|
||||
|
||||
invariant ThumbnailPathBucketing {
|
||||
-- Thumbnails are bucketed by first 2 chars of media ID
|
||||
-- This avoids filesystem slowdowns from too many files in one directory
|
||||
for m in Media:
|
||||
let prefix = substring(m.id, 0, 2)
|
||||
m.thumbnails.small = format("thumbnails/{prefix}/{id}-small.webp",
|
||||
prefix: prefix, id: m.id)
|
||||
m.thumbnails.medium = format("thumbnails/{prefix}/{id}-medium.webp",
|
||||
prefix: prefix, id: m.id)
|
||||
m.thumbnails.large = format("thumbnails/{prefix}/{id}-large.webp",
|
||||
prefix: prefix, id: m.id)
|
||||
m.thumbnails.ai = format("thumbnails/{prefix}/{id}-ai.jpg",
|
||||
prefix: prefix, id: m.id)
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- THUMBNAIL GENERATION
|
||||
-- ============================================================================
|
||||
|
||||
config {
|
||||
-- Four thumbnail sizes generated per image
|
||||
thumbnail_small_width: Integer = 150
|
||||
thumbnail_medium_width: Integer = 400
|
||||
thumbnail_large_width: Integer = 800
|
||||
thumbnail_ai_size: Integer = 448 -- 448x448 square crop, JPEG
|
||||
thumbnail_format: String = "webp" -- All sizes except AI (encoder default quality)
|
||||
thumbnail_ai_format: String = "jpeg" -- AI thumbnail only
|
||||
}
|
||||
|
||||
rule GenerateThumbnails {
|
||||
when: MediaImported(media)
|
||||
requires: is_image(media.mime_type)
|
||||
-- Generate all four thumbnail sizes
|
||||
ensures: ThumbnailGenerated(
|
||||
source: media.file_path,
|
||||
destination: media.thumbnails.small,
|
||||
width: config.thumbnail_small_width,
|
||||
format: config.thumbnail_format
|
||||
)
|
||||
ensures: ThumbnailGenerated(
|
||||
source: media.file_path,
|
||||
destination: media.thumbnails.medium,
|
||||
width: config.thumbnail_medium_width,
|
||||
format: config.thumbnail_format
|
||||
)
|
||||
ensures: ThumbnailGenerated(
|
||||
source: media.file_path,
|
||||
destination: media.thumbnails.large,
|
||||
width: config.thumbnail_large_width,
|
||||
format: config.thumbnail_format
|
||||
)
|
||||
ensures: ThumbnailGenerated(
|
||||
source: media.file_path,
|
||||
destination: media.thumbnails.ai,
|
||||
size: config.thumbnail_ai_size,
|
||||
format: config.thumbnail_ai_format
|
||||
)
|
||||
}
|
||||
|
||||
-- Thumbnail generation algorithm
|
||||
value ThumbnailGeneration {
|
||||
-- 1. Load source image
|
||||
-- 2. Apply EXIF orientation correction (rotation, flip) so thumbnails display correctly
|
||||
-- 3. Resize: small/medium/large preserve aspect ratio (width-constrained)
|
||||
-- AI thumbnail is a 448x448 center crop (letterboxed on black background)
|
||||
-- 4. Encode as WebP (encoder default quality) for small/medium/large
|
||||
-- Encode as JPEG for AI thumbnail
|
||||
-- 5. Write to bucketed thumbnail path: thumbnails/{id[0:2]}/{id}-{size}.{ext}
|
||||
--
|
||||
-- No _source copy is made. Thumbnails regenerated from the original binary.
|
||||
}
|
||||
|
||||
surface ThumbnailGenerationSurface {
|
||||
context _: ThumbnailGeneration
|
||||
}
|
||||
|
||||
invariant ThumbnailExifHandling {
|
||||
-- EXIF orientation IS applied during thumbnail generation so that
|
||||
-- thumbnails always appear right-side-up regardless of camera metadata.
|
||||
-- Width/height stored in DB are the raw header values (pre-rotation).
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- IMAGE PROCESSING RULES
|
||||
-- ============================================================================
|
||||
|
||||
value ImageProcessing {
|
||||
-- Supported input formats:
|
||||
input_formats: Set<String> = {
|
||||
"image/jpeg", "image/png", "image/gif",
|
||||
"image/webp", "image/tiff", "image/bmp",
|
||||
"image/heic", "image/heif"
|
||||
}
|
||||
|
||||
-- Output formats:
|
||||
output_formats: Set<String> = {
|
||||
"image/webp", -- Primary output for thumbnails
|
||||
"image/jpeg" -- AI thumbnail only
|
||||
}
|
||||
|
||||
-- Processing rules:
|
||||
-- 1. All thumbnails (except AI) are encoded as WebP (encoder default quality)
|
||||
-- 2. AI thumbnail is encoded as JPEG (for vision model compatibility)
|
||||
-- 3. Original format is preserved for full-size assets (no conversion)
|
||||
-- 4. EXIF data is not stripped (thumbnails are re-encoded, so EXIF is naturally absent)
|
||||
}
|
||||
|
||||
surface ImageProcessingSurface {
|
||||
context processing: ImageProcessing
|
||||
|
||||
exposes:
|
||||
processing.input_formats
|
||||
processing.output_formats
|
||||
}
|
||||
|
||||
rule ProcessImageMetadata {
|
||||
when: MediaImported(media)
|
||||
-- Extract image metadata from raw file header
|
||||
ensures: media.width = extract_width_from_header(source_file)
|
||||
ensures: media.height = extract_height_from_header(source_file)
|
||||
ensures: media.mime_type = detect_mime_from_extension(source_file)
|
||||
ensures: media.size = file_size(source_file)
|
||||
}
|
||||
|
||||
invariant MimeDetection {
|
||||
-- MIME type is detected from file extension, not from file content/magic bytes
|
||||
-- Extension mapping: .jpg/.jpeg -> image/jpeg, .png -> image/png, etc.
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- MEDIA TRANSLATION FILES
|
||||
-- ============================================================================
|
||||
|
||||
value MediaTranslationFile {
|
||||
-- File path: {binary_path}.{language}.meta
|
||||
-- Format: YAML-like key-value sidecar (same as canonical sidecar)
|
||||
translation_for: String -- Canonical media ID
|
||||
language: String -- ISO 639-1 code
|
||||
title: String?
|
||||
alt: String?
|
||||
caption: String?
|
||||
}
|
||||
|
||||
surface MediaTranslationFileSurface {
|
||||
context file: MediaTranslationFile
|
||||
|
||||
exposes:
|
||||
file.translation_for
|
||||
file.language
|
||||
file.title when file.title != null
|
||||
file.alt when file.alt != null
|
||||
file.caption when file.caption != null
|
||||
}
|
||||
|
||||
invariant MediaTranslationFileLayout {
|
||||
for t in MediaTranslations:
|
||||
-- Translation sidecars sit next to the binary, with language suffix
|
||||
t.file_path = format("{binary_path}.{lang}.meta",
|
||||
binary_path: t.media.file_path,
|
||||
lang: t.language)
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- MEDIA IMPORT RULES
|
||||
-- ============================================================================
|
||||
|
||||
rule ImportMedia {
|
||||
when: ImportMediaRequested(source_path, project)
|
||||
-- 1. Validate file type (must be supported image)
|
||||
-- 2. Generate UUID v4 filename
|
||||
-- 3. Copy to media/{YYYY}/{MM}/{uuid}.{ext}
|
||||
-- 4. Write sidecar {binary_path}.meta
|
||||
-- 5. Generate four thumbnail sizes
|
||||
-- 6. Index for search (FTS5)
|
||||
ensures: media/Media.created(
|
||||
filename: generate_uuid_v4_filename(source_path),
|
||||
original_name: basename(source_path),
|
||||
mime_type: detect_mime_from_extension(source_path),
|
||||
size: file_size(source_path),
|
||||
width: extract_width_from_header(source_path),
|
||||
height: extract_height_from_header(source_path),
|
||||
file_path: format("media/{yyyy}/{mm}/{uuid}.{ext}"),
|
||||
sidecar_path: format("media/{yyyy}/{mm}/{uuid}.{ext}.meta"),
|
||||
checksum: sha256(source_path)
|
||||
)
|
||||
ensures: ThumbnailsGenerated(media_id)
|
||||
ensures: SearchIndexUpdated(media_id)
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- MEDIA TAGGING
|
||||
-- ============================================================================
|
||||
|
||||
invariant MediaTagsFormat {
|
||||
-- Media tags are stored as JSON array in sidecar file
|
||||
-- Tags are optional and only written if present
|
||||
-- Same format as post tags
|
||||
}
|
||||
|
||||
rule TagMedia {
|
||||
when: TagMediaRequested(media, tags)
|
||||
ensures: media.tags = tags
|
||||
ensures: SidecarFileUpdated(media)
|
||||
ensures: SearchIndexUpdated(media)
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- MEDIA DELETION
|
||||
-- ============================================================================
|
||||
|
||||
rule DeleteMedia {
|
||||
when: DeleteMediaRequested(media)
|
||||
-- 1. Remove from database
|
||||
-- 2. Delete binary file
|
||||
-- 3. Delete sidecar file ({binary_path}.meta)
|
||||
-- 4. Delete all four thumbnail files
|
||||
-- 5. Delete translation sidecars ({binary_path}.{lang}.meta)
|
||||
-- 6. Remove from search index
|
||||
-- 7. Remove from all post links
|
||||
ensures: FileDeleted(media.file_path)
|
||||
ensures: FileDeleted(media.sidecar_path)
|
||||
ensures: FileDeleted(media.thumbnails.small)
|
||||
ensures: FileDeleted(media.thumbnails.medium)
|
||||
ensures: FileDeleted(media.thumbnails.large)
|
||||
ensures: FileDeleted(media.thumbnails.ai)
|
||||
ensures: SearchIndexRemoved(media)
|
||||
ensures:
|
||||
for p in media.linked_posts:
|
||||
p.linked_media = p.linked_media - {media}
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- MEDIA VALIDATION
|
||||
-- ============================================================================
|
||||
|
||||
rule ValidateMedia {
|
||||
when: ValidateMediaRequested(project)
|
||||
-- Check for:
|
||||
-- 1. Missing binary files
|
||||
-- 2. Missing sidecar files
|
||||
-- 3. Missing thumbnails (all 4 sizes)
|
||||
-- 4. Corrupted image files
|
||||
-- 5. Orphan media (not linked to any post)
|
||||
for m in project.media:
|
||||
if not file_exists(m.file_path):
|
||||
ensures: ValidationIssueReported(m, "missing_binary")
|
||||
if not file_exists(m.sidecar_path):
|
||||
ensures: ValidationIssueReported(m, "missing_sidecar")
|
||||
if not file_exists(m.thumbnails.small):
|
||||
ensures: ValidationIssueReported(m, "missing_thumbnail_small")
|
||||
if not file_exists(m.thumbnails.medium):
|
||||
ensures: ValidationIssueReported(m, "missing_thumbnail_medium")
|
||||
if not file_exists(m.thumbnails.large):
|
||||
ensures: ValidationIssueReported(m, "missing_thumbnail_large")
|
||||
if not file_exists(m.thumbnails.ai):
|
||||
ensures: ValidationIssueReported(m, "missing_thumbnail_ai")
|
||||
if not is_valid_image(m.file_path):
|
||||
ensures: ValidationIssueReported(m, "corrupted")
|
||||
if not exists (p in Posts where m in p.linked_media):
|
||||
ensures: ValidationIssueReported(m, "orphan")
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- MEDIA SIDECAR FORMAT
|
||||
-- ============================================================================
|
||||
|
||||
invariant MediaSidecarFormat {
|
||||
-- Sidecar files use YAML-like key-value format (hand-built, not gray-matter)
|
||||
-- Path: {binary_path}.meta
|
||||
-- Only truthy fields are written (except required fields)
|
||||
-- Fields: title, alt, caption, author, tags, language, linkedPostIds
|
||||
-- Note: 'filename' is NOT written to sidecar (it is the binary filename itself)
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- IMAGE OPTIMIZATION
|
||||
-- ============================================================================
|
||||
|
||||
config {
|
||||
-- No file size limit on import
|
||||
-- Original files are stored as-is (no compression, no resize)
|
||||
-- Only thumbnails are generated from the original
|
||||
strip_exif: Boolean = false -- Not explicitly stripped; re-encoding naturally omits it
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- MEDIA SEARCH INDEXING
|
||||
-- ============================================================================
|
||||
|
||||
rule IndexMediaForSearch {
|
||||
when: SearchIndexUpdated(media: media/Media)
|
||||
-- Index fields: title, alt, caption, original_name, tags
|
||||
-- Plus all translation titles, alts, and captions
|
||||
let all_text = concat(
|
||||
media.title,
|
||||
media.alt,
|
||||
media.caption,
|
||||
media.original_name,
|
||||
join(media.tags, " ")
|
||||
)
|
||||
let stemmed = stem(all_text, detect_language(all_text))
|
||||
ensures: search/MediaSearchIndex.created(
|
||||
media: media,
|
||||
stemmed_content: stemmed
|
||||
)
|
||||
}
|
||||
56
specs/menu.allium
Normal file
56
specs/menu.allium
Normal file
@@ -0,0 +1,56 @@
|
||||
-- allium: 1
|
||||
-- bDS Navigation Menu
|
||||
-- Scope: core (read for rendering), extension Bucket F (menu editor UI)
|
||||
-- Distilled from: src/main/engine/MenuEngine.ts
|
||||
|
||||
surface MenuManagementSurface {
|
||||
facing _: MenuOperator
|
||||
|
||||
provides:
|
||||
UpdateMenuRequested(menu, items)
|
||||
}
|
||||
|
||||
value MenuItem {
|
||||
kind: page | submenu | category_archive | home
|
||||
label: String
|
||||
slug: String?
|
||||
children: List<MenuItem>? -- only for submenu kind
|
||||
}
|
||||
|
||||
entity Menu {
|
||||
items: List<MenuItem>
|
||||
|
||||
-- Derived
|
||||
home_items: items where kind = home
|
||||
home_entry: home_items.first
|
||||
}
|
||||
|
||||
surface MenuSurface {
|
||||
context menu: Menu
|
||||
|
||||
exposes:
|
||||
menu.items.count
|
||||
menu.home_items.count
|
||||
menu.home_entry.label
|
||||
}
|
||||
|
||||
invariant HomeAlwaysPresent {
|
||||
-- The menu always has a Home entry, extracted and prepended
|
||||
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
|
||||
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)
|
||||
}
|
||||
125
specs/metadata.allium
Normal file
125
specs/metadata.allium
Normal file
@@ -0,0 +1,125 @@
|
||||
-- allium: 1
|
||||
-- bDS Project Metadata, Categories, Publishing Preferences
|
||||
-- Scope: core (Wave 1)
|
||||
-- Distilled from: src/main/engine/MetaEngine.ts, schema.ts
|
||||
|
||||
use "./project.allium" as project
|
||||
|
||||
surface MetadataControlSurface {
|
||||
facing _: MetadataOperator
|
||||
|
||||
provides:
|
||||
UpdateProjectMetadataRequested(project, changes)
|
||||
AddCategoryRequested(project, name)
|
||||
RemoveCategoryRequested(project, name)
|
||||
UpdateCategorySettingsRequested(project, category, settings)
|
||||
SetPublishingPreferencesRequested(project, prefs)
|
||||
AppStarted(project)
|
||||
}
|
||||
|
||||
surface PublishingPreferencesSurface {
|
||||
context prefs: PublishingPreferences
|
||||
|
||||
exposes:
|
||||
prefs.ssh_host when prefs.ssh_host != null
|
||||
prefs.ssh_user when prefs.ssh_user != null
|
||||
prefs.ssh_remote_path when prefs.ssh_remote_path != null
|
||||
prefs.ssh_mode
|
||||
}
|
||||
|
||||
value CategoryRenderSettings {
|
||||
render_in_lists: Boolean
|
||||
show_title: Boolean
|
||||
post_template_slug: String?
|
||||
list_template_slug: String?
|
||||
}
|
||||
|
||||
entity ProjectMetadata {
|
||||
project: project/Project
|
||||
name: String
|
||||
description: String?
|
||||
public_url: String?
|
||||
main_language: String? -- ISO 639-1
|
||||
default_author: String?
|
||||
max_posts_per_page: Integer -- 1..500, default 50
|
||||
blogmark_category: String?
|
||||
pico_theme: String? -- 12+ named Pico CSS themes
|
||||
semantic_similarity_enabled: Boolean
|
||||
blog_languages: Set<String> -- subset of supported languages
|
||||
categories: Set<String> -- category names
|
||||
category_settings: Set<CategoryRenderSettings>
|
||||
}
|
||||
|
||||
entity PublishingPreferences {
|
||||
ssh_host: String?
|
||||
ssh_user: String?
|
||||
ssh_remote_path: String?
|
||||
ssh_mode: scp | rsync
|
||||
}
|
||||
|
||||
invariant DefaultCategories {
|
||||
-- New projects start with: article, picture, aside, page
|
||||
-- These are defaults, not invariants — user can remove them
|
||||
}
|
||||
|
||||
invariant MetadataPersistedAsFiles {
|
||||
-- Four separate JSON files in meta/:
|
||||
-- meta/project.json — name, description, publicUrl, mainLanguage, etc.
|
||||
-- meta/categories.json — sorted category list
|
||||
-- meta/category-meta.json — per-category render settings
|
||||
-- meta/publishing.json — SSH connection details (non-secret)
|
||||
-- All writes are atomic (temp file + rename)
|
||||
}
|
||||
|
||||
config {
|
||||
default_max_posts_per_page: Integer = 50
|
||||
min_posts_per_page: Integer = 1
|
||||
max_posts_per_page: Integer = 500
|
||||
default_categories: Set<String> = {"article", "picture", "aside", "page"}
|
||||
supported_pico_themes: Set<String> = {
|
||||
"default", "amber", "blue", "cyan", "fuchsia", "green",
|
||||
"grey", "indigo", "jade", "lime", "orange", "pink",
|
||||
"pumpkin", "purple", "red", "sand", "slate", "violet",
|
||||
"yellow", "zinc"
|
||||
}
|
||||
}
|
||||
|
||||
rule UpdateProjectMetadata {
|
||||
when: UpdateProjectMetadataRequested(project, changes)
|
||||
ensures: MetadataFieldsUpdated(project, changes)
|
||||
ensures: ProjectJsonWritten(project)
|
||||
}
|
||||
|
||||
rule AddCategory {
|
||||
when: AddCategoryRequested(project, name)
|
||||
requires: not (name in project.metadata.categories)
|
||||
ensures: project.metadata.categories = project.metadata.categories + {name}
|
||||
ensures: CategoriesJsonWritten(project)
|
||||
}
|
||||
|
||||
rule RemoveCategory {
|
||||
when: RemoveCategoryRequested(project, name)
|
||||
ensures: project.metadata.categories = project.metadata.categories - {name}
|
||||
ensures: CategorySettingsRemoved(project, name)
|
||||
ensures: CategoriesJsonWritten(project)
|
||||
ensures: CategoryMetaJsonWritten(project)
|
||||
}
|
||||
|
||||
rule UpdateCategorySettings {
|
||||
when: UpdateCategorySettingsRequested(project, category, settings)
|
||||
ensures: CategorySettingsUpdated(project, category, settings)
|
||||
ensures: CategoryMetaJsonWritten(project)
|
||||
}
|
||||
|
||||
rule SetPublishingPreferences {
|
||||
when: SetPublishingPreferencesRequested(project, prefs)
|
||||
ensures: project.publishing_preferences = prefs
|
||||
ensures: PublishingJsonWritten(project)
|
||||
}
|
||||
|
||||
rule StartupSync {
|
||||
when: AppStarted(project)
|
||||
-- Loads metadata from filesystem, merges with DB,
|
||||
-- creates defaults for new projects
|
||||
ensures: ProjectMetadata.synced_from_filesystem(project)
|
||||
}
|
||||
81
specs/metadata_diff.allium
Normal file
81
specs/metadata_diff.allium
Normal file
@@ -0,0 +1,81 @@
|
||||
-- allium: 1
|
||||
-- bDS Metadata Diff and Rebuild
|
||||
-- Scope: core (Wave 1)
|
||||
-- Distilled from: src/main/engine/MetadataDiffEngine.ts
|
||||
|
||||
use "./post.allium" as post
|
||||
use "./media.allium" as media
|
||||
use "./script.allium" as script
|
||||
use "./template.allium" as template
|
||||
|
||||
surface MetadataMaintenanceSurface {
|
||||
facing _: MaintenanceOperator
|
||||
|
||||
provides:
|
||||
MetadataDiffRequested(project)
|
||||
RebuildFromFilesystemRequested(project, entity_type)
|
||||
}
|
||||
|
||||
value DiffField {
|
||||
name: String
|
||||
db_value: String
|
||||
file_value: String
|
||||
}
|
||||
|
||||
value DiffReport {
|
||||
entity_type: String -- post, media, script, template
|
||||
entity_id: String
|
||||
differences: List<DiffField>
|
||||
}
|
||||
|
||||
value OrphanReport {
|
||||
file_path: String
|
||||
-- File exists on disk but has no DB record
|
||||
}
|
||||
|
||||
rule RunMetadataDiff {
|
||||
when: MetadataDiffRequested(project)
|
||||
-- Runs as background task via TaskManager
|
||||
-- Compares DB records against filesystem files for:
|
||||
-- posts, translations, media, scripts, templates
|
||||
-- Detected fields: tags, categories, title, excerpt, author,
|
||||
-- language, status, templateSlug, dates
|
||||
for post in project.posts:
|
||||
let file_data = parse_post_file(post.file_path)
|
||||
let diffs = compare_fields(post, file_data)
|
||||
if diffs.count > 0:
|
||||
ensures: DiffReport.created(entity_type: "post", entity_id: post.id, differences: diffs)
|
||||
|
||||
-- Detect orphan files (on disk but not in DB)
|
||||
for file in scan_directory(project.effective_data_dir + "/posts", "*.md"):
|
||||
let matching = Posts where file_path = file
|
||||
if matching.count = 0:
|
||||
ensures: OrphanReport.created(file_path: file)
|
||||
|
||||
-- Same pattern for media sidecar files, scripts, templates
|
||||
}
|
||||
|
||||
rule RebuildFromFilesystem {
|
||||
when: RebuildFromFilesystemRequested(project, entity_type)
|
||||
-- The inverse of metadata diff: filesystem is treated as truth
|
||||
-- Reads all files and upserts into DB
|
||||
ensures:
|
||||
if entity_type = "post":
|
||||
post/RebuildPostsFromFiles(project)
|
||||
if entity_type = "media":
|
||||
media/RebuildMediaFromFiles(project)
|
||||
if entity_type = "script":
|
||||
script/RebuildScriptsFromFiles(project)
|
||||
if entity_type = "template":
|
||||
template/RebuildTemplatesFromFiles(project)
|
||||
}
|
||||
|
||||
invariant ThreeWaySync {
|
||||
-- Metadata must stay in sync across three representations:
|
||||
-- 1. Database records
|
||||
-- 2. Filesystem files (frontmatter/sidecars)
|
||||
-- 3. Generated site output
|
||||
-- MetadataDiff detects divergence between (1) and (2)
|
||||
-- Rebuild resolves divergence by treating (2) as truth
|
||||
-- Site generation consumes (1) to produce (3)
|
||||
}
|
||||
251
specs/modals.allium
Normal file
251
specs/modals.allium
Normal file
@@ -0,0 +1,251 @@
|
||||
-- allium: 1
|
||||
-- bDS Shared Modals
|
||||
-- Scope: UI overlays used across multiple editor views
|
||||
-- Distilled from: PostEditor.tsx, MediaEditor.tsx
|
||||
|
||||
-- Shared modal components used by multiple editors.
|
||||
-- Editor-specific modals live in their respective editor spec files.
|
||||
|
||||
use "./i18n.allium" as i18n
|
||||
|
||||
-- ─── AI Suggestions Modal ────────────────────────────────────
|
||||
|
||||
-- Shared modal for presenting AI suggestions with per-field accept/reject.
|
||||
-- Used by PostAIAnalysis (editor_post.allium) and
|
||||
-- MediaAIImageAnalysis (editor_media.allium).
|
||||
|
||||
value AISuggestionsModal {
|
||||
fields: List<AISuggestionField>
|
||||
-- Layout: title bar ("AI Suggestions"), scrollable field list, button row
|
||||
-- Each field rendered as a row:
|
||||
-- Left: checkbox (accept/reject), label
|
||||
-- Center: current value (read-only, muted), arrow, suggested value (highlighted)
|
||||
-- Special: slug field checkbox disabled if post was ever published
|
||||
-- Buttons: Cancel (secondary), Apply Selected (primary)
|
||||
-- Cancel discards all; Apply writes only accepted fields to entity
|
||||
}
|
||||
|
||||
surface AISuggestionsModalSurface {
|
||||
context modal: AISuggestionsModal
|
||||
|
||||
exposes:
|
||||
modal.fields.count
|
||||
}
|
||||
|
||||
value AISuggestionField {
|
||||
label: String
|
||||
current_value: String
|
||||
suggested_value: String
|
||||
accepted: Boolean -- checkbox, default true
|
||||
locked: Boolean -- if true, checkbox disabled (e.g. published slug)
|
||||
}
|
||||
|
||||
-- ─── Insert Post Link Modal ──────────────────────────────────
|
||||
|
||||
value InsertPostLinkModal {
|
||||
-- Two-tab modal opened by Ctrl/Cmd+K in post editor (markdown mode).
|
||||
-- Tab 1 - Internal:
|
||||
-- Search input (debounced 300ms, queries post titles via FTS)
|
||||
--
|
||||
-- Empty query state (search_query < 2 chars):
|
||||
-- If semanticSimilarityEnabled: shows up to 5 related posts via
|
||||
-- FindSimilar(current_post, 5) ranked by embedding similarity
|
||||
-- Else: shows nothing (empty results)
|
||||
--
|
||||
-- Active query state (search_query >= 2 chars):
|
||||
-- Results from FTS title search
|
||||
-- If semanticSimilarityEnabled: each result augmented with similarity
|
||||
-- score from ComputeSimilarities(current_post, result_post_ids)
|
||||
-- Scores displayed as visual indicator per result row
|
||||
-- Results list: post title + status badge + optional similarity score
|
||||
--
|
||||
-- Click result: inserts [title](/YYYY/MM/DD/slug) at cursor, closes modal
|
||||
-- "Create Post" row at bottom of results:
|
||||
-- Creates new post with search query as title, inserts link to it
|
||||
-- Tab 2 - External:
|
||||
-- URL input field (required)
|
||||
-- Display text input field (optional)
|
||||
-- Insert button: inserts [text](url) or bare url if no text, closes modal
|
||||
active_tab: String -- internal | external
|
||||
search_query: String
|
||||
results: List<InsertLinkResult>
|
||||
related_posts: List<InsertLinkResult> -- similarity-based, shown when query empty
|
||||
}
|
||||
|
||||
surface InsertPostLinkModalSurface {
|
||||
context modal: InsertPostLinkModal
|
||||
|
||||
exposes:
|
||||
modal.active_tab
|
||||
modal.search_query
|
||||
modal.results.count
|
||||
modal.related_posts.count
|
||||
}
|
||||
|
||||
value InsertLinkResult {
|
||||
post_id: String
|
||||
title: String
|
||||
status: String -- draft | published | archived
|
||||
canonical_url: String -- /YYYY/MM/DD/slug
|
||||
similarity_score: Decimal? -- 0.0-1.0, present when embeddings enabled
|
||||
}
|
||||
|
||||
-- ─── Insert Media Modal ──────────────────────────────────────
|
||||
|
||||
value InsertMediaModal {
|
||||
-- Grid modal for inserting media references into post content.
|
||||
-- Search input filtering by media title and original filename.
|
||||
-- Grid of media items: bds-thumb:// thumbnail (medium 400px), title below.
|
||||
-- Click item:
|
||||
-- Images: inserts  at cursor
|
||||
-- Non-images: inserts [originalName](bds-media://id) at cursor
|
||||
-- Closes modal after insertion.
|
||||
search_query: String
|
||||
results: List<InsertMediaResult>
|
||||
}
|
||||
|
||||
surface InsertMediaModalSurface {
|
||||
context modal: InsertMediaModal
|
||||
|
||||
exposes:
|
||||
modal.search_query
|
||||
modal.results.count
|
||||
}
|
||||
|
||||
value InsertMediaResult {
|
||||
media_id: String
|
||||
title: String
|
||||
original_name: String
|
||||
is_image: Boolean
|
||||
thumbnail_url: String? -- bds-thumb:// for images, null for others
|
||||
}
|
||||
|
||||
-- ─── Language Picker Modal ───────────────────────────────────
|
||||
|
||||
value LanguagePickerModal {
|
||||
-- Shown for Translate Post and Translate Media Metadata actions.
|
||||
-- Lists all configured blogLanguages except source language.
|
||||
-- Each row: flag emoji, language name, status badge if translation exists.
|
||||
-- Existing translations show "(draft)" or "(published)" badge.
|
||||
-- Click selects target language and initiates translation flow.
|
||||
-- Cancel closes without action.
|
||||
source_language: String
|
||||
available_targets: List<LanguageTarget>
|
||||
}
|
||||
|
||||
surface LanguagePickerModalSurface {
|
||||
context modal: LanguagePickerModal
|
||||
|
||||
exposes:
|
||||
modal.source_language
|
||||
modal.available_targets.count
|
||||
}
|
||||
|
||||
value LanguageTarget {
|
||||
code: String
|
||||
name: String
|
||||
flag_emoji: String
|
||||
has_existing_translation: Boolean
|
||||
existing_status: String? -- draft | published, if translation exists
|
||||
}
|
||||
|
||||
-- ─── Confirm Delete Modal ────────────────────────────────────
|
||||
|
||||
value ConfirmDeleteModal {
|
||||
-- Custom styled modal for destructive operations with reference info.
|
||||
-- Used by: MediaDelete (shows linked posts), TagDelete (shows post count).
|
||||
-- Layout: warning icon, title, entity name, reference section, buttons.
|
||||
-- Reference section: "This item is referenced by:" + bulleted list.
|
||||
-- Buttons: Cancel (secondary), Delete (destructive red).
|
||||
entity_name: String
|
||||
entity_type: String -- media | tag
|
||||
reference_count: Integer
|
||||
reference_list: List<String> -- titles of referencing entities
|
||||
}
|
||||
|
||||
surface ConfirmDeleteModalSurface {
|
||||
context modal: ConfirmDeleteModal
|
||||
|
||||
exposes:
|
||||
modal.entity_name
|
||||
modal.entity_type
|
||||
modal.reference_count
|
||||
modal.reference_list
|
||||
}
|
||||
|
||||
-- ─── Confirm Dialog ──────────────────────────────────────────
|
||||
|
||||
value ConfirmDialog {
|
||||
-- Custom styled modal for non-delete confirmations.
|
||||
-- Used by: TagMerge ("Merge N tags into {target}? Cannot be undone.").
|
||||
-- Layout: title, descriptive message, buttons.
|
||||
-- Buttons: Cancel (secondary), Confirm (primary).
|
||||
title: String
|
||||
message: String
|
||||
}
|
||||
|
||||
surface ConfirmDialogSurface {
|
||||
context modal: ConfirmDialog
|
||||
|
||||
exposes:
|
||||
modal.title
|
||||
modal.message
|
||||
}
|
||||
|
||||
-- System confirm dialogs are NOT modelled as values.
|
||||
-- They are simple yes/no system dialogs with a message string.
|
||||
-- Used by: PostDelete, PostDiscard, TemplateDelete (with references).
|
||||
|
||||
-- ─── Gallery Overlay ─────────────────────────────────────────
|
||||
|
||||
value GalleryOverlay {
|
||||
-- Full-screen overlay showing all media linked to a post.
|
||||
-- Opened from Gallery button in post editor toolbar (markdown mode).
|
||||
-- Image grid: bds-thumb:// thumbnails (medium 400px), 3-4 columns.
|
||||
-- Click image: opens LightboxView for that image.
|
||||
-- Close: X button or ESC key.
|
||||
post_id: String
|
||||
images: List<GalleryImage>
|
||||
}
|
||||
|
||||
surface GalleryOverlaySurface {
|
||||
context overlay: GalleryOverlay
|
||||
|
||||
exposes:
|
||||
overlay.post_id
|
||||
overlay.images.count
|
||||
}
|
||||
|
||||
value GalleryImage {
|
||||
media_id: String
|
||||
thumbnail_url: String -- bds-thumb://media_id
|
||||
alt_text: String?
|
||||
}
|
||||
|
||||
value LightboxView {
|
||||
-- Full-screen image viewer, sub-view of GalleryOverlay.
|
||||
-- Shows single image at full resolution via bds-media:// protocol.
|
||||
-- Navigation: left/right arrow buttons, keyboard left/right arrow keys.
|
||||
-- Close: X button, ESC key, or click outside image area.
|
||||
-- Header: image title or filename, index counter "3 of 12".
|
||||
current_index: Integer
|
||||
total_count: Integer
|
||||
media_id: String
|
||||
image_url: String -- bds-media://media_id
|
||||
alt_text: String?
|
||||
}
|
||||
|
||||
surface LightboxViewSurface {
|
||||
context view: LightboxView
|
||||
|
||||
exposes:
|
||||
view.current_index
|
||||
view.total_count
|
||||
view.media_id
|
||||
view.image_url
|
||||
view.alt_text when view.alt_text != null
|
||||
}
|
||||
|
||||
-- All modals rendered as centered overlay with backdrop dimming.
|
||||
-- ESC key or backdrop click closes modal (cancel semantics).
|
||||
-- Overlays (PostPicker, ColourPicker) are positioned inline near trigger.
|
||||
234
specs/post.allium
Normal file
234
specs/post.allium
Normal file
@@ -0,0 +1,234 @@
|
||||
-- allium: 1
|
||||
-- bDS Post Lifecycle
|
||||
-- Scope: core (Wave 1)
|
||||
-- Distilled from: src/main/engine/PostEngine.ts, postFileUtils.ts, schema.ts
|
||||
|
||||
use "./project.allium" as project
|
||||
|
||||
value Slug {
|
||||
value: String
|
||||
|
||||
-- Generated by: transliterate unicode to ASCII, lowercase,
|
||||
-- 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}
|
||||
}
|
||||
|
||||
value PostFilePath {
|
||||
-- posts/YYYY/MM/{slug}.md
|
||||
-- YYYY and MM derived from created_at
|
||||
base_dir: String
|
||||
year: String
|
||||
month: String
|
||||
slug: Slug
|
||||
}
|
||||
|
||||
value PostCanonicalUrl {
|
||||
-- /{YYYY}/{MM}/{DD}/{slug}
|
||||
-- YYYY/MM/DD from created_at (zero-padded)
|
||||
year: String
|
||||
month: String
|
||||
day: String
|
||||
slug: Slug
|
||||
}
|
||||
|
||||
value Frontmatter {
|
||||
-- YAML between --- delimiters at start of .md file
|
||||
-- Always present: id, title, slug, status, createdAt, updatedAt, tags, categories
|
||||
-- Optional (written only when truthy): excerpt, author, language,
|
||||
-- doNotTranslate (only when true), templateSlug, publishedAt
|
||||
}
|
||||
|
||||
surface PostControlSurface {
|
||||
facing _: PostOperator
|
||||
|
||||
provides:
|
||||
CreatePostRequested(project, title, content, tags, categories, author, language, template_slug)
|
||||
UpdatePostRequested(post, changes)
|
||||
PublishPostRequested(post)
|
||||
DeletePostRequested(post)
|
||||
ArchivePostRequested(post)
|
||||
}
|
||||
|
||||
surface PostFilePathSurface {
|
||||
context path: PostFilePath
|
||||
|
||||
exposes:
|
||||
path.base_dir
|
||||
path.year
|
||||
path.month
|
||||
path.slug
|
||||
}
|
||||
|
||||
surface PostCanonicalUrlSurface {
|
||||
context url: PostCanonicalUrl
|
||||
|
||||
exposes:
|
||||
url.year
|
||||
url.month
|
||||
url.day
|
||||
url.slug
|
||||
}
|
||||
|
||||
surface FrontmatterSurface {
|
||||
context _: Frontmatter
|
||||
}
|
||||
|
||||
entity Post {
|
||||
project: project/Project
|
||||
title: String
|
||||
slug: Slug
|
||||
excerpt: String?
|
||||
content: String?
|
||||
status: draft | published | archived
|
||||
author: String?
|
||||
language: String?
|
||||
do_not_translate: Boolean
|
||||
template_slug: String?
|
||||
file_path: String
|
||||
checksum: String?
|
||||
tags: List<String>
|
||||
categories: List<String>
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
published_at: Timestamp?
|
||||
|
||||
-- Relationships
|
||||
translations: PostTranslation with canonical_post = this
|
||||
linked_media: PostMediaLink with post = this
|
||||
outgoing_links: PostLink with source = this
|
||||
incoming_links: PostLink with target = this
|
||||
|
||||
-- Derived
|
||||
available_languages: translations -> language
|
||||
is_slug_frozen: published_at != null
|
||||
-- Slug changes only allowed before first publish
|
||||
content_location: if status = published: file_path else: content
|
||||
-- Published: body in filesystem. Draft: body in DB field.
|
||||
|
||||
transitions status {
|
||||
draft -> published
|
||||
draft -> archived
|
||||
published -> draft
|
||||
published -> archived
|
||||
archived -> draft
|
||||
archived -> published
|
||||
}
|
||||
}
|
||||
|
||||
entity PostLink {
|
||||
source: Post
|
||||
target: Post
|
||||
link_text: String?
|
||||
}
|
||||
|
||||
entity PostMediaLink {
|
||||
post: Post
|
||||
media_id: String
|
||||
sort_order: Integer
|
||||
}
|
||||
|
||||
invariant UniqueSlugPerProject {
|
||||
for a in Posts:
|
||||
for b in Posts:
|
||||
(a != b and a.project = b.project) implies a.slug != b.slug
|
||||
}
|
||||
|
||||
rule CreatePost {
|
||||
when: CreatePostRequested(project, title, content, tags, categories, author, language, template_slug)
|
||||
let slug = Slug.generate(title ?? "untitled")
|
||||
let unique_slug = Slug.ensure_unique(slug, project)
|
||||
ensures:
|
||||
let new_post = Post.created(
|
||||
project: project,
|
||||
title: title ?? "",
|
||||
slug: unique_slug,
|
||||
content: content,
|
||||
status: draft,
|
||||
author: author,
|
||||
language: language,
|
||||
tags: tags ?? {},
|
||||
categories: categories ?? {},
|
||||
template_slug: template_slug,
|
||||
do_not_translate: false,
|
||||
file_path: ""
|
||||
)
|
||||
new_post.status = draft
|
||||
SearchIndexUpdated(new_post)
|
||||
}
|
||||
|
||||
rule UpdatePost {
|
||||
when: UpdatePostRequested(post, changes)
|
||||
requires: not post.is_slug_frozen or changes.slug = null
|
||||
-- Cannot change slug after first publish
|
||||
ensures: post.updated_at = now
|
||||
ensures: PostFieldsUpdated(post, changes)
|
||||
ensures: SearchIndexUpdated(post)
|
||||
@guidance
|
||||
-- If post is published and content/metadata changed,
|
||||
-- status auto-transitions back to draft
|
||||
}
|
||||
|
||||
rule ReopenPublishedPost {
|
||||
when: UpdatePostRequested(post, changes)
|
||||
requires: post.status = published
|
||||
requires: changes_affect_published_content(changes)
|
||||
ensures: post.status = draft
|
||||
}
|
||||
|
||||
rule PublishPost {
|
||||
when: PublishPostRequested(post)
|
||||
requires: post.status = draft or post.status = archived
|
||||
ensures: post.status = published
|
||||
ensures: post.published_at = post.published_at ?? now
|
||||
-- Preserve original publish date on re-publish
|
||||
ensures: PostFileWritten(post)
|
||||
-- Writes frontmatter + markdown to posts/YYYY/MM/{slug}.md
|
||||
ensures: post.content = null
|
||||
-- Content cleared from DB; now lives in filesystem only
|
||||
ensures: SearchIndexUpdated(post)
|
||||
ensures: PostLinksUpdated(post)
|
||||
-- Parse inter-post links, update link graph
|
||||
ensures:
|
||||
for t in post.translations:
|
||||
TranslationFileWritten(t)
|
||||
}
|
||||
|
||||
rule DeletePost {
|
||||
when: DeletePostRequested(post)
|
||||
ensures: not exists post
|
||||
ensures: PostFileDeleted(post)
|
||||
-- Remove .md file if it exists
|
||||
ensures:
|
||||
for t in post.translations:
|
||||
not exists t
|
||||
ensures: SearchIndexUpdated(post)
|
||||
}
|
||||
|
||||
rule ArchivePost {
|
||||
when: ArchivePostRequested(post)
|
||||
requires: post.status = draft or post.status = published
|
||||
ensures: post.status = archived
|
||||
}
|
||||
|
||||
-- File format axioms
|
||||
|
||||
invariant FrontmatterRoundtrip {
|
||||
-- Reading a post file written by the system produces identical
|
||||
-- field values to the database record at time of writing
|
||||
for post in Posts where status = published:
|
||||
parse_frontmatter(read_file(post.file_path)) = frontmatter_fields(post)
|
||||
}
|
||||
|
||||
invariant DateBasedFileLayout {
|
||||
for post in Posts where file_path != "":
|
||||
post.file_path = format("posts/{yyyy}/{mm}/{slug}.md",
|
||||
yyyy: post.created_at.year,
|
||||
mm: post.created_at.month_padded,
|
||||
slug: post.slug)
|
||||
}
|
||||
|
||||
-- Slug freeze: once published_at is set, the slug is permanently frozen.
|
||||
-- This follows the established bDS rule: is_slug_frozen = published_at != null
|
||||
-- Even if the post reverts to draft, the slug cannot be changed.
|
||||
108
specs/preview.allium
Normal file
108
specs/preview.allium
Normal file
@@ -0,0 +1,108 @@
|
||||
-- allium: 1
|
||||
-- bDS Local Preview Server
|
||||
-- Scope: core (Wave 4)
|
||||
-- Distilled from: src/main/engine/PreviewServer.ts, PageRenderer.ts
|
||||
|
||||
use "./template.allium" as template
|
||||
use "./generation.allium" as generation
|
||||
|
||||
entity PreviewServer {
|
||||
host: String -- 127.0.0.1
|
||||
port: Integer -- 4123
|
||||
is_running: Boolean
|
||||
}
|
||||
|
||||
config {
|
||||
preview_host: String = "127.0.0.1"
|
||||
preview_port: Integer = 4123
|
||||
}
|
||||
|
||||
surface PreviewControlSurface {
|
||||
facing _: PreviewOperator
|
||||
|
||||
provides:
|
||||
StartPreviewRequested(project)
|
||||
StopPreviewRequested(server)
|
||||
}
|
||||
|
||||
surface PreviewHttpSurface {
|
||||
facing _: PreviewClient
|
||||
|
||||
provides:
|
||||
PreviewRequest(path)
|
||||
PreviewDraftRequest(path, post_id)
|
||||
}
|
||||
|
||||
rule StartPreview {
|
||||
when: StartPreviewRequested(project)
|
||||
ensures: PreviewServer.created(
|
||||
host: config.preview_host,
|
||||
port: config.preview_port,
|
||||
is_running: true
|
||||
)
|
||||
}
|
||||
|
||||
rule StopPreview {
|
||||
when: StopPreviewRequested(server)
|
||||
-- Graceful shutdown with inflight request tracking
|
||||
ensures: server.is_running = false
|
||||
}
|
||||
|
||||
-- Route resolution
|
||||
|
||||
rule ServePostPreview {
|
||||
when: PreviewRequest(path)
|
||||
requires: is_post_path(path)
|
||||
-- path matches "/{yyyy}/{mm}/{dd}/{slug}"
|
||||
-- Renders post via Liquid template with full PageRenderer context
|
||||
ensures: PreviewResponse(rendered_html)
|
||||
}
|
||||
|
||||
rule ServeDraftPreview {
|
||||
when: PreviewDraftRequest(path, post_id)
|
||||
-- Renders draft content (from DB, not filesystem)
|
||||
ensures: PreviewResponse(rendered_html)
|
||||
}
|
||||
|
||||
rule ServeArchivePreview {
|
||||
when: PreviewRequest(path)
|
||||
requires: is_archive_path(path)
|
||||
-- Category, tag, date archives with pagination
|
||||
ensures: PreviewResponse(rendered_html)
|
||||
}
|
||||
|
||||
rule ServeMediaFile {
|
||||
when: PreviewRequest(path)
|
||||
requires: is_media_path(path)
|
||||
-- Path-traversal protection: validates path stays within media directory
|
||||
ensures: PreviewResponse(media_file)
|
||||
}
|
||||
|
||||
rule ServeAssets {
|
||||
when: PreviewRequest(path)
|
||||
requires: is_asset_path(path)
|
||||
ensures: PreviewResponse(asset_file)
|
||||
}
|
||||
|
||||
rule ServeLanguagePrefixedRoute {
|
||||
when: PreviewRequest(path)
|
||||
requires: is_language_prefixed(path)
|
||||
-- Detects language prefix from supported languages
|
||||
-- Renders with translation overlay for that language
|
||||
ensures: PreviewResponse(translated_html)
|
||||
}
|
||||
|
||||
invariant ThemeSwitching {
|
||||
-- Preview supports live theme/mode switching via query params
|
||||
-- ?theme=amber&mode=dark etc.
|
||||
-- Uses Pico CSS with configurable themes
|
||||
}
|
||||
|
||||
invariant PreviewServerBinding {
|
||||
for server in PreviewServers where server.is_running:
|
||||
server.host = config.preview_host and server.port = config.preview_port
|
||||
}
|
||||
|
||||
invariant LocalhostOnly {
|
||||
-- Preview server binds to 127.0.0.1 only, never 0.0.0.0
|
||||
}
|
||||
110
specs/project.allium
Normal file
110
specs/project.allium
Normal file
@@ -0,0 +1,110 @@
|
||||
-- allium: 1
|
||||
-- bDS Project Management
|
||||
-- Scope: core (Wave 1)
|
||||
-- Distilled from: src/main/engine/ProjectEngine.ts, schema.ts
|
||||
|
||||
surface ProjectControlSurface {
|
||||
facing _: ProjectOperator
|
||||
|
||||
provides:
|
||||
CreateProjectRequested(name, data_path)
|
||||
SetActiveProjectRequested(project)
|
||||
DeleteProjectRequested(project)
|
||||
}
|
||||
|
||||
entity Project {
|
||||
name: String
|
||||
slug: String
|
||||
description: String?
|
||||
data_path: String?
|
||||
is_active: Boolean
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
|
||||
-- Relationships
|
||||
posts: Post with project = this
|
||||
media: Media with project = this
|
||||
tags: Tag with project = this
|
||||
|
||||
-- Derived
|
||||
internal_base_dir: String
|
||||
-- {user_data}/projects/{id}/
|
||||
-- Contains: meta/, thumbnails/, tags.json
|
||||
effective_data_dir: data_path ?? internal_base_dir
|
||||
-- Custom data path overrides default
|
||||
}
|
||||
|
||||
surface ProjectSurface {
|
||||
context project: Project
|
||||
|
||||
exposes:
|
||||
project.name
|
||||
project.slug
|
||||
project.description when project.description != null
|
||||
project.data_path when project.data_path != null
|
||||
project.is_active
|
||||
project.created_at
|
||||
project.updated_at
|
||||
project.posts.count
|
||||
project.media.count
|
||||
project.tags.count
|
||||
project.internal_base_dir
|
||||
project.effective_data_dir
|
||||
}
|
||||
|
||||
invariant SingleActiveProject {
|
||||
-- Exactly one project is active at any time
|
||||
let active = Projects where is_active
|
||||
active.count = 1
|
||||
}
|
||||
|
||||
invariant UniqueProjectSlug {
|
||||
for a in Projects:
|
||||
for b in Projects:
|
||||
a != b implies a.slug != b.slug
|
||||
}
|
||||
|
||||
rule CreateProject {
|
||||
when: CreateProjectRequested(name, data_path)
|
||||
let slug = slugify(name)
|
||||
ensures: Project.created(
|
||||
name: name,
|
||||
slug: slug,
|
||||
data_path: data_path,
|
||||
is_active: false
|
||||
)
|
||||
ensures: StarterTemplatesCopied(project)
|
||||
-- Bundled starter templates are copied into the new project
|
||||
}
|
||||
|
||||
rule SetActiveProject {
|
||||
when: SetActiveProjectRequested(project)
|
||||
let previous = Projects where is_active = true
|
||||
ensures:
|
||||
for p in previous:
|
||||
p.is_active = false
|
||||
ensures: project.is_active = true
|
||||
}
|
||||
|
||||
rule DeleteProject {
|
||||
when: DeleteProjectRequested(project)
|
||||
requires: project.id != "default"
|
||||
-- The default project (id='default') cannot be deleted
|
||||
requires: project.is_active = false
|
||||
-- The currently active project cannot be deleted
|
||||
ensures: not exists project
|
||||
@guidance
|
||||
-- deleteProjectWithData removes DB rows + internal directory
|
||||
-- but preserves external data at custom data_path
|
||||
}
|
||||
|
||||
config {
|
||||
default_project_id: String = "default"
|
||||
default_project_name: String = "My Blog"
|
||||
}
|
||||
|
||||
invariant DefaultProjectExists {
|
||||
-- A project with id='default' always exists
|
||||
-- It is created on first launch if missing
|
||||
exists p in Projects where p.id = "default"
|
||||
}
|
||||
140
specs/publishing.allium
Normal file
140
specs/publishing.allium
Normal file
@@ -0,0 +1,140 @@
|
||||
-- allium: 1
|
||||
-- bDS SSH Publishing
|
||||
-- Scope: core (Wave 5)
|
||||
-- Distilled from: src/main/engine/PublishEngine.ts
|
||||
|
||||
use "./metadata.allium" as meta
|
||||
|
||||
entity PublishJob {
|
||||
ssh_host: String
|
||||
ssh_user: String
|
||||
ssh_remote_path: String
|
||||
ssh_mode: scp | rsync
|
||||
status: pending | running | completed | failed
|
||||
|
||||
transitions status {
|
||||
pending -> running
|
||||
running -> completed
|
||||
running -> failed
|
||||
}
|
||||
}
|
||||
|
||||
value UploadTarget {
|
||||
kind: html | thumbnails | media
|
||||
local_dir: String
|
||||
remote_dir: String
|
||||
}
|
||||
|
||||
surface PublishJobSurface {
|
||||
context job: PublishJob
|
||||
|
||||
exposes:
|
||||
job.ssh_host
|
||||
job.ssh_user
|
||||
job.ssh_remote_path
|
||||
job.ssh_mode
|
||||
job.status
|
||||
}
|
||||
|
||||
surface UploadTargetSurface {
|
||||
context target: UploadTarget
|
||||
|
||||
exposes:
|
||||
target.kind
|
||||
target.local_dir
|
||||
target.remote_dir
|
||||
}
|
||||
|
||||
surface PublishingControlSurface {
|
||||
facing _: PublishOperator
|
||||
|
||||
provides:
|
||||
UploadSiteRequested(project, credentials)
|
||||
}
|
||||
|
||||
surface PublishingRuntimeSurface {
|
||||
facing _: PublishRuntime
|
||||
|
||||
provides:
|
||||
PublishJobStarted(project, job, credentials)
|
||||
PublishTargetFailed(job, target, error)
|
||||
}
|
||||
|
||||
rule UploadSite {
|
||||
when: UploadSiteRequested(project, credentials)
|
||||
ensures:
|
||||
let job = PublishJob.created(
|
||||
ssh_host: credentials.ssh_host,
|
||||
ssh_user: credentials.ssh_user,
|
||||
ssh_remote_path: credentials.ssh_remote_path,
|
||||
ssh_mode: credentials.ssh_mode,
|
||||
status: pending
|
||||
)
|
||||
job.status = pending
|
||||
PublishJobStarted(project, job, credentials)
|
||||
}
|
||||
|
||||
rule StartPublishJob {
|
||||
when: PublishJobStarted(project, job, credentials)
|
||||
requires: job.status = pending
|
||||
ensures: job.status = running
|
||||
ensures: UploadTargetStarted(job, html, "html/", credentials.ssh_remote_path, credentials)
|
||||
ensures: UploadTargetStarted(job, thumbnails, "thumbnails/", credentials.ssh_remote_path + "/thumbnails", credentials)
|
||||
ensures: UploadTargetStarted(job, media, "media/", credentials.ssh_remote_path + "/media", credentials)
|
||||
}
|
||||
|
||||
rule UploadViaScp {
|
||||
when: UploadTargetStarted(job, target, local_dir, remote_dir, credentials)
|
||||
requires: credentials.ssh_mode = scp
|
||||
-- mtime-based upload detection: skip unchanged files
|
||||
-- Uses SSH agent (SSH_AUTH_SOCK) for authentication
|
||||
ensures: ScpUploadCompleted(job, target)
|
||||
ensures: UploadTargetCompleted(job, target)
|
||||
}
|
||||
|
||||
rule UploadViaRsync {
|
||||
when: UploadTargetStarted(job, target, local_dir, remote_dir, credentials)
|
||||
requires: credentials.ssh_mode = rsync
|
||||
-- rsync --update --compress --verbose
|
||||
-- Media uploads exclude .meta sidecar files
|
||||
ensures: RsyncUploadCompleted(job, target)
|
||||
ensures: UploadTargetCompleted(job, target)
|
||||
|
||||
@guidance
|
||||
-- rsync exclude filters for .meta files on media target
|
||||
}
|
||||
|
||||
rule CompletePublishJob {
|
||||
when: PublishTargetsCompleted(job)
|
||||
requires: job.status = running
|
||||
ensures: job.status = completed
|
||||
}
|
||||
|
||||
rule FailPublishJob {
|
||||
when: PublishTargetFailed(job, target, error)
|
||||
requires: job.status = running
|
||||
ensures: job.status = failed
|
||||
}
|
||||
|
||||
rule TrackUploadCompletion {
|
||||
when: UploadTargetCompleted(job, target)
|
||||
requires: all_upload_targets_completed(job)
|
||||
ensures: PublishTargetsCompleted(job)
|
||||
}
|
||||
|
||||
invariant MediaSidecarsExcludedFromUpload {
|
||||
-- .meta sidecar files are never uploaded to the remote server
|
||||
-- They are project metadata, not public content
|
||||
}
|
||||
|
||||
invariant PublishJobLifecycle {
|
||||
-- UploadSiteRequested creates one PublishJob in pending state.
|
||||
-- PublishJobStarted moves the job to running before any target starts.
|
||||
-- A job reaches completed only after PublishTargetsCompleted(job).
|
||||
-- Any PublishTargetFailed(job, target, error) transitions the job to failed.
|
||||
}
|
||||
|
||||
invariant SshAgentAuth {
|
||||
-- Publishing uses SSH_AUTH_SOCK for key-based authentication
|
||||
-- No password prompts, no interactive auth
|
||||
}
|
||||
713
specs/schema.allium
Normal file
713
specs/schema.allium
Normal file
@@ -0,0 +1,713 @@
|
||||
-- allium: 1
|
||||
-- bDS Persistence Data Contract
|
||||
-- Scope: core (Wave 1 — exact compatibility contract)
|
||||
-- Distilled from: ../bDS/src/main/database/schema.ts
|
||||
--
|
||||
-- This document specifies the persisted data model the rewrite must be able
|
||||
-- to read and write. It is the ground truth for storage compatibility.
|
||||
|
||||
-- ============================================================================
|
||||
-- CORE ENTITIES
|
||||
-- ============================================================================
|
||||
|
||||
entity Project {
|
||||
id: String -- UUID v4
|
||||
name: String -- Display name
|
||||
slug: String -- URL-safe identifier
|
||||
description: String? -- Optional description
|
||||
data_path: String? -- Custom data directory (null = default)
|
||||
created_at: Timestamp -- Unix timestamp
|
||||
updated_at: Timestamp -- Unix timestamp
|
||||
is_active: Boolean -- Exactly one project is active at a time
|
||||
}
|
||||
|
||||
entity Post {
|
||||
id: String -- UUID v4
|
||||
project_id: String
|
||||
title: String
|
||||
slug: String -- URL-friendly identifier
|
||||
excerpt: String? -- Optional summary
|
||||
content: String? -- Draft body (null when published)
|
||||
status: draft | published | archived
|
||||
author: String? -- Author name
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
published_at: Timestamp?
|
||||
file_path: String -- Empty for never-published drafts
|
||||
checksum: String? -- SHA-256 of content
|
||||
tags: Set<String> -- JSON array stored as text
|
||||
categories: Set<String> -- JSON array stored as text
|
||||
template_slug: String? -- User template override
|
||||
language: String? -- ISO 639-1 code
|
||||
do_not_translate: Boolean
|
||||
|
||||
-- Published snapshot columns (written on publish for diff detection)
|
||||
published_title: String?
|
||||
published_content: String?
|
||||
published_tags: String?
|
||||
published_categories: String?
|
||||
published_excerpt: String?
|
||||
}
|
||||
|
||||
entity PostTranslation {
|
||||
id: String -- UUID v4
|
||||
project_id: String
|
||||
translation_for: String -- Canonical post ID
|
||||
language: String -- ISO 639-1 code
|
||||
title: String
|
||||
excerpt: String?
|
||||
content: String? -- Draft body (null when published)
|
||||
status: draft | published
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
published_at: Timestamp?
|
||||
file_path: String
|
||||
checksum: String?
|
||||
}
|
||||
|
||||
entity Media {
|
||||
id: String -- UUID v4
|
||||
project_id: String
|
||||
filename: String -- Generated filename
|
||||
original_name: String -- Original uploaded filename
|
||||
mime_type: String -- e.g. "image/jpeg"
|
||||
size: Integer -- Bytes
|
||||
width: Integer? -- Image dimensions
|
||||
height: Integer?
|
||||
title: String?
|
||||
alt: String?
|
||||
caption: String?
|
||||
author: String?
|
||||
file_path: String -- Absolute path to binary
|
||||
sidecar_path: String -- Path to .meta sidecar file
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
checksum: String?
|
||||
tags: Set<String> -- JSON array stored as text
|
||||
language: String? -- ISO 639-1 code
|
||||
}
|
||||
|
||||
entity MediaTranslation {
|
||||
id: String -- UUID v4
|
||||
project_id: String
|
||||
translation_for: String -- Canonical media ID
|
||||
language: String -- ISO 639-1 code
|
||||
title: String?
|
||||
alt: String?
|
||||
caption: String?
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
}
|
||||
|
||||
entity Tag {
|
||||
id: String -- UUID v4
|
||||
project_id: String
|
||||
name: String -- Case-insensitive unique per project
|
||||
color: String? -- Hex color like #ff0000
|
||||
post_template_slug: String? -- Template override for this tag
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
}
|
||||
|
||||
entity Template {
|
||||
id: String -- UUID v4
|
||||
project_id: String
|
||||
slug: String -- URL-safe identifier
|
||||
title: String
|
||||
kind: post | list | not_found | partial
|
||||
enabled: Boolean
|
||||
version: Integer -- Incremented on each update
|
||||
file_path: String -- templates/{slug}.liquid
|
||||
status: draft | published
|
||||
content: String? -- Draft body (null when published)
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
}
|
||||
|
||||
entity Script {
|
||||
id: String -- UUID v4
|
||||
project_id: String
|
||||
slug: String -- URL-safe identifier
|
||||
title: String
|
||||
kind: macro | utility | transform
|
||||
entrypoint: String -- Default: "render" for macros
|
||||
enabled: Boolean
|
||||
version: Integer -- Incremented on each update
|
||||
file_path: String -- scripts/{slug}.{extension}
|
||||
status: draft | published
|
||||
content: String? -- Draft body (null when published)
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- RELATIONSHIP TABLES
|
||||
-- ============================================================================
|
||||
|
||||
entity PostLink {
|
||||
id: String -- UUID v4
|
||||
source_post_id: String -- Post containing the link
|
||||
target_post_id: String -- Post being linked to
|
||||
link_text: String? -- Anchor text
|
||||
created_at: Timestamp
|
||||
}
|
||||
|
||||
entity PostMediaLink {
|
||||
id: String -- UUID v4
|
||||
project_id: String
|
||||
post_id: String
|
||||
media_id: String
|
||||
sort_order: Integer -- For ordering media within a post
|
||||
created_at: Timestamp
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- METADATA TABLES
|
||||
-- ============================================================================
|
||||
|
||||
entity Setting {
|
||||
key: String -- Primary key
|
||||
value: String -- Serialized value
|
||||
updated_at: Timestamp
|
||||
}
|
||||
|
||||
entity GeneratedFileHash {
|
||||
project_id: String
|
||||
relative_path: String
|
||||
content_hash: String -- SHA-256 of file content
|
||||
updated_at: Timestamp
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- SEARCH INDEX (FTS5 Virtual Tables)
|
||||
-- ============================================================================
|
||||
|
||||
entity PostSearchIndex {
|
||||
-- Full-text search index projection, not a user-authored entity
|
||||
-- Indexed fields: title, excerpt, content, tags, categories
|
||||
-- Plus all translation titles, excerpts, and content
|
||||
post: Post
|
||||
stemmed_content: String -- Processed via Snowball stemmer
|
||||
}
|
||||
|
||||
entity MediaSearchIndex {
|
||||
-- Full-text search index projection
|
||||
-- Indexed fields: title, alt, caption, original_name, tags
|
||||
-- Plus all translation titles, alts, and captions
|
||||
media: Media
|
||||
stemmed_content: String -- Processed via Snowball stemmer
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- AI / CHAT TABLES
|
||||
-- ============================================================================
|
||||
|
||||
entity ChatConversation {
|
||||
id: String -- UUID v4
|
||||
title: String
|
||||
model: String? -- Model used for conversation
|
||||
copilot_session_id: String? -- Legacy, no longer used
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
}
|
||||
|
||||
entity ChatMessage {
|
||||
id: Integer -- Auto-increment
|
||||
conversation_id: String
|
||||
role: system | user | assistant | tool
|
||||
content: String?
|
||||
tool_call_id: String? -- For tool responses
|
||||
tool_calls: String? -- JSON array of tool calls
|
||||
created_at: Timestamp
|
||||
}
|
||||
|
||||
entity AiProvider {
|
||||
-- Provider catalog, populated from upstream model registry.
|
||||
-- Managed by the application and treated as read-only during normal use.
|
||||
id: String -- PRIMARY KEY
|
||||
name: String
|
||||
env: String? -- Environment variable for API key
|
||||
package_ref: String? -- Legacy package reference
|
||||
api: String? -- Base API URL
|
||||
doc: String? -- Documentation URL
|
||||
updated_at: Timestamp
|
||||
}
|
||||
|
||||
entity AiModel {
|
||||
-- Full model catalog with capability metadata.
|
||||
-- Composite primary key: (provider, model_id).
|
||||
provider: AiProvider
|
||||
model_id: String
|
||||
name: String
|
||||
family: String?
|
||||
attachment: Boolean -- supports file attachments
|
||||
reasoning: Boolean -- supports chain-of-thought
|
||||
tool_call: Boolean -- supports tool/function calling
|
||||
structured_output: Boolean
|
||||
temperature: Boolean -- supports temperature parameter
|
||||
knowledge: String? -- training data cutoff
|
||||
release_date: String?
|
||||
last_updated_date: String?
|
||||
open_weights: Boolean
|
||||
input_price: Integer? -- price per million input tokens
|
||||
output_price: Integer? -- price per million output tokens
|
||||
cache_read_price: Integer?
|
||||
cache_write_price: Integer?
|
||||
context_window: Integer
|
||||
max_input_tokens: Integer
|
||||
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
|
||||
}
|
||||
|
||||
entity AiModelModality {
|
||||
-- Input/output modality declarations per model.
|
||||
provider: AiProvider
|
||||
model_id: String
|
||||
direction: String -- "input" | "output"
|
||||
modality: String -- "text" | "image" | "audio" | "video"
|
||||
}
|
||||
|
||||
entity AiCatalogMeta {
|
||||
key: String -- "{endpoint_kind}_etag" | "{endpoint_kind}_lastFetchedAt"
|
||||
value: String
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- EMBEDDINGS TABLES
|
||||
-- ============================================================================
|
||||
|
||||
entity EmbeddingKey {
|
||||
label: Integer -- USearch bigint key
|
||||
post_id: String
|
||||
project_id: String
|
||||
content_hash: String -- SHA-256 of title+content
|
||||
vector: String -- Encoded vector payload (1536 bytes for 384-dim)
|
||||
}
|
||||
|
||||
entity DismissedDuplicatePair {
|
||||
id: String -- UUID v4
|
||||
project_id: String
|
||||
post_id_a: String
|
||||
post_id_b: String
|
||||
dismissed_at: Timestamp
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- IMPORT TABLES
|
||||
-- ============================================================================
|
||||
|
||||
entity ImportDefinition {
|
||||
id: String -- UUID v4
|
||||
project_id: String
|
||||
name: String
|
||||
wxr_file_path: String? -- WordPress XML export file
|
||||
uploads_folder_path: String? -- WordPress uploads directory
|
||||
last_analysis_result: String? -- JSON text of ImportAnalysisReport
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- NOTIFICATION TABLES
|
||||
-- ============================================================================
|
||||
|
||||
entity DbNotification {
|
||||
id: Integer -- Auto-increment
|
||||
entity_type: String -- 'post' | 'media' | 'script' | 'template'
|
||||
entity_id: String
|
||||
action: created | updated | deleted
|
||||
from_cli: Boolean -- 1 = written by CLI
|
||||
seen_at: Timestamp? -- NULL = unprocessed
|
||||
created_at: Timestamp
|
||||
}
|
||||
|
||||
surface ProjectRecordSurface {
|
||||
context project: Project
|
||||
|
||||
exposes:
|
||||
project.id
|
||||
project.name
|
||||
project.slug
|
||||
project.description when project.description != null
|
||||
project.data_path when project.data_path != null
|
||||
project.created_at
|
||||
project.updated_at
|
||||
project.is_active
|
||||
}
|
||||
|
||||
surface PostTranslationRecordSurface {
|
||||
context translation: PostTranslation
|
||||
|
||||
exposes:
|
||||
translation.id
|
||||
translation.project_id
|
||||
translation.translation_for
|
||||
translation.language
|
||||
translation.title
|
||||
translation.excerpt when translation.excerpt != null
|
||||
translation.content when translation.content != null
|
||||
translation.status
|
||||
translation.created_at
|
||||
translation.updated_at
|
||||
translation.published_at when translation.published_at != null
|
||||
translation.file_path
|
||||
translation.checksum when translation.checksum != null
|
||||
}
|
||||
|
||||
surface MediaTranslationRecordSurface {
|
||||
context translation: MediaTranslation
|
||||
|
||||
exposes:
|
||||
translation.id
|
||||
translation.project_id
|
||||
translation.translation_for
|
||||
translation.language
|
||||
translation.title when translation.title != null
|
||||
translation.alt when translation.alt != null
|
||||
translation.caption when translation.caption != null
|
||||
translation.created_at
|
||||
translation.updated_at
|
||||
}
|
||||
|
||||
surface TagRecordSurface {
|
||||
context tag: Tag
|
||||
|
||||
exposes:
|
||||
tag.id
|
||||
tag.project_id
|
||||
tag.name
|
||||
tag.color when tag.color != null
|
||||
tag.post_template_slug when tag.post_template_slug != null
|
||||
tag.created_at
|
||||
tag.updated_at
|
||||
}
|
||||
|
||||
surface TemplateRecordSurface {
|
||||
context template: Template
|
||||
|
||||
exposes:
|
||||
template.id
|
||||
template.project_id
|
||||
template.slug
|
||||
template.title
|
||||
template.kind
|
||||
template.enabled
|
||||
template.version
|
||||
template.file_path
|
||||
template.status
|
||||
template.content when template.content != null
|
||||
template.created_at
|
||||
template.updated_at
|
||||
}
|
||||
|
||||
surface ScriptRecordSurface {
|
||||
context script: Script
|
||||
|
||||
exposes:
|
||||
script.id
|
||||
script.project_id
|
||||
script.slug
|
||||
script.title
|
||||
script.kind
|
||||
script.entrypoint
|
||||
script.enabled
|
||||
script.version
|
||||
script.file_path
|
||||
script.status
|
||||
script.content when script.content != null
|
||||
script.created_at
|
||||
script.updated_at
|
||||
}
|
||||
|
||||
surface PostLinkRecordSurface {
|
||||
context link: PostLink
|
||||
|
||||
exposes:
|
||||
link.id
|
||||
link.source_post_id
|
||||
link.target_post_id
|
||||
link.link_text when link.link_text != null
|
||||
link.created_at
|
||||
}
|
||||
|
||||
surface PostMediaLinkRecordSurface {
|
||||
context link: PostMediaLink
|
||||
|
||||
exposes:
|
||||
link.id
|
||||
link.project_id
|
||||
link.post_id
|
||||
link.media_id
|
||||
link.sort_order
|
||||
link.created_at
|
||||
}
|
||||
|
||||
surface SettingRecordSurface {
|
||||
context setting: Setting
|
||||
|
||||
exposes:
|
||||
setting.key
|
||||
setting.value
|
||||
setting.updated_at
|
||||
}
|
||||
|
||||
surface GeneratedFileHashRecordSurface {
|
||||
context record: GeneratedFileHash
|
||||
|
||||
exposes:
|
||||
record.project_id
|
||||
record.relative_path
|
||||
record.content_hash
|
||||
record.updated_at
|
||||
}
|
||||
|
||||
surface PostSearchIndexRecordSurface {
|
||||
context record: PostSearchIndex
|
||||
|
||||
exposes:
|
||||
record.post
|
||||
record.stemmed_content
|
||||
}
|
||||
|
||||
surface MediaSearchIndexRecordSurface {
|
||||
context record: MediaSearchIndex
|
||||
|
||||
exposes:
|
||||
record.media
|
||||
record.stemmed_content
|
||||
}
|
||||
|
||||
surface ChatConversationRecordSurface {
|
||||
context conversation: ChatConversation
|
||||
|
||||
exposes:
|
||||
conversation.id
|
||||
conversation.title
|
||||
conversation.model when conversation.model != null
|
||||
conversation.copilot_session_id when conversation.copilot_session_id != null
|
||||
conversation.created_at
|
||||
conversation.updated_at
|
||||
}
|
||||
|
||||
surface ChatMessageRecordSurface {
|
||||
context message: ChatMessage
|
||||
|
||||
exposes:
|
||||
message.id
|
||||
message.conversation_id
|
||||
message.role
|
||||
message.content when message.content != null
|
||||
message.tool_call_id when message.tool_call_id != null
|
||||
message.tool_calls when message.tool_calls != null
|
||||
message.created_at
|
||||
}
|
||||
|
||||
surface AiModelRecordSurface {
|
||||
context model: AiModel
|
||||
|
||||
exposes:
|
||||
model.provider
|
||||
model.model_id
|
||||
model.name
|
||||
model.family when model.family != null
|
||||
model.attachment
|
||||
model.reasoning
|
||||
model.tool_call
|
||||
model.structured_output
|
||||
model.temperature
|
||||
model.knowledge when model.knowledge != null
|
||||
model.release_date when model.release_date != null
|
||||
model.last_updated_date when model.last_updated_date != null
|
||||
model.open_weights
|
||||
model.input_price when model.input_price != null
|
||||
model.output_price when model.output_price != null
|
||||
model.cache_read_price when model.cache_read_price != null
|
||||
model.cache_write_price when model.cache_write_price != null
|
||||
model.context_window
|
||||
model.max_input_tokens
|
||||
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
|
||||
}
|
||||
|
||||
surface AiModelModalityRecordSurface {
|
||||
context modality: AiModelModality
|
||||
|
||||
exposes:
|
||||
modality.provider
|
||||
modality.model_id
|
||||
modality.direction
|
||||
modality.modality
|
||||
}
|
||||
|
||||
surface AiCatalogMetaRecordSurface {
|
||||
context meta: AiCatalogMeta
|
||||
|
||||
exposes:
|
||||
meta.key
|
||||
meta.value
|
||||
}
|
||||
|
||||
surface EmbeddingKeyRecordSurface {
|
||||
context key: EmbeddingKey
|
||||
|
||||
exposes:
|
||||
key.label
|
||||
key.post_id
|
||||
key.project_id
|
||||
key.content_hash
|
||||
key.vector
|
||||
}
|
||||
|
||||
surface DismissedDuplicatePairRecordSurface {
|
||||
context pair: DismissedDuplicatePair
|
||||
|
||||
exposes:
|
||||
pair.id
|
||||
pair.project_id
|
||||
pair.post_id_a
|
||||
pair.post_id_b
|
||||
pair.dismissed_at
|
||||
}
|
||||
|
||||
surface ImportDefinitionRecordSurface {
|
||||
context definition: ImportDefinition
|
||||
|
||||
exposes:
|
||||
definition.id
|
||||
definition.project_id
|
||||
definition.name
|
||||
definition.wxr_file_path when definition.wxr_file_path != null
|
||||
definition.uploads_folder_path when definition.uploads_folder_path != null
|
||||
definition.last_analysis_result when definition.last_analysis_result != null
|
||||
definition.created_at
|
||||
definition.updated_at
|
||||
}
|
||||
|
||||
surface DbNotificationRecordSurface {
|
||||
context notification: DbNotification
|
||||
|
||||
exposes:
|
||||
notification.id
|
||||
notification.entity_type
|
||||
notification.entity_id
|
||||
notification.action
|
||||
notification.from_cli
|
||||
notification.seen_at when notification.seen_at != null
|
||||
notification.created_at
|
||||
}
|
||||
|
||||
surface Fts5PostSchemaSurface {
|
||||
context schema: Fts5PostSchema
|
||||
|
||||
exposes:
|
||||
schema.fields
|
||||
schema.stemmer_languages
|
||||
}
|
||||
|
||||
surface Fts5MediaSchemaSurface {
|
||||
context schema: Fts5MediaSchema
|
||||
|
||||
exposes:
|
||||
schema.fields
|
||||
schema.stemmer_languages
|
||||
}
|
||||
|
||||
surface MigrationVersionSurface {
|
||||
context _: MigrationVersion
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- SCHEMA CONSTRAINTS AND INDEXES
|
||||
-- ============================================================================
|
||||
|
||||
invariant UniqueProjectSlug {
|
||||
-- projects.slug must be unique across all projects
|
||||
}
|
||||
|
||||
invariant UniquePostSlugPerProject {
|
||||
-- posts.slug must be unique within each project.project_id
|
||||
-- Enforced by: posts_project_slug_idx unique index
|
||||
}
|
||||
|
||||
invariant UniqueTranslationPerPostLanguage {
|
||||
-- post_translations must have unique (translation_for, language)
|
||||
-- Enforced by: post_translations_translation_language_idx
|
||||
}
|
||||
|
||||
invariant UniqueMediaTranslationPerMediaLanguage {
|
||||
-- media_translations must have unique (translation_for, language)
|
||||
-- Enforced by: media_translations_translation_language_idx
|
||||
}
|
||||
|
||||
invariant UniqueTagNamePerProject {
|
||||
-- tags.name must be unique within each project.project_id
|
||||
-- Enforced by: tags_project_name_idx unique index
|
||||
}
|
||||
|
||||
invariant UniqueScriptSlugPerProject {
|
||||
-- scripts.slug must be unique within each project.project_id
|
||||
-- Enforced by: scripts_project_slug_idx unique index
|
||||
}
|
||||
|
||||
invariant UniqueTemplateSlugPerProject {
|
||||
-- templates.slug must be unique within each project.project_id
|
||||
-- Enforced by: templates_project_slug_idx unique index
|
||||
}
|
||||
|
||||
invariant UniquePostMediaLink {
|
||||
-- post_media must have unique (post_id, media_id) pair
|
||||
-- Enforced by: post_media_post_media_idx unique index
|
||||
}
|
||||
|
||||
invariant UniqueGeneratedFileHash {
|
||||
-- generated_file_hashes must have unique (project_id, relative_path)
|
||||
-- Enforced by: generated_file_hashes_project_path_idx unique index
|
||||
}
|
||||
|
||||
invariant UniqueDismissedDuplicatePair {
|
||||
-- dismissed_duplicate_pairs must have unique (project_id, post_id_a, post_id_b)
|
||||
-- Enforced by: dismissed_pairs_idx unique index
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- FTS5 VIRTUAL TABLE SCHEMAS (Snowball Stemmer Integration)
|
||||
-- ============================================================================
|
||||
|
||||
value Fts5PostSchema {
|
||||
-- CREATE VIRTUAL TABLE posts_fts USING fts5(
|
||||
-- post_id UNINDEXED,
|
||||
-- title, excerpt, content, tags, categories
|
||||
-- );
|
||||
-- Standalone table (no content-sync) because text is pre-stemmed
|
||||
-- via Snowball before insertion; content-sync would read un-stemmed
|
||||
-- base-table text at query time instead.
|
||||
fields: Set<String> -- {post_id UNINDEXED, title, excerpt, content, tags, categories}
|
||||
stemmer_languages: Integer = 24
|
||||
}
|
||||
|
||||
value Fts5MediaSchema {
|
||||
-- CREATE VIRTUAL TABLE media_fts USING fts5(
|
||||
-- media_id UNINDEXED,
|
||||
-- title, alt, caption, original_name, tags
|
||||
-- );
|
||||
-- Standalone table (no content-sync) — same rationale as posts_fts.
|
||||
fields: Set<String> -- {media_id UNINDEXED, title, alt, caption, original_name, tags}
|
||||
stemmer_languages: Integer = 24
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- MIGRATION HISTORY
|
||||
-- ============================================================================
|
||||
|
||||
value MigrationVersion {
|
||||
-- Schema version tracking via refinery migrations
|
||||
-- Current version: 0007 (scripts and templates draft lifecycle)
|
||||
-- Migration files located in: migrations/
|
||||
-- Note: Migration list documented in comments, not as Allium value
|
||||
}
|
||||
188
specs/script.allium
Normal file
188
specs/script.allium
Normal file
@@ -0,0 +1,188 @@
|
||||
-- allium: 1
|
||||
-- bDS Scripting System
|
||||
-- Scope: core (Wave 6 — scripting behaviour and file contracts)
|
||||
-- Distilled from: src/main/engine/ScriptEngine.ts, schema.ts
|
||||
-- The scripting runtime is intentionally unspecified here; only behavioural
|
||||
-- contracts are normative.
|
||||
|
||||
config {
|
||||
script_extension: String = "script"
|
||||
}
|
||||
|
||||
entity Script {
|
||||
slug: String
|
||||
title: String
|
||||
kind: macro | utility | transform
|
||||
entrypoint: String -- default: "render" for macros
|
||||
enabled: Boolean
|
||||
status: draft | published
|
||||
content: String?
|
||||
version: Integer
|
||||
file_path: String
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
|
||||
-- Derived
|
||||
content_location: if status = published: file_path else: content
|
||||
|
||||
transitions status {
|
||||
draft -> published
|
||||
published -> draft
|
||||
}
|
||||
}
|
||||
|
||||
surface ScriptSurface {
|
||||
context script: Script
|
||||
|
||||
exposes:
|
||||
script.slug
|
||||
script.title
|
||||
script.kind
|
||||
script.entrypoint
|
||||
script.enabled
|
||||
script.status
|
||||
script.content when script.content != null
|
||||
script.version
|
||||
script.file_path
|
||||
script.created_at
|
||||
script.updated_at
|
||||
script.content_location
|
||||
}
|
||||
|
||||
surface ScriptManagementSurface {
|
||||
facing _: ScriptOperator
|
||||
|
||||
provides:
|
||||
CreateScriptRequested(title, kind, content, entrypoint)
|
||||
CreateAndPublishScriptRequested(title, kind, content, entrypoint)
|
||||
UpdateScriptRequested(script, changes)
|
||||
PublishScriptRequested(script)
|
||||
DeleteScriptRequested(script)
|
||||
RunUtilityRequested(script)
|
||||
MacroExpansionRequested(script, template_context)
|
||||
BlogmarkReceived(data)
|
||||
RebuildScriptsFromFilesRequested(project)
|
||||
}
|
||||
|
||||
invariant UniqueScriptSlug {
|
||||
for a in Scripts:
|
||||
for b in Scripts:
|
||||
a != b implies a.slug != b.slug
|
||||
}
|
||||
|
||||
invariant ScriptFileLayout {
|
||||
for s in Scripts where file_path != "":
|
||||
s.file_path = format("scripts/{slug}.{extension}", slug: s.slug, extension: config.script_extension)
|
||||
}
|
||||
-- Script files use standard --- YAML frontmatter
|
||||
|
||||
rule CreateScript {
|
||||
when: CreateScriptRequested(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(
|
||||
slug: slug,
|
||||
title: title,
|
||||
kind: kind,
|
||||
content: content,
|
||||
entrypoint: entrypoint ?? "render",
|
||||
status: draft,
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: ""
|
||||
)
|
||||
new_script.status = draft
|
||||
}
|
||||
|
||||
rule UpdateScript {
|
||||
when: UpdateScriptRequested(script, changes)
|
||||
ensures: ScriptFieldsUpdated(script, changes)
|
||||
ensures: script.updated_at = now
|
||||
ensures: script.version = script.version + 1
|
||||
}
|
||||
|
||||
rule ReopenPublishedScript {
|
||||
when: UpdateScriptRequested(script, changes)
|
||||
requires: script.status = published
|
||||
requires: script_changes_affect_published_output(changes)
|
||||
ensures: script.status = draft
|
||||
}
|
||||
|
||||
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)
|
||||
let slug = slugify(title)
|
||||
requires: ValidateScript(content) = valid
|
||||
ensures:
|
||||
let new_script = Script.created(
|
||||
slug: slug,
|
||||
title: title,
|
||||
kind: kind,
|
||||
content: null,
|
||||
entrypoint: entrypoint ?? "render",
|
||||
status: published,
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: format("scripts/{slug}.{extension}", slug: slug, extension: config.script_extension)
|
||||
)
|
||||
ScriptFileWritten(new_script)
|
||||
}
|
||||
|
||||
rule PublishScript {
|
||||
when: PublishScriptRequested(script)
|
||||
requires: script.status = draft
|
||||
requires: ValidateScript(script.content) = valid
|
||||
-- AST parsing must succeed
|
||||
ensures: script.status = published
|
||||
ensures: ScriptFileWritten(script)
|
||||
ensures: script.content = null
|
||||
}
|
||||
|
||||
rule DeleteScript {
|
||||
when: DeleteScriptRequested(script)
|
||||
ensures: not exists script
|
||||
ensures: ScriptFileDeleted(script)
|
||||
}
|
||||
|
||||
-- Script execution contracts by kind
|
||||
|
||||
rule ExecuteMacro {
|
||||
when: MacroExpansionRequested(script, template_context)
|
||||
requires: script.kind = macro
|
||||
requires: script.enabled = true
|
||||
-- Macro scripts are invoked during template rendering
|
||||
-- via [[slug param1=value1 param2=value2]] syntax in post content
|
||||
-- They receive named parameters and the template context, return HTML
|
||||
ensures: MacroOutputProduced(script, html_output)
|
||||
}
|
||||
|
||||
rule ExecuteUtility {
|
||||
when: RunUtilityRequested(script)
|
||||
requires: script.kind = utility
|
||||
requires: script.enabled = true
|
||||
-- Runs on-demand from the UI, produces stdout output
|
||||
ensures: UtilityOutputProduced(script, stdout)
|
||||
}
|
||||
|
||||
rule ExecuteTransform {
|
||||
when: BlogmarkReceived(data)
|
||||
-- Transform scripts run sequentially on blogmark deep link data
|
||||
-- Input: title, content, tags, categories, source url
|
||||
-- Each transform can modify the data before post creation
|
||||
let transforms = Scripts where kind = transform and enabled = true
|
||||
for t in ordered_by(transforms, s => s.slug):
|
||||
ensures: TransformApplied(t, data)
|
||||
|
||||
@guidance
|
||||
-- bds://new-post deep links from browser bookmarks
|
||||
-- Max 5 toast notifications per script, 20 total
|
||||
}
|
||||
|
||||
rule RebuildScriptsFromFiles {
|
||||
when: RebuildScriptsFromFilesRequested(project)
|
||||
for file in scan_directory(project.effective_data_dir + "/scripts", "*." + config.script_extension):
|
||||
let parsed = parse_script_file(file)
|
||||
ensures: Script.created(parsed)
|
||||
}
|
||||
118
specs/search.allium
Normal file
118
specs/search.allium
Normal file
@@ -0,0 +1,118 @@
|
||||
-- allium: 1
|
||||
-- bDS Full-Text Search
|
||||
-- Scope: core (Wave 1 — in-app full-text search with Snowball stemmers)
|
||||
-- Distilled from: src/main/engine/PostEngine.ts (FTS methods),
|
||||
-- MediaEngine.ts (FTS methods), stemmer.ts
|
||||
|
||||
use "./post.allium" as post
|
||||
use "./media.allium" as media
|
||||
|
||||
surface SearchControlSurface {
|
||||
facing _: SearchOperator
|
||||
|
||||
provides:
|
||||
SearchPostsRequested(query, filters)
|
||||
SearchMediaRequested(query)
|
||||
}
|
||||
|
||||
surface SearchIndexRuntimeSurface {
|
||||
facing _: SearchRuntime
|
||||
|
||||
provides:
|
||||
SearchIndexUpdated(post)
|
||||
SearchIndexUpdated(media)
|
||||
}
|
||||
|
||||
value StemmerLanguage {
|
||||
-- Snowball stemmers for 24 languages
|
||||
-- ISO 639-1 to Snowball mapping
|
||||
-- Applied to both indexing and query processing
|
||||
code: String
|
||||
}
|
||||
|
||||
surface StemmerLanguageSurface {
|
||||
context language: StemmerLanguage
|
||||
|
||||
exposes:
|
||||
language.code
|
||||
}
|
||||
|
||||
entity PostSearchIndex {
|
||||
-- Full-text index projection
|
||||
-- Indexed fields: title, excerpt, content, tags, categories
|
||||
-- Plus all translation titles, excerpts, and content
|
||||
post: post/Post
|
||||
stemmed_content: String
|
||||
}
|
||||
|
||||
entity MediaSearchIndex {
|
||||
-- Full-text index projection
|
||||
-- Indexed fields: title, alt, caption, original_name, tags
|
||||
-- Plus all translation titles, alts, and captions
|
||||
media: media/Media
|
||||
stemmed_content: String
|
||||
}
|
||||
|
||||
invariant CrossLanguageStemming {
|
||||
-- Search index uses Snowball stemmer matched to content language
|
||||
-- A post in German is stemmed with the German stemmer
|
||||
-- Translations are stemmed with their respective language stemmers
|
||||
-- Query-time stemming matches the index language
|
||||
}
|
||||
|
||||
rule SearchPosts {
|
||||
when: SearchPostsRequested(query, filters)
|
||||
-- Full-text search with optional filters:
|
||||
-- status, tags, categories, language, missingTranslationLanguage,
|
||||
-- year, month, date range (from/to)
|
||||
-- Returns paginated results with total count
|
||||
let stemmed_query = stem(query, detect_language(query))
|
||||
let matched = search_fts(PostSearchIndex, stemmed_query, filters)
|
||||
ensures: SearchResults(
|
||||
posts: matched,
|
||||
total: matched.count,
|
||||
offset: filters.offset,
|
||||
limit: filters.limit
|
||||
)
|
||||
}
|
||||
|
||||
rule SearchMedia {
|
||||
when: SearchMediaRequested(query)
|
||||
let stemmed_query = stem(query, detect_language(query))
|
||||
let matched = search_fts(MediaSearchIndex, stemmed_query)
|
||||
ensures: SearchResults(
|
||||
media: matched
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
799
specs/sidebar_views.allium
Normal file
799
specs/sidebar_views.allium
Normal file
@@ -0,0 +1,799 @@
|
||||
-- allium: 1
|
||||
-- bDS Sidebar Views
|
||||
-- Scope: UI content (all waves + extensions)
|
||||
-- Distilled from: Sidebar.tsx, PostsList.tsx, MediaList.tsx,
|
||||
-- SidebarEntityList.tsx, SettingsNav.tsx, TagsNav.tsx,
|
||||
-- ChatList.tsx, ImportList.tsx, ScriptsList.tsx, TemplatesList.tsx,
|
||||
-- GitSidebar.tsx, sidebarDateFormatting.ts
|
||||
|
||||
-- Describes the content and behaviour of each of the 10 sidebar views.
|
||||
-- The sidebar shell (visibility, resize, view switching) is in layout.allium.
|
||||
-- Tab opening behaviour is in tabs.allium.
|
||||
|
||||
use "./layout.allium" as layout
|
||||
use "./tabs.allium" as tabs
|
||||
use "./post.allium" as post
|
||||
use "./media.allium" as media
|
||||
use "./tag.allium" as tag
|
||||
use "./i18n.allium" as i18n
|
||||
|
||||
-- ─── Sidebar view registry ───────────────────────────────────
|
||||
|
||||
-- 10 views: posts, pages, media, scripts, templates, settings, tags, chat, import, git
|
||||
-- Default view: posts
|
||||
-- The sidebar renders exactly one view at a time, selected by active_view.
|
||||
-- Each ActivityId maps 1:1 to a SidebarView of the same name.
|
||||
|
||||
config {
|
||||
default_sidebar_view: String = "posts"
|
||||
}
|
||||
|
||||
-- ─── Shared patterns ─────────────────────────────────────────
|
||||
|
||||
value LocaleMapping {
|
||||
ui_locale: String -- en | de | fr | it | es
|
||||
format_locale: String -- en-US | de-DE | fr-FR | it-IT | es-ES
|
||||
}
|
||||
|
||||
default LocaleMapping en_locale = { ui_locale: "en", format_locale: "en-US" }
|
||||
default LocaleMapping de_locale = { ui_locale: "de", format_locale: "de-DE" }
|
||||
default LocaleMapping fr_locale = { ui_locale: "fr", format_locale: "fr-FR" }
|
||||
default LocaleMapping it_locale = { ui_locale: "it", format_locale: "it-IT" }
|
||||
default LocaleMapping es_locale = { ui_locale: "es", format_locale: "es-ES" }
|
||||
|
||||
config {
|
||||
fallback_format_locale: String = "en-US"
|
||||
}
|
||||
|
||||
value RelativeDateFormat {
|
||||
timestamp: Timestamp
|
||||
locale: String
|
||||
diff_days: Integer -- (today - timestamp.date).days
|
||||
|
||||
-- Derived
|
||||
display: String =
|
||||
if diff_days = 0: timestamp.toLocaleTimeString(locale)
|
||||
else if diff_days = 1: i18n/translate("sidebar.chat.yesterday", locale)
|
||||
else if diff_days < 7: timestamp.toLocaleDateString(locale, weekday: short)
|
||||
else: timestamp.toLocaleDateString(locale, month: short, day: numeric)
|
||||
}
|
||||
|
||||
surface RelativeDateFormatSurface {
|
||||
context format: RelativeDateFormat
|
||||
|
||||
exposes:
|
||||
format.timestamp
|
||||
format.locale
|
||||
format.diff_days
|
||||
format.display
|
||||
}
|
||||
|
||||
value PostDateFormat {
|
||||
timestamp: Timestamp
|
||||
locale: String
|
||||
|
||||
-- Derived
|
||||
display: String = timestamp.toLocaleDateString(locale, month: short, day: numeric, year: numeric)
|
||||
-- Example: "Feb 10, 2026"
|
||||
}
|
||||
|
||||
surface PostDateFormatSurface {
|
||||
context format: PostDateFormat
|
||||
|
||||
exposes:
|
||||
format.timestamp
|
||||
format.locale
|
||||
format.display
|
||||
}
|
||||
|
||||
value PostTypeIcon {
|
||||
categories: List<String>
|
||||
|
||||
-- Derived: first category match wins, case-insensitive
|
||||
icon: String =
|
||||
if categories.any(c => lowercase(c) in {"picture", "photo", "image"}): "camera"
|
||||
else if categories.any(c => lowercase(c) in {"aside", "note", "quick"}): "notepad"
|
||||
else if categories.any(c => lowercase(c) in {"link", "bookmark"}): "link"
|
||||
else if categories.any(c => lowercase(c) = "video"): "film"
|
||||
else if categories.any(c => lowercase(c) = "quote"): "speech_bubble"
|
||||
else: "document"
|
||||
}
|
||||
|
||||
surface PostTypeIconSurface {
|
||||
context icon: PostTypeIcon
|
||||
|
||||
exposes:
|
||||
icon.categories
|
||||
icon.icon
|
||||
}
|
||||
|
||||
invariant SidebarEntityListPattern {
|
||||
-- Views following this pattern (scripts, templates, chat, import) must provide:
|
||||
-- 1. Header with localised title and create button.
|
||||
-- 2. Scrollable list of items.
|
||||
-- 3. Empty state with localised message and action call-to-action when items list is empty.
|
||||
-- 4. All text in list items uses CSS text-overflow:ellipsis on sidebar width overflow.
|
||||
}
|
||||
|
||||
-- ─── 1. Posts view ────────────────────────────────────────────
|
||||
|
||||
value PostsView {
|
||||
mode: String -- "posts" or "pages"
|
||||
search_query: String? -- FTS via posts.search(query)
|
||||
filter_panel_visible: Boolean -- collapsible, toggled by icon button
|
||||
calendar_filter: CalendarFilter? -- year/month archive tree
|
||||
tag_filter: List<String> -- selected tags (multi-select chips with colours)
|
||||
category_filter: List<String> -- selected categories (multi-select chips)
|
||||
draft_section: List<PostListItem>
|
||||
published_section: List<PostListItem>
|
||||
archived_section: List<PostListItem>
|
||||
has_more: Boolean -- pagination, 500 per batch
|
||||
}
|
||||
|
||||
surface PostsViewSurface {
|
||||
context view: PostsView
|
||||
|
||||
exposes:
|
||||
view.mode
|
||||
view.search_query
|
||||
view.filter_panel_visible
|
||||
view.calendar_filter when view.calendar_filter != null
|
||||
view.tag_filter
|
||||
view.category_filter
|
||||
view.draft_section.count
|
||||
view.published_section.count
|
||||
view.archived_section.count
|
||||
view.has_more
|
||||
}
|
||||
|
||||
-- Drafts section always shows all drafts regardless of filters.
|
||||
-- Published and archived sections respect active filters.
|
||||
-- "Clear All Filters" button resets search, date, tags, categories.
|
||||
-- Filters auto-refresh when any post's status changes.
|
||||
|
||||
value PostListItem {
|
||||
post_id: String
|
||||
type_icon: String -- derived via PostTypeIcon from source post categories
|
||||
title: String -- post.title, fallback "Untitled"
|
||||
language_count: Integer? -- shown when availableLanguages.count > 1
|
||||
date: String -- locale-formatted via PostDateFormat
|
||||
active: Boolean -- true when activeTabId = post.id
|
||||
}
|
||||
|
||||
surface PostListItemEntry {
|
||||
context item: PostListItem
|
||||
|
||||
exposes:
|
||||
item.type_icon
|
||||
item.title
|
||||
item.language_count when item.language_count != null
|
||||
item.date
|
||||
item.active
|
||||
|
||||
provides:
|
||||
PostListItemClicked(item.post_id, single)
|
||||
PostListItemClicked(item.post_id, double)
|
||||
|
||||
@guarantee RowLayout
|
||||
-- Row with two columns.
|
||||
-- Left column: type_icon (fixed width, top-aligned).
|
||||
-- Right column, line 1: title (fills available width, truncated with ellipsis)
|
||||
-- and language_count badge (right-aligned pill, smaller font) when present.
|
||||
-- Right column, line 2: date (smaller, muted colour).
|
||||
|
||||
@guarantee ActiveIndicator
|
||||
-- When item.active is true, entry shows a coloured left-border accent.
|
||||
|
||||
@guarantee PostTypeBackground
|
||||
-- Row has a subtle background tint derived from the type_icon category.
|
||||
|
||||
@guarantee DateSource
|
||||
-- For published posts: date derives from publishedAt, falling back to updatedAt.
|
||||
-- For draft and archived posts: date derives from updatedAt.
|
||||
}
|
||||
|
||||
rule PostListClick {
|
||||
when: PostListItemClicked(post_id, click_type)
|
||||
if click_type = single:
|
||||
ensures: OpenTabRequested(type: post, id: post_id, intent: preview)
|
||||
if click_type = double:
|
||||
ensures: OpenTabRequested(type: post, id: post_id, intent: pin)
|
||||
}
|
||||
|
||||
-- ─── 2. Pages view ───────────────────────────────────────────
|
||||
|
||||
-- Identical to PostsView but:
|
||||
-- mode = "pages"
|
||||
-- Filters to posts with "page" category
|
||||
-- Create button auto-adds "page" category
|
||||
-- Category filter excludes "page" from chips but auto-merges into backend calls
|
||||
|
||||
-- ─── 3. Media view ────────────────────────────────────────────
|
||||
|
||||
value MediaView {
|
||||
search_query: String? -- FTS via media.search(query)
|
||||
filter_panel_visible: Boolean
|
||||
calendar_filter: CalendarFilter?
|
||||
tag_filter: List<String> -- tags only, no categories for media
|
||||
grid: List<MediaGridItem> -- grid layout (not list)
|
||||
}
|
||||
|
||||
surface MediaViewSurface {
|
||||
context view: MediaView
|
||||
|
||||
exposes:
|
||||
view.search_query
|
||||
view.filter_panel_visible
|
||||
view.calendar_filter when view.calendar_filter != null
|
||||
view.tag_filter
|
||||
view.grid.count
|
||||
}
|
||||
|
||||
value MediaGridItem {
|
||||
media_id: String
|
||||
thumbnail_path: String? -- small (150px) thumbnail on disk when image; null for non-image
|
||||
name: String -- title truncated to config.media_title_max_length + "..."; fallback originalName (no truncation)
|
||||
file_size: String -- formatted (B / KB / MB)
|
||||
dimensions: String? -- "WxH" when width and height known; null otherwise
|
||||
tooltip: String -- caption ?? originalName
|
||||
active: Boolean -- true when activeTabId = media.id
|
||||
}
|
||||
|
||||
surface MediaGridItemEntry {
|
||||
context item: MediaGridItem
|
||||
|
||||
exposes:
|
||||
item.thumbnail_path when item.thumbnail_path != null
|
||||
item.name
|
||||
item.file_size
|
||||
item.dimensions when item.dimensions != null
|
||||
item.tooltip
|
||||
item.active
|
||||
|
||||
provides:
|
||||
MediaGridItemClicked(item.media_id, single)
|
||||
MediaGridItemClicked(item.media_id, double)
|
||||
|
||||
@guarantee CellLayout
|
||||
-- Grid cell, row layout.
|
||||
-- Left: 40x40 thumbnail (rounded, object-fit cover) loaded from
|
||||
-- thumbnail_path (small 150px WebP) when present;
|
||||
-- otherwise generic file icon of same dimensions.
|
||||
-- Right column, line 1: name (truncated with ellipsis).
|
||||
-- Right column, line 2: file_size (smaller, muted) followed by
|
||||
-- dimensions when present, separated by " · ".
|
||||
|
||||
@guarantee NameTruncation
|
||||
-- Title is hard-truncated at config.media_title_max_length characters
|
||||
-- with "..." suffix appended. originalName is never hard-truncated.
|
||||
-- CSS text-overflow:ellipsis applies as additional safety net on both.
|
||||
|
||||
@guarantee TooltipContent
|
||||
-- Tooltip shows caption when available, otherwise originalName.
|
||||
}
|
||||
|
||||
config {
|
||||
media_title_max_length: Integer = 60
|
||||
}
|
||||
|
||||
rule MediaListClick {
|
||||
when: MediaGridItemClicked(media_id, click_type)
|
||||
if click_type = single:
|
||||
ensures: OpenTabRequested(type: media, id: media_id, intent: preview)
|
||||
if click_type = double:
|
||||
ensures: OpenTabRequested(type: media, id: media_id, intent: pin)
|
||||
}
|
||||
|
||||
-- Import button: opens native file import dialog
|
||||
|
||||
-- ─── 4. Scripts view ──────────────────────────────────────────
|
||||
|
||||
-- Follows SidebarEntityListPattern.
|
||||
|
||||
value ScriptsView {
|
||||
items: List<ScriptListItem>
|
||||
}
|
||||
|
||||
surface ScriptsViewSurface {
|
||||
context view: ScriptsView
|
||||
|
||||
exposes:
|
||||
view.items.count
|
||||
}
|
||||
|
||||
value ScriptListItem {
|
||||
script_id: String
|
||||
title: String -- truncated with ellipsis on overflow
|
||||
date: String -- relative date format via RelativeDateFormat
|
||||
active: Boolean
|
||||
}
|
||||
|
||||
surface ScriptListItemEntry {
|
||||
context item: ScriptListItem
|
||||
|
||||
exposes:
|
||||
item.title
|
||||
item.date
|
||||
item.active
|
||||
|
||||
provides:
|
||||
ScriptListItemClicked(item.script_id, single)
|
||||
ScriptListItemClicked(item.script_id, double)
|
||||
ScriptDeleteRequested(item.script_id)
|
||||
|
||||
@guarantee RowLayout
|
||||
-- Row with two areas.
|
||||
-- Left area (fills width), two lines:
|
||||
-- Line 1: title (truncated with ellipsis).
|
||||
-- Line 2: date in relative format (smaller, muted).
|
||||
-- Right area: delete button (x), visible only on row hover, turns red on hover.
|
||||
|
||||
@guarantee KeyboardNavigation
|
||||
-- Enter key: pin (opens pinned tab).
|
||||
-- Space key: preview (opens transient tab).
|
||||
-- Row is focusable with tabIndex and has aria-label from title.
|
||||
|
||||
@guarantee DeleteBehaviour
|
||||
-- Delete removes the script and closes its open tab if any.
|
||||
|
||||
@guarantee CreateDefaults
|
||||
-- New scripts default to: kind=utility, content='print("new script")',
|
||||
-- entrypoint='render', enabled=true.
|
||||
|
||||
@guarantee LiveRefresh
|
||||
-- Item list refreshes on scripts-changed event.
|
||||
}
|
||||
|
||||
rule ScriptListClick {
|
||||
when: ScriptListItemClicked(script_id, click_type)
|
||||
if click_type = single:
|
||||
ensures: OpenTabRequested(type: scripts, id: script_id, intent: preview)
|
||||
if click_type = double:
|
||||
ensures: OpenTabRequested(type: scripts, id: script_id, intent: pin)
|
||||
}
|
||||
|
||||
-- ─── 5. Templates view ───────────────────────────────────────
|
||||
|
||||
-- Follows SidebarEntityListPattern. Same item layout as ScriptListItemEntry.
|
||||
|
||||
value TemplatesView {
|
||||
items: List<TemplateListItem>
|
||||
}
|
||||
|
||||
surface TemplatesViewSurface {
|
||||
context view: TemplatesView
|
||||
|
||||
exposes:
|
||||
view.items.count
|
||||
}
|
||||
|
||||
value TemplateListItem {
|
||||
template_id: String
|
||||
title: String -- truncated with ellipsis on overflow
|
||||
date: String -- relative date format via RelativeDateFormat
|
||||
active: Boolean
|
||||
}
|
||||
|
||||
surface TemplateListItemEntry {
|
||||
context item: TemplateListItem
|
||||
|
||||
exposes:
|
||||
item.title
|
||||
item.date
|
||||
item.active
|
||||
|
||||
provides:
|
||||
TemplateListItemClicked(item.template_id, single)
|
||||
TemplateListItemClicked(item.template_id, double)
|
||||
TemplateDeleteRequested(item.template_id)
|
||||
|
||||
@guarantee RowLayout
|
||||
-- Row with two areas.
|
||||
-- Left area (fills width), two lines:
|
||||
-- Line 1: title (truncated with ellipsis).
|
||||
-- Line 2: date in relative format (smaller, muted).
|
||||
-- Right area: delete button (x), visible only on row hover, turns red on hover.
|
||||
|
||||
@guarantee KeyboardNavigation
|
||||
-- Enter key: pin (opens pinned tab).
|
||||
-- Space key: preview (opens transient tab).
|
||||
-- Row is focusable with tabIndex and has aria-label from title.
|
||||
|
||||
@guarantee DeleteConfirmation
|
||||
-- If template is referenced by posts or tags, shows confirmation dialog
|
||||
-- with reference counts before force-delete.
|
||||
|
||||
@guarantee CreateDefaults
|
||||
-- New templates default to: kind=post, content='', enabled=true.
|
||||
|
||||
@guarantee LiveRefresh
|
||||
-- Item list refreshes on templates-changed event.
|
||||
}
|
||||
|
||||
rule TemplateListClick {
|
||||
when: TemplateListItemClicked(template_id, click_type)
|
||||
if click_type = single:
|
||||
ensures: OpenTabRequested(type: templates, id: template_id, intent: preview)
|
||||
if click_type = double:
|
||||
ensures: OpenTabRequested(type: templates, id: template_id, intent: pin)
|
||||
}
|
||||
|
||||
-- ─── 6. Settings view ────────────────────────────────────────
|
||||
|
||||
-- Navigation list that controls sections within the settings editor tab.
|
||||
|
||||
value SettingsNavEntry {
|
||||
section: String
|
||||
icon: String
|
||||
label_key: String
|
||||
active: Boolean
|
||||
}
|
||||
|
||||
invariant SettingsNavSections {
|
||||
-- Settings navigation has exactly 9 entries in this fixed order:
|
||||
-- 1. section="project", icon="folder", label_key="settings.nav.project"
|
||||
-- 2. section="editor", icon="notepad", label_key="settings.nav.editor"
|
||||
-- 3. section="content", icon="clipboard", label_key="settings.nav.content"
|
||||
-- 4. section="ai", icon="robot", label_key="settings.nav.ai"
|
||||
-- 5. section="technology", icon="gear", label_key="settings.nav.technology"
|
||||
-- 6. section="publishing", icon="rocket", label_key="settings.nav.publishing"
|
||||
-- 7. section="data", icon="database", label_key="settings.nav.data"
|
||||
-- 8. section="mcp", icon="plug", label_key="settings.nav.mcp"
|
||||
-- 9. section="style", icon="palette", label_key="settings.nav.style"
|
||||
-- Labels are localised via their label_key through i18n.
|
||||
}
|
||||
|
||||
surface SettingsNavEntryView {
|
||||
context entry: SettingsNavEntry
|
||||
|
||||
exposes:
|
||||
entry.icon
|
||||
entry.label_key
|
||||
entry.active
|
||||
|
||||
provides:
|
||||
SettingsNavEntryClicked(entry.section)
|
||||
|
||||
@guarantee RowLayout
|
||||
-- Row with icon (fixed 20px width, centred) and localised text label.
|
||||
-- Active entry has distinct selection background and foreground colours.
|
||||
|
||||
@guarantee FixedOrder
|
||||
-- Entries always appear in the order defined by SettingsNavSections invariant.
|
||||
}
|
||||
|
||||
value SettingsNav {
|
||||
active_section: String? -- persisted across sidebar switches
|
||||
}
|
||||
|
||||
surface SettingsNavSurface {
|
||||
context nav: SettingsNav
|
||||
|
||||
exposes:
|
||||
nav.active_section when nav.active_section != null
|
||||
}
|
||||
|
||||
rule SettingsNavClick {
|
||||
when: SettingsNavEntryClicked(section)
|
||||
if section = style:
|
||||
ensures: OpenTabRequested(type: style, id: style, intent: pin)
|
||||
else:
|
||||
ensures: OpenTabRequested(type: settings, id: settings, intent: pin)
|
||||
ensures: ScrollToSection(section)
|
||||
ensures: active_section = section
|
||||
-- Active section is persisted across sidebar switches
|
||||
}
|
||||
|
||||
-- ─── 7. Tags view ─────────────────────────────────────────────
|
||||
|
||||
-- Navigation list that controls sections within the tags editor tab.
|
||||
|
||||
value TagsNavEntry {
|
||||
section: String
|
||||
icon: String
|
||||
label_key: String
|
||||
active: Boolean
|
||||
}
|
||||
|
||||
invariant TagsNavSections {
|
||||
-- Tags navigation has exactly 3 entries in this fixed order:
|
||||
-- 1. section="cloud", icon="cloud", label_key="tags.nav.cloud" -- tag cloud visualisation
|
||||
-- 2. section="manage", icon="pencil", label_key="tags.nav.manage" -- create/edit tags
|
||||
-- 3. section="merge", icon="merge", label_key="tags.nav.merge" -- merge duplicate tags
|
||||
-- Labels are localised via their label_key through i18n.
|
||||
}
|
||||
|
||||
surface TagsNavEntryView {
|
||||
context entry: TagsNavEntry
|
||||
|
||||
exposes:
|
||||
entry.icon
|
||||
entry.label_key
|
||||
entry.active
|
||||
|
||||
provides:
|
||||
TagsNavEntryClicked(entry.section)
|
||||
|
||||
@guarantee RowLayout
|
||||
-- Row with icon (fixed 20px width, centred) and localised text label.
|
||||
-- Active entry has distinct selection background and foreground colours.
|
||||
-- Same visual structure as SettingsNavEntryView.
|
||||
|
||||
@guarantee FixedOrder
|
||||
-- Entries always appear in the order defined by TagsNavSections invariant.
|
||||
}
|
||||
|
||||
value TagsNav {
|
||||
active_section: String? -- persisted
|
||||
}
|
||||
|
||||
surface TagsNavSurface {
|
||||
context nav: TagsNav
|
||||
|
||||
exposes:
|
||||
nav.active_section when nav.active_section != null
|
||||
}
|
||||
|
||||
rule TagsNavClick {
|
||||
when: TagsNavEntryClicked(section)
|
||||
ensures: OpenTabRequested(type: tags, id: tags, intent: pin)
|
||||
ensures: ScrollToSection(section)
|
||||
ensures: active_section = section
|
||||
-- Active section is persisted across sidebar switches
|
||||
}
|
||||
|
||||
-- ─── 8. Chat view ─────────────────────────────────────────────
|
||||
|
||||
-- Follows SidebarEntityListPattern.
|
||||
|
||||
value ChatView {
|
||||
api_ready: Boolean -- shows API key prompt if false
|
||||
items: List<ChatListItem>
|
||||
}
|
||||
|
||||
surface ChatViewSurface {
|
||||
context view: ChatView
|
||||
|
||||
exposes:
|
||||
view.api_ready
|
||||
view.items.count
|
||||
}
|
||||
|
||||
value ChatListItem {
|
||||
conversation_id: String
|
||||
title: String -- live-updated via onTitleUpdated
|
||||
date: String -- relative date format via RelativeDateFormat
|
||||
}
|
||||
|
||||
surface ChatListItemEntry {
|
||||
context item: ChatListItem
|
||||
|
||||
exposes:
|
||||
item.title
|
||||
item.date
|
||||
|
||||
provides:
|
||||
ChatListItemClicked(item.conversation_id)
|
||||
ChatDeleteRequested(item.conversation_id)
|
||||
|
||||
@guarantee RowLayout
|
||||
-- Row with two areas.
|
||||
-- Left area (fills width), two lines:
|
||||
-- Line 1: title (truncated with ellipsis, live-updated).
|
||||
-- Line 2: date in relative format (smaller, muted).
|
||||
-- Right area: delete button (x), visible only on row hover.
|
||||
|
||||
@guarantee DeleteBehaviour
|
||||
-- Delete removes the conversation and closes its open tab if any.
|
||||
|
||||
@guarantee AlwaysPinned
|
||||
-- Chat tabs are always opened as pinned (never transient).
|
||||
}
|
||||
|
||||
rule ChatListClick {
|
||||
when: ChatListItemClicked(conversation_id)
|
||||
ensures: OpenTabRequested(type: chat, id: conversation_id, intent: pin)
|
||||
-- Chat tabs are always pinned
|
||||
}
|
||||
|
||||
-- ─── 9. Import view ──────────────────────────────────────────
|
||||
|
||||
-- Follows SidebarEntityListPattern.
|
||||
|
||||
value ImportView {
|
||||
items: List<ImportListItem>
|
||||
}
|
||||
|
||||
surface ImportViewSurface {
|
||||
context view: ImportView
|
||||
|
||||
exposes:
|
||||
view.items.count
|
||||
}
|
||||
|
||||
value ImportListItem {
|
||||
definition_id: String
|
||||
name: String -- live-updated via onNameUpdated
|
||||
date: String -- relative date format via RelativeDateFormat
|
||||
}
|
||||
|
||||
surface ImportListItemEntry {
|
||||
context item: ImportListItem
|
||||
|
||||
exposes:
|
||||
item.name
|
||||
item.date
|
||||
|
||||
provides:
|
||||
ImportListItemClicked(item.definition_id)
|
||||
ImportDeleteRequested(item.definition_id)
|
||||
|
||||
@guarantee RowLayout
|
||||
-- Row with two areas.
|
||||
-- Left area (fills width), two lines:
|
||||
-- Line 1: name (truncated with ellipsis, live-updated).
|
||||
-- Line 2: date in relative format (smaller, muted).
|
||||
-- Right area: delete button (x), visible only on row hover.
|
||||
|
||||
@guarantee DeleteBehaviour
|
||||
-- Delete removes the import definition and closes its open tab if any.
|
||||
|
||||
@guarantee AlwaysPinned
|
||||
-- Import tabs are always opened as pinned (never transient).
|
||||
}
|
||||
|
||||
rule ImportListClick {
|
||||
when: ImportListItemClicked(definition_id)
|
||||
ensures: OpenTabRequested(type: import, id: definition_id, intent: pin)
|
||||
-- Import tabs are always pinned
|
||||
}
|
||||
|
||||
-- ─── 10. Git view ─────────────────────────────────────────────
|
||||
|
||||
-- Full git interface, not the SidebarEntityList pattern.
|
||||
-- Three possible states: loading, not_a_repo, active_repo.
|
||||
|
||||
-- State: not_a_repo
|
||||
-- Remote URL text input + "Initialize Git" button.
|
||||
-- Init progress with phase/percentage/detail, collapsible transcript.
|
||||
|
||||
-- State: active_repo
|
||||
value GitActiveView {
|
||||
branch: String -- current branch name
|
||||
upstream: String? -- tracking info (local -> upstream)
|
||||
ahead: Integer
|
||||
behind: Integer
|
||||
status_files: List<GitStatusFile>
|
||||
history_entries: List<GitHistoryEntry>
|
||||
has_more_history: Boolean -- paginated, 20 per page
|
||||
}
|
||||
|
||||
surface GitActiveViewSurface {
|
||||
context view: GitActiveView
|
||||
|
||||
exposes:
|
||||
view.branch
|
||||
view.upstream when view.upstream != null
|
||||
view.ahead
|
||||
view.behind
|
||||
view.status_files.count
|
||||
view.history_entries.count
|
||||
view.has_more_history
|
||||
|
||||
provides:
|
||||
GitCommitRequested(message)
|
||||
}
|
||||
|
||||
value GitStatusFile {
|
||||
path: String
|
||||
status: String -- modified, added, deleted, renamed, etc.
|
||||
}
|
||||
|
||||
surface GitStatusFileEntry {
|
||||
context file: GitStatusFile
|
||||
|
||||
exposes:
|
||||
file.path
|
||||
file.status
|
||||
|
||||
provides:
|
||||
GitStatusFileClicked(file.path, single)
|
||||
GitStatusFileClicked(file.path, double)
|
||||
|
||||
@guarantee RowLayout
|
||||
-- Row with two elements, justified space-between.
|
||||
-- Left: file path (truncated with ellipsis, fills available width).
|
||||
-- Right: status badge (short uppercase code e.g. "M", "A", "D"; muted colour, fixed width).
|
||||
|
||||
@guarantee Tooltip
|
||||
-- Tooltip shows "status: path" (e.g. "modified: src/main.rs").
|
||||
}
|
||||
|
||||
value GitHistoryEntry {
|
||||
short_hash: String -- 7 chars
|
||||
subject: String -- wraps (word-break), not truncated
|
||||
author: String
|
||||
date: String -- locale-formatted
|
||||
sync_status: String -- synced, local_only, remote_only
|
||||
}
|
||||
|
||||
surface GitHistoryEntryView {
|
||||
context entry: GitHistoryEntry
|
||||
|
||||
exposes:
|
||||
entry.short_hash
|
||||
entry.subject
|
||||
entry.author
|
||||
entry.date
|
||||
entry.sync_status
|
||||
|
||||
provides:
|
||||
GitHistoryEntryClicked(entry.short_hash, single)
|
||||
GitHistoryEntryClicked(entry.short_hash, double)
|
||||
|
||||
@guarantee EntryLayout
|
||||
-- Two lines.
|
||||
-- Line 1: subject (wraps with word-break, never truncated).
|
||||
-- Line 2: short_hash + author + date + sync_status indicator, separated by spacing.
|
||||
|
||||
@guarantee SyncStatusIndicator
|
||||
-- sync_status rendered as a coloured dot.
|
||||
-- "synced" = both local and remote. "local_only" = local only. "remote_only" = remote only.
|
||||
-- A colour legend is shown in the git view header.
|
||||
}
|
||||
|
||||
-- Action buttons: fetch, pull, push, prune_lfs. All disabled while any action is loading.
|
||||
-- Changes section: file count, commit message input, commit button, file list.
|
||||
-- Changes list polls every 2 seconds in background.
|
||||
-- Remote state refreshes every 30 seconds with auto-fetch.
|
||||
|
||||
rule GitFileClick {
|
||||
when: GitStatusFileClicked(file_path, click_type)
|
||||
if click_type = single:
|
||||
ensures: OpenTabRequested(type: git_diff, id: "git-diff:" + file_path, intent: preview)
|
||||
if click_type = double:
|
||||
ensures: OpenTabRequested(type: git_diff, id: "git-diff:" + file_path, intent: pin)
|
||||
}
|
||||
|
||||
rule GitHistoryClick {
|
||||
when: GitHistoryEntryClicked(commit_hash, click_type)
|
||||
if click_type = single:
|
||||
ensures: OpenTabRequested(type: git_diff, id: "git-diff:commit:" + commit_hash, intent: preview)
|
||||
if click_type = double:
|
||||
ensures: OpenTabRequested(type: git_diff, id: "git-diff:commit:" + commit_hash, intent: pin)
|
||||
}
|
||||
|
||||
rule GitCommit {
|
||||
when: GitCommitRequested(message)
|
||||
ensures: git.commitAll(message)
|
||||
-- Also: all git_diff tabs are closed and git state is reloaded
|
||||
}
|
||||
|
||||
-- ─── Calendar archive (shared widget) ─────────────────────────
|
||||
|
||||
-- Collapsible year/month tree.
|
||||
-- Selecting a year loads all posts/media for that year.
|
||||
-- Selecting a month narrows to that month.
|
||||
|
||||
value CalendarFilter {
|
||||
selected_year: Integer?
|
||||
selected_month: Integer? -- 1-12
|
||||
}
|
||||
|
||||
value CalendarYear {
|
||||
year: Integer
|
||||
months: List<CalendarMonth>
|
||||
}
|
||||
|
||||
surface CalendarYearSurface {
|
||||
context calendar_year: CalendarYear
|
||||
|
||||
exposes:
|
||||
calendar_year.year
|
||||
calendar_year.months.count
|
||||
}
|
||||
|
||||
value CalendarMonth {
|
||||
month: Integer -- 1-12
|
||||
count: Integer -- number of items in this month
|
||||
}
|
||||
247
specs/tabs.allium
Normal file
247
specs/tabs.allium
Normal file
@@ -0,0 +1,247 @@
|
||||
-- allium: 1
|
||||
-- bDS Tab System and Editor Routing
|
||||
-- Scope: UI navigation (all waves)
|
||||
-- Distilled from: tabPolicy.ts, TabBar.tsx, editorRouting.ts, appStore.ts
|
||||
|
||||
-- Governs the tab bar, tab lifecycle (open/close/pin), editor routing,
|
||||
-- and the relationship between tabs and the content area.
|
||||
|
||||
use "./layout.allium" as layout
|
||||
|
||||
surface TabControlSurface {
|
||||
facing _: TabOperator
|
||||
|
||||
provides:
|
||||
OpenTabRequested(type, id, intent)
|
||||
OpenTabInBackgroundRequested(type, id, intent)
|
||||
CloseTabRequested(tab)
|
||||
PinTabRequested(tab)
|
||||
ClearTabsRequested()
|
||||
}
|
||||
|
||||
surface TabRuntimeSurface {
|
||||
facing _: TabRuntime
|
||||
|
||||
provides:
|
||||
TabOpening(tab_type, intent)
|
||||
ActiveTabChanged(active_tab)
|
||||
}
|
||||
|
||||
-- ─── Tab types ────────────────────────────────────────────────
|
||||
|
||||
-- 17 distinct tab types, each routing to a matching editor view.
|
||||
-- Plus "dashboard" as the no-tab default view.
|
||||
--
|
||||
-- Tab types: post, media, settings, style, tags, chat, import,
|
||||
-- menu_editor, metadata_diff, git_diff, documentation,
|
||||
-- api_documentation, site_validation, translation_validation,
|
||||
-- scripts, templates, find_duplicates
|
||||
--
|
||||
-- Editor routes: all of the above plus "dashboard" (shown when no tab is active).
|
||||
-- Route registry is 1:1: every tab type maps to itself as an editor route.
|
||||
|
||||
-- ─── Tab entity ───────────────────────────────────────────────
|
||||
|
||||
value Tab {
|
||||
type: String -- one of the 17 tab types
|
||||
id: String -- singleton: id = type name; entity: external ID
|
||||
is_transient: Boolean -- true = preview tab (italic title, replaceable)
|
||||
}
|
||||
|
||||
surface TabSurface {
|
||||
context tab: Tab
|
||||
|
||||
exposes:
|
||||
tab.type
|
||||
tab.id
|
||||
tab.is_transient
|
||||
}
|
||||
|
||||
-- ─── Tab categories ───────────────────────────────────────────
|
||||
|
||||
-- 1. Singleton tool tabs: always one instance, never transient, id = type name.
|
||||
-- settings, tags, style, scripts (bare), menu_editor, documentation,
|
||||
-- api_documentation, metadata_diff, site_validation,
|
||||
-- translation_validation, find_duplicates
|
||||
-- Total: 11 singleton types.
|
||||
|
||||
-- 2. Entity tabs: keyed by external ID, support preview/pin intent.
|
||||
-- post (id = postId), media (id = mediaId)
|
||||
|
||||
-- 3. Script tabs: type = scripts, id = scriptId (NOT the singleton).
|
||||
-- Support preview/pin intent.
|
||||
|
||||
-- 4. Template tabs: type = templates, id = templateId.
|
||||
-- Support preview/pin intent.
|
||||
|
||||
-- 5. Chat tabs: type = chat, id = conversationId. Always pinned (not transient).
|
||||
|
||||
-- 6. Import tabs: type = import, id = definitionId. Always pinned.
|
||||
|
||||
-- 7. Git diff tabs: type = git_diff.
|
||||
-- File diff: id = "git-diff:{filePath}"
|
||||
-- Commit diff: id = "git-diff:commit:{commitHash}"
|
||||
-- Support preview/pin intent.
|
||||
|
||||
-- ─── Open intent ──────────────────────────────────────────────
|
||||
|
||||
-- preview: transient tab (replaced by next preview of same type)
|
||||
-- pin: permanent tab (persists until explicitly closed)
|
||||
|
||||
rule DeriveTransient {
|
||||
when: TabOpening(tab_type, intent)
|
||||
if tab_type in singleton_tool_tabs:
|
||||
ensures: tab.is_transient = false
|
||||
else if tab_type = chat or tab_type = import:
|
||||
ensures: tab.is_transient = false
|
||||
else:
|
||||
ensures: tab.is_transient = (intent = preview)
|
||||
}
|
||||
|
||||
-- ─── Tab lifecycle ────────────────────────────────────────────
|
||||
|
||||
rule OpenTab {
|
||||
when: OpenTabRequested(type, id, intent)
|
||||
|
||||
-- Dedup: if tab with same (type, id) already exists, activate it.
|
||||
-- If intent = pin, also set is_transient = false.
|
||||
-- Transient replacement: if opening as transient and a transient tab
|
||||
-- of same type exists, replace it with the new tab.
|
||||
-- Otherwise: append a new tab.
|
||||
-- Always sets active_tab to the opened/reused tab.
|
||||
ensures: active_tab = resolved_tab
|
||||
}
|
||||
|
||||
rule OpenTabInBackground {
|
||||
when: OpenTabInBackgroundRequested(type, id, intent)
|
||||
-- Same dedup/replace logic as OpenTab, but does NOT change active_tab
|
||||
}
|
||||
|
||||
rule CloseTab {
|
||||
when: CloseTabRequested(tab)
|
||||
ensures: not exists tab
|
||||
-- If tab was active: activate next tab at same index, or last tab, or null
|
||||
}
|
||||
|
||||
rule PinTab {
|
||||
when: PinTabRequested(tab)
|
||||
ensures: tab.is_transient = false
|
||||
}
|
||||
|
||||
rule ClearTabs {
|
||||
when: ClearTabsRequested()
|
||||
ensures: tabs = empty
|
||||
ensures: active_tab = null
|
||||
}
|
||||
|
||||
-- ─── Editor routing ───────────────────────────────────────────
|
||||
|
||||
-- The editor content area renders a view based on the active tab.
|
||||
|
||||
rule ResolveEditorRoute {
|
||||
when: ActiveTabChanged(active_tab)
|
||||
if active_tab = null:
|
||||
ensures: editor_route = dashboard
|
||||
else:
|
||||
ensures: editor_route = active_tab.type
|
||||
-- 1:1 mapping; every tab type maps to itself as editor route
|
||||
}
|
||||
|
||||
-- ─── Editor views (what each route renders) ───────────────────
|
||||
|
||||
-- dashboard: Overview stats, timeline, tag cloud, recent posts
|
||||
-- post: Post editor (keyed by postId)
|
||||
-- media: Media editor (keyed by mediaId)
|
||||
-- settings: Settings view with scrollable sections
|
||||
-- style: Pico CSS theme editor
|
||||
-- tags: Tag cloud, create/edit, merge sections
|
||||
-- chat: AI chat panel (keyed by conversationId)
|
||||
-- import: Import analysis view (keyed by definitionId)
|
||||
-- menu_editor: OPML menu editor
|
||||
-- metadata_diff: DB vs filesystem diff viewer
|
||||
-- git_diff: Git diff view (file or commit, keyed by tab id)
|
||||
-- documentation: Rendered markdown (DOCUMENTATION.md)
|
||||
-- api_documentation: Rendered markdown (API.md)
|
||||
-- site_validation: Generated site link/structure validation
|
||||
-- translation_validation: Translation completeness checks
|
||||
-- scripts: Script editor (keyed by scriptId)
|
||||
-- templates: Template editor (keyed by templateId)
|
||||
-- find_duplicates: Duplicate post detection via embeddings
|
||||
|
||||
-- ─── Tab bar rendering ───────────────────────────────────────
|
||||
|
||||
-- Hidden when no tabs exist.
|
||||
-- Horizontal strip with overflow scroll (left/right arrow buttons, 150px per click).
|
||||
-- Auto-scrolls to bring active tab into view (10px padding).
|
||||
|
||||
config {
|
||||
tab_min_width: Integer = 100
|
||||
tab_max_width: Integer = 160
|
||||
tab_scroll_step: Integer = 150
|
||||
chat_title_max_length: Integer = 18
|
||||
git_hash_display_length: Integer = 7
|
||||
}
|
||||
|
||||
value TabBarItem {
|
||||
title: String -- resolved per type (see below); CSS ellipsis at tab_max_width
|
||||
is_active: Boolean
|
||||
is_transient: Boolean -- italic title when true
|
||||
is_dirty: Boolean -- dot indicator, only for post tabs
|
||||
}
|
||||
|
||||
surface TabBarItemSurface {
|
||||
context item: TabBarItem
|
||||
|
||||
exposes:
|
||||
item.title
|
||||
item.is_active
|
||||
item.is_transient
|
||||
item.is_dirty
|
||||
}
|
||||
|
||||
-- Tab title resolution:
|
||||
-- post: post.title from DB (listens post-updated events); no JS truncation
|
||||
-- media: media.originalName; no JS truncation
|
||||
-- scripts: script.title from DB (listens scripts-changed); no JS truncation
|
||||
-- templates: template.title from DB (listens templates-changed); no JS truncation
|
||||
-- chat: conversation.title, JS-truncated to 18 chars + "..." if over limit
|
||||
-- import: definition.name (listens name-updated); no JS truncation
|
||||
-- git_diff file: filename only (last path segment); no JS truncation
|
||||
-- git_diff commit: "{shortHash} {subject}" (shortHash = 7 chars); fallback: 7-char hash only
|
||||
-- singletons: i18n key lookup (common.settings, tabBar.style, etc.)
|
||||
-- fallback: i18n:tabBar.unknown
|
||||
--
|
||||
-- All tab titles are additionally CSS-truncated (text-overflow:ellipsis, white-space:nowrap)
|
||||
-- within the tab's max-width of 160px.
|
||||
|
||||
-- ─── Tab interactions ─────────────────────────────────────────
|
||||
|
||||
-- Single click on tab: activate it
|
||||
-- Double click on tab: if transient, pin it
|
||||
-- Middle click on tab: close it
|
||||
-- Close button: close the tab
|
||||
|
||||
-- ─── Dirty tracking ──────────────────────────────────────────
|
||||
|
||||
invariant DirtyIndicator {
|
||||
-- Only post tabs show dirty state
|
||||
-- A post tab is dirty when its in-memory content differs from saved
|
||||
for tab in tabs:
|
||||
tab.is_dirty = (tab.type = post and dirtyPosts.contains(tab.id))
|
||||
}
|
||||
|
||||
-- ─── Tab tooltip ──────────────────────────────────────────────
|
||||
|
||||
-- Base: tab title
|
||||
-- If transient: append " (Preview)"
|
||||
-- If dirty: append " * Modified"
|
||||
|
||||
-- ─── Keyboard shortcuts ──────────────────────────────────────
|
||||
|
||||
-- Ctrl/Cmd+W: close active tab
|
||||
-- Ctrl/Cmd+B: toggle sidebar (see layout.allium)
|
||||
|
||||
-- ─── Tab state persistence ───────────────────────────────────
|
||||
|
||||
-- Tab state (list + activeTabId) can be serialized/restored
|
||||
-- for session continuity across project switches.
|
||||
121
specs/tag.allium
Normal file
121
specs/tag.allium
Normal file
@@ -0,0 +1,121 @@
|
||||
-- allium: 1
|
||||
-- bDS Tag System
|
||||
-- Scope: core (Wave 1)
|
||||
-- Distilled from: src/main/engine/TagEngine.ts, schema.ts
|
||||
|
||||
use "./project.allium" as project
|
||||
use "./post.allium" as post
|
||||
|
||||
surface TagControlSurface {
|
||||
facing _: TagOperator
|
||||
|
||||
provides:
|
||||
CreateTagRequested(project, name, color)
|
||||
UpdateTagRequested(tag, changes)
|
||||
DeleteTagRequested(tag)
|
||||
RenameTagRequested(tag, new_name)
|
||||
MergeTagsRequested(sources, target)
|
||||
SyncTagsFromPostsRequested(project)
|
||||
}
|
||||
|
||||
entity Tag {
|
||||
project: project/Project
|
||||
name: String
|
||||
color: String? -- hex color code
|
||||
post_template_slug: String?
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
|
||||
-- Derived
|
||||
posts: post/Post with this.name in tags
|
||||
post_count: posts.count
|
||||
}
|
||||
|
||||
surface TagSurface {
|
||||
context tag: Tag
|
||||
|
||||
exposes:
|
||||
tag.project
|
||||
tag.name
|
||||
tag.color when tag.color != null
|
||||
tag.post_template_slug when tag.post_template_slug != null
|
||||
tag.created_at
|
||||
tag.updated_at
|
||||
tag.posts.count
|
||||
tag.post_count
|
||||
}
|
||||
|
||||
invariant UniqueTagNamePerProject {
|
||||
-- Case-insensitive uniqueness
|
||||
for a in Tags:
|
||||
for b in Tags:
|
||||
(a != b and a.project = b.project)
|
||||
implies lowercase(a.name) != lowercase(b.name)
|
||||
}
|
||||
|
||||
invariant TagsPersistToFilesystem {
|
||||
-- meta/tags.json is the portable format (no internal IDs)
|
||||
-- Must stay in sync with DB tag table
|
||||
parse_json(read_file("meta/tags.json")) = serialize_portable(Tags)
|
||||
}
|
||||
|
||||
rule CreateTag {
|
||||
when: CreateTagRequested(project, name, color)
|
||||
let existing_tags = Tags where project = project
|
||||
requires: not existing_tags.any(t => lowercase(t.name) = lowercase(name))
|
||||
-- Case-insensitive duplicate check
|
||||
ensures: Tag.created(
|
||||
project: project,
|
||||
name: name,
|
||||
color: color
|
||||
)
|
||||
ensures: TagsFileWritten(project)
|
||||
}
|
||||
|
||||
rule UpdateTag {
|
||||
when: UpdateTagRequested(tag, changes)
|
||||
ensures: TagFieldsUpdated(tag, changes)
|
||||
ensures: tag.updated_at = now
|
||||
ensures: TagsFileWritten(tag.project)
|
||||
}
|
||||
|
||||
rule DeleteTag {
|
||||
when: DeleteTagRequested(tag)
|
||||
-- Runs as background task, removes tag from all posts
|
||||
for p in tag.posts:
|
||||
ensures: p.tags = p.tags - {tag.name}
|
||||
ensures: not exists tag
|
||||
ensures: TagsFileWritten(tag.project)
|
||||
}
|
||||
|
||||
rule RenameTag {
|
||||
when: RenameTagRequested(tag, new_name)
|
||||
-- Runs as background task
|
||||
let old_name = tag.name
|
||||
for p in tag.posts:
|
||||
ensures: p.tags = (p.tags - {old_name}) + {new_name}
|
||||
ensures: tag.name = new_name
|
||||
ensures: TagsFileWritten(tag.project)
|
||||
}
|
||||
|
||||
rule MergeTags {
|
||||
when: MergeTagsRequested(sources, target)
|
||||
-- Runs as background task
|
||||
-- Merges multiple source tags into a single target
|
||||
requires: sources.count >= 1
|
||||
for source in sources:
|
||||
for p in source.posts:
|
||||
ensures: p.tags = (p.tags - {source.name}) + {target.name}
|
||||
ensures: not exists source
|
||||
ensures: TagsFileWritten(target.project)
|
||||
}
|
||||
|
||||
rule SyncTagsFromPosts {
|
||||
when: SyncTagsFromPostsRequested(project)
|
||||
-- Discovers tags used in posts that are not in the tags table
|
||||
for post in project.posts:
|
||||
for tag_name in post.tags:
|
||||
if not exists Tag{project: project, name: tag_name}:
|
||||
ensures: Tag.created(project: project, name: tag_name)
|
||||
ensures: TagsFileWritten(project)
|
||||
}
|
||||
124
specs/task.allium
Normal file
124
specs/task.allium
Normal file
@@ -0,0 +1,124 @@
|
||||
-- allium: 1
|
||||
-- bDS Background Task Manager
|
||||
-- Scope: core (Wave 1)
|
||||
-- Distilled from: src/main/engine/TaskManager.ts
|
||||
|
||||
entity Task {
|
||||
name: String
|
||||
status: pending | running | completed | failed | cancelled
|
||||
progress: Decimal? -- 0.0..1.0
|
||||
message: String?
|
||||
group_id: String? -- Optional task grouping
|
||||
group_name: String?
|
||||
created_at: Timestamp
|
||||
|
||||
transitions status {
|
||||
pending -> running
|
||||
running -> completed
|
||||
running -> failed
|
||||
running -> cancelled
|
||||
}
|
||||
}
|
||||
|
||||
surface TaskControlSurface {
|
||||
facing _: TaskOperator
|
||||
|
||||
provides:
|
||||
SubmitTaskRequested(name, work)
|
||||
CancelTaskRequested(task)
|
||||
RegisterExternalTaskRequested(name)
|
||||
}
|
||||
|
||||
surface TaskRuntimeSurface {
|
||||
facing _: TaskRuntime
|
||||
|
||||
provides:
|
||||
TaskWorkCompleted(task)
|
||||
TaskWorkFailed(task, error_message)
|
||||
ProgressReported(task, value, message)
|
||||
}
|
||||
|
||||
surface TaskSurface {
|
||||
context task: Task
|
||||
|
||||
exposes:
|
||||
task.name
|
||||
task.status
|
||||
task.progress when task.progress != null
|
||||
task.message when task.message != null
|
||||
task.group_id when task.group_id != null
|
||||
task.group_name when task.group_name != null
|
||||
task.created_at
|
||||
}
|
||||
|
||||
config {
|
||||
max_concurrent: Integer = 3
|
||||
progress_throttle: Duration = 250.milliseconds
|
||||
}
|
||||
|
||||
invariant MaxConcurrency {
|
||||
-- At most max_concurrent tasks run simultaneously
|
||||
let running_tasks = Tasks where status = running
|
||||
running_tasks.count <= config.max_concurrent
|
||||
}
|
||||
|
||||
invariant FifoQueue {
|
||||
-- When max concurrent reached, new tasks queue in FIFO order
|
||||
-- Queued tasks transition to running as slots open
|
||||
}
|
||||
|
||||
rule SubmitTask {
|
||||
when: SubmitTaskRequested(name, work)
|
||||
let running_tasks = Tasks where status = running
|
||||
ensures:
|
||||
let task = Task.created(name: name, status: pending)
|
||||
task.status = pending
|
||||
if running_tasks.count < config.max_concurrent:
|
||||
task.status = running
|
||||
TaskStarted(task, work)
|
||||
}
|
||||
|
||||
rule CompleteTask {
|
||||
when: TaskWorkCompleted(task)
|
||||
ensures: task.status = completed
|
||||
ensures: task.progress = 1.0
|
||||
ensures: NextQueuedTaskStarted()
|
||||
}
|
||||
|
||||
rule FailTask {
|
||||
when: TaskWorkFailed(task, error_message)
|
||||
ensures: task.status = failed
|
||||
ensures: task.message = error_message
|
||||
ensures: NextQueuedTaskStarted()
|
||||
}
|
||||
|
||||
rule CancelTask {
|
||||
when: CancelTaskRequested(task)
|
||||
requires: task.status = running or task.status = pending
|
||||
-- Cancellation uses a runtime-specific cancellation mechanism
|
||||
ensures: task.status = cancelled
|
||||
ensures: NextQueuedTaskStarted()
|
||||
}
|
||||
|
||||
rule ReportProgress {
|
||||
when: ProgressReported(task, value, message)
|
||||
-- Progress events throttled to 250ms
|
||||
ensures: task.progress = value
|
||||
ensures: task.message = message
|
||||
}
|
||||
|
||||
invariant ProgressThrottled {
|
||||
-- Progress update events are throttled to prevent UI flooding
|
||||
-- At most one progress event per 250ms per task
|
||||
}
|
||||
|
||||
-- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts)
|
||||
rule RegisterExternalTask {
|
||||
when: RegisterExternalTaskRequested(name)
|
||||
ensures:
|
||||
let task = Task.created(name: name, status: running)
|
||||
task.status = running
|
||||
@guidance
|
||||
-- External tasks are not managed by the queue
|
||||
-- The caller is responsible for updating status
|
||||
}
|
||||
211
specs/template.allium
Normal file
211
specs/template.allium
Normal file
@@ -0,0 +1,211 @@
|
||||
-- allium: 1
|
||||
-- bDS Liquid Template System
|
||||
-- Scope: core (Wave 1 data, Wave 4 rendering)
|
||||
-- Distilled from: src/main/engine/TemplateEngine.ts, PageRenderer.ts, schema.ts,
|
||||
-- bundled starter templates in src/main/engine/templates/
|
||||
|
||||
entity Template {
|
||||
slug: String
|
||||
title: String
|
||||
kind: post | list | not_found | partial
|
||||
enabled: Boolean
|
||||
status: draft | published
|
||||
content: String?
|
||||
version: Integer
|
||||
file_path: String
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
|
||||
-- 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
|
||||
|
||||
transitions status {
|
||||
draft -> published
|
||||
published -> draft
|
||||
}
|
||||
}
|
||||
|
||||
surface TemplateManagementSurface {
|
||||
facing _: TemplateOperator
|
||||
|
||||
provides:
|
||||
CreateTemplateRequested(title, kind, content)
|
||||
CreateAndPublishTemplateRequested(title, kind, content)
|
||||
UpdateTemplateRequested(template, changes)
|
||||
PublishTemplateRequested(template)
|
||||
DeleteTemplateRequested(template)
|
||||
RebuildTemplatesFromFilesRequested(project)
|
||||
}
|
||||
|
||||
invariant UniqueTemplateSlug {
|
||||
for a in Templates:
|
||||
for b in Templates:
|
||||
a != b implies a.slug != b.slug
|
||||
}
|
||||
|
||||
invariant TemplateFrontmatter {
|
||||
-- .liquid files use standard --- YAML frontmatter
|
||||
-- Fields: id, slug, title, kind, enabled, version, createdAt, updatedAt
|
||||
for t in Templates where status = published:
|
||||
parse_frontmatter(read_file(t.file_path)).slug = t.slug
|
||||
}
|
||||
|
||||
invariant TemplateFileLayout {
|
||||
for t in Templates where file_path != "":
|
||||
t.file_path = format("templates/{slug}.liquid", slug: t.slug)
|
||||
}
|
||||
|
||||
rule CreateTemplate {
|
||||
when: CreateTemplateRequested(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(
|
||||
slug: slug,
|
||||
title: title,
|
||||
kind: kind,
|
||||
content: content,
|
||||
status: draft,
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: ""
|
||||
)
|
||||
new_template.status = draft
|
||||
}
|
||||
|
||||
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)
|
||||
let slug = slugify(title)
|
||||
requires: ValidateLiquid(content) = valid
|
||||
ensures:
|
||||
let new_template = Template.created(
|
||||
slug: slug,
|
||||
title: title,
|
||||
kind: kind,
|
||||
content: null,
|
||||
status: published,
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: format("templates/{slug}.liquid", slug: slug)
|
||||
)
|
||||
TemplateFileWritten(new_template)
|
||||
}
|
||||
|
||||
rule UpdateTemplate {
|
||||
when: UpdateTemplateRequested(template, changes)
|
||||
ensures: TemplateFieldsUpdated(template, changes)
|
||||
ensures: template.updated_at = now
|
||||
ensures: template.version = template.version + 1
|
||||
}
|
||||
|
||||
rule ReopenPublishedTemplate {
|
||||
when: UpdateTemplateRequested(template, changes)
|
||||
requires: template.status = published
|
||||
requires: template_changes_affect_rendered_output(changes)
|
||||
ensures: template.status = draft
|
||||
}
|
||||
|
||||
rule PublishTemplate {
|
||||
when: PublishTemplateRequested(template)
|
||||
requires: template.status = draft
|
||||
requires: ValidateLiquid(template.content) = valid
|
||||
-- The template parser must accept the template
|
||||
ensures: template.status = published
|
||||
ensures: TemplateFileWritten(template)
|
||||
-- Writes frontmatter + liquid to templates/{slug}.liquid
|
||||
ensures: template.content = null
|
||||
}
|
||||
|
||||
rule DeleteTemplate {
|
||||
when: DeleteTemplateRequested(template)
|
||||
requires: template.referencing_posts.count = 0
|
||||
requires: template.referencing_tags.count = 0
|
||||
-- Cannot delete a template still referenced by posts or tags
|
||||
ensures: not exists template
|
||||
ensures: TemplateFileDeleted(template)
|
||||
}
|
||||
|
||||
rule CascadeSlugUpdate {
|
||||
when: template: Template.slug transitions_to new_slug
|
||||
-- When a template slug changes, update all references
|
||||
for p in template.referencing_posts:
|
||||
ensures: p.template_slug = new_slug
|
||||
for t in template.referencing_tags:
|
||||
ensures: t.post_template_slug = new_slug
|
||||
}
|
||||
|
||||
rule RebuildTemplatesFromFiles {
|
||||
when: RebuildTemplatesFromFilesRequested(project)
|
||||
for file in scan_directory(project.effective_data_dir + "/templates", "*.liquid"):
|
||||
let parsed = parse_template_file(file)
|
||||
ensures: Template.created(parsed)
|
||||
-- or updated if slug already exists
|
||||
}
|
||||
|
||||
-- Exact Liquid subset required (distilled from bundled starter templates)
|
||||
-- No features beyond this list are used.
|
||||
|
||||
invariant LiquidTagSubset {
|
||||
-- Only these 5 tags are used:
|
||||
-- {% if %} / {% elsif %} / {% else %} / {% endif %}
|
||||
-- {% for %} / {% endfor %}
|
||||
-- {% assign %}
|
||||
-- {% render 'partial', named_param: value %} (with named parameters)
|
||||
-- Whitespace-stripped variants: {%- -%}
|
||||
--
|
||||
-- NOT used: include, capture, case/when, unless, raw, comment,
|
||||
-- cycle, tablerow, increment, decrement, liquid, echo
|
||||
}
|
||||
|
||||
invariant LiquidFilterSubset {
|
||||
-- Standard filters (4):
|
||||
-- | escape
|
||||
-- | url_encode
|
||||
-- | default: fallback_value
|
||||
-- | append: suffix_string
|
||||
--
|
||||
-- Custom filters (2):
|
||||
-- | i18n: language — translates a key string for given language
|
||||
-- | markdown: post_id, post_data_json_by_id, canonical_post_path_by_slug,
|
||||
-- canonical_media_path_by_source_path, language, language_prefix
|
||||
-- — renders Markdown to HTML with link rewriting (6 arguments)
|
||||
--
|
||||
-- NOT used: date, strip_html, truncate, split, join, size (as filter),
|
||||
-- upcase, downcase, replace, remove, sort, map, where, first, last,
|
||||
-- reverse, concat, uniq, compact, strip, newline_to_br, json, prepend,
|
||||
-- and all math filters
|
||||
}
|
||||
|
||||
invariant LiquidOperatorSubset {
|
||||
-- Comparison: ==, >
|
||||
-- Logical: or, and
|
||||
-- Truthy/falsy: bare variable in {% if variable %}
|
||||
-- Special values: blank (nil/empty comparison)
|
||||
-- Property access: dot notation (object.property), .size on arrays,
|
||||
-- bracket notation for map lookups (map[key])
|
||||
}
|
||||
|
||||
invariant LiquidRenderContext {
|
||||
-- Template rendering context provides these top-level variables:
|
||||
-- language, language_prefix, html_theme_attribute,
|
||||
-- page_title, pico_stylesheet_href,
|
||||
-- blog_languages (array of {is_current, code, flag, href_prefix}),
|
||||
-- alternate_links (array of {hreflang, href}),
|
||||
-- menu_items (tree of {href, title, has_children, children}),
|
||||
-- calendar_initial_year, calendar_initial_month,
|
||||
-- post (single post context: {title, content, id, slug, show_title}),
|
||||
-- post_categories, post_tags, tag_color_by_name (map),
|
||||
-- backlinks (array of {path, display_slug}),
|
||||
-- day_blocks (array of {show_date_marker, date_label, posts, show_separator}),
|
||||
-- archive_context ({kind, name, month, year, day}),
|
||||
-- show_archive_range_heading, min_date, max_date,
|
||||
-- canonical_post_path_by_slug (map), canonical_media_path_by_source_path (map),
|
||||
-- post_data_json_by_id (map),
|
||||
-- is_list_page, is_first_page, is_last_page,
|
||||
-- has_prev_page, has_next_page, prev_page_href, next_page_href,
|
||||
-- not_found_message, not_found_back_label
|
||||
}
|
||||
308
specs/template_context.allium
Normal file
308
specs/template_context.allium
Normal file
@@ -0,0 +1,308 @@
|
||||
-- allium: 1
|
||||
-- bDS Liquid Template Context Specification
|
||||
-- Scope: core (Wave 4 — rendering parity)
|
||||
-- Distilled from: ../bDS/src/main/engine/GenerationRouteRendererFactory.ts,
|
||||
-- PageRenderer.ts, BlogGenerationEngine.ts
|
||||
--
|
||||
-- This document specifies the exact data structure passed to Liquid templates
|
||||
-- during rendering. It is the contract between the generation engine and
|
||||
-- template authors.
|
||||
|
||||
-- ============================================================================
|
||||
-- GLOBAL TEMPLATE VARIABLES
|
||||
-- ============================================================================
|
||||
|
||||
surface TemplateRenderingSurface {
|
||||
facing _: RenderPipeline
|
||||
|
||||
provides:
|
||||
RenderPostPageRequested(post, language)
|
||||
RenderListPageRequested(posts, pagination, archive_context)
|
||||
RenderNotFoundPageRequested()
|
||||
}
|
||||
|
||||
surface RenderContextSurface {
|
||||
context context: RenderContext
|
||||
|
||||
exposes:
|
||||
context.language
|
||||
context.language_prefix
|
||||
context.page_title
|
||||
context.pico_stylesheet_href
|
||||
context.blog_languages
|
||||
context.alternate_links
|
||||
context.menu_items
|
||||
context.post
|
||||
context.day_blocks
|
||||
context.archive_context
|
||||
context.is_list_page
|
||||
context.prev_page_href
|
||||
context.next_page_href
|
||||
context.not_found_message
|
||||
}
|
||||
|
||||
surface PaginationContextSurface {
|
||||
context pagination: PaginationContext
|
||||
|
||||
exposes:
|
||||
pagination.is_first_page
|
||||
pagination.is_last_page
|
||||
pagination.has_prev_page
|
||||
pagination.has_next_page
|
||||
pagination.prev_page_href
|
||||
pagination.next_page_href
|
||||
pagination.current_page
|
||||
pagination.total_pages
|
||||
pagination.total_items
|
||||
pagination.items_per_page
|
||||
}
|
||||
|
||||
value RenderContext {
|
||||
-- Top-level variables available in all templates
|
||||
language: String -- Current language code
|
||||
language_prefix: String? -- "/de" or "" depending on language
|
||||
html_theme_attribute: String -- Theme class for <html> element
|
||||
page_title: String -- Page title for <title> tag
|
||||
pico_stylesheet_href: String -- Path to Pico CSS theme
|
||||
blog_languages: List<BlogLanguage>
|
||||
alternate_links: List<AlternateLink>
|
||||
menu_items: List<MenuItem>
|
||||
calendar_initial_year: Integer
|
||||
calendar_initial_month: Integer
|
||||
post: PostContext? -- Present on single post pages
|
||||
post_categories: List<Category>
|
||||
post_tags: List<Tag>
|
||||
tag_color_by_name: Map<String, String>
|
||||
backlinks: List<Backlink>
|
||||
day_blocks: List<DayBlock>
|
||||
archive_context: ArchiveContext?
|
||||
show_archive_range_heading: Boolean
|
||||
min_date: Timestamp?
|
||||
max_date: Timestamp?
|
||||
is_list_page: Boolean
|
||||
is_first_page: Boolean
|
||||
is_last_page: Boolean
|
||||
has_prev_page: Boolean
|
||||
has_next_page: Boolean
|
||||
prev_page_href: String?
|
||||
next_page_href: String?
|
||||
not_found_message: String?
|
||||
not_found_back_label: String?
|
||||
|
||||
-- Lookup maps for macro expansion
|
||||
canonical_post_path_by_slug: Map<String, String>
|
||||
canonical_media_path_by_source_path: Map<String, String>
|
||||
post_data_json_by_id: Map<String, PostDataJson>
|
||||
}
|
||||
|
||||
value BlogLanguage {
|
||||
is_current: Boolean
|
||||
code: String
|
||||
flag: String
|
||||
href: String
|
||||
href_prefix: String
|
||||
}
|
||||
|
||||
value AlternateLink {
|
||||
href: String
|
||||
hreflang: String
|
||||
}
|
||||
|
||||
value MenuItem {
|
||||
href: String
|
||||
title: String
|
||||
has_children: Boolean
|
||||
children: List<MenuItem>?
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- POST CONTEXT (Single Post Pages)
|
||||
-- ============================================================================
|
||||
|
||||
value PostContext {
|
||||
id: String
|
||||
title: String
|
||||
content: String -- Rendered HTML (after markdown + macros)
|
||||
slug: String
|
||||
excerpt: String?
|
||||
author: String?
|
||||
language: String?
|
||||
show_title: Boolean -- Always true for post pages
|
||||
published_at: Timestamp
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
tags: List<String>
|
||||
categories: List<String>
|
||||
template_slug: String?
|
||||
do_not_translate: Boolean
|
||||
linked_media: List<MediaContext>
|
||||
outgoing_links: List<LinkContext>
|
||||
incoming_links: List<LinkContext>
|
||||
}
|
||||
|
||||
value PostDataJson {
|
||||
-- Serialized post data for Liquid template access
|
||||
id: String
|
||||
title: String
|
||||
slug: String
|
||||
excerpt: String?
|
||||
author: String?
|
||||
language: String?
|
||||
published_at: Timestamp
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
tags: List<String>
|
||||
categories: List<String>
|
||||
}
|
||||
|
||||
value MediaContext {
|
||||
id: String
|
||||
filename: String
|
||||
original_name: String
|
||||
mime_type: String
|
||||
title: String?
|
||||
alt: String?
|
||||
caption: String?
|
||||
author: String?
|
||||
width: Integer?
|
||||
height: Integer?
|
||||
file_path: String
|
||||
}
|
||||
|
||||
value LinkContext {
|
||||
href: String
|
||||
title: String
|
||||
display_slug: String
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- ARCHIVE CONTEXT (List Pages)
|
||||
-- ============================================================================
|
||||
|
||||
value ArchiveContext {
|
||||
kind: category | tag | date
|
||||
name: String?
|
||||
month: Integer?
|
||||
year: Integer?
|
||||
day: Integer?
|
||||
}
|
||||
|
||||
value DayBlock {
|
||||
show_date_marker: Boolean
|
||||
date_label: String
|
||||
posts: List<PostContext>
|
||||
show_separator: Boolean
|
||||
}
|
||||
|
||||
value Category {
|
||||
name: String
|
||||
slug: String
|
||||
post_count: Integer
|
||||
}
|
||||
|
||||
value Tag {
|
||||
name: String
|
||||
slug: String
|
||||
color: String?
|
||||
post_count: Integer
|
||||
}
|
||||
|
||||
value Backlink {
|
||||
path: String
|
||||
display_slug: String
|
||||
title: String
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- PAGINATION CONTEXT
|
||||
-- ============================================================================
|
||||
|
||||
value PaginationContext {
|
||||
is_list_page: Boolean
|
||||
is_first_page: Boolean
|
||||
is_last_page: Boolean
|
||||
has_prev_page: Boolean
|
||||
has_next_page: Boolean
|
||||
prev_page_href: String?
|
||||
next_page_href: String?
|
||||
current_page: Integer
|
||||
total_pages: Integer
|
||||
total_items: Integer
|
||||
items_per_page: Integer
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- LIQUID FILTER SPECIFICATION
|
||||
-- Note: Allium does not have a 'filter' keyword. Filters are documented here
|
||||
-- for reference but are implemented in the template engine, not in Allium specs.
|
||||
-- ============================================================================
|
||||
|
||||
-- Built-in filters:
|
||||
-- default: {{ value | default: "fallback" }}
|
||||
-- escape: {{ text | escape }}
|
||||
-- url_encode: {{ text | url_encode }}
|
||||
-- append: {{ text | append: suffix }}
|
||||
--
|
||||
-- Custom filters:
|
||||
-- i18n: {{ "key" | i18n: language }} - translation lookup
|
||||
-- markdown: {{ content | markdown: ... }} - markdown rendering with macro expansion
|
||||
|
||||
-- ============================================================================
|
||||
-- BUILT-IN MACROS
|
||||
-- Note: Allium does not have a 'macro' keyword. Macros are documented here
|
||||
-- for reference but are implemented by the rendering subsystem.
|
||||
-- ============================================================================
|
||||
|
||||
-- Built-in macros:
|
||||
-- gallery: [[gallery images=post.linked_media columns=3]]
|
||||
-- youtube: [[youtube id=dQw4w9WgXcQ]]
|
||||
-- vimeo: [[vimeo id=123456789]]
|
||||
-- photo_archive: [[photo_archive media=project.media]]
|
||||
-- tag_cloud: [[tag_cloud tags=Tags]]
|
||||
|
||||
-- ============================================================================
|
||||
-- TEMPLATE LOOKUP RULES
|
||||
-- ============================================================================
|
||||
|
||||
invariant TemplateLookupPriority {
|
||||
-- Templates are resolved in this order:
|
||||
-- 1. Post-specific template (post.template_slug)
|
||||
-- 2. Tag-specific template (tag.post_template_slug)
|
||||
-- 3. Category-specific template (category.post_template_slug)
|
||||
-- 4. Default "post" template
|
||||
--
|
||||
-- List pages use "list" template
|
||||
-- 404 pages use "not_found" template
|
||||
-- Partials are referenced via {% render 'partial' %}
|
||||
}
|
||||
|
||||
invariant PartialResolution {
|
||||
-- Partials are looked up in templates/ directory
|
||||
-- Usage: {% render 'partials/header', context_var: value %}
|
||||
-- Partial files: templates/partials/{name}.liquid
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
-- RENDERING RULES
|
||||
-- ============================================================================
|
||||
|
||||
rule RenderPostPage {
|
||||
when: RenderPostPageRequested(post, language)
|
||||
let template = resolve_template(post)
|
||||
let context = BuildPostContext(post, language)
|
||||
ensures: RenderedHtml = liquid_render(template.content, context)
|
||||
}
|
||||
|
||||
rule RenderListPage {
|
||||
when: RenderListPageRequested(posts, pagination, archive_context)
|
||||
let template = first (t in Templates where t.kind = "list" and t.enabled)
|
||||
let context = BuildListContext(posts, pagination, archive_context)
|
||||
ensures: RenderedHtml = liquid_render(template.content, context)
|
||||
}
|
||||
|
||||
rule RenderNotFoundPage {
|
||||
when: RenderNotFoundPageRequested()
|
||||
let template = first (t in Templates where t.kind = "not_found" and t.enabled)
|
||||
let context = BuildNotFoundContext
|
||||
ensures: RenderedHtml = liquid_render(template.content, context)
|
||||
}
|
||||
171
specs/translation.allium
Normal file
171
specs/translation.allium
Normal file
@@ -0,0 +1,171 @@
|
||||
-- allium: 1
|
||||
-- bDS Translation System
|
||||
-- Scope: core (Wave 1)
|
||||
-- Distilled from: src/main/engine/PostEngine.ts (translation methods),
|
||||
-- postTranslationFileUtils.ts, MediaEngine.ts
|
||||
|
||||
use "./post.allium" as post
|
||||
use "./media.allium" as media
|
||||
|
||||
surface TranslationControlSurface {
|
||||
facing _: TranslationOperator
|
||||
|
||||
provides:
|
||||
UpsertPostTranslationRequested(post, language, title, content, excerpt)
|
||||
DeletePostTranslationRequested(translation)
|
||||
ValidateTranslationsRequested(project)
|
||||
}
|
||||
|
||||
surface TranslationRuntimeSurface {
|
||||
facing _: TranslationRuntime
|
||||
|
||||
provides:
|
||||
PostPublished(post)
|
||||
PublishTranslationRequested(translation)
|
||||
TranslationEdited(translation, changes)
|
||||
}
|
||||
|
||||
value SupportedLanguage {
|
||||
-- en, de, fr, it, es
|
||||
code: String
|
||||
}
|
||||
|
||||
surface SupportedLanguageSurface {
|
||||
context language: SupportedLanguage
|
||||
|
||||
exposes:
|
||||
language.code
|
||||
}
|
||||
|
||||
entity PostTranslation {
|
||||
canonical_post: post/Post
|
||||
language: SupportedLanguage
|
||||
title: String
|
||||
excerpt: String?
|
||||
content: String?
|
||||
status: draft | published
|
||||
file_path: String
|
||||
checksum: String?
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
published_at: Timestamp?
|
||||
|
||||
-- Derived
|
||||
content_location: if status = published: file_path else: content
|
||||
|
||||
transitions status {
|
||||
draft -> published
|
||||
published -> draft
|
||||
}
|
||||
}
|
||||
|
||||
surface PostTranslationSurface {
|
||||
context translation: PostTranslation
|
||||
|
||||
exposes:
|
||||
translation.canonical_post
|
||||
translation.language.code
|
||||
translation.title
|
||||
translation.excerpt when translation.excerpt != null
|
||||
translation.content when translation.content != null
|
||||
translation.status
|
||||
translation.file_path
|
||||
translation.checksum when translation.checksum != null
|
||||
translation.created_at
|
||||
translation.updated_at
|
||||
translation.published_at when translation.published_at != null
|
||||
translation.content_location
|
||||
}
|
||||
|
||||
invariant UniqueTranslationPerLanguage {
|
||||
for a in PostTranslations:
|
||||
for b in PostTranslations:
|
||||
(a != b and a.canonical_post = b.canonical_post)
|
||||
implies a.language != b.language
|
||||
}
|
||||
|
||||
invariant TranslationFilePath {
|
||||
-- posts/YYYY/MM/{slug}.{language}.md
|
||||
for t in PostTranslations where file_path != "":
|
||||
t.file_path = format("posts/{yyyy}/{mm}/{slug}.{lang}.md",
|
||||
yyyy: t.canonical_post.created_at.year,
|
||||
mm: t.canonical_post.created_at.month_padded,
|
||||
slug: t.canonical_post.slug,
|
||||
lang: t.language.code)
|
||||
}
|
||||
|
||||
rule UpsertPostTranslation {
|
||||
when: UpsertPostTranslationRequested(post, language, title, content, excerpt)
|
||||
requires: not post.do_not_translate
|
||||
ensures:
|
||||
let translation = PostTranslation.created(
|
||||
canonical_post: post,
|
||||
language: language,
|
||||
title: title,
|
||||
content: content,
|
||||
excerpt: excerpt,
|
||||
status: draft,
|
||||
file_path: ""
|
||||
)
|
||||
translation.status = draft
|
||||
-- If translation already exists, update it instead
|
||||
}
|
||||
|
||||
rule PublishPostTranslation {
|
||||
when: PostPublished(post)
|
||||
-- All translations are also published when the canonical post is published
|
||||
for t in post.translations:
|
||||
ensures: PublishTranslationRequested(t)
|
||||
}
|
||||
|
||||
rule PublishTranslation {
|
||||
when: PublishTranslationRequested(translation)
|
||||
requires: translation.status = draft
|
||||
ensures: translation.status = published
|
||||
ensures: translation.published_at = translation.published_at ?? now
|
||||
ensures: TranslationFileWritten(translation)
|
||||
ensures: translation.content = null
|
||||
-- Content moves to filesystem
|
||||
}
|
||||
|
||||
rule ReopenPublishedTranslation {
|
||||
when: TranslationEdited(translation, changes)
|
||||
requires: translation.status = published
|
||||
requires: translation_edit_affects_published_content(changes)
|
||||
ensures: translation.status = draft
|
||||
ensures: translation.updated_at = now
|
||||
}
|
||||
|
||||
rule DeletePostTranslation {
|
||||
when: DeletePostTranslationRequested(translation)
|
||||
ensures: not exists translation
|
||||
ensures: TranslationFileDeleted(translation)
|
||||
ensures: SearchIndexUpdated(translation.canonical_post)
|
||||
-- FTS index includes all translations of a post
|
||||
}
|
||||
|
||||
rule ValidateTranslations {
|
||||
when: ValidateTranslationsRequested(project)
|
||||
-- Checks all posts against configured blog languages
|
||||
-- Reports: missing translations, orphan translation files,
|
||||
-- posts marked do_not_translate
|
||||
for post in project.posts where status = published:
|
||||
for lang in project.blog_languages:
|
||||
if lang != project.main_language:
|
||||
if not post.do_not_translate:
|
||||
if not (lang in post.available_languages):
|
||||
ensures: ValidationIssueReported(post, lang, "missing")
|
||||
|
||||
@guidance
|
||||
-- This produces a validation report, not automatic fixes
|
||||
-- The report drives targeted re-rendering
|
||||
}
|
||||
|
||||
invariant FtsIncludesTranslations {
|
||||
-- Full-text search index for a post includes stemmed content
|
||||
-- from all its translations, not just the canonical language
|
||||
for post in Posts:
|
||||
includes_text(search_index(post), post.title)
|
||||
for t in post.translations:
|
||||
includes_text(search_index(post), t.title)
|
||||
}
|
||||
203
specs/ui_data_flow.allium
Normal file
203
specs/ui_data_flow.allium
Normal file
@@ -0,0 +1,203 @@
|
||||
-- allium: 1
|
||||
-- bDS UI Data Flow Model
|
||||
-- Scope: cross-cutting (all waves)
|
||||
-- Distilled from: appStore.ts, PostEditor.tsx, MediaEditor.tsx, Editor.tsx,
|
||||
-- Sidebar.tsx, NotificationWatcher.ts
|
||||
|
||||
-- Describes the reactive data flows that connect sidebar, editor, tab bar,
|
||||
-- and backend engine operations. Covers: sidebar->editor, editor->sidebar,
|
||||
-- cross-tab coordination, and backend->UI event propagation.
|
||||
|
||||
use "./layout.allium" as layout
|
||||
use "./tabs.allium" as tabs
|
||||
use "./sidebar_views.allium" as sidebar
|
||||
|
||||
-- ─── External surfaces ──────────────────────────────────────
|
||||
|
||||
-- User-initiated navigation and entity management actions.
|
||||
-- These triggers originate from sidebar clicks, tab operations,
|
||||
-- dashboard interactions, and save/delete actions in editors.
|
||||
|
||||
surface UserNavigation {
|
||||
provides: SidebarItemClicked(entity_type, entity_id, click_type)
|
||||
provides: SidebarCreateRequested(entity_type)
|
||||
provides: SidebarDeleteRequested(entity_type, entity_id)
|
||||
provides: DashboardPostClicked(post_id, click_type)
|
||||
provides: PostSaved(post_id, updated_post)
|
||||
provides: PostStatusTransitioned(post_id, old_status, new_status)
|
||||
provides: PostDeletedFromEditor(post_id)
|
||||
provides: MediaSavedFromEditor(media_id, updated_media)
|
||||
provides: MediaDeletedFromEditor(media_id)
|
||||
provides: SettingsRebuildCompleted(entity_type, new_data)
|
||||
provides: TransientTabBeingReplaced(old_tab, new_tab)
|
||||
provides: TabClosed(tab)
|
||||
}
|
||||
|
||||
-- ─── 1. Sidebar -> Editor flows ──────────────────────────────
|
||||
|
||||
-- All UI coordination flows through shared application state.
|
||||
-- There are no direct calls between sidebar and editor.
|
||||
-- Both read from and write to the same state model; changes propagate
|
||||
-- reactively.
|
||||
|
||||
rule SidebarEntityClick {
|
||||
when: SidebarItemClicked(entity_type, entity_id, click_type)
|
||||
-- Single click: open as transient/preview tab
|
||||
-- Double click: open as pinned tab
|
||||
-- See sidebar_views.allium for per-view click rules
|
||||
-- Effect: Editor mounts the matching view keyed by entity_id
|
||||
-- Effect: Sidebar item highlights based on activeTabId match
|
||||
-- If a prior transient tab of same type exists, it is replaced,
|
||||
-- and the old editor unmounts (triggering auto-save if dirty)
|
||||
}
|
||||
|
||||
rule SidebarCreateEntity {
|
||||
when: SidebarCreateRequested(entity_type)
|
||||
-- Posts/Pages: backend creates post, emits post:created,
|
||||
-- store adds to posts list, sidebar re-renders with new item.
|
||||
-- Does NOT open a tab automatically. User must click to open.
|
||||
-- Sets selectedPostId for highlighting, optionally ensures sidebar visible.
|
||||
-- Scripts/Templates: backend creates, emits event, opens tab immediately.
|
||||
-- Chat: creates conversation, opens as pinned tab.
|
||||
-- Import: creates definition, opens as pinned tab.
|
||||
}
|
||||
|
||||
rule SidebarDeleteEntity {
|
||||
when: SidebarDeleteRequested(entity_type, entity_id)
|
||||
-- Scripts/Templates/Chat/Import: backend deletes entity,
|
||||
-- then closeTab(entity_id) removes its tab,
|
||||
-- activates adjacent tab or shows dashboard.
|
||||
-- Posts/Media: deletion is triggered from the EDITOR (not sidebar).
|
||||
-- See editor_post.allium PostDeleteAction / editor_media.allium MediaDeleteAction.
|
||||
}
|
||||
|
||||
invariant SidebarFilterIsolation {
|
||||
-- Sidebar search/filter state is local to the sidebar component.
|
||||
-- Filtering never affects: active tab, editor content, selectedPostId.
|
||||
-- Only the visible list of items changes.
|
||||
}
|
||||
|
||||
-- ─── 2. Editor -> Sidebar flows ──────────────────────────────
|
||||
|
||||
rule PostTitleChanged {
|
||||
when: PostSaved(post_id, updated_post)
|
||||
-- Auto-save fires after 3s idle, or on Ctrl+S, or on unmount/tab switch
|
||||
-- Store's posts array is updated with new title/metadata
|
||||
-- Sidebar PostsList re-renders reactively showing new title
|
||||
-- TabBar re-renders showing new title
|
||||
ensures: dirtyPosts.remove(post_id)
|
||||
}
|
||||
|
||||
rule PostStatusChanged {
|
||||
when: PostStatusTransitioned(post_id, old_status, new_status)
|
||||
-- Store's posts array is updated with new status
|
||||
-- Sidebar detects status change (compares prev/current status maps)
|
||||
-- and re-runs search/filter if active
|
||||
-- Post moves between draft/published/archived sections in sidebar
|
||||
}
|
||||
|
||||
rule PostEditorDelete {
|
||||
when: PostDeletedFromEditor(post_id)
|
||||
ensures: store.removePost(post_id)
|
||||
-- Removes from posts array, clears selectedPostId if matching,
|
||||
-- removes from dirtyPosts
|
||||
ensures: closeTab(post_id)
|
||||
-- Sidebar re-renders without the post
|
||||
}
|
||||
|
||||
rule MediaEditorSave {
|
||||
when: MediaSavedFromEditor(media_id, updated_media)
|
||||
ensures: store.updateMedia(media_id, updated_media)
|
||||
-- Sidebar MediaList re-renders with updated metadata
|
||||
}
|
||||
|
||||
rule MediaEditorDelete {
|
||||
when: MediaDeletedFromEditor(media_id)
|
||||
ensures: store.removeMedia(media_id)
|
||||
-- Editor.tsx safety net: detects active media tab references
|
||||
-- non-existent item, calls closeTab(activeTab.id)
|
||||
-- Sidebar re-renders without the media item
|
||||
}
|
||||
|
||||
rule SettingsRebuild {
|
||||
when: SettingsRebuildCompleted(entity_type, new_data)
|
||||
-- Wholesale replacement of posts or media array in store
|
||||
ensures: store.setPosts(new_data) or store.setMedia(new_data)
|
||||
-- Sidebar re-renders entirely with fresh data
|
||||
}
|
||||
|
||||
-- ─── 3. Cross-tab coordination ──────────────────────────────
|
||||
|
||||
invariant TabSwitchDoesNotChangeSidebarView {
|
||||
-- Switching tabs does NOT change activeView in the sidebar.
|
||||
-- The sidebar view is controlled exclusively by the Activity Bar.
|
||||
-- Exception: Dashboard post click explicitly sets activeView = "posts".
|
||||
}
|
||||
|
||||
invariant SidebarHighlightFollowsActiveTab {
|
||||
-- Sidebar item highlight is based on activeTabId === entity.id,
|
||||
-- NOT on selectedPostId/selectedMediaId (which are separate concepts
|
||||
-- used only for post creation flow).
|
||||
}
|
||||
|
||||
rule TransientTabReplacement {
|
||||
when: TransientTabBeingReplaced(old_tab, new_tab)
|
||||
-- Old editor unmounts -> cleanup auto-save fires if dirty
|
||||
-- New editor mounts with new entity
|
||||
-- Sidebar highlight shifts to newly active entity
|
||||
}
|
||||
|
||||
rule TabCloseCleanup {
|
||||
when: TabClosed(tab)
|
||||
-- If post tab: PostEditor unmounts, auto-save fires if dirty
|
||||
-- Store activates adjacent tab (prefer right, then left, then null)
|
||||
-- Sidebar highlight updates to new active tab
|
||||
-- If no tabs remain: dashboard shown
|
||||
}
|
||||
|
||||
rule DashboardPostClick {
|
||||
when: DashboardPostClicked(post_id, click_type)
|
||||
-- This is the ONLY place where opening a tab also switches sidebar view
|
||||
ensures: setActiveView("posts")
|
||||
ensures: setSelectedPost(post_id)
|
||||
if click_type = single:
|
||||
ensures: OpenTabRequested(type: post, id: post_id, intent: preview)
|
||||
if click_type = double:
|
||||
ensures: OpenTabRequested(type: post, id: post_id, intent: pin)
|
||||
}
|
||||
|
||||
-- ─── 4. Backend -> UI event propagation ─────────────────────
|
||||
|
||||
-- Backend engines emit events via IPC. The renderer listens and updates
|
||||
-- the shared store. Both sidebar and editor re-render reactively.
|
||||
-- Events: post:created, post:updated, post:deleted,
|
||||
-- media:imported, media:updated, media:deleted,
|
||||
-- template:created/updated/deleted, script:created/updated/deleted,
|
||||
-- entity:changed (from CLI/MCP mutations via NotificationWatcher)
|
||||
|
||||
-- TabBar also listens directly for:
|
||||
-- post-updated (title refresh)
|
||||
-- bds:scripts-changed (script title refresh)
|
||||
-- BDS_EVENT_TEMPLATES_CHANGED (template title refresh)
|
||||
-- chat.onTitleUpdated (chat conversation title refresh)
|
||||
-- importDefinitions.onNameUpdated (import name refresh)
|
||||
|
||||
-- Editor.tsx has safety-net useEffect guards that:
|
||||
-- Close media tabs when referenced media no longer exists in store
|
||||
-- Clear selectedPostId/selectedMediaId when entity is gone
|
||||
|
||||
-- ─── 5. Keyboard shortcut map ─────────────────────────────
|
||||
|
||||
-- All shortcuts use Cmd on macOS, Ctrl on other platforms.
|
||||
|
||||
-- Global:
|
||||
-- Ctrl/Cmd+B: toggle sidebar visibility
|
||||
-- Ctrl/Cmd+W: close active tab
|
||||
|
||||
-- Post editor:
|
||||
-- Ctrl/Cmd+S: save post immediately (resets auto-save timer)
|
||||
-- Ctrl/Cmd+K: open InsertModal (insert post link)
|
||||
|
||||
-- Sidebar lists:
|
||||
-- Enter: open selected item as pinned tab
|
||||
-- Space: open selected item as preview/transient tab
|
||||
Reference in New Issue
Block a user