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:
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,""")+'"></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) {