+
+
Two ways to write
+
+ New to this? Use the in-browser editor — add headings, images, tables, video, and math visually, then download a ready-to-publish
+ folder. No markdown, no setup.
+
+
+ Comfortable with MDX? Write it directly. Either way, send it to us as a pull request or
+ by email and we'll take it from there.
+
+
+
+
What we publish
({ id: a.id, name: a.data.name }))
+ .sort((a, b) => (a.id === 'guest' ? -1 : b.id === 'guest' ? 1 : a.name.localeCompare(b.name)));
+
+const topics = TOPICS.map((t) => ({ id: t.id, name: t.name }));
+const repoUrl = SITE.github;
+---
+
+
+
+
Write an article
+
+ Draft your post here — no Markdown, no setup. When you’re done, download a ready-to-publish
+ folder and send it to us as a pull request or by email . Prefer
+ writing MDX by hand? That works too — see the authoring guide .
+
+
+
+
+
diff --git a/src/styles/global.css b/src/styles/global.css
index 13cb2f6..9d04fbe 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -47,6 +47,28 @@ html {
--accent-2: #8a3318;
--accent-soft: rgba(184, 67, 31, 0.08);
+ /* Highlight tints (light) */
+ --mark-gray: #ebeced;
+ --mark-brown: #e9e5e3;
+ --mark-red: #fbe4e4;
+ --mark-orange: #f6e9d9;
+ --mark-yellow: #fbf3db;
+ --mark-green: #ddedea;
+ --mark-blue: #ddebf1;
+ --mark-purple: #eae4f2;
+ --mark-pink: #f4dfeb;
+
+ /* Text colors (light) */
+ --tc-gray: #6b6b68;
+ --tc-brown: #64473a;
+ --tc-red: #c0392b;
+ --tc-orange: #b5610a;
+ --tc-yellow: #9a6a10;
+ --tc-green: #2f7a54;
+ --tc-blue: #2b6a8f;
+ --tc-purple: #7a4f9e;
+ --tc-pink: #b23a76;
+
/* Spacing scale (density: comfortable default) */
--sp-1: 4px;
--sp-2: 8px;
@@ -94,6 +116,28 @@ html {
--accent: #ff7a4d;
--accent-2: #ffa07a;
--accent-soft: rgba(255, 122, 77, 0.1);
+
+ /* Highlight tints (dark) — translucent so light text stays readable */
+ --mark-gray: rgba(255, 255, 255, 0.1);
+ --mark-brown: rgba(150, 110, 90, 0.28);
+ --mark-red: rgba(224, 62, 62, 0.28);
+ --mark-orange: rgba(217, 115, 13, 0.28);
+ --mark-yellow: rgba(203, 145, 47, 0.3);
+ --mark-green: rgba(68, 131, 97, 0.32);
+ --mark-blue: rgba(51, 126, 169, 0.32);
+ --mark-purple: rgba(144, 101, 176, 0.32);
+ --mark-pink: rgba(193, 76, 138, 0.3);
+
+ /* Text colors (dark) */
+ --tc-gray: #a6a5a1;
+ --tc-brown: #c39a7f;
+ --tc-red: #f0776b;
+ --tc-orange: #e0954a;
+ --tc-yellow: #d6b25a;
+ --tc-green: #6cc394;
+ --tc-blue: #6bb3db;
+ --tc-purple: #b79ad6;
+ --tc-pink: #e089b6;
}
/* Accent variants */
@@ -1189,8 +1233,19 @@ main {
}
.article-body h3 {
font-family: var(--font-sans);
+ font-size: clamp(20px, 2.4vw, 22px);
font-weight: 600;
letter-spacing: -0.015em;
+ margin: 36px 0 12px;
+ line-height: 1.3;
+}
+.article-body h4 {
+ font-family: var(--font-sans);
+ font-size: 18px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ margin: 28px 0 10px;
+ line-height: 1.35;
}
[data-typeset='modern'] .article-body h2 {
font-weight: 600;
@@ -1230,8 +1285,9 @@ main {
font-size: 1em;
}
.article-body pre {
- background: var(--paper-2);
- border: 1px solid var(--line);
+ background: #24292e;
+ color: #e1e4e8;
+ border: 1px solid #3a3f46;
border-radius: 8px;
padding: 20px;
overflow-x: auto;
diff --git a/src/write/WritePortal.tsx b/src/write/WritePortal.tsx
new file mode 100644
index 0000000..fbde10b
--- /dev/null
+++ b/src/write/WritePortal.tsx
@@ -0,0 +1,333 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
+import { AllSelection, TextSelection } from 'prosemirror-state';
+import { filterSuggestionItems } from '@blocknote/core';
+import {
+ SuggestionMenuController,
+ useCreateBlockNote,
+ useEditorSelectionChange,
+} from '@blocknote/react';
+import { BlockNoteView } from '@blocknote/mantine';
+import '@blocknote/mantine/style.css';
+import { schema } from './editor/schema';
+import { getSlashItems } from './editor/slashMenu';
+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 {
+ clearDraft,
+ clearStoredAssets,
+ loadDraft,
+ restoreAssets,
+ saveDraftDebounced,
+} from './storage/drafts';
+import './editor/editor-theme.css';
+
+const BORDER_VARIANTS: TableStyle['border'][] = ['rule', 'lined', 'plain'];
+const DEFAULT_TABLE_STYLE: TableStyle = { border: 'rule', zebra: false };
+
+function tableVariantCss(variants: Record): string {
+ return Object.entries(variants)
+ .map(([id, style]) => {
+ if (!style || typeof style !== 'object') return '';
+ const sel = `.bn-editor [data-id="${id}"] [data-content-type='table']`;
+ const rules: string[] = [];
+ if (style.border === 'lined') {
+ rules.push(
+ `${sel} :is(td, th) { border: 1px solid var(--line); padding-left: 12px; padding-right: 12px; }`,
+ );
+ }
+ if (style.border === 'plain') {
+ rules.push(`${sel} tr:not(:first-child) > * { border-bottom: none; }`);
+ }
+ if (style.zebra) {
+ rules.push(
+ `${sel} tr:not(:first-child):nth-child(odd) > * { background: var(--paper-2); }`,
+ );
+ }
+ return rules.join('\n');
+ })
+ .filter(Boolean)
+ .join('\n');
+}
+
+type Props = {
+ authors: Option[];
+ topics: Option[];
+ repoUrl: string;
+};
+
+function emptyMeta(defaultAuthor: string): PostMeta {
+ return {
+ title: '',
+ summary: '',
+ author: defaultAuthor,
+ writerName: '',
+ topicId: '',
+ topicName: '',
+ tags: [],
+ slug: '',
+ coverFileName: '',
+ };
+}
+
+export default function WritePortal({ authors, topics, repoUrl }: Props) {
+ const editor = useCreateBlockNote({ schema });
+ const [meta, setMeta] = useState(() => emptyMeta(authors[0]?.id ?? 'guest'));
+ const [tableVariants, setTableVariants] = useState>({});
+ const [currentTableId, setCurrentTableId] = useState(null);
+ const [barPos, setBarPos] = useState<{ top: number; left: number } | null>(null);
+ const [restore, setRestore] = useState(null);
+ const [issues, setIssues] = useState([]);
+ const [storageOff, setStorageOff] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [siteTheme, setSiteTheme] = useState<'light' | 'dark'>('light');
+
+ const variantCss = useMemo(() => tableVariantCss(tableVariants), [tableVariants]);
+
+ useEffect(() => {
+ const root = document.documentElement;
+ const read = () => setSiteTheme(root.getAttribute('data-theme') === 'dark' ? 'dark' : 'light');
+ read();
+ const obs = new MutationObserver(read);
+ obs.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
+ return () => obs.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const draft = loadDraft();
+ if (draft) setRestore({ savedAt: draft.savedAt });
+ }, []);
+
+ useEffect(() => {
+ if (!currentTableId) {
+ setBarPos(null);
+ return;
+ }
+ const update = () => {
+ const el = document.querySelector(
+ `.bn-block-outer[data-id="${currentTableId}"]`,
+ );
+ if (!el) {
+ setBarPos(null);
+ return;
+ }
+ const r = el.getBoundingClientRect();
+ const above = r.top - 52;
+ setBarPos({ top: above < 76 ? r.bottom + 10 : above, left: r.left });
+ };
+ update();
+ window.addEventListener('scroll', update, true);
+ window.addEventListener('resize', update);
+ return () => {
+ window.removeEventListener('scroll', update, true);
+ window.removeEventListener('resize', update);
+ };
+ }, [currentTableId]);
+
+ const getDraft = useCallback(
+ () => ({
+ meta,
+ blocks: editor.document as unknown as SBlock[],
+ tableVariants,
+ savedAt: Date.now(),
+ }),
+ [editor, meta, tableVariants],
+ );
+
+ const autosave = useCallback(() => {
+ if (restore) return;
+ saveDraftDebounced(getDraft, () => setStorageOff(true));
+ }, [getDraft, restore]);
+
+ useEditorSelectionChange(() => {
+ try {
+ const sel = editor.getSelection();
+ if (sel && sel.blocks.length !== 1) {
+ setCurrentTableId(null);
+ return;
+ }
+ const block = editor.getTextCursorPosition().block;
+ setCurrentTableId(block?.type === 'table' ? block.id : null);
+ } catch {
+ setCurrentTableId(null);
+ }
+ }, editor);
+
+ const acceptRestore = async () => {
+ const draft = loadDraft();
+ if (draft) {
+ await restoreAssets().catch(() => setStorageOff(true));
+ editor.replaceBlocks(editor.document, draft.blocks as never);
+ setMeta(draft.meta);
+ setTableVariants(draft.tableVariants ?? {});
+ }
+ setRestore(null);
+ };
+
+ const discardRestore = async () => {
+ clearDraft();
+ await clearStoredAssets().catch(() => undefined);
+ setRestore(null);
+ };
+
+ const download = async () => {
+ const blocks = editor.document as unknown as SBlock[];
+ const found = validate(meta, blocks);
+ setIssues(found);
+ if (found.length > 0) return;
+ setBusy(true);
+ try {
+ const serialized = serializePost(meta, blocks, { tableVariants, today: new Date() });
+ const blob = await buildZip({
+ serialized,
+ slug: meta.slug,
+ writerName: meta.writerName,
+ repoUrl,
+ assets: allAssets(),
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${meta.slug}.zip`;
+ a.click();
+ URL.revokeObjectURL(url);
+ clearDraft();
+ await clearStoredAssets().catch(() => undefined);
+ } catch {
+ setIssues(['Something went wrong while packaging your post. Please try downloading again.']);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const slashItems = useMemo(() => getSlashItems(editor), [editor]);
+
+ const handleSelectAll = (e: ReactKeyboardEvent) => {
+ if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'a' || e.shiftKey || e.altKey) return;
+ const view = editor.prosemirrorView;
+ if (!view) return;
+ const { state } = view;
+ const { $from } = state.selection;
+ const blockStart = $from.start($from.depth);
+ const blockEnd = $from.end($from.depth);
+ e.preventDefault();
+ e.stopPropagation();
+ const coversBlock =
+ !state.selection.empty &&
+ state.selection.from <= blockStart &&
+ state.selection.to >= blockEnd;
+ const next =
+ blockStart === blockEnd || coversBlock
+ ? new AllSelection(state.doc)
+ : TextSelection.create(state.doc, blockStart, blockEnd);
+ view.dispatch(state.tr.setSelection(next));
+ view.focus();
+ };
+
+ return (
+
+
{
+ setMeta(m);
+ autosave();
+ }}
+ />
+
+ {currentTableId &&
+ barPos &&
+ (() => {
+ const style = tableVariants[currentTableId] ?? DEFAULT_TABLE_STYLE;
+ const setStyle = (patch: Partial) => {
+ setTableVariants((prev) => ({
+ ...prev,
+ [currentTableId]: { ...(prev[currentTableId] ?? DEFAULT_TABLE_STYLE), ...patch },
+ }));
+ autosave();
+ };
+ return (
+ e.preventDefault()}
+ >
+ Table
+ {BORDER_VARIANTS.map((v) => (
+ setStyle({ border: v })}
+ >
+ {v}
+
+ ))}
+
+ setStyle({ zebra: !style.zebra })}
+ >
+ Zebra rows
+
+
+ );
+ })()}
+
+ {restore && (
+
+
You have an unsaved draft from {new Date(restore.savedAt).toLocaleString()}.
+
+
+ Continue draft
+
+
+ Start fresh
+
+
+
+ )}
+
+
+
+
+ filterSuggestionItems(slashItems, query)}
+ />
+
+
+
+ {issues.length > 0 && (
+
+
Before you download, fix these:
+
+ {issues.map((issue) => (
+ {issue}
+ ))}
+
+
+ )}
+
+
+ {storageOff && (
+ Autosave is off — your browser blocked storage.
+ )}
+
+ {busy ? 'Packaging…' : 'Download post folder'}
+
+
+
+ );
+}
diff --git a/src/write/editor/blocks/ComponentBlock.tsx b/src/write/editor/blocks/ComponentBlock.tsx
new file mode 100644
index 0000000..dd64fd1
--- /dev/null
+++ b/src/write/editor/blocks/ComponentBlock.tsx
@@ -0,0 +1,51 @@
+import { createReactBlockSpec } from '@blocknote/react';
+import { COMPONENT_NAME_RE } from '../../serialize/validate';
+
+export const createComponentBlock = createReactBlockSpec(
+ {
+ type: 'customComponent',
+ propSchema: {
+ componentName: { default: '' },
+ source: { default: '' },
+ },
+ content: 'none',
+ },
+ {
+ render: ({ block, editor }) => {
+ const setProps = (patch: Partial) =>
+ editor.updateBlock(block, { props: { ...block.props, ...patch } });
+ const name = block.props.componentName;
+ const badName = name !== '' && !COMPONENT_NAME_RE.test(name);
+
+ return (
+
+
+ Custom React component — shows here as a placeholder, renders after publish
+
+ setProps({ componentName: e.target.value })}
+ />
+ {badName && (
+
+ Use PascalCase: letters and digits, starting with a capital letter.
+
+ )}
+
;\n}'}
+ value={block.props.source}
+ onChange={(e) => setProps({ source: e.target.value })}
+ />
+ {name && !badName && block.props.source && (
+ ⚙ {name}.tsx — ships with your post folder
+ )}
+
+ );
+ },
+ },
+);
diff --git a/src/write/editor/blocks/FigureBlock.tsx b/src/write/editor/blocks/FigureBlock.tsx
new file mode 100644
index 0000000..c57bcd6
--- /dev/null
+++ b/src/write/editor/blocks/FigureBlock.tsx
@@ -0,0 +1,134 @@
+import { useState } from 'react';
+import { createReactBlockSpec } from '@blocknote/react';
+import { addAsset, getAssetUrl, removeAsset } from '../../storage/assets';
+
+const SIZES: { key: string; label: string; width: number }[] = [
+ { key: 'small', label: 'Small', width: 360 },
+ { key: 'medium', label: 'Medium', width: 620 },
+ { key: 'large', label: 'Large', width: 960 },
+];
+
+function widthToKey(width: string | number): string {
+ const n = Number(width);
+ const match = SIZES.find((s) => s.width === n);
+ return match ? match.key : 'medium';
+}
+
+export const createFigureBlock = createReactBlockSpec(
+ {
+ type: 'figure',
+ propSchema: {
+ fileName: { default: '' },
+ src: { default: '' },
+ alt: { default: '' },
+ caption: { default: '' },
+ width: { default: 620 },
+ },
+ content: 'none',
+ },
+ {
+ render: ({ block, editor }) => {
+ const [url, setUrl] = useState('');
+ const [error, setError] = useState(false);
+ const setProps = (patch: Partial
) =>
+ editor.updateBlock(block, { props: { ...block.props, ...patch } });
+
+ const addUrl = () => {
+ const u = url.trim();
+ if (/^https?:\/\/\S+/i.test(u) || u.startsWith('data:image/')) setProps({ src: u });
+ else setError(true);
+ };
+
+ const hasImage = block.props.fileName || block.props.src;
+
+ if (!hasImage) {
+ return (
+
+
Image — upload a file or paste a URL
+
{
+ const file = e.target.files?.[0];
+ if (file) setProps({ fileName: addAsset(file) });
+ }}
+ />
+
+ {
+ setUrl(e.target.value);
+ setError(false);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && url.trim()) {
+ e.preventDefault();
+ addUrl();
+ }
+ }}
+ />
+
+ Add
+
+
+ {error && (
+
+ That doesn’t look like an image URL — it should start with https://.
+
+ )}
+
+ );
+ }
+
+ const previewSrc = block.props.fileName ? getAssetUrl(block.props.fileName) : block.props.src;
+ const activeKey = widthToKey(block.props.width);
+
+ return (
+
+
+
+
{
+ if (block.props.fileName) removeAsset(block.props.fileName);
+ setProps({ fileName: '', src: '' });
+ }}
+ >
+ ✕
+
+
+ setProps({ alt: e.target.value })}
+ />
+ setProps({ caption: e.target.value })}
+ />
+
+ {SIZES.map((s) => (
+ setProps({ width: s.width })}
+ >
+ {s.label}
+
+ ))}
+
+
+ );
+ },
+ },
+);
diff --git a/src/write/editor/blocks/GalleryBlock.tsx b/src/write/editor/blocks/GalleryBlock.tsx
new file mode 100644
index 0000000..12fbde9
--- /dev/null
+++ b/src/write/editor/blocks/GalleryBlock.tsx
@@ -0,0 +1,89 @@
+import { createReactBlockSpec } from '@blocknote/react';
+import { addAsset, getAssetUrl, removeAsset } from '../../storage/assets';
+
+function parseList(value: string): string[] {
+ try {
+ const parsed = JSON.parse(value || '[]');
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+export const createGalleryBlock = createReactBlockSpec(
+ {
+ type: 'gallery',
+ propSchema: {
+ fileNames: { default: '[]' },
+ alts: { default: '[]' },
+ min: { default: '' },
+ },
+ content: 'none',
+ },
+ {
+ render: ({ block, editor }) => {
+ const fileNames = parseList(block.props.fileNames);
+ const alts = parseList(block.props.alts);
+ const update = (names: string[], newAlts: string[]) =>
+ editor.updateBlock(block, {
+ props: {
+ ...block.props,
+ fileNames: JSON.stringify(names),
+ alts: JSON.stringify(newAlts),
+ },
+ });
+
+ return (
+
+
Gallery — images share a row
+
+ {fileNames.map((name, i) => (
+
+
+
+
{
+ removeAsset(name);
+ update(
+ fileNames.filter((_, j) => j !== i),
+ alts.filter((_, j) => j !== i),
+ );
+ }}
+ >
+ ✕
+
+
+
{
+ const next = [...alts];
+ next[i] = e.target.value;
+ update(fileNames, next);
+ }}
+ />
+
+ ))}
+
+
{
+ const files = [...(e.target.files ?? [])];
+ if (files.length === 0) return;
+ const added = files.map((f) => addAsset(f));
+ update([...fileNames, ...added], [...alts, ...added.map(() => '')]);
+ e.target.value = '';
+ }}
+ />
+
+ );
+ },
+ },
+);
diff --git a/src/write/editor/blocks/MathBlock.tsx b/src/write/editor/blocks/MathBlock.tsx
new file mode 100644
index 0000000..8477767
--- /dev/null
+++ b/src/write/editor/blocks/MathBlock.tsx
@@ -0,0 +1,58 @@
+import { useMemo, useState } from 'react';
+import { createReactBlockSpec } from '@blocknote/react';
+import katex from 'katex';
+
+export const createMathBlock = createReactBlockSpec(
+ {
+ type: 'math',
+ propSchema: {
+ latex: { default: '' },
+ },
+ content: 'none',
+ },
+ {
+ render: ({ block, editor }) => {
+ const [editing, setEditing] = useState(!block.props.latex);
+ const html = useMemo(
+ () =>
+ block.props.latex
+ ? katex.renderToString(block.props.latex, { displayMode: true, throwOnError: false })
+ : '',
+ [block.props.latex],
+ );
+
+ return (
+
+ {editing ? (
+
+ ) : (
+
setEditing(true)}
+ dangerouslySetInnerHTML={{ __html: html }}
+ />
+ )}
+
+ );
+ },
+ },
+);
diff --git a/src/write/editor/blocks/NoteBlock.tsx b/src/write/editor/blocks/NoteBlock.tsx
new file mode 100644
index 0000000..ba9348f
--- /dev/null
+++ b/src/write/editor/blocks/NoteBlock.tsx
@@ -0,0 +1,19 @@
+import { createReactBlockSpec } from '@blocknote/react';
+
+export const createNoteBlock = createReactBlockSpec(
+ {
+ type: 'note',
+ propSchema: {},
+ content: 'inline',
+ },
+ {
+ render: ({ contentRef }) => (
+
+ ),
+ },
+);
diff --git a/src/write/editor/blocks/SeparatorBlock.tsx b/src/write/editor/blocks/SeparatorBlock.tsx
new file mode 100644
index 0000000..fcb6930
--- /dev/null
+++ b/src/write/editor/blocks/SeparatorBlock.tsx
@@ -0,0 +1,16 @@
+import { createReactBlockSpec } from '@blocknote/react';
+
+export const createSeparatorBlock = createReactBlockSpec(
+ {
+ type: 'separator',
+ propSchema: {},
+ content: 'none',
+ },
+ {
+ render: () => (
+
+ · · ·
+
+ ),
+ },
+);
diff --git a/src/write/editor/blocks/VideoBlock.tsx b/src/write/editor/blocks/VideoBlock.tsx
new file mode 100644
index 0000000..dee952e
--- /dev/null
+++ b/src/write/editor/blocks/VideoBlock.tsx
@@ -0,0 +1,87 @@
+import { useState } from 'react';
+import { createReactBlockSpec } from '@blocknote/react';
+
+export function parseYouTubeId(input: string): string {
+ const s = input.trim();
+ if (/^[\w-]{11}$/.test(s)) return s;
+ const path = s.match(/(?:youtu\.be\/|\/embed\/|\/shorts\/|\/live\/)([\w-]{11})/);
+ if (path) return path[1];
+ const query = s.match(/[?&]v=([\w-]{11})/);
+ if (query && /youtube\.com/.test(s)) return query[1];
+ return '';
+}
+
+export const createVideoBlock = createReactBlockSpec(
+ {
+ type: 'video',
+ propSchema: {
+ videoId: { default: '' },
+ caption: { default: '' },
+ },
+ content: 'none',
+ },
+ {
+ render: ({ block, editor }) => {
+ const [url, setUrl] = useState('');
+ const [error, setError] = useState(false);
+ const setProps = (patch: Partial) =>
+ editor.updateBlock(block, { props: { ...block.props, ...patch } });
+
+ if (!block.props.videoId) {
+ return (
+
+
YouTube video
+
+ {
+ setUrl(e.target.value);
+ setError(false);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ const id = parseYouTubeId(url);
+ if (id) setProps({ videoId: id });
+ else setError(true);
+ }
+ }}
+ />
+ {
+ const id = parseYouTubeId(url);
+ if (id) setProps({ videoId: id });
+ else setError(true);
+ }}
+ >
+ Add
+
+
+ {error && (
+
That doesn’t look like a YouTube link.
+ )}
+
+ );
+ }
+
+ return (
+
+
+
+
▶ YouTube
+
+ setProps({ caption: e.target.value })}
+ />
+
+ );
+ },
+ },
+);
diff --git a/src/write/editor/editor-theme.css b/src/write/editor/editor-theme.css
new file mode 100644
index 0000000..16eeb3a
--- /dev/null
+++ b/src/write/editor/editor-theme.css
@@ -0,0 +1,550 @@
+.write-portal {
+ max-width: var(--article-w, 880px);
+ margin: 0 auto;
+ padding: 0 20px 120px;
+}
+
+.write-meta {
+ padding: 24px 0 12px;
+ border-bottom: 1px solid var(--line-2);
+ margin-bottom: 24px;
+}
+
+.write-title {
+ width: 100%;
+ border: none;
+ background: none;
+ color: var(--ink-1);
+ font-family: var(--font-sans, 'Geist', sans-serif);
+ font-weight: 700;
+ font-size: clamp(32px, 6vw, 56px);
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+ padding: 0;
+ outline: none;
+}
+
+.write-summary {
+ width: 100%;
+ border: none;
+ background: none;
+ resize: none;
+ color: var(--ink-2);
+ font-family: var(--font-sans, 'Geist', sans-serif);
+ font-size: clamp(15px, 2.5vw, 22px);
+ line-height: 1.4;
+ margin-top: 12px;
+ padding: 0;
+ outline: none;
+}
+
+.write-meta-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+ margin-top: 16px;
+}
+
+.write-meta-row label {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--ink-3);
+}
+
+.write-meta-row input,
+.write-meta-row select {
+ font-family: var(--font-sans, sans-serif);
+ font-size: 14px;
+ text-transform: none;
+ letter-spacing: normal;
+ color: var(--ink-1);
+ background: var(--paper-2, var(--paper));
+ border: 1px solid var(--line-2);
+ border-radius: 6px;
+ padding: 7px 10px;
+ min-width: 200px;
+}
+
+.write-tags {
+ flex: 1;
+}
+
+.write-tags-box {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ border: 1px solid var(--line-2);
+ border-radius: 6px;
+ padding: 5px 8px;
+ background: var(--paper-2, var(--paper));
+}
+
+.write-tags-box input {
+ border: none;
+ background: none;
+ min-width: 120px;
+ flex: 1;
+ padding: 2px;
+}
+
+.write-tag {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--accent);
+ background: var(--accent-soft);
+ border: none;
+ border-radius: 99px;
+ padding: 2px 8px;
+ cursor: pointer;
+}
+
+.write-banner {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
+ padding: 14px 18px;
+ border: 1px solid var(--line-2);
+ border-radius: 8px;
+ margin-bottom: 16px;
+ font-size: 14px;
+ justify-content: space-between;
+ background: var(--accent-soft);
+}
+
+.write-banner > div {
+ display: flex;
+ gap: 5px;
+ flex-shrink: 0;
+}
+
+.write-floating-bar {
+ position: fixed;
+ z-index: 50;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 10px;
+ border: 1px solid var(--line-2);
+ border-radius: 10px;
+ background: var(--paper);
+ box-shadow:
+ 0 4px 16px rgba(0, 0, 0, 0.12),
+ 0 1px 3px rgba(0, 0, 0, 0.08);
+ font-size: 14px;
+ animation: write-bar-in 0.12s ease-out;
+}
+
+@keyframes write-bar-in {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.write-banner button,
+.write-chip,
+.write-download {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ cursor: pointer;
+ border-radius: 6px;
+ padding: 6px 12px;
+ border: 1px solid var(--line-2);
+ background: var(--paper);
+ color: var(--ink-1);
+}
+
+.write-ghost,
+.write-chip {
+ background: none;
+}
+
+.write-chip.is-active {
+ background: var(--accent);
+ color: var(--paper);
+ border-color: var(--accent);
+}
+
+.write-floating-bar span:first-child {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--ink-3);
+}
+
+.write-table-divider {
+ width: 1px;
+ align-self: stretch;
+ background: var(--line-2);
+ margin: 0 4px;
+}
+
+.write-issues {
+ margin-top: 20px;
+ padding: 14px 18px;
+ border-left: 3px solid #c0392b;
+ background: rgba(192, 57, 43, 0.06);
+ border-radius: 0 6px 6px 0;
+ font-size: 14px;
+}
+
+.write-issues ul {
+ margin: 8px 0 0;
+ padding-left: 18px;
+}
+
+.write-actions {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 16px;
+ margin-top: 40px;
+ padding-top: 28px;
+ border-top: 1px solid var(--line-2);
+}
+
+.write-download {
+ background: var(--accent);
+ color: var(--paper);
+ border-color: var(--accent);
+ font-size: 13px;
+ padding: 10px 20px;
+}
+
+.write-download:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+
+.write-note-inline {
+ font-size: 12px;
+ color: var(--ink-3);
+}
+
+.write-canvas {
+ position: relative;
+ border: 1px solid var(--line-2);
+ border-radius: 12px;
+ padding: 18px 20px;
+ margin-top: 8px;
+ background: var(--paper);
+ min-height: 320px;
+}
+
+@media (min-width: 1040px) {
+ .write-canvas {
+ margin-inline: -24px;
+ padding-inline: 24px;
+ }
+ .write-canvas::before {
+ left: 24px;
+ }
+}
+
+.write-canvas::before {
+ content: 'Post';
+ position: absolute;
+ top: -9px;
+ left: 20px;
+ padding: 0 8px;
+ background: var(--paper);
+ font-family: var(--font-mono);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--ink-3);
+}
+
+.bn-container,
+.bn-editor {
+ --bn-font-family: var(--font-read, 'Source Serif 4', serif);
+ background: transparent;
+ color: var(--ink-body, var(--ink-1));
+}
+
+.bn-side-menu .mantine-UnstyledButton-root:not(.mantine-Menu-item) svg {
+ color: var(--ink-3);
+}
+
+.bn-side-menu .mantine-UnstyledButton-root:hover {
+ background-color: var(--paper-3);
+ border-radius: 6px;
+}
+
+.bn-side-menu button:has([data-test='dragHandleAdd']) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ height: 34px;
+ border: 1.5px solid var(--line-2);
+ border-radius: 50%;
+ transition:
+ border-color 0.12s ease,
+ background 0.12s ease;
+}
+
+.bn-side-menu button:has([data-test='dragHandleAdd']):hover {
+ border-color: var(--ink-3);
+ background: var(--paper-2, var(--paper));
+}
+
+.bn-editor {
+ font-family: var(--font-read, 'Source Serif 4', serif);
+ font-size: clamp(18px, 1.7vw, 20px);
+ line-height: 1.65;
+ padding-inline: 0;
+}
+
+.bn-editor
+ .bn-block-content:is([data-content-type='paragraph'], [data-content-type='heading']):has(
+ > .bn-inline-content > br.ProseMirror-trailingBreak:only-child
+ ) {
+ border-radius: 5px;
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--line-2) 45%, transparent);
+ background: color-mix(in srgb, var(--ink-1) 2%, transparent);
+}
+
+.bn-block-content h1,
+.bn-block-content h2,
+.bn-block-content h3 {
+ font-family: var(--font-sans, 'Geist', sans-serif);
+ font-weight: 600;
+ color: var(--ink-1);
+}
+
+.write-block-form,
+.write-figure,
+.write-gallery,
+.write-video,
+.write-math,
+.write-component {
+ border: 1px solid var(--line-2);
+ border-radius: 8px;
+ padding: 14px;
+ margin: 6px 0;
+ background: var(--paper-2, var(--paper));
+ font-family: var(--font-sans, sans-serif);
+}
+
+.write-block-label {
+ display: block;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--ink-3);
+ margin-bottom: 8px;
+}
+
+.write-block-row {
+ display: flex;
+ gap: 8px;
+}
+
+.write-block-form input[type='text'],
+.write-block-form textarea,
+.write-component input,
+.write-component textarea,
+.write-alt-input,
+.write-caption-input {
+ width: 100%;
+ border: 1px solid var(--line-2);
+ border-radius: 6px;
+ padding: 7px 10px;
+ font-family: inherit;
+ font-size: 14px;
+ background: var(--paper);
+ color: var(--ink-1);
+}
+
+.write-component textarea,
+.write-math textarea {
+ font-family: var(--font-mono);
+ font-size: 13px;
+}
+
+.write-input-invalid {
+ border-color: #c0392b;
+}
+
+.write-block-error {
+ display: block;
+ color: #c0392b;
+ font-size: 12px;
+ margin-top: 6px;
+}
+
+.write-figure-frame {
+ position: relative;
+}
+
+.write-figure-frame img,
+.write-video-thumb img {
+ width: 100%;
+ border-radius: 6px;
+ display: block;
+}
+
+.write-remove {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ border: none;
+ border-radius: 99px;
+ width: 24px;
+ height: 24px;
+ cursor: pointer;
+ background: rgba(0, 0, 0, 0.6);
+ color: #fff;
+}
+
+.write-alt-input,
+.write-caption-input {
+ margin-top: 8px;
+}
+
+.write-size-toggle {
+ display: flex;
+ gap: 6px;
+ margin-top: 10px;
+}
+
+.write-gallery-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 12px;
+ margin: 8px 0;
+}
+
+.write-video-thumb {
+ position: relative;
+}
+
+.write-video-badge {
+ position: absolute;
+ left: 10px;
+ bottom: 10px;
+ background: rgba(0, 0, 0, 0.75);
+ color: #fff;
+ font-size: 12px;
+ padding: 3px 8px;
+ border-radius: 4px;
+}
+
+.write-separator {
+ text-align: center;
+ letter-spacing: 0.5em;
+ color: var(--ink-3);
+ padding: 12px 0;
+}
+
+.write-note {
+ border-left: 2px solid var(--accent);
+ background: var(--accent-soft);
+ border-radius: 0 6px 6px 0;
+ padding: 12px 16px;
+ margin: 6px 0;
+}
+
+.write-note-label {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--accent);
+ margin-bottom: 4px;
+}
+
+.write-math-preview,
+.write-math-rendered {
+ margin-top: 10px;
+ padding: 10px;
+ text-align: center;
+ background: none;
+ border: none;
+ width: 100%;
+ cursor: pointer;
+}
+
+.write-component-card {
+ margin-top: 10px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--ink-3);
+ padding: 8px 12px;
+ border: 1px dashed var(--line-2);
+ border-radius: 6px;
+}
+
+.bn-editor [data-content-type='table'] table {
+ border-collapse: collapse;
+ font-family: var(--font-sans, sans-serif);
+ font-size: 15px;
+ line-height: 1.5;
+}
+.bn-editor [data-content-type='table'] :is(td, th) {
+ border: 1px dashed color-mix(in srgb, var(--line-2) 55%, transparent);
+ border-bottom: 1px solid var(--line);
+ padding: 10px 16px;
+ color: var(--ink);
+ text-align: left;
+ vertical-align: top;
+}
+.bn-editor [data-content-type='table'] tr:first-child > * {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--ink-3);
+ border-bottom: 1.5px solid var(--line-2);
+}
+.bn-editor [data-content-type='table'] tr:last-child > * {
+ border-bottom: none;
+}
+
+.bn-editor .bn-block-content[data-content-type='codeBlock'] {
+ background: #24292e;
+ color: #e1e4e8;
+ border: 1px solid #3a3f46;
+ border-radius: 8px;
+}
+.bn-editor .bn-block-content[data-content-type='codeBlock'] > pre {
+ padding: 36px 18px 16px;
+ font-family: var(--font-mono);
+ font-size: 13px;
+ line-height: 1.55;
+}
+.bn-editor .bn-block-content[data-content-type='codeBlock'] > div > select {
+ opacity: 1;
+ transition: none;
+ top: 8px;
+ left: 12px;
+ padding: 2px 6px;
+ border-radius: 5px;
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.16);
+ color: #e1e4e8;
+ font-family: var(--font-mono);
+ font-size: 11px;
+}
+.bn-editor .bn-block-content[data-content-type='codeBlock'] > div > select > option {
+ color: #1a1a1a;
+}
+.bn-editor .bn-block-content[data-content-type='codeBlock']:hover > div > select,
+.bn-editor .bn-block-content[data-content-type='codeBlock'] > div > select:focus {
+ opacity: 1;
+}
diff --git a/src/write/editor/schema.ts b/src/write/editor/schema.ts
new file mode 100644
index 0000000..4b2aae2
--- /dev/null
+++ b/src/write/editor/schema.ts
@@ -0,0 +1,24 @@
+import { BlockNoteSchema, createCodeBlockSpec } from '@blocknote/core';
+import { codeBlockOptions } from '@blocknote/code-block';
+import { createVideoBlock } from './blocks/VideoBlock';
+import { createNoteBlock } from './blocks/NoteBlock';
+import { createSeparatorBlock } from './blocks/SeparatorBlock';
+import { createMathBlock } from './blocks/MathBlock';
+import { createFigureBlock } from './blocks/FigureBlock';
+import { createGalleryBlock } from './blocks/GalleryBlock';
+import { createComponentBlock } from './blocks/ComponentBlock';
+
+export const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ codeBlock: createCodeBlockSpec(codeBlockOptions),
+ video: createVideoBlock(),
+ note: createNoteBlock(),
+ separator: createSeparatorBlock(),
+ math: createMathBlock(),
+ figure: createFigureBlock(),
+ gallery: createGalleryBlock(),
+ customComponent: createComponentBlock(),
+ },
+});
+
+export type WriteEditor = typeof schema.BlockNoteEditor;
diff --git a/src/write/editor/slashMenu.tsx b/src/write/editor/slashMenu.tsx
new file mode 100644
index 0000000..7cb7199
--- /dev/null
+++ b/src/write/editor/slashMenu.tsx
@@ -0,0 +1,159 @@
+import { insertOrUpdateBlockForSlashMenu } from '@blocknote/core';
+import { getDefaultReactSlashMenuItems } from '@blocknote/react';
+import type { WriteEditor } from './schema';
+
+const HIDDEN = [
+ 'image',
+ 'video',
+ 'audio',
+ 'file',
+ 'check list',
+ 'toggle',
+ 'emoji',
+ 'table',
+ 'divider',
+ 'page',
+];
+
+const GROUP_ORDER = ['Headings', 'Subheadings', 'Basic blocks', 'Media', 'Advanced'];
+
+function groupRank(group: string | undefined): number {
+ const i = GROUP_ORDER.indexOf(group ?? '');
+ return i === -1 ? GROUP_ORDER.length : i;
+}
+
+const EMPTY_3X3 = {
+ type: 'tableContent' as const,
+ rows: [{ cells: ['', '', ''] }, { cells: ['', '', ''] }, { cells: ['', '', ''] }],
+};
+
+const svg = (children: React.ReactNode) => (
+
+ {children}
+
+);
+
+const ICONS = {
+ image: svg(
+ <>
+
+
+
+ >,
+ ),
+ gallery: svg(
+ <>
+
+
+
+
+ >,
+ ),
+ video: svg(
+ <>
+
+
+ >,
+ ),
+ note: svg( ),
+ divider: svg(
+ <>
+
+
+
+ >,
+ ),
+ math: (
+ ∑
+ ),
+ component: svg(
+ <>
+
+
+ >,
+ ),
+};
+
+export function getSlashItems(editor: WriteEditor) {
+ const defaults = getDefaultReactSlashMenuItems(editor).filter(
+ (item) => !HIDDEN.some((h) => item.title.toLowerCase().includes(h)),
+ );
+
+ const custom = [
+ {
+ title: 'Image',
+ subtext: 'Upload an image or paste a URL',
+ aliases: ['image', 'figure', 'photo', 'picture'],
+ group: 'Media',
+ icon: ICONS.image,
+ onItemClick: () => insertOrUpdateBlockForSlashMenu(editor, { type: 'figure' }),
+ },
+ {
+ title: 'Gallery',
+ subtext: 'Several images side by side',
+ aliases: ['gallery', 'images', 'grid'],
+ group: 'Media',
+ icon: ICONS.gallery,
+ onItemClick: () => insertOrUpdateBlockForSlashMenu(editor, { type: 'gallery' }),
+ },
+ {
+ title: 'Video',
+ subtext: 'Embed a YouTube video',
+ aliases: ['video', 'youtube', 'embed'],
+ group: 'Media',
+ icon: ICONS.video,
+ onItemClick: () => insertOrUpdateBlockForSlashMenu(editor, { type: 'video' }),
+ },
+ {
+ title: 'Table',
+ subtext: 'A 3×3 table — the first row is the header',
+ aliases: ['table', 'grid', 'rows', 'columns'],
+ group: 'Basic blocks',
+ onItemClick: () =>
+ insertOrUpdateBlockForSlashMenu(editor, { type: 'table', content: EMPTY_3X3 }),
+ },
+ {
+ title: 'Note',
+ subtext: 'A callout the reader should not miss',
+ aliases: ['note', 'callout', 'aside'],
+ group: 'Basic blocks',
+ icon: ICONS.note,
+ onItemClick: () => insertOrUpdateBlockForSlashMenu(editor, { type: 'note' }),
+ },
+ {
+ title: 'Divider',
+ subtext: 'A · · · section break',
+ aliases: ['divider', 'separator', 'hr', 'rule', 'break'],
+ group: 'Basic blocks',
+ icon: ICONS.divider,
+ onItemClick: () => insertOrUpdateBlockForSlashMenu(editor, { type: 'separator' }),
+ },
+ {
+ title: 'Math equation',
+ subtext: 'A display equation written in LaTeX',
+ aliases: ['math', 'latex', 'equation', 'katex'],
+ group: 'Basic blocks',
+ icon: ICONS.math,
+ onItemClick: () => insertOrUpdateBlockForSlashMenu(editor, { type: 'math' }),
+ },
+ {
+ title: 'Custom component',
+ subtext: 'Attach React code that renders after publish',
+ aliases: ['component', 'react', 'tsx', 'advanced'],
+ group: 'Advanced',
+ icon: ICONS.component,
+ onItemClick: () => insertOrUpdateBlockForSlashMenu(editor, { type: 'customComponent' }),
+ },
+ ];
+
+ return [...defaults, ...custom].sort((a, b) => groupRank(a.group) - groupRank(b.group));
+}
diff --git a/src/write/meta/MetaForm.tsx b/src/write/meta/MetaForm.tsx
new file mode 100644
index 0000000..f73ec7b
--- /dev/null
+++ b/src/write/meta/MetaForm.tsx
@@ -0,0 +1,132 @@
+import { useState } from 'react';
+import type { PostMeta } from '../serialize/toMdx';
+import { slugify } from '../serialize/validate';
+
+export type Option = { id: string; name: string };
+
+type Props = {
+ authors: Option[];
+ topics: Option[];
+ meta: PostMeta;
+ onChange: (meta: PostMeta) => void;
+};
+
+export function MetaForm({ authors, topics, meta, onChange }: Props) {
+ const [slugTouched, setSlugTouched] = useState(false);
+ const [tagInput, setTagInput] = useState('');
+
+ const set = (patch: Partial) => onChange({ ...meta, ...patch });
+
+ const slugLocked = slugTouched || (!!meta.slug && meta.slug !== slugify(meta.title));
+
+ const addTag = () => {
+ const tag = tagInput.trim().toLowerCase().replace(/^#/, '');
+ if (tag && !meta.tags.includes(tag)) set({ tags: [...meta.tags, tag] });
+ setTagInput('');
+ };
+
+ return (
+
+ );
+}
diff --git a/src/write/serialize/toMdx.test.ts b/src/write/serialize/toMdx.test.ts
new file mode 100644
index 0000000..03e26d7
--- /dev/null
+++ b/src/write/serialize/toMdx.test.ts
@@ -0,0 +1,418 @@
+import { describe, expect, it } from 'vitest';
+import {
+ escapeText,
+ serializeInline,
+ serializePost,
+ type InlineRun,
+ type PostMeta,
+ type SBlock,
+ type TableStyle,
+} from './toMdx';
+
+const t = (text: string, styles: Record = {}): InlineRun => ({
+ type: 'text',
+ text,
+ styles,
+});
+
+const block = (
+ type: string,
+ props: Record = {},
+ content?: unknown,
+ children?: SBlock[],
+): SBlock => ({
+ id: `id-${type}-${Math.random().toString(36).slice(2, 8)}`,
+ type,
+ props,
+ content,
+ children,
+});
+
+const meta: PostMeta = {
+ title: 'Test post',
+ summary: 'A summary.',
+ author: 'guest',
+ writerName: '',
+ topicId: 'inference',
+ topicName: 'Inference & Serving',
+ tags: [],
+ slug: 'test-post',
+ coverFileName: '',
+};
+
+const today = new Date('2026-07-12T10:00:00Z');
+
+function body(
+ blocks: SBlock[],
+ overrides: Partial = {},
+ tableVariants: Record = {},
+) {
+ const { mdx } = serializePost({ ...meta, ...overrides }, blocks, { tableVariants, today });
+ return mdx.split('---\n\n').slice(1).join('---\n\n').trimEnd();
+}
+
+describe('escapeText', () => {
+ it('escapes MDX-breaking characters', () => {
+ expect(escapeText('a < b {c} *d* _e_ `f` [g] \\h')).toBe(
+ 'a \\< b \\{c} \\*d\\* \\_e\\_ \\`f\\` \\[g] \\\\h',
+ );
+ });
+
+ it('escapes leading blockquote and heading markers', () => {
+ expect(escapeText('> not a quote')).toBe('\\> not a quote');
+ expect(escapeText('# not a heading')).toBe('\\# not a heading');
+ });
+
+ it('leaves dollar signs alone so inline math passes through', () => {
+ expect(escapeText('the scale is $1/\\sqrt{d_k}$')).toContain('$');
+ });
+});
+
+describe('serializeInline', () => {
+ it('renders styles', () => {
+ expect(
+ serializeInline([t('plain '), t('bold', { bold: true }), t(' '), t('it', { italic: true })]),
+ ).toBe('plain **bold** _it_');
+ });
+
+ it('combines bold and italic', () => {
+ expect(serializeInline([t('both', { bold: true, italic: true })])).toBe('_**both**_');
+ });
+
+ it('renders code spans without escaping', () => {
+ expect(serializeInline([t('q @ k.T * 2', { code: true })])).toBe('`q @ k.T * 2`');
+ });
+
+ it('handles backticks inside code spans', () => {
+ expect(serializeInline([t('a `tick`', { code: true })])).toBe('`` a `tick` ``');
+ });
+
+ it('renders links with styled content', () => {
+ expect(
+ serializeInline([
+ { type: 'link', href: 'https://example.com', content: [t('a '), t('b', { bold: true })] },
+ ]),
+ ).toBe('[a **b**](https://example.com)');
+ });
+
+ it('renders strikethrough', () => {
+ expect(serializeInline([t('gone', { strike: true })])).toBe('~~gone~~');
+ });
+
+ it('renders underline as a tag', () => {
+ expect(serializeInline([t('under', { underline: true })])).toBe('under ');
+ });
+
+ it('renders text color as a theme-aware color span, mapping named colors', () => {
+ expect(serializeInline([t('warn', { textColor: 'red' })])).toBe(
+ 'warn ',
+ );
+ });
+
+ it('renders highlight as a theme-aware background-color span', () => {
+ expect(serializeInline([t('hl', { backgroundColor: 'yellow' })])).toBe(
+ 'hl ',
+ );
+ });
+
+ it('ignores the default color', () => {
+ expect(serializeInline([t('plain', { textColor: 'default' })])).toBe('plain');
+ });
+
+ it('layers underline and color over markdown emphasis', () => {
+ expect(serializeInline([t('x', { bold: true, underline: true, textColor: 'blue' })])).toBe(
+ '**x** ',
+ );
+ });
+
+ it('wraps link destinations that contain parentheses in angle brackets', () => {
+ const run = {
+ type: 'link' as const,
+ href: 'https://en.wikipedia.org/wiki/GPT-4_(language_model)',
+ content: [{ type: 'text' as const, text: 'GPT-4', styles: {} }],
+ };
+ expect(serializeInline([run])).toBe(
+ '[GPT-4]()',
+ );
+ });
+
+ it('leaves plain destinations bare', () => {
+ const run = {
+ type: 'link' as const,
+ href: 'https://example.com/a',
+ content: [{ type: 'text' as const, text: 'x', styles: {} }],
+ };
+ expect(serializeInline([run])).toBe('[x](https://example.com/a)');
+ });
+
+ it('escapes leading markdown markers so plain text is not reinterpreted', () => {
+ expect(serializeInline([t('- not a list', {})])).toBe('\\- not a list');
+ expect(serializeInline([t('1. not numbered', {})])).toBe('1\\. not numbered');
+ expect(serializeInline([t('--- not a rule', {})])).toBe('\\--- not a rule');
+ });
+});
+
+describe('block serialization', () => {
+ it('maps heading levels down one (title is H1)', () => {
+ expect(body([block('heading', { level: 1 }, [t('Top')])])).toBe('## Top');
+ expect(body([block('heading', { level: 2 }, [t('Mid')])])).toBe('### Mid');
+ expect(body([block('heading', { level: 3 }, [t('Low')])])).toBe('#### Low');
+ });
+
+ it('groups consecutive list items and nests children', () => {
+ const out = body([
+ block('bulletListItem', {}, [t('one')]),
+ block('bulletListItem', {}, [t('two')], [block('bulletListItem', {}, [t('sub')])]),
+ block('paragraph', {}, [t('after')]),
+ ]);
+ expect(out).toBe('- one\n- two\n - sub\n\nafter');
+ });
+
+ it('numbers ordered lists sequentially', () => {
+ const out = body([
+ block('numberedListItem', {}, [t('first')]),
+ block('numberedListItem', {}, [t('second')]),
+ ]);
+ expect(out).toBe('1. first\n2. second');
+ });
+
+ it('renders quotes', () => {
+ expect(body([block('quote', {}, [t('wise words')])])).toBe('> wise words');
+ });
+
+ it('renders fenced code blocks without escaping', () => {
+ const out = body([
+ block('codeBlock', { language: 'python' }, [t('def f(x):\n return x * 2')]),
+ ]);
+ expect(out).toBe('```python\ndef f(x):\n return x * 2\n```');
+ });
+
+ it('renders separators', () => {
+ expect(body([block('separator')])).toBe('---');
+ });
+
+ it('renders math blocks', () => {
+ expect(body([block('math', { latex: 'E = mc^2' })])).toBe('$$\nE = mc^2\n$$');
+ });
+
+ it('skips empty paragraphs', () => {
+ expect(body([block('paragraph', {}, []), block('paragraph', {}, [t('kept')])])).toBe('kept');
+ });
+
+ it('wraps centered blocks in an aligned div', () => {
+ expect(body([block('paragraph', { textAlignment: 'center' }, [t('mid')])])).toBe(
+ '\n\nmid\n\n
',
+ );
+ });
+
+ it('centers headings too', () => {
+ expect(body([block('heading', { level: 1, textAlignment: 'center' }, [t('Title')])])).toBe(
+ '\n\n## Title\n\n
',
+ );
+ });
+
+ it('leaves left-aligned blocks unwrapped', () => {
+ expect(body([block('paragraph', { textAlignment: 'left' }, [t('normal')])])).toBe('normal');
+ });
+});
+
+describe('video blocks', () => {
+ it('renders with caption', () => {
+ expect(body([block('video', { videoId: 'dQw4w9WgXcQ', caption: 'On TDD.' })])).toBe(
+ ' ',
+ );
+ });
+
+ it('omits empty caption', () => {
+ expect(body([block('video', { videoId: 'dQw4w9WgXcQ', caption: '' })])).toBe(
+ ' ',
+ );
+ });
+
+ it('uses an expression for captions containing double quotes', () => {
+ expect(body([block('video', { videoId: 'abcdefghijk', caption: 'He said "hi"' })])).toBe(
+ ' ',
+ );
+ });
+});
+
+describe('note blocks', () => {
+ it('renders inline content inside Note', () => {
+ expect(body([block('note', {}, [t('do not '), t('miss', { bold: true })])])).toBe(
+ 'do not **miss** ',
+ );
+ });
+});
+
+describe('figures and galleries', () => {
+ it('hoists imports and wraps captioned figures', () => {
+ const { mdx, assetNames } = serializePost(
+ meta,
+ [
+ block('figure', {
+ fileName: 'flash-attention.png',
+ alt: 'Tiling',
+ caption: 'HBM traffic.',
+ width: 900,
+ }),
+ ],
+ { tableVariants: {}, today },
+ );
+ expect(mdx).toContain("import { Image } from 'astro:assets';");
+ expect(mdx).toContain("import flashAttention from './flash-attention.png';");
+ expect(mdx).toContain(
+ '\n \n ',
+ );
+ expect(assetNames).toEqual(['flash-attention.png']);
+ });
+
+ it('wraps in Figure without a caption attribute when caption is empty', () => {
+ const out = body([block('figure', { fileName: 'a.png', alt: 'A', caption: '', width: '' })]);
+ expect(out).toContain('\n \n ');
+ });
+
+ it('emits a width when a size is set', () => {
+ const out = body([block('figure', { fileName: 'a.png', alt: 'A', caption: '', width: 620 })]);
+ expect(out).toContain('\n \n ');
+ });
+
+ it('renders a URL image as a plain img with no astro import', () => {
+ const { mdx } = serializePost(
+ meta,
+ [
+ block('figure', {
+ src: 'https://example.com/x.png',
+ alt: 'Remote',
+ caption: '',
+ width: 360,
+ }),
+ ],
+ { tableVariants: {}, today },
+ );
+ expect(mdx).not.toContain("import { Image } from 'astro:assets';");
+ expect(mdx).toContain(
+ '\n \n ',
+ );
+ });
+
+ it('dedupes import identifiers', () => {
+ const out = body([
+ block('figure', { fileName: 'a-b.png', alt: 'x', caption: '', width: '' }),
+ block('figure', { fileName: 'a_b.png', alt: 'y', caption: '', width: '' }),
+ ]);
+ expect(out).toContain("import aB from './a-b.png';");
+ expect(out).toContain("import aB2 from './a_b.png';");
+ });
+
+ it('renders galleries with min', () => {
+ const out = body([
+ block('gallery', {
+ fileNames: JSON.stringify(['one.png', 'two.png']),
+ alts: JSON.stringify(['One', 'Two']),
+ min: 160,
+ }),
+ ]);
+ expect(out).toContain(
+ '\n \n \n ',
+ );
+ });
+});
+
+describe('custom component blocks', () => {
+ it('hoists the import, emits usage, and returns the file', () => {
+ const source = 'export default function Viz() {\n return hi
;\n}';
+ const { mdx, componentFiles } = serializePost(
+ meta,
+ [block('customComponent', { componentName: 'Viz', source })],
+ { tableVariants: {}, today },
+ );
+ expect(mdx).toContain("import Viz from './Viz';");
+ expect(mdx).toContain(' ');
+ expect(componentFiles).toEqual([{ fileName: 'Viz.tsx', source }]);
+ });
+});
+
+describe('tables', () => {
+ const tableContent = {
+ type: 'tableContent',
+ rows: [{ cells: [[t('Model')], [t('Params')]] }, { cells: [[t('7B')], [t('7 | 8')]] }],
+ };
+
+ it('renders pipe tables with escaped pipes', () => {
+ const out = body([block('table', {}, tableContent)]);
+ expect(out).toBe('| Model | Params |\n| --- | --- |\n| 7B | 7 \\| 8 |');
+ });
+
+ it('wraps in Table with a border variant', () => {
+ const tb = block('table', {}, tableContent);
+ const out = body([tb], {}, { [tb.id]: { border: 'lined', zebra: false } });
+ expect(out).toBe(
+ '\n\n| Model | Params |\n| --- | --- |\n| 7B | 7 \\| 8 |\n\n
',
+ );
+ });
+
+ it('emits zebra as a standalone attribute over the default border', () => {
+ const tb = block('table', {}, tableContent);
+ const out = body([tb], {}, { [tb.id]: { border: 'rule', zebra: true } });
+ expect(out).toBe(
+ '\n\n| Model | Params |\n| --- | --- |\n| 7B | 7 \\| 8 |\n\n
',
+ );
+ });
+
+ it('combines a border variant with zebra', () => {
+ const tb = block('table', {}, tableContent);
+ const out = body([tb], {}, { [tb.id]: { border: 'lined', zebra: true } });
+ expect(out).toBe(
+ '\n\n| Model | Params |\n| --- | --- |\n| 7B | 7 \\| 8 |\n\n
',
+ );
+ });
+
+ it('does not wrap when style is the plain default (rule, no zebra)', () => {
+ const tb = block('table', {}, tableContent);
+ const out = body([tb], {}, { [tb.id]: { border: 'rule', zebra: false } });
+ expect(out).toBe('| Model | Params |\n| --- | --- |\n| 7B | 7 \\| 8 |');
+ });
+
+ it('supports object-shaped cells', () => {
+ const objTable = {
+ type: 'tableContent',
+ rows: [
+ { cells: [{ type: 'tableCell', content: [t('H')] }] },
+ { cells: [{ type: 'tableCell', content: [t('v')] }] },
+ ],
+ };
+ expect(body([block('table', {}, objTable)])).toBe('| H |\n| --- |\n| v |');
+ });
+});
+
+describe('frontmatter', () => {
+ it('emits required fields with quoting and computed values', () => {
+ const words = Array.from({ length: 440 }, (_, i) => `w${i}`).join(' ');
+ const { mdx } = serializePost(
+ { ...meta, title: "It's alive", tags: ['attention', 'kernels'], coverFileName: 'hero.png' },
+ [block('paragraph', {}, [t(words)])],
+ { tableVariants: {}, today },
+ );
+ const fm = mdx.split('---')[1];
+ expect(fm).toContain("title: 'It''s alive'");
+ expect(fm).toContain("summary: 'A summary.'");
+ expect(fm).toContain("authors: ['guest']");
+ expect(fm).toContain("date: '2026-07-12'");
+ expect(fm).toContain('readMin: 2');
+ expect(fm).toContain("topic: 'Inference & Serving'");
+ expect(fm).toContain("topicId: 'inference'");
+ expect(fm).toContain("tags: ['attention', 'kernels']");
+ expect(fm).toContain("cover: './hero.png'");
+ });
+
+ it('omits optional fields when unset and floors readMin at 1', () => {
+ const { mdx } = serializePost(meta, [block('paragraph', {}, [t('short')])], {
+ tableVariants: {},
+ today,
+ });
+ const fm = mdx.split('---')[1];
+ expect(fm).not.toContain('tags:');
+ expect(fm).not.toContain('cover:');
+ expect(fm).toContain('readMin: 1');
+ });
+});
diff --git a/src/write/serialize/toMdx.ts b/src/write/serialize/toMdx.ts
new file mode 100644
index 0000000..70ee58f
--- /dev/null
+++ b/src/write/serialize/toMdx.ts
@@ -0,0 +1,386 @@
+export type InlineRun =
+ | { type: 'text'; text: string; styles: Record }
+ | {
+ type: 'link';
+ href: string;
+ content: Array<{ type: 'text'; text: string; styles: Record }>;
+ };
+
+export type SBlock = {
+ id: string;
+ type: string;
+ props: Record;
+ content?: unknown;
+ children?: SBlock[];
+};
+
+export type PostMeta = {
+ title: string;
+ summary: string;
+ author: string;
+ writerName: string;
+ topicId: string;
+ topicName: string;
+ tags: string[];
+ slug: string;
+ coverFileName: string;
+};
+
+export type TableStyle = { border: 'rule' | 'lined' | 'plain'; zebra: boolean };
+
+export type SerializeOptions = {
+ tableVariants: Record;
+ today: Date;
+ wordsPerMinute?: number;
+};
+
+export type SerializedPost = {
+ mdx: string;
+ assetNames: string[];
+ componentFiles: { fileName: string; source: string }[];
+};
+
+type Ctx = {
+ imports: { ident: string; fileName: string }[];
+ idents: Set;
+ componentFiles: { fileName: string; source: string }[];
+ componentImports: string[];
+ tableVariants: Record;
+};
+
+export function escapeText(s: string): string {
+ return s
+ .replace(/[\\<{*_`[~]/g, (c) => `\\${c}`)
+ .replace(/^(\s{0,3})(\d+)([.)])/, '$1$2\\$3')
+ .replace(/^(\s{0,3})([>#+-])/, '$1\\$2');
+}
+
+const TEXT_COLORS: Record = {
+ gray: '#9b9a97',
+ brown: '#64473a',
+ red: '#e03e3e',
+ orange: '#d9730d',
+ yellow: '#cb912f',
+ green: '#448361',
+ blue: '#337ea9',
+ purple: '#9065b0',
+ pink: '#c14c8a',
+};
+
+const BG_COLORS: Record = {
+ gray: '#ebeced',
+ brown: '#e9e5e3',
+ red: '#fbe4e4',
+ orange: '#f6e9d9',
+ yellow: '#fbf3db',
+ green: '#ddedea',
+ blue: '#ddebf1',
+ purple: '#eae4f2',
+ pink: '#f4dfeb',
+};
+
+function wrapStyles(text: string, styles: Record): string {
+ let out: string;
+ if (styles.code) {
+ out = text.includes('`') ? `\`\` ${text} \`\`` : `\`${text}\``;
+ } else {
+ out = escapeText(text);
+ if (styles.bold) out = `**${out}**`;
+ if (styles.italic) out = `_${out}_`;
+ if (styles.strike) out = `~~${out}~~`;
+ }
+ if (styles.underline) out = `${out} `;
+ if (typeof styles.textColor === 'string' && styles.textColor && styles.textColor !== 'default') {
+ const name = styles.textColor;
+ const color = TEXT_COLORS[name] ? `var(--tc-${name}, ${TEXT_COLORS[name]})` : name;
+ out = `${out} `;
+ }
+ if (typeof styles.backgroundColor === 'string' && styles.backgroundColor !== 'default') {
+ const name = styles.backgroundColor;
+ const bg = BG_COLORS[name] ? `var(--mark-${name}, ${BG_COLORS[name]})` : name;
+ out = `${out} `;
+ }
+ return out;
+}
+
+export function serializeInline(content: unknown): string {
+ if (!Array.isArray(content)) return '';
+ return (content as InlineRun[])
+ .map((run) => {
+ if (run.type === 'link') {
+ const label = run.content.map((r) => wrapStyles(r.text, r.styles)).join('');
+ const dest = /[()\s]/.test(run.href) ? `<${run.href}>` : run.href;
+ return `[${label}](${dest})`;
+ }
+ if (run.type === 'text') return wrapStyles(run.text, run.styles);
+ return '';
+ })
+ .join('');
+}
+
+function rawText(content: unknown): string {
+ if (!Array.isArray(content)) return '';
+ return (content as InlineRun[])
+ .map((run) => {
+ if (run.type === 'link') return run.content.map((r) => r.text).join('');
+ if (run.type === 'text') return run.text;
+ return '';
+ })
+ .join('');
+}
+
+function attr(name: string, value: string): string {
+ if (value.includes('"')) return `${name}={${JSON.stringify(value)}}`;
+ return `${name}="${value}"`;
+}
+
+function identFor(fileName: string, ctx: Ctx): string {
+ const existing = ctx.imports.find((i) => i.fileName === fileName);
+ if (existing) return existing.ident;
+ const stem = fileName.replace(/\.[^.]+$/, '');
+ const parts = stem.split(/[^a-zA-Z0-9]+/).filter(Boolean);
+ let base = parts
+ .map((p, i) => (i === 0 ? p.toLowerCase() : p[0].toUpperCase() + p.slice(1)))
+ .join('');
+ if (!base || /^\d/.test(base)) base = `img${base}`;
+ let ident = base;
+ let n = 2;
+ while (ctx.idents.has(ident)) ident = `${base}${n++}`;
+ ctx.idents.add(ident);
+ ctx.imports.push({ ident, fileName });
+ return ident;
+}
+
+function imageLine(fileName: string, alt: string, ctx: Ctx): string {
+ return ` `;
+}
+
+function figureInner(block: SBlock, ctx: Ctx): string {
+ const alt = String(block.props.alt ?? '');
+ const fileName = String(block.props.fileName ?? '');
+ if (fileName) return imageLine(fileName, alt, ctx);
+ const src = String(block.props.src ?? '');
+ return ` `;
+}
+
+function indent(text: string, spaces: number): string {
+ const pad = ' '.repeat(spaces);
+ return text
+ .split('\n')
+ .map((l) => (l ? pad + l : l))
+ .join('\n');
+}
+
+function tableCells(row: unknown): unknown[] {
+ const cells = (row as { cells?: unknown[] }).cells;
+ return Array.isArray(cells) ? cells : [];
+}
+
+function cellContent(cell: unknown): unknown {
+ if (Array.isArray(cell)) return cell;
+ if (cell && typeof cell === 'object' && 'content' in cell) {
+ return (cell as { content: unknown }).content;
+ }
+ return [];
+}
+
+function serializeTable(block: SBlock, ctx: Ctx): string {
+ const rows = ((block.content as { rows?: unknown[] })?.rows ?? []) as unknown[];
+ if (rows.length === 0) return '';
+ const lines = rows.map(
+ (row) =>
+ `| ${tableCells(row)
+ .map((cell) => serializeInline(cellContent(cell)).replace(/\|/g, '\\|'))
+ .join(' | ')} |`,
+ );
+ const cols = tableCells(rows[0]).length;
+ lines.splice(1, 0, `| ${Array.from({ length: cols }, () => '---').join(' | ')} |`);
+ const table = lines.join('\n');
+ const style = ctx.tableVariants[block.id];
+ const border = style?.border ?? 'rule';
+ const zebra = style?.zebra ?? false;
+ if (border !== 'rule' || zebra) {
+ const attrs = [border !== 'rule' ? `variant="${border}"` : null, zebra ? 'zebra' : null]
+ .filter(Boolean)
+ .join(' ');
+ return ``;
+ }
+ return table;
+}
+
+function serializeFigure(block: SBlock, ctx: Ctx): string {
+ const fileName = String(block.props.fileName ?? '');
+ const src = String(block.props.src ?? '');
+ if (!fileName && !src) return '';
+ const image = figureInner(block, ctx);
+ const caption = String(block.props.caption ?? '');
+ const width = block.props.width;
+ const captionAttr = caption ? ` ${attr('caption', caption)}` : '';
+ const widthAttr = width !== '' && width != null ? ` width={${Number(width)}}` : '';
+ return `\n ${image}\n `;
+}
+
+function serializeGallery(block: SBlock, ctx: Ctx): string {
+ const fileNames = JSON.parse(String(block.props.fileNames || '[]')) as string[];
+ if (fileNames.length === 0) return '';
+ const alts = JSON.parse(String(block.props.alts || '[]')) as string[];
+ const min = block.props.min;
+ const minAttr = min !== '' && min != null ? ` min={${Number(min)}}` : '';
+ const images = fileNames.map((f, i) => ` ${imageLine(f, alts[i] ?? '', ctx)}`).join('\n');
+ return `\n${images}\n `;
+}
+
+function serializeComponent(block: SBlock, ctx: Ctx): string {
+ const name = String(block.props.componentName ?? '');
+ const source = String(block.props.source ?? '');
+ if (!name || !source) return '';
+ if (!ctx.componentFiles.some((c) => c.fileName === `${name}.tsx`)) {
+ ctx.componentFiles.push({ fileName: `${name}.tsx`, source });
+ ctx.componentImports.push(`import ${name} from './${name}';`);
+ }
+ return `<${name} client:visible />`;
+}
+
+function aligned(block: SBlock, s: string): string {
+ const a = block.props.textAlignment;
+ if (typeof a === 'string' && a !== 'left' && s) {
+ return `\n\n${s}\n\n
`;
+ }
+ return s;
+}
+
+function serializeBlock(block: SBlock, ctx: Ctx, listNumber: number): string {
+ switch (block.type) {
+ case 'paragraph':
+ return aligned(block, serializeInline(block.content));
+ case 'heading': {
+ const level = Math.min(Number(block.props.level ?? 1), 3);
+ return aligned(block, `${'#'.repeat(level + 1)} ${serializeInline(block.content)}`);
+ }
+ case 'bulletListItem':
+ case 'numberedListItem': {
+ const marker = block.type === 'bulletListItem' ? '- ' : `${listNumber}. `;
+ let out = marker + serializeInline(block.content);
+ if (block.children?.length) {
+ out += `\n${indent(serializeBlocks(block.children, ctx), marker.length)}`;
+ }
+ return out;
+ }
+ case 'quote':
+ return aligned(block, `> ${serializeInline(block.content)}`);
+ case 'codeBlock': {
+ const lang = String(block.props.language ?? '');
+ return `\`\`\`${lang}\n${rawText(block.content)}\n\`\`\``;
+ }
+ case 'separator':
+ return '---';
+ case 'math': {
+ const latex = String(block.props.latex ?? '');
+ return latex ? `$$\n${latex}\n$$` : '';
+ }
+ case 'video': {
+ const id = String(block.props.videoId ?? '');
+ if (!id) return '';
+ const caption = String(block.props.caption ?? '');
+ return caption ? ` ` : ` `;
+ }
+ case 'note': {
+ const inner = serializeInline(block.content);
+ return inner ? `${inner} ` : '';
+ }
+ case 'figure':
+ return serializeFigure(block, ctx);
+ case 'gallery':
+ return serializeGallery(block, ctx);
+ case 'customComponent':
+ return serializeComponent(block, ctx);
+ case 'table':
+ return serializeTable(block, ctx);
+ default:
+ return serializeInline(block.content);
+ }
+}
+
+function isListItem(type?: string): boolean {
+ return type === 'bulletListItem' || type === 'numberedListItem';
+}
+
+function serializeBlocks(blocks: SBlock[], ctx: Ctx): string {
+ const parts: string[] = [];
+ let listNumber = 0;
+ blocks.forEach((block, i) => {
+ listNumber = block.type === 'numberedListItem' ? listNumber + 1 : 0;
+ if (block.type === 'numberedListItem' && blocks[i - 1]?.type !== 'numberedListItem') {
+ listNumber = 1;
+ }
+ const s = serializeBlock(block, ctx, listNumber);
+ if (!s) return;
+ if (parts.length > 0 && isListItem(block.type) && isListItem(blocks[i - 1]?.type)) {
+ parts[parts.length - 1] += `\n${s}`;
+ } else {
+ parts.push(s);
+ }
+ });
+ return parts.join('\n\n');
+}
+
+function countWords(blocks: SBlock[]): number {
+ let words = 0;
+ for (const block of blocks) {
+ if (block.type !== 'codeBlock' && block.type !== 'math' && block.type !== 'customComponent') {
+ words += rawText(block.content).split(/\s+/).filter(Boolean).length;
+ }
+ if (block.children?.length) words += countWords(block.children);
+ }
+ return words;
+}
+
+function yaml(value: string): string {
+ return `'${value.replace(/'/g, "''")}'`;
+}
+
+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 lines = [
+ `title: ${yaml(meta.title)}`,
+ `summary: ${yaml(meta.summary)}`,
+ `authors: [${yaml(meta.author)}]`,
+ `date: ${yaml(opts.today.toISOString().slice(0, 10))}`,
+ `readMin: ${readMin}`,
+ `topic: ${yaml(meta.topicName)}`,
+ `topicId: ${yaml(meta.topicId)}`,
+ ];
+ 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---`;
+}
+
+export function serializePost(
+ meta: PostMeta,
+ blocks: SBlock[],
+ opts: SerializeOptions,
+): SerializedPost {
+ const ctx: Ctx = {
+ imports: [],
+ idents: new Set(),
+ componentFiles: [],
+ componentImports: [],
+ tableVariants: opts.tableVariants,
+ };
+ const body = serializeBlocks(blocks, ctx);
+ const importLines: string[] = [];
+ if (ctx.imports.length > 0) {
+ importLines.push(`import { Image } from 'astro:assets';`);
+ importLines.push(...ctx.imports.map((i) => `import ${i.ident} from './${i.fileName}';`));
+ }
+ importLines.push(...ctx.componentImports);
+ const sections = [buildFrontmatter(meta, blocks, opts)];
+ if (importLines.length > 0) sections.push(importLines.join('\n'));
+ if (body) sections.push(body);
+ return {
+ mdx: `${sections.join('\n\n')}\n`,
+ assetNames: ctx.imports.map((i) => i.fileName),
+ componentFiles: ctx.componentFiles,
+ };
+}
diff --git a/src/write/serialize/toZip.ts b/src/write/serialize/toZip.ts
new file mode 100644
index 0000000..71db643
--- /dev/null
+++ b/src/write/serialize/toZip.ts
@@ -0,0 +1,48 @@
+import JSZip from 'jszip';
+import type { SerializedPost } from './toMdx';
+
+export type ZipInput = {
+ serialized: SerializedPost;
+ slug: string;
+ writerName: string;
+ repoUrl: string;
+ assets: { name: string; file: File }[];
+};
+
+function submitNote(slug: string, writerName: string, repoUrl: string): string {
+ return `# How to submit your article
+
+Written by: ${writerName || '(name not given)'}
+
+This ZIP contains a ready-to-publish post folder for mlsystems.dev.
+
+## Option 1 — open a pull request
+
+1. Fork ${repoUrl}
+2. Copy the \`${slug}/\` folder into \`src/content/posts/\`
+3. Open a pull request — see CONTRIBUTING.md in the repo
+
+## Option 2 — let us do it
+
+Open an issue at ${repoUrl}/issues, mention you have an article ready,
+and attach this ZIP. A maintainer will take it from there.
+
+Thanks for writing!
+`;
+}
+
+export async function buildZip(input: ZipInput): Promise {
+ const zip = new JSZip();
+ const folder = zip.folder(input.slug);
+ if (!folder) throw new Error('zip folder creation failed');
+ folder.file('index.mdx', input.serialized.mdx);
+ const wanted = new Set(input.serialized.assetNames);
+ for (const { name, file } of input.assets) {
+ if (wanted.has(name)) folder.file(name, file);
+ }
+ for (const { fileName, source } of input.serialized.componentFiles) {
+ folder.file(fileName, `${source.trimEnd()}\n`);
+ }
+ zip.file('HOW-TO-SUBMIT.md', submitNote(input.slug, input.writerName, input.repoUrl));
+ return zip.generateAsync({ type: 'blob' });
+}
diff --git a/src/write/serialize/validate.ts b/src/write/serialize/validate.ts
new file mode 100644
index 0000000..f69e210
--- /dev/null
+++ b/src/write/serialize/validate.ts
@@ -0,0 +1,67 @@
+import type { PostMeta, SBlock } from './toMdx';
+
+export const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+export const COMPONENT_NAME_RE = /^[A-Z][A-Za-z0-9]*$/;
+
+function walk(blocks: SBlock[], visit: (b: SBlock) => void): void {
+ for (const block of blocks) {
+ visit(block);
+ if (block.children?.length) walk(block.children, visit);
+ }
+}
+
+export function validate(meta: PostMeta, blocks: SBlock[]): string[] {
+ const issues: string[] = [];
+ if (!meta.title.trim()) issues.push('Add a title.');
+ if (!meta.summary.trim()) issues.push('Add a one-sentence summary below the title.');
+ if (!meta.topicId) issues.push('Pick a topic.');
+ if (!SLUG_RE.test(meta.slug)) {
+ issues.push('The URL slug must be lowercase words separated by hyphens, like my-article.');
+ }
+
+ let hasContent = false;
+ walk(blocks, (b) => {
+ if (b.type === 'paragraph' && Array.isArray(b.content) && b.content.length > 0) {
+ hasContent = true;
+ }
+ if (b.type === 'figure' && (b.props.fileName || b.props.src) && !String(b.props.alt).trim()) {
+ issues.push('An image is missing alt text.');
+ }
+ if (b.type === 'gallery') {
+ try {
+ const names = JSON.parse(String(b.props.fileNames || '[]')) as string[];
+ const alts = JSON.parse(String(b.props.alts || '[]')) as string[];
+ if (names.some((_, i) => !String(alts[i] ?? '').trim())) {
+ issues.push('A gallery image is missing alt text.');
+ }
+ } catch {
+ issues.push('A gallery block is corrupted — remove and re-add it.');
+ }
+ }
+ if (b.type === 'video' && !b.props.videoId) {
+ issues.push('A video block has no YouTube link yet.');
+ }
+ if (b.type === 'customComponent') {
+ const name = String(b.props.componentName ?? '');
+ const source = String(b.props.source ?? '');
+ if (!name || !COMPONENT_NAME_RE.test(name)) {
+ issues.push('A custom component needs a PascalCase name, like ThroughputViz.');
+ }
+ if (!source.trim()) issues.push('A custom component block has no code.');
+ }
+ if (b.type === 'math' && !String(b.props.latex ?? '').trim()) {
+ issues.push('A math block is empty.');
+ }
+ });
+ if (!hasContent) issues.push('Write at least one paragraph.');
+
+ return [...new Set(issues)];
+}
+
+export function slugify(title: string): string {
+ return title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 64);
+}
diff --git a/src/write/storage/assets.ts b/src/write/storage/assets.ts
new file mode 100644
index 0000000..a172d60
--- /dev/null
+++ b/src/write/storage/assets.ts
@@ -0,0 +1,62 @@
+const files = new Map();
+const urls = new Map();
+
+function sanitize(name: string): string {
+ const dot = name.lastIndexOf('.');
+ const stem = (dot > 0 ? name.slice(0, dot) : name)
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+ const ext = dot > 0 ? name.slice(dot).toLowerCase() : '';
+ return `${stem || 'image'}${ext}`;
+}
+
+export function addAsset(file: File): string {
+ const base = sanitize(file.name);
+ let name = base;
+ let n = 2;
+ while (files.has(name)) {
+ const dot = base.lastIndexOf('.');
+ name = dot > 0 ? `${base.slice(0, dot)}-${n}${base.slice(dot)}` : `${base}-${n}`;
+ n++;
+ }
+ files.set(name, file);
+ return name;
+}
+
+export function restoreAsset(name: string, file: File): void {
+ files.set(name, file);
+}
+
+export function getAsset(name: string): File | undefined {
+ return files.get(name);
+}
+
+export function getAssetUrl(name: string): string {
+ const cached = urls.get(name);
+ if (cached) return cached;
+ const file = files.get(name);
+ if (!file) return '';
+ const url = URL.createObjectURL(file);
+ urls.set(name, url);
+ return url;
+}
+
+export function allAssets(): { name: string; file: File }[] {
+ return [...files.entries()].map(([name, file]) => ({ name, file }));
+}
+
+export function removeAsset(name: string): void {
+ files.delete(name);
+ const url = urls.get(name);
+ if (url) {
+ URL.revokeObjectURL(url);
+ urls.delete(name);
+ }
+}
+
+export function clearAssets(): void {
+ for (const url of urls.values()) URL.revokeObjectURL(url);
+ files.clear();
+ urls.clear();
+}
diff --git a/src/write/storage/drafts.ts b/src/write/storage/drafts.ts
new file mode 100644
index 0000000..fc28850
--- /dev/null
+++ b/src/write/storage/drafts.ts
@@ -0,0 +1,112 @@
+import type { PostMeta, SBlock, TableStyle } from '../serialize/toMdx';
+import { allAssets, restoreAsset } from './assets';
+
+export type Draft = {
+ meta: PostMeta;
+ blocks: SBlock[];
+ tableVariants: Record;
+ savedAt: number;
+};
+
+const KEY = 'mlsys-write-draft-v1';
+const DB_NAME = 'mlsys-write';
+const STORE = 'assets';
+
+export function loadDraft(): Draft | null {
+ try {
+ const raw = localStorage.getItem(KEY);
+ return raw ? (JSON.parse(raw) as Draft) : null;
+ } catch {
+ return null;
+ }
+}
+
+let timer: ReturnType | undefined;
+
+export function saveDraftDebounced(getDraft: () => Draft, onFail?: () => void): void {
+ clearTimeout(timer);
+ timer = setTimeout(() => {
+ try {
+ localStorage.setItem(KEY, JSON.stringify(getDraft()));
+ } catch {
+ onFail?.();
+ }
+ void persistAssets().catch(() => onFail?.());
+ }, 1000);
+}
+
+export function clearDraft(): void {
+ clearTimeout(timer);
+ try {
+ localStorage.removeItem(KEY);
+ } catch {
+ return;
+ }
+}
+
+function openDb(): Promise {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(DB_NAME, 1);
+ req.onupgradeneeded = () => {
+ if (!req.result.objectStoreNames.contains(STORE)) req.result.createObjectStore(STORE);
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+function tx(db: IDBDatabase, mode: IDBTransactionMode): IDBObjectStore {
+ return db.transaction(STORE, mode).objectStore(STORE);
+}
+
+export async function persistAssets(): Promise {
+ const db = await openDb();
+ const assets = allAssets();
+ const wanted = new Set(assets.map((a) => a.name));
+ await new Promise((resolve, reject) => {
+ const store = tx(db, 'readwrite');
+ const keysReq = store.getAllKeys();
+ keysReq.onsuccess = () => {
+ const existing = new Set((keysReq.result as string[]).map(String));
+ for (const key of existing) if (!wanted.has(key)) store.delete(key);
+ for (const { name, file } of assets) if (!existing.has(name)) store.put(file, name);
+ };
+ store.transaction.oncomplete = () => resolve();
+ store.transaction.onerror = () => reject(store.transaction.error);
+ });
+ db.close();
+}
+
+export async function restoreAssets(): Promise {
+ const db = await openDb();
+ await new Promise((resolve, reject) => {
+ const store = tx(db, 'readonly');
+ const keysReq = store.getAllKeys();
+ const valsReq = store.getAll();
+ store.transaction.oncomplete = () => {
+ const keys = keysReq.result as string[];
+ const vals = valsReq.result as unknown[];
+ keys.forEach((k, i) => {
+ const value = vals[i];
+ if (value instanceof Blob) {
+ const file = value instanceof File ? value : new File([value], String(k));
+ restoreAsset(String(k), file);
+ }
+ });
+ resolve();
+ };
+ store.transaction.onerror = () => reject(store.transaction.error);
+ });
+ db.close();
+}
+
+export async function clearStoredAssets(): Promise {
+ const db = await openDb();
+ await new Promise((resolve, reject) => {
+ const store = tx(db, 'readwrite');
+ store.clear();
+ store.transaction.oncomplete = () => resolve();
+ store.transaction.onerror = () => reject(store.transaction.error);
+ });
+ db.close();
+}