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); }