mirror of
https://github.com/Screenly/Anthias.git
synced 2026-06-10 09:08:09 -04:00
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>
284 lines
12 KiB
HTML
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>
|