Skip to content

Commit 787cb99

Browse files
Render dev disaster bodies as markdown, not plain paragraphs (#41)
Disaster story bodies were split on blank lines into paragraphs and drawn as plain text, so any markdown someone typed into the submit form (bold, links, code fences) came through literally instead of being rendered. Comments already had a safe markdown renderer in src/lib/markdown.ts, built from marked's token stream with an allow list of eight tags, headings demoted to bold paragraphs, and code fences highlighted through shiki. There was no reason for a disaster body to have a second, weaker renderer, so it now goes through the same one. shapeDisasters is async now, since rendering a body is. paragraphs() is gone; renderBody() takes its place and calls renderComment. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 402d3c8 commit 787cb99

3 files changed

Lines changed: 45 additions & 50 deletions

File tree

‎src/lib/disaster-rows.ts‎

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@
1515
function of the network. What is still unexecuted after this is the query itself and the
1616
shape Supabase returns, which genuinely does need the project ref.
1717
18-
Nothing is imported here but types, deliberately. disasters.ts has top level await and
19-
imports ./supabase, which is a directory, so importing it from a test fails before any
20-
assertion runs. Severity ids arrive as an argument rather than from ../config/site for
21-
the same reason, and it makes the drift they guard against directly testable.
18+
Nothing is imported here but types and ./markdown, deliberately. disasters.ts has top
19+
level await and imports ./supabase, which is a directory, so importing it from a test
20+
fails before any assertion runs. Severity ids arrive as an argument rather than from
21+
../config/site for the same reason, and it makes the drift they guard against directly
22+
testable. ./markdown imports shiki and marked, neither of which touches a database, so
23+
it is safe to pull into a test the way ./supabase is not.
2224
*/
2325

2426
import type { SeverityId } from '../config/site';
27+
import { renderComment } from './markdown.ts';
2528

2629
/**
2730
* Who told a story, and what the byline is allowed to say about them.
@@ -58,8 +61,8 @@ export interface Disaster {
5861
date: Date;
5962
/** When Michael put this on the front page, or null. A real act, not a side effect. */
6063
featuredAt: Date | null;
61-
/** Story paragraphs. Plain prose, no markup. */
62-
body: string[];
64+
/** The story body, rendered from markdown through the same allow list comments use. */
65+
body: string;
6366
}
6467

6568
export interface DisasterRow {
@@ -83,17 +86,17 @@ export interface ProfileRow {
8386
}
8487

8588
/**
86-
* Prose as typed, split into paragraphs.
89+
* A story body, rendered from markdown the same way a comment is.
8790
*
88-
* The column is one text field because that is what somebody typed into one textarea.
89-
* Blank lines are where they chose to break it. A story with no blank lines is one
90-
* paragraph, which is a real way to write a short one and not a defect to repair.
91+
* The column is one text field because that is what somebody typed into one textarea, and
92+
* renderComment already turns blank lines into paragraphs on its own, so nothing here has
93+
* to split anything first. Routing it through the comment renderer rather than a second
94+
* one means a disaster body gets the same allow list, the same highlighted code fences,
95+
* and the same demoted headings a comment gets, instead of a second surface that has to be
96+
* kept in step with the first by hand.
9197
*/
92-
export function paragraphs(body: string): string[] {
93-
return body
94-
.split(/\n\s*\n/)
95-
.map((p) => p.trim())
96-
.filter(Boolean);
98+
export async function renderBody(body: string): Promise<string> {
99+
return renderComment(body);
97100
}
98101

99102
/**
@@ -158,7 +161,7 @@ export interface ShapeOptions {
158161
}
159162

160163
/** Rows to stories, newest first, with anything undrawable left out and reported. */
161-
export function shapeDisasters(rows: readonly DisasterRow[], tellers: Map<string, Teller>, opts: ShapeOptions): Disaster[] {
164+
export async function shapeDisasters(rows: readonly DisasterRow[], tellers: Map<string, Teller>, opts: ShapeOptions): Promise<Disaster[]> {
162165
const warn = opts.warn ?? console.warn;
163166
const out: Disaster[] = [];
164167

@@ -207,7 +210,7 @@ export function shapeDisasters(rows: readonly DisasterRow[], tellers: Map<string
207210
replies: opts.repliesById.get(String(r.id)) ?? 0,
208211
date: new Date(r.published_at),
209212
featuredAt: r.featured_at ? new Date(r.featured_at) : null,
210-
body: paragraphs(r.body)
213+
body: await renderBody(r.body)
211214
});
212215
}
213216

‎src/pages/dev-disasters/[slug].astro‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,7 @@ const filed = d.date.toLocaleDateString('en-GB', {
8989
<p class="lede">{d.line}</p>
9090
</header>
9191

92-
<div class="prose dd-body">
93-
{d.body.map((p) => <p>{p}</p>)}
94-
</div>
92+
<div class="prose dd-body" set:html={d.body} />
9593

9694
<p class="notice dd-notice">
9795
Names and the company have been scrubbed. Stories are lightly edited for length

‎tests/disaster.rows.test.mjs‎

Lines changed: 24 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import assert from 'node:assert/strict';
2121

2222
import {
2323
authorIdsToResolve,
24-
paragraphs,
2524
shapeDisasters,
2625
tellerFor,
2726
tellersFromProfiles
@@ -43,9 +42,9 @@ const row = (over = {}) => ({
4342
...over
4443
});
4544

46-
const shape = (rows, tellers = new Map(), over = {}) => {
45+
const shape = async (rows, tellers = new Map(), over = {}) => {
4746
const warnings = [];
48-
const out = shapeDisasters(rows, tellers, {
47+
const out = await shapeDisasters(rows, tellers, {
4948
severityIds: SEVERITY_IDS,
5049
likesById: new Map(),
5150
repliesById: new Map(),
@@ -119,25 +118,25 @@ test('an anonymous story asks for no profile at all', () => {
119118
assert.deepEqual(ids, ['u2'], 'anonymous rows must not send their author id to the profiles query');
120119
});
121120

122-
test('an unknown severity is left off the site and reported with its id', () => {
123-
const { out, warnings } = shape([row({ id: 7, severity: 'catastrophe' })]);
121+
test('an unknown severity is left off the site and reported with its id', async () => {
122+
const { out, warnings } = await shape([row({ id: 7, severity: 'catastrophe' })]);
124123

125124
assert.equal(out.length, 0);
126125
assert.equal(warnings.length, 1);
127126
assert.match(warnings[0], /Dev disaster 7/, 'the warning does not name the row, so nobody can find it');
128127
assert.match(warnings[0], /catastrophe/);
129128
});
130129

131-
test('an incomplete published row is left off rather than half drawn', () => {
130+
test('an incomplete published row is left off rather than half drawn', async () => {
132131
for (const missing of ['slug', 'title', 'line', 'body', 'published_at']) {
133-
const { out, warnings } = shape([row({ [missing]: null })]);
132+
const { out, warnings } = await shape([row({ [missing]: null })]);
134133
assert.equal(out.length, 0, `a row with no ${missing} was drawn anyway`);
135134
assert.match(warnings[0], /published but incomplete/);
136135
}
137136
});
138137

139-
test('one bad row does not take the rest of the wall with it', () => {
140-
const { out } = shape([
138+
test('one bad row does not take the rest of the wall with it', async () => {
139+
const { out } = await shape([
141140
row({ id: 1, slug: 'good', published_at: '2026-07-01T00:00:00Z' }),
142141
row({ id: 2, severity: 'nonsense' }),
143142
row({ id: 3, slug: 'also-good', published_at: '2026-07-02T00:00:00Z' })
@@ -150,32 +149,27 @@ test('one bad row does not take the rest of the wall with it', () => {
150149
);
151150
});
152151

153-
test('stories come back newest first whatever order the rows arrived in', () => {
154-
const { out } = shape([
152+
test('stories come back newest first whatever order the rows arrived in', async () => {
153+
const { out } = await shape([
155154
row({ id: 1, published_at: '2026-01-01T00:00:00Z' }),
156155
row({ id: 2, published_at: '2026-07-01T00:00:00Z' }),
157156
row({ id: 3, published_at: '2026-03-01T00:00:00Z' })
158157
]);
159158
assert.deepEqual(out.map((d) => d.id), [2, 3, 1]);
160159
});
161160

162-
test('a story with no blank lines is one paragraph, not a defect', () => {
163-
assert.deepEqual(paragraphs('Just the one.'), ['Just the one.']);
164-
assert.deepEqual(paragraphs('First.\n\nSecond.'), ['First.', 'Second.']);
165-
assert.deepEqual(paragraphs('First.\n\n\n \n\nSecond.'), ['First.', 'Second.']);
166-
assert.deepEqual(paragraphs(' Padded. '), ['Padded.']);
167-
assert.deepEqual(paragraphs(''), [], 'an empty body should be no paragraphs rather than one empty one');
161+
test('a story body is rendered from markdown rather than left as plain paragraphs', async () => {
162+
const { out } = await shape([row({ body: 'One **bold** paragraph.\n\nAnd a second one.' })]);
163+
assert.equal(out[0].body, '<p>One <strong>bold</strong> paragraph.</p><p>And a second one.</p>');
168164
});
169165

170-
test('a single newline is a line break inside a paragraph, not a new one', () => {
171-
assert.deepEqual(
172-
paragraphs('One line.\nStill the same paragraph.'),
173-
['One line.\nStill the same paragraph.']
174-
);
166+
test('a heading in a story body is demoted the same way a heading in a comment is', async () => {
167+
const { out } = await shape([row({ body: '# Not a page title' })]);
168+
assert.equal(out[0].body, '<p><strong>Not a page title</strong></p>');
175169
});
176170

177-
test('counts default to zero rather than undefined', () => {
178-
const { out } = shape([row({ id: 42 })], new Map(), {
171+
test('counts default to zero rather than undefined', async () => {
172+
const { out } = await shape([row({ id: 42 })], new Map(), {
179173
likesById: new Map([['42', 11]]),
180174
repliesById: new Map()
181175
});
@@ -184,17 +178,17 @@ test('counts default to zero rather than undefined', () => {
184178
assert.equal(out[0].replies, 0, 'a story nobody has replied to must read zero, not undefined');
185179
});
186180

187-
test('the url is built from the slug and stays under dev-disasters', () => {
181+
test('the url is built from the slug and stays under dev-disasters', async () => {
188182
/*
189183
A disaster never belongs to a topic. If this ever starts reading a topic field, the
190184
whole URL scheme decision has been undone somewhere upstream.
191185
*/
192-
const { out } = shape([row({ slug: 'the-time-i-dropped-prod' })]);
186+
const { out } = await shape([row({ slug: 'the-time-i-dropped-prod' })]);
193187
assert.equal(out[0].url, '/dev-disasters/the-time-i-dropped-prod/');
194188
});
195189

196-
test('featured_at becomes a date or null, never an invalid date', () => {
197-
const { out } = shape([
190+
test('featured_at becomes a date or null, never an invalid date', async () => {
191+
const { out } = await shape([
198192
row({ id: 1, featured_at: '2026-07-15T00:00:00Z' }),
199193
row({ id: 2, featured_at: null, published_at: '2026-06-01T00:00:00Z' })
200194
]);
@@ -203,8 +197,8 @@ test('featured_at becomes a date or null, never an invalid date', () => {
203197
assert.equal(out.find((d) => d.id === 2).featuredAt, null);
204198
});
205199

206-
test('an empty read produces an empty wall and no warnings', () => {
207-
const { out, warnings } = shape([]);
200+
test('an empty read produces an empty wall and no warnings', async () => {
201+
const { out, warnings } = await shape([]);
208202
assert.deepEqual(out, []);
209203
assert.deepEqual(warnings, []);
210204
});

0 commit comments

Comments
 (0)