Files
familienarchiv/frontend/src/lib/shared/discussion/mention.ts
Marcel 567612761d refactor: move lib-root files to lib/shared/ and finalize domain structure
- Move api.server.ts, errors.ts, types.ts, utils.ts, relativeTime.ts to lib/shared/
- Move person relationship components to lib/person/relationship/
- Move Stammbaum components to lib/person/genealogy/
- Move HelpPopover to lib/shared/primitives/
- Update all import paths across routes, specs, and lib files
- Update vi.mock() paths in server-project test files
- Remove now-empty legacy directories (components/, hooks/, server/, etc.)
- Update vite.config.ts coverage include paths for new structure
- Update frontend/CLAUDE.md to reflect domain-based lib/ layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:53:31 +02:00

164 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>();
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Strict UUID v1v5 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 `<a href="/persons/{personId}" class="person-mention" …>DisplayName</a>`.
* 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<string>();
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 = `<a href="/persons/${escapedPersonId}" class="person-mention" data-person-id="${escapedPersonId}">${escapedDisplayName}</a>`;
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 <br>
*/
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 = `<span class="mention" data-user-id="${mention.id}">@${escapedDisplayName}</span>`;
escaped = escaped.replaceAll(`@${escapedDisplayName}`, span);
}
return escaped.replaceAll('\n', '<br>') as SafeHtml;
}