initial commit

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-23 10:42:27 +02:00
commit cd998f24a9
57 changed files with 9751 additions and 0 deletions

4
.formatter.exs Normal file
View File

@@ -0,0 +1,4 @@
[
import_deps: [:ecto, :ecto_sql],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
/_build/
/cover/
/deps/
/doc/
/.elixir_ls/
/erl_crash.dump
/priv/data/*.db
/priv/data/*.db-shm
/priv/data/*.db-wal
*.ez

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"chat.tools.terminal.autoApprove": {
"mix": true,
"allium": true
}
}

109
README.md Normal file
View File

@@ -0,0 +1,109 @@
# bDS2
bDS2 is the Elixir rewrite of bDS, the offline-first desktop blogging workspace in [../bDS](/Users/gb/Projects/bDS). This repository is the new implementation baseline: Elixir for the application core, Ecto for persistence, and a desktop shell to be selected as the rewrite matures.
The repository currently contains a minimal Elixir/OTP foundation plus a set of Allium specifications in [specs/](/Users/gb/Projects/bDS2/specs). Those specs should define application behavior, file contracts, and user-visible workflows without tying the rewrite to a specific implementation stack.
## Scope
The rewrite aims to preserve the product behavior of bDS while replacing the technical stack.
Behaviour that should remain stable includes:
- Offline-first editorial workflows.
- Filesystem-backed content with stable frontmatter, media sidecars, templates, scripts, and menu formats.
- Project, post, media, translation, tag, template, generation, preview, publishing, AI, and MCP workflows.
- Generated site output, search behavior, metadata synchronization, and rebuild behavior where those are part of the product contract.
The following are intentionally not part of the behavioral contract:
- The implementation language.
- Desktop container or UI framework.
- ORM choice.
- Internal state management, concurrency model, or runtime libraries.
## Repository Layout
- [mix.exs](/Users/gb/Projects/bDS2/mix.exs): Mix project definition.
- [config/](/Users/gb/Projects/bDS2/config): Elixir and Ecto configuration.
- [lib/](/Users/gb/Projects/bDS2/lib): application bootstrap and shared runtime modules.
- [priv/repo/](/Users/gb/Projects/bDS2/priv/repo): Ecto migrations.
- [specs/](/Users/gb/Projects/bDS2/specs): Allium specs distilled from the existing bDS product and being normalized for implementation-agnostic use.
## macOS Development Setup
This machine does not have Elixir installed yet, so start with the toolchain.
### 1. Install Xcode Command Line Tools
```bash
xcode-select --install
```
### 2. Install Homebrew
If Homebrew is not already installed:
```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
### 3. Install Erlang, Elixir, and SQLite
```bash
brew update
brew install erlang elixir sqlite
```
Verify the installation:
```bash
elixir --version
mix --version
sqlite3 --version
```
### 4. Fetch Dependencies
```bash
cd /Users/gb/Projects/bDS2
mix deps.get
```
### 5. Create the Local Database
```bash
mix ecto.create
mix ecto.migrate
```
### 6. Run Tests
```bash
mix test
```
## Current Elixir Baseline
The initial scaffold is intentionally small:
- OTP application startup in [lib/bds/application.ex](/Users/gb/Projects/bDS2/lib/bds/application.ex).
- Ecto repository in [lib/bds/repo.ex](/Users/gb/Projects/bDS2/lib/bds/repo.ex).
- Environment config in [config/config.exs](/Users/gb/Projects/bDS2/config/config.exs), [config/dev.exs](/Users/gb/Projects/bDS2/config/dev.exs), [config/test.exs](/Users/gb/Projects/bDS2/config/test.exs), and [config/runtime.exs](/Users/gb/Projects/bDS2/config/runtime.exs).
- Basic test bootstrap in [test/](/Users/gb/Projects/bDS2/test).
This is not yet the desktop application. It is the base runtime that future work will extend with the domain model, persistence layer, content pipelines, and desktop-facing boundaries.
## Spec Hygiene
When editing files in [specs/](/Users/gb/Projects/bDS2/specs):
- Keep user-visible workflows, content formats, and compatibility rules explicit.
- Keep behavior that affects imported data, generated output, or persisted content.
- Remove references to Rust, TypeScript, Electron, React, specific crates, specific packages, and other implementation choices unless the choice itself is a product requirement.
## Next Steps
1. Translate the core content and project specs into Ecto schemas and migrations.
2. Choose the desktop shell and boundary architecture for the Elixir application.
3. Add executable tests that map directly to the Allium specifications.

19
config/config.exs Normal file
View File

@@ -0,0 +1,19 @@
import Config
config :bds,
ecto_repos: [BDS.Repo]
config :bds, BDS.Repo,
database: Path.expand("../priv/data/bds_dev.db", __DIR__),
pool_size: 5,
stacktrace: true,
show_sensitive_data_on_connection_error: true
config :bds, BDS.Application,
desktop_adapter: :pending_selection
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
import_config "#{config_env()}.exs"

4
config/dev.exs Normal file
View File

@@ -0,0 +1,4 @@
import Config
config :bds, BDS.Repo,
pool_size: 5

11
config/runtime.exs Normal file
View File

@@ -0,0 +1,11 @@
import Config
if config_env() == :prod do
database_path =
System.get_env("BDS_DATABASE_PATH") ||
Path.expand("../priv/data/bds_prod.db", __DIR__)
config :bds, BDS.Repo,
database: database_path,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
end

8
config/test.exs Normal file
View File

@@ -0,0 +1,8 @@
import Config
config :bds, BDS.Repo,
database: Path.expand("../priv/data/bds_test.db", __DIR__),
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 5
config :logger, level: :warning

5
lib/bds.ex Normal file
View File

@@ -0,0 +1,5 @@
defmodule BDS do
@moduledoc """
Entry point for the bDS rewrite domain.
"""
end

15
lib/bds/application.ex Normal file
View File

@@ -0,0 +1,15 @@
defmodule BDS.Application do
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
BDS.Repo
]
opts = [strategy: :one_for_one, name: BDS.Supervisor]
Supervisor.start_link(children, opts)
end
end

5
lib/bds/repo.ex Normal file
View File

@@ -0,0 +1,5 @@
defmodule BDS.Repo do
use Ecto.Repo,
otp_app: :bds,
adapter: Ecto.Adapters.SQLite3
end

37
mix.exs Normal file
View File

@@ -0,0 +1,37 @@
defmodule BDS.MixProject do
use Mix.Project
def project do
[
app: :bds,
version: "0.1.0",
elixir: "~> 1.17",
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps()
]
end
def application do
[
extra_applications: [:logger],
mod: {BDS.Application, []}
]
end
defp deps do
[
{:ecto_sql, "~> 3.13"},
{:ecto_sqlite3, "~> 0.21"}
]
end
defp aliases do
[
setup: ["deps.get", "ecto.setup"],
"ecto.setup": ["ecto.create", "ecto.migrate"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"]
]
end
end

11
mix.lock Normal file
View File

@@ -0,0 +1,11 @@
%{
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
}

View File

@@ -0,0 +1 @@

View 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 ![](bds-media://id) 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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: ![alt](bds-media://id)
-- 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: ![](bds-media://id)
-- 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
}

View 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
}

View 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
View 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.
}

View 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
View 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)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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
View 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
View 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)
}

View 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
View 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 ![alt](bds-media://id) 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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
View 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
View 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

7
test/bds_test.exs Normal file
View File

@@ -0,0 +1,7 @@
defmodule BDSTest do
use ExUnit.Case, async: false
test "the repo module is configured" do
assert BDS.Repo.config()[:otp_app] == :bds
end
end

2
test/test_helper.exs Normal file
View File

@@ -0,0 +1,2 @@
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, :manual)