From eb6e21f0321c4ab885ef26515d520639a1f4dffc Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 08:12:52 +0200 Subject: [PATCH 01/18] feat(person-mention): renderTranscriptionBody for safe read-mode HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces every @DisplayName in a transcription block's text with an anchor link to /persons/{personId}, sourced from the mentionedPersons sidecar. The @ prefix is stripped from the rendered link text per spec — it is an editor affordance, not part of the historical text. Stored-XSS hardening: HTML-escapes block text, displayName, and personId before injection. Word-boundary lookahead avoids prefix collisions (@Hans vs @HansMüller). Longest-displayName-first + first-sidecar-wins make rendering deterministic for the OQ-1 collision case (#5339). Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/utils/mention.spec.ts | 151 ++++++++++++++++++++++++- frontend/src/lib/utils/mention.ts | 46 +++++++- 2 files changed, 194 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/utils/mention.spec.ts b/frontend/src/lib/utils/mention.spec.ts index 04b659b1..47a84645 100644 --- a/frontend/src/lib/utils/mention.spec.ts +++ b/frontend/src/lib/utils/mention.spec.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { detectMention, escapeHtml, extractContent, renderBody } from './mention'; -import type { MentionDTO } from '$lib/types'; +import { + detectMention, + escapeHtml, + extractContent, + renderBody, + renderTranscriptionBody +} from './mention'; +import type { MentionDTO, PersonMention } from '$lib/types'; // ─── escapeHtml ─────────────────────────────────────────────────────────────── @@ -161,3 +167,144 @@ describe('renderBody', () => { expect(result).not.toContain('\n'); }); }); + +// ─── renderTranscriptionBody ────────────────────────────────────────────────── + +describe('renderTranscriptionBody', () => { + const auguste: PersonMention = { + personId: '550e8400-e29b-41d4-a716-446655440000', + displayName: 'Auguste Raddatz' + }; + const hans: PersonMention = { + personId: '550e8400-e29b-41d4-a716-446655440001', + displayName: 'Hans' + }; + + it('returns empty string for empty input', () => { + expect(renderTranscriptionBody('', [])).toBe(''); + }); + + it('returns escaped plain text when no mentions', () => { + expect(renderTranscriptionBody('Hello world', [])).toBe('Hello world'); + }); + + it('escapes < and > in plain block text', () => { + const result = renderTranscriptionBody('', []); + expect(result).toBe('<script>alert(1)</script>'); + expect(result).not.toContain('' + }; + const result = renderTranscriptionBody('Hi @ there', [xss]); + expect(result).not.toContain(' + +
+ {#if state.status === 'loading'} +
+
+
+
+
+ {:else if state.status === 'error'} +
+ {m.person_mention_load_error()} +
+ + {:else} +
+
+
{state.person.displayName}
+ {#if dateRange} +
{dateRange}
+ {/if} + {#if state.person.alias} +
geb. {state.person.alias}
+ {/if} +
+ {#if familyChips.length > 0} +
+ {#each familyChips as chip (chip.id)} + {chip.relatedPersonDisplayName} + {/each} +
+ {/if} + {#if notesExcerpt} +

{notesExcerpt}

+ {/if} + +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts b/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts new file mode 100644 index 00000000..ac8bb400 --- /dev/null +++ b/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts @@ -0,0 +1,272 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonHoverCard from './PersonHoverCard.svelte'; +import type { components } from '$lib/generated/api'; + +type Person = components['schemas']['Person']; +type RelationshipDTO = components['schemas']['RelationshipDTO']; + +const AUGUSTE: Person = { + id: 'p-aug', + firstName: 'Auguste', + lastName: 'Raddatz', + displayName: 'Auguste Raddatz', + personType: 'PERSON', + familyMember: true, + birthYear: 1882, + deathYear: 1944 +} as unknown as Person; + +const POSITION = { top: 100, left: 200 }; + +afterEach(() => cleanup()); + +describe('PersonHoverCard — loading state', () => { + it('shows the skeleton when state.status is loading', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loading' } + }); + await expect.element(page.getByTestId('person-hover-card-skeleton')).toBeInTheDocument(); + }); + + it('renders three skeleton bars', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loading' } + }); + const bars = document.querySelectorAll('[data-testid="person-hover-card-skeleton"] .bar'); + expect(bars.length).toBe(3); + }); +}); + +describe('PersonHoverCard — error state', () => { + it('shows a generic error message when state.status is error', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'error' } + }); + await expect.element(page.getByTestId('person-hover-card-error')).toBeInTheDocument(); + }); + + it('still allows the link footer to navigate (link present in error state)', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'error' } + }); + // The card root must show the footer link even when the body errored — + // click navigation works regardless of fetch outcome. + const link = document.querySelector('a[href="/persons/p-aug"]'); + expect(link).not.toBeNull(); + }); +}); + +describe('PersonHoverCard — loaded state', () => { + it('renders the person displayName as the header name', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + }); + + it('renders the life-date range when birthYear and deathYear are present', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument(); + }); + + it('omits the life-date line when both years are missing', async () => { + const noDates = { ...AUGUSTE, birthYear: undefined, deathYear: undefined } as Person; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: noDates, relationships: [] } + }); + const dates = document.querySelector('[data-testid="person-hover-card-dates"]'); + expect(dates).toBeNull(); + }); + + it('renders "geb. " when alias is set', async () => { + const withAlias = { ...AUGUSTE, alias: 'Müller' } as Person; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: withAlias, relationships: [] } + }); + await expect.element(page.getByText('geb. Müller')).toBeInTheDocument(); + }); + + it('omits the maiden name line when alias is null', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + const maiden = document.querySelector('[data-testid="person-hover-card-maiden"]'); + expect(maiden).toBeNull(); + }); + + it('renders family relationship chips for PARENT_OF, SPOUSE_OF, SIBLING_OF only', async () => { + const relationships: RelationshipDTO[] = [ + { + id: 'r1', + personId: 'p-aug', + relatedPersonId: 'p-spouse', + personDisplayName: 'Auguste', + relatedPersonDisplayName: 'Otto Raddatz', + relationType: 'SPOUSE_OF' + }, + { + id: 'r2', + personId: 'p-aug', + relatedPersonId: 'p-friend', + personDisplayName: 'Auguste', + relatedPersonDisplayName: 'Karl Friend', + relationType: 'FRIEND' + }, + { + id: 'r3', + personId: 'p-aug', + relatedPersonId: 'p-sibling', + personDisplayName: 'Auguste', + relatedPersonDisplayName: 'Marie Sister', + relationType: 'SIBLING_OF' + } + ]; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships } + }); + await expect.element(page.getByText('Otto Raddatz')).toBeInTheDocument(); + await expect.element(page.getByText('Marie Sister')).toBeInTheDocument(); + // Non-family relationship type must be filtered out + const friendChip = page.getByText('Karl Friend'); + await expect.element(friendChip).not.toBeInTheDocument(); + }); + + it('omits the chips section entirely when no family relationships', async () => { + const onlyFriend: RelationshipDTO[] = [ + { + id: 'r1', + personId: 'p-aug', + relatedPersonId: 'p-friend', + personDisplayName: 'Auguste', + relatedPersonDisplayName: 'Karl Friend', + relationType: 'FRIEND' + } + ]; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: onlyFriend } + }); + const chips = document.querySelector('[data-testid="person-hover-card-chips"]'); + expect(chips).toBeNull(); + }); + + it('renders notes excerpt unchanged when notes ≤ 120 characters', async () => { + const withNotes = { ...AUGUSTE, notes: 'Born in Berlin.' } as Person; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: withNotes, relationships: [] } + }); + await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument(); + }); + + it('truncates notes longer than 120 characters with an ellipsis', async () => { + const long = 'x'.repeat(150); + const withLongNotes = { ...AUGUSTE, notes: long } as Person; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: withLongNotes, relationships: [] } + }); + const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!; + expect(notes.textContent!.length).toBeLessThanOrEqual(122); + expect(notes.textContent).toContain('…'); + }); + + it('omits notes section when notes is null', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + const notes = document.querySelector('[data-testid="person-hover-card-notes"]'); + expect(notes).toBeNull(); + }); + + it('footer renders an anchor link to /persons/{personId}', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + const link = document.querySelector('a[href="/persons/p-aug"]')!; + expect(link).not.toBeNull(); + }); +}); + +describe('PersonHoverCard — accessibility', () => { + it('uses aria-live="polite" so screen readers announce loaded content', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loading' } + }); + const root = document.querySelector('[data-testid="person-hover-card"]')!; + expect(root.getAttribute('aria-live')).toBe('polite'); + }); + + it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-xyz', + position: POSITION, + state: { status: 'loading' } + }); + const root = document.querySelector('[data-testid="person-hover-card"]')!; + expect(root.id).toBe('card-xyz'); + }); + + it('positions itself absolutely at the given top/left', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: { top: 333, left: 444 }, + state: { status: 'loading' } + }); + const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement; + expect(root.style.top).toBe('333px'); + expect(root.style.left).toBe('444px'); + expect(root.style.position).toBe('absolute'); + }); +}); -- 2.49.1 From 1fd38830fe5ce6b4269f79cba89fd8e3d4792ba9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 08:21:35 +0200 Subject: [PATCH 04/18] feat(person-mention): TranscriptionReadView wires hover card and click nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composes splitByMarkers + renderTranscriptionBody so [unleserlich] markers render as siblings of the mention anchor — neither nested inside the other (B19b). Hover card lifecycle on each .person-mention anchor: mouseenter → set aria-describedby, place card via getBoundingClientRect (default below-right; flip up if <200px from bottom or mention is in bottom 30% of viewport; flip left if <300px from right), fire fetch, mount card with skeleton state resolved → swap card to loaded state with person + family relationships (PARENT_OF / SPOUSE_OF / SIBLING_OF only) 404 → degrade: mark anchor with data-person-deleted="true", unmount card, suppress future hovers/clicks network → swap card to error state — link still navigates mouseleave → drop aria-describedby, unmount card Per-page SvelteMap cache (B15.5) so a sweep across N mentions of the same person fires the backend once. Click handler calls goto() so SvelteKit handles routing without a full reload. Event listeners are attached once per article via a Svelte action because the anchor HTML is injected via {@html ...} and would not receive declarative bindings. The eslint-disable comment mirrors the rationale on CommentMessage.svelte:88-89. Co-Authored-By: Claude Sonnet 4.6 --- .../components/TranscriptionReadView.svelte | 197 +++++++++++++- .../TranscriptionReadView.svelte.test.ts | 242 +++++++++++++++++- 2 files changed, 428 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte b/frontend/src/lib/components/TranscriptionReadView.svelte index f1586e6c..80829678 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte +++ b/frontend/src/lib/components/TranscriptionReadView.svelte @@ -1,6 +1,16 @@ -
+
{#each sorted as block (block.id)}
a.sortOrder - b.sortOrder)); onclick={() => onParagraphClick(block.annotationId)} role="button" tabindex="0" - onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId); }} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId); + }} > - {#each splitByMarkers(block.text) as segment, i (i)} - {#if segment.type === 'marker'} - {segment.text} - {:else} - {segment.text} - {/if} - {/each} + + {@html renderBlockHtml(block)}
{/each}
+{#if activeCard} + +{/if} +