diff --git a/CLAUDE.md b/CLAUDE.md index 8b74e8d..9476b9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ moments-worker — ingestion daemon binary (runs migrations, connects as mom React 19 + Vite 6 (SWC) + TypeScript + Bootstrap 5. State/data via `@tanstack/react-query`. Package manager is **pnpm**. -Routes: `/` (dashboard), `/activity` (timeline), `/project/:source/*` (project detail), `/cv` (resume). +Routes: `/` (dashboard), `/activity` (timeline), `/project/:source/*` (project detail), `/blog` + `/blog/:slug` (blog), `/cv` (resume). ## Build & Dev Commands @@ -71,7 +71,9 @@ PostgreSQL with three migrations in `crates/moments-data/migrations/`. Two roles ## API Endpoints -All under `/v1/`: `healthz`, `events`, `sources`, `projects`, `activity/daily`, `forge/{source}/*`, `og/contributions.png`. +All under `/v1/`: `healthz`, `events`, `sources`, `projects`, `blog`, `blog/{slug}`, `activity/daily`, `forge/{source}/*`, `og/contributions.png`. + +Blog posts are markdown files with YAML frontmatter (`title`, `slug`, `date`; optional `draft`/`public`) in the `grenade/blog` Gitea repo. The worker's `BlogSource` polls the repo (branch-tip sha as change detection) and upserts posts into `events` with `source='blog'` and `occurred_at` from the frontmatter date, so imported posts keep their original publish dates. Publishing or editing a post = pushing to that repo. ## Deployment diff --git a/Cargo.lock b/Cargo.lock index 12e4f79..601107a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1311,6 +1311,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serde_yaml_ng", "sqlx", "thiserror", "tracing", @@ -1957,6 +1958,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2701,6 +2715,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 762f23b..3793294 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ thiserror = "2" # core / data sqlx = { version = "0.8", default-features = false, features = ["postgres", "runtime-tokio-rustls", "macros", "migrate", "chrono", "json"] } async-trait = "0.1" +serde_yaml_ng = "0.10" # binaries tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time"] } diff --git a/crates/moments-api/src/main.rs b/crates/moments-api/src/main.rs index d8cf72b..4a6bfd4 100644 --- a/crates/moments-api/src/main.rs +++ b/crates/moments-api/src/main.rs @@ -11,7 +11,7 @@ use chrono::{DateTime, Datelike, NaiveDate, Utc}; use clap::Parser; use moments_core::{EventReader, reshape}; use moments_data::PgStore; -use moments_entities::{DailyCount, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem}; +use moments_entities::{BlogPost, BlogPostSummary, DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem}; use serde::Deserialize; use tower_http::{cors::CorsLayer, trace::TraceLayer}; use tracing::info; @@ -56,6 +56,8 @@ async fn main() -> anyhow::Result<()> { .route("/v1/events", get(list_events)) .route("/v1/sources", get(list_sources)) .route("/v1/projects", get(list_projects)) + .route("/v1/blog", get(list_blog_posts)) + .route("/v1/blog/{slug}", get(get_blog_post)) .route("/v1/activity/daily", get(daily_counts)) .route("/v1/activity/hourly", get(hourly_avgs)) .route("/v1/languages/daily", get(language_daily_counts)) @@ -144,6 +146,61 @@ async fn list_projects( Ok(Json(projects)) } +/// All public blog events, newest first. Blog posts live in the events +/// table (`source = 'blog'`); the payload carries the frontmatter fields +/// and the full markdown body. +async fn blog_events(state: &AppState) -> Result, ApiError> { + let query = EventQuery { + sources: Some(vec![Source::Blog]), + // Drafts are stored with public = false and stay invisible here. + include_private: false, + limit: 1000, + ..Default::default() + }; + state.store.list_events(&query).await.map_err(internal) +} + +fn payload_str<'a>(event: &'a Event, key: &str) -> &'a str { + event.payload.get(key).and_then(|v| v.as_str()).unwrap_or("") +} + +async fn list_blog_posts( + State(state): State, +) -> Result>, ApiError> { + let posts = blog_events(&state) + .await? + .iter() + .map(|ev| BlogPostSummary { + slug: payload_str(ev, "slug").to_string(), + title: payload_str(ev, "title").to_string(), + published_at: ev.occurred_at, + excerpt: moments_core::presentation::blog::excerpt(payload_str(ev, "markdown")), + }) + .collect(); + Ok(Json(posts)) +} + +async fn get_blog_post( + State(state): State, + Path(slug): Path, +) -> Result, ApiError> { + let id = format!("blog:{slug}"); + let events = blog_events(&state).await?; + let ev = events.iter().find(|ev| ev.id == id).ok_or(ApiError { + status: StatusCode::NOT_FOUND, + message: "no such post".into(), + })?; + Ok(Json(BlogPost { + slug: payload_str(ev, "slug").to_string(), + title: payload_str(ev, "title").to_string(), + published_at: ev.occurred_at, + markdown: payload_str(ev, "markdown").to_string(), + host: payload_str(ev, "_host").to_string(), + repo: payload_str(ev, "_repo").to_string(), + branch: payload_str(ev, "_branch").to_string(), + })) +} + #[derive(Debug, Deserialize)] struct DailyCountsParams { from: Option, diff --git a/crates/moments-core/src/presentation.rs b/crates/moments-core/src/presentation.rs index b7346d9..fc3cf39 100644 --- a/crates/moments-core/src/presentation.rs +++ b/crates/moments-core/src/presentation.rs @@ -5,6 +5,7 @@ use moments_entities::{Event, Source, TimelineItem}; +pub mod blog; mod bugzilla; mod gitea; mod github; @@ -16,5 +17,6 @@ pub fn reshape(event: &Event) -> TimelineItem { Source::Gitea => gitea::reshape(event), Source::Hg => hg::reshape(event), Source::Bugzilla => bugzilla::reshape(event), + Source::Blog => blog::reshape(event), } } diff --git a/crates/moments-core/src/presentation/blog.rs b/crates/moments-core/src/presentation/blog.rs new file mode 100644 index 0000000..cda6cfb --- /dev/null +++ b/crates/moments-core/src/presentation/blog.rs @@ -0,0 +1,138 @@ +use moments_entities::{Event, Source, TimelineBody, TimelineIcon, TimelineItem, TitleSegment}; +use serde_json::Value; + +const EXCERPT_MAX_CHARS: usize = 280; + +pub(crate) fn reshape(event: &Event) -> TimelineItem { + let p = &event.payload; + let title_text = p + .get("title") + .and_then(Value::as_str) + .unwrap_or("(untitled post)"); + let slug = p.get("slug").and_then(Value::as_str).unwrap_or(""); + let markdown = p.get("markdown").and_then(Value::as_str).unwrap_or(""); + + let title = vec![ + TitleSegment::text("published "), + TitleSegment::link(title_text, format!("/blog/{slug}")), + ]; + + let summary = excerpt(markdown); + let body = (!summary.is_empty()).then_some(TimelineBody::Markdown { text: summary }); + + TimelineItem { + id: event.id.clone(), + source: Source::Blog, + action: event.action.clone(), + occurred_at: event.occurred_at, + icon: TimelineIcon::Post, + title, + subtitle: None, + body, + } +} + +/// First paragraph of prose from a markdown document — skips headings, +/// images, and other block furniture — truncated on a word boundary. +/// Reused by the API for `GET /v1/blog` summaries. +pub fn excerpt(markdown: &str) -> String { + let para = markdown + .split("\n\n") + .map(str::trim) + .find(|block| { + !block.is_empty() + && !block.starts_with('#') + && !block.starts_with("![") + && !block.starts_with("```") + && !block.starts_with('>') + && !block.starts_with("---") + }) + .unwrap_or(""); + let para = para.replace('\n', " "); + if para.chars().count() <= EXCERPT_MAX_CHARS { + return para; + } + let cut: String = para.chars().take(EXCERPT_MAX_CHARS).collect(); + let trimmed = match cut.rfind(' ') { + Some(idx) => &cut[..idx], + None => cut.as_str(), + }; + format!("{}…", trimmed.trim_end()) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use serde_json::json; + + fn ev() -> Event { + Event { + id: "blog:last-week".into(), + source: Source::Blog, + action: "publish_post".into(), + occurred_at: Utc.with_ymd_and_hms(2026, 6, 12, 0, 0, 0).unwrap(), + public: true, + payload: json!({ + "title": "a watchdog, a torrent tracker, and an unhinged friday", + "slug": "last-week", + "markdown": "## monday\n\nthe week opened deep in helexa's neuron engine.\n", + "_host": "git.lair.cafe", + "_repo": "grenade/blog", + "_branch": "main", + }), + } + } + + #[test] + fn reshape_links_to_blog_route() { + let item = reshape(&ev()); + assert_eq!(item.icon, TimelineIcon::Post); + assert_eq!( + item.title, + vec![ + TitleSegment::text("published "), + TitleSegment::link( + "a watchdog, a torrent tracker, and an unhinged friday", + "/blog/last-week" + ), + ] + ); + match item.body { + Some(TimelineBody::Markdown { ref text }) => { + assert_eq!(text, "the week opened deep in helexa's neuron engine.") + } + other => panic!("expected markdown body, got {other:?}"), + } + } + + #[test] + fn excerpt_skips_headings_and_images() { + let md = "# title\n\n![alt](img.jpg)\n\nfirst real paragraph\nwith a wrapped line\n\nsecond"; + assert_eq!(excerpt(md), "first real paragraph with a wrapped line"); + } + + #[test] + fn excerpt_truncates_on_word_boundary() { + let long: String = (1..=100) + .map(|i| format!("w{i}")) + .collect::>() + .join(" "); + let e = excerpt(&long); + assert!(e.chars().count() <= EXCERPT_MAX_CHARS + 1); + assert!(e.ends_with('…')); + let prefix = e.trim_end_matches('…'); + assert!(long.starts_with(prefix), "excerpt must be a prefix: {e}"); + assert_eq!( + long.as_bytes()[prefix.len()], + b' ', + "cut should land on a word boundary: {e}" + ); + } + + #[test] + fn excerpt_of_empty_or_heading_only_doc_is_empty() { + assert_eq!(excerpt(""), ""); + assert_eq!(excerpt("# only a heading\n"), ""); + } +} diff --git a/crates/moments-data/Cargo.toml b/crates/moments-data/Cargo.toml index 1578b90..e3d1eff 100644 --- a/crates/moments-data/Cargo.toml +++ b/crates/moments-data/Cargo.toml @@ -17,4 +17,5 @@ tracing.workspace = true async-trait.workspace = true reqwest.workspace = true serde.workspace = true +serde_yaml_ng.workspace = true percent-encoding = "2" diff --git a/crates/moments-data/src/blog.rs b/crates/moments-data/src/blog.rs new file mode 100644 index 0000000..16349a0 --- /dev/null +++ b/crates/moments-data/src/blog.rs @@ -0,0 +1,334 @@ +//! Blog post ingestion from a Gitea-hosted markdown repo. +//! +//! Posts are markdown files with YAML frontmatter (`title`, `slug`, `date`) +//! at the root of a repo. The poll cycle is cheap: one request for the +//! branch tip sha, compared against `poller_state.etag`; only when the repo +//! has new commits are the file listing and contents fetched. +//! +//! `occurred_at` comes from the frontmatter `date`, so imported posts keep +//! their original publish dates. Edits re-upsert under the same +//! `blog:{slug}` id. + +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; +use moments_core::{EventSource, EventWriter, PollerStateStore, SourceError}; +use moments_entities::{Event, Source}; +use reqwest::{Client, header}; +use serde::Deserialize; +use serde_json::{Value, json}; +use tracing::{debug, warn}; + +const SOURCE_NAME: &str = "blog"; +const USER_AGENT: &str = concat!( + "moments/", + env!("CARGO_PKG_VERSION"), + " (+https://rob.tn)" +); + +#[derive(Clone, Debug)] +pub struct BlogConfig { + /// e.g. `git.lair.cafe`. Stamped into each payload as `_host` so the + /// reshape layer and UI can build raw-content URLs without config. + pub host: String, + /// Repo holding the posts, e.g. `grenade/blog`. + pub repo: String, + pub branch: String, + /// Only needed if the repo is private (which breaks raw image URLs in + /// the UI — keep it public). + pub token: Option, +} + +impl Default for BlogConfig { + fn default() -> Self { + Self { + host: "git.lair.cafe".into(), + repo: "grenade/blog".into(), + branch: "main".into(), + token: None, + } + } +} + +pub struct BlogSource { + client: Client, + writer: Arc, + state: Arc, + config: BlogConfig, +} + +impl BlogSource { + pub fn new( + client: Client, + writer: Arc, + state: Arc, + config: BlogConfig, + ) -> Self { + Self { + client, + writer, + state, + config, + } + } + + fn api_url(&self, rest: &str) -> String { + format!( + "https://{}/api/v1/repos/{}/{rest}", + self.config.host, self.config.repo + ) + } + + fn apply_headers(&self, mut req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + req = req.header(header::USER_AGENT, USER_AGENT); + if let Some(token) = &self.config.token { + req = req.header(header::AUTHORIZATION, format!("token {token}")); + } + req + } + + async fn get(&self, url: &str) -> Result { + let resp = self + .apply_headers(self.client.get(url)) + .send() + .await + .map_err(|e| SourceError::Http(e.to_string()))?; + if !resp.status().is_success() { + return Err(SourceError::Http(format!("{} GET {}", resp.status(), url))); + } + Ok(resp) + } + + /// Tip sha of the configured branch — the change-detection key. + async fn branch_tip(&self) -> Result { + let url = self.api_url(&format!("branches/{}", self.config.branch)); + let body: Value = self + .get(&url) + .await? + .json() + .await + .map_err(|e| SourceError::Parse(e.to_string()))?; + body.get("commit") + .and_then(|c| c.get("id")) + .and_then(Value::as_str) + .map(String::from) + .ok_or_else(|| SourceError::Parse("branch response missing commit.id".into())) + } + + /// Markdown file paths at the repo root, excluding READMEs. + async fn list_post_paths(&self) -> Result, SourceError> { + let url = self.api_url(&format!("contents?ref={}", self.config.branch)); + let entries: Vec = self + .get(&url) + .await? + .json() + .await + .map_err(|e| SourceError::Parse(e.to_string()))?; + Ok(entries + .iter() + .filter(|e| e.get("type").and_then(Value::as_str) == Some("file")) + .filter_map(|e| e.get("path").and_then(Value::as_str)) + .filter(|p| { + p.to_ascii_lowercase().ends_with(".md") + && !p.eq_ignore_ascii_case("readme.md") + }) + .map(String::from) + .collect()) + } + + async fn fetch_raw(&self, path: &str) -> Result { + let url = self.api_url(&format!("raw/{path}?ref={}", self.config.branch)); + self.get(&url) + .await? + .text() + .await + .map_err(|e| SourceError::Parse(e.to_string())) + } +} + +#[async_trait] +impl EventSource for BlogSource { + fn name(&self) -> &'static str { + SOURCE_NAME + } + + async fn poll(&self) -> Result { + let tip = self.branch_tip().await?; + let prior = self.state.load(SOURCE_NAME).await?; + if prior.as_ref().and_then(|p| p.etag.as_deref()) == Some(tip.as_str()) { + self.state.touch(SOURCE_NAME).await?; + return Ok(0); + } + + let mut events = Vec::new(); + for path in self.list_post_paths().await? { + let content = self.fetch_raw(&path).await?; + match parse_post(&path, &content, &self.config) { + Some(event) => events.push(event), + None => warn!(path = %path, "blog post missing or invalid frontmatter; skipping"), + } + } + + let total = self.writer.upsert_events(&events).await?; + self.state.save(SOURCE_NAME, Some(&tip), None).await?; + debug!(ingested = total, tip = %tip, "blog poll complete"); + Ok(total) + } +} + +#[derive(Debug, Deserialize)] +struct Frontmatter { + title: String, + slug: Option, + date: String, + public: Option, + draft: Option, +} + +/// Split `content` into (frontmatter yaml, markdown body). The file must +/// open with a `---` fence on the first line. +fn split_frontmatter(content: &str) -> Option<(&str, &str)> { + let rest = content.strip_prefix("---")?; + let rest = rest.strip_prefix('\n').or_else(|| rest.strip_prefix("\r\n"))?; + // The closing fence is a `---` alone on a line. + let mut offset = 0; + for line in rest.split_inclusive('\n') { + if line.trim_end() == "---" { + let yaml = &rest[..offset]; + let body = &rest[offset + line.len()..]; + return Some((yaml, body)); + } + offset += line.len(); + } + // Closing fence with no trailing newline at EOF. + if rest[offset..].trim_end() == "---" && offset > 0 { + return Some((&rest[..offset], "")); + } + None +} + +/// Accept RFC3339 (`2026-06-12T09:30:00Z`) or a bare date (`2026-06-12`, +/// treated as midnight UTC) so imported posts keep original publish dates. +fn parse_date(s: &str) -> Option> { + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Some(dt.with_timezone(&Utc)); + } + let date = NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()?; + Some(date.and_time(NaiveTime::MIN).and_utc()) +} + +fn parse_post(path: &str, content: &str, config: &BlogConfig) -> Option { + let (yaml, body) = split_frontmatter(content)?; + let fm: Frontmatter = serde_yaml_ng::from_str(yaml) + .map_err(|e| warn!(path = %path, error = %e, "blog frontmatter parse failed")) + .ok()?; + let occurred_at = parse_date(&fm.date)?; + let slug = fm.slug.unwrap_or_else(|| { + path.rsplit('/') + .next() + .unwrap_or(path) + .trim_end_matches(".md") + .to_string() + }); + let public = fm.public.unwrap_or(true) && !fm.draft.unwrap_or(false); + + Some(Event { + id: format!("blog:{slug}"), + source: Source::Blog, + action: "publish_post".into(), + occurred_at, + public, + payload: json!({ + "title": fm.title, + "slug": slug, + "markdown": body.trim_start(), + "_host": config.host, + "_repo": config.repo, + "_branch": config.branch, + "_path": path, + }), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> BlogConfig { + BlogConfig::default() + } + + const POST: &str = "---\ntitle: a watchdog, a torrent tracker, and an unhinged friday\nslug: last-week\ndate: 2026-06-12\n---\n\nthe week opened deep in helexa's neuron engine.\n"; + + #[test] + fn parse_post_with_bare_date() { + let ev = parse_post("last-week.md", POST, &config()).expect("parses"); + assert_eq!(ev.id, "blog:last-week"); + assert_eq!(ev.source, Source::Blog); + assert_eq!(ev.action, "publish_post"); + assert_eq!(ev.occurred_at.to_rfc3339(), "2026-06-12T00:00:00+00:00"); + assert!(ev.public); + assert_eq!( + ev.payload.get("title").and_then(Value::as_str), + Some("a watchdog, a torrent tracker, and an unhinged friday") + ); + assert_eq!( + ev.payload.get("markdown").and_then(Value::as_str), + Some("the week opened deep in helexa's neuron engine.\n") + ); + assert_eq!( + ev.payload.get("_repo").and_then(Value::as_str), + Some("grenade/blog") + ); + } + + #[test] + fn parse_post_with_rfc3339_date() { + let post = "---\ntitle: t\ndate: 2019-03-04T12:30:00+02:00\n---\nbody\n"; + let ev = parse_post("old-post.md", post, &config()).expect("parses"); + assert_eq!(ev.occurred_at.to_rfc3339(), "2019-03-04T10:30:00+00:00"); + } + + #[test] + fn slug_falls_back_to_filename_stem() { + let post = "---\ntitle: t\ndate: 2020-01-01\n---\nbody\n"; + let ev = parse_post("posts/my-old-entry.md", post, &config()).expect("parses"); + assert_eq!(ev.id, "blog:my-old-entry"); + } + + #[test] + fn draft_is_private() { + let post = "---\ntitle: t\ndate: 2020-01-01\ndraft: true\n---\nbody\n"; + let ev = parse_post("p.md", post, &config()).expect("parses"); + assert!(!ev.public); + } + + #[test] + fn explicit_public_false_is_private() { + let post = "---\ntitle: t\ndate: 2020-01-01\npublic: false\n---\nbody\n"; + let ev = parse_post("p.md", post, &config()).expect("parses"); + assert!(!ev.public); + } + + #[test] + fn missing_frontmatter_is_skipped() { + assert!(parse_post("p.md", "# just a heading\n\nbody\n", &config()).is_none()); + } + + #[test] + fn invalid_date_is_skipped() { + let post = "---\ntitle: t\ndate: last tuesday\n---\nbody\n"; + assert!(parse_post("p.md", post, &config()).is_none()); + } + + #[test] + fn split_handles_crlf_and_eof_fence() { + let (yaml, body) = split_frontmatter("---\r\ntitle: t\r\n---\r\nbody").expect("splits"); + assert!(yaml.contains("title: t")); + assert_eq!(body, "body"); + let (yaml, body) = split_frontmatter("---\ntitle: t\n---").expect("splits at eof"); + assert_eq!(yaml, "title: t\n"); + assert_eq!(body, ""); + } +} diff --git a/crates/moments-data/src/lib.rs b/crates/moments-data/src/lib.rs index 293f700..9271c27 100644 --- a/crates/moments-data/src/lib.rs +++ b/crates/moments-data/src/lib.rs @@ -1,3 +1,4 @@ +pub mod blog; pub mod bugzilla; pub mod gitea; pub mod github; diff --git a/crates/moments-entities/src/lib.rs b/crates/moments-entities/src/lib.rs index 771ed64..c48f540 100644 --- a/crates/moments-entities/src/lib.rs +++ b/crates/moments-entities/src/lib.rs @@ -8,6 +8,7 @@ pub enum Source { Gitea, Hg, Bugzilla, + Blog, } impl Source { @@ -16,6 +17,7 @@ impl Source { Source::Gitea, Source::Hg, Source::Bugzilla, + Source::Blog, ]; pub fn as_str(&self) -> &'static str { @@ -24,6 +26,7 @@ impl Source { Source::Gitea => "gitea", Source::Hg => "hg", Source::Bugzilla => "bugzilla", + Source::Blog => "blog", } } } @@ -37,6 +40,7 @@ impl std::str::FromStr for Source { "gitea" => Ok(Source::Gitea), "hg" => Ok(Source::Hg), "bugzilla" => Ok(Source::Bugzilla), + "blog" => Ok(Source::Blog), other => Err(ParseSourceError(other.to_string())), } } @@ -204,5 +208,28 @@ pub enum TimelineIcon { Star, Release, Bug, + Post, Generic, } + +/// Blog index entry returned by `GET /v1/blog`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlogPostSummary { + pub slug: String, + pub title: String, + pub published_at: DateTime, + pub excerpt: String, +} + +/// Full blog post returned by `GET /v1/blog/{slug}`. The host/repo/branch +/// triple lets the UI resolve relative image srcs to forge raw URLs. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlogPost { + pub slug: String, + pub title: String, + pub published_at: DateTime, + pub markdown: String, + pub host: String, + pub repo: String, + pub branch: String, +} diff --git a/crates/moments-worker/src/main.rs b/crates/moments-worker/src/main.rs index 4cc5c05..090ad86 100644 --- a/crates/moments-worker/src/main.rs +++ b/crates/moments-worker/src/main.rs @@ -4,6 +4,7 @@ use clap::Parser; use moments_core::{EventSource, run_poller}; use moments_data::{ PgStore, + blog::{BlogConfig, BlogSource}, bugzilla::{BugzillaConfig, BugzillaSource}, gitea::{GiteaConfig, GiteaSource}, github::{GithubConfig, GithubSource}, @@ -102,6 +103,19 @@ struct Args { /// Seconds between bugzilla creator-query polls (defaults to 24h). #[arg(long, env = "BUGZILLA_POLL_INTERVAL_SECS", default_value = "86400")] bugzilla_interval_secs: u64, + + /// Gitea repo holding blog posts (markdown + frontmatter at the repo + /// root, on `GITEA_HOST`). Empty string disables blog ingestion. + #[arg(long, env = "BLOG_REPO", default_value = "grenade/blog")] + blog_repo: String, + + #[arg(long, env = "BLOG_BRANCH", default_value = "main")] + blog_branch: String, + + /// Seconds between blog repo polls (cheap: one branch-tip request when + /// nothing changed). + #[arg(long, env = "BLOG_POLL_INTERVAL_SECS", default_value = "600")] + blog_interval_secs: u64, } #[tokio::main] @@ -185,6 +199,20 @@ async fn main() -> anyhow::Result<()> { }, )) as Arc; + let blog = (!args.blog_repo.is_empty()).then(|| { + Arc::new(BlogSource::new( + http.clone(), + store.clone(), + store.clone(), + BlogConfig { + host: args.gitea_host.clone(), + repo: args.blog_repo.clone(), + branch: args.blog_branch.clone(), + token: args.gitea_token.clone(), + }, + )) as Arc + }); + info!( github_user = args.github_user, gitea_host = args.gitea_host, @@ -201,6 +229,9 @@ async fn main() -> anyhow::Result<()> { gitea_interval_secs = args.gitea_interval_secs, hg_interval_secs = args.hg_interval_secs, bugzilla_interval_secs = args.bugzilla_interval_secs, + blog_repo = args.blog_repo, + blog_branch = args.blog_branch, + blog_interval_secs = args.blog_interval_secs, "worker started" ); @@ -210,6 +241,7 @@ async fn main() -> anyhow::Result<()> { let gitea_interval = Duration::from_secs(args.gitea_interval_secs); let hg_interval = Duration::from_secs(args.hg_interval_secs); let bugzilla_interval = Duration::from_secs(args.bugzilla_interval_secs); + let blog_interval = Duration::from_secs(args.blog_interval_secs); let github_task = tokio::spawn(async move { run_poller(github, interval).await }); let github_search_task = @@ -220,6 +252,8 @@ async fn main() -> anyhow::Result<()> { let hg_task = tokio::spawn(async move { run_poller(hg, hg_interval).await }); let bugzilla_task = tokio::spawn(async move { run_poller(bugzilla, bugzilla_interval).await }); + let blog_task = + blog.map(|src| tokio::spawn(async move { run_poller(src, blog_interval).await })); tokio::signal::ctrl_c().await?; info!("shutdown signal received"); @@ -229,6 +263,9 @@ async fn main() -> anyhow::Result<()> { gitea_task.abort(); hg_task.abort(); bugzilla_task.abort(); + if let Some(task) = blog_task { + task.abort(); + } Ok(()) } diff --git a/ui/src/App.css b/ui/src/App.css index 5ce5de1..a65be2b 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -178,6 +178,19 @@ a.hot-pink { max-width: 100%; } +.blog-post img { + max-width: 100%; + height: auto; + border-radius: 6px; +} + +.blog-post pre { + background: rgba(0, 0, 0, 0.2); + padding: 0.75rem; + border-radius: 4px; + overflow-x: auto; +} + .site-footer { margin-top: 3rem; padding: 1rem 0; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 3bf838c..7375e2a 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -10,6 +10,8 @@ import { DashPage } from './pages/DashPage'; import { TimelineHome } from './pages/TimelineHome'; import { ProjectPage } from './pages/ProjectPage'; import { CvPage } from './pages/CvPage'; +import { BlogIndexPage } from './pages/BlogIndexPage'; +import { BlogPostPage } from './pages/BlogPostPage'; export default function App() { return ( @@ -20,6 +22,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index fc4331d..a7a8240 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -2,7 +2,7 @@ // Hand-maintained for now; if drift becomes a problem, generate them // from the Rust crate via ts-rs or specta. -export type Source = 'github' | 'gitea' | 'hg' | 'bugzilla'; +export type Source = 'github' | 'gitea' | 'hg' | 'bugzilla' | 'blog'; export type TitleSegment = | { kind: 'text'; text: string } @@ -34,6 +34,7 @@ export type TimelineIcon = | 'star' | 'release' | 'bug' + | 'post' | 'generic'; export interface TimelineItem { @@ -158,6 +159,35 @@ export async function fetchProjects(): Promise { return resp.json(); } +export interface BlogPostSummary { + slug: string; + title: string; + published_at: string; + excerpt: string; +} + +export interface BlogPost { + slug: string; + title: string; + published_at: string; + markdown: string; + host: string; + repo: string; + branch: string; +} + +export async function fetchBlogPosts(): Promise { + const resp = await fetch(`${API_BASE}/blog`); + if (!resp.ok) throw new Error(`blog: HTTP ${resp.status}`); + return resp.json(); +} + +export async function fetchBlogPost(slug: string): Promise { + const resp = await fetch(`${API_BASE}/blog/${encodeURIComponent(slug)}`); + if (!resp.ok) throw new Error(`blog post: HTTP ${resp.status}`); + return resp.json(); +} + /** Fetch repo README as raw markdown via the forge proxy. */ export async function fetchReadme(source: Source, host: string, repo: string): Promise { if (source === 'github') { diff --git a/ui/src/components/Filters.tsx b/ui/src/components/Filters.tsx index 080aa59..00147e1 100644 --- a/ui/src/components/Filters.tsx +++ b/ui/src/components/Filters.tsx @@ -4,7 +4,7 @@ import Row from 'react-bootstrap/Row'; import Slider from 'rc-slider'; import type { Source, SourceSummary } from '../api/client'; -const ALL_SOURCES: Source[] = ['github', 'gitea', 'hg', 'bugzilla']; +const ALL_SOURCES: Source[] = ['github', 'gitea', 'hg', 'bugzilla', 'blog']; interface Props { enabledSources: Record; diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 42cf682..3752eb7 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -17,6 +17,7 @@ export function Layout() {