dorfteich/apps/api/src/public/html-shell.ts
Claude Fable 5 4ba7b50336 #167: Farbkontraste — Dark-Shell, Wikilink-Unterstreichung, Feld-Ränder
Die öffentliche Server-Shell bekommt AA-geprüfte Dark-Mode-Farben
(color-scheme: light dark hatte den UA dunkel rendern lassen, Links
fielen durch 1.4.3; Text 14,8:1, Links 10,1:1, Muted 8,5:1). Wikilinks
tragen eine permanente Unterstreichung — Farbe allein war das einzige
Link-Merkmal bei nur 2,5:1 Abstand zum Fließtext (1.4.1). Neues Token
--color-border-input (#7d8a97, 3,5:1/3,3:1) für Eingabefeld-Ränder
(1.4.11); Wächter-Kommentar am Favoriten-Gold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 14:26:54 +02:00

81 lines
2.8 KiB
TypeScript

import { apiI18n } from '../i18n/api-i18n';
/**
* The document shell shared by every server-rendered public page (#56's
* page view, #82's legal pages): identical for every viewer, crawler-safe,
* no session-dependent content. Chrome (footer links) renders in the
* instance default locale (ADR 0012). `bodyHtml` must already be safe —
* it comes from the sanitizing editor renderers, never from raw input.
*/
export interface HtmlShellOptions {
lang: 'de' | 'en';
/** Plain text; escaped here. */
title: string;
canonical?: string;
/** Atom feed of the surrounding pond (issue #149), advertised to readers. */
feedUrl?: string;
bodyHtml: string;
}
export function htmlDocument({
lang,
title,
canonical,
feedUrl,
bodyHtml,
}: HtmlShellOptions): string {
const canonicalTag = canonical ? `\n<link rel="canonical" href="${escapeHtml(canonical)}">` : '';
const feedTag = feedUrl
? `\n<link rel="alternate" type="application/atom+xml" href="${escapeHtml(feedUrl)}">`
: '';
const imprintLabel = escapeHtml(apiI18n.t('legal:links.imprint', { lng: lang }));
const privacyLabel = escapeHtml(apiI18n.t('legal:links.privacy', { lng: lang }));
return `<!doctype html>
<html lang="${lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>${canonicalTag}${feedTag}
<style>
:root { color-scheme: light dark; }
body { max-width: 48rem; margin: 2rem auto; padding: 0 1rem;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; line-height: 1.6; }
img { max-width: 100%; height: auto; }
.public-page__pond { color: #64748b; font-size: 0.9rem; }
pre { overflow-x: auto; }
.public-footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #64748b;
font-size: 0.9rem; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden;
clip-path: inset(50%); white-space: nowrap; }
/* color-scheme allows dark UA rendering, so give it AA-checked colors
(issue #167, WCAG 1.4.3): text 14.8:1, links 10.1:1, muted 8.5:1. */
@media (prefers-color-scheme: dark) {
body { background: #10161d; color: #e2e8f0; }
a { color: #93c5fd; }
.public-page__pond, .public-footer { color: #a7b3c0; }
.public-footer { border-top-color: #a7b3c0; }
}
</style>
</head>
<body>
<main class="public-page">
${bodyHtml}
</main>
<footer class="public-footer">
<a href="/legal/imprint">${imprintLabel}</a> · <a href="/legal/privacy">${privacyLabel}</a>
</footer>
</body>
</html>
`;
}
/** Minimal HTML escaping for the values interpolated into the shell (not the
* already-sanitized body HTML). */
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}