Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(docs): markdown export — convert TipTap v3 tables to GFM (#716)

Closes #716

scott 8fec14b4 d24d8c47

+113 -2
+1
CHANGELOG.md
··· 8 8 ## [Unreleased] 9 9 10 10 ### Fixed 11 + - Docs: markdown source view + Export Markdown produce GFM tables instead of raw HTML (v0.62.9, #716). TipTap v3 emits `<table><colgroup><col>...<tbody><tr><th><p>header</p></th>...</tbody></table>` — header in `<tbody>` (no `<thead>`), cells wrapped in `<p>`. turndown-plugin-gfm refused to convert that shape so the entire `<table>` fell through to raw HTML in .md exports and the MD source view. Added `normalizeTipTapTables()` in `src/docs/markdown-export.ts` that pre-processes the HTML before turndown: drops `<colgroup>`, unwraps single-`<p>` cells, and promotes the first `<tr>` to `<thead>` when it has `<th>` cells. Pure string-based so it runs in both browser and Node. 3 regression tests in `tests/markdown-export.test.ts` cover the TipTap v3 shape. (#716) 11 12 - Topbar status chips — unified Synced + E2EE indicators across slides / forms / diagrams / calendar (v0.62.7, #695). Docs and sheets already rendered three chips (Saved / Synced / E2EE) but slides, forms, diagrams, and calendar only showed the shared Saved indicator, leaving users unable to tell whether the document had synced to the server or whether it was encrypted. Extracted the Synced-chip wiring into `src/lib/status-chips.ts` (`wireStatusChips({ provider })`) that listens to the provider's `status` + `sync` events and flips `#status-dot` + `#status-text` — same pattern docs and sheets have used inline. Added the matching HTML (two `.status-indicator` spans) to all four editor templates that were missing them, and wired `wireStatusChips` into each editor's `main.ts` next to `wireSaveStatus`. 9 regression tests in `tests/status-chips.test.ts`: 3 for the helper behavior (status toggle, sync transition, no-op when host markup absent) + 6 that scan every editor template for both the Synced status-indicator and the E2EE chip markup so no editor can silently regress. (#695) 12 13 - Sheets: formula-bar input now runs the same auto-format parser as the cell-editor input (v0.62.8, #711). Before: typing `$100` into the formula bar stored the raw string `"$100"`, so any downstream formula like `=A1*2` silently evaluated to `0`. The cell-editor path in `src/sheets/cell-editing.ts` called `detectAndParseEntry()` to parse `$100`→`100`/`currency`, `75%`→`0.75`/`percent`, `1,234`→`1234`/`number`, `2026-03-15`→timestamp/`date`; the formula-bar path in `src/sheets/formula-bar-ui.ts:115` did plain `Number(raw)` instead, which `NaN`'d on those patterns and fell through to storing the raw string. `commitFormulaBar()` now mirrors `commitEdit()`'s full parse + format-stamp logic (including the "existing explicit format wins" rule so typing `42` into a currency cell still lands as a plain number under the existing format). 8 regression tests in `tests/sheets-formula-bar-autoformat.test.ts` cover the parse matrix: currency, percent, comma-number, ISO date, plain number, formula passthrough, existing-format override, and non-matching text. Caught live by entering `$100` in A1 via the formula bar and observing `=A1*2` → `0`. (#711) 13 14
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.62.8", 3 + "version": "0.62.9", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+55 -1
src/docs/markdown-export.ts
··· 85 85 const turndownService = createTurndownService(); 86 86 87 87 /** 88 + * Normalize TipTap v3 table markup into a shape turndown-plugin-gfm can 89 + * convert to GFM. TipTap v3 emits: 90 + * 91 + * <table><colgroup><col><col></colgroup> 92 + * <tbody> 93 + * <tr><th><p>header</p></th>...</tr> 94 + * <tr><td><p>cell</p></td>...</tr> 95 + * </tbody> 96 + * </table> 97 + * 98 + * turndown-plugin-gfm's table rule requires a <thead> and bails when header 99 + * cells sit in <tbody>, so without this normalization the whole <table> 100 + * falls through to raw HTML in the markdown output. See #716. 101 + */ 102 + function normalizeTipTapTables(html: string): string { 103 + if (!/<table/i.test(html)) return html; 104 + 105 + let out = html; 106 + 107 + // 1. Drop <colgroup>...</colgroup> — only carries min-width hints that 108 + // have no markdown equivalent and confuses turndown-plugin-gfm's row 109 + // walker. 110 + out = out.replace(/<colgroup\b[^>]*>[\s\S]*?<\/colgroup>/gi, ''); 111 + 112 + // 2. Unwrap single-paragraph cells — <td...><p>text</p></td> → <td>text</td> 113 + // (same for <th>). Only touch cells whose *only* content is a single 114 + // <p>; mixed content stays intact. The regex handles empty <p></p> too. 115 + out = out.replace( 116 + /(<(td|th)\b[^>]*>)\s*<p\b[^>]*>([\s\S]*?)<\/p>\s*(<\/\2>)/gi, 117 + '$1$3$4', 118 + ); 119 + 120 + // 3. Promote the first <tr> of a table to <thead> when it contains <th> 121 + // cells and there is no existing <thead>. turndown-plugin-gfm requires 122 + // a <thead> to emit GFM table syntax. 123 + out = out.replace( 124 + /<table\b([^>]*)>([\s\S]*?)<\/table>/gi, 125 + (fullMatch, tableAttrs: string, inner: string) => { 126 + if (/<thead\b/i.test(inner)) return fullMatch; 127 + // Find first <tr>...</tr> inside this table and check it has <th>. 128 + const trMatch = inner.match(/<tr\b[^>]*>[\s\S]*?<\/tr>/i); 129 + if (!trMatch) return fullMatch; 130 + const firstTr = trMatch[0]; 131 + if (!/<th\b/i.test(firstTr)) return fullMatch; 132 + const rest = inner.slice(0, trMatch.index!) + 133 + inner.slice(trMatch.index! + firstTr.length); 134 + return `<table${tableAttrs}><thead>${firstTr}</thead>${rest}</table>`; 135 + }, 136 + ); 137 + 138 + return out; 139 + } 140 + 141 + /** 88 142 * Convert HTML to Markdown. 89 143 */ 90 144 export function htmlToMarkdown(html: string): string { 91 145 if (!html) return ''; 92 - return turndownService.turndown(html); 146 + return turndownService.turndown(normalizeTipTapTables(html)); 93 147 }
+56
tests/markdown-export.test.ts
··· 179 179 }); 180 180 }); 181 181 182 + describe('tables — TipTap v3 shape (#716)', () => { 183 + // TipTap v3 emits tables like this: 184 + // <table style="min-width: ...;"> 185 + // <colgroup><col style="min-width: 25px;"><col style="min-width: 25px;"></colgroup> 186 + // <tbody> 187 + // <tr><th colspan="1" rowspan="1"><p>Name</p></th><th ..><p>Age</p></th></tr> 188 + // <tr><td colspan="1" rowspan="1"><p>Alice</p></td><td ..><p>30</p></td></tr> 189 + // </tbody> 190 + // </table> 191 + // 192 + // Header row lives in <tbody>, not <thead>, and cells wrap their text in 193 + // <p>. turndown-plugin-gfm's row-walker bails on that shape and the whole 194 + // <table> falls through to raw HTML. Regression guard for #716. 195 + 196 + it('converts TipTap v3 table (colgroup + header in tbody + <p>-wrapped cells) to GFM', () => { 197 + const md = htmlToMarkdown( 198 + '<table style="min-width: 75px;">' + 199 + '<colgroup><col style="min-width: 25px;"><col style="min-width: 25px;"></colgroup>' + 200 + '<tbody>' + 201 + '<tr><th colspan="1" rowspan="1"><p>Name</p></th><th colspan="1" rowspan="1"><p>Age</p></th></tr>' + 202 + '<tr><td colspan="1" rowspan="1"><p>Alice</p></td><td colspan="1" rowspan="1"><p>30</p></td></tr>' + 203 + '</tbody>' + 204 + '</table>', 205 + ); 206 + expect(md).not.toContain('<table'); 207 + expect(md).not.toContain('<colgroup'); 208 + expect(md).not.toContain('<tbody'); 209 + expect(md).not.toContain('<th '); 210 + expect(md).toContain('Name'); 211 + expect(md).toContain('Age'); 212 + expect(md).toContain('Alice'); 213 + expect(md).toContain('30'); 214 + expect(md).toContain('|'); 215 + expect(md).toContain('---'); 216 + }); 217 + 218 + it('does not duplicate cell text when <p>-wrapped cells are unwrapped', () => { 219 + const md = htmlToMarkdown( 220 + '<table><tbody><tr><th><p>H</p></th></tr><tr><td><p>cell</p></td></tr></tbody></table>', 221 + ); 222 + expect((md.match(/cell/g) || []).length).toBe(1); 223 + expect((md.match(/\bH\b/g) || []).length).toBe(1); 224 + }); 225 + 226 + it('still converts an empty-cell TipTap v3 table (no crashes on <p></p>)', () => { 227 + const md = htmlToMarkdown( 228 + '<table><colgroup><col><col></colgroup><tbody>' + 229 + '<tr><th><p></p></th><th><p></p></th></tr>' + 230 + '<tr><td><p></p></td><td><p></p></td></tr>' + 231 + '</tbody></table>', 232 + ); 233 + expect(md).not.toContain('<table'); 234 + expect(md).toContain('|'); 235 + }); 236 + }); 237 + 182 238 describe('tables (GFM)', () => { 183 239 it('converts simple table', () => { 184 240 const md = htmlToMarkdown(