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;
}