A framework-agnostic, universal document renderer with optional chunked loading
polyrender.wisp.place/
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, '&')
135 .replace(/</g, '<')
136 .replace(/>/g, '>')
137 .replace(/"/g, '"')
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}