dorfteich/apps/api/src/public/html-shell.ts
Claude Fable 5 521ea514b4
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
#211: classification through feeds, public API, search and the no-JS shell
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>
2026-07-31 07:23:53 +02:00

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}