- 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>
164 lines
6.3 KiB
TypeScript
164 lines
6.3 KiB
TypeScript
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('&', '&')
|
||
.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 `<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;
|
||
}
|