From f28d204c12f539732fe26e48e1ae3617a21eea3c Mon Sep 17 00:00:00 2001 From: Dave Marquard Date: Thu, 9 Jul 2026 15:54:50 -0700 Subject: [PATCH] Support Substack-style footnotes in the article popover Substack footnote references are bare anchors (not wrapped in like Multimarkdown), and their targets are backlink anchors whose note text lives in the following sibling elements rather than inside the target itself. Neither the footnote badge styling nor the newsfoot popover recognized them. - main.js: broaden styleLocalFootnotes() to also badge these bare anchors, and let isLocalFootnote() accept the "#footnote-" hash prefix (it previously only matched "#fn"). - newsfoot.js: add a Substack footnote format that recognizes the reference and extracts note content from the sibling elements after the backlink; the click handler now uses each format's own content extractor instead of assuming the target's innerHTML. Co-Authored-By: Claude Opus 4.8 --- Shared/Article Rendering/main.js | 6 ++-- Shared/Article Rendering/newsfoot.js | 41 ++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/Shared/Article Rendering/main.js b/Shared/Article Rendering/main.js index 1856d2e143..2dc4601c64 100644 --- a/Shared/Article Rendering/main.js +++ b/Shared/Article Rendering/main.js @@ -167,11 +167,13 @@ function error() { // Takes into account absoluting of URLs. function isLocalFootnote(target) { - return target.hash.startsWith("#fn") && target.href.indexOf(document.baseURI) === 0; + return (target.hash.startsWith("#fn") || target.hash.startsWith("#footnote")) && target.href.indexOf(document.baseURI) === 0; } function styleLocalFootnotes() { - for (elem of document.querySelectorAll("sup > a[href*='#fn'], sup > div > a[href*='#fn']")) { + // Multimarkdown-style: . Substack-style: a bare + // with no wrapper. + for (elem of document.querySelectorAll("sup > a[href*='#fn'], sup > div > a[href*='#fn'], a[id^='footnote-anchor-']")) { if (isLocalFootnote(elem)) { elem.classList.add("footnote"); } diff --git a/Shared/Article Rendering/newsfoot.js b/Shared/Article Rendering/newsfoot.js index 9f1dae3f6d..765c06bd04 100644 --- a/Shared/Article Rendering/newsfoot.js +++ b/Shared/Article Rendering/newsfoot.js @@ -125,12 +125,37 @@ if (!target.hash) return; return decodeURIComponent(target.hash.substring(1)); } - /** @type {{fnref(target:HTMLAnchorElement): string|undefined}[]} */ + /** + * @type {{ + * fnref(target:HTMLAnchorElement): string|undefined, + * content(targetElement:HTMLElement): string + * }[]} + */ const footnoteFormats = [ + { // Substack: the ref + // points at a backlink N whose note text lives + // in the sibling elements that follow it, not inside the anchor. + fnref(target) { + if (!target.matches("a[id^='footnote-anchor-']")) return; + return idFromHash(target); + }, + content(targetElement) { + let html = ""; + let node = targetElement.nextElementSibling; + while (node && !node.matches("a[id^='footnote-']")) { + html += node.outerHTML; + node = node.nextElementSibling; + } + return html; + } + }, { // Multimarkdown fnref(target) { if (!target.matches(".footnote")) return; return idFromHash(target); + }, + content(targetElement) { + return targetElement.innerHTML; } } ]; @@ -140,22 +165,26 @@ if (!(ev.target && ev.target instanceof HTMLAnchorElement)) return; let targetId = undefined; + let format = undefined; for(const f of footnoteFormats) { targetId = f.fnref(ev.target); - if (targetId) break; + if (targetId) { + format = f; + break; + } } if (targetId === undefined) return; - + // Only override the default behaviour when we know we can find the // target element const targetElement = document.getElementById(targetId); if (targetElement === null) return; - + ev.preventDefault(); installContainer(ev.target); - - void new Footnote(targetElement.innerHTML, ev.target); + + void new Footnote(format.content(targetElement), ev.target); }); // Handle clicks on the footnote reverse link