Initial commit: allium behavioural specs distilled from bDS TypeScript app
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
129
specs/ai.allium
Normal file
129
specs/ai.allium
Normal file
@@ -0,0 +1,129 @@
|
||||
-- allium: 1
|
||||
-- bDS AI Integration
|
||||
-- Distilled from: src/main/engine/ChatEngine.ts, ai/providers.ts,
|
||||
-- ai/chat.ts, ai/tasks.ts, SecureKeyStore.ts
|
||||
|
||||
use "./post.allium" as post
|
||||
use "./media.allium" as media
|
||||
|
||||
value AiProvider {
|
||||
kind: opencode_zen | mistral | ollama | lm_studio
|
||||
-- OpenCode Zen: routes claude* to Anthropic Messages API,
|
||||
-- everything else to OpenAI Chat Completions
|
||||
-- Mistral: native Mistral API
|
||||
-- Ollama: localhost:11434, local models
|
||||
-- LM Studio: localhost:1234, local models
|
||||
}
|
||||
|
||||
value AiModel {
|
||||
provider: AiProvider
|
||||
name: String
|
||||
modalities: Set<String> -- text, vision, etc.
|
||||
}
|
||||
|
||||
entity SecureKeyStore {
|
||||
-- Encrypts API keys using OS keychain
|
||||
-- macOS: Keychain, Windows: DPAPI, Linux: libsecret
|
||||
-- Stored as base64 in settings table with __encrypted_ prefix
|
||||
-- No plain-text fallback
|
||||
}
|
||||
|
||||
entity ChatConversation {
|
||||
title: String
|
||||
model: String -- default: claude-sonnet-4-5
|
||||
created_at: Timestamp
|
||||
updated_at: Timestamp
|
||||
|
||||
messages: ChatMessage with conversation = this
|
||||
}
|
||||
|
||||
entity ChatMessage {
|
||||
conversation: ChatConversation
|
||||
role: system | user | assistant | tool
|
||||
content: String
|
||||
token_usage_input: Integer?
|
||||
token_usage_output: Integer?
|
||||
created_at: Timestamp
|
||||
}
|
||||
|
||||
-- One-shot AI tasks (core scope, no streaming)
|
||||
|
||||
rule AnalyzeTaxonomy {
|
||||
when: AnalyzeTaxonomyRequested(post)
|
||||
requires: not airplane_mode
|
||||
-- All AI activities gated by offline mode
|
||||
-- Suggests tags and categories for a post
|
||||
ensures: TaxonomySuggestion(tags, categories)
|
||||
}
|
||||
|
||||
rule AnalyzeImage {
|
||||
when: AnalyzeImageRequested(media)
|
||||
requires: not airplane_mode
|
||||
requires: is_image(media.mime_type)
|
||||
-- Checks mime type is an image type
|
||||
-- Vision model generates alt text and caption
|
||||
ensures: ImageAnalysisResult(alt, caption)
|
||||
}
|
||||
|
||||
rule AnalyzePost {
|
||||
when: AnalyzePostRequested(post)
|
||||
requires: not airplane_mode
|
||||
-- Generates title, excerpt, slug suggestions
|
||||
ensures: PostAnalysisResult(title, excerpt, slug)
|
||||
}
|
||||
|
||||
rule DetectLanguage {
|
||||
when: DetectLanguageRequested(text)
|
||||
requires: not airplane_mode
|
||||
ensures: LanguageDetectionResult(language_code)
|
||||
}
|
||||
|
||||
rule TranslatePost {
|
||||
when: TranslatePostRequested(post, target_language)
|
||||
requires: not airplane_mode
|
||||
-- Translates title, excerpt, content to target language
|
||||
ensures: TranslationResult(title, excerpt, content)
|
||||
}
|
||||
|
||||
rule TranslateMedia {
|
||||
when: TranslateMediaRequested(media, target_language)
|
||||
requires: not airplane_mode
|
||||
-- Translates title, alt, caption to target language
|
||||
ensures: MediaTranslationResult(title, alt, caption)
|
||||
}
|
||||
|
||||
-- Chat (extension scope, with streaming and tool use)
|
||||
|
||||
rule StartChat {
|
||||
when: StartChatRequested(model)
|
||||
ensures: ChatConversation.created(model: model ?? "claude-sonnet-4-5")
|
||||
}
|
||||
|
||||
rule SendChatMessage {
|
||||
when: SendChatMessageRequested(conversation, content)
|
||||
requires: not airplane_mode
|
||||
ensures: ChatMessage.created(conversation: conversation, role: user, content: content)
|
||||
ensures: AiStreamingResponse(conversation)
|
||||
-- AI SDK v6 streamText() with tool-call loop (max 10 rounds)
|
||||
-- Blog data tools for post/media querying during chat
|
||||
-- Token usage tracking (input, output, cache read/write)
|
||||
}
|
||||
|
||||
-- Model catalog
|
||||
|
||||
rule RefreshModelCatalog {
|
||||
when: RefreshModelCatalogRequested(provider)
|
||||
-- 5-minute cache TTL
|
||||
ensures: ModelCatalogUpdated(provider)
|
||||
}
|
||||
|
||||
invariant AirplaneModeGating {
|
||||
-- All AI activities must be gated by airplane (offline) mode
|
||||
-- When offline: either use local model (Ollama/LM Studio) or
|
||||
-- inform the user via toast notification
|
||||
}
|
||||
|
||||
invariant SecureKeyStorage {
|
||||
-- API keys are never stored in plain text
|
||||
-- Always encrypted via OS keychain before DB storage
|
||||
}
|
||||
59
specs/bds.allium
Normal file
59
specs/bds.allium
Normal file
@@ -0,0 +1,59 @@
|
||||
-- allium: 1
|
||||
-- bDS (Blogging Desktop Server) — Axiom Specification
|
||||
-- Distilled from TypeScript implementation at ../bDS/
|
||||
-- This is the behavioural baseline for the Rust rewrite (RuDS)
|
||||
|
||||
-- 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)
|
||||
|
||||
-- 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 (from RUST_PLAN.md)
|
||||
--
|
||||
-- MUST stay identical:
|
||||
-- SQLite schema semantics, post markdown frontmatter,
|
||||
-- translation file naming, media sidecars, thumbnail conventions,
|
||||
-- template file formats, menu OPML, generated routes/feeds/sitemaps,
|
||||
-- FTS5 search behaviour, slug generation, metadata diff, rebuild-from-filesystem
|
||||
--
|
||||
-- MAY change intentionally:
|
||||
-- Implementation language (TS -> Rust), editor (WYSIWYG -> plain text + preview),
|
||||
-- scripting runtime (Python -> Lua), process model (Electron -> native),
|
||||
-- UI framework (React -> Iced)
|
||||
|
||||
-- Resolved questions:
|
||||
--
|
||||
-- 1. Slug generation scope: only German and English letters are used.
|
||||
-- Verify deunicode handles ä/ö/ü/ß/ÄÖÜ correctly against transliteration npm.
|
||||
--
|
||||
-- 2. Liquid subset: see template.allium for the exact subset.
|
||||
-- Only 5 tags, 4 standard filters, 2 custom filters, 5 operators.
|
||||
--
|
||||
-- 3. Macro calling convention: [[macroslug param1=value1 ...]]
|
||||
-- Double-bracket syntax, not Liquid tags. Identical to current app.
|
||||
59
specs/cli_sync.allium
Normal file
59
specs/cli_sync.allium
Normal file
@@ -0,0 +1,59 @@
|
||||
-- allium: 1
|
||||
-- bDS CLI / App Notification Sync
|
||||
-- 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
|
||||
}
|
||||
|
||||
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 SQLite DB file + WAL via filesystem watcher
|
||||
-- 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 Electron app uses a no-op notifier for its own writes
|
||||
-- It already knows about its own mutations
|
||||
-- Only CLI writes produce notification rows
|
||||
}
|
||||
92
specs/embedding.allium
Normal file
92
specs/embedding.allium
Normal file
@@ -0,0 +1,92 @@
|
||||
-- allium: 1
|
||||
-- bDS Semantic Similarity / Embeddings
|
||||
-- Distilled from: src/main/engine/EmbeddingEngine.ts
|
||||
|
||||
use "./post.allium" as post
|
||||
use "./tag.allium" as tag
|
||||
|
||||
value EmbeddingVector {
|
||||
dimensions: Integer -- 384 (multilingual-e5-small)
|
||||
values: List<Decimal>
|
||||
}
|
||||
|
||||
entity EmbeddingKey {
|
||||
label: Integer -- HNSW label for USearch
|
||||
post: post/Post
|
||||
content_hash: String -- Skip re-embedding unchanged posts
|
||||
vector: EmbeddingVector
|
||||
}
|
||||
|
||||
entity DismissedDuplicatePair {
|
||||
post_a: post/Post
|
||||
post_b: post/Post
|
||||
}
|
||||
|
||||
config {
|
||||
model: String = "multilingual-e5-small"
|
||||
embedding_dimensions: Integer = 384
|
||||
debounce_persist: Duration = 5.seconds
|
||||
}
|
||||
|
||||
rule ReindexAll {
|
||||
when: ReindexAllRequested(project)
|
||||
-- Re-embeds all posts, rebuilds HNSW index
|
||||
for p in project.posts:
|
||||
ensures: EmbeddingKeyUpdated(p)
|
||||
ensures: HnswIndexRebuilt(project)
|
||||
}
|
||||
|
||||
rule IndexUnindexed {
|
||||
when: IndexUnindexedRequested(project)
|
||||
-- Only embeds posts without existing embeddings or with changed content_hash
|
||||
for p in project.posts:
|
||||
let existing = EmbeddingKey{post: p}
|
||||
if not exists existing or existing.content_hash != p.checksum:
|
||||
ensures: EmbeddingKeyUpdated(p)
|
||||
}
|
||||
|
||||
rule FindSimilar {
|
||||
when: FindSimilarRequested(post, limit)
|
||||
-- HNSW vector search via USearch
|
||||
-- Returns ranked list of similar posts with similarity scores
|
||||
ensures: SimilarPostsResult(post, ranked_matches)
|
||||
}
|
||||
|
||||
rule SuggestTags {
|
||||
when: SuggestTagsRequested(post)
|
||||
-- Uses semantic similarity to find related posts,
|
||||
-- then aggregates their tags as suggestions
|
||||
ensures: TagSuggestionResult(post, suggested_tags)
|
||||
}
|
||||
|
||||
rule FindDuplicates {
|
||||
when: FindDuplicatesRequested(project)
|
||||
-- Finds near-duplicate post pairs above similarity threshold
|
||||
-- Includes exact-match detection
|
||||
-- Excludes dismissed pairs
|
||||
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)
|
||||
ensures: DismissedDuplicatePair.created(post_a: post_a, post_b: post_b)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
invariant VectorCacheInDb {
|
||||
-- Vector cache persisted as BLOB in embedding_keys table
|
||||
-- Float32Array, 384 dimensions per vector
|
||||
-- Enables instant reload without re-embedding
|
||||
}
|
||||
146
specs/generation.allium
Normal file
146
specs/generation.allium
Normal file
@@ -0,0 +1,146 @@
|
||||
-- allium: 1
|
||||
-- bDS Static Site Generation
|
||||
-- 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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
-- 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)
|
||||
-- LiquidJS 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, report)
|
||||
-- Targeted re-rendering for affected sections only
|
||||
for section in report.affected_sections:
|
||||
ensures: GenerateSiteRequested(project, sections: {section})
|
||||
}
|
||||
|
||||
-- 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
|
||||
}
|
||||
125
specs/git.allium
Normal file
125
specs/git.allium
Normal file
@@ -0,0 +1,125 @@
|
||||
-- allium: 1
|
||||
-- bDS Git Integration
|
||||
-- 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
|
||||
}
|
||||
|
||||
entity GitRepository {
|
||||
is_initialized: Boolean
|
||||
remote_url: String?
|
||||
provider: GitProvider?
|
||||
current_branch: String?
|
||||
has_lfs: Boolean
|
||||
}
|
||||
|
||||
rule InitializeRepo {
|
||||
when: InitializeRepoRequested(project)
|
||||
ensures: GitRepository.created(is_initialized: true, has_lfs: true)
|
||||
ensures: GitignoreCreated(project)
|
||||
-- .gitignore manages: thumbnails, generated html, node_modules, 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: ReconcileFromGit(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)
|
||||
}
|
||||
52
specs/i18n.allium
Normal file
52
specs/i18n.allium
Normal file
@@ -0,0 +1,52 @@
|
||||
-- allium: 1
|
||||
-- bDS Internationalization
|
||||
-- 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 render locale, not UI locale
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
256
specs/mcp.allium
Normal file
256
specs/mcp.allium
Normal file
@@ -0,0 +1,256 @@
|
||||
-- allium: 1
|
||||
-- bDS MCP Server (Model Context Protocol)
|
||||
-- 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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
-- Derived
|
||||
is_expired: expires_at <= now
|
||||
|
||||
transitions status {
|
||||
pending -> accepted
|
||||
pending -> discarded
|
||||
pending -> expired
|
||||
}
|
||||
}
|
||||
|
||||
config {
|
||||
http_port: Integer = 4124
|
||||
proposal_ttl_app: Duration = 30.minutes
|
||||
proposal_ttl_cli: Duration = 8.hours
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
Proposal.created(kind: draft_post, entity_id: new_post.id, 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
|
||||
)
|
||||
Proposal.created(kind: propose_script, entity_id: new_script.id, 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
|
||||
)
|
||||
Proposal.created(kind: propose_template, entity_id: new_template.id, status: pending)
|
||||
}
|
||||
|
||||
rule ProposeMediaMetadata {
|
||||
when: McpToolInvoked("propose_media_metadata", params)
|
||||
ensures: Proposal.created(kind: propose_media_metadata, entity_id: params.media_id, data: params, status: pending)
|
||||
}
|
||||
|
||||
rule ProposePostMetadata {
|
||||
when: McpToolInvoked("propose_post_metadata", params)
|
||||
ensures: Proposal.created(kind: propose_post_metadata, entity_id: params.post_id, data: params, status: pending)
|
||||
}
|
||||
|
||||
-- Proposal lifecycle
|
||||
|
||||
rule AcceptProposal {
|
||||
when: AcceptProposalRequested(proposal)
|
||||
requires: not proposal.is_expired
|
||||
ensures:
|
||||
if proposal.kind = draft_post:
|
||||
post/PublishPostRequested(proposal.entity_id)
|
||||
if proposal.kind = propose_script:
|
||||
script/PublishScriptRequested(proposal.entity_id)
|
||||
if proposal.kind = propose_template:
|
||||
template/PublishTemplateRequested(proposal.entity_id)
|
||||
if proposal.kind = propose_media_metadata:
|
||||
media/UpdateMediaRequested(proposal.entity_id, proposal.data)
|
||||
if proposal.kind = propose_post_metadata:
|
||||
post/UpdatePostRequested(proposal.entity_id, proposal.data)
|
||||
not exists proposal
|
||||
}
|
||||
|
||||
rule DiscardProposal {
|
||||
when: DiscardProposalRequested(proposal)
|
||||
ensures:
|
||||
if proposal.kind = draft_post:
|
||||
post/DeletePostRequested(proposal.entity_id)
|
||||
if proposal.kind = propose_script:
|
||||
script/DeleteScriptRequested(proposal.entity_id)
|
||||
if proposal.kind = propose_template:
|
||||
template/DeleteTemplateRequested(proposal.entity_id)
|
||||
not exists proposal
|
||||
}
|
||||
|
||||
rule ExpireProposal {
|
||||
when: proposal: Proposal.is_expired becomes true
|
||||
-- On expiry: clean up draft DB rows
|
||||
ensures: DiscardProposalRequested(proposal)
|
||||
}
|
||||
|
||||
-- Agent configuration
|
||||
|
||||
value McpAgentKind {
|
||||
-- Supported: claude_code, claude_desktop, github_copilot,
|
||||
-- gemini_cli, opencode, mistral_vibe, openai_codex
|
||||
kind: String
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
148
specs/media.allium
Normal file
148
specs/media.allium
Normal file
@@ -0,0 +1,148 @@
|
||||
-- allium: 1
|
||||
-- bDS Media Lifecycle
|
||||
-- Distilled from: src/main/engine/MediaEngine.ts, schema.ts
|
||||
|
||||
use "./project.allium" as 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
|
||||
}
|
||||
|
||||
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: Set<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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
39
specs/menu.allium
Normal file
39
specs/menu.allium
Normal file
@@ -0,0 +1,39 @@
|
||||
-- allium: 1
|
||||
-- bDS Navigation Menu
|
||||
-- Distilled from: src/main/engine/MenuEngine.ts
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
102
specs/metadata.allium
Normal file
102
specs/metadata.allium
Normal file
@@ -0,0 +1,102 @@
|
||||
-- allium: 1
|
||||
-- bDS Project Metadata, Categories, Publishing Preferences
|
||||
-- Distilled from: src/main/engine/MetaEngine.ts, schema.ts
|
||||
|
||||
use "./project.allium" as project
|
||||
|
||||
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)
|
||||
}
|
||||
72
specs/metadata_diff.allium
Normal file
72
specs/metadata_diff.allium
Normal file
@@ -0,0 +1,72 @@
|
||||
-- allium: 1
|
||||
-- bDS Metadata Diff and Rebuild
|
||||
-- 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
|
||||
|
||||
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)
|
||||
}
|
||||
189
specs/post.allium
Normal file
189
specs/post.allium
Normal file
@@ -0,0 +1,189 @@
|
||||
-- allium: 1
|
||||
-- bDS Post Lifecycle
|
||||
-- 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 deunicode handles this set correctly against transliteration npm.
|
||||
-- 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
|
||||
}
|
||||
|
||||
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: Set<String>
|
||||
categories: Set<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: 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: ""
|
||||
)
|
||||
ensures: SearchIndexUpdated(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 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)
|
||||
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 freezing behaviour matches TypeScript app:
|
||||
-- slug changes allowed on draft posts even if previously published,
|
||||
-- frozen only while status = published (is_slug_frozen = published_at != null
|
||||
-- is the guard, but status must also be published for the file to exist)
|
||||
86
specs/preview.allium
Normal file
86
specs/preview.allium
Normal file
@@ -0,0 +1,86 @@
|
||||
-- allium: 1
|
||||
-- bDS Local Preview Server
|
||||
-- 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
|
||||
}
|
||||
|
||||
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 LocalhostOnly {
|
||||
-- Preview server binds to 127.0.0.1 only, never 0.0.0.0
|
||||
}
|
||||
73
specs/project.allium
Normal file
73
specs/project.allium
Normal file
@@ -0,0 +1,73 @@
|
||||
-- allium: 1
|
||||
-- bDS Project Management
|
||||
-- Distilled from: src/main/engine/ProjectEngine.ts, schema.ts
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 != default_project
|
||||
-- The default 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_name: String = "My Blog"
|
||||
}
|
||||
62
specs/publishing.allium
Normal file
62
specs/publishing.allium
Normal file
@@ -0,0 +1,62 @@
|
||||
-- allium: 1
|
||||
-- bDS SSH Publishing
|
||||
-- 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
|
||||
}
|
||||
|
||||
rule UploadSite {
|
||||
when: UploadSiteRequested(project, credentials)
|
||||
-- Three upload targets run as parallel tasks
|
||||
ensures: UploadTargetStarted(html, "html/", credentials.ssh_remote_path, credentials)
|
||||
ensures: UploadTargetStarted(thumbnails, "thumbnails/", credentials.ssh_remote_path + "/thumbnails", credentials)
|
||||
ensures: UploadTargetStarted(media, "media/", credentials.ssh_remote_path + "/media", credentials)
|
||||
}
|
||||
|
||||
rule UploadViaScp {
|
||||
when: UploadTargetCompleted(target, credentials)
|
||||
requires: credentials.ssh_mode = scp
|
||||
-- mtime-based upload detection: skip unchanged files
|
||||
-- Uses SSH agent (SSH_AUTH_SOCK) for authentication
|
||||
ensures: ScpUploadCompleted(target)
|
||||
}
|
||||
|
||||
rule UploadViaRsync {
|
||||
when: UploadTargetCompleted(target, credentials)
|
||||
requires: credentials.ssh_mode = rsync
|
||||
-- rsync --update --compress --verbose
|
||||
-- Media uploads exclude .meta sidecar files
|
||||
ensures: RsyncUploadCompleted(target)
|
||||
|
||||
@guidance
|
||||
-- rsync exclude filters for .meta files on media target
|
||||
}
|
||||
|
||||
invariant MediaSidecarsExcludedFromUpload {
|
||||
-- .meta sidecar files are never uploaded to the remote server
|
||||
-- They are project metadata, not public content
|
||||
}
|
||||
|
||||
invariant SshAgentAuth {
|
||||
-- Publishing uses SSH_AUTH_SOCK for key-based authentication
|
||||
-- No password prompts, no interactive auth
|
||||
}
|
||||
112
specs/script.allium
Normal file
112
specs/script.allium
Normal file
@@ -0,0 +1,112 @@
|
||||
-- allium: 1
|
||||
-- bDS Scripting System
|
||||
-- Distilled from: src/main/engine/ScriptEngine.ts, schema.ts
|
||||
-- Note: TypeScript app uses Python/Pyodide. Rust rewrite uses Lua.
|
||||
-- Behavioural contract is identical; only runtime changes.
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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}.lua", slug: s.slug)
|
||||
}
|
||||
-- TypeScript app uses .py with triple-quote docstring frontmatter
|
||||
-- Rust app uses .lua with standard --- frontmatter
|
||||
|
||||
rule CreateScript {
|
||||
when: CreateScriptRequested(title, kind, content, entrypoint)
|
||||
let slug = slugify(title)
|
||||
ensures: Script.created(
|
||||
slug: slug,
|
||||
title: title,
|
||||
kind: kind,
|
||||
content: content,
|
||||
entrypoint: entrypoint ?? "render",
|
||||
status: draft,
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: ""
|
||||
)
|
||||
}
|
||||
|
||||
rule PublishScript {
|
||||
when: PublishScriptRequested(script)
|
||||
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", "*.lua"):
|
||||
let parsed = parse_script_file(file)
|
||||
ensures: Script.created(parsed)
|
||||
}
|
||||
94
specs/search.allium
Normal file
94
specs/search.allium
Normal file
@@ -0,0 +1,94 @@
|
||||
-- allium: 1
|
||||
-- bDS Full-Text Search
|
||||
-- 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
|
||||
|
||||
value StemmerLanguage {
|
||||
-- Snowball stemmers for 24 languages
|
||||
-- ISO 639-1 to Snowball mapping
|
||||
-- Applied to both indexing and query processing
|
||||
code: String
|
||||
}
|
||||
|
||||
entity PostSearchIndex {
|
||||
-- SQLite FTS5 virtual table
|
||||
-- Indexed fields: title, excerpt, content, tags, categories
|
||||
-- Plus all translation titles, excerpts, and content
|
||||
post: post/Post
|
||||
stemmed_content: String
|
||||
}
|
||||
|
||||
entity MediaSearchIndex {
|
||||
-- SQLite FTS5 virtual table
|
||||
-- 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))
|
||||
}
|
||||
94
specs/tag.allium
Normal file
94
specs/tag.allium
Normal file
@@ -0,0 +1,94 @@
|
||||
-- allium: 1
|
||||
-- bDS Tag System
|
||||
-- Distilled from: src/main/engine/TagEngine.ts, schema.ts
|
||||
|
||||
use "./project.allium" as project
|
||||
use "./post.allium" as post
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
86
specs/task.allium
Normal file
86
specs/task.allium
Normal file
@@ -0,0 +1,86 @@
|
||||
-- allium: 1
|
||||
-- bDS Background Task Manager
|
||||
-- Distilled from: src/main/engine/TaskManager.ts
|
||||
|
||||
entity Task {
|
||||
title: String
|
||||
status: pending | running | completed | failed | cancelled
|
||||
progress: Decimal? -- 0.0..1.0
|
||||
message: String?
|
||||
created_at: Timestamp
|
||||
|
||||
transitions status {
|
||||
pending -> running
|
||||
running -> completed
|
||||
running -> failed
|
||||
running -> cancelled
|
||||
}
|
||||
}
|
||||
|
||||
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(title, work)
|
||||
let running_tasks = Tasks where status = running
|
||||
ensures: Task.created(title: title, status: pending)
|
||||
ensures:
|
||||
if running_tasks.count < config.max_concurrent:
|
||||
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
|
||||
-- AbortController-based cancellation
|
||||
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(title)
|
||||
ensures: Task.created(title: title, status: running)
|
||||
@guidance
|
||||
-- External tasks are not managed by the queue
|
||||
-- The caller is responsible for updating status
|
||||
}
|
||||
167
specs/template.allium
Normal file
167
specs/template.allium
Normal file
@@ -0,0 +1,167 @@
|
||||
-- allium: 1
|
||||
-- bDS Liquid Template System
|
||||
-- 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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
ensures: Template.created(
|
||||
slug: slug,
|
||||
title: title,
|
||||
kind: kind,
|
||||
content: content,
|
||||
status: draft,
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: ""
|
||||
)
|
||||
}
|
||||
|
||||
rule UpdateTemplate {
|
||||
when: UpdateTemplateRequested(template, changes)
|
||||
ensures: TemplateFieldsUpdated(template, changes)
|
||||
ensures: template.updated_at = now
|
||||
ensures: template.version = template.version + 1
|
||||
}
|
||||
|
||||
rule PublishTemplate {
|
||||
when: PublishTemplateRequested(template)
|
||||
requires: ValidateLiquid(template.content) = valid
|
||||
-- LiquidJS 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
|
||||
}
|
||||
111
specs/translation.allium
Normal file
111
specs/translation.allium
Normal file
@@ -0,0 +1,111 @@
|
||||
-- allium: 1
|
||||
-- bDS Translation System
|
||||
-- Distilled from: src/main/engine/PostEngine.ts (translation methods),
|
||||
-- postTranslationFileUtils.ts, MediaEngine.ts
|
||||
|
||||
use "./post.allium" as post
|
||||
use "./media.allium" as media
|
||||
|
||||
value SupportedLanguage {
|
||||
-- en, de, fr, it, es
|
||||
code: String
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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: PostTranslation.created(
|
||||
canonical_post: post,
|
||||
language: language,
|
||||
title: title,
|
||||
content: content,
|
||||
excerpt: excerpt,
|
||||
status: draft,
|
||||
file_path: ""
|
||||
)
|
||||
-- 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: t.status = published
|
||||
ensures: t.published_at = t.published_at ?? now
|
||||
ensures: TranslationFileWritten(t)
|
||||
ensures: t.content = null
|
||||
-- Content moves to filesystem
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user