Custom Index Landing Page Templates#
1. Introduction#
When AetherPak publishes your Flatpak repository to GitHub Pages, it automatically generates an index.html landing page. The built-in page is a fully featured single-page application: it supports light and dark themes, animated skeleton loading, live application search, copy-to-clipboard install commands, and a GPG-signing setup wizard. For most teams, this default is ready to ship without any extra work.
Some projects, however, need more than a generic landing page. You may want to match your project's visual identity — a custom typeface, a unique color palette, an inline SVG logo, or a hero section that tells your product's story. You might need a simpler, purely static page with no JavaScript dependency, or a richer page that adds features like a release changelog or a theme picker that persists user preferences.
AetherPak's custom template system gives you complete control. Supply any valid HTML file and AetherPak will treat it as a Go html/template source, inject your repository's live metadata, and write the finished page to index.html at build time .
There are two broad approaches to building a custom template:
-
Server-side rendering — Write a standard Go HTML template. AetherPak resolves all template variables (application names, install commands, GPG fingerprints, etc.) and produces a fully rendered, self-contained static HTML file. No JavaScript is required.
-
Client-side JavaScript SPA — Use a minimal Go template (or just legacy placeholders) for the static shell, and fetch the live repository index from
index/staticat page load using JavaScript. This mirrors the pattern used by the built-in default template itself.
Both approaches can be combined: use placeholders for simple scalar values like the repo title and remote name, and rely on a fetch() call for the full application listing. The following sections walk through configuration, the template data model, helper functions, and a worked example of each approach.
2. Configuration#
There are two ways to tell AetherPak where to find your custom template. A workflow input takes priority; the config-file setting is the fallback.
2.1 Workflow input (index-template)#
Pass the repository-relative path to your template file via the index-template input of the aetherpak/actions shared workflow :
jobs:
publish:
uses: aetherpak/actions/.github/workflows/publish.yml@v3
with:
config: aetherpak.yaml
index-template: .github/workflows/templates/index.html
An empty index-template value (the default) defers to whatever is set in aetherpak.yaml. If neither is set, the built-in default template is used.
2.2 Configuration file (branding.index_template)#
Set the path under the branding section of your aetherpak.yaml :
branding:
logo_url: https://example.org/logo.png
favicon_url: https://example.org/favicon.ico
accent_color: "#e05c00"
footer_text: "© 2026 My Project"
index_template: .github/workflows/templates/index.html # path to your template
The path is resolved relative to the working directory in which aetherpak build-site runs — typically the root of your repository .
2.3 Precedence#
AetherPak resolves the template location in this order:
| Priority | Source | Example |
|---|---|---|
| 1 (highest) | --index-template CLI flag | aetherpak build-site --index-template ./templates/index.html |
| 2 | AETHERPAK_INDEX_TEMPLATE environment variable | Set in CI secrets or job env |
| 3 | index-template workflow input (passed as env var) | with: index-template: ... |
| 4 | branding.index_template in aetherpak.yaml | Config file setting |
| 5 (fallback) | Built-in default | Bundled in the AetherPak CLI binary |
2.4 Recommended file placement#
Keep your template alongside your other workflow assets. A common convention is:
.github/
workflows/
publish.yml
templates/
index.html ← your custom template
Storing the template under .github/workflows/templates/ keeps it close to the workflow that uses it, makes it easy to diff in pull requests, and avoids cluttering the repository root.
2.5 Skipping the landing page entirely#
If you want to build your own page outside of AetherPak's template system — for example, as part of a larger static site — set landing-page: false in the workflow input. AetherPak will omit index.html entirely. Your page can then fetch data directly from the index/static endpoint that AetherPak publishes .
3. Template System#
AetherPak uses Go's html/template package to execute custom index landing page templates. The template engine provides automatic HTML escaping for safe rendering of untrusted content, while giving template authors access to structured repository data, branding configuration, and helper functions for formatting.
Template Loading Pipeline#
When building the static site, AetherPak processes custom templates through a multi-stage pipeline :
- Read template file — If
IndexTemplateis set, reads the custom HTML file; otherwise uses the embedded default template - Convert legacy placeholders — Replaces six legacy
__AETHERPAK_*__placeholders with Go template syntax for backward compatibility - Parse template — Parses the HTML with Go's
html/templateengine, registering helper functions viaFuncMap - Execute template — Executes the parsed template with a
TemplateDatastruct containing all repository metadata - Write output — Writes the rendered HTML to
index.htmlin the site directory
Template Context Data#
Templates receive a TemplateData struct containing all repository, branding, signing, and application data.
Repository Metadata#
.RemoteName(string) — Flatpak remote name (e.g.my-apps), defaults to"aetherpak".RepoTitle(string) — Repository title displayed in page header, defaults to"Flatpak Repository".PagesURL(string) — Public hosting URL (e.g.https://owner.github.io/repo).RepoHomepage(string) — Optional homepage URL link.RuntimeRepo(string) — Runtime dependency flatpakrepo URL, defaults to Flathub
Branding#
.LogoURL(string) — URL to header logo image.LogoHTML(template.HTML) — Pre-formatted<img>element containing the logo, or empty if no logo is set.FaviconURL(string) — URL to favicon.AccentColor(string) — Hex accent color (e.g.#3584e4), defaults to#8b5cf6.FooterText(template.HTML) — Footer HTML content safe for raw output
Signing#
.Signing.Enabled(bool) — True if GPG signing is enabled.Signing.Fingerprint(string) — GPG key fingerprint.Signing.PublicKey(string) — Path to armored public key file (e.g.sigs/key.asc).Signing.Lookaside(string) — Path to signature lookaside directory (e.g.sigs)
Applications#
.Apps ([]TemplateApp) — Pre-processed, sorted application records . Each application contains:
.ID(string) — Reverse-DNS app ID (e.g.org.example.App).Name(string) — App name from AppStream appdata, falls back to ID.Summary(string) — App description from appdata.Icon(string) — URL to 64x64 icon if defined.Branches([]TemplateBranch) — Release channels sorted newest first :.Branch(string) — Channel name (e.g.stable,beta).Arches([]string) — Sorted architectures.Timestamp(int64) — Unix epoch timestamp.FormattedDate(string) — Human-readable date inJan 02, 2006format.InstalledSize(int64) — Installed size in bytes.DownloadSize(int64) — Download size in bytes.Commit(string) — Flatpak commit hash.RefFile(string) — Path to.flatpakreffile (e.g.refs/org.example.App-stable.flatpakref).InstallCmd(string) — Ready-to-use flatpak install command
Helper Functions#
Templates have access to three helper functions registered in the FuncMap :
join <slice> <separator>#
Wraps Go's strings.Join to concatenate a slice of strings with a separator .
Example: {{join .Arches "/"}} → aarch64/x86_64
formatSize <bytes>#
Formats raw bytes into human-readable binary units .
Example: {{formatSize .InstalledSize}} → 20 MB
formatDate <timestamp> <layout>#
Formats a Unix epoch timestamp using Go's standard time layout string .
Example: {{formatDate .Timestamp "2006-01-02"}} → 2026-06-01
Legacy Placeholder Support#
For backward compatibility, AetherPak automatically converts six legacy placeholders to Go template syntax before parsing :
__AETHERPAK_REMOTE_NAME__→{{.RemoteName}}__AETHERPAK_REPO_TITLE__→{{.RepoTitle}}__AETHERPAK_BRANDING_ACCENT_COLOR__→{{.AccentColor}}__AETHERPAK_BRANDING_FAVICON_URL__→{{.FaviconURL}}__AETHERPAK_BRANDING_LOGO_HTML__→{{.LogoHTML}}__AETHERPAK_BRANDING_FOOTER_TEXT__→{{.FooterText}}
These placeholders only cover scalar branding values. Apps, branches, and signing data require full Go template syntax. The default embedded template itself uses this hybrid approach , mixing legacy placeholders for static metadata with JavaScript that fetches index/static at runtime for dynamic app rendering .
HTML Safety and Escaping#
Go's html/template automatically escapes all string values to prevent XSS attacks. Two fields are intentionally marked as template.HTML and are not escaped :
.LogoHTML— Pre-formatted<img>tag built from sanitizedLogoURL.FooterText— Raw HTML fragment for custom footer content
These fields accept raw HTML to enable branding customization. All other fields are plain strings and are safely escaped during rendering.
4. Template Approaches#
Before writing your first custom template, it is worth understanding the two fundamental patterns AetherPak supports. They differ in when data is rendered:
| Server-Side Go Template | Client-Side JavaScript SPA | |
|---|---|---|
| Data resolved | At build time, by AetherPak | At page load, by the browser |
| JavaScript required | No | Yes |
| Go template syntax | Yes — {{range .Apps}}, {{if .Signing.Enabled}}, etc. | Minimal — only needed for scalar values via legacy placeholders |
| Interactive features | Limited to static HTML | Full: themes, animations, modals |
| Works offline / CDN edge | Yes — pure static file | Only if the index/static endpoint is reachable |
| Complexity | Lower | Higher |
Server-side templates are the right choice when you want a simple, maintainable page that works without JavaScript. All app data is baked in at publish time, so the output is a single self-contained HTML file. Go's html/template handles escaping automatically, so there is no risk of XSS from application names or summaries.
Client-side SPAs match the pattern used by AetherPak's own built-in default template. The HTML shell is rendered by AetherPak (replacing legacy placeholders for the remote name, repo title, and branding values), but the application listing is loaded at runtime by calling fetch('index/static'). This unlocks richer interactivity — theme toggles, search, animated loading states, copy-to-clipboard — but requires JavaScript to be enabled in the user's browser.
You can also mix both: use {{.RemoteName}} and {{.AccentColor}} directly in the HTML shell, and still build the app grid with a JavaScript fetch() at runtime. The two styles are fully composable because AetherPak processes legacy placeholders and Go template syntax in a single pass before the browser ever receives the page.
The next two sections walk through a complete worked example of each approach.
5. Example 1: Server-Side Go Template#
Server-side Go templates render all repository data at build time into a fully self-contained static HTML file. This approach requires no JavaScript to display app listings — AetherPak injects all application metadata, release information, and branding directly into the HTML during the build-site command .
The template receives a structured TemplateData object containing repository metadata, branding configuration, signing details, and a preprocessed list of applications with their branches . AetherPak uses Go's html/template engine to parse and execute the template , automatically escaping HTML to prevent injection attacks while allowing controlled use of safe HTML through template.HTML types.
Basic Structure#
A minimal server-side template includes a standard HTML5 document with placeholders for repository metadata. The following example from the CLI README demonstrates the essential structure :
<!DOCTYPE html>
<html>
<head>
<title>{{.RepoTitle}}</title>
<link rel="icon" href="{{.FaviconURL}}">
</head>
<body>
<h1>{{.RepoTitle}}</h1>
{{range .Apps}}
<div class="app-card">
{{if .Icon}}<img src="{{.Icon}}" width="64">{{end}}
<h2>{{.Name}} ({{.ID}})</h2>
<p>{{.Summary}}</p>
<h3>Releases</h3>
<ul>
{{range .Branches}}
<li>
Branch: <strong>{{.Branch}}</strong> | Arches: {{join .Arches ", "}}
<br>Released on: {{.FormattedDate}} | Size: {{formatSize .InstalledSize}}
<br>Install: <code>{{.InstallCmd}}</code>
</li>
{{end}}
</ul>
</div>
{{end}}
<footer>{{.FooterText}}</footer>
</body>
</html>
This template demonstrates three core patterns: basic variable substitution ({{.RepoTitle}}), conditional rendering ({{if .Icon}}), and iteration over collections ({{range .Apps}}).
Enhanced Example with Branding and Signing#
The following expanded template illustrates a production-ready implementation with repository setup instructions, GPG signing support, and rich application metadata:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.RepoTitle}}</title>
<link rel="icon" href="{{.FaviconURL}}">
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
line-height: 1.6;
color: #333;
}
.hero {
text-align: center;
padding: 2rem 0;
border-bottom: 2px solid #eee;
margin-bottom: 2rem;
}
.hero h1 {
font-size: 2.5rem;
margin: 0.5rem 0;
color: {{.AccentColor}};
}
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #f0f0f0;
border-radius: 4px;
font-size: 0.85rem;
margin: 0.25rem;
}
.setup-card {
background: #f9f9f9;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.signing-info {
background: #e8f5e9;
border-left: 4px solid #4caf50;
padding: 1rem;
margin: 1rem 0;
}
pre {
background: #2d2d2d;
color: #f8f8f8;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
}
code {
font-family: 'Courier New', monospace;
font-size: 0.9rem;
}
.app-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.app-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.app-icon {
width: 64px;
height: 64px;
border-radius: 8px;
}
.release {
border-left: 3px solid {{.AccentColor}};
padding-left: 1rem;
margin: 1rem 0;
}
footer {
text-align: center;
padding: 2rem 0;
color: #666;
border-top: 1px solid #eee;
margin-top: 3rem;
}
</style>
</head>
<body>
<div class="hero">
{{if .LogoHTML}}
{{.LogoHTML}}
{{end}}
<h1>{{.RepoTitle}}</h1>
<div>
<span class="badge">📦 Remote: {{.RemoteName}}</span>
<span class="badge">🎯 Apps: {{len .Apps}}</span>
</div>
</div>
<div class="setup-card">
<h2>Add This Repository</h2>
{{if .Signing.Enabled}}
<div class="signing-info">
<strong>🔒 GPG-Signed Repository</strong>
<p>This repository is cryptographically signed. All packages are verified for authenticity.</p>
<p><strong>Fingerprint:</strong> <code>{{.Signing.Fingerprint}}</code></p>
</div>
<h3>For Flatpak ≥ 1.17 (Verified Installation)</h3>
<pre><code>flatpak remote-add --user \
--signature-lookaside={{.PagesURL}}/{{.Signing.Lookaside}} \
{{.RemoteName}} \
{{.PagesURL}}/{{.RemoteName}}.flatpakrepo</code></pre>
<details>
<summary>Older clients (< 1.17) — unverified</summary>
<p>Clients before Flatpak 1.17 cannot read the signature lookaside path. Add the repository without verification:</p>
<pre><code>flatpak remote-add --if-not-exists --user --no-gpg-verify \
{{.RemoteName}} \
oci+{{.PagesURL}}</code></pre>
</details>
{{else}}
<p>Add this repository to your Flatpak remotes:</p>
<pre><code>flatpak remote-add --if-not-exists --user --no-gpg-verify \
{{.RemoteName}} \
oci+{{.PagesURL}}</code></pre>
{{end}}
</div>
<h2>Available Applications</h2>
{{if .Apps}}
{{range .Apps}}
<div class="app-card">
<div class="app-header">
{{if .Icon}}
<img src="{{.Icon}}" alt="{{.Name}} icon" class="app-icon">
{{end}}
<div>
<h3 style="margin: 0;">{{.Name}}</h3>
<code style="color: #666;">{{.ID}}</code>
</div>
</div>
{{if .Summary}}
<p>{{.Summary}}</p>
{{end}}
<h4>Releases</h4>
{{range .Branches}}
<div class="release">
<div>
<strong>Branch:</strong> {{.Branch}} |
<strong>Architectures:</strong> {{join .Arches ", "}}
</div>
<div style="font-size: 0.9rem; color: #666; margin: 0.25rem 0;">
Released: {{.FormattedDate}} |
Installed Size: {{formatSize .InstalledSize}} |
Download Size: {{formatSize .DownloadSize}}
{{if .Commit}} | Commit: {{.Commit}}{{end}}
</div>
<pre><code>{{.InstallCmd}}</code></pre>
</div>
{{end}}
</div>
{{end}}
{{else}}
<p style="text-align: center; padding: 2rem; color: #999;">No applications published yet.</p>
{{end}}
<footer>
{{.FooterText}}
</footer>
</body>
</html>
Template Variables#
All fields in the TemplateData structure are available . For the complete reference, see Section 3: Template System. A quick summary:
Repository Metadata:
.RemoteName— Flatpak remote name (defaults to "aetherpak").RepoTitle— Repository title (defaults to "Flatpak Repository").PagesURL— Public URL where the site is hosted.RepoHomepage— Repository homepage URL.RuntimeRepo— Fallback runtime repository URL
Branding:
.LogoURL— Custom logo image URL.LogoHTML— Pre-formatted<img>tag with logo (safe HTML).FaviconURL— Favicon URL.AccentColor— Hex color code (defaults to#8b5cf6).FooterText— Footer content (safe HTML)
Signing Block:
.Signing.Enabled—trueif GPG signing is configured.Signing.Fingerprint— GPG key fingerprint.Signing.PublicKey— Path to public key file (e.g.,sigs/key.asc).Signing.Lookaside— Signature lookaside directory (e.g.,sigs)
Applications:
.Apps— Slice of applications, each containing:.ID— Application identifier (e.g.,org.example.App).Name— Display name from AppStream metadata.Summary— One-line description.Icon— URL to 64×64 icon.Branches— Slice of release branches, each containing:.Branch— Branch name (e.g.,stable,beta).Arches— Alphabetically sorted architectures (e.g.,[aarch64, x86_64]).Timestamp— Unix epoch timestamp.FormattedDate— Human-readable date (e.g.,Jan 02, 2006).InstalledSize— Installed size in bytes.DownloadSize— Download size in bytes.Commit— OSTree commit hash.RefFile— Path to.flatpakreffile.InstallCmd— Completeflatpak installcommand
Helper Functions#
Three template functions are available for data formatting . For the full reference and implementation details, see Section 3: Template System.
join — Concatenates a slice of strings with a separator :
{{join .Arches ", "}} // → "aarch64, x86_64"
{{join .Arches "/"}} // → "aarch64/x86_64"
formatSize — Converts bytes to human-readable size :
{{formatSize .InstalledSize}} // → "20 MB"
{{formatSize .DownloadSize}} // → "5.2 MB"
formatDate — Formats Unix timestamp using Go time layout :
{{formatDate .Timestamp "2006-01-02"}} // → "2026-06-01"
{{formatDate .Timestamp "January 2, 2006"}} // → "June 1, 2026"
{{formatDate .Timestamp "Jan 02, 2006 15:04"}} // → "Jun 01, 2026 14:30"
The layout string uses Go's reference time Mon Jan 2 15:04:05 MST 2006 as the formatting template.
Conditional Rendering#
Use {{if}} to show or hide sections based on data availability:
<!-- Show signing section only if GPG is configured -->
{{if .Signing.Enabled}}
<div class="signing-info">
<p>🔒 GPG-signed repository</p>
<p>Fingerprint: <code>{{.Signing.Fingerprint}}</code></p>
<p>Public Key: <a href="{{.Signing.PublicKey}}">Download</a></p>
</div>
{{else}}
<p>⚠️ This repository is not GPG-signed.</p>
{{end}}
<!-- Show icon only if available -->
{{if .Icon}}
<img src="{{.Icon}}" alt="{{.Name}} icon">
{{end}}
<!-- Check for empty app list -->
{{if .Apps}}
<!-- render apps -->
{{else}}
<p>No applications available.</p>
{{end}}
<!-- Conditional commit hash display -->
{{if .Commit}}
<span>Commit: {{.Commit}}</span>
{{end}}
Nested Iteration#
Iterate over applications and their branches with nested {{range}} blocks:
{{range .Apps}}
<div class="app-card">
<h2>{{.Name}}</h2>
<p>{{.ID}}</p>
{{range .Branches}}
<div class="branch">
<strong>{{.Branch}}</strong> — {{join .Arches ", "}}
<br>Released: {{.FormattedDate}}
<br>Size: {{formatSize .InstalledSize}}
<pre><code>{{.InstallCmd}}</code></pre>
</div>
{{end}}
</div>
{{end}}
Each {{range}} changes the context (the . variable). Inside the outer range, .Name refers to the current application's name. Inside the nested range, .Branch refers to the current branch's name. Access the parent context with $:
{{range .Apps}}
{{$appID := .ID}}
{{range .Branches}}
<!-- .Branch is current branch, $appID is parent app ID -->
<p>Installing {{$appID}} branch {{.Branch}}</p>
{{end}}
{{end}}
Pre-Built Install Commands#
Each branch includes a pre-formatted .InstallCmd field containing the complete Flatpak install command . AetherPak builds this for you at publish time using the repository's remote name, app ID, and branch name:
{{range .Branches}}
<pre><code>{{.InstallCmd}}</code></pre>
{{end}}
This renders as:
flatpak install --user example-repo org.example.App//stable
The command uses the repository's .RemoteName, the application's .ID, and the branch's .Branch value.
Safe HTML Rendering#
Two fields contain pre-formatted HTML that should not be escaped: .LogoHTML and .FooterText. These are typed as template.HTML, which Go's template engine renders without escaping :
<!-- LogoHTML already contains <img> tag -->
{{.LogoHTML}}
<!-- FooterText may contain <a> tags and formatting -->
<footer>{{.FooterText}}</footer>
All other string fields are automatically HTML-escaped. User-provided data in .RepoTitle, .Name, .Summary, etc., cannot inject malicious HTML or JavaScript.
Build-Time Rendering#
All data is embedded when AetherPak runs build-site. The resulting index.html is a fully static file with no external dependencies. No JavaScript is required to display the application list, making the page:
- Fast — No API calls or client-side rendering delay
- Accessible — Works in all browsers, including those with JavaScript disabled
- SEO-friendly — Search engines index the full content immediately
- Simple — Easy to cache, mirror, and distribute
The template is loaded from the path specified in branding.index_template (config file), --index-template (CLI flag), or AETHERPAK_INDEX_TEMPLATE (environment variable) .
6. Example 2: Client-Side JavaScript SPA#
The client-side JavaScript SPA approach minimizes server-side templating and uses JavaScript to dynamically fetch and render repository data at runtime. This is the approach used by AetherPak's own default template .
Unlike server-side Go templates that embed all data during the build process, the client-side approach only uses legacy placeholders for minimal static metadata, then fetches the repository index at page load. This enables richer interactivity including dark/light themes, skeleton loading states, search, and copy-to-clipboard features.
Legacy Placeholders in the HTML Shell#
The template uses legacy placeholders for static metadata that is replaced before the template is parsed . The Go template engine is not needed for the HTML body — only the simple placeholder replacement system:
<title>__AETHERPAK_REPO_TITLE__</title>
<link rel="icon" href="__AETHERPAK_BRANDING_FAVICON_URL__">
Placeholders are also used inline within the page structure for the logo and footer :
__AETHERPAK_BRANDING_LOGO_HTML__
<h1>__AETHERPAK_REPO_TITLE__</h1>
And in the footer :
<footer><p>__AETHERPAK_BRANDING_FOOTER_TEXT__</p></footer>
The accent color is injected as a CSS variable :
:root {
--accent-color: __AETHERPAK_BRANDING_ACCENT_COLOR__;
--primary: var(--primary-default-light, #7c3aed);
}
JavaScript then validates whether the placeholder was replaced :
const accentColor = '__AETHERPAK_BRANDING_ACCENT_COLOR__';
const isDefaultAccent = accentColor === '#8b5cf6' || /^__.+__$/.test(accentColor);
if (isDefaultAccent) {
document.documentElement.style.setProperty('--primary-default-dark', '#8b5cf6');
document.documentElement.style.setProperty('--primary-default-light', '#7c3aed');
} else {
document.documentElement.style.setProperty('--primary-default-dark', accentColor);
document.documentElement.style.setProperty('--primary-default-light', accentColor);
}
The guard pattern /^__.+__$/ detects if the placeholder remains unreplaced (for example, during local testing when the template is not processed by AetherPak). The remote name uses the same guard :
const remoteName = (n => /^__.+__$/.test(n) ? 'aetherpak' : n)('__AETHERPAK_REMOTE_NAME__');
Dark/Light Theme Toggle#
The default template implements a theme switcher with system preference detection and manual override :
(function() {
const saved = localStorage.getItem('theme');
const theme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.className = theme;
})();
This immediately-invoked function runs in the <head> to prevent a flash of unstyled content. The theme is stored in localStorage so the user's preference persists across visits.
The toggle button switches between themes and updates storage :
function setupThemeToggle() {
const toggle = document.getElementById('theme-toggle');
if (toggle) {
toggle.addEventListener('click', () => {
const current = document.documentElement.classList.contains('dark')
|| (window.matchMedia('(prefers-color-scheme: dark)').matches && !document.documentElement.classList.contains('light'))
? 'dark' : 'light';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.className = next;
localStorage.setItem('theme', next);
});
}
}
CSS media queries define default color schemes for both light and dark preferences , and explicit .light and .dark classes override the system preference when the user manually selects a theme .
JavaScript Fetch Pattern#
After the DOM loads, the template fetches two JSON files in parallel :
Promise.all([
fetch('sigs/signing.json').then(r => r.ok ? r.json() : null).catch(() => null),
fetch('index/static').then(r => { if (!r.ok) throw new Error('load'); return r.json(); })
]).then(([signing, data]) => {
// render logic
})
sigs/signing.jsonis optional and returnsnullif missing or on errorindex/staticis required and throws an error if the fetch failsPromise.allexecutes both requests concurrently for faster loading
This pattern decouples the template from the data, allowing the same HTML shell to work with any repository's data.
Rendering App Cards#
The template parses the OCI index to extract app metadata . Each image result contains labels with Flatpak metadata:
(data.Results || []).forEach(res => (res.Images || []).forEach(img => {
const L = img.Labels || {}; const ref = L['org.flatpak.ref'];
if (!ref || !L['org.flatpak.metadata']) return;
const [, id, arch, branch] = ref.split('/'); if (!branch) return;
const a = apps[id] || (apps[id] = { id, icon: L['org.freedesktop.appstream.icon-64'], meta: appMeta(L['org.freedesktop.appstream.appdata'] || ''), ch: {} });
const c = a.ch[branch] || (a.ch[branch] = { branch, arches: new Set(), ts: 0, isize: L['org.flatpak.installed-size'], dsize: L['org.flatpak.download-size'], commit: L['org.flatpak.commit'] });
c.arches.add(arch); c.ts = Math.max(c.ts, +L['org.flatpak.timestamp'] || 0);
}));
The org.flatpak.ref label contains the app ID, architecture, and branch. The code groups images by app ID and branch, aggregating architectures into a set.
App names and summaries are extracted from the org.freedesktop.appstream.appdata XML label :
function appMeta(xml) {
try { const doc = new DOMParser().parseFromString(xml, 'application/xml');
const pick = sel => { const e = [...doc.querySelectorAll(sel)]; return (e.find(x => !x.getAttribute('xml:lang')) || e[0])?.textContent; };
return { name: pick('component > name'), summary: pick('component > summary') }; }
catch (e) { return {}; }
}
This function parses the AppStream XML and extracts the name and summary, preferring entries without an xml:lang attribute (the default language).
The template then builds HTML strings for each app card dynamically . Helper functions mirror the server-side template functions:
fmtSize()formats bytes as human-readable sizes like "20 MB"fmtDate()formats Unix timestamps as localized date strings
These JavaScript helpers provide the same functionality as the server-side formatSize and formatDate template functions.
Skeleton Loading States#
Before the fetch completes, the template displays skeleton placeholders :
<div class="skeleton-card">
<div class="skeleton-line title"></div>
<div class="skeleton-line" style="height: 3.5rem; border-radius: 8px;"></div>
<div class="skeleton-line" style="height: 2.2rem; width: 140px; border-radius: 8px; margin-top: 1rem;"></div>
</div>
<div class="skeleton-card">
<div class="skeleton-app-header">
<div class="skeleton-icon"></div>
<div class="skeleton-app-titles">
<div class="skeleton-line skeleton-app-title"></div>
<div class="skeleton-line skeleton-app-id"></div>
</div>
</div>
<div class="skeleton-line paragraph"></div>
<div class="skeleton-line paragraph-short"></div>
<div class="skeleton-line" style="height: 1.5rem; width: 80px; margin-top: 1.5rem; margin-bottom: 0.5rem;"></div>
<div class="skeleton-line" style="height: 4rem; border-radius: 8px;"></div>
</div>
These placeholders use a shimmer animation to indicate loading :
.skeleton-line {
height: 1rem;
background: linear-gradient(90deg, var(--border) 25%, color-mix(in srgb, var(--border) 60%, var(--text-muted)) 50%, var(--border) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite linear;
border-radius: 4px;
margin-bottom: 0.75rem;
}
Once the data loads, the skeletons are replaced with real content.
Copy-to-Clipboard#
The template uses the Clipboard API to copy installation commands :
function copyToClipboard(text, btn) {
navigator.clipboard.writeText(text).then(() => {
const o = btn.innerHTML; btn.classList.add('success');
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
setTimeout(() => { btn.classList.remove('success'); btn.innerHTML = o; }, 1500);
});
}
The button displays a checkmark icon and applies a success style for 1.5 seconds, then reverts to the original state. This provides clear visual feedback that the copy succeeded.
Search Functionality#
The template includes a search input that filters apps by name, ID, or summary . The search input is only shown if there are multiple applications :
const searchWrapper = document.getElementById('search-wrapper');
if (ids.length > 1) {
if (searchWrapper) {
searchWrapper.style.display = 'block';
setupSearch();
}
} else {
if (searchWrapper) {
searchWrapper.style.display = 'none';
}
}
The search listener filters app cards based on their data-app-name, data-app-id, and data-app-summary attributes, hiding non-matching cards. It also announces the result count to screen readers via an ARIA live region.
Benefits of the Client-Side Approach#
This approach offers several advantages over server-side Go templates:
- Richer interactivity: Theme switching, search, and copy-to-clipboard without page reloads
- Better perceived performance: Skeleton loading states reduce the feeling of waiting
- Simpler template syntax: No Go template syntax in the HTML body — only standard HTML, CSS, and JavaScript
- System integration: Dark/light theme respects user's OS preference with manual override
- Accessibility: ARIA labels, focus management, keyboard shortcuts, and screen reader announcements
Trade-offs#
The client-side approach has some drawbacks:
- Requires JavaScript: The page will not display app data without JavaScript enabled
- Network requests at page load: Users must wait for the fetch to complete before seeing apps
- More complex to implement: Requires understanding of JavaScript, async programming, and DOM manipulation
- Larger initial payload: The template itself is larger because it includes all the JavaScript and CSS
Despite these trade-offs, this approach is well-suited for modern repositories where interactive features and visual polish are priorities.
7. Choosing an Approach#
Neither approach is universally better. The right choice depends on your project's priorities.
Choose server-side Go templates when…#
- Simplicity matters. A Go template is easier to understand and maintain than an HTML file with embedded JavaScript logic.
- JavaScript reliability is a concern. Static Go templates work identically in all browsers and environments, with no network request required to render the app list.
- Build-time data is sufficient. All the information you need — app names, install commands, GPG fingerprints, architecture lists, sizes — is available in the
TemplateDatastruct at build time. Nothing is deferred to page load. - You already know Go templates. If your team writes Go or works with other Go-based tools, the
html/templatesyntax will feel natural. - You want guaranteed XSS safety. Go's
html/templateengine auto-escapes all template variables (with the explicit exception oftemplate.HTML-typed fields like.FooterTextand.LogoHTML).
Choose a client-side JavaScript SPA when…#
- Interactive features are required. Theme switching, animated loading states, search, modals, and copy-to-clipboard are far easier to implement in JavaScript than in server-rendered HTML.
- You want to decouple the page shell from the data. With a SPA, you can ship a polished page design and have the application listing load independently — useful when the app catalogue changes frequently.
- You want to follow the built-in template's pattern. AetherPak's default page (built-in
pkg/site/index.html) uses this exact approach and serves as a fully working reference implementation. - Your team is more comfortable with JavaScript than Go templates.
A practical framework#
| Requirement | Recommended approach |
|---|---|
| No JavaScript in environment | Server-side Go template |
| Simple branding with minimal interaction | Server-side Go template |
| Dark/light theme toggle | Client-side SPA |
| Animated loading, search, modals | Client-side SPA |
| App catalogue changes faster than CI/CD | Client-side SPA |
| Frequent data changes needing real-time feel | Client-side SPA |
Need to embed data in OpenGraph / <meta> tags | Server-side Go template (data available at build time) |
Tip: The hybrid approach often works well for simple SPAs. Use
__AETHERPAK_REPO_TITLE__and__AETHERPAK_REMOTE_NAME__as placeholders for the scalar values that belong in<title>or<meta>tags, and keep the interactive app listing purely client-side viafetch('index/static').
8. Best Practices and Tips#
Keep templates under version control#
Store your template file alongside your workflow — .github/workflows/templates/index.html is a natural home. This makes it easy to review changes in pull requests and keeps the template co-located with the workflow that uses it.
Test locally before pushing#
Run aetherpak build-site locally against a real or sample index/static to verify your template renders correctly before committing:
aetherpak build-site \
--index-template .github/workflows/templates/index.html \
--pages-url https://your-org.github.io/your-repo \
--site-dir /tmp/test-site
For client-side templates, open the output index.html through a local HTTP server (e.g. python3 -m http.server -d /tmp/test-site) rather than directly from the filesystem, because fetch() calls require an HTTP origin.
Always guard the signing block#
The .Signing.Enabled flag is false for unsigned repositories. Referencing .Signing.Fingerprint or .Signing.Lookaside without an {{if .Signing.Enabled}} guard is safe — the values will simply be empty strings — but rendering a "GPG-signed" UI badge unconditionally is misleading. Always conditionally render signing-related UI:
{{if .Signing.Enabled}}
<p>🔒 Verified — GPG fingerprint: <code>{{.Signing.Fingerprint}}</code></p>
{{else}}
<p>⚠️ This repository is not GPG-signed.</p>
{{end}}
Guard empty app lists#
The .Apps slice is empty if no applications have been published yet. Provide a graceful empty state rather than rendering a blank section:
{{if .Apps}}
{{range .Apps}}…{{end}}
{{else}}
<p>No applications have been published yet.</p>
{{end}}
Handle unset branding values gracefully (client-side templates)#
In a JavaScript SPA, the __AETHERPAK_REMOTE_NAME__ placeholder may remain unreplaced if the template is opened directly from the filesystem during development. Guard against this with a regex check, as the default template does :
const remoteName = (n => /^__.+__$/.test(n) ? 'my-remote' : n)('__AETHERPAK_REMOTE_NAME__');
The same guard is useful for __AETHERPAK_BRANDING_ACCENT_COLOR__ — if it tests as an unreplaced placeholder, fall back to your default CSS values .
Ensure mobile responsiveness#
Test your template on small screens. Use a <meta name="viewport" content="width=device-width, initial-scale=1.0"> tag, relative units, and CSS flexbox/grid layout. Install commands are often long strings — use overflow-x: auto on <pre> blocks to prevent horizontal overflow.
Use semantic HTML and accessibility attributes#
- Add
aria-labelattributes to icon-only buttons (copy-to-clipboard, theme toggle) - Use
.sr-onlyCSS for screen-reader-visible but visually hidden content - Use
<code>for install commands - Prefer
<button>elements for interactive controls - Test keyboard navigation, especially for modals and copy interactions
Keep the template self-contained#
Avoid runtime dependencies on external stylesheets or scripts that could become unavailable. For fonts and icons, either inline them or use a CDN with appropriate fallbacks. Your template must work reliably in the GitHub Pages environment — no backend, no server-side includes.
Validate HTML structure#
Run your rendered output through an HTML validator and check the browser console for errors. Go's html/template will fail fast at parse time for syntax errors in {{…}} blocks, but it cannot catch HTML structural errors.
Maintain reasonable file size#
A single self-contained template can grow large with inlined fonts and styles. Keep it well-organized and documented — future maintainers will thank you. If the template exceeds a few hundred lines of CSS, consider splitting concerns using CSS custom properties so the bulk of styling lives in one place and is easy to override.
9. Conclusion#
AetherPak's custom template system covers the full spectrum from minimal to highly interactive landing pages — and both ends of that spectrum are first-class citizens.
A server-side Go template can be as short as thirty lines of HTML, fully rendered at build time with no JavaScript required. The same template engine that powers that minimal page can also render a sophisticated repository showcase with signed/unsigned conditional sections, per-branch release timelines, architecture badges, and formatted file sizes — all baked into a static file.
A client-side JavaScript SPA, following the same pattern as AetherPak's own built-in default template, unlocks the richer end of that spectrum: dark and light themes, animated loading skeletons, real-time search, copy-to-clipboard commands, and modal dialogs. Legacy placeholders handle the scalar branding values; a fetch('index/static') call at page load handles the rest.
The template data model is stable and comprehensive — repository metadata, branding, GPG signing details, and a fully pre-processed application list are all available in a single TemplateData context . Helper functions for formatting sizes and dates remove boilerplate from your template code . Legacy placeholder support means that even the simplest HTML file can be adapted for AetherPak without knowing any Go template syntax at all.
The best starting point is always your project's visual identity and your team's comfort with the two approaches. Start simple, test locally, and evolve the template as your needs grow. The configuration is a single line — switching templates, or disabling the landing page entirely in favour of a custom site, takes only a moment.