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:
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
138
crates/moments-core/src/presentation/blog.rs
Normal file
138
crates/moments-core/src/presentation/blog.rs
Normal 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\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"), "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user