Skip to content

Commit 035472c

Browse files
committed
fix(website): render entry bodies as prose, with no second bullet level
The changelog is one bullet per released change, and a second level of glyphs under half of them is noise. 303 of the 589 entries carried a nested bullet, and 119 of those 378 bullets restated the entry title directly above them, because the generator writes each squashed commit subject as its own indented line. An indented bullet is now a paragraph of its entry with the marker dropped, so indentation controls grouping and never bullet depth. The page renders zero nested bullets. That also retires the nesting-depth question this PR had deferred twice: with no second level to render, there is nothing for depth to mean.
1 parent ee484fa commit 035472c

2 files changed

Lines changed: 83 additions & 152 deletions

File tree

website/modules/changelog/utils/render-entry.ts

Lines changed: 39 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -28,47 +28,47 @@ function inline(s: string): string {
2828
return out;
2929
}
3030

31-
/**
32-
* One block of an entry's body: a paragraph of soft-wrapped lines, or a
33-
* nested bullet list whose items are themselves soft-wrapped lines.
34-
*/
35-
type Block =
36-
| { kind: 'p'; lines: string[] }
37-
| { kind: 'ul'; marker: string; items: string[][] };
31+
/** One paragraph of an entry's body, as its soft-wrapped source lines. */
32+
type Para = string[];
3833

3934
/** Render the body of one changelog entry: h1 / h2 / bulleted lists / paragraphs. */
4035
export function renderEntryBody(md: string): string {
4136
const lines = md.split('\n');
4237
const out: string[] = [];
4338
let inList = false;
44-
// An entry item is a sequence of blocks, not a flat run of lines. A blank
45-
// line inside an item is a paragraph BREAK within it (CommonMark reads a
46-
// 2-space-indented paragraph after a blank as list-item continuation), so
47-
// it closes the open block rather than the item. Closing the item there is
48-
// what used to turn one multi-paragraph entry into a stack of sibling
49-
// bullets: 3 entries rendered as 23 peer list items.
39+
// An entry item is a sequence of PARAGRAPHS, not a flat run of lines. A
40+
// blank line inside an item is a paragraph BREAK within it (CommonMark
41+
// reads a 2-space-indented paragraph after a blank as list-item
42+
// continuation), so it closes the open paragraph rather than the item.
43+
// Closing the item there is what used to turn one multi-paragraph entry
44+
// into a stack of sibling bullets: 3 entries rendered as 23 peer items.
45+
//
46+
// An indented BULLET is a paragraph too, with its marker dropped. The
47+
// changelog is one bullet per released change, and a second level of
48+
// glyphs under half of them is noise: the generator writes each squashed
49+
// commit subject as its own `*` line, which restated the entry title above
50+
// it in 119 of the corpus's 378 indented bullets. So indentation controls
51+
// grouping here, never bullet depth, and no entry body renders a list.
5052
let itemOpen = false;
51-
let blocks: Block[] = [];
52-
let openBlock: Block | null = null;
53+
let paras: Para[] = [];
54+
let open: Para | null = null;
5355

54-
function pushBlock(b: Block) { blocks.push(b); openBlock = b; }
56+
function startPara(text: string) { open = [text]; paras.push(open); }
5557

56-
function renderBlocks(bs: Block[]): string {
58+
function renderParas(ps: Para[]): string {
5759
// The overwhelmingly common entry is a single line with no body. Emit it
5860
// bare so its markup is unchanged by the multi-paragraph support.
59-
if (bs.length === 1 && bs[0].kind === 'p') return inline(bs[0].lines.join(' '));
60-
return bs.map((b) => b.kind === 'p'
61-
? `<p class="my-2 first:mt-0 last:mb-0">${inline(b.lines.join(' '))}</p>`
62-
: `<ul class="list-disc pl-5 space-y-1 my-2 last:mb-0">${b.items.map((it) => `<li>${inline(it.join(' '))}</li>`).join('')}</ul>`
63-
).join('');
61+
if (ps.length === 1) return inline(ps[0].join(' '));
62+
return ps.map((lines) =>
63+
`<p class="my-2 first:mt-0 last:mb-0">${inline(lines.join(' '))}</p>`).join('');
6464
}
6565

6666
function flushItem() {
6767
if (itemOpen) {
68-
out.push(`<li class="text-fg-muted text-[14px] leading-relaxed">${renderBlocks(blocks)}</li>`);
68+
out.push(`<li class="text-fg-muted text-[14px] leading-relaxed">${renderParas(paras)}</li>`);
6969
itemOpen = false;
70-
blocks = [];
71-
openBlock = null;
70+
paras = [];
71+
open = null;
7272
}
7373
}
7474
function endList() {
@@ -78,12 +78,6 @@ export function renderEntryBody(md: string): string {
7878
function startList() {
7979
if (!inList) { out.push('<ul class="list-disc pl-5 space-y-2 my-3">'); inList = true; }
8080
}
81-
function openItem(first: string) {
82-
itemOpen = true;
83-
blocks = [];
84-
openBlock = null;
85-
pushBlock({ kind: 'p', lines: [first] });
86-
}
8781

8882
for (const raw of lines) {
8983
const line = raw;
@@ -95,46 +89,26 @@ export function renderEntryBody(md: string): string {
9589
out.push(`<h4 class="font-mono text-[11px] uppercase tracking-[0.15em] font-semibold text-fg-subtle mt-4 mb-1.5">${inline(line.slice(3).trim())}</h4>`);
9690
} else if (/^- /.test(line)) {
9791
// A top-level entry, recognised by its column-0 marker. Checking this
98-
// BEFORE the indented-continuation branch is what stops an open item
99-
// swallowing the entry that follows it.
92+
// BEFORE the indented branches is what stops an open item swallowing
93+
// the entry that follows it.
10094
flushItem();
10195
startList();
102-
openItem(line.slice(2).trim());
96+
itemOpen = true;
97+
paras = [];
98+
open = null;
99+
startPara(line.slice(2).trim());
103100
} else if (itemOpen && /^ {2,}[-*] /.test(line)) {
104-
const marker = line.trim()[0];
105-
const text = line.trim().slice(2).trim();
106-
// Resume the TRAILING nested list rather than the open one. A blank
107-
// line between indented bullets is a LOOSE list in CommonMark, still
108-
// one list, and the generator writes exactly that shape (one `*`
109-
// commit subject per blank-separated line). Keying off the open block
110-
// would emit a separate single-item list per bullet, each with its own
111-
// margin, splitting one list into several. A paragraph in between is a
112-
// different matter, and genuinely does start a new list.
113-
//
114-
// The marker has to match to resume, because CommonMark starts a NEW
115-
// list when the bullet character changes. Merging across that would
116-
// join two lists the markdown deliberately separated.
117-
const last = blocks[blocks.length - 1];
118-
if (last && last.kind === 'ul' && last.marker === marker) { last.items.push([text]); openBlock = last; }
119-
else pushBlock({ kind: 'ul', marker, items: [[text]] });
101+
// Its own paragraph, marker dropped. Depth is not read, so a deeper
102+
// run groups exactly like a 2-space one instead of nesting.
103+
startPara(line.trim().slice(2).trim());
120104
} else if (itemOpen && /^ {2,}\S/.test(line)) {
121105
const text = line.trim();
122-
// A lazy continuation of the sub-item when a nested list is open, a
123-
// soft-wrapped line when a paragraph is, and a fresh paragraph when a
124-
// blank line closed whatever came before.
125-
//
126-
// Indentation DEPTH is deliberately not read here, or in the nested
127-
// bullet branch above. Every indented bullet in the corpus sits at two
128-
// spaces, so a deeper run has no instance to render, and honouring
129-
// depth for real means recursive sub-lists. Reading it in one branch
130-
// and not the other is worse than ignoring it in both, since a 4-space
131-
// paragraph would then stay in its bullet while a 4-space bullet is
132-
// still hoisted to a sibling.
133-
if (openBlock && openBlock.kind === 'ul') openBlock.items[openBlock.items.length - 1].push(text);
134-
else if (openBlock && openBlock.kind === 'p') openBlock.lines.push(text);
135-
else pushBlock({ kind: 'p', lines: [text] });
106+
// Soft-wrapped continuation of the open paragraph, or the start of a
107+
// fresh one when a blank line closed the last.
108+
if (open) open.push(text);
109+
else startPara(text);
136110
} else if (line.trim() === '') {
137-
if (itemOpen) openBlock = null;
111+
if (itemOpen) open = null;
138112
else flushItem();
139113
} else {
140114
endList();

website/test/changelog/render-entry.test.ts

Lines changed: 44 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
*
1111
* The whole corpus is the fixture. changelog/ carries both shapes the repo
1212
* produces (hand-written multi-paragraph release notes, and generator output
13-
* that dumps a squashed commit body as indented `*` bullets), so asserting
13+
* that dumps a squashed commit body as indented `*` lines), so asserting
1414
* across every file is what stops one shape being fixed at the other's cost.
15+
* No entry body renders a second level of bullets: the page is one bullet
16+
* per released change, and everything under it is prose.
1517
*/
1618
import test from 'node:test';
1719
import assert from 'node:assert/strict';
@@ -42,8 +44,8 @@ function everyEntryFile(): Array<[string, string]> {
4244
}
4345

4446
/**
45-
* Top-level items carry the entry class; a nested sub-list's items are bare
46-
* `<li>`, so the class is what separates the two levels.
47+
* Entry items carry the entry class. Nothing else in an entry body is an
48+
* `<li>` at all, which is the property the corpus guard below pins.
4749
*/
4850
const entryItems = (html: string) => html.split('<li class="text-fg-muted').slice(1);
4951
const countEntryItems = (html: string) => entryItems(html).length;
@@ -69,16 +71,20 @@ test('a multi-paragraph entry is ONE list item whose body is paragraphs', () =>
6971
);
7072
});
7173

72-
test('a nested sub-list renders as a nested list inside its entry', () => {
74+
test('an indented sub-list renders as paragraphs, not a second level of bullets', () => {
75+
// The changelog is one bullet per released change. A second level of glyphs
76+
// under half the entries is noise, so an indented bullet keeps its grouping
77+
// and loses its marker.
7378
const md = bodyOf(`${CHANGELOG_DIR}server/0.8.57.md`);
7479
const html = renderEntryBody(md);
7580

76-
const nested = html.match(/<ul class="list-disc pl-5 space-y-1[^"]*">[\s\S]*?<\/ul>/g) || [];
77-
assert.equal(nested.length, 1, 'the four indented bullets form one nested list');
78-
assert.equal((nested[0].match(/<li>/g) || []).length, 4);
79-
80-
// It sits inside the entry it belongs to, not beside it.
81-
assert.ok(entryItems(html)[0].includes(nested[0]));
81+
assert.equal(html.match(/<ul/g)?.length, 1, 'only the entry list itself');
82+
const first = entryItems(html)[0];
83+
// The four attack vectors are four paragraphs of the entry they explain.
84+
for (const vector of ['A request target beginning with', 'was honored for any scheme', 'threw', 'supplied the origin outright']) {
85+
assert.ok(first.includes(vector), `kept: ${vector}`);
86+
}
87+
assert.ok(!/<li>/.test(first.slice(0, first.indexOf('</li>') + 5)), 'no bullet inside the entry');
8288
});
8389

8490
test('a top-level entry after an indented block is not absorbed into it', () => {
@@ -98,8 +104,10 @@ test('a top-level entry after an indented block is not absorbed into it', () =>
98104
assert.equal(countEntryItems(html), 2);
99105
assert.match(html, /<strong[^>]*>first<\/strong>/);
100106
assert.match(html, /<strong[^>]*>second<\/strong>/);
101-
// The nested points stayed nested rather than becoming entries of their own.
102-
assert.equal((html.match(/<li>a nested point<\/li>/g) || []).length, 1);
107+
// The indented points stayed inside the first entry, as its body prose,
108+
// rather than becoming entries of their own.
109+
assert.ok(entryItems(html)[0].includes('a nested point'));
110+
assert.ok(entryItems(html)[0].includes('another nested point'));
103111
});
104112

105113
test('a single-line entry still renders as bare text in its item', () => {
@@ -108,85 +116,32 @@ test('a single-line entry still renders as bare text in its item', () => {
108116
assert.ok(!html.includes('<p class="my-2'), 'no paragraph wrapper for a body-less entry');
109117
});
110118

111-
test('blank-separated indented bullets stay ONE nested list', () => {
112-
// The dominant generated shape, and the one the tight-list fixture above
113-
// cannot exercise: the generator writes each commit subject as its own
114-
// ` * ` line separated by a whitespace-only line. CommonMark reads that
115-
// as one LOOSE list. Emitting a single-item list per bullet would give
116-
// each its own margin and visually split the list apart.
119+
test('blank-separated indented bullets become paragraphs of ONE entry', () => {
120+
// The dominant generated shape: the generator writes each squashed commit
121+
// subject as its own ` * ` line separated by a whitespace-only line. Each
122+
// becomes a paragraph, and the entry stays a single bullet.
117123
const md = bodyOf(`${CHANGELOG_DIR}cli/0.10.11.md`);
118124
const html = renderEntryBody(md);
119125

120126
const first = entryItems(html)[0];
121-
const nested = first.match(/<ul class="list-disc pl-5 space-y-1[^"]*">[\s\S]*?<\/ul>/g) || [];
122-
assert.equal(nested.length, 1, 'two blank-separated bullets form one list, not two');
123-
assert.equal((nested[0].match(/<li>/g) || []).length, 2);
124-
});
125-
126-
test('a paragraph between indented bullets does start a new nested list', () => {
127-
// The counterfactual for the rule above. Resuming the trailing list is
128-
// correct across a blank line only; real prose in between separates them.
129-
const html = renderEntryBody([
130-
'- **entry**',
131-
' - first list',
132-
'',
133-
' Prose that interrupts.',
134-
'',
135-
' - second list',
136-
].join('\n'));
137-
138-
const nested = html.match(/<ul class="list-disc pl-5 space-y-1[^"]*">[\s\S]*?<\/ul>/g) || [];
139-
assert.equal(nested.length, 2);
140-
assert.equal(countEntryItems(html), 1);
141-
});
142-
143-
test('a changed bullet marker starts a new nested list', () => {
144-
// CommonMark starts a new list when the bullet character changes, so the
145-
// resume rule has to match on the marker too. Merging across it would join
146-
// two lists the markdown separated on purpose.
147-
const html = renderEntryBody([
148-
'- **entry**',
149-
' - dash item',
150-
'',
151-
' * star item',
152-
].join('\n'));
153-
154-
const nested = html.match(/<ul class="list-disc pl-5 space-y-1[^"]*">[\s\S]*?<\/ul>/g) || [];
155-
assert.equal(nested.length, 2);
156-
assert.equal(countEntryItems(html), 1);
127+
assert.ok(first.includes('feat: enforce scaffold-content removal'));
128+
assert.ok(first.includes('docs: document the no-scaffold-placeholder'));
129+
assert.equal(html.match(/<ul/g)?.length, 2, 'one entry list per section heading, and nothing nested');
157130
});
158131

159-
/**
160-
* Split a changelog body into its entries, each as its own lines. Anything
161-
* before the first column-0 bullet is section chrome and belongs to none.
162-
*/
163-
function sourceEntries(md: string): string[][] {
164-
const entries: string[][] = [];
165-
for (const line of md.split('\n')) {
166-
if (/^- /.test(line)) entries.push([line]);
167-
else if (entries.length) entries[entries.length - 1].push(line);
168-
}
169-
return entries;
170-
}
171-
172-
test('no changelog file renders a fragmented nested list', () => {
173-
// Adjacent nested lists mean one list came out as several, EXCEPT where the
174-
// entry changes bullet character, which legitimately starts a new list. So
175-
// read the markers straight off the source and only demand adjacency-free
176-
// output where there is a single marker. Deliberately not a model of the
177-
// renderer's run-splitting: a guard that recomputes the thing it checks
178-
// cannot fail, and an earlier attempt at one disagreed with the renderer on
179-
// lazy continuations, which would have false-alarmed on the first file to
180-
// use one.
132+
test('no changelog file renders a nested list', () => {
133+
// Whole-corpus form of the two tests above, and the property the page is
134+
// meant to have: the only lists are the per-section entry lists, so an
135+
// entry body is always prose.
181136
for (const [label, md] of everyEntryFile()) {
182-
const items = entryItems(renderEntryBody(md));
183-
const sources = sourceEntries(md);
184-
items.forEach((item, i) => {
185-
const markers = new Set((sources[i] || []).flatMap((l) => /^ {2,}([-*]) /.exec(l)?.[1] ?? []));
186-
if (markers.size > 1) return;
187-
const adjacent = (item.match(/<\/ul><ul class="list-disc pl-5 space-y-1/g) || []).length;
188-
assert.equal(adjacent, 0, `${label}: entry ${i + 1} split one nested list into ${adjacent + 1}`);
189-
});
137+
const html = renderEntryBody(md);
138+
const sections = (md.match(/^## /gm) || []).length || 1;
139+
const lists = (html.match(/<ul/g) || []).length;
140+
assert.ok(lists <= sections, `${label}: ${lists} lists for ${sections} sections, so an entry body rendered one`);
141+
for (const item of entryItems(html)) {
142+
const body = item.slice(0, item.indexOf('</li>') + 5);
143+
assert.ok(!body.includes('<li>'), `${label}: an entry body rendered a bullet`);
144+
}
190145
}
191146
});
192147

@@ -195,9 +150,11 @@ test('a generated entry keeps its commit-body content', () => {
195150
const html = renderEntryBody(md);
196151

197152
assert.equal(countEntryItems(html), countMarkdownEntries(md));
198-
// The ` * ` commit-subject line becomes a nested bullet, not literal text
199-
// with a stray asterisk, and the wrapped prose under it survives.
200-
assert.match(html, /<li>feat: ship @webjsdev\/ui class-helper primitives/);
153+
// The ` * ` commit-subject line becomes body prose with its marker
154+
// dropped, not literal text carrying a stray asterisk, and the wrapped
155+
// prose under it survives.
156+
assert.match(html, /<p class="my-2[^"]*">feat: ship @webjsdev\/ui class-helper primitives/);
157+
assert.ok(!html.includes('* feat: ship'), 'the marker is not left in the text');
201158
assert.match(html, /Add components\/ui\//);
202159
});
203160

0 commit comments

Comments
 (0)