Files
WRNexusJS/packages/viewer/src/registry.ts
T

260 lines
7.3 KiB
TypeScript

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);
}