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
81 lines
2.8 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|