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>
175 lines
6.1 KiB
Svelte
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// 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}
|