2026-05-21 17:23:08 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
from .html_utils import SafeHtml, attr, escape, trusted
|
|
|
|
|
from .icons import _svg_icon
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
|
|
|
|
|
account_html = ""
|
|
|
|
|
if label:
|
|
|
|
|
account_html = f'<span class="navbar-text nav-label text-truncate">{escape(label)}</span>'
|
|
|
|
|
if links is None:
|
|
|
|
|
links = trusted('<a class="nav-link px-0" href="/docs">API</a>')
|
|
|
|
|
theme_toggle = f"""
|
|
|
|
|
<button type="button" class="theme-toggle" data-theme-toggle aria-label="Switch to dark theme">
|
|
|
|
|
{_svg_icon("moon", "theme-icon theme-icon-moon")}
|
|
|
|
|
{_svg_icon("sun", "theme-icon theme-icon-sun")}
|
|
|
|
|
</button>
|
|
|
|
|
"""
|
|
|
|
|
return trusted(
|
|
|
|
|
f"""
|
|
|
|
|
<a class="skip-link" href="#main">Skip to content</a>
|
|
|
|
|
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
|
|
|
|
|
<div class="container">
|
|
|
|
|
<a class="navbar-brand brand" href="/"><img src="/logo-nav.webp?v=3" alt="" width="28" height="28" decoding="async">KarlDNS</a>
|
|
|
|
|
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end gap-2 gap-sm-3">
|
|
|
|
|
{links}
|
|
|
|
|
{account_html}
|
|
|
|
|
{theme_toggle}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</nav>
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _site_footer() -> SafeHtml:
|
|
|
|
|
return trusted(
|
|
|
|
|
f"""
|
|
|
|
|
<footer class="site-footer border-top">
|
|
|
|
|
<div class="container footer-inner">
|
|
|
|
|
<div class="footer-made">Made with <span aria-label="love">❤️</span> by karl</div>
|
|
|
|
|
<nav class="footer-links" aria-label="Footer links">
|
|
|
|
|
<a class="footer-link" href="https://github.com/StasonJatham/karldns.de" rel="noopener">
|
|
|
|
|
{_svg_icon("github", "footer-icon")}
|
|
|
|
|
<span>GitHub</span>
|
|
|
|
|
</a>
|
|
|
|
|
<a class="footer-link" href="https://karl.fail" rel="noopener">
|
|
|
|
|
{_svg_icon("blog", "footer-icon")}
|
|
|
|
|
<span>karl.fail</span>
|
|
|
|
|
</a>
|
|
|
|
|
<a class="footer-link" href="https://karlcom.de" rel="noopener">
|
|
|
|
|
{_svg_icon("business", "footer-icon")}
|
|
|
|
|
<span>karlcom.de</span>
|
|
|
|
|
</a>
|
|
|
|
|
<a class="footer-link" href="/docs">
|
|
|
|
|
{_svg_icon("docs", "footer-icon")}
|
|
|
|
|
<span>API</span>
|
|
|
|
|
</a>
|
|
|
|
|
<a class="footer-link" href="/privacy">
|
|
|
|
|
{_svg_icon("privacy", "footer-icon")}
|
|
|
|
|
<span>Legal & privacy</span>
|
|
|
|
|
</a>
|
|
|
|
|
</nav>
|
|
|
|
|
</div>
|
|
|
|
|
</footer>
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _page(
|
|
|
|
|
title: str,
|
|
|
|
|
body: SafeHtml,
|
|
|
|
|
extra_styles: SafeHtml | None = None,
|
|
|
|
|
extra_scripts: SafeHtml | None = None,
|
|
|
|
|
description: str = "KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, Linux cron jobs, and home networks.",
|
|
|
|
|
preload_logo: bool = False,
|
|
|
|
|
canonical_url: str | None = None,
|
|
|
|
|
image_url: str | None = None,
|
|
|
|
|
image_alt: str | None = None,
|
|
|
|
|
structured_data: dict[str, object] | None = None,
|
|
|
|
|
) -> str:
|
|
|
|
|
if not isinstance(body, SafeHtml):
|
|
|
|
|
raise TypeError("body must be a SafeHtml fragment built by a template helper")
|
|
|
|
|
extra_styles_html = extra_styles or trusted("")
|
|
|
|
|
extra_scripts_html = extra_scripts or trusted("")
|
|
|
|
|
preload_logo_html = trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">') if preload_logo else trusted("")
|
|
|
|
|
canonical_html = trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
|
|
|
|
|
social_url = canonical_url or ""
|
|
|
|
|
social_image = image_url or ""
|
|
|
|
|
social_html = trusted("")
|
|
|
|
|
if social_url:
|
|
|
|
|
social_card = "summary_large_image" if social_image else "summary"
|
|
|
|
|
social_image_alt = image_alt or title
|
|
|
|
|
social_image_html = f"""
|
|
|
|
|
<meta property="og:image" content="{attr(social_image)}">
|
|
|
|
|
<meta property="og:image:width" content="1200">
|
|
|
|
|
<meta property="og:image:height" content="630">
|
|
|
|
|
<meta property="og:image:alt" content="{attr(social_image_alt)}">
|
|
|
|
|
<meta name="twitter:image" content="{attr(social_image)}">
|
|
|
|
|
<meta name="twitter:image:alt" content="{attr(social_image_alt)}">
|
|
|
|
|
""" if social_image else ""
|
|
|
|
|
social_html = trusted(f"""
|
|
|
|
|
<meta property="og:type" content="website">
|
|
|
|
|
<meta property="og:title" content="{attr(title)}">
|
|
|
|
|
<meta property="og:description" content="{attr(description)}">
|
|
|
|
|
<meta property="og:url" content="{attr(social_url)}">
|
|
|
|
|
{social_image_html}
|
|
|
|
|
<meta name="twitter:card" content="{attr(social_card)}">
|
|
|
|
|
<meta name="twitter:title" content="{attr(title)}">
|
|
|
|
|
<meta name="twitter:description" content="{attr(description)}">
|
|
|
|
|
""")
|
|
|
|
|
structured_data_html = trusted("")
|
|
|
|
|
if structured_data:
|
|
|
|
|
structured_data_html = trusted(
|
|
|
|
|
'<script type="application/ld+json">'
|
|
|
|
|
+ escape(json.dumps(structured_data, ensure_ascii=False, separators=(",", ":")))
|
|
|
|
|
+ "</script>"
|
|
|
|
|
)
|
|
|
|
|
return f"""
|
|
|
|
|
<!doctype html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
<meta name="theme-color" content="#ffffff">
|
|
|
|
|
<meta name="description" content="{attr(description)}">
|
|
|
|
|
{canonical_html}
|
|
|
|
|
{social_html}
|
|
|
|
|
<title>{escape(title)}</title>
|
|
|
|
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
|
|
|
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
|
|
|
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
|
|
|
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|
|
|
|
<link rel="manifest" href="/site.webmanifest">
|
|
|
|
|
{preload_logo_html}
|
|
|
|
|
<script src="/theme.js?v=2"></script>
|
|
|
|
|
<script src="/clipboard.js?v=3" defer></script>
|
|
|
|
|
{extra_scripts_html}
|
|
|
|
|
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
|
2026-05-21 18:39:36 +02:00
|
|
|
<link href="/app.css?v=13" rel="stylesheet">
|
2026-05-21 17:23:08 +02:00
|
|
|
{extra_styles_html}
|
|
|
|
|
{structured_data_html}
|
|
|
|
|
</head>
|
|
|
|
|
<body>{body}{_site_footer()}</body>
|
|
|
|
|
</html>
|
|
|
|
|
"""
|