Files
collective-lists/apps/web/src/lib/components/ChangelogModal.svelte
Oier Bravo Urtasun d11cf6ccef feat(fase-17): ChangelogModal component + unit tests
ChangelogModal renders the repo-root CHANGELOG.md (bundled at build
time via Vite's ?raw query, 5 levels up from the component file) in
an overlay+panel modal. Markdown is parsed inline (`#`/`##`/`###`
headers, `-`/`*` lists, `**bold**`) with HTML-escape on every input
line — input is static repo content so the surface is closed.

Esc and overlay-click both call `onClose`. The keydown listener is
attached only while `open` is true via a $effect.

Three new Paraglide messages (en/es): `settings_about_view_changelog`,
`changelog_modal_title`, `changelog_modal_close`.

Unit tests (CHM-U-01..04) follow the Spinner.test.ts pattern (Svelte 5
mount/unmount + flushSync for the Escape spec).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:17:08 +02:00

175 lines
6.1 KiB
Svelte

<!--
Fase 17.2 — About-modal that renders the repo-root CHANGELOG.md.
Architecture notes:
* CHANGELOG is imported via Vite's `?raw` query so the file is
bundled at build time — no fetch at runtime, no SSR + adapter-node
filesystem read, no service-worker miss. Bundle size is ~6 KB
today; revisit (dynamic import, marked, etc.) when it grows past
~30 KB or starts using non-trivial markdown features (tables,
links, code blocks).
* The path is 5 levels up from this file:
components → lib → src → web → apps → repo-root. The plan
estimated 3; documenting the off-by-two so anyone moving the
component up/down the tree adjusts the import accordingly.
* Markdown is parsed inline (`renderMarkdown` below). Input is
static repo content, never user-supplied, so we're not exposed to
the usual sanitisation concerns — we still HTML-escape every
input line before adding our own tags to keep the surface clean
and to defend against accidentally putting `<` in a future entry.
* Esc + overlay-click both call `onClose`. We attach the keydown
listener to `window` only while the modal is open; otherwise
typing Escape outside the modal would still call back.
-->
<script lang="ts">
import { onDestroy } from 'svelte';
import * as m from '$lib/paraglide/messages';
import changelogRaw from '../../../../../CHANGELOG.md?raw';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Tailwind class strings for each rendered element. Living here so
// JIT scans them at build time — putting them inside a template
// string in renderMarkdown also works, but keeping them as named
// constants makes them grep-able and theme-able.
const CLS_H1 = 'mb-3 text-xl font-bold text-slate-900 dark:text-slate-50';
const CLS_H2 = 'mt-6 mb-2 text-base font-semibold text-slate-900 dark:text-slate-50';
const CLS_H3 = 'mt-4 mb-1 text-sm font-semibold text-slate-800 dark:text-slate-100';
const CLS_UL = 'mt-1 mb-3 list-disc pl-5 space-y-0.5';
const CLS_LI = 'leading-relaxed';
const CLS_P = 'my-2 leading-relaxed';
const CLS_STRONG = 'font-semibold text-slate-900 dark:text-slate-50';
function renderMarkdown(src: string): string {
const lines = src.split('\n');
const out: string[] = [];
let inList = false;
const closeList = () => {
if (inList) {
out.push('</ul>');
inList = false;
}
};
const inlineStrong = (s: string): string =>
s.replace(/\*\*([^*]+)\*\*/g, `<strong class="${CLS_STRONG}">$1</strong>`);
for (const raw of lines) {
const line = raw.trimEnd();
if (line.startsWith('### ')) {
closeList();
out.push(`<h3 class="${CLS_H3}">${inlineStrong(escapeHtml(line.slice(4)))}</h3>`);
} else if (line.startsWith('## ')) {
closeList();
out.push(`<h2 class="${CLS_H2}">${inlineStrong(escapeHtml(line.slice(3)))}</h2>`);
} else if (line.startsWith('# ')) {
closeList();
out.push(`<h1 class="${CLS_H1}">${inlineStrong(escapeHtml(line.slice(2)))}</h1>`);
} else if (/^[-*]\s+/.test(line)) {
if (!inList) {
out.push(`<ul class="${CLS_UL}">`);
inList = true;
}
const body = line.replace(/^[-*]\s+/, '');
out.push(`<li class="${CLS_LI}">${inlineStrong(escapeHtml(body))}</li>`);
} else if (line === '') {
closeList();
} else {
closeList();
out.push(`<p class="${CLS_P}">${inlineStrong(escapeHtml(line))}</p>`);
}
}
closeList();
return out.join('\n');
}
// Pre-render once on module evaluation — the source is build-time
// static, so there's no point re-running per modal open. Placed
// after the const class strings + renderMarkdown declaration so the
// TDZ doesn't kick in.
const html = renderMarkdown(changelogRaw);
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
// Attach the global keydown listener as soon as `open` flips true.
// $effect runs after mount and re-runs on each prop change, so this
// gives us subscribe-on-open and unsubscribe-on-close without an
// extra `mounted` gate (which delayed subscription past the first
// dispatched keydown in jsdom).
$effect(() => {
if (typeof window === 'undefined') return;
if (!open) return;
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
});
onDestroy(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', handleKey);
}
});
function onOverlayClick(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
</script>
{#if open}
<div
data-testid="changelog-modal-overlay"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onclick={onOverlayClick}
role="presentation"
>
<div
data-testid="changelog-modal"
class="flex w-full max-w-2xl flex-col rounded-lg bg-surface shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised"
role="dialog"
aria-modal="true"
aria-label={m.changelog_modal_title()}
>
<header
class="flex items-center justify-between border-b border-slate-200 px-5 py-3 dark:border-slate-700"
>
<h2 class="text-base font-semibold text-slate-900 dark:text-slate-50">
{m.changelog_modal_title()}
</h2>
<button
type="button"
data-testid="changelog-modal-close"
onclick={onClose}
aria-label={m.changelog_modal_close()}
class="rounded-md p-1 text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</header>
<div
data-testid="changelog-modal-body"
class="max-h-[70vh] overflow-y-auto px-5 py-4 text-sm text-slate-700 dark:text-slate-200"
>
{@html html}
</div>
</div>
</div>
{/if}