Enhance documentation and admin UI: Add detailed implementation guidelines in CLAUDE.md, introduce a referrer index in README.md, and update admin UI translations for improved user experience. Update package dependencies for better functionality and performance.

This commit is contained in:
Peter Meier
2026-03-13 10:55:33 +01:00
parent 7754d800f5
commit 606455c59b
42 changed files with 3814 additions and 421 deletions

View File

@@ -11,6 +11,7 @@ use axum::Json;
use serde_json::{json, Value};
use tokio::sync::RwLock;
use crate::referrers::{Referrer, ReferrerIndex};
use crate::schema::types::{SchemaDefinition, VALID_FIELD_TYPES};
use crate::schema::validator;
use crate::schema::SchemaRegistry;
@@ -53,6 +54,10 @@ pub struct AppState {
pub stores: Option<HashMap<String, Arc<dyn ContentStore>>>,
/// Assets dir per environment when environments is set. Key = env name.
pub assets_dirs: Option<HashMap<String, PathBuf>>,
/// Reverse index for "who references this entry?". Updated on create/update/delete. None when environments are used.
pub referrer_index: Option<Arc<RwLock<ReferrerIndex>>>,
/// Path to persist referrer index (e.g. content/_referrers.json).
pub referrer_index_path: Option<PathBuf>,
}
impl AppState {
@@ -726,6 +731,7 @@ pub async fn create_entry(
// Normalize reference arrays: objects with _slug → slug strings (so clients can send resolved refs)
validator::normalize_reference_arrays(&schema, &mut body);
validator::normalize_multi_select(&schema, &mut body);
// Validate against schema (type checks, constraints, strict mode, …)
let errors = validator::validate_content(&schema, &body);
@@ -766,6 +772,27 @@ pub async fn create_entry(
.map_err(ApiError::from)?;
state.cache.invalidate_collection(&env, &collection).await;
// Update referrer index: this entry references (ref_coll, ref_slug)
if let (Some(ref idx), Some(ref path)) = (&state.referrer_index, &state.referrer_index_path) {
let refs = validator::extract_references(&schema, &body);
let referrer = Referrer {
collection: collection.clone(),
slug: slug.clone(),
field: String::new(), // we add per (ref_coll, ref_slug, field) below
locale: locale_ref.map(str::to_string),
};
let mut index = idx.write().await;
for (ref_coll, ref_slug, field) in refs {
let mut r = referrer.clone();
r.field = field;
index.add_referrer(&ref_coll, &ref_slug, r);
}
drop(index);
if let Err(e) = idx.read().await.save(path) {
tracing::warn!("Failed to save referrer index: {}", e);
}
}
// Return created entry (with reference format)
let entry = store
.get(&collection, &slug, locale_ref)
@@ -846,6 +873,7 @@ pub async fn update_entry(
// Normalize reference arrays: objects with _slug → slug strings (so clients can send resolved refs)
validator::normalize_reference_arrays(&schema, &mut body);
validator::normalize_multi_select(&schema, &mut body);
// Load existing content for readonly check
let existing = store
@@ -902,6 +930,28 @@ pub async fn update_entry(
.map_err(ApiError::from)?;
state.cache.invalidate_collection(&env, &collection).await;
// Update referrer index: remove old refs from this entry, add new refs
if let (Some(ref idx), Some(ref path)) = (&state.referrer_index, &state.referrer_index_path) {
let mut index = idx.write().await;
index.remove_all_referrers_from(&collection, &slug);
let refs = validator::extract_references(&schema, &body);
let referrer = Referrer {
collection: collection.clone(),
slug: slug.clone(),
field: String::new(),
locale: locale_ref.map(str::to_string),
};
for (ref_coll, ref_slug, field) in refs {
let mut r = referrer.clone();
r.field = field;
index.add_referrer(&ref_coll, &ref_slug, r);
}
drop(index);
if let Err(e) = idx.read().await.save(path) {
tracing::warn!("Failed to save referrer index: {}", e);
}
}
// Return updated entry (with reference format)
let entry = store
.get(&collection, &slug, locale_ref)
@@ -933,6 +983,22 @@ pub async fn update_entry(
Ok(Json(formatted))
}
// ---------------------------------------------------------------------------
// GET /api/content/:collection/:slug/referrers
// ---------------------------------------------------------------------------
pub async fn get_referrers(
State(state): State<Arc<AppState>>,
Path((collection, slug)): Path<(String, String)>,
) -> Result<Json<Value>, ApiError> {
let referrers = match &state.referrer_index {
Some(idx) => idx.read().await.get_referrers(&collection, &slug),
None => vec![],
};
Ok(Json(
serde_json::to_value(&referrers).unwrap_or_else(|_| json!([])),
))
}
// ---------------------------------------------------------------------------
// GET /api/locales
// ---------------------------------------------------------------------------
@@ -982,6 +1048,17 @@ pub async fn delete_entry(
)));
}
// Update referrer index: remove this entry from every (ref_coll, ref_slug) it referenced; and remove key (collection, slug)
if let (Some(ref idx), Some(ref path)) = (&state.referrer_index, &state.referrer_index_path) {
let mut index = idx.write().await;
index.remove_all_referrers_from(&collection, &slug);
index.remove_referenced_entry(&collection, &slug);
drop(index);
if let Err(e) = idx.read().await.save(path) {
tracing::warn!("Failed to save referrer index: {}", e);
}
}
store
.delete(&collection, &slug, locale_ref)
.await

View File

@@ -235,6 +235,41 @@ pub fn generate_spec(registry: &SchemaRegistry, server_url: &str) -> Value {
}
}),
);
// GET /api/content/:collection/:slug/referrers who references this entry
paths.insert(
format!("/api/content/{}/{{slug}}/referrers", name),
json!({
"get": {
"summary": format!("List referrers of '{}' entry", name),
"description": "Returns all entries that reference this entry (reverse index). Empty when not using referrer index (e.g. with RUSTYCMS_ENVIRONMENTS).",
"operationId": format!("get{}Referrers", pascal),
"tags": [tag],
"parameters": [
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Entry slug" }
],
"responses": {
"200": {
"description": "List of referrers (collection, slug, field, locale)",
"content": { "application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"collection": { "type": "string", "description": "Collection of the referring entry" },
"slug": { "type": "string", "description": "Slug of the referring entry" },
"field": { "type": "string", "description": "Field that holds the reference" },
"locale": { "type": "string", "nullable": true, "description": "Locale of the referring entry if applicable" }
}
}
}
}}
}
}
}
}),
);
}
// ── Asset management ─────────────────────────────────────────────────
@@ -701,6 +736,7 @@ const API_INDEX_HTML: &str = r#"<!DOCTYPE html>
<li><code>POST</code> /api/content/:type Create entry</li>
<li><code>PUT</code> /api/content/:type/:slug Update entry</li>
<li><code>DELETE</code> /api/content/:type/:slug Delete entry</li>
<li><code>GET</code> /api/content/:type/:slug/referrers List entries that reference this entry (reverse index)</li>
<li><code>GET</code> <a href="/api/transform?url=https://httpbin.org/image/png&w=80&h=80">/api/transform</a> Transform image from URL (w, h, ar, fit, format)</li>
<li><code>GET</code> <a href="/health">/health</a> Health check</li>
</ul>

View File

@@ -41,6 +41,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
"/api/content/:collection",
get(handlers::list_entries).post(handlers::create_entry),
)
.route(
"/api/content/:collection/:slug/referrers",
get(handlers::get_referrers),
)
.route(
"/api/content/:collection/:slug",
get(handlers::get_entry)

View File

@@ -1,5 +1,6 @@
//! Library for RustyCMS (used by the main server binary and by tools like export-json-schema).
pub mod api;
pub mod referrers;
pub mod schema;
pub mod store;

View File

@@ -13,6 +13,8 @@ use tracing_subscriber::EnvFilter;
use rustycms::api::cache::ContentCache;
use rustycms::api::handlers::AppState;
use rustycms::referrers::{Referrer, ReferrerIndex};
use rustycms::schema::validator;
use rustycms::schema::SchemaRegistry;
use rustycms::store::{filesystem::FileStore, sqlite::SqliteStore, ContentStore};
@@ -69,33 +71,26 @@ fn detect_locales(content_dir: &std::path::Path) -> Option<Vec<String>> {
}
fn reload_schemas(
types_dir: &PathBuf,
server_url: &str,
registry: &Arc<RwLock<SchemaRegistry>>,
openapi_spec: &Arc<RwLock<serde_json::Value>>,
cache: &Arc<ContentCache>,
rt_handle: tokio::runtime::Handle,
types_dir: PathBuf,
server_url: String,
registry: Arc<RwLock<SchemaRegistry>>,
openapi_spec: Arc<RwLock<serde_json::Value>>,
cache: Arc<ContentCache>,
) {
let types_dir = types_dir.clone();
let server_url = server_url.to_string();
let registry = Arc::clone(registry);
let openapi_spec = Arc::clone(openapi_spec);
let cache = Arc::clone(cache);
std::thread::spawn(move || {
let rt = tokio::runtime::Handle::current();
rt.block_on(async move {
match SchemaRegistry::load(&types_dir) {
Ok(new_registry) => {
let spec = rustycms::api::openapi::generate_spec(&new_registry, &server_url);
*registry.write().await = new_registry;
*openapi_spec.write().await = spec;
cache.invalidate_all().await;
tracing::info!("Hot-reload: schemas and OpenAPI spec updated, content cache cleared");
}
Err(e) => {
tracing::error!("Hot-reload failed: {}", e);
}
rt_handle.spawn(async move {
match SchemaRegistry::load(&types_dir) {
Ok(new_registry) => {
let spec = rustycms::api::openapi::generate_spec(&new_registry, &server_url);
*registry.write().await = new_registry;
*openapi_spec.write().await = spec;
cache.invalidate_all().await;
tracing::info!("Hot-reload: schemas and OpenAPI spec updated, content cache cleared");
}
});
Err(e) => {
tracing::error!("Hot-reload failed: {}", e);
}
}
});
}
@@ -223,6 +218,67 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("Webhooks enabled: {} URL(s)", webhook_urls.len());
}
// Reverse referrer index (file-based in content dir). Only when not using environments (single content root).
// When the index file is missing, run a full reindex over all collections and save.
let (referrer_index, referrer_index_path) = if environments.is_none() {
let path = assets_dir.parent().unwrap().join("_referrers.json");
let index = if path.exists() {
ReferrerIndex::load(&path)
} else {
tracing::info!("Referrer index not found, building full index from content…");
let mut index = ReferrerIndex::new();
let collections_with_schema: Vec<(String, rustycms::schema::types::SchemaDefinition)> = {
let guard = registry.read().await;
guard
.collection_names()
.into_iter()
.filter_map(|c| {
guard.get(&c).filter(|s| !s.reusable).map(|s| (c, s.clone()))
})
.collect()
};
for (collection, schema) in collections_with_schema {
let locale_opts: Vec<Option<&str>> = locales
.as_ref()
.map(|l| l.iter().map(|s| s.as_str()).map(Some).collect())
.unwrap_or_else(|| vec![None]);
for locale_ref in locale_opts {
match store.list(&collection, locale_ref).await {
Ok(entries) => {
for (slug, value) in entries {
let refs = validator::extract_references(&schema, &value);
let referrer = Referrer {
collection: collection.clone(),
slug: slug.clone(),
field: String::new(),
locale: locale_ref.map(str::to_string),
};
for (ref_coll, ref_slug, field) in refs {
let mut r = referrer.clone();
r.field = field;
index.add_referrer(&ref_coll, &ref_slug, r);
}
}
}
Err(e) => tracing::warn!("List {} (locale {:?}) failed: {}", collection, locale_ref, e),
}
}
}
if let Err(e) = index.save(&path) {
tracing::warn!("Failed to save referrer index: {}", e);
} else {
tracing::info!("Referrer index saved to {}", path.display());
}
index
};
if path.exists() {
tracing::info!("Referrer index loaded from {}", path.display());
}
(Some(Arc::new(RwLock::new(index))), Some(path))
} else {
(None, None)
};
let state = Arc::new(AppState {
registry: Arc::clone(&registry),
store,
@@ -239,9 +295,12 @@ async fn main() -> anyhow::Result<()> {
environments,
stores: stores_map,
assets_dirs: assets_dirs_map,
referrer_index,
referrer_index_path,
});
// Hot-reload: watch types_dir and reload schemas on change
// Hot-reload: watch types_dir and reload schemas on change (run reload on main Tokio runtime from watcher thread)
let rt_handle = tokio::runtime::Handle::current();
let types_dir_for_callback = cli.types_dir.canonicalize().unwrap_or_else(|_| cli.types_dir.clone());
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher = RecommendedWatcher::new(
@@ -275,7 +334,14 @@ async fn main() -> anyhow::Result<()> {
// Debounce: wait for editor to finish writing, drain extra events, then reload once
std::thread::sleep(Duration::from_millis(800));
while rx.try_recv().is_ok() {}
reload_schemas(&types_dir_watch, &server_url_watch, &registry, &openapi_spec, &cache);
reload_schemas(
rt_handle.clone(),
types_dir_watch.clone(),
server_url_watch.clone(),
Arc::clone(&registry),
Arc::clone(&openapi_spec),
Arc::clone(&cache),
);
}
});
tracing::info!("Hot-reload: watching {}", cli.types_dir.display());

123
src/referrers.rs Normal file
View File

@@ -0,0 +1,123 @@
//! Reverse index: for each (collection, slug) store which entries reference it.
//! Updated on create/update/delete so we can answer "where is this entry referenced?".
//! Persisted as a single JSON file in the content directory (e.g. `content/_referrers.json`).
use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
/// One referrer: an entry that points to another via a reference field.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct Referrer {
pub collection: String,
pub slug: String,
pub field: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
}
fn key(collection: &str, slug: &str) -> String {
format!("{}:{}", collection, slug)
}
/// In-memory index: (ref_collection, ref_slug) -> list of referrers.
/// Persisted as JSON file so it survives restarts.
#[derive(Debug, Default, Clone)]
pub struct ReferrerIndex {
/// "collection:slug" -> list of referrers
index: HashMap<String, Vec<Referrer>>,
}
impl ReferrerIndex {
pub fn new() -> Self {
Self::default()
}
/// Load index from JSON file. Missing or invalid file => empty index.
pub fn load(path: &Path) -> Self {
let data = match std::fs::read_to_string(path) {
Ok(s) => s,
_ => return Self::new(),
};
let index: HashMap<String, Vec<Referrer>> = match serde_json::from_str(&data) {
Ok(m) => m,
_ => return Self::new(),
};
Self { index }
}
/// Persist index to JSON file.
pub fn save(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let data = serde_json::to_string_pretty(&self.index)?;
std::fs::write(path, data)
}
/// Remove this (referrer_collection, referrer_slug) from the referrer list of (ref_collection, ref_slug).
pub fn remove_referrer(
&mut self,
ref_collection: &str,
ref_slug: &str,
referrer: &Referrer,
) {
let k = key(ref_collection, ref_slug);
if let Some(list) = self.index.get_mut(&k) {
list.retain(|r| {
r.collection != referrer.collection
|| r.slug != referrer.slug
|| r.field != referrer.field
|| r.locale != referrer.locale
});
if list.is_empty() {
self.index.remove(&k);
}
}
}
/// Add this referrer to the list for (ref_collection, ref_slug).
pub fn add_referrer(
&mut self,
ref_collection: &str,
ref_slug: &str,
referrer: Referrer,
) {
let k = key(ref_collection, ref_slug);
self.index
.entry(k)
.or_default()
.push(referrer);
}
/// Remove all referrers that are (referrer_collection, referrer_slug) from every key.
/// Used when an entry is updated: clear its old refs, then add new refs.
pub fn remove_all_referrers_from(&mut self, referrer_collection: &str, referrer_slug: &str) {
let keys_to_check: Vec<String> = self.index.keys().cloned().collect();
for k in keys_to_check {
let empty = if let Some(list) = self.index.get_mut(&k) {
list.retain(|r| r.collection != referrer_collection || r.slug != referrer_slug);
list.is_empty()
} else {
false
};
if empty {
self.index.remove(&k);
}
}
}
/// Get all referrers of (collection, slug).
pub fn get_referrers(&self, collection: &str, slug: &str) -> Vec<Referrer> {
self.index
.get(&key(collection, slug))
.cloned()
.unwrap_or_default()
}
/// Remove the list for (ref_collection, ref_slug). Used when that entry is deleted.
pub fn remove_referenced_entry(&mut self, ref_collection: &str, ref_slug: &str) {
self.index.remove(&key(ref_collection, ref_slug));
}
}

View File

@@ -81,7 +81,7 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
}
}
"reference" => {
let desc = if let Some(ref list) = fd.collections {
let mut desc = if let Some(ref list) = fd.collections {
if list.is_empty() {
"Reference (slug)".to_string()
} else {
@@ -92,10 +92,20 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
} else {
"Reference (slug)".to_string()
};
if let Some(ref allowed) = fd.allowed_slugs {
if !allowed.is_empty() {
desc.push_str(&format!(" Allowed slugs: {}.", allowed.join(", ")));
}
}
if let Some(ref allowed) = fd.allowed_collections {
if !allowed.is_empty() {
desc.push_str(&format!(" Allowed content types: {}.", allowed.join(", ")));
}
}
json!({ "type": "string", "description": desc })
},
"referenceOrInline" => {
let ref_desc = if let Some(ref list) = fd.collections {
let mut ref_desc = if let Some(ref list) = fd.collections {
if list.is_empty() {
"Reference (slug)".to_string()
} else {
@@ -106,6 +116,16 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
} else {
"Reference (slug)".to_string()
};
if let Some(ref allowed) = fd.allowed_slugs {
if !allowed.is_empty() {
ref_desc.push_str(&format!(" Allowed slugs: {}.", allowed.join(", ")));
}
}
if let Some(ref allowed) = fd.allowed_collections {
if !allowed.is_empty() {
ref_desc.push_str(&format!(" Allowed content types: {}.", allowed.join(", ")));
}
}
let ref_schema = json!({ "type": "string", "description": ref_desc });
let inline_schema = if let Some(ref nested) = fd.fields {
let mut props = serde_json::Map::new();
@@ -128,6 +148,14 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
};
json!({ "oneOf": [ ref_schema, inline_schema ] })
},
"multiSelect" => {
let items = if let Some(ref ev) = fd.enum_values {
json!({ "type": "string", "enum": ev })
} else {
json!({ "type": "string" })
};
json!({ "type": "array", "items": items })
},
_ => json!({ "type": "string" }),
};
@@ -136,8 +164,10 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
if let Some(ref desc) = fd.description {
obj.insert("description".to_string(), json!(desc));
}
if let Some(ref ev) = fd.enum_values {
obj.insert("enum".to_string(), json!(ev));
if fd.field_type != "multiSelect" {
if let Some(ref ev) = fd.enum_values {
obj.insert("enum".to_string(), json!(ev));
}
}
if let Some(ref dv) = fd.default {
obj.insert("default".to_string(), dv.clone());

View File

@@ -150,6 +150,14 @@ pub struct FieldDefinition {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub collections: Option<Vec<String>>,
/// Optional whitelist of allowed slugs for reference fields. If set and non-empty, only these slugs are valid.
#[serde(rename = "allowedSlugs", skip_serializing_if = "Option::is_none", default)]
pub allowed_slugs: Option<Vec<String>>,
/// Optional whitelist of allowed content types (collections) for reference fields. If set and non-empty, only entries from these collections are valid (intersection with collection/collections).
#[serde(rename = "allowedCollections", skip_serializing_if = "Option::is_none", default)]
pub allowed_collections: Option<Vec<String>>,
/// Human-readable description (appears in Swagger UI).
#[serde(skip_serializing_if = "Option::is_none", default)]
pub description: Option<String>,
@@ -158,6 +166,14 @@ pub struct FieldDefinition {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub section: Option<String>,
/// Optional hint for admin UI how to render the field (e.g. "textarea" for string → multi-line input, "code" for code editor with syntax highlighting).
#[serde(skip_serializing_if = "Option::is_none", default)]
pub widget: Option<String>,
/// When widget is "code", language for syntax highlighting (e.g. "css", "javascript", "json"). Passed through to admin UI.
#[serde(rename = "codeLanguage", skip_serializing_if = "Option::is_none", default)]
pub code_language: Option<String>,
// ── String constraints ───────────────────────────────────────────────
#[serde(skip_serializing_if = "Option::is_none", default, rename = "minLength")]
@@ -196,6 +212,7 @@ pub const VALID_FIELD_TYPES: &[&str] = &[
"richtext", "html", "markdown",
"textOrRef", // string = inline text, or "file:path" = content loaded from file
"array", "object", "reference", "referenceOrInline",
"multiSelect", // array of strings, each must be one of enum; UI: checkboxes or multi-select
];
impl FieldDefinition {
@@ -204,15 +221,25 @@ impl FieldDefinition {
}
/// Collections to try for reference resolution/validation (polymorphic or single).
/// When `allowed_collections` is set and non-empty, returns the intersection with collection(s).
pub fn reference_collections(&self) -> Vec<&str> {
if let Some(ref list) = self.collections {
if !list.is_empty() {
return list.iter().map(String::as_str).collect();
let base: Vec<&str> = if let Some(ref list) = self.collections {
if list.is_empty() {
vec![]
} else {
list.iter().map(String::as_str).collect()
}
} else if let Some(ref c) = self.collection {
vec![c.as_str()]
} else {
vec![]
};
if let Some(ref allowed) = self.allowed_collections {
if !allowed.is_empty() {
let set: std::collections::HashSet<_> = allowed.iter().map(String::as_str).collect();
return base.into_iter().filter(|c| set.contains(c)).collect();
}
}
if let Some(ref c) = self.collection {
return vec![c.as_str()];
}
vec![]
base
}
}

View File

@@ -96,11 +96,12 @@ fn validate_field(
errors: &mut Vec<ValidationError>,
) {
// ── Null handling ────────────────────────────────────────────────────
// Optional fields (not required) may be null; required fields may not.
if value.is_null() {
if !fd.nullable {
if fd.required {
errors.push(ValidationError {
field: field_name.to_string(),
message: "Field does not allow null (set nullable: true to permit)".to_string(),
message: "Field is required".to_string(),
});
}
return;
@@ -112,7 +113,7 @@ fn validate_field(
"number" => value.is_number(),
"integer" => value.is_i64() || value.is_u64() || value.as_f64().map(|f| f.fract() == 0.0 && f.is_finite()).unwrap_or(false),
"boolean" => value.is_boolean(),
"array" => value.is_array(),
"array" | "multiSelect" => value.is_array(),
"object" => value.is_object(),
"reference" => value.is_string(),
"referenceOrInline" => value.is_string() || value.is_object(),
@@ -131,13 +132,39 @@ fn validate_field(
return; // no point checking constraints if type is wrong
}
// ── Enum constraint ─────────────────────────────────────────────────
if let Some(ref allowed) = fd.enum_values {
if !allowed.contains(value) {
errors.push(ValidationError {
field: field_name.to_string(),
message: format!("Value must be one of: {:?}", allowed),
});
// ── Enum constraint (single value) ───────────────────────────────────
// Optional string+enum: empty string = no selection, allowed
if fd.field_type != "multiSelect" {
if let Some(ref allowed) = fd.enum_values {
if !allowed.contains(value) {
let empty_ok = !fd.required && value.as_str().map_or(false, |s| s.is_empty());
if !empty_ok {
errors.push(ValidationError {
field: field_name.to_string(),
message: format!("Value must be one of: {:?}", allowed),
});
}
}
}
}
// ── multiSelect: array of strings, each must be in enum ──────────────
if fd.field_type == "multiSelect" {
if let Some(arr) = value.as_array() {
let allowed: Vec<&Value> = fd.enum_values.as_ref().map(|e| e.iter().collect()).unwrap_or_default();
for (i, item) in arr.iter().enumerate() {
if !item.is_string() {
errors.push(ValidationError {
field: format!("{}[{}]", field_name, i),
message: "Each item must be a string".to_string(),
});
} else if !allowed.is_empty() && !allowed.iter().any(|v| v.as_str() == item.as_str()) {
errors.push(ValidationError {
field: format!("{}[{}]", field_name, i),
message: format!("Item must be one of: {:?}", fd.enum_values),
});
}
}
}
}
@@ -270,6 +297,11 @@ pub fn apply_defaults(schema: &SchemaDefinition, content: &mut Value) {
continue;
}
if fd.field_type == "multiSelect" {
obj.insert(name.clone(), Value::Array(vec![]));
continue;
}
if let Some(ref default_value) = fd.default {
obj.insert(name.clone(), default_value.clone());
continue;
@@ -285,6 +317,27 @@ pub fn apply_defaults(schema: &SchemaDefinition, content: &mut Value) {
}
}
// ---------------------------------------------------------------------------
// Normalize multiSelect: string (e.g. "") → [] before validation
// ---------------------------------------------------------------------------
/// Coerce multiSelect fields that have a string value (e.g. from old data or form) to [].
pub fn normalize_multi_select(schema: &SchemaDefinition, content: &mut Value) {
let obj = match content.as_object_mut() {
Some(o) => o,
None => return,
};
for (field_name, fd) in &schema.fields {
if fd.field_type == "multiSelect" {
if let Some(v) = obj.get_mut(field_name) {
if v.is_string() {
*v = Value::Array(vec![]);
}
}
}
}
}
// ---------------------------------------------------------------------------
// Normalize reference arrays (used before validation on create/update)
// ---------------------------------------------------------------------------
@@ -458,6 +511,9 @@ pub fn validate_references(
continue;
}
if let Some(Value::String(slug)) = obj.get(field_name) {
if slug.trim().is_empty() {
continue; // empty = no selection, valid for optional reference
}
let found = colls.iter().any(|c| entry_exists(c, slug));
if !found {
errors.push(ValidationError {
@@ -467,6 +523,16 @@ pub fn validate_references(
slug, colls
),
});
} else if let Some(ref allowed) = fd.allowed_slugs {
if !allowed.is_empty() && !allowed.iter().any(|s| s.as_str() == slug) {
errors.push(ValidationError {
field: field_name.clone(),
message: format!(
"Slug '{}' is not in the allowed list for this reference",
slug
),
});
}
}
}
continue;
@@ -485,6 +551,9 @@ pub fn validate_references(
if let Some(Value::Array(arr)) = obj.get(field_name) {
for (i, item) in arr.iter().enumerate() {
if let Some(slug) = item.as_str() {
if slug.trim().is_empty() {
continue; // empty = no selection in list
}
let found = colls.iter().any(|c| entry_exists(c, slug));
if !found {
errors.push(ValidationError {
@@ -494,6 +563,16 @@ pub fn validate_references(
slug, colls
),
});
} else if let Some(ref allowed) = items.allowed_slugs {
if !allowed.is_empty() && !allowed.iter().any(|s| s.as_str() == slug) {
errors.push(ValidationError {
field: format!("{}[{}]", field_name, i),
message: format!(
"Slug '{}' is not in the allowed list for this reference",
slug
),
});
}
}
}
}
@@ -505,3 +584,77 @@ pub fn validate_references(
errors
}
// ---------------------------------------------------------------------------
// Extract references (for reverse index / referrers)
// ---------------------------------------------------------------------------
/// One referenced entry: (collection, slug). Used to update the referrer index.
fn parse_ref_value(value: &str, collections: &[&str]) -> Option<(String, String)> {
let value = value.trim();
if value.is_empty() {
return None;
}
if let Some((coll, slug)) = value.split_once(':') {
let slug = slug.trim();
if !slug.is_empty() && collections.contains(&coll) {
return Some((coll.to_string(), slug.to_string()));
}
}
if let Some(&coll) = collections.first() {
return Some((coll.to_string(), value.to_string()));
}
None
}
/// One reference: (referenced collection, referenced slug, field name).
pub type ExtractedRef = (String, String, String);
/// Extract all (referenced collection, referenced slug, field name) from content for referrer index updates.
/// Only considers reference and referenceOrInline (string) fields; referenceOrInline as object is inline, not a ref.
pub fn extract_references(schema: &SchemaDefinition, content: &Value) -> Vec<ExtractedRef> {
let mut out = Vec::new();
let obj = match content.as_object() {
Some(o) => o,
None => return out,
};
for (field_name, fd) in &schema.fields {
let is_ref = fd.field_type == "reference"
|| (fd.field_type == "referenceOrInline" && obj.get(field_name).map_or(false, |v| v.is_string()));
if is_ref {
let colls = fd.reference_collections();
if colls.is_empty() {
continue;
}
if let Some(Value::String(s)) = obj.get(field_name) {
if let Some((c, slug)) = parse_ref_value(s, &colls) {
out.push((c, slug, field_name.clone()));
}
}
continue;
}
if fd.field_type == "array" {
if let Some(ref items) = fd.items {
let is_ref_item = items.field_type == "reference"
|| items.field_type == "referenceOrInline";
if !is_ref_item {
continue;
}
let colls = items.reference_collections();
if colls.is_empty() {
continue;
}
if let Some(Value::Array(arr)) = obj.get(field_name) {
for item in arr.iter() {
if let Some(s) = item.as_str() {
if let Some((c, slug)) = parse_ref_value(s, &colls) {
out.push((c, slug, field_name.clone()));
}
}
}
}
}
}
}
out
}