Ship file viewer, editor improvements, and private package releases

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

No files matched your search

+136 -8
View File
@@ -48,7 +48,9 @@
"@wrnexus/core": "workspace:*",
"@wrnexus/db": "workspace:*",
"@wrnexus/ui": "workspace:*",
"@wrnexus/uploader": "workspace:*",
"@wrnexus/validation": "workspace:*",
"@wrnexus/viewer": "workspace:*",
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -270,7 +272,7 @@
},
"packages/block-editor": {
"name": "@wrnexus/block-editor",
"version": "0.1.14",
"version": "0.1.18",
"dependencies": {
"@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "6.12.4",
@@ -320,7 +322,7 @@
},
"packages/cli": {
"name": "@wrnexus/cli",
"version": "0.8.65",
"version": "0.8.66",
"bin": {
"wrnexus": "src/index.ts",
},
@@ -366,7 +368,7 @@
},
"packages/core": {
"name": "@wrnexus/core",
"version": "0.8.12",
"version": "0.8.13",
},
"packages/csr": {
"name": "@wrnexus/csr",
@@ -377,7 +379,7 @@
},
"packages/db": {
"name": "@wrnexus/db",
"version": "0.8.21",
"version": "0.8.23",
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^6.0.3",
@@ -385,7 +387,7 @@
},
"packages/dev-server": {
"name": "@wrnexus/dev-server",
"version": "0.8.61",
"version": "0.8.62",
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/cache": "workspace:*",
@@ -434,7 +436,7 @@
},
"packages/email-renderer": {
"name": "@wrnexus/email-renderer",
"version": "0.1.46",
"version": "0.1.50",
"dependencies": {
"@wrnexus/blocks": "workspace:*",
"amphtml-validator": "1.0.38",
@@ -604,7 +606,7 @@
},
"packages/plugin": {
"name": "@wrnexus/plugin",
"version": "0.8.8",
"version": "0.8.9",
"dependencies": {
"@wrnexus/syntax": "workspace:*",
},
@@ -777,6 +779,36 @@
"typescript": "^6.0.3",
},
},
"packages/viewer": {
"name": "@wrnexus/viewer",
"version": "0.8.9",
"dependencies": {
"@wrnexus/block-editor": "workspace:*",
"@wrnexus/plugin": "workspace:*",
"@wrnexus/ui": "workspace:*",
},
"devDependencies": {
"@wrnexus/uploader": "workspace:*",
"fflate": "^0.8.2",
"mammoth": "^1.8.0",
"pdfjs-dist": "^4.10.38",
"xlsx": "^0.18.5",
},
"peerDependencies": {
"@wrnexus/uploader": "workspace:*",
"fflate": "*",
"mammoth": "*",
"pdfjs-dist": "*",
"xlsx": "*",
},
"optionalPeers": [
"@wrnexus/uploader",
"fflate",
"mammoth",
"pdfjs-dist",
"xlsx",
],
},
"services/managed-captcha": {
"name": "@wrnexus/managed-captcha-service",
"version": "0.8.6",
@@ -940,6 +972,30 @@
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.4", "", {}, "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ=="],
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.100", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.100", "@napi-rs/canvas-darwin-arm64": "0.1.100", "@napi-rs/canvas-darwin-x64": "0.1.100", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", "@napi-rs/canvas-linux-arm64-musl": "0.1.100", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-musl": "0.1.100", "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", "@napi-rs/canvas-win32-x64-msvc": "0.1.100" } }, "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA=="],
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.100", "", { "os": "android", "cpu": "arm64" }, "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ=="],
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.100", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A=="],
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.100", "", { "os": "darwin", "cpu": "x64" }, "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw=="],
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.100", "", { "os": "linux", "cpu": "arm" }, "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA=="],
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw=="],
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ=="],
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.100", "", { "os": "linux", "cpu": "none" }, "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw=="],
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg=="],
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA=="],
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.100", "", { "os": "win32", "cpu": "arm64" }, "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw=="],
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
@@ -1236,12 +1292,18 @@
"@wrnexus/validation": ["@wrnexus/validation@workspace:packages/validation"],
"@wrnexus/viewer": ["@wrnexus/viewer@workspace:packages/viewer"],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="],
"abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="],
"acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="],
"admin": ["admin@workspace:examples/inter-app-api-showcase/apps/admin"],
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
@@ -1256,7 +1318,7 @@
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
@@ -1266,10 +1328,14 @@
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw=="],
"basic-app": ["basic-app@workspace:examples/basic-app"],
"bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
@@ -1294,6 +1360,8 @@
"captcha-showcase": ["captcha-showcase@workspace:examples/captcha-showcase"],
"cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="],
"cheerio": ["cheerio@1.0.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="],
"cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
@@ -1304,6 +1372,8 @@
"codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="],
"codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -1320,8 +1390,12 @@
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cosmiconfig": ["cosmiconfig@10.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "js-yaml": "^5.4.1" } }, "sha512-/zvGoYn0y27QTBmK+0B+oPdEPC4fKGUmJsMTQbDjqph00TrkqfVFW/M+C6ya54sYCgHC6QCwK/mI6djt8cEGHQ=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
"crelt": ["crelt@1.0.7", "", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -1356,6 +1430,8 @@
"detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
"dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@@ -1364,6 +1440,8 @@
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"editorconfig": ["editorconfig@1.0.7", "", { "dependencies": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", "minimatch": "^9.0.1", "semver": "^7.5.3" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw=="],
@@ -1428,6 +1506,8 @@
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
@@ -1452,10 +1532,14 @@
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"inter-app-api-showcase": ["inter-app-api-showcase@workspace:examples/inter-app-api-showcase"],
@@ -1470,6 +1554,8 @@
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
@@ -1490,12 +1576,16 @@
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
"juice": ["juice@11.1.1", "", { "dependencies": { "cheerio": "1.0.0", "commander": "^12.1.0", "entities": "^7.0.0", "mensch": "^0.3.4", "slick": "^1.12.2", "web-resource-inliner": "^8.0.0" }, "bin": { "juice": "bin/juice" } }, "sha512-4SBfZqKcc6DrIS+5b/WiGoWaZsdUPBH+e6SbRlNjJpaIRtfoBhYReAtobIEW6mcLeFFDXLBJMuZwkJLkBJjs2w=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -1534,10 +1624,14 @@
"lodash.uniq": ["lodash.uniq@4.5.0", "", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="],
"lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="],
"lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mammoth": ["mammoth@1.12.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-MH2vkgafD/2MYUaEOtoXLKrHQZ7yLYTHGQgyLNtYZHiUxU1K1QQ+8qMFquDAfhBm06wSGSws3J+Q9QTsKtR44g=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"mensch": ["mensch@0.3.4", "", {}, "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g=="],
@@ -1636,6 +1730,8 @@
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@@ -1646,6 +1742,8 @@
"package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
@@ -1654,12 +1752,16 @@
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pdfjs-dist": ["pdfjs-dist@4.10.38", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.65" } }, "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
@@ -1742,6 +1844,8 @@
"prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"promise": ["promise@8.3.0", "", { "dependencies": { "asap": "~2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="],
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
@@ -1756,6 +1860,8 @@
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
@@ -1766,6 +1872,8 @@
"rollup": ["rollup@4.62.5", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.5", "@rollup/rollup-android-arm64": "4.62.5", "@rollup/rollup-darwin-arm64": "4.62.5", "@rollup/rollup-darwin-x64": "4.62.5", "@rollup/rollup-freebsd-arm64": "4.62.5", "@rollup/rollup-freebsd-x64": "4.62.5", "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", "@rollup/rollup-linux-arm-musleabihf": "4.62.5", "@rollup/rollup-linux-arm64-gnu": "4.62.5", "@rollup/rollup-linux-arm64-musl": "4.62.5", "@rollup/rollup-linux-loong64-gnu": "4.62.5", "@rollup/rollup-linux-loong64-musl": "4.62.5", "@rollup/rollup-linux-ppc64-gnu": "4.62.5", "@rollup/rollup-linux-ppc64-musl": "4.62.5", "@rollup/rollup-linux-riscv64-gnu": "4.62.5", "@rollup/rollup-linux-riscv64-musl": "4.62.5", "@rollup/rollup-linux-s390x-gnu": "4.62.5", "@rollup/rollup-linux-x64-gnu": "4.62.5", "@rollup/rollup-linux-x64-musl": "4.62.5", "@rollup/rollup-openbsd-x64": "4.62.5", "@rollup/rollup-openharmony-arm64": "4.62.5", "@rollup/rollup-win32-arm64-msvc": "4.62.5", "@rollup/rollup-win32-ia32-msvc": "4.62.5", "@rollup/rollup-win32-x64-gnu": "4.62.5", "@rollup/rollup-win32-x64-msvc": "4.62.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="],
@@ -1776,6 +1884,8 @@
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
@@ -1788,10 +1898,16 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -1838,6 +1954,8 @@
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
"underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="],
"undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
@@ -1870,6 +1988,10 @@
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="],
"word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -1880,6 +2002,10 @@
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="],
"xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="],
@@ -1922,6 +2048,8 @@
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"juice/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+2 -2
View File
@@ -948,7 +948,7 @@ security: {
```
Defaults include a strict CSP (`default-src 'self'`, `script-src 'self'`, `style-src 'self'
'unsafe-inline'`, `frame-ancestors 'none'`, …), `X-Frame-Options: DENY`, restrictive
'unsafe-inline'`, `frame-ancestors 'self'`, …), `X-Frame-Options: SAMEORIGIN`, restrictive
Permissions‑Policy, COOP `same-origin`, and Referrer‑Policy. Set any directive/section to `false`
to remove it, or `security.headers = false` to disable all framework headers. WebSocket upgrades are
guarded against cross‑site hijacking (same‑origin + configured CORS origins + non‑browser clients).
@@ -1342,7 +1342,7 @@ export default config;
**CSP defaults:** `default-src 'self'`; `script-src 'self'`; `style-src 'self' 'unsafe-inline'`;
`img-src 'self' data: blob:`; `font-src 'self' data:`; `connect-src 'self' ws: wss:`;
`object-src 'none'`; `base-uri 'self'`; `frame-ancestors 'none'`; `form-action 'self'`. Set a
`object-src 'none'`; `base-uri 'self'`; `frame-ancestors 'self'`; `form-action 'self'`. Set a
directive to `false`/`null` to remove it.
## 33. Directive & attribute cheat sheet
+332
View File
@@ -0,0 +1,332 @@
# Universal file viewer design (`@wrnexus/viewer`)
Date: 2026-09-08
Status: implemented — build order steps 1-5 complete.
Follow-ups: [2026-09-09-file-viewer-follow-ups.md](2026-09-09-file-viewer-follow-ups.md)
Depends on: `@wrnexus/ui` (tokens, `.wrn` components), `@wrnexus/plugin` (`routeEntries`),
`@wrnexus/cache`, `@wrnexus/block-editor` (read-only CodeMirror), `@wrnexus/uploader` (optional)
## Problem
Every app in the workspace that stores a file eventually has to show it. Today each one
solves this alone: an `<img>` here, an `<iframe src=…>` there, a download link for
everything else. There is no shared answer for "the user uploaded a `.docx`, show it to
them", no consistent chrome (zoom, paging, download, fullscreen), and no consistent theming —
a preview pane looks nothing like the app around it.
`@wrnexus/uploader` already owns getting bytes _in_ and serving them back out, including the
extension↔MIME table in `src/mime.ts`. `@wrnexus/ui` already owns the token system that makes
components follow an app's accent and dark mode. `@wrnexus/block-editor` already wraps
CodeMirror read-only in `src/source-editor.ts`. What is missing is the layer that looks at an
arbitrary file and decides how to render it.
## Scope
One package that any app drops in as a single component:
```wrn
<Viewer src={file.url} name={file.name} />
```
…and gets the best available rendering of that file, themed to the app, with a graceful
fallback when the format cannot be rendered at all.
It is **not** an editor. Everything is read-only; editing stays with `@wrnexus/block-editor`
and `WysiwygEditor.wrn`. It is also not a file _manager_ — no listing, no tree, no rename.
It renders one file at a time (with an optional filmstrip for multi-file callers).
## Approach
Three tiers, decided by what the platform can actually do, not by what we would like:
| Tier | Rendered by | Cost |
| ---- | ------------------------------ | ------------------------------------ |
| 1 | The browser, natively | Zero new dependencies |
| 2 | A JS parser in the client | Optional peer dep, lazily imported |
| 3 | A server-side binary converter | Opt-in plugin + a binary on the host |
The tier boundary is a hard fact about the platform: no amount of JavaScript renders a `.psd`
faithfully, and no server round-trip is needed to show a `.png`. Pretending otherwise is how
viewer libraries end up shipping 8 MB to display a JPEG.
Everything degrades down a fixed ladder — **full render → thumbnail → metadata card →
download card** — so an unknown extension, a missing optional dep, and an absent converter
binary all land somewhere useful instead of erroring.
Rejected alternatives:
- **One monolithic `Viewer.wrn` with a big `if/else`.** Every format's dependency ends up in
every app's bundle, and apps cannot add their own formats.
- **Server-render everything to PDF/PNG.** Uniform output, but it makes a hard host-binary
dependency mandatory for viewing a text file, loses text selection and copy, and puts a
conversion hop in front of the 60% of files the browser renders for free.
- **Wrap an existing all-in-one viewer library.** They bundle their own design system, fight
our tokens, and none of them cover Tier 3 anyway.
## Module layout
```
@wrnexus/viewer
src/
kinds.ts ViewerKind union; ext/MIME -> kind table (extends uploader's)
resolve.ts ViewerSource -> ViewerPlan (kind, renderer, fallback chain)
sniff.ts magic-byte detection; extensions are never trusted alone
registry.ts renderer registry; register/override per kind or per ext
parse/ csv, json, xml, sql, archive listing, sanitize-svg
convert/ ViewerConverter contract + the shipped local-binary driver
plugin.ts viewerPlugin(): componentDirs + /_viewer/* routeEntries
uploader.ts separate export: storage key -> signed URL + metadata
index.ts
components/
Viewer.wrn the one component apps use
ViewerToolbar.wrn zoom, page, rotate, download, fullscreen
ViewerShell.wrn chrome, loading, error, and fallback states
ImageViewer.wrn VectorViewer.wrn PdfViewer.wrn CodeViewer.wrn
SheetViewer.wrn DocViewer.wrn MediaViewer.wrn ArchiveViewer.wrn
DownloadCard.wrn
viewer.css consumes the same CSS variables as ui.css
```
## Core contract
```ts
export type ViewerKind =
| "image"
| "vector"
| "pdf"
| "code"
| "text"
| "markup"
| "sheet"
| "doc"
| "audio"
| "video"
| "archive"
| "binary";
export interface ViewerSource {
src?: string; // URL, blob:, or data:
blob?: Blob | File; // client-side preview before upload
key?: string; // @wrnexus/uploader storage key
name: string;
mime?: string; // sniffed when absent
size?: number;
}
export interface Renderer {
kind: ViewerKind;
exts: string[];
tier: 1 | 2 | 3;
component: string; // .wrn component name
load?: () => Promise<unknown>; // lazy optional dep; missing -> degrade
maxInlineBytes?: number;
}
export interface ViewerPlan {
kind: ViewerKind;
renderer: Renderer;
fallback: Renderer[]; // the degradation ladder, pre-computed
needsConvert: boolean;
sandboxed: boolean;
}
export function resolve(source: ViewerSource): Promise<ViewerPlan>;
export function registerRenderer(renderer: Renderer): void;
```
`resolve()` runs ext → declared MIME → magic-byte sniff, then picks the highest-tier renderer
whose dependency is actually available. Apps override anything:
```ts
registerRenderer({ kind: "binary", exts: [".dwg"], tier: 2, component: "CadViewer" });
```
## Format matrix
**Tier 1 — browser-native, zero dependencies.**
| Group | Extensions | Renderer |
| --------- | -------------------------------------------------------- | ------------------------------ |
| Images | `.jpg .jpeg .png .gif .webp .bmp .ico .avif` | `ImageViewer` (zoom/pan) |
| Video | `.mp4 .webm .mov` | `MediaViewer` (`<video>`) |
| Audio | `.mp3 .wav .aac .flac .ogg` | `MediaViewer` + waveform bar |
| Documents | `.pdf` | `PdfViewer` (native `<embed>`) |
| Text | `.txt` | `CodeViewer` (plain mode) |
| Code | `.js .py .java .cpp .cs .css .html .htm .json .xml .sql` | `CodeViewer` (CodeMirror) |
`.mov` and `.flac` are codec-dependent in practice; when playback fails the ladder drops to
the Tier 3 poster-frame/transcode path, then to the download card.
**Tier 2 — pure JS in the client, lazily loaded.**
| Extensions | Parser | Notes |
| ------------------ | -------------------- | ------------------------------------------- |
| `.csv` | in-package | Virtualized table; handles quoted/multiline |
| `.json` | in-package | Collapsible tree + raw toggle |
| `.xml` | `DOMParser` | Tree view + raw toggle |
| `.svg` | in-package sanitizer | Sandboxed; see Security |
| `.xls .xlsx` | SheetJS | Sheet tabs, formulas shown as values |
| `.docx` | mammoth | Semantic HTML, **not** faithful layout |
| `.zip` | fflate | Entry listing, drill-in re-enters `resolve` |
| `.pdf` (rich mode) | pdf.js | Opt-in; page API, text layer, theming |
All declared as optional `peerDependencies` and dynamically imported on first use. An app that
never views a spreadsheet neither installs nor ships SheetJS.
**Tier 3 — requires a server-side converter.**
| Extensions | Converted to | Binary |
| ------------------ | ------------------ | ---------- |
| `.doc .odt .rtf` | PDF | `soffice` |
| `.docx` (fidelity) | PDF | `soffice` |
| `.ppt .pptx` | PDF | `soffice` |
| `.psd .ai .tiff` | PNG | `magick` |
| `.avi .mkv` | poster PNG, or MP4 | `ffmpeg` |
| `.rar .7z` | entry listing | `7z` |
| `.tar .gz` | entry listing | in-package |
## Converter contract
```ts
export interface ViewerConverter {
name: string;
supports(kind: ViewerKind, ext: string): boolean;
toPreview(
input: ReadableStream,
meta: ConvertMeta,
): Promise<{ mime: string; stream: ReadableStream }>;
toThumb?(input: ReadableStream, meta: ConvertMeta): Promise<Uint8Array>;
toText?(input: ReadableStream, meta: ConvertMeta): Promise<string>;
}
```
`viewerPlugin()` contributes three `routeEntries`:
- `GET /_viewer/preview` — the renderable form (PDF or PNG)
- `GET /_viewer/thumb` — a small poster image, for grids and filmstrips
- `GET /_viewer/text` — extracted plain text, for search and indexing
Output is content-addressed by input hash + converter version and cached through
`@wrnexus/cache`, so a 40 MB `.doc` converts once. The shipped `localBinaryConverter` probes
for `soffice`/`magick`/`ffmpeg`/`7z` once at boot; **an absent binary is not an error** — the
format simply falls to the download card. Apps that would rather call Gotenberg or an internal
service implement `ViewerConverter` themselves and pass it to the plugin.
The converter is opt-in. `viewerPlugin()` with no options mounts no routes and shells out to
nothing.
## Security
Uploaded files are hostile input, and this package's whole job is to open them.
- **Never trust the extension.** `sniff.ts` reads magic bytes before choosing a renderer, so a
`.png` that is really an HTML document does not reach an HTML path.
- **Sandbox all markup.** `.html`, `.htm`, and `.svg` render inside an iframe with `sandbox`
(no `allow-scripts`, no `allow-same-origin`) under a strict CSP. SVG is _additionally_
stripped of `<script>`, `<foreignObject>`, external references, and every `on*` attribute —
sandbox and sanitizer, not either alone. Nothing from a file ever reaches `innerHTML`.
- **Cap what is fetched.** `maxInlineBytes` per renderer, checked against a `HEAD` before the
body is pulled; over the cap the ladder drops straight to the download card rather than
pulling a 400 MB `.mkv` into a tab.
- **Contain the converters.** `soffice`, `magick`, and `ffmpeg` are large C/C++ parsers with a
long CVE history. Every invocation gets a hard wall-clock timeout, memory and output-size
caps, no network, and a per-job temp directory as its only writable path. A converter that
exceeds any limit is killed and reported as an unrenderable file.
- **Signed, short-lived URLs.** The uploader integration mints per-view URLs; converted output
inherits the same access check as its input, never a public cache path.
## Theming
`viewer.css` defines no colors of its own. It consumes the same CSS variables `ui.css` does,
so accent, radius, spacing, and dark mode follow the host app with no configuration —
including the CodeMirror theme, which is derived from the tokens rather than shipping a
hard-coded editor palette. `<Viewer theme="auto" />` follows the app; `"light"` / `"dark"`
pin it for cases like a print preview.
## Uploader integration
Core is source-agnostic: `src` or `blob` is enough, and the package has no hard dependency on
`@wrnexus/uploader`. The `key` path lives behind a separate export (`@wrnexus/viewer/uploader`)
that resolves a storage key to a signed URL and reads MIME and size from the upload record,
so uploader-backed apps write `<Viewer key={f.key} />` and everyone else is unaffected. The
ext↔MIME table extends `uploader/src/mime.ts` rather than forking it.
## Testing
- `resolve()` table tests over every extension in the matrix, including conflicting
extension/MIME/magic-byte combinations.
- Sanitizer tests: a corpus of hostile SVG and HTML, asserting no script survives.
- Degradation tests: optional dep missing, converter binary missing, file over the byte cap —
each must land on the expected rung of the ladder, never throw.
- Converter tests run only when the binaries are present, and are skipped otherwise, so CI
without LibreOffice stays green.
- A `.wrn` render test per component against the example app.
The remaining presentation and verification follow-ups were completed on 2026-09-09; see
[the follow-up plan](2026-09-09-file-viewer-follow-ups.md). The checkpoint below records the original implementation.
## Build order
Implementation checkpoint (2026-09-08): steps 1, 2 and 4 are done; step 3 was skipped for now
and step 5 remains. `packages/viewer` has classification, signature sniffing, a renderer
registry, bounded source loading, native image/PDF/media previews, read-only CodeMirror, the
fallback ladder, plugin assets and lifecycle cleanup; the in-package Tier 2 parsers (delimited
tables with windowed rendering, lazy JSON and XML trees, the SVG sanitiser behind a sandboxed
frame, tar/gzip listings); and the Tier 3 converter contract with its opt-in routes, response
cache and contained local-binary driver. `examples/basic-app` has a `/file-viewer` demo
covering each group, with conversion enabled.
Five deviations from this document, all deliberate:
- Renderers are imperative browser adapters sharing one `ViewerMountContext`, not the
separate `.wrn` components listed under Module layout. `Viewer.wrn` is the only component.
`component` is a display label; the runtime does not switch on it, and a renderer with no
`mount` is never selected.
- Conversion routes are `/api/viewer/*`, not `/_viewer/*`: package API routes are served
under the `/api` prefix, as `@wrnexus/graphql` already does.
- Image, media and PDF renderers are `streaming`: for an http(s) source the element is
pointed at the original URL instead of a `blob:` copy. That lets the browser stream the
file, and it keeps the request under the app's own `default-src`/`media-src`/`frame-src`
policy — `blob:` URLs are blocked by the framework's default CSP. Local `File`/`Blob`
input still renders from an object URL served under its sniffed type.
- The PDF renderer uses an `iframe`, not `<object>`, because `object-src 'none'` is a common
default while frames fall back to `default-src`. Embedding a same-origin PDF still needs
the app to allow self-framing; the framework defaults (`frame-ancestors 'none'` plus
`X-Frame-Options: DENY`, `packages/core/src/headers.ts`) block an app from framing even its
own assets, so `examples/basic-app` opts into `frame-ancestors 'self'` / `SAMEORIGIN`.
- `.xlsx` and `.zip` prefer their Tier 2 parser over the converter, since neither needs a
server round-trip; `.docx` prefers the converter, because mammoth's HTML is not a faithful
page layout. `.psd`, `.tiff` and other design formats have signatures so they reach the
converter or the download card rather than being mistaken for text.
- pdf.js is served from its own published build rather than bundled — bundling its ESM entry
breaks the worker handshake — and each page render is bounded by a timeout, because pdf.js
rasterises from `requestAnimationFrame`, which never fires while a document is hidden.
A sixth deviation: there is no default access check for a private object. The plan said the
uploader integration "mints per-view URLs"; in practice a private store is refused unless the
app supplies both a signing secret and an `authorize` hook, because no default answer to "may
this viewer see this object" is safe.
See `packages/viewer/README.md` for current limits, the renderer extension API, the converter
contract, and the uploader options.
1. ~~**Core + Tier 1.**~~ Done. `kinds`, `sniff`, `resolve`, `registry`, the toolbar and
shell chrome, image, media, native PDF, `CodeViewer` over the existing read-only
CodeMirror, and the download card.
2. ~~**Tier 2 in-package parsers.**~~ Done. csv/tsv, json, xml, svg sanitiser, `tar`/`gz`
listing — still zero runtime dependencies.
3. ~~**Tier 2 optional deps.**~~ Done. SheetJS, mammoth, fflate and opt-in pdf.js, each an
optional peer dependency served as its own runtime asset and fetched on first use.
4. ~~**Tier 3.**~~ Done. `ViewerConverter`, `/api/viewer/{capabilities,preview,thumb,text}`,
a bounded response cache, `localBinaryConverter`, and containment of the child processes.
5. ~~**Uploader integration + docs**~~ Done. `@wrnexus/viewer/uploader`, `<Viewer key="…">`,
signed short-lived links for private stores, and `/file-viewer` in `examples/basic-app`
exercising every group including public and private storage keys.
## Out of scope
- Editing or annotation of any kind.
- File management (listing, rename, delete, move).
- OCR over scanned PDFs and images.
- Collaborative or presence features on a preview.
- `.exe`/`.dll` and other executables — metadata card only, never executed or introspected.
@@ -0,0 +1,142 @@
# File viewer follow-ups (`@wrnexus/viewer`)
Date: 2026-09-09
Status: implemented — accepted limits retained
Follows: [file viewer design](2026-09-08-file-viewer-design.md), whose build order is complete
Build order steps 1-5 are implemented and the package renders every group in the format
matrix. What remains falls into four kinds: defects found after the fact, work the design
described but that was never built, one verification gap, and limits that were accepted
deliberately and should stay accepted until something forces the issue.
Nothing here blocks using the package. The first section is the only part with a
correctness argument behind it.
## Implementation result (2026-09-09)
- Stable conversion identities and authorization before cache hits; source streams released on hits.
- Exported `firstOf` / `sameOriginSource`; demo uses the composite with signed uploads.
- Renderer and chrome `.wrn` presentation components integrated through preserved DOM hooks.
- Sanitized HTML preview by default, with Source toggle and strict sandbox/CSP.
- Multi-file filmstrip consuming thumbnails, plus on-demand audio waveform and seek slider.
- Extracted-text UI and bounded public helper consuming the text route.
- Explicit host base URI for conversion/optional-module helpers, with regression tests.
- Suppressed plugin factories are skipped before import/invocation.
- Same-origin framing is now the framework default, explicitly approved by the user;
explicit DENY still sets restrictive framing headers.
- pdf.js success observed in ordinary Chrome: a completed page canvas visibly painted the
sample document text. Waveform generation and filmstrip switching also verified there.
The per-process cache, streaming-driver byte-range limitation and host-binary DOCX fidelity
remain accepted as this plan specifies. No distributed-cache or uploader-driver redesign was
introduced. The findings below are the original rationale, retained for context.
## Defects worth fixing first
**A rotating token defeats the conversion cache.** `packages/viewer/src/convert/service.ts`
keys cached output on `src`, and for a private uploader object `src` is
`/api/viewer/file?token=…` — a link that rotates every `ttlMs` (5 minutes by default). Every
view after the link is re-minted is a cache miss, so exactly the files most expensive to
convert (a 40 MB `.docx` through LibreOffice) are the ones that never hit. The cache key must
identify the _object_, not the URL that happens to reach it: have `resolveSource` return an
optional stable identity (`store:key` for uploader sources, the URL for everything else) and
hash that instead. Until then, private conversions re-run on a timer.
**The example app can no longer convert its own static files.**
`examples/basic-app/wrnexus.config.ts` sets `resolveSource: uploadedConversionSource()`, which
replaces the same-origin default outright. Uploader objects convert; the page's own
`/viewer/sample.docx` cannot, because it has no signed link. On this machine that is invisible
— no LibreOffice, so the request stops at 415 — and it would surface only on a host with the
binaries installed. The example should use whichever composite lands below.
**There is no way to accept more than one kind of source.** `ConversionOptions.resolveSource`
is a single function, so an app that stores some files in an uploader store and serves others
statically has to hand-write the fan-out. Ship a composer — `firstOf(uploadedConversionSource(),
sameOriginSource())` — and export `sameOriginSource` so the default is nameable rather than
private to the module. Small surface, removes the trap the example fell into.
## Unbuilt from the design
**Renderer and chrome `.wrn` components.** The design's module layout lists `ViewerShell`,
`ViewerToolbar`, `ImageViewer`, `MediaViewer`, `PdfViewer`, `CodeViewer`, `SheetViewer`,
`DocViewer`, `ArchiveViewer` and `DownloadCard`; what exists is one `Viewer.wrn` plus
imperative browser adapters sharing a `ViewerMountContext`. The adapters work and the seam is
clean, so this is a presentation question, not a correctness one: components would let an app
restyle the chrome through the normal component-override path instead of registering a
renderer. Worth doing only if an app actually asks to restyle it.
**HTML still renders as inert source.** The design's security section describes `.html` in the
same sandboxed, CSP-restricted frame as SVG. The frame exists (`renderers/frame.ts`) and SVG
and `.docx` both use it; pointing `.html` at it is a small change. It was left out because
inert source is strictly safer and nobody had asked for a rendered preview. Doing it means
deciding what the default view is — preview with a source toggle, or the reverse.
**`/api/viewer/thumb` and `/api/viewer/text` are mounted but unused.** The routes work and the
converter implements both. Nothing calls them because the two features that would — a
multi-file filmstrip, and feeding extracted text to search — are not built. Either build a
consumer or drop the routes; a live endpoint with no caller is a maintenance cost and an
attack surface for nothing.
**Audio waveform and multi-file filmstrip.** Both are in the design, neither is built. The
filmstrip is the one with a dependency: it wants `thumb`, and it wants a story for what a
viewer showing _several_ files does to the toolbar and the disposal path.
## The verification gap
**pdf.js rasterisation has never been observed working.** The library and worker are served
verbatim, `getDocument()` resolves and reports the page count in a real browser, and every
step up to `page.render()` was verified. `page.render()` itself never completes in this
environment, because pdf.js drives rasterisation from `requestAnimationFrame` and the
preview pane reports `document.hidden === true` permanently, even when fronted. The renderer
now bounds each page with a timeout and degrades to the native frame rather than hanging —
that degradation _is_ verified — but the success path is unproven. It needs one run in an
ordinary visible browser: open `/file-viewer` after `registerRenderer(richPdfRenderer())` and
confirm a canvas paints. Until someone does that, treat rich PDF as untested.
## Outside the package
**Plugin discovery calls a suppressed manifest factory anyway.**
`packages/plugin/src/discovery.ts:353` decides whether to keep a manifest-built plugin _after_
constructing it, so a plugin configured explicitly in `wrnexus.config.ts` still has its
factory invoked a second time with no options. That silently cleared the viewer's conversion
config until `viewerPlugin` was changed to only ever arm, never disarm. Any plugin using the
config-global idiom will hit this. Already queued as its own task.
**`frame-ancestors 'none'` stops an app framing its own assets.**
`packages/core/src/headers.ts:119`, with `X-Frame-Options: DENY` alongside it. Embedding a
same-origin PDF — or anything else in a frame — requires every app to opt out, as
`examples/basic-app` now does. `'self'` would still block cross-origin clickjacking while
letting an app embed its own content. This is a framework-wide security default, so it is a
decision to take deliberately rather than a bug to fix in passing.
## Known limits, deliberately accepted
**The conversion cache is per-process.** A bounded `Map` with a TTL, so a multi-process deploy
converts once per process and a restart loses everything. Moving it to `@wrnexus/cache` with
the distributed invalidation layer is the obvious upgrade, and is only worth it once the key
is fixed — a shared cache keyed on a rotating token would be shared misses.
**Byte ranges are served only for buffered objects.** `uploads/service.ts:135` slices a
`Uint8Array` body for a `Range` request; a driver returning a stream answers the whole body
instead. Seeking a large private video therefore re-reads it. Fixing this properly means a
range-aware read on the driver contract, which belongs to `@wrnexus/uploader`, not here.
**Legacy `.doc` and `.ppt` remain converter-only.** The OOXML and OpenDocument families are
ZIP containers of XML and are now read in-package, and RTF is read with no dependency at all.
The pre-2007 OLE compound formats are a different problem — a binary record stream with no
public schema — and reading them in a browser is not worth the size or the risk. LibreOffice
handles them, and without it they keep the download card.
**`.docx` fidelity depends on a host binary.** Mammoth produces semantic HTML, not a page
layout, so the converter's PDF is preferred and mammoth is the fallback. An app with no
LibreOffice gets an approximation, and that is the honest ceiling of a pure-JS path.
**Three client helpers still read the global `document`.** `optional/load.ts:7` and
`convert/client.ts:13,36`. `resolveKey` was changed to take the host's `baseURI` when it turned
out to be untestable without a global; the others have the same latent problem and no test
covering them. Consistency work, not a live bug.
## Out of scope, still
Unchanged from the original design: editing or annotation, file management, OCR,
collaborative features, and executing anything from an uploaded file.
@@ -0,0 +1,16 @@
import Viewer from "@wrnexus/viewer/components/Viewer.wrn"
page FilePreviewPage {
layout = "public"
render = "hybrid"
hydrate = "load"
view {
<main class="file-preview-page">
<Viewer src="/viewer/sample.pdf" name="sample.pdf" layout="page" />
</main>
}
style {
.file-preview-page { display: flex; flex-direction: column; height: calc(100vh - 4rem); padding: 1rem; }
}
}
@@ -0,0 +1,66 @@
import Viewer from "@wrnexus/viewer/components/Viewer.wrn"
page FileViewerDemo {
layout = "public"
render = "hybrid"
hydrate = "load"
view {
<main class="file-viewer-demo">
<h1>File previews</h1>
<h2>Browse several files</h2>
<Viewer files={[{src: '/viewer/sample.png', name: 'sample.png'}, {src: '/viewer/sample.html', name: 'sample.html'}, {src: '/viewer/sample.wav', name: 'sample.wav'}, {src: '/viewer/sample.docx', name: 'sample.docx'}]} />
<h2>Rich PDF canvas</h2>
<Viewer src="/viewer/sample.pdf" name="rich-sample.pdf" mime="application/pdf" richPdf={true} />
<p>Images, documents, media, tables, trees, vectors, and archives share the app theme and preview controls.</p>
<h2>Image</h2>
<Viewer src="/viewer/sample.png" name="sample.png" />
<h2>Document</h2>
<Viewer src="/viewer/sample.pdf" name="sample.pdf" />
<h2>Video</h2>
<Viewer src="/viewer/sample.webm" name="sample.webm" />
<h2>Open a preview over the page</h2>
<p>Any element with <code>data-viewer-open</code> opens the file in a dialog.</p>
<p>
<button type="button" data-viewer-open data-src="/viewer/sample.webm" data-name="sample.webm">Preview the video</button>
<button type="button" data-viewer-open data-src="/viewer/sample.pptx" data-name="sample.pptx">Preview the deck</button>
</p>
<h2>Covering the page</h2>
<p>A viewer can mount already expanded, with a Close control that returns it inline.</p>
<Viewer src="/viewer/sample.png" name="expanded.png" fullScreen={false} />
<h2>Audio</h2>
<Viewer src="/viewer/sample.wav" name="sample.wav" />
<h2>Structured data</h2>
<Viewer src="/viewer/sample.json" name="sample.json" />
<Viewer src="/viewer/sample.xml" name="sample.xml" />
<h2>Spreadsheet</h2>
<Viewer src="/viewer/sample.csv" name="sample.csv" />
<Viewer src="/viewer/sample.xlsx" name="sample.xlsx" />
<h2>Vector graphic</h2>
<Viewer src="/viewer/sample.svg" name="sample.svg" />
<h2>Archive</h2>
<Viewer src="/viewer/sample.tar" name="sample.tar" />
<Viewer src="/viewer/sample.zip" name="sample.zip" />
<h2>HTML preview and source</h2>
<Viewer src="/viewer/sample.html" name="sample.html" />
<h2>From uploader storage</h2>
<p>A public object serves itself; a private one resolves to a short-lived signed link.</p>
<Viewer key="brand-logo.png" store="media" />
<Viewer key="quarterly-report.pdf" store="reports" />
<Viewer key="regional-figures.csv" store="reports" />
<h2>Office documents</h2>
<p>Presentations, OpenDocument files and RTF read in the browser; a converter, where one is installed, is preferred for page-faithful output.</p>
<Viewer src="/viewer/sample.pptx" name="sample.pptx" />
<Viewer src="/viewer/sample.odt" name="sample.odt" />
<Viewer src="/viewer/sample.ods" name="sample.ods" />
<Viewer src="/viewer/sample.rtf" name="sample.rtf" />
<h2>Optional parser or server-side converter</h2>
<Viewer src="/viewer/sample.docx" name="sample.docx" />
<h2>Unsupported file</h2>
<Viewer src="/viewer/sample.bin" name="sample.bin" />
</main>
}
style {
.file-viewer-demo { display: grid; gap: 1rem; max-width: 64rem; margin: 2rem auto; padding: 1rem; }
}
}
+6
View File
@@ -9,6 +9,8 @@ export interface Routes {
"/chat": Record<string, never>;
"/client-only": Record<string, never>;
"/dashboard": Record<string, never>;
"/file-preview": Record<string, never>;
"/file-viewer": Record<string, never>;
"/hello": Record<string, never>;
"/island-demo": Record<string, never>;
"/language-tools": Record<string, never>;
@@ -33,6 +35,8 @@ export interface RouteNames {
"chat": "/chat";
"client.only": "/client-only";
"dashboard": "/dashboard";
"file.preview": "/file-preview";
"file.viewer": "/file-viewer";
"hello": "/hello";
"island.demo": "/island-demo";
"language.tools": "/language-tools";
@@ -126,6 +130,8 @@ export function route<N extends RouteName>(
"chat": "/chat",
"client.only": "/client-only",
"dashboard": "/dashboard",
"file.preview": "/file-preview",
"file.viewer": "/file-viewer",
"hello": "/hello",
"island.demo": "/island-demo",
"language.tools": "/language-tools",
+4 -2
View File
@@ -19,9 +19,11 @@
},
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/validation": "workspace:*",
"@wrnexus/db": "workspace:*",
"@wrnexus/ui": "workspace:*"
"@wrnexus/ui": "workspace:*",
"@wrnexus/uploader": "workspace:*",
"@wrnexus/validation": "workspace:*",
"@wrnexus/viewer": "workspace:*"
},
"devDependencies": {
"@wrnexus/test": "workspace:*",
Binary file not shown.
@@ -0,0 +1,5 @@
region,units,revenue
"North, upper",120,15400.50
"South ""central""",95,11200.00
East,140,18990.75
West,88,9400.25
1 region units revenue
2 North, upper 120 15400.50
3 South "central" 95 11200.00
4 East 140 18990.75
5 West 88 9400.25
Binary file not shown.
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en">
<title>HTML source preview</title>
<body><h1>HTML preview in a sandbox</h1><p>Use Source to inspect this document. Scripts and external resources are disabled.</p></body>
</html>
@@ -0,0 +1,5 @@
{
"name": "WRNexus file viewer",
"readOnly": true,
"features": ["image controls", "native PDF", "media playback", "source preview"]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.
@@ -0,0 +1 @@
{\rtf1\ansi{\fonttbl{\f0 Times New Roman;}}\f0\fs24 Rich Text Format\par\par This document is read in the browser with no dependency at all \u8212? RTF is text once the control words are gone.\par\par Accented text survives: Caf\'e9.}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"><circle cx="60" cy="60" r="52" fill="none" stroke="currentColor" stroke-width="6"/><path d="M38 62l16 16 30-34" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<catalog>
<product id="a-1"><name>Desk lamp</name><price currency="INR">1499</price></product>
<product id="a-2"><name>Notebook</name><price currency="INR">249</price></product>
</catalog>
Binary file not shown.
@@ -0,0 +1,5 @@
region,units,revenue
"North, upper",120,15400.50
"South ""central""",95,11200.00
East,140,18990.75
West,88,9400.25
1 region units revenue
2 North, upper 120 15400.50
3 South "central" 95 11200.00
4 East 140 18990.75
5 West 88 9400.25
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

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