Skip to content

Commit 55281a5

Browse files
tylergraydevTyler Grayclaude
authored
fix(whatsnew): render GFM tables in release notes (#235) (#238)
Closes #235. ## What was wrong `WhatsNewModal`'s hand-rolled `renderMarkdown()` handled headers, bold, italic, code blocks, inline code, links, and bullet lists — but had no table support. GFM tables in release notes (e.g. the "Download" platform table in `release.yml`) fell through and rendered as raw markdown pipes: | Platform | Download | |----------|----------| | Windows | .msi | ## Fix Added a table-handling step in `renderMarkdown` between the list wrapping and newline conversion. Parses the standard GFM 3-part shape — header line, separator line, one-or-more body lines — and emits a styled `<table>` with `<thead>` / `<tbody>`. Honors column alignment specifiers (`:---`, `:---:`, `---:`) via inline `text-align` style. Matched Tailwind classes to the rest of the modal's palette (gray-200/700 borders for the header row, gray-100/800 for body rows). The substitution runs BEFORE the `\n\n` → `</p><p>` and `\n` → `<br>` conversions so the table block's internal newlines survive long enough to be parsed. ## Regression tests added - `should render GFM tables in release notes (#235)` — renders a modal with a 2-column, 2-row table body and asserts the DOM has a real `<table>` with the right header/row counts and cell text. - `should honor table column alignment specifiers` — asserts left / center / right specifiers produce the corresponding `text-align` style on the header cells. ## Test plan - [x] `npx vitest run shared.test.ts` — 31/31 pass (29 + 2 new) - [x] `npm run check` — 53 errors (same as `main` baseline, no new type errors introduced) Co-authored-by: Tyler Gray <tylerg@emergentsoftware.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 78e8006 commit 55281a5

2 files changed

Lines changed: 85 additions & 0 deletions

File tree

src/lib/components/shared/WhatsNewModal.svelte

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,46 @@
5050
.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc">$1</li>')
5151
// Wrap consecutive list items in ul
5252
.replace(/(<li[^>]*>.*<\/li>\n?)+/g, '<ul class="my-2 space-y-1">$&</ul>')
53+
// GFM tables: `| h1 | h2 |\n|---|---|\n| a | b |` -> <table>. Run before
54+
// the \n -> <br> conversion below so newlines inside the table survive.
55+
// Supports column alignment via the separator row (`:---`, `---:`, `:---:`).
56+
.replace(
57+
/^(\|[^\n]+\|)\n(\|[-:| ]+\|)\n((?:\|[^\n]+\|\n?)+)/gm,
58+
(_match: string, headerLine: string, separatorLine: string, bodyBlock: string) => {
59+
const parseCells = (line: string): string[] =>
60+
line.slice(1, -1).split('|').map((c) => c.trim());
61+
const headers = parseCells(headerLine);
62+
const aligns = parseCells(separatorLine).map((sep) => {
63+
const left = sep.startsWith(':');
64+
const right = sep.endsWith(':');
65+
if (left && right) return 'center';
66+
if (right) return 'right';
67+
if (left) return 'left';
68+
return '';
69+
});
70+
const rows: string[][] = bodyBlock.trim().split('\n').map(parseCells);
71+
const styleAttr = (i: number) =>
72+
aligns[i] ? ` style="text-align:${aligns[i]}"` : '';
73+
const ths = headers
74+
.map(
75+
(h, i) =>
76+
`<th class="px-2 py-1 border-b border-gray-200 dark:border-gray-700 font-semibold text-left"${styleAttr(i)}>${h}</th>`
77+
)
78+
.join('');
79+
const trs = rows
80+
.map(
81+
(r: string[]) =>
82+
`<tr>${r
83+
.map(
84+
(c: string, i: number) =>
85+
`<td class="px-2 py-1 border-b border-gray-100 dark:border-gray-800"${styleAttr(i)}>${c}</td>`
86+
)
87+
.join('')}</tr>`
88+
)
89+
.join('');
90+
return `<table class="my-2 border-collapse w-full text-sm"><thead><tr>${ths}</tr></thead><tbody>${trs}</tbody></table>`;
91+
}
92+
)
5393
// Line breaks
5494
.replace(/\n\n/g, '</p><p class="my-2">')
5595
.replace(/\n/g, '<br>');

src/tests/components/shared.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,51 @@ describe('WhatsNewModal Component', () => {
379379
expect(screen.getByText('No release notes available.')).toBeInTheDocument();
380380
(whatsNew as any).isOpen = false;
381381
});
382+
383+
it('should render GFM tables in release notes (#235)', async () => {
384+
const { whatsNew } = await import('$lib/stores/whatsNew.svelte');
385+
(whatsNew as any).isOpen = true;
386+
(whatsNew as any).isLoading = false;
387+
(whatsNew as any).release = {
388+
version: '3.10.0',
389+
body: '## Download\n\n| Platform | Download |\n|----------|----------|\n| Windows | `.msi` |\n| macOS | `.dmg` |\n',
390+
htmlUrl: 'https://github.com/test',
391+
publishedAt: '2026-05-16'
392+
};
393+
render(WhatsNewModal);
394+
// The raw markdown pipes should NOT be visible as text — they should be
395+
// inside a <table>. This is the regression #235 reported: tables rendered
396+
// as raw `|---|---|` literals.
397+
const table = document.querySelector('table');
398+
expect(table).toBeInTheDocument();
399+
expect(table?.querySelectorAll('thead th')).toHaveLength(2);
400+
expect(table?.querySelectorAll('tbody tr')).toHaveLength(2);
401+
expect(table?.querySelector('thead')?.textContent).toContain('Platform');
402+
expect(table?.querySelector('thead')?.textContent).toContain('Download');
403+
expect(table?.querySelector('tbody')?.textContent).toContain('Windows');
404+
expect(table?.querySelector('tbody')?.textContent).toContain('macOS');
405+
(whatsNew as any).isOpen = false;
406+
(whatsNew as any).release = null;
407+
});
408+
409+
it('should honor table column alignment specifiers', async () => {
410+
const { whatsNew } = await import('$lib/stores/whatsNew.svelte');
411+
(whatsNew as any).isOpen = true;
412+
(whatsNew as any).isLoading = false;
413+
(whatsNew as any).release = {
414+
version: '3.10.0',
415+
body: '| L | C | R |\n|:---|:---:|---:|\n| a | b | c |\n',
416+
htmlUrl: 'https://github.com/test',
417+
publishedAt: '2026-05-16'
418+
};
419+
render(WhatsNewModal);
420+
const ths = document.querySelectorAll('table thead th');
421+
expect(ths[0].getAttribute('style')).toBe('text-align:left');
422+
expect(ths[1].getAttribute('style')).toBe('text-align:center');
423+
expect(ths[2].getAttribute('style')).toBe('text-align:right');
424+
(whatsNew as any).isOpen = false;
425+
(whatsNew as any).release = null;
426+
});
382427
});
383428

384429
describe('Shared index.ts exports', () => {

0 commit comments

Comments
 (0)