import fs from 'node:fs'; import path from 'node:path'; const SKIP_REFERENCE = /^(?:https?:|mailto:|javascript:|data:|tel:|#|\/\/)/i; const DOWNLOAD_EXTENSIONS = /\.(?:docx|doc|pdf|xlsx|xls|pptx|ppt|zip|rar|7z|csv)$/i; export function isFilesystemPublicReference(raw) { const value = String(raw ?? '').trim(); if (!value) return false; if (SKIP_REFERENCE.test(value)) return false; // /api/... and /MindSpace/... are server routes, not on-disk siblings of public HTML. if (value.startsWith('/')) return false; return true; } /** @deprecated use isFilesystemPublicReference */ export function isRelativePublicReference(raw) { return isFilesystemPublicReference(raw); } export function isDownloadLikeReference(raw, source = '') { if (!isFilesystemPublicReference(raw)) return false; const ref = normalizeReferencePath(raw); if (DOWNLOAD_EXTENSIONS.test(ref)) return true; return /\bdownload\b/i.test(String(source ?? '')); } export function normalizeReferencePath(raw) { const trimmed = String(raw ?? '') .trim() .split('#')[0] .split('?')[0] .trim(); if (!trimmed) return ''; try { return decodeURIComponent(trimmed); } catch { return trimmed; } } export function resolveReferencePath(htmlDir, raw) { const ref = normalizeReferencePath(raw); if (!ref) return null; return path.resolve(htmlDir, ref); } function parseMindspaceCoverPaths(contentAttr) { const paths = []; if (!contentAttr) return paths; let parsed = null; try { parsed = JSON.parse(contentAttr); } catch { for (const key of ['cover', 'image']) { const match = contentAttr.match(new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`)); if (match?.[1]) paths.push(match[1]); } return paths; } for (const key of ['cover', 'image']) { if (parsed?.[key]) paths.push(String(parsed[key])); } return paths; } export function collectPublicHtmlReferences(html, htmlDir, { downloadsOnly = false } = {}) { const refs = []; const seen = new Set(); const add = (raw, source) => { if (downloadsOnly && !isDownloadLikeReference(raw, source)) return; if (!downloadsOnly && !isFilesystemPublicReference(raw)) return; const ref = normalizeReferencePath(raw); if (!ref) return; const key = `${source}\0${ref}`; if (seen.has(key)) return; seen.add(key); refs.push({ ref, source, resolvedPath: resolveReferencePath(htmlDir, ref), }); }; for (const match of String(html ?? '').matchAll(/\b(?:href|src)\s*=\s*["']([^"']+)["']/gi)) { add(match[1], match[0]); } if (!downloadsOnly) { for (const match of String(html ?? '').matchAll( /]*\bname\s*=\s*["']mindspace-cover["'][^>]*>/gi, )) { const tag = match[0]; const contentMatch = tag.match(/\bcontent\s*=\s*"([^"]*)"/i) ?? tag.match(/\bcontent\s*=\s*'([^']*)'/i); for (const coverPath of parseMindspaceCoverPaths(contentMatch?.[1])) { add(coverPath, 'mindspace-cover'); } } } return refs; } export function findMissingPublicHtmlReferences(htmlPath, html = null, options = {}) { const resolvedHtmlPath = path.resolve(htmlPath); const content = html ?? fs.readFileSync(resolvedHtmlPath, 'utf8'); const htmlDir = path.dirname(resolvedHtmlPath); const missing = []; const seen = new Set(); for (const item of collectPublicHtmlReferences(content, htmlDir, options)) { const key = item.ref; if (seen.has(key)) continue; seen.add(key); if (!item.resolvedPath || !fs.existsSync(item.resolvedPath)) { missing.push({ htmlPath: resolvedHtmlPath, ...item }); } } return missing; } export function listPublicHtmlFiles(publishRoot, { userId = null } = {}) { const root = path.resolve(publishRoot); if (!fs.existsSync(root)) return []; const userDirs = userId ? [path.join(root, userId)] : fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => path.join(root, d.name)); const files = []; for (const userDir of userDirs) { const publicDir = path.join(userDir, 'public'); if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) continue; for (const name of fs.readdirSync(publicDir)) { if (!name.toLowerCase().endsWith('.html')) continue; files.push(path.join(publicDir, name)); } } return files.sort(); } export function scanPublicHtmlLinks(publishRoot, options = {}) { const issues = []; for (const htmlPath of listPublicHtmlFiles(publishRoot, options)) { issues.push(...findMissingPublicHtmlReferences(htmlPath, null, options)); } return issues; }