commit aec533b54f7efab343352f8f90f58f7ac4963095 Author: Chili Palmer Date: Thu Apr 2 18:36:20 2026 +0200 Initial commit: allium behavioural specs distilled from bDS TypeScript app Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..000ab0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Rust +/target/ +**/*.rs.bk +*.pdb + +# Build artifacts +*.d +*.o +*.so +*.dylib +*.dll +*.a +*.lib + +# Cargo.lock for binaries (keep it committed for applications) +# Uncomment if this is a library: +# Cargo.lock + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Environment +.env +.env.* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..79a8467 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,44 @@ +# Agents Instructions for Blogging Desktop Server (bDS) + +This is the Rust rewrite of an existing project bDS written in Typescript and living in ../bDS - if +in doubt about behaviour, look at the original code to verify. + +This project has an allium spec in the folder spec/ - use it to verify behaviour against expected behaviour. It is based on the typescript implementation. + +Invariants and behaviours in the allium spec should be covered by unit tests of the application code, to make sure the spec is followed. + +## Plan Mode + +- Make the plan extremely concise. Sacrifice grammar for the sake of concision. +- At the end of each plan, give me a list of unresolved questions to answer, if any. + +## Commits + +- our default branch is origin/master +- commit messages are short - one sentence. do not write long articles. +- pull requests are more verbose and especially give reasoning for changes + +## Important facts + +- published posts don't have body in the database, the body content is only in the file +- functionality you implement have to be tied to UI +- UI you implement has to be tied to functionality +- you must use proper tools to generate migrations and snapshots, don't hack SQL +- we use an sqlite database. use sqlite semantics in snapshots and other artifacts +- on MacOS we use native menus and you have to hook them into the intercept for new menu items +- there are two areas of localization, you sometimes need both (menus for example) +- all automatic AI activities must be gated by airplane (offline) mode of the app and either use the local model or inform the user via toast +- metadata needs to be flushed to the filesystem and needs to be included in metadata diff tool and in rebuild from filesystem. All three aspects have to be in sync with each other. +- if you add new metadata, add them to publishing, metadata-diff and rebuild-from-database + +## important behaviour + +- HEREDOCs don't work most of the time. Don't use them. Use editor tools to create proper scripots +- use red/green TDD for new implementations +- there are no "pre-existing" problems - you own every problem, you fix every problem +- don't leave unused code in the codebase, remove it instead +- after implementing / changing things, run the build and run tests to verify all works +- do not reference external JavaScript or CSS on CDNs, always bring it into the project +- do not embedd CSS/JavaScript into HTML, always reference .css and .js files in the project assets +- always make sure you follow proper i18n best practices. no untranslated string constants. + diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..a1df697 --- /dev/null +++ b/BUILD.md @@ -0,0 +1,113 @@ +# RuDS — Build Prerequisites + +## macOS system requirements + +- macOS 13 (Ventura) or later +- Apple Silicon or Intel Mac with Metal or Vulkan support (required by Iced's wgpu backend) + +## Linux system requirements (optional, for CI or cross-platform development) + +- A Vulkan-capable GPU and driver, or software rendering via `WGPU_BACKEND=gl` +- System packages for GTK and related libraries (for muda and rfd): + +```sh +# Debian/Ubuntu +sudo apt install build-essential cmake pkg-config libgtk-3-dev libxdo-dev libdbus-1-dev +``` + +## Install Homebrew packages (macOS) + +```sh +brew install rust cmake pkg-config +``` + +| Package | Why | +|---|---| +| `rust` | Rust toolchain (alternatively install via `rustup`, see below) | +| `cmake` | Required by some native dependencies during `cargo build` | +| `pkg-config` | Locates system libraries during `cargo build` | + +## Install Xcode Command Line Tools (macOS) + +```sh +xcode-select --install +``` + +Required for the macOS SDK, Metal framework headers, and the Apple linker. A full Xcode install also works but is not required. + +## Recommended: install Rust via rustup + +If you prefer managing Rust versions explicitly (recommended for pinning toolchain versions across the team): + +```sh +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +Then set the stable toolchain: + +```sh +rustup default stable +``` + +## Additional dependencies by milestone + +### M0–M4 (core through rendering) + +No additional system packages beyond the above. Key crates use bundled/vendored native code: + +- `rusqlite` with `bundled` feature compiles SQLite from source +- `refinery` manages SQL migrations against rusqlite +- `iced` uses wgpu which links to Metal (macOS) or Vulkan (Linux/Windows) at runtime +- `muda` uses native platform menu APIs (NSMenu on macOS, GTK on Linux, Win32 on Windows) +- `rfd` uses native platform dialog APIs (NSOpenPanel on macOS, GTK on Linux, Win32 on Windows) +- `cosmic-text` bundles its own font shaping; uses system font discovery via `fontdb` +- `syntect` bundles syntax definitions; no system dependency +- `ropey` is pure Rust; no system dependency +- `image` crate is pure Rust for most codecs; no system dependency for JPEG/PNG/WEBP +- `pulldown-cmark`, `liquid`, `quick-xml`, `rayon` are pure Rust; no system dependencies +- `axum` + `tokio` are pure Rust; no system dependencies +- `ssh2` links to libssh2 (bundled via `ssh2` crate's default features) +- `objc2` / `objc2-app-kit` (macOS only, cfg-gated) links to system AppKit frameworks already available via Xcode CLI tools + +### M5–M6 (Lua scripting) + +When Lua support is added via the `mlua` crate: + +```sh +brew install lua@5.4 +``` + +Alternatively, use the `vendored` feature flag on `mlua` to compile Lua 5.4 from source and skip the system install entirely. The choice should be made when Wave 6 starts. + +### Publishing (SSH/rsync) + +The publish engine uses SSH and rsync. Both ship with macOS by default. No Homebrew install needed unless you want a newer rsync: + +```sh +brew install rsync # optional, for a newer version than the macOS default +``` + +## Verify the setup + +After installing prerequisites: + +```sh +# check Rust toolchain +rustc --version +cargo --version + +# check native tooling +cmake --version +pkg-config --version +xcode-select -p # macOS only + +# clone and build +cargo build +``` + +## Environment notes + +- The project is a Cargo workspace. Always run `cargo` commands from the repository root. +- Iced requires a GPU context (Metal, Vulkan, or OpenGL fallback). CI runners must support one of these or use headless test targets that do not create windows. +- The `bds-core` and `bds-cli` crates do not depend on Iced, muda, or rfd and can be built and tested without a display server. +- The `bds-editor` crate depends on Iced (for the custom widget trait) but its buffer, highlighting, and layout logic can be unit tested without a display server. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/RUST_COMPATIBILITY_MATRIX_TEMPLATE.md b/RUST_COMPATIBILITY_MATRIX_TEMPLATE.md new file mode 100644 index 0000000..cbc9697 --- /dev/null +++ b/RUST_COMPATIBILITY_MATRIX_TEMPLATE.md @@ -0,0 +1,98 @@ +# bDS Rust Rewrite — Compatibility Matrix Template + +## Purpose + +This document is the required inventory of persisted fields and compatibility-sensitive behaviors. Fill it before engine implementation starts, then keep it current as the rewrite proceeds. + +One row per persisted field or compatibility-sensitive artifact. + +## How To Use + +1. Start with posts, translations, media, templates, project metadata, menus, and generated outputs. +2. Record the current TypeScript behavior first. +3. Record the intended Rust behavior only if it is identical or explicitly approved as a divergence. +4. Link every row to tests or golden fixtures once available. + +## Field Matrix + +| Domain | Entity | Field / Artifact | Type | Current Source Of Truth | Persisted In | Read Path | Write Path | Rebuild From Files | Metadata Diff | Publish Impact | Generation Impact | Rust Status | Fixture / Test | Notes | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| posts | Post | id | string | DB + frontmatter | DB, frontmatter | | | | | | | not-started | | | +| posts | Post | title | string | | | | | | | | | not-started | | | +| posts | Post | slug | string | | | | | | | | | not-started | | | +| posts | Post | excerpt | string? | | | | | | | | | not-started | | | +| posts | Post | content body | markdown | published body in file; draft body per current app rules | DB?, markdown file | | | | | | | not-started | | | +| posts | Post | status | enum | | | | | | | | | not-started | | | +| posts | Post | author | string? | | | | | | | | | not-started | | | +| posts | Post | language | string? | | | | | | | | | not-started | | | +| posts | Post | do_not_translate | bool | | | | | | | | | not-started | | | +| posts | Post | tags | list | | | | | | | | | not-started | | | +| posts | Post | categories | list | | | | | | | | | not-started | | | +| posts | Post | template_slug | string? | | | | | | | | | not-started | | | +| posts | Post | checksum | string? | | | | | | | | | not-started | | | +| translations | PostTranslation | translation_for | string | | | | | | | | | not-started | | | +| media | Media | alt | string? | | | | | | | | | not-started | | | +| media | Media | caption | string? | | | | | | | | | not-started | | | +| media | MediaTranslation | translationFor | string | | DB | | | | | | | not-started | | | +| media | MediaTranslation | language | string | | DB | | | | | | | not-started | | | +| media | MediaTranslation | title | string? | | DB | | | | | | | not-started | | | +| media | MediaTranslation | alt | string? | | DB | | | | | | | not-started | | | +| media | MediaTranslation | caption | string? | | DB | | | | | | | not-started | | | +| posts | PostLink | sourcePostId | string | | DB | | | | | | | not-started | | | +| posts | PostLink | targetPostId | string | | DB | | | | | | | not-started | | | +| posts | PostLink | linkText | string? | | DB | | | | | | | not-started | | | +| templates | Template | file content | liquid | | | | | | | | | not-started | | | +| scripts | Script | runtime | runtime | Python in current app, Lua in Rust app | app behavior | | | n/a | n/a | yes | yes | approved-divergence | | Explicit break | +| scripts | BlogmarkTransform | transform chain | runtime | Python in current app | app behavior | | | n/a | n/a | no | no | deferred | | Deferred to extension Bucket H | +| menus | MenuDocument | source document | opml/xml | | | | | | | | | not-started | | | +| generation | SiteOutput | route path | path | | generated files | | | n/a | n/a | yes | yes | not-started | | | +| generation | Feed | rss/atom output | xml | | generated files | | | n/a | n/a | yes | yes | not-started | | | +| generation | Sitemap | sitemap output | xml | | generated files | | | n/a | n/a | yes | yes | not-started | | | +| generation | GeneratedFileHash | content hash | string | DB | DB | | | n/a | n/a | yes | yes | not-started | | Skip-unchanged-writes optimization | +| search | PostsFts | FTS5 index | virtual table | DB | DB (FTS5) | | | rebuild | n/a | no | no | not-started | | | +| search | MediaFts | FTS5 index | virtual table | DB | DB (FTS5) | | | rebuild | n/a | no | no | not-started | | | +| search | ClientSearch | search index | binary | generated files | generated files | | | n/a | n/a | yes | yes | not-started | | Determine in M0: integrate via `pagefind` crate library API | +| media | Thumbnail | generated thumbnail | image file | filesystem | filesystem | | | regenerate | n/a | yes | yes | not-started | | `image` crate; verify size/format matches current `sharp` output | +| macros | BuiltInMacro | gallery | Rust-native | app code | n/a | | | n/a | n/a | no | yes | not-started | | Was JS in TS app; Rust in Rust app | +| macros | BuiltInMacro | youtube | Rust-native | app code | n/a | | | n/a | n/a | no | yes | not-started | | Was JS in TS app; Rust in Rust app | +| macros | BuiltInMacro | vimeo | Rust-native | app code | n/a | | | n/a | n/a | no | yes | not-started | | Was JS in TS app; Rust in Rust app | +| macros | BuiltInMacro | photo_archive | Rust-native | app code | n/a | | | n/a | n/a | no | yes | not-started | | Was JS in TS app; Rust in Rust app | +| macros | BuiltInMacro | tag_cloud | Rust-native | app code | n/a | | | n/a | n/a | no | yes | not-started | | Was JS in TS app; Rust in Rust app | + +## Behavior Matrix + +Use this for compatibility-sensitive behaviors that are not a single persisted field. + +| Area | Behavior | Current App Rule | Rust Target Rule | Allowed Divergence | Validation Method | Status | Notes | +|---|---|---|---|---|---|---|---| +| posts | published body storage | body not stored in DB when published | identical | no | fixture DB + file assertions | not-started | | +| localization | UI locale | follows OS locale | identical | no | manual + integration test | not-started | | +| localization | render language | follows project setting | identical | no | preview/generation tests | not-started | | +| menus | native menu routing | current app uses native menus | native menus via muda (cross-platform: NSMenu, Win32, GTK) | implementation changes, behavior identical | menu integration tests | not-started | | +| scripts | user Python scripts | supported in current app | unsupported in Rust app | yes | migration note + runtime detection tests | approved-divergence | | +| rendering | built-in macros | JS server-side (gallery, youtube, vimeo, photo_archive, tag_cloud) | Rust-native in bds-core/render | implementation language changes, output must match | golden-generated-site comparisons | not-started | These are NOT Python macros | +| rendering | Liquid feature subset | liquidjs 10.25 (full spec available) | Rust Liquid (scoped to used subset) | implementation may differ for unused features | template compatibility suite | not-started | Only ~35% of spec used by default templates | +| slugs | slug generation | `transliteration` npm package | `deunicode` Rust crate | possible edge-case differences | slug corpus tests in M0 fixtures | not-started | Verify against real content | +| editor | content editing | Milkdown WYSIWYG (default) | plain-text syntax-highlighting editor (bds-editor: ropey + syntect + cosmic-text) + live preview | yes | n/a | approved-divergence | Rich editor deferred to extension Bucket I, builds on bds-editor foundation | +| preview | asset sourcing | local assets only | identical | no | HTML assertions | not-started | | +| runtime | JS dependency | Electron (Chromium + Node.js) | no JavaScript anywhere — pure Rust + native APIs | yes (intentional) | build verification: no JS in dependency tree | approved-divergence | Supply-chain security constraint | +| runtime | async executor | Node.js event loop | tokio | yes (internal) | n/a | approved-divergence | Used for preview server, publish, file watching | +| media | thumbnail generation | `sharp` (libvips) | `image` crate | output dimensions and format must match | golden-file comparison of thumbnails | not-started | Fallback to `libvips-rs` if quality/perf insufficient | +| generation | client-side search | determine in M0 | `pagefind` crate library API (`PagefindIndex`) | output must match | golden-generated-site comparisons | not-started | Rust library dep, no CLI binary | +| generation | parallel rendering | single-threaded in current app | `rayon` for parallel page rendering | yes (faster, same output) | golden-generated-site comparisons | approved-divergence | Output must be identical regardless of parallelism | +| ai | one-shot AI operations | not in current app | `reqwest` against configurable OpenAI-compatible endpoint | yes (new feature) | mocked endpoint tests | approved-divergence | Translation, alt text, title suggestion. Entirely optional — app works without endpoint configured | + +## Status Values + +- `not-started` +- `in-progress` +- `verified` +- `approved-divergence` +- `blocked` + +## Sign-Off Checklist + +- every persisted field used by core is represented +- every approved divergence is explicit +- every row links to a fixture or test before release +- metadata diff, rebuild, and publish implications are filled in for all relevant rows \ No newline at end of file diff --git a/RUST_EXECUTION_BACKLOG.md b/RUST_EXECUTION_BACKLOG.md new file mode 100644 index 0000000..6565fe8 --- /dev/null +++ b/RUST_EXECUTION_BACKLOG.md @@ -0,0 +1,378 @@ +# bDS Rust Rewrite — Execution Backlog + +## Purpose + +This document converts the core and extension plans into execution order. It is organized by milestone first and by crate second. + +Rules: + +1. Do not pull work forward from a later milestone unless it directly unblocks the current milestone. +2. Keep tasks vertically sliced where possible: engine + UI + tests. +3. Compatibility tasks outrank polish tasks. + +## Milestone M0: Compatibility Baseline + +### `bds-core` + +- create workspace and crate boundaries (bds-core, bds-editor, bds-ui, bds-cli) +- add SQLite connection management via `rusqlite` (bundled, vtab features) +- add migration loader via `refinery` +- define initial shared model modules with `serde` derives +- add checksum (`sha2`) and slug (`deunicode`) utilities +- establish error handling conventions: `thiserror` for bds-core, `anyhow` for bds-ui/bds-cli +- add `tokio` runtime as workspace dependency (used by bds-ui and bds-cli, not directly by bds-core engine code) + +### `bds-editor` + +- create crate with ropey, syntect, cosmic-text dependencies +- implement basic rope buffer wrapper with edit operations +- implement syntect integration for markdown highlighting +- implement cosmic-text layout for rendering highlighted text +- implement basic cursor model (position, move, place by click) +- implement basic text insertion and deletion +- implement Iced custom widget that composes buffer + highlight + layout + cursor +- implement basic vertical scrolling with viewport-aware rendering +- verify IME composition events work through winit (early risk check) + +### `bds-ui` + +- create app entry point with Iced `Application` impl +- wire Iced window creation +- wire muda menu bar with skeleton App/File/Edit/View/Window/Help menus +- wire macOS lifecycle shim via objc2 (application:openFile:, application:openURLs:) behind cfg gate +- wire muda `MenuEvent` receiver as Iced `Subscription` + +### `fixtures` and `docs` + +- collect representative fixture projects from current app +- capture golden generated output for those fixtures +- create initial compatibility inventory from the matrix template (must include `mediaTranslations`, `postLinks`, FTS5 tables) +- create Liquid feature inventory from default templates (12 files in `src/main/engine/templates/`) +- determine whether current app uses Pagefind or another client-side search index — if Pagefind, plan for `pagefind` crate library integration (not CLI binary) +- create slug compatibility test suite comparing `deunicode` vs `transliteration` output on fixture content +- create Iced architecture patterns document (message design, subscription model, custom widget patterns) + +### Validation + +- DB readability tests (all tables including `mediaTranslations`, `postLinks`, FTS5, and AI/catalog tables) +- app launch smoke test (Iced window + muda menus) +- bds-editor PoC test: renders highlighted markdown, accepts keyboard input, cursor moves +- fixture harness test +- slug compatibility tests + +## Milestone M1: Data Fidelity + +### `bds-core/db` + +- verify schema compatibility against existing projects +- add FTS5 virtual tables (`posts_fts`, `media_fts`) for in-app full-text search +- verify `mediaTranslations` table read/write +- verify `postLinks` table read/write + +### `bds-core/engine` + +- implement `ProjectEngine` +- implement `PostEngine` +- implement `MediaEngine` +- implement `PostMediaEngine` +- implement `TagEngine` +- implement `MetaEngine` +- implement metadata diff flow (posts, media, scripts, templates) +- implement rebuild-from-filesystem flow + +### `bds-core/util` + +- frontmatter parser/writer via `serde_yaml` +- translation file parser/writer +- sidecar parser/writer +- content hashing support via `sha2` +- thumbnail generation via `image` crate (resize, WEBP encoding) +- recursive directory traversal via `walkdir` (for rebuild-from-filesystem) + +### Validation + +- round-trip persistence tests +- rebuild tests +- metadata diff tests +- golden-file comparisons for written files + +## Milestone M2: Native Workspace + +### `bds-ui/platform` + +- muda menu bar: full menu tree with accelerators +- menu enable/disable validation synced to app state +- menu event routing as `Message` variants +- rfd integration for file/folder open and save dialogs +- macOS lifecycle shim: file-open and URL-open events forwarded as `Message` variants + +### `bds-ui/app` + +- root `Message` enum and `update()` dispatcher +- app state model +- task surface model + +### `bds-ui/views` + +- workspace layout +- sidebar +- activity bar +- tab bar +- status bar +- project selector + +### Validation + +- menu event → `Message` routing integration tests +- keyboard shortcut integration tests +- rfd dialog invocation tests +- fixture project open flow test + +## Milestone M3: Authoring + +### `bds-editor` (maturation) + +- full cursor movement (arrows, word, line, page, home/end) +- selection (shift+movement, click-and-drag, double-click word select) +- system clipboard integration (copy/cut/paste) +- undo/redo with edit grouping +- line numbers gutter +- incremental syntax rehighlighting on edits +- IME input handling for CJK and other input methods +- configurable soft wrap +- additional syntax grammars: Liquid/HTML, Lua, YAML, JSON + +### `bds-core/engine` + +- expose editor-safe save APIs +- expose publish/unpublish/discard flows +- expose template validation APIs +- expose script validation APIs + +### `bds-ui/views` + +- dashboard +- post editor (bds-editor with markdown + YAML frontmatter highlighting) +- translation editor (bds-editor) +- media browser +- media editor +- tags view +- settings view +- templates view and editor (bds-editor with Liquid/HTML highlighting) +- scripts view and editor (bds-editor with Lua highlighting) + +### `bds-ui/components` + +- inputs +- modals +- toast/error surfaces +- reusable list rows and panels + +### Validation + +- entity create/edit/save flows +- template validation flow +- script validation flow +- editor widget tests: cursor movement, selection, undo/redo, clipboard, IME + +## Milestone M4: Rendering Parity + +### `bds-core/render` + +- markdown render pipeline via `pulldown-cmark` +- Liquid render pipeline via `liquid` crate (scoped to the feature subset documented in the Liquid inventory: `if`/`elsif`/`else`, `for`, `assign`, `render`, whitespace stripping, plus filters: `default`, `escape`, `url_encode`, `append`, `size`) +- custom Liquid filter: `i18n` (translation lookup by key and language) +- custom Liquid filter: `markdown` (markdown-to-HTML with macro expansion, URL rewriting, media path canonicalization) +- built-in macro renderers: `gallery`, `youtube`, `vimeo`, `photo_archive`, `tag_cloud` (native Rust, not Lua) +- template resolution rules +- URL rewriting +- RSS/Atom feed generation via `quick-xml` +- sitemap generation via `quick-xml` +- generated file hash tracking (`generatedFileHashes` table for skip-unchanged-writes) +- Pagefind search index generation via `pagefind` crate library API (`PagefindIndex::add_html_file()` + `get_files()`) — if required for parity (determine in M0 inventory) + +### `bds-core/ai` + +- one-shot AI client: `reqwest` + `serde_json` against OpenAI-compatible Chat Completions endpoint +- AI-assisted translation operation (translate field content to target language) +- AI image description / alt text generation operation +- AI title suggestion operation +- AI endpoint configuration model (endpoint URL, API key, model name) +- error handling: surface failures as user-visible feedback, never silent + +### `bds-core/engine` + +- `TemplateEngine` +- `PageRenderer` (parallelize rendering via `rayon`) +- `BlogGenerationEngine` +- `PreviewServer` (localhost HTTP via `axum` on `tokio`) +- `SearchIndexEngine` only if required for parity + +### `bds-ui/views` + +- preview controls +- generation progress display +- render errors and diagnostics +- AI operation triggers in post editor (title suggestion), translation editor (translate), and media editor (alt text generation) +- AI endpoint configuration in settings view + +### Validation + +- golden generated-site comparisons +- preview route coverage +- template compatibility suite +- one-shot AI client tests (mocked endpoint: translation, alt text, title suggestion) +- AI endpoint configuration persistence tests + +## Milestone M5: Operate And Ship + +### `bds-core/engine` + +- `PublishEngine` (SSH/SCP via `ssh2`, rsync via child process) +- validation and integrity services +- filesystem watcher via `notify` (if needed for detecting external changes to open content) + +### `bds-core/scripting` + +- `ScriptEngine` +- `LuaRuntime` via `mlua` (Lua 5.4, vendored) — user-authored scripts only; built-in macros are native Rust in `bds-core/render` +- `LuaApi` (expose post, media, tag, and project data to Lua scripts via `mlua::UserData` trait) +- user-authored Lua macro execution at render time +- user-authored transform and utility script execution +- scripting docs generator + +### `bds-ui/views` + +- publish workflow screens +- publish progress and failure surfaces +- script docs access points if needed for core usability + +### Validation + +- publish end-to-end tests +- Lua API bridge tests +- built-in macro compatibility tests +- docs sync tests + +## Extension Backlog + +### Bucket A: Git And Validation + +#### `bds-core` + +- `GitEngine` via `git2` (shell out for LFS operations) +- richer site validation service support + +#### `bds-ui` + +- Git sidebar +- diff view +- richer metadata diff UI + +### Bucket B: Import + +#### `bds-core` + +- WXR parser +- import analysis +- import execution +- import definitions + +#### `bds-ui` + +- import wizard +- import progress and result views + +### Bucket C: AI Chat And Tool Use + +#### `bds-core` + +- streaming client extension (SSE parsing on top of core `reqwest` client) +- tool execution framework (tool definitions, call routing, result formatting) +- multi-turn conversation management + +#### `bds-ui` + +- chat sidebar +- chat panel +- provider settings UI (extends core AI endpoint configuration) + +### Bucket D: Embeddings And Duplicates + +#### `bds-core` + +- embedding engine via `ort` (ONNX Runtime) +- vector index via `usearch` (HNSW) +- duplicate detection logic + +#### `bds-ui` + +- semantic search UI +- duplicates view + +### Bucket E: Translation QA And Docs UX + +#### `bds-core` + +- translation validation engine + +#### `bds-ui` + +- translation validation view +- documentation browser + +### Bucket F: Menu Editing And Deep Links + +#### `bds-core` + +- menu editing services +- deep-link flow support beyond core shell hooks + +#### `bds-ui` + +- menu editor UI + +### Bucket G: MCP And Automation + +#### `bds-cli` + +- headless commands +- MCP server surface +- CLI-to-app notification mechanism (`db_notifications` / `NotificationWatcher`) + +### Bucket H: Blogmark And Transforms + +#### `bds-core` + +- `BlogmarkTransformService` +- transform script chain execution + +#### `bds-ui` + +- external content capture workflow + +### Bucket I: Rich Editor + +#### `bds-editor` (evolution) + +- inline bold/italic/header rendering via cosmic-text mixed font styles +- inline image preview via Iced image rendering within the custom widget +- macro block preview (render macro output inline) +- clickable links + +#### `bds-ui` + +- rich editor mode toggle +- image insert dialog from linked media + +### Bucket J: A2UI Surfaces + +#### `bds-ui` + +- A2UI component renderer +- A2UI surface manager +- AI assistant dynamic surface integration + +## Exit Rule + +Do not start extension buckets until Milestone M5 is complete and the Rust app is already a credible replacement for the current authoring and publishing workflow. \ No newline at end of file diff --git a/RUST_PLAN.md b/RUST_PLAN.md new file mode 100644 index 0000000..b9bc30c --- /dev/null +++ b/RUST_PLAN.md @@ -0,0 +1,131 @@ +# bDS Rust Rewrite Plan + +bDS is a blogging desktop system that is currently in the following folder: + +../bDS/ + +this is to be rewritten with Rust and this is the plan. For any implementation detail, look into the ../bDS/ folder into the typescript code there. Assume the typescript code as "the truth" about the current implementation. + +This plan is split into multiple documents: + +- [RUST_PLAN_CORE.md](RUST_PLAN_CORE.md) — the shipping core. This must already be a fully functioning blogging app: create, edit, preview, generate, and publish content using the exact same on-disk and generated formats as the current app. +- [RUST_PLAN_EXTENSION.md](RUST_PLAN_EXTENSION.md) — parity work and advanced tooling that can land after the core app is usable. +- [RUST_EXECUTION_BACKLOG.md](RUST_EXECUTION_BACKLOG.md) — implementation backlog grouped by milestone and crate. +- [RUST_COMPATIBILITY_MATRIX_TEMPLATE.md](RUST_COMPATIBILITY_MATRIX_TEMPLATE.md) — template for the required persistence and compatibility inventory. + +## Non-Negotiable Constraints + +1. **Content compatibility is exact.** Existing SQLite data, markdown/frontmatter, translation files, media sidecars, templates, menu documents, generated HTML structure, feeds, and sitemaps must remain readable and writable by the Rust app. +2. **No JavaScript anywhere.** No npm, no webview, no JS runtime, no Electron. This is a supply-chain security constraint. The entire app is pure Rust plus native platform APIs. Pagefind is integrated as a Rust library dependency (`pagefind` crate), not as an external binary. +3. **Script compatibility is intentionally broken.** Python/Pyodide is removed. Rust bDS is a green-field app and uses Lua for user-authored scripting. Existing Python scripts are not carried forward as compatible runtime artifacts. Built-in macros (gallery, youtube, vimeo, photo_archive, tag_cloud) are re-implemented as native Rust, not routed through Lua. +4. **Core release ships as a native desktop app.** Primary target is macOS, but the UI stack (Iced + muda + rfd) is cross-platform from day one. Native menus, key handling, open-file/deep-link handling, and platform command routing are part of core scope, not follow-up polish. +5. **Template editing is core scope.** Template rendering without template management UI is not sufficient. +6. **Script API docs remain mandatory.** The runtime changes to Lua, but the app still ships and generates proper scripting API documentation. +7. **Split localization is mandatory.** UI locale follows the OS. Rendered and previewed content language follows project settings. + +## UI Technology Stack + +The application uses the following UI and platform integration stack: + +| Layer | Technology | Purpose | +|---|---|---| +| UI framework | **Iced** (Elm architecture) | Application layout, views, widgets, event loop | +| Text editing | **ropey** + **syntect** + **cosmic-text** | Custom syntax-highlighting editor widget for markdown, Liquid templates, and Lua scripts | +| Native menus | **muda** | Cross-platform native menu bar (NSMenu on macOS, Win32 menus on Windows, GTK/dbus on Linux) | +| File dialogs | **rfd** | Cross-platform native file/folder dialogs (NSOpenPanel/NSSavePanel on macOS, equivalents elsewhere) | +| Platform lifecycle | **objc2** (macOS only, cfg-gated) | Thin shim for `application:openFile:`, `application:openURLs:`, and other `NSApplicationDelegate` hooks | + +### Why this stack + +- **Iced** is a published crate with versioned releases, proper documentation, and a stable API. It uses wgpu for GPU-accelerated rendering and follows the Elm architecture (Message → update → view). +- **muda** and **rfd** render through real platform APIs (NSMenu, NSOpenPanel, etc.) with zero fidelity loss versus hand-rolled platform code, while providing cross-platform support from day one. +- **ropey + syntect + cosmic-text** gives full control over the editor experience: rope-based efficient text storage, Sublime Text syntax grammars (markdown, Liquid, Lua), and proper font shaping and layout via the same engine used by cosmic-DE. +- The only platform-specific code is a small (~50 line) lifecycle shim for macOS app delegate hooks, conditionally compiled via `cfg(target_os = "macos")`. Linux and Windows equivalents are bounded and isolated to the same module. + +## Workspace Shape + +```text +bds-rust/ +├── Cargo.toml +├── crates/ +│ ├── bds-core/ # engines, models, rendering, publishing +│ ├── bds-editor/ # custom Iced editor widget (ropey + syntect + cosmic-text) +│ ├── bds-ui/ # Iced application, views, platform integration (muda, rfd) +│ └── bds-cli/ # later, optional automation surface +├── migrations/ +├── locales/ +├── assets/ +└── docs/ + └── scripting/ # generated Lua API docs + guides +``` + +### Crate responsibilities + +- **bds-core**: all engines, models, persistence, rendering, publishing — zero UI dependencies. +- **bds-editor**: reusable Iced custom widget for syntax-highlighting text editing. Depends on ropey, syntect, cosmic-text, and iced. Does not depend on bds-core. Can be extracted as a standalone crate. +- **bds-ui**: application shell, Iced views and components, message routing, platform integration (muda for menus, rfd for dialogs, objc2 shim for macOS lifecycle). Depends on bds-core and bds-editor. +- **bds-cli**: headless automation surface. Depends on bds-core only. + +## Distribution Characteristics + +- **Single static binary.** No external runtime dependencies (no GTK, no Electron, no Node.js). Lua and SQLite are compiled into the binary. +- **Binary size:** ~15–25 MB (versus 150–200 MB for the current Electron app). +- **Memory usage:** ~50–80% less RAM than the Electron app (no Chromium process). +- **Startup:** Significantly faster (no V8/Chromium initialization). +- **Zero install dependencies:** users download one binary. No Homebrew, no system packages. + +## Split Rationale + +The old single-file plan mixed three categories of work: + +- work required to ship a real replacement for the current app +- work required for eventual feature parity +- work that is valuable but not on the critical path to replacing TypeScript + +The new split keeps the shipping path narrow. Core is only the work needed to replace the current app for everyday authoring and publishing. Extensions carry the rest. + +## Release Gates + +The Rust rewrite is not considered successful until the core release can do all of the following with existing bDS project data: + +1. Open a real project created by the TypeScript app. +2. Create and edit posts, translations, media, tags, templates, and settings. +3. Preview drafts and published content locally. +4. Generate a complete site whose output matches the current app modulo approved normalization differences. +5. Publish the generated output to a remote target. +6. Rebuild the database from files and run metadata diff without losing information. +7. Operate as a native desktop application with native menus and command handling. + +## Verification Baseline + +Both plan documents assume the same verification baseline: + +1. Fixture projects exported from the current app are the compatibility corpus. +2. Golden-file tests compare Rust-written files against TypeScript-written files. +3. Golden-output tests compare generated sites between the current app and the Rust app. +4. No feature is complete until both UI behavior and underlying engine behavior are covered. + +## Recommended Repository Strategy + +Build the Rust rewrite as a separate parallel project, not inside the current TypeScript application tree. + +Why: + +1. The rewrite has a different language stack, build chain, packaging model, runtime model, and test harness. +2. The compatibility target is the current app's behavior and data, not shared source code. +3. Keeping the rewrite isolated avoids contaminating this repo with long-lived dual-toolchain complexity. +4. The current app remains the stable reference implementation while the Rust app catches up. + +Recommended structure: + +- keep this repository as the reference implementation and fixture source +- create a sibling repository such as `bds-rust` +- pull compatibility fixtures from this repo into the Rust repo on a controlled cadence +- compare generated output across repos in the Rust repo's CI + +Only use the same repository if you explicitly want a temporary umbrella monorepo and are willing to accept: + +- slower CI +- mixed Node and Rust release pipelines +- more complex contributor onboarding +- higher risk of plan drift between the legacy app and the rewrite diff --git a/RUST_PLAN_CORE.md b/RUST_PLAN_CORE.md new file mode 100644 index 0000000..0647f0f --- /dev/null +++ b/RUST_PLAN_CORE.md @@ -0,0 +1,867 @@ +# bDS Rust Rewrite — Core Plan + +## Goal + +Ship a native desktop Rust application (macOS primary, cross-platform ready) that fully replaces the current TypeScript app for the primary blogging workflow: + +1. open an existing project +2. create and edit content +3. preview content locally +4. generate the site +5. publish the site + +Core is not a toy MVP. Core must already be a production-capable replacement for the current app's content pipeline. + +## Core Planning Rules + +1. Critical path first. Do not start advanced parity work while the authoring and publishing path is incomplete. +2. Compatibility before optimization. Matching data and output behavior matters more than early performance work. +3. Engine and UI land together at milestone boundaries. No long-lived backend-only branches of product functionality. +4. No new persistence formats during core. +5. Every milestone ends with a runnable app, not just passing unit tests. + +## Explicit Compatibility Contract + +### Must stay identical + +- SQLite schema semantics and data readability +- post markdown files and frontmatter layout +- translation file naming and frontmatter layout +- media sidecars and thumbnail conventions +- media translation records +- post-link tracking records +- template file formats and lookup rules +- menu document read format for rendering +- generated routes, feeds, sitemaps, and local preview behavior +- metadata diff and rebuild-from-filesystem behavior +- full-text search behavior (SQLite FTS5 virtual tables for posts and media) +- slug generation behavior (verify `deunicode` output matches current `transliteration` output for the project's content corpus) + +### May change intentionally + +- app implementation language and architecture +- editor technology +- scripting runtime: Python out, Lua in +- internal process model: no Electron main/renderer split +- UI framework: Iced replaces Electron/web UI +- platform integration: muda/rfd replace Electron's built-in menu and dialog APIs + +### Compatibility truth + +User content and project data are compatible. +User-authored Python scripts are not compatible and are treated as legacy artifacts. + +## Architecture Decisions + +- **Single process Rust app**: no Electron, no IPC boundary. +- **Iced (Elm architecture) for UI**: Message-driven update cycle. All user interactions produce `Message` variants; state mutations happen in a centralized `update()` function; views are pure functions that return element trees. +- **muda for native menus**: cross-platform native menu bar from day one. NSMenu on macOS, Win32 menus on Windows, GTK/dbus on Linux. Key equivalents and menu validation handled through muda's API. +- **rfd for file dialogs**: cross-platform native open/save/folder dialogs. NSOpenPanel/NSSavePanel on macOS, equivalents elsewhere. +- **objc2 for macOS lifecycle shim** (cfg-gated): thin bridge for `application:openFile:`, `application:openURLs:`, and other `NSApplicationDelegate` hooks that muda/rfd do not cover. ~50 lines of platform code. +- **Custom editor widget (bds-editor crate)**: syntax-highlighting markdown/Liquid/Lua editor built on ropey (rope buffer), syntect (syntax highlighting), and cosmic-text (font shaping and text layout). This is the highest-risk custom component and gets a proof-of-concept in Wave 0. +- **rusqlite + embedded SQL migrations**: no ORM. SQL stays explicit. Migrations managed via `refinery`. +- **tokio as the async runtime**: preview server (axum), SSH publishing, file watching, and rfd async dialogs all require an async executor. tokio is the standard choice and is used workspace-wide. Synchronous engine code in bds-core does not use tokio directly — it remains callable from both async (bds-ui) and sync (bds-cli) contexts. +- **Plain-text markdown editor first**: no WYSIWYG in core. Live preview is required. This is an intentional regression from the current app's Milkdown WYSIWYG editor. A rich editor can be added as an extension bucket after core ships — and the bds-editor widget provides a natural foundation for it. +- **Lua for user-authored scripting**: `mlua` with Lua 5.4. Only user-authored macros, transforms, and utility scripts run through Lua. Built-in macros are native Rust — see the macro architecture section below. +- **One-shot AI operations are core scope**: translation, image description / alt text generation, and title suggestion use a simple OpenAI-compatible HTTP client (`reqwest` + `serde_json`). The endpoint is user-configurable (OpenAI, Anthropic-via-proxy, local Ollama, etc.). These are fire-and-forget request/response calls — no streaming, no tool use, no chat history. The AI chat UI, streaming responses, and tool execution remain in Extension Bucket C. + +## Iced Architecture Patterns + +The Iced Elm architecture imposes a specific application structure: + +### Message routing + +All user interactions, menu events, file dialog results, platform lifecycle events, and async task completions are expressed as variants of a root `Message` enum. The application's `update()` method matches on these variants and mutates state accordingly. This replaces the "command dispatcher" pattern seen in imperative UI frameworks. + +### View composition + +Views are pure functions: `fn view(&self) -> Element`. They read application state and return a widget tree. Views never mutate state directly. This makes UI code inherently testable — you can assert on the element tree without rendering. + +### Command and subscription model + +Side effects (file I/O, network, timers, platform events) are expressed as `Command` or `Task` values returned from `update()`. Menu events from muda arrive via a `Subscription` that polls `MenuEvent::receiver()`. Platform lifecycle events from the objc2 shim arrive via a similar channel-based subscription. + +### State model + +The UI state must track at minimum: + +- active project +- open tabs and selected tab +- selected entities +- dirty editors (with undo/redo state in bds-editor) +- task progress +- `ui_locale` +- project render settings including `content_language` +- menu state (enabled/disabled/checked items synced to app state) + +## Workspace Structure + +```text +bds-rust/ +├── Cargo.toml +├── crates/ +│ ├── bds-core/ +│ │ ├── src/ +│ │ │ ├── db/ +│ │ │ ├── engine/ +│ │ │ ├── model/ +│ │ │ ├── render/ +│ │ │ ├── scripting/ +│ │ │ ├── i18n/ +│ │ │ └── util/ +│ ├── bds-editor/ +│ │ ├── src/ +│ │ │ ├── buffer.rs # ropey rope wrapper, edit operations +│ │ │ ├── highlight.rs # syntect integration, incremental rehighlight +│ │ │ ├── layout.rs # cosmic-text buffer/shaping/layout +│ │ │ ├── cursor.rs # cursor model, selection, multi-cursor +│ │ │ ├── history.rs # undo/redo operation log +│ │ │ ├── input.rs # key event → buffer mutation mapping +│ │ │ ├── widget.rs # Iced custom widget implementation +│ │ │ └── lib.rs +│ ├── bds-ui/ +│ │ ├── src/ +│ │ │ ├── app.rs # Iced Application impl, root Message enum, update() +│ │ │ ├── platform/ +│ │ │ │ ├── mod.rs +│ │ │ │ ├── macos.rs # objc2 lifecycle shim (cfg-gated) +│ │ │ │ └── menu.rs # muda menu construction and event routing +│ │ │ ├── views/ +│ │ │ ├── components/ +│ │ │ └── i18n/ +│ └── bds-cli/ +├── migrations/ +├── fixtures/ +│ ├── compatibility-projects/ +│ └── golden-generated-sites/ +└── docs/ + └── scripting/ +``` + +## Core Feature Scope + +### Included in core + +- project open/create/select +- posts and translations +- media import and metadata editing +- tags and categories +- template editing and validation +- settings and publishing preferences +- live preview and full static generation +- publish via SSH and rsync +- metadata diff and rebuild-from-filesystem +- native menus and shortcuts via muda (cross-platform) +- native file dialogs via rfd (cross-platform) +- macOS lifecycle hooks via objc2 shim (open-file, URL-open) +- syntax-highlighting editor for markdown, Liquid templates, and Lua scripts via bds-editor +- built-in macros implemented natively in Rust (gallery, youtube, vimeo, photo_archive, tag_cloud) +- Lua runtime for user-authored macros, transforms, and utility scripts +- generated Lua scripting API docs +- FTS5 virtual tables for in-app post and media search +- one-shot AI operations: translation, image description / alt text generation, title suggestion (configurable OpenAI-compatible endpoint via `reqwest`) + +### Deferred to extensions + +- AI chat UI, streaming responses, and tool execution (Bucket C) +- Git UI and history tools +- WordPress import wizard +- embeddings and duplicate detection +- translation validation reports +- menu editor UI +- documentation browser UI +- MCP and remote automation surfaces +- Blogmark transform service (external content capture pipeline) +- A2UI server-driven UI surfaces +- WYSIWYG / rich markdown editor (builds on bds-editor foundation) + +## Cross-Cutting Requirements + +### Native platform shell + +Core includes: + +- native menu bar via muda (App, File, Edit, View, Window, Help menus) +- standard key equivalents and accelerators via muda +- menu enable/disable validation synced to application state +- menu actions routed as `Message` variants into the Iced update cycle +- file-open and folder-open dialogs via rfd +- macOS: open-file from Finder and URL scheme handling via objc2 lifecycle shim +- proper window lifecycle and recent-project tracking + +### Split localization + +Two independent localization domains are required: + +- `ui_locale`: detected from the OS and used for menus, dialogs, toasts, and workspace chrome +- `content_language`: read from project settings and used for rendering, preview, feeds, sitemaps, and generation + +No design in core may collapse those into a single "current language" field. + +### Editor widget requirements + +The bds-editor crate is a custom Iced widget that provides syntax-highlighting text editing. It must support: + +- **Buffer**: ropey `Rope` for O(log n) edits and line indexing +- **Syntax highlighting**: syntect `HighlightLines` with incremental rehighlighting on edits. Grammars needed: Markdown, Liquid (HTML), Lua, YAML (frontmatter), JSON +- **Text layout**: cosmic-text `Buffer` for font shaping, glyph positioning, and line layout +- **Cursor**: position tracking, selection (shift+movement), click-to-place, click-and-drag selection +- **Input handling**: standard key bindings (arrows, home/end, page up/down, word movement with option/ctrl), text insertion, delete/backspace, tab/indent +- **Undo/redo**: operation log tracking edit groups, triggered by standard shortcuts +- **Line numbers**: gutter with line numbers, synchronized scroll +- **Scroll**: vertical and horizontal scrolling, viewport-aware rendering (only lay out visible lines) +- **Soft wrap**: configurable per editor instance +- **IME support**: proper handling of composition events from winit for CJK and other input methods — test early + +The widget emits Iced `Message` variants for content changes, cursor movement, and save requests. The parent view owns the buffer state and passes it to the widget. + +### Metadata parity matrix + +Before any engine work begins, create a compatibility inventory for every persisted field across: + +- database columns +- markdown frontmatter +- translation frontmatter +- media sidecars +- project metadata files +- template files +- generated output metadata + +For each field record: + +- source of truth +- persisted locations +- read path +- write path +- rebuild behavior +- metadata diff behavior +- publish behavior + +This matrix is a release artifact, not a temporary note. + +### Liquid template feature subset + +The current default templates use a small subset of the Liquid specification. Before implementing the Liquid render pipeline, inventory the exact features used. The current templates (12 files: 3 main templates, 5 macro templates, 4 partials in `src/main/engine/templates/`) use only: + +- **Tags:** `if`/`elsif`/`else`, `for`, `assign`, `render` (partials), whitespace stripping (`{%- -%}`) +- **Filters (built-in):** `default`, `escape`, `url_encode`, `append`, `size` +- **Filters (custom, must be re-implemented in Rust):** `i18n` (translation lookup by key and language), `markdown` (markdown-to-HTML with macro expansion, URL rewriting, and media path canonicalization) +- **Not used:** `unless`, `case/when`, `capture`, `layout`, `include`, `comment`, `raw`, `date`, `truncate`, `split`, `join`, `where`, `group_by`, `map`, `sort`, `reverse`, `cycle`, `tablerow`, `increment`/`decrement`, and most other standard filters + +This means the Rust Liquid implementation only needs roughly 35% of the full specification. Use `liquid-rust` or a minimal custom engine scoped to just the features above. User templates may use additional features, but parity with the current default templates is the release gate. + +### Built-in macro architecture + +The current app has two distinct macro systems that must not be conflated: + +1. **Built-in macros** (`gallery`, `youtube`, `vimeo`, `photo_archive`, `tag_cloud`) — implemented in JavaScript, rendered server-side during generation. Each has a corresponding `.liquid` template in `src/main/engine/templates/macros/`. These are **not** Python scripts and never were. +2. **User-authored script macros** — implemented in Python (Pyodide), invoked via the `PythonMacroWorkerRuntime` worker thread. These are replaced by Lua in the Rust app. + +In the Rust app: + +- Built-in macros become **native Rust functions** in `bds-core/render`. They are part of Wave 4 (rendering parity), not Wave 6 (Lua scripting). Rendering cannot produce correct output without them. +- User-authored macros run through the **Lua runtime** (`mlua`). This is Wave 6 scope. During Wave 4, unknown macro placeholders should produce a visible "unsupported macro" marker rather than silently dropping content. + +Macro invocation syntax in content: `[[macro_name param1="value1" param2="value2"]]` + +### Slug generation compatibility + +The current app uses the `transliteration` npm package for Unicode-to-ASCII conversion in slugs. The plan specifies `deunicode` for Rust. These libraries may produce different output for edge cases (CJK characters, Cyrillic, accented Latin, etc.). Add a slug compatibility test suite to M0 fixtures that runs both libraries against the project's actual content corpus and documents any divergences. + +### Client-side search index (Pagefind) + +If the current TypeScript app generates a Pagefind search index as part of site generation, the Rust app must produce the same artifact. Pagefind publishes a Rust library (`pagefind` crate) with a programmatic API (`pagefind::api::PagefindIndex`). The generation pipeline feeds rendered HTML to `PagefindIndex::add_html_file()` and writes the resulting index files via `PagefindIndex::get_files()`. No external binary, no npm — this is a pure Rust library dependency, fully compatible with the no-JavaScript constraint. + +Determine during the compatibility inventory (M0) whether Pagefind or another client-side search solution is used. If Pagefind: add it to the generation pipeline in Wave 4 via the `pagefind` crate. If a different solution: document it and plan accordingly. + +### Image processing + +Media import requires thumbnail generation and potentially format conversion (e.g., to WEBP). The current TypeScript app uses `sharp` (libvips bindings). The Rust equivalent is the `image` crate for decoding/encoding and basic transforms (resize, crop). If WEBP encoding performance or advanced processing (EXIF handling, ICC profiles) proves insufficient with `image` alone, `libvips` Rust bindings (`libvips-rs`) are the fallback. Start with `image` — it covers the common cases and has no system dependencies. + +## Planned Crate Registry + +All crate choices for core scope, organized by subsystem. This prevents ad-hoc technology decisions during implementation. + +### Foundation (Wave 0 onward) + +| Crate | Purpose | Notes | +|---|---|---| +| `rusqlite` (bundled, vtab) | SQLite database access | Bundled compiles SQLite from source, vtab enables FTS5 | +| `refinery` | SQL migration management | Replaces hand-rolled migration loader | +| `uuid` (v4) | Entity identifiers | | +| `serde` + `serde_json` | Serialization/deserialization | Used everywhere | +| `serde_yaml` | YAML frontmatter parsing/writing | Posts, translations, media sidecars | +| `chrono` | Date/time handling | | +| `sha2` | Content hashing, checksums | | +| `deunicode` | Unicode-to-ASCII for slug generation | Verify against `transliteration` output | +| `thiserror` | Typed error definitions in library crates | bds-core, bds-editor | +| `anyhow` | Ergonomic error handling in application crates | bds-ui, bds-cli | +| `tokio` | Async runtime | Preview server, publish, file watching, async dialogs | + +### UI Framework (Wave 0 onward) + +| Crate | Purpose | Notes | +|---|---|---| +| `iced` (wgpu, advanced, image) | Application framework | Elm architecture, GPU-accelerated via wgpu/Metal | +| `muda` | Cross-platform native menu bar | NSMenu / Win32 / GTK | +| `rfd` | Cross-platform native file dialogs | NSOpenPanel / Win32 / GTK | + +### Editor Widget (Wave 0 onward, bds-editor crate) + +| Crate | Purpose | Notes | +|---|---|---| +| `ropey` | Rope buffer for text storage | O(log n) edits, line indexing | +| `syntect` | Syntax highlighting | Sublime Text grammars: Markdown, Liquid, Lua, YAML, JSON | +| `cosmic-text` | Font shaping, text layout | Same engine as cosmic-DE | + +### Platform Lifecycle (Wave 0 onward, macOS cfg-gated) + +| Crate | Purpose | Notes | +|---|---|---| +| `objc2` | Objective-C runtime bindings | | +| `objc2-foundation` | Foundation framework types | | +| `objc2-app-kit` | AppKit framework (NSApplicationDelegate hooks) | ~50 lines of shim code | + +### Data Layer (Wave 1) + +| Crate | Purpose | Notes | +|---|---|---| +| `serde_yaml` | Frontmatter read/write | Already listed in foundation | +| `notify` | Filesystem watching | Detect external file changes affecting open editors | +| `image` | Thumbnail generation, format conversion | Start here; libvips-rs as fallback if needed | +| `walkdir` | Recursive directory traversal | Rebuild-from-filesystem, media import | + +### Rendering and Generation (Wave 4) + +| Crate | Purpose | Notes | +|---|---|---| +| `pulldown-cmark` | Markdown → HTML | Fast, CommonMark-compliant | +| `liquid` | Liquid template rendering | Scoped to the ~35% feature subset actually used | +| `quick-xml` | RSS/Atom feeds, sitemaps | Also handles OPML menu documents | +| `rayon` | Parallel site generation | Parallelize page rendering across CPU cores | +| `axum` | Preview HTTP server | Lightweight, tokio-based | + +### Publishing (Wave 5) + +| Crate | Purpose | Notes | +|---|---|---| +| `ssh2` | SSH/SCP file upload | For publish-via-SSH targets | +| (shell out) | rsync invocation | rsync ships with macOS/Linux; invoke as child process | + +### Client-Side Search (Wave 4) + +| Crate | Purpose | Notes | +|---|---|---| +| `pagefind` | Search index generation | Rust library API: `PagefindIndex::add_html_file()` + `get_files()`. No CLI binary needed. | + +### AI — One-Shot Operations (Wave 4–5) + +| Crate | Purpose | Notes | +|---|---|---| +| `reqwest` | HTTP client for AI endpoints | OpenAI-compatible Chat Completions API. Used for translation, alt text, title suggestion. | + +### Scripting (Wave 6) + +| Crate | Purpose | Notes | +|---|---|---| +| `mlua` (lua54) | Lua 5.4 embedding | User-authored macros, transforms, utility scripts. Use `vendored` feature to compile Lua from source. | + +### Extension-Only Crates (not used in core) + +| Crate | Purpose | Bucket | +|---|---|---| +| `git2` | Git operations | A (Git + Validation) | +| `ort` | ONNX inference for embeddings | D (Embeddings) | +| `usearch` | HNSW vector index | D (Embeddings) | + +## Critical Path + +The hard sequence is: + +1. compatibility inventory and fixtures +2. exact-read and exact-write data layer +3. native platform shell and command system (muda menus, rfd dialogs, Iced message routing) +4. editor widget MVP (ropey + syntect + cosmic-text proof of concept) +5. editors for posts, media, templates, scripts, and settings +6. preview and generation parity +7. one-shot AI operations (translation, alt text, title suggestion) +8. publish and integrity tooling +9. Lua built-ins plus generated script API docs + +Anything outside that path is noise until the previous step is stable. + +## Core Milestones + +### Milestone M0: Compatibility Baseline + +- fixture projects checked in +- golden harness working +- schema readable +- native empty app launches with Iced window and muda menu bar +- bds-editor proof-of-concept renders a markdown file with syntax highlighting + +### Milestone M1: Data Fidelity + +- posts, translations, media, tags, and settings round-trip +- rebuild works +- metadata diff works +- no format drift on fixture writes + +### Milestone M2: Native Workspace + +- projects open in a real app window +- native menus and shortcuts work via muda +- sidebar, tabs, and message routing work +- file dialogs work via rfd + +### Milestone M3: Authoring + +- post, translation, media, template, and script editing works using bds-editor +- errors surface in the UI +- all required authoring entities are reachable from the workspace + +### Milestone M4: Rendering Parity + +- preview is trustworthy +- generation matches golden output +- route, feed, and sitemap parity is acceptable +- one-shot AI operations work (translation, image alt text, title suggestion) with a configurable OpenAI-compatible endpoint + +### Milestone M5: Operate And Ship + +- publish works end to end +- integrity checks surface actionable issues +- Lua built-ins and scripting docs are complete enough for users + +Core release happens only after M5. + +## Wave 0: Foundation And Compatibility Inventory + +**Goal:** Bootstrapped workspace, exact compatibility target defined, golden fixtures checked in, and editor widget risk retired. + +### Deliverables + +- Cargo workspace for `bds-core`, `bds-editor`, `bds-ui`, `bds-cli` +- SQLite connection layer, migrations loader, and base models +- compatibility inventory document covering all persisted fields (including `mediaTranslations`, `postLinks`, and FTS5 virtual tables) +- Liquid feature inventory documenting exactly which tags, filters, and patterns the default templates use +- slug compatibility test suite comparing `deunicode` output against `transliteration` output for fixture content +- fixture projects copied from the current app +- golden-file harness for file writes and generation output comparisons +- empty native app window: Iced window with muda-driven menu bar (App/File/Edit/View/Window/Help) +- **bds-editor proof-of-concept**: custom Iced widget rendering a markdown file with syntax highlighting via ropey + syntect + cosmic-text. Must demonstrate: text display with highlighting, cursor placement, text insertion, basic scrolling. This retires the highest-risk component early. +- Iced architecture patterns document covering: message design conventions, subscription model for menu events and platform hooks, custom widget patterns for bds-editor + +### Dependencies + +```toml +# Core +rusqlite = { version = "0.33", features = ["bundled", "vtab"] } +refinery = { version = "0.8", features = ["rusqlite"] } +uuid = { version = "1", features = ["v4"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +chrono = "0.4" +sha2 = "0.10" +deunicode = "1" +thiserror = "2" +anyhow = "1" +tokio = { version = "1", features = ["full"] } +walkdir = "2" + +# UI framework +iced = { version = "0.13", features = ["wgpu", "advanced", "image"] } + +# Editor widget +cosmic-text = "0.12" +ropey = "1" +syntect = "5" + +# Platform integration (cross-platform) +muda = "0.15" +rfd = "0.15" + +# Platform lifecycle (macOS only) +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-foundation = "0.3" +objc2-app-kit = "0.3" +``` + +Not all of these are needed in Wave 0 — this is the full foundation set. Wave-specific additions (image, pulldown-cmark, liquid, axum, rayon, quick-xml, pagefind, reqwest, ssh2, mlua, notify) are added when their wave begins. See the Planned Crate Registry for the complete list. + +### Tests + +- open real bDS databases and verify read access across all tables (including `mediaTranslations`, `postLinks`, FTS5 tables, and AI/catalog tables that must not cause errors even though they are not used in core) +- verify empty Rust app opens as a native window with working menu bar +- verify bds-editor PoC renders highlighted text and accepts keyboard input +- verify fixture loader and golden harness run in CI +- slug compatibility tests pass for all fixture content + +### Done when + +- workspace builds on macOS (and ideally on Linux for CI verification) +- fixture projects load +- compatibility corpus exists +- Liquid feature inventory is complete +- bds-editor PoC works +- Iced patterns doc is available +- native app shell with menus exists +- milestone M0 acceptance review passes + +## Wave 1: Core Data Layer And Filesystem Contract + +**Goal:** Exact-read and exact-write support for the current project data model. + +### Key crates introduced + +- `serde_yaml` for frontmatter parsing/writing +- `image` for thumbnail generation and format conversion during media import +- `walkdir` for recursive directory traversal during rebuild-from-filesystem +- `notify` for filesystem change detection (may be deferred to Wave 5 if not needed until publish) + +### Engines + +- `ProjectEngine` +- `PostEngine` +- `MediaEngine` +- `PostMediaEngine` +- `TagEngine` +- `MetaEngine` +- `TaskManager` +- `MetadataDiffEngine` +- `RebuildEngine` or equivalent rebuild services + +### File and DB responsibilities + +- draft vs published post lifecycle +- translation files with canonical linkage +- media sidecars and thumbnails (thumbnail generation via `image` crate) +- tag/category sync +- checksum and content-hash tracking +- rebuild database from filesystem +- metadata diff against filesystem + +### Required behavior + +- published posts continue storing body content in files, not in DB +- every field persisted by the current app remains persisted in the Rust app in the same places +- metadata writes, metadata diff, rebuild, and publish all use the same field mapping + +### Tests + +- round-trip tests for posts, translations, media, templates, and project metadata +- rebuild tests using fixture projects +- metadata diff tests proving no false negatives for known field changes +- file-by-file golden comparisons against TypeScript output + +### Done when + +- Rust can read and write existing project data without format drift +- rebuild restores the DB from files accurately +- metadata diff catches every field covered by the matrix +- milestone M1 acceptance review passes + +## Wave 2: Native Platform Workspace Shell + +**Goal:** Native-feeling desktop application shell with real menus and command plumbing. + +### UI scope + +- workspace window (Iced application) +- activity bar +- sidebar +- tab bar +- status bar +- task/output panel +- project selector +- message routing layer (root `Message` enum with command dispatch in `update()`) + +### Platform scope + +- App, File, Edit, View, Window, Help menus via muda +- accelerators and key equivalents via muda +- menu enable/disable synced to app state (e.g., Save disabled when no dirty editor) +- menu events received via `Subscription` polling `MenuEvent::receiver()` +- file/folder dialogs via rfd +- macOS: open-file from Finder and URL-open handled by objc2 lifecycle shim, forwarded as `Message` variants +- recent project tracking (app-managed list; no platform API dependency) + +### State model + +The Iced application state must track at minimum: + +- active project +- open tabs +- selected entities +- dirty editors +- task progress +- `ui_locale` +- project render settings including `content_language` + +### Tests + +- tab lifecycle +- message dispatch from both menu events and keyboard entry points +- app-shell integration tests for opening a fixture project +- rfd dialog invocation tests (mocked where needed) + +### Done when + +- the app can be navigated by menu or keyboard alone +- native menu labels are localized from the UI locale +- shell behavior feels like a native desktop app +- milestone M2 acceptance review passes + +## Wave 3: Content Editing UI + +**Goal:** Full editing UI for the content types required by the publishing workflow. + +### Views + +- dashboard +- post editor (using bds-editor with markdown + YAML frontmatter highlighting) +- translation editor (using bds-editor) +- media browser and media editor +- tags view +- settings view +- templates view and template editor (using bds-editor with Liquid/HTML highlighting) +- scripts view and script editor (using bds-editor with Lua highlighting) +- lightweight linked-media and post-links views where needed for parity + +### Required capabilities + +- create, edit, duplicate, publish, unpublish, discard, and delete posts +- edit title, slug, excerpt, tags, categories, language, author, template assignment +- import media (via rfd file dialog) and edit title, alt, caption, author, tags, language +- create and edit templates with syntax validation +- create and edit Lua scripts with syntax validation +- expose errors and conflicts through dialogs and task panel output +- undo/redo in all editor instances via bds-editor history + +### Editor widget maturation + +By Wave 3, bds-editor must support the full feature set documented in the editor widget requirements section: + +- all cursor movement patterns (arrows, word, line, page, home/end) +- selection (shift+movement, click-and-drag, double-click word select) +- copy/cut/paste via system clipboard +- undo/redo with edit grouping +- line numbers gutter +- incremental syntax rehighlighting on edits +- IME input for non-Latin scripts +- configurable soft wrap + +### Tests + +- integration tests from sidebar selection to persisted save +- template edit and validation tests +- script save and validation tests +- editor widget tests for cursor movement, selection, undo/redo, and IME composition + +### Done when + +- every content type needed by preview/generation/publish has an editor +- template management is reachable and usable from the UI +- scripts are manageable from the UI even if advanced tooling is deferred +- bds-editor handles real-world editing scenarios reliably +- milestone M3 acceptance review passes + +## Wave 4: Rendering, Preview, And Generation + +**Goal:** Reproduce the current site's rendering pipeline and local preview behavior. + +### Engines + +- `TemplateEngine` +- `PageRenderer` +- `BlogGenerationEngine` +- `PreviewServer` (axum, localhost-only HTTP server) +- `SearchIndexEngine` if required for parity of generated sites + +### Key crates introduced + +- `pulldown-cmark` for Markdown → HTML +- `liquid` for Liquid template rendering (scoped to the feature subset) +- `quick-xml` for RSS/Atom feeds, sitemaps, and OPML menu document reading +- `rayon` for parallel page rendering during generation +- `axum` for the preview HTTP server (runs on tokio) +- `pagefind` for client-side search index generation (Rust library API, not CLI binary) +- `reqwest` for one-shot AI operations (translation, alt text, title suggestion) against an OpenAI-compatible endpoint + +### Rendering pipeline + +1. load published posts, translations, menus, templates, and project settings +2. resolve templates using the same lookup rules as the current app +3. render markdown to HTML via `pulldown-cmark` +4. expand built-in macros (gallery, youtube, vimeo, photo_archive, tag_cloud) natively in Rust — these do not go through the Lua runtime +5. for user-authored Lua macros: delegate to the Lua runtime if available, otherwise emit an "unsupported macro" placeholder +6. apply Liquid templates with custom `i18n` and `markdown` filters +7. rewrite URLs and media references +8. generate archives, feeds (via `quick-xml`), sitemaps, and search artifacts required for parity +9. feed rendered HTML pages to Pagefind via `pagefind::api::PagefindIndex::add_html_file()`, then write the search index files via `get_files()` — if client-side search index is required for parity +10. write only changed outputs when safe to do so (track output file content hashes in a `generatedFileHashes` table, matching the current app's skip-unchanged-writes behavior) +11. parallelize page rendering via `rayon` where safe (each page render is independent) + +### Preview rules + +- preview binds only to localhost +- preview and generated HTML use local assets only +- preview must support drafts and language-prefixed routes +- rendered language is controlled by project settings, not by UI locale + +### One-shot AI operations + +Wave 4 introduces the one-shot AI client in `bds-core`. This is a minimal `reqwest`-based HTTP client that sends single request/response calls to an OpenAI-compatible Chat Completions endpoint. No streaming, no tool use, no chat history. + +**Operations:** + +- **Translation**: translate a post or media metadata field to a target language. Used from the translation editor and media editor. +- **Image description / alt text**: generate an alt text description for a media item. Used from the media editor. +- **Title suggestion**: suggest a title from post content. Used from the post editor. + +**Configuration:** + +- endpoint URL (default: none — AI features are opt-in) +- API key (stored securely, not in project files) +- model name (configurable per operation or globally) + +**Constraints:** + +- AI operations are entirely optional. The app is fully functional without any AI endpoint configured. +- When no endpoint is configured, AI-related UI elements are disabled or hidden. +- Error responses produce user-visible feedback (toast or inline error), never silent failures. +- Request/response payloads use the OpenAI Chat Completions wire format. This works with OpenAI, Anthropic-via-proxy, local Ollama, or any compatible endpoint. + +### Tests + +- fixture-based generation comparisons against current app output +- preview route tests for posts, drafts, assets, media, and language routes +- template compatibility tests using current `.liquid` templates + +### Done when + +- the same project can be generated by both apps with matching output +- preview is accurate enough to be trusted as an authoring tool +- milestone M4 acceptance review passes + +## Wave 5: Publishing And Operational Integrity + +**Goal:** Publishing and integrity tooling needed to operate the app in production. + +### Key crates introduced + +- `ssh2` for SSH/SCP file upload +- rsync invoked as child process (ships with macOS/Linux) + +### Engines and services + +- `PublishEngine` +- `SiteValidationDiffService` or equivalent core validation service +- `NotificationWatcher` if needed for filesystem coherence in core workflows + +### Required capabilities + +- upload generated site via SCP or rsync +- upload media and thumbnails correctly +- exclude metadata-only files from deploy targets where appropriate +- surface publish progress and failures in the UI +- detect and surface external file changes that affect open editors or preview accuracy + +### Tests + +- publish tests against mocked remote targets +- validation tests on generated sites +- watcher tests for externally modified content files + +### Done when + +- publishing works end to end from the Rust app +- operator-visible integrity issues are surfaced before or during publish +- milestone M5 operational review is unblocked pending Wave 6 completion + +## Wave 6: Lua Runtime And Scripting Docs + +**Goal:** Deliver user-authored scripting via Lua and complete scripting documentation. + +Built-in macros (gallery, youtube, vimeo, photo_archive, tag_cloud) are already implemented as native Rust in Wave 4. Wave 6 covers only the user-extensible scripting layer. + +### Engines + +- `ScriptEngine` +- `LuaRuntime` +- `LuaApi` + +### Required scope + +- user-authored Lua macros invoked at render time via `[[macro_name]]` syntax +- user-authored transforms (post processing pipelines) +- user-authored utility scripts +- Lua API bridge exposing post, media, tag, and project data to scripts + +### Documentation requirements + +- generated Lua API documentation from source annotations or schema definitions +- canonical data structure reference for script-visible types +- examples for macro, transform, and utility scripts +- docs bundled with the app and exported as markdown files in `docs/scripting/` + +### Tests + +- Lua execution tests +- API bridge tests +- macro compatibility tests for built-in macros +- docs sync tests proving generated docs match exposed API + +### Done when + +- Lua scripting covers the built-ins needed by current templates and render flows +- scripting docs are complete enough for third-party script authors +- milestone M5 acceptance review passes + +## Core Dependency Graph + +```text +Wave 0 Foundation + Editor PoC + ↓ +Wave 1 Data + Filesystem Contract + ↓ +Wave 2 Native Platform Shell (Iced + muda + rfd) + ↓ +Wave 3 Content Editing UI (bds-editor maturation) + ↓ +Wave 4 Rendering + Preview + Generation + ↓ +Wave 5 Publishing + Operational Integrity + ↓ +Wave 6 Lua Runtime + Scripting Docs +``` + +Wave 4 depends on built-in macros (native Rust, not Lua). Wave 6 can start earlier for Lua runtime bootstrapping, but core release is not complete until user-authored scripting and docs are done. + +## Core Release Checklist + +Core ships only when all of the following are true: + +1. An existing bDS project opens without manual migration. +2. Posts, translations, media, templates, and settings can be edited in-app. +3. Preview and generation match the current app closely enough to pass golden tests. +4. Publishing works against supported remote targets. +5. Metadata diff and rebuild are accurate. +6. The app is a native desktop app with native menus and shortcuts. +7. Lua replaces Python and has proper generated API documentation. + +## Cross-Platform Notes + +The core stack (Iced + muda + rfd) is cross-platform. The only platform-specific code is the macOS lifecycle shim in `bds-ui/src/platform/macos.rs`. To ship on Linux or Windows: + +- Linux: no lifecycle shim needed (file open arrives as CLI args; URL handling via XDG). Iced, muda, and rfd work natively. +- Windows: similar lifecycle shim for file associations and URL protocol handling via Windows APIs. Iced, muda, and rfd work natively. + +Cross-platform packaging is not core scope, but the architecture does not accumulate macOS-only technical debt that would block it. + +## Supporting Docs + +- [RUST_EXECUTION_BACKLOG.md](RUST_EXECUTION_BACKLOG.md) +- [RUST_COMPATIBILITY_MATRIX_TEMPLATE.md](RUST_COMPATIBILITY_MATRIX_TEMPLATE.md) \ No newline at end of file diff --git a/RUST_PLAN_EXTENSION.md b/RUST_PLAN_EXTENSION.md new file mode 100644 index 0000000..55e0a19 --- /dev/null +++ b/RUST_PLAN_EXTENSION.md @@ -0,0 +1,252 @@ +# bDS Rust Rewrite — Extension Plan + +## Goal + +Deliver the rest of current-app parity and the advanced tooling that is valuable, but not required to ship a production-capable Rust replacement for everyday authoring and publishing. + +Extensions begin only after the core plan is already usable end to end. + +## Extension Principles + +1. No extension may break the core compatibility contract. +2. Extensions must reuse core models, engines, and persistence rules rather than invent parallel formats. +3. UI features must still be tied to underlying functionality; no placeholder shells. +4. AI features remain gated by offline mode and must prefer local models or provide explicit user feedback when unavailable. One-shot AI operations (translation, alt text, title suggestion) are part of core but respect the same offline gating. +5. Extensions use the same Iced + muda + rfd platform stack as core. No additional UI frameworks. + +## Extension Buckets + +### Bucket A: Git And Validation Tooling + +#### Scope + +- `GitEngine` via `git2` crate (shell out for LFS operations — no LFS library binding) +- Git sidebar +- diff view +- commit, fetch, pull, push +- richer site validation views +- richer metadata diff UI + +#### Why extension + +Helpful for operators, but not required to create, preview, generate, and publish content. + +#### Done when + +- users can inspect repo state and diffs from within the app +- Git actions work reliably enough to replace the current Git tooling + +### Bucket B: Import And Migration Tooling + +#### Scope + +- WXR parser +- import analysis +- import execution +- saved import definitions + +#### Why extension + +Important onboarding feature, but not required to operate existing bDS projects. + +#### Done when + +- WordPress import flows are usable and recoverable +- import results match the current app's expectations closely enough for fixture-based tests + +### Bucket C: AI Chat And Tool Use + +#### Scope + +- chat UI (sidebar panel with conversation history) +- streaming responses via `reqwest` (SSE / chunked transfer) +- tool use against local engines (post lookup, media search, template info, etc.) +- multi-turn conversation management +- model and credential settings UI (extends the core AI endpoint configuration) + +One-shot AI operations (translation, image alt text, title suggestion) are already in core scope. This bucket adds the interactive, conversational AI layer on top. + +The AI client extends the core `reqwest` + `serde_json` client with streaming support and tool-call parsing. Works with any OpenAI-compatible endpoint: OpenAI, Anthropic-via-proxy, local Ollama, etc. + +#### Hard constraints + +- offline mode gates all automatic AI work +- cloud providers are disabled when offline mode is enabled +- local providers remain usable when allowed +- unavailable operations produce explicit user-visible feedback + +#### Done when + +- AI chat is useful for content-related queries and actions without weakening the app's offline guarantees +- Streaming and tool use work reliably with at least one OpenAI-compatible provider + +### Bucket D: Search, Embeddings, And Duplicate Detection + +#### Scope + +- ONNX embeddings via `ort` (ONNX Runtime Rust bindings) +- HNSW vector index via `usearch` +- semantic search UI +- duplicate detection UI + +#### Why extension + +Improves discovery and cleanup, but not required for core publishing flows. + +#### Done when + +- near-duplicate detection and semantic search are trustworthy on real projects + +### Bucket E: Translation QA And Documentation UX + +#### Scope + +- translation validation engine and report view +- in-app documentation browser +- richer scripting docs browser and examples + +#### Why extension + +Operationally valuable, but the core release can ship with generated markdown docs and without dedicated browsing surfaces. + +#### Done when + +- translation integrity issues are discoverable before publish +- docs are comfortably browsable in-app + +### Bucket F: Menu Editing And Deep Links + +#### Scope + +- menu editor UI for OPML/menu documents +- deep-link protocol handling beyond core app-open behaviors + +#### Why extension + +Core must read menu documents for rendering compatibility, but editing them can follow once the main authoring path is stable. + +#### Done when + +- users can inspect and edit menus from the Rust app +- deep links cover parity flows from the current app + +### Bucket G: MCP And Automation Surfaces + +#### Scope + +- headless CLI maturation +- MCP server +- remote automation contracts +- `NotificationWatcher` / `db_notifications` mechanism for CLI-to-app synchronization + +#### Why extension + +Useful ecosystem surface, not required to replace the desktop app itself. + +#### Done when + +- automation consumers can drive the Rust app safely and consistently +- CLI changes are detected and surfaced to the running desktop app + +### Bucket H: Blogmark And Transform Pipeline + +#### Scope + +- `BlogmarkTransformService` +- external content capture (bookmarklet) workflow +- transform script execution chain +- integration with Lua transform scripts + +#### Why extension + +Secondary content-capture workflow. Not required for core authoring with existing projects. + +#### Done when + +- external content can be captured and transformed into posts via the Blogmark pipeline +- transform scripts execute reliably + +### Bucket I: Rich Markdown Editor + +#### Scope + +- WYSIWYG or hybrid markdown editing (similar to current Milkdown editor) +- macro syntax preview in editor +- image insert dialog from linked media + +#### Why extension + +Core ships with the bds-editor syntax-highlighting plain-text editor and live preview. The current app defaults to a Milkdown WYSIWYG editor, so this is a user-facing regression that should be addressed after core stabilizes. + +#### Architecture advantage + +The bds-editor crate (ropey + syntect + cosmic-text) built during core provides the foundation for the rich editor. Incremental additions: + +- inline rendering of bold/italic/headers via cosmic-text mixed font styles +- inline image preview via Iced image rendering within the custom widget +- macro block preview (render macro output inline in the editor) +- clickable links + +This is an evolution of the existing editor widget, not a separate technology decision. + +#### Done when + +- users can edit content with a rich editor comparable to the current app's Milkdown experience + +### Bucket J: A2UI Server-Driven Surfaces + +#### Scope + +- A2UI component renderer (layout, input, display, chart, etc.) +- A2UI surface manager for bidirectional data flow +- integration with AI assistant outputs + +#### Why extension + +Tightly coupled to the AI feature set (Bucket C). Not required until AI features are active. + +#### Done when + +- AI-generated dynamic UI surfaces render correctly in the app + +## Suggested Extension Ordering + +```text +Bucket A Git + Validation + ↓ +Bucket B Import + ↓ +Bucket C AI + ↓ +Bucket D Embeddings + Duplicates + ↓ +Bucket E Translation QA + Docs UX + ↓ +Bucket F Menu Editing + Deep Links + ↓ +Bucket G MCP + Automation + ↓ +Bucket H Blogmark + Transforms + ↓ +Bucket I Rich Editor (builds on bds-editor from core) + ↓ +Bucket J A2UI Surfaces (after Bucket C) +``` + +The ordering is pragmatic, not mandatory. Git and validation are the closest to operational parity, so they should land first after core. + +## Extension Verification + +Every extension still inherits the core verification baseline plus extension-specific tests: + +- Git fixtures for repo state and diff rendering +- WXR fixtures for import +- mocked SSE and provider fixtures for AI +- embedding fixtures for semantic search and duplicate detection +- translation fixture projects with intentional integrity failures +- OPML fixtures for menu editing + +## Out Of Scope For Now + +- cross-platform packaging polish beyond what the core and extension work naturally require +- feature work that introduces new persistence formats before full parity is reached \ No newline at end of file diff --git a/ideas/recursive-sparking-haven.md b/ideas/recursive-sparking-haven.md new file mode 100644 index 0000000..12a6fa4 --- /dev/null +++ b/ideas/recursive-sparking-haven.md @@ -0,0 +1,303 @@ +# Feasibility Analysis: bDS Rewrite in Rust (Green-Field, No JavaScript) + +## Context + +Green-field rewrite of bDS blogging CMS in pure Rust. No migration — only the on-disc content format (Markdown + YAML frontmatter) must be preserved. Everything else is up for debate. + +**Constraints:** +- No JavaScript at all (supply chain security concern — no npm/webview/JS runtime) +- Python scripting replaced with Rust-native scripting language (Lua) +- WYSIWYG Markdown editor not needed — syntax-highlighting source editor is sufficient +- Mac is first-class, Windows slightly behind, Linux as compatibility option +- Built by team of AI agents, no timeline pressure +- Green-field: existing codebase is reference only, not migrated + +--- + +## Current Scale + +| Layer | LOC | Key Tech | +|-------|-----|----------| +| Main process (engines, IPC, DB) | ~45,800 | Node.js, 43+ engine classes, 257 IPC methods | +| Renderer (UI) | ~30,200 | React 19, Zustand, 80 components, Milkdown, Monaco | +| Tests | ~69,500 | Vitest, 210 test files | +| Dependencies | 54 runtime + 27 dev | AI SDK, Pyodide, sharp, git, MCP, etc. | + +--- + +## Rust GUI Framework Comparison (Mac-First) + +The "Mac must be first-class" constraint is critical. It reshuffles the ranking. + +### Framework Assessment + +| Framework | Maturity | Mac Quality | Editor Story | Widget Set | +|-----------|----------|-------------|-------------|------------| +| **GTK4-rs + libadwaita** | 9/10 | 6/10 — functional but non-native feel (no system menu bar by default, non-native scrolling, GTK file dialogs) | GtkSourceView (excellent) | Rich | +| **Iced** | 6/10 | 8/10 — GPU-rendered via Metal, consistent look, respects platform DPI. Not "native macOS" but consistent everywhere (like Electron was). | Custom needed (`syntect` + `ropey` + `cosmic-text`) | Growing, needs custom work | +| **Floem** | 5/10 | 7/10 — used in Lapce which runs well on Mac | Lapce has one but not extracted | Sparse | +| **Dioxus** | 4/10 | Variable | None | Tiny | +| **Slint** | 7/10 | 7/10 — native rendering support | None | Limited | +| **egui** | 8/10 | 7/10 — works well on Mac via wgpu | Wrong paradigm for editing | Immediate mode | + +### Mac-specific concerns + +**GTK4 on macOS:** +- Requires GTK4 installed (Homebrew/MacPorts) or bundled (~50MB overhead) +- Doesn't use native macOS menu bar by default (can be configured but finicky) +- Non-native scrolling physics, selection behavior, keyboard shortcuts +- File/print dialogs look GTK, not macOS +- Apps like GIMP/Inkscape run on Mac via GTK — functional but feel "foreign" + +**Iced on macOS:** +- Renders via wgpu/Metal — native GPU acceleration +- Consistent cross-platform look (no "foreign toolkit" feel) +- You control all UX conventions (can implement macOS cmd-shortcuts, native-feeling scrolling) +- No external toolkit dependency — statically compiled +- Binary stays small (~10-20MB) +- The trade-off: you build more widgets yourself, but AI agents can handle this + +### The editor situation + +With WYSIWYG dropped, a syntax-highlighting text editor is achievable in pure Rust: + +| Approach | Effort | Quality | +|----------|--------|---------| +| **GtkSourceView** (GTK4 only) | 0 weeks (built-in) | Production-grade, 50+ languages | +| **Custom iced widget** (`ropey` + `syntect` + `cosmic-text`) | 4-6 weeks | Good — proven building blocks, AI agents can build this | +| **Extract cosmic-edit** from COSMIC | 3-4 weeks | Untested outside COSMIC | + +For an iced-based app, a custom editor widget built from `ropey` (rope buffer) + `syntect` (highlighting) + `cosmic-text` (text layout/shaping) is realistic. This is how Lapce and COSMIC's editors work internally. + +### Recommendation: **Iced** + +Given Mac-first + no JS + green-field + AI agents building it: + +1. **Iced** gives the best Mac experience without GTK's foreign feel +2. Custom editor is feasible (4-6 weeks for AI agents using proven crates) +3. GPU rendering (wgpu/Metal) = smooth, modern feel on all platforms +4. Elm architecture = predictable state management, good for AI-generated code +5. No external toolkit dependency = simpler distribution +6. System76 backing ensures continued development + +--- + +## Scripting Language Replacement (Python → ?) + +| Language | Crate | Maturity | Speed | Ecosystem | Best For | +|----------|-------|----------|-------|-----------|----------| +| **Lua** | `mlua` | 10/10 | Very fast (LuaJIT) | Huge (30+ years, games, tools) | Battle-tested embedding, rich stdlib | +| **Rhai** | `rhai` | 8/10 | Moderate | Small but growing | Rust-native, safe sandbox, no FFI | +| **Starlark** | `starlark-rust` | 7/10 | Fast | Niche (Bazel/Buck) | Python-like syntax, deterministic | + +**Recommendation: Lua via `mlua`.** +- Most mature embedding story. Used by Neovim, Redis, nginx, game engines. +- `mlua` supports Lua 5.4 and LuaJIT. Sandboxing built in. +- Exposing engine methods to Lua is trivial (`mlua::UserData` trait). +- Distribution: Lua runtime is ~300KB, compiled into the binary. No external install needed. +- Existing Python macros would need rewriting, but Lua syntax is simple for end users. + +--- + +## Two Viable Paths + +### Path A: Iced (Recommended — Mac-first, fully native, zero JS) + +| Aspect | Assessment | +|--------|-----------| +| **JS purity** | 100%. No webview, no JS runtime, no npm. | +| **Rendering** | wgpu (Metal on Mac, Vulkan/DX12 elsewhere). Smooth, consistent everywhere. | +| **Markdown editor** | Custom widget: `ropey` + `syntect` + `cosmic-text`. Live preview pane via `pulldown-cmark` rendering. | +| **Code editor** | Same custom widget with Lua/Liquid/CSS language definitions via `syntect`. | +| **Widgets needed** | Tree view, resizable panes, tabs, modals, toasts, forms — all buildable in iced, some exist in `iced_aw`. | +| **Mac experience** | Native GPU, respects DPI, no foreign toolkit feel. cmd-key shortcuts, native-feeling scroll. | +| **Distribution** | Single static binary. No external deps. ~15-25MB. | +| **Risk** | Medium. Custom editor is the biggest piece. Iced ecosystem is growing but you build more yourself. | + +### Path B: GTK4-rs + GtkSourceView (Fastest, Linux-best) + +| Aspect | Assessment | +|--------|-----------| +| **JS purity** | 100%. | +| **Rendering** | Native GTK (Cairo/Vulkan). | +| **Editors** | GtkSourceView — zero work, production-grade. | +| **Widgets** | All built-in (TreeView, Paned, Notebook, etc.). | +| **Mac experience** | 6/10. Functional but feels non-native. Requires GTK4 installed via Homebrew. | +| **Distribution** | Requires GTK4 runtime on user machine (Mac/Windows). | +| **Risk** | Low for functionality, medium-high for Mac UX polish. | + +--- + +## Backend Subsystem Assessment (Green-Field) + +Since this is a rewrite, effort is for building clean, not porting messy. AI agents can generate verbose Rust boilerplate efficiently. + +### 1. Database — Easy +- **`rusqlite`** with `refinery` for migrations. Design schema fresh. +- On-disc format preserved: Markdown + YAML frontmatter files. DB is for indexing/metadata only. +- **Key crates:** `rusqlite`, `serde`, `serde_yaml`, `gray_matter` or manual YAML parsing +- **Cost:** 2-3 weeks + +### 2. Core Engine Layer — Medium +- Fresh design with Rust idioms (traits, enums, Result). No need to mirror 43 TS classes. +- **No WXR import** — dropped entirely. +- **Key crates:** `pulldown-cmark` (Markdown), `liquid` (templates), `image` (thumbnails/WEBP), `git2` (Git, no LFS — shell out for LFS ops), `notify` (file watching), `uuid`, `serde_json` +- **Cost:** 8-12 weeks + +### 3. AI/LLM Integration — Easy ✅ (simplified) +- **OpenAI-compatible endpoint only.** Single wire format (Chat Completions API). +- Single-shot calls only (no streaming, no chat, no tool call loops). Used for: translation, image description, title generation. +- **`reqwest`** + `serde_json` + OpenAI request/response structs. ~500 LOC. +- Works with OpenCode Zen, local Ollama, any OpenAI-compatible proxy. +- **Cost:** 1-2 weeks + +### 4. Lua Scripting — Easy +- **`mlua`** crate: embed Lua 5.4. Sandboxed, ~300KB. Expose engine APIs via `UserData` trait. +- **Cost:** 2-3 weeks + +### 5. Static Site Generation — Medium +- `pulldown-cmark` → HTML, `liquid` templates, `rayon` for parallel generation +- Pagefind CLI for search index (unchanged, it's a binary) +- RSS/Atom/sitemap generation via `quick-xml` +- **Cost:** 4-6 weeks + +### 6. Publishing — Easy +- SSH: `ssh2` crate. rsync: shell out to `rsync`. Git: `git2`. +- **Cost:** 1-2 weeks + +### 7. Embeddings & Similarity — Medium +- `ort` (ONNX Runtime) for local embeddings. `usearch` Rust bindings for HNSW index. +- **Cost:** 2-3 weeks + +### 8. MCP Server — Optional (v2) +- Deferred. Not needed for v1. +- **Cost:** 0 weeks (v1) + +### 9. Preview Server — Easy +- `axum` HTTP server serving generated HTML + media. +- **Cost:** 1-2 weeks + +### 10. UI (Iced) — Hard (largest piece) +- Custom editor widget: `ropey` + `syntect` + `cosmic-text` (4-6 weeks) +- Application chrome: tree sidebar, tab bar, resizable panes, settings, modals (6-8 weeks) +- Chat panel, AI integration UI (2-3 weeks) +- Git diff view, validation views, import wizard (3-4 weeks) +- **Cost:** 15-21 weeks + +### 11. Tests — Medium +- Rust `#[test]` + `mockall` for traits. AI agents generate test boilerplate well. +- Test as you build (TDD per CLAUDE.md rules) +- **Cost:** Folded into each subsystem (add ~40% to each estimate) + +--- + +## What Gets Better (All Paths) + +1. **Binary size:** 5-15MB vs 150-200MB +2. **Memory:** ~50-80% less RAM (no Chromium main process) +3. **Startup:** Significantly faster +4. **Generation perf:** `rayon` + `pulldown-cmark` + `liquid` = faster site generation +5. **Type safety:** Compile-time guarantees on everything +6. **Security:** Memory-safe, smaller supply-chain surface + +## What Gets Worse (All Paths) + +1. **Dev velocity:** Rust is slower to iterate than TypeScript +2. **AI SDK:** Maintain your own streaming multi-provider SDK +3. **Macro migration:** Existing user Python macros must be rewritten in Lua +4. **Talent pool:** Smaller contributor base + +--- + +## Effort Summary (Green-Field with AI Agents) + +| Subsystem | Weeks (with tests) | +|-----------|-------------------| +| Database + on-disc format | 3-4 | +| Core engines (posts, media, tags, projects) | 11-17 | +| AI/LLM (single-shot, OpenAI-compat) | 1-2 | +| Lua scripting | 3-4 | +| Static site generation | 6-8 | +| Publishing + Git | 2-3 | +| Embeddings + search | 3-4 | +| Preview server | 1-2 | +| **UI (Iced)** | **21-29** | +| Integration + polish | 4-6 | +| **Total** | **55-79 person-weeks** | + +Compared to original estimate (76-103 weeks): **dropping WXR import, streaming chat, multi-provider AI, and MCP server saves ~20 weeks.** + +With AI agents running 2-3 parallel workstreams: **4-8 months** of active sessions. + +--- + +## Recommended Architecture: Iced + Rust + +``` +bds-rust/ +├── crates/ +│ ├── bds-core/ # Domain types, traits, on-disc format (Markdown+YAML) +│ ├── bds-db/ # SQLite layer (rusqlite + refinery migrations) +│ ├── bds-engine/ # Business logic (posts, media, tags, projects, generation) +│ ├── bds-ai/ # Multi-provider LLM client (streaming, tool calls) +│ ├── bds-lua/ # Lua scripting runtime (mlua) +│ ├── bds-git/ # Git operations (git2 + LFS shell-out) +│ ├── bds-publish/ # SSH/rsync deployment +│ ├── bds-search/ # Embeddings (ort) + similarity (usearch) + pagefind +│ ├── bds-mcp/ # MCP server +│ ├── bds-preview/ # HTTP preview server (axum) +│ └── bds-editor/ # Syntax-highlighting editor widget (ropey+syntect+cosmic-text) +├── src/ # Iced application (UI, state, views) +├── assets/ # Icons, themes +└── Cargo.toml # Workspace +``` + +**Key Rust crates:** +| Purpose | Crate | +|---------|-------| +| GUI framework | `iced` | +| Text buffer | `ropey` | +| Syntax highlighting | `syntect` | +| Text layout | `cosmic-text` | +| Database | `rusqlite` | +| Migrations | `refinery` | +| Markdown → HTML | `pulldown-cmark` | +| Templates | `liquid` | +| YAML frontmatter | `serde_yaml` | +| Image processing | `image` | +| HTTP server (preview) | `axum` | +| HTTP client (AI) | `reqwest` | +| Git | `git2` | +| SSH | `ssh2` | +| File watching | `notify` | +| Lua scripting | `mlua` | +| ONNX inference | `ort` | +| Vector search | `usearch` | +| Async runtime | `tokio` | +| Parallelism | `rayon` | +| Serialization | `serde` + `serde_json` | +| UUID | `uuid` | +| Error handling | `thiserror` + `anyhow` | + +--- + +## Verdict + +**Feasible: Yes.** With the constraints resolved (no WYSIWYG, Lua instead of Python, green-field, AI agents, no timeline): + +- Every subsystem has proven Rust crate equivalents +- The hardest piece is the custom editor widget (~4-6 weeks) and AI streaming client (~5-7 weeks) +- Iced gives Mac-first cross-platform GUI without GTK's foreign feel or any JS runtime +- Single static binary, ~15-25MB, zero external dependencies +- AI agents are well-suited to Rust's verbose but structured patterns + +**The main risk** is Iced's maturity (6/10). It's real software backing a real desktop environment (COSMIC), but the widget ecosystem is young. You will build things that React gave you for free. With AI agents and no deadline, this is acceptable. + +--- + +## Remaining Questions + +1. Confirm Iced vs GTK4-rs preference? (Iced = better Mac, more custom work. GTK = more widgets, worse Mac feel.) +2. Which AI providers are must-have? (Anthropic + OpenAI + Ollama? Or all 5?) +3. Should the MCP server remain, or is it optional for v1? +4. Is WordPress WXR import a v1 requirement? diff --git a/specs/ai.allium b/specs/ai.allium new file mode 100644 index 0000000..d2f4dba --- /dev/null +++ b/specs/ai.allium @@ -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 -- 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 +} diff --git a/specs/bds.allium b/specs/bds.allium new file mode 100644 index 0000000..d9584c9 --- /dev/null +++ b/specs/bds.allium @@ -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. diff --git a/specs/cli_sync.allium b/specs/cli_sync.allium new file mode 100644 index 0000000..544db80 --- /dev/null +++ b/specs/cli_sync.allium @@ -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 +} diff --git a/specs/embedding.allium b/specs/embedding.allium new file mode 100644 index 0000000..15b4490 --- /dev/null +++ b/specs/embedding.allium @@ -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 +} + +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 +} diff --git a/specs/generation.allium b/specs/generation.allium new file mode 100644 index 0000000..0c021b9 --- /dev/null +++ b/specs/generation.allium @@ -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 + max_posts_per_page: Integer + pico_theme: String? + sections: Set + + -- 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 +} diff --git a/specs/git.allium b/specs/git.allium new file mode 100644 index 0000000..e248b31 --- /dev/null +++ b/specs/git.allium @@ -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) +} diff --git a/specs/i18n.allium b/specs/i18n.allium new file mode 100644 index 0000000..a4d1759 --- /dev/null +++ b/specs/i18n.allium @@ -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(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 +} diff --git a/specs/mcp.allium b/specs/mcp.allium new file mode 100644 index 0000000..ebc88d0 --- /dev/null +++ b/specs/mcp.allium @@ -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) +} diff --git a/specs/media.allium b/specs/media.allium new file mode 100644 index 0000000..30bd500 --- /dev/null +++ b/specs/media.allium @@ -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 + 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 +} diff --git a/specs/menu.allium b/specs/menu.allium new file mode 100644 index 0000000..e1cd1b8 --- /dev/null +++ b/specs/menu.allium @@ -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? -- only for submenu kind +} + +entity Menu { + items: List + + -- 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) +} diff --git a/specs/metadata.allium b/specs/metadata.allium new file mode 100644 index 0000000..702ff5c --- /dev/null +++ b/specs/metadata.allium @@ -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 -- subset of supported languages + categories: Set -- category names + category_settings: Set +} + +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 = {"article", "picture", "aside", "page"} + supported_pico_themes: Set = { + "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) +} diff --git a/specs/metadata_diff.allium b/specs/metadata_diff.allium new file mode 100644 index 0000000..961cac3 --- /dev/null +++ b/specs/metadata_diff.allium @@ -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 +} + +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) +} diff --git a/specs/post.allium b/specs/post.allium new file mode 100644 index 0000000..8918170 --- /dev/null +++ b/specs/post.allium @@ -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 + categories: Set + 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) diff --git a/specs/preview.allium b/specs/preview.allium new file mode 100644 index 0000000..8ab5cf7 --- /dev/null +++ b/specs/preview.allium @@ -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 +} diff --git a/specs/project.allium b/specs/project.allium new file mode 100644 index 0000000..ea5cdf0 --- /dev/null +++ b/specs/project.allium @@ -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" +} diff --git a/specs/publishing.allium b/specs/publishing.allium new file mode 100644 index 0000000..caa2f05 --- /dev/null +++ b/specs/publishing.allium @@ -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 +} diff --git a/specs/script.allium b/specs/script.allium new file mode 100644 index 0000000..7bde1cf --- /dev/null +++ b/specs/script.allium @@ -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) +} diff --git a/specs/search.allium b/specs/search.allium new file mode 100644 index 0000000..ef30ede --- /dev/null +++ b/specs/search.allium @@ -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)) +} diff --git a/specs/tag.allium b/specs/tag.allium new file mode 100644 index 0000000..f93cb2e --- /dev/null +++ b/specs/tag.allium @@ -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) +} diff --git a/specs/task.allium b/specs/task.allium new file mode 100644 index 0000000..2b54408 --- /dev/null +++ b/specs/task.allium @@ -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 +} diff --git a/specs/template.allium b/specs/template.allium new file mode 100644 index 0000000..82a6413 --- /dev/null +++ b/specs/template.allium @@ -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 +} diff --git a/specs/translation.allium b/specs/translation.allium new file mode 100644 index 0000000..11aa605 --- /dev/null +++ b/specs/translation.allium @@ -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) +}