TableSharePreview.tsx5.2 KBView on GitHub 'use client';
/**
* A table on the PUBLIC share page — read-only, and deliberately not the grid.
*
* The share page is unauthenticated: it has no session, no tRPC client, no Y.js provider, and
* no way to resolve a `[[conversation: id]]` into a name. So it renders the markdown mirror
* the share endpoint already returns, which is exactly the escape hatch the design names for
* the surfaces that key off ProseMirror ("both can read the markdown mirror directly", §3.2
* step 9). Reference tokens degrade to a muted type label rather than leaking an internal id
* as if it were content.
*
* The parse here is deliberately only the PIPE TABLE — not the frontmatter schema. Column
* labels are in the header row, `_id` is the first column and is dropped, and nothing else
* about a read-only view depends on the schema. That is why this is ~40 lines instead of a
* second copy of `table-markdown.ts`.
*/
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { parseCellRefs } from './cell-refs';
export interface TableSharePreviewProps {
/** `documents.content` — the markdown mirror: frontmatter, then one GFM pipe table. */
content: string;
className?: string;
}
export function TableSharePreview({ content, className }: TableSharePreviewProps) {
const table = useMemo(() => parsePipeTable(content), [content]);
if (!table) {
return <pre className="whitespace-pre-wrap break-words text-sm text-gray-600">{content}</pre>;
}
return (
<div className={cn('overflow-x-auto rounded-lg border border-gray-200', className)}>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-gray-50">
{table.headers.map((header, index) => (
<th
key=[redacted]
className="border-b border-gray-200 px-3 py-2 text-left font-medium text-gray-700"
>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{table.rows.map((row, rowIndex) => (
<tr key=[redacted] className="border-b border-gray-100 last:border-b-0">
{table.headers.map((header, columnIndex) => (
<td key=[redacted] className="px-3 py-2 align-top text-gray-800">
<ShareCell value={row[columnIndex] ?? ''} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
/** Renders text runs verbatim and reference tokens as their kind — never a raw id. */
function ShareCell({ value }: { value: string }) {
const refs = parseCellRefs(value);
if (refs.length === 0) return <>{value}</>;
const parts: React.ReactNode[] = [];
let cursor = 0;
refs.forEach((ref, index) => {
if (ref.start > cursor) parts.push(value.slice(cursor, ref.start));
parts.push(
<span
key=[redacted]
title={ref.refType}
className="mx-0.5 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs capitalize text-gray-500"
>
{ref.refType}
</span>,
);
cursor = ref.end;
});
if (cursor < value.length) parts.push(value.slice(cursor));
return <>{parts}</>;
}
interface ParsedPipeTable {
headers: string[];
rows: string[][];
}
/**
* The first GFM pipe table in `content`, minus its leading `_id` column.
*
* Mirrors `splitPipeRow` in `apps/server/src/services/documents/table/table-markdown.ts`,
* including the `\|` escape the serializer emits — the one thing a naive `split('|')` gets
* wrong, and the one that would silently shift every cell in the row.
*/
function parsePipeTable(content: string): ParsedPipeTable | null {
const lines = stripFrontmatter(content)
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('|'));
if (lines.length === 0) return null;
const header = splitPipeRow(lines[0] ?? '');
// Line 2 of a GFM table is the alignment rule; anything after it is data.
const bodyStart = lines[1] && /^[\s|:-]+$/.test(lines[1]) ? 2 : 1;
const rows = lines.slice(bodyStart).map((line) => splitPipeRow(line));
// The first column is the reserved `_id`: a stable handle, never data.
const isIdFirst = (header[0] ?? '').replace(/\\/g, '') === '_id';
return {
headers: isIdFirst ? header.slice(1) : header,
rows: rows.map((row) => (isIdFirst ? row.slice(1) : row)),
};
}
function stripFrontmatter(content: string): string {
if (!content.startsWith('---')) return content;
const end = content.indexOf('\n---', 3);
return end < 0 ? content : content.slice(end + 4);
}
function splitPipeRow(line: string): string[] {
const inner = line.startsWith('|') ? line.slice(1) : line;
const body = inner.endsWith('|') && !inner.endsWith('\\|') ? inner.slice(0, -1) : inner;
const cells: string[] = [];
let current = '';
for (let index = 0; index < body.length; index++) {
const character = body[index];
if (character === '\\' && body[index + 1] === '|') {
current += '|';
index += 1;
continue;
}
if (character === '|') {
cells.push(current.trim());
current = '';
continue;
}
current += character;
}
cells.push(current.trim());
return cells;
}