From f4e447028da87342126cff6db252b1bdba7010be Mon Sep 17 00:00:00 2001 From: Tyler Gray Date: Sat, 16 May 2026 12:12:02 -0400 Subject: [PATCH] fix(whatsnew): render GFM tables in release notes (#235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` with `` / ``. 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` → `

` and `\n` → `
` 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 `

` 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: Claude Opus 4.7 (1M context) --- .../components/shared/WhatsNewModal.svelte | 40 +++++++++++++++++ src/tests/components/shared.test.ts | 45 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/lib/components/shared/WhatsNewModal.svelte b/src/lib/components/shared/WhatsNewModal.svelte index 29c28b43..27689a15 100644 --- a/src/lib/components/shared/WhatsNewModal.svelte +++ b/src/lib/components/shared/WhatsNewModal.svelte @@ -50,6 +50,46 @@ .replace(/^- (.+)$/gm, '
  • $1
  • ') // Wrap consecutive list items in ul .replace(/(]*>.*<\/li>\n?)+/g, '
      $&
    ') + // GFM tables: `| h1 | h2 |\n|---|---|\n| a | b |` ->
    . Run before + // the \n ->
    conversion below so newlines inside the table survive. + // Supports column alignment via the separator row (`:---`, `---:`, `:---:`). + .replace( + /^(\|[^\n]+\|)\n(\|[-:| ]+\|)\n((?:\|[^\n]+\|\n?)+)/gm, + (_match: string, headerLine: string, separatorLine: string, bodyBlock: string) => { + const parseCells = (line: string): string[] => + line.slice(1, -1).split('|').map((c) => c.trim()); + const headers = parseCells(headerLine); + const aligns = parseCells(separatorLine).map((sep) => { + const left = sep.startsWith(':'); + const right = sep.endsWith(':'); + if (left && right) return 'center'; + if (right) return 'right'; + if (left) return 'left'; + return ''; + }); + const rows: string[][] = bodyBlock.trim().split('\n').map(parseCells); + const styleAttr = (i: number) => + aligns[i] ? ` style="text-align:${aligns[i]}"` : ''; + const ths = headers + .map( + (h, i) => + `` + ) + .join(''); + const trs = rows + .map( + (r: string[]) => + `${r + .map( + (c: string, i: number) => + `` + ) + .join('')}` + ) + .join(''); + return `
    ${h}
    ${c}
    ${ths}${trs}
    `; + } + ) // Line breaks .replace(/\n\n/g, '

    ') .replace(/\n/g, '
    '); diff --git a/src/tests/components/shared.test.ts b/src/tests/components/shared.test.ts index 9d10e077..78672497 100644 --- a/src/tests/components/shared.test.ts +++ b/src/tests/components/shared.test.ts @@ -379,6 +379,51 @@ describe('WhatsNewModal Component', () => { expect(screen.getByText('No release notes available.')).toBeInTheDocument(); (whatsNew as any).isOpen = false; }); + + it('should render GFM tables in release notes (#235)', async () => { + const { whatsNew } = await import('$lib/stores/whatsNew.svelte'); + (whatsNew as any).isOpen = true; + (whatsNew as any).isLoading = false; + (whatsNew as any).release = { + version: '3.10.0', + body: '## Download\n\n| Platform | Download |\n|----------|----------|\n| Windows | `.msi` |\n| macOS | `.dmg` |\n', + htmlUrl: 'https://github.com/test', + publishedAt: '2026-05-16' + }; + render(WhatsNewModal); + // The raw markdown pipes should NOT be visible as text — they should be + // inside a . This is the regression #235 reported: tables rendered + // as raw `|---|---|` literals. + const table = document.querySelector('table'); + expect(table).toBeInTheDocument(); + expect(table?.querySelectorAll('thead th')).toHaveLength(2); + expect(table?.querySelectorAll('tbody tr')).toHaveLength(2); + expect(table?.querySelector('thead')?.textContent).toContain('Platform'); + expect(table?.querySelector('thead')?.textContent).toContain('Download'); + expect(table?.querySelector('tbody')?.textContent).toContain('Windows'); + expect(table?.querySelector('tbody')?.textContent).toContain('macOS'); + (whatsNew as any).isOpen = false; + (whatsNew as any).release = null; + }); + + it('should honor table column alignment specifiers', async () => { + const { whatsNew } = await import('$lib/stores/whatsNew.svelte'); + (whatsNew as any).isOpen = true; + (whatsNew as any).isLoading = false; + (whatsNew as any).release = { + version: '3.10.0', + body: '| L | C | R |\n|:---|:---:|---:|\n| a | b | c |\n', + htmlUrl: 'https://github.com/test', + publishedAt: '2026-05-16' + }; + render(WhatsNewModal); + const ths = document.querySelectorAll('table thead th'); + expect(ths[0].getAttribute('style')).toBe('text-align:left'); + expect(ths[1].getAttribute('style')).toBe('text-align:center'); + expect(ths[2].getAttribute('style')).toBe('text-align:right'); + (whatsNew as any).isOpen = false; + (whatsNew as any).release = null; + }); }); describe('Shared index.ts exports', () => {