Skip to content

Commit 68bff82

Browse files
authored
fix(changelog): preserve fenced code blocks in custom PR changelog entries (#771)
Fenced code blocks (```lang ... ```) in custom `### Changelog Entry` PR description sections were silently dropped during changelog generation. ## Root Cause Two functions in `src/utils/changelog.ts` only handled a subset of `marked.lexer` token types: 1. **`extractNestedContent()`** — only handled `list` tokens. Code blocks inside list items (the reported scenario) were silently skipped. 2. **`parseTokensToEntries()`** — only handled `list` and `paragraph`. Standalone code blocks after paragraphs were dropped. ## Fix Added `code` token handling to both functions: - `extractNestedContent()`: renders code blocks as indented fenced blocks with proper nesting - `parseTokensToEntries()`: attaches standalone code blocks as `nestedContent` on the previous entry ## Changelog Entry - Fix fenced code blocks being silently dropped from custom changelog entries in PR descriptions
1 parent 0a24524 commit 68bff82

File tree

2 files changed

+143
-0
lines changed

2 files changed

+143
-0
lines changed

src/utils/__tests__/changelog-extract.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,108 @@ Intro paragraph:
197197
});
198198
});
199199
});
200+
201+
describe('extractChangelogEntry code blocks', () => {
202+
it('preserves fenced code block inside a bullet item', () => {
203+
const prBody = `### Changelog Entry
204+
205+
- Added a new \`foo\` function:
206+
\`\`\`go
207+
func foo() {
208+
fmt.Println("Hello")
209+
}
210+
\`\`\`
211+
- Another entry`;
212+
213+
const result = extractChangelogEntry(prBody);
214+
expect(result).toHaveLength(2);
215+
expect(result![0].text).toBe('Added a new `foo` function:');
216+
expect(result![0].nestedContent).toContain('```go');
217+
expect(result![0].nestedContent).toContain('func foo()');
218+
expect(result![1].text).toBe('Another entry');
219+
expect(result![1].nestedContent).toBeUndefined();
220+
});
221+
222+
it('preserves code block without language specifier', () => {
223+
const prBody = `### Changelog Entry
224+
225+
- Example code:
226+
\`\`\`
227+
some code here
228+
\`\`\``;
229+
230+
const result = extractChangelogEntry(prBody);
231+
expect(result).toHaveLength(1);
232+
expect(result![0].text).toBe('Example code:');
233+
expect(result![0].nestedContent).toContain('```');
234+
expect(result![0].nestedContent).toContain('some code here');
235+
// Should not have a language tag after the opening fence
236+
expect(result![0].nestedContent).not.toMatch(/```\S/);
237+
});
238+
239+
it('preserves standalone code block after paragraph', () => {
240+
const prBody = `### Changelog Entry
241+
242+
Here is a change with code:
243+
244+
\`\`\`go
245+
func foo() {}
246+
\`\`\``;
247+
248+
const result = extractChangelogEntry(prBody);
249+
expect(result).toHaveLength(1);
250+
expect(result![0].text).toBe('Here is a change with code:');
251+
expect(result![0].nestedContent).toContain('```go');
252+
expect(result![0].nestedContent).toContain('func foo() {}');
253+
// Verify 2-space indentation for proper nesting under list items
254+
expect(result![0].nestedContent).toBe(' ```go\n func foo() {}\n ```');
255+
});
256+
257+
it('preserves code block in loose list item', () => {
258+
const prBody = `### Changelog Entry
259+
260+
- First entry with code:
261+
262+
\`\`\`go
263+
func foo() {}
264+
\`\`\`
265+
266+
- Second entry`;
267+
268+
const result = extractChangelogEntry(prBody);
269+
expect(result).toHaveLength(2);
270+
expect(result![0].text).toBe('First entry with code:');
271+
expect(result![0].nestedContent).toContain('```go');
272+
expect(result![0].nestedContent).toContain('func foo() {}');
273+
expect(result![1].text).toBe('Second entry');
274+
});
275+
276+
it('preserves nested list items alongside code blocks', () => {
277+
const prBody = `### Changelog Entry
278+
279+
- Main entry
280+
- Sub item
281+
\`\`\`js
282+
const x = 1;
283+
\`\`\``;
284+
285+
const result = extractChangelogEntry(prBody);
286+
expect(result).toHaveLength(1);
287+
expect(result![0].text).toBe('Main entry');
288+
expect(result![0].nestedContent).toContain('- Sub item');
289+
expect(result![0].nestedContent).toContain('```js');
290+
expect(result![0].nestedContent).toContain('const x = 1;');
291+
});
292+
293+
it('skips orphaned code block with no preceding entry', () => {
294+
const prBody = `### Changelog Entry
295+
296+
\`\`\`go
297+
func foo() {}
298+
\`\`\``;
299+
300+
const result = extractChangelogEntry(prBody);
301+
// A bare code block without descriptive text is not a meaningful entry
302+
expect(result).toBeNull();
303+
});
304+
});

src/utils/changelog.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,23 @@ export function extractChangelogEntry(
312312
return parseTokensToEntries(contentTokens);
313313
}
314314

315+
/**
316+
* Renders a fenced code block token as indented markdown lines.
317+
* Each line (opening fence, content lines, closing fence) is prefixed with `indent`.
318+
*/
319+
function renderIndentedCodeBlock(
320+
codeToken: Tokens.Code,
321+
indent = ' ',
322+
): string[] {
323+
const fence = codeToken.lang ? `\`\`\`${codeToken.lang}` : '```';
324+
const lines = [`${indent}${fence}`];
325+
for (const line of codeToken.text.split('\n')) {
326+
lines.push(`${indent}${line}`);
327+
}
328+
lines.push(`${indent}\`\`\``);
329+
return lines;
330+
}
331+
315332
/**
316333
* Recursively extracts nested content from a list item's tokens.
317334
*/
@@ -337,6 +354,9 @@ function extractNestedContent(tokens: Token[]): string {
337354
nestedLines.push(indentedDeeper);
338355
}
339356
}
357+
} else if (token.type === 'code') {
358+
// Preserve fenced code blocks (e.g., ```go ... ```) as nested content
359+
nestedLines.push(...renderIndentedCodeBlock(token as Tokens.Code));
340360
}
341361
}
342362

@@ -390,6 +410,24 @@ function parseTokensToEntries(tokens: Token[]): ChangelogEntryItem[] | null {
390410
if (text) {
391411
entries.push({ text });
392412
}
413+
} else if (token.type === 'code') {
414+
// Standalone code blocks become nested content on the previous entry,
415+
// or a new entry if there is no previous entry.
416+
// Indent with 2 spaces so the block nests properly under the list item
417+
// in the final markdown (consistent with extractNestedContent).
418+
const codeBlock = renderIndentedCodeBlock(token as Tokens.Code).join(
419+
'\n',
420+
);
421+
422+
if (entries.length > 0) {
423+
// Attach to previous entry as nested content
424+
const prev = entries[entries.length - 1];
425+
prev.nestedContent = prev.nestedContent
426+
? `${prev.nestedContent}\n${codeBlock}`
427+
: codeBlock;
428+
}
429+
// If no previous entry exists, skip the orphaned code block — a bare
430+
// code block without descriptive text isn't a meaningful changelog entry.
393431
}
394432
}
395433

0 commit comments

Comments
 (0)