Files
Anthias/website/layouts/_default/baseof.html
Viktor Petersson dfa56b9643 feat(website): deep-linkable anchors on FAQ entries (#2903)
Generate a slug-based id per FAQ entry from the question text and add
a hover-revealed "#" permalink next to each. The accordion JS now
opens (and scrolls to) the entry whose slug matches the URL hash on
load and on hashchange, and writes the slug back to the URL when a
question is clicked open — so the address bar always reflects the
expanded entry and is safe to copy.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:05:07 +01:00

284 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="{{ default "en" site.LanguageCode }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{- $brand := "Anthias" -}}
{{- $defaultDescription := "Anthias is the world's most popular open source digital signage solution. Turn any Raspberry Pi or PC into a powerful digital sign displaying images, videos, and web pages." -}}
{{- $description := default $defaultDescription .Params.description -}}
{{- $titleSuffix := "" -}}
{{- if and (not .IsHome) (not (in .Title $brand)) -}}
{{- $titleSuffix = printf " | %s" $brand -}}
{{- end -}}
{{- $pageTitle := printf "%s%s" .Title $titleSuffix -}}
{{- /* OG/Twitter card image. Prefer the first marketing slide if
the screenshots dir has been populated (CI artifact / local
fetch); otherwise fall back to the static logo so the
crawler always gets *something*. */ -}}
{{- $pageImage := "" -}}
{{- $pageImageAlt := "Anthias logo" -}}
{{- /* Stable string key — the partial reads only site.Data and
bundled resources, so memoising once per build (instead of
per-page via "." which differs across pages) is correct and
avoids re-running the Resize pipeline for every page. */ -}}
{{- $heroSlides := partialCached "screenshots.html" . "screenshots-singleton" -}}
{{- if $heroSlides -}}
{{- $first := index $heroSlides 0 -}}
{{- $pageImage = $first.fallback | absURL -}}
{{- with $first.alt -}}{{- $pageImageAlt = . -}}{{- end -}}
{{- else -}}
{{- with resources.Get "images/logo.svg" -}}
{{- $pageImage = .Permalink -}}
{{- end -}}
{{- end -}}
<title>{{ $pageTitle }}</title>
<meta name="description" content="{{ $description }}">
<link rel="canonical" href="{{ .Permalink }}">
<meta name="theme-color" content="#270035">
<meta property="og:title" content="{{ $pageTitle }}">
<meta property="og:description" content="{{ $description }}">
<meta property="og:type" content="{{ if and .IsPage (eq (string .Section) "docs") }}article{{ else }}website{{ end }}">
<meta property="og:url" content="{{ .Permalink }}">
<meta property="og:image" content="{{ $pageImage }}">
<meta property="og:image:alt" content="{{ $pageImageAlt }}">
<meta property="og:site_name" content="{{ $brand }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $pageTitle }}">
<meta name="twitter:description" content="{{ $description }}">
<meta name="twitter:image" content="{{ $pageImage }}">
<meta name="twitter:image:alt" content="{{ $pageImageAlt }}">
{{- $favicon := resources.Get "images/favicon.ico" -}}
{{- $style := resources.Get "styles/style.css" | fingerprint -}}
<link rel="icon" href="{{ $favicon.RelPermalink }}">
{{- /* Preload primary font weights so text renders without a FOUT.
Latin only — latin-ext loads on demand via unicode-range. */ -}}
<link rel="preload" href="/fonts/plus-jakarta-sans-latin-400-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/plus-jakarta-sans-latin-800-normal.woff2" as="font" type="font/woff2" crossorigin>
{{- /* Preload the LCP hero image on the home page — only the
first slide of the screenshot slider. Subsequent slides
come in via lazy <img> as the user scrolls/advances. */ -}}
{{- if and .IsHome $heroSlides -}}
{{- $first := index $heroSlides 0 -}}
<link rel="preload" as="image" type="image/webp" href="{{ $first.fallbackWebp }}" imagesrcset="{{ $first.webpSrcset }}" imagesizes="(min-width: 1280px) 1200px, 100vw" fetchpriority="high">
{{- end }}
<link rel="stylesheet" href="{{ $style.RelPermalink }}" integrity="{{ $style.Data.Integrity }}">
{{- /* chroma.css is only needed on pages that produce highlighted
code blocks. Hugo wraps each block in <pre class="chroma">,
so that exact attribute is the precise sentinel — looser
matches like "chroma" alone would false-positive on prose.
The faq layout renders answers from data/faq.yaml via
markdownify, so its highlighted output is not in .Content
and we have to special-case it. */ -}}
{{- $needsChroma := or (strings.Contains .Content `class="chroma"`) (eq .Layout "faq") -}}
{{- if $needsChroma -}}
{{- $chroma := resources.Get "styles/chroma.css" | fingerprint -}}
<link rel="stylesheet" href="{{ $chroma.RelPermalink }}" integrity="{{ $chroma.Data.Integrity }}">
{{- end }}
{{ if .IsHome }}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Anthias",
"description": {{ $defaultDescription | jsonify }},
"url": "{{ site.BaseURL }}",
"applicationCategory": "MultimediaApplication",
"operatingSystem": "Linux",
"license": "https://github.com/Screenly/Anthias/blob/master/LICENSE",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"author": {
"@type": "Organization",
"name": "Screenly, Inc",
"url": "https://www.screenly.io"
}
}
</script>
{{ end }}
{{- /* FAQPage structured data — generated from data/faq.yaml */ -}}
{{ if eq .Layout "faq" }}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{{- $first := true -}}
{{- range site.Data.faq -}}
{{- range .items -}}
{{- if not $first }},{{ end -}}
{{- $first = false }}
{
"@type": "Question",
"name": {{ .question | jsonify }},
"acceptedAnswer": {
"@type": "Answer",
"text": {{ .answer | markdownify | plainify | jsonify }}
}
}
{{- end -}}
{{- end }}
]
}
</script>
{{ end }}
{{- /* Screenshot slider script — only on the home page where the
slider markup exists. js.Build runs esbuild internally
(target=es2020, minify=true) so the published artifact is
a small, tree-shaken bundle. `defer` lets the browser
parse it without blocking LCP; fingerprint busts caches
on next deploy. */ -}}
{{- if and .IsHome $heroSlides -}}
{{- $sliderOpts := dict "targetPath" "js/slider.js" "minify" true "target" "es2020" -}}
{{- $slider := resources.Get "js/slider.ts" | js.Build $sliderOpts | fingerprint -}}
<script defer src="{{ $slider.RelPermalink }}" integrity="{{ $slider.Data.Integrity }}"></script>
{{- end }}
{{- /* Article structured data on individual docs pages */ -}}
{{ if and .IsPage (eq (string .Section) "docs") }}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": {{ .Title | jsonify }},
"description": {{ $description | jsonify }},
"url": "{{ .Permalink }}",
"image": "{{ $pageImage }}",
"publisher": {
"@type": "Organization",
"name": "Screenly, Inc",
"url": "https://www.screenly.io"
}
}
</script>
{{ end }}
</head>
<body class="font-sans text-base text-brand-near-black">
{{ partial "navbar.html" . }}
{{ block "main" . }}{{ end }}
{{ partial "footer.html" . }}
<script>
document.addEventListener('DOMContentLoaded', function () {
var toggle = document.getElementById('menu-toggle');
var menu = document.getElementById('mobile-menu');
if (toggle && menu) {
toggle.addEventListener('click', function () {
var isOpen = !menu.classList.toggle('hidden');
toggle.setAttribute('aria-expanded', isOpen);
});
menu.querySelectorAll('a').forEach(function (link) {
link.addEventListener('click', function () {
menu.classList.add('hidden');
toggle.setAttribute('aria-expanded', 'false');
});
});
}
function closeAllAccordions() {
document.querySelectorAll('[data-accordion-content]').forEach(function (c) {
c.classList.add('hidden');
});
document.querySelectorAll('[data-accordion]').forEach(function (b) {
b.querySelector('.icon-minus').classList.add('hidden');
b.querySelector('.icon-plus').classList.remove('hidden');
b.setAttribute('aria-expanded', 'false');
});
}
function openAccordion(btn) {
var target = document.getElementById(btn.dataset.accordion);
if (!target) return;
target.classList.remove('hidden');
btn.querySelector('.icon-minus').classList.remove('hidden');
btn.querySelector('.icon-plus').classList.add('hidden');
btn.setAttribute('aria-expanded', 'true');
}
document.querySelectorAll('[data-accordion]').forEach(function (btn) {
btn.addEventListener('click', function () {
var wasOpen = this.getAttribute('aria-expanded') === 'true';
closeAllAccordions();
if (!wasOpen) {
openAccordion(this);
var wrapper = this.parentElement;
if (wrapper && wrapper.id) {
history.replaceState(null, '', '#' + wrapper.id);
}
}
});
});
function openAccordionFromHash() {
var hash = window.location.hash;
if (!hash || hash.length < 2) return;
var wrapper;
try {
wrapper = document.querySelector(hash);
} catch (e) {
return;
}
if (!wrapper) return;
var btn = wrapper.querySelector('[data-accordion]');
if (!btn) return;
closeAllAccordions();
openAccordion(btn);
wrapper.scrollIntoView({ block: 'start' });
}
openAccordionFromHash();
window.addEventListener('hashchange', openAccordionFromHash);
});
</script>
<script>
// Defer GA until first user interaction (or 4 s after load).
// Keeps the Lighthouse audit window clean while still capturing
// analytics for real visitors.
(function () {
var loaded = false;
function load() {
if (loaded) return;
loaded = true;
// Stand up dataLayer and queue the initial gtag calls
// BEFORE appending the async script, so any cached gtag.js
// that parses immediately finds the queue already populated
// and doesn't drop our 'js'/'config' events.
globalThis.dataLayer = globalThis.dataLayer || [];
function gtag() { globalThis.dataLayer.push(arguments); }
globalThis.gtag = gtag;
gtag('js', new Date());
gtag('config', 'G-W6BH3H6SZ6');
var s = document.createElement('script');
s.async = true;
s.src = 'https://www.googletagmanager.com/gtag/js?id=G-W6BH3H6SZ6';
document.head.appendChild(s);
}
['scroll', 'mousemove', 'keydown', 'touchstart', 'click'].forEach(
function (ev) { addEventListener(ev, load, { once: true, passive: true }); }
);
// bfcache restores can land here with the page already loaded;
// schedule the timeout immediately in that case instead of
// waiting for a 'load' event that has already fired.
function scheduleFallback() { setTimeout(load, 4000); }
if (document.readyState === 'complete') scheduleFallback();
else addEventListener('load', scheduleFallback);
})();
</script>
</body>
</html>