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>
70 lines
1.7 KiB
TypeScript
70 lines
1.7 KiB
TypeScript
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>
|
|
);
|
|
}
|