From c051f68fa0ca40fe148158ee44551570070f6ae2 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 31 Aug 2026 06:25:21 +0900 Subject: [PATCH 1/4] Add typed content collections Define collection schemas in Rust so plugins get concrete CRUD and publication handlers without duplicating persistence. Export field contracts for the shared admin UI and keep content writes and audit events in one transaction. --- Cargo.lock | 12 + apps/example-app/Cargo.toml | 1 + apps/example-app/src/main.rs | 2 +- apps/example-app/tests/system.rs | 249 ++++ crates/app/src/app.rs | 96 +- crates/cli/src/commands/prebuild.rs | 180 ++- crates/core/Cargo.toml | 1 + ...004_create_content_entries.vespertide.json | 83 ++ .../models/content_entries.vespertide.json | 72 ++ crates/core/src/content.rs | 828 ++++++++++++- crates/core/src/export.rs | 27 +- crates/core/src/models/content_entries.rs | 38 + crates/core/src/models/mod.rs | 1 + crates/plugin-macros/src/lib.rs | 457 +++++++- crates/plugin/src/error.rs | 22 + crates/plugin/src/lib.rs | 13 +- crates/plugin/src/metadata.rs | 37 +- .../content/ContentCollectionCrud.tsx | 1039 +++++++++++++++++ .../content/ContentCollectionsHub.tsx | 70 ++ plugins/content/Cargo.toml | 15 + plugins/content/package.json | 9 + plugins/content/src/lib.rs | 54 + plugins/content/tsconfig.json | 12 + plugins/content/types.d.ts | 1 + 24 files changed, 3263 insertions(+), 56 deletions(-) create mode 100644 crates/core/migrations/0004_create_content_entries.vespertide.json create mode 100644 crates/core/models/content_entries.vespertide.json create mode 100644 crates/core/src/models/content_entries.rs create mode 100644 packages/app/src/components/content/ContentCollectionCrud.tsx create mode 100644 packages/app/src/components/content/ContentCollectionsHub.tsx create mode 100644 plugins/content/Cargo.toml create mode 100644 plugins/content/package.json create mode 100644 plugins/content/src/lib.rs create mode 100644 plugins/content/tsconfig.json create mode 100644 plugins/content/types.d.ts 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/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/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/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/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 {} From 33f42b5eb7d647a01a0a9060b0f5c445990b6e54 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 31 Aug 2026 06:25:37 +0900 Subject: [PATCH 2/4] Document typed content collections Explain collection declarations, generated routes, publication security, audit events, media references, and prebuild-owned admin pages. --- CHANGELOG.md | 2 + README.md | 3 +- docs/architecture.md | 15 +++++-- docs/plugin-authoring.md | 84 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 4 deletions(-) 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/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/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 From fef94fc62dc815b5510d1a45bc07cfb1fce02898 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 31 Aug 2026 06:37:19 +0900 Subject: [PATCH 3/4] Record the content workspace package Keep frozen Bun installs reproducible after adding the new plugin workspace. --- bun.lock | 6 ++++++ 1 file changed, 6 insertions(+) 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"], From e9627248d11183cdb49fa9cab7462ed2b4b03c20 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 31 Aug 2026 06:43:36 +0900 Subject: [PATCH 4/4] Track the content route module Ensure Vespera route discovery sees its required source directory on filesystems that do not preserve empty folders. --- plugins/content/src/routes/mod.rs | 1 + 1 file changed, 1 insertion(+) create mode 100644 plugins/content/src/routes/mod.rs 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!`.