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:
@@ -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
|
||||
|
||||
|
||||
20
Cargo.lock
generated
20
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<Vec<Event>, 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<AppState>,
|
||||
) -> Result<Json<Vec<BlogPostSummary>>, 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<AppState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<BlogPost>, 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<NaiveDate>,
|
||||
|
||||
@@ -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"), "");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
334
crates/moments-data/src/blog.rs
Normal file
334
crates/moments-data/src/blog.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<dyn EventWriter>,
|
||||
state: Arc<dyn PollerStateStore>,
|
||||
config: BlogConfig,
|
||||
}
|
||||
|
||||
impl BlogSource {
|
||||
pub fn new(
|
||||
client: Client,
|
||||
writer: Arc<dyn EventWriter>,
|
||||
state: Arc<dyn PollerStateStore>,
|
||||
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<reqwest::Response, SourceError> {
|
||||
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<String, SourceError> {
|
||||
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<Vec<String>, SourceError> {
|
||||
let url = self.api_url(&format!("contents?ref={}", self.config.branch));
|
||||
let entries: Vec<Value> = 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<String, SourceError> {
|
||||
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<usize, SourceError> {
|
||||
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<String>,
|
||||
date: String,
|
||||
public: Option<bool>,
|
||||
draft: Option<bool>,
|
||||
}
|
||||
|
||||
/// 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<DateTime<Utc>> {
|
||||
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<Event> {
|
||||
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, "");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod blog;
|
||||
pub mod bugzilla;
|
||||
pub mod gitea;
|
||||
pub mod github;
|
||||
|
||||
@@ -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<Utc>,
|
||||
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<Utc>,
|
||||
pub markdown: String,
|
||||
pub host: String,
|
||||
pub repo: String,
|
||||
pub branch: String,
|
||||
}
|
||||
|
||||
@@ -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<dyn EventSource>;
|
||||
|
||||
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<dyn EventSource>
|
||||
});
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/activity" element={<TimelineHome />} />
|
||||
<Route path="/activity/:timespan" element={<TimelineHome />} />
|
||||
<Route path="/project/:source/*" element={<ProjectPage />} />
|
||||
<Route path="/blog" element={<BlogIndexPage />} />
|
||||
<Route path="/blog/:slug" element={<BlogPostPage />} />
|
||||
<Route path="/cv" element={<CvPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -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<ProjectSummary[]> {
|
||||
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<BlogPostSummary[]> {
|
||||
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<BlogPost> {
|
||||
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<string | null> {
|
||||
if (source === 'github') {
|
||||
|
||||
@@ -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<Source, boolean>;
|
||||
|
||||
@@ -17,6 +17,7 @@ export function Layout() {
|
||||
<nav className="d-flex flex-wrap gap-3 align-items-center">
|
||||
<NavLink to="/" end>dash</NavLink>
|
||||
<NavLink to="/activity">activity</NavLink>
|
||||
<NavLink to="/blog">blog</NavLink>
|
||||
<NavLink to="/cv">cv</NavLink>
|
||||
<span className="nav-divider">|</span>
|
||||
{externalLinks.map((el) => (
|
||||
|
||||
69
ui/src/components/Markdown.tsx
Normal file
69
ui/src/components/Markdown.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import ReactMarkdown, { type UrlTransform } from 'react-markdown';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
// rehype-sanitize defaults are conservative — markdown authors lean on raw
|
||||
// HTML for layout (centered headers, collapsible sections, image
|
||||
// dimensions). Extend the schema to permit those tags/attributes while
|
||||
// still blocking script-y or interactive content (iframe, object, etc.).
|
||||
const sanitizeSchema = {
|
||||
...defaultSchema,
|
||||
tagNames: [
|
||||
...(defaultSchema.tagNames ?? []),
|
||||
'details',
|
||||
'summary',
|
||||
'picture',
|
||||
'source',
|
||||
'kbd',
|
||||
'sub',
|
||||
'sup',
|
||||
'mark',
|
||||
'abbr',
|
||||
'cite',
|
||||
'figure',
|
||||
'figcaption',
|
||||
'center',
|
||||
],
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
'*': [
|
||||
...((defaultSchema.attributes && defaultSchema.attributes['*']) || []),
|
||||
'align',
|
||||
'style',
|
||||
],
|
||||
a: [
|
||||
...((defaultSchema.attributes && defaultSchema.attributes.a) || []),
|
||||
'target',
|
||||
'rel',
|
||||
],
|
||||
img: [
|
||||
...((defaultSchema.attributes && defaultSchema.attributes.img) || []),
|
||||
'width',
|
||||
'height',
|
||||
'align',
|
||||
'srcset',
|
||||
],
|
||||
source: ['srcset', 'media', 'type'],
|
||||
details: ['open'],
|
||||
},
|
||||
};
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
/** Rewrite URLs (e.g. resolve relative image paths to forge raw URLs). */
|
||||
urlTransform?: UrlTransform;
|
||||
}
|
||||
|
||||
/** GFM + sanitized embedded HTML, shared by READMEs and blog posts. */
|
||||
export function Markdown({ text, urlTransform }: Props) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
urlTransform={urlTransform}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DashCircle,
|
||||
Diagram3,
|
||||
ExclamationCircle,
|
||||
PencilSquare,
|
||||
PlusCircle,
|
||||
StarFill,
|
||||
Tag,
|
||||
@@ -28,6 +29,7 @@ const map: Record<TimelineIcon, typeof Wrench> = {
|
||||
star: StarFill,
|
||||
release: Tag,
|
||||
bug: Bug,
|
||||
post: PencilSquare,
|
||||
generic: Wrench,
|
||||
};
|
||||
|
||||
@@ -44,6 +46,7 @@ const colors: Record<TimelineIcon, string> = {
|
||||
star: '#f9a825',
|
||||
release: '#6a1b9a',
|
||||
bug: '#c62828',
|
||||
post: '#00695c',
|
||||
generic: '#546e7a',
|
||||
};
|
||||
|
||||
|
||||
48
ui/src/pages/BlogIndexPage.tsx
Normal file
48
ui/src/pages/BlogIndexPage.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Col from 'react-bootstrap/Col';
|
||||
import Row from 'react-bootstrap/Row';
|
||||
|
||||
import { fetchBlogPosts } from '../api/client';
|
||||
|
||||
export function BlogIndexPage() {
|
||||
const postsQ = useQuery({
|
||||
queryKey: ['blog-posts'],
|
||||
queryFn: fetchBlogPosts,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (postsQ.isLoading) return <p>loading...</p>;
|
||||
if (postsQ.isError) return <p>error: {(postsQ.error as Error).message}</p>;
|
||||
|
||||
const posts = postsQ.data ?? [];
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col md={{ span: 8, offset: 2 }}>
|
||||
{posts.length === 0 && <p>nothing here yet.</p>}
|
||||
{posts.map((post) => (
|
||||
<article key={post.slug} className="mb-4">
|
||||
<h3 className="mb-1">
|
||||
<Link to={`/blog/${post.slug}`}>{post.title}</Link>
|
||||
</h3>
|
||||
<p className="text-muted mb-1" style={{ fontSize: '85%' }}>
|
||||
{formatDate(post.published_at)}
|
||||
</p>
|
||||
{post.excerpt && <p className="mb-0">{post.excerpt}</p>}
|
||||
</article>
|
||||
))}
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
return new Date(iso)
|
||||
.toLocaleDateString('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
.toLowerCase();
|
||||
}
|
||||
49
ui/src/pages/BlogPostPage.tsx
Normal file
49
ui/src/pages/BlogPostPage.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Col from 'react-bootstrap/Col';
|
||||
import Row from 'react-bootstrap/Row';
|
||||
|
||||
import { fetchBlogPost } from '../api/client';
|
||||
import { Markdown } from '../components/Markdown';
|
||||
import { formatDate } from './BlogIndexPage';
|
||||
|
||||
export function BlogPostPage() {
|
||||
const { slug } = useParams();
|
||||
|
||||
const postQ = useQuery({
|
||||
queryKey: ['blog-post', slug],
|
||||
queryFn: () => fetchBlogPost(slug ?? ''),
|
||||
enabled: !!slug,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (postQ.isLoading) return <p>loading...</p>;
|
||||
if (postQ.isError) return <p>error: {(postQ.error as Error).message}</p>;
|
||||
|
||||
const post = postQ.data;
|
||||
if (!post) return null;
|
||||
|
||||
// Posts reference images committed alongside them in the blog repo;
|
||||
// resolve relative srcs to the forge's raw-content URL.
|
||||
const resolveUrl = (url: string) =>
|
||||
/^[a-z][a-z0-9+.-]*:|^\/|^#/i.test(url)
|
||||
? url
|
||||
: `https://${post.host}/${post.repo}/raw/branch/${post.branch}/${url}`;
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col md={{ span: 8, offset: 2 }}>
|
||||
<p className="mb-2" style={{ fontSize: '85%' }}>
|
||||
<Link to="/blog">← blog</Link>
|
||||
</p>
|
||||
<h2 className="mb-1">{post.title}</h2>
|
||||
<p className="text-muted" style={{ fontSize: '85%' }}>
|
||||
{formatDate(post.published_at)}
|
||||
</p>
|
||||
<div className="blog-post">
|
||||
<Markdown text={post.markdown} urlTransform={resolveUrl} />
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,11 @@ import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Col from 'react-bootstrap/Col';
|
||||
import Row from 'react-bootstrap/Row';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { VerticalTimeline } from 'react-vertical-timeline-component';
|
||||
|
||||
import { fetchEvents, fetchReadme, fetchRepoLanguages, fetchProjects, type Source } from '../api/client';
|
||||
import { LanguageBar } from '../components/LanguageBar';
|
||||
import { Markdown } from '../components/Markdown';
|
||||
import { TimelineEntry } from '../components/TimelineEntry';
|
||||
|
||||
export function ProjectPage() {
|
||||
@@ -85,12 +82,7 @@ export function ProjectPage() {
|
||||
<Row className="mb-4">
|
||||
<Col>
|
||||
<div className="project-readme">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, readmeSanitizeSchema]]}
|
||||
>
|
||||
{readmeQ.data}
|
||||
</ReactMarkdown>
|
||||
<Markdown text={readmeQ.data} />
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -134,49 +126,4 @@ function forgeIcon(source: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// rehype-sanitize defaults are conservative — README authors lean on raw
|
||||
// HTML for layout (centered headers, collapsible sections, image
|
||||
// dimensions). Extend the schema to permit those tags/attributes while
|
||||
// still blocking script-y or interactive content (iframe, object, etc.).
|
||||
const readmeSanitizeSchema = {
|
||||
...defaultSchema,
|
||||
tagNames: [
|
||||
...(defaultSchema.tagNames ?? []),
|
||||
'details',
|
||||
'summary',
|
||||
'picture',
|
||||
'source',
|
||||
'kbd',
|
||||
'sub',
|
||||
'sup',
|
||||
'mark',
|
||||
'abbr',
|
||||
'cite',
|
||||
'figure',
|
||||
'figcaption',
|
||||
'center',
|
||||
],
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
'*': [
|
||||
...((defaultSchema.attributes && defaultSchema.attributes['*']) || []),
|
||||
'align',
|
||||
'style',
|
||||
],
|
||||
a: [
|
||||
...((defaultSchema.attributes && defaultSchema.attributes.a) || []),
|
||||
'target',
|
||||
'rel',
|
||||
],
|
||||
img: [
|
||||
...((defaultSchema.attributes && defaultSchema.attributes.img) || []),
|
||||
'width',
|
||||
'height',
|
||||
'align',
|
||||
'srcset',
|
||||
],
|
||||
source: ['srcset', 'media', 'type'],
|
||||
details: ['open'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export function TimelineHome() {
|
||||
gitea: true,
|
||||
hg: true,
|
||||
bugzilla: true,
|
||||
blog: true,
|
||||
});
|
||||
const [rangeValue, setRangeValue] = useState<[number, number]>(() => {
|
||||
const parsed = parseTimespan(timespan);
|
||||
|
||||
@@ -10,7 +10,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
target: process.env.API_PROXY_TARGET ?? 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user