Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement content metadata validation #72

Merged
merged 1 commit into from
Feb 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 62 additions & 1 deletion src/cmd/build.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
use std::path::Path;
use std::sync::Arc;

use eyre::{bail, Result};
use futures_util::{self, StreamExt};
use tera::{Context, Tera};
use walkdir::WalkDir;
use tokio::sync::Mutex;

use crate::{config, fs, shared};
use crate::{
config,
fs,
shared,
schema::{ContentSchema, format_errors, validate_metadata},
};

async fn prepare_build_directory(root_path: &Path) -> Result<()> {
let public_dir = root_path.join("public");
Expand All @@ -24,14 +31,21 @@ async fn generate_public_build(
) -> Result<()> {
let build_dir = root_path.join(".build");
let public_dir = root_path.join("public");
let content_dir = root_path.join("content");
let entries = WalkDir::new(&build_dir).into_iter().filter_map(|e| e.ok());

// Shared error state for concurrent validation
let validation_errors = Arc::new(Mutex::new(Vec::new()));

// Parallel processing
futures_util::stream::iter(entries)
.for_each_concurrent(num_cpus::get(), |entry| {
let build_dir = build_dir.clone();
let public_dir = public_dir.clone();
let content_dir = content_dir.clone();
let site_config = site_config.clone();
let validation_errors = Arc::clone(&validation_errors);

async move {
let path = entry.path();
if path.is_file() && path.extension().map(|e| e == "html").unwrap_or(false) {
Expand Down Expand Up @@ -67,6 +81,47 @@ async fn generate_public_build(
}
};

// Metadata schema validation
if let Some(schema) = &site_config.content_schema {
// Get relative content path
let content_path = path.strip_prefix(&build_dir)
.unwrap()
.with_extension("")
.to_str()
.unwrap()
.replace('\\', "/");

// Resolve schema hierarchy
let schema_nodes = schema.resolve_path(&content_path);
let merged_schema = ContentSchema::merge_hierarchy(&schema_nodes);

// Convert metadata to hashmap for validation
let metadata_map = metadata.as_table()
.unwrap()
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();

// Perform validation
let errors = validate_metadata(&metadata_map, &merged_schema);

// Collect errors
if !errors.is_empty() {
let norg_path = content_dir.join(content_path.clone()).strip_prefix(root_path).unwrap().with_extension("norg");
let error_output = format!(
"[build] {}",
format_errors(
&norg_path,
&content_path,
&errors,
false
)
);

validation_errors.lock().await.push(error_output);
}
}

// Do not try to build draft content for production builds
if toml::Value::as_bool(
metadata.get("draft").unwrap_or(&toml::Value::from(false)),
Expand Down Expand Up @@ -115,6 +170,12 @@ async fn generate_public_build(
})
.await;

// Check collected errors
let errors = validation_errors.lock().await;
if !errors.is_empty() {
bail!(errors.join("\n"));
}

Ok(())
}

Expand Down
46 changes: 45 additions & 1 deletion src/cmd/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use tokio::{
};
use tokio_tungstenite::accept_async;

use crate::{config, fs, shared};
use crate::{config, fs, shared, schema::{ContentSchema, format_errors, validate_metadata}};

// Global state for reloading
struct ServerState {
Expand Down Expand Up @@ -500,6 +500,7 @@ pub async fn serve(port: u16, drafts: bool, open: bool) -> Result<()> {
if rebuild_needed {
let state = Arc::clone(&state_watcher);
let root_url = state.config.root_url.clone();
let content_schema = state.config.content_schema.clone();
tokio::task::spawn(async move {
match shared::convert_document(
&rebuild_document_path,
Expand All @@ -513,6 +514,49 @@ pub async fn serve(port: u16, drafts: bool, open: bool) -> Result<()> {
let stripped_path = rebuild_document_path
.strip_prefix(&state.content_dir)
.unwrap();

if let Some(schema) = &content_schema {
let content_path = stripped_path
.to_str()
.unwrap()
.replace('\\', "/")
.trim_end_matches(".norg")
.to_string();

// Read generated metadata
let build_dir = state.content_dir.parent().unwrap().join(".build");
let meta_path = build_dir
.join(stripped_path)
.with_extension("meta.toml");

if let Ok(metadata_content) = tokio::fs::read_to_string(&meta_path).await {
let metadata: toml::Value = toml::from_str(&metadata_content).unwrap_or_else(|e| {
// Fallback to empty table on parse errors
eprintln!("[server] Failed to parse metadata: {}", e);
toml::Value::Table(toml::map::Map::new())
});
let metadata_map = metadata.as_table().unwrap().iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();

// Resolve schema
let schema_nodes = schema.resolve_path(&content_path);
let merged_schema = ContentSchema::merge_hierarchy(&schema_nodes);

// Validate and report warnings
let errors = validate_metadata(&metadata_map, &merged_schema);
if !errors.is_empty() {
let error_output = format_errors(
&rebuild_document_path,
&content_path,
&errors,
true
);
eprintln!("[server] {}", error_output);
}
}
}

println!(
"[server] Content successfully regenerated: {}",
stripped_path.display()
Expand Down
4 changes: 4 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};

use crate::schema::ContentSchema;

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SiteConfigHighlighter {
pub enable: bool,
Expand All @@ -13,5 +15,7 @@ pub struct SiteConfig {
pub language: String,
pub title: String,
pub author: String,
#[serde(default)]
pub content_schema: Option<ContentSchema>,
pub highlighter: Option<SiteConfigHighlighter>,
}
3 changes: 2 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod converter;
mod fs;
mod net;
mod theme;
mod schema;
mod shared;
mod tera_functions;

Expand All @@ -19,7 +20,7 @@ async fn main() -> Result<()> {
//println!("HTML code:\n{}", norg_html);

if let Err(e) = cli::start().await {
eprintln!("Something went wrong: {:?}", e);
eprintln!("Something went wrong:\n{:?}", e);
std::process::exit(1);
}

Expand Down
Loading
Loading