// ========================================== // CSV Table Viewer — Browser Extension // ========================================== // Renders ```csv and ```tsv code blocks as sortable HTML tables. // No external dependencies. // ========================================== Extensions.register({ id: 'csv-table', async init(ctx) { const self = this; // ── Inject styles ── this._injectStyles(); ctx.renderers.register('csv-block', { type: 'block', priority: 10, match(lang) { const l = (lang || '').toLowerCase(); return l === 'csv' || l === 'tsv'; }, render(lang, code, container) { const delimiter = lang.toLowerCase() === 'tsv' ? '\t' : ','; const rows = self._parseCSV(code.trim(), delimiter); if (rows.length === 0) { container.innerHTML = '
No data
'; return; } const headers = rows[0]; const data = rows.slice(1); const tableId = 'csv-' + Math.random().toString(36).slice(2, 9); container.innerHTML = `
${data.length} row${data.length !== 1 ? 's' : ''} × ${headers.length} col${headers.length !== 1 ? 's' : ''}
${headers.map((h, i) => ``).join('')} ${data.map(row => `${row.map(cell => ``).join('')}`).join('')}
${self._escapeHtml(h)}
${self._escapeHtml(cell)}
📄 View raw
${self._escapeHtml(code.trim())}
`; // Attach sort handlers + copy self._attachHandlers(container, tableId, code.trim()); } }); }, _attachHandlers(container, tableId, rawCSV) { const block = container.querySelector(`[data-csv-id="${tableId}"]`); if (!block) return; // ── Column sorting ── const ths = block.querySelectorAll('th[data-col]'); let sortCol = -1; let sortAsc = true; ths.forEach(th => { th.style.cursor = 'pointer'; th.addEventListener('click', () => { const col = parseInt(th.dataset.col, 10); if (sortCol === col) { sortAsc = !sortAsc; } else { sortCol = col; sortAsc = true; } const tbody = block.querySelector('tbody'); const rows = Array.from(tbody.querySelectorAll('tr')); rows.sort((a, b) => { const cellA = (a.children[col]?.textContent || '').trim(); const cellB = (b.children[col]?.textContent || '').trim(); const numA = parseFloat(cellA); const numB = parseFloat(cellB); // Numeric sort if both are numbers if (!isNaN(numA) && !isNaN(numB)) { return sortAsc ? numA - numB : numB - numA; } // String sort const cmp = cellA.localeCompare(cellB, undefined, { numeric: true, sensitivity: 'base' }); return sortAsc ? cmp : -cmp; }); rows.forEach(row => tbody.appendChild(row)); // Update sort icons ths.forEach(h => { const icon = h.querySelector('.csv-sort-icon'); if (icon) { const hCol = parseInt(h.dataset.col, 10); icon.textContent = hCol === sortCol ? (sortAsc ? ' ▲' : ' ▼') : ''; } }); }); }); // ── Copy button ── const copyBtn = block.querySelector('.csv-copy-btn'); if (copyBtn) { copyBtn.addEventListener('click', () => { navigator.clipboard.writeText(rawCSV).then(() => { copyBtn.textContent = '✓ Copied'; setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500); }).catch(() => { copyBtn.textContent = '✗ Failed'; setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500); }); }); } }, /** * Simple CSV parser that handles quoted fields. */ _parseCSV(text, delimiter) { const rows = []; let row = []; let field = ''; let inQuotes = false; let i = 0; while (i < text.length) { const ch = text[i]; if (inQuotes) { if (ch === '"') { // Peek ahead: escaped quote or end of quoted field if (i + 1 < text.length && text[i + 1] === '"') { field += '"'; i += 2; } else { inQuotes = false; i++; } } else { field += ch; i++; } } else { if (ch === '"' && field === '') { inQuotes = true; i++; } else if (ch === delimiter) { row.push(field.trim()); field = ''; i++; } else if (ch === '\n' || (ch === '\r' && text[i + 1] === '\n')) { row.push(field.trim()); if (row.some(cell => cell !== '')) rows.push(row); row = []; field = ''; i += (ch === '\r') ? 2 : 1; } else { field += ch; i++; } } } // Last field/row row.push(field.trim()); if (row.some(cell => cell !== '')) rows.push(row); // Normalize: pad short rows to header length if (rows.length > 0) { const maxCols = Math.max(...rows.map(r => r.length)); rows.forEach(r => { while (r.length < maxCols) r.push(''); }); } return rows; }, _escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }, _injectStyles() { if (document.getElementById('ext-style-csv-table')) return; const style = document.createElement('style'); style.id = 'ext-style-csv-table'; style.textContent = ` .csv-table-block { background: var(--bg-2); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin: 12px 0; } .csv-toolbar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-3); } .csv-info { flex: 1; } .csv-copy-btn { background: none; border: 1px solid var(--border); border-radius: 4px; padding: 2px 8px; font-size: 12px; cursor: pointer; color: var(--text-3); } .csv-copy-btn:hover { color: var(--text-2); border-color: var(--text-3); } .csv-table-scroll { overflow-x: auto; } .csv-table { width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--mono); } .csv-table th { background: var(--bg-3); color: var(--text-2); font-weight: 600; padding: 6px 12px; text-align: left; white-space: nowrap; border-bottom: 2px solid var(--border); user-select: none; } .csv-table th:hover { color: var(--text-1); } .csv-sort-icon { font-size: 11px; opacity: 0.7; } .csv-table td { padding: 4px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; max-width: 300px; overflow: hidden; text-overflow: ellipsis; } .csv-table tr:hover td { background: var(--bg-3); } .csv-source, .csv-empty { border-top: 1px solid var(--border); font-size: 12px; } .csv-source summary { padding: 6px 12px; cursor: pointer; color: var(--text-3); user-select: none; } .csv-source summary:hover { color: var(--text-2); } .csv-source pre { margin: 0; border-radius: 0; border: none; max-height: 200px; overflow: auto; } .csv-empty { padding: 16px; text-align: center; color: var(--text-3); }`; document.head.appendChild(style); }, destroy() { document.getElementById('ext-style-csv-table')?.remove(); } });