fix(chronik): sentinel-based title split + drop duplicate comment preview
Two PR #288 blockers from Felix and Leonie: Felix: verbText.indexOf(docTitle) broke when the title was empty (indexOf returned 0, the before/after slices both emptied) or when the title substring-matched any word in the compiled Paraglide message (e.g. "Brief" appearing inside a translated verb). Swap to a sentinel approach: interpolate {doc} with U+0001, then split the compiled text on that sentinel — robust regardless of title content or translator sentence order. Two new red tests lock the invariant: empty title still renders the row link; short titles that could substring-match render exactly once as a single chronik-doc-title span. Leonie: the comment variant rendered „{documentTitle}" as a placeholder, which made the row show the same title twice — once as the underlined link, once as the italic "preview quote" — implying the comment was quoting itself. Replace with an italic ellipsis „…". A new red test asserts the preview no longer contains the document title text verbatim. While here, add a SECURITY comment next to the TODO so the next person who wires item.commentPreview knows the backend must truncate/strip server-side and the frontend must use {text}, never {@html} (Nora, issue #285 #3552). Part of #285, address PR #288 review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -68,13 +68,18 @@ function formatTimeHHMM(iso: string): string {
|
||||
const actorName: string = $derived(item.actor?.name ?? item.actor?.initials ?? '?');
|
||||
const docTitle: string = $derived(item.documentTitle);
|
||||
|
||||
// The visible verb phrase (split so we can render the document title as a styled span).
|
||||
// We compute the text once and then split it on the document title string — a pragmatic
|
||||
// choice that lets translators keep the sentence natural while we still style the doc link.
|
||||
// We split the translated verb around the document title so the title can be
|
||||
// rendered as a styled <span> inside the <a> without nesting anchors. Using a
|
||||
// non-printable sentinel (U+0001) as the {doc} interpolation value lets us
|
||||
// split the compiled message regardless of what the actual title contains —
|
||||
// empty strings, short substrings that also appear in the verb, and any
|
||||
// translator sentence order all work without special cases.
|
||||
const SENTINEL = '\u0001';
|
||||
|
||||
const verbText: string = $derived(
|
||||
variant === 'rollup'
|
||||
? verbRollup(item.kind, actorName, docTitle, item.count)
|
||||
: verbSingleton(item.kind, actorName, docTitle)
|
||||
? verbRollup(item.kind, actorName, SENTINEL, item.count)
|
||||
: verbSingleton(item.kind, actorName, SENTINEL)
|
||||
);
|
||||
|
||||
const timeLabel: string = $derived(
|
||||
@@ -83,13 +88,12 @@ const timeLabel: string = $derived(
|
||||
: relativeTime(item.happenedAt)
|
||||
);
|
||||
|
||||
// Split the verb text around the document title so the title can be styled without nesting anchors.
|
||||
const verbParts: { before: string; after: string } = $derived.by(() => {
|
||||
const idx = verbText.indexOf(docTitle);
|
||||
const idx = verbText.indexOf(SENTINEL);
|
||||
if (idx === -1) return { before: verbText, after: '' };
|
||||
return {
|
||||
before: verbText.slice(0, idx),
|
||||
after: verbText.slice(idx + docTitle.length)
|
||||
after: verbText.slice(idx + SENTINEL.length)
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@@ -133,8 +137,9 @@ const verbParts: { before: string; after: string } = $derived.by(() => {
|
||||
<!-- Body -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-sans text-sm leading-snug text-ink">
|
||||
{verbParts.before}<span class="underline decoration-accent underline-offset-2"
|
||||
>{docTitle}</span
|
||||
{verbParts.before}<span
|
||||
data-testid="chronik-doc-title"
|
||||
class="underline decoration-accent underline-offset-2">{docTitle}</span
|
||||
>{verbParts.after}
|
||||
{#if variant === 'rollup'}
|
||||
<span
|
||||
@@ -149,15 +154,18 @@ const verbParts: { before: string; after: string } = $derived.by(() => {
|
||||
{#if variant === 'comment'}
|
||||
<!--
|
||||
TODO: the backend does not yet expose a comment body preview on
|
||||
ActivityFeedItemDTO. For now we render the document title as a placeholder
|
||||
so the comment variant structure is already in place; swap this to
|
||||
item.commentPreview (or similar) once the DTO gains the field.
|
||||
ActivityFeedItemDTO. Render an ellipsis placeholder until it does —
|
||||
duplicating the document title here looks like the comment is
|
||||
quoting itself (Leonie, PR #288 review).
|
||||
SECURITY: once item.commentPreview lands, render via {text}, never
|
||||
{@html}. The backend must truncate and strip tags server-side (Nora,
|
||||
issue #285 comment #3552).
|
||||
-->
|
||||
<p
|
||||
data-testid="chronik-comment-preview"
|
||||
class="mt-1 line-clamp-1 font-serif text-sm text-ink-2 italic sm:line-clamp-2"
|
||||
>
|
||||
„{docTitle}“
|
||||
„…“
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -118,4 +118,37 @@ describe('ChronikRow', () => {
|
||||
const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
|
||||
expect(preview).not.toBeNull();
|
||||
});
|
||||
|
||||
it('comment preview does NOT duplicate the document title verbatim', async () => {
|
||||
// Leonie: user sees the title twice otherwise — looks like the comment is quoting itself.
|
||||
// Until the backend exposes item.commentPreview, the placeholder must be distinct.
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'COMMENT_ADDED',
|
||||
documentTitle: 'Brief vom 12. Juli 1920'
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
|
||||
expect(preview).not.toBeNull();
|
||||
expect(preview?.textContent).not.toContain('Brief vom 12. Juli 1920');
|
||||
});
|
||||
|
||||
// --- robustness: title rendering for edge cases ---
|
||||
it('still renders the row link when documentTitle is an empty string', async () => {
|
||||
// Felix: verbText.indexOf(docTitle) returned 0 for empty titles — the span
|
||||
// collapsed and before/after both emptied out. Swap to a sentinel-based
|
||||
// approach so this case renders like every other row.
|
||||
const empty: ActivityFeedItemDTO = { ...baseItem, documentTitle: '' };
|
||||
render(ChronikRow, { item: empty });
|
||||
const link = document.querySelector('a[href="/documents/doc-1"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders a short document title that could substring-match the verb', async () => {
|
||||
const short: ActivityFeedItemDTO = { ...baseItem, documentTitle: 'Brief' };
|
||||
render(ChronikRow, { item: short });
|
||||
const titleEls = document.querySelectorAll('[data-testid="chronik-doc-title"]');
|
||||
expect(titleEls.length).toBe(1);
|
||||
expect(titleEls[0].textContent).toBe('Brief');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user