davideisinger.com

My personal website
Log | Files | Refs | README

commit 3c8b635997e475671223b16d64508d98ba4fd00b
parent 2917c4720bf41d5f25d687c6a89a52aed5680ac0
Author: David Eisinger <[email protected]>
Date:   Mon, 12 Oct 2026 10:43:45 -0400

Re-run CF email script on page transition

Diffstat:
Atest/fixtures/cloudflare-email-decode.min.js | 3+++
Mtest/navigation.cjs | 43++++++++++++++++++++++++++++++++++++++++++-
Mthemes/v2/assets/js/navigation.js | 26++++++++++++++++++++++++++
3 files changed, 71 insertions(+), 1 deletion(-)

diff --git a/test/fixtures/cloudflare-email-decode.min.js b/test/fixtures/cloudflare-email-decode.min.js @@ -0,0 +1,3 @@ +// Cloudflare decoder fetched from https://davideisinger.com/cdn-cgi/scripts/5c5dd728/cloudflare-static/email-decode.min.js +// Captured 2026-10-12; served only by the browser regression test. +!function(){"use strict";function e(e){try{if("undefined"==typeof console)return;"error"in console?console.error(e):console.log(e)}catch(e){}}function t(e){return d.innerHTML='<a href="'+e.replace(/"/g,"&quot;")+'"></a>',d.childNodes[0].getAttribute("href")||""}function r(e,t){var r=e.substr(t,2);return parseInt(r,16)}function n(n,c){for(var o="",a=r(n,c),i=c+2;i<n.length;i+=2){var l=r(n,i)^a;o+=String.fromCharCode(l)}try{o=decodeURIComponent(escape(o))}catch(u){e(u)}return t(o)}function c(t){for(var r=t.querySelectorAll("a"),c=0;c<r.length;c++)try{var o=r[c],a=o.href.indexOf(l);a>-1&&(o.href="mailto:"+n(o.href,a+l.length))}catch(i){e(i)}}function o(t){for(var r=t.querySelectorAll(u),c=0;c<r.length;c++)try{var o=r[c],a=o.parentNode,i=o.getAttribute(f);if(i){var l=n(i,0),d=document.createTextNode(l);a.replaceChild(d,o)}}catch(h){e(h)}}function a(t){for(var r=t.querySelectorAll("template"),n=0;n<r.length;n++)try{i(r[n].content)}catch(c){e(c)}}function i(t){try{c(t),o(t),a(t)}catch(r){e(r)}}var l="/cdn-cgi/l/email-protection#",u=".__cf_email__",f="data-cfemail",d=document.createElement("div");i(document),function(){var e=document.currentScript||document.scripts[document.scripts.length-1];e.parentNode.removeChild(e)}()}(); diff --git a/test/navigation.cjs b/test/navigation.cjs @@ -14,6 +14,14 @@ let server; let failNextFetch = false; let delayNextFetch = false; let changedAssets = false; +let failNextDecoder = false; +// Reproduce the markup Cloudflare adds at the edge (absent from local builds). +function protectedEmail(email, key) { + return Buffer.from([key, ...Buffer.from(email)].map((byte, index) => index ? byte ^ key : byte)).toString('hex'); +} +const contactEmail = '[email protected]'; +const protectedContact = '/cdn-cgi/l/email-protection#3f575a5353507f5b5e49565b5a564c5651585a4d115c5052'; +const decoderPath = '/cdn-cgi/scripts/test-version/cloudflare-static/email-decode.min.js'; function write(name, text) { const file = path.join(root, name); fs.mkdirSync(path.dirname(file), { recursive: true }); @@ -28,7 +36,11 @@ function write(name, text) { 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)); + '## Contact\n\n[Music](/music/)\n\n' + + `<a id="contact-email" href="${protectedContact}"><span class="__cf_email__" data-cfemail="${protectedEmail(contactEmail, 244)}">[email protected]</span></a>\n\n` + + `<a id="named-email" href="/cdn-cgi/l/email-protection#${protectedEmail('hé[email protected]', 27)}">Email me</a>\n\n` + + `<span id="plain-email" class="__cf_email__" data-cfemail="${protectedEmail('<hello>@example.com', 91)}">[email protected]</span>\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.\n\n<audio controls src="/journal/example/Night Moon.mp3"></audio>'); fs.copyFileSync(path.join(repo, 'content/journal/dispatch-44-october-2026/Night Moon.mp3'), path.join(root, 'content/journal/example/Night Moon.mp3')); @@ -37,6 +49,11 @@ function write(name, text) { path.join(root, 'content/journal/older/Older Song.mp3')); write('content/elsewhere/example/index.md', '---\ntitle: Elsewhere example\ndate: 2025-01-01\ncanonical_url: https://example.org/original\nexclude_music: true\n---\nOriginal article.\n\n<audio controls src="/journal/example/Night Moon.mp3"></audio>'); server = http.createServer((request, response) => { + if (request.url === decoderPath && failNextDecoder) { + failNextDecoder = false; + response.writeHead(503).end('Temporary decoder failure'); + return; + } const isFetch = request.headers.accept === 'text/html'; if (isFetch && failNextFetch) { failNextFetch = false; @@ -53,6 +70,10 @@ function write(name, text) { if (changedAssets && type === 'text/html') { body = Buffer.from(body.toString().replace(/(href="\/css\/[^"?]+)(")/, '$1?v=2$2')); } + if (type === 'text/html' && pathname === '/about/index.html') { + body = Buffer.from(body.toString().replace('</body>', + `<script data-cfasync="false" src="${decoderPath}"></script></body>`)); + } response.writeHead(200, { 'Content-Type': type, 'Content-Length': body.length }).end(body); }; if (isFetch && delayNextFetch) { delayNextFetch = false; setTimeout(send, 400); } @@ -61,6 +82,7 @@ function write(name, text) { 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' }); + write('public' + decoderPath, fs.readFileSync(path.join(__dirname, 'fixtures/cloudflare-email-decode.min.js'), 'utf8')); 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 }); @@ -96,11 +118,21 @@ function write(name, text) { && originalAudio === document.querySelector('#site-header audio') && !originalAudio.paused && audioInterruptions === 0), true); } + async function emailsDecoded() { + assert.equal(await page.locator('#contact-email').textContent(), contactEmail); + assert.equal(await page.locator('#contact-email').getAttribute('href'), 'mailto:' + contactEmail); + assert.equal(await page.locator('#named-email').textContent(), 'Email me'); + assert.equal(await page.locator('#named-email').getAttribute('href'), 'mailto:hé[email protected]'); + assert.ok((await page.locator('#content').textContent()).includes('<hello>@example.com')); + assert.equal(await page.locator('#content hello').count(), 0); + assert.equal(await page.locator(`script[src="${decoderPath}"]`).count(), 0); + } await click('/'); // Clicking the site title on the homepage must not reload. await intact(); await click('/about/'); await intact(); + await emailsDecoded(); assert.equal(await page.title(), 'About – David Eisinger'); assert.equal(await page.evaluate(() => document.activeElement.id), 'content'); await page.evaluate(() => scrollTo(0, 700)); @@ -112,6 +144,7 @@ function write(name, text) { await page.waitForFunction(() => document.title.startsWith('About') && !document.querySelector('#content').hasAttribute('aria-busy')); assert.equal(await page.evaluate(() => scrollY), 700); await intact(); + await emailsDecoded(); await page.goForward(); await page.waitForFunction(() => document.title.startsWith('Music') && !document.querySelector('#content').hasAttribute('aria-busy')); await intact(); @@ -231,10 +264,18 @@ function write(name, text) { await click('/about/'); await page.waitForFunction(() => !window.originalHeader); assert.equal(await page.title(), 'About – David Eisinger'); + await emailsDecoded(); changedAssets = true; await page.evaluate(() => { window.assetSentinel = true; }); await click('/music/'); await page.waitForFunction(() => !window.assetSentinel); + + // A decoder load failure falls back to a full load, which can retry it. + failNextDecoder = true; + await page.evaluate(() => { window.decoderSentinel = true; }); + await Promise.all([page.waitForEvent('load'), click('/about/')]); + await page.waitForFunction(() => !window.decoderSentinel && history.state?.siteNavigation); + await emailsDecoded(); changedAssets = false; // Direct loads and navigation continue to work without JavaScript. diff --git a/themes/v2/assets/js/navigation.js b/themes/v2/assets/js/navigation.js @@ -50,6 +50,30 @@ .map(node => node.outerHTML).join('\n'); } + function decodeProtectedEmails(doc, signal) { + // Cloudflare exposes no callable decoder. Reinsert its script so it scans + // the updated document, using the version URL supplied in the fetched page. + const decoder = [...doc.querySelectorAll('script[src]')].find(script => + /^\/cdn-cgi\/scripts\/[^/]+\/cloudflare-static\/email-decode\.min\.js$/.test(script.getAttribute('src'))); + if (!decoder) return; + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + function finish(error) { + signal.removeEventListener('abort', abort); + script.remove(); + if (error) reject(error); + else resolve(); + } + const abort = () => finish(new Error('Email decoding aborted')); + script.src = decoder.getAttribute('src'); + script.setAttribute('data-cfasync', 'false'); + script.onload = () => finish(); + script.onerror = () => finish(new Error('Email decoder failed to load')); + signal.addEventListener('abort', abort, { once: true }); + document.body.append(script); + }); + } + async function navigate(url, { pop = false, savedScroll = null } = {}) { pending?.abort(); const controller = new AbortController(); @@ -92,6 +116,8 @@ 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)); + await decodeProtectedEmails(next, controller.signal); + if (controller.signal.aborted) return; positionPage(destination, savedScroll, true); status.textContent = `Loaded ${next.title}`; } catch (error) {