1import { DEFAULT_DISCLAIMER_ROOT, DISCLAIMER_ENDPOINT } from './disclaimer-config.js'; 2 3const requestCache = new Map(); 4 5function createDisclaimerResult(overrides = {}) { 6 return { 7 top: [], 8 disclaimers: [], 9 bottom: [], 10 warnings: [], 11 requestSucceeded: false, 12 ...overrides, 13 }; 14} 15 16export function normalizeDisclaimerPath(path, disclaimerRoot = DEFAULT_DISCLAIMER_ROOT) { 17 if (typeof path !== 'string') return null; 18 19 let normalizedPath = path; 20 while (normalizedPath.endsWith('/')) { 21 normalizedPath = normalizedPath.slice(0, -1); 22 } 23 if (normalizedPath.endsWith('.html')) { 24 normalizedPath = normalizedPath.slice(0, -'.html'.length); 25 } 26 27 const rootPrefix = `${disclaimerRoot}/`; 28 if (!normalizedPath.startsWith(rootPrefix)) return null; 29 30 const relativePath = normalizedPath.slice(rootPrefix.length); 31 if (!relativePath || relativePath.includes('%')) return null; 32 33 const segments = relativePath.split('/'); 34 const hasValidSegments = segments.every((segment) => { 35 const isRelativeSegment = segment !== '.' && segment !== '..'; 36 const hasSafeCharacters = /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(segment); 37 return isRelativeSegment && hasSafeCharacters; 38 }); 39 40 return hasValidSegments ? relativePath : null; 41} 42 43function normalizeContentFragmentEntries(entries, disclaimerRoot, requiresNumber = false) { 44 if (!Array.isArray(entries)) return null; 45 46 const normalizedPaths = new Set(); 47 const hasValidEntries = entries.every((entry, index) => { 48 const relativePath = normalizeDisclaimerPath(entry?.path, disclaimerRoot); 49 const hasValidPath = relativePath !== null; 50 const hasUniquePath = !normalizedPaths.has(relativePath); 51 const hasValidHtml = typeof entry.html === 'string'; 52 const hasValidNumber = !requiresNumber || entry.number === index + 1; 53 if (![hasValidPath, hasUniquePath, hasValidHtml, hasValidNumber].every(Boolean)) return false; 54 55 normalizedPaths.add(relativePath); 56 return true; 57 }); 58 if (!hasValidEntries) return null; 59 60 return entries.map((entry) => ({ 61 ...(requiresNumber ? { number: entry.number } : {}), 62 path: `${disclaimerRoot}/${normalizeDisclaimerPath(entry.path, disclaimerRoot)}`, 63 html: entry.html, 64 })); 65} 66 67function hasValidWarnings(warnings) { 68 if (warnings === undefined) return true; 69 if (!Array.isArray(warnings)) return false; 70 71 return warnings.every((warning) => { 72 const hasType = typeof warning?.type === 'string'; 73 const hasMessage = typeof warning.message === 'string'; 74 const hasPath = typeof warning.path === 'string'; 75 return hasType && hasMessage && hasPath; 76 }); 77} 78 79function normalizeDisclaimerResponse(data, disclaimerRoot) { 80 if (!data || !hasValidWarnings(data.warnings)) return null; 81 82 const top = normalizeContentFragmentEntries(data.top, disclaimerRoot); 83 const disclaimers = normalizeContentFragmentEntries(data.disclaimers, disclaimerRoot, true); 84 const bottom = normalizeContentFragmentEntries(data.bottom, disclaimerRoot); 85 if (!top || !disclaimers || !bottom) return null; 86 87 return { 88 top, 89 disclaimers, 90 bottom, 91 warnings: data.warnings || [], 92 }; 93} 94 95export function collectDisclaimerQueryEntries(main, blockData = {}, options = {}) { 96 const { disclaimerRoot = DEFAULT_DISCLAIMER_ROOT } = options; 97 const entries = []; 98 const topPath = normalizeDisclaimerPath(blockData.topCFPath, disclaimerRoot); 99 if (topPath) entries.push(`top~${topPath}`); 100 101 const uniqueFootnotePaths = new Set(); 102 const links = main.querySelectorAll(`a[href^="${disclaimerRoot}/"]`); 103 links.forEach((link) => { 104 if (link.closest('.disclaimer')) return; 105 if (link.textContent.trim() !== '[#]') return; 106 107 const relativePath = normalizeDisclaimerPath(link.getAttribute('href'), disclaimerRoot); 108 if (relativePath) uniqueFootnotePaths.add(relativePath); 109 }); 110 entries.push(...uniqueFootnotePaths); 111 112 const bottomPath = normalizeDisclaimerPath(blockData.bottomCFPath, disclaimerRoot); 113 if (bottomPath) entries.push(`bottom~${bottomPath}`); 114 115 return entries; 116} 117 118export function buildDisclaimerQueryUrl(host, entries) { 119 const url = new URL(DISCLAIMER_ENDPOINT, host); 120 url.search = new URLSearchParams({ cf: entries.join(':') }).toString(); 121 return url.toString(); 122} 123 124function getCachedRequest(url, fetchImplementation) { 125 return requestCache.get(url)?.get(fetchImplementation); 126} 127
128function cacheRequest(url, fetchImplementation, request) { 129 const requestsByFetch = requestCache.get(url) || new Map(); 130 requestsByFetch.set(fetchImplementation, request); 131 requestCache.set(url, requestsByFetch); 132} 133 134export async function fetchQueryDisclaimers(entries, options = {}) { 135 if (!Array.isArray(entries) || entries.length === 0) { 136 return createDisclaimerResult({ requestSucceeded: true }); 137 } 138 139 const { host, disclaimerRoot = DEFAULT_DISCLAIMER_ROOT, fetchImplementation = fetch } = options; 140 if (!host || typeof fetchImplementation !== 'function') return createDisclaimerResult(); 141 142 let url; 143 try { 144 url = buildDisclaimerQueryUrl(host, entries); 145 } catch { 146 return createDisclaimerResult(); 147 } 148 149 const cacheKey = `${disclaimerRoot}:${url}`; 150 const cachedRequest = getCachedRequest(cacheKey, fetchImplementation); 151 if (cachedRequest) return cachedRequest; 152 153 const request = (async () => { 154 try { 155 const response = await fetchImplementation(url); 156 if (!response.ok) return createDisclaimerResult(); 157 158 const normalizedResponse = normalizeDisclaimerResponse(await response.json(), disclaimerRoot); 159 if (!normalizedResponse) return createDisclaimerResult(); 160 161 return createDisclaimerResult({ 162 ...normalizedResponse, 163 requestSucceeded: true, 164 }); 165 } catch { 166 return createDisclaimerResult(); 167 } 168 })(); 169 170 cacheRequest(cacheKey, fetchImplementation, request); 171 return request; 172}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.