← Files DefuddleARCHIVED FILE
skills/defuddle-web-content/references/source/src/elements/footnotes.ts
50.1 KB · Oct 5, 2026 · 18:35 UTC
import { FOOTNOTE_LIST_SELECTORS, FOOTNOTE_INLINE_REFERENCES, BLOCK_LEVEL_ELEMENTS } from '../constants';
import { transferContent, parseHTML, serializeHTML, getClassName } from '../utils/dom';
import { isElement, isTextNode } from '../utils';
import { removeOrphanedDividers } from '../standardize';
// Matches heading text for loose footnote section delimiters
export const FOOTNOTE_SECTION_RE = /^(foot\s*notes?|end\s*notes?|notes?|references?)$/i;
// Return/backref symbols used as backlink text (Unicode arrows + ASCII caret)
const BACKREF_SYMBOLS_RE = /^[\^\u21A9\u21A5\u2191\u21B5\u2934\u2935\u23CE]+$/;
// MediaWiki cite_ref backref href pattern
const CITE_REF_RE = /^#cite_ref-/;
// Numeric footnote marker with optional wrapping brackets/parens (e.g. "1", "[1]", "(23)", "([1])").
// Capture group 1 is the digits. Wrappers are matched loosely — this is a filter, not an identifier.
const FOOTNOTE_MARKER_RE = /^\[?\(?(\d{1,4})\)?\]?$/;
// Lowercase fragment id from an anchor's href (part after the last '#').
function getHrefFragment(anchor: any): string {
const href = anchor?.getAttribute('href') || '';
return href.split('#').pop()?.toLowerCase() || '';
}
type FootnoteData = { content: any; originalId: string; refs: string[] };
type FootnoteCollection = Record<number, FootnoteData>;
type CollectState = {
footnotes: FootnoteCollection;
processedIds: Set<string>;
count: number;
};
// Per-selector rules for extracting the footnote id from an inline reference element.
// First matching selector wins. Arxiv's multi-citation `cite.ltx_cite` is handled as
// a special case in standardizeFootnotes, not through this table.
const INLINE_REF_EXTRACTORS: Array<{ selector: string; extract: (el: any) => string }> = [
{
selector: 'sup.footnoteref',
extract: (el) => {
const link = el.querySelector('a[id^="footnoteref-"]');
return link?.id.match(/^footnoteref-(\d+)$/)?.[1] || '';
},
},
// Nature.com
{ selector: 'a[id^="ref-link"]', extract: (el) => el.textContent?.trim() || '' },
// Science.org
{
selector: 'a[role="doc-biblioref"]',
extract: (el) => {
const xmlRid = el.getAttribute('data-xml-rid');
if (xmlRid) return xmlRid;
const href = el.getAttribute('href') || '';
return href.startsWith('#core-R') ? href.replace('#core-', '') : '';
},
},
// Substack
{
selector: 'a.footnote-anchor, span.footnote-hovercard-target a',
extract: (el) => (el.id?.replace('footnote-anchor-', '') || '').toLowerCase(),
},
// MediaWiki / Wikipedia: take the id from the last matching child anchor.
{
selector: 'sup.reference',
extract: (el) => {
let id = '';
el.querySelectorAll('a').forEach((link: any) => {
const href = link.getAttribute('href') || '';
const match = href.split('/').pop()?.match(/(?:cite_note|cite_ref)-(.+)/);
if (match) id = match[1].toLowerCase();
});
return id;
},
},
{
selector: 'sup[id^="fnref:"], span[id^="fnref:"]',
extract: (el) => el.id.replace('fnref:', '').toLowerCase(),
},
{ selector: 'sup[id^="fnr"]', extract: (el) => el.id.replace('fnr', '').toLowerCase() },
{
selector: 'sup.footnote-reference',
extract: (el) => getHrefFragment(el.querySelector('a[href^="#"]')),
},
{
selector: 'span.footnote-reference',
// LessWrong uses id="fnrefXXX" on the span when data-footnote-id is missing.
extract: (el) => {
const attrId = el.getAttribute('data-footnote-id') || '';
if (attrId) return attrId;
return el.id?.startsWith('fnref') ? el.id.replace('fnref', '').toLowerCase() : '';
},
},
{ selector: 'span.footnote-link', extract: (el) => el.getAttribute('data-footnote-id') || '' },
{ selector: 'a.citation', extract: (el) => el.textContent?.trim() || '' },
{ selector: 'a[id^="fnref"]', extract: (el) => el.id.replace('fnref', '').toLowerCase() },
// O'Reilly/HTMLBook: <a data-type="noteref" href="chNN.html#chMMfnK"> — the id is the
// href fragment, which may include a same-document filename prefix.
{ selector: 'a[data-type="noteref"]', extract: (el) => getHrefFragment(el) },
];
class FootnoteHandler {
private doc: any;
private pendingRemovals: any[] = [];
constructor(doc: any) {
this.doc = doc;
}
private makeRefId(footnoteNumber: string, refsLength: number): string {
return refsLength > 0 ? `fnref:${footnoteNumber}-${refsLength + 1}` : `fnref:${footnoteNumber}`;
}
private mergeFootnotes(target: FootnoteCollection, source: FootnoteCollection): void {
for (const [num, data] of Object.entries(source)) {
const n = parseInt(num);
if (!target[n]) target[n] = data;
}
}
// Record a footnote if id is non-empty and unseen. Without explicitNum, assigns
// the next sequential slot. With explicitNum, uses that key and advances state.count
// past it so later sequential adds don't collide.
private addFootnote(state: CollectState, id: string, content: any, explicitNum?: number): boolean {
if (!id || state.processedIds.has(id)) return false;
const key = explicitNum ?? state.count;
state.footnotes[key] = { content, originalId: id, refs: [] };
state.processedIds.add(id);
if (explicitNum === undefined) {
state.count++;
} else if (explicitNum >= state.count) {
state.count = explicitNum + 1;
}
return true;
}
createFootnoteItem(
footnoteNumber: number,
content: string | any,
refs: string[]
): any {
const doc = typeof content === 'string' ? this.doc : content.ownerDocument;
const newItem = doc.createElement('li');
newItem.className = 'footnote';
newItem.id = `fn:${footnoteNumber}`;
if (typeof content === 'string') {
const paragraph = doc.createElement('p');
paragraph.appendChild(parseHTML(doc, content));
newItem.appendChild(paragraph);
} else {
const children = Array.from(content.children) as any[];
const hasParagraphs = children.some((c: any) => c.tagName.toLowerCase() === 'p');
const hasBlockChildren = children.some((c: any) => BLOCK_LEVEL_ELEMENTS.has(c.tagName.toLowerCase()));
if (!hasParagraphs && !hasBlockChildren) {
// Wrap inline content in a paragraph
const paragraph = doc.createElement('p');
transferContent(content, paragraph);
this.removeBackrefs(paragraph);
newItem.appendChild(paragraph);
} else if (!hasParagraphs && hasBlockChildren) {
// Append block children directly to avoid invalid <p><div> nesting
children.forEach((child: any) => {
if (this.isBackrefLink(child)) return;
const clone = child.cloneNode(true);
this.removeBackrefs(clone);
newItem.appendChild(clone);
});
} else {
children.forEach((child: any) => {
if (this.isBackrefLink(child)) return;
if (child.tagName.toLowerCase() === 'p') {
if (!child.textContent?.trim() && !child.querySelector('img, br')) return;
const newP = doc.createElement('p');
transferContent(child, newP);
this.removeBackrefs(newP);
newItem.appendChild(newP);
} else {
const clone = child.cloneNode(true);
this.removeBackrefs(clone);
newItem.appendChild(clone);
}
});
}
}
const lastParagraph = newItem.querySelector('p:last-of-type') || newItem;
refs.forEach((refId, index) => {
const backlink = doc.createElement('a');
backlink.href = `#${refId}`;
backlink.title = 'return to article';
backlink.className = 'footnote-backref';
backlink.textContent = '↩';
if (index < refs.length - 1) {
backlink.textContent += ' ';
}
lastParagraph.appendChild(backlink);
});
return newItem;
}
collectFootnotes(element: any): FootnoteCollection {
const state: CollectState = { footnotes: {}, processedIds: new Set(), count: 1 };
const footnoteLists = element.querySelectorAll(FOOTNOTE_LIST_SELECTORS);
footnoteLists.forEach((list: any) => {
// Wikidot uses div.footnotes-footer containing div.footnote-footer children
if (list.matches('div.footnotes-footer')) {
const footnoteDivs = list.querySelectorAll('div.footnote-footer');
footnoteDivs.forEach((div: any) => {
const match = (div.id || '').match(/^footnote-(\d+)$/);
if (!match) return;
const id = match[1];
if (state.processedIds.has(id)) return;
// Clone to avoid modifying the original DOM
const clone = div.cloneNode(true);
const backLink = clone.querySelector('a');
if (backLink) backLink.remove();
const text = serializeHTML(clone).replace(/^\s*\.\s*/, '');
const contentDiv = element.ownerDocument.createElement('div');
contentDiv.appendChild(parseHTML(element.ownerDocument, text.trim()));
this.addFootnote(state, id, contentDiv);
});
return;
}
// pulldown-cmark / mdBook / zola: standalone div.footnote-definition.
// Skip if wrapped in div.footnote-definitions (handled by the next branch).
if (list.matches('div.footnote-definition') && !list.parentElement?.matches('div.footnote-definitions')) {
const id = (list.id || '').toLowerCase();
const clone = list.cloneNode(true);
const label = clone.querySelector('sup.footnote-definition-label');
if (label) label.remove();
this.addFootnote(state, id, clone);
return;
}
// Hugo/org-mode: div.footnote-definitions containing div.footnote-definition children
// Each child: <sup id="footnote-N"><a href="#footnote-reference-N">N</a></sup>
// <div class="footnote-body"><p>content</p></div>
if (list.matches('div.footnote-definitions')) {
const defs = list.querySelectorAll('div.footnote-definition');
defs.forEach((def: any) => {
const supEl = def.querySelector('sup[id]');
const body = def.querySelector('.footnote-body');
if (!supEl || !body) return;
this.addFootnote(state, (supEl.id || '').toLowerCase(), body.cloneNode(true));
});
const parent = list.parentElement;
if (parent && parent !== element && parent.classList?.contains('footnotes')) {
this.pendingRemovals.push(parent);
}
return;
}
// Easy Footnotes WP plugin: li items have no id, id is on a child span
if (list.matches('ol.easy-footnotes-wrapper')) {
const items = list.querySelectorAll('li.easy-footnote-single');
items.forEach((li: any) => {
const idSpan = li.querySelector('span[id^="easy-footnote-bottom-"]');
if (!idSpan) return;
const clone = li.cloneNode(true);
clone.querySelector('span[id^="easy-footnote-bottom-"]')?.remove();
clone.querySelector('a.easy-footnote-to-top')?.remove();
this.addFootnote(state, idSpan.id.toLowerCase(), clone);
});
// Track empty anchor spans left in the body by the plugin for later removal
element.querySelectorAll('span.easy-footnote-margin-adjust').forEach((span: any) => {
this.pendingRemovals.push(span);
});
return;
}
// GNU Texinfo / makeinfo: <div class="footnotes-segment"> with a heading per
// footnote — <h5 class="footnote-body-heading"><a id="FOOTn" href="#DOCFn">(n)</a></h5>
// followed by the body <p>. Inline markers (<a class="footnote" id="DOCFn"
// href="#FOOTn">) reference the heading anchor id, so register under that id.
if (list.matches('div.footnotes-segment')) {
const headings = list.querySelectorAll('h5.footnote-body-heading');
headings.forEach((heading: any) => {
const id = (heading.querySelector('a[id]')?.id || '').toLowerCase();
if (!id) return;
const contentDiv = element.ownerDocument.createElement('div');
let sibling = heading.nextElementSibling;
while (sibling && !(sibling.tagName.toLowerCase() === 'h5'
&& sibling.classList?.contains('footnote-body-heading'))) {
if (sibling.textContent?.trim() || sibling.querySelector?.('img, br')) {
contentDiv.appendChild(sibling.cloneNode(true));
}
sibling = sibling.nextElementSibling;
}
this.addFootnote(state, id, contentDiv);
});
this.pendingRemovals.push(list);
return;
}
// Substack has individual footnote divs with no parent
if (list.matches('div.footnote[data-component-name="FootnoteToDOM"]')) {
const anchor = list.querySelector('a.footnote-number');
const content = list.querySelector('.footnote-content');
if (anchor && content) {
this.addFootnote(state, anchor.id.replace('footnote-', '').toLowerCase(), content);
}
return;
}
const items = list.querySelectorAll('li, div[role="listitem"]');
items.forEach((li: any) => {
const { id, content } = this.extractListItemIdAndContent(li);
this.addFootnote(state, id, content || li);
});
});
const fallbacks = [
this.tryDataTypeFootnotes,
this.tryGenericIdDetection,
this.tryWordExport,
this.tryGoogleDocs,
this.tryLabeledSection,
this.tryLooseFootnotes,
this.tryClassFootnote,
this.tryBrSeparatedFootnotes,
];
for (const fallback of fallbacks) {
if (state.count > 1) break;
fallback.call(this, element, state);
}
return state.footnotes;
}
// O'Reilly / HTMLBook: footnote definitions are <p data-type="footnote" id="chNNfnK">,
// each opening with a <sup><a href="#…-marker">K</a></sup> backlink. The body text
// (including the inline <a data-type="noteref"> markers) is otherwise indistinguishable
// from definitions to tryGenericIdDetection, which would mis-collect surrounding
// paragraphs as footnote content (#296), so handle this explicit markup first.
private tryDataTypeFootnotes(element: any, state: CollectState): void {
const defs = element.querySelectorAll('p[data-type="footnote"][id]');
defs.forEach((def: any) => {
const id = (def.id || '').toLowerCase();
if (!id) return;
const contentDiv = element.ownerDocument.createElement('div');
const clone = def.cloneNode(true);
// Strip the leading backlink marker (<sup><a href="#…-marker">K</a></sup>).
const marker = clone.firstElementChild;
if (marker && marker.tagName.toLowerCase() === 'sup' && marker.querySelector('a[href*="#"]')) {
marker.remove();
this.trimLeadingWhitespace(clone);
}
contentDiv.appendChild(clone);
this.addFootnote(state, id, contentDiv);
this.pendingRemovals.push(def);
});
}
// Generic fallback: detect footnotes by numeric anchor text referencing an in-container id.
private tryGenericIdDetection(element: any, state: CollectState): void {
const candidateRefs = new Map<string, any[]>();
element.querySelectorAll('a[href*="#"]').forEach((a: any) => {
const fragment = getHrefFragment(a);
if (!fragment) return;
const text = a.textContent?.trim() || '';
if (!FOOTNOTE_MARKER_RE.test(text)) return;
if (!candidateRefs.has(fragment)) candidateRefs.set(fragment, []);
candidateRefs.get(fragment)!.push(a);
});
if (candidateRefs.size < 2) return;
const fragmentSet = new Set(candidateRefs.keys());
const containers = element.querySelectorAll('div, section, aside, footer, ol, ul');
let bestContainer: any = null;
let bestMatchCount = 0;
containers.forEach((container: any) => {
if (container === element) return;
const matchCount = this.findMatchingFootnoteElements(container, fragmentSet).length;
if (matchCount >= 2 && matchCount >= bestMatchCount) {
bestMatchCount = matchCount;
bestContainer = container;
}
});
if (!bestContainer) return;
// Validate: require >=75% of external candidate refs (anchors outside the container,
// i.e. not back-links) to point to footnote elements in this container.
// This prevents equation/theorem cross-references in the body from being
// mis-classified as footnotes when only a subset link to any one container.
const orderedElements = this.findMatchingFootnoteElements(bestContainer, fragmentSet);
const footnoteFragments = new Set(orderedElements.map(({ id }) => id));
let externalTotal = 0, externalMatch = 0;
candidateRefs.forEach((anchors: any[], frag: string) => {
if (anchors.some((a: any) => bestContainer.contains(a))) return; // back-link
externalTotal++;
if (footnoteFragments.has(frag)) externalMatch++;
});
if (externalMatch < Math.max(2, Math.ceil(externalTotal * 0.75))) bestContainer = null;
orderedElements.forEach(({ el, id }) => {
if (state.processedIds.has(id)) return;
const contentDiv = element.ownerDocument.createElement('div');
const clone = el.cloneNode(true);
// Remove empty/numeric ID anchors (e.g. <a id="r1"></a> or <a id="r1">1.</a>)
const idAnchor = clone.querySelector(`a[id="${id}"]`);
if (idAnchor && (!idAnchor.textContent?.trim() || /^\d+[.)]*\s*$/.test(idAnchor.textContent.trim()))) {
idAnchor.remove();
}
// Remove named anchor footnote marker (e.g. Gutenberg)
const namedAnchor = clone.querySelector('a[name]');
if (namedAnchor && namedAnchor.getAttribute('name')?.toLowerCase() === id) {
namedAnchor.remove();
}
const firstText = clone.childNodes[0];
if (firstText && firstText.nodeType === 3) {
firstText.textContent = firstText.textContent.replace(/^\d+\.\s*/, '').replace(/^\s+/, '');
}
if (clone.matches('li')) {
transferContent(clone, contentDiv);
} else {
contentDiv.appendChild(clone);
}
let sibling = el.nextElementSibling;
while (sibling && !sibling.id) {
const sibAnchorId = this.getChildAnchorId(sibling);
if (sibAnchorId && fragmentSet.has(sibAnchorId)) break;
contentDiv.appendChild(sibling.cloneNode(true));
sibling = sibling.nextElementSibling;
}
this.addFootnote(state, id, contentDiv);
});
if (bestContainer) this.pendingRemovals.push(bestContainer);
}
// Microsoft Word HTML export: body refs use href="#_ftn[N]",
// footnote list items contain back-links href="..#_ftnref[N]" (no IDs on elements).
private tryWordExport(element: any, state: CollectState): void {
const wordBackrefs = Array.from(element.querySelectorAll('a[href*="#_ftnref"]'));
if (wordBackrefs.length < 2) return;
const pairs: Array<{num: number, anchor: any}> = [];
wordBackrefs.forEach((anchor: any) => {
const match = getHrefFragment(anchor).match(/^_ftnref(\d+)$/);
if (match) pairs.push({ num: parseInt(match[1]), anchor });
});
pairs.sort((a, b) => a.num - b.num);
pairs.forEach(({ num, anchor }) => {
const originalId = `_ftn${num}`;
if (state.processedIds.has(originalId)) return;
let container: any = anchor.parentElement;
while (container && container !== element) {
const tag = container.tagName.toLowerCase();
if (tag === 'p' || tag === 'div' || tag === 'li') break;
container = container.parentElement;
}
if (!container || container === element) return;
const clone = container.cloneNode(true) as any;
const backrefAnchor = clone.querySelector('a[href*="_ftnref"]');
if (backrefAnchor) {
const wrapSup = backrefAnchor.closest('sup');
if (wrapSup) wrapSup.remove();
else backrefAnchor.remove();
}
const contentDiv = element.ownerDocument.createElement('div');
contentDiv.appendChild(clone);
this.addFootnote(state, originalId, contentDiv, num);
this.pendingRemovals.push(container);
});
}
// Google Docs/Sites: p[id^="ftnt"] with back-link a[href*="#ftnt_ref"]
private tryGoogleDocs(element: any, state: CollectState): void {
const gdocPairs: Array<{num: number, el: any}> = [];
element.querySelectorAll('p[id^="ftnt"]').forEach((p: any) => {
const match = (p.id || '').match(/^ftnt(\d+)$/);
if (match) gdocPairs.push({ num: parseInt(match[1]), el: p });
});
if (gdocPairs.length < 2) return;
gdocPairs.sort((a, b) => a.num - b.num);
gdocPairs.forEach(({ num, el }) => {
const originalId = `ftnt${num}`;
if (state.processedIds.has(originalId)) return;
const clone = el.cloneNode(true) as any;
clone.querySelector('a[href*="#ftnt_ref"]')?.remove();
const contentDiv = element.ownerDocument.createElement('div');
contentDiv.appendChild(clone);
this.addFootnote(state, originalId, contentDiv, num);
// Remove the paragraph and its wrapper div
this.pendingRemovals.push(el);
const parent = el.parentElement;
if (parent && parent !== element && parent.tagName.toLowerCase() === 'div' && parent.children.length === 1) {
this.pendingRemovals.push(parent);
}
});
// Remove "Footnotes" heading preceding the first footnote
const firstEl = gdocPairs[0].el;
const firstParent = firstEl.parentElement;
const scanFrom = (firstParent && firstParent !== element && firstParent.tagName.toLowerCase() === 'div')
? firstParent : firstEl;
const prev = scanFrom.previousElementSibling;
if (prev && /^h[1-6]$/.test(prev.tagName.toLowerCase()) && FOOTNOTE_SECTION_RE.test(prev.textContent?.trim() || '')) {
this.pendingRemovals.push(prev);
}
}
// Loose footnotes: trailing numbered paragraphs cross-referenced with inline <sup>N</sup> refs,
// or a direct-child container whose child <p>s are all numbered paragraphs.
private tryLooseFootnotes(element: any, state: CollectState): void {
const numbered = this.findLooseFootnoteParagraphs(element);
if (!numbered) return;
const { paragraphs, toRemove } = numbered;
const toRemoveSet = new Set(toRemove);
for (let i = 0; i < paragraphs.length; i++) {
const { num, el: defPara } = paragraphs[i];
const nextDef = paragraphs[i + 1]?.el ?? null;
const contentDiv = this.stripMarkerAndWrap(defPara);
let sibling: any = defPara.nextElementSibling;
while (sibling && sibling !== nextDef && toRemoveSet.has(sibling)) {
contentDiv.appendChild(sibling.cloneNode(true));
sibling = sibling.nextElementSibling;
}
this.addFootnote(state, String(num), contentDiv);
}
this.pendingRemovals.push(...toRemove);
}
// Class-based footnote paragraphs: <p class="footnote"><sup>N</sup>content...</p>.
// The "footnote" class is a strong enough signal that we don't require cross-validation
// or a minimum count, so even a single footnote is detected.
private tryClassFootnote(element: any, state: CollectState): void {
const footnoteParagraphs: Array<{num: number; el: any}> = [];
element.querySelectorAll('p.footnote').forEach((p: any) => {
const num = this.parseFootnoteNum(p);
if (num !== null) footnoteParagraphs.push({ num, el: p });
});
for (const { num, el: defPara } of footnoteParagraphs) {
this.addFootnote(state, String(num), this.stripMarkerAndWrap(defPara));
}
this.pendingRemovals.push(...footnoteParagraphs.map(p => p.el));
}
// Labeled footnote sections: a container whose class/id contains "footnote" and
// whose first heading matches FOOTNOTE_SECTION_RE (e.g. "Footnotes"). The heading
// is a strong enough signal that even a single footnote is detected. Handles CSS
// module hashes like "PostDetail__UQuRMa__footnotes".
private tryLabeledSection(element: any, state: CollectState): void {
const containers = element.querySelectorAll('div, section, aside');
for (const container of Array.from(containers) as any[]) {
const className = getClassName(container);
const id = container.id || '';
if (!/footnote/i.test(className) && !/footnote/i.test(id)) continue;
const heading = container.querySelector('h1, h2, h3, h4, h5, h6');
if (!heading || !FOOTNOTE_SECTION_RE.test(heading.textContent?.trim() || '')) continue;
// Definitions are either numbered paragraphs or an ordered list.
if (!this.collectSectionParagraphs(container, state)
&& !this.collectSectionList(container, state)) continue;
this.pendingRemovals.push(container);
break;
}
}
// Several definitions packed into one block and separated by <br>, each segment
// opening with a named anchor:
// <p><a name="one"><sup>1</sup></a> text… ↩<br><br><a name="two">…</p>
// Anchor names must be targeted by inline numeric links, so ordinary <br>-separated
// prose is not mistaken for footnotes.
private tryBrSeparatedFootnotes(element: any, state: CollectState): void {
const linkedFragments = new Set<string>();
element.querySelectorAll('a[href^="#"]').forEach((a: any) => {
if (FOOTNOTE_MARKER_RE.test(a.textContent?.trim() || '')) {
const fragment = getHrefFragment(a);
if (fragment) linkedFragments.add(fragment);
}
});
if (linkedFragments.size < 2) return;
for (const block of Array.from(element.querySelectorAll('p, div')) as any[]) {
if (!block.querySelector('br')) continue;
const defs: Array<{ id: string; nodes: any[] }> = [];
for (const nodes of this.splitOnBreaks(block)) {
const anchor = this.leadingAnchor(nodes);
if (!anchor) continue;
const id = (anchor.getAttribute('name') || anchor.id || '').toLowerCase();
if (!id || !linkedFragments.has(id)) continue;
defs.push({ id, nodes: nodes.filter((n: any) => n !== anchor) });
}
if (defs.length < 2) continue;
defs.forEach(({ id, nodes }) => {
const contentDiv = element.ownerDocument.createElement('div');
nodes.forEach((node: any) => contentDiv.appendChild(node.cloneNode(true)));
this.removeBackrefs(contentDiv);
this.trimLeadingWhitespace(contentDiv);
this.addFootnote(state, id, contentDiv);
});
this.pendingRemovals.push(block);
// Drop the divider that set the block off from the article body
const prev = block.previousElementSibling;
if (prev?.tagName.toLowerCase() === 'hr') this.pendingRemovals.push(prev);
return;
}
}
// Child nodes grouped into runs delimited by <br>. Consecutive breaks yield no
// empty runs, so <br><br> reads as a single separator.
private splitOnBreaks(block: any): any[][] {
const segments: any[][] = [];
let current: any[] = [];
for (const node of Array.from(block.childNodes) as any[]) {
if (isElement(node) && node.tagName.toLowerCase() === 'br') {
if (current.length) segments.push(current);
current = [];
} else {
current.push(node);
}
}
if (current.length) segments.push(current);
return segments;
}
// The anchor a segment opens with, ignoring leading whitespace. Returns null if
// the segment starts with text or any other element.
private leadingAnchor(nodes: any[]): any {
for (const node of nodes) {
if (isTextNode(node)) {
if (node.textContent?.trim()) return null;
continue;
}
if (!isElement(node)) continue;
if (node.tagName.toLowerCase() !== 'a') return null;
return node.hasAttribute('name') || node.id ? node : null;
}
return null;
}
// Section definitions as numbered paragraphs: <p><sup>N</sup> content…</p>,
// with any following unnumbered siblings folded into the same footnote.
private collectSectionParagraphs(container: any, state: CollectState): boolean {
const paragraphs: Array<{num: number; el: any}> = [];
container.querySelectorAll('p').forEach((p: any) => {
const num = this.parseFootnoteNum(p);
if (num !== null) paragraphs.push({ num, el: p });
});
if (paragraphs.length === 0) return false;
const numberedSet = new Set(paragraphs.map(p => p.el));
for (const { num, el: defPara } of paragraphs) {
const contentDiv = this.stripMarkerAndWrap(defPara);
// Collect subsequent siblings until the next numbered paragraph
let sibling = defPara.nextElementSibling;
while (sibling && !numberedSet.has(sibling)) {
if (sibling.textContent?.trim()) {
contentDiv.appendChild(sibling.cloneNode(true));
}
this.pendingRemovals.push(sibling);
sibling = sibling.nextElementSibling;
}
this.addFootnote(state, String(num), contentDiv);
this.pendingRemovals.push(defPara);
}
return true;
}
// Section definitions as an ordered list, e.g. <h4>Footnotes</h4><ol><li id="footnote-1">…</li></ol>.
// Items are numbered by position; the item id is kept so href-based inline refs still match.
private collectSectionList(container: any, state: CollectState): boolean {
const list = container.querySelector('ol');
if (!list) return false;
const items = (Array.from(list.children) as any[])
.filter((el: any) => el.tagName?.toLowerCase() === 'li');
if (items.length === 0) return false;
items.forEach((li: any, index: number) => {
this.addFootnote(state, li.id?.toLowerCase() || String(index + 1), li.cloneNode(true));
});
return true;
}
private trimLeadingWhitespace(parent: any): void {
const first = parent.firstChild;
if (first?.nodeType === 3) {
first.textContent = first.textContent.replace(/^\s+/, '');
}
}
private isBoldWrappedSup(el: any): boolean {
const tag = el.tagName?.toLowerCase();
return (tag === 'b' || tag === 'strong') &&
el.firstChild === el.firstElementChild &&
el.firstElementChild?.tagName?.toLowerCase() === 'sup';
}
stripMarkerAndWrap(el: any): any {
const contentDiv = el.ownerDocument.createElement('div');
const clone = el.cloneNode(true) as any;
const marker = clone.firstElementChild;
if (marker) {
if (this.isBoldWrappedSup(marker)) {
marker.firstElementChild.remove();
this.trimLeadingWhitespace(marker);
} else {
marker.remove();
this.trimLeadingWhitespace(clone);
}
}
contentDiv.appendChild(clone);
return contentDiv;
}
parseFootnoteNum(el: any): number | null {
// Marker must be the very first child (no preceding text node)
if (!el.firstChild) return null;
let first = el.firstElementChild;
if (!first || first !== el.firstChild) return null;
let tag = first.tagName.toLowerCase();
if (this.isBoldWrappedSup(first)) {
first = first.firstElementChild;
tag = 'sup';
}
if (tag !== 'sup' && tag !== 'strong') return null;
const numText = first.textContent?.trim() || '';
const num = parseInt(numText, 10);
return !isNaN(num) && num >= 1 && String(num) === numText ? num : null;
}
crossValidate(element: any, paragraphs: Array<{num: number; el: any}>): boolean {
const numberedNums = new Set(paragraphs.map(p => p.num));
const matchedNums = new Set<number>();
element.querySelectorAll('sup').forEach((sup: any) => {
if (paragraphs.some(fn => fn.el.contains(sup))) return;
if (sup.querySelector('a')) return; // already standardized or linked
const text = sup.textContent?.trim() || '';
const n = parseInt(text, 10);
if (!isNaN(n) && n >= 1 && String(n) === text && numberedNums.has(n)) {
matchedNums.add(n);
}
});
return matchedNums.size >= 2;
}
findLooseFootnoteParagraphs(
element: any
): { paragraphs: Array<{num: number; el: any}>; toRemove: any[] } | null {
// Use parent of last <p> as scan container for nested layouts (e.g. Next.js)
const allPs = Array.from(element.querySelectorAll('p')) as any[];
const container = allPs.length > 0 ? (allPs[allPs.length - 1].parentElement ?? element) : element;
const children = Array.from(container.children) as any[];
// Strategy 1: forward-scan after the last <hr>
for (let i = children.length - 1; i >= 0; i--) {
if (children[i].tagName.toLowerCase() !== 'hr') continue;
const paragraphs: Array<{num: number; el: any}> = [];
for (let j = i + 1; j < children.length; j++) {
const num = this.parseFootnoteNum(children[j]);
if (num !== null) paragraphs.push({ num, el: children[j] });
}
if (paragraphs.length >= 2 && this.crossValidate(element, paragraphs)) {
return { paragraphs, toRemove: children.slice(i) };
}
break; // only check the last <hr>
}
// Strategy 2: backward-scan for trailing numbered paragraphs
const trailingNumbered: Array<{num: number; el: any}> = [];
let firstFootnoteIdx = -1;
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
const tag = child.tagName.toLowerCase();
if (tag === 'p') {
const num = this.parseFootnoteNum(child);
if (num !== null) {
trailingNumbered.unshift({ num, el: child });
firstFootnoteIdx = i;
continue;
}
break; // non-numbered paragraph — stop
}
if (tag === 'ul' || tag === 'ol' || tag === 'blockquote') continue;
break; // any other element (heading, div, ...) — stop
}
if (trailingNumbered.length >= 2 && this.crossValidate(element, trailingNumbered)) {
const toRemove: any[] = children.slice(firstFootnoteIdx);
const prev = trailingNumbered[0].el.previousElementSibling;
if (prev) {
const prevTag = prev.tagName.toLowerCase();
if (/^h[1-6]$/.test(prevTag) && FOOTNOTE_SECTION_RE.test(prev.textContent?.trim() || '')) {
toRemove.unshift(prev);
}
}
return { paragraphs: trailingNumbered, toRemove };
}
// Strategy 3: catches footnotes followed by non-footnote trailing content,
// or footnotes in a different container than the last <p>
const halfParaIdx = Math.floor(allPs.length / 2);
const scattered: Array<{num: number; el: any}> = [];
for (let i = halfParaIdx; i < allPs.length; i++) {
const num = this.parseFootnoteNum(allPs[i]);
if (num !== null) scattered.push({ num, el: allPs[i] });
}
if (scattered.length >= 2 && this.crossValidate(element, scattered)) {
return { paragraphs: scattered, toRemove: scattered.map(p => p.el) };
}
return null;
}
isBackrefLink(el: any): boolean {
if (el.tagName?.toLowerCase() !== 'a') return false;
const text = el.textContent?.trim().replace(/\uFE0E|\uFE0F/g, '') || '';
if (BACKREF_SYMBOLS_RE.test(text) || !!el.classList?.contains('footnote-backref')) return true;
// MediaWiki multi-ref backrefs: <a href="#cite_ref-...">3.0</a>
const href = el.getAttribute('href') || '';
return CITE_REF_RE.test(href);
}
removeBackrefs(el: any): void {
el.querySelectorAll('a').forEach((a: any) => {
if (this.isBackrefLink(a)) {
// Remove the wrapping <sup> if it only contained this link
const parent = a.parentElement;
if (parent?.tagName?.toLowerCase() === 'sup' && parent.children.length === 1) {
parent.remove();
} else {
a.remove();
}
}
});
// Trim leading backref text nodes (e.g. bare "^" before numbered multi-ref links)
while (el.firstChild && el.firstChild.nodeType === 3) {
const text = el.firstChild.textContent;
if (text && /^[\s\^,.;]*$/.test(text) && text.includes('^')) {
el.firstChild.remove();
} else {
break;
}
}
// Trim whitespace left where the backref was. Punctuation is kept — a lone "."
// before the backref ends the footnote's last sentence, it isn't residue.
while (el.lastChild && el.lastChild.nodeType === 3) {
const text = el.lastChild.textContent;
if (/^\s*$/.test(text)) {
el.lastChild.remove();
} else {
break;
}
}
}
getChildAnchorId(el: any): string {
const anchor = el.querySelector('a[id], a[name]');
if (!anchor) return '';
return (anchor.id || anchor.getAttribute('name') || '').toLowerCase();
}
// Extract the footnote id and content element from a generic <li>/<div role=listitem>.
// Handles Science .citations, Arxiv bib.bib*, fn:*, fn*, Nature data-counter, MediaWiki cite_note.
private extractListItemIdAndContent(li: any): { id: string; content: any } {
const citationsDiv = li.querySelector('.citations');
if (citationsDiv?.id?.toLowerCase().startsWith('r')) {
return {
id: citationsDiv.id.toLowerCase(),
content: citationsDiv.querySelector('.citation-content') || null,
};
}
const rawId = (li.id || '').toLowerCase();
// Order matters: `fn:` must precede `fn`, else `fn:3` would strip to `:3`.
for (const prefix of ['bib.bib', 'fn:', 'fn']) {
if (rawId.startsWith(prefix)) return { id: rawId.slice(prefix.length), content: li };
}
if (li.hasAttribute('data-counter')) {
const id = (li.getAttribute('data-counter') || '').replace(/\.$/, '').toLowerCase();
return { id, content: li };
}
const match = rawId.split('/').pop()?.match(/cite_note-(.+)/);
return { id: match ? match[1] : rawId, content: li };
}
findMatchingFootnoteElements(container: any, fragmentSet: Set<string>): Array<{el: any, id: string}> {
const results: Array<{el: any, id: string}> = [];
const seen = new Set<string>();
container.querySelectorAll('li, p, div').forEach((el: any) => {
let id = '';
if (el.id && fragmentSet.has(el.id.toLowerCase())) {
id = el.id.toLowerCase();
} else if (!el.id) {
const anchorId = this.getChildAnchorId(el);
if (anchorId && fragmentSet.has(anchorId)) id = anchorId;
}
if (id && !seen.has(id)) {
results.push({ el, id });
seen.add(id);
}
});
return results;
}
replaceContainerPreservingText(container: any, footnoteRef: any): void {
let directText = '';
let hasChildElements = false;
for (const node of container.childNodes) {
if (isTextNode(node)) directText += node.textContent || '';
else if (isElement(node)) hasChildElements = true;
}
directText = directText.trim();
if (directText && hasChildElements) {
const fragment = container.ownerDocument.createDocumentFragment();
fragment.appendChild(container.ownerDocument.createTextNode(directText));
fragment.appendChild(footnoteRef);
container.replaceWith(fragment);
} else {
container.replaceWith(footnoteRef);
}
}
findOuterFootnoteContainer(el: any): any {
let current: any = el;
let parent: any = el.parentElement;
while (parent) {
const tag = parent.tagName.toLowerCase();
if (tag !== 'span' && tag !== 'sup') break;
// Don't walk into spans that contain substantial non-footnote content
if (tag === 'span') {
let hasNonFootnoteContent = false;
for (const child of parent.childNodes) {
if (child === current) continue;
if (isTextNode(child) && child.textContent?.trim()) {
hasNonFootnoteContent = true;
break;
}
if (isElement(child) && child.tagName.toLowerCase() !== 'sup') {
hasNonFootnoteContent = true;
break;
}
}
if (hasNonFootnoteContent) break;
}
current = parent;
parent = parent.parentElement;
}
return current;
}
createFootnoteReference(footnoteNumber: string, refId: string): any {
const sup = this.doc.createElement('sup');
sup.id = refId;
const link = this.doc.createElement('a');
link.href = `#fn:${footnoteNumber}`;
link.textContent = footnoteNumber;
sup.appendChild(link);
return sup;
}
// Tufte-style and inline sidenotes embedded in text
collectInlineSidenotes(element: any): FootnoteCollection {
const footnotes: FootnoteCollection = {};
const containers = element.querySelectorAll('span.footnote-container, span.sidenote-container, span.inline-footnote');
if (containers.length === 0) {
// Org Mode CSS sidenotes: label.footref + input.footref-toggle + span.sidenote
const footrefs = element.querySelectorAll('label.footref');
if (footrefs.length > 0) {
let footnoteCount = 1;
footrefs.forEach((label: any) => {
// Find the sidenote that follows this label (possibly with input in between)
let sibling = label.nextElementSibling;
if (sibling?.tagName === 'INPUT' && sibling.classList?.contains('footref-toggle')) {
sibling = sibling.nextElementSibling;
}
if (!sibling || sibling.tagName !== 'SPAN' || !sibling.classList?.contains('sidenote')) return;
const content = sibling.cloneNode(true);
// Remove the leading sup number from the sidenote content
const leadingSup = content.querySelector('sup');
if (leadingSup && content.firstChild === leadingSup) {
leadingSup.remove();
}
footnotes[footnoteCount] = {
content: content,
originalId: String(footnoteCount),
refs: [`fnref:${footnoteCount}`]
};
// Replace label + input + sidenote with a footnote reference
const ref = this.createFootnoteReference(String(footnoteCount), `fnref:${footnoteCount}`);
const inputEl = label.nextElementSibling;
if (inputEl?.tagName === 'INPUT' && inputEl.classList?.contains('footref-toggle')) {
inputEl.remove();
}
sibling.remove();
label.replaceWith(ref);
footnoteCount++;
});
// Remove the footer that duplicates these sidenotes (Org Mode footdef list)
element.querySelectorAll('footer').forEach((footer: any) => {
if (footer.querySelector('.footdef')) {
footer.remove();
}
});
return footnotes;
}
// Remove standalone sidenotes that duplicate the formal footnote list
element.querySelectorAll('span.sidenote').forEach((sidenote: any) => {
sidenote.remove();
});
return footnotes;
}
let footnoteCount = 1;
containers.forEach((container: any) => {
const content = container.querySelector('span.footnote, span.sidenote, span.footnoteContent');
if (!content) return;
footnotes[footnoteCount] = {
content: content.cloneNode(true),
originalId: String(footnoteCount),
refs: [`fnref:${footnoteCount}`]
};
const ref = this.createFootnoteReference(String(footnoteCount), `fnref:${footnoteCount}`);
container.replaceWith(ref);
footnoteCount++;
});
return footnotes;
}
// Sidenotes rendered in a separate column/container
collectSidenotesColumn(element: any): FootnoteCollection {
const footnotes: FootnoteCollection = {};
let columns = Array.from(element.querySelectorAll('.sidenotes-column')) as any[];
// Sidenote columns are often siblings of an ancestor
// (e.g. <div class="wrapper"><article>…</article><aside class="sidenotes-column">)
if (columns.length === 0) {
let ancestor = element.parentElement;
for (let i = 0; i < 3 && ancestor && columns.length === 0; i++) {
columns = Array.from(ancestor.querySelectorAll(':scope > .sidenotes-column')) as any[];
ancestor = ancestor.parentElement;
}
}
if (columns.length === 0) return footnotes;
let footnoteCount = 1;
columns.forEach((column: any) => {
const sidenotes = column.querySelectorAll('.sidenote[id]');
sidenotes.forEach((sidenote: any) => {
const id = sidenote.id;
if (!id) return;
const idSpan = sidenote.querySelector('.sidenote__id');
const num = idSpan?.textContent?.replace(/\D/g, '');
const footnoteNumber = num ? parseInt(num, 10) : footnoteCount;
const contentDiv = this.doc.createElement('div');
Array.from(sidenote.childNodes).forEach((node: any) => {
if (isElement(node)) {
if (node.classList?.contains('sidenote__id')) return;
if (node.classList?.contains('sidenote__label')) return;
if (node.classList?.contains('sn-backref')) return;
}
contentDiv.appendChild(node.cloneNode(true));
});
this.removeBackrefs(contentDiv);
footnotes[footnoteNumber] = {
content: contentDiv,
originalId: id.toLowerCase(),
refs: []
};
footnoteCount++;
});
column.remove();
});
return footnotes;
}
// Footnotes in asides with numbered ordered lists
collectAsideFootnotes(element: any): FootnoteCollection {
const footnotes: FootnoteCollection = {};
const ols = Array.from(element.querySelectorAll('aside > ol[start]')) as any[];
if (ols.length === 0) return footnotes;
ols.forEach((ol: any) => {
const aside = ol.parentElement;
const footnoteNumber = parseInt(ol.getAttribute('start') || '', 10);
if (isNaN(footnoteNumber) || footnoteNumber < 1) return;
const items = Array.from(ol.querySelectorAll('li')) as any[];
if (items.length === 0) return;
const contentDiv = this.doc.createElement('div');
if (items.length === 1) {
transferContent(items[0].cloneNode(true), contentDiv);
} else {
items.forEach((li: any) => {
const p = this.doc.createElement('p');
transferContent(li.cloneNode(true), p);
contentDiv.appendChild(p);
});
}
footnotes[footnoteNumber] = {
content: contentDiv,
originalId: String(footnoteNumber),
refs: []
};
aside.remove();
});
return footnotes;
}
// Hidden aside footnotes linked by data-definition attribute
// Pattern: <span data-definition="id"><a href="#">*</a></span>
// <aside style="display:none" id="id">content</aside>
collectHiddenAsideFootnotes(element: any): FootnoteCollection {
const footnotes: FootnoteCollection = {};
const refs = Array.from(element.querySelectorAll('span[data-definition]')) as any[];
if (refs.length === 0) return footnotes;
const asideMap = new Map<string, any>();
element.querySelectorAll('aside[id]').forEach((aside: any) => {
asideMap.set(aside.id, aside);
});
let footnoteCount = 1;
refs.forEach((ref: any) => {
const defId = ref.getAttribute('data-definition');
if (!defId) return;
const aside = asideMap.get(defId);
if (!aside) return;
const contentDiv = this.doc.createElement('div');
transferContent(aside, contentDiv);
aside.remove();
const footnoteNumber = String(footnoteCount);
const refId = `fnref:${footnoteNumber}`;
footnotes[footnoteCount] = {
content: contentDiv,
originalId: defId.toLowerCase(),
refs: [refId]
};
ref.replaceWith(this.createFootnoteReference(footnoteNumber, refId));
footnoteCount++;
});
return footnotes;
}
standardizeFootnotes(element: any) {
const sidenotes = this.collectInlineSidenotes(element);
const footnotes = this.collectHiddenAsideFootnotes(element);
this.mergeFootnotes(footnotes, this.collectFootnotes(element));
this.mergeFootnotes(footnotes, this.collectSidenotesColumn(element));
this.mergeFootnotes(footnotes, this.collectAsideFootnotes(element));
const footnoteInlineReferences = element.querySelectorAll(FOOTNOTE_INLINE_REFERENCES);
const supGroups = new Map();
const footnotesByOriginalId = new Map<string, [string, FootnoteData]>();
Object.entries(footnotes).forEach(([num, data]) => {
footnotesByOriginalId.set(data.originalId.toLowerCase(), [num, data]);
});
footnoteInlineReferences.forEach((el: any) => {
if (!el || !el.parentNode) return;
if (!el.textContent?.trim()) return;
// Arxiv — multi-citation groups (e.g. [35, 2, 5]) are handled specially because
// one element can expand into several refs.
if (el.matches('cite.ltx_cite')) {
const refs: any[] = [];
el.querySelectorAll('a').forEach((link: any) => {
const href = link.getAttribute('href');
if (!href) return;
const match = href.split('/').pop()?.match(/bib\.bib(\d+)/);
if (!match) return;
const entry = footnotesByOriginalId.get(match[1].toLowerCase());
if (!entry) return;
const [fnNum, fnData] = entry;
const refId = this.makeRefId(fnNum, fnData.refs.length);
fnData.refs.push(refId);
refs.push(this.createFootnoteReference(fnNum, refId));
});
if (refs.length > 0) {
const container = this.findOuterFootnoteContainer(el);
const fragment = el.ownerDocument.createDocumentFragment();
refs.forEach((ref: any, i: number) => {
if (i > 0) fragment.appendChild(el.ownerDocument.createTextNode(' '));
fragment.appendChild(ref);
});
container.replaceWith(fragment);
}
return;
}
let footnoteId = '';
for (const { selector, extract } of INLINE_REF_EXTRACTORS) {
if (el.matches(selector)) {
footnoteId = extract(el);
break;
}
}
if (!footnoteId) {
// Fallback: use the href fragment when no selector matched.
const href = el.getAttribute('href');
if (href) footnoteId = href.replace(/^[#]/, '').toLowerCase();
}
if (footnoteId) {
const footnoteEntry = footnotesByOriginalId.get(footnoteId.toLowerCase());
if (footnoteEntry) {
const [footnoteNumber, footnoteData] = footnoteEntry;
const container = this.findOuterFootnoteContainer(el);
const isSup = container.tagName.toLowerCase() === 'sup';
// Dedupe: when an outer sup and its inner anchor both match the same footnote,
// keep only the first reference.
if (isSup && supGroups.get(container)?.some((r: any) => r.footnoteNumber === footnoteNumber)) {
return;
}
const refId = this.makeRefId(footnoteNumber, footnoteData.refs.length);
footnoteData.refs.push(refId);
if (isSup) {
if (!supGroups.has(container)) supGroups.set(container, []);
supGroups.get(container).push({ footnoteNumber, refId });
} else {
this.replaceContainerPreservingText(container, this.createFootnoteReference(footnoteNumber, refId));
}
}
}
});
// Fallback: match remaining unmatched footnotes
const unmatchedFootnotes = Object.entries(footnotes).filter(
([_, data]) => data.refs.length === 0
);
if (unmatchedFootnotes.length > 0) {
const footnoteIdMap = new Map<string, [string, FootnoteData]>();
const footnoteNumMap = new Map<string, [string, FootnoteData]>();
unmatchedFootnotes.forEach(([num, data]) => {
footnoteIdMap.set(data.originalId, [num, data]);
footnoteNumMap.set(num, [num, data]);
});
const isInsideFootnotes = (el: any) =>
el.closest('[id^="fnref:"]') || el.closest('#footnotes') ||
this.pendingRemovals.some((g: any) => g.contains(el));
const assignRef = (el: any, entry: [string, FootnoteData]) => {
const [footnoteNumber, footnoteData] = entry;
const refId = this.makeRefId(footnoteNumber, footnoteData.refs.length);
footnoteData.refs.push(refId);
const container = this.findOuterFootnoteContainer(el);
this.replaceContainerPreservingText(container, this.createFootnoteReference(footnoteNumber, refId));
};
// Pass 1: Match by fragment link
element.querySelectorAll('a[href*="#"]').forEach((link: any) => {
if (!link.parentNode || isInsideFootnotes(link)) return;
const fragment = getHrefFragment(link);
if (!fragment) return;
const entry = footnoteIdMap.get(fragment);
if (!entry) return;
const text = link.textContent?.trim() || '';
if (!FOOTNOTE_MARKER_RE.test(text)) return;
assignRef(link, entry);
});
// Pass 2: Match sup/span elements with numeric text
const hasUnmatched = Object.values(footnotes).some(d => d.refs.length === 0);
if (hasUnmatched) {
element.querySelectorAll('sup, span.footnote-ref').forEach((el: any) => {
if (!el.parentNode || el.id?.startsWith('fnref:') || el.closest('#footnotes')) return;
const match = (el.textContent?.trim() || '').match(FOOTNOTE_MARKER_RE);
if (!match) return;
const entry = footnoteNumMap.get(match[1]) || footnoteIdMap.get(match[1]);
if (!entry || entry[1].refs.length > 0) return;
assignRef(el, entry);
});
}
}
supGroups.forEach((refs: Array<{footnoteNumber: string, refId: string}>, container: any) => {
const fragment = this.doc.createDocumentFragment();
refs.forEach(({ footnoteNumber, refId }) => {
fragment.appendChild(this.createFootnoteReference(footnoteNumber, refId));
});
container.replaceWith(fragment);
});
const newList = this.doc.createElement('div');
newList.id = 'footnotes';
const orderedList = this.doc.createElement('ol');
const allFootnotes = { ...sidenotes, ...footnotes };
Object.entries(allFootnotes).forEach(([number, data]) => {
orderedList.appendChild(this.createFootnoteItem(parseInt(number), data.content, data.refs));
});
element.querySelectorAll(FOOTNOTE_LIST_SELECTORS).forEach((list: any) => list.remove());
this.pendingRemovals.forEach((el: any) => { if (el.parentNode) el.remove(); });
removeOrphanedDividers(element);
if (orderedList.children.length > 0) {
newList.appendChild(orderedList);
element.appendChild(newList);
}
}
}
export function standardizeFootnotes(element: any): void {
const doc = element.ownerDocument;
if (!doc) {
// No document available
return;
}
const handler = new FootnoteHandler(doc);
handler.standardizeFootnotes(element);
}SHA-256: 9f992d2333eb4b82b2a1063fb26060245602a8f75822b7c964df13452883f392