feat: first cut on M0 - base structure of project
This commit is contained in:
@@ -33,7 +33,7 @@ Invariants and behaviours in the allium spec should be covered by unit tests of
|
||||
|
||||
## important behaviour
|
||||
|
||||
- HEREDOCs don't work most of the time. Don't use them. Use editor tools to create proper scripots
|
||||
- HEREDOCs don't work most of the time. Don't use them. Use editor tools to create proper scripts
|
||||
- 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
|
||||
|
||||
6123
Cargo.lock
generated
Normal file
6123
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
Cargo.toml
Normal file
45
Cargo.toml
Normal file
@@ -0,0 +1,45 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/bds-core",
|
||||
"crates/bds-editor",
|
||||
"crates/bds-ui",
|
||||
"crates/bds-cli",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
version = "0.1.0"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Foundation
|
||||
rusqlite = { version = "0.33", features = ["bundled", "vtab"] }
|
||||
refinery = { version = "0.8", features = ["rusqlite"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
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"
|
||||
|
||||
# Internal crates
|
||||
bds-core = { path = "crates/bds-core" }
|
||||
bds-editor = { path = "crates/bds-editor" }
|
||||
@@ -180,7 +180,7 @@ Rules:
|
||||
### `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`)
|
||||
- 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` is property access on arrays, not a pipe filter)
|
||||
- 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)
|
||||
|
||||
10
crates/bds-cli/Cargo.toml
Normal file
10
crates/bds-cli/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "bds-cli"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bds-core = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
3
crates/bds-cli/src/main.rs
Normal file
3
crates/bds-cli/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("bds-cli: headless automation surface (not yet implemented)");
|
||||
}
|
||||
21
crates/bds-core/Cargo.toml
Normal file
21
crates/bds-core/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "bds-core"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { workspace = true }
|
||||
refinery = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
deunicode = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
walkdir = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
49
crates/bds-core/src/db/connection.rs
Normal file
49
crates/bds-core/src/db/connection.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use rusqlite::Connection;
|
||||
use std::path::Path;
|
||||
use crate::db::migrations;
|
||||
|
||||
/// Database wrapper managing a SQLite connection.
|
||||
pub struct Database {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Open an existing bDS project database.
|
||||
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
/// Open an in-memory database (for tests).
|
||||
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON;")?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
/// Get a reference to the underlying connection.
|
||||
pub fn conn(&self) -> &Connection {
|
||||
&self.conn
|
||||
}
|
||||
|
||||
/// Run all pending migrations.
|
||||
pub fn migrate(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
migrations::run_migrations(&self.conn)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_in_memory() {
|
||||
let db = Database::open_in_memory().expect("should open in-memory db");
|
||||
let result: i64 = db
|
||||
.conn()
|
||||
.query_row("SELECT 1", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(result, 1);
|
||||
}
|
||||
}
|
||||
22
crates/bds-core/src/db/migrations.rs
Normal file
22
crates/bds-core/src/db/migrations.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Run all embedded migrations against the given connection.
|
||||
///
|
||||
/// For M0, this is a stub. Once we have the full schema from the TypeScript
|
||||
/// app's migrations, we will embed them via refinery.
|
||||
pub fn run_migrations(conn: &Connection) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// TODO(M0): Embed real migrations from the TypeScript app's schema.
|
||||
// For now, create the minimal tables needed for read-access verification.
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
5
crates/bds-core/src/db/mod.rs
Normal file
5
crates/bds-core/src/db/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod connection;
|
||||
mod migrations;
|
||||
|
||||
pub use connection::Database;
|
||||
pub use migrations::run_migrations;
|
||||
1
crates/bds-core/src/engine/mod.rs
Normal file
1
crates/bds-core/src/engine/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
// Engine modules — stubs for M0, implemented in M1.
|
||||
3
crates/bds-core/src/i18n/mod.rs
Normal file
3
crates/bds-core/src/i18n/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Internationalization — split localization support.
|
||||
// ui_locale: follows OS locale (menus, dialogs, toasts).
|
||||
// content_language: follows project settings (rendering, preview, feeds).
|
||||
6
crates/bds-core/src/lib.rs
Normal file
6
crates/bds-core/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod db;
|
||||
pub mod engine;
|
||||
pub mod model;
|
||||
pub mod render;
|
||||
pub mod i18n;
|
||||
pub mod util;
|
||||
38
crates/bds-core/src/model/generation.rs
Normal file
38
crates/bds-core/src/model/generation.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Tracks content hashes of generated files to skip unchanged writes.
|
||||
/// Matches the `generated_file_hashes` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeneratedFileHash {
|
||||
pub project_id: String,
|
||||
pub relative_path: String,
|
||||
pub content_hash: String,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// Notification for CLI-to-app synchronization.
|
||||
/// Matches the `db_notifications` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DbNotification {
|
||||
pub id: i64,
|
||||
pub entity: String,
|
||||
pub entity_id: String,
|
||||
pub action: String,
|
||||
pub from_cli: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub seen_at: Option<i64>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// Publishing preferences stored in meta/publishing.json.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PublishingPreferences {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ssh_host: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ssh_user: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ssh_remote_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ssh_mode: Option<String>,
|
||||
}
|
||||
54
crates/bds-core/src/model/media.rs
Normal file
54
crates/bds-core/src/model/media.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A media item (image, video, etc.).
|
||||
/// Matches the `media` table schema.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Media {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub filename: String,
|
||||
pub original_name: String,
|
||||
pub mime_type: String,
|
||||
pub size: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub width: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub height: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caption: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
pub file_path: String,
|
||||
pub sidecar_path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checksum: Option<String>,
|
||||
/// JSON-serialized string array in DB.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// A translation of media metadata into another language.
|
||||
/// Matches the `media_translations` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MediaTranslation {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub translation_for: String,
|
||||
pub language: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caption: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
15
crates/bds-core/src/model/mod.rs
Normal file
15
crates/bds-core/src/model/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
mod post;
|
||||
mod media;
|
||||
mod tag;
|
||||
mod project;
|
||||
mod template;
|
||||
mod script;
|
||||
mod generation;
|
||||
|
||||
pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation};
|
||||
pub use media::{Media, MediaTranslation};
|
||||
pub use tag::Tag;
|
||||
pub use project::{Project, Setting};
|
||||
pub use template::Template;
|
||||
pub use script::Script;
|
||||
pub use generation::{DbNotification, GeneratedFileHash, PublishingPreferences};
|
||||
107
crates/bds-core/src/model/post.rs
Normal file
107
crates/bds-core/src/model/post.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PostStatus {
|
||||
Draft,
|
||||
Published,
|
||||
Archived,
|
||||
}
|
||||
|
||||
/// A blog post. Matches the `posts` table schema.
|
||||
///
|
||||
/// NOTE: `content` is null for published posts — body lives in the filesystem
|
||||
/// `.md` file only. Draft content is stored in DB.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Post {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excerpt: Option<String>,
|
||||
/// Draft body text. Null/empty when published (content is in the file).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
pub status: PostStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
#[serde(default)]
|
||||
pub do_not_translate: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub template_slug: Option<String>,
|
||||
pub file_path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checksum: Option<String>,
|
||||
/// JSON-serialized string array in DB.
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// JSON-serialized string array in DB.
|
||||
#[serde(default)]
|
||||
pub categories: Vec<String>,
|
||||
// Published snapshot fields (used for diff detection)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub published_title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub published_content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub published_tags: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub published_categories: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub published_excerpt: Option<String>,
|
||||
/// Unix timestamp (integer in DB).
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub published_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// A translation of a post into another language.
|
||||
/// Matches the `post_translations` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PostTranslation {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub translation_for: String,
|
||||
pub language: String,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excerpt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
pub status: PostStatus,
|
||||
pub file_path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checksum: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub published_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// A link between two posts (tracked for backlinks).
|
||||
/// Matches the `post_links` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PostLink {
|
||||
pub id: String,
|
||||
pub source_post_id: String,
|
||||
pub target_post_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link_text: Option<String>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// Association between a post and media item.
|
||||
/// Matches the `post_media` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PostMedia {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub post_id: String,
|
||||
pub media_id: String,
|
||||
pub sort_order: i32,
|
||||
pub created_at: i64,
|
||||
}
|
||||
24
crates/bds-core/src/model/project.rs
Normal file
24
crates/bds-core/src/model/project.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A bDS project. Matches the `projects` table schema.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data_path: Option<String>,
|
||||
pub is_active: bool,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// Key-value settings. Matches the `settings` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Setting {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
21
crates/bds-core/src/model/script.rs
Normal file
21
crates/bds-core/src/model/script.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A user-authored script. Matches the `scripts` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Script {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
/// "macro", "transform", or "utility"
|
||||
pub kind: String,
|
||||
pub entrypoint: String,
|
||||
pub enabled: bool,
|
||||
pub version: i32,
|
||||
pub file_path: String,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
14
crates/bds-core/src/model/tag.rs
Normal file
14
crates/bds-core/src/model/tag.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tag {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub post_template_slug: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
19
crates/bds-core/src/model/template.rs
Normal file
19
crates/bds-core/src/model/template.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A Liquid template. Matches the `templates` table.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Template {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub kind: String,
|
||||
pub enabled: bool,
|
||||
pub version: i32,
|
||||
pub file_path: String,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
1
crates/bds-core/src/render/mod.rs
Normal file
1
crates/bds-core/src/render/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
// Rendering pipeline — stubs for M0, implemented in M4.
|
||||
1
crates/bds-core/src/scripting/mod.rs
Normal file
1
crates/bds-core/src/scripting/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
// Scripting (Lua) — stubs for M0, implemented in M5/Wave 6.
|
||||
48
crates/bds-core/src/util/checksum.rs
Normal file
48
crates/bds-core/src/util/checksum.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Compute a hex-encoded SHA-256 hash of the given content.
|
||||
pub fn content_hash(content: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(content);
|
||||
let result = hasher.finalize();
|
||||
hex::encode(result)
|
||||
}
|
||||
|
||||
// sha2 doesn't include hex encoding, so we use a tiny inline helper.
|
||||
mod hex {
|
||||
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
|
||||
|
||||
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
|
||||
let bytes = bytes.as_ref();
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for &b in bytes {
|
||||
s.push(HEX_CHARS[(b >> 4) as usize] as char);
|
||||
s.push(HEX_CHARS[(b & 0xf) as usize] as char);
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_hash() {
|
||||
// SHA-256 of "hello"
|
||||
let hash = content_hash(b"hello");
|
||||
assert_eq!(
|
||||
hash,
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_hash() {
|
||||
let hash = content_hash(b"");
|
||||
assert_eq!(
|
||||
hash,
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
}
|
||||
}
|
||||
5
crates/bds-core/src/util/mod.rs
Normal file
5
crates/bds-core/src/util/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod slug;
|
||||
mod checksum;
|
||||
|
||||
pub use slug::slugify;
|
||||
pub use checksum::content_hash;
|
||||
61
crates/bds-core/src/util/slug.rs
Normal file
61
crates/bds-core/src/util/slug.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use deunicode::deunicode;
|
||||
|
||||
/// Generate a URL-safe slug from a title string.
|
||||
///
|
||||
/// Transliterates Unicode to ASCII, lowercases, replaces non-alphanumeric
|
||||
/// chars with hyphens, and collapses/trims hyphens.
|
||||
pub fn slugify(input: &str) -> String {
|
||||
let ascii = deunicode(input);
|
||||
let lowered = ascii.to_lowercase();
|
||||
let mut slug = String::with_capacity(lowered.len());
|
||||
let mut prev_hyphen = true; // avoid leading hyphen
|
||||
for c in lowered.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
slug.push(c);
|
||||
prev_hyphen = false;
|
||||
} else if !prev_hyphen {
|
||||
slug.push('-');
|
||||
prev_hyphen = true;
|
||||
}
|
||||
}
|
||||
// Trim trailing hyphen
|
||||
if slug.ends_with('-') {
|
||||
slug.pop();
|
||||
}
|
||||
slug
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn basic_slug() {
|
||||
assert_eq!(slugify("Hello World"), "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_slug() {
|
||||
assert_eq!(slugify("Über die Brücke"), "uber-die-brucke");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_chars() {
|
||||
assert_eq!(slugify("What's up? (2024)"), "what-s-up-2024");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_clean() {
|
||||
assert_eq!(slugify("already-clean"), "already-clean");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input() {
|
||||
assert_eq!(slugify(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_special_chars() {
|
||||
assert_eq!(slugify("a --- b"), "a-b");
|
||||
}
|
||||
}
|
||||
11
crates/bds-editor/Cargo.toml
Normal file
11
crates/bds-editor/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "bds-editor"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
iced = { workspace = true }
|
||||
cosmic-text = { workspace = true }
|
||||
ropey = { workspace = true }
|
||||
syntect = { workspace = true }
|
||||
152
crates/bds-editor/src/buffer.rs
Normal file
152
crates/bds-editor/src/buffer.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
use ropey::Rope;
|
||||
|
||||
/// Rope-based text buffer with edit operations.
|
||||
pub struct EditorBuffer {
|
||||
rope: Rope,
|
||||
/// Cursor byte offset within the rope.
|
||||
cursor_line: usize,
|
||||
cursor_col: usize,
|
||||
}
|
||||
|
||||
impl EditorBuffer {
|
||||
pub fn new(text: &str) -> Self {
|
||||
Self {
|
||||
rope: Rope::from_str(text),
|
||||
cursor_line: 0,
|
||||
cursor_col: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rope(&self) -> &Rope {
|
||||
&self.rope
|
||||
}
|
||||
|
||||
pub fn text(&self) -> String {
|
||||
self.rope.to_string()
|
||||
}
|
||||
|
||||
pub fn line_count(&self) -> usize {
|
||||
self.rope.len_lines()
|
||||
}
|
||||
|
||||
pub fn line(&self, idx: usize) -> Option<ropey::RopeSlice<'_>> {
|
||||
if idx < self.rope.len_lines() {
|
||||
Some(self.rope.line(idx))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cursor(&self) -> (usize, usize) {
|
||||
(self.cursor_line, self.cursor_col)
|
||||
}
|
||||
|
||||
pub fn set_cursor(&mut self, line: usize, col: usize) {
|
||||
self.cursor_line = line.min(self.line_count().saturating_sub(1));
|
||||
let line_len = self.current_line_len();
|
||||
self.cursor_col = col.min(line_len);
|
||||
}
|
||||
|
||||
/// Insert text at the current cursor position.
|
||||
pub fn insert(&mut self, text: &str) {
|
||||
let char_idx = self.cursor_char_idx();
|
||||
self.rope.insert(char_idx, text);
|
||||
// Advance cursor past inserted text
|
||||
for c in text.chars() {
|
||||
if c == '\n' {
|
||||
self.cursor_line += 1;
|
||||
self.cursor_col = 0;
|
||||
} else {
|
||||
self.cursor_col += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete one character before the cursor (backspace).
|
||||
pub fn backspace(&mut self) {
|
||||
if self.cursor_col > 0 {
|
||||
let char_idx = self.cursor_char_idx();
|
||||
self.rope.remove(char_idx - 1..char_idx);
|
||||
self.cursor_col -= 1;
|
||||
} else if self.cursor_line > 0 {
|
||||
// Join with previous line
|
||||
let prev_line_len = self
|
||||
.rope
|
||||
.line(self.cursor_line - 1)
|
||||
.len_chars()
|
||||
.saturating_sub(1); // subtract newline
|
||||
let char_idx = self.cursor_char_idx();
|
||||
self.rope.remove(char_idx - 1..char_idx);
|
||||
self.cursor_line -= 1;
|
||||
self.cursor_col = prev_line_len;
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_char_idx(&self) -> usize {
|
||||
let line_start = self.rope.line_to_char(self.cursor_line);
|
||||
line_start + self.cursor_col
|
||||
}
|
||||
|
||||
fn current_line_len(&self) -> usize {
|
||||
if self.cursor_line < self.rope.len_lines() {
|
||||
let line = self.rope.line(self.cursor_line);
|
||||
let len = line.len_chars();
|
||||
// Subtract trailing newline if present
|
||||
if len > 0 && line.char(len - 1) == '\n' {
|
||||
len - 1
|
||||
} else {
|
||||
len
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_buffer() {
|
||||
let buf = EditorBuffer::new("hello\nworld");
|
||||
assert_eq!(buf.line_count(), 2);
|
||||
assert_eq!(buf.cursor(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_text() {
|
||||
let mut buf = EditorBuffer::new("hello");
|
||||
buf.set_cursor(0, 5);
|
||||
buf.insert(" world");
|
||||
assert_eq!(buf.text(), "hello world");
|
||||
assert_eq!(buf.cursor(), (0, 11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace() {
|
||||
let mut buf = EditorBuffer::new("hello");
|
||||
buf.set_cursor(0, 5);
|
||||
buf.backspace();
|
||||
assert_eq!(buf.text(), "hell");
|
||||
assert_eq!(buf.cursor(), (0, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_newline() {
|
||||
let mut buf = EditorBuffer::new("hello");
|
||||
buf.set_cursor(0, 5);
|
||||
buf.insert("\n");
|
||||
assert_eq!(buf.line_count(), 2);
|
||||
assert_eq!(buf.cursor(), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_at_line_start_joins_lines() {
|
||||
let mut buf = EditorBuffer::new("hello\nworld");
|
||||
buf.set_cursor(1, 0);
|
||||
buf.backspace();
|
||||
assert_eq!(buf.text(), "helloworld");
|
||||
assert_eq!(buf.cursor(), (0, 5));
|
||||
}
|
||||
}
|
||||
81
crates/bds-editor/src/highlight.rs
Normal file
81
crates/bds-editor/src/highlight.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use syntect::highlighting::{Style, ThemeSet};
|
||||
use syntect::parsing::{SyntaxReference, SyntaxSet};
|
||||
|
||||
/// Syntax highlighter using syntect.
|
||||
pub struct Highlighter {
|
||||
syntax_set: SyntaxSet,
|
||||
theme_set: ThemeSet,
|
||||
theme_name: String,
|
||||
}
|
||||
|
||||
/// A line of highlighted text: spans of (style, text).
|
||||
pub type HighlightedLine = Vec<(Style, String)>;
|
||||
|
||||
impl Highlighter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
syntax_set: SyntaxSet::load_defaults_newlines(),
|
||||
theme_set: ThemeSet::load_defaults(),
|
||||
theme_name: "base16-ocean.dark".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the syntax definition for a file extension.
|
||||
pub fn syntax_for_extension(&self, ext: &str) -> &SyntaxReference {
|
||||
self.syntax_set
|
||||
.find_syntax_by_extension(ext)
|
||||
.unwrap_or_else(|| self.syntax_set.find_syntax_plain_text())
|
||||
}
|
||||
|
||||
/// Highlight all lines of the given text for a specific syntax.
|
||||
pub fn highlight_lines(&self, text: &str, syntax: &SyntaxReference) -> Vec<HighlightedLine> {
|
||||
use syntect::easy::HighlightLines;
|
||||
|
||||
let theme = &self.theme_set.themes[&self.theme_name];
|
||||
let mut h = HighlightLines::new(syntax, theme);
|
||||
let mut result = Vec::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let line_with_newline = format!("{line}\n");
|
||||
let ranges = h
|
||||
.highlight_line(&line_with_newline, &self.syntax_set)
|
||||
.unwrap_or_default();
|
||||
let styled: HighlightedLine = ranges
|
||||
.into_iter()
|
||||
.map(|(style, text)| (style, text.to_string()))
|
||||
.collect();
|
||||
result.push(styled);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Highlighter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn highlight_markdown() {
|
||||
let h = Highlighter::new();
|
||||
let syntax = h.syntax_for_extension("md");
|
||||
let lines = h.highlight_lines("# Hello\n\nSome text.", syntax);
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert!(!lines[0].is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_fallback() {
|
||||
let h = Highlighter::new();
|
||||
let syntax = h.syntax_for_extension("nonexistent");
|
||||
// Should not panic, falls back to plain text
|
||||
let lines = h.highlight_lines("hello", syntax);
|
||||
assert_eq!(lines.len(), 1);
|
||||
}
|
||||
}
|
||||
7
crates/bds-editor/src/lib.rs
Normal file
7
crates/bds-editor/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod buffer;
|
||||
mod highlight;
|
||||
mod widget;
|
||||
|
||||
pub use buffer::EditorBuffer;
|
||||
pub use highlight::Highlighter;
|
||||
pub use widget::CodeEditor;
|
||||
130
crates/bds-editor/src/widget.rs
Normal file
130
crates/bds-editor/src/widget.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use iced::advanced::layout::{self, Layout};
|
||||
use iced::advanced::renderer;
|
||||
use iced::advanced::widget::{self, Widget};
|
||||
use iced::advanced::{Clipboard, Shell};
|
||||
use iced::event::Status;
|
||||
use iced::mouse;
|
||||
use iced::{Color, Element, Event, Length, Rectangle, Size, Theme};
|
||||
|
||||
use crate::buffer::EditorBuffer;
|
||||
use crate::highlight::Highlighter;
|
||||
|
||||
/// Messages emitted by the CodeEditor widget.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EditorMessage {
|
||||
ContentChanged(String),
|
||||
}
|
||||
|
||||
/// A syntax-highlighting code editor widget for Iced.
|
||||
///
|
||||
/// M0 proof of concept: renders highlighted text with line numbers.
|
||||
/// Full editing (cursor, input, selection) follows in M3.
|
||||
pub struct CodeEditor<'a> {
|
||||
buffer: &'a EditorBuffer,
|
||||
highlighter: &'a Highlighter,
|
||||
extension: &'a str,
|
||||
line_height: f32,
|
||||
char_width: f32,
|
||||
gutter_width: f32,
|
||||
}
|
||||
|
||||
impl<'a> CodeEditor<'a> {
|
||||
pub fn new(
|
||||
buffer: &'a EditorBuffer,
|
||||
highlighter: &'a Highlighter,
|
||||
extension: &'a str,
|
||||
) -> Self {
|
||||
Self {
|
||||
buffer,
|
||||
highlighter,
|
||||
extension,
|
||||
line_height: 20.0,
|
||||
char_width: 8.4,
|
||||
gutter_width: 50.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Renderer> Widget<Message, Theme, Renderer> for CodeEditor<'a>
|
||||
where
|
||||
Renderer: renderer::Renderer,
|
||||
{
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size::new(Length::Fill, Length::Fill)
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&self,
|
||||
_tree: &mut widget::Tree,
|
||||
_renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
let size = limits.max();
|
||||
layout::Node::new(size)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
_tree: &widget::Tree,
|
||||
renderer: &mut Renderer,
|
||||
_theme: &Theme,
|
||||
_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
_cursor: mouse::Cursor,
|
||||
_viewport: &Rectangle,
|
||||
) {
|
||||
let bounds = layout.bounds();
|
||||
|
||||
// Draw background
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds,
|
||||
border: iced::Border::default(),
|
||||
shadow: iced::Shadow::default(),
|
||||
},
|
||||
Color::from_rgb(0.18, 0.20, 0.25),
|
||||
);
|
||||
|
||||
// Draw gutter background
|
||||
let gutter_bounds = Rectangle {
|
||||
width: self.gutter_width,
|
||||
..bounds
|
||||
};
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: gutter_bounds,
|
||||
border: iced::Border::default(),
|
||||
shadow: iced::Shadow::default(),
|
||||
},
|
||||
Color::from_rgb(0.15, 0.17, 0.21),
|
||||
);
|
||||
|
||||
// Note: Full text rendering with cosmic-text will be added when
|
||||
// we integrate cosmic-text's Buffer for font shaping and layout.
|
||||
// For M0 PoC, we verify the widget mounts and draws backgrounds.
|
||||
}
|
||||
|
||||
fn on_event(
|
||||
&mut self,
|
||||
_state: &mut widget::Tree,
|
||||
_event: Event,
|
||||
_layout: Layout<'_>,
|
||||
_cursor: mouse::Cursor,
|
||||
_renderer: &Renderer,
|
||||
_clipboard: &mut dyn Clipboard,
|
||||
_shell: &mut Shell<'_, Message>,
|
||||
_viewport: &Rectangle,
|
||||
) -> Status {
|
||||
Status::Ignored
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Renderer> From<CodeEditor<'a>> for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Renderer: renderer::Renderer + 'a,
|
||||
Message: 'a,
|
||||
{
|
||||
fn from(editor: CodeEditor<'a>) -> Self {
|
||||
Self::new(editor)
|
||||
}
|
||||
}
|
||||
19
crates/bds-ui/Cargo.toml
Normal file
19
crates/bds-ui/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "bds-ui"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bds-core = { workspace = true }
|
||||
bds-editor = { workspace = true }
|
||||
iced = { workspace = true }
|
||||
muda = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-foundation = "0.3"
|
||||
objc2-app-kit = "0.3"
|
||||
48
crates/bds-ui/src/app.rs
Normal file
48
crates/bds-ui/src/app.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use iced::widget::{column, container, text};
|
||||
use iced::{Element, Length, Subscription, Task};
|
||||
|
||||
use crate::platform::menu;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
MenuEvent(muda::MenuId),
|
||||
Noop,
|
||||
}
|
||||
|
||||
pub struct BdsApp {
|
||||
_menu_bar: muda::Menu,
|
||||
}
|
||||
|
||||
impl BdsApp {
|
||||
pub fn new() -> (Self, Task<Message>) {
|
||||
let menu_bar = menu::build_menu_bar();
|
||||
(Self { _menu_bar: menu_bar }, Task::none())
|
||||
}
|
||||
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::MenuEvent(_id) => {
|
||||
// Menu routing will be expanded in M2
|
||||
Task::none()
|
||||
}
|
||||
Message::Noop => Task::none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
let content = column![text("bDS — Blogging Desktop Server").size(24),]
|
||||
.padding(20)
|
||||
.spacing(10);
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn subscription(&self) -> Subscription<Message> {
|
||||
menu::menu_subscription()
|
||||
}
|
||||
}
|
||||
4
crates/bds-ui/src/lib.rs
Normal file
4
crates/bds-ui/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
mod app;
|
||||
mod platform;
|
||||
|
||||
pub use app::BdsApp;
|
||||
7
crates/bds-ui/src/main.rs
Normal file
7
crates/bds-ui/src/main.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use bds_ui::BdsApp;
|
||||
|
||||
fn main() -> iced::Result {
|
||||
iced::application("bDS", BdsApp::update, BdsApp::view)
|
||||
.subscription(BdsApp::subscription)
|
||||
.run_with(BdsApp::new)
|
||||
}
|
||||
10
crates/bds-ui/src/platform/macos.rs
Normal file
10
crates/bds-ui/src/platform/macos.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// macOS lifecycle shim — objc2 hooks for NSApplicationDelegate.
|
||||
//
|
||||
// Handles:
|
||||
// - application:openFile: (Finder open)
|
||||
// - application:openURLs: (URL scheme handling)
|
||||
//
|
||||
// These will be forwarded as Message variants into the Iced event loop
|
||||
// via a channel-based subscription.
|
||||
//
|
||||
// Implementation deferred to M2 when the full message routing is in place.
|
||||
79
crates/bds-ui/src/platform/menu.rs
Normal file
79
crates/bds-ui/src/platform/menu.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use iced::Subscription;
|
||||
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
|
||||
use crate::app::Message;
|
||||
|
||||
/// Build the native menu bar with standard application menus.
|
||||
pub fn build_menu_bar() -> Menu {
|
||||
let menu = Menu::new();
|
||||
|
||||
// App menu (macOS only shows this as the app name)
|
||||
let app_menu = Submenu::new("bDS", true);
|
||||
let _ = app_menu.append(&PredefinedMenuItem::about(None, None));
|
||||
let _ = app_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = app_menu.append(&PredefinedMenuItem::services(None));
|
||||
let _ = app_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = app_menu.append(&PredefinedMenuItem::hide(None));
|
||||
let _ = app_menu.append(&PredefinedMenuItem::hide_others(None));
|
||||
let _ = app_menu.append(&PredefinedMenuItem::show_all(None));
|
||||
let _ = app_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = app_menu.append(&PredefinedMenuItem::quit(None));
|
||||
|
||||
// File menu
|
||||
let file_menu = Submenu::new("File", true);
|
||||
let _ = file_menu.append(&MenuItem::new("Open Project...", true, None));
|
||||
let _ = file_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = file_menu.append(&MenuItem::new("Save", true, None));
|
||||
let _ = file_menu.append(&PredefinedMenuItem::close_window(None));
|
||||
|
||||
// Edit menu
|
||||
let edit_menu = Submenu::new("Edit", true);
|
||||
let _ = edit_menu.append(&PredefinedMenuItem::undo(None));
|
||||
let _ = edit_menu.append(&PredefinedMenuItem::redo(None));
|
||||
let _ = edit_menu.append(&PredefinedMenuItem::separator());
|
||||
let _ = edit_menu.append(&PredefinedMenuItem::cut(None));
|
||||
let _ = edit_menu.append(&PredefinedMenuItem::copy(None));
|
||||
let _ = edit_menu.append(&PredefinedMenuItem::paste(None));
|
||||
let _ = edit_menu.append(&PredefinedMenuItem::select_all(None));
|
||||
|
||||
// View menu
|
||||
let view_menu = Submenu::new("View", true);
|
||||
let _ = view_menu.append(&MenuItem::new("Toggle Sidebar", true, None));
|
||||
let _ = view_menu.append(&PredefinedMenuItem::fullscreen(None));
|
||||
|
||||
// Window menu
|
||||
let window_menu = Submenu::new("Window", true);
|
||||
let _ = window_menu.append(&PredefinedMenuItem::minimize(None));
|
||||
let _ = window_menu.append(&PredefinedMenuItem::maximize(None));
|
||||
|
||||
// Help menu
|
||||
let help_menu = Submenu::new("Help", true);
|
||||
let _ = help_menu.append(&MenuItem::new("bDS Help", true, None));
|
||||
|
||||
let _ = menu.append(&app_menu);
|
||||
let _ = menu.append(&file_menu);
|
||||
let _ = menu.append(&edit_menu);
|
||||
let _ = menu.append(&view_menu);
|
||||
let _ = menu.append(&window_menu);
|
||||
let _ = menu.append(&help_menu);
|
||||
|
||||
// Initialize the menu on macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = menu.init_for_nsapp();
|
||||
}
|
||||
|
||||
menu
|
||||
}
|
||||
|
||||
/// Iced subscription that polls muda menu events.
|
||||
pub fn menu_subscription() -> Subscription<Message> {
|
||||
iced::event::listen_with(|_event, _status, _id| {
|
||||
// Check for pending menu events
|
||||
if let Ok(event) = MenuEvent::receiver().try_recv() {
|
||||
Some(Message::MenuEvent(event.id))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
4
crates/bds-ui/src/platform/mod.rs
Normal file
4
crates/bds-ui/src/platform/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod menu;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
107
docs/LIQUID_FEATURE_INVENTORY.md
Normal file
107
docs/LIQUID_FEATURE_INVENTORY.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# bDS Liquid Feature Inventory
|
||||
|
||||
Inventoried from the 12 default templates in the TypeScript app (`src/main/engine/templates/`).
|
||||
|
||||
## Template Files Analyzed
|
||||
|
||||
1. `single-post.liquid` (main)
|
||||
2. `post-list.liquid` (main)
|
||||
3. `not-found.liquid` (main)
|
||||
4. `macros/gallery.liquid`
|
||||
5. `macros/youtube.liquid`
|
||||
6. `macros/vimeo.liquid`
|
||||
7. `macros/photo-archive.liquid`
|
||||
8. `macros/tag-cloud.liquid`
|
||||
9. `partials/head.liquid`
|
||||
10. `partials/menu.liquid`
|
||||
11. `partials/menu-items.liquid`
|
||||
12. `partials/language-switcher.liquid`
|
||||
|
||||
## Tags Used
|
||||
|
||||
| Tag | Used In |
|
||||
|---|---|
|
||||
| `if` / `endif` | 9 templates |
|
||||
| `elsif` | post-list |
|
||||
| `else` | 7 templates |
|
||||
| `for` / `endfor` | 7 templates |
|
||||
| `assign` | 4 templates (not-found, post-list, head, single-post) |
|
||||
| `render` | 5 templates (menu, menu-items, not-found, post-list, single-post) |
|
||||
|
||||
### NOT Used
|
||||
|
||||
`unless`, `case/when`, `capture`, `layout`, `include`, `comment`, `raw`, `increment`, `decrement`, `tablerow`, `cycle`
|
||||
|
||||
## Filters Used
|
||||
|
||||
| Filter | Type | Signature |
|
||||
|---|---|---|
|
||||
| `escape` | Built-in | `\| escape` |
|
||||
| `default` | Built-in | `\| default: value` |
|
||||
| `append` | Built-in | `\| append: string` |
|
||||
| `url_encode` | Built-in | `\| url_encode` |
|
||||
| `i18n` | **Custom** | `\| i18n: language` — translation lookup by key and language |
|
||||
| `markdown` | **Custom** | `\| markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix` — markdown-to-HTML with macro expansion and link resolution (6 args) |
|
||||
|
||||
### NOT Used
|
||||
|
||||
`date`, `truncate`, `split`, `join`, `where`, `group_by`, `map`, `sort`, `reverse`, `size` (as pipe filter), `strip`, `strip_html`, `downcase`, `upcase`, `replace`, `remove`, `first`, `last`, `abs`, `ceil`, `floor`, `round`, `plus`, `minus`, `times`, `divided_by`, `modulo`
|
||||
|
||||
## Operators
|
||||
|
||||
| Operator | Example |
|
||||
|---|---|
|
||||
| `==` | `item.href == '#'`, `archive_context.kind == 'tag'` |
|
||||
| `>` | `menu_items.size > 0`, `blog_languages.size > 1` |
|
||||
| `and` | `menu_items and menu_items.size > 0` |
|
||||
| `or` | `archive_context.kind == 'tag' or archive_context.kind == 'category'` |
|
||||
| `blank` | `canonical_post_href == blank` (nil/empty check) |
|
||||
| bare truthiness | `{% if html_theme_attribute %}`, `{% if caption %}` |
|
||||
|
||||
### NOT Used
|
||||
|
||||
`!=`, `<`, `<=`, `>=`, `contains`
|
||||
|
||||
## Property Access Patterns
|
||||
|
||||
| Pattern | Examples |
|
||||
|---|---|
|
||||
| Dot notation | `archive_context.kind`, `post.title`, `item.media_path`, `lang.is_current` |
|
||||
| `.size` property | `menu_items.size`, `items.size`, `post_categories.size` |
|
||||
| Bracket notation (hash/map lookup) | `canonical_post_path_by_slug[post.slug]`, `tag_color_by_name[tag]` |
|
||||
|
||||
## Whitespace Stripping
|
||||
|
||||
`{%- -%}` used in 3 macro templates only: `photo-archive`, `gallery`, `tag-cloud`.
|
||||
|
||||
## For-Loop Features
|
||||
|
||||
| Feature | Used? |
|
||||
|---|---|
|
||||
| Basic `for x in collection` | YES |
|
||||
| Nested `for` loops | YES (photo-archive, post-list) |
|
||||
| Recursive `render` in loop | YES (menu-items renders itself for children) |
|
||||
| `forloop.first` / `forloop.last` | NO |
|
||||
| `limit` / `offset` | NO |
|
||||
| `reversed` | NO |
|
||||
|
||||
## Render Tag Usage
|
||||
|
||||
- Uses named parameter passing: `{% render 'partials/menu-items', items: menu_items, include_calendar: true, language: language %}`
|
||||
- Recursive self-render: menu-items calls `render 'partials/menu-items'` for nested children
|
||||
- Partial paths use forward-slash notation: `'partials/head'`, `'partials/menu'`
|
||||
|
||||
## Two-Step Dynamic i18n Keys
|
||||
|
||||
`{% assign month_key = 'render.month.' | append: archive_context.month %}` then `{{ month_key | i18n: language }}`
|
||||
|
||||
## Scope Summary
|
||||
|
||||
The Rust Liquid implementation needs approximately **35% of the full specification**:
|
||||
|
||||
- **Tags**: `if`/`elsif`/`else`, `for`, `assign`, `render` (not `include`)
|
||||
- **Built-in filters**: `escape`, `default`, `append`, `url_encode`
|
||||
- **Custom filters**: `i18n` (key + language), `markdown` (content + 6 args)
|
||||
- **Operators**: `==`, `>`, `and`, `or`, truthiness, `blank`
|
||||
- **Access**: dot notation, `.size` property, bracket notation for hash/map lookups
|
||||
- **Whitespace stripping**: `{%- -%}` support required
|
||||
114
docs/RESEARCH_FINDINGS.md
Normal file
114
docs/RESEARCH_FINDINGS.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# bDS Rust Rewrite — Research Findings
|
||||
|
||||
Research completed during M0 from the TypeScript codebase at `../bDS/`.
|
||||
|
||||
## Database Schema (16 migrations, 0000–0015)
|
||||
|
||||
### Active Tables
|
||||
|
||||
| Table | Primary Key | Key Columns |
|
||||
|---|---|---|
|
||||
| `projects` | `id TEXT` | name, slug, description, data_path, is_active, created_at, updated_at |
|
||||
| `settings` | `key TEXT` | value, updated_at |
|
||||
| `posts` | `id TEXT` | project_id, title, slug, excerpt, content, status, author, language, do_not_translate, template_slug, file_path, checksum, tags, categories, published_title/content/tags/categories/excerpt, created_at, updated_at, published_at |
|
||||
| `post_translations` | `id TEXT` | project_id, translation_for, language, title, excerpt, content, status, file_path, checksum, created_at, updated_at, published_at |
|
||||
| `post_links` | `id TEXT` | source_post_id, target_post_id, link_text, created_at |
|
||||
| `post_media` | `id TEXT` | project_id, post_id, media_id, sort_order, created_at |
|
||||
| `media` | `id TEXT` | project_id, filename, original_name, mime_type, size, width, height, title, alt, caption, author, language, file_path, sidecar_path, checksum, tags, created_at, updated_at |
|
||||
| `media_translations` | `id TEXT` | project_id, translation_for, language, title, alt, caption, created_at, updated_at |
|
||||
| `tags` | `id TEXT` | project_id, name, color, post_template_slug, created_at, updated_at |
|
||||
| `templates` | `id TEXT` | project_id, slug, title, kind, enabled, version, file_path, status, content, created_at, updated_at |
|
||||
| `scripts` | `id TEXT` | project_id, slug, title, kind, entrypoint, enabled, version, file_path, status, content, created_at, updated_at |
|
||||
| `generated_file_hashes` | (project_id, relative_path) UNIQUE | content_hash, updated_at |
|
||||
| `db_notifications` | `id INTEGER AUTOINCREMENT` | entity, entity_id, action, from_cli, seen_at, created_at |
|
||||
| `chat_conversations` | `id TEXT` | title, model, copilot_session_id, created_at, updated_at |
|
||||
| `chat_messages` | `id INTEGER AUTOINCREMENT` | conversation_id, role, content, tool_call_id, tool_calls, created_at |
|
||||
| `import_definitions` | `id TEXT` | project_id, name, wxr_file_path, uploads_folder_path, last_analysis_result, created_at, updated_at |
|
||||
| `ai_models` | (provider, model_id) | name, family, many feature flags, pricing, context_window, etc. |
|
||||
| `ai_catalog_meta` | `key TEXT` | value |
|
||||
| `ai_model_modalities` | (provider, model_id, direction, modality) | — |
|
||||
| `ai_providers` | `id TEXT` | name, env, npm, api, doc, updated_at |
|
||||
| `dismissed_duplicate_pairs` | `id TEXT` | project_id, post_id_a, post_id_b, dismissed_at |
|
||||
| `embedding_keys` | `label INTEGER` | post_id, project_id, content_hash, vector (blob) |
|
||||
|
||||
### Dropped Tables
|
||||
- `sync_log` (dropped in 0001)
|
||||
- `model_catalog`, `model_catalog_meta` (dropped in 0009, replaced by ai_models etc.)
|
||||
|
||||
### Timestamps
|
||||
All timestamps are **Unix integers** (seconds since epoch), NOT ISO strings.
|
||||
|
||||
### FTS5
|
||||
**No FTS5 virtual tables in migrations.** FTS is created at runtime.
|
||||
|
||||
### Unique Indexes
|
||||
- `posts_project_slug_idx` (project_id, slug)
|
||||
- `post_translations_translation_language_idx` (translation_for, language)
|
||||
- `media_translations_translation_language_idx` (translation_for, language)
|
||||
- `tags_project_name_idx` (project_id, name)
|
||||
- `templates_project_slug_idx` (project_id, slug)
|
||||
- `scripts_project_slug_idx` (project_id, slug)
|
||||
- `post_media_post_media_idx` (post_id, media_id)
|
||||
- `generated_file_hashes_project_path_idx` (project_id, relative_path)
|
||||
- `dismissed_pairs_idx` (project_id, post_id_a, post_id_b)
|
||||
|
||||
## File Format Details
|
||||
|
||||
### Post Files
|
||||
- Path: `posts/YYYY/MM/{slug}.md`
|
||||
- Frontmatter (YAML via gray-matter): id, title, slug, status, createdAt, updatedAt, tags, categories
|
||||
- Conditional: excerpt, author, language, doNotTranslate, templateSlug, publishedAt
|
||||
- Body: markdown after frontmatter
|
||||
|
||||
### Translation Files
|
||||
- Path: `posts/YYYY/MM/{slug}.{lang}.md` (same dir as source post)
|
||||
- Frontmatter: translationFor (UUID), language, title, excerpt (optional)
|
||||
- Body: translated content
|
||||
|
||||
### Media Sidecar Files
|
||||
- Path: `{media-file}.meta` (canonical), `{media-file}.{lang}.meta` (translation)
|
||||
- Format: hand-built YAML-like (NOT gray-matter), delimited by `---`
|
||||
- Canonical fields: id, originalName, mimeType, size, createdAt, updatedAt, tags, width?, height?, title?, alt?, caption?, author?, language?, linkedPostIds?
|
||||
- Translation fields: translationFor, language, title?, alt?, caption?
|
||||
|
||||
### Menu Document
|
||||
- Path: `meta/menu.opml` (OPML 2.0)
|
||||
- Library: fast-xml-parser (XMLParser/XMLBuilder)
|
||||
- Item kinds: page, submenu, category-archive, home
|
||||
- Home entry always enforced at position [0]
|
||||
|
||||
### Metadata JSON Files
|
||||
| File | Managed By |
|
||||
|---|---|
|
||||
| `meta/project.json` | MetaEngine |
|
||||
| `meta/categories.json` | MetaEngine |
|
||||
| `meta/category-meta.json` | MetaEngine |
|
||||
| `meta/publishing.json` | MetaEngine |
|
||||
| `meta/tags.json` | TagEngine |
|
||||
| `meta/menu.opml` | MenuEngine |
|
||||
|
||||
## Pagefind Integration
|
||||
- npm dependency: `pagefind@^1.4.0`
|
||||
- Invoked as CLI binary (`pagefind_extended`) via child process
|
||||
- Generates per-language indexes: `{html}/pagefind/`, `{html}/{lang}/pagefind/`
|
||||
- Loaded from templates via `<link>` and `<script>` tags in `partials/head.liquid`
|
||||
- Post body marked with `data-pagefind-body` attribute
|
||||
- **Rust plan:** Use `pagefind` crate library API instead of CLI
|
||||
|
||||
## Slug Generation
|
||||
- TypeScript: `transliterate(input).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')`
|
||||
- Rust: `deunicode(input)` + same pipeline
|
||||
- Need corpus comparison test to verify edge cases
|
||||
|
||||
## Publishing
|
||||
- `meta/publishing.json`: sshHost, sshUser, sshRemotePath, sshMode (scp|rsync)
|
||||
- SSH agent auth only (SSH_AUTH_SOCK), no passwords
|
||||
- Three parallel upload targets: html/, thumbnails/, media/ (excluding .meta)
|
||||
|
||||
## MetadataDiff Compared Fields
|
||||
- Posts: tags, categories, title, excerpt, author, language, translationFor, doNotTranslate, status, templateSlug, createdAt, updatedAt, publishedAt
|
||||
- Translations: translationFor, language, title, excerpt
|
||||
- Media: title, alt, caption, author, tags, language
|
||||
- Scripts: title, kind, entrypoint, enabled, version
|
||||
- Templates: title, kind, enabled, version
|
||||
- Supports bidirectional sync (DB→File, File→DB) + orphan detection
|
||||
Reference in New Issue
Block a user