import type { MentionDTO, PersonMention } from '$lib/shared/types'; /** * Single-source CSS selector for rendered person-mention anchors. Used by: * - layout.css (.person-mention rule, focus ring, underline) * - TranscriptionReadView (delegated mouseenter/leave/click handlers) * - unit + e2e tests * * Keep these in sync — the renderer template below emits exactly this class. */ export const PERSON_MENTION_SELECTOR = 'a.person-mention'; /** * Branded string type for HTML that has been pre-escaped and assembled by * one of the trusted renderers in this module. The brand exists so that * `{@html …}` consumers can require a SafeHtml input at compile time — * `{@html block.text}` won't typecheck unless the string came through * a renderer that escapes its inputs. * * Defense in depth against stored XSS (Sina #5505 / Nora PR-B2 review). */ export type SafeHtml = string & { readonly __brand: 'SafeHtml' }; /** * Given the current textarea value and cursor position, returns the * @-mention query being typed (the text after the last triggering @), * or null if no mention is active. * * Rules: * - @ must be preceded by whitespace or be at the start of the string * - The text between @ and the cursor must not contain a space (a * completed mention word already has a space) */ export function detectMention(text: string, cursorPos: number): string | null { const before = text.slice(0, cursorPos); const atIndex = before.lastIndexOf('@'); if (atIndex === -1) return null; // @ must be at start or preceded by whitespace if (atIndex > 0 && !/\s/.test(before[atIndex - 1])) return null; const query = before.slice(atIndex + 1); // If the query contains a space the user has moved past the trigger word if (query.includes(' ')) return null; return query; } /** * Given the raw textarea value and a list of candidate users (from the * mention popup selections), returns the plain content string and the * de-duplicated list of mentioned user IDs. */ export function extractContent( text: string, candidates: MentionDTO[] ): { content: string; mentionedUserIds: string[] } { const seen = new Set(); for (const user of candidates) { const displayName = `${user.firstName} ${user.lastName}`.trim(); if (text.includes(`@${displayName}`)) { seen.add(user.id); } } return { content: text, mentionedUserIds: [...seen] }; } /** * Escapes the five HTML-special characters that can break out of text content * or attribute values. & must be escaped first to avoid double-encoding. * * Includes the apostrophe so the helper is safe in single-quoted attribute * values too — the renderTranscriptionBody anchor template in PR-B2 uses * double quotes today, but a future template change shouldn't open a * stored-XSS hole (Sina #5505 action item). */ export function escapeHtml(str: string): string { return str .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function escapeRegExp(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Strict UUID v1–v5 check. Used as a defensive boundary on PersonMention.personId * before substituting it into an `href` — even though the backend currently only * emits UUIDs, a future "external person" feature must not accidentally turn this * helper into an open-redirect surface (CWE-601). */ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function isUuid(value: string): boolean { return UUID_RE.test(value); } /** * Renders a transcription block's text segment as safe HTML for read mode. * * Rules: * 1. The full text is HTML-escaped first (defense against stored XSS). * 2. For each entry in `mentionedPersons`, every `@DisplayName` occurrence is * replaced with `DisplayName`. * The `@` prefix is stripped from the rendered link text — it is an editor * affordance, not part of the historical text (issue #362). * 3. Longest displayNames are processed first so a short prefix in the sidecar * cannot shadow a longer match in the text (e.g. `@Auguste` vs `@Auguste Raddatz`). * 4. Word-boundary lookahead prevents `@Hans` from matching `@HansMüller`. * 5. First-sidecar-wins for entries that share a displayName (deterministic * rule per Felix decision OQ-1, comment #5339). */ export function renderTranscriptionBody(text: string, mentionedPersons: PersonMention[]): SafeHtml { if (!text) return '' as SafeHtml; let escaped = escapeHtml(text); const seen = new Set(); const unique: PersonMention[] = []; for (const mention of mentionedPersons) { if (seen.has(mention.displayName)) continue; // Defense in depth: refuse to render an anchor for a non-UUID personId. // The escaped block text falls through unchanged, so the @-trigger is // preserved as plain content — no silent data loss, no clickable link. if (!isUuid(mention.personId)) continue; seen.add(mention.displayName); unique.push(mention); } const sorted = [...unique].sort((a, b) => b.displayName.length - a.displayName.length); for (const mention of sorted) { const escapedDisplayName = escapeHtml(mention.displayName); const escapedPersonId = escapeHtml(mention.personId); const pattern = new RegExp(`@${escapeRegExp(escapedDisplayName)}(?![\\p{L}\\p{N}])`, 'gu'); const link = `${escapedDisplayName}`; escaped = escaped.replace(pattern, link); } return escaped as SafeHtml; } /** * Renders a comment body as safe HTML: * 1. Escapes all HTML-special characters in the raw content * 2. Replaces every @FirstName LastName occurrence with an anchor link * 3. Converts newlines to
*/ export function renderBody(content: string, mentions: MentionDTO[]): SafeHtml { let escaped = escapeHtml(content); for (const mention of mentions) { const displayName = `${mention.firstName} ${mention.lastName}`.trim(); const escapedDisplayName = escapeHtml(displayName); const span = `@${escapedDisplayName}`; escaped = escaped.replaceAll(`@${escapedDisplayName}`, span); } return escaped.replaceAll('\n', '
') as SafeHtml; }