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 */
1618import test from 'node:test' ;
1719import 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 */
4850const entryItems = ( html : string ) => html . split ( '<li class="text-fg-muted' ) . slice ( 1 ) ;
4951const 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 ( / < u l c l a s s = " l i s t - d i s c p l - 5 s p a c e - y - 1 [ ^ " ] * " > [ \s \S ] * ?< \/ u l > / g) || [ ] ;
77- assert . equal ( nested . length , 1 , 'the four indented bullets form one nested list' ) ;
78- assert . equal ( ( nested [ 0 ] . match ( / < l i > / 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 ( / < u l / 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 ( ! / < l i > / . test ( first . slice ( 0 , first . indexOf ( '</li>' ) + 5 ) ) , 'no bullet inside the entry' ) ;
8288} ) ;
8389
8490test ( '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 , / < s t r o n g [ ^ > ] * > f i r s t < \/ s t r o n g > / ) ;
100106 assert . match ( html , / < s t r o n g [ ^ > ] * > s e c o n d < \/ s t r o n g > / ) ;
101- // The nested points stayed nested rather than becoming entries of their own.
102- assert . equal ( ( html . match ( / < l i > a n e s t e d p o i n t < \/ l i > / 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
105113test ( '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 ( / < u l c l a s s = " l i s t - d i s c p l - 5 s p a c e - y - 1 [ ^ " ] * " > [ \s \S ] * ?< \/ u l > / g) || [ ] ;
122- assert . equal ( nested . length , 1 , 'two blank-separated bullets form one list, not two' ) ;
123- assert . equal ( ( nested [ 0 ] . match ( / < l i > / 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 ( / < u l c l a s s = " l i s t - d i s c p l - 5 s p a c e - y - 1 [ ^ " ] * " > [ \s \S ] * ?< \/ u l > / 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 ( / < u l c l a s s = " l i s t - d i s c p l - 5 s p a c e - y - 1 [ ^ " ] * " > [ \s \S ] * ?< \/ u l > / 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 ( / < u l / 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 ( / < \/ u l > < u l c l a s s = " l i s t - d i s c p l - 5 s p a c e - 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 ( / < u l / 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 , / < l i > f e a t : s h i p @ w e b j s d e v \/ u i c l a s s - h e l p e r p r i m i t i v e s / ) ;
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 c l a s s = " m y - 2 [ ^ " ] * " > f e a t : s h i p @ w e b j s d e v \/ u i c l a s s - h e l p e r p r i m i t i v e s / ) ;
157+ assert . ok ( ! html . includes ( '* feat: ship' ) , 'the marker is not left in the text' ) ;
201158 assert . match ( html , / A d d c o m p o n e n t s \/ u i \/ / ) ;
202159} ) ;
203160
0 commit comments