A framework-agnostic, universal document renderer with optional chunked loading polyrender.wisp.place/
at main 319 lines 11 kB view raw
1import type { DocumentSource, DocumentFormat, PolyRenderError } from './types.js' 2import { PolyRenderError as DVError } from './types.js' 3 4// --------------------------------------------------------------------------- 5// DOM Helpers 6// --------------------------------------------------------------------------- 7 8/** Create an element with optional class and attributes. */ 9export function el<K extends keyof HTMLElementTagNameMap>( 10 tag: K, 11 className?: string, 12 attrs?: Record<string, string>, 13): HTMLElementTagNameMap[K] { 14 const element = document.createElement(tag) 15 if (className) element.className = className 16 if (attrs) { 17 for (const [k, v] of Object.entries(attrs)) { 18 element.setAttribute(k, v) 19 } 20 } 21 return element 22} 23 24/** Create an SVG icon from a path string (16x16 viewBox). */ 25export function svgIcon(pathD: string): SVGSVGElement { 26 const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') 27 svg.setAttribute('viewBox', '0 0 16 16') 28 svg.setAttribute('fill', 'none') 29 svg.setAttribute('stroke', 'currentColor') 30 svg.setAttribute('stroke-width', '1.5') 31 svg.setAttribute('stroke-linecap', 'round') 32 svg.setAttribute('stroke-linejoin', 'round') 33 const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') 34 path.setAttribute('d', pathD) 35 svg.appendChild(path) 36 return svg 37} 38 39/** Common SVG icon paths (16x16 coordinate space). */ 40export const icons = { 41 chevronLeft: 'M10 3 L5 8 L10 13', 42 chevronRight: 'M6 3 L11 8 L6 13', 43 zoomIn: 'M7.5 3v9M3 7.5h9M12.5 12.5 L15 15', 44 zoomOut: 'M3 7.5h9M12.5 12.5 L15 15', 45 fitWidth: 'M1 4h14M1 12h14M4 1v3M4 12v3M12 1v3M12 12v3', 46 fullscreen: 'M2 5V2h3M11 2h3v3M14 11v3h-3M5 14H2v-3', 47 download: 'M8 2v8M4 7l4 4 4-4M3 13h10', 48 wrapToggle: 'M2 4h12M2 9h6M13 7v2a2 2 0 0 1-2 2H5m2-2L5 11l2 2', 49} as const 50 51/** Remove all child nodes from an element. */ 52export function clearElement(el: HTMLElement): void { 53 while (el.firstChild) el.removeChild(el.firstChild) 54} 55 56 57// --------------------------------------------------------------------------- 58// Format Detection 59// --------------------------------------------------------------------------- 60 61const EXTENSION_MAP: Record<string, DocumentFormat> = { 62 // Comic book archives 63 cbz: 'comic', 64 cbr: 'comic', 65 cb7: 'comic', 66 cbt: 'comic', 67 cba: 'comic', 68 pdf: 'pdf', 69 epub: 'epub', 70 docx: 'docx', 71 doc: 'docx', 72 odt: 'odt', 73 ods: 'ods', 74 csv: 'csv', 75 tsv: 'tsv', 76 txt: 'text', 77 text: 'text', 78 md: 'markdown', 79 markdown: 'markdown', 80 html: 'html', 81 htm: 'html', 82 json: 'json', 83 xml: 'xml', 84 svg: 'xml', 85 // Common code extensions 86 js: 'code', jsx: 'code', ts: 'code', tsx: 'code', mjs: 'code', cjs: 'code', 87 py: 'code', rb: 'code', rs: 'code', go: 'code', java: 'code', kt: 'code', 88 c: 'code', h: 'code', cpp: 'code', hpp: 'code', cc: 'code', 89 cs: 'code', swift: 'code', m: 'code', 90 php: 'code', pl: 'code', r: 'code', lua: 'code', zig: 'code', 91 sh: 'code', bash: 'code', zsh: 'code', fish: 'code', ps1: 'code', 92 sql: 'code', graphql: 'code', gql: 'code', 93 yaml: 'code', yml: 'code', toml: 'code', ini: 'code', env: 'code', 94 dockerfile: 'code', makefile: 'code', 95 css: 'code', scss: 'code', sass: 'code', less: 'code', 96 vue: 'code', svelte: 'code', astro: 'code', 97 hs: 'code', elm: 'code', ex: 'code', exs: 'code', erl: 'code', 98 clj: 'code', cljs: 'code', lisp: 'code', scm: 'code', 99 dart: 'code', scala: 'code', groovy: 'code', 100 proto: 'code', thrift: 'code', 101 tf: 'code', hcl: 'code', 102 sol: 'code', move: 'code', 103 wasm: 'code', wat: 'code', 104} 105 106const MIME_MAP: Record<string, DocumentFormat> = { 107 // Comic book archives 108 'application/vnd.comicbook+zip': 'comic', 109 'application/vnd.comicbook-rar': 'comic', 110 'application/x-cbr': 'comic', 111 'application/x-cbz': 'comic', 112 'application/x-cb7': 'comic', 113 'application/x-cbt': 'comic', 114 'application/pdf': 'pdf', 115 'application/epub+zip': 'epub', 116 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', 117 'application/msword': 'docx', 118 'application/vnd.oasis.opendocument.text': 'odt', 119 'application/vnd.oasis.opendocument.spreadsheet': 'ods', 120 'text/csv': 'csv', 121 'text/tab-separated-values': 'tsv', 122 'text/plain': 'text', 123 'text/markdown': 'markdown', 124 'text/html': 'html', 125 'application/json': 'json', 126 'application/xml': 'xml', 127 'text/xml': 'xml', 128 'image/svg+xml': 'xml', 129 'application/javascript': 'code', 130 'text/javascript': 'code', 131 'application/typescript': 'code', 132 'text/x-python': 'code', 133 'text/x-rust': 'code', 134 'text/x-go': 'code', 135 'text/x-java-source': 'code', 136 'text/x-c': 'code', 137 'text/x-c++src': 'code', 138 'text/css': 'code', 139 'text/x-yaml': 'code', 140 'text/x-toml': 'code', 141 'text/x-shellscript': 'code', 142 'application/x-sh': 'code', 143 'text/x-sql': 'code', 144} 145 146/** Map file extension to highlight.js language identifier. */ 147const EXTENSION_TO_LANGUAGE: Record<string, string> = { 148 js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript', 149 ts: 'typescript', tsx: 'typescript', 150 py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', 151 kt: 'kotlin', c: 'c', h: 'c', cpp: 'cpp', hpp: 'cpp', cc: 'cpp', 152 cs: 'csharp', swift: 'swift', m: 'objectivec', 153 php: 'php', pl: 'perl', r: 'r', lua: 'lua', 154 sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', ps1: 'powershell', 155 sql: 'sql', graphql: 'graphql', 156 yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini', 157 dockerfile: 'dockerfile', makefile: 'makefile', 158 css: 'css', scss: 'scss', sass: 'scss', less: 'less', 159 html: 'html', htm: 'html', xml: 'xml', svg: 'xml', 160 json: 'json', md: 'markdown', markdown: 'markdown', 161 vue: 'html', svelte: 'html', astro: 'html', 162 hs: 'haskell', elm: 'elm', ex: 'elixir', exs: 'elixir', erl: 'erlang', 163 clj: 'clojure', cljs: 'clojure', lisp: 'lisp', scm: 'scheme', 164 dart: 'dart', scala: 'scala', groovy: 'groovy', 165 proto: 'protobuf', tf: 'hcl', sol: 'solidity', 166} 167 168/** Extract file extension from a filename or URL path. */ 169export function getExtension(filenameOrUrl: string): string { 170 const clean = filenameOrUrl.split('?')[0].split('#')[0] 171 const lastSlash = clean.lastIndexOf('/') 172 const basename = lastSlash >= 0 ? clean.slice(lastSlash + 1) : clean 173 const dot = basename.lastIndexOf('.') 174 if (dot < 0) return basename.toLowerCase() // no extension, use whole name (e.g., "Makefile") 175 return basename.slice(dot + 1).toLowerCase() 176} 177 178/** Detect document format from a source descriptor. */ 179export function detectFormat(source: DocumentSource): DocumentFormat | null { 180 // Pages and chunked sources have explicit types 181 if (source.type === 'pages') return 'pages' 182 if (source.type === 'chunked') return 'chunked-pdf' 183 184 // Try MIME type first 185 const mime = 'mimeType' in source ? source.mimeType : undefined 186 if (mime && MIME_MAP[mime]) return MIME_MAP[mime] 187 188 // Try filename / URL extension 189 let name: string | undefined 190 if ('filename' in source) name = source.filename 191 if (!name && source.type === 'url') name = source.url 192 193 if (name) { 194 const ext = getExtension(name) 195 if (EXTENSION_MAP[ext]) return EXTENSION_MAP[ext] 196 } 197 198 return null 199} 200 201/** Get highlight.js language from file extension. */ 202export function getLanguageFromExtension(filename: string): string | undefined { 203 const ext = getExtension(filename) 204 return EXTENSION_TO_LANGUAGE[ext] 205} 206 207/** Determine which underlying renderer format to use. Some formats alias to the same renderer. */ 208export function getRendererFormat(format: DocumentFormat): DocumentFormat { 209 switch (format) { 210 case 'markdown': 211 case 'html': 212 case 'json': 213 case 'xml': 214 // These are rendered as code with language-specific highlighting 215 return 'code' 216 case 'tsv': 217 return 'csv' 218 case 'chunked-pdf': 219 return 'chunked-pdf' 220 case 'pages': 221 return 'pages' 222 default: 223 return format 224 } 225} 226 227 228// --------------------------------------------------------------------------- 229// Data Conversion 230// --------------------------------------------------------------------------- 231 232/** Convert various binary types to ArrayBuffer. */ 233export async function toArrayBuffer( 234 data: Blob | ArrayBuffer | Uint8Array, 235): Promise<ArrayBuffer> { 236 if (data instanceof ArrayBuffer) return data 237 if (data instanceof Uint8Array) return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer 238 return data.arrayBuffer() 239} 240 241/** Convert various binary types to Blob. */ 242export function toBlob( 243 data: Blob | ArrayBuffer | Uint8Array, 244 mimeType = 'application/octet-stream', 245): Blob { 246 if (data instanceof Blob) return data 247 return new Blob([data as BlobPart], { type: mimeType }) 248} 249 250/** Read binary data as UTF-8 text. */ 251export async function toText(data: Blob | ArrayBuffer | Uint8Array): Promise<string> { 252 if (data instanceof Blob) return data.text() 253 const decoder = new TextDecoder('utf-8') 254 return decoder.decode(data instanceof ArrayBuffer ? data : data.buffer) 255} 256 257/** Fetch a URL as ArrayBuffer with optional custom fetch options. */ 258export async function fetchAsBuffer( 259 url: string, 260 options?: RequestInit, 261): Promise<ArrayBuffer> { 262 const response = await fetch(url, options) 263 if (!response.ok) { 264 throw new DVError( 265 'SOURCE_LOAD_FAILED', 266 `Failed to fetch ${url}: ${response.status} ${response.statusText}`, 267 ) 268 } 269 return response.arrayBuffer() 270} 271 272 273// --------------------------------------------------------------------------- 274// Peer Dependency Loading 275// --------------------------------------------------------------------------- 276 277/** 278 * Attempt a dynamic import and throw a helpful error if the module is missing. 279 * Each renderer calls this for its peer dependency. 280 */ 281export async function requirePeerDep<T>( 282 moduleName: string, 283 formatName: string, 284): Promise<T> { 285 try { 286 const mod = await import(/* @vite-ignore */ moduleName) 287 return mod as T 288 } catch { 289 throw new DVError( 290 'PEER_DEPENDENCY_MISSING', 291 `The "${moduleName}" package is required to render ${formatName} files. ` + 292 `Install it with: npm install ${moduleName}`, 293 ) 294 } 295} 296 297 298// --------------------------------------------------------------------------- 299// Misc 300// --------------------------------------------------------------------------- 301 302/** Clamp a number between min and max. */ 303export function clamp(value: number, min: number, max: number): number { 304 return Math.max(min, Math.min(max, value)) 305} 306 307/** Debounce a function. */ 308export function debounce<T extends (...args: unknown[]) => void>( 309 fn: T, 310 ms: number, 311): T & { cancel(): void } { 312 let timer: ReturnType<typeof setTimeout> 313 const debounced = ((...args: unknown[]) => { 314 clearTimeout(timer) 315 timer = setTimeout(() => fn(...args), ms) 316 }) as T & { cancel(): void } 317 debounced.cancel = () => clearTimeout(timer) 318 return debounced 319}