A framework-agnostic, universal document renderer with optional chunked loading
polyrender.wisp.place/
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}