diff --git a/packages/api/package.json b/packages/api/package.json index d77f986525..56f64dddd6 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -24,6 +24,8 @@ "./client": "./src/client/index.ts", "./client/*": "./src/client/*.ts", "./dev/*": "./src/dev/*.ts", + "./client/markdown": "./src/client/markdown/index.ts", + "./client/markdown/*": "./src/client/markdown/*.ts", "./lib/*": "./src/lib/*.ts", "./urbit": "./src/urbit/index.ts", "./urbit/*": "./src/urbit/*.ts", @@ -46,6 +48,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.190.0", "@aws-sdk/s3-request-presigner": "^3.190.0", + "@types/mdast": "^4.0.0", "@urbit/aura": "^3.0.0", "@urbit/nockjs": "^1.6.0", "any-ascii": "^0.3.1", @@ -56,7 +59,13 @@ "exponential-backoff": "^3.1.1", "libphonenumber-js": "^1.11.18", "lodash": "^4.17.21", + "mdast-util-gfm": "^3.0.0", + "mdast-util-to-markdown": "^2.1.2", + "remark-gfm": "^4.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", "sorted-btree": "^1.8.1", + "unified": "^11.0.0", "validator": "^13.7.0", "zod": "^3.25.76" } diff --git a/packages/shared/src/logic/markdown/astUtils.ts b/packages/api/src/client/markdown/astUtils.ts similarity index 100% rename from packages/shared/src/logic/markdown/astUtils.ts rename to packages/api/src/client/markdown/astUtils.ts diff --git a/packages/api/src/client/markdown/extractTables.test.ts b/packages/api/src/client/markdown/extractTables.test.ts new file mode 100644 index 0000000000..0fdcb0d125 --- /dev/null +++ b/packages/api/src/client/markdown/extractTables.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, it } from 'vitest'; + +import { extractTablesFromContent } from './extractTables'; + +describe('extractTablesFromContent', () => { + it('extracts a simple table from a paragraph block', () => { + const result = extractTablesFromContent([ + { + type: 'paragraph', + content: [ + { type: 'text', text: '| A | B |' }, + { type: 'lineBreak' }, + { type: 'text', text: '|---|---|' }, + { type: 'lineBreak' }, + { type: 'text', text: '| 1 | 2 |' }, + ], + }, + ]); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'table', + header: { + cells: [ + { content: [{ type: 'text', text: 'A' }] }, + { content: [{ type: 'text', text: 'B' }] }, + ], + }, + rows: [ + { + cells: [ + { content: [{ type: 'text', text: '1' }] }, + { content: [{ type: 'text', text: '2' }] }, + ], + }, + ], + }); + }); + + it('joins soft-wrapped continuation lines back into their row', () => { + // Simulates a long cell whose tail gets soft-wrapped onto its own line. + // Without normalization, remark-gfm parses the continuation as a phantom + // one-cell row and the wrapped portion is lost from the real row's last + // cell. + const result = extractTablesFromContent([ + { + type: 'paragraph', + content: [ + { type: 'text', text: '| Foo | Bar | Baz |' }, + { type: 'lineBreak' }, + { type: 'text', text: '|---|---|---|' }, + { type: 'lineBreak' }, + { + type: 'text', + text: '| alpha | beta | Lorem ipsum dolor sit amet,', + }, + { type: 'lineBreak' }, + { type: 'text', text: 'consectetur adipiscing elit. |' }, + ], + }, + ]); + + expect(result).toHaveLength(1); + const table = result[0]; + expect(table.type).toBe('table'); + if (table.type !== 'table') throw new Error('unreachable'); + + expect(table.rows).toHaveLength(1); + const lastCell = table.rows[0].cells[2]; + expect(lastCell.content).toEqual([ + { + type: 'text', + text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, + ]); + }); + + it('handles multiple continuation lines in the same row', () => { + const result = extractTablesFromContent([ + { + type: 'paragraph', + content: [ + { type: 'text', text: '| A | B |' }, + { type: 'lineBreak' }, + { type: 'text', text: '|---|---|' }, + { type: 'lineBreak' }, + { type: 'text', text: '| 1 | first line' }, + { type: 'lineBreak' }, + { type: 'text', text: 'second line' }, + { type: 'lineBreak' }, + { type: 'text', text: 'third line |' }, + ], + }, + ]); + + const table = result[0]; + if (table.type !== 'table') throw new Error('unreachable'); + expect(table.rows[0].cells[1].content).toEqual([ + { type: 'text', text: 'first line second line third line' }, + ]); + }); + + it('extracts tables that omit outer pipes', () => { + // GFM allows tables without leading/trailing pipes. The continuation-line + // normalizer must not merge data rows here, since no row starts with `|`. + const result = extractTablesFromContent([ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'A | B' }, + { type: 'lineBreak' }, + { type: 'text', text: '---|---' }, + { type: 'lineBreak' }, + { type: 'text', text: '1 | 2' }, + ], + }, + ]); + + expect(result).toHaveLength(1); + const table = result[0]; + if (table.type !== 'table') throw new Error('unreachable'); + expect(table.header.cells).toEqual([ + { content: [{ type: 'text', text: 'A' }] }, + { content: [{ type: 'text', text: 'B' }] }, + ]); + expect(table.rows).toHaveLength(1); + expect(table.rows[0].cells).toEqual([ + { content: [{ type: 'text', text: '1' }] }, + { content: [{ type: 'text', text: '2' }] }, + ]); + }); + + it('does not split a table when a data row looks like a separator', () => { + // `| --- | --- |` matches the separator-row regex but inside a table + // body it's just a data row of dash strings. The scan must not bail + // out here. + const result = extractTablesFromContent([ + { + type: 'paragraph', + content: [ + { type: 'text', text: '| H1 | H2 |' }, + { type: 'lineBreak' }, + { type: 'text', text: '|---|---|' }, + { type: 'lineBreak' }, + { type: 'text', text: '| a | b |' }, + { type: 'lineBreak' }, + { type: 'text', text: '| --- | --- |' }, + { type: 'lineBreak' }, + { type: 'text', text: '| c | d |' }, + ], + }, + ]); + + expect(result).toHaveLength(1); + const table = result[0]; + if (table.type !== 'table') throw new Error('unreachable'); + expect(table.rows).toHaveLength(3); + expect(table.rows[1].cells.map((c) => c.content)).toEqual([ + [{ type: 'text', text: '---' }], + [{ type: 'text', text: '---' }], + ]); + }); + + it('leaves non-table paragraphs untouched', () => { + const input = [ + { + type: 'paragraph' as const, + content: [ + { type: 'text' as const, text: 'just a sentence with | a pipe' }, + ], + }, + ]; + expect(extractTablesFromContent(input)).toEqual(input); + }); + + it('preserves bold and link inlines inside cells', () => { + // Mirrors the wire shape we've seen in real bot posts: a bold inline + // mid-row and a link inline embedded in the last cell. + const result = extractTablesFromContent([ + { + type: 'paragraph', + content: [ + { type: 'text', text: '| Foo | Bar | Baz |' }, + { type: 'lineBreak' }, + { type: 'text', text: '|---|---|---|' }, + { type: 'lineBreak' }, + { type: 'text', text: '| alpha | ' }, + { + type: 'style', + style: 'bold', + children: [{ type: 'text', text: 'beta' }], + }, + { type: 'text', text: ' | Lorem ipsum dolor sit amet. ' }, + { + type: 'link', + href: 'https://example.com/source', + text: '(citation, Jan 1 2030)', + }, + { type: 'text', text: ' |' }, + ], + }, + ]); + + expect(result).toHaveLength(1); + const table = result[0]; + if (table.type !== 'table') throw new Error('unreachable'); + expect(table.rows).toHaveLength(1); + + const [first, second, third] = table.rows[0].cells; + expect(first.content).toEqual([{ type: 'text', text: 'alpha' }]); + expect(second.content).toEqual([ + { + type: 'style', + style: 'bold', + children: [{ type: 'text', text: 'beta' }], + }, + ]); + expect(third.content).toEqual([ + { type: 'text', text: 'Lorem ipsum dolor sit amet. ' }, + { + type: 'link', + href: 'https://example.com/source', + text: '(citation, Jan 1 2030)', + }, + ]); + }); + + it('preserves group mentions inside cells', () => { + const result = extractTablesFromContent([ + { + type: 'paragraph', + content: [ + { type: 'text', text: '| Who | Note |' }, + { type: 'lineBreak' }, + { type: 'text', text: '|---|---|' }, + { type: 'lineBreak' }, + { type: 'text', text: '| ' }, + { type: 'groupMention', group: 'all' }, + { type: 'text', text: ' | everyone |' }, + { type: 'lineBreak' }, + { type: 'text', text: '| ' }, + { type: 'groupMention', group: 'admin' }, + { type: 'text', text: ' | staff |' }, + ], + }, + ]); + + expect(result).toHaveLength(1); + const table = result[0]; + if (table.type !== 'table') throw new Error('unreachable'); + expect(table.rows[0].cells[0].content).toEqual([ + { type: 'groupMention', group: 'all' }, + ]); + expect(table.rows[1].cells[0].content).toEqual([ + { type: 'groupMention', group: 'admin' }, + ]); + }); + + it('catches group mentions adjacent to punctuation in cells', () => { + // remark-gfm leaves cell text intact, so a cell written as `(@all)` or + // `@all,` shows up as a text node with `@` next to non-alphanumeric + // punctuation. The lookbehind has to permit those cases. + const result = extractTablesFromContent([ + { + type: 'paragraph', + content: [ + { type: 'text', text: '| A | B | C |' }, + { type: 'lineBreak' }, + { type: 'text', text: '|---|---|---|' }, + { type: 'lineBreak' }, + { + type: 'text', + text: '| (@all) | @admin, others | email@example.com |', + }, + ], + }, + ]); + + const table = result[0]; + if (table.type !== 'table') throw new Error('unreachable'); + const [paren, comma, email] = table.rows[0].cells; + + // `(@all)` → `(` text, groupMention, `)` text + expect(paren.content).toEqual([ + { type: 'text', text: '(' }, + { type: 'groupMention', group: 'all' }, + { type: 'text', text: ')' }, + ]); + + // `@admin, others` → groupMention, then the trailing text + expect(comma.content).toEqual([ + { type: 'groupMention', group: 'admin' }, + { type: 'text', text: ', others' }, + ]); + + // `email@example.com` is autolinked by remark-gfm, so the `@` is inside + // a link node, not a text node — the group-mention scan never sees it. + // Even if it did, the negative lookbehind rejects `@` after `l`. + expect(email.content).toEqual([ + { + type: 'link', + href: 'mailto:email@example.com', + text: 'email@example.com', + }, + ]); + }); + + it('preserves pipes inside structured inlines (inline code, links)', () => { + // Inline code containing `|` (e.g. shell pipes, urbit ++mark commands) + // would otherwise be interpreted by remark-gfm as a cell delimiter and + // shift the row's cell count. + const result = extractTablesFromContent([ + { + type: 'paragraph', + content: [ + { type: 'text', text: '| Command | Notes |' }, + { type: 'lineBreak' }, + { type: 'text', text: '|---|---|' }, + { type: 'lineBreak' }, + { type: 'text', text: '| ' }, + { + type: 'style', + style: 'code', + children: [{ type: 'text', text: 'awk -F | print' }], + }, + { type: 'text', text: ' | filters input |' }, + ], + }, + ]); + + expect(result).toHaveLength(1); + const table = result[0]; + if (table.type !== 'table') throw new Error('unreachable'); + expect(table.rows).toHaveLength(1); + expect(table.rows[0].cells).toHaveLength(2); + expect(table.rows[0].cells[0].content).toEqual([ + { + type: 'style', + style: 'code', + children: [{ type: 'text', text: 'awk -F | print' }], + }, + ]); + expect(table.rows[0].cells[1].content).toEqual([ + { type: 'text', text: 'filters input' }, + ]); + }); + + it('preserves link text containing markdown-special characters', () => { + // `]` in link text would have broken the old hand-written serializer; + // mdast-util-to-markdown escapes it properly. + const result = extractTablesFromContent([ + { + type: 'paragraph', + content: [ + { type: 'text', text: '| Source |' }, + { type: 'lineBreak' }, + { type: 'text', text: '|---|' }, + { type: 'lineBreak' }, + { type: 'text', text: '| ' }, + { + type: 'link', + href: 'https://example.com', + text: 'foo [bar] baz', + }, + { type: 'text', text: ' |' }, + ], + }, + ]); + + expect(result).toHaveLength(1); + const table = result[0]; + if (table.type !== 'table') throw new Error('unreachable'); + expect(table.rows[0].cells[0].content).toEqual([ + { + type: 'link', + href: 'https://example.com', + text: 'foo [bar] baz', + }, + ]); + }); + + it('splits paragraph around a table', () => { + const result = extractTablesFromContent([ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Before the table.' }, + { type: 'lineBreak' }, + { type: 'text', text: '| A | B |' }, + { type: 'lineBreak' }, + { type: 'text', text: '|---|---|' }, + { type: 'lineBreak' }, + { type: 'text', text: '| 1 | 2 |' }, + { type: 'lineBreak' }, + { type: 'text', text: 'After the table.' }, + ], + }, + ]); + + expect(result).toHaveLength(3); + expect(result[0]).toMatchObject({ + type: 'paragraph', + content: [{ type: 'text', text: 'Before the table.' }], + }); + expect(result[1].type).toBe('table'); + expect(result[2]).toMatchObject({ + type: 'paragraph', + content: [{ type: 'text', text: 'After the table.' }], + }); + }); +}); diff --git a/packages/api/src/client/markdown/extractTables.ts b/packages/api/src/client/markdown/extractTables.ts new file mode 100644 index 0000000000..ac8a037421 --- /dev/null +++ b/packages/api/src/client/markdown/extractTables.ts @@ -0,0 +1,435 @@ +import type { + Table as MdastTable, + TableCell as MdastTableCell, + TableRow as MdastTableRow, + Paragraph, + PhrasingContent, + Root, +} from 'mdast'; +import { gfmToMarkdown } from 'mdast-util-gfm'; +import { toMarkdown } from 'mdast-util-to-markdown'; +import remarkGfm from 'remark-gfm'; +import remarkParse from 'remark-parse'; +import { unified } from 'unified'; + +import { assertNever } from '../../lib/assertNever'; +import type { + BlockData, + InlineData, + ParagraphBlockData, + PostContent, + TableAlignment, + TableBlockData, + TableRowData, +} from '../postContent'; +import { convertInlineContent } from '../postContentInlines'; +import { remarkGroupMentions } from './groupMentionPlugin'; +import { phrasingToInlines } from './mdastToStory'; +import { remarkShipMentions } from './shipMentionPlugin'; + +// Matches a GFM table separator row: `|---|---|`, `| :---: | ---: |`, etc. +// Requires at least one cell of three or more dashes, with optional alignment +// colons and surrounding whitespace. +const SEPARATOR_LINE = + /^[ \t]*\|?[ \t]*:?-{3,}:?[ \t]*(\|[ \t]*:?-{3,}:?[ \t]*)*\|?[ \t]*$/; + +// toMarkdown handlers for our custom inline node types. Registered on both +// directions (parse via remarkShipMentions / remarkGroupMentions, serialize +// via these handlers) so that ship and group mentions round-trip cleanly +// through the table-cell parse pipeline. Cast through `unknown` because +// mdast-util-to-markdown's `Handlers` only enumerates the canonical mdast +// types — our custom nodes aren't in the union. +const mentionHandlers = { + handlers: { + shipMention(node: { value: string }) { + return `~${node.value}`; + }, + groupMention(node: { value: string }) { + return `@${node.value}`; + }, + }, +} as unknown as NonNullable[1]>; + +// Convert one local InlineData node to an mdast PhrasingContent node. +// `task` doesn't have an inline mdast equivalent — GFM's task-list marker +// is only recognized at the start of a list item, not in arbitrary +// phrasing — so we degrade to plain text representing the marker. The +// structure is lost in the round trip; the rendered text is preserved. +function inlineDataToPhrasing(inline: InlineData): PhrasingContent { + switch (inline.type) { + case 'text': + return { type: 'text', value: inline.text }; + case 'lineBreak': + return { type: 'break' }; + case 'style': { + const children = inline.children.map(inlineDataToPhrasing); + switch (inline.style) { + case 'bold': + return { type: 'strong', children }; + case 'italic': + return { type: 'emphasis', children }; + case 'strikethrough': + return { type: 'delete', children }; + case 'code': { + const text = + inline.children[0]?.type === 'text' ? inline.children[0].text : ''; + return { type: 'inlineCode', value: text }; + } + default: + return assertNever(inline.style); + } + } + case 'link': + return { + type: 'link', + url: inline.href, + children: [{ type: 'text', value: inline.text }], + }; + case 'mention': + return { + type: 'shipMention', + value: inline.contactId.replace(/^~/, ''), + } as unknown as PhrasingContent; + case 'groupMention': + return { + type: 'groupMention', + value: inline.group, + } as unknown as PhrasingContent; + case 'task': { + const inner = inline.children.map(inlineDataChildText).join(''); + return { + type: 'text', + value: `${inline.checked ? '[x]' : '[ ]'} ${inner}`, + }; + } + } +} + +function inlineDataChildText(inline: InlineData): string { + switch (inline.type) { + case 'text': + return inline.text; + case 'lineBreak': + return '\n'; + case 'style': + return inline.children.map(inlineDataChildText).join(''); + case 'link': + return inline.text; + case 'mention': + return inline.contactId; + case 'groupMention': + return `@${inline.group}`; + case 'task': + return `${inline.checked ? '[x]' : '[ ]'} ${inline.children + .map(inlineDataChildText) + .join('')}`; + } +} + +// Serializes a local InlineData back to its markdown source form. +// +// `text` is emitted verbatim because the wire format's `|` characters are +// load-bearing table syntax — escaping them would break detection. (Wire- +// format text that contains literal markdown syntax is an acknowledged +// edge case; structural inlines are emitted as proper InlineData and +// preserved through this function.) +// +// `lineBreak` is emitted as a literal `\n` so paragraphs reassemble into +// multi-line text the table regex can scan. +// +// Everything else goes through mdast-util-to-markdown + gfm so structural +// inlines (links, bold, mentions, …) get proper escaping and survive the +// round trip through remark-gfm without breaking the table grid. +function inlineDataToMarkdown(inline: InlineData): string { + if (inline.type === 'text') return inline.text; + if (inline.type === 'lineBreak') return '\n'; + const paragraph: Paragraph = { + type: 'paragraph', + children: [inlineDataToPhrasing(inline)], + }; + const md = toMarkdown(paragraph, { + extensions: [gfmToMarkdown(), mentionHandlers], + }); + // Every `|` in `md` is content (cell delimiters live only in `text` + // inlines, which take the early-return path above). Escape them so + // remark-gfm doesn't treat e.g. inline code `awk -F | print` as a + // cell boundary when this string gets reassembled into a row. + return md.replace(/\n+$/, '').replace(/\|/g, '\\|'); +} + +const tableProcessor = unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkShipMentions) + .use(remarkGroupMentions); + +type InlineMapping = { + text: string; + positions: Array<{ start: number; end: number; inline: InlineData }>; +}; + +function inlinesToText(inlines: InlineData[]): InlineMapping { + let text = ''; + const positions: InlineMapping['positions'] = []; + for (const inline of inlines) { + const start = text.length; + text += inlineDataToMarkdown(inline); + positions.push({ start, end: text.length, inline }); + } + return { text, positions }; +} + +function sliceInlines( + positions: InlineMapping['positions'], + offsetStart: number, + offsetEnd: number +): InlineData[] { + const result: InlineData[] = []; + for (const pos of positions) { + if (pos.end <= offsetStart) continue; + if (pos.start >= offsetEnd) continue; + + const fullyContained = pos.start >= offsetStart && pos.end <= offsetEnd; + + if (fullyContained) { + result.push(pos.inline); + } else if (pos.inline.type === 'text') { + // Region boundaries are at line edges and the inlines we serialize + // (text, lineBreak as `\n`, structured-as-markdown) don't span lines, + // so straddling shouldn't happen. Slice defensively if it ever does. + const sliceStart = Math.max(0, offsetStart - pos.start); + const sliceEnd = Math.min(pos.inline.text.length, offsetEnd - pos.start); + const slicedText = pos.inline.text.slice(sliceStart, sliceEnd); + if (slicedText) { + result.push({ type: 'text', text: slicedText }); + } + } + // Non-text inlines that straddle are dropped — same rationale: they + // shouldn't straddle a line edge in the first place. + } + return result; +} + +function trimEdgeLineBreaks( + inlines: InlineData[], + side: 'leading' | 'trailing' | 'both' +): InlineData[] { + let start = 0; + let end = inlines.length; + if (side === 'leading' || side === 'both') { + while (start < end && inlines[start].type === 'lineBreak') start++; + } + if (side === 'trailing' || side === 'both') { + while (end > start && inlines[end - 1].type === 'lineBreak') end--; + } + return inlines.slice(start, end); +} + +function isEffectivelyEmpty(inlines: InlineData[]): boolean { + return inlines.every((i) => i.type === 'text' && i.text.trim() === ''); +} + +type Region = { start: number; end: number }; + +function findTableRegions(text: string): Region[] { + const regions: Region[] = []; + const lines = text.split('\n'); + + const lineStarts: number[] = []; + let off = 0; + for (const line of lines) { + lineStarts.push(off); + off += line.length + 1; + } + + // Returns the index of the next line whose contents look like part of the + // table (contains `|`, isn't blank), or -1 if no such line exists before + // the next blank line. We deliberately don't check for `SEPARATOR_LINE` + // here — a GFM table has only one separator row (between header and body), + // so any later line that happens to match the separator pattern (e.g. + // a data row with cells of `---`) is just another row. + const nextTableLine = (from: number): number => { + for (let k = from; k < lines.length; k++) { + if (lines[k].trim() === '') return -1; + if (lines[k].includes('|')) return k; + } + return -1; + }; + + let i = 0; + while (i < lines.length) { + const isSeparator = + i > 0 && SEPARATOR_LINE.test(lines[i]) && lines[i - 1].includes('|'); + + if (!isSeparator) { + i++; + continue; + } + + const headerLineIdx = i - 1; + let endLineIdx = i; + let j = i + 1; + // Once we're past the separator we don't bail on lines that also match + // the separator pattern — see `nextTableLine` for the rationale. + while (j < lines.length) { + if (lines[j].includes('|')) { + endLineIdx = j; + j++; + continue; + } + // Non-pipe line: only a continuation if another pipe row follows + // before a blank line. + if (lines[j].trim() === '') break; + if (nextTableLine(j + 1) === -1) break; + j++; + } + + const start = lineStarts[headerLineIdx]; + const end = lineStarts[endLineIdx] + lines[endLineIdx].length; + regions.push({ start, end }); + i = j; + } + + return regions; +} + +function normalizeAlign(align: MdastTable['align']): (TableAlignment | null)[] { + if (!align) return []; + return align.map((a) => + a === 'left' || a === 'center' || a === 'right' ? a : null + ); +} + +function mdastCellToCellData(cell: MdastTableCell) { + const inlines = convertInlineContent(phrasingToInlines(cell.children)); + return { content: inlines }; +} + +function mdastRowToRowData(row: MdastTableRow): TableRowData { + return { + cells: row.children.map(mdastCellToCellData), + }; +} + +function mdastTableToBlockData(node: MdastTable): TableBlockData { + const rows = node.children.map(mdastRowToRowData); + const header: TableRowData = rows[0] ?? { cells: [] }; + return { + type: 'table', + header, + rows: rows.slice(1), + align: normalizeAlign(node.align), + }; +} + +// Merges continuation lines into their preceding row. Bots that soft-wrap +// long cell content emit lines that don't start with `|` mid-table; without +// this, remark-gfm treats those fragments as phantom one-cell rows and the +// wrapped portion of the real row's last cell is lost. +// +// Only applied to tables that use outer pipes (separator line starts with +// `|`). GFM also allows tables to omit outer pipes — e.g. `A | B / ---|--- / +// 1 | 2` — and in that form data rows legitimately don't start with `|`, so +// merging them would corrupt the table. +function normalizeTableCandidate(text: string): string { + const lines = text.split('\n'); + const sepIdx = lines.findIndex((l) => SEPARATOR_LINE.test(l)); + if (sepIdx === -1) return text; + const usesOuterPipes = lines[sepIdx].trimStart().startsWith('|'); + if (!usesOuterPipes) return text; + + const out: string[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trimStart(); + if (i === 0 || trimmed.startsWith('|') || SEPARATOR_LINE.test(line)) { + out.push(line); + } else { + const prev = out.pop() ?? ''; + out.push(prev + ' ' + trimmed); + } + } + return out.join('\n'); +} + +function parseTableCandidate(text: string): MdastTable | null { + const normalized = normalizeTableCandidate(text); + let tree: Root; + try { + tree = tableProcessor.parse(normalized) as Root; + tableProcessor.runSync(tree); + } catch { + return null; + } + // Expect exactly one child, a table node. If remark parsed it as something + // else (e.g., a paragraph), the candidate isn't a real table. + if (tree.children.length !== 1) return null; + const first = tree.children[0]; + if (first.type !== 'table') return null; + return first as MdastTable; +} + +function extractTablesFromParagraph(block: ParagraphBlockData): BlockData[] { + // Cheap short-circuit: a table requires at least one `|` in plain text + // content (cell delimiters live in `text` inlines; structured inlines + // get their pipes escaped at serialization). Avoids the cost of + // reassembly + regex + remark for the common no-table case. + const hasPipe = block.content.some( + (i) => i.type === 'text' && i.text.includes('|') + ); + if (!hasPipe) return [block]; + + const mapping = inlinesToText(block.content); + const regions = findTableRegions(mapping.text); + if (regions.length === 0) return [block]; + + const out: BlockData[] = []; + let cursor = 0; + let tableEmitted = false; + + for (const region of regions) { + const candidateText = mapping.text.slice(region.start, region.end); + const tableNode = parseTableCandidate(candidateText); + if (!tableNode) continue; + + if (region.start > cursor) { + const preInlines = trimEdgeLineBreaks( + sliceInlines(mapping.positions, cursor, region.start), + 'trailing' + ); + if (preInlines.length > 0 && !isEffectivelyEmpty(preInlines)) { + out.push({ type: 'paragraph', content: preInlines }); + } + } + + out.push(mdastTableToBlockData(tableNode)); + tableEmitted = true; + cursor = region.end; + } + + if (!tableEmitted) return [block]; + + if (cursor < mapping.text.length) { + const postInlines = trimEdgeLineBreaks( + sliceInlines(mapping.positions, cursor, mapping.text.length), + 'leading' + ); + if (postInlines.length > 0 && !isEffectivelyEmpty(postInlines)) { + out.push({ type: 'paragraph', content: postInlines }); + } + } + + return out; +} + +export function extractTablesFromContent(content: PostContent): PostContent { + const out: PostContent = []; + for (const block of content) { + if (block.type === 'paragraph') { + out.push(...extractTablesFromParagraph(block)); + } else { + out.push(block); + } + } + return out; +} diff --git a/packages/api/src/client/markdown/groupMentionPlugin.ts b/packages/api/src/client/markdown/groupMentionPlugin.ts new file mode 100644 index 0000000000..1e06939ef2 --- /dev/null +++ b/packages/api/src/client/markdown/groupMentionPlugin.ts @@ -0,0 +1,86 @@ +import type { PhrasingContent, Text } from 'mdast'; +import type { Literal, Node } from 'unist'; + +import { visit } from './astUtils'; + +/** + * Custom mdast node type for group/role mentions (@all, @admin, etc.) + */ +export interface GroupMention extends Literal { + type: 'groupMention'; + value: string; // group/role name without the @ +} + +// `@` followed by an identifier-like token. Negative lookbehind rejects `@` +// preceded by an alphanumeric character so we don't match inside emails +// (`user@example.com`) or IDs (`abc123@foo`); punctuation and table syntax +// like `(@all)`, `,@all,`, `|@all|` all proceed to match. +const GROUP_MENTION_PATTERN = /(? lastIndex) { + const textNode: Text = { + type: 'text', + value: text.slice(lastIndex, match.index), + }; + result.push(textNode); + } + + const groupMention: GroupMention = { + type: 'groupMention', + value: match[1], + }; + result.push(groupMention as unknown as PhrasingContent); + lastIndex = (match.index ?? 0) + match[0].length; + } + + if (lastIndex < text.length) { + const textNode: Text = { + type: 'text', + value: text.slice(lastIndex), + }; + result.push(textNode); + } + + return result; +} + +export function transformGroupMentions(tree: Node): void { + visit(tree, 'text', (node, index, parent) => { + if (!parent || index === undefined) return; + + const parsed = parseGroupMentions(node.value); + + if (parsed.length === 1 && parsed[0].type === 'text') { + return; + } + + parent.children.splice(index, 1, ...parsed); + }); +} + +/** + * Serialize a group mention back to Markdown (@group). + */ +export function groupMentionToMarkdown(node: GroupMention): string { + return `@${node.value}`; +} + +/** + * remark plugin that adds group-mention parsing support. + * Transforms text nodes containing @group patterns into groupMention nodes. + */ +export function remarkGroupMentions() { + return (tree: Node) => { + transformGroupMentions(tree); + }; +} diff --git a/packages/shared/src/logic/markdown/index.ts b/packages/api/src/client/markdown/index.ts similarity index 64% rename from packages/shared/src/logic/markdown/index.ts rename to packages/api/src/client/markdown/index.ts index 654c189304..6ba059a7bc 100644 --- a/packages/shared/src/logic/markdown/index.ts +++ b/packages/api/src/client/markdown/index.ts @@ -14,3 +14,10 @@ export { storyToMdast, inlinesToPhrasing } from './storyToMdast'; // Ship mention plugin export { remarkShipMentions, parseShipMentions } from './shipMentionPlugin'; export type { ShipMention } from './shipMentionPlugin'; + +// Group mention plugin +export { remarkGroupMentions, parseGroupMentions } from './groupMentionPlugin'; +export type { GroupMention } from './groupMentionPlugin'; + +// Table extraction (post-processing for message rendering) +export { extractTablesFromContent } from './extractTables'; diff --git a/packages/shared/src/logic/markdown/markdown.test.ts b/packages/api/src/client/markdown/markdown.test.ts similarity index 100% rename from packages/shared/src/logic/markdown/markdown.test.ts rename to packages/api/src/client/markdown/markdown.test.ts diff --git a/packages/shared/src/logic/markdown/mdastToStory.ts b/packages/api/src/client/markdown/mdastToStory.ts similarity index 93% rename from packages/shared/src/logic/markdown/mdastToStory.ts rename to packages/api/src/client/markdown/mdastToStory.ts index 026e92d2c2..ff1b168138 100644 --- a/packages/shared/src/logic/markdown/mdastToStory.ts +++ b/packages/api/src/client/markdown/mdastToStory.ts @@ -1,9 +1,23 @@ -import { - Story, - Verse, - VerseBlock, - VerseInline, -} from '@tloncorp/api/urbit/channel'; +import type { + Delete, + Emphasis, + Heading, + Blockquote as MdastBlockquote, + Code as MdastCode, + Image as MdastImage, + InlineCode as MdastInlineCode, + Link as MdastLink, + List as MdastList, + ListItem as MdastListItem, + Paragraph, + PhrasingContent, + RootContent, + Strong, + Text, + ThematicBreak, +} from 'mdast'; + +import { Story, Verse, VerseBlock, VerseInline } from '../../urbit/channel'; import { Block, Blockquote, @@ -22,29 +36,12 @@ import { Listing, ListingBlock, Rule, + Sect, Ship, Strikethrough, Task, -} from '@tloncorp/api/urbit/content'; -import type { - Delete, - Emphasis, - Heading, - Blockquote as MdastBlockquote, - Code as MdastCode, - Image as MdastImage, - InlineCode as MdastInlineCode, - Link as MdastLink, - List as MdastList, - ListItem as MdastListItem, - Paragraph, - PhrasingContent, - RootContent, - Strong, - Text, - ThematicBreak, -} from 'mdast'; - +} from '../../urbit/content'; +import type { GroupMention } from './groupMentionPlugin'; import type { ShipMention } from './shipMentionPlugin'; /** @@ -58,6 +55,17 @@ function isShipMention(node: unknown): node is ShipMention { ); } +/** + * Check if a node is a group mention (custom node type from our plugin). + */ +function isGroupMention(node: unknown): node is GroupMention { + return ( + typeof node === 'object' && + node !== null && + (node as { type?: string }).type === 'groupMention' + ); +} + /** * Check if a node has a 'checked' property (GFM task list item). */ @@ -81,6 +89,14 @@ export function phrasingToInlines(nodes: PhrasingContent[]): Inline[] { continue; } + // Group mentions: `@all` → { sect: null }, `@admin` → { sect: 'admin' } + if (isGroupMention(node)) { + const value = (node as GroupMention).value; + const sect: Sect = { sect: value === 'all' ? null : value }; + result.push(sect); + continue; + } + // Type assertion needed because TypeScript doesn't know about our custom node type const mdastNode = node as PhrasingContent; diff --git a/packages/shared/src/logic/markdown/parse.ts b/packages/api/src/client/markdown/parse.ts similarity index 96% rename from packages/shared/src/logic/markdown/parse.ts rename to packages/api/src/client/markdown/parse.ts index 5b5ef3c06f..ac247780db 100644 --- a/packages/shared/src/logic/markdown/parse.ts +++ b/packages/api/src/client/markdown/parse.ts @@ -1,9 +1,9 @@ -import { Story } from '@tloncorp/api/urbit/channel'; import type { Root } from 'mdast'; import remarkGfm from 'remark-gfm'; import remarkParse from 'remark-parse'; import { unified } from 'unified'; +import { Story } from '../../urbit/channel'; import { mdastToStory } from './mdastToStory'; import { remarkShipMentions } from './shipMentionPlugin'; diff --git a/packages/shared/src/logic/markdown/serialize.ts b/packages/api/src/client/markdown/serialize.ts similarity index 96% rename from packages/shared/src/logic/markdown/serialize.ts rename to packages/api/src/client/markdown/serialize.ts index 929440e5f4..48a6f59c06 100644 --- a/packages/shared/src/logic/markdown/serialize.ts +++ b/packages/api/src/client/markdown/serialize.ts @@ -1,10 +1,10 @@ -import { Story } from '@tloncorp/api/urbit/channel'; -import { Block, Inline } from '@tloncorp/api/urbit/content'; import type { Node, PhrasingContent, Root, RootContent } from 'mdast'; import remarkGfm from 'remark-gfm'; import remarkStringify from 'remark-stringify'; import { unified } from 'unified'; +import { Story } from '../../urbit/channel'; +import { Block, Inline } from '../../urbit/content'; import { visit, visitAll } from './astUtils'; import type { ShipMention } from './shipMentionPlugin'; import { inlinesToPhrasing, storyToMdast } from './storyToMdast'; diff --git a/packages/shared/src/logic/markdown/shipMentionPlugin.ts b/packages/api/src/client/markdown/shipMentionPlugin.ts similarity index 100% rename from packages/shared/src/logic/markdown/shipMentionPlugin.ts rename to packages/api/src/client/markdown/shipMentionPlugin.ts diff --git a/packages/shared/src/logic/storyToMarkdown.test.ts b/packages/api/src/client/markdown/storyToMarkdown.test.ts similarity index 98% rename from packages/shared/src/logic/storyToMarkdown.test.ts rename to packages/api/src/client/markdown/storyToMarkdown.test.ts index beceffa03f..467731d5b9 100644 --- a/packages/shared/src/logic/storyToMarkdown.test.ts +++ b/packages/api/src/client/markdown/storyToMarkdown.test.ts @@ -1,4 +1,6 @@ -import { Story } from '@tloncorp/api/urbit/channel'; +import { describe, expect, test } from 'vitest'; + +import { Story } from '../../urbit/channel'; import { Block, Blockquote, @@ -16,14 +18,8 @@ import { Ship, Strikethrough, Task, -} from '@tloncorp/api/urbit/content'; -import { describe, expect, test } from 'vitest'; - -import { - blockToMarkdown, - inlinesToMarkdown, - storyToMarkdown, -} from './markdown'; +} from '../../urbit/content'; +import { blockToMarkdown, inlinesToMarkdown, storyToMarkdown } from './index'; describe('inlinesToMarkdown', () => { test('converts plain string', () => { diff --git a/packages/shared/src/logic/markdown/storyToMdast.ts b/packages/api/src/client/markdown/storyToMdast.ts similarity index 99% rename from packages/shared/src/logic/markdown/storyToMdast.ts rename to packages/api/src/client/markdown/storyToMdast.ts index e4f96b85ee..e36dfd0297 100644 --- a/packages/shared/src/logic/markdown/storyToMdast.ts +++ b/packages/api/src/client/markdown/storyToMdast.ts @@ -1,4 +1,23 @@ -import { Story, Verse, isBlockVerse } from '@tloncorp/api/urbit/channel'; +import type { + Delete, + Emphasis, + Heading, + Blockquote as MdastBlockquote, + Code as MdastCode, + Image as MdastImage, + InlineCode as MdastInlineCode, + Link as MdastLink, + List as MdastList, + ListItem as MdastListItem, + Paragraph, + PhrasingContent, + RootContent, + Strong, + Text, + ThematicBreak, +} from 'mdast'; + +import { Story, Verse, isBlockVerse } from '../../urbit/channel'; import { Block, Blockquote, @@ -35,26 +54,7 @@ import { isShip, isStrikethrough, isTask, -} from '@tloncorp/api/urbit/content'; -import type { - Delete, - Emphasis, - Heading, - Blockquote as MdastBlockquote, - Code as MdastCode, - Image as MdastImage, - InlineCode as MdastInlineCode, - Link as MdastLink, - List as MdastList, - ListItem as MdastListItem, - Paragraph, - PhrasingContent, - RootContent, - Strong, - Text, - ThematicBreak, -} from 'mdast'; - +} from '../../urbit/content'; import type { ShipMention } from './shipMentionPlugin'; /** diff --git a/packages/shared/src/logic/markdown/test.md b/packages/api/src/client/markdown/test.md similarity index 100% rename from packages/shared/src/logic/markdown/test.md rename to packages/api/src/client/markdown/test.md diff --git a/packages/api/src/client/postContent.ts b/packages/api/src/client/postContent.ts index dfa5319a50..c193936e94 100644 --- a/packages/api/src/client/postContent.ts +++ b/packages/api/src/client/postContent.ts @@ -9,6 +9,8 @@ import { assertNever } from '../lib/assertNever'; import { VIDEO_REGEX, containsOnlyEmoji } from '../lib/utils'; import type { ContentReference } from '../types/references'; import * as ub from '../urbit'; +import { extractTablesFromContent } from './markdown/extractTables'; +import { convertInlineContent } from './postContentInlines'; import type { PostContent as ApiPostContent } from './postsApi'; // Inline types @@ -157,6 +159,23 @@ export type ListData = { children?: ListData[]; }; +export type TableAlignment = 'left' | 'center' | 'right'; + +export type TableCellData = { + content: InlineData[]; +}; + +export type TableRowData = { + cells: TableCellData[]; +}; + +export type TableBlockData = { + type: 'table'; + header: TableRowData; + rows: TableRowData[]; + align: (TableAlignment | null)[]; +}; + export type BlockData = | BlockquoteBlockData | ParagraphBlockData @@ -170,7 +189,8 @@ export type BlockData = | HeaderBlockData | RuleBlockData | ListBlockData - | BigEmojiBlockData; + | BigEmojiBlockData + | TableBlockData; export type BlockType = BlockData['type']; @@ -263,6 +283,13 @@ export function plaintextPreviewOf( return plaintextPreviewOfListData(block.list, config); case 'bigEmoji': return block.emoji; + case 'table': { + const headerText = block.header.cells + .map((cell) => plaintextPreviewOfInlineString(cell.content, config)) + .filter((text) => text.length > 0) + .join(' | '); + return headerText || '(Table)'; + } } }) .join(config.blockSeparator) @@ -422,7 +449,7 @@ export function convertContent( } out.push(...convertContentSafe(story)); - return out; + return extractTablesFromContent(out); } /** @@ -604,77 +631,10 @@ function convertListing(listing: ub.Listing): ListData { } } -function convertInlineContent(inlines: ub.Inline[]): InlineData[] { - const nodes: InlineData[] = []; - inlines.forEach((inline, i) => { - if (typeof inline === 'string') { - nodes.push({ - type: 'text', - text: inline, - }); - } else if (ub.isBold(inline)) { - nodes.push({ - type: 'style', - style: 'bold', - children: convertInlineContent(inline.bold), - }); - } else if (ub.isItalics(inline)) { - nodes.push({ - type: 'style', - style: 'italic', - children: convertInlineContent(inline.italics), - }); - } else if (ub.isStrikethrough(inline)) { - nodes.push({ - type: 'style', - style: 'strikethrough', - children: convertInlineContent(inline.strike), - }); - } else if (ub.isInlineCode(inline)) { - nodes.push({ - type: 'style', - style: 'code', - children: [{ type: 'text', text: inline['inline-code'] }], - }); - } else if (ub.isLink(inline)) { - nodes.push({ - type: 'link', - href: inline.link.href, - text: inline.link.content ?? inline.link.href, - }); - } else if (ub.isBreak(inline)) { - // Most content has a final line break after it -- we don't want to render it. - if (i !== inlines.length - 1) { - nodes.push({ - type: 'lineBreak', - }); - } - } else if (ub.isShip(inline)) { - nodes.push({ - type: 'mention', - contactId: inline.ship, - }); - } else if (ub.isSect(inline)) { - nodes.push({ - type: 'groupMention', - group: !inline.sect ? 'all' : inline.sect, - }); - } else if (ub.isTask(inline)) { - nodes.push({ - type: 'task', - checked: inline.task.checked, - children: convertInlineContent(inline.task.content), - }); - } else { - console.warn('Unhandled inline type:', { inline }); - nodes.push({ - type: 'text', - text: 'Unknown content type', - }); - } - }); - return nodes; -} +// Re-exported (and used internally) — definition lives in postContentInlines +// so that markdown/extractTables can import it without going through +// postContent (which would create a cycle). +export { convertInlineContent }; export function prependInline( content: BlockData[], diff --git a/packages/api/src/client/postContentInlines.ts b/packages/api/src/client/postContentInlines.ts new file mode 100644 index 0000000000..b102e5a24f --- /dev/null +++ b/packages/api/src/client/postContentInlines.ts @@ -0,0 +1,80 @@ +import * as ub from '../urbit'; +import type { InlineData } from './postContent'; + +/** + * Convert a list of wire-format `ub.Inline` values to local `InlineData[]`. + * Lives in its own file so that `extractTables.ts` (called from + * `convertContent` in `postContent.ts`) can import it without creating a + * cycle through `postContent.ts`. + */ +export function convertInlineContent(inlines: ub.Inline[]): InlineData[] { + const nodes: InlineData[] = []; + inlines.forEach((inline, i) => { + if (typeof inline === 'string') { + nodes.push({ + type: 'text', + text: inline, + }); + } else if (ub.isBold(inline)) { + nodes.push({ + type: 'style', + style: 'bold', + children: convertInlineContent(inline.bold), + }); + } else if (ub.isItalics(inline)) { + nodes.push({ + type: 'style', + style: 'italic', + children: convertInlineContent(inline.italics), + }); + } else if (ub.isStrikethrough(inline)) { + nodes.push({ + type: 'style', + style: 'strikethrough', + children: convertInlineContent(inline.strike), + }); + } else if (ub.isInlineCode(inline)) { + nodes.push({ + type: 'style', + style: 'code', + children: [{ type: 'text', text: inline['inline-code'] }], + }); + } else if (ub.isLink(inline)) { + nodes.push({ + type: 'link', + href: inline.link.href, + text: inline.link.content ?? inline.link.href, + }); + } else if (ub.isBreak(inline)) { + // Most content has a final line break after it -- we don't want to render it. + if (i !== inlines.length - 1) { + nodes.push({ + type: 'lineBreak', + }); + } + } else if (ub.isShip(inline)) { + nodes.push({ + type: 'mention', + contactId: inline.ship, + }); + } else if (ub.isSect(inline)) { + nodes.push({ + type: 'groupMention', + group: !inline.sect ? 'all' : inline.sect, + }); + } else if (ub.isTask(inline)) { + nodes.push({ + type: 'task', + checked: inline.task.checked, + children: convertInlineContent(inline.task.content), + }); + } else { + console.warn('Unhandled inline type:', { inline }); + nodes.push({ + type: 'text', + text: 'Unknown content type', + }); + } + }); + return nodes; +} diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts index aa4a0d9c33..6cb3e50ced 100644 --- a/packages/api/vitest.config.ts +++ b/packages/api/vitest.config.ts @@ -3,7 +3,11 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', - include: ['src/test/**/*.test.ts', 'src/__tests__/**/*.test.ts'], + include: [ + 'src/test/**/*.test.ts', + 'src/__tests__/**/*.test.ts', + 'src/client/**/*.test.ts', + ], setupFiles: ['./src/test/setup.ts'], passWithNoTests: true, testTimeout: 30000, // 30s for network operations diff --git a/packages/app/ui/components/PostContent/BlockRenderer.tsx b/packages/app/ui/components/PostContent/BlockRenderer.tsx index 6793ae7a72..4c914b3954 100644 --- a/packages/app/ui/components/PostContent/BlockRenderer.tsx +++ b/packages/app/ui/components/PostContent/BlockRenderer.tsx @@ -19,10 +19,17 @@ import React, { memo, useCallback, useContext, + useEffect, useMemo, + useRef, useState, } from 'react'; -import { ActivityIndicator, Linking, Platform } from 'react-native'; +import { + ActivityIndicator, + type LayoutChangeEvent, + Linking, + Platform, +} from 'react-native'; import { ScrollView, View, ViewStyle, XStack, YStack, styled } from 'tamagui'; import { useNowPlayingController } from '../../contexts/nowPlaying'; @@ -678,6 +685,157 @@ export const HeaderText = styled(Text, { }); HeaderText.displayName = 'HeaderText'; +function alignToTextAlign( + align: cn.TableAlignment | null | undefined +): 'left' | 'center' | 'right' | 'auto' { + if (align === 'left' || align === 'center' || align === 'right') return align; + return 'auto'; +} + +const TABLE_MAX_COLUMN_WIDTH = 280; + +type TablePhase = 'measure-natural' | 'measure-wrapped' | 'done'; + +export function TableBlock({ block }: { block: cn.TableBlockData }) { + const columnCount = Math.max( + block.header.cells.length, + ...block.rows.map((r) => r.cells.length) + ); + const allRows = [block.header, ...block.rows]; + const totalCells = columnCount * allRows.length; + + const naturalDimsRef = useRef>( + new Map() + ); + const finalHeightsRef = useRef>(new Map()); + const pendingRef = useRef>(new Set()); + + const [phase, setPhase] = useState('measure-natural'); + const [columnWidths, setColumnWidths] = useState(null); + const [rowHeights, setRowHeights] = useState(null); + + // Reset measurement state when the block changes — ContentRenderer keys + // children by array index, so an edited post can land a new table at the + // same index and reuse this component instance. + useEffect(() => { + setPhase('measure-natural'); + setColumnWidths(null); + setRowHeights(null); + naturalDimsRef.current.clear(); + finalHeightsRef.current.clear(); + pendingRef.current.clear(); + }, [block]); + + const finalizeHeights = useCallback(() => { + const heights: number[] = []; + for (let row = 0; row < allRows.length; row++) { + let max = 0; + for (let col = 0; col < columnCount; col++) { + max = Math.max(max, finalHeightsRef.current.get(`${row}-${col}`) ?? 0); + } + heights.push(max); + } + setRowHeights(heights); + setPhase('done'); + }, [columnCount, allRows.length]); + + const handleCellLayout = useCallback( + (rowIdx: number, colIdx: number) => (e: LayoutChangeEvent) => { + const key = `${rowIdx}-${colIdx}`; + const { width, height } = e.nativeEvent.layout; + + if (phase === 'measure-natural') { + naturalDimsRef.current.set(key, { w: width, h: height }); + if (naturalDimsRef.current.size < totalCells) return; + + const widths: number[] = []; + for (let col = 0; col < columnCount; col++) { + let max = 0; + for (let row = 0; row < allRows.length; row++) { + max = Math.max(max, naturalDimsRef.current.get(`${row}-${col}`)!.w); + } + widths.push(Math.min(max, TABLE_MAX_COLUMN_WIDTH)); + } + + // For cells whose natural width fits the (possibly capped) column, + // their natural height is the final height. For cells that exceed the + // cap, we mark them pending — they'll re-layout after widths apply. + for (let row = 0; row < allRows.length; row++) { + for (let col = 0; col < columnCount; col++) { + const k = `${row}-${col}`; + const dims = naturalDimsRef.current.get(k)!; + if (dims.w > widths[col]) { + pendingRef.current.add(k); + } else { + finalHeightsRef.current.set(k, dims.h); + } + } + } + + setColumnWidths(widths); + + if (pendingRef.current.size === 0) { + finalizeHeights(); + } else { + setPhase('measure-wrapped'); + } + return; + } + + if (phase === 'measure-wrapped') { + if (!pendingRef.current.has(key)) return; + finalHeightsRef.current.set(key, height); + pendingRef.current.delete(key); + if (pendingRef.current.size === 0) { + finalizeHeights(); + } + } + }, + [phase, columnCount, allRows.length, totalCells, finalizeHeights] + ); + + return ( + + + {Array.from({ length: columnCount }).map((_, colIdx) => { + const align = block.align[colIdx] ?? null; + const textAlign = alignToTextAlign(align); + const colWidth = columnWidths?.[colIdx]; + return ( + + {allRows.map((row, rowIdx) => { + const cell = row.cells[colIdx] ?? { content: [] }; + const isHeader = rowIdx === 0; + const rowHeight = rowHeights?.[rowIdx]; + return ( + + + + ); + })} + + ); + })} + + + ); +} + export type BlockRenderer = (props: { block: T; }) => React.ReactNode; @@ -709,6 +867,7 @@ export const defaultBlockRenderers: BlockRendererConfig = { bigEmoji: BigEmojiBlock, file: FileUploadBlock, voicememo: VoiceMemoBlock, + table: TableBlock, }; type BlockSettings = Partial> & { @@ -731,6 +890,7 @@ export type DefaultRendererProps = { bigEmoji: BlockSettings; file: BlockSettings; voicememo: BlockSettings; + table: BlockSettings; }; interface BlockRendererContextValue { diff --git a/packages/shared/package.json b/packages/shared/package.json index fc375c7b3b..5df726e6a9 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -69,17 +69,12 @@ "big-integer": "^1.6.52", "browser-or-node": "^3.0.0", "exponential-backoff": "^3.1.1", - "remark-gfm": "^4.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", "sorted-btree": "^1.8.1", - "unified": "^11.0.0", "uuid": "^9.0.0", "validator": "^13.7.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.9", - "@types/mdast": "^4.0.0", "better-sqlite3": "11.8.1", "drizzle-kit": "0.28.0", "typescript": "5.1.3", diff --git a/packages/shared/src/logic/index.ts b/packages/shared/src/logic/index.ts index 099dad1632..402d315e9a 100644 --- a/packages/shared/src/logic/index.ts +++ b/packages/shared/src/logic/index.ts @@ -6,7 +6,7 @@ export * as featureFlags from '@tloncorp/api/lib/featureFlags'; export * from './tiptap'; export * from '@tloncorp/api/lib/hosting'; export * from '@tloncorp/api/lib/noun'; -export * from './markdown'; +export * from '@tloncorp/api/client/markdown'; export * from '@tloncorp/api/lib/pinning'; export * from '@tloncorp/api/client/activity'; export * from '@tloncorp/api/client/branch'; diff --git a/packages/shared/src/logic/roundTrip.test.ts b/packages/shared/src/logic/roundTrip.test.ts index f99c5fe068..c1b0e81886 100644 --- a/packages/shared/src/logic/roundTrip.test.ts +++ b/packages/shared/src/logic/roundTrip.test.ts @@ -1,3 +1,7 @@ +import { + markdownToStory, + storyToMarkdown, +} from '@tloncorp/api/client/markdown'; import { JSONContent } from '@tloncorp/api/urbit'; import { Story, constructStory } from '@tloncorp/api/urbit/channel'; import { @@ -19,7 +23,6 @@ import { } from '@tloncorp/api/urbit/content'; import { describe, expect, it } from 'vitest'; -import { markdownToStory, storyToMarkdown } from './markdown'; import { JSONToInlines } from './tiptap'; /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e516c360e..bdf1f56fa9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1000,6 +1000,9 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.190.0 version: 3.190.0 + '@types/mdast': + specifier: ^4.0.0 + version: 4.0.4 '@urbit/aura': specifier: ^3.0.0 version: 3.0.0 @@ -1030,9 +1033,27 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.23 + mdast-util-gfm: + specifier: ^3.0.0 + version: 3.1.0 + mdast-util-to-markdown: + specifier: ^2.1.2 + version: 2.1.2 + remark-gfm: + specifier: ^4.0.0 + version: 4.0.1 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 + remark-stringify: + specifier: ^11.0.0 + version: 11.0.0 sorted-btree: specifier: ^1.8.1 version: 1.8.1 + unified: + specifier: ^11.0.0 + version: 11.0.5 validator: specifier: ^13.7.0 version: 13.7.0 @@ -1261,21 +1282,9 @@ importers: react: specifier: 19.1.0 version: 19.1.0 - remark-gfm: - specifier: ^4.0.0 - version: 4.0.1 - remark-parse: - specifier: ^11.0.0 - version: 11.0.0 - remark-stringify: - specifier: ^11.0.0 - version: 11.0.0 sorted-btree: specifier: ^1.8.1 version: 1.8.1 - unified: - specifier: ^11.0.0 - version: 11.0.5 uuid: specifier: ^9.0.0 version: 9.0.0 @@ -1289,9 +1298,6 @@ importers: '@types/better-sqlite3': specifier: ^7.6.9 version: 7.6.9 - '@types/mdast': - specifier: ^4.0.0 - version: 4.0.4 better-sqlite3: specifier: 11.8.1 version: 11.8.1