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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user