diff --git a/CHANGELOG.md b/CHANGELOG.md index 1233f2a..eac766e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ The project is pre-1.0, so breaking changes can appear in any release. ### Added +- Compile-time typed content collections through `yeollin_content_collection!` and the `collections` plugin declaration. Collection field types drive concrete handlers, validation, exported schemas, and generated editor pages while the framework owns IDs, collection-scoped slugs, author, and timestamps. +- A shared draft/published content repository with paginated administrator CRUD, transactional `content.created` / `updated` / `published` / `unpublished` / `deleted` audit events, and an exact public-by-slug endpoint that exposes only published entries. The reference `content` plugin ships a typed `pages` collection with media-reference validation. - The `media` plugin provides an administrator media library, typed multipart image uploads, paginated metadata, deletion, and a public serving route. JPEG, PNG, GIF, and WebP are verified from their signatures; upload size has a 10 MiB hard ceiling and a typed 1–10 MiB setting. - Writable runtime object storage through `YeollinAppBuilder::with_storage_dir` and the `RuntimeStorage` plugin extension. Objects use namespace/shard/opaque-key paths outside the embedded frontend, and required storage is initialized only after metadata export exits. - Plugins can declare exact `public_api_routes` and a multipart `request_body_limit` in `yeollin_plugin!`. Public API declarations reject roots, dynamic segments, queries, traversal, and trailing slashes so path-based authentication stays exact. diff --git a/Cargo.lock b/Cargo.lock index 74e5503..9f84b5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -898,6 +898,16 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "content" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "vespera", + "yeollin-plugin", +] + [[package]] name = "cookie" version = "0.18.2" @@ -1335,6 +1345,7 @@ dependencies = [ "anyhow", "audit-log", "auth", + "content", "example-memo-plugin", "example-plugin", "media", @@ -5248,6 +5259,7 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "rand 0.10.2", "sea-orm", "serde", "serde_json", diff --git a/README.md b/README.md index 5bd8095..346e00b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Yeollin CMS is a Tauri-inspired, plugin-based CMS *framework* rather than a fini |------|----------| | `crates/` | The Rust workspace crates: `core` (shared types), `auth` (JWT, Argon2, middleware), `plugin` (`PluginMetadata`, `FrontendAssets`), `plugin-macros` (`yeollin_plugin!`, `yeollin_app!`), `app` (`YeollinAppBuilder` runtime), `cli` (`init`, `prebuild`, `dev`, `build`). | | `packages/` | The Node workspace. `packages/app` is the vinext frontend template that gets extracted into `.yeollin/app/` at prebuild time. It is a template, not the running app. | -| `plugins/` | Plugin crates. `auth` owns accounts and sessions; `audit-log` reads explicitly marked outbox events; `media` owns runtime image uploads; `example-plugin` is a minimal library plugin; `example-memo-plugin` demonstrates database CRUD, typed settings, and audited events. | +| `plugins/` | Plugin crates. `auth` owns accounts and sessions; `audit-log` reads explicitly marked outbox events; `media` owns runtime image uploads; `content` demonstrates compile-time typed draft/publish collections; `example-plugin` is a minimal library plugin; `example-memo-plugin` demonstrates database CRUD, typed settings, and audited events. | | `apps/` | Standalone application crates. `apps/example-app` wires the example plugins together with `yeollin_app!` and is the entry point used for local development. | `.yeollin/` is generated during prebuild and is gitignored. Never edit it by hand. @@ -99,6 +99,7 @@ CI additionally builds the release binary with ## Further reading - [Architecture overview](docs/architecture.md) +- [Plugin authoring](docs/plugin-authoring.md) - [Contributing guide](CONTRIBUTING.md) - [Security policy](SECURITY.md) - [Changelog](CHANGELOG.md) diff --git a/apps/example-app/Cargo.toml b/apps/example-app/Cargo.toml index 34bf4a4..7e29f11 100644 --- a/apps/example-app/Cargo.toml +++ b/apps/example-app/Cargo.toml @@ -25,6 +25,7 @@ anyhow = { workspace = true } sea-orm = { workspace = true } audit-log = { path = "../../plugins/audit-log" } media = { path = "../../plugins/media" } +content = { path = "../../plugins/content" } [dev-dependencies] reqwest = { workspace = true, features = ["json", "multipart"] } diff --git a/apps/example-app/src/main.rs b/apps/example-app/src/main.rs index 5a794b1..aa9cea7 100644 --- a/apps/example-app/src/main.rs +++ b/apps/example-app/src/main.rs @@ -42,7 +42,7 @@ async fn main() -> anyhow::Result<()> { // Create app builder using yeollin_app! macro // This macro handles both register_plugin() and vespera merge in one call let app = yeollin::yeollin_app! { - plugins: [audit_log, auth, example_memo_plugin, example_plugin, media], + plugins: [audit_log, auth, content, example_memo_plugin, example_plugin, media], openapi: "openapi.json", title: "Example CMS API", version: "1.0.0", diff --git a/apps/example-app/tests/system.rs b/apps/example-app/tests/system.rs index d66d628..fae501a 100644 --- a/apps/example-app/tests/system.rs +++ b/apps/example-app/tests/system.rs @@ -627,6 +627,255 @@ async fn assembled_system_uploads_serves_and_deletes_runtime_media() { assert_eq!(gone.status(), 404); } +#[tokio::test] +async fn assembled_system_manages_typed_content_publication() { + let server = start().await; + let client = reqwest::Client::new(); + + let protected = client + .get(server.url("/api/content/pages")) + .send() + .await + .unwrap(); + assert_eq!(protected.status(), 401, "collection management is protected"); + + let absent_public = client + .get(server.url("/api/content/pages/published?slug=first-page")) + .send() + .await + .unwrap(); + assert_eq!( + absent_public.status(), + 404, + "the fixed published endpoint is public even before content exists" + ); + let widened = client + .get(server.url("/api/content/pages/published/extra?slug=first-page")) + .send() + .await + .unwrap(); + assert_eq!( + widened.status(), + 401, + "the public content path must match exactly" + ); + + let token = admin_token(&client, &server).await; + let invalid = client + .post(server.url("/api/content/pages")) + .bearer_auth(&token) + .json(&serde_json::json!({ + "title": "Invalid page", + "slug": "invalid-page", + "fields": { + "excerpt": "Bad media reference", + "body": "This should be rejected.", + "heroImage": "../../secrets", + }, + })) + .send() + .await + .unwrap(); + assert_eq!(invalid.status(), 400, "typed field validation must run"); + + let created = client + .post(server.url("/api/content/pages")) + .bearer_auth(&token) + .json(&serde_json::json!({ + "title": "First page", + "slug": "First_page", + "fields": { + "excerpt": "A typed first page", + "body": "Draft body", + "heroImage": null, + }, + })) + .send() + .await + .unwrap(); + assert_eq!(created.status(), 200); + let first: Value = created.json().await.unwrap(); + let first_id = first["id"].as_str().expect("content id"); + assert_eq!(first_id.len(), 32); + assert_eq!(first["slug"], "first-page"); + assert_eq!(first["status"], "draft"); + assert_eq!(first["author"], ADMIN); + assert!(first["createdAt"].as_str().is_some()); + assert!(first["updatedAt"].as_str().is_some()); + assert!(first["publishedAt"].is_null()); + + let hidden_draft = client + .get(server.url("/api/content/pages/published?slug=first-page")) + .send() + .await + .unwrap(); + assert_eq!(hidden_draft.status(), 404, "drafts must never be public"); + + let duplicate = client + .post(server.url("/api/content/pages")) + .bearer_auth(&token) + .json(&serde_json::json!({ + "title": "Duplicate", + "slug": "first-page", + "fields": { + "excerpt": "Duplicate", + "body": "Duplicate body", + "heroImage": null, + }, + })) + .send() + .await + .unwrap(); + assert_eq!(duplicate.status(), 409, "slugs are unique per collection"); + + let second = client + .post(server.url("/api/content/pages")) + .bearer_auth(&token) + .json(&serde_json::json!({ + "title": "Second page", + "slug": "second-page", + "fields": { + "excerpt": "Second", + "body": "Second body", + "heroImage": null, + }, + })) + .send() + .await + .unwrap(); + assert_eq!(second.status(), 200); + + let second_page: Value = client + .get(server.url("/api/content/pages?page=2&pageSize=1")) + .bearer_auth(&token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(second_page["total"], 2); + assert_eq!(second_page["page"], 2); + assert_eq!(second_page["pageSize"], 1); + assert_eq!(second_page["entries"].as_array().unwrap().len(), 1); + + let published = client + .post(server.url(&format!("/api/content/pages/{first_id}/publish"))) + .bearer_auth(&token) + .send() + .await + .unwrap(); + assert_eq!(published.status(), 200); + let published: Value = published.json().await.unwrap(); + assert_eq!(published["status"], "published"); + assert!(published["publishedAt"].as_str().is_some()); + + let public: Value = client + .get(server.url("/api/content/pages/published?slug=first-page")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(public["id"], first_id); + assert_eq!(public["fields"]["body"], "Draft body"); + + let updated = client + .put(server.url(&format!("/api/content/pages/{first_id}"))) + .bearer_auth(&token) + .json(&serde_json::json!({ + "title": "Renamed page", + "slug": "renamed-page", + "fields": { + "excerpt": "Updated", + "body": "Published body", + "heroImage": "media:0123456789abcdef0123456789abcdef", + }, + })) + .send() + .await + .unwrap(); + assert_eq!(updated.status(), 200); + let updated: Value = updated.json().await.unwrap(); + assert_eq!(updated["status"], "published"); + assert_eq!(updated["fields"]["heroImage"], "media:0123456789abcdef0123456789abcdef"); + + let unpublished = client + .post(server.url(&format!("/api/content/pages/{first_id}/unpublish"))) + .bearer_auth(&token) + .send() + .await + .unwrap(); + assert_eq!(unpublished.status(), 200); + let unpublished: Value = unpublished.json().await.unwrap(); + assert_eq!(unpublished["status"], "draft"); + assert!(unpublished["publishedAt"].is_null()); + let hidden_again = client + .get(server.url("/api/content/pages/published?slug=renamed-page")) + .send() + .await + .unwrap(); + assert_eq!(hidden_again.status(), 404); + + let account = client + .post(server.url("/api/auth/users")) + .bearer_auth(&token) + .json(&serde_json::json!({ + "username": "content-reader", + "password": "content-reader-password", + "role": "user", + })) + .send() + .await + .unwrap(); + assert_eq!(account.status(), 200); + let user_tokens: Value = login_as( + &client, + &server, + "content-reader", + "content-reader-password", + ) + .await + .json() + .await + .unwrap(); + let refused = client + .get(server.url("/api/content/pages")) + .bearer_auth(user_tokens["access_token"].as_str().unwrap()) + .send() + .await + .unwrap(); + assert_eq!(refused.status(), 403, "content management requires admin"); + + let audit: Value = client + .get(server.url("/api/audit-log?eventName=content.created")) + .bearer_auth(&token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(audit["total"], 2); + assert_eq!(audit["events"][0]["payload"]["content"]["collection"], "pages"); + + let deleted = client + .delete(server.url(&format!("/api/content/pages/{first_id}"))) + .bearer_auth(&token) + .send() + .await + .unwrap(); + assert_eq!(deleted.status(), 200); + let gone = client + .get(server.url(&format!("/api/content/pages/{first_id}"))) + .bearer_auth(&token) + .send() + .await + .unwrap(); + assert_eq!(gone.status(), 404); +} + #[tokio::test] async fn assembled_system_manages_accounts() { let server = start().await; diff --git a/bun.lock b/bun.lock index d7ce980..a34dfe7 100644 --- a/bun.lock +++ b/bun.lock @@ -77,6 +77,10 @@ "vinext": "^1.0.0-beta.8", }, }, + "plugins/content": { + "name": "@yeollin-plugin/content", + "version": "0.1.0", + }, "plugins/example-memo-plugin": { "name": "@yeollin-plugin/example-memo-plugin", "version": "0.1.0", @@ -515,6 +519,8 @@ "@yeollin-plugin/auth": ["@yeollin-plugin/auth@workspace:plugins/auth"], + "@yeollin-plugin/content": ["@yeollin-plugin/content@workspace:plugins/content"], + "@yeollin-plugin/example-memo-plugin": ["@yeollin-plugin/example-memo-plugin@workspace:plugins/example-memo-plugin"], "@yeollin-plugin/example-plugin": ["@yeollin-plugin/example-plugin@workspace:plugins/example-plugin"], diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 2ce5ac0..f0e344b 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -12,8 +12,8 @@ use std::{path::PathBuf, sync::Arc}; use vespera::Schema; use yeollin_auth::{auth_middleware, AuthConfig, AuthState}; use yeollin_core::{ - compile_route_manifest, EventBus, ExportEnvelope, MenuConfig, PluginInfo, RouteAccess, - RouteEntry, RouteSource, RuntimeStorage, SettingsRegistration, SettingsStore, + build_menu, compile_route_manifest, EventBus, ExportEnvelope, MenuConfig, PluginInfo, + RouteAccess, RouteEntry, RouteSource, RuntimeStorage, SettingsRegistration, SettingsStore, SubscriberRegistration, EXPORT_ENV_VAR, EXPORT_SCHEMA_VERSION, }; use yeollin_plugin::PluginMetadata; @@ -63,6 +63,8 @@ pub struct YeollinApp { runtime_storage: Option, /// Plugins that cannot serve without runtime storage. storage_required_by: Vec, + /// Typed content collections require the shared database repository. + content_required_by: Vec, } impl YeollinApp { @@ -133,6 +135,12 @@ impl YeollinApp { if !self.subscriber_registrations.is_empty() { anyhow::bail!("plugins register event subscribers but no database is configured"); } + if !self.content_required_by.is_empty() { + anyhow::bail!( + "content collections are registered by plugins [{}] but no database is configured", + self.content_required_by.join(", ") + ); + } None }; @@ -410,7 +418,9 @@ impl YeollinAppBuilder { let mut page_routes: Vec = vec![]; let mut public_api_routes = vec![]; let mut storage_required_by = vec![]; + let mut content_required_by = vec![]; let mut request_body_limit = None; + let mut collection_names = std::collections::HashSet::new(); if let Some((path, embedded)) = self.app_frontend { if std::path::Path::new(path).is_dir() { @@ -451,6 +461,23 @@ impl YeollinAppBuilder { .iter() .map(|route| route.to_string()), ); + let collection_infos = plugin + .content_collections + .iter() + .map(|collection| { + if !collection_names.insert(collection.name()) { + panic!( + "content collection `{}` is registered more than once", + collection.name() + ); + } + public_api_routes.push(collection.public_api_path().to_string()); + collection.export_info() + }) + .collect::>(); + if !collection_infos.is_empty() { + content_required_by.push(plugin.name.to_string()); + } if plugin.requires_runtime_storage { storage_required_by.push(plugin.name.to_string()); } @@ -472,6 +499,7 @@ impl YeollinAppBuilder { license: plugin.license.map(|s| s.to_string()), frontend_path: plugin.frontend_path.map(|s| s.to_string()), settings: settings_info, + collections: collection_infos, }); if let Some(settings) = plugin.settings { @@ -496,12 +524,51 @@ impl YeollinAppBuilder { // Merge the router router = router.merge(plugin.router); - // Collect menus - if let Some(menu) = plugin.frontend.menu() { - menus.push(menu.clone()); + let mut plugin_routes = plugin.frontend.routes().to_vec(); + if !plugin.content_collections.is_empty() { + let root_path = format!("/{}", plugin.name); + if !plugin_routes.iter().any(|route| route.path == root_path) { + plugin_routes.push(RouteEntry { + path: root_path, + plugin: Some(plugin.name.to_string()), + label: humanize_identifier(plugin.name), + icon: None, + order: plugin + .content_collections + .iter() + .map(|collection| collection.order()) + .min() + .unwrap_or(50), + access: RouteAccess::Authenticated, + menu: true, + }); + } + for collection in &plugin.content_collections { + if plugin_routes + .iter() + .any(|route| route.path == collection.page_path()) + { + panic!( + "content collection page `{}` collides with a plugin frontend route", + collection.page_path() + ); + } + plugin_routes.push(RouteEntry { + path: collection.page_path().to_string(), + plugin: Some(plugin.name.to_string()), + label: collection.label().to_string(), + icon: None, + order: collection.order(), + access: RouteAccess::Authenticated, + menu: true, + }); + } } - page_routes.extend(plugin.frontend.routes().iter().cloned()); + if let Some(menu) = build_menu(&plugin_routes, plugin.name) { + menus.push(menu); + } + page_routes.extend(plugin_routes); } let state = AppState::new(self.host, self.port); @@ -602,6 +669,7 @@ impl YeollinAppBuilder { subscriber_registrations, runtime_storage, storage_required_by, + content_required_by, } } } @@ -636,7 +704,23 @@ pub async fn get_plugins(Extension(plugins): Extension) -> Json String { + value + .split(['-', '_']) + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + chars + .next() + .map(|first| first.to_uppercase().chain(chars).collect::()) + .unwrap_or_default() + }) + .collect::>() + .join(" ") +} diff --git a/crates/cli/src/commands/prebuild.rs b/crates/cli/src/commands/prebuild.rs index b077944..7ca5827 100644 --- a/crates/cli/src/commands/prebuild.rs +++ b/crates/cli/src/commands/prebuild.rs @@ -11,7 +11,10 @@ use std::process::Stdio; use tokio::fs; use tokio::process::Command; use tracing::{debug, info}; -use yeollin_core::{ExportEnvelope, PluginSettingsInfo, EXPORT_ENV_VAR, EXPORT_SCHEMA_VERSION}; +use yeollin_core::{ + ContentCollectionInfo, ExportEnvelope, PluginSettingsInfo, EXPORT_ENV_VAR, + EXPORT_SCHEMA_VERSION, +}; use crate::template::AppTemplate; @@ -871,36 +874,123 @@ async fn copy_plugin_frontends( copied_any = true; } - let Some(frontend_path) = plugin.frontend_path.as_deref() else { - continue; - }; - - let frontend_dir = Path::new(frontend_path); - if !frontend_dir.exists() || !frontend_dir.is_dir() { - debug!("Plugin {} frontend path not found: {}", name, frontend_path); - continue; + if let Some(frontend_path) = plugin.frontend_path.as_deref() { + let frontend_dir = Path::new(frontend_path); + if !frontend_dir.exists() || !frontend_dir.is_dir() { + debug!("Plugin {} frontend path not found: {}", name, frontend_path); + } else { + let mut entries = fs::read_dir(frontend_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let entry_path = entry.path(); + + if !entry_path.is_dir() { + continue; + } + + let dir_name = entry.file_name(); + let dir_name_str = dir_name.to_str().unwrap_or(""); + + if dir_name_str.starts_with('(') && dir_name_str.ends_with(')') { + copy_dir_contents_parallel(&entry_path, &dest_base).await?; + info!("Copied plugin frontend: {} from {}", name, dir_name_str); + copied_any = true; + } + } + } } - let mut entries = fs::read_dir(frontend_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let entry_path = entry.path(); + if !plugin.collections.is_empty() { + write_generated_content_pages(&dest_base, name, &plugin.collections).await?; + info!( + plugin = name, + collections = plugin.collections.len(), + "Generated typed content collection pages" + ); + copied_any = true; + } + } - if !entry_path.is_dir() { - continue; - } + Ok(copied_any) +} - let dir_name = entry.file_name(); - let dir_name_str = dir_name.to_str().unwrap_or(""); +async fn write_generated_content_pages( + plugin_dir: &Path, + plugin_name: &str, + collections: &[ContentCollectionInfo], +) -> Result<()> { + fs::create_dir_all(plugin_dir).await?; + let hub_path = plugin_dir.join("page.tsx"); + if !hub_path.exists() { + let plugin_name = serde_json::to_string(plugin_name)?; + let links = collections + .iter() + .map(|collection| { + Ok(format!( + " {{ label: {}, path: {} }}", + serde_json::to_string(&collection.label)?, + serde_json::to_string(&collection.page_path)?, + )) + }) + .collect::>>()? + .join(",\n"); + let content = format!( + r#"import {{ ContentCollectionsHub }} from '@/components/content/ContentCollectionsHub' + +const collections = [ +{links} +] + +export default function ContentHubPage() {{ + return +}} +"# + ); + fs::write(hub_path, content).await?; + } - if dir_name_str.starts_with('(') && dir_name_str.ends_with(')') { - copy_dir_contents_parallel(&entry_path, &dest_base).await?; - info!("Copied plugin frontend: {} from {}", name, dir_name_str); - copied_any = true; - } + for collection in collections { + let collection_dir = plugin_dir.join(&collection.name); + fs::create_dir_all(&collection_dir).await?; + let page_path = collection_dir.join("page.tsx"); + if page_path.exists() { + anyhow::bail!( + "generated content page {} collides with a plugin frontend page", + page_path.display() + ); } + write_generated_content_page(&page_path, collection).await?; } + Ok(()) +} - Ok(copied_any) +async fn write_generated_content_page( + page_path: &Path, + collection: &ContentCollectionInfo, +) -> Result<()> { + let api_path = serde_json::to_string(&collection.api_path)?; + let label = serde_json::to_string(&collection.label)?; + let schema = serde_json::to_string_pretty(&collection.schema)?; + let default_value = serde_json::to_string_pretty(&collection.default_value)?; + let content = format!( + r#"import {{ ContentCollectionCrud, type ContentFieldSchema }} from '@/components/content/ContentCollectionCrud' + +const schema = {schema} as ContentFieldSchema +const defaultValue = {default_value} as Record + +export default function ContentCollectionPage() {{ + return ( + + ) +}} +"# + ); + fs::write(page_path, content).await?; + Ok(()) } async fn write_generated_settings_page( @@ -1086,7 +1176,8 @@ mod assembly_tests { use std::path::{Path, PathBuf}; use tempfile::TempDir; use yeollin_core::{ - ExportEnvelope, PluginInfo, PluginSettingsInfo, EXPORT_SCHEMA_VERSION, + ContentCollectionInfo, ExportEnvelope, PluginInfo, PluginSettingsInfo, + EXPORT_SCHEMA_VERSION, }; fn write(path: &Path, contents: &str) { @@ -1110,6 +1201,7 @@ mod assembly_tests { license: None, frontend_path: Some(dir.to_string_lossy().into_owned()), settings: None, + collections: vec![], } } @@ -1269,6 +1361,46 @@ mod assembly_tests { assert!(!custom.contains("PluginSettingsForm")); } + #[tokio::test] + async fn typed_content_pages_are_generated_from_exported_schema() { + let tmp = TempDir::new().unwrap(); + let (app_dir, output_dir, frontend, mut metadata) = fixture(&tmp); + metadata.plugins[0].collections.push(ContentCollectionInfo { + name: "articles".to_string(), + label: "Articles".to_string(), + order: 30, + schema: serde_json::json!({ + "type": "object", + "properties": { "body": { "type": "string" } }, + "required": ["body"], + }), + default_value: serde_json::json!({ "body": "" }), + api_path: "/api/plugin-alpha/articles".to_string(), + page_path: "/plugin-alpha/articles".to_string(), + public_api_path: "/api/plugin-alpha/articles/published".to_string(), + }); + + run_prebuild( + &output_dir, + &app_dir, + Some(&frontend), + Some(&metadata), + true, + false, + ) + .await + .unwrap(); + + let generated = std::fs::read_to_string( + output_dir.join("src/app/(auth)/plugin-alpha/articles/page.tsx"), + ) + .unwrap(); + assert!(generated.contains("ContentCollectionCrud")); + assert!(generated.contains("/api/plugin-alpha/articles")); + assert!(generated.contains("\"body\"")); + assert!(generated.contains("defaultValue")); + } + #[tokio::test] async fn prebuild_is_deterministic_across_runs() { let tmp = TempDir::new().unwrap(); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 7091465..69c2bed 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -16,6 +16,7 @@ chrono = { workspace = true } anyhow = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } +rand = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/core/migrations/0004_create_content_entries.vespertide.json b/crates/core/migrations/0004_create_content_entries.vespertide.json new file mode 100644 index 0000000..3032fdf --- /dev/null +++ b/crates/core/migrations/0004_create_content_entries.vespertide.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/migration.schema.json", + "actions": [ + { + "columns": [ + { + "name": "id", + "nullable": false, + "primary_key": true, + "type": "text" + }, + { + "index": true, + "name": "collection", + "nullable": false, + "type": "text" + }, + { + "name": "title", + "nullable": false, + "type": "text" + }, + { + "name": "slug", + "nullable": false, + "type": "text" + }, + { + "index": true, + "name": "status", + "nullable": false, + "type": "text" + }, + { + "index": true, + "name": "author", + "nullable": false, + "type": "text" + }, + { + "name": "fields", + "nullable": false, + "type": "json" + }, + { + "default": "NOW()", + "index": true, + "name": "created_at", + "nullable": false, + "type": "timestamptz" + }, + { + "default": "NOW()", + "name": "updated_at", + "nullable": false, + "type": "timestamptz" + }, + { + "index": true, + "name": "published_at", + "nullable": true, + "type": "timestamptz" + } + ], + "constraints": [ + { + "columns": [ + "collection", + "slug" + ], + "name": "uq_content_entries_collection_slug", + "type": "unique" + } + ], + "table": "content_entries", + "type": "create_table" + } + ], + "comment": "create content entries", + "created_at": "2026-08-30T20:24:26Z", + "id": "e5528874-f547-432f-9b69-b3b7adfa36bd", + "version": 4 +} \ No newline at end of file diff --git a/crates/core/models/content_entries.vespertide.json b/crates/core/models/content_entries.vespertide.json new file mode 100644 index 0000000..286ac5f --- /dev/null +++ b/crates/core/models/content_entries.vespertide.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "content_entries", + "description": "Typed collection entries with framework-owned publication metadata", + "columns": [ + { + "name": "id", + "type": "text", + "nullable": false, + "primary_key": true + }, + { + "name": "collection", + "type": "text", + "nullable": false, + "index": true + }, + { + "name": "title", + "type": "text", + "nullable": false + }, + { + "name": "slug", + "type": "text", + "nullable": false + }, + { + "name": "status", + "type": "text", + "nullable": false, + "index": true + }, + { + "name": "author", + "type": "text", + "nullable": false, + "index": true + }, + { + "name": "fields", + "type": "json", + "nullable": false + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "NOW()", + "index": true + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "NOW()" + }, + { + "name": "published_at", + "type": "timestamptz", + "nullable": true, + "index": true + } + ], + "constraints": [ + { + "type": "unique", + "name": "uq_content_entries_collection_slug", + "columns": ["collection", "slug"] + } + ] +} diff --git a/crates/core/src/content.rs b/crates/core/src/content.rs index 5155185..d123aea 100644 --- a/crates/core/src/content.rs +++ b/crates/core/src/content.rs @@ -1,29 +1,825 @@ -//! Content types for Yeollin CMS +//! Compile-time typed content collections and their shared persistence layer. -use serde::{Deserialize, Serialize}; +use std::{marker::PhantomData, str::FromStr}; + +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, ModelTrait, + Order, PaginatorTrait, QueryFilter, QueryOrder, Set, +}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::Value; use vespera::Schema; -/// Generic content metadata -#[derive(Debug, Clone, Serialize, Deserialize, Schema)] +use crate::{models::content_entries, ContentCollectionInfo, Event, EventBus}; + +pub const DEFAULT_CONTENT_PAGE_SIZE: u64 = 20; +pub const MAX_CONTENT_PAGE_SIZE: u64 = 100; +pub const CONTENT_CREATED_EVENT: &str = "content.created"; +pub const CONTENT_UPDATED_EVENT: &str = "content.updated"; +pub const CONTENT_PUBLISHED_EVENT: &str = "content.published"; +pub const CONTENT_UNPUBLISHED_EVENT: &str = "content.unpublished"; +pub const CONTENT_DELETED_EVENT: &str = "content.deleted"; + +/// Fields supplied by a collection author. +/// +/// Implementations are concrete Rust types, so request decoding, validation, +/// and OpenAPI remain compile-time checked even though the shared table stores +/// the fields as JSON. +pub trait ContentFields: Serialize + DeserializeOwned + Clone + Send + Sync + 'static { + fn validate(&self) -> Result<(), String> { + Ok(()) + } +} + +/// The only two publication states exposed by the reusable workflow. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Schema)] +#[serde(rename_all = "lowercase")] +pub enum ContentStatus { + #[default] + Draft, + Published, +} + +impl ContentStatus { + pub const fn as_str(self) -> &'static str { + match self { + Self::Draft => "draft", + Self::Published => "published", + } + } +} + +impl FromStr for ContentStatus { + type Err = ContentError; + + fn from_str(value: &str) -> Result { + match value { + "draft" => Ok(Self::Draft), + "published" => Ok(Self::Published), + other => Err(ContentError::InvalidStoredStatus(other.to_string())), + } + } +} + +/// One typed entry returned by a collection repository. +#[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct ContentMeta { +pub struct ContentRecord { pub id: String, + pub collection: String, pub title: String, pub slug: String, pub status: ContentStatus, + pub author: String, + pub fields: T, pub created_at: String, - pub updated_at: Option, + pub updated_at: String, pub published_at: Option, } -/// Content status -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Schema)] -#[serde(rename_all = "lowercase")] -#[derive(Default)] -pub enum ContentStatus { - #[default] - Draft, - Review, - Published, - Archived, +/// Paginated typed collection result. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ContentPage { + pub entries: Vec>, + pub total: u64, + pub page: u64, + pub page_size: u64, +} + +/// Input shared by generated create handlers. +pub struct NewContent { + pub title: String, + pub slug: String, + pub fields: T, +} + +/// Input shared by generated update handlers. +pub struct ContentPatch { + pub title: Option, + pub slug: Option, + pub fields: Option, +} + +/// Compile-time collection metadata retained for routing and prebuild. +#[derive(Clone, Debug)] +pub struct ContentCollectionRegistration { + name: &'static str, + label: &'static str, + order: i32, + schema: Value, + default_value: Value, + plugin_name: Option<&'static str>, + api_path: Option, + page_path: Option, + public_api_path: Option, +} + +impl ContentCollectionRegistration { + pub fn new(name: &'static str, label: &'static str, order: i32, schema: Value) -> Self + where + T: Serialize + Default, + { + assert_valid_collection_name(name); + assert!( + !label.trim().is_empty(), + "content collection label must not be empty" + ); + + Self { + name, + label, + order, + schema, + default_value: serde_json::to_value(T::default()) + .expect("content collection Default must serialize as JSON"), + plugin_name: None, + api_path: None, + page_path: None, + public_api_path: None, + } + } + + /// Bind the collection to the plugin namespace that declared it. + #[must_use] + pub fn for_plugin(mut self, plugin_name: &'static str, api_prefix: &'static str) -> Self { + let api_path = format!("{api_prefix}/{}", self.name); + self.plugin_name = Some(plugin_name); + self.page_path = Some(format!("/{plugin_name}/{}", self.name)); + self.public_api_path = Some(format!("{api_path}/published")); + self.api_path = Some(api_path); + self + } + + pub fn name(&self) -> &'static str { + self.name + } + + pub fn label(&self) -> &'static str { + self.label + } + + pub fn order(&self) -> i32 { + self.order + } + + pub fn plugin_name(&self) -> &'static str { + self.plugin_name + .expect("content collection must be assigned to a plugin") + } + + pub fn api_path(&self) -> &str { + self.api_path + .as_deref() + .expect("content collection must be assigned to a plugin") + } + + pub fn page_path(&self) -> &str { + self.page_path + .as_deref() + .expect("content collection must be assigned to a plugin") + } + + pub fn public_api_path(&self) -> &str { + self.public_api_path + .as_deref() + .expect("content collection must be assigned to a plugin") + } + + pub fn export_info(&self) -> ContentCollectionInfo { + ContentCollectionInfo { + name: self.name.to_string(), + label: self.label.to_string(), + order: self.order, + schema: self.schema.clone(), + default_value: self.default_value.clone(), + api_path: self.api_path().to_string(), + page_path: self.page_path().to_string(), + public_api_path: self.public_api_path().to_string(), + } + } +} + +fn assert_valid_collection_name(name: &str) { + let valid = !name.is_empty() + && name.len() <= 64 + && !name.starts_with('-') + && !name.ends_with('-') + && !name.contains("--") + && name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); + assert!( + valid, + "content collection names must be 1-64 lowercase kebab-case characters" + ); +} + +/// Shared CRUD implementation used by concrete generated collection handlers. +#[derive(Clone)] +pub struct ContentRepository { + db: DatabaseConnection, + collection: &'static str, + fields: PhantomData T>, +} + +impl ContentRepository +where + T: ContentFields, +{ + pub fn new(db: DatabaseConnection, collection: &'static str) -> Self { + assert_valid_collection_name(collection); + debug_assert_eq!( + content_entries::COMPOSITE_UNIQUES, + &[&["collection", "slug"] as &[&str]], + "the content repository relies on collection-scoped unique slugs" + ); + Self { + db, + collection, + fields: PhantomData, + } + } + + pub async fn list( + &self, + page: u64, + page_size: u64, + status: Option, + ) -> Result, ContentError> { + let page = page.max(1); + let page_size = page_size.clamp(1, MAX_CONTENT_PAGE_SIZE); + let mut query = content_entries::Entity::find() + .filter(content_entries::Column::Collection.eq(self.collection)); + if let Some(status) = status { + query = query.filter(content_entries::Column::Status.eq(status.as_str())); + } + let paginator = query + .order_by(content_entries::Column::UpdatedAt, Order::Desc) + .paginate(&self.db, page_size); + let total = paginator.num_items().await?; + let entries = paginator + .fetch_page(page - 1) + .await? + .into_iter() + .map(decode_record) + .collect::, _>>()?; + + Ok(ContentPage { + entries, + total, + page, + page_size, + }) + } + + pub async fn get(&self, id: &str) -> Result, ContentError> { + let model = content_entries::Entity::find_by_id(id) + .filter(content_entries::Column::Collection.eq(self.collection)) + .one(&self.db) + .await? + .ok_or(ContentError::NotFound)?; + decode_record(model) + } + + pub async fn published(&self, slug: &str) -> Result, ContentError> { + let slug = normalize_slug(slug)?; + let model = content_entries::Entity::find() + .filter(content_entries::Column::Collection.eq(self.collection)) + .filter(content_entries::Column::Slug.eq(slug)) + .filter(content_entries::Column::Status.eq(ContentStatus::Published.as_str())) + .one(&self.db) + .await? + .ok_or(ContentError::NotFound)?; + decode_record(model) + } + + pub async fn create( + &self, + events: &EventBus, + actor: &str, + input: NewContent, + ) -> Result, ContentError> { + let title = normalize_title(&input.title)?; + let slug = normalize_slug(&input.slug)?; + input.fields.validate().map_err(ContentError::Invalid)?; + let fields = serde_json::to_value(&input.fields)?; + let now = chrono::Utc::now(); + let mut transaction = events.begin().await?; + + ensure_slug_available(transaction.connection(), self.collection, &slug, None).await?; + let stored = content_entries::ActiveModel { + id: Set(random_id()), + collection: Set(self.collection.to_string()), + title: Set(title), + slug: Set(slug), + status: Set(ContentStatus::Draft.as_str().to_string()), + author: Set(actor.to_string()), + fields: Set(fields), + created_at: Set(now.into()), + updated_at: Set(now.into()), + published_at: Set(None), + } + .insert(transaction.connection()) + .await?; + let record = decode_record(stored)?; + transaction + .emit(&ContentCreated { + actor: actor.to_string(), + content: ContentSnapshot::from_record(&record)?, + }) + .await?; + transaction.commit().await?; + Ok(record) + } + + pub async fn update( + &self, + events: &EventBus, + actor: &str, + id: &str, + patch: ContentPatch, + ) -> Result, ContentError> { + if patch.title.is_none() && patch.slug.is_none() && patch.fields.is_none() { + return Err(ContentError::Invalid( + "an update must include title, slug, or fields".to_string(), + )); + } + + let mut transaction = events.begin().await?; + let model = content_entries::Entity::find_by_id(id) + .filter(content_entries::Column::Collection.eq(self.collection)) + .one(transaction.connection()) + .await? + .ok_or(ContentError::NotFound)?; + let mut active = model.into_active_model(); + + if let Some(title) = patch.title { + active.title = Set(normalize_title(&title)?); + } + if let Some(slug) = patch.slug { + let slug = normalize_slug(&slug)?; + ensure_slug_available(transaction.connection(), self.collection, &slug, Some(id)) + .await?; + active.slug = Set(slug); + } + if let Some(fields) = patch.fields { + fields.validate().map_err(ContentError::Invalid)?; + active.fields = Set(serde_json::to_value(fields)?); + } + active.updated_at = Set(chrono::Utc::now().into()); + let stored = active.update(transaction.connection()).await?; + let record = decode_record(stored)?; + transaction + .emit(&ContentUpdated { + actor: actor.to_string(), + content: ContentSnapshot::from_record(&record)?, + }) + .await?; + transaction.commit().await?; + Ok(record) + } + + pub async fn publish( + &self, + events: &EventBus, + actor: &str, + id: &str, + ) -> Result, ContentError> { + self.transition(events, actor, id, ContentStatus::Published) + .await + } + + pub async fn unpublish( + &self, + events: &EventBus, + actor: &str, + id: &str, + ) -> Result, ContentError> { + self.transition(events, actor, id, ContentStatus::Draft) + .await + } + + async fn transition( + &self, + events: &EventBus, + actor: &str, + id: &str, + status: ContentStatus, + ) -> Result, ContentError> { + let mut transaction = events.begin().await?; + let model = content_entries::Entity::find_by_id(id) + .filter(content_entries::Column::Collection.eq(self.collection)) + .one(transaction.connection()) + .await? + .ok_or(ContentError::NotFound)?; + if ContentStatus::from_str(&model.status)? == status { + transaction.rollback().await?; + return decode_record(model); + } + + let now = chrono::Utc::now(); + let mut active = model.into_active_model(); + active.status = Set(status.as_str().to_string()); + active.updated_at = Set(now.into()); + active.published_at = Set(match status { + ContentStatus::Draft => None, + ContentStatus::Published => Some(now.into()), + }); + let stored = active.update(transaction.connection()).await?; + let record = decode_record(stored)?; + let snapshot = ContentSnapshot::from_record(&record)?; + match status { + ContentStatus::Draft => { + transaction + .emit(&ContentUnpublished { + actor: actor.to_string(), + content: snapshot, + }) + .await?; + } + ContentStatus::Published => { + transaction + .emit(&ContentPublished { + actor: actor.to_string(), + content: snapshot, + }) + .await?; + } + } + transaction.commit().await?; + Ok(record) + } + + pub async fn delete( + &self, + events: &EventBus, + actor: &str, + id: &str, + ) -> Result { + let mut transaction = events.begin().await?; + let model = content_entries::Entity::find_by_id(id) + .filter(content_entries::Column::Collection.eq(self.collection)) + .one(transaction.connection()) + .await? + .ok_or(ContentError::NotFound)?; + let record: ContentRecord = decode_record(model.clone())?; + model.delete(transaction.connection()).await?; + transaction + .emit(&ContentDeleted { + actor: actor.to_string(), + content: ContentSnapshot::from_record(&record)?, + }) + .await?; + transaction.commit().await?; + Ok(id.to_string()) + } +} + +async fn ensure_slug_available( + db: &sea_orm::DatabaseTransaction, + collection: &str, + slug: &str, + except_id: Option<&str>, +) -> Result<(), ContentError> { + let mut query = content_entries::Entity::find() + .filter(content_entries::Column::Collection.eq(collection)) + .filter(content_entries::Column::Slug.eq(slug)); + if let Some(id) = except_id { + query = query.filter(content_entries::Column::Id.ne(id)); + } + if query.one(db).await?.is_some() { + return Err(ContentError::DuplicateSlug(slug.to_string())); + } + Ok(()) +} + +fn decode_record(model: content_entries::Model) -> Result, ContentError> +where + T: DeserializeOwned, +{ + Ok(ContentRecord { + id: model.id, + collection: model.collection, + title: model.title, + slug: model.slug, + status: ContentStatus::from_str(&model.status)?, + author: model.author, + fields: serde_json::from_value(model.fields)?, + created_at: model.created_at.to_rfc3339(), + updated_at: model.updated_at.to_rfc3339(), + published_at: model.published_at.map(|value| value.to_rfc3339()), + }) +} + +fn normalize_title(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(ContentError::Invalid("title must not be empty".to_string())); + } + if value.chars().count() > 200 { + return Err(ContentError::Invalid( + "title must be at most 200 characters".to_string(), + )); + } + Ok(value.to_string()) +} + +pub fn normalize_slug(value: &str) -> Result { + let mut slug = String::new(); + let mut separator = false; + for byte in value.trim().bytes() { + let byte = byte.to_ascii_lowercase(); + if byte.is_ascii_lowercase() || byte.is_ascii_digit() { + if separator && !slug.is_empty() { + slug.push('-'); + } + separator = false; + slug.push(char::from(byte)); + } else if matches!(byte, b'-' | b'_' | b' ' | b'\t') { + separator = true; + } else { + return Err(ContentError::Invalid( + "slug may contain only ASCII letters, numbers, spaces, underscores, and hyphens" + .to_string(), + )); + } + } + if slug.is_empty() { + return Err(ContentError::Invalid("slug must not be empty".to_string())); + } + if slug.len() > 100 { + return Err(ContentError::Invalid( + "slug must be at most 100 characters".to_string(), + )); + } + Ok(slug) +} + +fn random_id() -> String { + rand::random::<[u8; 16]>() + .iter() + .fold(String::with_capacity(32), |mut value, byte| { + use std::fmt::Write; + write!(value, "{byte:02x}").expect("writing to a String cannot fail"); + value + }) +} + +/// Stable JSON shape consumed by audit, webhooks, and the phase-8 search index. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContentSnapshot { + pub id: String, + pub collection: String, + pub title: String, + pub slug: String, + pub status: ContentStatus, + pub author: String, + pub fields: Value, + pub created_at: String, + pub updated_at: String, + pub published_at: Option, +} + +impl ContentSnapshot { + fn from_record(record: &ContentRecord) -> Result + where + T: Serialize, + { + Ok(Self { + id: record.id.clone(), + collection: record.collection.clone(), + title: record.title.clone(), + slug: record.slug.clone(), + status: record.status, + author: record.author.clone(), + fields: serde_json::to_value(&record.fields)?, + created_at: record.created_at.clone(), + updated_at: record.updated_at.clone(), + published_at: record.published_at.clone(), + }) + } +} + +macro_rules! content_event { + ($type_name:ident, $event_name:expr) => { + #[derive(Clone, Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct $type_name { + pub actor: String, + pub content: ContentSnapshot, + } + + impl Event for $type_name { + const NAME: &'static str = $event_name; + const AUDIT: bool = true; + } + }; +} + +content_event!(ContentCreated, CONTENT_CREATED_EVENT); +content_event!(ContentUpdated, CONTENT_UPDATED_EVENT); +content_event!(ContentPublished, CONTENT_PUBLISHED_EVENT); +content_event!(ContentUnpublished, CONTENT_UNPUBLISHED_EVENT); +content_event!(ContentDeleted, CONTENT_DELETED_EVENT); + +#[derive(Debug, thiserror::Error)] +pub enum ContentError { + #[error("content is invalid: {0}")] + Invalid(String), + #[error("content entry was not found")] + NotFound, + #[error("slug `{0}` is already used by this collection")] + DuplicateSlug(String), + #[error("persisted content has unknown status `{0}`")] + InvalidStoredStatus(String), + #[error(transparent)] + Serialize(#[from] serde_json::Error), + #[error(transparent)] + Database(#[from] sea_orm::DbErr), + #[error(transparent)] + Event(#[from] crate::EventError), +} + +#[cfg(test)] +mod tests { + use sea_orm::{Database, EntityTrait, PaginatorTrait}; + + use super::*; + use crate::{migrate_core, models::events}; + + #[derive(Clone, Debug, Default, Serialize, Deserialize, Schema)] + struct TestFields { + body: String, + } + + impl ContentFields for TestFields { + fn validate(&self) -> Result<(), String> { + if self.body.trim().is_empty() { + return Err("body must not be empty".to_string()); + } + Ok(()) + } + } + + async fn repository() -> (ContentRepository, EventBus) { + let db = Database::connect("sqlite::memory:").await.unwrap(); + migrate_core(&db).await.unwrap(); + let events = EventBus::new(db.clone(), std::iter::empty()).unwrap(); + (ContentRepository::new(db, "articles"), events) + } + + #[test] + fn collection_registration_is_bound_to_the_plugin_namespace() { + let registration = ContentCollectionRegistration::new::( + "articles", + "Articles", + 30, + serde_json::to_value(vespera::schema!(TestFields)).unwrap(), + ) + .for_plugin("content", "/api/content"); + + assert_eq!(registration.api_path(), "/api/content/articles"); + assert_eq!(registration.page_path(), "/content/articles"); + assert_eq!( + registration.public_api_path(), + "/api/content/articles/published" + ); + assert_eq!(registration.export_info().default_value["body"], ""); + } + + #[test] + #[should_panic(expected = "lowercase kebab-case")] + fn invalid_collection_names_fail_registration() { + ContentCollectionRegistration::new::( + "Bad/Articles", + "Articles", + 30, + serde_json::json!({}), + ); + } + + #[test] + fn slugs_are_canonical_and_path_free() { + assert_eq!(normalize_slug(" Hello_world ").unwrap(), "hello-world"); + assert!(normalize_slug("../../secret").is_err()); + assert!(normalize_slug("ν•œκΈ€").is_err()); + } + + #[tokio::test] + async fn draft_publish_update_unpublish_and_delete_share_one_generic() { + let (repository, events) = repository().await; + let created = repository + .create( + &events, + "admin", + NewContent { + title: "First article".to_string(), + slug: "First Article".to_string(), + fields: TestFields { + body: "Draft body".to_string(), + }, + }, + ) + .await + .unwrap(); + assert_eq!(created.status, ContentStatus::Draft); + assert_eq!(created.slug, "first-article"); + assert!(repository.published(&created.slug).await.is_err()); + + let published = repository + .publish(&events, "publisher", &created.id) + .await + .unwrap(); + assert_eq!(published.status, ContentStatus::Published); + assert!(published.published_at.is_some()); + assert_eq!( + repository.published("first-article").await.unwrap().id, + created.id + ); + + let updated = repository + .update( + &events, + "editor", + &created.id, + ContentPatch { + title: Some("Renamed".to_string()), + slug: Some("renamed".to_string()), + fields: Some(TestFields { + body: "Published body".to_string(), + }), + }, + ) + .await + .unwrap(); + assert_eq!(updated.fields.body, "Published body"); + assert_eq!(repository.list(1, 1, None).await.unwrap().total, 1); + + let draft = repository + .unpublish(&events, "editor", &created.id) + .await + .unwrap(); + assert_eq!(draft.status, ContentStatus::Draft); + assert!(repository.published("renamed").await.is_err()); + + repository + .delete(&events, "admin", &created.id) + .await + .unwrap(); + assert!(repository.get(&created.id).await.is_err()); + assert_eq!( + events::Entity::find().count(&repository.db).await.unwrap(), + 5 + ); + } + + #[tokio::test] + async fn invalid_fields_and_duplicate_slugs_are_refused() { + let (repository, events) = repository().await; + let invalid = repository + .create( + &events, + "admin", + NewContent { + title: "Invalid".to_string(), + slug: "invalid".to_string(), + fields: TestFields::default(), + }, + ) + .await + .unwrap_err(); + assert!(matches!(invalid, ContentError::Invalid(_))); + + repository + .create( + &events, + "admin", + NewContent { + title: "One".to_string(), + slug: "same".to_string(), + fields: TestFields { + body: "body".to_string(), + }, + }, + ) + .await + .unwrap(); + let duplicate = repository + .create( + &events, + "admin", + NewContent { + title: "Two".to_string(), + slug: "same".to_string(), + fields: TestFields { + body: "body".to_string(), + }, + }, + ) + .await + .unwrap_err(); + assert!(matches!(duplicate, ContentError::DuplicateSlug(_))); + } } diff --git a/crates/core/src/export.rs b/crates/core/src/export.rs index 73e966b..18f206f 100644 --- a/crates/core/src/export.rs +++ b/crates/core/src/export.rs @@ -16,7 +16,29 @@ use crate::route::RouteEntry; pub const EXPORT_ENV_VAR: &str = "YEOLLIN_EXPORT"; /// Version of the [`ExportEnvelope`] contract. -pub const EXPORT_SCHEMA_VERSION: u32 = 2; +pub const EXPORT_SCHEMA_VERSION: u32 = 3; + +/// Build-time contract for one compile-time typed content collection. +#[derive(Debug, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct ContentCollectionInfo { + /// Stable lowercase kebab-case identifier used in URLs and persistence. + pub name: String, + /// Human-readable navigation label. + pub label: String, + /// Menu ordering weight within the owning plugin. + pub order: i32, + /// Exact Vespera-generated schema for the collection-specific fields. + pub schema: serde_json::Value, + /// Serialized `Default` value used by the generated editor. + pub default_value: serde_json::Value, + /// Authenticated CRUD API root. + pub api_path: String, + /// Authenticated frontend route generated during prebuild. + pub page_path: String, + /// Exact public endpoint that returns published content by slug query. + pub public_api_path: String, +} /// Build-time information used to expose a plugin's settings screen. #[derive(Debug, Clone, Serialize, Deserialize, Schema)] @@ -49,6 +71,9 @@ pub struct PluginInfo { pub frontend_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub settings: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schema(default = "[]")] + pub collections: Vec, } /// Everything prebuild needs from a built binary, in one document. diff --git a/crates/core/src/models/content_entries.rs b/crates/core/src/models/content_entries.rs new file mode 100644 index 0000000..6b46404 --- /dev/null +++ b/crates/core/src/models/content_entries.rs @@ -0,0 +1,38 @@ +use sea_orm::entity::prelude::*; + +/// Typed collection entries with framework-owned publication metadata +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "content_entries")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: String, + #[sea_orm(indexed)] + pub collection: String, + pub title: String, + pub slug: String, + #[sea_orm(indexed)] + pub status: String, + #[sea_orm(indexed)] + pub author: String, + pub fields: Json, + #[sea_orm(indexed, default_value = "NOW()")] + pub created_at: DateTimeWithTimeZone, + #[sea_orm(default_value = "NOW()")] + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(indexed)] + pub published_at: Option, +} + +// Index definitions (SeaORM uses Statement builders externally) +// (unnamed) on [collection] +// (unnamed) on [status] +// (unnamed) on [author] +// (unnamed) on [created_at] +// (unnamed) on [published_at] + +/// Composite unique constraints β€” declare in migrations or use Statement builder. +pub const COMPOSITE_UNIQUES: &[&[&str]] = &[ + &["collection", "slug"], // uq_content_entries_collection_slug +]; +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/core/src/models/mod.rs b/crates/core/src/models/mod.rs index 88d2fe2..d54a25d 100644 --- a/crates/core/src/models/mod.rs +++ b/crates/core/src/models/mod.rs @@ -1,2 +1,3 @@ +pub mod content_entries; pub mod events; pub mod settings; diff --git a/crates/plugin-macros/src/lib.rs b/crates/plugin-macros/src/lib.rs index 92d89db..97b0be1 100644 --- a/crates/plugin-macros/src/lib.rs +++ b/crates/plugin-macros/src/lib.rs @@ -11,7 +11,7 @@ use syn::{ parse::{Parse, ParseStream}, parse_macro_input, punctuated::Punctuated, - Expr, Ident, LitBool, LitStr, Path, Token, + Expr, Ident, LitBool, LitInt, LitStr, Path, Token, Type, }; /// Convert a kebab-case or snake_case string to PascalCase @@ -33,6 +33,407 @@ fn ident_to_pascal_case(ident: &Ident) -> Ident { format_ident!("{}", to_pascal_case(&ident.to_string())) } +// ============================================================ +// yeollin_content_collection! macro +// ============================================================ + +struct ContentCollectionDef { + module: Ident, + name: LitStr, + label: LitStr, + fields: Type, + order: LitInt, +} + +impl Parse for ContentCollectionDef { + fn parse(input: ParseStream) -> syn::Result { + let mut module = None; + let mut name = None; + let mut label = None; + let mut fields = None; + let mut order = None; + + while !input.is_empty() { + let key: Ident = input.parse()?; + input.parse::()?; + match key.to_string().as_str() { + "module" => module = Some(input.parse()?), + "name" => name = Some(input.parse()?), + "label" => label = Some(input.parse()?), + "fields" => fields = Some(input.parse()?), + "order" => order = Some(input.parse()?), + _ => { + return Err(syn::Error::new( + key.span(), + format!("unknown content collection field: {key}"), + )); + } + } + if input.peek(Token![,]) { + input.parse::()?; + } + } + + Ok(Self { + module: module.ok_or_else(|| input.error("missing required field: module"))?, + name: name.ok_or_else(|| input.error("missing required field: name"))?, + label: label.ok_or_else(|| input.error("missing required field: label"))?, + fields: fields.ok_or_else(|| input.error("missing required field: fields"))?, + order: order.ok_or_else(|| input.error("missing required field: order"))?, + }) + } +} + +fn validate_collection_name(name: &LitStr) -> syn::Result<()> { + let value = name.value(); + let valid = !value.is_empty() + && value.len() <= 64 + && !value.starts_with('-') + && !value.ends_with('-') + && !value.contains("--") + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); + if valid { + Ok(()) + } else { + Err(syn::Error::new( + name.span(), + "content collection names must be 1-64 lowercase kebab-case characters", + )) + } +} + +/// Declare one compile-time typed content collection. +/// +/// The generated module owns concrete request and response types plus the full +/// draft/publish CRUD surface, so Vespera can describe every field in OpenAPI. +#[proc_macro] +pub fn yeollin_content_collection(input: TokenStream) -> TokenStream { + let def = parse_macro_input!(input as ContentCollectionDef); + if let Err(error) = validate_collection_name(&def.name) { + return TokenStream::from(error.to_compile_error()); + } + if def.label.value().trim().is_empty() { + return TokenStream::from( + syn::Error::new( + def.label.span(), + "content collection label must not be empty", + ) + .to_compile_error(), + ); + } + let order = match def.order.base10_parse::() { + Ok(order) => order, + Err(error) => { + return TokenStream::from(syn::Error::new(def.order.span(), error).to_compile_error()) + } + }; + + let module = def.module; + let name = def.name; + let label = def.label; + let fields = def.fields; + let type_prefix = to_pascal_case(&name.value()); + let response = format_ident!("{type_prefix}ContentResponse"); + let list_response = format_ident!("List{type_prefix}ContentResponse"); + let list_query = format_ident!("List{type_prefix}ContentQuery"); + let published_query = format_ident!("Published{type_prefix}ContentQuery"); + let create_request = format_ident!("Create{type_prefix}ContentRequest"); + let update_request = format_ident!("Update{type_prefix}ContentRequest"); + let delete_response = format_ident!("Delete{type_prefix}ContentResponse"); + let collection_path = LitStr::new(&format!("/{}", name.value()), name.span()); + let item_path = LitStr::new(&format!("/{}/{{id}}", name.value()), name.span()); + let publish_path = LitStr::new(&format!("/{}/{{id}}/publish", name.value()), name.span()); + let unpublish_path = LitStr::new(&format!("/{}/{{id}}/unpublish", name.value()), name.span()); + let published_path = LitStr::new(&format!("/{}/published", name.value()), name.span()); + + TokenStream::from(quote! { + pub mod #module { + #[derive(Clone, Debug, yeollin_plugin::serde::Serialize, yeollin_plugin::vespera::Schema)] + #[serde(rename_all = "camelCase")] + pub struct #response { + pub id: String, + pub collection: String, + pub title: String, + pub slug: String, + pub status: yeollin_plugin::ContentStatus, + pub author: String, + pub fields: #fields, + pub created_at: String, + pub updated_at: String, + pub published_at: Option, + } + + impl From> for #response { + fn from(record: yeollin_plugin::ContentRecord<#fields>) -> Self { + Self { + id: record.id, + collection: record.collection, + title: record.title, + slug: record.slug, + status: record.status, + author: record.author, + fields: record.fields, + created_at: record.created_at, + updated_at: record.updated_at, + published_at: record.published_at, + } + } + } + + #[derive(Debug, yeollin_plugin::serde::Serialize, yeollin_plugin::vespera::Schema)] + #[serde(rename_all = "camelCase")] + pub struct #list_response { + pub entries: Vec<#response>, + pub total: u64, + pub page: u64, + pub page_size: u64, + } + + impl From> for #list_response { + fn from(page: yeollin_plugin::ContentPage<#fields>) -> Self { + Self { + entries: page.entries.into_iter().map(#response::from).collect(), + total: page.total, + page: page.page, + page_size: page.page_size, + } + } + } + + #[derive(Default, Debug, yeollin_plugin::serde::Deserialize, yeollin_plugin::vespera::Schema)] + #[serde(rename_all = "camelCase")] + pub struct #list_query { + pub page: Option, + pub page_size: Option, + pub status: Option, + } + + #[derive(Debug, yeollin_plugin::serde::Deserialize, yeollin_plugin::vespera::Schema)] + pub struct #published_query { + pub slug: String, + } + + #[derive(Debug, yeollin_plugin::serde::Deserialize, yeollin_plugin::vespera::Schema)] + pub struct #create_request { + pub title: String, + pub slug: String, + pub fields: #fields, + } + + #[derive(Debug, yeollin_plugin::serde::Deserialize, yeollin_plugin::vespera::Schema)] + pub struct #update_request { + pub title: Option, + pub slug: Option, + pub fields: Option<#fields>, + } + + #[derive(Debug, yeollin_plugin::serde::Serialize, yeollin_plugin::vespera::Schema)] + #[serde(rename_all = "camelCase")] + pub struct #delete_response { + pub success: bool, + pub deleted_id: String, + } + + pub fn registration() -> yeollin_plugin::ContentCollection { + let registration = yeollin_plugin::ContentCollectionRegistration::new::<#fields>( + #name, + #label, + #order, + yeollin_plugin::serde_json::to_value( + yeollin_plugin::vespera::schema!(#fields) + ).expect("Vespera content schema must serialize"), + ); + let router = yeollin_plugin::vespera::axum::Router::new() + .route( + #collection_path, + yeollin_plugin::vespera::axum::routing::get(list_content) + .post(create_content), + ) + .route( + #published_path, + yeollin_plugin::vespera::axum::routing::get(published_content), + ) + .route( + #item_path, + yeollin_plugin::vespera::axum::routing::get(get_content) + .put(update_content) + .delete(delete_content), + ) + .route( + #publish_path, + yeollin_plugin::vespera::axum::routing::post(publish_content), + ) + .route( + #unpublish_path, + yeollin_plugin::vespera::axum::routing::post(unpublish_content), + ); + yeollin_plugin::ContentCollection::new(registration, router) + } + + #[yeollin_plugin::vespera::route(get, path = #collection_path, tags = [#name])] + pub async fn list_content( + yeollin_plugin::vespera::axum::Extension(db): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::Extension(current): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::extract::Query(query): + yeollin_plugin::vespera::axum::extract::Query<#list_query>, + ) -> Result, yeollin_plugin::PluginError> { + yeollin_plugin::Authorize::require_role(¤t, "admin")?; + let page = query.page.unwrap_or(1); + let page_size = query.page_size.unwrap_or(yeollin_plugin::DEFAULT_CONTENT_PAGE_SIZE); + let result = yeollin_plugin::ContentRepository::<#fields>::new(db, #name) + .list(page, page_size, query.status) + .await?; + Ok(yeollin_plugin::vespera::axum::Json(result.into())) + } + + #[yeollin_plugin::vespera::route(get, path = #item_path, tags = [#name])] + pub async fn get_content( + yeollin_plugin::vespera::axum::Extension(db): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::Extension(current): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::extract::Path(id): + yeollin_plugin::vespera::axum::extract::Path, + ) -> Result, yeollin_plugin::PluginError> { + yeollin_plugin::Authorize::require_role(¤t, "admin")?; + let result = yeollin_plugin::ContentRepository::<#fields>::new(db, #name) + .get(&id) + .await?; + Ok(yeollin_plugin::vespera::axum::Json(result.into())) + } + + #[yeollin_plugin::vespera::route(post, path = #collection_path, tags = [#name])] + pub async fn create_content( + yeollin_plugin::vespera::axum::Extension(db): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::Extension(events): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::Extension(current): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::Json(request): + yeollin_plugin::vespera::axum::Json<#create_request>, + ) -> Result, yeollin_plugin::PluginError> { + yeollin_plugin::Authorize::require_role(¤t, "admin")?; + let result = yeollin_plugin::ContentRepository::<#fields>::new(db, #name) + .create( + &events, + ¤t.sub, + yeollin_plugin::NewContent { + title: request.title, + slug: request.slug, + fields: request.fields, + }, + ) + .await?; + Ok(yeollin_plugin::vespera::axum::Json(result.into())) + } + + #[yeollin_plugin::vespera::route(put, path = #item_path, tags = [#name])] + pub async fn update_content( + yeollin_plugin::vespera::axum::Extension(db): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::Extension(events): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::Extension(current): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::extract::Path(id): + yeollin_plugin::vespera::axum::extract::Path, + yeollin_plugin::vespera::axum::Json(request): + yeollin_plugin::vespera::axum::Json<#update_request>, + ) -> Result, yeollin_plugin::PluginError> { + yeollin_plugin::Authorize::require_role(¤t, "admin")?; + let result = yeollin_plugin::ContentRepository::<#fields>::new(db, #name) + .update( + &events, + ¤t.sub, + &id, + yeollin_plugin::ContentPatch { + title: request.title, + slug: request.slug, + fields: request.fields, + }, + ) + .await?; + Ok(yeollin_plugin::vespera::axum::Json(result.into())) + } + + #[yeollin_plugin::vespera::route(post, path = #publish_path, tags = [#name])] + pub async fn publish_content( + yeollin_plugin::vespera::axum::Extension(db): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::Extension(events): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::Extension(current): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::extract::Path(id): + yeollin_plugin::vespera::axum::extract::Path, + ) -> Result, yeollin_plugin::PluginError> { + yeollin_plugin::Authorize::require_role(¤t, "admin")?; + let result = yeollin_plugin::ContentRepository::<#fields>::new(db, #name) + .publish(&events, ¤t.sub, &id) + .await?; + Ok(yeollin_plugin::vespera::axum::Json(result.into())) + } + + #[yeollin_plugin::vespera::route(post, path = #unpublish_path, tags = [#name])] + pub async fn unpublish_content( + yeollin_plugin::vespera::axum::Extension(db): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::Extension(events): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::Extension(current): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::extract::Path(id): + yeollin_plugin::vespera::axum::extract::Path, + ) -> Result, yeollin_plugin::PluginError> { + yeollin_plugin::Authorize::require_role(¤t, "admin")?; + let result = yeollin_plugin::ContentRepository::<#fields>::new(db, #name) + .unpublish(&events, ¤t.sub, &id) + .await?; + Ok(yeollin_plugin::vespera::axum::Json(result.into())) + } + + #[yeollin_plugin::vespera::route(delete, path = #item_path, tags = [#name])] + pub async fn delete_content( + yeollin_plugin::vespera::axum::Extension(db): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::Extension(events): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::Extension(current): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::extract::Path(id): + yeollin_plugin::vespera::axum::extract::Path, + ) -> Result, yeollin_plugin::PluginError> { + yeollin_plugin::Authorize::require_role(¤t, "admin")?; + let deleted_id = yeollin_plugin::ContentRepository::<#fields>::new(db, #name) + .delete(&events, ¤t.sub, &id) + .await?; + Ok(yeollin_plugin::vespera::axum::Json(#delete_response { + success: true, + deleted_id, + })) + } + + #[yeollin_plugin::vespera::route(get, path = #published_path, tags = [#name])] + pub async fn published_content( + yeollin_plugin::vespera::axum::Extension(db): + yeollin_plugin::vespera::axum::Extension, + yeollin_plugin::vespera::axum::extract::Query(query): + yeollin_plugin::vespera::axum::extract::Query<#published_query>, + ) -> Result, yeollin_plugin::PluginError> { + let result = yeollin_plugin::ContentRepository::<#fields>::new(db, #name) + .published(&query.slug) + .await?; + Ok(yeollin_plugin::vespera::axum::Json(result.into())) + } + } + }) +} + // ============================================================ // yeollin_plugin! macro // ============================================================ @@ -46,6 +447,7 @@ struct PluginDef { frontend: Option, api_base: Option, settings: Option, + collections: Vec, subscribers: Vec, public_api_routes: Vec, runtime_storage: bool, @@ -61,6 +463,7 @@ impl Parse for PluginDef { let mut frontend: Option = None; let mut api_base: Option = None; let mut settings: Option = None; + let mut collections = vec![]; let mut subscribers = vec![]; let mut public_api_routes = vec![]; let mut runtime_storage = false; @@ -93,6 +496,13 @@ impl Parse for PluginDef { "settings" => { settings = Some(input.parse()?); } + "collections" => { + let content; + bracketed!(content in input); + collections = Punctuated::::parse_terminated(&content)? + .into_iter() + .collect(); + } "subscribers" => { let content; bracketed!(content in input); @@ -139,6 +549,7 @@ impl Parse for PluginDef { frontend, api_base, settings, + collections, subscribers, public_api_routes, runtime_storage, @@ -479,6 +890,12 @@ pub fn yeollin_plugin(input: TokenStream) -> TokenStream { let subscriber_setters = def.subscribers.iter().map(|subscriber| { quote! { .subscriber(#subscriber) } }); + let api_prefix_lit = LitStr::new(&api_prefix, name_lit.span()); + let collection_setters = def.collections.iter().map(|collection| { + quote! { + .content_collection((#collection).for_plugin(#name_lit, #api_prefix_lit)) + } + }); let expanded = quote! { #settings_tokens @@ -502,6 +919,7 @@ pub fn yeollin_plugin(input: TokenStream) -> TokenStream { #on_init_setter #frontend_setters #settings_setter + #(#collection_setters)* #(#subscriber_setters)* #(#public_api_setters)* #runtime_storage_setter @@ -670,7 +1088,10 @@ pub fn yeollin_app(input: TokenStream) -> TokenStream { #[cfg(test)] mod api_base_tests { - use super::{resolve_api_prefix, resolve_public_api_route, PluginDef}; + use super::{ + resolve_api_prefix, resolve_public_api_route, validate_collection_name, + ContentCollectionDef, PluginDef, + }; use syn::LitStr; fn prefix_of(declaration: &str) -> String { @@ -758,6 +1179,38 @@ mod api_base_tests { assert_eq!(def.subscribers.len(), 2); } + #[test] + fn accepts_content_collection_registrations() { + let def: PluginDef = + syn::parse_str(r#"name: "content", collections: [pages::registration()]"#).unwrap(); + + assert_eq!(def.collections.len(), 1); + } + + #[test] + fn parses_a_typed_content_collection() { + let def: ContentCollectionDef = syn::parse_str( + r#"module: pages, name: "pages", label: "Pages", fields: crate::PageFields, order: 30"#, + ) + .unwrap(); + + assert_eq!(def.module, "pages"); + assert_eq!(def.name.value(), "pages"); + assert_eq!(def.label.value(), "Pages"); + assert_eq!(def.order.base10_parse::().unwrap(), 30); + } + + #[test] + fn rejects_non_canonical_content_collection_names() { + for name in ["", "Pages", "blog_posts", "blog/pages", "-pages"] { + assert!(validate_collection_name(&LitStr::new( + name, + proc_macro2::Span::call_site() + )) + .is_err()); + } + } + #[test] fn resolves_fixed_public_api_routes_below_the_namespace() { let def: PluginDef = diff --git a/crates/plugin/src/error.rs b/crates/plugin/src/error.rs index 53ce3cb..687e98e 100644 --- a/crates/plugin/src/error.rs +++ b/crates/plugin/src/error.rs @@ -159,6 +159,28 @@ impl From for PluginError { } } +impl From for PluginError { + fn from(error: yeollin_core::ContentError) -> Self { + match error { + yeollin_core::ContentError::Invalid(message) => Self::bad_request(message), + yeollin_core::ContentError::NotFound => Self::not_found("Content entry not found"), + yeollin_core::ContentError::DuplicateSlug(slug) => { + Self::conflict(format!("Slug `{slug}` is already in use")) + } + yeollin_core::ContentError::InvalidStoredStatus(status) => { + tracing::error!(%status, "content entry has invalid stored status"); + Self::internal() + } + yeollin_core::ContentError::Serialize(error) => { + tracing::error!(%error, "could not serialize typed content fields"); + Self::internal() + } + yeollin_core::ContentError::Database(error) => Self::from(error), + yeollin_core::ContentError::Event(error) => Self::from(error), + } + } +} + impl From for PluginError { fn from(error: yeollin_core::StorageError) -> Self { tracing::error!(%error, "plugin runtime storage error"); diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index cf76317..b319c6c 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -27,20 +27,23 @@ pub use error::*; pub use frontend::*; pub use metadata::*; -// Re-export yeollin_plugin! proc-macro (auto-infers export name from plugin name) -pub use yeollin_plugin_macros::yeollin_plugin; +// Re-export plugin declaration macros. +pub use yeollin_plugin_macros::{yeollin_content_collection, yeollin_plugin}; // Re-export for convenience pub use include_dir; pub use sea_orm; +pub use serde; pub use serde_json; pub use vespera; pub use yeollin_auth; pub use yeollin_core; pub use yeollin_core::{ - Event, EventBus, EventEnvelope, EventError, EventTransaction, InlineSubscriberFuture, - RuntimeStorage, SettingsError, SettingsRegistration, SettingsStore, StorageError, - SubscriberMode, SubscriberRegistration, + ContentCollectionRegistration, ContentError, ContentFields, ContentPage, ContentPatch, + ContentRecord, ContentRepository, ContentStatus, Event, EventBus, EventEnvelope, EventError, + EventTransaction, InlineSubscriberFuture, NewContent, RuntimeStorage, SettingsError, + SettingsRegistration, SettingsStore, StorageError, SubscriberMode, SubscriberRegistration, + DEFAULT_CONTENT_PAGE_SIZE, }; // Re-export commonly used auth types diff --git a/crates/plugin/src/metadata.rs b/crates/plugin/src/metadata.rs index 4d418b1..50a35f7 100644 --- a/crates/plugin/src/metadata.rs +++ b/crates/plugin/src/metadata.rs @@ -5,7 +5,30 @@ use axum::Router; use sea_orm::DatabaseConnection; use std::future::Future; use std::pin::Pin; -use yeollin_core::{SettingsRegistration, SubscriberRegistration}; +use yeollin_core::{ContentCollectionRegistration, SettingsRegistration, SubscriberRegistration}; + +/// A typed content registration paired with the concrete generated handlers. +pub struct ContentCollection { + registration: ContentCollectionRegistration, + router: Router, +} + +impl ContentCollection { + pub fn new(registration: ContentCollectionRegistration, router: Router) -> Self { + Self { + registration, + router, + } + } + + /// Bind both metadata and runtime routes to their plugin API namespace. + #[must_use] + pub fn for_plugin(mut self, plugin_name: &'static str, api_prefix: &'static str) -> Self { + self.registration = self.registration.for_plugin(plugin_name, api_prefix); + self.router = Router::new().nest(api_prefix, self.router); + self + } +} /// Type alias for plugin initialization function /// Called when plugin is loaded with database connection available @@ -37,6 +60,8 @@ pub struct PluginMetadata { pub on_init: Option, /// Optional typed settings contract. pub settings: Option, + /// Compile-time typed content collections owned by this plugin. + pub content_collections: Vec, /// Observe-only event subscribers owned by this plugin. pub subscribers: Vec, /// Exact API paths that are reachable without authentication. @@ -61,6 +86,7 @@ impl PluginMetadata { frontend_path: None, on_init: None, settings: None, + content_collections: vec![], subscribers: vec![], public_api_routes: vec![], requires_runtime_storage: false, @@ -81,6 +107,7 @@ pub struct PluginMetadataBuilder { frontend_path: Option<&'static str>, on_init: Option, settings: Option, + content_collections: Vec, subscribers: Vec, public_api_routes: Vec<&'static str>, requires_runtime_storage: bool, @@ -151,6 +178,13 @@ impl PluginMetadataBuilder { self } + /// Register one compile-time typed content collection. + pub fn content_collection(mut self, collection: ContentCollection) -> Self { + self.router = self.router.merge(collection.router); + self.content_collections.push(collection.registration); + self + } + /// Add an Inline or Deferred event subscriber. pub fn subscriber(mut self, subscriber: SubscriberRegistration) -> Self { self.subscribers.push(subscriber); @@ -190,6 +224,7 @@ impl PluginMetadataBuilder { frontend_path: self.frontend_path, on_init: self.on_init, settings: self.settings, + content_collections: self.content_collections, subscribers: self.subscribers, public_api_routes: self.public_api_routes, requires_runtime_storage: self.requires_runtime_storage, diff --git a/docs/architecture.md b/docs/architecture.md index f722490..06c2492 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,10 +16,10 @@ flowchart TB subgraph CoreCrates["?? Rust Core Crates"] direction TB - core["yeollin-core
(ContentMeta, MenuItem, MenuConfig)"] + core["yeollin-core
(ContentRepository, Events, Settings, Menus)"] auth["yeollin-auth
(JWT, Argon2, Middleware)"] pluginLib["yeollin-plugin
(PluginMetadata, FrontendAssets)"] - macros["yeollin-plugin-macros
(yeollin_plugin!, yeollin_app!)"] + macros["yeollin-plugin-macros
(yeollin_plugin!, yeollin_content_collection!, yeollin_app!)"] appLib["yeollin-app
(YeollinApp, YeollinAppBuilder)"] end @@ -113,7 +113,8 @@ flowchart TB `YEOLLIN_EXPORT`, which prints one envelope on stdout and exits. 2. `yeollin prebuild` ??extracts the `packages/app` template into `.yeollin/app/`, copies each plugin's `app/` pages in, generates typed settings forms unless a - plugin supplies `app/settings/page.tsx`, and writes `menus.json` / `plugins.json`. + plugin supplies `app/settings/page.tsx`, generates typed collection hubs and + CRUD editors, and writes `menus.json` / `plugins.json`. 3. `vinext build` ??static export to `.yeollin/app/dist/client/`, then the CLI copies the client output to `.yeollin/app/out/`. 4. `cargo build --release` ??final binary, embedding static files via `include_dir!`. @@ -135,6 +136,14 @@ in place. Its retention pass deletes only processed, marked rows so the outbox remains the single source of truth and pending delivery is never treated as a disposable log. +The same core migration owns `content_entries`. A plugin collection registers a +concrete Rust field type, generated handlers, and its build-time schema. Runtime +writes round-trip that concrete type through the shared JSON field while the +framework owns publication metadata and collection-scoped slug uniqueness. +Prebuild uses only exported schema/default data to assemble the generic editor; +the public endpoint is a fixed exact path and filters to `published` in the +database query. + Embedded frontend output remains read-only. Plugins that declare `runtime_storage: true` receive a `RuntimeStorage` extension backed by the application's `with_storage_dir` root. `YeollinApp::run` creates that directory diff --git a/docs/plugin-authoring.md b/docs/plugin-authoring.md index 4184afd..e6c3e81 100644 --- a/docs/plugin-authoring.md +++ b/docs/plugin-authoring.md @@ -106,6 +106,7 @@ collected automatically. | `frontend` | no | bool literal | `true` by default. Set `false` for an API-only plugin with no `app/` directory. | | `api_base` | no | string literal | Override the API namespace derived from `name`; never include `api`. | | `settings` | no | Rust type path | Register a typed settings contract and its generated API and page. | +| `collections` | no | expression list | Register compile-time typed content collections and their generated CRUD surfaces. | | `subscribers` | no | expression list | Register observe-only Inline or Deferred event subscribers. | | `public_api_routes` | no | string-literal list | Exact, fixed suffixes below this plugin's API namespace that do not require authentication. | | `runtime_storage` | no | bool literal | Require the host to configure a writable `RuntimeStorage` extension. | @@ -261,6 +262,89 @@ generates `//settings`. To own the presentation, add typed API and persistence stay unchanged. Do not place the override under a route group. +## Typed content collections + +A collection declares its plugin-specific fields as an ordinary Rust type. The +framework adds the shared content envelope: ID, title, slug, draft/published +status, author, created/updated timestamps, and the optional published +timestamp. + +```rust +use serde::{Deserialize, Serialize}; +use vespera::Schema; +use yeollin_plugin::ContentFields; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct PageFields { + pub excerpt: String, + pub body: String, + pub hero_image: Option, +} + +impl ContentFields for PageFields { + fn validate(&self) -> Result<(), String> { + if self.body.trim().is_empty() { + return Err("body must not be empty".to_string()); + } + Ok(()) + } +} + +yeollin_plugin::yeollin_content_collection! { + module: pages, + name: "pages", + label: "Pages", + fields: crate::PageFields, + order: 30, +} + +yeollin_plugin::yeollin_plugin! { + name: "content", + frontend: false, + collections: [pages::registration()], +} +``` + +Collection names are stable lowercase kebab-case identifiers. The macro emits +concrete request, response, query, and list types for `PageFields`; there is no +untyped route payload. `Default` seeds the generated editor, `Schema` describes +its controls at build time, and `ContentFields::validate` runs before every +create or field update. Keep validation deterministic and free of I/O. + +The example above owns these routes: + +| Method | Path | Access | Purpose | +|--------|------|--------|---------| +| `GET`, `POST` | `/api/content/pages` | administrator | Paginated list and draft creation. | +| `GET`, `PUT`, `DELETE` | `/api/content/pages/{id}` | administrator | Read, update, or delete one entry. | +| `POST` | `/api/content/pages/{id}/publish` | administrator | Publish a draft. | +| `POST` | `/api/content/pages/{id}/unpublish` | administrator | Return published content to draft. | +| `GET` | `/api/content/pages/published?slug=about` | public | Fetch one published entry by its exact slug. | + +The public route is fixed and whole-path exact. The slug remains a query value, +so no dynamic route has to be exempted from authentication. Drafts return 404 +there. Management handlers always call `require_role("admin")`; authentication +alone does not authorize them. + +All collections share the framework-owned `content_entries` table. Slugs are +unique within a collection, while collection-specific fields are serialized +from and back into their concrete Rust type. Create, update, publish, unpublish, +and delete emit audit-enabled `content.*` events in the same transaction as the +write. The original author is retained across later edits. + +Prebuild exports each field schema and writes a collection hub plus a reusable +list/editor page under `//`. It never fetches during SSG. +Primitive fields get native form controls; object and array fields get a JSON +editor. A plugin can therefore set `frontend: false` and still ship the generated +content UI. Because persistence is shared, registering any collection requires +the host application to configure a database. + +Content may store media references such as +`media:0123456789abcdef0123456789abcdef`. Validate the canonical reference in +the field type and store that reference, not `/api/media/file?...`, an original +filename, or a runtime storage path. + ## Typed events and subscribers An event is a serializable Rust type with one stable name. The action and event diff --git a/packages/app/src/components/content/ContentCollectionCrud.tsx b/packages/app/src/components/content/ContentCollectionCrud.tsx new file mode 100644 index 0000000..5891616 --- /dev/null +++ b/packages/app/src/components/content/ContentCollectionCrud.tsx @@ -0,0 +1,1039 @@ +'use client' + +import { Box, Flex, Grid, Text, VStack } from '@devup-ui/react' +import { useEffect, useId, useState } from 'react' + +export interface ContentFieldSchema { + anyOf?: ContentFieldSchema[] + description?: string + enum?: Array + format?: string + items?: ContentFieldSchema + properties?: Record + required?: string[] + title?: string + type?: string | string[] +} + +interface ContentEntry { + id: string + collection: string + title: string + slug: string + status: 'draft' | 'published' + author: string + fields: Record + createdAt: string + updatedAt: string + publishedAt: string | null +} + +interface ContentPage { + entries: ContentEntry[] + total: number + page: number + pageSize: number +} + +interface ContentForm { + title: string + slug: string + fields: Record +} + +interface ContentCollectionCrudProps { + apiPath: string + defaultValue: Record + label: string + schema: ContentFieldSchema +} + +class ContentRequestError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function parseEntry(value: unknown): ContentEntry | null { + if (!isRecord(value) || !isRecord(value.fields)) return null + if ( + typeof value.id !== 'string' || + typeof value.collection !== 'string' || + typeof value.title !== 'string' || + typeof value.slug !== 'string' || + (value.status !== 'draft' && value.status !== 'published') || + typeof value.author !== 'string' || + typeof value.createdAt !== 'string' || + typeof value.updatedAt !== 'string' || + (value.publishedAt !== null && typeof value.publishedAt !== 'string') + ) { + return null + } + return { + id: value.id, + collection: value.collection, + title: value.title, + slug: value.slug, + status: value.status, + author: value.author, + fields: value.fields, + createdAt: value.createdAt, + updatedAt: value.updatedAt, + publishedAt: value.publishedAt, + } +} + +function parsePage(value: unknown, requestedPage: number): ContentPage { + if (!isRecord(value)) { + throw new Error('The server returned invalid content data.') + } + const entries = Array.isArray(value.entries) + ? value.entries.map(parseEntry).filter((entry) => entry !== null) + : [] + return { + entries, + total: typeof value.total === 'number' ? value.total : 0, + page: typeof value.page === 'number' ? value.page : requestedPage, + pageSize: typeof value.pageSize === 'number' ? value.pageSize : 20, + } +} + +async function requestError( + response: Response, + fallback: string, +): Promise { + const body = (await response.json().catch(() => null)) as unknown + const message = + isRecord(body) && typeof body.error === 'string' ? body.error : fallback + return new ContentRequestError(message, response.status) +} + +async function loadPage( + apiPath: string, + page: number, + status: string, +): Promise { + const query = new URLSearchParams({ page: String(page), pageSize: '20' }) + if (status !== 'all') query.set('status', status) + const response = await fetch(`${apiPath}?${query}`) + if (!response.ok) { + throw await requestError(response, 'Could not load content.') + } + return parsePage(await response.json(), page) +} + +async function mutate( + path: string, + method: string, + body?: unknown, +): Promise { + const response = await fetch(path, { + body: body === undefined ? undefined : JSON.stringify(body), + headers: + body === undefined ? undefined : { 'Content-Type': 'application/json' }, + method, + }) + if (!response.ok) { + throw await requestError(response, 'Could not save content.') + } + return (await response.json()) as T +} + +function cloneFields(value: Record) { + return JSON.parse(JSON.stringify(value)) as Record +} + +function emptyForm(defaultValue: Record): ContentForm { + return { fields: cloneFields(defaultValue), slug: '', title: '' } +} + +function formFromEntry(entry: ContentEntry): ContentForm { + return { + fields: cloneFields(entry.fields), + slug: entry.slug, + title: entry.title, + } +} + +function fieldLabel(name: string, schema: ContentFieldSchema) { + if (schema.title) return schema.title + return name + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replaceAll('_', ' ') + .replace(/^./, (letter) => letter.toUpperCase()) +} + +function concreteSchema(schema: ContentFieldSchema) { + return schema.anyOf?.find((candidate) => candidate.type !== 'null') ?? schema +} + +function schemaType(schema: ContentFieldSchema) { + return Array.isArray(schema.type) + ? schema.type.find((type) => type !== 'null') + : schema.type +} + +function formatDate(value: string | null) { + if (value === null) return 'Not published' + const date = new Date(value) + return Number.isNaN(date.getTime()) ? 'Unknown time' : date.toLocaleString() +} + +interface JsonFieldProps { + id: string + onChange: (value: unknown) => void + required: boolean + value: unknown +} + +function JsonField({ id, onChange, required, value }: JsonFieldProps) { + const [raw, setRaw] = useState(() => JSON.stringify(value, null, 2)) + const [invalid, setInvalid] = useState(false) + + return ( + + ) => { + const next = event.target.value + setRaw(next) + try { + onChange(JSON.parse(next)) + setInvalid(false) + } catch { + setInvalid(true) + } + }} + outline="none" + p={3} + required={required} + value={raw} + /> + {invalid ? ( + + Enter valid JSON before saving. + + ) : null} + + ) +} + +interface FieldEditorProps { + name: string + onChange: (value: unknown) => void + required: boolean + schema: ContentFieldSchema + value: unknown +} + +function FieldEditor({ + name, + onChange, + required, + schema, + value, +}: FieldEditorProps) { + const fallbackId = useId() + const id = `content-${name}-${fallbackId.replaceAll(':', '')}` + const field = concreteSchema(schema) + const type = schemaType(field) + const label = fieldLabel(name, field) + + let control: React.ReactNode + if (field.enum) { + control = ( + ) => + onChange(event.target.value) + } + p={3} + required={required} + value={String(value ?? '')} + > + {!required ? : null} + {field.enum.map((option) => ( + + ))} + + ) + } else if (type === 'boolean') { + control = ( + + ) => + onChange(event.target.checked) + } + type="checkbox" + /> + + Enabled + + + ) + } else if (type === 'number' || type === 'integer') { + control = ( + ) => + onChange( + event.target.value === '' ? null : event.target.valueAsNumber, + ) + } + outline="none" + p={3} + required={required} + step={type === 'integer' ? 1 : 'any'} + type="number" + value={typeof value === 'number' ? value : ''} + /> + ) + } else if (type === 'array' || type === 'object') { + control = ( + + ) + } else { + const multiline = + field.format === 'textarea' || /body|content|description/i.test(name) + control = multiline ? ( + ) => + onChange( + event.target.value === '' && !required ? null : event.target.value, + ) + } + outline="none" + p={3} + required={required} + value={typeof value === 'string' ? value : ''} + /> + ) : ( + ) => + onChange( + event.target.value === '' && !required ? null : event.target.value, + ) + } + outline="none" + p={3} + placeholder={/image|media/i.test(name) ? 'media:0123...' : undefined} + required={required} + type="text" + value={typeof value === 'string' ? value : ''} + /> + ) + } + + return ( + + + {label} + {required ? ' *' : ''} + + {control} + {field.description ? ( + + {field.description} + + ) : null} + + ) +} + +export function ContentCollectionCrud({ + apiPath, + defaultValue, + label, + schema, +}: ContentCollectionCrudProps) { + const [contentPage, setContentPage] = useState({ + entries: [], + page: 1, + pageSize: 20, + total: 0, + }) + const [page, setPage] = useState(1) + const [statusFilter, setStatusFilter] = useState('all') + const [refresh, setRefresh] = useState(0) + const [selected, setSelected] = useState(null) + const [creating, setCreating] = useState(false) + const [form, setForm] = useState(() => emptyForm(defaultValue)) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [forbidden, setForbidden] = useState(false) + + useEffect(() => { + let cancelled = false + void loadPage(apiPath, page, statusFilter) + .then((result) => { + if (cancelled) return + setContentPage(result) + setError('') + setForbidden(false) + }) + .catch((cause: unknown) => { + if (cancelled) return + setContentPage((current) => ({ ...current, entries: [], total: 0 })) + setError( + cause instanceof Error ? cause.message : 'Could not load content.', + ) + setForbidden( + cause instanceof ContentRequestError && cause.status === 403, + ) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [apiPath, page, refresh, statusFilter]) + + function reload() { + setLoading(true) + setRefresh((current) => current + 1) + } + + function startCreate() { + setCreating(true) + setSelected(null) + setForm(emptyForm(defaultValue)) + setError('') + setNotice('') + } + + function startEdit(entry: ContentEntry) { + setCreating(false) + setSelected(entry) + setForm(formFromEntry(entry)) + setError('') + setNotice('') + } + + function updateField(name: string, value: unknown) { + setForm((current) => ({ + ...current, + fields: { ...current.fields, [name]: value }, + })) + } + + async function save(event: React.FormEvent) { + event.preventDefault() + setSaving(true) + setError('') + setNotice('') + try { + const result = creating + ? await mutate(apiPath, 'POST', form) + : await mutate(`${apiPath}/${selected?.id}`, 'PUT', form) + const entry = parseEntry(result) + if (entry === null) + throw new Error('The server returned invalid content data.') + setSelected(entry) + setCreating(false) + setForm(formFromEntry(entry)) + setNotice(creating ? 'Draft created.' : 'Changes saved.') + reload() + } catch (cause) { + setError( + cause instanceof Error ? cause.message : 'Could not save content.', + ) + } finally { + setSaving(false) + } + } + + async function transition(action: 'publish' | 'unpublish') { + if (selected === null) return + setSaving(true) + setError('') + setNotice('') + try { + const result = await mutate( + `${apiPath}/${selected.id}/${action}`, + 'POST', + ) + const entry = parseEntry(result) + if (entry === null) + throw new Error('The server returned invalid content data.') + setSelected(entry) + setForm(formFromEntry(entry)) + setNotice( + action === 'publish' ? 'Entry published.' : 'Entry returned to draft.', + ) + reload() + } catch (cause) { + setError( + cause instanceof Error ? cause.message : `Could not ${action} content.`, + ) + } finally { + setSaving(false) + } + } + + async function remove() { + if ( + selected === null || + !window.confirm(`Delete "${selected.title}"? This cannot be undone.`) + ) { + return + } + setSaving(true) + setError('') + try { + await mutate(`${apiPath}/${selected.id}`, 'DELETE') + setSelected(null) + setCreating(false) + setForm(emptyForm(defaultValue)) + setNotice('Entry deleted.') + if (contentPage.entries.length === 1 && page > 1) setPage(page - 1) + else reload() + } catch (cause) { + setError( + cause instanceof Error ? cause.message : 'Could not delete content.', + ) + } finally { + setSaving(false) + } + } + + const pageCount = Math.max( + 1, + Math.ceil(contentPage.total / contentPage.pageSize), + ) + const fields = schema.properties ?? {} + const editorOpen = creating || selected !== null + + return ( + + + + + {label} + + Manage typed drafts and make only reviewed entries public. + + + + + {loading ? 'Refreshing...' : 'Refresh'} + + + New draft + + + + + {forbidden ? ( + + + Administrator access required + + + ) : null} + {!forbidden && error !== '' ? ( + + + {error} + + + ) : null} + {notice !== '' ? ( + + + {notice} + + + ) : null} + + {!forbidden ? ( + + + + + {contentPage.total}{' '} + {contentPage.total === 1 ? 'entry' : 'entries'} + + ) => { + setLoading(true) + setPage(1) + setStatusFilter(event.target.value) + }} + px={3} + py={2} + value={statusFilter} + > + + + + + + + {loading && contentPage.entries.length === 0 ? ( + + + Loading content... + + + ) : contentPage.entries.length === 0 ? ( + + + No entries yet + + Create the first draft in this collection. + + + + ) : ( + + {contentPage.entries.map((entry) => ( + startEdit(entry)} + p={4} + textAlign="left" + type="button" + w="100%" + > + + + + {entry.title} + + + + {entry.status} + + + + + /{entry.slug} / Updated {formatDate(entry.updatedAt)} + + + + ))} + + )} + + {contentPage.total > 0 ? ( + + { + setLoading(true) + setPage(page - 1) + }} + opacity={page <= 1 || loading ? 0.5 : 1} + px={4} + py={2} + type="button" + > + Previous + + + Page {page} of {pageCount} + + = pageCount || loading ? 'not-allowed' : 'pointer' + } + disabled={page >= pageCount || loading} + onClick={() => { + setLoading(true) + setPage(page + 1) + }} + opacity={page >= pageCount || loading ? 0.5 : 1} + px={4} + py={2} + type="button" + > + Next + + + ) : null} + + + {editorOpen ? ( + + + + + + + {creating ? 'New draft' : 'Edit entry'} + + {!creating && selected ? ( + + Author {selected.author} / Published{' '} + {formatDate(selected.publishedAt)} + + ) : null} + + { + setCreating(false) + setSelected(null) + }} + type="button" + > + Close + + + + + + Title * + + , + ) => + setForm((current) => ({ + ...current, + title: event.target.value, + })) + } + outline="none" + p={3} + required + value={form.title} + /> + + + + Slug * + + , + ) => + setForm((current) => ({ + ...current, + slug: event.target.value, + })) + } + outline="none" + p={3} + placeholder="about-us" + required + value={form.slug} + /> + + + {Object.entries(fields).map(([name, field]) => ( + updateField(name, value)} + required={schema.required?.includes(name) ?? false} + schema={field} + value={form.fields[name]} + /> + ))} + + + + {saving + ? 'Saving...' + : creating + ? 'Create draft' + : 'Save changes'} + + {!creating && selected?.status === 'draft' ? ( + void transition('publish')} + px={4} + py={3} + type="button" + > + Publish + + ) : null} + {!creating && selected?.status === 'published' ? ( + void transition('unpublish')} + px={4} + py={3} + type="button" + > + Return to draft + + ) : null} + {!creating ? ( + void remove()} + px={4} + py={3} + type="button" + > + Delete + + ) : null} + + + + + ) : null} + + ) : null} + + + ) +} diff --git a/packages/app/src/components/content/ContentCollectionsHub.tsx b/packages/app/src/components/content/ContentCollectionsHub.tsx new file mode 100644 index 0000000..27b6a28 --- /dev/null +++ b/packages/app/src/components/content/ContentCollectionsHub.tsx @@ -0,0 +1,70 @@ +import { Box, Grid, Text, VStack } from '@devup-ui/react' +import Link from 'next/link' + +interface ContentCollectionLink { + label: string + path: string +} + +interface ContentCollectionsHubProps { + collections: ContentCollectionLink[] + pluginName: string +} + +function humanize(value: string) { + return value + .split(/[-_]/) + .filter(Boolean) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(' ') +} + +export function ContentCollectionsHub({ + collections, + pluginName, +}: ContentCollectionsHubProps) { + return ( + + + + {humanize(pluginName)} + + Choose a typed collection to manage its drafts and published + entries. + + + + {collections.map((collection) => ( + + + + {collection.label} + + Create, edit, publish, and unpublish entries. + + + + + ))} + + + + ) +} diff --git a/plugins/content/Cargo.toml b/plugins/content/Cargo.toml new file mode 100644 index 0000000..46499ef --- /dev/null +++ b/plugins/content/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "content" +version = "0.1.0" +edition = "2021" +description = "A Yeollin CMS plugin" +license = "MIT" + +[lib] +path = "src/lib.rs" + +[dependencies] +yeollin-plugin = { workspace = true } +vespera = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/plugins/content/package.json b/plugins/content/package.json new file mode 100644 index 0000000..6e8522b --- /dev/null +++ b/plugins/content/package.json @@ -0,0 +1,9 @@ +{ + "name": "@yeollin-plugin/content", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "cargo run -p yeollin-cli -- dev", + "build": "cargo run -p yeollin-cli -- build" + } +} diff --git a/plugins/content/src/lib.rs b/plugins/content/src/lib.rs new file mode 100644 index 0000000..505bae4 --- /dev/null +++ b/plugins/content/src/lib.rs @@ -0,0 +1,54 @@ +//! Reference compile-time typed content collections. + +use serde::{Deserialize, Serialize}; +use vespera::Schema; +use yeollin_plugin::ContentFields; + +/// Fields specific to the reference pages collection. +#[derive(Clone, Debug, Default, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct PageFields { + pub excerpt: String, + pub body: String, + pub hero_image: Option, +} + +impl ContentFields for PageFields { + fn validate(&self) -> Result<(), String> { + if self.excerpt.chars().count() > 240 { + return Err("excerpt must be at most 240 characters".to_string()); + } + if self.body.trim().is_empty() { + return Err("body must not be empty".to_string()); + } + if let Some(reference) = self.hero_image.as_deref() { + let id = reference + .strip_prefix("media:") + .ok_or_else(|| "heroImage must be a media reference".to_string())?; + if id.len() != 32 + || !id + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("heroImage must be a canonical media reference".to_string()); + } + } + Ok(()) + } +} + +yeollin_plugin::yeollin_content_collection! { + module: pages, + name: "pages", + label: "Pages", + fields: crate::PageFields, + order: 30, +} + +yeollin_plugin::yeollin_plugin! { + name: "content", + author: "DevFive", + description: "Compile-time typed content collections", + frontend: false, + collections: [pages::registration()], +} diff --git a/plugins/content/src/routes/mod.rs b/plugins/content/src/routes/mod.rs new file mode 100644 index 0000000..fad1add --- /dev/null +++ b/plugins/content/src/routes/mod.rs @@ -0,0 +1 @@ +//! Content collection handlers are generated by `yeollin_content_collection!`. diff --git a/plugins/content/tsconfig.json b/plugins/content/tsconfig.json new file mode 100644 index 0000000..e551132 --- /dev/null +++ b/plugins/content/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../packages/app/tsconfig.json", + "compilerOptions": { + "paths": { + "@/*": ["../../packages/app/src/*"] + }, + "types": [], + "noEmit": true + }, + "include": ["types.d.ts"], + "exclude": ["node_modules"] +} diff --git a/plugins/content/types.d.ts b/plugins/content/types.d.ts new file mode 100644 index 0000000..336ce12 --- /dev/null +++ b/plugins/content/types.d.ts @@ -0,0 +1 @@ +export {}