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