print-table.ts4.1 KBView on GitHub
/**
 * Render a table document's markdown mirror as an HTML `<table>` for printing.
 *
 * Print is the one surface with no grid component available: it opens a blob URL in a new
 * window, so it gets a static string rather than React. The mirror was previously dumped into a
 * `<pre>`, which prints pipe syntax — legible only to someone who already knows the format, and
 * that is not who reaches for Print.
 *
 * Deliberately a small local parser rather than the server's `parseTableMarkdown`: that module is
 * not on the client's export surface (`@zero/server/table` exposes the types and the Y layout,
 * not the markdown codec) and pulling it in would drag the frontmatter reader and the schema
 * coercion into the bundle to render a header row. The input here is not arbitrary markdown —
 * it is this product's own serializer output — so recognising a pipe table is enough.
 *
 * The reserved `_id` column is dropped: it is Cedar's row handle, not content, and a printout is
 * for a human.
 */

/** Split a pipe row on unescaped `|`, mirroring the serializer's `\|` escape. */
function splitRow(line: string): string[] {
  const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
  const cells: string[] = [];
  let current = '';
  for (let i = 0; i < trimmed.length; i++) {
    const ch = trimmed[i]!;
    if (ch === '\\' && trimmed[i + 1] === '|') {
      current += '|';
      i += 1;
      continue;
    }
    if (ch === '|') {
      cells.push(current.trim());
      current = '';
      continue;
    }
    current += ch;
  }
  cells.push(current.trim());
  return cells;
}

const isDelimiter = (line: string): boolean =>
  splitRow(line).every((c) => /^:?-{1,}:?$/.test(c) || c === '');

function escapeHtml(value: string): string {
  return value
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

/**
 * Decode the cell escapes the serializer emits, so a printed cell shows the value rather than
 * its encoding. Kept in step with `table-cell-escape.ts` on the server — the named set there is
 * deliberately small, which is what makes this short.
 */
function decodeCell(value: string): string {
  let out = '';
  for (let i = 0; i < value.length; i++) {
    const ch = value[i]!;
    if (ch !== '\\') {
      out += ch;
      continue;
    }
    const next = value[i + 1];
    if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(value.slice(i + 2, i + 6))) {
      out += String.fromCharCode(parseInt(value.slice(i + 2, i + 6), 16));
      i += 5;
      continue;
    }
    if (next === '\\' || next === '|') {
      out += next;
      i += 1;
      continue;
    }
    if (next === 'n') {
      out += '\n';
      i += 1;
      continue;
    }
    out += ch;
  }
  return out;
}

/**
 * Markdown mirror → printable HTML. Falls back to a `<pre>` of the raw markdown when no pipe
 * table is found, so a malformed or half-written document still prints something rather than a
 * blank page.
 */
export function tableMarkdownToPrintHtml(markdown: string): string {
  const lines = markdown
    .split('\n')
    .map((l) => l.trim())
    .filter((l) => l.startsWith('|') && l.length > 1);

  if (lines.length === 0) return `<pre>${escapeHtml(markdown)}</pre>`;

  const header = splitRow(lines[0]!);
  const bodyStart = lines.length > 1 && isDelimiter(lines[1]!) ? 2 : 1;

  // `_id` is Cedar's row handle, not content.
  const keep = header.map((h) => h !== '_id');
  const cell = (raw: string, tag: 'th' | 'td'): string => {
    // A cell newline becomes a `<br>`; the value itself is escaped first, so content can never
    // introduce markup.
    const text = escapeHtml(decodeCell(raw)).replace(/\n/g, '<br />');
    return `<${tag}>${text}</${tag}>`;
  };

  const head = header
    .filter((_, i) => keep[i])
    .map((h) => cell(h, 'th'))
    .join('');

  const rows = lines
    .slice(bodyStart)
    .map((line) => {
      const cells = splitRow(line);
      const tds = header
        .map((_, i) => (keep[i] ? cell(cells[i] ?? '', 'td') : ''))
        .join('');
      return `<tr>${tds}</tr>`;
    })
    .join('');

  return `<table><thead><tr>${head}</tr></thead><tbody>${rows}</tbody></table>`;
}