Ship file viewer, editor improvements, and private package releases

This commit is contained in:
Clintchiz committed 2026-09-09 11:20:11 +05:30
1 parent 645914955c
commit a4a31032b9
158 files changed
+10523 -137

No files matched your search

+3 -2
View File
@@ -1,11 +1,12 @@
{
"name": "@wrnexus/block-editor",
"version": "0.1.15",
"version": "0.1.18",
"type": "module",
"description": "Hydrated accessible editor runtime for WRNexus block documents.",
"exports": {
".": "./src/index.ts",
"./runtime": "./src/runtime.ts"
"./runtime": "./src/runtime.ts",
"./source-editor": "./src/source-editor.ts"
},
"files": [
"src",
+12 -18
View File
@@ -17,6 +17,12 @@ export function showBuilderBlockActions(canvas: Document, id: string, label: str
canvas.querySelector("[data-builder-block-actions]")?.remove();
const block = Array.from(canvas.querySelectorAll<HTMLElement>("[data-block-id]")).find(item => item.dataset.blockId === id);
if (!block) return;
if (!canvas.querySelector('[data-builder-selection-style]')) {
const style = canvas.createElement('style');
style.dataset.builderSelectionStyle = '';
style.textContent = '.wrn-builder-selected[data-block-type="content.button"]{outline:none!important}.wrn-builder-selected[data-block-type="content.button"] a{outline:2px solid var(--wrn-builder-accent);outline-offset:3px}';
canvas.head.append(style);
}
const toolbar = canvas.createElement("div");
toolbar.dataset.builderBlockActions = "";
toolbar.setAttribute("role", "toolbar");
@@ -53,31 +59,19 @@ export function showBuilderBlockActions(canvas: Document, id: string, label: str
}
// Keep controls outside presentation tables so browser table repair cannot move them.
canvas.body.prepend(toolbar);
const bounds = block.getBoundingClientRect();
const surface = block.dataset.blockType === 'content.button' ? block.querySelector('a') ?? block : block;
const bounds = surface.getBoundingClientRect();
toolbar.style.top = Math.max(0, bounds.top + (canvas.defaultView?.scrollY ?? 0) - toolbar.offsetHeight) + "px";
toolbar.style.left = Math.max(0, Math.min(bounds.right - toolbar.offsetWidth, canvas.documentElement.clientWidth - toolbar.offsetWidth)) + "px";
block.setAttribute("aria-label", label + " selected");
}
/** Text remains a string, not pasted HTML. The owner validates every committed value. */
export function editBuilderText(canvas: Document, id: string, value: string, update: (text: string) => void, finish: () => void): boolean {
/** Inline formatting is returned as semantic runs, never raw HTML. */
export function editBuilderText(canvas: Document, id: string, value: string, update: (text: string, runs?: BuilderTextRun[]) => void, finish: () => void, runs: BuilderTextRun[] = []): boolean {
const block = Array.from(canvas.querySelectorAll<HTMLElement>("[data-block-id]")).find(item => item.dataset.blockId === id);
const element = block?.querySelector<HTMLElement>("h1,h2,h3,h4,p");
if (!element) return false;
element.textContent = value;
element.contentEditable = "plaintext-only";
element.setAttribute("role", "textbox");
element.setAttribute("aria-label", "Edit block text");
element.style.whiteSpace = "pre-wrap";
element.addEventListener("input", () => update(element.innerText));
element.addEventListener("keydown", event => {
event.stopPropagation();
if (event.key === "Escape") { event.preventDefault(); element.blur(); }
});
element.addEventListener("blur", () => {
element.contentEditable = "false";
finish();
}, { once: true });
element.focus({ preventScroll: true });
mountInlineRichText(canvas, element, value, update, finish, runs);
return true;
}
import { mountInlineRichText, type BuilderTextRun } from "./rich-text.ts";
+83
View File
@@ -0,0 +1,83 @@
export interface BuilderTextRun { text: string; bold?: boolean; italic?: boolean; underline?: boolean }
/** Read only allowlisted inline semantics; pasted HTML and executable content are never stored. */
export function readBuilderText(element: HTMLElement): BuilderTextRun[] {
const runs: BuilderTextRun[] = [];
const append = (text: string, marks: Omit<BuilderTextRun, "text">) => {
if (!text) return;
const last = runs.at(-1);
if (last && !!last.bold === !!marks.bold && !!last.italic === !!marks.italic && !!last.underline === !!marks.underline) last.text += text;
else runs.push({text, ...marks});
};
const walk = (node: Node, marks: Omit<BuilderTextRun, "text">) => {
if (node.nodeType === 3) { append(node.textContent ?? "", marks); return; }
if (node.nodeType !== 1) return;
const el = node as HTMLElement, tag = el.tagName;
if (["SCRIPT", "STYLE", "IFRAME", "OBJECT"].includes(tag)) return;
if (tag === "BR") { append("\n", marks); return; }
if (["DIV", "P"].includes(tag) && runs.length && !runs.at(-1)!.text.endsWith("\n")) append("\n", marks);
const next = {...marks};
if (["B", "STRONG"].includes(tag) || ["bold", "700"].includes(el.style.fontWeight)) next.bold = true;
if (["I", "EM"].includes(tag) || el.style.fontStyle === "italic") next.italic = true;
if (tag === "U" || el.style.textDecoration.includes("underline")) next.underline = true;
el.childNodes.forEach(child => walk(child, next));
};
element.childNodes.forEach(node => walk(node, {}));
return runs;
}
export function mountInlineRichText(canvas: Document, element: HTMLElement, value: string, update: (text: string, runs?: BuilderTextRun[]) => void, finish: () => void, runs: BuilderTextRun[] = []): void {
element.replaceChildren();
const initial = runs.map(run => run.text).join("") === value ? runs : [{text: value}];
for (const run of initial) {
let child: Node = canvas.createTextNode(run.text);
for (const [mark, tag] of [[run.underline, "u"], [run.italic, "em"], [run.bold, "strong"]] as const) {
if (mark) { const wrapper = canvas.createElement(tag); wrapper.append(child); child = wrapper; }
}
element.append(child);
}
element.contentEditable = "true";
element.setAttribute("role", "textbox");
element.setAttribute("aria-label", "Edit rich text");
element.setAttribute("aria-multiline", "true");
element.style.whiteSpace = "pre-wrap";
const block = element.closest<HTMLElement>('[data-block-id]');
block?.classList.add('wrn-inline-editing');
const editingStyle = canvas.createElement('style');
editingStyle.textContent = '.wrn-inline-editing{outline:none!important}.wrn-inline-editing>[data-block-handle]{visibility:hidden!important}.wrn-inline-editing [contenteditable="true"]{outline:2px solid var(--wrn-builder-accent)!important;outline-offset:4px;border-radius:2px;cursor:text}';
canvas.head.append(editingStyle);
const toolbar = canvas.createElement("div");
toolbar.dataset.builderBlockActions = "rich-text";
toolbar.setAttribute("role", "toolbar");
toolbar.setAttribute("aria-label", "Text formatting");
toolbar.style.cssText = "position:fixed;z-index:1000;display:flex;gap:4px;padding:6px;border-radius:8px;background:var(--wrn-builder-accent,#2563eb);color:white;box-shadow:0 4px 16px #0003;font:14px Arial";
canvas.querySelectorAll('[data-builder-block-actions]').forEach(item => item.remove());
canvas.body.append(toolbar);
const position = () => { const box = element.getBoundingClientRect(); toolbar.style.top = Math.max(4, box.top - 44) + "px"; toolbar.style.left = Math.max(4, Math.min(box.left, canvas.documentElement.clientWidth - toolbar.offsetWidth - 4)) + "px"; };
const emit = () => { const result = readBuilderText(element); update(result.map(run => run.text).join(""), result); };
let selection: Range | null = null;
const remember = () => { const current = canvas.getSelection(); if (current?.rangeCount && element.contains(current.anchorNode) && element.contains(current.focusNode)) selection = current.getRangeAt(0).cloneRange(); };
const restore = () => { element.focus({preventScroll:true}); if (selection) {const current = canvas.getSelection(); current?.removeAllRanges(); current?.addRange(selection);} };
const abort = new (canvas.defaultView!.AbortController)();
const options = {signal: abort.signal};
let done = false;
const close = () => { if (done) return; done = true; abort.abort(); toolbar.remove(); editingStyle.remove(); block?.classList.remove('wrn-inline-editing'); element.contentEditable = "false"; finish(); };
for (const [label, command] of [["Bold", "bold"], ["Italic", "italic"], ["Underline", "underline"], ["Clear formatting", "removeFormat"], ["Done", "done"]]) {
const button = canvas.createElement("button");
button.type = "button"; button.textContent = label; button.setAttribute("aria-label", label!);
button.style.cssText = "background:transparent;color:inherit;border:0;border-radius:4px;padding:6px;cursor:pointer;font:inherit";
button.addEventListener("mousedown", event => event.preventDefault(), options);
button.addEventListener("click", event => {event.stopPropagation(); if (command === "done") { close(); return; } restore(); canvas.execCommand(command!); remember(); emit();}, options);
toolbar.append(button);
}
element.addEventListener("input", emit, options);
element.addEventListener("paste", event => {event.preventDefault(); canvas.execCommand("insertText", false, event.clipboardData?.getData("text/plain") ?? ""); emit();}, options);
element.addEventListener("drop", event => event.preventDefault(), options);
element.addEventListener("keydown", event => {event.stopPropagation(); if(event.key === "Escape") {event.preventDefault(); close();}}, options);
toolbar.addEventListener("keydown", event => {event.stopPropagation(); if(event.key === "Escape") close();}, options);
canvas.addEventListener("selectionchange", remember, options);
canvas.addEventListener("focusin", event => {if (!element.contains(event.target as Node) && !toolbar.contains(event.target as Node)) close();}, options);
element.addEventListener("blur", () => {setTimeout(() => {if (!toolbar.contains(canvas.activeElement) && canvas.activeElement !== element) close();}, 0);}, options);
canvas.defaultView?.addEventListener("scroll", position, options);
element.focus({preventScroll:true}); position();
}
+78 -25
View File
@@ -1,37 +1,90 @@
/** Read-only derived output. Loading this entry does not load CodeMirror until opened. */
export async function mountBuilderSourceEditor(parent: HTMLElement, language: "text" | "xml", content: string) {
const [{ EditorView, basicSetup }, { EditorState, Compartment }, { xml }, { HighlightStyle, syntaxHighlighting }, { tags }] = await Promise.all([
import("codemirror"), import("@codemirror/state"), import("@codemirror/lang-xml"),
import("@codemirror/language"), import("@lezer/highlight"),
export async function mountBuilderSourceEditor(
parent: HTMLElement,
language: "text" | "xml",
content: string,
options: { label?: string; theme?: "tokens" } = {},
) {
const [
{ EditorView, basicSetup },
{ EditorState, Compartment },
{ xml },
{ HighlightStyle, syntaxHighlighting },
{ tags },
] = await Promise.all([
import("codemirror"),
import("@codemirror/state"),
import("@codemirror/lang-xml"),
import("@codemirror/language"),
import("@lezer/highlight"),
]);
if (!parent.isConnected) return null;
const stopShortcut = (event: KeyboardEvent) => event.stopPropagation();
parent.addEventListener("keydown", stopShortcut);
const wrapping = new Compartment();
const view = new EditorView({ parent, doc: content, extensions: [
basicSetup, EditorState.readOnly.of(true), EditorView.editable.of(false),
EditorView.contentAttributes.of({ tabindex: "0", "aria-label": "Generated email source" }),
language === "xml" ? xml() : [], wrapping.of([]),
syntaxHighlighting(HighlightStyle.define([
{ tag: tags.tagName, color: "#7dd3fc" }, { tag: tags.attributeName, color: "#c4b5fd" },
{ tag: tags.attributeValue, color: "#a7f3d0" }, { tag: tags.angleBracket, color: "#94a3b8" },
{ tag: tags.comment, color: "#94a3b8" },
])),
EditorView.theme({
"&": { height: "100%", fontSize: "12px", backgroundColor: "#111827", color: "#e5e7eb" },
".cm-scroller": { overflow: "auto", fontFamily: "ui-monospace, Consolas, monospace" },
".cm-gutters": { backgroundColor: "#111827", color: "#94a3b8", borderColor: "#334155" },
".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "transparent" },
"&.cm-focused": { outline: "2px solid var(--wrn-color-primary)", outlineOffset: "-2px" },
}),
] });
const color = (token: string, legacy: string) =>
options.theme === "tokens" ? `var(--wrn-color-${token})` : legacy;
const view = new EditorView({
parent,
doc: content,
extensions: [
basicSetup,
EditorState.readOnly.of(true),
EditorView.editable.of(false),
EditorView.contentAttributes.of({
tabindex: "0",
"aria-label": options.label ?? "Generated email source",
}),
language === "xml" ? xml() : [],
wrapping.of([]),
syntaxHighlighting(
HighlightStyle.define([
{ tag: tags.tagName, color: color("primary", "#7dd3fc") },
{ tag: tags.attributeName, color: color("text", "#c4b5fd") },
{ tag: tags.attributeValue, color: color("primary", "#a7f3d0") },
{ tag: tags.angleBracket, color: color("muted", "#94a3b8") },
{ tag: tags.comment, color: color("muted", "#94a3b8") },
]),
),
EditorView.theme({
"&": {
height: "100%",
fontSize: "12px",
backgroundColor: color("surface", "#111827"),
color: color("text", "#e5e7eb"),
},
".cm-scroller": { overflow: "auto", fontFamily: "ui-monospace, Consolas, monospace" },
".cm-gutters": {
backgroundColor: color("surface", "#111827"),
color: color("muted", "#94a3b8"),
borderColor: color("border", "#334155"),
},
".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "transparent" },
"&.cm-focused": { outline: "2px solid var(--wrn-color-primary)", outlineOffset: "-2px" },
}),
],
});
let destroyed = false;
const observer = new MutationObserver(() => { if (!parent.isConnected) destroy(); });
function destroy() { if (destroyed) return; destroyed = true; observer.disconnect(); parent.removeEventListener("keydown", stopShortcut); view.destroy(); }
const observer = new MutationObserver(() => {
if (!parent.isConnected) destroy();
});
function destroy() {
if (destroyed) return;
destroyed = true;
observer.disconnect();
parent.removeEventListener("keydown", stopShortcut);
view.destroy();
}
observer.observe(parent.ownerDocument.documentElement, { childList: true, subtree: true });
return {
update(value: string) { if (!destroyed && view.state.doc.toString() !== value) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } }); },
wrap(enabled: boolean) { if (!destroyed) view.dispatch({ effects: wrapping.reconfigure(enabled ? EditorView.lineWrapping : []) }); },
update(value: string) {
if (!destroyed && view.state.doc.toString() !== value)
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
},
wrap(enabled: boolean) {
if (!destroyed)
view.dispatch({ effects: wrapping.reconfigure(enabled ? EditorView.lineWrapping : []) });
},
destroy,
};
}
@@ -0,0 +1,26 @@
import { expect, test } from "bun:test";
import { Window } from "happy-dom";
import { readBuilderText, mountInlineRichText } from "../src/rich-text.ts";
test("inline rich text extracts only supported semantics", () => {
const window = new Window();
const element = window.document.createElement("p");
element.innerHTML = 'Hello <strong>bold <em>and italic</em></strong><br><u>line</u><script>bad()</script>';
expect(readBuilderText(element as unknown as HTMLElement)).toEqual([{text:"Hello "},{text:"bold ",bold:true},{text:"and italic",bold:true,italic:true},{text:"\n"},{text:"line",underline:true}]);
});
test("rich editor commits semantic input and cleans up on Done", () => {
const window = new Window();
const element = window.document.createElement("p");
window.document.body.append(element);
const updates: unknown[] = []; let finished = 0;
mountInlineRichText(window.document as unknown as Document, element as unknown as HTMLElement, "hello", (text, runs) => updates.push({text,runs}), () => finished++);
expect(element.contentEditable).toBe("true");
element.innerHTML = "<strong>bold</strong>";
element.dispatchEvent(new window.Event("input"));
expect(updates).toEqual([{text:"bold",runs:[{text:"bold",bold:true}]}]);
(window.document.querySelector('button[aria-label="Done"]') as any).click();
expect(finished).toBe(1);
expect(element.contentEditable).toBe("false");
expect(window.document.querySelector('[role="toolbar"]')).toBeNull();
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.8.65",
"version": "0.8.66",
"type": "module",
"main": "src/index.ts",
"exports": {
+1
View File
@@ -118,6 +118,7 @@ Thumbs.db
"@wrnexus/ui": "${scaffoldFrameworkRange}",
"@wrnexus/uploader": "${scaffoldFrameworkRange}",
"@wrnexus/validation": "${scaffoldFrameworkRange}",
"@wrnexus/viewer": "${scaffoldFrameworkRange}",
"@wrnexus/authz": "${scaffoldFrameworkRange}"
},
"devDependencies": {
+10
View File
@@ -851,6 +851,16 @@ const MIGRATIONS: Migration[] = [
// no application source rewrite is required.
},
},
{
version: "0.8.66",
id: "0.8.66-file-viewer-and-framing",
description:
"Adds the optional file viewer to new applications and allows same-origin framing by default; applications can retain DENY through their header configuration.",
apply() {
// Existing applications opt into the viewer explicitly. Header policy is
// configurable, so no application source rewrite is required.
},
},
{
version: "0.9.0",
id: "remove-dead-config-keys",
+1
View File
@@ -85,6 +85,7 @@ test("scaffoldApp includes the complete v0.8 configuration and starter structure
"@wrnexus/ui",
"@wrnexus/uploader",
"@wrnexus/validation",
"@wrnexus/viewer",
]) {
expect(pkg.dependencies[packageName]).toBe(scaffoldFrameworkRange);
}
+1 -1
View File
@@ -224,7 +224,7 @@ broadcasts and `toUser` cross the bridge.
Config types: `SecurityConfig` (top-level), `CorsConfig`/`CorsOrigin`,
`ContentSecurityPolicyConfig`/`CspDirectiveValue`, `HstsConfig`,
`TrustedTypesConfig`, `PermissionsPolicyConfig`. WrNexus applies sensible
defaults (self-only CSP, `frame-ancestors 'none'`, restrictive Permissions-Policy,
defaults (self-only CSP, `frame-ancestors 'self'`, restrictive Permissions-Policy,
HSTS in production, Trusted Types in production); each is individually
overridable or disable-able via `false`.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.8.12",
"version": "0.8.13",
"type": "module",
"main": "src/index.ts",
"exports": {
+4 -3
View File
@@ -91,7 +91,7 @@ export interface SecurityConfig {
trustedTypes?: false | TrustedTypesConfig;
/** Defaults to "same-origin". */
crossOriginOpenerPolicy?: false | "same-origin" | "same-origin-allow-popups" | "unsafe-none";
/** Defaults to "DENY". */
/** Defaults to "SAMEORIGIN". */
frameOptions?: false | "DENY" | "SAMEORIGIN";
/** Defaults to "strict-origin-when-cross-origin". */
referrerPolicy?: false | string;
@@ -116,7 +116,7 @@ const DEFAULT_CSP: Record<string, string[]> = {
"connect-src": ["'self'", "ws:", "wss:"],
"object-src": ["'none'"],
"base-uri": ["'self'"],
"frame-ancestors": ["'none'"],
"frame-ancestors": ["'self'"],
"form-action": ["'self'"],
};
@@ -248,7 +248,7 @@ function applyBaseSecurityHeaders(
): void {
headers.set("X-Content-Type-Options", "nosniff");
const frameOptions = security?.frameOptions ?? "DENY";
const frameOptions = security?.frameOptions ?? "SAMEORIGIN";
if (frameOptions !== false) headers.set("X-Frame-Options", frameOptions);
const coop = security?.crossOriginOpenerPolicy ?? "same-origin";
@@ -369,6 +369,7 @@ function serializeCsp(mode: Mode, security?: SecurityConfig, nonce?: string): st
for (const [name, value] of Object.entries(DEFAULT_CSP)) {
directives.set(name, [...value]);
}
if (security?.frameOptions === "DENY") directives.set("frame-ancestors", ["'none'"]);
if (mode === "development") {
directives.set("script-src", ["'self'", "'unsafe-inline'"]);
} else {
+21
View File
@@ -0,0 +1,21 @@
import { expect, test } from "bun:test";
import { withSecurityHeaders } from "../src/headers.ts";
test("default framing permits self but not cross-origin ancestors", () => {
const headers = withSecurityHeaders(
new Request("https://app.test"),
new Response("ok"),
"production",
).headers;
expect(headers.get("x-frame-options")).toBe("SAMEORIGIN");
expect(headers.get("content-security-policy")).toContain("frame-ancestors 'self'");
});
test("explicit DENY keeps both framing policies restrictive", () => {
const headers = withSecurityHeaders(
new Request("https://app.test"),
new Response("ok"),
"production",
{ frameOptions: "DENY" },
).headers;
expect(headers.get("x-frame-options")).toBe("DENY");
expect(headers.get("content-security-policy")).toContain("frame-ancestors 'none'");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.8.21",
"version": "0.8.23",
"private": true,
"type": "module",
"main": "./src/index.ts",
+6 -2
View File
@@ -49,8 +49,12 @@ function runnerFor(client: BunSqlClient, dialect: Dialect): TxHandle {
/** Create a Bun.sql-backed driver for the given connection URL + dialect. */
export function bunSql(url: string, dialect: Dialect): Driver {
const Ctor = (Bun as unknown as { SQL: new (u: string) => BunSqlClient }).SQL;
const client = new Ctor(url);
const max = Number(process.env.WRN_DB_POOL_MAX || 10);
const idleTimeout = Number(process.env.WRN_DB_IDLE_TIMEOUT || 30);
if (!Number.isInteger(max) || max < 1 || max > 100 || !Number.isInteger(idleTimeout) || idleTimeout < 0)
throw new Error('Invalid WRN_DB_POOL_MAX or WRN_DB_IDLE_TIMEOUT');
const Ctor = (Bun as unknown as { SQL: new (u: {url:string;max:number;idleTimeout:number}) => BunSqlClient }).SQL;
const client = new Ctor({url,max,idleTimeout});
const runner = runnerFor(client, dialect);
return {
dialect,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.8.61",
"version": "0.8.62",
"type": "module",
"main": "src/index.ts",
"exports": {
+6
View File
@@ -15,11 +15,17 @@ const CONTENT_TYPES: Record<string, string> = {
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".m4a": "audio/mp4",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".ogg": "audio/ogg",
".pdf": "application/pdf",
".png": "image/png",
".svg": "image/svg+xml",
".txt": "text/plain; charset=utf-8",
".wasm": "application/wasm",
".wav": "audio/wav",
".webm": "video/webm",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/email-renderer",
"version": "0.1.47",
"version": "0.1.50",
"type": "module",
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
"exports": {
+9 -7
View File
@@ -12,6 +12,7 @@ import {
type ValueSchema,
} from "@wrnexus/blocks";
import { brandColor, brandFont, brandWidth } from "./brand.ts";
import { richTextSchema, renderRichText } from "./rich-text.ts";
import { emailStyleValue } from "./style-values.ts";
const styles = record(json, { maxKeys: 50 }) as never;
@@ -332,19 +333,20 @@ export const headingBlock = leaf(
"Heading",
"icon-[lucide--heading]",
object({
richText: richTextSchema,
text: string({ min: 1, max: 500, default: "Heading" }),
level: choice(["1", "2", "3", "4"] as const, { default: "2" }),
}) as never,
{
emailHtml: ({ node }) =>
emailCell(
`<h${attrs(node).level} style="font-family:inherit;margin:0 0 16px;">${html(attrs(node).text)}</h${attrs(node).level}>`,
`<h${attrs(node).level} style="font-family:inherit;margin:0 0 16px;">${renderRichText(attrs(node))}</h${attrs(node).level}>`,
node,
),
ampEmail: ({ node }) =>
emailCell(`<h${attrs(node).level}>${html(attrs(node).text)}</h${attrs(node).level}>`, node),
emailCell(`<h${attrs(node).level}>${renderRichText(attrs(node))}</h${attrs(node).level}>`, node),
mjml: ({ node }) =>
`<mj-text font-size="24px" font-weight="700">${html(attrs(node).text)}</mj-text>`,
`<mj-text font-size="24px" font-weight="700">${renderRichText(attrs(node))}</mj-text>`,
},
({ node }) => `${String(attrs(node).text)}\n\n`,
);
@@ -353,15 +355,15 @@ export const textBlock = leaf(
"content.text",
"Text",
"icon-[lucide--text]",
object({ text: string({ max: 200_000, default: "" }) }) as never,
object({ richText: richTextSchema, text: string({ max: 200_000, default: "" }) }) as never,
{
emailHtml: ({ node }) =>
emailCell(
`<p style="font-family:inherit;line-height:1.5;margin:0 0 16px;">${multilineText(attrs(node).text)}</p>`,
`<p style="font-family:inherit;line-height:1.5;margin:0 0 16px;">${renderRichText(attrs(node))}</p>`,
node,
),
ampEmail: ({ node }) => emailCell(`<p>${multilineText(attrs(node).text)}</p>`, node),
mjml: ({ node }) => `<mj-text>${multilineText(attrs(node).text)}</mj-text>`,
ampEmail: ({ node }) => emailCell(`<p>${renderRichText(attrs(node))}</p>`, node),
mjml: ({ node }) => `<mj-text>${renderRichText(attrs(node))}</mj-text>`,
},
({ node }) => `${String(attrs(node).text)}\n\n`,
);
+89 -54
View File
@@ -1,66 +1,101 @@
import type { BlockNode, JsonObject } from "@wrnexus/blocks";
/** Designed compositions made entirely from native, editable email blocks. */
export function createSectionPreset(name: string, id: () => string): BlockNode {
const node = (
type: string,
props: JsonObject,
slots: Record<string, BlockNode[]> = {},
): BlockNode => ({
id: id(),
type,
version: 1,
props,
styles: {},
slots,
const node = (type: string, props: JsonObject, slots: Record<string, BlockNode[]> = {}, appearance: Record<string, string> = {}): BlockNode => ({
id: id(), type, version: 1, props,
styles: Object.fromEntries(Object.entries(appearance).map(([key, base]) => [key, {base}])), slots,
});
const text = (value: string) => node("content.text", { text: value });
const heading = (value: string) => node("content.heading", { text: value, level: "2" });
const button = (label: string, href: string) => node("content.button", { label, href });
let content: BlockNode[];
if (name === "welcome")
content = [
heading("Welcome, {{recipientName|friend}}!"),
text("We’re glad you’re here. Discover what’s new and find your next favorite."),
button("Explore now", "https://example.com"),
const text = (value: string, appearance: Record<string, string> = {}) =>
node("content.text", {text: value}, {}, {fontSize: "16px", color: "#475569", ...appearance});
const heading = (value: string, appearance: Record<string, string> = {}) =>
node("content.heading", {text: value, level: "2"}, {}, {fontSize: "22px", color: "#14213d", ...appearance});
const eyebrow = (value: string, color = "#0f766e") => text(value, {fontSize: "11px", fontWeight: "700", color});
const button = (label: string, background = "#14213d", color = "#ffffff", href = "https://example.com") =>
node("content.button", {label, href}, {}, {backgroundColor: background, color, padding: "14px 24px", borderRadius: "8px", fontSize: "14px", fontWeight: "700"});
const space = (height = "16") => node("content.spacer", {height});
const column = (content: BlockNode[], width = "100%") => node("layout.column", {width}, {content});
const row = (columns: BlockNode[]) => node("layout.columns", {}, {columns});
let rows: BlockNode[], background = "#ffffff", padding = "40px";
if (name === "welcome") {
background = "#edf7f3";
rows = [row([column([
eyebrow("A FRESH START"),
heading("Good things start here.", {fontSize: "26px"}),
text("Welcome, {{recipientName|friend}}. A little inspiration, thoughtful discoveries, and more of what you love — all in one place."),
space("8"), button("Make yourself at home", "#0f766e"),
space("24"), text("YOUR NEXT CHAPTER / STARTS TODAY", {fontSize: "10px", color: "#0f766e", fontWeight: "700"}),
])])];
} else if (name === "feature") {
background = "#f4f1eb";
rows = [row([
column([
text("THE EDIT", {fontSize: "11px", fontWeight: "700", color: "#9a3412"}),
heading("Less noise.\nMore possibility.", {fontSize: "23px"}),
text("Meet your next everyday favorite. Thoughtfully made, beautifully simple, and ready for whatever comes next.", {fontSize: "14px"}),
space("8"), button("Discover the collection", "#9a3412"),
], "65%"),
column([
text("01", {fontSize: "64px", fontWeight: "700", color: "#9a3412", textAlign: "center", backgroundColor: "#eadfd1", padding: "24px 8px"}),
text("THOUGHTFULLY\nCURATED", {fontSize: "11px", fontWeight: "700", textAlign: "center", color: "#9a3412", padding: "16px 8px"}),
], "35%"),
])];
} else if (name === "announcement") {
background = "#14213d";
rows = [row([column([
eyebrow("JUST RELEASED", "#a7f3d0"),
heading("Something new.\nMade for you.", {fontSize: "28px", color: "#ffffff"}),
text("A fresh perspective. A better experience. Discover the latest chapter and see what we’ve been working on.", {color: "#cbd5e1"}),
space("12"), button("Take a first look", "#a7f3d0", "#14213d"),
space("20"), text("BE THE FIRST TO EXPLORE", {fontSize: "10px", color: "#a7f3d0", fontWeight: "700"}),
])])];
} else if (name === "testimonial") {
background = "#fff7ed";
rows = [row([column([
eyebrow("FROM OUR COMMUNITY", "#9a3412"),
text("“", {fontSize: "64px", color: "#c2410c", fontWeight: "700"}),
heading("The details make\nall the difference.", {fontSize: "23px", color: "#431407"}),
text("“A thoughtful experience from start to finish. Everything felt effortless — and the result was even better than I imagined.”", {color: "#7c2d12"}),
space("12"), text("CUSTOMER NAME", {fontSize: "12px", fontWeight: "700", color: "#431407"}),
text("Customer story · Replace with an approved quote", {fontSize: "11px", color: "#9a3412"}),
])])];
} else if (name === "two-column") {
rows = [
row([column([eyebrow("A LITTLE INSPIRATION"), heading("Two ways to find your next favorite."), space("12")])]),
row([
column([
text("01 / DISCOVER", {fontSize: "11px", fontWeight: "700", color: "#0f766e", backgroundColor: "#edf7f3", padding: "20px 16px"}),
heading("Fresh perspectives", {fontSize: "16px", padding: "16px 16px 0"}),
text("Ideas and inspiration to make every day feel a little different.", {fontSize: "14px", padding: "0 16px"}),
button("Explore ideas", "#0f766e"),
], "50%"),
column([
text("02 / EXPLORE", {fontSize: "11px", fontWeight: "700", color: "#9a3412", backgroundColor: "#fff7ed", padding: "20px 16px"}),
heading("Thoughtful essentials", {fontSize: "16px", padding: "16px 16px 0"}),
text("Considered details. Everyday favorites. Find something that fits.", {fontSize: "14px", padding: "0 16px"}),
button("Find your favorite", "#9a3412"),
], "50%"),
]),
];
else if (name === "feature")
content = [
node("content.image", {
src: "https://placehold.co/600x300",
alt: "Replace with your featured image",
width: "600",
height: "300",
decorative: false,
}),
heading("Something worth sharing"),
text("Introduce your product, tell your story, or share your latest announcement."),
button("Learn more", "https://example.com"),
];
else if (name === "footer")
content = [
} else if (name === "footer") {
background = "#f1f5f9";
padding = "32px 40px";
rows = [row([column([
eyebrow("LET’S STAY CONNECTED", "#334155"),
heading("Thanks for being here.", {fontSize: "18px"}),
text("Good things are better shared. We’re glad you’re part of our community.", {fontSize: "13px"}),
node("content.divider", {}),
text("Thank you for being part of our community."),
text("{{organizationAddress|Add your organization address}}"),
button("Unsubscribe", "{{unsubscribeUrl|https://example.com/unsubscribe}}"),
];
else if (name === "announcement")
content = [heading("Big news, just for you"), text("Share your latest launch, update, or upcoming event with your community."), button("See what’s new", "https://example.com")];
else if (name === "testimonial")
content = [heading("What our customers say"), text("“A thoughtful experience from start to finish. We couldn’t be happier.”"), text("— Customer name, Company")];
else if (name === "two-column") {
const columns = ["First highlight", "Second highlight"].map(title => node("layout.column", { width: "50%" }, { content: [heading(title), text("Describe a benefit, share an update, or introduce a service."), button("Learn more", "https://example.com")] }));
return node("layout.section", { background: "#ffffff", padding: "24px" }, { content: [node("layout.columns", {}, { columns })] });
}
else throw new Error("Unknown section preset.");
const column = node("layout.column", { width: "100%" }, { content });
const columns = node("layout.columns", {}, { columns: [column] });
return node("layout.section", { background: "#ffffff", padding: "24px" }, { content: [columns] });
text("{{organizationAddress|Add your organization address}}", {fontSize: "12px", color: "#64748b"}),
text("You’re receiving this email because you signed up for our updates.", {fontSize: "11px", color: "#64748b"}),
button("Unsubscribe", "#e2e8f0", "#334155", "{{unsubscribeUrl|https://example.com/unsubscribe}}"),
])])];
} else throw new Error("Unknown section preset.");
return node("layout.section", {background, padding}, {content: rows});
}
/** Native, editable section presets; no HTML snapshots. */
export const emailPresets = ["welcome", "feature", "announcement", "testimonial", "two-column", "footer"].map(id => ({
id,
name: { key: "email.presets." + id, fallback: id[0].toUpperCase() + id.slice(1) },
name: {key: "email.presets." + id, fallback: id[0].toUpperCase() + id.slice(1)},
icon: "icon-[lucide--layout-template]",
create: (context: { id(): string }) => createSectionPreset(id, context.id),
create: (context: {id(): string}) => createSectionPreset(id, context.id),
}));
+26
View File
@@ -0,0 +1,26 @@
import type { ValueSchema } from "@wrnexus/blocks";
export interface TextRun { text: string; bold?: boolean; italic?: boolean; underline?: boolean }
export const richTextSchema: ValueSchema<TextRun[]> = {
kind: "rich-text",
parse(value = [], path = "richText") {
if (!Array.isArray(value) || value.length > 4000 || value.some(run => !run || typeof run !== "object" || typeof run.text !== "string" || Object.keys(run).some(key => !["text", "bold", "italic", "underline"].includes(key)) || ["bold", "italic", "underline"].some(key => run[key] !== undefined && typeof run[key] !== "boolean")) || value.reduce((size, run) => size + run.text.length, 0) > 200000)
return { ok: false, errors: [{code: "email.rich-text", message: "Invalid or oversized rich text.", severity: "error", path}] };
return { ok: true, value };
},
};
const escape = (value: string) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;").replace(/\r\n?|\n/g, "<br />");
/** Ignore stale formatting if a plain-text inspector or importer changed the text. */
export function renderRichText(props: Record<string, unknown>): string {
const parsed = richTextSchema.parse(props.richText);
const text = String(props.text ?? "");
if (!parsed.ok || !parsed.value.length || parsed.value.map(run => run.text).join("") !== text) return escape(text);
return parsed.value.map(run => {
let result = escape(run.text);
if (run.underline) result = `<u>${result}</u>`;
if (run.italic) result = `<em>${result}</em>`;
if (run.bold) result = `<strong>${result}</strong>`;
return result;
}).join("");
}
@@ -0,0 +1,21 @@
import { expect, test } from "bun:test";
import { createSectionPreset, emailPresets } from "../src/presets.ts";
import { renderEmailPreview } from "../src/browser.ts";
import type { BlockDocument } from "@wrnexus/blocks";
test("every designed preset renders natively without remote placeholder assets", async () => {
for (const preset of emailPresets) {
let id = 0;
const section = createSectionPreset(preset.id, () => `preset-${++id}`);
const document: BlockDocument = {schemaVersion:1,kind:"email",meta:{subject:"Preset QA"},theme:{tokens:{}},root:{id:"root",type:"layout.email-document",version:1,props:{width:"600"},styles:{},slots:{content:[section]}}};
const before = JSON.stringify(document);
const result = await renderEmailPreview(document, {variables:{recipientName:"Alex",organizationAddress:"Company address",unsubscribeUrl:"https://example.com/unsubscribe"}});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.html).not.toContain("placehold.co");
expect(result.value.html).toContain(String(section.props.background));
expect(result.value.text.length).toBeGreaterThan(80);
}
expect(JSON.stringify(document)).toBe(before);
}
});
@@ -0,0 +1,10 @@
import { expect, test } from "bun:test";
import { renderRichText, richTextSchema } from "../src/rich-text.ts";
test("semantic text is escaped, bounded, and rejects arbitrary markup properties", () => {
expect(renderRichText({text:"<hello>\nworld",richText:[{text:"<hello>",bold:true},{text:"\nworld",italic:true,underline:true}]})).toBe("<strong>&lt;hello&gt;</strong><em><u><br />world</u></em>");
expect(richTextSchema.parse([{text:"hello",html:"<script>"}]).ok).toBe(false);
expect(richTextSchema.parse([{text:"hello",bold:"yes"}]).ok).toBe(false);
expect(richTextSchema.parse([{text:"a".repeat(200001)}]).ok).toBe(false);
expect(renderRichText({text:"Changed",richText:[{text:"Old",bold:true}]})).toBe("Changed");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/plugin",
"version": "0.8.8",
"version": "0.8.9",
"type": "module",
"main": "src/index.ts",
"exports": {
+2
View File
@@ -356,6 +356,8 @@ export async function discoverPlugins(
if (!declared) continue;
const manifest = pluginManifest(declared);
if (manifest.autoDiscover === false) continue;
// Skip before importing or invoking the factory: it may mutate package state.
if (explicitNames.has(packageJson?.name ?? name)) continue;
try {
const plugin = await loadPlugin(packageRoot, manifest);
enforcePermissions(name, packageManifest, modulePermissions(plugin), options);
+40
View File
@@ -5,6 +5,46 @@ import { join } from "node:path";
import { createPluginRunner, discoverPlugins, resolvePlugins } from "../src/index.ts";
import { parse } from "@wrnexus/syntax";
test("an explicitly configured package never imports its automatic factory", async () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-suppressed-factory-"));
const app = join(root, "apps", "web");
const pkg = join(root, "packages", "stateful");
try {
mkdirSync(app, { recursive: true });
mkdirSync(pkg, { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({ private: true, workspaces: ["apps/*", "packages/*"] }),
);
writeFileSync(
join(app, "package.json"),
JSON.stringify({ name: "web", dependencies: { "@test/stateful": "workspace:*" } }),
);
writeFileSync(
join(pkg, "package.json"),
JSON.stringify({
name: "@test/stateful",
type: "module",
wrnexus: { plugin: { plugin: "./factory.ts", factory: true } },
}),
);
writeFileSync(
join(pkg, "factory.ts"),
'throw new Error("Automatic factory imported"); export default () => ({name:"@test/stateful"});',
);
const explicit = { name: "@test/stateful" };
const result = await discoverPlugins(app, [explicit], { strict: true });
expect(resolvePlugins(result).map((plugin) => plugin.name)).toContain(explicit.name);
await expect(discoverPlugins(app, [], { strict: true })).rejects.toThrow(
"Automatic factory imported",
);
} finally {
// mkdtemp creates this directory directly under the explicitly named temp root.
if (root.startsWith(join(tmpdir(), "wrnexus-suppressed-factory-")))
rmSync(root, { recursive: true, force: true });
}
});
describe("plugin ordering", () => {
test("orders pre, normal, and post plugins", () => {
expect(
+437
View File
@@ -0,0 +1,437 @@
# @wrnexus/viewer
Complete implementation of the
[file viewer plan](../../docs/plans/2026-09-08-file-viewer-design.md).
Add `@wrnexus/viewer` to an app's dependencies. Its package plugin discovers the component,
stylesheet and browser runtime automatically. Explicit plugin configuration can import
`viewerPlugin` from `@wrnexus/viewer/plugin`; do not register it twice.
```wrn
import Viewer from "@wrnexus/viewer/components/Viewer.wrn"
<Viewer src={file.url} name={file.name} />
```
Optional props: `mime`, `size` (byte count as a string), `class`, and `theme` (`auto`, `light`,
`dark`), `files` (an array of source descriptors), and `richPdf` (opt-in canvas rendering). Pinned themes use the host's generated theme tokens. The `/file-viewer` page in
`examples/basic-app` provides local samples for every supported group.
For an upload preview before any network request, mount from the browser:
```ts
import { mountViewer } from "@wrnexus/viewer/runtime";
const dispose = mountViewer(container, { blob: file, name: file.name });
// Before replacing the preview or removing your component:
dispose();
```
## What renders
**Tier 1 — no dependencies.** Native image formats with zoom, rotation, reset and
scroll/drag panning; PDF and audio/video through native controls; UTF-8 text and code
through the block editor's lazy read-only CodeMirror mount, with theme tokens, line numbers
and a wrap toggle. HTML/XML use XML highlighting; other languages use plain mode. A text-only
`<pre>` remains usable if the editor cannot load.
**Tier 2 — in-package parsers, still no runtime dependencies.**
| Files | Preview |
| -------------- | ------------------------------------------------------------------ |
| `.csv`, `.tsv` | Windowed table (RFC 4180 quoting, delimiter detection), raw toggle |
| `.json` | Lazy collapsible tree, raw toggle |
| `.xml` | Lazy element tree with attributes, raw toggle |
| `.svg` | Sanitised markup inside a sandboxed frame, source toggle |
| `.tar`, `.gz` | Entry listing with sizes; gzip is expanded in the browser |
Each Tier 2 renderer is registered per extension and sits _in front of_ the source view for
its kind, so a parse failure — malformed JSON, a `.csv` that is really prose, an unreadable
archive — falls through to the highlighted source rather than erroring.
**Tier 2 with an optional dependency.** Declared as optional peer dependencies and never
installed for you. Each is served as its own runtime asset, contributed only when the package
resolves on the server, and fetched by the browser the first time such a file is opened — an
app that never views a spreadsheet neither builds nor ships `xlsx`.
| Files | Package | Preview |
| --------------- | ------------ | ------------------------------------------------------- |
| `.xls`, `.xlsx` | `xlsx` | Sheet tabs over the same windowed table as CSV |
| `.zip` | `fflate` | Entry listing; the archive is never inflated |
| `.docx` | `mammoth` | Semantic HTML in a sandboxed frame — layout is not kept |
| `.pdf` | `pdfjs-dist` | Opt-in page rasterisation with page count (see below) |
`.docx` sits _behind_ the Tier 3 converter: LibreOffice's PDF keeps the page layout, which
mammoth cannot, so mammoth is the fallback when no converter is reachable. Rich PDF is opt-in
because the native frame costs nothing:
```ts
import { registerRenderer } from "@wrnexus/viewer";
import { richPdfRenderer } from "@wrnexus/viewer/renderers/pdf-rich";
registerRenderer(richPdfRenderer());
```
pdf.js is served from its own published build rather than bundled — putting its ESM entry
through a bundler breaks the worker handshake and `getDocument()` never settles. Page
rendering is driven by `requestAnimationFrame`, which does not run while the document is
hidden, so each page render is bounded by a timeout (20s by default) and a page that will not
draw degrades to the native frame rather than hanging.
**Office documents.** Everything below reads in the browser with no binary on the host. All of
it recovers content and structure, not a faithful page layout, so wherever a converter is
reachable its PDF is preferred and these are the rung beneath it — the exception is
spreadsheets, where the data matters more than the layout and the table view comes first.
| Files | Read by | Preview |
| ------------------------- | ------------------- | ------------------------------------------ |
| `.pptx`, `.ppsx`, `.potx` | in-package + fflate | Slide-by-slide reader with presenter notes |
| `.odt` | in-package + fflate | Headings, paragraphs and lists |
| `.odp` | in-package + fflate | Slide-by-slide reader |
| `.ods` | SheetJS, then ODF | Sheet tabs over the windowed table |
| `.rtf` | in-package | Text, with a Source toggle — no dependency |
| `.docx` | mammoth | Semantic HTML in a sandboxed frame |
| `.xls`, `.xlsx` | SheetJS | Sheet tabs over the windowed table |
`.doc` and `.ppt` are the legacy OLE compound formats and stay converter-only: there is no
honest way to read them in a browser. With LibreOffice installed every format in this table
converts to PDF instead.
**Tier 3 — server-side conversion, opt-in.** `.doc`, `.docx`, `.odt`, `.rtf`, `.ppt`,
`.pptx`, `.xls` and `.xlsx` convert to PDF; `.psd`, `.ai`, `.tif` and `.tiff` to PNG; `.avi`
and `.mkv` to a poster frame; `.zip`, `.rar` and `.7z` to a listing. Disabled by default —
see Conversion below.
HTML opens in a sanitized, fully sandboxed preview with a Source toggle. Markup masquerading as another format stays inert source. Anything with no renderer and no converter displays
a metadata/download fallback, and native playback errors do the same.
## Sources and streaming
Image, media and PDF renderers are `streaming`: for an `http(s)` source the element is
pointed at the original URL, so the browser fetches and decodes it, playback starts without
buffering the whole file here, and the request is governed by the app's own CSP rather than
by a `blob:` URL (which `default-src 'self'` blocks). A local `File`/`Blob` still renders
from an object URL served under its _sniffed_ type.
The PDF renderer uses an iframe. The framework now defaults to `frame-ancestors 'self'`
and `X-Frame-Options: SAMEORIGIN`, so an app can embed its own files. Apps can retain full
framing denial with `security.frameOptions: "DENY"`; explicit CSP directives still win.
## Conversion
```ts
import { viewerPlugin } from "@wrnexus/viewer/plugin";
import { localBinaryConverter } from "@wrnexus/viewer/convert";
plugins: [viewerPlugin({ convert: { converters: [localBinaryConverter()] } })];
```
That mounts `GET /api/viewer/capabilities`, `/preview`, `/thumb` and `/text`. Without the
`convert` option no routes exist and no external process is ever run.
The client asks `capabilities` once per page and only requests a conversion for an extension
the server actually advertises, so a host without the binaries costs one small request and
every affected file keeps its download card. `localBinaryConverter` probes for `soffice`,
`magick`, `ffmpeg` and `7z` on first use; **a missing binary is not an error**.
Converted output is cached in-process, keyed by stable source identity, extension, operation, converter name
and version, and returned with `X-Viewer-Cache: hit|miss`. The source resolver runs before every cache lookup,
so an expired or withdrawn signed link cannot read cached output. Unused source streams are
cancelled on cache hits. Responses are private and no-store.
Apps that store files privately implement the contract themselves:
```ts
viewerPlugin({
convert: {
converters: [myGotenbergConverter],
// Apply your own access checks here; the default accepts same-origin URLs only.
resolveSource: async (request, { src, name }) => ({
body: await openStream(src, name),
identity: objectRevision(src),
}),
maxInputBytes: 32 * 1024 * 1024,
},
});
```
### Converter safety
`soffice`, `magick` and `ffmpeg` are large C/C++ parsers with a long CVE history, pointed at
untrusted files. Every run gets a hard wall-clock timeout, a private scratch directory as its
only writable path, a stripped environment (no proxy or credential variables), no stdin, and
a cap on the bytes read back. Input is size-capped before it reaches a binary. Converter
error messages stay server-side — they can carry file paths — and the client sees only a 422.
The default source policy accepts same-origin URLs and fetches them without credentials, so
the endpoint cannot be used to reach a private network or to read anything the caller could
not already read anonymously.
## Security
Uploaded files are hostile input, so nothing here trusts a filename.
- Classification uses the extension, declared/server MIME, and then up to 4 KiB of signature
bytes. Extension overrides cannot bypass conflicting signatures, and executable names or
signatures always fall back.
- Nothing from a file ever reaches `innerHTML`. Text, tables and trees are built as DOM nodes
with `textContent`.
- The SVG frame is its own document, so theme tokens and `currentColor` cannot reach it and
its base background is opaque. The host's resolved text and surface colours are copied into
the frame by value, which keeps themed artwork legible in light and dark.
- SVG is sanitised to an element/attribute allowlist — no `script`, no `foreignObject`, no
event handlers, and only same-document `#fragment` references — _and_ rendered inside an
`iframe` with `sandbox=""` under `default-src 'none'`. Neither layer is trusted alone.
- A file whose bytes look like active markup is shown as inert source regardless of its name,
including a `.json` or `.png` that carries a `<script>` tag.
## Resolution and limits
`resolve(source)` returns a renderer and ordered fallback list. Signature checks select a
renderer; the browser decoder still validates the complete file.
For remote sources the resolver requests HEAD before a bounded Range read. A server without
HEAD/Range support can still work: the response stream is cancelled at the sniff limit.
Complete preview reads enforce a byte cap even if Content-Length is absent or inaccurate,
and the downloaded body is reclassified before rendering. A single network chunk can be
larger than the cap; excess bytes are never retained as a preview. HTTP(S), blob and data
URLs are supported; remote servers must allow browser CORS access. A kind with no renderer
at all reports that no preview exists rather than a size limit.
Defaults: text/code 2 MiB, SVG 2 MiB, CSV/JSON/XML 8 MiB, images/PDF 32 MiB, audio/video and
archives 64 MiB. Delimited files are additionally capped at 200,000 parsed rows, and only the
visible window of rows is kept in the DOM. Remote native media streams from its original URL; local blobs remain bounded. Disposal aborts pending requests, destroys
editors and revokes object URLs. Automatic mounts observe source changes and framework
navigation.
## Uploader-backed sources
Core is source-agnostic — `src` or `blob` is all it needs, and `@wrnexus/uploader` is an
optional peer dependency. Enabling `uploads` lets a component name a storage key instead:
```wrn
<Viewer key={file.key} store="reports" />
```
```ts
import { viewerPlugin } from "@wrnexus/viewer/plugin";
plugins: [
viewerPlugin({
uploads: {
stores: ["media", "reports"],
secret: process.env.VIEWER_FILE_SECRET!,
ttlMs: 5 * 60 * 1000,
authorize: async (request, { store, key }) => canRead(await currentUser(request), store, key),
},
}),
];
```
That mounts `GET /api/viewer/source` and `GET /api/viewer/file`. The browser exchanges the key
for a descriptor, and only then loads anything:
- A **public** object answers with its own URL — no token, nothing signed, nothing to leak.
- A **private** object answers with a short-lived signed link to the file route, and only
after `authorize` agrees. Signing uses the uploader's own `createSignedFileToken`.
The file route serves `Content-Disposition: inline`, `X-Content-Type-Options: nosniff` and
`Cache-Control: private, no-store` — a per-viewer link must never enter a shared cache — and
answers HEAD and byte ranges so audio and video can seek. An expired, forged, or
withdrawn-store link is answered exactly like a missing one.
There is deliberately **no default access check**: a private store without both a `secret` and
an `authorize` hook is refused rather than served. `stores` is an allow-list; anything outside
it is refused before a key is even looked at, and keys that could escape their store
(`..`, absolute paths, control characters) are rejected outright.
The MIME in the descriptor comes from the uploader's own extension table, not a second copy of
it here — and the viewer still sniffs the bytes before choosing a renderer.
For conversion, `uploadedConversionSource()` makes converted output inherit exactly the access
check of its input: a converter can only open what a valid signed link already grants.
```ts
import { uploadedConversionSource } from "@wrnexus/viewer/uploader";
viewerPlugin({
convert: { converters: [localBinaryConverter()], resolveSource: uploadedConversionSource() },
uploads: {/* … */},
});
```
## Where the preview appears
Inline in a card is the default. Three other placements are built in.
**Over the page.** `openViewer` puts a file in a native `<dialog>`, which takes the top layer,
traps focus, closes on Escape and restores focus to whatever opened it. Closing disposes the
viewer, so a video stops and object URLs are revoked rather than leaking behind the overlay.
```ts
import { openViewer } from "@wrnexus/viewer";
const preview = openViewer({ src: file.url, name: file.name });
// preview.close() when you need to close it yourself.
```
Nothing needs wiring for the common case — any element carrying `data-viewer-open` opens the
file it names:
```wrn
<button type="button" data-viewer-open data-src={file.url} data-name={file.name}>Preview</button>
```
It reads the same attributes the component takes (`data-src`, `data-key`, `data-store`,
`data-name`, `data-mime`, `data-size`, `data-files`), so a storage key or a whole filmstrip
opens the same way.
**Covering the page.** `fullScreen` mounts the viewer over the page with a Close control that
returns it inline. It is a layered overlay rather than the Fullscreen API, so it needs no user
gesture and can be the state a viewer starts in. Toggling the attribute later re-mounts it.
```wrn
<Viewer src={file.url} name={file.name} fullScreen={true} />
```
**A whole page.** `layout="page"` drops the card chrome and fills the container, for a route
given over to one file.
```wrn
<Viewer src={file.url} name={file.name} layout="page" />
```
## Toolbar actions
Every control is an icon button carrying its name in `aria-label`, in a visually
hidden span for screen readers, and in a CSS tooltip on hover and focus. The icons are drawn
as SVG nodes from a small built-in set rather than markup strings, because the framework
enables Trusted Types in production where `innerHTML` is refused.
**Print** is offered on any preview that actually rendered — slides, tables, documents,
images and PDFs — and withheld on the download card, where there is nothing to print. It
prints the preview rather than the page: the viewer is marked and `viewer.css` hides
everything else, while a same-origin PDF frame is asked to print itself. Opt out with
`<Viewer print={false} />` or `mountViewer(host, source, { print: false })`.
Apps add their own actions, either globally or per viewer:
```ts
import { registerViewerAction } from "@wrnexus/viewer";
const unregister = registerViewerAction({
id: "open-in-tab",
label: "Open in new tab",
icon: "preview", // a name from ICONS; falls back to one from the label
placement: "actions", // or "controls", beside the renderer's own buttons
when: ({ source, kind }) => kind === "pdf" && Boolean(source.src),
run: ({ source, content }) => window.open(source.src, "_blank", "noopener"),
});
```
```ts
mountViewer(host, source, {
actions: [{ id: "send", label: "Send for review", run: ({ source }) => share(source) }],
});
```
Actions are offered after every render, so `when` sees the renderer and kind that actually
drew, and a re-render (a Raw/Table toggle, say) never leaves duplicates behind. `run` receives
the `content` element the renderer drew into, so an action can read what is on screen instead
of fetching the file again. An action is offered on the download card too — only its `when`
decides — so "report this file" still works for something nothing can preview.
## Custom renderers
```ts
import { registerRenderer } from "@wrnexus/viewer";
import { mountViewer } from "@wrnexus/viewer/runtime";
const unregister = registerRenderer({
kind: "text",
exts: [".log"],
tier: 2,
component: "LogViewer",
maxInlineBytes: 1024 * 1024,
load: () => import("./log-parser"),
async mount({ parent, file, button }) {
parent.textContent = await file.text();
button("Reload", () => location.reload());
return () => parent.replaceChildren();
},
});
```
Every renderer — built-in and app-defined — receives the same `ViewerMountContext`: the
`parent` container, the `file` blob, the `source` descriptor, the resolved `kind`, a `button`
factory for toolbar controls, an `objectUrl` helper whose URLs are revoked on disposal, a
`degrade` callback that abandons the renderer for the download card, and an abort `signal`.
`component` selects a presentation template when one exists; the mount adapter determines rendering. A renderer without a
`mount` can never be displayed, and the ladder moves to the next candidate.
Bundle registration and manual mounting in the same browser entry. New registrations take
precedence and return an unregister function; isolated `RendererRegistry` instances can be
passed to `resolve`. Failed optional loads move to the next renderer. Custom adapters are
trusted application code and must handle file contents safely.
The parsers are exported for reuse: `parseDelimited`, `detectDelimiter`, `sanitizeSvg`,
`parseTar`, `isTar`, `gzipName`, `gunzip`.
## Source composition
The example accepts both signed uploader objects and anonymous static files:
```ts
import { firstOf, sameOriginSource } from "@wrnexus/viewer/convert";
import { uploadedConversionSource } from "@wrnexus/viewer/uploader";
resolveSource: firstOf(uploadedConversionSource(), sameOriginSource());
```
A resolver returns undefined when it cannot handle a source. Errors stop composition. The
same-origin resolver refuses viewer API links, cross-origin URLs, embedded credentials and
redirects. Uploader identities use the store and object key rather than the rotating token.
For mutable custom sources include a revision in `identity`; otherwise replacement bytes
may remain cached until TTL expiry. The URL remains the fallback identity.
## Presentation components
`ViewerShell` composes `ViewerToolbar`, `DownloadCard`, and renderer templates:
`ImageViewer`, `VectorViewer`, `PdfViewer`, `CodeViewer`, `SheetViewer`, `DocViewer`,
`MediaViewer`, and `ArchiveViewer`. Copy a component into `app/components` with the same
name to override its presentation. Keep its `data-viewer-*` hooks. The browser mounts the
existing safe adapters into `data-viewer-mount`, preserves custom classes, and restores the
shell on disposal. Components are presentation shells; source handling stays with Viewer.
## Filmstrip, waveform and extracted text
```wrn
<Viewer files={[{src: '/photo.png', name: 'photo.png'}, {src: '/report.pdf', name: 'report.pdf'}]} />
<Viewer src="/report.pdf" name="report.pdf" richPdf={true} />
```
Manual mounting accepts the same source array, including local blobs. Up to 500 files are
shown; one preview is active. Arrow keys, Home and End move selection. Switching disposes
media, editors, worker work, object URLs and pending fetches. Image thumbnails use the source;
convertible files request `/api/viewer/thumb` sequentially with a 2 MiB limit. Missing or
non-image thumbnails keep the filename button. No converter is required to use a filmstrip.
Audio has a **Show waveform** action. It decodes on demand, caps the encoded read at 8 MiB,
and accepts known durations up to ten minutes; unsupported or larger files retain native
playback. The waveform combines all channels and includes an accessible seek slider.
Convertible sources have an **Extract text** action. The exported `extractText(source,
{baseURI, signal})` returns at most 2 MiB of plain text for search/index consumers. The action
shows the same text inline and reports unavailable extraction without breaking the preview.
Client conversion and optional-module helpers accept the host base URI for embedded documents
and tests, instead of requiring the global document.
Rich PDF success was verified in ordinary Chrome on 2026-09-09: the canvas visibly painted
text and the renderer reported one completed page. Timeout fallback remains for hidden tabs.
## Accepted limits
The conversion cache is per-process rather than shared, and byte
ranges on a signed link are served only for buffered objects — a streaming driver answers the
whole body, so seeking a large private video re-reads it.
Run `bun test packages/viewer`, `bun run --cwd packages/viewer typecheck`.
@@ -0,0 +1,10 @@
component ArchiveViewer {
props {
class: string = ""
}
view {
<div class="wrn-viewer-renderer wrn-archiveviewer {class}" data-viewer-renderer="ArchiveViewer">
<div data-viewer-mount=""><slot></slot></div>
</div>
}
}
+10
View File
@@ -0,0 +1,10 @@
component CodeViewer {
props {
class: string = ""
}
view {
<div class="wrn-viewer-renderer wrn-codeviewer {class}" data-viewer-renderer="CodeViewer">
<div data-viewer-mount=""><slot></slot></div>
</div>
}
}
+10
View File
@@ -0,0 +1,10 @@
component DocViewer {
props {
class: string = ""
}
view {
<div class="wrn-viewer-renderer wrn-docviewer {class}" data-viewer-renderer="DocViewer">
<div data-viewer-mount=""><slot></slot></div>
</div>
}
}
@@ -0,0 +1,20 @@
component DownloadCard {
props {
reason: string = "Preview unavailable for this file"
class: string = ""
}
view {
<div class="wrn-viewer-download {class}">
<div class="wrn-viewer-download-art" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z" />
<path d="M14 3v5h5" />
<path d="M9.5 13.5l5 5" />
<path d="M14.5 13.5l-5 5" />
</svg>
</div>
<p class="wrn-viewer-download-reason" data-viewer-reason="" role="status">{reason}</p>
<p class="wrn-viewer-download-meta" data-viewer-size=""></p>
</div>
}
}
@@ -0,0 +1,10 @@
component ImageViewer {
props {
class: string = ""
}
view {
<div class="wrn-viewer-renderer wrn-imageviewer {class}" data-viewer-renderer="ImageViewer">
<div data-viewer-mount=""><slot></slot></div>
</div>
}
}
@@ -0,0 +1,10 @@
component MediaViewer {
props {
class: string = ""
}
view {
<div class="wrn-viewer-renderer wrn-mediaviewer {class}" data-viewer-renderer="MediaViewer">
<div data-viewer-mount=""><slot></slot></div>
</div>
}
}
+10
View File
@@ -0,0 +1,10 @@
component PdfViewer {
props {
class: string = ""
}
view {
<div class="wrn-viewer-renderer wrn-pdfviewer {class}" data-viewer-renderer="PdfViewer">
<div data-viewer-mount=""><slot></slot></div>
</div>
}
}
@@ -0,0 +1,10 @@
component SheetViewer {
props {
class: string = ""
}
view {
<div class="wrn-viewer-renderer wrn-sheetviewer {class}" data-viewer-renderer="SheetViewer">
<div data-viewer-mount=""><slot></slot></div>
</div>
}
}
@@ -0,0 +1,10 @@
component VectorViewer {
props {
class: string = ""
}
view {
<div class="wrn-viewer-renderer wrn-vectorviewer {class}" data-viewer-renderer="VectorViewer">
<div data-viewer-mount=""><slot></slot></div>
</div>
}
}
+24
View File
@@ -0,0 +1,24 @@
import ViewerShell from "@wrnexus/viewer/components/ViewerShell.wrn"
component Viewer {
props {
files: unknown[] = []
richPdf: boolean = false
fullScreen: boolean = false
layout: string = "inline"
src: string = ""
key: string = ""
store: string = ""
name: string = ""
mime: string = ""
size: string = ""
theme: string = "auto"
class: string = ""
}
view {
<section class="wrn-viewer {class}" data-theme="{theme === 'auto' ? '' : theme}" data-wrnexus-runtime="viewer" data-files="{JSON.stringify(files)}" data-rich-pdf="{richPdf}" data-full-screen="{fullScreen}" data-layout="{layout}" data-src="{src}" data-key="{key}" data-store="{store}" data-name="{name}" data-mime="{mime}" data-size="{size}" aria-label="{name === '' ? 'File preview' : name}">
<ViewerShell name={name} />
<noscript>Enable JavaScript to preview this file.</noscript>
</section>
}
}
@@ -0,0 +1,31 @@
import ViewerToolbar from "@wrnexus/viewer/components/ViewerToolbar.wrn"
import DownloadCard from "@wrnexus/viewer/components/DownloadCard.wrn"
import ImageViewer from "@wrnexus/viewer/components/ImageViewer.wrn"
import VectorViewer from "@wrnexus/viewer/components/VectorViewer.wrn"
import PdfViewer from "@wrnexus/viewer/components/PdfViewer.wrn"
import CodeViewer from "@wrnexus/viewer/components/CodeViewer.wrn"
import SheetViewer from "@wrnexus/viewer/components/SheetViewer.wrn"
import DocViewer from "@wrnexus/viewer/components/DocViewer.wrn"
import MediaViewer from "@wrnexus/viewer/components/MediaViewer.wrn"
import ArchiveViewer from "@wrnexus/viewer/components/ArchiveViewer.wrn"
component ViewerShell {
props {
name: string = "File"
class: string = ""
}
view {
<div class="wrn-viewer-shell {class}">
<ViewerToolbar name={name} />
<div class="wrn-viewer-content" data-viewer-content=""><p role="status">Loading preview…</p></div>
<template data-viewer-template="DownloadCard"><DownloadCard /></template>
<template data-viewer-template="ImageViewer"><ImageViewer /></template>
<template data-viewer-template="VectorViewer"><VectorViewer /></template>
<template data-viewer-template="PdfViewer"><PdfViewer /></template>
<template data-viewer-template="CodeViewer"><CodeViewer /></template>
<template data-viewer-template="SheetViewer"><SheetViewer /></template>
<template data-viewer-template="DocViewer"><DocViewer /></template>
<template data-viewer-template="MediaViewer"><MediaViewer /></template>
<template data-viewer-template="ArchiveViewer"><ArchiveViewer /></template>
</div>
}
}
@@ -0,0 +1,13 @@
component ViewerToolbar {
props {
name: string = "File"
class: string = ""
}
view {
<div class="wrn-viewer-toolbar {class}" data-viewer-toolbar="" role="toolbar" aria-label="File preview controls">
<span data-viewer-title="">{name}</span>
<span data-viewer-controls=""></span>
<span data-viewer-actions=""></span>
</div>
}
}
+77
View File
@@ -0,0 +1,77 @@
{
"name": "@wrnexus/viewer",
"version": "0.8.9",
"type": "module",
"description": "Read-only file previews with bounded loading and graceful fallback.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./plugin": "./src/plugin.ts",
"./runtime": "./src/runtime.ts",
"./code-editor": "./src/code-editor.ts",
"./optional/sheet": "./src/optional/sheet.ts",
"./optional/docx": "./src/optional/docx.ts",
"./optional/zip": "./src/optional/zip.ts",
"./optional/office": "./src/optional/office.ts",
"./optional/pdf": "./src/optional/pdf.ts",
"./renderers/pdf-rich": "./src/renderers/pdf-rich.ts",
"./viewer.css": "./viewer.css",
"./components/*": "./components/*",
"./convert": "./src/convert/index.ts",
"./uploader": "./src/uploads/index.ts"
},
"files": [
"src",
"components",
"viewer.css",
"README.md"
],
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@wrnexus/block-editor": "workspace:*",
"@wrnexus/plugin": "workspace:*",
"@wrnexus/ui": "workspace:*"
},
"wrnexus": {
"plugin": {
"plugin": "./src/plugin.ts",
"export": "default",
"factory": true
}
},
"peerDependencies": {
"fflate": "*",
"mammoth": "*",
"pdfjs-dist": "*",
"xlsx": "*",
"@wrnexus/uploader": "workspace:*"
},
"peerDependenciesMeta": {
"fflate": {
"optional": true
},
"mammoth": {
"optional": true
},
"pdfjs-dist": {
"optional": true
},
"xlsx": {
"optional": true
},
"@wrnexus/uploader": {
"optional": true
}
},
"devDependencies": {
"fflate": "^0.8.2",
"mammoth": "^1.8.0",
"pdfjs-dist": "^4.10.38",
"xlsx": "^0.18.5",
"@wrnexus/uploader": "workspace:*"
}
}
+60
View File
@@ -0,0 +1,60 @@
import type { Renderer, ViewerSource } from "./types.ts";
/**
* What an action can see when it is offered and when it runs. `content` is the
* element the renderer drew into, so an action can read or print what is on
* screen rather than re-fetching the file.
*/
export interface ViewerActionContext {
host: HTMLElement;
content: HTMLElement;
source: ViewerSource;
/** Absent while the preview is still resolving, or when nothing could render. */
renderer?: Renderer;
kind?: string;
}
export interface ViewerAction {
/** Stable identifier; registering the same id again replaces the earlier one. */
id: string;
label: string;
/** Name from the built-in icon set. Falls back to one derived from the label. */
icon?: string;
/** Toolbar side: alongside the renderer's own controls, or with download. */
placement?: "controls" | "actions";
/** Offered only when this returns true. Defaults to always. */
when?: (context: ViewerActionContext) => boolean;
run: (context: ViewerActionContext) => void | Promise<void>;
}
const registered = new Map<string, ViewerAction>();
/**
* Adds a toolbar action to every viewer in the page. Returns an unregister
* function, so a component can add one for as long as it is mounted.
*
* ```ts
* registerViewerAction({
* id: "open-in-tab",
* label: "Open in new tab",
* when: ({ source }) => Boolean(source.src),
* run: ({ source }) => window.open(source.src, "_blank", "noopener"),
* });
* ```
*/
export function registerViewerAction(action: ViewerAction): () => void {
registered.set(action.id, action);
return () => {
if (registered.get(action.id) === action) registered.delete(action.id);
};
}
/** Globally registered actions, in registration order. */
export function viewerActions(): ViewerAction[] {
return [...registered.values()];
}
/** Testing helper: drops every globally registered action. */
export function clearViewerActions(): void {
registered.clear();
}
+2
View File
@@ -0,0 +1,2 @@
/** Separately served by viewerPlugin; downloaded only when a text preview opens. */
export { mountBuilderSourceEditor } from "@wrnexus/block-editor/source-editor";
+52
View File
@@ -0,0 +1,52 @@
import type { ViewerSource } from "../types.ts";
export const CONVERT_BASE = "/api/viewer";
export interface ConverterClientOptions {
baseURI?: string;
fetch?: typeof fetch;
}
const probes = new Map<string, Promise<Record<string, string>>>();
const base = (value?: string) =>
value ?? (typeof document === "undefined" ? undefined : document.baseURI);
export async function converterFormats(
options: ConverterClientOptions = {},
): Promise<Record<string, string>> {
const baseURI = base(options.baseURI);
if (!baseURI) return Promise.resolve({});
let url: string;
try {
url = new URL(`${CONVERT_BASE}/capabilities`, baseURI).href;
} catch {
return {};
}
const request = () =>
(options.fetch ?? fetch)(url)
.then((response) => (response.ok ? response.json() : { formats: {} }))
.then((body: { formats?: Record<string, string> }) => body.formats ?? {})
.catch(() => ({}));
if (options.fetch) return request();
if (!probes.has(url)) probes.set(url, request());
return probes.get(url)!;
}
export async function requireConverter(
exts: string[],
options: ConverterClientOptions = {},
): Promise<string> {
const formats = await converterFormats(options);
for (const ext of exts) if (formats[ext]) return formats[ext]!;
throw new Error(`No converter for ${exts.join(", ")}`);
}
export function convertUrl(
source: ViewerSource,
operation: "preview" | "thumb" | "text" = "preview",
baseURI?: string,
): string | undefined {
const origin = base(baseURI);
if (!source.src || !origin) return undefined;
const url = new URL(`${CONVERT_BASE}/${operation}`, origin);
url.searchParams.set("src", new URL(source.src, origin).href);
url.searchParams.set("name", source.name);
return url.href;
}
export function resetConverterProbe(): void {
probes.clear();
}
+16
View File
@@ -0,0 +1,16 @@
import type { ConversionOptions } from "./types.ts";
const KEY = "__wrnexusViewerConversion";
/**
* Route modules are loaded by the router, not by the plugin factory, so the
* configuration is handed over through a global — the same arrangement
* `@wrnexus/graphql` uses for its handler.
*/
export function configureConversion(options: ConversionOptions | undefined): void {
(globalThis as Record<string, unknown>)[KEY] = options;
}
export function conversionConfig(): ConversionOptions | undefined {
return (globalThis as Record<string, unknown>)[KEY] as ConversionOptions | undefined;
}
+14
View File
@@ -0,0 +1,14 @@
export { configureConversion, conversionConfig } from "./config.ts";
export { handleCapabilities, handleConvert } from "./service.ts";
export { localBinaryConverter } from "./local.ts";
export { firstOf, sameOriginSource } from "./sources.ts";
export type { LocalConverterOptions } from "./local.ts";
export type {
ConversionOptions,
ConvertMeta,
ConvertOperation,
ConvertOutput,
ResolvedSource,
SourceResolver,
ViewerConverter,
} from "./types.ts";
+254
View File
@@ -0,0 +1,254 @@
import { spawn, spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ConvertMeta, ConvertOutput, ViewerConverter } from "./types.ts";
import type { ViewerKind } from "../types.ts";
type Binary = "soffice" | "magick" | "ffmpeg" | "7z";
interface Recipe {
binary: Binary;
mime: string;
/** Output is read from this file, or from stdout when omitted. */
output?: string;
args: (input: string, dir: string) => string[];
}
const DOCUMENT: Recipe = {
binary: "soffice",
mime: "application/pdf",
output: "input.pdf",
args: (input, dir) => [
"--headless",
"--norestore",
"--nolockcheck",
`-env:UserInstallation=file://${dir.replace(/\\/g, "/")}/profile`,
"--convert-to",
"pdf",
"--outdir",
dir,
input,
],
};
const RASTER: Recipe = {
binary: "magick",
mime: "image/png",
output: "output.png",
// [0] takes the first layer/page only, which bounds the work for huge files.
args: (input, dir) => [`${input}[0]`, "-flatten", join(dir, "output.png")],
};
const POSTER: Recipe = {
binary: "ffmpeg",
mime: "image/png",
output: "output.png",
args: (input, dir) => [
"-nostdin",
"-y",
"-i",
input,
"-frames:v",
"1",
"-vf",
"scale='min(1280,iw)':-2",
join(dir, "output.png"),
],
};
const LISTING: Recipe = {
binary: "7z",
mime: "text/plain; charset=utf-8",
args: (input) => ["l", "-ba", "-p", input],
};
const RECIPES: Record<string, Recipe> = {
".doc": DOCUMENT,
".docx": DOCUMENT,
".odt": DOCUMENT,
".rtf": DOCUMENT,
".ppt": DOCUMENT,
".pptx": DOCUMENT,
".xls": DOCUMENT,
".xlsx": DOCUMENT,
".dotx": DOCUMENT,
".potx": DOCUMENT,
".ppsx": DOCUMENT,
".odp": DOCUMENT,
".ods": DOCUMENT,
".psd": RASTER,
".ai": RASTER,
".tif": RASTER,
".tiff": RASTER,
".avi": POSTER,
".mkv": POSTER,
".zip": LISTING,
".rar": LISTING,
".7z": LISTING,
};
export interface LocalConverterOptions {
/** Wall-clock limit for one conversion. Default 30s. */
timeoutMs?: number;
/** Refuse output larger than this. Default 64 MiB. */
maxOutputBytes?: number;
/** Override or disable a binary, e.g. `{ ffmpeg: false }`. */
binaries?: Partial<Record<Binary, string | false>>;
/** Parent directory for per-job scratch space. Defaults to the system temp. */
tempDir?: string;
}
async function collect(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = stream.getReader();
const parts: Uint8Array[] = [];
let length = 0;
while (true) {
const next = await reader.read();
if (next.done) break;
parts.push(next.value);
length += next.value.length;
}
const bytes = new Uint8Array(length);
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.length;
}
return bytes;
}
function probe(binary: string): boolean {
try {
const finder = process.platform === "win32" ? "where" : "which";
return spawnSync(finder, [binary], { stdio: "ignore" }).status === 0;
} catch {
return false;
}
}
/**
* Runs LibreOffice, ImageMagick, ffmpeg and 7-Zip on the host.
*
* These are large C/C++ parsers with a long history of memory-safety bugs, and
* they are being pointed at untrusted files, so every run is given a hard
* timeout, a private scratch directory as its only writable path, a stripped
* environment, no stdin, and a cap on how much output is read back. A missing
* binary is not an error: the format simply reports as unsupported and the
* viewer falls back to its download card.
*/
export function localBinaryConverter(options: LocalConverterOptions = {}): ViewerConverter {
const timeoutMs = options.timeoutMs ?? 30_000;
const maxOutputBytes = options.maxOutputBytes ?? 64 * 1024 * 1024;
const available = new Map<Binary, string | false>();
const resolveBinary = (binary: Binary): string | false => {
const cached = available.get(binary);
if (cached !== undefined) return cached;
const override = options.binaries?.[binary];
const command = override === false ? false : (override ?? binary);
const found = command === false ? false : probe(command) ? command : false;
available.set(binary, found);
return found;
};
const recipeFor = (ext: string): Recipe | undefined => {
const recipe = RECIPES[ext];
return recipe && resolveBinary(recipe.binary) ? recipe : undefined;
};
async function run(input: ReadableStream<Uint8Array>, meta: ConvertMeta): Promise<ConvertOutput> {
const recipe = recipeFor(meta.ext);
const command = recipe && resolveBinary(recipe.binary);
if (!recipe || !command) throw new Error(`No local converter for ${meta.ext}`);
const dir = await mkdtemp(join(options.tempDir ?? tmpdir(), "wrn-viewer-"));
try {
const inputPath = join(dir, `input${meta.ext}`);
await writeFile(inputPath, await collect(input));
const bytes = await execute(command, recipe.args(inputPath, dir), dir, meta.signal);
if (recipe.output) {
const produced = await readFile(join(dir, recipe.output));
if (produced.byteLength > maxOutputBytes) throw new Error("Converted output too large");
return { mime: recipe.mime, bytes: new Uint8Array(produced) };
}
return { mime: recipe.mime, bytes };
} finally {
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
}
async function execute(
command: string,
args: string[],
dir: string,
signal: AbortSignal,
): Promise<Uint8Array> {
return new Promise<Uint8Array>((resolve, reject) => {
const child = spawn(command, args, {
cwd: dir,
stdio: ["ignore", "pipe", "ignore"],
// A stripped environment: no proxies, credentials or user profile leak
// into a process parsing an untrusted file.
env: {
PATH: process.env.PATH ?? "",
HOME: dir,
TMPDIR: dir,
TEMP: dir,
TMP: dir,
SystemRoot: process.env.SystemRoot ?? "",
},
});
const parts: Buffer[] = [];
let length = 0;
let settled = false;
const finish = (error?: Error, value?: Uint8Array) => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
if (error) {
child.kill("SIGKILL");
reject(error);
} else resolve(value!);
};
const timer = setTimeout(
() => finish(new Error(`Conversion timed out after ${timeoutMs}ms`)),
timeoutMs,
);
const onAbort = () => finish(new Error("Conversion aborted"));
signal.addEventListener("abort", onAbort, { once: true });
child.stdout.on("data", (chunk: Buffer) => {
length += chunk.byteLength;
if (length > maxOutputBytes) {
finish(new Error("Converted output too large"));
return;
}
parts.push(chunk);
});
child.on("error", (error) => finish(error));
child.on("close", (code) =>
code === 0
? finish(undefined, new Uint8Array(Buffer.concat(parts)))
: finish(new Error(`${command} exited with ${code}`)),
);
});
}
return {
name: "local-binary",
version: "1",
supports: (_kind: ViewerKind, ext: string) => Boolean(recipeFor(ext)),
outputType: (ext: string) => recipeFor(ext)?.mime,
toPreview: run,
toThumb: run,
async toText(input, meta) {
const recipe = recipeFor(meta.ext);
if (!recipe || !recipe.mime.startsWith("text/"))
throw new Error(`No text extraction for ${meta.ext}`);
return new TextDecoder().decode((await run(input, meta)).bytes);
},
};
}
+198
View File
@@ -0,0 +1,198 @@
import { createHash } from "node:crypto";
import { extensionOf, kindOfExtension } from "../kinds.ts";
import { conversionConfig } from "./config.ts";
import { sameOriginSource } from "./sources.ts";
import type { ConvertOperation, ConvertOutput, ResolvedSource } from "./types.ts";
const MAX_INPUT_BYTES = 64 * 1024 * 1024;
const CACHE_TTL_MS = 60 * 60 * 1000;
const MAX_CACHE_ENTRIES = 64;
interface CacheEntry extends ConvertOutput {
expires: number;
}
const cache = new Map<string, CacheEntry>();
function remember(key: string, value: ConvertOutput, ttlMs: number): void {
if (cache.size >= MAX_CACHE_ENTRIES) {
const oldest = cache.keys().next();
if (!oldest.done) cache.delete(oldest.value);
}
cache.set(key, { ...value, expires: Date.now() + ttlMs });
}
function recall(key: string): ConvertOutput | undefined {
const entry = cache.get(key);
if (!entry) return undefined;
if (entry.expires < Date.now()) {
cache.delete(key);
return undefined;
}
return { mime: entry.mime, bytes: entry.bytes };
}
/** Caps the input before it reaches a binary parser. */
function bounded(stream: ReadableStream<Uint8Array>, cap: number): ReadableStream<Uint8Array> {
const reader = stream.getReader();
let seen = 0;
return new ReadableStream<Uint8Array>({
async pull(controller) {
const next = await reader.read();
if (next.done) {
controller.close();
return;
}
seen += next.value.length;
if (seen > cap) {
await reader.cancel().catch(() => {});
controller.error(new Error("Source exceeds the conversion limit"));
return;
}
controller.enqueue(next.value);
},
cancel: (reason) => reader.cancel(reason).catch(() => {}),
});
}
const problem = (status: number, message: string) =>
new Response(message, {
status,
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
});
/** `GET /api/viewer/capabilities` — what the client can ask to have converted. */
export function handleCapabilities(): Response {
const config = conversionConfig();
if (!config) return problem(404, "Conversion is not enabled");
const formats: Record<string, string> = {};
for (const converter of config.converters)
for (const ext of Object.keys(EXTENSIONS)) {
if (formats[ext]) continue;
const mime = converter.supports(kindOfExtension(ext), ext) && converter.outputType(ext);
if (mime) formats[ext] = mime;
}
return Response.json(
{ converters: config.converters.map((item) => item.name), formats },
{ headers: { "cache-control": "no-store" } },
);
}
/** Extensions the client may ask about. Keeps the probe response bounded. */
const EXTENSIONS: Record<string, true> = Object.fromEntries(
[
".doc",
".docx",
".odt",
".rtf",
".ppt",
".pptx",
".xls",
".xlsx",
".dotx",
".potx",
".ppsx",
".odp",
".ods",
".psd",
".ai",
".tif",
".tiff",
".avi",
".mkv",
".zip",
".rar",
".7z",
].map((ext) => [ext, true as const]),
);
export async function handleConvert(
request: Request,
operation: ConvertOperation,
): Promise<Response> {
const config = conversionConfig();
if (!config) return problem(404, "Conversion is not enabled");
const url = new URL(request.url);
const src = url.searchParams.get("src") ?? "";
const name = url.searchParams.get("name") ?? "";
if (!src || !name) return problem(400, "src and name are required");
const ext = extensionOf(name);
if (!EXTENSIONS[ext]) return problem(415, `Nothing converts ${ext || "this file"}`);
const kind = kindOfExtension(ext);
const converter = config.converters.find((item) => item.supports(kind, ext));
if (!converter) return problem(415, `No converter for ${ext}`);
// Resolve on every request, including cache hits, so revoked credentials cannot read cached output.
let source: ResolvedSource | undefined;
try {
source = await (config.resolveSource ?? sameOriginSource())(request, { src, name });
} catch {
return problem(403, "Source is not available for conversion");
}
if (!source) return problem(403, "Source is not available for conversion");
const maxInputBytes = config.maxInputBytes ?? MAX_INPUT_BYTES;
if (source.size !== undefined && source.size > maxInputBytes) {
await source.body.cancel().catch(() => {});
return problem(413, "Source exceeds the conversion limit");
}
const key = createHash("sha256")
.update(
// Encode the tuple so identity delimiters cannot cause key collisions.
JSON.stringify([
url.origin,
source.identity ?? src,
ext,
operation,
converter.name,
converter.version ?? "0",
]),
)
.digest("hex");
const cached = recall(key);
if (cached) {
await source.body.cancel().catch(() => {});
return output(cached, true);
}
const controller = new AbortController();
const abort = () => controller.abort();
request.signal.addEventListener("abort", abort, { once: true });
if (request.signal.aborted) controller.abort();
const meta = { name, ext, kind, size: source.size, signal: controller.signal };
const body = bounded(source.body, maxInputBytes);
try {
const result =
operation === "text" && converter.toText
? {
mime: "text/plain; charset=utf-8",
bytes: new TextEncoder().encode(await converter.toText(body, meta)),
}
: operation === "thumb" && converter.toThumb
? await converter.toThumb(body, meta)
: await converter.toPreview(body, meta);
remember(key, result, config.cacheTtlMs ?? CACHE_TTL_MS);
return output(result, false);
} catch (error) {
// The reason stays server-side: it can carry file paths and binary output.
console.warn(`[wrnexus] viewer conversion failed for ${name}:`, error);
return problem(422, "This file could not be converted");
} finally {
request.signal.removeEventListener("abort", abort);
if (!body.locked) await body.cancel().catch(() => {});
}
}
function output(result: ConvertOutput, hit: boolean): Response {
return new Response(result.bytes as unknown as BodyInit, {
headers: {
"content-type": result.mime,
"content-length": String(result.bytes.byteLength),
"content-disposition": "inline",
"x-content-type-options": "nosniff",
"cache-control": "private, no-store",
"x-viewer-cache": hit ? "hit" : "miss",
},
});
}
+48
View File
@@ -0,0 +1,48 @@
import type { SourceResolver } from "./types.ts";
/** Tries policies in order. Undefined means not handled; errors fail closed. */
export function firstOf(...resolvers: SourceResolver[]): SourceResolver {
return async (request, params) => {
for (const resolve of resolvers) {
const source = await resolve(request, params);
if (source) return source;
}
return undefined;
};
}
/** Anonymous same-origin resources only. Redirects and viewer API recursion are refused. */
export function sameOriginSource(): SourceResolver {
return async (request, { src }) => {
const origin = new URL(request.url);
let target: URL;
try {
target = new URL(src, origin);
} catch {
return undefined;
}
if (
!["http:", "https:"].includes(target.protocol) ||
target.origin !== origin.origin ||
target.username ||
target.password ||
target.pathname.startsWith("/api/viewer/")
)
return undefined;
const response = await fetch(target, {
credentials: "omit",
redirect: "error",
signal: request.signal,
});
if (!response.ok || !response.body) {
await response.body?.cancel();
return undefined;
}
const length = response.headers.get("content-length");
return {
body: response.body,
size: length && /^\d+$/.test(length) ? Number(length) : undefined,
identity: target.href,
};
};
}
+60
View File
@@ -0,0 +1,60 @@
import type { ViewerKind } from "../types.ts";
export type ConvertOperation = "preview" | "thumb" | "text";
export interface ConvertMeta {
/** Original filename, used only for logging and the output extension. */
name: string;
/** Lowercased extension including the dot, e.g. `.docx`. */
ext: string;
kind: ViewerKind;
size?: number;
signal: AbortSignal;
}
export interface ConvertOutput {
mime: string;
bytes: Uint8Array;
}
/**
* Turns a format the browser cannot render into one it can. Implementations are
* server-side only and are never bundled into a client runtime.
*/
export interface ViewerConverter {
name: string;
/** Bumped when output changes; part of the cache key. */
version?: string;
supports(kind: ViewerKind, ext: string): boolean;
/** MIME this converter produces for the extension, for client-side planning. */
outputType(ext: string): string | undefined;
toPreview(input: ReadableStream<Uint8Array>, meta: ConvertMeta): Promise<ConvertOutput>;
toThumb?(input: ReadableStream<Uint8Array>, meta: ConvertMeta): Promise<ConvertOutput>;
toText?(input: ReadableStream<Uint8Array>, meta: ConvertMeta): Promise<string>;
}
export interface ResolvedSource {
body: ReadableStream<Uint8Array>;
size?: number;
/** Stable object identity, independent of credentials. Include a revision if mutable. */
identity?: string;
}
export type SourceResolver = (
request: Request,
params: { src: string; name: string },
) => Promise<ResolvedSource | undefined>;
export interface ConversionOptions {
converters: ViewerConverter[];
/**
* Produces the bytes for a requested source. The default accepts same-origin
* URLs only and fetches them without credentials; replace it to serve from
* private storage, and apply the app's own access checks inside it.
*/
resolveSource?: SourceResolver;
/** Refuse inputs larger than this. Default 64 MiB. */
maxInputBytes?: number;
/** Converted output time-to-live in the response cache. Default 1 hour. */
cacheTtlMs?: number;
}
+141
View File
@@ -0,0 +1,141 @@
import { mountViewer, type MountViewerOptions } from "./runtime.ts";
import { convertUrl, converterFormats } from "./convert/client.ts";
import { extensionOf, kindOfExtension } from "./kinds.ts";
import { readBounded, safeSourceUrl } from "./source.ts";
import { resolveKey } from "./uploads/client.ts";
import { resolve } from "./resolve.ts";
import { sniff } from "./sniff.ts";
import type { ViewerSource } from "./types.ts";
/** One active preview; switching always disposes its editor, media and pending work. */
export function mountFilmstrip(
host: HTMLElement,
files: ViewerSource[],
options: MountViewerOptions = {},
): () => void {
files = files.slice(0, 500);
const original = [...host.childNodes];
const doc = host.ownerDocument;
const templates = original.map((node) => node.cloneNode(true));
const controller = new AbortController();
const urls: string[] = [];
const stage = doc.createElement("div");
const strip = doc.createElement("div");
strip.className = "wrn-viewer-filmstrip";
strip.setAttribute("role", "group");
strip.setAttribute("aria-label", "Files to preview");
const status = doc.createElement("p");
status.setAttribute("role", "status");
host.replaceChildren(stage, status, strip);
let dispose: (() => void) | undefined;
let selected = -1;
const buttons: HTMLButtonElement[] = [];
const show = (index: number, focus = false) => {
if (!files.length || index === selected) return;
dispose?.();
selected = Math.max(0, Math.min(files.length - 1, index));
stage.replaceChildren(...templates.map((node) => node.cloneNode(true)));
dispose = mountViewer(stage, files[selected]!, options);
buttons.forEach((button, i) => {
button.setAttribute("aria-pressed", String(i === selected));
button.tabIndex = i === selected ? 0 : -1;
});
status.textContent = `${selected + 1} of ${files.length}: ${files[selected]!.name}`;
if (focus) buttons[selected]?.focus();
};
for (const [index, file] of files.slice(0, 500).entries()) {
const button = doc.createElement("button");
button.type = "button";
button.className = "wrn-viewer-file";
button.textContent = file.name || "File";
button.onclick = () => show(index);
button.onkeydown = (event) => {
const next =
event.key === "ArrowRight"
? Math.min(files.length - 1, selected + 1)
: event.key === "ArrowLeft"
? Math.max(0, selected - 1)
: event.key === "Home"
? 0
: event.key === "End"
? files.length - 1
: undefined;
if (next !== undefined) {
event.preventDefault();
show(next, true);
}
};
buttons.push(button);
strip.append(button);
}
if (files.length) show(0);
else status.textContent = "No files to preview.";
// Sequential thumbnail work bounds concurrent conversion and source requests.
void (async () => {
const formats = await converterFormats({ baseURI: doc.baseURI });
for (const [index, file] of files.slice(0, 500).entries()) {
if (controller.signal.aborted) return;
try {
const source =
file.key && !file.src
? await resolveKey(file, { baseURI: doc.baseURI, signal: controller.signal })
: file;
if (!source || controller.signal.aborted) continue;
let url: string | undefined;
if (kindOfExtension(extensionOf(source.name)) === "image") {
const normalized = source.src
? { ...source, src: new URL(source.src, doc.baseURI).href }
: source;
const plan = await resolve(normalized, {
baseURI: doc.baseURI,
signal: controller.signal,
});
if (
plan.kind !== "image" ||
plan.renderer.component === "DownloadCard" ||
controller.signal.aborted
)
continue;
if (source.blob) {
const type = sniff(new Uint8Array(await source.blob.slice(0, 4096).arrayBuffer())).mime;
if (controller.signal.aborted) return;
url = URL.createObjectURL(new Blob([source.blob], { type }));
urls.push(url);
} else if (normalized.src) url = safeSourceUrl(normalized.src);
} else if (formats[extensionOf(source.name)]) {
const endpoint = convertUrl(source, "thumb", doc.baseURI);
if (!endpoint) continue;
const response = await fetch(endpoint, { signal: controller.signal });
if (!/^image\/(png|jpeg|webp)$/.test(response.headers.get("content-type") ?? "")) {
await response.body?.cancel();
continue;
}
const bytes = await readBounded(response, 2 * 1024 * 1024);
if (controller.signal.aborted) return;
url = URL.createObjectURL(
new Blob([bytes as Uint8Array<ArrayBuffer>], {
type: response.headers.get("content-type")!,
}),
);
urls.push(url);
}
if (url && !controller.signal.aborted) {
const img = doc.createElement("img");
img.alt = "";
img.loading = "lazy";
img.src = url;
img.onerror = () => img.remove();
buttons[index]!.prepend(img);
}
} catch {
/* The filename remains a useful thumbnail fallback. */
}
}
})().catch(() => {});
return () => {
controller.abort();
dispose?.();
urls.forEach((url) => URL.revokeObjectURL(url));
host.replaceChildren(...original);
};
}
+20
View File
@@ -0,0 +1,20 @@
export type * from "./types.ts";
export { extensions, extensionOf, kindOfExtension, kindOfMime } from "./kinds.ts";
export { sniff } from "./sniff.ts";
export { resolve } from "./resolve.ts";
export { RendererRegistry, registerRenderer } from "./registry.ts";
export { parseDelimited, detectDelimiter } from "./parse/delimited.ts";
export type { DelimitedTable } from "./parse/delimited.ts";
export { sanitizeSvg } from "./parse/svg.ts";
export type { SvgSanitizerView } from "./parse/svg.ts";
export { parseTar, isTar, gzipName, gunzip } from "./parse/archive.ts";
export type { ArchiveEntry } from "./parse/archive.ts";
export { convertUrl, converterFormats, requireConverter } from "./convert/client.ts";
export { extractText } from "./text.ts";
export { sanitizeHtml } from "./parse/html.ts";
export { registerViewerAction, viewerActions, clearViewerActions } from "./actions.ts";
export type { ViewerAction, ViewerActionContext } from "./actions.ts";
export { canPrint, printViewer } from "./print.ts";
export { ICONS, icon, iconFor } from "./renderers/icons.ts";
export { openViewer, startViewerTriggers, sourceFromTrigger } from "./overlay.ts";
export type { OpenViewerOptions, OpenedViewer } from "./overlay.ts";
+68
View File
@@ -0,0 +1,68 @@
import type { ViewerKind } from "./types.ts";
/** Kind classification only; MIME ownership remains with uploader. */
export const extensions: Record<ViewerKind, readonly string[]> = {
image: ["jpg", "jpeg", "png", "gif", "webp", "bmp", "ico", "avif"],
vector: ["svg"],
pdf: ["pdf"],
code: [
"js",
"jsx",
"ts",
"tsx",
"py",
"java",
"cpp",
"c",
"h",
"cs",
"css",
"json",
"xml",
"sql",
"sh",
"yaml",
"yml",
],
text: ["txt", "md", "log"],
markup: ["html", "htm"],
sheet: ["csv", "tsv", "xls", "xlsx", "ods"],
doc: ["doc", "docx", "dotx", "odt", "rtf", "ppt", "pptx", "ppsx", "potx", "odp"],
audio: ["mp3", "wav", "aac", "flac", "ogg"],
video: ["mp4", "webm", "mov", "avi", "mkv"],
archive: ["zip", "rar", "7z", "tar", "gz", "tgz"],
binary: ["psd", "ai", "tif", "tiff", "exe", "dll"],
};
export function extensionOf(name: string): string {
const base = (name.split(/[?#]/)[0] ?? "").split(/[/\\]/).pop() ?? "";
const dot = base.lastIndexOf(".");
return dot < 0 ? "" : base.slice(dot).toLowerCase();
}
export function kindOfExtension(ext: string): ViewerKind {
return (
(Object.keys(extensions) as ViewerKind[]).find((kind) =>
extensions[kind].includes(ext.replace(/^\./, "").toLowerCase()),
) ?? "binary"
);
}
export function kindOfMime(mime: string): ViewerKind | undefined {
const type = mime.split(";")[0]!.trim().toLowerCase();
if (type === "image/svg+xml") return "vector";
if (type === "text/html" || type === "application/xhtml+xml") return "markup";
if (type === "application/pdf") return "pdf";
if (/^(application|text)\/(json|xml|javascript|x-javascript)$/.test(type) || type === "text/css")
return "code";
if (/^text\/(csv|tab-separated-values)$/.test(type) || /spreadsheet|ms-excel/.test(type))
return "sheet";
if (/opendocument\.spreadsheet/.test(type)) return "sheet";
if (/msword|wordprocessing|presentation|powerpoint|opendocument|rtf/.test(type)) return "doc";
if (/zip|gzip|x-tar|x-7z|rar/.test(type)) return "archive";
if (/^image\/(png|jpeg|gif|webp|bmp|x-icon|vnd.microsoft.icon|avif)$/.test(type)) return "image";
if (type.startsWith("audio/")) return "audio";
if (type.startsWith("video/")) return "video";
if (type.startsWith("text/")) return "text";
return undefined;
}
+19
View File
@@ -0,0 +1,19 @@
import { convertToHtml } from "mammoth";
/**
* Semantic HTML, not a faithful page layout: headers, footers, page breaks and
* exact positioning are lost. The output is derived from an untrusted file, so
* callers must render it inside a sandboxed frame.
*/
export async function docxToHtml(bytes: Uint8Array): Promise<string> {
const arrayBuffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
// The browser build reads `arrayBuffer`, the Node build reads `buffer`.
// Supplying both keeps this module usable — and testable — in either.
const result = await convertToHtml({ arrayBuffer, buffer: bytes } as Parameters<
typeof convertToHtml
>[0]);
return result.value;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Optional parsers ship as separate plugin runtime assets, contributed only
* when their package resolves on the server. Importing one that was not
* contributed fails, which is how a renderer reports itself unavailable.
*/
export async function loadOptional<T>(
id: string,
baseURI?: string,
importModule: (url: string) => Promise<unknown> = (url) => import(/* @vite-ignore */ url),
): Promise<T> {
baseURI ??= typeof document === "undefined" ? undefined : document.baseURI;
if (!baseURI) throw new Error(`${id} needs a browser`);
const url = new URL(`/__wrnexus/assets/${id}.js`, baseURI).href;
const module = (await importModule(url)) as T;
return module;
}
+194
View File
@@ -0,0 +1,194 @@
import { unzip } from "fflate";
/**
* Office documents that are ZIP containers of XML: OOXML presentations
* (`.pptx`) and the whole OpenDocument family (`.odt`, `.ods`, `.odp`).
*
* Both are read here rather than through a converter so they preview with no
* binary on the host. What comes back is content and structure — headings,
* bullets, cells, slide order — not a faithful page layout. Layout fidelity is
* what the Tier 3 converter is for, and it stays preferred where it exists.
*/
export interface Slide {
title: string;
/** Text blocks in shape order, each already split into lines. */
blocks: string[][];
notes?: string;
}
export interface OfficeText {
type: "text";
/** Paragraphs with an outline level: 0 is body text, 1+ are headings. */
blocks: { level: number; text: string; list?: boolean }[];
}
export interface OfficeSheets {
type: "sheets";
sheets: { name: string; header: string[]; rows: string[][] }[];
}
export interface OfficeSlides {
type: "slides";
slides: Slide[];
}
export type OfficeDocument = OfficeText | OfficeSheets | OfficeSlides;
const MAX_SLIDES = 300;
const MAX_ROWS = 200_000;
/** A repeat count high enough to be a "rest of the row" filler, not real data. */
const REPEAT_LIMIT = 1024;
/** Reads only the entries a preview needs; media is never inflated. */
function unzipOnly(bytes: Uint8Array, wanted: RegExp): Promise<Record<string, Uint8Array>> {
return new Promise((resolve, reject) => {
unzip(bytes, { filter: (file) => wanted.test(file.name) }, (error, files) =>
error ? reject(error) : resolve(files),
);
});
}
const decoder = new TextDecoder();
function parse(xml: Uint8Array, view: { DOMParser: new () => DOMParser }): Document {
const document = new view.DOMParser().parseFromString(decoder.decode(xml), "application/xml");
if (document.getElementsByTagName("parsererror").length) throw new Error("Malformed office XML");
return document;
}
/** Local-name lookup: office XML is namespaced and prefixes are not fixed. */
function byTag(root: Element | Document, local: string): Element[] {
return [...root.getElementsByTagName("*")].filter(
(node) => node.localName === local || node.nodeName.split(":").pop() === local,
);
}
/** Slide and note files sort by their number, not their string name. */
function inOrder(names: string[]): string[] {
return names.sort((a, b) => {
const number = (value: string) => Number(/(\d+)\.xml$/.exec(value)?.[1] ?? 0);
return number(a) - number(b);
});
}
export async function readPptx(
bytes: Uint8Array,
view: { DOMParser: new () => DOMParser },
): Promise<OfficeSlides> {
const files = await unzipOnly(bytes, /^ppt\/(slides|notesSlides)\/[^/]+\.xml$/);
const slideNames = inOrder(Object.keys(files).filter((name) => name.startsWith("ppt/slides/")));
if (!slideNames.length) throw new Error("Presentation holds no slides");
const noteNames = inOrder(
Object.keys(files).filter((name) => name.startsWith("ppt/notesSlides/")),
);
const slides: Slide[] = [];
for (const name of slideNames.slice(0, MAX_SLIDES)) {
const document = parse(files[name]!, view);
// One block per shape, so a title and a bullet list stay separate.
const blocks: string[][] = [];
for (const shape of byTag(document, "sp")) {
const lines = byTag(shape, "p")
.map((paragraph) =>
byTag(paragraph, "t")
.map((run) => run.textContent ?? "")
.join("")
.trim(),
)
.filter(Boolean);
if (lines.length) blocks.push(lines);
}
const notesFile = noteNames.find(
(note) => /(\d+)\.xml$/.exec(note)?.[1] === /(\d+)\.xml$/.exec(name)?.[1],
);
const notes = notesFile
? byTag(parse(files[notesFile]!, view), "t")
.map((run) => run.textContent ?? "")
.join("")
.trim()
: "";
// The first shape carrying text is the title in every standard layout.
const title = blocks[0]?.[0] ?? "";
slides.push({ title, blocks, ...(notes ? { notes } : {}) });
}
return { type: "slides", slides };
}
function odfParagraphs(
root: Element | Document,
): { level: number; text: string; list?: boolean }[] {
const blocks: { level: number; text: string; list?: boolean }[] = [];
const walk = (node: Element, inList: boolean) => {
for (const child of [...node.children]) {
const local = child.localName ?? child.nodeName.split(":").pop() ?? "";
if (local === "h" || local === "p") {
const text = (child.textContent ?? "").trim();
const level =
local === "h" ? Number(child.getAttribute("text:outline-level") ?? 1) || 1 : 0;
if (text) blocks.push({ level, text, ...(inList && local === "p" ? { list: true } : {}) });
} else if (local === "list" || local === "list-item") walk(child, true);
else walk(child, inList);
}
};
walk(root as Element, false);
return blocks;
}
function odfSheet(table: Element): { name: string; header: string[]; rows: string[][] } {
const name = table.getAttribute("table:name") ?? "Sheet";
const grid: string[][] = [];
for (const row of byTag(table, "table-row")) {
const cells: string[] = [];
for (const cell of byTag(row, "table-cell")) {
const text = (cell.textContent ?? "").trim();
// ODF collapses runs of identical cells; a huge repeat is trailing padding.
const repeat = Number(cell.getAttribute("table:number-columns-repeated") ?? 1) || 1;
for (let i = 0; i < Math.min(repeat, REPEAT_LIMIT); i++) cells.push(text);
}
while (cells.length && cells[cells.length - 1] === "") cells.pop();
if (cells.length) grid.push(cells);
if (grid.length > MAX_ROWS) break;
}
const header = grid.shift() ?? [];
return { name, header, rows: grid };
}
export async function readOdf(
bytes: Uint8Array,
view: { DOMParser: new () => DOMParser },
): Promise<OfficeDocument> {
const files = await unzipOnly(bytes, /^(content\.xml|mimetype)$/);
const content = files["content.xml"];
if (!content) throw new Error("Not an OpenDocument file");
const mimetype = files["mimetype"] ? decoder.decode(files["mimetype"]) : "";
const document = parse(content, view);
if (mimetype.includes("spreadsheet") || byTag(document, "spreadsheet").length) {
const sheets = byTag(document, "table").map(odfSheet);
if (!sheets.length) throw new Error("Spreadsheet holds no sheets");
return { type: "sheets", sheets };
}
if (mimetype.includes("presentation") || byTag(document, "presentation").length) {
const slides: Slide[] = byTag(document, "page")
.slice(0, MAX_SLIDES)
.map((page) => {
const blocks = byTag(page, "frame")
.map((frame) =>
odfParagraphs(frame)
.map((block) => block.text)
.filter(Boolean),
)
.filter((lines) => lines.length);
return { title: blocks[0]?.[0] ?? page.getAttribute("draw:name") ?? "", blocks };
});
if (!slides.length) throw new Error("Presentation holds no slides");
return { type: "slides", slides };
}
const body = byTag(document, "text")[0] ?? document.documentElement;
const blocks = odfParagraphs(body);
if (!blocks.length) throw new Error("Document holds no readable text");
return { type: "text", blocks };
}
+126
View File
@@ -0,0 +1,126 @@
import type * as PdfJs from "pdfjs-dist";
export interface RichPdfOptions {
signal?: AbortSignal;
/** URL of the pdf.js ESM build, served verbatim by the plugin. */
libSrc: string;
/** URL of the pdf.js worker, served verbatim by the plugin. */
workerSrc: string;
/** Pages rendered before the viewer stops. */
maxPages?: number;
/** CSS pixels per PDF point at scale 1, before device pixel ratio. */
scale?: number;
/** Give up on a page that will not finish. Default 20s. */
timeoutMs?: number;
}
export interface RichPdfResult {
pages: number;
rendered: number;
destroy: () => void;
}
/**
* pdf.js drives rasterisation from `requestAnimationFrame`, which never fires
* while the document is hidden, so a page render can stay pending forever. A
* viewer must fall back rather than hang: exceeding this rejects, and the
* ladder moves on to the native frame or the download card.
*/
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), ms);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error instanceof Error ? error : new Error(String(error)));
},
);
});
}
/**
* Rasterises pages into canvases appended to `parent`. Unlike the native
* viewer this gives page count, controlled sizing and consistent output across
* browsers, at the cost of downloading pdf.js.
*
* pdf.js is imported from its own published build at runtime rather than being
* bundled: putting its ESM entry through a bundler breaks the worker handshake,
* and `getDocument()` then never settles.
*/
export async function renderPdf(
parent: HTMLElement,
bytes: Uint8Array,
options: RichPdfOptions,
): Promise<RichPdfResult> {
const pdfjs = (await import(/* @vite-ignore */ options.libSrc)) as typeof PdfJs;
pdfjs.GlobalWorkerOptions.workerSrc = options.workerSrc;
const doc = parent.ownerDocument;
const timeoutMs = options.timeoutMs ?? 20_000;
const task = pdfjs.getDocument({ data: bytes, isEvalSupported: false });
let stopped = false;
let activeRender: { cancel(): void } | undefined;
const destroy = () => {
if (stopped) return;
stopped = true;
options.signal?.removeEventListener("abort", destroy);
activeRender?.cancel();
void task.destroy().catch(() => {});
};
options.signal?.addEventListener("abort", destroy, { once: true });
if (options.signal?.aborted) {
destroy();
throw new Error("Preview disposed");
}
try {
const pdf = await withTimeout(task.promise, timeoutMs, "The document could not be opened");
const limit = Math.min(pdf.numPages, options.maxPages ?? 50);
const ratio = Math.min(doc.defaultView?.devicePixelRatio ?? 1, 2);
let rendered = 0;
for (let number = 1; number <= limit; number++) {
if (stopped) throw new Error("Preview disposed");
const page = await withTimeout(pdf.getPage(number), timeoutMs, "Page could not be opened");
const viewport = page.getViewport({ scale: (options.scale ?? 1.4) * ratio });
const canvas = doc.createElement("canvas");
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
canvas.style.width = `${Math.floor(viewport.width / ratio)}px`;
canvas.style.maxWidth = "100%";
canvas.className = "wrn-viewer-page";
canvas.setAttribute("role", "img");
canvas.setAttribute("aria-label", `Page ${number} of ${pdf.numPages}`);
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas rendering is unavailable");
parent.append(canvas);
const render = page.render({ canvasContext: context, viewport });
activeRender = render;
try {
await withTimeout(render.promise, timeoutMs, "Page rendering did not complete");
} catch (error) {
render.cancel();
canvas.remove();
// A first page that will not draw means this renderer cannot be used.
if (rendered === 0) {
destroy();
throw error;
}
break;
}
rendered++;
}
return {
pages: pdf.numPages,
rendered,
destroy,
};
} catch (error) {
destroy();
throw error;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { read, utils } from "xlsx";
export interface SheetData {
name: string;
header: string[];
rows: string[][];
truncated: boolean;
}
const MAX_ROWS = 200_000;
/** Every sheet as strings; formulas arrive as their cached values. */
export function readWorkbook(bytes: Uint8Array): SheetData[] {
const workbook = read(bytes, { type: "array", cellFormula: false, cellHTML: false });
return workbook.SheetNames.map((name) => {
const sheet = workbook.Sheets[name];
const grid = sheet
? (utils.sheet_to_json(sheet, { header: 1, raw: false, defval: "" }) as unknown[][])
: [];
const rows = grid.map((row) => row.map((cell) => (cell == null ? "" : String(cell))));
const header = rows.shift() ?? [];
return {
name,
header,
rows: rows.slice(0, MAX_ROWS),
truncated: rows.length > MAX_ROWS,
};
});
}
+31
View File
@@ -0,0 +1,31 @@
import { unzip } from "fflate";
export interface ZipEntry {
name: string;
size: number;
directory: boolean;
}
/**
* Lists a zip without inflating it: fflate's filter runs per entry, and
* returning false skips decompression, so a 2 GB archive costs its directory.
*/
export function listZip(bytes: Uint8Array): Promise<ZipEntry[]> {
return new Promise((resolve, reject) => {
const entries: ZipEntry[] = [];
unzip(
bytes,
{
filter: (file) => {
entries.push({
name: file.name,
size: file.originalSize ?? 0,
directory: file.name.endsWith("/"),
});
return false;
},
},
(error) => (error ? reject(error) : resolve(entries)),
);
});
}
+127
View File
@@ -0,0 +1,127 @@
import { mountViewer, type MountViewerOptions } from "./runtime.ts";
import type { ViewerSource } from "./types.ts";
export interface OpenViewerOptions extends MountViewerOptions {
/** Document to open in. Defaults to the ambient one. */
document?: Document;
/** Called once the overlay has closed, however it was closed. */
onClose?: () => void;
/** Accessible name for the dialog. Defaults to the file's name. */
label?: string;
}
export interface OpenedViewer {
close: () => void;
dialog: HTMLElement;
}
/**
* Opens a file over the page, for the "click a button, preview the file" case.
*
* A native `<dialog>` does the work that is easy to get wrong by hand: it takes
* the top layer, traps focus, closes on Escape, and restores focus to whatever
* opened it. Closing also disposes the viewer, so a video stops and object URLs
* are revoked rather than leaking behind the overlay.
*/
export function openViewer(
source: ViewerSource | ViewerSource[],
options: OpenViewerOptions = {},
): OpenedViewer {
const doc = options.document ?? document;
const dialog = doc.createElement("dialog");
dialog.className = "wrn-viewer-dialog";
const first = Array.isArray(source) ? source[0] : source;
dialog.setAttribute("aria-label", options.label ?? first?.name ?? "File preview");
const host = doc.createElement("section");
host.className = "wrn-viewer";
dialog.append(host);
doc.body.append(dialog);
let closed = false;
// Assigned below; `close` may run before the viewer has finished mounting.
let dispose: (() => void) | undefined;
const close = (): void => {
if (closed) return;
closed = true;
dispose?.();
// `close()` on an open dialog fires `close`, which lands back here; the
// guard above makes that a no-op rather than a loop.
if (dialog.open) dialog.close();
dialog.remove();
options.onClose?.();
};
dialog.addEventListener("close", close);
// The dialog element fills the viewport, so a click that lands on it rather
// than on the viewer inside is a click on the backdrop.
dialog.addEventListener("click", (event) => {
if (event.target === dialog) close();
});
const stop = mountViewer(host, source, {
...options,
actions: [
{
id: "close",
label: "Close preview",
icon: "close",
placement: "actions",
run: close,
},
...(options.actions ?? []),
],
});
// If closing raced the mount, tear the viewer down rather than orphan it.
if (closed) stop();
else dispose = stop;
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
return { close, dialog };
}
/** Reads a trigger's data attributes into the source(s) it points at. */
export function sourceFromTrigger(trigger: HTMLElement): ViewerSource | ViewerSource[] | undefined {
const { src, key, store, name, mime, size, files } = trigger.dataset;
if (files) {
try {
const parsed = JSON.parse(files) as ViewerSource[];
if (Array.isArray(parsed) && parsed.length) return parsed;
} catch {
return undefined;
}
}
if (!src && !key) return undefined;
const bytes = Number(size);
return {
src,
key,
store,
name: name ?? "",
mime,
size: size && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined,
};
}
/**
* Any element carrying `data-viewer-open` opens a preview when clicked, so a
* plain button in a list of files needs no JavaScript of its own.
*/
export function startViewerTriggers(root: Document = document): () => void {
const onClick = (event: MouseEvent) => {
const target = event.target as Element | null;
const trigger = target?.closest?.<HTMLElement>("[data-viewer-open]");
if (!trigger) return;
const source = sourceFromTrigger(trigger);
if (!source) return;
event.preventDefault();
openViewer(source, {
document: root,
richPdf: trigger.dataset.richPdf === "true",
});
};
root.addEventListener("click", onClick);
return () => root.removeEventListener("click", onClick);
}
+86
View File
@@ -0,0 +1,86 @@
export interface ArchiveEntry {
name: string;
size: number;
directory: boolean;
}
const decoder = new TextDecoder();
/** Reads a NUL-padded fixed-width tar header field. */
function field(bytes: Uint8Array, offset: number, length: number): string {
const raw = bytes.subarray(offset, offset + length);
const end = raw.indexOf(0);
return decoder.decode(end < 0 ? raw : raw.subarray(0, end)).trim();
}
const octal = (value: string) => (/^[0-7]+$/.test(value) ? parseInt(value, 8) : 0);
/** USTAR/GNU tar listing. Entry data is skipped; only headers are read. */
export function parseTar(bytes: Uint8Array): ArchiveEntry[] {
const entries: ArchiveEntry[] = [];
let offset = 0;
let longName: string | undefined;
while (offset + 512 <= bytes.length) {
const header = bytes.subarray(offset, offset + 512);
if (header.every((byte) => byte === 0)) break;
const size = octal(field(header, 124, 12));
const type = String.fromCharCode(header[156] ?? 0);
const blocks = Math.ceil(size / 512) * 512;
if (type === "L") {
// GNU long name: the following data block holds this entry's real name.
longName = field(bytes, offset + 512, size);
} else if (type !== "x" && type !== "g") {
const prefix = field(header, 345, 155);
const name = longName ?? [prefix, field(header, 0, 100)].filter(Boolean).join("/");
longName = undefined;
if (name) entries.push({ name, size, directory: type === "5" || name.endsWith("/") });
}
offset += 512 + blocks;
}
return entries;
}
/** True when the buffer carries the USTAR signature at its header offset. */
export function isTar(bytes: Uint8Array): boolean {
return field(bytes, 257, 5) === "ustar";
}
/** Original filename recorded in a gzip member header, when present. */
export function gzipName(bytes: Uint8Array): string | undefined {
const flags = bytes[3] ?? 0;
if (bytes[0] !== 0x1f || bytes[1] !== 0x8b || !(flags & 0x08)) return undefined;
let offset = 10;
if (flags & 0x04) offset += 2 + ((bytes[10] ?? 0) | ((bytes[11] ?? 0) << 8));
const end = bytes.indexOf(0, offset);
return end > offset ? decoder.decode(bytes.subarray(offset, end)) : undefined;
}
/** Gunzip through the platform stream; undefined when unsupported or invalid. */
export async function gunzip(file: Blob, cap: number): Promise<Uint8Array | undefined> {
if (typeof DecompressionStream === "undefined") return undefined;
try {
const reader = file.stream().pipeThrough(new DecompressionStream("gzip")).getReader();
const parts: Uint8Array[] = [];
let length = 0;
try {
while (length < cap) {
const next = await reader.read();
if (next.done) break;
const part = next.value.subarray(0, cap - length);
parts.push(part);
length += part.length;
}
} finally {
await reader.cancel().catch(() => {});
}
const bytes = new Uint8Array(length);
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.length;
}
return bytes;
} catch {
return undefined;
}
}
+81
View File
@@ -0,0 +1,81 @@
export interface DelimitedTable {
header: string[];
rows: string[][];
delimiter: string;
/** True when the file held more rows than `maxRows`. */
truncated: boolean;
}
const CANDIDATES = [",", "\t", ";", "|"];
/** Delimiter used by the most fields on the first unquoted line. */
export function detectDelimiter(text: string): string {
const line = text.slice(0, 64 * 1024).split(/\r?\n/, 1)[0] ?? "";
let best = ",";
let bestCount = 0;
for (const candidate of CANDIDATES) {
let count = 0;
let quoted = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') quoted = !quoted;
else if (!quoted && char === candidate) count++;
}
if (count > bestCount) {
best = candidate;
bestCount = count;
}
}
return best;
}
/**
* RFC 4180 parsing: quoted fields may hold the delimiter, CR/LF, and `""`
* escapes. Rows are capped so a large file cannot exhaust memory in the tab.
*/
export function parseDelimited(
text: string,
options: { delimiter?: string; maxRows?: number } = {},
): DelimitedTable {
const delimiter = options.delimiter ?? detectDelimiter(text);
const maxRows = options.maxRows ?? 200_000;
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
let truncated = false;
const endField = () => {
row.push(field);
field = "";
};
const endRow = () => {
endField();
// A trailing newline must not produce a phantom row.
if (row.length > 1 || row[0] !== "") rows.push(row);
row = [];
if (rows.length >= maxRows) truncated = true;
};
for (let i = 0; i < text.length && !truncated; i++) {
const char = text[i]!;
if (quoted) {
if (char !== '"') field += char;
else if (text[i + 1] === '"') {
field += '"';
i++;
} else quoted = false;
continue;
}
if (char === '"' && field === "") quoted = true;
else if (char === delimiter) endField();
else if (char === "\n") endRow();
else if (char === "\r" && text[i + 1] === "\n") continue;
else if (char === "\r") endRow();
else field += char;
}
if (!truncated && (field !== "" || row.length)) endRow();
const header = rows.shift() ?? [];
return { header, rows, delimiter, truncated };
}
+48
View File
@@ -0,0 +1,48 @@
const TAGS = new Set(
"a abbr article aside b blockquote br caption code col colgroup dd del details div dl dt em figcaption figure footer h1 h2 h3 h4 h5 h6 header hr i img ins kbd li main mark nav ol p pre s section small span strong sub summary sup table tbody td tfoot th thead time tr u ul".split(
" ",
),
);
const DROP = new Set(
"script style iframe object embed link meta base form input button textarea select template svg math".split(
" ",
),
);
const escape = (text: string) =>
text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
/** Rebuilds an inert allowlisted body; never inserts file HTML into the host DOM. */
export function sanitizeHtml(html: string, view: { DOMParser: new () => DOMParser }): string {
const doc = new view.DOMParser().parseFromString(html, "text/html");
function serialize(node: Node): string {
if (node.nodeType === 3) return escape(node.textContent ?? "");
if (node.nodeType !== 1) return "";
const element = node as Element;
const tag = element.localName.toLowerCase();
if (DROP.has(tag)) return "";
const children = [...element.childNodes].map(serialize).join("");
if (!TAGS.has(tag)) return children;
const attrs: string[] = [];
for (const attr of [...element.attributes]) {
const name = attr.name.toLowerCase();
const value = attr.value;
if (
["title", "alt", "colspan", "rowspan", "width", "height", "dir", "lang", "id"].includes(
name,
) ||
(name === "href" && /^#[\w.-]+$/.test(value)) ||
(name === "src" &&
tag === "img" &&
/^data:image\/(png|jpeg|gif|webp);base64,[a-z\d+/=\s]+$/i.test(value)) ||
(name === "style" && !/url|expression|@|\\|behavior|binding/i.test(value))
)
attrs.push(`${name}="${escape(value)}"`);
}
return `<${tag}${attrs.length ? " " + attrs.join(" ") : ""}>${children}</${tag}>`;
}
return [...doc.body.childNodes].map(serialize).join("");
}
+162
View File
@@ -0,0 +1,162 @@
/**
* RTF to plain text. In-package and dependency-free, because RTF is a text
* format: the whole file is control words and braces, and everything a preview
* needs is the character data between them.
*
* This recovers text, paragraphs and tabs. Formatting, tables, images and
* embedded objects are deliberately dropped — a faithful RTF layout is what the
* converter is for.
*/
/** Groups whose entire contents are metadata, never document text. */
const SKIPPED = new Set([
"fonttbl",
"colortbl",
"stylesheet",
"listtable",
"listoverridetable",
"revtbl",
"rsidtbl",
"generator",
"info",
"pict",
"object",
"themedata",
"colorschememapping",
"latentstyles",
"datastore",
"xmlnstbl",
"filetbl",
"header",
"footer",
"headerl",
"headerr",
"footerl",
"footerr",
"footnote",
"annotation",
]);
const BREAKS: Record<string, string> = {
par: "\n",
line: "\n",
sect: "\n\n",
page: "\n\n",
tab: "\t",
cell: "\t",
row: "\n",
lquote: "‘",
rquote: "’",
ldblquote: "“",
rdblquote: "”",
emdash: "—",
endash: "–",
bullet: "•",
nbsp: " ",
"~": " ",
"-": "",
_: "-",
};
export function isRtf(source: string): boolean {
return source.trimStart().startsWith("{\\rtf");
}
export function rtfToText(source: string): string {
if (!isRtf(source)) throw new Error("Not an RTF document");
let out = "";
let depth = 0;
// Depth at which a metadata group started; everything deeper is discarded.
let skipFrom = -1;
// How many ANSI substitute characters trail each \uN, set by \ucN. It is
// group-scoped, so the stack restores the outer value when a group closes.
// The RTF default is 1, and \uc0 means a \uN stands alone.
let substitutes = 1;
const scopes: number[] = [];
let skipChars = 0;
for (let i = 0; i < source.length; i++) {
const char = source[i]!;
if (char === "{") {
scopes.push(substitutes);
depth++;
continue;
}
if (char === "}") {
if (skipFrom >= 0 && depth <= skipFrom) skipFrom = -1;
substitutes = scopes.pop() ?? 1;
depth--;
continue;
}
if (char === "\\") {
const next = source[i + 1] ?? "";
// \* marks an optional destination: skip the whole group.
if (next === "*") {
skipFrom = depth;
i++;
continue;
}
if (next === "'") {
const hex = source.slice(i + 2, i + 4);
i += 3;
if (skipFrom < 0 && /^[0-9a-f]{2}$/i.test(hex)) {
if (skipChars > 0) skipChars--;
else out += String.fromCharCode(parseInt(hex, 16));
}
continue;
}
if (!/[a-z]/i.test(next)) {
// A control symbol: one escaped literal character.
i++;
if (skipFrom < 0) out += BREAKS[next] ?? next;
continue;
}
const word = /^[a-z]+/i.exec(source.slice(i + 1))![0];
let cursor = i + 1 + word.length;
let parameter = "";
if (source[cursor] === "-") {
parameter = "-";
cursor++;
}
while (/[0-9]/.test(source[cursor] ?? "")) parameter += source[cursor++]!;
// A single trailing space is part of the control word, not the text.
if (source[cursor] === " ") cursor++;
i = cursor - 1;
if (SKIPPED.has(word.toLowerCase())) {
skipFrom = depth;
continue;
}
if (skipFrom >= 0) continue;
if (word === "u") {
const code = Number(parameter);
if (Number.isFinite(code)) out += String.fromCodePoint(code < 0 ? code + 65536 : code);
skipChars = substitutes;
continue;
}
if (word === "uc") {
const count = Number(parameter);
substitutes = Number.isFinite(count) && count >= 0 ? count : 1;
continue;
}
out += BREAKS[word] ?? "";
continue;
}
if (skipFrom >= 0) continue;
if (char === "\r" || char === "\n") continue;
if (skipChars > 0) {
skipChars--;
continue;
}
out += char;
}
return out
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
+107
View File
@@ -0,0 +1,107 @@
/**
* SVG sanitiser. This is the inner half of the defence: previews also render
* inside a sandboxed, CSP-restricted frame. Neither layer is trusted alone.
*/
const ELEMENTS = new Set([
"svg",
"g",
"defs",
"symbol",
"use",
"title",
"desc",
"switch",
"path",
"rect",
"circle",
"ellipse",
"line",
"polyline",
"polygon",
"text",
"tspan",
"textpath",
"marker",
"clippath",
"mask",
"pattern",
"lineargradient",
"radialgradient",
"stop",
"filter",
"style",
"fegaussianblur",
"feoffset",
"feblend",
"fecolormatrix",
"fecomposite",
"feflood",
"femerge",
"femergenode",
"femorphology",
"fedropshadow",
"feturbulence",
"fedisplacementmap",
"fespecularlighting",
"fediffuselighting",
"fepointlight",
"fedistantlight",
"fespotlight",
"fetile",
]);
export interface SvgSanitizerView {
DOMParser: new () => DOMParser;
XMLSerializer: new () => XMLSerializer;
}
/** Only same-document fragment references survive. */
function safeReference(value: string): boolean {
return value.trim().startsWith("#");
}
function safeStyle(value: string): boolean {
const text = value.toLowerCase();
if (text.includes("@import") || text.includes("javascript:") || text.includes("expression("))
return false;
// url() may only point at a fragment inside this same document.
return [...text.matchAll(/url\(\s*['"]?([^'")]*)/g)].every((match) =>
safeReference(match[1] ?? ""),
);
}
function scrub(element: Element): void {
for (const child of [...element.children]) {
if (ELEMENTS.has(child.tagName.toLowerCase())) scrub(child);
else child.remove();
}
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase();
const value = attribute.value;
const unsafe =
name.startsWith("on") ||
(/(^|:)href$/.test(name) && !safeReference(value)) ||
(name === "style" && !safeStyle(value)) ||
/javascript:/i.test(value);
if (unsafe) element.removeAttribute(attribute.name);
}
if (element.tagName.toLowerCase() === "style" && !safeStyle(element.textContent ?? ""))
element.textContent = "";
}
/** Returns sanitised SVG markup, or `undefined` when the source is not an SVG. */
export function sanitizeSvg(source: string, view: SvgSanitizerView): string | undefined {
const parsed = new view.DOMParser().parseFromString(source, "image/svg+xml");
const root = parsed.documentElement as Element | null;
if (!root || root.tagName.toLowerCase() !== "svg") return undefined;
if (parsed.getElementsByTagName("parsererror").length) return undefined;
scrub(root);
// A viewBox is what lets the frame's CSS size the graphic; without one the
// intrinsic size stands, and forcing 100% would stretch a small icon to fill.
if (root.getAttribute("viewBox")) {
root.removeAttribute("width");
root.removeAttribute("height");
root.setAttribute("width", "100%");
}
return new view.XMLSerializer().serializeToString(root);
}
+106
View File
@@ -0,0 +1,106 @@
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { definePlugin } from "@wrnexus/plugin";
import { configureConversion } from "./convert/config.ts";
import type { ConversionOptions } from "./convert/types.ts";
import { configureUploads } from "./uploads/config.ts";
import type { ViewerUploadsOptions } from "./uploads/types.ts";
export interface ViewerPluginOptions {
/**
* Server-side conversion for formats no browser can render. Omitted, the
* plugin mounts no routes and never runs an external process; the affected
* formats keep their download card.
*/
convert?: ConversionOptions;
/**
* Lets `<Viewer key="…">` resolve `@wrnexus/uploader` storage keys. Omitted,
* no routes are mounted and a `key` on its own produces the download card.
*/
uploads?: ViewerUploadsOptions;
}
const here = (path: string) =>
fileURLToPath(
new URL(import.meta.url.endsWith(".js") ? path.replace(/\.ts$/, ".js") : path, import.meta.url),
);
const required = createRequire(import.meta.url);
function resolves(specifier: string): string | undefined {
try {
return required.resolve(specifier);
} catch {
return undefined;
}
}
/**
* Tier 2 parsers with an optional peer dependency. Each is served as its own
* runtime asset and contributed only when its package resolves here, so an app
* that never installs `xlsx` neither builds nor ships it — and the matching
* renderer reports itself unavailable because the asset is not there.
*/
const OPTIONAL = [
{ id: "viewer-sheet", package: "xlsx", entry: "./optional/sheet.ts" },
{ id: "viewer-docx", package: "mammoth", entry: "./optional/docx.ts" },
{ id: "viewer-zip", package: "fflate", entry: "./optional/zip.ts" },
{ id: "viewer-office", package: "fflate", entry: "./optional/office.ts" },
{ id: "viewer-pdfjs", package: "pdfjs-dist", entry: "./optional/pdf.ts" },
];
function optionalRuntimes(): { id: string; entry: string; bundle?: boolean }[] {
const runtimes: { id: string; entry: string; bundle?: boolean }[] = [];
for (const item of OPTIONAL) {
if (!resolves(item.package)) continue;
runtimes.push({ id: item.id, entry: here(item.entry) });
}
// pdf.js is served from its own published build, not bundled: putting its ESM
// entry through a bundler breaks the worker handshake. Both files are already
// browser-ready ESM, and `.mjs` is served verbatim.
const library = resolves("pdfjs-dist/build/pdf.mjs");
const worker = resolves("pdfjs-dist/build/pdf.worker.mjs");
if (library && worker && runtimes.some((runtime) => runtime.id === "viewer-pdfjs")) {
runtimes.push({ id: "viewer-pdfjs-lib", entry: library, bundle: false });
runtimes.push({ id: "viewer-pdfjs-worker", entry: worker, bundle: false });
}
return runtimes;
}
const route = (path: string, entry: string) => ({
kind: "api" as const,
path: `/api/viewer/${path}`,
entry: here(`./routes/${entry}.ts`),
});
function routes(options: ViewerPluginOptions) {
return [
...(options.convert
? [
route("capabilities", "capabilities"),
route("preview", "preview"),
route("thumb", "thumb"),
route("text", "text"),
]
: []),
...(options.uploads ? [route("source", "source"), route("file", "file")] : []),
];
}
export function viewerPlugin(options: ViewerPluginOptions = {}) {
configureConversion(options.convert);
configureUploads(options.uploads);
return definePlugin({
name: "@wrnexus/viewer",
componentDirs: [here("../components")],
clientRuntimes: [
{ id: "viewer-code", entry: here("./code-editor.ts") },
{ id: "viewer", entry: here("./runtime.ts") },
...optionalRuntimes(),
],
styleSources: [{ id: "viewer", entry: here("../viewer.css") }],
// Each feature mounts only its own routes, so enabling one does not publish
// endpoints for the other that could only answer "not enabled".
...(routes(options).length ? { routeEntries: routes(options) } : {}),
});
}
export default viewerPlugin;
+92
View File
@@ -0,0 +1,92 @@
import type { ViewerActionContext } from "./actions.ts";
const ROOT = "data-viewer-print-root";
/**
* Printing what is on screen, not the whole page.
*
* The preview is cloned into a print-only container and every sibling is taken
* out of the printed flow. Hiding the page with `visibility` instead would keep
* its space and print blank pages around the preview.
*
* Three things do not survive a plain clone and are repaired here: a canvas
* loses its bitmap, a collapsed `<details>` hides its content, and the table
* view's spacer rows stand in for thousands of rows that are not in the DOM.
*/
export function canPrint(context: ViewerActionContext): boolean {
// Nothing rendered means nothing to print; the download card is not a preview.
return (
context.content.getAttribute("data-viewer-state") === "ready" &&
context.content.childElementCount > 0
);
}
function printableClone(content: HTMLElement): HTMLElement {
const doc = content.ownerDocument;
const clone = content.cloneNode(true) as HTMLElement;
const originals = content.querySelectorAll("canvas");
const copies = clone.querySelectorAll("canvas");
copies.forEach((canvas, index) => {
const original = originals[index];
if (!original) return;
try {
const image = doc.createElement("img");
image.src = original.toDataURL();
image.style.maxWidth = "100%";
canvas.replaceWith(image);
} catch {
canvas.remove();
}
});
for (const details of clone.querySelectorAll("details")) details.open = true;
for (const spacer of clone.querySelectorAll("[data-viewer-spacer]")) spacer.remove();
return clone;
}
export function printViewer(context: ViewerActionContext): void {
const doc = context.host.ownerDocument;
const view = doc.defaultView;
if (!view) return;
const frame = context.content.querySelector("iframe");
if (frame) {
try {
// Same-origin only: a sandboxed frame has an opaque origin and throws.
const inner = frame.contentWindow;
if (inner) {
inner.focus();
inner.print();
return;
}
} catch {
/* Fall through to printing a clone of the preview. */
}
}
const root = doc.createElement("div");
root.setAttribute(ROOT, "");
const heading = doc.createElement("p");
heading.className = "wrn-viewer-print-title";
heading.textContent = context.source.name || "File preview";
root.append(heading, printableClone(context.content));
doc.body.append(root);
doc.documentElement.setAttribute("data-viewer-printing-active", "");
let done = false;
const cleanup = () => {
if (done) return;
done = true;
root.remove();
doc.documentElement.removeAttribute("data-viewer-printing-active");
view.removeEventListener("afterprint", cleanup);
};
view.addEventListener("afterprint", cleanup);
try {
view.print();
} finally {
// Browsers that return before `afterprint` fires still need cleaning up.
view.setTimeout(cleanup, 1000);
}
}
+259
View File
@@ -0,0 +1,259 @@
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);
}
+38
View File
@@ -0,0 +1,38 @@
import { gunzip, gzipName, isTar, parseTar, type ArchiveEntry } from "../parse/archive.ts";
import { elementFactory, requireFile } from "./dom.ts";
import { entriesSummary, formatSize, renderEntries } from "./table-view.ts";
import type { ViewerMount } from "../types.ts";
const DECOMPRESS_CAP = 64 * 1024 * 1024;
export const mountArchive: ViewerMount = async ({ parent, file, source, signal }) => {
const element = elementFactory(parent.ownerDocument);
const input = requireFile(file);
let bytes: Uint8Array<ArrayBufferLike> = new Uint8Array(await input.arrayBuffer());
if (signal.aborted) return;
let note = "";
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
const inner = gzipName(bytes);
const expanded = await gunzip(input, DECOMPRESS_CAP);
if (signal.aborted) return;
if (!expanded) throw new Error("Compressed listing unavailable");
note = `gzip, ${formatSize(bytes.length)} compressed to ${formatSize(expanded.length)}`;
bytes = expanded;
if (!isTar(bytes)) {
// A plain gzipped file, not an archive: report what it holds.
parent.append(element("p", note));
parent.append(element("p", inner ?? "Single compressed file"));
return;
}
}
if (!isTar(bytes)) throw new Error("Unsupported archive format");
const entries: ArchiveEntry[] = parseTar(bytes);
if (!entries.length) throw new Error("Archive holds no readable entries");
parent.append(
element("p", entriesSummary(entries, note)),
renderEntries(parent.ownerDocument, entries, `Contents of ${source.name}`),
);
};
@@ -0,0 +1,188 @@
import { setButtonLabel } from "./dom.ts";
import { readSource } from "../source.ts";
import type { ViewerSource } from "../types.ts";
import { waveformPeaks } from "./waveform.ts";
const clock = (seconds: number) => {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const whole = Math.floor(seconds);
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
};
/**
* One transport for audio instead of two.
*
* The native controls and a separate seek slider were both on screen, each able to
* move the same playhead. This is a single row — play, elapsed, a seekable
* track, duration, mute — with the waveform drawn *into* the track so the
* envelope and the progress are the same control rather than two stacked bars.
*/
export function mountAudioPlayer(
parent: HTMLElement,
audio: HTMLMediaElement,
source: ViewerSource,
signal: AbortSignal,
): () => void {
const doc = parent.ownerDocument;
const view = doc.defaultView;
const make = <K extends keyof HTMLElementTagNameMap>(tag: K, className?: string) => {
const node = doc.createElement(tag);
if (className) node.className = className;
return node;
};
const shell = make("div", "wrn-viewer-player");
const transport = make("div", "wrn-viewer-player-transport");
const play = make("button", "wrn-viewer-action wrn-viewer-play");
play.type = "button";
setButtonLabel(play, "Play", "play");
const elapsed = make("span", "wrn-viewer-time");
elapsed.textContent = "0:00";
const duration = make("span", "wrn-viewer-time");
duration.textContent = "0:00";
const mute = make("button", "wrn-viewer-action");
mute.type = "button";
setButtonLabel(mute, "Mute", "volume");
const track = make("div", "wrn-viewer-track");
const canvas = make("canvas", "wrn-viewer-waveform");
canvas.setAttribute("aria-hidden", "true");
const progress = make("div", "wrn-viewer-progress");
const seek = make("input", "wrn-viewer-seek");
seek.type = "range";
seek.min = "0";
seek.max = "1000";
seek.value = "0";
seek.setAttribute("aria-label", `Seek ${source.name || "audio"}`);
track.append(canvas, progress, seek);
transport.append(play, elapsed, track, duration, mute);
const note = make("p", "wrn-viewer-note");
note.setAttribute("role", "status");
shell.append(transport, note);
parent.append(shell);
const BINS = 200;
let envelope: number[] = [];
/**
* Drawn at device resolution and redrawn on resize: a fixed bitmap stretched
* to the track is what made the bars look soft and blobby.
*/
const drawPeaks = (peaks = envelope) => {
envelope = peaks;
const box = canvas.getBoundingClientRect();
const ratio = Math.min(view?.devicePixelRatio ?? 1, 2);
const width = Math.max(1, Math.round(box.width * ratio));
const height = Math.max(1, Math.round(box.height * ratio));
if (!peaks.length || width < 2) return;
canvas.width = width;
canvas.height = height;
const draw = canvas.getContext("2d");
if (!draw) return;
const styles = view!.getComputedStyle(shell);
draw.fillStyle = styles.getPropertyValue("--wrn-color-primary").trim() || styles.color;
// Normalised against the loudest bin, so a quiet recording still reads as a
// waveform rather than a flat line down the middle.
const loudest = Math.max(...peaks, 0.0001);
const step = width / peaks.length;
const bar = Math.max(1 * ratio, Math.min(3 * ratio, step * 0.55));
const radius = bar / 2;
peaks.forEach((peak, i) => {
const size = Math.max(bar, (peak / loudest) * height * 0.9);
const x = i * step + (step - bar) / 2;
const y = (height - size) / 2;
draw.beginPath();
// Rounded caps read as a waveform; square blocks read as a bar chart.
if (draw.roundRect) draw.roundRect(x, y, bar, size, radius);
else draw.rect(x, y, bar, size);
draw.fill();
});
};
const setProgress = () => {
const ratio =
Number.isFinite(audio.duration) && audio.duration > 0
? audio.currentTime / audio.duration
: 0;
progress.style.width = `${ratio * 100}%`;
seek.value = String(Math.round(ratio * 1000));
elapsed.textContent = clock(audio.currentTime);
};
const setDuration = () => {
duration.textContent = clock(audio.duration);
};
const setPlayState = () => {
setButtonLabel(play, audio.paused ? "Play" : "Pause", audio.paused ? "play" : "pause");
};
play.addEventListener("click", () => {
if (audio.paused) void audio.play().catch(() => {});
else audio.pause();
});
mute.addEventListener("click", () => {
audio.muted = !audio.muted;
setButtonLabel(mute, audio.muted ? "Unmute" : "Mute", audio.muted ? "muted" : "volume");
});
seek.addEventListener("input", () => {
if (!Number.isFinite(audio.duration)) return;
audio.currentTime = (Number(seek.value) / 1000) * audio.duration;
setProgress();
});
audio.addEventListener("timeupdate", setProgress);
audio.addEventListener("loadedmetadata", setDuration);
audio.addEventListener("durationchange", setDuration);
audio.addEventListener("play", setPlayState);
audio.addEventListener("pause", setPlayState);
audio.addEventListener("ended", setPlayState);
setDuration();
setProgress();
setPlayState();
// The envelope is drawn once metadata is in and decoding stays bounded; a
// file too long or too large simply keeps a flat track.
let disposed = false;
let context: AudioContext | undefined;
void (async () => {
try {
if (!view?.AudioContext) return;
await new Promise<void>((done) => {
if (Number.isFinite(audio.duration)) done();
else audio.addEventListener("loadedmetadata", () => done(), { once: true });
});
if (disposed || !Number.isFinite(audio.duration) || audio.duration > 600) return;
const file = await readSource(source, 8 * 1024 * 1024, { signal });
if (disposed) return;
context = new view.AudioContext();
const decoded = await context.decodeAudioData(await file.arrayBuffer());
if (disposed) return;
const peaks = waveformPeaks(
Array.from({ length: decoded.numberOfChannels }, (_, i) => decoded.getChannelData(i)),
BINS,
);
drawPeaks(peaks);
canvas.classList.add("is-ready");
} catch {
/* A flat track is a fine outcome; playback is unaffected. */
} finally {
await context?.close().catch(() => {});
context = undefined;
}
})();
const observer =
view && "ResizeObserver" in view ? new view.ResizeObserver(() => drawPeaks()) : undefined;
observer?.observe(track);
return () => {
disposed = true;
observer?.disconnect();
audio.removeEventListener("timeupdate", setProgress);
audio.removeEventListener("loadedmetadata", setDuration);
audio.removeEventListener("durationchange", setDuration);
audio.removeEventListener("play", setPlayState);
audio.removeEventListener("pause", setPlayState);
audio.removeEventListener("ended", setPlayState);
void context?.close().catch(() => {});
shell.remove();
};
}
+48
View File
@@ -0,0 +1,48 @@
import { elementFactory, requireFile } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
/** Served separately by `viewerPlugin`; fetched only when a text preview opens. */
const editorAsset = "/__wrnexus/assets/viewer-code.js";
export const mountCode: ViewerMount = async ({ parent, file, source, button, signal }) => {
const doc = parent.ownerDocument;
const element = elementFactory(doc);
const text = await requireFile(file).text();
if (signal.aborted) return;
// Plain text first: never a parse, never markup, and correct with no editor.
const plain = element("pre", text);
parent.append(plain);
try {
const { mountBuilderSourceEditor } = (await import(
new URL(editorAsset, doc.baseURI).href
)) as typeof import("@wrnexus/block-editor/source-editor");
if (signal.aborted) return;
const host = element("div");
host.className = "wrn-viewer-code";
parent.replaceChildren(host);
const editor = await mountBuilderSourceEditor(
host,
/\.(xml|html|htm|svg)$/i.test(source.name) ? "xml" : "text",
text,
{ label: source.name || "File source", theme: "tokens" },
);
if (signal.aborted) {
editor?.destroy();
return;
}
if (!editor) {
parent.replaceChildren(plain);
return;
}
let wrapped = false;
const wrap = button("Wrap lines", () => {
wrapped = !wrapped;
editor.wrap(wrapped);
wrap.setAttribute("aria-pressed", String(wrapped));
});
wrap.setAttribute("aria-pressed", "false");
return () => editor.destroy();
} catch {
if (!signal.aborted) parent.replaceChildren(plain);
}
};
@@ -0,0 +1,56 @@
import { convertUrl, requireConverter } from "../convert/client.ts";
import { extensionOf } from "../kinds.ts";
import { readBounded } from "../source.ts";
import { elementFactory } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
const MAX_TEXT_BYTES = 2 * 1024 * 1024;
/**
* Renders what the server produced rather than the original bytes. The output
* is same-origin, so the element points straight at the conversion endpoint and
* the browser streams it under the app's own policy.
*/
export const mountConverted: ViewerMount = async ({ parent, source, signal, degrade }) => {
const element = elementFactory(parent.ownerDocument);
const url = convertUrl(source, "preview", parent.ownerDocument.baseURI);
const mime = await requireConverter([extensionOf(source.name)], {
baseURI: parent.ownerDocument.baseURI,
});
if (!url) throw new Error("Conversion needs a URL source");
if (signal.aborted) return;
const note = (text: string) => {
const line = element("p", text);
line.className = "wrn-viewer-note";
return line;
};
if (mime.startsWith("text/")) {
// Listings are small and are shown inline. The cap is applied to the stream,
// not to the decoded string: slicing afterwards has already read it all.
const response = await fetch(url, { signal });
if (!response.ok) throw new Error("Conversion failed");
const text = new TextDecoder().decode(await readBounded(response, MAX_TEXT_BYTES, true));
if (signal.aborted) return;
parent.append(note(`Listed by the server from ${source.name}.`), element("pre", text));
return;
}
if (mime.startsWith("image/")) {
const image = element("img");
image.alt = `Converted preview of ${source.name}`;
image.src = url;
image.addEventListener("error", () => degrade("This file could not be converted"), {
once: true,
});
parent.append(image, note(`Rendered by the server from ${source.name}.`));
return;
}
const frame = element("iframe");
frame.src = url;
frame.className = "wrn-viewer-frame wrn-viewer-pdf";
frame.setAttribute("title", `Converted preview of ${source.name}`);
parent.append(frame, note("Converted by the server. Use Download for the original file."));
};
+36
View File
@@ -0,0 +1,36 @@
import { loadOptional } from "../optional/load.ts";
import type { docxToHtml } from "../optional/docx.ts";
import { requireFile } from "./dom.ts";
import { sandboxFrame } from "./frame.ts";
import type { ViewerMount } from "../types.ts";
/** Mammoth may emit inline images as data URLs; nothing else may load. */
const POLICY = "default-src 'none'; style-src 'unsafe-inline'; img-src data:";
const CSS =
"body{padding:1.5rem;font:16px/1.6 system-ui,sans-serif}" +
"img{max-width:100%;height:auto}table{border-collapse:collapse}" +
"td,th{border:1px solid currentColor;padding:.25rem .5rem}";
export const mountDocx: ViewerMount = async ({ parent, file, source, signal }) => {
const { docxToHtml: convert } = await loadOptional<{ docxToHtml: typeof docxToHtml }>(
"viewer-docx",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
const html = await convert(bytes);
if (signal.aborted) return;
if (!html.trim()) throw new Error("Document produced no readable content");
// Converted markup is derived from an untrusted file, so it is framed under a
// policy that permits no script and no network, exactly like the SVG preview.
parent.append(
sandboxFrame(parent, {
title: source.name || "Document preview",
policy: POLICY,
body: html,
css: CSS,
}),
);
};
+35
View File
@@ -0,0 +1,35 @@
import { icon, iconFor } from "./icons.ts";
/** Element factory bound to a host document; never parses strings into nodes. */
export function elementFactory(doc: Document) {
return <K extends keyof HTMLElementTagNameMap>(
tag: K,
text?: string,
): HTMLElementTagNameMap[K] => {
const node = doc.createElement(tag);
if (text !== undefined) node.textContent = text;
return node;
};
}
/** Renderers that parse bytes are never `streaming`, so the body is present. */
export function requireFile(file: Blob | undefined): Blob {
if (!file) throw new Error("This renderer needs the file body");
return file;
}
/**
* Toolbar buttons carry an icon plus a label that is read out but not shown, so
* assigning `textContent` would delete the icon. Toggling a label goes through
* here instead, which also swaps the icon to match.
*/
export function setButtonLabel(node: HTMLElement, label: string, iconName?: string): void {
const doc = node.ownerDocument;
node.setAttribute("aria-label", label);
node.setAttribute("data-tooltip", label);
node.replaceChildren(icon(doc, iconName ?? iconFor(label)));
const text = doc.createElement("span");
text.className = "wrn-viewer-sr";
text.textContent = label;
node.append(text);
}
+55
View File
@@ -0,0 +1,55 @@
import { elementFactory } from "./dom.ts";
export interface SandboxFrameOptions {
title: string;
/** Content-Security-Policy for the framed document. */
policy: string;
/** Markup placed inside the frame. Must already be sanitised. */
body: string;
/** Extra CSS for the framed document. */
css?: string;
}
const SAFE_COLOR = /^[#a-z0-9(),.%\s/-]+$/i;
/**
* A frame with every sandbox restriction on, carrying its own CSP.
*
* The framed document is a separate document, so theme tokens and
* `currentColor` do not reach it and Chrome paints its own opaque base
* background rather than letting the element's background show. Both the
* resolved text and surface colours are therefore copied in by value.
*/
export function sandboxFrame(host: HTMLElement, options: SandboxFrameOptions): HTMLIFrameElement {
const doc = host.ownerDocument;
const element = elementFactory(doc);
const view = doc.defaultView;
const computed = (node: Element) => view?.getComputedStyle?.(node);
const safe = (value: string, fallback: string) => (SAFE_COLOR.test(value) ? value : fallback);
const color = safe(computed(host)?.color ?? "", "#000");
let background = "#fff";
for (let node: Element | null = host; node; node = node.parentElement) {
const value = computed(node)?.backgroundColor ?? "";
if (value && !/^(transparent|rgba\(0, *0, *0, *0\))$/.test(value)) {
background = safe(value, "#fff");
break;
}
}
const frame = element("iframe");
frame.setAttribute("sandbox", "");
frame.setAttribute("title", options.title);
frame.className = "wrn-viewer-frame";
frame.setAttribute(
"srcdoc",
[
'<meta charset="utf-8">',
`<meta http-equiv="Content-Security-Policy" content="${options.policy}">`,
`<style>html,body{margin:0;height:100%;color:${color};background:${background}}` +
`${options.css ?? ""}</style>`,
options.body,
].join(""),
);
return frame;
}
+28
View File
@@ -0,0 +1,28 @@
import { sanitizeHtml } from "../parse/html.ts";
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { sandboxFrame } from "./frame.ts";
import type { ViewerMount } from "../types.ts";
export const mountHtml: ViewerMount = async ({ parent, file, source, button, signal }) => {
const text = await requireFile(file).text();
if (signal.aborted) return;
const view = parent.ownerDocument.defaultView;
if (!view) throw new Error("HTML preview requires a document");
const frame = sandboxFrame(parent, {
title: source.name || "HTML preview",
policy:
"default-src 'none'; base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:",
body: sanitizeHtml(text, view),
css: "body{padding:1rem;box-sizing:border-box;font:16px/1.5 system-ui}img{max-width:100%}",
});
const raw = elementFactory(parent.ownerDocument)("pre", text);
let sourceView = false;
const toggle = button("Source", () => {
sourceView = !sourceView;
setButtonLabel(toggle, sourceView ? "Preview" : "Source");
toggle.setAttribute("aria-pressed", String(sourceView));
parent.replaceChildren(sourceView ? raw : frame);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(frame);
};
+133
View File
@@ -0,0 +1,133 @@
/**
* Toolbar icons, drawn as SVG nodes rather than markup strings: the framework
* enables Trusted Types in production, where `innerHTML` is refused outright.
*
* One line-drawn family at 24×24 on `currentColor`, so every icon inherits the
* app's theme with no palette of its own.
*/
type Shape = [tag: string, attributes: Record<string, string>];
const path = (d: string): Shape => ["path", { d }];
export const ICONS: Record<string, Shape[]> = {
"zoom-in": [
["circle", { cx: "11", cy: "11", r: "7" }],
path("M21 21l-4.3-4.3"),
path("M8 11h6"),
path("M11 8v6"),
],
"zoom-out": [
["circle", { cx: "11", cy: "11", r: "7" }],
path("M21 21l-4.3-4.3"),
path("M8 11h6"),
],
rotate: [path("M21 12a9 9 0 1 1-3-6.7"), path("M21 4v5h-5")],
reset: [path("M3 12a9 9 0 1 0 3-6.7"), path("M3 4v5h5")],
fullscreen: [path("M8 3H3v5"), path("M21 8V3h-5"), path("M16 21h5v-5"), path("M3 16v5h5")],
download: [path("M12 3v12"), path("M7 12l5 5 5-5"), path("M4 21h16")],
print: [
path("M7 8V3h10v5"),
path("M7 18H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2"),
["rect", { x: "7", y: "15", width: "10", height: "6", rx: "1" }],
],
previous: [path("M15 5l-7 7 7 7")],
next: [path("M9 5l7 7-7 7")],
top: [path("M12 20V6"), path("M6 11l6-6 6 6")],
source: [path("M9 6l-6 6 6 6"), path("M15 6l6 6-6 6")],
preview: [
path("M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7Z"),
["circle", { cx: "12", cy: "12", r: "3" }],
],
table: [
["rect", { x: "3", y: "4", width: "18", height: "16", rx: "2" }],
path("M3 10h18"),
path("M9 10v10"),
],
tree: [path("M5 4v12a2 2 0 0 0 2 2h12"), path("M9 8h11"), path("M13 18h7")],
raw: [path("M4 5h16"), path("M4 10h16"), path("M4 15h10"), path("M4 20h7")],
wrap: [
path("M4 6h16"),
path("M4 12h13a3 3 0 0 1 0 6h-4"),
path("M16 15l-3 3 3 3"),
path("M4 18h4"),
],
waveform: [path("M3 12h2"), path("M8 7v10"), path("M12 4v16"), path("M16 8v8"), path("M20 11h1")],
sheets: [
["rect", { x: "3", y: "3", width: "13", height: "13", rx: "2" }],
path("M8 21h11a2 2 0 0 0 2-2V8"),
],
text: [path("M5 4h14"), path("M12 4v16"), path("M8 20h8")],
play: [path("M7 4.5v15l12-7.5Z")],
close: [path("M6 6l12 12"), path("M18 6L6 18")],
expand: [path("M9 3H3v6"), path("M21 15v6h-6"), path("M3 3l7 7"), path("M21 21l-7-7")],
pause: [path("M9 4v16"), path("M15 4v16")],
volume: [
path("M11 5 6.5 9H3v6h3.5L11 19Z"),
path("M16 9.5a3.5 3.5 0 0 1 0 5"),
path("M18.5 7a7 7 0 0 1 0 10"),
],
muted: [path("M11 5 6.5 9H3v6h3.5L11 19Z"), path("M17 10l4 4"), path("M21 10l-4 4")],
file: [path("M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"), path("M14 3v5h5")],
slides: [
["rect", { x: "3", y: "4", width: "18", height: "12", rx: "2" }],
path("M12 16v4"),
path("M8 20h8"),
],
};
/** Toolbar labels are stable strings, so the icon can be derived from them. */
const BY_LABEL: Record<string, string> = {
"zoom in": "zoom-in",
"zoom out": "zoom-out",
rotate: "rotate",
reset: "reset",
fullscreen: "fullscreen",
download: "download",
print: "print",
previous: "previous",
next: "next",
top: "top",
source: "source",
preview: "preview",
table: "table",
raw: "raw",
tree: "tree",
text: "text",
"wrap lines": "wrap",
"show waveform": "waveform",
"hide waveform": "waveform",
sheets: "sheets",
play: "play",
pause: "pause",
close: "close",
expand: "expand",
collapse: "close",
mute: "volume",
unmute: "muted",
"extract text": "text",
"hide text": "text",
};
export function iconFor(label: string): string {
return BY_LABEL[label.trim().toLowerCase()] ?? "file";
}
export function icon(doc: Document, name: string): SVGSVGElement {
const NS = "http://www.w3.org/2000/svg";
const svg = doc.createElementNS(NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.75");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
svg.classList.add("wrn-viewer-icon");
for (const [tag, attributes] of ICONS[name] ?? ICONS.file!) {
const shape = doc.createElementNS(NS, tag);
for (const [key, value] of Object.entries(attributes)) shape.setAttribute(key, value);
svg.append(shape);
}
return svg;
}
+74
View File
@@ -0,0 +1,74 @@
import { elementFactory } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
export const mountImage: ViewerMount = async ({
parent,
source,
sourceUrl,
button,
degrade,
signal,
}) => {
const element = elementFactory(parent.ownerDocument);
const url = await sourceUrl();
if (signal.aborted) return;
const stage = element("div");
stage.className = "wrn-viewer-stage";
const image = element("img");
image.alt = source.name;
image.src = url;
image.draggable = false;
image.addEventListener("error", () => degrade("Image could not be displayed"), { once: true });
// A readout, because four zoom buttons with no feedback leave you guessing.
const readout = element("span", "100%");
readout.className = "wrn-viewer-zoom";
stage.append(image, readout);
parent.append(stage);
let zoom = 1;
let rotation = 0;
const update = () => {
image.style.width = zoom === 1 ? "" : `${zoom * 100}%`;
image.style.maxWidth = zoom === 1 ? "" : "none";
image.style.transform = rotation ? `rotate(${rotation}deg)` : "";
readout.textContent = `${Math.round(zoom * 100)}%${rotation ? ` · ${rotation}°` : ""}`;
};
button("Zoom in", () => {
zoom = Math.min(4, zoom + 0.25);
update();
});
button("Zoom out", () => {
zoom = Math.max(0.25, zoom - 0.25);
update();
});
button("Rotate", () => {
rotation = (rotation + 90) % 360;
update();
});
button("Reset", () => {
zoom = 1;
rotation = 0;
update();
});
// Native scrolling supports keyboard/touch panning; pointer drag supports mouse.
parent.tabIndex = 0;
let drag: { x: number; y: number; left: number; top: number } | undefined;
parent.onpointerdown = (event) => {
drag = { x: event.clientX, y: event.clientY, left: parent.scrollLeft, top: parent.scrollTop };
parent.setPointerCapture(event.pointerId);
};
parent.onpointermove = (event) => {
if (drag) {
parent.scrollLeft = drag.left - event.clientX + drag.x;
parent.scrollTop = drag.top - event.clientY + drag.y;
}
};
parent.onpointerup = parent.onpointercancel = () => {
drag = undefined;
};
return () => {
parent.onpointerdown = parent.onpointermove = parent.onpointerup = null;
parent.onpointercancel = null;
};
};
+56
View File
@@ -0,0 +1,56 @@
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { branch, leaf } from "./tree.ts";
import type { ViewerMount } from "../types.ts";
const preview = (value: unknown): string =>
Array.isArray(value)
? `[] ${value.length} item${value.length === 1 ? "" : "s"}`
: `{} ${Object.keys(value as object).length} key${
Object.keys(value as object).length === 1 ? "" : "s"
}`;
function render(doc: Document, key: string, value: unknown, depth: number): HTMLElement {
if (value === null || typeof value !== "object")
return leaf(
doc,
key,
typeof value === "string" ? JSON.stringify(value) : String(value),
`wrn-viewer-value wrn-viewer-${value === null ? "null" : typeof value}`,
);
const entries: [string, unknown][] = Array.isArray(value)
? value.map((item, index) => [String(index), item])
: Object.entries(value as object);
return branch(
doc,
`${key ? `${key} ` : ""}${preview(value)}`,
"wrn-viewer-key",
(into) => {
for (const [childKey, child] of entries) into.append(render(doc, childKey, child, depth + 1));
},
depth < 1,
);
}
export const mountJson: ViewerMount = async ({ parent, file, button, signal }) => {
const doc = parent.ownerDocument;
const element = elementFactory(doc);
const text = await requireFile(file).text();
if (signal.aborted) return;
// A parse failure throws, and the ladder falls through to the source view.
const data = JSON.parse(text) as unknown;
const tree = element("div");
tree.className = "wrn-viewer-tree";
tree.append(render(doc, "", data, 0));
const raw = element("pre", text);
let showingRaw = false;
const toggle = button("Raw", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Tree" : "Raw");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(showingRaw ? raw : tree);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(tree);
};
+41
View File
@@ -0,0 +1,41 @@
import { elementFactory } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
import { mountAudioPlayer } from "./audio-player.ts";
export const mountMedia: ViewerMount = async ({
parent,
source,
sourceUrl,
kind,
degrade,
signal,
}) => {
const element = elementFactory(parent.ownerDocument);
const url = await sourceUrl();
if (signal.aborted) return;
const media = element(kind === "audio" ? "audio" : "video");
// Audio gets the package's own transport, so the native controls would be a
// second set of the same buttons driving the same playhead.
media.controls = kind !== "audio";
media.preload = "metadata";
media.src = url;
media.setAttribute("aria-label", source.name);
media.addEventListener("error", () => degrade("This browser cannot play this file"), {
once: true,
});
if (kind === "video") {
// Video shares the image's stage: a bounded, framed surface rather than a
// bare element stretched into the panel.
const stage = element("div");
stage.className = "wrn-viewer-stage";
stage.append(media);
parent.append(stage);
} else parent.append(media);
const stopPlayer = kind === "audio" ? mountAudioPlayer(parent, media, source, signal) : undefined;
return () => {
stopPlayer?.();
media.pause();
media.removeAttribute("src");
media.load();
};
};
+76
View File
@@ -0,0 +1,76 @@
import { loadOptional } from "../optional/load.ts";
import type { OfficeDocument, readOdf, readPptx } from "../optional/office.ts";
import { elementFactory, requireFile } from "./dom.ts";
import { renderWorkbook } from "./workbook.ts";
import { renderSlides, type SlideDeckView } from "./slides.ts";
import type { ViewerMount, ViewerMountContext } from "../types.ts";
interface OfficeModule {
readPptx: typeof readPptx;
readOdf: typeof readOdf;
}
function parser(parent: HTMLElement): { DOMParser: new () => DOMParser } {
const view = parent.ownerDocument.defaultView;
if (!view?.DOMParser) throw new Error("No XML parser available");
return view as unknown as { DOMParser: new () => DOMParser };
}
/** Shared presentation of a parsed office document. */
async function show(context: ViewerMountContext, document: OfficeDocument) {
const { parent, signal } = context;
const element = elementFactory(parent.ownerDocument);
if (signal.aborted) return;
if (document.type === "slides") {
// The deck carries its own pager, so toolbar arrows would be a second set
// of the same two controls — and they push the toolbar onto two rows.
const deck: SlideDeckView = renderSlides(parent.ownerDocument, document.slides);
parent.append(deck.node);
return () => deck.dispose();
}
if (document.type === "sheets") {
return renderWorkbook(parent, document.sheets);
}
const article = element("article");
article.className = "wrn-viewer-document";
for (const block of document.blocks) {
if (block.level > 0) {
const heading = element(
(["h1", "h2", "h3", "h4", "h5", "h6"] as const)[Math.min(block.level, 6) - 1]!,
block.text,
);
article.append(heading);
} else if (block.list) {
const previous = article.lastElementChild;
const list = previous?.tagName === "UL" ? previous : element("ul");
list.append(element("li", block.text));
if (list !== previous) article.append(list);
} else article.append(element("p", block.text));
}
parent.append(article);
}
export const mountPresentation: ViewerMount = async (context) => {
const { parent, file, signal } = context;
const { readPptx: read } = await loadOptional<OfficeModule>(
"viewer-office",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
return show(context, await read(bytes, parser(parent)));
};
export const mountOpenDocument: ViewerMount = async (context) => {
const { parent, file, signal } = context;
const { readOdf: read } = await loadOptional<OfficeModule>(
"viewer-office",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
return show(context, await read(bytes, parser(parent)));
};
+62
View File
@@ -0,0 +1,62 @@
import { loadOptional } from "../optional/load.ts";
import type { renderPdf } from "../optional/pdf.ts";
import { elementFactory, requireFile } from "./dom.ts";
import type { Renderer, ViewerMount } from "../types.ts";
const LIB_ASSET = "/__wrnexus/assets/viewer-pdfjs-lib.js";
const WORKER_ASSET = "/__wrnexus/assets/viewer-pdfjs-worker.js";
const mountRichPdf: ViewerMount = async ({ parent, file, button, signal }) => {
const element = elementFactory(parent.ownerDocument);
const { renderPdf: render } = await loadOptional<{ renderPdf: typeof renderPdf }>(
"viewer-pdfjs",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
const pages = element("div");
pages.className = "wrn-viewer-pages";
parent.append(pages);
const base = parent.ownerDocument.baseURI;
const result = await render(pages, bytes, {
signal,
libSrc: new URL(LIB_ASSET, base).href,
workerSrc: new URL(WORKER_ASSET, base).href,
});
if (signal.aborted) {
result.destroy();
return;
}
const status = element(
"p",
result.rendered < result.pages
? `Showing ${result.rendered} of ${result.pages} pages. Use Download for the rest.`
: `${result.pages} page${result.pages === 1 ? "" : "s"}`,
);
status.className = "wrn-viewer-note";
parent.append(status);
button("Top", () => pages.firstElementChild?.scrollIntoView({ block: "start" }));
return result.destroy;
};
/**
* Opt-in pdf.js rendering. The native frame is the default because it costs
* nothing; this trades roughly a megabyte of script for page count, controlled
* sizing and consistent output across browsers.
*
* ```ts
* registerRenderer(richPdfRenderer());
* ```
*/
export function richPdfRenderer(options: { maxInlineBytes?: number } = {}): Renderer {
return {
kind: "pdf",
exts: [".pdf"],
tier: 2,
component: "RichPdfViewer",
maxInlineBytes: options.maxInlineBytes ?? 32 * 1024 * 1024,
load: (options) => loadOptional("viewer-pdfjs", options?.baseURI),
mount: mountRichPdf,
};
}
+25
View File
@@ -0,0 +1,25 @@
import { directUrl } from "../source.ts";
import { elementFactory } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
/**
* A frame, not an `<object>`: `object-src` is commonly `'none'` while frames
* fall back to `default-src`, so a same-origin PDF loads under the app's own
* policy instead of being blocked.
*/
export const mountPdf: ViewerMount = async ({ parent, source, sourceUrl, signal }) => {
const element = elementFactory(parent.ownerDocument);
// The original URL wins over an object URL whenever there is one. Reaching
// here as a fallback — after a richer renderer failed — means a body was
// already read, and framing that `blob:` is refused by the common
// `default-src 'self'` policy, leaving an empty frame.
const url = directUrl(source) ?? (await sourceUrl());
if (signal.aborted) return;
const frame = element("iframe");
frame.src = url;
frame.className = "wrn-viewer-frame wrn-viewer-pdf";
frame.setAttribute("title", source.name || "PDF preview");
const note = element("p", "If the document does not appear, use Download to open it.");
note.className = "wrn-viewer-note";
parent.append(frame, note);
};
+28
View File
@@ -0,0 +1,28 @@
import { rtfToText } from "../parse/rtf.ts";
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
/** RTF needs no dependency at all: it is text once the control words are gone. */
export const mountRtf: ViewerMount = async ({ parent, file, button, signal }) => {
const element = elementFactory(parent.ownerDocument);
const source = await requireFile(file).text();
if (signal.aborted) return;
const text = rtfToText(source);
if (!text) throw new Error("Document holds no readable text");
const article = element("article");
article.className = "wrn-viewer-document";
for (const paragraph of text.split(/\n{2,}/))
for (const line of paragraph.split("\n")) article.append(element("p", line));
const raw = element("pre", source);
let showingRaw = false;
const toggle = button("Source", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Text" : "Source");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(showingRaw ? raw : article);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(article);
};
+17
View File
@@ -0,0 +1,17 @@
import { loadOptional } from "../optional/load.ts";
import type { readWorkbook, SheetData } from "../optional/sheet.ts";
import { requireFile } from "./dom.ts";
import { renderWorkbook } from "./workbook.ts";
import type { ViewerMount } from "../types.ts";
export const mountSheet: ViewerMount = async ({ parent, file, signal }) => {
const { readWorkbook: parse } = await loadOptional<{ readWorkbook: typeof readWorkbook }>(
"viewer-sheet",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
const sheets: SheetData[] = parse(bytes);
if (!sheets.length) throw new Error("Workbook holds no sheets");
return renderWorkbook(parent, sheets);
};
+141
View File
@@ -0,0 +1,141 @@
import { elementFactory } from "./dom.ts";
import { icon } from "./icons.ts";
import type { Slide } from "../optional/office.ts";
/**
* Slide-by-slide reader shared by `.pptx` and `.odp`. Content and order, not a
* faithful layout: the converter's PDF stays preferred wherever it is reachable.
*/
export interface SlideDeckView {
node: HTMLElement;
/** Toolbar label describing the deck, e.g. "3 of 12". */
status: () => string;
go: (index: number) => void;
next: (step: number) => void;
dispose: () => void;
}
/** Beyond this many slides a row of dots stops being readable. */
const MAX_DOTS = 20;
export function renderSlides(
doc: Document,
slides: Slide[],
/** Called after every move, however it was made. */
onChange?: (index: number) => void,
): SlideDeckView {
const element = elementFactory(doc);
const wrapper = element("div");
wrapper.className = "wrn-viewer-slides";
// A deck is one control: focus it once and the arrow keys drive it.
wrapper.tabIndex = 0;
wrapper.setAttribute("role", "group");
wrapper.setAttribute("aria-roledescription", "slide deck");
wrapper.setAttribute("aria-label", `${slides.length} slides. Use the arrow keys to move.`);
const stage = element("article");
stage.className = "wrn-viewer-slide";
stage.setAttribute("aria-live", "polite");
const pager = element("div");
pager.className = "wrn-viewer-pager";
const back = element("button");
back.type = "button";
back.className = "wrn-viewer-pager-step";
back.setAttribute("aria-label", "Previous slide");
back.append(icon(doc, "previous"));
const forward = element("button");
forward.type = "button";
forward.className = "wrn-viewer-pager-step";
forward.setAttribute("aria-label", "Next slide");
forward.append(icon(doc, "next"));
const dots = element("div");
dots.className = "wrn-viewer-dots";
const count = element("span");
count.className = "wrn-viewer-pager-count";
let current = 0;
const useDots = slides.length <= MAX_DOTS;
const marks = useDots
? slides.map((slide, index) => {
const dot = element("button");
dot.type = "button";
dot.className = "wrn-viewer-dot";
dot.title = slide.title || `Slide ${index + 1}`;
dot.setAttribute(
"aria-label",
`Slide ${index + 1}${slide.title ? `: ${slide.title}` : ""}`,
);
dot.addEventListener("click", () => go(index));
dots.append(dot);
return dot;
})
: [];
pager.append(back, useDots ? dots : count, forward);
if (useDots) pager.append(count);
function go(index: number): void {
current = Math.max(0, Math.min(slides.length - 1, index));
const slide = slides[current]!;
marks.forEach((dot, position) => {
dot.setAttribute("aria-current", String(position === current));
dot.tabIndex = position === current ? 0 : -1;
});
count.textContent = `${current + 1} / ${slides.length}`;
back.disabled = current === 0;
forward.disabled = current === slides.length - 1;
stage.replaceChildren();
stage.append(element("h2", slide.title || `Slide ${current + 1}`));
// The title shape is already the heading; render the rest as its body.
slide.blocks.forEach((lines, position) => {
const body = position === 0 ? lines.slice(1) : lines;
if (!body.length) return;
const list = element("ul");
for (const line of body) list.append(element("li", line));
stage.append(list);
});
if (slide.notes) {
const notes = element("details");
notes.append(element("summary", "Presenter notes"), element("p", slide.notes));
notes.className = "wrn-viewer-slide-notes";
stage.append(notes);
}
marks[current]?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
// Every route into the deck ends here, so a caller that subscribes cannot
// miss a move — keyboard navigation included.
onChange?.(current);
}
back.addEventListener("click", () => go(current - 1));
forward.addEventListener("click", () => go(current + 1));
const onKey = (event: KeyboardEvent) => {
const keys: Record<string, number> = {
ArrowRight: current + 1,
PageDown: current + 1,
ArrowDown: current + 1,
ArrowLeft: current - 1,
PageUp: current - 1,
ArrowUp: current - 1,
Home: 0,
End: slides.length - 1,
};
const target = keys[event.key];
if (target === undefined) return;
event.preventDefault();
go(target);
};
wrapper.addEventListener("keydown", onKey);
wrapper.append(stage, pager);
go(0);
return {
node: wrapper,
status: () => `${current + 1} of ${slides.length}`,
go,
next: (step) => go(current + step),
dispose: () => wrapper.removeEventListener("keydown", onKey),
};
}
+42
View File
@@ -0,0 +1,42 @@
import { sanitizeSvg } from "../parse/svg.ts";
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { sandboxFrame } from "./frame.ts";
import type { ViewerMount } from "../types.ts";
/** No network, no scripts, no plugins: only inline styles may apply. */
const POLICY = "default-src 'none'; style-src 'unsafe-inline'";
export const mountSvg: ViewerMount = async ({ parent, file, source, button, signal }) => {
const doc = parent.ownerDocument;
const view = doc.defaultView;
const element = elementFactory(doc);
const text = await requireFile(file).text();
if (signal.aborted) return;
if (!view?.DOMParser || !view.XMLSerializer) throw new Error("No XML parser available");
const safe = sanitizeSvg(text, view);
if (!safe) throw new Error("Not a valid SVG document");
// Sanitised markup still renders inside a sandboxed frame: two layers, so a
// gap in either one is not on its own enough to run anything.
const frame = sandboxFrame(parent, {
title: source.name || "SVG preview",
policy: POLICY,
body: safe,
// Centred and contained: a small icon should not be blown up to the full
// height of the frame just because the frame is tall.
css:
"html,body{display:flex;align-items:center;justify-content:center;padding:1rem;box-sizing:border-box}" +
"svg{display:block;max-width:100%;max-height:100%}",
});
const raw = element("pre", text);
let showingRaw = false;
const toggle = button("Source", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Preview" : "Source");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(showingRaw ? raw : frame);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(frame);
};
+181
View File
@@ -0,0 +1,181 @@
import { elementFactory } from "./dom.ts";
/** Must match `.wrn-viewer-sheet td` in viewer.css for windowing to line up. */
const ROW_HEIGHT = 28;
const OVERSCAN = 20;
export interface TableData {
header: string[];
rows: string[][];
truncated?: boolean;
}
export interface TableView {
/** Scroll container content: the windowed grid. */
node: HTMLElement;
caption: string;
/** Re-measure and redraw, e.g. after the view is shown again. */
refresh: () => void;
dispose: () => void;
}
/** Spreadsheet column labels: A, B … Z, AA, AB … */
export function columnLabel(index: number): string {
let label = "";
for (let n = index; n >= 0; n = Math.floor(n / 26) - 1)
label = String.fromCharCode(65 + (n % 26)) + label;
return label;
}
/**
* A spreadsheet grid, not a data table: lettered columns across the top,
* numbered rows down the side, and the file's first row kept as row 1 rather
* than promoted into a header — because in a spreadsheet it *is* row 1.
*
* Only the visible rows plus overscan exist in the DOM; two spacer rows stand
* in for everything above and below.
*/
export function renderTable(scroller: HTMLElement, data: TableData): TableView {
const element = elementFactory(scroller.ownerDocument);
// Row 1 of a sheet is data, so the parsed header rejoins the rows here.
const rows = data.header.length ? [data.header, ...data.rows] : data.rows;
const columns = rows.reduce((max, row) => Math.max(max, row.length), 0);
const caption = `${rows.length}${data.truncated ? "+" : ""} rows × ${columns} columns`;
// A column of numbers reads right-aligned; one of words does not. Row 1 is
// skipped because a label above a numeric column is still a label.
const numeric = Array.from({ length: columns }, (_, column) => {
const values = rows
.slice(1, 51)
.map((row) => (row[column] ?? "").trim())
.filter(Boolean);
return values.length > 0 && values.every((value) => /^-?[\d.,]+%?$/.test(value));
});
const wrapper = element("div");
wrapper.className = "wrn-viewer-sheet";
const grid = element("table");
const head = element("thead");
const headRow = element("tr");
const corner = element("th");
corner.setAttribute("data-viewer-index", "");
corner.setAttribute("aria-label", "Row numbers");
headRow.append(corner);
for (let i = 0; i < columns; i++) {
const cell = element("th", columnLabel(i));
cell.scope = "col";
headRow.append(cell);
}
head.append(headRow);
const body = element("tbody");
grid.append(head, body);
wrapper.append(grid);
const spacer = (height: number) => {
const row = element("tr");
row.setAttribute("data-viewer-spacer", "");
const cell = element("td");
cell.colSpan = columns + 1;
cell.style.height = `${height}px`;
cell.style.padding = "0";
row.append(cell);
return row;
};
let windowStart = -1;
const draw = () => {
const visible = Math.ceil((scroller.clientHeight || 400) / ROW_HEIGHT) + OVERSCAN * 2;
const start = Math.max(0, Math.floor(scroller.scrollTop / ROW_HEIGHT) - OVERSCAN);
if (start === windowStart) return;
windowStart = start;
const slice = rows.slice(start, start + visible);
const nodes: HTMLElement[] = [spacer(start * ROW_HEIGHT)];
slice.forEach((row, index) => {
const line = element("tr");
const number = element("th", String(start + index + 1));
number.setAttribute("data-viewer-index", "");
number.scope = "row";
line.append(number);
for (let i = 0; i < columns; i++) {
const cell = element("td", row[i] ?? "");
if (numeric[i]) cell.setAttribute("data-viewer-numeric", "");
line.append(cell);
}
// Row 1 usually carries the column names, so it is given some weight.
if (start + index === 0 && data.header.length) line.setAttribute("data-viewer-labels", "");
nodes.push(line);
});
nodes.push(spacer(Math.max(0, rows.length - start - slice.length) * ROW_HEIGHT));
body.replaceChildren(...nodes);
};
draw();
scroller.addEventListener("scroll", draw, { passive: true });
return {
node: wrapper,
caption,
refresh: () => {
windowStart = -1;
draw();
},
dispose: () => scroller.removeEventListener("scroll", draw),
};
}
export function formatSize(bytes: number): string {
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
}
export interface ListedEntry {
name: string;
size: number;
directory: boolean;
}
/** Archive contents table, shared by the tar/gzip and zip previews. */
export function renderEntries(doc: Document, entries: ListedEntry[], label: string): HTMLElement {
const element = elementFactory(doc);
const wrapper = element("div");
wrapper.className = "wrn-viewer-table";
wrapper.setAttribute("aria-label", label);
const table = element("table");
const head = element("thead");
const headRow = element("tr");
const sizeHead = element("th", "Size");
sizeHead.setAttribute("data-viewer-numeric", "");
headRow.append(element("th", "Name"), sizeHead);
head.append(headRow);
const body = element("tbody");
for (const entry of entries) {
const row = element("tr");
const name = element("td", entry.name);
name.setAttribute("data-viewer-grow", "");
const size = element("td", entry.directory ? "" : formatSize(entry.size));
size.setAttribute("data-viewer-numeric", "");
row.append(name, size);
body.append(row);
}
table.append(head, body);
wrapper.append(table);
return wrapper;
}
/** "3 files, 1.2 MB uncompressed" — the line above an archive listing. */
export function entriesSummary(entries: ListedEntry[], note = ""): string {
const files = entries.filter((entry) => !entry.directory);
const total = files.reduce((sum, entry) => sum + entry.size, 0);
return [
`${files.length} file${files.length === 1 ? "" : "s"}`,
`${formatSize(total)} uncompressed`,
note,
]
.filter(Boolean)
.join(", ");
}
+32
View File
@@ -0,0 +1,32 @@
import { parseDelimited } from "../parse/delimited.ts";
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { renderTable } from "./table-view.ts";
import type { ViewerMount } from "../types.ts";
export const mountTable: ViewerMount = async ({ parent, file, source, button, signal }) => {
const element = elementFactory(parent.ownerDocument);
const text = await requireFile(file).text();
if (signal.aborted) return;
const table = parseDelimited(text, {
delimiter: /\.tsv$/i.test(source.name) ? "\t" : undefined,
});
// A single column means no delimiter was found: show it as text instead.
if (table.header.length < 2) throw new Error("Not a delimited table");
const view = renderTable(parent, table);
const caption = element("p", view.caption);
const raw = element("pre", text);
let showingRaw = false;
const toggle = button("Raw", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Table" : "Raw");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(caption, showingRaw ? raw : view.node);
if (!showingRaw) view.refresh();
});
toggle.setAttribute("aria-pressed", "false");
parent.append(caption, view.node);
return view.dispose;
};
+45
View File
@@ -0,0 +1,45 @@
import { elementFactory } from "./dom.ts";
/**
* Shared collapsible tree used by the JSON and XML previews. Children are built
* on first expand, so a deep document costs nothing until it is opened.
*/
export function branch(
doc: Document,
label: string,
summaryClass: string,
fill: (into: HTMLElement) => void,
open = false,
): HTMLElement {
const element = elementFactory(doc);
const details = element("details");
details.open = open;
const summary = element("summary", label);
summary.className = summaryClass;
const children = element("div");
children.className = "wrn-viewer-branch";
details.append(summary, children);
let filled = false;
const build = () => {
if (filled || !details.open) return;
filled = true;
fill(children);
};
details.addEventListener("toggle", build);
build();
return details;
}
export function leaf(doc: Document, label: string, value: string, valueClass: string): HTMLElement {
const element = elementFactory(doc);
const line = element("div");
line.className = "wrn-viewer-leaf";
if (label) {
line.append(element("span", label));
line.append(doc.createTextNode(": "));
}
const rendered = element("span", value);
rendered.className = valueClass;
line.append(rendered);
return line;
}
+108
View File
@@ -0,0 +1,108 @@
import { setButtonLabel } from "./dom.ts";
import { readSource } from "../source.ts";
import type { ViewerSource } from "../types.ts";
/** Peak envelope in bounded bins, including every channel. */
export function waveformPeaks(channels: Float32Array[], bins = 160): number[] {
const length = Math.max(0, ...channels.map((channel) => channel.length));
return Array.from({ length: bins }, (_, i) => {
let peak = 0;
for (const channel of channels)
for (
let n = Math.floor((i * length) / bins);
n < Math.min(channel.length, Math.ceil(((i + 1) * length) / bins));
n++
)
peak = Math.max(peak, Math.abs(channel[n] ?? 0));
return Math.min(1, peak);
});
}
/** User-triggered decoding preserves streaming playback until a waveform is requested. */
export function mountWaveform(
parent: HTMLElement,
audio: HTMLMediaElement,
source: ViewerSource,
signal: AbortSignal,
): () => void {
const doc = parent.ownerDocument;
const view = doc.defaultView;
const button = doc.createElement("button");
button.type = "button";
button.className = "wrn-viewer-action";
setButtonLabel(button, "Show waveform");
const note = doc.createElement("p");
note.setAttribute("role", "status");
const canvas = doc.createElement("canvas");
canvas.width = 640;
canvas.height = 96;
canvas.className = "wrn-viewer-waveform";
canvas.setAttribute("role", "img");
canvas.setAttribute("aria-label", "Audio waveform");
const seek = doc.createElement("input");
seek.type = "range";
seek.min = "0";
seek.max = "1000";
seek.value = "0";
seek.setAttribute("aria-label", "Seek audio");
let context: AudioContext | undefined;
let disposed = false;
const update = () => {
if (Number.isFinite(audio.duration) && audio.duration > 0)
seek.value = String(Math.round((audio.currentTime / audio.duration) * 1000));
};
seek.oninput = () => {
if (Number.isFinite(audio.duration))
audio.currentTime = (Number(seek.value) / 1000) * audio.duration;
};
audio.addEventListener("timeupdate", update);
button.onclick = async () => {
button.disabled = true;
note.textContent = "Building waveform…";
parent.append(note);
try {
if (!view?.AudioContext) throw new Error("No decoder");
// 8 MiB encoded and 10 minutes of audio bound this optional analysis.
if (!Number.isFinite(audio.duration) || audio.duration > 600)
throw new Error("Duration unavailable or too long");
const file = await readSource(source, 8 * 1024 * 1024, { signal });
if (disposed) return;
context = new view.AudioContext();
const decoded = await context.decodeAudioData(await file.arrayBuffer());
if (disposed) return;
const peaks = waveformPeaks(
Array.from({ length: decoded.numberOfChannels }, (_, i) => decoded.getChannelData(i)),
);
const draw = canvas.getContext("2d");
if (!draw) throw new Error("No canvas");
draw.fillStyle =
view.getComputedStyle(parent).getPropertyValue("--wrn-color-primary") ||
view.getComputedStyle(parent).color;
peaks.forEach((peak, i) => {
const h = Math.max(1, peak * 90);
draw.fillRect(i * 4, (96 - h) / 2, 3, h);
});
note.textContent = "";
button.remove();
parent.append(canvas, seek);
} catch {
if (!disposed) {
note.textContent = "Waveform unavailable for this file. Audio controls remain available.";
button.disabled = false;
}
} finally {
await context?.close().catch(() => {});
context = undefined;
}
};
parent.append(button);
return () => {
disposed = true;
audio.removeEventListener("timeupdate", update);
void context?.close().catch(() => {});
button.remove();
note.remove();
canvas.remove();
seek.remove();
};
}
+75
View File
@@ -0,0 +1,75 @@
import { elementFactory } from "./dom.ts";
import { renderTable, type TableData, type TableView } from "./table-view.ts";
export interface WorkbookSheet extends TableData {
name: string;
}
/**
* A workbook: the grid scrolls, and the sheet bar stays beneath it with the
* active tab joined to the sheet — where a spreadsheet keeps it.
*
* Shared by `.xlsx` and `.ods` so the same file looks the same whichever
* reader opened it.
*/
export function renderWorkbook(parent: HTMLElement, sheets: WorkbookSheet[]): () => void {
const doc = parent.ownerDocument;
const element = elementFactory(doc);
const shell = element("div");
shell.className = "wrn-viewer-workbook";
const surface = element("div");
surface.className = "wrn-viewer-workbook-surface";
const bar = element("div");
bar.className = "wrn-viewer-sheetbar";
bar.setAttribute("role", "tablist");
bar.setAttribute("aria-label", "Sheets");
const status = element("span");
status.className = "wrn-viewer-sheetbar-status";
let view: TableView | undefined;
let current = 0;
const show = (index: number) => {
current = Math.max(0, Math.min(sheets.length - 1, index));
const sheet = sheets[current]!;
view?.dispose();
view = renderTable(surface, sheet);
surface.replaceChildren(view.node);
status.textContent = view.caption;
tabs.forEach((tab, position) => {
const selected = position === current;
tab.setAttribute("aria-selected", String(selected));
tab.tabIndex = selected ? 0 : -1;
});
view.refresh();
};
const tabs = sheets.map((sheet, index) => {
const tab = element("button", sheet.name);
tab.type = "button";
tab.setAttribute("role", "tab");
tab.className = "wrn-viewer-sheettab";
tab.title = sheet.name;
tab.addEventListener("click", () => show(index));
return tab;
});
// Arrow keys walk the bar, as a tablist should.
bar.addEventListener("keydown", (event) => {
const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
if (!step) return;
event.preventDefault();
const next = (current + step + sheets.length) % sheets.length;
show(next);
tabs[next]?.focus();
});
bar.append(...tabs, status);
// One sheet needs no tabs, but its size is still worth stating.
if (sheets.length === 1) bar.classList.add("is-single");
shell.append(surface, bar);
parent.append(shell);
show(0);
return () => view?.dispose();
}
+62
View File
@@ -0,0 +1,62 @@
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { branch, leaf } from "./tree.ts";
import type { ViewerMount } from "../types.ts";
function label(node: Element): string {
const attributes = [...node.attributes]
.map((attribute) => `${attribute.name}="${attribute.value}"`)
.join(" ");
return `<${node.tagName}${attributes ? ` ${attributes}` : ""}>`;
}
function render(doc: Document, node: Element, depth: number): HTMLElement {
const children = [...node.childNodes].filter(
(child) => child.nodeType === 1 || (child.textContent ?? "").trim(),
);
const elements = children.filter((child) => child.nodeType === 1) as Element[];
if (!elements.length)
return leaf(doc, label(node), (node.textContent ?? "").trim(), "wrn-viewer-value");
return branch(
doc,
label(node),
"wrn-viewer-key",
(into) => {
for (const child of children)
into.append(
child.nodeType === 1
? render(doc, child as Element, depth + 1)
: leaf(doc, "", (child.textContent ?? "").trim(), "wrn-viewer-value"),
);
},
depth < 2,
);
}
export const mountXml: ViewerMount = async ({ parent, file, button, signal }) => {
const doc = parent.ownerDocument;
const view = doc.defaultView;
const element = elementFactory(doc);
const text = await requireFile(file).text();
if (signal.aborted) return;
if (!view?.DOMParser) throw new Error("No XML parser available");
const parsed = new view.DOMParser().parseFromString(text, "application/xml");
const root = parsed.documentElement;
// Malformed XML throws, and the ladder falls through to the source view.
if (!root || parsed.getElementsByTagName("parsererror").length)
throw new Error("Malformed XML document");
const tree = element("div");
tree.className = "wrn-viewer-tree";
tree.append(render(doc, root, 0));
const raw = element("pre", text);
let showingRaw = false;
const toggle = button("Raw", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Tree" : "Raw");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(showingRaw ? raw : tree);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(tree);
};
+22
View File
@@ -0,0 +1,22 @@
import { loadOptional } from "../optional/load.ts";
import type { listZip } from "../optional/zip.ts";
import { elementFactory, requireFile } from "./dom.ts";
import { entriesSummary, renderEntries } from "./table-view.ts";
import type { ViewerMount } from "../types.ts";
export const mountZip: ViewerMount = async ({ parent, file, source, signal }) => {
const element = elementFactory(parent.ownerDocument);
const { listZip: list } = await loadOptional<{ listZip: typeof listZip }>(
"viewer-zip",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
const entries = await list(bytes);
if (signal.aborted) return;
if (!entries.length) throw new Error("Archive holds no readable entries");
parent.append(
element("p", entriesSummary(entries)),
renderEntries(parent.ownerDocument, entries, `Contents of ${source.name}`),
);
};
+95
View File
@@ -0,0 +1,95 @@
import { extensionOf, kindOfExtension, kindOfMime } from "./kinds.ts";
import { defaultRegistry, downloadRenderer } from "./registry.ts";
import { sniff } from "./sniff.ts";
import { readBounded, safeSourceUrl } from "./source.ts";
import type { ResolveOptions, ViewerKind, ViewerPlan, ViewerSource } from "./types.ts";
export async function resolve(
source: ViewerSource,
options: ResolveOptions = {},
): Promise<ViewerPlan> {
const ext = extensionOf(source.name);
let kind = kindOfMime(source.mime ?? source.blob?.type ?? "") ?? kindOfExtension(ext);
const fallback = (reason: string): ViewerPlan => ({
kind,
renderer: downloadRenderer,
fallback: [],
needsConvert: false,
sandboxed: false,
reason,
});
if ([".exe", ".dll"].includes(ext)) return fallback("Executable files cannot be previewed");
const registry = options.registry ?? defaultRegistry;
const candidates = (k: ViewerKind, extension = ext) => registry.candidates(k, extension);
// No renderer for the kind is not an oversized file: leave the cap open so the
// ladder reports why nothing can display it rather than "exceeds preview limit".
const capFor = (k: ViewerKind) => {
const caps = candidates(k).map((r) => r.maxInlineBytes ?? 2 * 1024 * 1024);
return caps.length ? Math.max(...caps) : Infinity;
};
try {
let size = source.blob?.size ?? source.size;
let bytes: Uint8Array;
if (source.blob) {
bytes = new Uint8Array(await source.blob.slice(0, 4096).arrayBuffer());
} else {
const url = source.src && safeSourceUrl(source.src);
if (!url) return fallback("No readable file source");
const request = options.fetch ?? fetch;
const head = await request(url, { method: "HEAD", signal: options.signal });
if (head.ok) {
const length = head.headers.get("content-length");
if (length !== null && /^\d+$/.test(length)) size = Math.max(size ?? 0, Number(length));
kind = kindOfMime(head.headers.get("content-type") ?? "") ?? kind;
}
if (size !== undefined && size > capFor(kind)) return fallback("File exceeds preview limit");
const response = await request(url, {
headers: { Range: "bytes=0-4095" },
signal: options.signal,
});
bytes = await readBounded(response, 4096, true);
}
const detected = sniff(bytes);
if (detected.executable) return fallback("Executable files cannot be previewed");
const previous = kind;
// Plain UTF-8 retains code classification; active markup and signatures win.
// RTF is the one office format that is genuinely text, so `doc` survives a
// text signature too — otherwise a .rtf loses its extension and its reader.
// A generic container signature (OLE, ZIP, unrecognised binary) says only
// "not text", so a .docx or .psd keeps the kind its extension implies.
const generic = detected.kind === "binary" || detected.kind === "archive";
const keepsExtensionKind =
(detected.kind === "text" && ["text", "code", "markup", "sheet", "doc"].includes(kind)) ||
(generic && ["doc", "sheet", "archive", "binary"].includes(kind));
kind = keepsExtensionKind ? kind : detected.kind;
// An extension override must not bypass conflicting signatures.
const selectedExt =
kind === previous || (detected.kind === "text" && previous === "binary") ? ext : "";
const available = [];
let needsConvert = false;
for (const renderer of candidates(kind, selectedExt)) {
// A tier 3 renderer needs a converter, and `load` is how it proves one is
// reachable. Without that check it could never report itself unavailable.
if (renderer.tier === 3 && !renderer.load) continue;
if (size !== undefined && size > (renderer.maxInlineBytes ?? 2 * 1024 * 1024)) continue;
try {
await renderer.load?.(options);
if (renderer.tier === 3) needsConvert = true;
available.push(renderer);
} catch {
/* Optional dependency unavailable. */
}
}
const renderer = available.shift();
if (!renderer) return fallback("Preview unavailable for this file");
return {
kind,
renderer,
fallback: [...available, downloadRenderer],
needsConvert: needsConvert && renderer.tier === 3,
sandboxed: kind === "markup" || kind === "vector",
};
} catch {
return fallback("File could not be loaded");
}
}
@@ -0,0 +1,5 @@
import { handleCapabilities } from "../convert/service.ts";
export function GET(): Response {
return handleCapabilities();
}
+9
View File
@@ -0,0 +1,9 @@
import { handleFile } from "../uploads/service.ts";
export async function GET(ctx: { req: Request }): Promise<Response> {
return handleFile(ctx.req);
}
export async function HEAD(ctx: { req: Request }): Promise<Response> {
return handleFile(ctx.req);
}
+5
View File
@@ -0,0 +1,5 @@
import { handleConvert } from "../convert/service.ts";
export async function GET(ctx: { req: Request }): Promise<Response> {
return handleConvert(ctx.req, "preview");
}
+5
View File
@@ -0,0 +1,5 @@
import { handleSource } from "../uploads/service.ts";
export async function GET(ctx: { req: Request }): Promise<Response> {
return handleSource(ctx.req);
}
+5
View File
@@ -0,0 +1,5 @@
import { handleConvert } from "../convert/service.ts";
export async function GET(ctx: { req: Request }): Promise<Response> {
return handleConvert(ctx.req, "text");
}
+5
View File
@@ -0,0 +1,5 @@
import { handleConvert } from "../convert/service.ts";
export async function GET(ctx: { req: Request }): Promise<Response> {
return handleConvert(ctx.req, "thumb");
}
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more