Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 81 additions & 17 deletions website/modules/changelog/utils/render-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,26 @@ function inline(s: string): string {
return out;
}

/** One paragraph of an entry's body, as its soft-wrapped source lines. */
type Para = string[];
/**
* One paragraph of an entry's body, as its soft-wrapped source lines plus the
* indent depth it was opened at. Depth 1 is the entry's own level.
*/
type Para = { depth: number; lines: string[] };

/**
* Depth a bullet's leading indent states. Two spaces per level, clamped at 3
* because a release note has no legitimate fourth level and the clamp is what
* stops a pathological source file emitting a runaway ladder.
*/
function depthOf(indent: number): number { return Math.min(Math.floor(indent / 2), 3); }

/**
* Depth as a left inset, from a literal lookup. Tailwind v4 scans this tree
* for complete literal class strings, so a computed `pl-${depth * 4}` would
* generate no utility at all and the inset would silently do nothing while
* every test asserting on the class name still passed.
*/
const INSET: Record<number, string> = { 1: '', 2: ' pl-4', 3: ' pl-8' };

/** Render the body of one changelog entry: h1 / h2 / bulleted lists / paragraphs. */
export function renderEntryBody(md: string): string {
Expand All @@ -47,8 +65,32 @@ export function renderEntryBody(md: string): string {
// changelog is one bullet per released change, and a second level of
// glyphs under half of them is noise: the generator writes each squashed
// commit subject as its own `*` line, which restated the entry title above
// it in 119 of the corpus's 378 indented bullets. So indentation controls
// grouping here, never bullet depth, and no entry body renders a list.
// it in 119 of the corpus's 378 indented bullets. So no entry body renders
// a list, at any depth.
//
// Depth is still REPRESENTED, as a left inset on the paragraph and nothing
// else (no marker, no type-size change, no rule), so a child point reads as
// subordinate to its parent rather than as its parent's peer. The rule both
// indented branches follow, stated once: only a bullet marker ESTABLISHES
// depth, so an unmarked line can inherit or go shallower but never deeper.
//
// The corpus decided the inheriting half, and the numbers are worth stating
// exactly, because the loose version of them ("37 lines, 10 files") reads
// as much stronger evidence than it is. There are zero bullets at four or
// more spaces across the 229 entry files, and 37 NON-bullet lines at four
// or more, spread over 10 files. All 37 render with no inset, but by two
// different mechanisms: 31 of them are soft wraps that join the paragraph
// above through the depth-blind branch below, which reads no indent under
// any implementation. Only 6 lines, in 4 files, actually reach the
// fresh-paragraph branch, and letting THAT branch read its own indent
// outright is what would re-render them. They are also not the wrapped
// prose the shorthand suggests: they are indented code blocks (a component
// class, an `export const metadata = {`) and one alignment table. Measured
// by rendering the whole corpus both ways, not by reading the sources.
//
// The shallower half stops the opposite failure: closing prose written back
// at the entry level after a deeper bullet must not stay dragged under that
// bullet.
let itemOpen = false;
let paras: Para[] = [];
let open: Para | null = null;
Expand All @@ -59,14 +101,20 @@ export function renderEntryBody(md: string): string {
// leaves `open` narrowed to `null` at the read below and the truthiness
// guard there narrows it to `never`. Assigning at each call site keeps the
// write in the enclosing function's own control-flow graph.
function startPara(text: string): Para { const p: Para = [text]; paras.push(p); return p; }
function startPara(text: string, depth: number): Para {
const p: Para = { depth, lines: [text] };
paras.push(p);
return p;
}

function renderParas(ps: Para[]): string {
// The overwhelmingly common entry is a single line with no body. Emit it
// bare so its markup is unchanged by the multi-paragraph support.
if (ps.length === 1) return inline(ps[0].join(' '));
return ps.map((lines) =>
`<p class="my-2 first:mt-0 last:mb-0">${inline(lines.join(' '))}</p>`).join('');
// bare so its markup is unchanged by the multi-paragraph support. Only
// the column-0 branch can open an item's first paragraph, so a lone
// paragraph is always depth 1; the guard says so rather than assuming it.
if (ps.length === 1 && ps[0].depth === 1) return inline(ps[0].lines.join(' '));
return ps.map((p) =>
`<p class="my-2 first:mt-0 last:mb-0${INSET[p.depth] ?? ''}">${inline(p.lines.join(' '))}</p>`).join('');
}

function flushItem() {
Expand Down Expand Up @@ -101,17 +149,33 @@ export function renderEntryBody(md: string): string {
startList();
itemOpen = true;
paras = [];
open = startPara(line.slice(2).trim());
open = startPara(line.slice(2).trim(), 1);
} else if (itemOpen && /^ {2,}[-*] /.test(line)) {
// Its own paragraph, marker dropped. Depth is not read, so a deeper
// run groups exactly like a 2-space one instead of nesting.
open = startPara(line.trim().slice(2).trim());
// Its own paragraph, marker dropped, at the depth its own indent
// states. This is the branch that ESTABLISHES depth.
const indent = (/^( +)/.exec(line)?.[1] ?? '').length;
open = startPara(line.trim().slice(2).trim(), depthOf(indent));
} else if (itemOpen && /^ {2,}\S/.test(line)) {
const text = line.trim();
// Soft-wrapped continuation of the open paragraph, or the start of a
// fresh one when a blank line closed the last.
if (open) open.push(text);
else open = startPara(text);
// Soft-wrapped continuation of the open paragraph, which simply joins
// it and reads no indent at all, or the start of a fresh one when a
// blank line closed the last.
//
// A fresh one is capped at the depth of the paragraph BEFORE it, so it
// inherits or goes shallower but never deeper. Note the ceiling is that
// one paragraph, not the deepest level any bullet in the item reached:
// once the item steps back out to the entry level, a later unmarked
// line cannot climb back in, which is the conservative direction for a
// signal as weak as leading whitespace. Both halves earn their keep,
// and each has its own test. The cap keeps the 6 corpus lines that
// reach this branch out of an inset; the shallower direction is what
// stops closing prose written back at the entry level from being
// dragged under the last deep bullet.
if (open) open.lines.push(text);
else {
const last = paras.length ? paras[paras.length - 1].depth : 1;
open = startPara(text, Math.min(depthOf((/^( +)/.exec(line)?.[1] ?? '').length), last));
}
} else if (line.trim() === '') {
if (itemOpen) open = null;
else flushItem();
Expand Down
139 changes: 138 additions & 1 deletion website/test/changelog/render-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,17 @@
* that dumps a squashed commit body as indented `*` lines), so asserting
* across every file is what stops one shape being fixed at the other's cost.
* No entry body renders a second level of bullets: the page is one bullet
* per released change, and everything under it is prose.
* per released change, and everything under it is prose. Depth is still
* represented, as a left inset on the paragraph, so a child point reads as
* subordinate rather than as its parent's peer. Only a bullet marker
* establishes that depth, so an unmarked line inherits or goes shallower but
* never deeper. Both directions have their own case below, because both are
* real: a deep line with no bullet at its depth gains no inset, and closing
* prose written back at the entry level is not left dragged under the last
* deep bullet.
*
* The corpus has zero bullets past two spaces, so the depth cases below are
* necessarily synthetic. A green corpus run proves nothing about them.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
Expand Down Expand Up @@ -129,6 +139,133 @@ test('blank-separated indented bullets become paragraphs of ONE entry', () => {
assert.equal(html.match(/<ul/g)?.length, 2, 'one entry list per section heading, and nothing nested');
});

test('a child point is inset, its parent and its parent peer are not', () => {
// Synthetic by necessity: the corpus has no bullet past two spaces, so this
// behaviour has no real fixture. Before the inset, `child of parent` and
// `sibling of parent` rendered identically and a reader could not tell
// which was subordinate to which.
const html = renderEntryBody([
'- **entry**',
' - parent point',
' - child of parent',
' - second child',
' - sibling of parent',
].join('\n'));

const para = (text: string) => {
const m = new RegExp(`<p class="([^"]*)">${text}</p>`).exec(html);
assert.ok(m, `expected a paragraph for "${text}" in ${html}`);
return m[1];
};

assert.ok(!para('parent point').includes('pl-'), 'a 2-space bullet keeps the entry level');
assert.ok(!para('sibling of parent').includes('pl-'), 'a peer of the parent is at the parent level');
assert.ok(para('child of parent').includes('pl-4'), 'a 4-space bullet is inset one level');
assert.ok(para('second child').includes('pl-4'));
assert.notEqual(para('child of parent'), para('sibling of parent'), 'a child and a peer render differently');
// Still no second level of glyphs: the depth is the inset, nothing else.
assert.ok(!/<li>/.test(html));
assert.equal(html.match(/<ul/g)?.length, 1, 'only the entry list itself');
});

test('bullet depth clamps at three levels', () => {
const html = renderEntryBody([
'- **entry**',
' - six spaces',
' - ten spaces',
' - sixteen spaces',
].join('\n'));

for (const text of ['six spaces', 'ten spaces', 'sixteen spaces']) {
const m = new RegExp(`<p class="([^"]*)">${text}</p>`).exec(html);
assert.ok(m, `expected a paragraph for "${text}"`);
assert.ok(m[1].includes('pl-8'), `${text}: clamped to depth 3`);
}
// The `includes('pl-8')` loop above IS the clamp's guard: drop the clamp
// and the 10-space bullet asks for depth 5, which the inset lookup has no
// key for, so it renders with no inset at all and the loop reds. An extra
// "nothing deeper than pl-8" assertion would add nothing, since the lookup
// can only ever emit the three classes it holds.
});

test('closing prose written back at the entry level is not dragged under a deeper bullet', () => {
// The other half of the depth rule. An unmarked line inherits so the
// corpus's wrapped prose gains no inset, but it must still be able to go
// SHALLOWER, or a paragraph the author wrote at column 2 stays inset under
// whatever bullet happened to precede it.
const html = renderEntryBody([
'- **entry**',
' - parent point',
' - child of parent',
'',
' Closing prose written at the entry level.',
].join('\n'));

const closing = /<p class="([^"]*)">Closing prose written at the entry level\.<\/p>/.exec(html);
assert.ok(closing, 'expected the closing paragraph');
assert.ok(!closing[1].includes('pl-'), 'a column-2 paragraph sits at the entry level');
// The deeper bullet it follows keeps its own inset.
assert.match(html, /<p class="[^"]*pl-4[^"]*">child of parent<\/p>/);
});

test('a paragraph after a blank line cannot invent a level no bullet established', () => {
// The inheriting half, as a unit rather than via the corpus. Deliberately
// NOT described as standing for the corpus's 37 deep non-bullet lines: 31
// of those are soft wraps that take the join branch instead and would pass
// this either way. Only 6 lines, in 4 files, reach the branch under test,
// and they are indented code blocks rather than prose. What this pins is
// the rule itself, that a line with no bullet at its depth does not claim
// that depth.
const html = renderEntryBody([
'- **entry**',
' * commit subject line',
'',
' Wrapped prose the author aligned under the subject above it.',
].join('\n'));

const wrapped = /<p class="([^"]*)">Wrapped prose the author aligned/.exec(html);
assert.ok(wrapped, 'expected the wrapped paragraph');
assert.ok(!wrapped[1].includes('pl-'), 'no bullet established depth 2, so the prose does not claim it');
});

test('a 4-space soft wrap joins its bullet paragraph instead of reading its own indent', () => {
// cli/0.10.30.md wraps prose under a 2-space bullet at four spaces. This is
// the JOIN branch, which reads no indent at all, and it is the shape 31 of
// the corpus's 37 deep non-bullet lines take. The fresh-paragraph branch is
// covered separately above; conflating the two overstates what either
// case proves.
const html = renderEntryBody(bodyOf(`${CHANGELOG_DIR}cli/0.10.30.md`));

// The captured body spans inline markup (the glob in this line trips the
// italic rule), so match through tags rather than up to the first one.
const wrapped = /<p class="([^"]*)">#794: Fix block-comment-close bug([\s\S]*?)<\/p>/.exec(html);
assert.ok(wrapped, 'the bullet and its wrapped lines are one paragraph');
assert.ok(!wrapped[1].includes('pl-'), 'the wrapped continuation gains no inset');
assert.ok(wrapped[2].includes('closed the enclosing'), 'the 4-space lines stayed in that paragraph');
});

test('a corpus entry file insets only when its source carries a deep bullet', () => {
// The invariance guard for the 229 files. Every indented bullet on disk
// sits at exactly two spaces today, so every file must render with no inset
// at all, exactly as it did before depth was represented.
//
// Phrased against the SOURCE rather than as a flat "no file insets",
// because the corpus is generated data, not a fixture. backfill-changelog
// prefixes each commit-body line with two spaces, so a commit body carrying
// its own nested bullet lands at four and legitimately insets. A flat
// assertion would red the next release PR for rendering exactly right.
// This stays a coarse presence check, never a re-implementation of the
// renderer's run-splitting: the counterfactual that proves it still bites
// is making the continuation branch read its own indent, which insets files
// whose source has no deep bullet at all and reds this.
for (const [label, md] of everyEntryFile()) {
const deepBullets = md.split('\n').filter((l) => /^ {4,}[-*] /.test(l)).length;
const insets = (renderEntryBody(md).match(/\bpl-[48]\b/g) || []).length;
if (deepBullets === 0) assert.equal(insets, 0, `${label}: inset with no deep bullet in its source`);
else assert.ok(insets > 0, `${label}: ${deepBullets} deep bullets rendered flat`);
}
});

test('no changelog file renders a nested list', () => {
// Whole-corpus form of the two tests above, and the property the page is
// meant to have: the only lists are the per-section entry lists, so an
Expand Down
Loading