diff --git a/crates/mantra-server/src/corpus_loader.rs b/crates/mantra-server/src/corpus_loader.rs index 0b35c93..7710e7a 100644 --- a/crates/mantra-server/src/corpus_loader.rs +++ b/crates/mantra-server/src/corpus_loader.rs @@ -1,11 +1,21 @@ //! Loads the design-dna corpus from a directory of markdown files. //! //! Layout expected (relative to MANTRA_CONTENT_DIR): -//! cycle-1-philosophy/ -//! _index.md ← corpus-level index with themes -//! .md × N ← one distillation per source +//! cycle-1-philosophy/ ← EN cycle 1 sources +//! _index.md +//! .md × N +//! cycle-1-philosophy-ru/ ← RU cycle 1 sources (parallel to en) +//! _index.md +//! .md × N +//! cycle-2-design-theory/ ← cycle 2 sources (Russian only for v0.2) +//! _index.md +//! .md × N +//! manifest.md ← cycle 1 closing artifact +//! applied-principles.md ← cycle 2 closing artifact //! -//! The loader returns an in-memory `Corpus` ready for SSR renders. +//! Each lang build of the Corpus includes: cycle-1 in that lang + +//! cycle-2 (Russian) + artifacts (Russian). For lang=en, cycles 2 and +//! artifacts show Russian content — cycle 2 is not yet translated. use std::collections::HashMap; use std::fs; @@ -19,7 +29,7 @@ use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use serde::Deserialize; -use mantra_ui::corpus::{Corpus, Source, Theme}; +use mantra_ui::corpus::{Artifact, Corpus, Cycle, Source, Theme}; #[derive(Deserialize, Debug, Default)] struct SourceFrontmatter { @@ -33,23 +43,102 @@ struct SourceFrontmatter { _kind: String, } -pub fn load_corpus(root: &Path) -> Result { - load_corpus_alt_cycle_dir(root, "cycle-1-philosophy") +#[derive(Deserialize, Debug, Default)] +struct ArtifactFrontmatter { + #[serde(default, rename = "type")] + _kind: String, + #[serde(default)] + cycle: Option, + #[serde(default)] + closes: Option, } -pub fn load_corpus_alt_cycle_dir(root: &Path, cycle_subdir: &str) -> Result { - let cycle_dir = root.join(cycle_subdir); - if !cycle_dir.is_dir() { - return Err(anyhow!( - "content dir missing {} subdir: {}", - cycle_subdir, - cycle_dir.display() - )); +/// Language mode for cycle 1. Cycle 2 is Russian-only for v0.2. +#[derive(Debug, Clone, Copy)] +pub enum CycleOneLang { + En, + Ru, +} + +/// Load the entire corpus for one language. Cycle 1 is language-aware; +/// cycle 2 and artifacts are always loaded from their Russian-only +/// source and shown as-is in both language modes. +pub fn load_corpus(root: &Path, lang: CycleOneLang) -> Result { + let mut cycles: Vec = Vec::new(); + + // --- Cycle 1 (bilingual) --- + let cycle1_subdir = match lang { + CycleOneLang::En => "cycle-1-philosophy", + CycleOneLang::Ru => "cycle-1-philosophy-ru", + }; + let cycle1_dir = root.join(cycle1_subdir); + if cycle1_dir.is_dir() { + let (title, subtitle) = match lang { + CycleOneLang::En => ("philosophy", "cycle 1 · 20 sources"), + CycleOneLang::Ru => ("философия", "цикл 1 · 20 источников"), + }; + cycles.push(load_cycle( + &cycle1_dir, + "cycle-1-philosophy", + title, + subtitle, + )?); } - // First pass: gather all source files (exclude _index.md). + // --- Cycle 2 (Russian only for v0.2) --- + let cycle2_dir = root.join("cycle-2-design-theory"); + if cycle2_dir.is_dir() { + let (title, subtitle) = match lang { + CycleOneLang::En => ("design theory", "cycle 2 · 14 masters · в переводе"), + CycleOneLang::Ru => ("теория дизайна", "цикл 2 · 14 мастеров"), + }; + cycles.push(load_cycle( + &cycle2_dir, + "cycle-2-design-theory", + title, + subtitle, + )?); + } + + // --- Artifacts (Russian only for v0.2) --- + let mut artifacts: Vec = Vec::new(); + for (slug, title_ru, title_en, subtitle_ru, subtitle_en) in [ + ( + "manifest", + "манифест", + "manifesto", + "что делает форму живой · цикл 1", + "what makes form alive · cycle 1", + ), + ( + "applied-principles", + "прикладные принципы", + "applied principles", + "невидимая эротика + 5 принципов · цикл 2", + "invisible eros + 5 principles · cycle 2", + ), + ] { + let path = root.join(format!("{slug}.md")); + if path.exists() { + let (title, subtitle) = match lang { + CycleOneLang::En => (title_en, subtitle_en), + CycleOneLang::Ru => (title_ru, subtitle_ru), + }; + artifacts.push(load_artifact(&path, slug, title, subtitle)?); + } + } + + Ok(Corpus { cycles, artifacts }) +} + +fn load_cycle( + cycle_dir: &Path, + cycle_slug: &str, + title: &str, + subtitle: &str, +) -> Result { let mut source_paths: Vec = Vec::new(); - for entry in fs::read_dir(&cycle_dir)? { + for entry in fs::read_dir(cycle_dir)? { let path = entry?.path(); let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; @@ -67,24 +156,63 @@ pub fn load_corpus_alt_cycle_dir(root: &Path, cycle_subdir: &str) -> Result = HashMap::new(); let mut order: Vec = Vec::new(); for path in &source_paths { - let src = parse_source(path).with_context(|| { + let src = parse_source(path, cycle_slug).with_context(|| { format!("parsing source {}", path.display()) })?; order.push(src.slug.clone()); sources.insert(src.slug.clone(), src); } - // Themes come from the _index.md if present; skip gracefully otherwise. - let themes = parse_themes(&cycle_dir.join("_index.md")) + let themes = parse_themes(&cycle_dir.join("_index.md"), cycle_slug) .unwrap_or_else(|e| { - tracing::warn!("no themes loaded: {e}"); + tracing::warn!("no themes loaded for {cycle_slug}: {e}"); Vec::new() }); - Ok(Corpus { order, sources, themes }) + Ok(Cycle { + slug: cycle_slug.to_string(), + title: title.to_string(), + subtitle: subtitle.to_string(), + order, + sources, + themes, + }) } -fn parse_source(path: &Path) -> Result { +fn load_artifact( + path: &Path, + slug: &str, + title: &str, + subtitle: &str, +) -> Result { + let raw = fs::read_to_string(path)?; + let matter = Matter::::new(); + let parsed = matter.parse(&raw); + let fm: ArtifactFrontmatter = parsed + .data + .as_ref() + .and_then(|p| p.deserialize().ok()) + .unwrap_or_default(); + + let body_md = parsed.content; + let body_html = md_to_html(&body_md); + + let closes_cycle = fm.cycle.map(|n| match n { + 1 => "cycle-1-philosophy".to_string(), + 2 => "cycle-2-design-theory".to_string(), + other => format!("cycle-{other}"), + }).or(fm.closes); + + Ok(Artifact { + slug: slug.to_string(), + title: title.to_string(), + subtitle: subtitle.to_string(), + closes_cycle, + body_html, + }) +} + +fn parse_source(path: &Path, cycle_slug: &str) -> Result { let raw = fs::read_to_string(path)?; let matter = Matter::::new(); let parsed = matter.parse(&raw); @@ -101,7 +229,6 @@ fn parse_source(path: &Path) -> Result { .ok_or_else(|| anyhow!("bad filename: {}", path.display()))? .to_string(); - // Extract H1 (### replaces some files — just grab the first #-line). let title = body_md .lines() .find(|l| l.starts_with("# ")) @@ -135,10 +262,10 @@ fn parse_source(path: &Path) -> Result { fm.confidence }, body_html, + cycle: cycle_slug.to_string(), }) } -/// Extract text content between a given heading and the next `## ` heading. fn extract_section(md: &str, heading: &str) -> Option { let mut out = String::new(); let mut capturing = false; @@ -158,11 +285,6 @@ fn extract_section(md: &str, heading: &str) -> Option { if out.trim().is_empty() { None } else { Some(out) } } -/// Render markdown to HTML and inject stable `data-para-id` attributes -/// on every `

` element. The id is a short content-hash: stable -/// across minor edits of the surrounding text, unique enough within a -/// single source document. The margin-layer UI uses these ids to -/// anchor notes and Claude-asks to specific paragraphs. fn md_to_html(md: &str) -> String { let mut opts = Options::empty(); opts.insert(Options::ENABLE_TABLES); @@ -171,15 +293,10 @@ fn md_to_html(md: &str) -> String { opts.insert(Options::ENABLE_TASKLISTS); opts.insert(Options::ENABLE_SMART_PUNCTUATION); - // First pass: render plain HTML. let parser = Parser::new_ext(md, opts); let mut raw = String::new(); html::push_html(&mut raw, parser); - // Second pass: re-parse to capture paragraph text for hashing, and - // inject data-para-id. pulldown-cmark emits events in order, so we - // can walk the source again, track paragraphs, compute hashes, and - // then do a simple textual rewrite on the output. let parser2 = Parser::new_ext(md, opts); let mut current_para_text = String::new(); let mut in_para = false; @@ -201,12 +318,6 @@ fn md_to_html(md: &str) -> String { } } - // Inject attributes: replace each `

` (in order) with - // `

`. We walk the byte buffer to find the - // ASCII literal `

`, but copy UTF-8 slices (not byte-by-byte) - // to preserve non-ASCII correctly — the earlier `push(u8 as char)` - // approach silently decoded multibyte UTF-8 as Latin-1 and turned - // em-dashes / smart-quotes / Cyrillic / Greek into mojibake. let mut out = String::with_capacity(raw.len() + para_hashes.len() * 24); let mut pending = para_hashes.into_iter(); let bytes = raw.as_bytes(); @@ -214,7 +325,6 @@ fn md_to_html(md: &str) -> String { let mut i = 0usize; while i + 3 <= bytes.len() { if &bytes[i..i + 3] == b"

" { - // flush everything from last_copied..i as a proper str slice out.push_str(&raw[last_copied..i]); if let Some(id) = pending.next() { out.push_str(&format!(r#"

"#)); @@ -237,24 +347,16 @@ fn short_hash(s: &str) -> String { format!("{:08x}", h.finish() & 0xFFFF_FFFF) } -/// Parse `_index.md` for the 12 running themes + their contributing sources. -/// -/// Format assumption: each theme is introduced by `### {n}. {title}` -/// with the following paragraph carrying a comma-separated list of -/// author names. We map author names back to slugs by substring-match -/// on the source files' author field. Fallback if pattern doesn't -/// match: empty themes list, landing shows empty themes column. -fn parse_themes(index_path: &Path) -> Result> { +fn parse_themes(index_path: &Path, cycle_slug: &str) -> Result> { let raw = fs::read_to_string(index_path)?; let mut themes: Vec = Vec::new(); - let mut current: Option<(String, String, String)> = None; // (slug, title, desc_md) + let mut current: Option<(String, String, String)> = None; for line in raw.lines() { if let Some(rest) = line.strip_prefix("### ") { if let Some((slug, title, desc)) = current.take() { - themes.push(finalize_theme(slug, title, desc)); + themes.push(finalize_theme(slug, title, desc, cycle_slug)); } - // e.g. "### 1. Emptiness / absence как носитель формы" let rest = rest.trim(); let after_num = rest .split_once('.') @@ -263,9 +365,8 @@ fn parse_themes(index_path: &Path) -> Result> { let slug = slugify(&after_num); current = Some((slug, after_num, String::new())); } else if line.starts_with("## ") { - // exit "running themes" section — fallthrough to push last theme if let Some((slug, title, desc)) = current.take() { - themes.push(finalize_theme(slug, title, desc)); + themes.push(finalize_theme(slug, title, desc, cycle_slug)); } } else if let Some((_, _, desc)) = current.as_mut() { desc.push_str(line); @@ -273,12 +374,17 @@ fn parse_themes(index_path: &Path) -> Result> { } } if let Some((slug, title, desc)) = current.take() { - themes.push(finalize_theme(slug, title, desc)); + themes.push(finalize_theme(slug, title, desc, cycle_slug)); } Ok(themes) } -fn finalize_theme(slug: String, title: String, desc_md: String) -> Theme { +fn finalize_theme( + slug: String, + title: String, + desc_md: String, + cycle_slug: &str, +) -> Theme { let contributing = parse_theme_contributors(&desc_md); let description_html = md_to_html(desc_md.trim()); Theme { @@ -286,16 +392,11 @@ fn finalize_theme(slug: String, title: String, desc_md: String) -> Theme { title, description_html, contributing, + cycle: cycle_slug.to_string(), } } -/// Very loose extraction: pull parenthesis-grouped author names out of -/// the theme description and attempt to match them to known sources. -/// Since we don't yet have the corpus at this point, we just return a -/// best-guess list of tokens — the landing component can filter by -/// membership later if desired. fn parse_theme_contributors(desc: &str) -> Vec { - // Heuristic: grab author names from the first parenthetical group. let Some(start) = desc.find('(') else { return Vec::new(); }; @@ -308,7 +409,6 @@ fn parse_theme_contributors(desc: &str) -> Vec { .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| { - // Strip parenthetical hints like "Laozi (ch. 11)" → "Laozi" s.split_whitespace() .next() .unwrap_or(s) diff --git a/crates/mantra-server/src/main.rs b/crates/mantra-server/src/main.rs index c8c3585..2fa0102 100644 --- a/crates/mantra-server/src/main.rs +++ b/crates/mantra-server/src/main.rs @@ -1,7 +1,9 @@ //! mantra server — axum + leptos_axum SSR. //! //! Loads the corpus from disk once at startup (env `MANTRA_CONTENT_DIR`), -//! hands a shared `Arc` to every render pass via Leptos context. +//! hands a shared `Arc` to every render pass via +//! Leptos context. Server functions also get the context so they can +//! read corpus data during their SSR-side execution. #![recursion_limit = "256"] @@ -24,6 +26,8 @@ use tracing::info; use mantra_ui::app::{shell, App}; use mantra_ui::corpus::{BilingualCorpus, BilingualHandle}; +use crate::corpus_loader::CycleOneLang; + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -38,46 +42,34 @@ async fn main() -> Result<()> { std::env::var("MANTRA_CONTENT_DIR") .unwrap_or_else(|_| "content".to_string()), ); - info!("loading bilingual corpus from {}", content_dir.display()); + info!("loading design-dna corpus from {}", content_dir.display()); - // The loader expects a language-specific subdir; we call it twice - // against the two sibling directories. - let en = corpus_loader::load_corpus(&content_dir)?; - // ru lives under ../-ru relative path — or env-overridden. - let ru_dir = std::env::var("MANTRA_CONTENT_DIR_RU") - .map(PathBuf::from) - .unwrap_or_else(|_| { - // derive from content_dir by replacing trailing component - let mut p = content_dir.clone(); - let last = p - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| String::from("content")); - p.pop(); - p.push(format!("{last}-ru")); - p - }); - // Hack for symlinked content: our Mac dev uses `content` symlink to - // design-dna/, so content-ru doesn't exist at peer — fall back to the - // sibling `cycle-1-philosophy-ru` under the DESIGN-DNA root instead. - let ru = if ru_dir.is_dir() { - corpus_loader::load_corpus(&ru_dir)? - } else { - // if MANTRA_CONTENT_DIR is design-dna root, then ru lives under - // /cycle-1-philosophy-ru — but that's a file layout - // difference, handled inside load_corpus_ru. - corpus_loader::load_corpus_alt_cycle_dir(&content_dir, "cycle-1-philosophy-ru")? - }; + let en = corpus_loader::load_corpus(&content_dir, CycleOneLang::En)?; + let ru = corpus_loader::load_corpus(&content_dir, CycleOneLang::Ru)?; let bilingual: BilingualHandle = Arc::new(BilingualCorpus { ru: Arc::new(ru), en: Arc::new(en), }); + for cyc in &bilingual.ru.cycles { + info!( + "cycle ru={} sources={} themes={}", + cyc.slug, + cyc.sources.len(), + cyc.themes.len() + ); + } + for cyc in &bilingual.en.cycles { + info!( + "cycle en={} sources={} themes={}", + cyc.slug, + cyc.sources.len(), + cyc.themes.len() + ); + } info!( - "loaded: ru={} sources / en={} sources, themes={}", - bilingual.ru.sources.len(), - bilingual.en.sources.len(), - bilingual.ru.themes.len() + "artifacts loaded: {}", + bilingual.ru.artifacts.len() ); let conf = get_configuration(None) diff --git a/crates/mantra-ui/src/api/mod.rs b/crates/mantra-ui/src/api/mod.rs index 9aaab66..8558a97 100644 --- a/crates/mantra-ui/src/api/mod.rs +++ b/crates/mantra-ui/src/api/mod.rs @@ -9,13 +9,12 @@ pub mod types; use leptos::prelude::*; -pub use types::{LandingData, NoteEntry, NoteKind, SourcePageData, ThemePageData}; +pub use types::{ + ArtifactPageData, ArtifactRef, CycleSnapshot, LandingData, NoteEntry, NoteKind, + SourcePageData, ThemePageData, +}; // --- fetch_source_page ------------------------------------------------- -// -// Snapshot for `/source/:slug`. Moves corpus access into a server fn -// so SSR and hydrate render from the same Resource payload and the -// client doesn't need the full `Arc` in context. #[server(endpoint = "source_page")] pub async fn fetch_source_page( @@ -29,12 +28,13 @@ pub async fn fetch_source_page( .ok_or_else(|| ServerFnError::new("no corpus in context"))?; let lang = Lang::from_query(Some(&lang)); let corpus = bilingual.for_lang(lang); - let Some(src) = corpus.get(&slug).cloned() else { + let Some((cycle, src)) = corpus.get(&slug).map(|(c, s)| (c.clone(), s.clone())) else { return Ok(None); }; let (prev, next) = corpus.neighbors(&slug); Ok(Some(SourcePageData { source: src, + cycle, prev: prev.map(|s| s.to_string()), next: next.map(|s| s.to_string()), })) @@ -57,16 +57,39 @@ pub async fn fetch_landing(lang: String) -> Result { .ok_or_else(|| ServerFnError::new("no corpus in context"))?; let lang = Lang::from_query(Some(&lang)); let corpus = bilingual.for_lang(lang); - let sources = corpus - .order + + let cycles = corpus + .cycles .iter() - .filter_map(|slug| corpus.get(slug).cloned()) + .map(|c| { + let sources = c + .order + .iter() + .filter_map(|slug| c.sources.get(slug).cloned()) + .collect(); + CycleSnapshot { + slug: c.slug.clone(), + title: c.title.clone(), + subtitle: c.subtitle.clone(), + order: c.order.clone(), + sources, + themes: c.themes.clone(), + } + }) .collect(); - Ok(LandingData { - order: corpus.order.clone(), - sources, - themes: corpus.themes.clone(), - }) + + let artifacts = corpus + .artifacts + .iter() + .map(|a| ArtifactRef { + slug: a.slug.clone(), + title: a.title.clone(), + subtitle: a.subtitle.clone(), + closes_cycle: a.closes_cycle.clone(), + }) + .collect(); + + Ok(LandingData { cycles, artifacts }) } #[cfg(not(feature = "ssr"))] { @@ -89,15 +112,47 @@ pub async fn fetch_theme_page( .ok_or_else(|| ServerFnError::new("no corpus in context"))?; let lang = Lang::from_query(Some(&lang)); let corpus = bilingual.for_lang(lang); - let Some(theme) = corpus.themes.iter().find(|t| t.slug == slug).cloned() else { + let Some((cycle, theme)) = corpus + .get_theme(&slug) + .map(|(c, t)| (c.clone(), t.clone())) else { return Ok(None); }; let contributing = theme .contributing .iter() - .filter_map(|s| corpus.get(s).cloned()) + .filter_map(|s| cycle.sources.get(s).cloned()) .collect(); - Ok(Some(ThemePageData { theme, contributing })) + Ok(Some(ThemePageData { + theme, + cycle, + contributing, + })) + } + #[cfg(not(feature = "ssr"))] + { + let _ = (slug, lang); + unreachable!() + } +} + +// --- fetch_artifact ---------------------------------------------------- + +#[server(endpoint = "artifact_page")] +pub async fn fetch_artifact( + slug: String, + lang: String, +) -> Result, ServerFnError> { + #[cfg(feature = "ssr")] + { + use crate::corpus::{BilingualHandle, Lang}; + let bilingual = use_context::() + .ok_or_else(|| ServerFnError::new("no corpus in context"))?; + let lang = Lang::from_query(Some(&lang)); + let corpus = bilingual.for_lang(lang); + let Some(artifact) = corpus.get_artifact(&slug).cloned() else { + return Ok(None); + }; + Ok(Some(ArtifactPageData { artifact })) } #[cfg(not(feature = "ssr"))] { diff --git a/crates/mantra-ui/src/api/types.rs b/crates/mantra-ui/src/api/types.rs index a4344e5..b90142b 100644 --- a/crates/mantra-ui/src/api/types.rs +++ b/crates/mantra-ui/src/api/types.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; -use crate::corpus::{Source, Theme}; +use crate::corpus::{Artifact, Cycle, Source, Theme}; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum NoteKind { @@ -20,27 +20,53 @@ pub struct NoteEntry { pub body: String, } -/// Snapshot of a single source + its neighbors for the source page. -/// Carries everything the page needs so SSR and hydrate render -/// identically from the same payload. +/// A cycle-shaped snapshot for the landing page: everything needed to +/// render one cycle's section (works list + themes list). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct SourcePageData { - pub source: Source, - pub prev: Option, - pub next: Option, -} - -/// Snapshot for the landing page — works in reading order + themes. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct LandingData { +pub struct CycleSnapshot { + pub slug: String, + pub title: String, + pub subtitle: String, pub order: Vec, pub sources: Vec, pub themes: Vec, } +/// Small artifact reference for landing-page listing (no body). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ArtifactRef { + pub slug: String, + pub title: String, + pub subtitle: String, + pub closes_cycle: Option, +} + +/// Landing page snapshot — all cycles + all artifacts. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LandingData { + pub cycles: Vec, + pub artifacts: Vec, +} + +/// Snapshot of a single source + its neighbors for the source page. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SourcePageData { + pub source: Source, + pub cycle: Cycle, + pub prev: Option, + pub next: Option, +} + /// Snapshot for the theme page — one theme + contributing sources. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ThemePageData { pub theme: Theme, + pub cycle: Cycle, pub contributing: Vec, } + +/// Full artifact body for the artifact page. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ArtifactPageData { + pub artifact: Artifact, +} diff --git a/crates/mantra-ui/src/app.rs b/crates/mantra-ui/src/app.rs index 7a6e324..52510c5 100644 --- a/crates/mantra-ui/src/app.rs +++ b/crates/mantra-ui/src/app.rs @@ -41,6 +41,7 @@ pub fn App() -> impl IntoView { + } diff --git a/crates/mantra-ui/src/corpus.rs b/crates/mantra-ui/src/corpus.rs index c8f1d4d..79321f2 100644 --- a/crates/mantra-ui/src/corpus.rs +++ b/crates/mantra-ui/src/corpus.rs @@ -1,51 +1,103 @@ //! Corpus data types — shared between SSR render pass and UI components. //! -//! The server crate owns `load_corpus` and populates the structures -//! from disk; the UI crate only reads them. Everything here is +//! 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 ← 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 ← closing documents (manifest, applied-principles) +//! └─ slug, title, closes_cycle, body_html use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; -/// The entire loaded corpus. Shared via Leptos context as `Arc`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Corpus { - /// Ordered list of source slugs (reading order — chronological or - /// thematic; driven by `_index.md`'s table of works). +/// 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, - /// Slug → Source lookup. pub sources: HashMap, - /// Running themes extracted from `_index.md`. pub themes: Vec, } +/// 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, + pub body_html: String, +} + +/// The entire loaded corpus. Shared via Leptos context as `Arc` +/// on the server; UI reads via server fns + Resources. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Corpus { + pub cycles: Vec, + pub artifacts: Vec, +} + impl Corpus { - pub fn get(&self, slug: &str) -> Option<&Source> { - self.sources.get(slug) + /// 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 } - /// Index-in-reading-order of `slug`, if present. - pub fn index_of(&self, slug: &str) -> Option { - self.order.iter().position(|s| s == slug) - } - - /// Neighbor (prev, next) by reading order — wrap-around. + /// Neighbors (prev, next) within the same cycle — wrap-around. pub fn neighbors(&self, slug: &str) -> (Option<&str>, Option<&str>) { - let Some(i) = self.index_of(slug) else { return (None, None) }; - let n = self.order.len(); - let prev = if n > 1 { - Some(self.order[(i + n - 1) % n].as_str()) - } else { - None - }; - let next = if n > 1 { - Some(self.order[(i + 1) % n].as_str()) - } else { - None - }; - (prev, next) + 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) } } @@ -65,6 +117,8 @@ pub struct Source { 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. @@ -76,6 +130,8 @@ pub struct Theme { pub description_html: String, /// Sources that contribute to this theme (by slug). pub contributing: Vec, + /// Which cycle this theme belongs to. + pub cycle: String, } /// Convenience alias — we always thread the corpus through context as @@ -104,9 +160,9 @@ impl Lang { } } -/// Bilingual container — same slugs in both languages. Each language -/// is held as an `Arc` so `for_lang` is cheap and callers can -/// keep their own handle without deep-cloning a 60kw corpus. +/// 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, diff --git a/crates/mantra-ui/src/pages/artifact.rs b/crates/mantra-ui/src/pages/artifact.rs new file mode 100644 index 0000000..15fdce4 --- /dev/null +++ b/crates/mantra-ui/src/pages/artifact.rs @@ -0,0 +1,162 @@ +//! `/artifact/:slug` — closing document of a cycle. +//! +//! Currently renders: manifest.md (cycle 1) and applied-principles.md +//! (cycle 2). Same book-grade typography as the source page, same +//! paragraph-id → margin drawer wiring so readers can annotate the +//! artifacts too. + +use leptos::prelude::*; +use leptos_router::hooks::{use_params, use_query_map}; +use leptos_router::params::Params; + +use crate::api::{fetch_artifact, ArtifactPageData}; +use crate::corpus::Lang; +use crate::pages::margin::{ActivePara, MarginDrawer}; +use crate::pages::shared::LangToggle; + +#[derive(Params, PartialEq, Clone, Debug)] +struct SlugParam { + slug: Option, +} + +#[component] +pub fn ArtifactPage() -> impl IntoView { + let params = use_params::(); + let slug = Memo::new(move |_| { + params.get().ok().and_then(|p| p.slug).unwrap_or_default() + }); + + let query = use_query_map(); + let lang = Memo::new(move |_| { + Lang::from_query(query.read().get("lang").as_deref()) + }); + + let (active, set_active) = signal::>(None); + let notes_tick = RwSignal::new(0u32); + + let slug_sig: Signal = Signal::derive(move || slug.get()); + let lang_sig: Signal = Signal::derive(move || lang.get()); + + let data = Resource::new( + move || (slug.get(), lang.get().as_str().to_string()), + |(s, l)| fetch_artifact(s, l), + ); + + #[cfg(feature = "hydrate")] + { + let slug_for_fx = slug_sig; + Effect::new(move |_| { + let _ = notes_tick.get(); + let current_slug = slug_for_fx.get(); + if current_slug.is_empty() { return; } + leptos::task::spawn_local(async move { + if let Ok(entries) = crate::api::fetch_notes(current_slug).await { + let ids: std::collections::HashSet = + entries.into_iter().map(|e| e.para_id).collect(); + annotate_notes(&ids); + } + }); + }); + } + + view! { + + "…"

}> + {move || { + data.get().map(|res| { + let lang_val = lang.get(); + let home_href = format!("/?lang={}", lang_val.as_str()); + let label_back = match lang_val { + Lang::Ru => "все источники", + Lang::En => "all sources", + }; + + match res { + Ok(Some(ArtifactPageData { artifact })) => view! { +
+ + +
+ + +
+ }.into_any(), + Ok(None) => view! { +
+

"artifact not found"

+ "← back" +
+ }.into_any(), + Err(e) => view! { +
+

{format!("{e}")}

+
+ }.into_any(), + } + }) + }} + + + + } +} + +#[cfg(feature = "hydrate")] +fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option { + use wasm_bindgen::JsCast; + let target = ev.target()?; + let mut el = target.dyn_into::().ok()?; + loop { + if el.has_attribute("data-para-id") { + let id = el.get_attribute("data-para-id")?; + let text = el.text_content().unwrap_or_default(); + let excerpt = text.trim().to_string(); + return Some(ActivePara { id, excerpt }); + } + el = el.parent_element()?; + } +} + +#[cfg(not(feature = "hydrate"))] +fn paragraph_from_event(_ev: &leptos::ev::MouseEvent) -> Option { + None +} + +#[cfg(feature = "hydrate")] +fn annotate_notes(ids: &std::collections::HashSet) { + use wasm_bindgen::JsCast; + let Some(win) = web_sys::window() else { return }; + let Some(doc) = win.document() else { return }; + let Ok(nodes) = doc.query_selector_all("[data-para-id]") else { return }; + for i in 0..nodes.length() { + let Some(node) = nodes.item(i) else { continue }; + let Ok(el) = node.dyn_into::() else { continue }; + let id = el.get_attribute("data-para-id").unwrap_or_default(); + let classes = el.class_list(); + if ids.contains(&id) { + let _ = classes.add_1("has-notes"); + } else { + let _ = classes.remove_1("has-notes"); + } + } +} diff --git a/crates/mantra-ui/src/pages/landing.rs b/crates/mantra-ui/src/pages/landing.rs index c85679c..8c67b24 100644 --- a/crates/mantra-ui/src/pages/landing.rs +++ b/crates/mantra-ui/src/pages/landing.rs @@ -1,9 +1,13 @@ -//! `/` — two-door entry into the corpus: works (20) and themes. +//! `/` — two-door entry into the design-dna library. +//! +//! Shows all cycles (each with its works + themes columns) plus the +//! artifacts strip. v0.2: cycle 1 (philosophy) + cycle 2 (design +//! theory) + manifest + applied-principles. use leptos::prelude::*; use leptos_router::hooks::use_query_map; -use crate::api::{fetch_landing, LandingData}; +use crate::api::{fetch_landing, ArtifactRef, CycleSnapshot, LandingData}; use crate::corpus::{Lang, Source, Theme}; use crate::pages::shared::LangToggle; @@ -24,13 +28,17 @@ pub fn Landing() -> impl IntoView { Lang::En => "design dna", }; let hero_subtitle = move || match lang.get() { - Lang::Ru => "цикл 1 · философия · 20 источников", - Lang::En => "cycle 1 · philosophy · 20 sources", + Lang::Ru => "философия · теория дизайна · дистилляция", + Lang::En => "philosophy · design theory · distillation", }; let hero_question = move || match lang.get() { Lang::Ru => "почему форма трогает человеческое сердце?", Lang::En => "why does form move the human heart?", }; + let artifacts_label = move || match lang.get() { + Lang::Ru => "артефакты", + Lang::En => "artifacts", + }; let works_label = move || match lang.get() { Lang::Ru => "работы", Lang::En => "works", @@ -39,10 +47,6 @@ pub fn Landing() -> impl IntoView { Lang::Ru => "темы", Lang::En => "themes", }; - let sources_suffix = move |n: usize| match lang.get() { - Lang::Ru => format!(" · {n} источников"), - Lang::En => format!(" · {n} sources"), - }; view! {
@@ -57,48 +61,42 @@ pub fn Landing() -> impl IntoView { {move || { data.get().map(|res| { let lang_val = lang.get(); - let suffix_for_themes = sources_suffix.clone(); match res { - Ok(LandingData { order, sources, themes }) => { - // Build slug -> Source map for cheap lookup in the For body - let mut by_slug: std::collections::HashMap = - std::collections::HashMap::with_capacity(sources.len()); - for s in sources { by_slug.insert(s.slug.clone(), s); } + Ok(LandingData { cycles, artifacts }) => { view! { -
-
-

{works_label}

-
    - - - -
-
-
-

{themes_label}

-
    - - - -
-
-
+ {if !artifacts.is_empty() { + let art_label = artifacts_label(); + view! { +
+ +
    + + + +
+
+ }.into_any() + } else { ().into_any() }} + + + + }.into_any() } Err(e) => view! {

{format!("{e}")}

}.into_any(), @@ -110,6 +108,74 @@ pub fn Landing() -> impl IntoView { } } +#[component] +fn CycleSection( + cycle: CycleSnapshot, + lang: Lang, + works_label: &'static str, + themes_label: &'static str, +) -> impl IntoView { + let cycle_title = cycle.title.clone(); + let cycle_subtitle = cycle.subtitle.clone(); + let sources = cycle.sources.clone(); + let themes = cycle.themes.clone(); + let order = cycle.order.clone(); + + let mut by_slug: std::collections::HashMap = + std::collections::HashMap::with_capacity(sources.len()); + for s in sources { + by_slug.insert(s.slug.clone(), s); + } + + let sources_suffix = move |n: usize| match lang { + Lang::Ru => format!(" · {n} источников"), + Lang::En => format!(" · {n} sources"), + }; + + view! { +
+
+

{cycle_title}

+

{cycle_subtitle}

+
+
+
+

{works_label}

+
    + + + +
+
+
+

{themes_label}

+
    + + + +
+
+
+
+ } +} + #[component] fn WorkLine(source: Option, slug: String, lang: Lang) -> impl IntoView { let href = format!("/source/{slug}?lang={}", lang.as_str()); @@ -144,3 +210,17 @@ where } } + +#[component] +fn ArtifactLine(artifact: ArtifactRef, lang: Lang) -> impl IntoView { + let href = format!("/artifact/{}?lang={}", artifact.slug, lang.as_str()); + view! { +
  • + + {artifact.title} + " · " + {artifact.subtitle} + +
  • + } +} diff --git a/crates/mantra-ui/src/pages/mod.rs b/crates/mantra-ui/src/pages/mod.rs index 026aad8..3206659 100644 --- a/crates/mantra-ui/src/pages/mod.rs +++ b/crates/mantra-ui/src/pages/mod.rs @@ -1,3 +1,4 @@ +pub mod artifact; pub mod landing; pub mod margin; pub mod shared; diff --git a/crates/mantra-ui/src/pages/source.rs b/crates/mantra-ui/src/pages/source.rs index 3b04a82..451aaf0 100644 --- a/crates/mantra-ui/src/pages/source.rs +++ b/crates/mantra-ui/src/pages/source.rs @@ -69,10 +69,6 @@ pub fn SourcePage() -> impl IntoView { data.get().map(|res| { let lang_val = lang.get(); let home_href = format!("/?lang={}", lang_val.as_str()); - let breadcrumb_cycle = match lang_val { - Lang::Ru => "цикл 1", - Lang::En => "cycle 1", - }; let label_all = match lang_val { Lang::Ru => "все источники", Lang::En => "all sources", @@ -88,7 +84,7 @@ pub fn SourcePage() -> impl IntoView { match res { Ok(Some(d)) => { - let SourcePageData { source: src, prev, next } = d; + let SourcePageData { source: src, cycle, prev, next } = d; let prev_href = prev.map(|p| format!("/source/{p}?lang={}", lang_val.as_str())); let next_href = next.map(|n| format!("/source/{n}?lang={}", lang_val.as_str())); view! { @@ -96,7 +92,7 @@ pub fn SourcePage() -> impl IntoView { diff --git a/crates/mantra-ui/src/pages/theme.rs b/crates/mantra-ui/src/pages/theme.rs index 4c53e80..987fc87 100644 --- a/crates/mantra-ui/src/pages/theme.rs +++ b/crates/mantra-ui/src/pages/theme.rs @@ -51,11 +51,13 @@ pub fn ThemePage() -> impl IntoView { }; match res { - Ok(Some(ThemePageData { theme, contributing })) => view! { + Ok(Some(ThemePageData { theme, cycle, contributing })) => view! {

    {theme.title}

    diff --git a/sass/main.scss b/sass/main.scss index 9edb808..eb54c95 100644 --- a/sass/main.scss +++ b/sass/main.scss @@ -111,7 +111,7 @@ main { // --- Landing -------------------------------------------------------- .landing { - max-width: 72ch; + max-width: 74ch; } .landing-head { margin-bottom: 4rem; } @@ -148,6 +148,83 @@ main { max-width: 34ch; } +.landing-section-label { + font-family: var(--mantra-sans); + font-variation-settings: "wght" 500; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.28em; + color: var(--mantra-muted); + margin: 0 0 1.5rem; +} + +.landing-artifacts { + margin-bottom: 5rem; +} + +.artifacts-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.9rem; + border-top: 1px solid var(--mantra-hairline); + border-bottom: 1px solid var(--mantra-hairline); + padding: 1rem 0; +} + +.artifact-line a { + display: block; + border-bottom: none; + line-height: 1.4; +} +.artifact-title { + font-family: var(--mantra-serif); + font-variation-settings: "opsz" 22, "SOFT" 40, "WONK" 0, "wght" 520; + font-size: 1.05rem; + color: var(--mantra-fg); +} +.artifact-sep { color: var(--mantra-faint); font-size: 0.85rem; } +.artifact-subtitle { + font-style: italic; + color: var(--mantra-muted); + font-size: 0.88rem; +} +.artifact-line a:hover .artifact-title { color: var(--mantra-accent); } + +.landing-cycle { + margin-bottom: 4.5rem; + padding-top: 2rem; + border-top: 1px solid var(--mantra-hairline); + + &:first-of-type { border-top: none; padding-top: 0; } +} + +.landing-cycle-head { + margin-bottom: 2.5rem; +} + +.landing-cycle-title { + font-family: var(--mantra-serif); + font-variation-settings: "opsz" 60, "SOFT" 50, "WONK" 0, "wght" 400; + font-size: clamp(1.6rem, 3vw, 2.1rem); + line-height: 1.1; + letter-spacing: -0.01em; + margin: 0; + color: var(--mantra-fg); +} + +.landing-cycle-subtitle { + font-family: var(--mantra-sans); + font-variation-settings: "wght" 400; + font-size: 0.72rem; + color: var(--mantra-muted); + text-transform: uppercase; + letter-spacing: 0.2em; + margin: 0.4rem 0 0; +} + .landing-grid { display: grid; grid-template-columns: 1fr 1fr;