A framework-agnostic, universal document renderer with optional chunked loading polyrender.wisp.place/
at main 156 lines 4.8 kB view raw
1import type { PolyRenderOptions, DocumentFormat } from '../types.js' 2import { BaseRenderer } from '../renderer.js' 3import { el, toText, fetchAsBuffer, requirePeerDep, getLanguageFromExtension } from '../utils.js' 4 5interface HighlightJS { 6 highlight(code: string, options: { language: string }): { value: string } 7 highlightAuto(code: string): { value: string; language: string } 8 getLanguage(name: string): unknown 9} 10 11/** 12 * Renders source code and structured text (JSON, XML, YAML, Markdown, HTML) 13 * with syntax highlighting via highlight.js, line numbers, and optional word wrap. 14 * 15 * Falls back to plain text rendering if highlight.js is not installed. 16 */ 17export class CodeRenderer extends BaseRenderer { 18 readonly format: DocumentFormat = 'code' 19 20 private codeContainer!: HTMLElement 21 private codeBody!: HTMLElement 22 private hljs: HighlightJS | null = null 23 private wordWrap = false 24 25 protected async onMount(viewport: HTMLElement, options: PolyRenderOptions): Promise<void> { 26 this.showLoading('Loading file…') 27 28 // Try to load highlight.js (optional peer dep) 29 try { 30 this.hljs = await requirePeerDep<HighlightJS>('highlight.js', 'code') 31 } catch { 32 this.hljs = null // Fallback to plain text 33 } 34 35 const text = await this.loadText(options) 36 this.hideLoading() 37 38 const codeOpts = options.code ?? {} 39 const showLineNumbers = codeOpts.lineNumbers !== false 40 const wordWrap = codeOpts.wordWrap === true 41 42 // Detect language 43 const language = codeOpts.language 44 ?? this.detectLanguage(options) 45 ?? undefined 46 47 // Highlight 48 let highlightedHtml: string 49 if (this.hljs && language && this.hljs.getLanguage(language)) { 50 highlightedHtml = this.hljs.highlight(text, { language }).value 51 } else if (this.hljs) { 52 const auto = this.hljs.highlightAuto(text) 53 highlightedHtml = auto.value 54 } else { 55 highlightedHtml = this.escapeHtml(text) 56 } 57 58 // Build DOM 59 this.codeContainer = el('div', 'dv-code-container') 60 viewport.appendChild(this.codeContainer) 61 62 const lines = text.split('\n') 63 64 // Line numbers gutter 65 if (showLineNumbers) { 66 const gutter = el('div', 'dv-code-gutter') 67 gutter.setAttribute('aria-hidden', 'true') 68 for (let i = 1; i <= lines.length; i++) { 69 const lineNum = el('div', 'dv-code-gutter-line') 70 lineNum.textContent = String(i) 71 gutter.appendChild(lineNum) 72 } 73 this.codeContainer.appendChild(gutter) 74 } 75 76 // Code body 77 this.wordWrap = wordWrap 78 const body = el('pre', `dv-code-body${wordWrap ? ' dv-word-wrap' : ''}`) 79 const codeEl = document.createElement('code') 80 if (language) codeEl.className = `language-${language}` 81 codeEl.innerHTML = highlightedHtml 82 if (codeOpts.tabSize) { 83 body.style.tabSize = String(codeOpts.tabSize) 84 } 85 body.appendChild(codeEl) 86 this.codeContainer.appendChild(body) 87 this.codeBody = body 88 89 this.setReady({ 90 format: 'code', 91 pageCount: 1, 92 filename: this.getFilename(options), 93 }) 94 } 95 96 private async loadText(options: PolyRenderOptions): Promise<string> { 97 const source = options.source 98 if (source.type === 'file') return toText(source.data) 99 if (source.type === 'url') { 100 const buffer = await fetchAsBuffer(source.url, source.fetchOptions) 101 return new TextDecoder('utf-8').decode(buffer) 102 } 103 return '' 104 } 105 106 private detectLanguage(options: PolyRenderOptions): string | null { 107 // From explicit format 108 const format = options.format 109 if (format && format !== 'code') { 110 const map: Record<string, string> = { 111 json: 'json', 112 xml: 'xml', 113 html: 'html', 114 markdown: 'markdown', 115 md: 'markdown', 116 } 117 if (map[format]) return map[format] 118 } 119 120 // From filename 121 const source = options.source 122 const name = ('filename' in source ? source.filename : undefined) 123 ?? (source.type === 'url' ? source.url : undefined) 124 if (name) { 125 const lang = getLanguageFromExtension(name) 126 if (lang) return lang 127 } 128 129 return null 130 } 131 132 private escapeHtml(text: string): string { 133 return text 134 .replace(/&/g, '&amp;') 135 .replace(/</g, '&lt;') 136 .replace(/>/g, '&gt;') 137 .replace(/"/g, '&quot;') 138 } 139 140 private getFilename(options: PolyRenderOptions): string | undefined { 141 const source = options.source 142 if ('filename' in source && source.filename) return source.filename 143 if (source.type === 'url') return source.url.split('/').pop()?.split('?')[0] 144 return undefined 145 } 146 147 toggleWrap(): boolean { 148 this.wordWrap = !this.wordWrap 149 this.codeBody.classList.toggle('dv-word-wrap', this.wordWrap) 150 return this.wordWrap 151 } 152 153 protected onDestroy(): void { 154 this.hljs = null 155 } 156}