import { requireConverter } from "./convert/client.ts"; import { mountArchive } from "./renderers/archive.ts"; import { mountConverted } from "./renderers/converted.ts"; import { mountDocx } from "./renderers/docx.ts"; import { mountOpenDocument, mountPresentation } from "./renderers/office.ts"; import { mountRtf } from "./renderers/rtf.ts"; import { mountCode } from "./renderers/code.ts"; import { mountHtml } from "./renderers/html.ts"; import { mountImage } from "./renderers/image.ts"; import { mountMedia } from "./renderers/media.ts"; import { mountJson } from "./renderers/json.ts"; import { mountPdf } from "./renderers/pdf.ts"; import { mountSvg } from "./renderers/svg.ts"; import { mountSheet } from "./renderers/sheet.ts"; import { mountTable } from "./renderers/table.ts"; import { mountXml } from "./renderers/xml.ts"; import { mountZip } from "./renderers/zip.ts"; import { loadOptional } from "./optional/load.ts"; import type { Renderer, ViewerKind } from "./types.ts"; const MB = 1024 * 1024; /** Terminal rung of the ladder: metadata and a download link, never a preview. */ export const downloadRenderer: Renderer = { kind: "binary", exts: [], tier: 1, component: "DownloadCard", maxInlineBytes: 0, }; const builtins: Renderer[] = [ { kind: "markup", exts: [".html", ".htm"], tier: 2, component: "HtmlViewer", maxInlineBytes: 2 * MB, mount: mountHtml, }, { kind: "image", exts: [], tier: 1, component: "ImageViewer", maxInlineBytes: 32 * MB, streaming: true, mount: mountImage, }, { kind: "pdf", exts: [], tier: 1, component: "PdfViewer", maxInlineBytes: 32 * MB, streaming: true, mount: mountPdf, }, ...(["audio", "video"] as const).map((kind) => ({ kind, exts: [], tier: 1 as const, component: "MediaViewer", maxInlineBytes: 64 * MB, streaming: true, mount: mountMedia, })), // Tier 2: in-package parsers, no runtime dependency. Extension-specific, so a // parse failure falls through to the source view below. { kind: "sheet" as const, exts: [".csv", ".tsv"], tier: 2 as const, component: "TableViewer", maxInlineBytes: 8 * MB, mount: mountTable, }, { kind: "code" as const, exts: [".json"], tier: 2 as const, component: "JsonViewer", maxInlineBytes: 8 * MB, mount: mountJson, }, { kind: "code" as const, exts: [".xml"], tier: 2 as const, component: "XmlViewer", maxInlineBytes: 8 * MB, mount: mountXml, }, { kind: "vector" as const, exts: [], tier: 2 as const, component: "SvgViewer", maxInlineBytes: 2 * MB, mount: mountSvg, }, { kind: "archive" as const, exts: [".tar", ".gz", ".tgz"], tier: 2 as const, component: "ArchiveViewer", maxInlineBytes: 64 * MB, mount: mountArchive, }, ...(["text", "code", "markup"] as const).map((kind) => ({ kind, exts: [], tier: 1 as const, component: "CodeViewer", maxInlineBytes: 2 * MB, mount: mountCode, })), ]; /** * Tier 2 renderers backed by an optional dependency. `load` imports the runtime * asset the plugin only publishes when the package is installed, so a missing * dependency is reported the same way a missing converter is: skip and move on. */ builtins.push( { kind: "sheet", exts: [".xls", ".xlsx", ".ods"], tier: 2, component: "SheetViewer", maxInlineBytes: 32 * MB, load: (options) => loadOptional("viewer-sheet", options?.baseURI), mount: mountSheet, }, { kind: "archive", exts: [".zip"], tier: 2, component: "ZipViewer", maxInlineBytes: 64 * MB, load: (options) => loadOptional("viewer-zip", options?.baseURI), mount: mountZip, }, ); /** * Tier 3: the browser cannot render these at all, so they are handed to a * server-side converter. Each carries a `load` that asks the server once * whether that format is actually convertible here; when conversion is not * enabled the load fails and the ladder moves on to the download card. */ const CONVERTIBLE: [ViewerKind, string[]][] = [ ["doc", [".doc", ".docx", ".dotx", ".odt", ".rtf", ".ppt", ".pptx", ".ppsx", ".potx", ".odp"]], ["sheet", [".xls", ".xlsx", ".ods"]], ["binary", [".psd", ".ai", ".tif", ".tiff"]], ["video", [".avi", ".mkv"]], ["archive", [".zip", ".rar", ".7z"]], ]; for (const [kind, exts] of CONVERTIBLE) for (const ext of exts) builtins.push({ kind, exts: [ext], tier: 3, component: "ConvertedViewer", // The preview comes from the conversion endpoint, so the original bytes // are never needed here: streaming keeps the runtime from downloading a // whole design file or video into memory only to discard it. streaming: true, maxInlineBytes: Number.POSITIVE_INFINITY, load: (options) => requireConverter([ext], options), mount: mountConverted, }); /** * In-package office readers. Each recovers content and structure, not a page * layout, so all of them sit *behind* the converter's PDF and render only when * no converter is reachable — the same trade the DOCX path already makes. * * `.ods` is the exception in spirit rather than order: a spreadsheet is data, * and the table view is the better reading of it, so it is registered ahead of * the converter alongside `.xlsx`. */ builtins.push( { kind: "doc", exts: [".docx"], tier: 2, component: "DocxViewer", maxInlineBytes: 32 * MB, load: (options) => loadOptional("viewer-docx", options?.baseURI), mount: mountDocx, }, { kind: "doc", exts: [".pptx", ".ppsx", ".potx"], tier: 2, component: "SlidesViewer", maxInlineBytes: 64 * MB, load: (options) => loadOptional("viewer-office", options?.baseURI), mount: mountPresentation, }, { kind: "doc", // `.ods` is here as well as on the spreadsheet reader: SheetJS handles most // OpenDocument workbooks, and this is the ladder's next rung when it cannot. exts: [".odt", ".odp", ".ods"], tier: 2, component: "OpenDocumentViewer", maxInlineBytes: 64 * MB, load: (options) => loadOptional("viewer-office", options?.baseURI), mount: mountOpenDocument, }, { // No dependency and no converter: RTF is text once the control words go. kind: "doc", exts: [".rtf"], tier: 2, component: "RtfViewer", maxInlineBytes: 16 * MB, mount: mountRtf, }, ); export class RendererRegistry { private renderers: Renderer[] = [...builtins]; fork(): RendererRegistry { const registry = new RendererRegistry(); registry.renderers = [...this.renderers]; return registry; } register(renderer: Renderer): () => void { const entry = { ...renderer, exts: renderer.exts.map((ext) => "." + ext.replace(/^\./, "").toLowerCase()), }; this.renderers.unshift(entry); return () => { this.renderers = this.renderers.filter((item) => item !== entry); }; } /** Extension-specific renderers outrank kind-wide ones. */ candidates(kind: ViewerKind, ext: string): Renderer[] { return [ ...this.renderers.filter((item) => item.exts.includes(ext)), ...this.renderers.filter((item) => item.kind === kind && item.exts.length === 0), ]; } } export const defaultRegistry = new RendererRegistry(); export function registerRenderer(renderer: Renderer): () => void { return defaultRegistry.register(renderer); }