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