From 9420307cb570a4be5bb9c9162e0d6428fa4b0b5a Mon Sep 17 00:00:00 2001
From: Dinesh <13635627+HumbleBee14@users.noreply.github.com>
Date: Mon, 13 Jul 2026 00:25:26 -0700
Subject: [PATCH] Add cover-image support and edit-existing-post to /write
portal
Cover: pick an in-post image or upload one; stored once, used as the
social/OG card and an optional post hero.
Edit: each export writes a .write-source.json sidecar next to the post;
'Edit a published post' dialog fetches it plus the original images from
GitHub raw and reloads them losslessly into the editor. Original publish
date preserved, updated date stamped on re-export.
---
src/pages/blog/[slug].astro | 19 ++++
src/styles/global.css | 12 +++
src/write/WritePortal.tsx | 124 ++++++++++++++++++++-
src/write/editor/editor-theme.css | 156 +++++++++++++++++++++++++++
src/write/meta/MetaForm.tsx | 48 ++++++++-
src/write/serialize/fetchExisting.ts | 80 ++++++++++++++
src/write/serialize/source.ts | 38 +++++++
src/write/serialize/toMdx.ts | 8 +-
src/write/serialize/toZip.ts | 4 +
9 files changed, 486 insertions(+), 3 deletions(-)
create mode 100644 src/write/serialize/fetchExisting.ts
create mode 100644 src/write/serialize/source.ts
diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro
index 632b403..fb1ed37 100644
--- a/src/pages/blog/[slug].astro
+++ b/src/pages/blog/[slug].astro
@@ -1,6 +1,7 @@
---
import { getCollection, getEntries, render } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
+import { Image } from 'astro:assets';
import BaseLayout from '@/layouts/BaseLayout.astro';
import { mdxComponents } from '@/components/MDXComponents.tsx';
import ArticleActions from '@/components/ArticleActions.tsx';
@@ -147,6 +148,24 @@ const breadcrumbJsonLd = {
+ {
+ cover && (
+
+ {typeof cover === 'string' ? (
+
+ ) : (
+
+ )}
+
+ )
+ }
+
diff --git a/src/styles/global.css b/src/styles/global.css
index 610f920..c22a896 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -1338,6 +1338,18 @@ a.hashtag:hover {
margin: 0 0 32px;
text-wrap: pretty;
}
+.article-cover {
+ margin: 0 auto 40px;
+ max-width: clamp(var(--text-w), 72vw, var(--text-w-max));
+ padding: 0 var(--sp-5);
+}
+.article-cover img {
+ width: 100%;
+ height: auto;
+ border-radius: var(--radius-lg, 10px);
+ border: 1px solid var(--line);
+ display: block;
+}
.article-body {
font-family: var(--font-read);
font-size: clamp(18px, 1.7vw, 20px);
diff --git a/src/write/WritePortal.tsx b/src/write/WritePortal.tsx
index 4848000..b3074d6 100644
--- a/src/write/WritePortal.tsx
+++ b/src/write/WritePortal.tsx
@@ -19,7 +19,9 @@ import { MetaForm, type Option } from './meta/MetaForm';
import { serializePost, type PostMeta, type SBlock, type TableStyle } from './serialize/toMdx';
import { validate } from './serialize/validate';
import { buildZip } from './serialize/toZip';
-import { allAssets } from './storage/assets';
+import { buildSource } from './serialize/source';
+import { fetchExisting } from './serialize/fetchExisting';
+import { allAssets, clearAssets } from './storage/assets';
import {
clearDraft,
clearStoredAssets,
@@ -64,6 +66,25 @@ type Props = {
contactEmail: string;
};
+function collectImages(blocks: SBlock[]): string[] {
+ const out: string[] = [];
+ const walk = (list: SBlock[]) => {
+ for (const b of list) {
+ if (b.type === 'figure' && b.props.fileName) out.push(String(b.props.fileName));
+ if (b.type === 'gallery') {
+ try {
+ out.push(...(JSON.parse(String(b.props.fileNames || '[]')) as string[]));
+ } catch {
+ // ignore a malformed gallery — it just won't offer cover options
+ }
+ }
+ if (b.children?.length) walk(b.children);
+ }
+ };
+ walk(blocks);
+ return [...new Set(out.filter(Boolean))];
+}
+
function isEmptyDraft(meta: PostMeta, blocks: SBlock[]): boolean {
const metaEmpty =
!meta.title.trim() && !meta.summary.trim() && !meta.slug.trim() && meta.tags.length === 0;
@@ -99,8 +120,12 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
const [busy, setBusy] = useState(false);
const [sentFile, setSentFile] = useState(null);
const [siteTheme, setSiteTheme] = useState<'light' | 'dark'>('light');
+ const [openError, setOpenError] = useState(null);
+ const [openUrl, setOpenUrl] = useState('');
+ const [openDialog, setOpenDialog] = useState(false);
const variantCss = useMemo(() => tableVariantCss(tableVariants), [tableVariants]);
+ const images = collectImages(editor.document as unknown as SBlock[]);
useEffect(() => {
const root = document.documentElement;
@@ -199,6 +224,34 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
setRestore(null);
};
+ const openExisting = async () => {
+ const input = openUrl.trim();
+ if (!input) return;
+ setOpenError(null);
+ setBusy(true);
+ try {
+ clearAssets();
+ const loaded = await fetchExisting(repoUrl, input);
+ await clearStoredAssets().catch(() => undefined);
+ setRestore(null);
+ setSentFile(null);
+ setIssues([]);
+ editor.replaceBlocks(editor.document, loaded.blocks as never);
+ setMeta({
+ ...emptyMeta(),
+ ...loaded.meta,
+ authors: Array.isArray(loaded.meta.authors) ? loaded.meta.authors : [],
+ });
+ setTableVariants(loaded.tableVariants ?? {});
+ setOpenUrl('');
+ setOpenDialog(false);
+ } catch (err) {
+ setOpenError(err instanceof Error ? err.message : 'That post could not be opened.');
+ } finally {
+ setBusy(false);
+ }
+ };
+
const download = async () => {
const blocks = editor.document as unknown as SBlock[];
const found = validate(meta, blocks);
@@ -214,6 +267,7 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
repoUrl,
contactEmail,
assets: allAssets(),
+ sourceJson: buildSource(meta, blocks, tableVariants),
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -268,10 +322,78 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
return (
+
+
+
+
+ {openDialog && (
+
!busy && setOpenDialog(false)}
+ role="presentation"
+ >
+
e.stopPropagation()}
+ >
+
Edit a published post
+
Paste the post’s URL.
+
setOpenUrl(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ void openExisting();
+ }
+ }}
+ />
+ {openError &&
{openError}
}
+
+
+
+
+
+
+ )}
+
{
setMeta(m);
autosave();
diff --git a/src/write/editor/editor-theme.css b/src/write/editor/editor-theme.css
index 06d4b19..8fc0fca 100644
--- a/src/write/editor/editor-theme.css
+++ b/src/write/editor/editor-theme.css
@@ -132,6 +132,162 @@
padding: 2px;
}
+.write-topbar {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 12px;
+}
+.write-open-link {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--ink-3);
+ background: none;
+ border: none;
+ padding: 2px 0;
+ cursor: pointer;
+}
+.write-open-link:hover {
+ color: var(--accent);
+}
+
+.write-modal-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 60;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ background: color-mix(in srgb, var(--ink) 45%, transparent);
+}
+.write-modal {
+ width: 100%;
+ max-width: 460px;
+ background: var(--paper);
+ border: 1px solid var(--line-2);
+ border-radius: 12px;
+ padding: 24px;
+ box-shadow: 0 20px 60px color-mix(in srgb, var(--ink) 30%, transparent);
+}
+.write-modal h3 {
+ margin: 0 0 6px;
+ font-size: 18px;
+}
+.write-modal p {
+ margin: 0 0 14px;
+ font-size: 14px;
+ color: var(--ink-2);
+}
+.write-modal .write-open-url {
+ width: 100%;
+ font-family: var(--font-mono);
+ font-size: 13px;
+ color: var(--ink);
+ background: var(--paper-2, var(--paper));
+ border: 1px solid var(--line-2);
+ border-radius: 8px;
+ padding: 9px 12px;
+}
+.write-modal .write-open-url:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+.write-modal-error {
+ color: var(--accent);
+ font-size: 13px;
+ margin: 12px 0 0 !important;
+}
+.write-modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 20px;
+}
+
+.write-cover {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 24px;
+}
+.write-cover-pick {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+.write-cover-thumb {
+ padding: 0;
+ border: 1px solid var(--line-2);
+ border-radius: 6px;
+ overflow: hidden;
+ cursor: pointer;
+ background: none;
+ line-height: 0;
+}
+.write-cover-thumb:hover {
+ border-color: var(--accent);
+}
+.write-cover-thumb img {
+ width: 72px;
+ height: 48px;
+ object-fit: cover;
+ display: block;
+}
+.write-cover-upload {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--ink-3);
+ border: 1px dashed var(--line-2);
+ border-radius: 8px;
+ padding: 12px 16px;
+ cursor: pointer;
+}
+.write-cover-upload:hover {
+ color: var(--accent);
+ border-color: var(--accent);
+}
+.write-cover-upload input {
+ display: none;
+}
+.write-cover-set {
+ display: flex;
+ justify-content: center;
+}
+.write-cover-frame {
+ position: relative;
+ display: inline-block;
+ line-height: 0;
+}
+.write-cover-frame img {
+ max-width: 220px;
+ max-height: 150px;
+ width: auto;
+ height: auto;
+ border-radius: 8px;
+ border: 1px solid var(--line-2);
+ display: block;
+}
+.write-cover-remove {
+ position: absolute;
+ top: -8px;
+ right: -8px;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ color: var(--paper);
+ background: var(--ink);
+ border: 1px solid var(--paper);
+ border-radius: 99px;
+ cursor: pointer;
+}
+.write-cover-remove:hover {
+ background: var(--accent);
+}
+
.write-banner {
display: flex;
align-items: center;
diff --git a/src/write/meta/MetaForm.tsx b/src/write/meta/MetaForm.tsx
index d5ea486..9458079 100644
--- a/src/write/meta/MetaForm.tsx
+++ b/src/write/meta/MetaForm.tsx
@@ -1,6 +1,7 @@
import { useState } from 'react';
import type { PostMeta } from '../serialize/toMdx';
import { slugify } from '../serialize/validate';
+import { addAsset, getAssetUrl } from '../storage/assets';
export type Option = { id: string; name: string };
@@ -8,10 +9,11 @@ type Props = {
authors: Option[];
topics: Option[];
meta: PostMeta;
+ images: string[];
onChange: (meta: PostMeta) => void;
};
-export function MetaForm({ authors, topics, meta, onChange }: Props) {
+export function MetaForm({ authors, topics, meta, images, onChange }: Props) {
const [slugTouched, setSlugTouched] = useState(false);
const [tagInput, setTagInput] = useState('');
@@ -154,6 +156,50 @@ export function MetaForm({ authors, topics, meta, onChange }: Props) {
+
+
+ {meta.coverFileName ? (
+
+
+
})
+
+
+
+ ) : (
+
+ {images.map((name) => (
+
+ ))}
+
+
+ )}
+
);
}
diff --git a/src/write/serialize/fetchExisting.ts b/src/write/serialize/fetchExisting.ts
new file mode 100644
index 0000000..217857f
--- /dev/null
+++ b/src/write/serialize/fetchExisting.ts
@@ -0,0 +1,80 @@
+import { restoreAsset } from '../storage/assets';
+import { parseSource, SOURCE_FILENAME, type ParsedSource } from './source';
+import type { SBlock } from './toMdx';
+
+export const NOT_FOUND_MESSAGE = 'No editable post found at that link. Please try manual update.';
+
+export const BAD_SOURCE_MESSAGE =
+ 'The editor data for that post is unreadable. Edit its index.mdx by hand instead.';
+
+export const NETWORK_MESSAGE =
+ 'Could not reach GitHub to load that post. Check your connection and try again.';
+
+function rawBase(repoUrl: string, slug: string, branch = 'main'): string {
+ const clean = repoUrl
+ .replace(/\/+$/, '')
+ .replace('https://github.com/', 'https://raw.githubusercontent.com/');
+ return `${clean}/${branch}/src/content/posts/${slug}/`;
+}
+
+export function slugFromInput(input: string): string {
+ const t = input.trim();
+ const blog = t.match(/\/blog\/([a-z0-9][a-z0-9-]*)/i);
+ if (blog) return blog[1];
+ const posts = t.match(/posts\/([a-z0-9][a-z0-9-]*)/i);
+ if (posts) return posts[1];
+ return t.replace(/\/+$/, '').split('/').pop() ?? t;
+}
+
+function imageNames(blocks: SBlock[], cover: string): string[] {
+ const out: string[] = [];
+ const walk = (list: SBlock[]) => {
+ for (const b of list) {
+ if (b.type === 'figure' && b.props.fileName) out.push(String(b.props.fileName));
+ if (b.type === 'gallery') {
+ try {
+ out.push(...(JSON.parse(String(b.props.fileNames || '[]')) as string[]));
+ } catch {
+ // malformed gallery — skip it
+ }
+ }
+ if (b.children?.length) walk(b.children);
+ }
+ };
+ walk(blocks);
+ if (cover) out.push(cover);
+ return [...new Set(out.filter(Boolean))];
+}
+
+// Pulls a published post's editor data + images straight from GitHub (raw),
+// so a URL is all the writer needs to re-open it. Never throws for a missing
+// image — only for a missing/unreadable post.
+export async function fetchExisting(repoUrl: string, input: string): Promise {
+ const base = rawBase(repoUrl, slugFromInput(input));
+
+ let res: Response;
+ try {
+ res = await fetch(base + SOURCE_FILENAME, { cache: 'no-store' });
+ } catch {
+ throw new Error(NETWORK_MESSAGE);
+ }
+ if (res.status === 404) throw new Error(NOT_FOUND_MESSAGE);
+ if (!res.ok) throw new Error(NETWORK_MESSAGE);
+
+ const parsed = parseSource(await res.text());
+ if (!parsed) throw new Error(BAD_SOURCE_MESSAGE);
+
+ await Promise.all(
+ imageNames(parsed.blocks, parsed.meta.coverFileName).map(async (name) => {
+ try {
+ const r = await fetch(base + encodeURIComponent(name), { cache: 'no-store' });
+ if (!r.ok) return;
+ const blob = await r.blob();
+ restoreAsset(name, new File([blob], name, { type: blob.type }));
+ } catch {
+ // a single missing image shouldn't block opening the post
+ }
+ }),
+ );
+ return parsed;
+}
diff --git a/src/write/serialize/source.ts b/src/write/serialize/source.ts
new file mode 100644
index 0000000..5a6d838
--- /dev/null
+++ b/src/write/serialize/source.ts
@@ -0,0 +1,38 @@
+import type { PostMeta, SBlock, TableStyle } from './toMdx';
+
+export const SOURCE_FILENAME = '.write-source.json';
+
+type Source = {
+ kind: 'mlsys-write-source';
+ version: 1;
+ meta: PostMeta;
+ blocks: SBlock[];
+ tableVariants: Record;
+};
+
+export type ParsedSource = Omit;
+
+// Committed alongside each post so it can be re-opened losslessly in the editor.
+export function buildSource(
+ meta: PostMeta,
+ blocks: SBlock[],
+ tableVariants: Record,
+): string {
+ const source: Source = { kind: 'mlsys-write-source', version: 1, meta, blocks, tableVariants };
+ return `${JSON.stringify(source, null, 2)}\n`;
+}
+
+export function parseSource(text: string): ParsedSource | null {
+ try {
+ const data = JSON.parse(text) as Partial;
+ if (data.kind !== 'mlsys-write-source' || !data.meta || !Array.isArray(data.blocks))
+ return null;
+ return {
+ meta: data.meta,
+ blocks: data.blocks as SBlock[],
+ tableVariants: data.tableVariants ?? {},
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/src/write/serialize/toMdx.ts b/src/write/serialize/toMdx.ts
index 501acf3..5cbf98d 100644
--- a/src/write/serialize/toMdx.ts
+++ b/src/write/serialize/toMdx.ts
@@ -24,6 +24,8 @@ export type PostMeta = {
tags: string[];
slug: string;
coverFileName: string;
+ // Set only when editing an existing post; preserves its original publish date.
+ date?: string;
};
export type TableStyle = { border: 'rule' | 'lined' | 'plain'; zebra: boolean };
@@ -37,6 +39,7 @@ export type SerializeOptions = {
export type SerializedPost = {
mdx: string;
assetNames: string[];
+ cover?: string;
componentFiles: { fileName: string; source: string }[];
};
@@ -358,15 +361,17 @@ function yaml(value: string): string {
function buildFrontmatter(meta: PostMeta, blocks: SBlock[], opts: SerializeOptions): string {
const wpm = opts.wordsPerMinute ?? 220;
const readMin = Math.max(1, Math.round(countWords(blocks) / wpm));
+ const todayStr = opts.today.toISOString().slice(0, 10);
const lines = [
`title: ${yaml(meta.title)}`,
`summary: ${yaml(meta.summary)}`,
`authors: [${(meta.authors.length ? meta.authors : ['guest']).map(yaml).join(', ')}]`,
- `date: ${yaml(opts.today.toISOString().slice(0, 10))}`,
+ `date: ${yaml(meta.date || todayStr)}`,
`readMin: ${readMin}`,
`topic: ${yaml(meta.topicName)}`,
`topicId: ${yaml(meta.topicId)}`,
];
+ if (meta.date && meta.date !== todayStr) lines.push(`updated: ${yaml(todayStr)}`);
if (meta.tags.length > 0) lines.push(`tags: [${meta.tags.map(yaml).join(', ')}]`);
if (meta.coverFileName) lines.push(`cover: ${yaml(`./${meta.coverFileName}`)}`);
return `---\n${lines.join('\n')}\n---`;
@@ -397,6 +402,7 @@ export function serializePost(
return {
mdx: `${sections.join('\n\n')}\n`,
assetNames: ctx.imports.map((i) => i.fileName),
+ cover: meta.coverFileName || undefined,
componentFiles: ctx.componentFiles,
};
}
diff --git a/src/write/serialize/toZip.ts b/src/write/serialize/toZip.ts
index 784dfd9..48bc29a 100644
--- a/src/write/serialize/toZip.ts
+++ b/src/write/serialize/toZip.ts
@@ -1,5 +1,6 @@
import JSZip from 'jszip';
import type { SerializedPost } from './toMdx';
+import { SOURCE_FILENAME } from './source';
export type ZipInput = {
serialized: SerializedPost;
@@ -8,6 +9,7 @@ export type ZipInput = {
repoUrl: string;
contactEmail: string;
assets: { name: string; file: File }[];
+ sourceJson: string;
};
function submitNote(
@@ -56,7 +58,9 @@ export async function buildZip(input: ZipInput): Promise {
const folder = zip.folder(input.slug);
if (!folder) throw new Error('zip folder creation failed');
folder.file('index.mdx', input.serialized.mdx);
+ folder.file(SOURCE_FILENAME, input.sourceJson);
const wanted = new Set(input.serialized.assetNames);
+ if (input.serialized.cover) wanted.add(input.serialized.cover);
for (const { name, file } of input.assets) {
if (wanted.has(name)) folder.file(name, file);
}