| | |
| | |
| | |
| | |
| | |
| |
|
| | export default function rehypeReferencesAndFootnotes() { |
| | return (tree) => { |
| | const isElement = (n) => n && typeof n === 'object' && n.type === 'element'; |
| | const getChildren = (n) => (Array.isArray(n?.children) ? n.children : []); |
| |
|
| | const walk = (node, parent, fn) => { |
| | if (!node || typeof node !== 'object') return; |
| | fn && fn(node, parent); |
| | const kids = getChildren(node); |
| | for (const child of kids) walk(child, node, fn); |
| | }; |
| |
|
| | const ensureArray = (v) => (Array.isArray(v) ? v : v != null ? [v] : []); |
| |
|
| | const hasClass = (el, name) => { |
| | const cn = ensureArray(el?.properties?.className).map(String); |
| | return cn.includes(name); |
| | }; |
| |
|
| | const setAttr = (el, key, val) => { |
| | el.properties = el.properties || {}; |
| | if (val == null) delete el.properties[key]; |
| | else el.properties[key] = val; |
| | }; |
| |
|
| | const getAttr = (el, key) => (el?.properties ? el.properties[key] : undefined); |
| |
|
| | |
| | const collectBacklinksForIdSet = (idSet, anchorPrefix) => { |
| | const idToBacklinks = new Map(); |
| | const idToAnchorNodes = new Map(); |
| | if (!idSet || idSet.size === 0) return { idToBacklinks, idToAnchorNodes }; |
| | walk(tree, null, (node) => { |
| | if (!isElement(node) || node.tagName !== 'a') return; |
| | const href = String(getAttr(node, 'href') || ''); |
| | if (!href.startsWith('#')) return; |
| | const id = href.slice(1); |
| | if (!idSet.has(id)) return; |
| | |
| | let anchorId = String(getAttr(node, 'id') || ''); |
| | if (!anchorId) { |
| | const list = idToBacklinks.get(id) || []; |
| | anchorId = `${anchorPrefix}-${id}-${list.length + 1}`; |
| | setAttr(node, 'id', anchorId); |
| | } |
| | const list = idToBacklinks.get(id) || []; |
| | list.push(anchorId); |
| | idToBacklinks.set(id, list); |
| | const nodes = idToAnchorNodes.get(id) || []; |
| | nodes.push(node); |
| | idToAnchorNodes.set(id, nodes); |
| | }); |
| | return { idToBacklinks, idToAnchorNodes }; |
| | }; |
| |
|
| | const createBackIcon = () => ({ |
| | type: 'element', |
| | tagName: 'svg', |
| | properties: { |
| | className: ['back-icon'], |
| | width: 12, |
| | height: 12, |
| | viewBox: '0 0 24 24', |
| | fill: 'none', |
| | stroke: 'currentColor', |
| | 'stroke-width': 2, |
| | 'stroke-linecap': 'round', |
| | 'stroke-linejoin': 'round', |
| | 'aria-hidden': 'true', |
| | focusable: 'false' |
| | }, |
| | children: [ |
| | { type: 'element', tagName: 'line', properties: { x1: 12, y1: 19, x2: 12, y2: 5 }, children: [] }, |
| | { type: 'element', tagName: 'polyline', properties: { points: '5 12 12 5 19 12' }, children: [] } |
| | ] |
| | }); |
| |
|
| | const appendBackrefsBlock = (listElement, idToBacklinks, ariaLabel) => { |
| | if (!listElement || !idToBacklinks || idToBacklinks.size === 0) return; |
| | for (const li of getChildren(listElement)) { |
| | if (!isElement(li) || li.tagName !== 'li') continue; |
| | const id = String(getAttr(li, 'id') || ''); |
| | if (!id) continue; |
| | const keys = idToBacklinks.get(id); |
| | if (!keys || !keys.length) continue; |
| | |
| | li.children = getChildren(li).filter((n) => !(isElement(n) && n.tagName === 'small' && hasClass(n, 'backrefs'))); |
| | const small = { |
| | type: 'element', |
| | tagName: 'small', |
| | properties: { className: ['backrefs'] }, |
| | children: [] |
| | }; |
| | if (keys.length === 1) { |
| | |
| | const a = { |
| | type: 'element', |
| | tagName: 'a', |
| | properties: { href: `#${keys[0]}`, 'aria-label': ariaLabel }, |
| | children: [ createBackIcon() ] |
| | }; |
| | small.children.push(a); |
| | } else { |
| | |
| | small.children.push(createBackIcon()); |
| | small.children.push({ type: 'text', value: ' back: ' }); |
| | keys.forEach((backId, idx) => { |
| | small.children.push({ |
| | type: 'element', |
| | tagName: 'a', |
| | properties: { href: `#${backId}`, 'aria-label': ariaLabel }, |
| | children: [ { type: 'text', value: String(idx + 1) } ] |
| | }); |
| | if (idx < keys.length - 1) small.children.push({ type: 'text', value: ', ' }); |
| | }); |
| | } |
| | li.children.push(small); |
| | } |
| | }; |
| | |
| | const getTextContent = (el) => { |
| | if (!el) return ''; |
| | const stack = [el]; |
| | let out = ''; |
| | while (stack.length) { |
| | const cur = stack.pop(); |
| | if (!cur) continue; |
| | if (cur.type === 'text') out += String(cur.value || ''); |
| | const kids = getChildren(cur); |
| | for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]); |
| | } |
| | return out; |
| | }; |
| |
|
| | const removeFootnoteBackrefAnchors = (el) => { |
| | if (!isElement(el)) return; |
| | const kids = getChildren(el); |
| | for (let i = kids.length - 1; i >= 0; i--) { |
| | const child = kids[i]; |
| | if (isElement(child)) { |
| | if ( |
| | child.tagName === 'a' && ( |
| | getAttr(child, 'data-footnote-backref') != null || |
| | hasClass(child, 'footnote-backref') || |
| | String(getAttr(child, 'role') || '').toLowerCase() === 'doc-backlink' || |
| | String(getAttr(child, 'aria-label') || '').toLowerCase().includes('back to content') || |
| | String(getAttr(child, 'href') || '').startsWith('#fnref') || |
| | |
| | /^\s*↩\s*\d*\s*$/u.test(getTextContent(child)) |
| | ) |
| | ) { |
| | |
| | el.children.splice(i, 1); |
| | continue; |
| | } |
| | |
| | removeFootnoteBackrefAnchors(child); |
| | |
| | const becameKids = getChildren(child); |
| | if ((child.tagName === 'sup' || child.tagName === 'span') && (!becameKids || becameKids.length === 0)) { |
| | el.children.splice(i, 1); |
| | } |
| | } |
| | } |
| | }; |
| |
|
| |
|
| | const normDoiHref = (href) => { |
| | if (!href) return href; |
| | const DUP = /https?:\/\/(?:dx\.)?doi\.org\/(?:https?:\/\/(?:dx\.)?doi\.org\/)+/gi; |
| | const ONE = /https?:\/\/(?:dx\.)?doi\.org\/(10\.[^\s<>"']+)/i; |
| | href = String(href).replace(DUP, 'https://doi.org/'); |
| | const m = href.match(ONE); |
| | return m ? `https://doi.org/${m[1]}` : href; |
| | }; |
| |
|
| | const DOI_BARE = /\b10\.[0-9]{4,9}\/[\-._;()\/:A-Z0-9]+\b/gi; |
| | const URL_GEN = /\bhttps?:\/\/[^\s<>()"']+/gi; |
| |
|
| | const linkifyTextNode = (textNode) => { |
| | const text = String(textNode.value || ''); |
| | let last = 0; |
| | const parts = []; |
| | const pushText = (s) => { if (s) parts.push({ type: 'text', value: s }); }; |
| |
|
| | const matches = []; |
| | |
| | let m; |
| | URL_GEN.lastIndex = 0; |
| | while ((m = URL_GEN.exec(text)) !== null) { |
| | matches.push({ type: 'url', start: m.index, end: URL_GEN.lastIndex, raw: m[0] }); |
| | } |
| | |
| | DOI_BARE.lastIndex = 0; |
| | while ((m = DOI_BARE.exec(text)) !== null) { |
| | matches.push({ type: 'doi', start: m.index, end: DOI_BARE.lastIndex, raw: m[0] }); |
| | } |
| | matches.sort((a, b) => a.start - b.start); |
| |
|
| | for (const match of matches) { |
| | if (match.start < last) continue; |
| | pushText(text.slice(last, match.start)); |
| | if (match.type === 'url') { |
| | const href = normDoiHref(match.raw); |
| | const doiOne = href.match(/https?:\/\/(?:dx\.)?doi\.org\/(10\.[^\s<>"']+)/i); |
| | const a = { |
| | type: 'element', |
| | tagName: 'a', |
| | properties: { href, target: '_blank', rel: 'noopener noreferrer' }, |
| | children: [{ type: 'text', value: doiOne ? doiOne[1] : href }] |
| | }; |
| | parts.push(a); |
| | } else { |
| | const href = `https://doi.org/${match.raw}`; |
| | const a = { |
| | type: 'element', |
| | tagName: 'a', |
| | properties: { href, target: '_blank', rel: 'noopener noreferrer' }, |
| | children: [{ type: 'text', value: match.raw }] |
| | }; |
| | parts.push(a); |
| | } |
| | last = match.end; |
| | } |
| |
|
| | pushText(text.slice(last)); |
| | return parts; |
| | }; |
| |
|
| | const linkifyInElement = (el) => { |
| | const kids = getChildren(el); |
| | for (let i = 0; i < kids.length; i++) { |
| | const child = kids[i]; |
| | if (!child) continue; |
| | if (child.type === 'text') { |
| | const replacement = linkifyTextNode(child); |
| | if (replacement.length === 1 && replacement[0].type === 'text') continue; |
| | |
| | el.children.splice(i, 1, ...replacement); |
| | i += replacement.length - 1; |
| | } else if (isElement(child)) { |
| | if (child.tagName === 'a') { |
| | const href = normDoiHref(getAttr(child, 'href')); |
| | setAttr(child, 'href', href); |
| | const m = String(href || '').match(/https?:\/\/(?:dx\.)?doi\.org\/(10\.[^\s<>"']+)/i); |
| | if (m && (!child.children || child.children.length === 0)) { |
| | child.children = [{ type: 'text', value: m[1] }]; |
| | } |
| | continue; |
| | } |
| | linkifyInElement(child); |
| | } |
| | } |
| | |
| | for (let i = 1; i < el.children.length; i++) { |
| | const prev = el.children[i - 1]; |
| | const curr = el.children[i]; |
| | if (isElement(prev) && isElement(curr) && prev.tagName === 'a' && curr.tagName === 'a') { |
| | const key = `${getAttr(prev, 'href') || ''}|${(prev.children?.[0]?.value) || ''}`; |
| | const key2 = `${getAttr(curr, 'href') || ''}|${(curr.children?.[0]?.value) || ''}`; |
| | if (key === key2) { |
| | el.children.splice(i, 1); |
| | i--; |
| | } |
| | } |
| | } |
| | }; |
| |
|
| | |
| | const findReferencesRoot = () => { |
| | let found = null; |
| | walk(tree, null, (node) => { |
| | if (found) return; |
| | if (!isElement(node)) return; |
| | const id = getAttr(node, 'id'); |
| | if (id === 'references' || hasClass(node, 'references') || hasClass(node, 'bibliography')) { |
| | found = node; |
| | } |
| | }); |
| | return found; |
| | }; |
| |
|
| | const toOrderedList = (container) => { |
| | |
| | let ol = getChildren(container).find((c) => isElement(c) && c.tagName === 'ol'); |
| | if (!ol) { |
| | ol = { type: 'element', tagName: 'ol', properties: { className: ['references'] }, children: [] }; |
| | const candidates = getChildren(container).filter((n) => isElement(n)); |
| | if (candidates.length) { |
| | for (const node of candidates) { |
| | if (hasClass(node, 'csl-entry') || node.tagName === 'li' || node.tagName === 'p' || node.tagName === 'div') { |
| | const li = { type: 'element', tagName: 'li', properties: {}, children: getChildren(node) }; |
| | if (getAttr(node, 'id')) setAttr(li, 'id', getAttr(node, 'id')); |
| | ol.children.push(li); |
| | } |
| | } |
| | } |
| | |
| | container.children = [ol]; |
| | } |
| | if (!hasClass(ol, 'references')) { |
| | const cls = ensureArray(ol.properties?.className).map(String); |
| | if (!cls.includes('references')) cls.push('references'); |
| | ol.properties = ol.properties || {}; |
| | ol.properties.className = cls; |
| | } |
| | return ol; |
| | }; |
| |
|
| | const refsRoot = findReferencesRoot(); |
| | let refsOl = null; |
| | const refIdSet = new Set(); |
| | const refIdToExternalHref = new Map(); |
| |
|
| | if (refsRoot) { |
| | refsOl = toOrderedList(refsRoot); |
| | |
| | for (const li of getChildren(refsOl)) { |
| | if (!isElement(li) || li.tagName !== 'li') continue; |
| | if (!getAttr(li, 'id')) { |
| | |
| | const nestedWithId = getChildren(li).find((n) => isElement(n) && getAttr(n, 'id')); |
| | if (nestedWithId) setAttr(li, 'id', getAttr(nestedWithId, 'id')); |
| | } |
| | const id = getAttr(li, 'id'); |
| | if (id) refIdSet.add(String(id)); |
| | linkifyInElement(li); |
| | |
| | if (id) { |
| | let externalHref = null; |
| | const stack = [li]; |
| | while (stack.length) { |
| | const cur = stack.pop(); |
| | const kids = getChildren(cur); |
| | for (const k of kids) { |
| | if (isElement(k) && k.tagName === 'a') { |
| | const href = String(getAttr(k, 'href') || ''); |
| | if (/^https?:\/\//i.test(href)) { |
| | externalHref = href; |
| | break; |
| | } |
| | } |
| | if (isElement(k)) stack.push(k); |
| | } |
| | if (externalHref) break; |
| | } |
| | if (externalHref) refIdToExternalHref.set(String(id), externalHref); |
| | } |
| | } |
| | setAttr(refsRoot, 'data-built-refs', '1'); |
| | } |
| |
|
| | |
| | const { idToBacklinks: refIdToBacklinks, idToAnchorNodes: refIdToCitationAnchors } = collectBacklinksForIdSet(refIdSet, 'refctx'); |
| |
|
| | |
| | appendBackrefsBlock(refsOl, refIdToBacklinks, 'Back to citation'); |
| |
|
| | |
| | if (refIdToCitationAnchors.size > 0) { |
| | for (const [id, anchors] of refIdToCitationAnchors.entries()) { |
| | const ext = refIdToExternalHref.get(id); |
| | if (!ext) continue; |
| | for (const a of anchors) { |
| | setAttr(a, 'data-ref-id', id); |
| | setAttr(a, 'href', ext); |
| | const existingTarget = getAttr(a, 'target'); |
| | if (!existingTarget) setAttr(a, 'target', '_blank'); |
| | const rel = String(getAttr(a, 'rel') || ''); |
| | const relSet = new Set(rel ? rel.split(/\s+/) : []); |
| | relSet.add('noopener'); |
| | relSet.add('noreferrer'); |
| | setAttr(a, 'rel', Array.from(relSet).join(' ')); |
| | } |
| | } |
| | } |
| |
|
| | |
| | const cleanupFootnotes = () => { |
| | let root = null; |
| | walk(tree, null, (node) => { |
| | if (!isElement(node)) return; |
| | if (hasClass(node, 'footnotes')) root = node; |
| | }); |
| | if (!root) return { root: null, ol: null, idSet: new Set() }; |
| | |
| | root.children = getChildren(root).filter((n) => !(isElement(n) && n.tagName === 'hr')); |
| | |
| | let ol = getChildren(root).find((c) => isElement(c) && c.tagName === 'ol'); |
| | if (!ol) { |
| | ol = { type: 'element', tagName: 'ol', properties: {}, children: [] }; |
| | const items = getChildren(root).filter((n) => isElement(n) && (n.tagName === 'li' || hasClass(n, 'footnote') || n.tagName === 'p' || n.tagName === 'div')); |
| | if (items.length) { |
| | for (const it of items) { |
| | const li = { type: 'element', tagName: 'li', properties: {}, children: getChildren(it) }; |
| | |
| | const nestedWithId = getChildren(it).find((n) => isElement(n) && getAttr(n, 'id')); |
| | if (nestedWithId) setAttr(li, 'id', getAttr(nestedWithId, 'id')); |
| | ol.children.push(li); |
| | } |
| | } |
| | root.children = [ol]; |
| | } |
| | |
| | for (const li of getChildren(ol)) { |
| | if (!isElement(li) || li.tagName !== 'li') continue; |
| | if (!getAttr(li, 'id')) { |
| | const nestedWithId = getChildren(li).find((n) => isElement(n) && getAttr(n, 'id')); |
| | if (nestedWithId) setAttr(li, 'id', getAttr(nestedWithId, 'id')); |
| | } |
| | |
| | removeFootnoteBackrefAnchors(li); |
| | } |
| | setAttr(root, 'data-built-footnotes', '1'); |
| | |
| | const idSet = new Set(); |
| | for (const li of getChildren(ol)) { |
| | if (!isElement(li) || li.tagName !== 'li') continue; |
| | const id = getAttr(li, 'id'); |
| | if (id) idSet.add(String(id)); |
| | } |
| | return { root, ol, idSet }; |
| | }; |
| |
|
| | const { root: footRoot, ol: footOl, idSet: footIdSet } = cleanupFootnotes(); |
| |
|
| | |
| | const { idToBacklinks: footIdToBacklinks } = collectBacklinksForIdSet(footIdSet, 'footctx'); |
| |
|
| | |
| | appendBackrefsBlock(footOl, footIdToBacklinks, 'Back to footnote call'); |
| | }; |
| | } |
| |
|
| |
|
| |
|