mantra/crates/mantra-ui/src/corpus.rs

181 lines
5.9 KiB
Rust

//! Corpus data types — shared between SSR render pass and UI components.
//!
//! The server crate owns the loaders and populates the structures from
//! disk; the UI crate only reads them. Everything here is
//! `Serialize + Deserialize + Clone` so it survives Leptos Resources
//! and possible future hydration round-trips.
//!
//! Library structure:
//! Corpus
//! └─ cycles: Vec<Cycle> ← each cycle is one research pass
//! ├─ slug, title, subtitle
//! ├─ order: reading order of source slugs
//! ├─ sources: slug → Source (distillation)
//! └─ themes: running patterns extracted from _index.md
//! └─ artifacts: Vec<Artifact> ← closing documents (manifest, applied-principles)
//! └─ slug, title, closes_cycle, body_html
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
/// One research cycle: a parallel distillation pass over a set of
/// sources, bound by a root question. cycle-1-philosophy asks "why does
/// form move the human heart?"; cycle-2-design-theory asks "how was
/// that practiced by masters?".
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Cycle {
pub slug: String,
pub title: String,
pub subtitle: String,
pub order: Vec<String>,
pub sources: HashMap<String, Source>,
pub themes: Vec<Theme>,
}
/// A closing document of a cycle — manifest (cycle 1), applied-principles
/// (cycle 2), and future cycle 3's visual-signature document.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Artifact {
pub slug: String,
pub title: String,
pub subtitle: String,
pub closes_cycle: Option<String>,
pub body_html: String,
}
/// The entire loaded corpus. Shared via Leptos context as `Arc<Corpus>`
/// on the server; UI reads via server fns + Resources.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Corpus {
pub cycles: Vec<Cycle>,
pub artifacts: Vec<Artifact>,
}
impl Corpus {
/// Find a source by slug across ALL cycles.
pub fn get(&self, slug: &str) -> Option<(&Cycle, &Source)> {
for cycle in &self.cycles {
if let Some(src) = cycle.sources.get(slug) {
return Some((cycle, src));
}
}
None
}
/// Neighbors (prev, next) within the same cycle — wrap-around.
pub fn neighbors(&self, slug: &str) -> (Option<&str>, Option<&str>) {
for cycle in &self.cycles {
let Some(i) = cycle.order.iter().position(|s| s == slug) else {
continue;
};
let n = cycle.order.len();
if n <= 1 {
return (None, None);
}
let prev = Some(cycle.order[(i + n - 1) % n].as_str());
let next = Some(cycle.order[(i + 1) % n].as_str());
return (prev, next);
}
(None, None)
}
/// Find a theme by slug across ALL cycles.
pub fn get_theme(&self, slug: &str) -> Option<(&Cycle, &Theme)> {
for cycle in &self.cycles {
if let Some(t) = cycle.themes.iter().find(|t| t.slug == slug) {
return Some((cycle, t));
}
}
None
}
/// Find an artifact by slug.
pub fn get_artifact(&self, slug: &str) -> Option<&Artifact> {
self.artifacts.iter().find(|a| a.slug == slug)
}
/// Get a cycle by slug.
pub fn get_cycle(&self, slug: &str) -> Option<&Cycle> {
self.cycles.iter().find(|c| c.slug == slug)
}
}
/// One distillation. Body is pre-rendered HTML (pulldown-cmark output).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Source {
pub slug: String,
/// Display title — "Symposium (Plato)" etc.
pub title: String,
/// Short author label — "Plato", "Ibn ʿArabī" etc.
pub author: String,
/// The core-claim one-liner (extracted from `## Core claim` section).
pub core_claim: String,
/// Tags from YAML frontmatter.
pub tags: Vec<String>,
/// Confidence level (high / medium / low) from frontmatter.
pub confidence: String,
/// The full markdown body rendered to HTML.
pub body_html: String,
/// Which cycle this source belongs to.
pub cycle: String,
}
/// A running theme — pattern that proustep across multiple sources.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Theme {
pub slug: String,
pub title: String,
/// Short pitch (one paragraph).
pub description_html: String,
/// Sources that contribute to this theme (by slug).
pub contributing: Vec<String>,
/// Which cycle this theme belongs to.
pub cycle: String,
}
/// Convenience alias — we always thread the corpus through context as
/// an `Arc` so multiple concurrent requests share one in-memory copy.
pub type CorpusHandle = Arc<Corpus>;
/// Reading language.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Lang {
Ru,
En,
}
impl Lang {
pub fn as_str(&self) -> &'static str {
match self { Lang::Ru => "ru", Lang::En => "en" }
}
pub fn from_query(s: Option<&str>) -> Self {
match s {
Some("en") => Lang::En,
_ => Lang::Ru,
}
}
pub fn other(&self) -> Lang {
match self { Lang::Ru => Lang::En, Lang::En => Lang::Ru }
}
}
/// Bilingual container — same slugs in both languages where possible.
/// Cycle 1 is bilingual; cycle 2 and artifacts are Russian-only, and
/// both language variants point at the same data for those.
#[derive(Debug, Clone)]
pub struct BilingualCorpus {
pub ru: Arc<Corpus>,
pub en: Arc<Corpus>,
}
impl BilingualCorpus {
pub fn for_lang(&self, lang: Lang) -> Arc<Corpus> {
match lang {
Lang::Ru => self.ru.clone(),
Lang::En => self.en.clone(),
}
}
}
pub type BilingualHandle = Arc<BilingualCorpus>;