commit 6ee2232c9b5a534b46694afa1fdf7114f03b6e81
parent 6e08e2bd4288ccfbb9ab803d61190c9938d08e2c
Author: David Eisinger <[email protected]>
Date: Sun, 11 Oct 2026 08:24:54 -0400
Persistent audio (track keeps playing)
Diffstat:
12 files changed, 507 insertions(+), 40 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -3,3 +3,4 @@
public
resources/_gen
secret.key
+node_modules
diff --git a/README.md b/README.md
@@ -21,6 +21,27 @@ SourceHut runs `bin/check-rss-template` during deployment. To run it locally, se
HUGO_VERSION=0.167.0 bin/check-rss-template
```
+## Navigation and audio
+
+The shared header and its player stay in place during internal page navigation.
+A small, dependency-free script fetches the next HTML page and replaces the main
+content and footer, updating the URL, title, canonical link, focus, and scroll
+position. External links, downloads, modified clicks, and links with
+`data-no-navigation` use normal browser navigation. Failed requests and changed
+JavaScript or CSS assets fall back to a full page load. Without JavaScript, every
+page and link still works normally; audio then stops when leaving the page.
+
+Only the header player persists between pages. Starting another audio player
+pauses the current one. Reloading the browser or leaving the site ends playback.
+
+The script is bundled by Hugo; Node.js is not required to build or deploy it.
+For browser regression tests, run `npm ci`, then `npm run test:navigation`.
+These tests use Hugo, a disposable local server, and Playwright (development only).
+On macOS they use installed Google Chrome; elsewhere run
+`npx playwright install chromium` first. Set `CHROME_BIN` to use another Chromium
+executable. The tests cover continuous playback, history, scroll restoration,
+focus, fragments, metadata, fallback navigation, and browsing without JavaScript.
+
## Image alt text
`bin/encrypt IMAGE` encrypts an image and uses Codex to write plain, descriptive
diff --git a/package-lock.json b/package-lock.json
@@ -0,0 +1,60 @@
+{
+ "name": "davideisinger-site-checks",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "davideisinger-site-checks",
+ "devDependencies": {
+ "playwright": "1.62.1"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "davideisinger-site-checks",
+ "private": true,
+ "scripts": {
+ "test:navigation": "node test/navigation.cjs"
+ },
+ "devDependencies": {
+ "playwright": "1.62.1"
+ }
+}
diff --git a/test/navigation.cjs b/test/navigation.cjs
@@ -0,0 +1,206 @@
+// Browser regression checks against a disposable Hugo site using the real theme.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+const http = require('node:http');
+const { execFileSync } = require('node:child_process');
+const { chromium } = require('playwright');
+
+const repo = path.resolve(__dirname, '..');
+const root = fs.mkdtempSync(path.join(os.tmpdir(), 'site-navigation-'));
+let browser;
+let server;
+let failNextFetch = false;
+let delayNextFetch = false;
+let changedAssets = false;
+function write(name, text) {
+ const file = path.join(root, name);
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, text);
+}
+
+(async () => {
+ fs.cpSync(path.join(repo, 'themes/v2'), path.join(root, 'themes/v2'), { recursive: true });
+ write('hugo.toml', 'baseURL="http://localhost/"\ntheme="v2"\ntitle="David Eisinger"\n');
+ write('layouts/partials/marky.html', '');
+ write('content/_index.md', 'A personal website.');
+ write('content/music/_index.md', '---\ntitle: Music\n---\n');
+ write('content/about/index.md', '---\ntitle: About\n---\n' +
+ '[Jump to contact](#contact)\n\n' + 'A paragraph for testing scroll restoration.\n\n'.repeat(90) +
+ '## Contact\n\n[Music](/music/)\n\n' + 'More content.\n\n'.repeat(20));
+ write('content/journal/example/index.md', '---\ntitle: Example post\ndate: 2026-01-01\ntags: [dispatch]\n---\nHello.');
+ fs.copyFileSync(path.join(repo, 'content/journal/dispatch-44-october-2026/Night Moon.mp3'),
+ path.join(root, 'content/journal/example/Night Moon.mp3'));
+ write('content/elsewhere/example/index.md', '---\ntitle: Elsewhere example\ndate: 2025-01-01\ncanonical_url: https://example.org/original\n---\nOriginal article.');
+ server = http.createServer((request, response) => {
+ const isFetch = request.headers.accept === 'text/html';
+ if (isFetch && failNextFetch) {
+ failNextFetch = false;
+ response.writeHead(503).end('Temporary failure');
+ return;
+ }
+ const send = () => {
+ let pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname);
+ if (pathname.endsWith('/')) pathname += 'index.html';
+ const file = path.join(root, 'public', pathname);
+ if (!fs.existsSync(file)) { response.writeHead(404).end('Not found'); return; }
+ let body = fs.readFileSync(file);
+ const type = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.mp3': 'audio/mpeg' }[path.extname(file)] || 'application/octet-stream';
+ if (changedAssets && type === 'text/html') {
+ body = Buffer.from(body.toString().replace(/(href="\/css\/[^"?]+)(")/, '$1?v=2$2'));
+ }
+ response.writeHead(200, { 'Content-Type': type, 'Content-Length': body.length }).end(body);
+ };
+ if (isFetch && delayNextFetch) { delayNextFetch = false; setTimeout(send, 400); }
+ else send();
+ });
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+ const origin = `http://127.0.0.1:${server.address().port}`;
+ execFileSync('hugo', ['--source', root, '--baseURL', origin + '/'], { stdio: 'pipe' });
+ const chrome = process.env.CHROME_BIN || (process.platform === 'darwin'
+ ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : undefined);
+ browser = await chromium.launch({ executablePath: chrome, headless: true });
+ const page = await browser.newPage({ viewport: { width: 1100, height: 800 } });
+ const errors = [];
+ page.on('pageerror', error => errors.push(error.message));
+ await page.goto(origin);
+ await page.waitForFunction(() => history.state?.siteNavigation);
+ await page.evaluate(async () => {
+ window.originalHeader = document.querySelector('#site-header');
+ window.originalAudio = originalHeader.querySelector('audio');
+ window.audioInterruptions = 0;
+ originalAudio.muted = true;
+ await originalAudio.play();
+ originalAudio.addEventListener('pause', () => audioInterruptions++);
+ originalAudio.addEventListener('emptied', () => audioInterruptions++);
+ });
+ await page.waitForFunction(() => originalAudio.currentTime > 0.1);
+
+ async function click(href) {
+ await page.evaluate(href => {
+ const link = document.createElement('a');
+ link.href = href;
+ document.querySelector('#content').append(link);
+ link.click();
+ }, href);
+ await page.waitForFunction(href => location.pathname + location.hash === href
+ && !document.querySelector('#content').hasAttribute('aria-busy'), href);
+ }
+ async function intact() {
+ assert.equal(await page.evaluate(() => originalHeader === document.querySelector('#site-header')
+ && originalAudio === document.querySelector('#site-header audio')
+ && !originalAudio.paused && audioInterruptions === 0), true);
+ }
+
+ await click('/'); // Clicking the site title on the homepage must not reload.
+ await intact();
+ await click('/about/');
+ await intact();
+ assert.equal(await page.title(), 'About – David Eisinger');
+ assert.equal(await page.evaluate(() => document.activeElement.id), 'content');
+ await page.evaluate(() => scrollTo(0, 700));
+ await page.waitForFunction(() => history.state.siteNavigation.y === 700);
+ await click('/music/');
+ await intact();
+ assert.equal(await page.evaluate(() => scrollY), 0);
+ await page.goBack();
+ await page.waitForFunction(() => document.title.startsWith('About') && !document.querySelector('#content').hasAttribute('aria-busy'));
+ assert.equal(await page.evaluate(() => scrollY), 700);
+ await intact();
+ await page.goForward();
+ await page.waitForFunction(() => document.title.startsWith('Music') && !document.querySelector('#content').hasAttribute('aria-busy'));
+ await intact();
+
+ await click('/about/#contact');
+ assert.equal(await page.evaluate(() => document.activeElement.id), 'contact');
+ assert.ok(await page.evaluate(() => scrollY > 1000));
+ await intact();
+ // Same-page anchors and returning from them remain native and keep playback.
+ await page.evaluate(() => document.querySelector('a[href="#contact"]').click());
+ await intact();
+ await click('/about/');
+ assert.equal(await page.evaluate(() => scrollY), 0);
+ await intact();
+ await page.locator('a[href="#contact"]').click();
+ await page.waitForFunction(() => location.hash === '#contact' && scrollY > 1000);
+ await page.goBack();
+ await page.waitForFunction(() => !location.hash && scrollY === 0);
+ await intact();
+ await click('/elsewhere/example/');
+ assert.equal(await page.locator('link[rel="canonical"]').getAttribute('href'), 'https://example.org/original');
+ await click('/music/');
+ assert.equal(await page.locator('link[rel="canonical"]').count(), 0);
+
+ // A later click must win over an older, slower response.
+ delayNextFetch = true;
+ await page.evaluate(() => {
+ const a = document.createElement('a'); a.href = '/about/'; document.body.append(a); a.click(); a.remove();
+ });
+ await click('/journal/example/');
+ await page.waitForTimeout(500);
+ assert.equal(new URL(page.url()).pathname, '/journal/example/');
+ await intact();
+
+ // Modified clicks, external links, file links and explicit opt-outs stay native.
+ assert.deepEqual(await page.evaluate(() => {
+ const cases = [
+ { href: 'https://example.org/' }, { href: '/index.xml' }, { href: '/song.mp3' },
+ { href: '/about/', target: '_blank' }, { href: '/about/', download: true },
+ { href: '/about/', optOut: true }, { href: '/about/', ctrlKey: true },
+ { href: '/about/', metaKey: true }, { href: '/about/', shiftKey: true },
+ ];
+ return cases.map(item => {
+ const a = document.createElement('a'); a.href = item.href;
+ if (item.target) a.target = item.target;
+ if (item.download) a.setAttribute('download', '');
+ if (item.optOut) a.setAttribute('data-no-navigation', '');
+ document.body.append(a);
+ let handled;
+ document.addEventListener('click', event => { handled = event.defaultPrevented; event.preventDefault(); }, { once: true });
+ a.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: item.ctrlKey, metaKey: item.metaKey, shiftKey: item.shiftKey }));
+ a.remove(); return handled;
+ });
+ }), Array(9).fill(false));
+
+ // Starting a content player prevents overlapping audio with the header.
+ await click('/music/');
+ await page.locator('#content audio').first().evaluate(async audio => {
+ audio.muted = true;
+ await audio.play();
+ });
+ assert.equal(await page.evaluate(() => originalAudio.paused), true);
+ await page.evaluate(() => originalAudio.play());
+ assert.equal(await page.locator('#content audio').first().evaluate(audio => audio.paused), true);
+
+ // A failed enhanced request must retry with normal document navigation.
+ failNextFetch = true;
+ await click('/about/');
+ await page.waitForFunction(() => !window.originalHeader);
+ assert.equal(await page.title(), 'About – David Eisinger');
+ changedAssets = true;
+ await page.evaluate(() => { window.assetSentinel = true; });
+ await click('/music/');
+ await page.waitForFunction(() => !window.assetSentinel);
+ changedAssets = false;
+
+ // Direct loads and navigation continue to work without JavaScript.
+ const noJS = await browser.newContext({ javaScriptEnabled: false });
+ const plain = await noJS.newPage();
+ await plain.goto(origin);
+ await plain.getByRole('link', { name: 'More about me →' }).click();
+ assert.equal(new URL(plain.url()).pathname, '/about/');
+ assert.equal(await plain.locator('#site-header audio').count(), 1);
+ await noJS.close();
+
+ await page.setViewportSize({ width: 390, height: 850 });
+ await page.goto(origin);
+ await click('/about/');
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
+ assert.deepEqual(errors, []);
+ console.log('Navigation checks passed: uninterrupted audio, history/scroll, focus, fragments, canonical URLs, races, native links, fallbacks, mobile, and no-JS navigation.');
+})().catch(error => { console.error(error); process.exitCode = 1; }).finally(async () => {
+ await browser?.close();
+ if (server) { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); }
+ fs.rmSync(root, { recursive: true, force: true });
+});
diff --git a/themes/v2/assets/css/style.scss b/themes/v2/assets/css/style.scss
@@ -235,7 +235,8 @@ pre {
margin-top: 1em;
margin-bottom: 0;
- +section>hr:first-child {
+ + #content > hr:first-child,
+ + #content > section:first-child > hr:first-child {
margin-top: 0;
}
@@ -463,4 +464,18 @@ audio::-webkit-media-controls-panel {
display: inline;
}
}
-}
-\ No newline at end of file
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+#content:focus {
+ outline: none;
+}
diff --git a/themes/v2/assets/js/navigation.js b/themes/v2/assets/js/navigation.js
@@ -0,0 +1,157 @@
+// Keep the header and its audio element attached while navigating HTML pages.
+(() => {
+ const content = document.querySelector('#content');
+ const status = document.querySelector('#navigation-status');
+ if (!content || !status || !document.querySelector('#site-header')
+ || !window.fetch || !window.AbortController) return;
+
+ const stateKey = 'siteNavigation';
+ let currentURL = new URL(location.href);
+ let pending;
+ let restoring = false;
+ let scrollFrame;
+
+ function saveScroll() {
+ if (restoring) return;
+ history.replaceState({ ...history.state, [stateKey]: { x: scrollX, y: scrollY } }, '');
+ }
+ saveScroll();
+ history.scrollRestoration = 'manual';
+ addEventListener('scroll', () => {
+ cancelAnimationFrame(scrollFrame);
+ scrollFrame = requestAnimationFrame(saveScroll);
+ }, { passive: true });
+ addEventListener('pagehide', saveScroll);
+
+ function fragmentTarget(url) {
+ if (!url.hash) return null;
+ let id;
+ try { id = decodeURIComponent(url.hash.slice(1)); } catch { return null; }
+ return document.getElementById(id) || document.getElementsByName(id)[0];
+ }
+
+ function positionPage(url, savedScroll, focus) {
+ const target = fragmentTarget(url);
+ if (focus) {
+ const element = target || content;
+ if (!element.hasAttribute('tabindex')) {
+ element.setAttribute('tabindex', '-1');
+ element.addEventListener('blur', () => element.removeAttribute('tabindex'), { once: true });
+ }
+ element.focus({ preventScroll: true });
+ }
+ if (savedScroll) scrollTo(savedScroll.x, savedScroll.y);
+ else if (target) target.scrollIntoView();
+ else scrollTo(0, 0);
+ }
+
+ function assetSignature(doc) {
+ return [...doc.head.querySelectorAll('link[rel="stylesheet"], script[src], style')]
+ .map(node => node.outerHTML).join('\n');
+ }
+
+ async function navigate(url, { pop = false, savedScroll = null } = {}) {
+ pending?.abort();
+ const controller = new AbortController();
+ pending = controller;
+ cancelAnimationFrame(scrollFrame);
+ if (!pop) saveScroll();
+ restoring = true;
+ content.setAttribute('aria-busy', 'true');
+ status.textContent = 'Loading page…';
+ const timeout = setTimeout(() => controller.abort('timeout'), 10000);
+ try {
+ const response = await fetch(url.href, {
+ signal: controller.signal, headers: { Accept: 'text/html' },
+ });
+ if (!response.ok || !response.headers.get('content-type')?.includes('text/html')) {
+ throw new Error('Use normal navigation for this response');
+ }
+ const destination = new URL(response.url);
+ if (destination.origin !== location.origin) throw new Error('External redirect');
+ destination.hash = url.hash;
+ const next = new DOMParser().parseFromString(await response.text(), 'text/html');
+ if (controller.signal.aborted) return;
+ const nextContent = next.querySelector('#content');
+ const nextFooter = next.querySelector('#site-footer');
+ // Different assets or page-specific scripts need a real page load.
+ if (!nextContent || !nextFooter || !next.querySelector('#site-header')
+ || next.querySelector('base, #content script')
+ || assetSignature(next) !== assetSignature(document)) {
+ throw new Error('Incompatible page');
+ }
+ if (pop) history.replaceState(history.state, '', destination.href);
+ else history.pushState({ [stateKey]: { x: 0, y: 0 } }, '', destination.href);
+ currentURL = destination;
+ content.replaceChildren(...nextContent.childNodes);
+ document.querySelector('#site-footer').replaceWith(nextFooter);
+ document.title = next.title;
+ document.body.className = next.body.className;
+ // Update per-page metadata, including canonical links on republished posts.
+ const metadata = 'meta[name="description"], meta[name="robots"], meta[property], link[rel="canonical"]';
+ document.head.querySelectorAll(metadata).forEach(node => node.remove());
+ next.head.querySelectorAll(metadata).forEach(node => document.head.append(node));
+ positionPage(destination, savedScroll, true);
+ status.textContent = `Loaded ${next.title}`;
+ } catch (error) {
+ if (pending !== controller) return;
+ if (controller.signal.aborted && controller.signal.reason !== 'timeout') return;
+ // Network failures, non-HTML responses, and new deployments fall back.
+ if (pop) location.replace(url.href);
+ else location.assign(url.href);
+ } finally {
+ clearTimeout(timeout);
+ if (pending === controller) {
+ pending = null;
+ restoring = false;
+ content.removeAttribute('aria-busy');
+ if (status.textContent === 'Loading page…') status.textContent = '';
+ saveScroll();
+ }
+ }
+ }
+
+ document.addEventListener('click', event => {
+ const link = event.target.closest?.('a[href]');
+ if (!link || event.defaultPrevented || event.button !== 0
+ || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey
+ || link.hasAttribute('download') || link.hasAttribute('data-no-navigation')
+ || (link.target && link.target !== '_self')) return;
+ const url = new URL(link.href);
+ if (url.origin !== location.origin || !['http:', 'https:'].includes(url.protocol)) return;
+ // Leave feeds, audio, images, and other files to the browser.
+ if (/\.[^/]+$/.test(url.pathname) && !/\.html?$/.test(url.pathname)) return;
+ if (url.pathname === currentURL.pathname && url.search === currentURL.search) {
+ pending?.abort();
+ saveScroll();
+ if (!url.hash) {
+ event.preventDefault();
+ if (currentURL.hash) history.pushState({ [stateKey]: { x: 0, y: 0 } }, '', url.href);
+ currentURL = url;
+ positionPage(url, null, true);
+ }
+ return; // Native fragment navigation also preserves :target behavior.
+ }
+ event.preventDefault();
+ navigate(url);
+ });
+
+ addEventListener('popstate', event => {
+ const url = new URL(location.href);
+ if (url.pathname === currentURL.pathname && url.search === currentURL.search) {
+ pending?.abort();
+ positionPage(url, event.state?.[stateKey], false);
+ currentURL = url;
+ return;
+ }
+ navigate(url, { pop: true, savedScroll: event.state?.[stateKey] });
+ });
+
+ // Avoid overlapping playback if a visitor starts another player on /music.
+ document.addEventListener('play', event => {
+ if (!(event.target instanceof HTMLAudioElement)) return;
+ document.querySelectorAll('audio').forEach(audio => {
+ if (audio !== event.target) audio.pause();
+ });
+ }, true);
+})();
diff --git a/themes/v2/layouts/_default/baseof.html b/themes/v2/layouts/_default/baseof.html
@@ -6,9 +6,10 @@
</head>
<body{{ if .IsHome }} class="home"{{ end }}>
{{- partial "header.html" . -}}
- <div id="content">
+ <main id="content" tabindex="-1">
{{- block "main" . }}{{- end }}
- </div>
+ </main>
{{- partial "footer.html" . -}}
+ <p id="navigation-status" class="visually-hidden" role="status" aria-live="polite"></p>
</body>
</html>
diff --git a/themes/v2/layouts/index.html b/themes/v2/layouts/index.html
@@ -1,34 +1,4 @@
{{ define "main" }}
- {{ $latestTrack := false }}
- {{ range (where .Site.RegularPages "Params.exclude_music" "!=" true).ByDate.Reverse }}
- {{ $page := . }}
- {{ with first 1 (sort (.Resources.Match "*.mp3") "Title" "desc") }}
- {{ $latestTrack = dict "track" (index . 0) "page" $page }}
- {{ break }}
- {{ end }}
- {{ end }}
-
- <header class="home-intro{{ if $latestTrack }} home-intro-with-music{{ end }}">
- <h1 class="home-name"><a href="/">{{ .Site.Title }}</a></h1>
- <div class="home-bio">
- {{ .Content }}
- </div>
- <p class="home-about">
- <a href="/about/">More about me →</a>
- <a href="https://dispatch.davideisinger.com/subscription/form">Subscribe via email →</a>
- </p>
-
- {{ with $latestTrack }}
- {{ $title := substr .track.Title 0 -4 }}
- <h2 class="home-music-heading home-music-cell" id="latest-track-heading">Latest track</h2>
- <div class="home-music home-music-cell" role="group" aria-labelledby="latest-track-heading">
- <p class="track-title">{{ $title }}</p>
- <p class="track-source">From <a href="{{ .page.RelPermalink }}">{{ .page.Title }}</a></p>
- <audio controls preload="none" aria-label="{{ $title }}" src="{{ .track.RelPermalink }}"></audio>
- </div>
- <p class="more-music home-music-cell"><a href="/music/">More music →</a></p>
- {{ end }}
- </header>
<section>
<hr>
diff --git a/themes/v2/layouts/partials/footer.html b/themes/v2/layouts/partials/footer.html
@@ -1,4 +1,4 @@
-<footer>
+<footer id="site-footer">
<hr>
<div>
diff --git a/themes/v2/layouts/partials/head.html b/themes/v2/layouts/partials/head.html
@@ -8,6 +8,8 @@
</title>
{{ $css := resources.Get "css/style.scss" | toCSS | minify | fingerprint }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}">
+{{ $navigation := resources.Get "js/navigation.js" | minify | fingerprint }}
+<script defer src="{{ $navigation.RelPermalink }}" data-site-navigation></script>
<link rel="alternate" href="/index.xml" type="application/rss+xml" title="{{ .Site.Title }}" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
diff --git a/themes/v2/layouts/partials/header.html b/themes/v2/layouts/partials/header.html
@@ -1,5 +1,30 @@
-{{ if not .IsHome }}
-<header>
- <h1><a href="/">{{ .Site.Title }}</a></h1>
-</header>
+{{ $latestTrack := false }}
+{{ range (where .Site.RegularPages "Params.exclude_music" "!=" true).ByDate.Reverse }}
+ {{ $page := . }}
+ {{ with first 1 (sort (.Resources.Match "*.mp3") "Title" "desc") }}
+ {{ $latestTrack = dict "track" (index . 0) "page" $page }}
+ {{ break }}
+ {{ end }}
{{ end }}
+
+<header id="site-header" class="home-intro{{ if $latestTrack }} home-intro-with-music{{ end }}">
+ <h1 class="home-name"><a href="/">{{ .Site.Title }}</a></h1>
+ <div class="home-bio">
+ {{ .Site.Home.Content }}
+ </div>
+ <p class="home-about">
+ <a href="/about/">More about me →</a>
+ <a href="https://dispatch.davideisinger.com/subscription/form">Subscribe via email →</a>
+ </p>
+
+ {{ with $latestTrack }}
+ {{ $title := substr .track.Title 0 -4 }}
+ <h2 class="home-music-heading home-music-cell" id="latest-track-heading">Latest track</h2>
+ <div class="home-music home-music-cell" role="group" aria-labelledby="latest-track-heading">
+ <p class="track-title">{{ $title }}</p>
+ <p class="track-source">From <a href="{{ .page.RelPermalink }}">{{ .page.Title }}</a></p>
+ <audio controls preload="none" aria-label="{{ $title }}" src="{{ .track.RelPermalink }}"></audio>
+ </div>
+ <p class="more-music home-music-cell"><a href="/music/">More music →</a></p>
+ {{ end }}
+</header>