Skip to content

Commit e390a25

Browse files
committed
Write portal improvements
1 parent 497b82d commit e390a25

9 files changed

Lines changed: 4073 additions & 446 deletions

File tree

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import fs from 'node:fs';
2+
3+
let SRC = process.argv[2];
4+
if (!SRC) {
5+
console.error(
6+
'usage: node docs/authoring/mdx-to-write-source.mjs <post-dir-or-index.mdx> [out.json]',
7+
);
8+
process.exit(1);
9+
}
10+
if (!fs.existsSync(SRC)) {
11+
console.error(`not found: ${SRC}`);
12+
process.exit(1);
13+
}
14+
if (fs.statSync(SRC).isDirectory()) SRC = SRC.replace(/\/$/, '') + '/index.mdx';
15+
const slug = SRC.split('/').filter(Boolean).at(-2) ?? 'post-slug';
16+
const OUT = process.argv[3] ?? `${slug}.write-source.json`;
17+
18+
let src = fs.readFileSync(SRC, 'utf8');
19+
20+
const fm = src.match(/^---\n([\s\S]*?)\n---\n/);
21+
const body = fm ? src.slice(fm[0].length) : src;
22+
const fmText = fm ? fm[1] : '';
23+
const fmVal = (key) => {
24+
const m = fmText.match(new RegExp(`^${key}: (.*)$`, 'm'));
25+
return m ? m[1].replace(/^"|"$/g, '') : '';
26+
};
27+
const tagsMatch = fmText.match(/^tags: (\[.*\])$/m);
28+
const tags = tagsMatch ? JSON.parse(tagsMatch[1].replace(/'/g, '"')) : [];
29+
30+
let n = 0;
31+
const id = (p) => `${p}-${++n}`;
32+
const D = { backgroundColor: 'default', textColor: 'default', textAlignment: 'left' };
33+
34+
const INLINE_PATTERNS = [
35+
{ re: /\*\*`([^`]+)`\*\*/g, kind: 'code' },
36+
{ re: /`([^`]+)`/g, kind: 'code' },
37+
{ re: /(?<!!)\[([^\]]+)\]\(([^)\s]+)\)/g, kind: 'link' },
38+
{ re: /\*\*([^*]+(?:\*(?!\*)[^*]*)*)\*\*/g, kind: 'style', key: 'bold' },
39+
{ re: /__([^_]+)__/g, kind: 'style', key: 'bold' },
40+
{ re: /~~([^~]+)~~/g, kind: 'style', key: 'strike' },
41+
{ re: /\*([^*\n]+)\*/g, kind: 'style', key: 'italic' },
42+
{ re: /(?<![\w`])_([^_\n]+)_(?![\w`])/g, kind: 'style', key: 'italic' },
43+
];
44+
45+
function smartQuotes(s) {
46+
return s
47+
.replace(/(\w)'(\w)/g, '$1’$2')
48+
.replace(/(^|[\s([{-])"/g, '$1“')
49+
.replace(/"/g, '”')
50+
.replace(/(^|[\s([{-])'/g, '$1‘')
51+
.replace(/'/g, '’');
52+
}
53+
54+
function inline(text, inherited = {}) {
55+
const runs = [];
56+
let pos = 0;
57+
while (pos < text.length) {
58+
let best = null;
59+
for (const p of INLINE_PATTERNS) {
60+
p.re.lastIndex = pos;
61+
const m = p.re.exec(text);
62+
if (m && (!best || m.index < best.m.index)) best = { p, m };
63+
}
64+
if (!best) {
65+
runs.push({ type: 'text', text: smartQuotes(text.slice(pos)), styles: { ...inherited } });
66+
break;
67+
}
68+
if (best.m.index > pos)
69+
runs.push({
70+
type: 'text',
71+
text: smartQuotes(text.slice(pos, best.m.index)),
72+
styles: { ...inherited },
73+
});
74+
const { p, m } = best;
75+
if (p.kind === 'code') {
76+
runs.push({ type: 'text', text: m[1], styles: { code: true } });
77+
} else if (p.kind === 'link') {
78+
runs.push({
79+
type: 'link',
80+
href: m[2],
81+
content: inline(m[1], inherited).filter((r) => r.type === 'text'),
82+
});
83+
} else {
84+
runs.push(...inline(m[1], { ...inherited, [p.key]: true }));
85+
}
86+
pos = m.index + m[0].length;
87+
}
88+
return runs.filter((r) => r.type !== 'text' || r.text !== '');
89+
}
90+
91+
const blocks = [];
92+
const push = (type, props, content, children = []) => {
93+
const b = { id: id(type), type, props, children };
94+
if (content !== undefined) b.content = content;
95+
blocks.push(b);
96+
};
97+
98+
const cell = (text) => ({
99+
type: 'tableCell',
100+
content: inline(text),
101+
props: { colspan: 1, rowspan: 1, ...D },
102+
});
103+
104+
let rest = body;
105+
const pattern =
106+
/(<Figure caption=(?:"([\s\S]*?)"|\{("(?:[^"\\]|\\.)*")\})>\s*([\s\S]*?)\s*<\/Figure>)|(<Note>\s*([\s\S]*?)\s*<\/Note>)|(```(\w*)\n([\s\S]*?)```)/g;
107+
108+
let cursor = 0;
109+
const segments = [];
110+
let m;
111+
while ((m = pattern.exec(rest))) {
112+
if (m.index > cursor) segments.push({ kind: 'md', text: rest.slice(cursor, m.index) });
113+
if (m[1]) {
114+
const caption = m[3] ? JSON.parse(m[3]) : (m[2] ?? '');
115+
segments.push({
116+
kind: 'figure',
117+
caption: caption.replace(/\s+/g, ' ').trim(),
118+
svg: m[4].trim(),
119+
});
120+
} else if (m[5]) segments.push({ kind: 'note', text: m[6].replace(/\s+/g, ' ').trim() });
121+
else if (m[7])
122+
segments.push({ kind: 'code', lang: m[8] || 'text', code: m[9].replace(/\n$/, '') });
123+
cursor = m.index + m[0].length;
124+
}
125+
if (cursor < rest.length) segments.push({ kind: 'md', text: rest.slice(cursor) });
126+
127+
function emitMd(text) {
128+
const lines = text.split('\n');
129+
let i = 0;
130+
while (i < lines.length) {
131+
const line = lines[i];
132+
if (!line.trim()) {
133+
i++;
134+
continue;
135+
}
136+
const h = line.match(/^(#{2,6}) /);
137+
if (h) {
138+
push(
139+
'heading',
140+
{ ...D, level: Math.min(h[1].length - 1, 3), isToggleable: false },
141+
inline(line.slice(h[1].length + 1)),
142+
);
143+
i++;
144+
continue;
145+
}
146+
const img = line.match(/^!\[([^\]]*)\]\(([^)\s]+)\)$/);
147+
if (img) {
148+
push('figure', { fileName: '', src: img[2], alt: img[1], caption: '', width: 360 });
149+
i++;
150+
continue;
151+
}
152+
if (line.startsWith('> ')) {
153+
const q = [];
154+
while (i < lines.length && lines[i].startsWith('> ')) {
155+
q.push(lines[i].slice(2));
156+
i++;
157+
}
158+
push('quote', { backgroundColor: 'default', textColor: 'default' }, inline(q.join(' ')));
159+
continue;
160+
}
161+
if (/^[-*] \[[ xX]\] /.test(line)) {
162+
while (i < lines.length && /^[-*] \[[ xX]\] /.test(lines[i])) {
163+
push(
164+
'checkListItem',
165+
{ ...D, checked: /^\S+ \[[xX]\]/.test(lines[i]) },
166+
inline(lines[i].replace(/^[-*] \[[ xX]\] /, '')),
167+
);
168+
i++;
169+
}
170+
continue;
171+
}
172+
if (/^[-*] /.test(line)) {
173+
while (i < lines.length && /^[-*] /.test(lines[i])) {
174+
push('bulletListItem', { ...D }, inline(lines[i].slice(2)));
175+
i++;
176+
}
177+
continue;
178+
}
179+
if (/^\d+\. /.test(line)) {
180+
while (i < lines.length && /^\d+\. /.test(lines[i])) {
181+
push('numberedListItem', { ...D }, inline(lines[i].replace(/^\d+\. /, '')));
182+
i++;
183+
}
184+
continue;
185+
}
186+
if (/^(---+|\*\*\*+)$/.test(line.trim())) {
187+
push('separator', {});
188+
i++;
189+
continue;
190+
}
191+
if (line.startsWith('|')) {
192+
const rows = [];
193+
while (i < lines.length && lines[i].startsWith('|')) {
194+
if (!/^\|[:\-\s|]+\|$/.test(lines[i])) {
195+
const cells = lines[i]
196+
.split('|')
197+
.slice(1, -1)
198+
.map((c) => c.trim());
199+
rows.push({ cells: cells.map(cell) });
200+
}
201+
i++;
202+
}
203+
const cols = rows[0].cells.length;
204+
push(
205+
'table',
206+
{ textColor: 'default' },
207+
{ type: 'tableContent', columnWidths: Array(cols).fill(null), rows },
208+
);
209+
continue;
210+
}
211+
const para = [];
212+
while (
213+
i < lines.length &&
214+
lines[i].trim() &&
215+
!/^(#{2,6} |> |\||```|[-*] |\d+\. |!\[)/.test(lines[i]) &&
216+
!/^(---+|\*\*\*+)$/.test(lines[i].trim())
217+
) {
218+
para.push(lines[i]);
219+
i++;
220+
}
221+
push('paragraph', { ...D }, inline(para.join(' ')));
222+
}
223+
}
224+
225+
for (const seg of segments) {
226+
if (seg.kind === 'md') emitMd(seg.text);
227+
else if (seg.kind === 'figure') push('svg', { code: seg.svg, caption: seg.caption });
228+
else if (seg.kind === 'note') push('note', {}, inline(seg.text));
229+
else if (seg.kind === 'code')
230+
push('codeBlock', { language: seg.lang }, [{ type: 'text', text: seg.code, styles: {} }]);
231+
}
232+
233+
const doc = {
234+
kind: 'mlsys-write-source',
235+
version: 1,
236+
meta: {
237+
title: fmVal('title'),
238+
summary: fmVal('summary'),
239+
authors: (() => {
240+
const list = [...fmText.matchAll(/^ {2}- (.+)$/gm)]
241+
.map((x) => x[1])
242+
.filter((a) => !/^\d{4}-/.test(a));
243+
return list.length ? list : ['guest'];
244+
})(),
245+
writerName: 'Author Name',
246+
topicId: fmVal('topicId'),
247+
topicName: fmVal('topic'),
248+
tags,
249+
slug,
250+
coverFileName: '',
251+
ogCard: false,
252+
proposedTopic: '',
253+
newAuthor: null,
254+
date: fmVal('date'),
255+
},
256+
blocks,
257+
tableVariants: {},
258+
};
259+
260+
const ids = blocks.map((b) => b.id);
261+
if (new Set(ids).size !== ids.length) throw new Error('duplicate ids');
262+
const allowed = new Set([
263+
'paragraph',
264+
'heading',
265+
'bulletListItem',
266+
'numberedListItem',
267+
'checkListItem',
268+
'toggleListItem',
269+
'quote',
270+
'note',
271+
'codeBlock',
272+
'math',
273+
'separator',
274+
'table',
275+
'figure',
276+
'gallery',
277+
'video',
278+
'svg',
279+
'customComponent',
280+
]);
281+
for (const b of blocks) if (!allowed.has(b.type)) throw new Error('bad type ' + b.type);
282+
for (const b of blocks)
283+
if (['svg', 'codeBlock'].includes(b.type) === false && b.content) JSON.stringify(b.content);
284+
285+
fs.writeFileSync(OUT, JSON.stringify(doc, null, 2) + '\n');
286+
const counts = {};
287+
for (const b of blocks) counts[b.type] = (counts[b.type] ?? 0) + 1;
288+
console.log('blocks:', blocks.length, JSON.stringify(counts));
289+
console.log('written:', OUT);

docs/authoring/sample.write-source.json

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,15 @@
5959
"backgroundColor": "default",
6060
"textColor": "default",
6161
"textAlignment": "left",
62-
"level": 2,
62+
"level": 1,
6363
"isToggleable": false
6464
},
6565
"content": [
66-
{ "type": "text", "text": "Headings structure the post (this is level 2)", "styles": {} }
66+
{
67+
"type": "text",
68+
"text": "A section heading (level 1 — publishes as H2, the title owns H1)",
69+
"styles": {}
70+
}
6771
],
6872
"children": []
6973
},
@@ -74,10 +78,12 @@
7478
"backgroundColor": "default",
7579
"textColor": "default",
7680
"textAlignment": "left",
77-
"level": 3,
81+
"level": 2,
7882
"isToggleable": false
7983
},
80-
"content": [{ "type": "text", "text": "A level-3 subsection", "styles": {} }],
84+
"content": [
85+
{ "type": "text", "text": "A subsection heading (level 2 — publishes as H3)", "styles": {} }
86+
],
8187
"children": []
8288
},
8389
{

docs/authoring/write-source-format.md

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -76,23 +76,24 @@ Every block has this shape:
7676

7777
- Booleans: `bold`, `italic`, `underline`, `strike`, `code`
7878
- `textColor` / `backgroundColor` — one of: `gray`, `brown`, `red`, `orange`, `yellow`, `green`, `blue`, `purple`, `pink` (or `"default"`). Use sparingly — body text should stay default.
79+
- **`code` is exclusive** — it cannot combine with any other style (`{ "code": true, "bold": true }` is rejected by the editor). A run is either code or styled text, never both.
7980

8081
### Text blocks
8182

8283
Shared default props: `{ "backgroundColor": "default", "textColor": "default", "textAlignment": "left" }`.
8384

8485
**Options:** `textAlignment`: `"left"` | `"center"` | `"right"`; `backgroundColor`/`textColor`: same named colors as inline styles (block-wide tint).
8586

86-
| Type | Extra props | Notes |
87-
| ------------------ | ------------------------------------------------- | ----------------------------------------------------------------------------- |
88-
| `paragraph` || body text |
89-
| `heading` | `"level": 2`, `"isToggleable": false` | use levels 2 or 3 — published MDX caps depth at 3, deeper levels flatten to 3 |
90-
| `bulletListItem` || **exact spelling** — one block per bullet; consecutive items group |
91-
| `numberedListItem` || one block per item |
92-
| `checkListItem` | `"checked": false` | task list |
93-
| `toggleListItem` || collapsible; hidden blocks go in `children` |
94-
| `quote` | only `backgroundColor`/`textColor` (no alignment) | pull-quote |
95-
| `note` | `"props": {}` | highlighted callout/aside for key takeaways |
87+
| Type | Extra props | Notes |
88+
| ------------------ | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
89+
| `paragraph` || body text |
90+
| `heading` | `"level": 1`, `"isToggleable": false` | levels 1–3; publishes one step deeper (the post title owns H1): level 1 → H2 section, 2 → H3 subsection, 3 → H4 |
91+
| `bulletListItem` || **exact spelling** — one block per bullet; consecutive items group |
92+
| `numberedListItem` || one block per item |
93+
| `checkListItem` | `"checked": false` | task list |
94+
| `toggleListItem` || collapsible; hidden blocks go in `children` |
95+
| `quote` | only `backgroundColor`/`textColor` (no alignment) | pull-quote |
96+
| `note` | `"props": {}` | highlighted callout/aside for key takeaways |
9697

9798
### Code
9899

@@ -255,12 +256,22 @@ Agents **can and should** author complete diagrams this way. Rules: use `stroke=
255256

256257
## Rules of thumb for a good agent draft
257258

258-
1. Start sections at heading level 2 `meta.title` is the visual level 1.
259+
1. Use heading level 1 for sections and 2 for subsections `meta.title` is the page's H1, so editor levels publish one step deeper (level 1 → H2).
259260
2. Short paragraphs; one `note` per major section carrying the takeaway.
260261
3. Prefer authored `svg` diagrams over empty figure slots; when a photo/screenshot is genuinely needed, use a placeholder-`src` figure with a caption telling the writer what to drop in.
261262
4. Every id unique; JSON must parse — validate before handing over.
262263
5. Don't invent topic ids or author handles — check `src/content/topics/` and `src/content/authors/`, or use `proposedTopic` / `["guest"]`.
263264

265+
## Converting an existing MDX post (backup tool)
266+
267+
[`mdx-to-write-source.mjs`](./mdx-to-write-source.mjs) converts a published post back into an uploadable JSON — useful if a post's `.write-source.json` sidecar is ever missing:
268+
269+
```bash
270+
node docs/authoring/mdx-to-write-source.mjs src/content/posts/<slug> [out.json]
271+
```
272+
273+
Best-effort, not a guaranteed round-trip: it maps standard markdown — headings, bold/italic/strike/links/inline code, bullet/numbered/check lists, quotes, horizontal rules, code fences, tables, markdown images (as figure placeholders with their URL), plus this site's `<Note>` and inline-SVG `<Figure>` components. Unrecognized MDX maps to the closest editor block. Good enough to port a post written outside the editor, then finish by hand in `/write`.
274+
264275
## Prompt to give your agent
265276

266-
> Write a first draft of a blog post for mlsystems.dev as a single `.write-source.json` file, following `docs/authoring/write-source-format.md` exactly (top-level `kind: "mlsys-write-source"`, `version: 1`, `meta`, `blocks`, `tableVariants`; block shapes and type names exactly as documented — note it's `bulletListItem`, tables use `tableCell` objects, and media blocks omit `content`). Start headings at level 2. Author diagrams as theme-aware inline `svg` blocks (currentColor); for photos use `figure` blocks with a placeholder `src` and a caption saying what image to add. All ids unique; valid JSON. Topic id and author handles must come from the site's existing lists (or use `proposedTopic` / `["guest"]`). The post: [describe your post here].
277+
> Write a first draft of a blog post for mlsystems.dev as a single `.write-source.json` file, following `docs/authoring/write-source-format.md` exactly (top-level `kind: "mlsys-write-source"`, `version: 1`, `meta`, `blocks`, `tableVariants`; block shapes and type names exactly as documented — note it's `bulletListItem`, tables use `tableCell` objects, and media blocks omit `content`). Use heading level 1 for sections, 2 for subsections. Author diagrams as theme-aware inline `svg` blocks (currentColor); for photos use `figure` blocks with a placeholder `src` and a caption saying what image to add. All ids unique; valid JSON. Topic id and author handles must come from the site's existing lists (or use `proposedTopic` / `["guest"]`). The post: [describe your post here].

0 commit comments

Comments
 (0)