Skip to content

Commit a98ef1f

Browse files
committed
fix(changelog): preserve fenced code blocks in custom PR changelog entries
Code blocks (```lang ... ```) in custom Changelog Entry sections of PR descriptions were silently dropped during parsing. Root cause: two functions only handled a subset of marked.lexer token types: 1. extractNestedContent() only handled 'list' tokens — code blocks inside list items were skipped 2. parseTokensToEntries() only handled 'list' and 'paragraph' — standalone code blocks after paragraphs were skipped Fix: add 'code' token handling to both functions: - In extractNestedContent: render as indented fenced code block (2-space indent, matching sub-bullet level), properly handled during recursive indentation for deeper nesting - In parseTokensToEntries: attach standalone code blocks as nestedContent on the previous entry, or create a new entry if no previous one exists
1 parent adb112b commit a98ef1f

File tree

2 files changed

+117
-0
lines changed

2 files changed

+117
-0
lines changed

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

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,94 @@ 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+
});
254+
255+
it('preserves code block in loose list item', () => {
256+
const prBody = `### Changelog Entry
257+
258+
- First entry with code:
259+
260+
\`\`\`go
261+
func foo() {}
262+
\`\`\`
263+
264+
- Second entry`;
265+
266+
const result = extractChangelogEntry(prBody);
267+
expect(result).toHaveLength(2);
268+
expect(result![0].text).toBe('First entry with code:');
269+
expect(result![0].nestedContent).toContain('```go');
270+
expect(result![0].nestedContent).toContain('func foo() {}');
271+
expect(result![1].text).toBe('Second entry');
272+
});
273+
274+
it('preserves nested list items alongside code blocks', () => {
275+
const prBody = `### Changelog Entry
276+
277+
- Main entry
278+
- Sub item
279+
\`\`\`js
280+
const x = 1;
281+
\`\`\``;
282+
283+
const result = extractChangelogEntry(prBody);
284+
expect(result).toHaveLength(1);
285+
expect(result![0].text).toBe('Main entry');
286+
expect(result![0].nestedContent).toContain('- Sub item');
287+
expect(result![0].nestedContent).toContain('```js');
288+
expect(result![0].nestedContent).toContain('const x = 1;');
289+
});
290+
});

src/utils/changelog.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,15 @@ function extractNestedContent(tokens: Token[]): string {
337337
nestedLines.push(indentedDeeper);
338338
}
339339
}
340+
} else if (token.type === 'code') {
341+
// Preserve fenced code blocks (e.g., ```go ... ```) as nested content
342+
const codeToken = token as Tokens.Code;
343+
const fence = codeToken.lang ? `\`\`\`${codeToken.lang}` : '```';
344+
nestedLines.push(` ${fence}`);
345+
for (const line of codeToken.text.split('\n')) {
346+
nestedLines.push(` ${line}`);
347+
}
348+
nestedLines.push(' ```');
340349
}
341350
}
342351

@@ -390,6 +399,23 @@ function parseTokensToEntries(tokens: Token[]): ChangelogEntryItem[] | null {
390399
if (text) {
391400
entries.push({ text });
392401
}
402+
} else if (token.type === 'code') {
403+
// Standalone code blocks become nested content on the previous entry,
404+
// or a new entry if there is no previous entry
405+
const codeToken = token as Tokens.Code;
406+
const fence = codeToken.lang ? `\`\`\`${codeToken.lang}` : '```';
407+
const codeBlock = [fence, codeToken.text, '```'].join('\n');
408+
409+
if (entries.length > 0) {
410+
// Attach to previous entry as nested content
411+
const prev = entries[entries.length - 1];
412+
prev.nestedContent = prev.nestedContent
413+
? `${prev.nestedContent}\n${codeBlock}`
414+
: codeBlock;
415+
} else {
416+
// No previous entry - create one with the code block as content
417+
entries.push({ text: '', nestedContent: codeBlock });
418+
}
393419
}
394420
}
395421

0 commit comments

Comments
 (0)