Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m38s
CI / Build container images (pull_request) Successful in 4m14s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 1m6s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
Feeds: classified entries carry a standard Atom <category> (term=level, scheme=urn:dorfteich:classification, label=the fixed wording); the feed document states the highest contained level once; all-open feeds carry none. Public API: page representations (list+get) gain the classification field, OpenAPI + public-api.md documented. Search: every hit carries the level and the palette renders the marking with the snippet (compact form of the banner, text token only). No-JS shell: banner above and below the content, own markup for the separate render path; unclassified pages unchanged everywhere. One test per channel (feed categories + count, public API list/get with the switch on, search hit levels, shell top+bottom). Also: fidelity CI sidecars get per-job container names — the fixed names collided across parallel runs on the shared host (run 547's red fidelity job; a fixed-name cleanup could even kill a sibling's live sidecars). Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
87 lines
3.2 KiB
TypeScript
87 lines
3.2 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; }
|
|
/* VS-NfD marking (issue #211, ADR 0022): same convention as the SPA —
|
|
bold, centered, ruled band above and below the content. currentColor
|
|
keeps full contrast in both color schemes. */
|
|
.classification-banner { margin: 0.75rem 0; padding: 0.25rem 0.5rem;
|
|
border-top: 2px solid currentColor; border-bottom: 2px solid currentColor;
|
|
font-weight: 700; letter-spacing: 0.08em; text-align: center; 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, '"');
|
|
}
|