feat(blog): add markdown blog sourced from a gitea repo

posts are markdown files with yaml frontmatter (title, slug, date;
optional draft/public) in the grenade/blog repo. the worker's new
BlogSource polls the repo — one branch-tip request when nothing
changed — and upserts posts into events with source='blog' and
occurred_at from the frontmatter date, so imported posts keep their
original publish dates and backfill the contribution graph.

- new /v1/blog and /v1/blog/{slug} endpoints over the existing
  EventReader port; drafts stay hidden via the public gate
- new /blog and /blog/:slug routes, nav link, activity-feed entry
  with post icon and filter toggle; relative image srcs resolve to
  gitea raw urls
- shared Markdown component extracted from ProjectPage
- vite proxy target overridable via API_PROXY_TARGET for local dev

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 22:44:56 +03:00
parent 2821548e6e
commit 88ce993df3
23 changed files with 846 additions and 61 deletions

View File

@@ -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),
}
}

View File

@@ -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::<Vec<_>>()
.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"), "");
}
}