Ship file viewer, editor improvements, and private package releases
This commit is contained in:
1 parent
645914955c
commit
a4a31032b9
158 files changed
+10523
-137
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.65",
|
||||
"version": "0.8.66",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -118,6 +118,7 @@ Thumbs.db
|
||||
"@wrnexus/ui": "${scaffoldFrameworkRange}",
|
||||
"@wrnexus/uploader": "${scaffoldFrameworkRange}",
|
||||
"@wrnexus/validation": "${scaffoldFrameworkRange}",
|
||||
"@wrnexus/viewer": "${scaffoldFrameworkRange}",
|
||||
"@wrnexus/authz": "${scaffoldFrameworkRange}"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.8.12",
|
||||
"version": "0.8.13",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.8.21",
|
||||
"version": "0.8.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.61",
|
||||
"version": "0.8.62",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'").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><hello></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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/plugin",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Separately served by viewerPlugin; downloaded only when a text preview opens. */
|
||||
export { mountBuilderSourceEditor } from "@wrnexus/block-editor/source-editor";
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
|
||||
/** 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("");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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."));
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
@@ -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)));
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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}`),
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { handleConvert } from "../convert/service.ts";
|
||||
|
||||
export async function GET(ctx: { req: Request }): Promise<Response> {
|
||||
return handleConvert(ctx.req, "preview");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { handleSource } from "../uploads/service.ts";
|
||||
|
||||
export async function GET(ctx: { req: Request }): Promise<Response> {
|
||||
return handleSource(ctx.req);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { handleConvert } from "../convert/service.ts";
|
||||
|
||||
export async function GET(ctx: { req: Request }): Promise<Response> {
|
||||
return handleConvert(ctx.req, "text");
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user