davideisinger.com

My personal website
Log | Files | Refs | README

commit 6ee2232c9b5a534b46694afa1fdf7114f03b6e81
parent 6e08e2bd4288ccfbb9ab803d61190c9938d08e2c
Author: David Eisinger <[email protected]>
Date:   Sun, 11 Oct 2026 08:24:54 -0400

Persistent audio (track keeps playing)

Diffstat:
M.gitignore | 1+
MREADME.md | 21+++++++++++++++++++++
Apackage-lock.json | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackage.json | 10++++++++++
Atest/navigation.cjs | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mthemes/v2/assets/css/style.scss | 20+++++++++++++++++---
Athemes/v2/assets/js/navigation.js | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mthemes/v2/layouts/_default/baseof.html | 5+++--
Mthemes/v2/layouts/index.html | 30------------------------------
Mthemes/v2/layouts/partials/footer.html | 2+-
Mthemes/v2/layouts/partials/head.html | 2++
Mthemes/v2/layouts/partials/header.html | 33+++++++++++++++++++++++++++++----
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>