···88## [Unreleased]
991010### Fixed
1111+- 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)
1112- 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)
1213- 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)
1314
···8585const turndownService = createTurndownService();
86868787/**
8888+ * Normalize TipTap v3 table markup into a shape turndown-plugin-gfm can
8989+ * convert to GFM. TipTap v3 emits:
9090+ *
9191+ * <table><colgroup><col><col></colgroup>
9292+ * <tbody>
9393+ * <tr><th><p>header</p></th>...</tr>
9494+ * <tr><td><p>cell</p></td>...</tr>
9595+ * </tbody>
9696+ * </table>
9797+ *
9898+ * turndown-plugin-gfm's table rule requires a <thead> and bails when header
9999+ * cells sit in <tbody>, so without this normalization the whole <table>
100100+ * falls through to raw HTML in the markdown output. See #716.
101101+ */
102102+function normalizeTipTapTables(html: string): string {
103103+ if (!/<table/i.test(html)) return html;
104104+105105+ let out = html;
106106+107107+ // 1. Drop <colgroup>...</colgroup> — only carries min-width hints that
108108+ // have no markdown equivalent and confuses turndown-plugin-gfm's row
109109+ // walker.
110110+ out = out.replace(/<colgroup\b[^>]*>[\s\S]*?<\/colgroup>/gi, '');
111111+112112+ // 2. Unwrap single-paragraph cells — <td...><p>text</p></td> → <td>text</td>
113113+ // (same for <th>). Only touch cells whose *only* content is a single
114114+ // <p>; mixed content stays intact. The regex handles empty <p></p> too.
115115+ out = out.replace(
116116+ /(<(td|th)\b[^>]*>)\s*<p\b[^>]*>([\s\S]*?)<\/p>\s*(<\/\2>)/gi,
117117+ '$1$3$4',
118118+ );
119119+120120+ // 3. Promote the first <tr> of a table to <thead> when it contains <th>
121121+ // cells and there is no existing <thead>. turndown-plugin-gfm requires
122122+ // a <thead> to emit GFM table syntax.
123123+ out = out.replace(
124124+ /<table\b([^>]*)>([\s\S]*?)<\/table>/gi,
125125+ (fullMatch, tableAttrs: string, inner: string) => {
126126+ if (/<thead\b/i.test(inner)) return fullMatch;
127127+ // Find first <tr>...</tr> inside this table and check it has <th>.
128128+ const trMatch = inner.match(/<tr\b[^>]*>[\s\S]*?<\/tr>/i);
129129+ if (!trMatch) return fullMatch;
130130+ const firstTr = trMatch[0];
131131+ if (!/<th\b/i.test(firstTr)) return fullMatch;
132132+ const rest = inner.slice(0, trMatch.index!) +
133133+ inner.slice(trMatch.index! + firstTr.length);
134134+ return `<table${tableAttrs}><thead>${firstTr}</thead>${rest}</table>`;
135135+ },
136136+ );
137137+138138+ return out;
139139+}
140140+141141+/**
88142 * Convert HTML to Markdown.
89143 */
90144export function htmlToMarkdown(html: string): string {
91145 if (!html) return '';
9292- return turndownService.turndown(html);
146146+ return turndownService.turndown(normalizeTipTapTables(html));
93147}