cell-refs.ts4.6 KBView on GitHub
/**
 * `[[type: id]]` tokens inside a cell value.
 *
 * Cell values are stored verbatim as strings — the tokens are text in the Y.Map and are
 * parsed into chips only at render time (apps/mail/docs/table-documents.md §3.2 step 2),
 * so this module is the render-time half of that contract: parse, never store.
 *
 * ── Why this is a second implementation, and how it stays one grammar ──
 *
 * The canonical `parseCellRefs` lives server-side in
 * `apps/server/src/services/documents/table/table-markdown.ts`, over the shared
 * `CEDAR_DOC_REFERENCE_REGEX` from `apps/server/src/services/documents/document-types.ts`.
 * Neither is reachable from the client: `@zero/server`'s exports map only publishes
 * `./table` (table-types.ts) and `./table/ydoc`, and a bare deep import is not resolvable.
 * Until one of those two symbols is re-exported through `@zero/server/table` (see the
 * Phase 6 report), the grammar is restated here ONCE, in `CELL_REF_PATTERN`, and
 * `apps/mail/tests/modules/documents/table/cell-refs.test.ts` pins it against the server
 * regex's literal source so a divergence fails a test rather than silently rendering a
 * token as plain text.
 */

import { CEDAR_DOC_REFERENCE_PATTERN, isTableRefType, type CellRef } from '@zero/server/table';

/**
 * The `[[type: id]]` grammar, taken from the server's single definition rather than
 * restated here — a second literal is a grammar that can silently diverge, and the failure
 * is a token the writer emits but the grid renders as plain text. It stays a pattern SOURCE
 * (not a shared regex) so nothing can leak a `lastIndex` between callers.
 */
export const CELL_REF_PATTERN = CEDAR_DOC_REFERENCE_PATTERN;

/**
 * Reference kinds the grid can render a chip for. A superset of `TABLE_REF_TYPE`: `event`
 * is not a column type and never validated server-side, but the `{{` mention inserts an
 * `eventNode` and a cell editor serializes it to `[[event: id]]`, so the grid has to be
 * able to render it back. Anything outside this set stays plain text.
 */
export const CHIP_REF_TYPES = [
  'conversation',
  'task',
  'draft',
  'doc',
  'person',
  'company',
  'event',
] as const;

export type ChipRefType = (typeof CHIP_REF_TYPES)[number];

const CHIP_REF_TYPE_VALUES = new Set<string>(CHIP_REF_TYPES);

export function isChipRefType(value: string): value is ChipRefType {
  return CHIP_REF_TYPE_VALUES.has(value);
}

/** A parsed token, widened from `CellRef` to the chip-renderable set. */
export interface CellChipRef extends Omit<CellRef, 'refType'> {
  refType: ChipRefType;
}

/**
 * Every `[[type: id]]` token in `value`, in order, with character offsets — so a renderer
 * can interleave the text between them. Malformed and unknown-type tokens are skipped and
 * therefore render as the literal text they are.
 */
export function parseCellRefs(value: string): CellChipRef[] {
  const refs: CellChipRef[] = [];
  const re = new RegExp(CELL_REF_PATTERN, 'g');
  let match: RegExpExecArray | null;
  while ((match = re.exec(value)) !== null) {
    const refType = match[1] ?? '';
    const refId = (match[2] ?? '').trim();
    if (!refId || !isChipRefType(refType)) continue;
    refs.push({ refType, refId, start: match.index, end: match.index + match[0].length });
  }
  return refs;
}

/** Serialize a ref back to its canonical token — the inverse of `parseCellRefs`. */
export function formatCellRef(refType: string, refId: string): string {
  return `[[${refType}: ${refId}]]`;
}

/** True when this ref kind is also a legal reference COLUMN type (server-validated). */
export function isColumnRefType(refType: ChipRefType): boolean {
  return isTableRefType(refType);
}

export type CellSegment =
  | { kind: 'text'; text: string }
  | { kind: 'ref'; ref: CellChipRef };

/**
 * Split a cell value into the alternating text / chip segments the display cell renders.
 * A value with no tokens yields exactly one text segment, so the caller needs no special
 * case for the overwhelmingly common plain-text cell.
 */
export function splitCellValue(value: string): CellSegment[] {
  const refs = parseCellRefs(value);
  if (refs.length === 0) return value ? [{ kind: 'text', text: value }] : [];
  const segments: CellSegment[] = [];
  let cursor = 0;
  for (const ref of refs) {
    if (ref.start > cursor) {
      segments.push({ kind: 'text', text: value.slice(cursor, ref.start) });
    }
    segments.push({ kind: 'ref', ref });
    cursor = ref.end;
  }
  if (cursor < value.length) segments.push({ kind: 'text', text: value.slice(cursor) });
  return segments;
}

/** True when the value holds at least one renderable reference. */
export function hasCellRefs(value: string): boolean {
  return parseCellRefs(value).length > 0;
}