diff --git a/bun.lock b/bun.lock index fd926123..f7a96b76 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/docs/GUIDE.md b/docs/GUIDE.md index d79d9327..bdcba1dc 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -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 diff --git a/docs/plans/2026-09-08-file-viewer-design.md b/docs/plans/2026-09-08-file-viewer-design.md new file mode 100644 index 00000000..acb574b7 --- /dev/null +++ b/docs/plans/2026-09-08-file-viewer-design.md @@ -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 `` here, an `link

safe

', + window as unknown as { DOMParser: new () => DOMParser }, + ); + expect(html).toContain("

Hello

"); + for (const bad of [ + "script", + "onclick", + "iframe", + "javascript:", + "https://evil", + "onerror", + " { + expect( + waveformPeaks([new Float32Array([0, 0.5, 0, 0]), new Float32Array([0, 0, -2, 0])], 2), + ).toEqual([0.5, 1]); + expect(waveformPeaks([new Float32Array(4)], 2)).toEqual([0, 0]); +}); +test("filmstrip switching disposes each active renderer and supports keyboard selection", async () => { + const window = new Window(); + const host = window.document.createElement("section"); + window.document.body.append(host); + const clean: string[] = []; + const unregister = registerRenderer({ + kind: "text", + exts: [".txt"], + tier: 2, + component: "TextTest", + mount: ({ parent, source }) => { + parent.textContent = source.name; + return () => { + clean.push(source.name); + }; + }, + }); + const dispose = mountViewer(host as unknown as HTMLElement, [ + { name: "one.txt", blob: new Blob(["one"]) }, + { name: "two.txt", blob: new Blob(["two"]) }, + ]); + await tick(); + const buttons = host.querySelectorAll(".wrn-viewer-file"); + (buttons[1] as unknown as HTMLButtonElement).click(); + await tick(); + expect(clean).toEqual(["one.txt"]); + expect(buttons[1]!.getAttribute("aria-pressed")).toBe("true"); + buttons[1]!.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Home", bubbles: true })); + await tick(); + expect(clean).toEqual(["one.txt", "two.txt"]); + expect(buttons[0]!.getAttribute("aria-pressed")).toBe("true"); + dispose(); + expect(clean).toEqual(["one.txt", "two.txt", "one.txt"]); + expect(host.children.length).toBe(0); + unregister(); + await window.happyDOM.close(); +}); +test("text extraction rejects non-text and enforces bounded output", async () => { + const source = { src: "/a.docx", name: "a.docx" }; + expect( + await extractText(source, { + baseURI: "https://app.test", + fetch: (async () => + new Response("search words", { + headers: { "content-type": "text/plain" }, + })) as unknown as typeof fetch, + }), + ).toBe("search words"); + await expect( + extractText(source, { + baseURI: "https://app.test", + fetch: (async () => + new Response("%PDF", { + headers: { "content-type": "application/pdf" }, + })) as unknown as typeof fetch, + }), + ).rejects.toThrow("unavailable"); +}); diff --git a/packages/viewer/test/office.test.ts b/packages/viewer/test/office.test.ts new file mode 100644 index 00000000..802423b9 --- /dev/null +++ b/packages/viewer/test/office.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { zipSync, strToU8 } from "fflate"; +import { readOdf, readPptx } from "../src/optional/office.ts"; +import { isRtf, rtfToText } from "../src/parse/rtf.ts"; +import { resolve } from "../src/resolve.ts"; +import { RendererRegistry } from "../src/registry.ts"; +import { kindOfExtension } from "../src/kinds.ts"; + +const view = new Window() as unknown as { DOMParser: new () => DOMParser }; +const A = "http://schemas.openxmlformats.org/drawingml/2006/main"; +const P = "http://schemas.openxmlformats.org/presentationml/2006/main"; +const OFFICE = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"; +const TEXT = "urn:oasis:names:tc:opendocument:xmlns:text:1.0"; +const TABLE = "urn:oasis:names:tc:opendocument:xmlns:table:1.0"; +const DRAW = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"; + +const file = (name: string, bytes: Uint8Array, mime = "") => ({ + name, + blob: new Blob([bytes as unknown as BlobPart], { type: mime }), +}); + +/** A slide with one title shape and one body shape, as PowerPoint writes them. */ +const slideXml = (title: string, bullets: string[]) => + `` + + `${title}` + + `${bullets + .map((line) => `${line}`) + .join("")}` + + ""; + +const pptx = zipSync({ + // Written out of order on purpose: slide 10 must not sort before slide 2. + "ppt/slides/slide10.xml": strToU8(slideXml("Last slide", ["The end"])), + "ppt/slides/slide1.xml": strToU8(slideXml("Quarterly review", ["Revenue up", "Costs flat"])), + "ppt/slides/slide2.xml": strToU8(slideXml("Regions", ["North", "South"])), + "ppt/notesSlides/notesSlide1.xml": strToU8( + `Say hello first`, + ), + // Media must never be inflated for a listing-style read. + "ppt/media/image1.png": new Uint8Array(2048), +}); + +const odf = (mimetype: string, body: string) => + zipSync({ + mimetype: strToU8(mimetype), + "content.xml": strToU8( + `${body}` + + "", + ), + }); + +describe("PowerPoint", () => { + test("slides come back in numeric order with titles and bullets", async () => { + const deck = await readPptx(pptx, view); + expect(deck.type).toBe("slides"); + expect(deck.slides.map((slide) => slide.title)).toEqual([ + "Quarterly review", + "Regions", + "Last slide", + ]); + expect(deck.slides[0]!.blocks[1]).toEqual(["Revenue up", "Costs flat"]); + expect(deck.slides[0]!.notes).toBe("Say hello first"); + expect(deck.slides[1]!.notes).toBeUndefined(); + }); + + test("a file with no slides rejects rather than showing an empty deck", async () => { + await expect(readPptx(zipSync({ "docProps/app.xml": strToU8("") }), view)).rejects.toThrow( + "no slides", + ); + }); +}); + +describe("OpenDocument", () => { + test("text keeps heading levels and list membership", async () => { + const bytes = odf( + "application/vnd.oasis.opendocument.text", + "" + + 'Annual report' + + "Opening paragraph." + + 'Detail' + + "First point" + + "Second point" + + "", + ); + const document = await readOdf(bytes, view); + expect(document.type).toBe("text"); + if (document.type !== "text") throw new Error("unreachable"); + expect(document.blocks[0]).toEqual({ level: 1, text: "Annual report" }); + expect(document.blocks[1]).toEqual({ level: 0, text: "Opening paragraph." }); + expect(document.blocks[2]!.level).toBe(2); + expect(document.blocks[3]).toEqual({ level: 0, text: "First point", list: true }); + }); + + test("spreadsheets expand repeated cells but not trailing padding", async () => { + const bytes = odf( + "application/vnd.oasis.opendocument.spreadsheet", + "" + + '' + + "Region" + + "Q1" + + "North" + + '10' + + '' + + "", + ); + const document = await readOdf(bytes, view); + if (document.type !== "sheets") throw new Error("expected sheets"); + expect(document.sheets[0]!.name).toBe("Revenue"); + expect(document.sheets[0]!.header).toEqual(["Region", "Q1"]); + // The repeat of 2 is real data; the 16384 empty tail is dropped. + expect(document.sheets[0]!.rows).toEqual([["North", "10", "10"]]); + }); + + test("presentations become slides", async () => { + const bytes = odf( + "application/vnd.oasis.opendocument.presentation", + "" + + 'Kickoff' + + "AgendaTimeline" + + "", + ); + const document = await readOdf(bytes, view); + if (document.type !== "slides") throw new Error("expected slides"); + expect(document.slides[0]!.title).toBe("Kickoff"); + expect(document.slides[0]!.blocks[1]).toEqual(["Agenda", "Timeline"]); + }); + + test("a zip that is not an OpenDocument file rejects", async () => { + await expect(readOdf(zipSync({ "a.txt": strToU8("hi") }), view)).rejects.toThrow( + "Not an OpenDocument", + ); + }); +}); + +describe("RTF", () => { + test("control words, escapes and unicode become text", () => { + const source = + "{\\rtf1\\ansi{\\fonttbl{\\f0 Times;}}{\\colortbl;\\red0\\green0\\blue0;}" + + "\\f0\\fs24 Hello\\tab world\\par Second line\\par " + + "Caf\\'e9 and \\u8212? dashes\\par}"; + const text = rtfToText(source); + expect(text).toContain("Hello\tworld"); + expect(text).toContain("Second line"); + // Font and colour tables must not leak into the output. + expect(text).not.toContain("Times"); + expect(text).not.toContain("red0"); + expect(text).toContain("Café"); + expect(text).toContain("—"); + }); + + test("optional destinations are skipped whole", () => { + const text = rtfToText( + "{\\rtf1{\\*\\generator Word 16;}Visible text{\\*\\bkmkstart hidden}\\par}", + ); + expect(text).toContain("Visible text"); + expect(text).not.toContain("Word 16"); + expect(text).not.toContain("hidden"); + }); + + test("non-RTF input is rejected", () => { + expect(isRtf("hello")).toBe(false); + expect(() => rtfToText("hello")).toThrow("Not an RTF"); + }); +}); + +describe("routing", () => { + // Optional modules cannot load outside a browser, so `resolve` would skip + // every renderer that needs one. Registration and precedence are the units + // under test here; the mounted result is verified in the example app. + const registry = new RendererRegistry(); + const components = (ext: string) => + registry.candidates(kindOfExtension(ext), ext).map((renderer) => renderer.component); + + test("presentations and documents prefer the converter, with a local reader behind it", () => { + expect(components(".pptx")).toEqual(["ConvertedViewer", "SlidesViewer"]); + expect(components(".odt")).toEqual(["ConvertedViewer", "OpenDocumentViewer"]); + expect(components(".odp")).toEqual(["ConvertedViewer", "OpenDocumentViewer"]); + expect(components(".docx")).toEqual(["ConvertedViewer", "DocxViewer"]); + }); + + test("spreadsheets prefer the local reader: the data matters, not the layout", () => { + expect(components(".ods")[0]).toBe("SheetViewer"); + expect(components(".xlsx")[0]).toBe("SheetViewer"); + // A workbook SheetJS cannot open still has the OpenDocument reader behind it. + expect(components(".ods")).toContain("OpenDocumentViewer"); + }); + + test("legacy binary formats stay converter-only", () => { + expect(components(".doc")).toEqual(["ConvertedViewer"]); + expect(components(".ppt")).toEqual(["ConvertedViewer"]); + }); + + test("RTF needs no optional module and no converter", async () => { + expect(components(".rtf")[0]).toBe("ConvertedViewer"); + const source = String.raw`{\rtf1\ansi Hello\par}`; + expect(isRtf(source)).toBe(true); + const plan = await resolve(file("letter.rtf", strToU8(source))); + expect(plan.renderer.component).toBe("RtfViewer"); + expect(rtfToText(source)).toBe("Hello"); + }); +}); diff --git a/packages/viewer/test/optional.test.ts b/packages/viewer/test/optional.test.ts new file mode 100644 index 00000000..85f9c403 --- /dev/null +++ b/packages/viewer/test/optional.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test"; +import { docxToHtml } from "../src/optional/docx.ts"; +import { readWorkbook } from "../src/optional/sheet.ts"; +import { listZip } from "../src/optional/zip.ts"; +import { zipSync, strToU8 } from "fflate"; + +/** + * These exercise the parsers themselves. The browser wiring — one runtime asset + * per installed package, fetched on first use — is covered in component.test.ts. + */ +describe("zip listing", () => { + const archive = zipSync({ + "docs/readme.md": strToU8("hello world\n".repeat(20)), + "src/index.ts": strToU8("export const value = 1;\n"), + }); + + test("entries are listed with their uncompressed sizes", async () => { + const entries = await listZip(archive); + expect(entries.map((entry) => entry.name).sort()).toEqual(["docs/readme.md", "src/index.ts"]); + expect(entries.find((entry) => entry.name === "docs/readme.md")!.size).toBe(240); + }); + + test("a corrupt archive rejects rather than resolving empty", async () => { + await expect(listZip(new Uint8Array([0x50, 0x4b, 3, 4, 9, 9, 9]))).rejects.toThrow(); + }); +}); + +describe("workbook reading", () => { + // Built with the same shape as examples/basic-app/public/viewer/sample.xlsx. + const NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + const R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + const sheet = (rows: (string | number)[][]) => + `${rows + .map( + (row, rowIndex) => + `${row + .map((value, index) => { + const ref = `${String.fromCharCode(65 + index)}${rowIndex + 1}`; + return typeof value === "number" + ? `${value}` + : `${value}`; + }) + .join("")}`, + ) + .join("")}`; + + const workbook = zipSync({ + "[Content_Types].xml": strToU8( + '' + + '' + + '' + + '' + + '' + + '' + + "", + ), + "_rels/.rels": strToU8( + ``, + ), + "xl/workbook.xml": strToU8( + `` + + '' + + '' + + "", + ), + "xl/_rels/workbook.xml.rels": strToU8( + `` + + `` + + `` + + "", + ), + "xl/worksheets/sheet1.xml": strToU8( + sheet([ + ["Region", "Q1"], + ["North", 15400], + ["South", 11200], + ]), + ), + "xl/worksheets/sheet2.xml": strToU8( + sheet([ + ["Team", "People"], + ["Engineering", 42], + ]), + ), + }); + + test("every sheet comes back named, with a header and string cells", () => { + const sheets = readWorkbook(workbook); + expect(sheets.map((item) => item.name)).toEqual(["Revenue", "Headcount"]); + expect(sheets[0]!.header).toEqual(["Region", "Q1"]); + expect(sheets[0]!.rows).toEqual([ + ["North", "15400"], + ["South", "11200"], + ]); + expect(sheets[1]!.rows).toEqual([["Engineering", "42"]]); + }); +}); + +describe("document conversion", () => { + const W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + const R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + const paragraph = (text: string, style?: string) => + `${style ? `` : ""}` + + `${text}`; + const document = zipSync({ + "[Content_Types].xml": strToU8( + '' + + '' + + '' + + '' + + "", + ), + "_rels/.rels": strToU8( + ``, + ), + "word/document.xml": strToU8( + `` + + paragraph("Quarterly summary", "Heading1") + + paragraph("Bodies of text survive; page layout does not.") + + "", + ), + }); + + test("a docx becomes semantic HTML", async () => { + const html = await docxToHtml(document); + expect(html).toContain("

Quarterly summary

"); + expect(html).toContain("Bodies of text survive"); + // Semantic output only: no document chrome, and nothing executable. + expect(html).not.toContain(" { + await expect(docxToHtml(strToU8("not a document"))).rejects.toThrow(); + }); +}); diff --git a/packages/viewer/test/overlay.test.ts b/packages/viewer/test/overlay.test.ts new file mode 100644 index 00000000..d39f3ad0 --- /dev/null +++ b/packages/viewer/test/overlay.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { openViewer, sourceFromTrigger, startViewerTriggers } from "../src/overlay.ts"; +import { mountViewer, startViewerRuntime } from "../src/runtime.ts"; + +async function settled(ticks = 30) { + for (let i = 0; i < ticks; i++) await new Promise((done) => setTimeout(done, 5)); +} + +function page() { + const window = new Window({ url: "https://app.test/" }); + return { window, doc: window.document as unknown as Document }; +} + +const csv = { name: "data.csv", blob: new Blob(["a,b\n1,2\n"]) }; + +describe("opening a preview over the page", () => { + test("the overlay mounts a viewer and offers a way out", async () => { + const { window, doc } = page(); + const opened = openViewer(csv, { document: doc }); + await settled(); + const dialog = doc.querySelector(".wrn-viewer-dialog")!; + expect(dialog).not.toBeNull(); + expect(dialog.querySelector(".wrn-viewer-content")!.getAttribute("data-viewer-state")).toBe( + "ready", + ); + expect(dialog.querySelector('[data-viewer-action="close"]')).not.toBeNull(); + opened.close(); + expect(doc.querySelector(".wrn-viewer-dialog")).toBeNull(); + await window.happyDOM.close(); + }); + + test("closing disposes the viewer rather than leaving it behind the overlay", async () => { + const { window, doc } = page(); + const opened = openViewer(csv, { document: doc }); + await settled(); + const host = doc.querySelector(".wrn-viewer-dialog .wrn-viewer")!; + expect(host.querySelector("table")).not.toBeNull(); + opened.close(); + // The host went with the dialog, so nothing keeps running off-screen. + expect(host.isConnected).toBe(false); + await window.happyDOM.close(); + }); + + test("closing twice, or from the dialog's own close event, is safe", async () => { + const { window, doc } = page(); + let closes = 0; + const opened = openViewer(csv, { document: doc, onClose: () => closes++ }); + await settled(); + doc.querySelector(".wrn-viewer-dialog")!.dispatchEvent(new window.Event("close") as never); + opened.close(); + expect(closes).toBe(1); + await window.happyDOM.close(); + }); + + test("a click on the dialog itself is a backdrop click", async () => { + const { window, doc } = page(); + openViewer(csv, { document: doc }); + await settled(); + const dialog = doc.querySelector(".wrn-viewer-dialog")!; + // A click inside the viewer must not close it. + dialog + .querySelector(".wrn-viewer")! + .dispatchEvent(new window.MouseEvent("click", { bubbles: true }) as never); + expect(doc.querySelector(".wrn-viewer-dialog")).not.toBeNull(); + dialog.dispatchEvent(new window.MouseEvent("click", { bubbles: true }) as never); + expect(doc.querySelector(".wrn-viewer-dialog")).toBeNull(); + await window.happyDOM.close(); + }); +}); + +describe("triggers", () => { + test("a data attribute is read into a source", () => { + const { window, doc } = page(); + const button = doc.createElement("button"); + button.dataset.viewerOpen = ""; + button.dataset.src = "/files/a.pdf"; + button.dataset.name = "a.pdf"; + button.dataset.size = "120"; + expect(sourceFromTrigger(button)).toEqual({ + src: "/files/a.pdf", + key: undefined, + store: undefined, + name: "a.pdf", + mime: undefined, + size: 120, + }); + // Nothing to open is not an error; the click is simply ignored. + expect(sourceFromTrigger(doc.createElement("button"))).toBeUndefined(); + void window.happyDOM.close(); + }); + + test("clicking an element with data-viewer-open opens the preview", async () => { + const { window, doc } = page(); + const stop = startViewerTriggers(doc); + const button = doc.createElement("button"); + button.dataset.viewerOpen = ""; + button.dataset.src = "https://app.test/notes.csv"; + button.dataset.name = "notes.csv"; + doc.body.append(button); + const original = globalThis.fetch; + globalThis.fetch = (async () => + new Response("a,b\n1,2\n", { headers: { "content-type": "text/csv" } })) as never; + try { + button.dispatchEvent(new window.MouseEvent("click", { bubbles: true }) as never); + await settled(); + expect(doc.querySelector(".wrn-viewer-dialog")).not.toBeNull(); + expect(doc.querySelector(".wrn-viewer-dialog table")).not.toBeNull(); + } finally { + globalThis.fetch = original; + stop(); + } + await window.happyDOM.close(); + }); +}); + +describe("layout modes", () => { + test("fullScreen mounts covering the page, and Close returns it inline", async () => { + const { window, doc } = page(); + const host = doc.createElement("section"); + doc.body.append(host); + const dispose = mountViewer(host as never, csv, { fullScreen: true }); + await settled(); + expect(host.classList.contains("wrn-viewer-expanded")).toBe(true); + const close = host.querySelector('[data-viewer-action="close"]') as HTMLButtonElement; + expect(close).not.toBeNull(); + close.click(); + expect(host.classList.contains("wrn-viewer-expanded")).toBe(false); + // The control goes with the state it exits. + expect(host.querySelector('[data-viewer-action="close"]')).toBeNull(); + dispose(); + await window.happyDOM.close(); + }); + + test("an inline viewer offers no Close, because there is nothing to close", async () => { + const { window, doc } = page(); + const host = doc.createElement("section"); + doc.body.append(host); + const dispose = mountViewer(host as never, csv); + await settled(); + expect(host.querySelector('[data-viewer-action="close"]')).toBeNull(); + dispose(); + await window.happyDOM.close(); + }); + + test("layout=page drops the card chrome", async () => { + const { window, doc } = page(); + const host = doc.createElement("section"); + doc.body.append(host); + const dispose = mountViewer(host as never, csv, { layout: "page" }); + await settled(); + expect(host.classList.contains("wrn-viewer-as-page")).toBe(true); + dispose(); + await window.happyDOM.close(); + }); + + test("the runtime re-mounts when the layout attributes change", async () => { + const { window, doc } = page(); + const host = doc.createElement("section"); + host.dataset.wrnexusRuntime = "viewer"; + host.dataset.name = "data.csv"; + host.dataset.src = "https://app.test/data.csv"; + host.dataset.fullScreen = "false"; + doc.body.append(host); + const original = globalThis.fetch; + globalThis.fetch = (async () => + new Response("a,b\n1,2\n", { headers: { "content-type": "text/csv" } })) as never; + const stop = startViewerRuntime(doc); + try { + await settled(); + expect(host.classList.contains("wrn-viewer-expanded")).toBe(false); + // Without this in the remount signature the attribute change is ignored. + host.dataset.fullScreen = "true"; + await settled(); + expect(host.classList.contains("wrn-viewer-expanded")).toBe(true); + } finally { + globalThis.fetch = original; + stop(); + } + await window.happyDOM.close(); + }); +}); diff --git a/packages/viewer/test/regressions.test.ts b/packages/viewer/test/regressions.test.ts new file mode 100644 index 00000000..cf7e38a6 --- /dev/null +++ b/packages/viewer/test/regressions.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { rtfToText } from "../src/parse/rtf.ts"; +import { renderSlides } from "../src/renderers/slides.ts"; +import { mountViewer } from "../src/runtime.ts"; +import { registerRenderer } from "../src/registry.ts"; +import { resetConverterProbe } from "../src/convert/client.ts"; +import { mountPdf } from "../src/renderers/pdf.ts"; + +async function settled(ticks = 40) { + for (let i = 0; i < ticks; i++) await new Promise((done) => setTimeout(done, 5)); +} + +function fixture() { + const window = new Window({ url: "https://app.test/" }); + const host = window.document.createElement("section") as unknown as HTMLElement; + window.document.body.append(host as never); + return { window, host }; +} + +describe("RTF unicode substitution", () => { + // \ucN says how many ANSI characters stand in for each \uN. Assuming 1 ate a + // real character after \uc0, and left a stray "?" behind after \uc2. + test("uc0 means the escape stands alone", () => { + expect(rtfToText(String.raw`{\rtf1\ansi\uc0舒 Hello world\par}`)).toBe("—Hello world"); + }); + + test("uc2 skips exactly two substitutes", () => { + expect(rtfToText(String.raw`{\rtf1\ansi\uc2舒?? Hello\par}`)).toBe("— Hello"); + }); + + test("the count is group-scoped and restored on close", () => { + expect(rtfToText(String.raw`{\rtf1\ansi{\uc0舒}舒? tail\par}`)).toBe("—— tail"); + }); +}); + +describe("slide navigation", () => { + // The counter used to be refreshed from a click listener, so arrow keys moved + // the deck without moving the label. + test("every move notifies, keyboard included", () => { + const { window } = fixture(); + const doc = window.document as unknown as Document; + const seen: number[] = []; + const deck = renderSlides( + doc, + [ + { title: "One", blocks: [["One"]] }, + { title: "Two", blocks: [["Two"]] }, + { title: "Three", blocks: [["Three"]] }, + ], + (index) => seen.push(index), + ); + doc.body.append(deck.node as never); + deck.next(1); + deck.node.dispatchEvent( + new window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }) as never, + ); + expect(seen).toEqual([0, 1, 2]); + expect(deck.status()).toBe("3 of 3"); + }); +}); + +describe("streaming renderers and the body", () => { + function stubFetch(requests: string[], body: Uint8Array) { + const original = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + const range = new Headers(init?.headers).get("range"); + requests.push(`${init?.method ?? "GET"}${range ? " ranged" : ""} ${new URL(url).pathname}`); + if (url.includes("/api/viewer/capabilities")) + return Response.json({ converters: ["fake"], formats: { ".psd": "image/png" } }); + return new Response(body as unknown as BodyInit, { + headers: { + "content-length": String(body.byteLength), + "content-type": "image/vnd.adobe.photoshop", + }, + }); + }) as unknown as typeof fetch; + return () => { + globalThis.fetch = original; + }; + } + + test("a converted preview never downloads the original body", async () => { + resetConverterProbe(); + const { window, host } = fixture(); + const requests: string[] = []; + // 40 MB of design file: reading it into a blob only to discard it was the bug. + const body = new Uint8Array(40 * 1024 * 1024); + body.set([0x38, 0x42, 0x50, 0x53]); + const restore = stubFetch(requests, body); + try { + const dispose = mountViewer(host, { src: "https://app.test/art.psd", name: "art.psd" }); + await settled(); + expect(requests.some((entry) => entry.includes("/api/viewer/capabilities"))).toBe(true); + expect( + requests.filter((entry) => entry === "GET /art.psd"), + // Only the bounded ranged sniff may touch the source. + ).toEqual([]); + dispose(); + } finally { + restore(); + resetConverterProbe(); + } + await window.happyDOM.close(); + }); + + test("a streaming renderer that fails still falls through to a buffered one", async () => { + const { window, host } = fixture(); + // Registered first so it sits *behind* the streaming renderer in the ladder. + const unregisterBuffered = registerRenderer({ + kind: "text", + exts: ["probe"], + tier: 2, + component: "BufferedProbe", + async mount({ parent, file }) { + parent.textContent = `buffered:${await file!.text()}`; + }, + }); + const unregisterStreaming = registerRenderer({ + kind: "text", + exts: ["probe"], + tier: 2, + component: "StreamingProbe", + streaming: true, + mount() { + throw new Error("streaming renderer failed"); + }, + }); + const requests: string[] = []; + const restore = stubFetch(requests, new TextEncoder().encode("hello")); + try { + const dispose = mountViewer(host, { src: "https://app.test/a.probe", name: "a.probe" }); + await settled(); + expect(host.textContent).toContain("buffered:hello"); + dispose(); + } finally { + restore(); + unregisterStreaming(); + unregisterBuffered(); + } + await window.happyDOM.close(); + }); +}); + +describe("PDF framing", () => { + // Reaching the native frame as a fallback means a body was already read. The + // object URL for it cannot be framed under a `default-src 'self'` policy, so + // the original URL has to win whenever there is one. + test("the frame uses the original URL even when a body is in hand", async () => { + const { window, host } = fixture(); + const blob = new Blob(["%PDF-1.7"], { type: "application/pdf" }); + await mountPdf({ + parent: host, + file: blob, + source: { src: "https://app.test/report.pdf", name: "report.pdf" }, + sourceUrl: async () => "blob:https://app.test/should-not-be-used", + kind: "pdf", + button: () => window.document.createElement("button") as never, + objectUrl: () => "blob:https://app.test/should-not-be-used", + degrade: () => {}, + signal: new AbortController().signal, + }); + expect(host.querySelector("iframe")!.getAttribute("src")).toBe("https://app.test/report.pdf"); + await window.happyDOM.close(); + }); +}); diff --git a/packages/viewer/test/resolve.test.ts b/packages/viewer/test/resolve.test.ts new file mode 100644 index 00000000..3eb0b57c --- /dev/null +++ b/packages/viewer/test/resolve.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from "bun:test"; +import { extensions, kindOfExtension } from "../src/kinds.ts"; +import { RendererRegistry } from "../src/registry.ts"; +import { resolve } from "../src/resolve.ts"; +import { readBounded } from "../src/source.ts"; +import { sniff } from "../src/sniff.ts"; + +const file = (name: string, bytes: string | number[], mime = "") => ({ + name, + blob: new Blob([typeof bytes === "string" ? bytes : new Uint8Array(bytes)], { type: mime }), +}); + +describe("classification and signatures", () => { + for (const [kind, exts] of Object.entries(extensions)) + for (const ext of exts) { + test(`classifies .${ext}`, () => + expect(String(kindOfExtension(`.${ext.toUpperCase()}`))).toBe(kind)); + } + const signatures: [string, string | number[], string][] = [ + ["png", [137, 80, 78, 71, 13, 10, 26, 10], "ImageViewer"], + ["jpeg", [255, 216, 255], "ImageViewer"], + ["gif", "GIF89a", "ImageViewer"], + ["webp", "RIFF0000WEBP", "ImageViewer"], + ["bmp", "BM00", "ImageViewer"], + ["ico", [0, 0, 1, 0], "ImageViewer"], + ["avif", "0000ftypavif", "ImageViewer"], + ["pdf", "%PDF-1.7", "PdfViewer"], + ["wav", "RIFF0000WAVE", "MediaViewer"], + ["mp3", "ID3", "MediaViewer"], + ["aac", [255, 241, 80, 128], "MediaViewer"], + ["flac", "fLaC", "MediaViewer"], + ["ogg", "OggS", "MediaViewer"], + ["mp4", "0000ftypisom", "MediaViewer"], + ["mov", "0000ftypqt ", "MediaViewer"], + ["webm", [0x1a, 0x45, 0xdf, 0xa3], "MediaViewer"], + ["txt", "hello", "CodeViewer"], + ["js", "const value = 1;", "CodeViewer"], + ["html", "", "HtmlViewer"], + ]; + for (const [ext, bytes, component] of signatures) + test(`resolves ${ext}`, async () => { + expect((await resolve(file(`test.${ext}`, bytes))).renderer.component).toBe(component); + }); + test("HTML masquerading as PNG is shown as inert source", async () => { + const plan = await resolve( + file("fake.png", "", "image/png"), + ); + expect(plan.kind).toBe("markup"); + expect(plan.renderer.component).toBe("CodeViewer"); + }); + test("SVG masquerading as image never reaches native image rendering", async () => { + const plan = await resolve(file("fake.png", '', "image/png")); + expect(plan.kind).toBe("vector"); + expect(plan.sandboxed).toBe(true); + expect(plan.renderer.component).toBe("SvgViewer"); + }); + test("binary or executables cannot become text through MIME", async () => { + expect((await resolve(file("fake.txt", [0, 1, 2], "text/plain"))).renderer.component).toBe( + "DownloadCard", + ); + expect((await resolve(file("fake.png", "MZ123", "image/png"))).renderer.component).toBe( + "DownloadCard", + ); + expect((await resolve(file("fake.exe", "hello"))).renderer.component).toBe("DownloadCard"); + }); + test("UTF-8 truncated at sniff boundary is still text", () => { + expect(sniff(new Uint8Array([97, 0xe2, 0x82])).kind).toBe("text"); + }); +}); + +describe("degradation and network limits", () => { + test("missing optional dependencies fall through to native renderer", async () => { + const registry = new RendererRegistry(); + registry.register({ + kind: "pdf", + exts: [".pdf"], + tier: 2, + component: "RichPdf", + load: async () => { + throw new Error("missing"); + }, + }); + expect((await resolve(file("test.pdf", "%PDF-1.7"), { registry })).renderer.component).toBe( + "PdfViewer", + ); + }); + test("overrides can be removed and conflicting signatures bypass them", async () => { + const registry = new RendererRegistry(); + const unregister = registry.register({ + kind: "text", + exts: ["txt"], + tier: 2, + component: "Custom", + }); + expect((await resolve(file("test.txt", "hi"), { registry })).renderer.component).toBe("Custom"); + expect((await resolve(file("test.txt", "%PDF-1.7"), { registry })).renderer.component).toBe( + "PdfViewer", + ); + unregister(); + expect((await resolve(file("test.txt", "hi"), { registry })).renderer.component).toBe( + "CodeViewer", + ); + }); + test("unavailable converters degrade", async () => { + const registry = new RendererRegistry(); + registry.register({ kind: "binary", exts: [".psd"], tier: 3, component: "Convert" }); + expect((await resolve(file("image.psd", [0, 0, 0]), { registry })).renderer.component).toBe( + "DownloadCard", + ); + }); + test("oversized HEAD prevents body fetch", async () => { + const methods: string[] = []; + const request = (async (_url, init) => { + methods.push(init?.method ?? "GET"); + return new Response(null, { headers: { "content-length": "400000000" } }); + }) as typeof fetch; + const plan = await resolve( + { name: "large.mp4", src: "https://example.test/file" }, + { fetch: request }, + ); + expect(plan.renderer.component).toBe("DownloadCard"); + expect(methods).toEqual(["HEAD"]); + }); + test("unknown-length or ignored Range stream is cancelled at prefix cap", async () => { + let cancelled = false; + const bytes = await readBounded( + new Response( + new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array(8192)); + }, + cancel() { + cancelled = true; + }, + }), + ), + 4096, + true, + ); + expect(bytes.length).toBe(4096); + expect(cancelled).toBe(true); + }); + test("full reads enforce actual bytes despite dishonest metadata", async () => { + await expect( + readBounded(new Response("123456", { headers: { "content-length": "1" } }), 3), + ).rejects.toThrow("limit"); + }); + test("HEAD without support still performs bounded sniff", async () => { + const request = (async (_url, init) => + init?.method === "HEAD" + ? new Response(null, { status: 405 }) + : new Response("hello")) as typeof fetch; + expect( + (await resolve({ name: "text.txt", src: "/file" }, { fetch: request })).renderer.component, + ).toBe("CodeViewer"); + }); + test("invalid URLs, rejected fetches and key-only sources degrade", async () => { + expect((await resolve({ name: "a.txt", src: "javascript:alert(1)" })).renderer.component).toBe( + "DownloadCard", + ); + expect((await resolve({ name: "a.txt", key: "private" })).renderer.component).toBe( + "DownloadCard", + ); + const request = (async () => { + throw new Error("offline"); + }) as unknown as typeof fetch; + expect( + (await resolve({ name: "a.txt", src: "/file" }, { fetch: request })).renderer.component, + ).toBe("DownloadCard"); + }); +}); + +describe("fallback reasons and unrenderable signatures", () => { + test("a kind with no renderer reports why, not a size limit", async () => { + const request = (async () => + new Response("PK", { + headers: { "content-length": "512", "content-type": "application/zip" }, + })) as unknown as typeof fetch; + const plan = await resolve( + { name: "bundle.zip", src: "https://example.test/bundle.zip" }, + { fetch: request }, + ); + expect(plan.kind).toBe("archive"); + expect(plan.reason).toBe("Preview unavailable for this file"); + }); + test("design formats with ASCII-clean headers are not mistaken for text", async () => { + const ascii = (signature: number[]) => { + const bytes = new Uint8Array(4096).fill(0x41); + bytes.set(signature); + return bytes; + }; + expect(sniff(ascii([0x38, 0x42, 0x50, 0x53])).kind).toBe("binary"); + expect(sniff(ascii([0x49, 0x49, 0x2a, 0x00])).kind).toBe("binary"); + expect(sniff(ascii([0x4d, 0x4d, 0x00, 0x2a])).kind).toBe("binary"); + expect( + (await resolve({ name: "art.psd", blob: new Blob([ascii([0x38, 0x42, 0x50, 0x53])]) })) + .renderer.component, + ).toBe("DownloadCard"); + }); +}); diff --git a/packages/viewer/test/runtime.test.ts b/packages/viewer/test/runtime.test.ts new file mode 100644 index 00000000..28a586b4 --- /dev/null +++ b/packages/viewer/test/runtime.test.ts @@ -0,0 +1,147 @@ +import { expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { mountViewer } from "../src/runtime.ts"; +import { registerRenderer } from "../src/registry.ts"; + +async function settled() { + for (let i = 0; i < 15; i++) await new Promise((resolve) => setTimeout(resolve, 5)); +} +function fixture() { + const window = new Window(); + const host = window.document.createElement("section"); + window.document.body.append(host); + return { window, host: host as unknown as HTMLElement }; +} +test("image controls, failure fallback, and teardown", async () => { + const { window, host } = fixture(); + const dispose = mountViewer(host, { + name: ".png", + blob: new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])]), + }); + await settled(); + expect(host.querySelector("script")).toBeNull(); + const image = host.querySelector("img")!; + expect(image).not.toBeNull(); + const zoom = [...host.querySelectorAll("button")].find( + (button) => button.textContent === "Zoom in", + )!; + zoom.click(); + expect(image.style.width).toBe("125%"); + const url = image.src; + image.dispatchEvent(new window.Event("error") as unknown as Event); + expect(host.textContent).toContain("Image could not be displayed"); + dispose(); + expect(host.children.length).toBe(0); + await expect(fetch(url)).rejects.toThrow(); + await window.happyDOM.close(); +}); +test("unsafe links are omitted and unavailable files retain metadata", async () => { + const { window, host } = fixture(); + const dispose = mountViewer(host, { name: "file", src: "javascript:alert(1)", size: 123 }); + await settled(); + expect(host.querySelector("a")).toBeNull(); + // The fallback card keeps the name and a human-readable size. + expect(host.textContent).toContain("file"); + expect(host.textContent).toContain("123 B"); + dispose(); + await window.happyDOM.close(); +}); +test("markup source never creates executable DOM", async () => { + const { window, host } = fixture(); + const dispose = mountViewer(host, { + name: "hostile.html", + blob: new Blob(['']), + }); + await settled(); + expect(host.querySelector("script, img")).toBeNull(); + const frame = host.querySelector("iframe")!; + expect(frame.getAttribute("sandbox")).toBe(""); + expect(frame.srcdoc).not.toContain(""); + dispose(); + await window.happyDOM.close(); +}); +test("disposing a pending custom renderer runs its eventual cleanup", async () => { + const { window, host } = fixture(); + let finish!: () => void; + let cleaned = false; + const ready = new Promise((resolve) => { + finish = resolve; + }); + const unregister = registerRenderer({ + kind: "text", + exts: ["txt"], + tier: 2, + component: "Custom", + mount: async () => { + await ready; + return () => { + cleaned = true; + }; + }, + }); + const dispose = mountViewer(host, { name: "a.txt", blob: new Blob(["hello"]) }); + await settled(); + dispose(); + finish(); + await settled(); + expect(cleaned).toBe(true); + expect(host.children.length).toBe(0); + unregister(); + await window.happyDOM.close(); +}); + +test("a renderer without a mount adapter never blocks the preview", async () => { + const { window, host } = fixture(); + const unregister = registerRenderer({ + kind: "text", + exts: ["txt"], + tier: 2, + component: "NoMount", + }); + const dispose = mountViewer(host, { name: "a.txt", blob: new Blob(["plain body"]) }); + await settled(); + expect(host.textContent).toContain("plain body"); + dispose(); + unregister(); + await window.happyDOM.close(); +}); + +test("an http source streams from its own URL instead of a blob copy", async () => { + const { window, host } = fixture(); + let bodyRequests = 0; + const original = globalThis.fetch; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const method = init?.method ?? "GET"; + if (method === "HEAD") return new Response(null, { headers: { "content-type": "video/mp4" } }); + bodyRequests++; + return new Response("\0\0\0\0ftypisom"); + }) as unknown as typeof fetch; + try { + const dispose = mountViewer(host, { name: "clip.mp4", src: "https://cdn.test/clip.mp4" }); + await settled(); + const video = host.querySelector("video")!; + expect(video).not.toBeNull(); + // The element points at the original URL: no blob copy, no full download. + expect(video.getAttribute("src")).toBe("https://cdn.test/clip.mp4"); + expect(bodyRequests).toBe(1); // the bounded sniff only + dispose(); + } finally { + globalThis.fetch = original; + } + await window.happyDOM.close(); +}); + +test("a local file still renders from an object URL under its sniffed type", async () => { + const { window, host } = fixture(); + const dispose = mountViewer(host, { + name: "shot.png", + blob: new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])]), + }); + await settled(); + expect(host.querySelector("img")!.src.startsWith("blob:")).toBe(true); + dispose(); + await window.happyDOM.close(); +}); diff --git a/packages/viewer/test/tier2.test.ts b/packages/viewer/test/tier2.test.ts new file mode 100644 index 00000000..5ec0b750 --- /dev/null +++ b/packages/viewer/test/tier2.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { detectDelimiter, parseDelimited } from "../src/parse/delimited.ts"; +import { gzipName, isTar, parseTar } from "../src/parse/archive.ts"; +import { sanitizeSvg } from "../src/parse/svg.ts"; +import { resolve } from "../src/resolve.ts"; +import { mountViewer } from "../src/runtime.ts"; + +const file = (name: string, bytes: string | Uint8Array, mime = "") => ({ + name, + blob: new Blob([bytes as string], { type: mime }), +}); + +async function settled() { + for (let i = 0; i < 15; i++) await new Promise((done) => setTimeout(done, 5)); +} +function fixture() { + const window = new Window(); + const host = window.document.createElement("section"); + window.document.body.append(host); + return { window, host: host as unknown as HTMLElement }; +} + +/** Minimal USTAR archive: one header block plus padded content. */ +function tar(entries: { name: string; body: string }[]): Uint8Array { + const blocks: Uint8Array[] = []; + const encoder = new TextEncoder(); + for (const entry of entries) { + const header = new Uint8Array(512); + header.set(encoder.encode(entry.name), 0); + header.set(encoder.encode(entry.body.length.toString(8).padStart(11, "0")), 124); + header[156] = "0".charCodeAt(0); + header.set(encoder.encode("ustar"), 257); + blocks.push(header); + const content = new Uint8Array(Math.ceil(entry.body.length / 512) * 512); + content.set(encoder.encode(entry.body)); + blocks.push(content); + } + blocks.push(new Uint8Array(1024)); + const total = blocks.reduce((sum, block) => sum + block.length, 0); + const bytes = new Uint8Array(total); + let offset = 0; + for (const block of blocks) { + bytes.set(block, offset); + offset += block.length; + } + return bytes; +} + +describe("delimited parsing", () => { + test("quotes may hold the delimiter, newlines and escaped quotes", () => { + const table = parseDelimited('a,b\r\n"x,1","says ""hi""\nagain"\r\n'); + expect(table.header).toEqual(["a", "b"]); + expect(table.rows).toEqual([["x,1", 'says "hi"\nagain']]); + expect(table.delimiter).toBe(","); + }); + test("tabs and semicolons are detected, quoted ones ignored", () => { + expect(detectDelimiter("a\tb\tc")).toBe("\t"); + expect(detectDelimiter("a;b;c")).toBe(";"); + expect(detectDelimiter('"a,b,c,d";e')).toBe(";"); + }); + test("a trailing newline does not add a row and caps are honoured", () => { + expect(parseDelimited("a,b\n1,2\n").rows).toHaveLength(1); + const capped = parseDelimited("a,b\n1,2\n3,4\n5,6\n", { maxRows: 2 }); + expect(capped.truncated).toBe(true); + expect(capped.rows).toHaveLength(1); + }); +}); + +describe("SVG sanitising", () => { + const window = new Window(); + const view = window as unknown as { + DOMParser: new () => DOMParser; + XMLSerializer: new () => XMLSerializer; + }; + const clean = (source: string) => sanitizeSvg(source, view) ?? ""; + + test("scripts, handlers and foreign content are removed", () => { + const output = clean( + '' + + "" + + "x" + + '' + + "", + ); + expect(output).not.toContain("script"); + expect(output).not.toContain("foreignObject"); + expect(output).not.toContain("onload"); + expect(output).not.toContain("onclick"); + expect(output).toContain("rect"); + }); + test("only same-document references survive", () => { + const output = clean( + '' + + '' + + '' + + 'go' + + "", + ); + expect(output).not.toContain("evil.test"); + expect(output).not.toContain("javascript:"); + expect(output).toContain("#local"); + }); + test("stylesheets may not import or fetch", () => { + expect( + clean(''), + ).not.toContain("@import"); + expect( + clean( + '', + ), + ).not.toContain("evil.test"); + }); + test("non-SVG input is rejected outright", () => { + expect(sanitizeSvg("hi", view)).toBeUndefined(); + expect(sanitizeSvg("not markup at all", view)).toBeUndefined(); + }); +}); + +describe("archive listing", () => { + test("USTAR entries are listed with sizes", () => { + const bytes = tar([ + { name: "readme.txt", body: "hello" }, + { name: "src/index.ts", body: "export {};" }, + ]); + expect(isTar(bytes)).toBe(true); + expect(parseTar(bytes).map((entry) => entry.name)).toEqual(["readme.txt", "src/index.ts"]); + expect(parseTar(bytes)[0]!.size).toBe(5); + }); + test("gzip member names are read when present", () => { + const header = new Uint8Array([0x1f, 0x8b, 8, 0x08, 0, 0, 0, 0, 0, 3]); + const name = new TextEncoder().encode("payload.tar\0"); + const bytes = new Uint8Array(header.length + name.length); + bytes.set(header); + bytes.set(name, header.length); + expect(gzipName(bytes)).toBe("payload.tar"); + expect(gzipName(new Uint8Array([0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 3]))).toBeUndefined(); + }); +}); + +describe("tier 2 routing", () => { + const cases: [string, string, string][] = [ + ["data.csv", "a,b\n1,2\n", "TableViewer"], + ["data.tsv", "a\tb\n1\t2\n", "TableViewer"], + ["data.json", '{"a":1}', "JsonViewer"], + ["data.xml", "", "XmlViewer"], + ["logo.svg", '', "SvgViewer"], + ]; + for (const [name, body, component] of cases) + test(`routes ${name}`, async () => + expect((await resolve(file(name, body))).renderer.component).toBe(component)); + + test("a tar archive is listed rather than downloaded", async () => { + const bytes = tar([{ name: "a.txt", body: "x" }]); + const plan = await resolve({ name: "bundle.tar", blob: new Blob([bytes]) }); + expect(plan.kind).toBe("archive"); + expect(plan.renderer.component).toBe("ArchiveViewer"); + }); + test("tier 2 renderers stay behind the source view for their kind", async () => { + const plan = await resolve(file("notes.json", '{"a":1}')); + expect(plan.fallback.map((renderer) => renderer.component)).toContain("CodeViewer"); + }); +}); + +describe("tier 2 rendering", () => { + test("a delimited file becomes a spreadsheet grid", async () => { + const { window, host } = fixture(); + const dispose = mountViewer(host, file("data.csv", "name,size\nalpha,10\nbeta,20\n")); + await settled(); + expect(host.querySelectorAll("table")).toHaveLength(1); + // The first line of the file is row 1 of the sheet, not a promoted header, + // so all three rows are counted and the columns are lettered. + expect(host.textContent).toContain("3 rows × 2 columns"); + expect([...host.querySelectorAll("thead th")].map((cell) => cell.textContent)).toEqual([ + "", + "A", + "B", + ]); + expect( + [...host.querySelectorAll("tbody [data-viewer-index]")].map((cell) => cell.textContent), + ).toEqual(["1", "2", "3"]); + expect(host.textContent).toContain("alpha"); + // A column of numbers is right-aligned; one of words is not. + expect(host.querySelectorAll("tbody [data-viewer-numeric]")).toHaveLength(3); + dispose(); + await window.happyDOM.close(); + }); + test("malformed JSON falls through to the source view", async () => { + const { window, host } = fixture(); + const dispose = mountViewer(host, file("broken.json", "{ not json")); + await settled(); + expect(host.querySelector(".wrn-viewer-tree")).toBeNull(); + expect(host.textContent).toContain("{ not json"); + dispose(); + await window.happyDOM.close(); + }); + test("JSON renders a lazy tree with nested values", async () => { + const { window, host } = fixture(); + const dispose = mountViewer(host, file("data.json", '{"tag":"alpha","list":[1,2]}')); + await settled(); + const tree = host.querySelector(".wrn-viewer-tree")!; + expect(tree).not.toBeNull(); + expect(tree.textContent).toContain("tag"); + // The nested array is a closed branch until it is opened. + const nested = host.querySelector(".wrn-viewer-branch details")!; + expect(nested.textContent).toContain("list"); + expect(nested.open).toBe(false); + nested.open = true; + nested.dispatchEvent(new window.Event("toggle") as unknown as Event); + expect(nested.textContent).toContain("2"); + dispose(); + await window.happyDOM.close(); + }); + test("a JSON file carrying markup is shown as inert source, not a tree", async () => { + const { window, host } = fixture(); + const dispose = mountViewer(host, file("data.json", '{"tag":""}')); + await settled(); + expect(host.querySelector("script")).toBeNull(); + expect(host.querySelector(".wrn-viewer-tree")).toBeNull(); + expect(host.textContent).toContain(""); + dispose(); + await window.happyDOM.close(); + }); + test("SVG renders sandboxed, with the sanitised document inside the frame", async () => { + const { window, host } = fixture(); + const dispose = mountViewer( + host, + file( + "logo.svg", + '', + ), + ); + await settled(); + const frame = host.querySelector("iframe")!; + expect(frame).not.toBeNull(); + expect(frame.getAttribute("sandbox")).toBe(""); + const srcdoc = frame.getAttribute("srcdoc")!; + expect(srcdoc).toContain("default-src 'none'"); + expect(srcdoc).not.toContain("onload"); + expect(srcdoc).not.toContain(" { + const { window, host } = fixture(); + const dispose = mountViewer(host, { + name: "bundle.tar", + blob: new Blob([tar([{ name: "docs/readme.md", body: "hello" }])]), + }); + await settled(); + expect(host.textContent).toContain("docs/readme.md"); + expect(host.textContent).toContain("1 file"); + dispose(); + await window.happyDOM.close(); + }); +}); diff --git a/packages/viewer/test/uploads.test.ts b/packages/viewer/test/uploads.test.ts new file mode 100644 index 00000000..da12e0f3 --- /dev/null +++ b/packages/viewer/test/uploads.test.ts @@ -0,0 +1,250 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { configureStorage } from "@wrnexus/uploader"; +import { configureUploads } from "../src/uploads/config.ts"; +import { handleFile, handleSource, uploadedConversionSource } from "../src/uploads/service.ts"; +import { viewerPlugin } from "../src/plugin.ts"; +import { Window } from "happy-dom"; +import { mountViewer } from "../src/runtime.ts"; + +const SECRET = "test-secret-value-32-chars-minimum!!"; +const root = mkdtempSync(join(tmpdir(), "wrn-viewer-uploads-")); + +function storage() { + for (const dir of ["public", "private"]) + mkdirSync(join(root, "uploads", dir), { recursive: true }); + writeFileSync(join(root, "uploads", "public", "logo.png"), Buffer.from([137, 80, 78, 71])); + writeFileSync(join(root, "uploads", "private", "report.pdf"), Buffer.from("%PDF-1.7 private")); + configureStorage( + { + default: "public", + stores: { + public: { driver: "local", access: "public", dir: "uploads/public" }, + private: { driver: "local", access: "private", dir: "uploads/private" }, + secret: { driver: "local", access: "private", dir: "uploads/private" }, + }, + }, + root, + ); +} +storage(); + +const ask = (query: string) => new Request(`https://app.test/api/viewer/source?${query}`); +const body = async (response: Response) => + (await response.json()) as { src: string; name: string; mime?: string }; + +afterEach(() => configureUploads(undefined)); + +describe("resolving a storage key", () => { + test("nothing resolves until uploads are enabled", async () => { + expect((await handleSource(ask("key=logo.png"))).status).toBe(404); + }); + + test("a public object answers with its own URL, no token", async () => { + configureUploads({ stores: ["public"] }); + const response = await handleSource(ask("key=logo.png&store=public")); + expect(response.status).toBe(200); + const descriptor = await body(response); + expect(descriptor.src).toContain("logo.png"); + expect(descriptor.src).not.toContain("token="); + expect(descriptor.name).toBe("logo.png"); + // The MIME comes from the uploader's table, not a second copy of it. + expect(descriptor.mime).toBe("image/png"); + }); + + test("stores outside the allow-list are refused", async () => { + configureUploads({ stores: ["public"], secret: SECRET, authorize: () => true }); + expect((await handleSource(ask("key=report.pdf&store=private"))).status).toBe(403); + }); + + test("keys that try to escape the store are refused", async () => { + configureUploads({ stores: ["public"] }); + for (const key of ["../secrets.env", "/etc/passwd", "a/../../b"]) + expect((await handleSource(ask(`key=${encodeURIComponent(key)}&store=public`))).status).toBe( + 400, + ); + }); + + test("a private object needs both a secret and an authorize hook", async () => { + configureUploads({ stores: ["private"] }); + expect((await handleSource(ask("key=report.pdf&store=private"))).status).toBe(403); + configureUploads({ stores: ["private"], secret: SECRET }); + expect((await handleSource(ask("key=report.pdf&store=private"))).status).toBe(403); + }); + + test("authorize decides, and a refusal mints nothing", async () => { + const seen: { store: string; key: string }[] = []; + configureUploads({ + stores: ["private"], + secret: SECRET, + authorize: (_request, params) => { + seen.push(params); + return false; + }, + }); + expect((await handleSource(ask("key=report.pdf&store=private"))).status).toBe(403); + expect(seen).toEqual([{ store: "private", key: "report.pdf" }]); + }); +}); + +describe("serving a signed link", () => { + const signedFor = async (key: string, store = "private") => { + configureUploads({ stores: [store], secret: SECRET, authorize: () => true }); + const descriptor = await body(await handleSource(ask(`key=${key}&store=${store}`))); + return new URL(descriptor.src, "https://app.test").searchParams.get("token")!; + }; + const fetchFile = (token: string, init: RequestInit = {}) => + handleFile( + new Request(`https://app.test/api/viewer/file?token=${encodeURIComponent(token)}`, init), + ); + + test("a valid link streams the object, uncacheable and inline", async () => { + const response = await fetchFile(await signedFor("report.pdf")); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("content-disposition")).toBe("inline"); + expect(await response.text()).toContain("%PDF"); + }); + + test("HEAD answers with the same headers and no body", async () => { + const response = await fetchFile(await signedFor("report.pdf"), { method: "HEAD" }); + expect(response.status).toBe(200); + expect(response.headers.get("content-length")).toBe("16"); + expect(await response.text()).toBe(""); + }); + + test("ranges are served so media can seek", async () => { + const response = await fetchFile(await signedFor("report.pdf"), { + headers: { range: "bytes=0-3" }, + }); + expect(response.status).toBe(206); + expect(response.headers.get("content-range")).toBe("bytes 0-3/16"); + expect(await response.text()).toBe("%PDF"); + }); + + test("a tampered, unsigned or expired link is indistinguishable from a miss", async () => { + const token = await signedFor("report.pdf"); + expect((await fetchFile(token.slice(0, -2) + "xx")).status).toBe(404); + expect((await fetchFile("not-a-token")).status).toBe(404); + expect((await handleFile(new Request("https://app.test/api/viewer/file"))).status).toBe(400); + + // A link signed with a different secret must not open under this one. + configureUploads({ + stores: ["private"], + secret: "another-secret-entirely-x", + authorize: () => true, + }); + expect((await fetchFile(token)).status).toBe(404); + }); + + test("a link for one store cannot be replayed after that store is withdrawn", async () => { + const token = await signedFor("report.pdf"); + configureUploads({ stores: ["public"], secret: SECRET, authorize: () => true }); + expect((await fetchFile(token)).status).toBe(404); + }); +}); + +describe("conversion inherits the same check", () => { + test("only a valid signed link opens an object for a converter", async () => { + configureUploads({ stores: ["private"], secret: SECRET, authorize: () => true }); + const descriptor = await body(await handleSource(ask("key=report.pdf&store=private"))); + const open = uploadedConversionSource(); + const request = new Request("https://app.test/api/viewer/preview"); + + const allowed = await open(request, { src: descriptor.src, name: "report.pdf" }); + expect(allowed).toBeDefined(); + expect(allowed!.size).toBe(16); + + // A bare key, a foreign URL, or a forged token opens nothing. + expect(await open(request, { src: "report.pdf", name: "report.pdf" })).toBeUndefined(); + expect( + await open(request, { src: "https://evil.test/x?token=abc", name: "report.pdf" }), + ).toBeUndefined(); + }); +}); + +describe("plugin wiring", () => { + test("uploader routes appear only when uploads are configured", () => { + const paths = (plugin: ReturnType) => + ((plugin.routeEntries ?? []) as { path: string }[]).map((route) => route.path); + expect(paths(viewerPlugin())).toEqual([]); + expect(paths(viewerPlugin({ uploads: { stores: ["public"] } }))).toEqual([ + "/api/viewer/source", + "/api/viewer/file", + ]); + // Enabling one feature must not publish the other's endpoints. + expect(paths(viewerPlugin({ uploads: { stores: ["public"] } }))).not.toContain( + "/api/viewer/preview", + ); + }); +}); + +describe("the browser side of a key", () => { + async function mountKeyed(descriptor: unknown, body: string, componentName = "") { + const window = new Window({ url: "https://app.test/" }); + const host = window.document.createElement("section") as unknown as HTMLElement; + window.document.body.append(host as never); + const asked: string[] = []; + const original = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input); + asked.push(url); + if (url.includes("/api/viewer/source")) + return descriptor ? Response.json(descriptor) : new Response("no", { status: 403 }); + return new Response(body, { headers: { "content-type": "text/csv" } }); + }) as unknown as typeof fetch; + try { + const dispose = mountViewer(host, { + key: "regional-figures.csv", + store: "reports", + name: componentName, + }); + for (let i = 0; i < 20; i++) await new Promise((done) => setTimeout(done, 5)); + return { host, asked, dispose, window }; + } finally { + globalThis.fetch = original; + } + } + + test("a key is exchanged for a link, and the store travels with it", async () => { + const { host, asked, dispose, window } = await mountKeyed( + { src: "/api/viewer/file?token=abc", name: "regional-figures.csv", mime: "text/csv" }, + "region,units\nNorth,120\n", + ); + const source = asked.find((url) => url.includes("/api/viewer/source"))!; + expect(source).toContain("key=regional-figures.csv"); + expect(source).toContain("store=reports"); + // The descriptor's filename must reach classification: without the extension + // the renderer for this kind cannot be found at all. + expect(host.querySelector("table")).not.toBeNull(); + expect(host.querySelector(".wrn-viewer-toolbar span")!.textContent).toBe( + "regional-figures.csv", + ); + dispose(); + await window.happyDOM.close(); + }); + + test("an explicit name on the component still wins", async () => { + const { host, dispose, window } = await mountKeyed( + { src: "/api/viewer/file?token=abc", name: "regional-figures.csv", mime: "text/csv" }, + "region,units\nNorth,120\n", + "Q3 figures.csv", + ); + expect(host.querySelector(".wrn-viewer-toolbar span")!.textContent).toBe("Q3 figures.csv"); + dispose(); + await window.happyDOM.close(); + }); + + test("a refused key shows the fallback, and no file is fetched", async () => { + const { host, asked, dispose, window } = await mountKeyed(undefined, ""); + expect(host.textContent).toContain("This file is not available to preview"); + expect(asked.every((url) => url.includes("/api/viewer/source"))).toBe(true); + dispose(); + await window.happyDOM.close(); + }); +}); + +process.on("exit", () => rmSync(root, { recursive: true, force: true })); diff --git a/packages/viewer/tsconfig.json b/packages/viewer/tsconfig.json new file mode 100644 index 00000000..e8b02ca4 --- /dev/null +++ b/packages/viewer/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/viewer/viewer.css b/packages/viewer/viewer.css new file mode 100644 index 00000000..f181e0a6 --- /dev/null +++ b/packages/viewer/viewer.css @@ -0,0 +1,923 @@ +.wrn-viewer { + color: var(--wrn-color-text); + background: var(--wrn-color-surface); + border: 1px solid var(--wrn-color-border); + border-radius: var(--wrn-radius); + overflow: hidden; + min-width: 0; +} +.wrn-viewer-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + border-bottom: 1px solid var(--wrn-color-border); +} +.wrn-viewer-toolbar > span:first-child { + /* Wide enough not to be crushed to one character per line by a full control + set; it wraps onto its own row before that happens. */ + flex: 1 1 10rem; + min-width: 8rem; + overflow-wrap: anywhere; +} +.wrn-viewer-toolbar > span { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} +/* The generic control look applies only to controls the package has not styled + itself; without this it outranks every specific rule below. */ +.wrn-viewer button:not([class*="wrn-viewer-"]), +.wrn-viewer a:not([class*="wrn-viewer-"]) { + font: inherit; + color: var(--wrn-color-primary); + background: var(--wrn-color-surface); + border: 1px solid var(--wrn-color-border); + border-radius: var(--wrn-radius); + padding: 0.25rem 0.5rem; + cursor: pointer; +} +.wrn-viewer :focus-visible { + outline: 2px solid var(--wrn-color-primary); + outline-offset: 2px; +} +.wrn-viewer-content { + overflow: auto; + max-height: 70vh; + min-height: 8rem; + padding: 1rem; +} +.wrn-viewer-content img, +.wrn-viewer-content video { + display: block; + max-width: 100%; + margin: auto; + transform-origin: center; +} +.wrn-viewer-content audio { + width: 100%; +} +.wrn-viewer-pdf { + height: 65vh; +} +.wrn-viewer-note { + margin: 0.5rem 0 0; + font-size: 0.875em; + color: var(--wrn-color-muted, inherit); +} +.wrn-viewer-content > pre { + white-space: pre; + margin: 0; +} +.wrn-viewer-code { + height: 55vh; +} +.wrn-viewer:fullscreen .wrn-viewer-content { + max-height: calc(100vh - 5rem); +} +.wrn-viewer-table { + overflow: auto; + max-width: 100%; + border: 1px solid var(--wrn-color-border); + border-radius: var(--wrn-radius); +} +.wrn-viewer-table table { + border-collapse: collapse; + width: 100%; + font-variant-numeric: tabular-nums; +} +.wrn-viewer-table th, +.wrn-viewer-table td { + height: 28px; + padding: 0 0.75rem; + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--wrn-color-border) 55%, transparent); + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 24rem; +} +.wrn-viewer-table thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--wrn-color-surface); + border-bottom: 1px solid var(--wrn-color-border); + font-size: 0.8125rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--wrn-color-muted, inherit); +} +.wrn-viewer-table tbody tr:nth-child(even) td, +.wrn-viewer-table tbody tr:nth-child(even) th { + background: color-mix(in srgb, var(--wrn-color-text) 4%, transparent); +} +.wrn-viewer-table tbody tr:hover td, +.wrn-viewer-table tbody tr:hover th { + background: color-mix(in srgb, var(--wrn-color-primary) 10%, transparent); +} +/* The row-number column is scaffolding, not data. */ +.wrn-viewer-table [data-viewer-index] { + width: 1%; + color: color-mix(in srgb, var(--wrn-color-text) 45%, transparent); + font-weight: normal; + text-align: right; + text-transform: none; + font-variant-numeric: tabular-nums; +} +/* Numbers line up on the right so magnitudes can be compared down a column. */ +.wrn-viewer-table [data-viewer-numeric] { + text-align: right; + font-variant-numeric: tabular-nums; +} +.wrn-viewer-table [data-viewer-grow] { + width: 100%; + white-space: normal; + overflow-wrap: anywhere; +} +.wrn-viewer-table tbody tr:last-child td, +.wrn-viewer-table tbody tr:last-child th { + border-bottom: 0; +} +.wrn-viewer-tree { + font-family: var(--wrn-font-mono, ui-monospace, monospace); + line-height: 1.6; +} +.wrn-viewer-branch { + padding-left: 1rem; + border-left: 1px solid var(--wrn-color-border); + margin-left: 0.35rem; +} +.wrn-viewer-tree summary { + cursor: pointer; +} +.wrn-viewer-key { + color: var(--wrn-color-primary); +} +.wrn-viewer-leaf > span:first-child { + color: var(--wrn-color-primary); +} +.wrn-viewer-string { + color: var(--wrn-color-success, var(--wrn-color-text)); +} +.wrn-viewer-number, +.wrn-viewer-boolean, +.wrn-viewer-null { + color: var(--wrn-color-accent, var(--wrn-color-text)); +} +.wrn-viewer-frame { + display: block; + width: 100%; + height: 60vh; + border: 0; + background: var(--wrn-color-surface); +} +.wrn-viewer-tabs { + display: inline-flex; + flex-wrap: wrap; + gap: 0.125rem; + margin-bottom: 0.75rem; + padding: 0.187rem; + border-radius: calc(var(--wrn-radius) + 0.187rem); + background: color-mix(in srgb, var(--wrn-color-text) 6%, transparent); +} +.wrn-viewer-tabs button { + border: 0; + background: transparent; + color: var(--wrn-color-muted, inherit); + padding: 0.3rem 0.75rem; + font-size: 0.875rem; + font-weight: 500; +} +.wrn-viewer-tabs button:hover { + color: var(--wrn-color-text); +} +.wrn-viewer-tabs button[aria-selected="true"] { + background: var(--wrn-color-primary); + color: var(--wrn-color-on-primary, #fff); + font-weight: 600; +} +.wrn-viewer-pages { + display: grid; + gap: 1rem; + justify-items: center; +} +.wrn-viewer-page { + display: block; + border: 1px solid var(--wrn-color-border); + border-radius: var(--wrn-radius); +} +.wrn-viewer-filmstrip { + display: flex; + gap: 0.5rem; + overflow-x: auto; + padding: 0.75rem; + border-top: 1px solid var(--wrn-color-border); +} +.wrn-viewer-file { + flex: 0 0 9rem; + display: grid; + gap: 0.5rem; + overflow-wrap: anywhere; +} +.wrn-viewer-file[aria-pressed="true"] { + outline: 2px solid var(--wrn-color-primary); + outline-offset: -2px; +} +.wrn-viewer-file img { + width: 100%; + height: 5rem; + object-fit: contain; +} +.wrn-viewer-waveform { + width: 100%; + max-height: 6rem; + display: block; + margin-top: 0.75rem; +} +.wrn-viewer input[type="range"] { + width: 100%; + accent-color: var(--wrn-color-primary); +} +.wrn-viewer-toolbar details { + flex-basis: 100%; + max-width: 100%; +} +.wrn-viewer-toolbar details pre { + max-height: 20rem; + overflow: auto; + white-space: pre-wrap; +} +.wrn-viewer [data-viewer-mount] { + min-width: 0; +} +.wrn-viewer-slides { + display: grid; + gap: 0.875rem; + outline: none; +} +.wrn-viewer-slides:focus-visible .wrn-viewer-slide { + outline: 2px solid var(--wrn-color-primary); + outline-offset: 3px; +} +.wrn-viewer-slide { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 16rem; + padding: 2rem 2.25rem; + border: 1px solid var(--wrn-color-border); + border-radius: calc(var(--wrn-radius) * 1.5); + background: color-mix(in srgb, var(--wrn-color-text) 3%, var(--wrn-color-surface)); +} +.wrn-viewer-slide h2 { + margin: 0; + font-size: 1.5rem; + line-height: 1.25; + letter-spacing: -0.01em; +} +.wrn-viewer-slide h2::after { + content: ""; + display: block; + width: 2.5rem; + height: 3px; + margin-top: 0.6rem; + border-radius: 2px; + background: var(--wrn-color-primary); +} +.wrn-viewer-slide ul { + margin: 0; + padding-left: 1.35rem; + display: grid; + gap: 0.4rem; + font-size: 1.0625rem; + line-height: 1.5; + list-style: disc outside; +} +.wrn-viewer-slide li { + list-style: inherit; +} +.wrn-viewer-slide li::marker { + color: var(--wrn-color-primary); +} +.wrn-viewer-slide-notes { + margin-top: 1rem; + border-top: 1px solid var(--wrn-color-border); + padding-top: 0.5rem; +} +.wrn-viewer-document { + max-width: 46rem; + margin: 0 auto; + line-height: 1.6; +} +.wrn-viewer-document p { + margin: 0 0 0.75rem; +} +.wrn-viewer-document ul { + margin: 0 0 0.75rem; + padding-left: 1.35rem; + list-style: disc outside; +} +.wrn-viewer-document li { + list-style: inherit; + margin-bottom: 0.25rem; +} +.wrn-viewer-document li::marker { + color: var(--wrn-color-primary); +} +/* Heading levels have to read as levels; the document is the only structure. */ +.wrn-viewer-document h1 { + margin: 0 0 0.75rem; + font-size: 1.75rem; + line-height: 1.2; + letter-spacing: -0.01em; +} +.wrn-viewer-document h2 { + margin: 1.75rem 0 0.6rem; + font-size: 1.25rem; + line-height: 1.3; +} +.wrn-viewer-document h3 { + margin: 1.25rem 0 0.5rem; + font-size: 1.0625rem; + color: var(--wrn-color-muted, inherit); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.wrn-viewer-document h4, +.wrn-viewer-document h5, +.wrn-viewer-document h6 { + margin: 1rem 0 0.4rem; + font-size: 1rem; +} +.wrn-viewer-document h1:first-child, +.wrn-viewer-document h2:first-child { + margin-top: 0; +} +.wrn-viewer-action { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + box-sizing: border-box; + width: 2rem; + height: 2rem; + padding: 0; + /* A link and a button must sit on the same line: page styles commonly give + one of them a margin the other does not have. */ + margin: 0; + line-height: 1; + text-decoration: none; + position: relative; + color: var(--wrn-color-text); + background: transparent; + border: 1px solid transparent; + border-radius: var(--wrn-radius); + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; +} +.wrn-viewer-action:hover, +.wrn-viewer-action:focus-visible { + color: var(--wrn-color-primary); + border-color: var(--wrn-color-border); + background: color-mix(in srgb, var(--wrn-color-primary) 10%, transparent); +} +.wrn-viewer-action[aria-pressed="true"] { + color: var(--wrn-color-on-primary, var(--wrn-color-surface)); + background: var(--wrn-color-primary); + border-color: var(--wrn-color-primary); +} +.wrn-viewer-icon { + width: 1.125rem; + height: 1.125rem; +} +.wrn-viewer-sr { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} +.wrn-viewer-action[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + top: calc(100% + 0.4rem); + left: 50%; + transform: translateX(-50%) translateY(-0.15rem); + z-index: 5; + padding: 0.2rem 0.45rem; + border-radius: var(--wrn-radius); + background: var(--wrn-color-text); + color: var(--wrn-color-surface); + font-size: 0.75rem; + line-height: 1.3; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: + opacity 0.12s ease, + transform 0.12s ease; +} +.wrn-viewer-action[data-tooltip]:hover::after, +.wrn-viewer-action[data-tooltip]:focus-visible::after { + opacity: 1; + transform: translateX(-50%) translateY(0); +} +@media (prefers-reduced-motion: reduce) { + .wrn-viewer-action, + .wrn-viewer-action[data-tooltip]::after { + transition: none; + } +} +.wrn-viewer-download { + display: grid; + place-items: center; + gap: 0.75rem; + min-height: 14rem; + padding: 2rem 1rem; + text-align: center; +} +.wrn-viewer-download-art { + display: grid; + place-items: center; + width: 4rem; + height: 4rem; + border-radius: 50%; + color: var(--wrn-color-primary); + background: color-mix(in srgb, var(--wrn-color-primary) 12%, transparent); +} +.wrn-viewer-download-art svg { + width: 2rem; + height: 2rem; +} +.wrn-viewer-download-reason { + margin: 0; + font-size: 1.0625rem; + font-weight: 600; +} +.wrn-viewer-download-meta { + margin: 0; + font-size: 0.875rem; + color: var(--wrn-color-muted, inherit); + overflow-wrap: anywhere; +} + +.wrn-viewer-player { + display: grid; + gap: 0.5rem; + padding: 1rem; + border: 1px solid var(--wrn-color-border); + border-radius: calc(var(--wrn-radius) * 1.5); + background: color-mix(in srgb, var(--wrn-color-text) 3%, var(--wrn-color-surface)); +} +.wrn-viewer-player-transport { + display: flex; + align-items: center; + gap: 0.75rem; +} +.wrn-viewer-play { + width: 2.75rem; + height: 2.75rem; + flex: 0 0 auto; + color: var(--wrn-color-on-primary, var(--wrn-color-surface)); + background: var(--wrn-color-primary); + border-color: var(--wrn-color-primary); +} +.wrn-viewer-play:hover, +.wrn-viewer-play:focus-visible { + color: var(--wrn-color-on-primary, var(--wrn-color-surface)); + background: color-mix(in srgb, var(--wrn-color-primary) 85%, black); + border-color: transparent; +} +.wrn-viewer-play .wrn-viewer-icon { + width: 1.375rem; + height: 1.375rem; +} +.wrn-viewer-time { + flex: 0 0 auto; + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; + color: var(--wrn-color-muted, inherit); +} +/* The waveform is the track: envelope and progress are one control. */ +.wrn-viewer-track { + position: relative; + flex: 1 1 auto; + min-width: 0; + height: 2.75rem; + border-radius: var(--wrn-radius); + background: color-mix(in srgb, var(--wrn-color-text) 8%, transparent); + overflow: hidden; +} +.wrn-viewer-track .wrn-viewer-waveform { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + opacity: 0; + transition: opacity 0.25s ease; +} +.wrn-viewer-track .wrn-viewer-waveform.is-ready { + opacity: 0.5; +} +/* The played part of the envelope reads at full strength. */ +.wrn-viewer-progress .wrn-viewer-waveform { + opacity: 1; +} +.wrn-viewer-progress { + position: absolute; + inset: 0 auto 0 0; + width: 0; + background: color-mix(in srgb, var(--wrn-color-primary) 22%, transparent); + border-right: 2px solid var(--wrn-color-primary); + pointer-events: none; +} +.wrn-viewer-track:hover .wrn-viewer-progress { + background: color-mix(in srgb, var(--wrn-color-primary) 30%, transparent); +} +.wrn-viewer-seek { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; +} +.wrn-viewer-seek:focus-visible { + opacity: 1; +} +.wrn-viewer-print-title { + margin: 0 0 1rem; + font-size: 1.125rem; + font-weight: 600; +} +[data-viewer-print-root] { + display: none; +} +@media print { + [data-viewer-printing-active] body > *:not([data-viewer-print-root]) { + display: none !important; + } + [data-viewer-print-root] { + display: block; + } + [data-viewer-print-root] .wrn-viewer-content, + [data-viewer-print-root] .wrn-viewer-table { + max-height: none; + overflow: visible; + border: 0; + } + [data-viewer-print-root] .wrn-viewer-slide-strip, + [data-viewer-print-root] .wrn-viewer-tabs, + [data-viewer-print-root] .wrn-viewer-player-transport { + display: none; + } +} + +/* Caption above a table or listing: context, not content. */ +.wrn-viewer-content > p:first-child:not(.wrn-viewer-note):not(.wrn-viewer-download-reason) { + margin: 0 0 0.6rem; + font-size: 0.8125rem; + color: var(--wrn-color-muted, inherit); +} + +/* Pager: a dot per slide, arrows either side, position on the right. */ +.wrn-viewer-pager { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; +} +.wrn-viewer-pager-step { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.875rem; + height: 1.875rem; + padding: 0; + margin: 0; + color: var(--wrn-color-text); + background: transparent; + border: 1px solid var(--wrn-color-border); + border-radius: 50%; + cursor: pointer; +} +.wrn-viewer-pager-step:hover:not(:disabled) { + color: var(--wrn-color-primary); + border-color: var(--wrn-color-primary); +} +.wrn-viewer-pager-step:disabled { + opacity: 0.35; + cursor: default; +} +.wrn-viewer-pager-step .wrn-viewer-icon { + width: 1rem; + height: 1rem; +} +.wrn-viewer-dots { + display: flex; + align-items: center; + gap: 0.375rem; + max-width: 60%; + overflow-x: auto; + padding: 0.25rem; +} +.wrn-viewer-dot { + flex: 0 0 auto; + width: 0.5rem; + height: 0.5rem; + padding: 0; + margin: 0; + border: 0; + border-radius: 50%; + background: color-mix(in srgb, var(--wrn-color-text) 25%, transparent); + cursor: pointer; + transition: + width 0.18s ease, + background-color 0.18s ease; +} +.wrn-viewer-dot:hover { + background: color-mix(in srgb, var(--wrn-color-primary) 60%, transparent); +} +.wrn-viewer-dot[aria-current="true"] { + width: 1.5rem; + border-radius: 0.25rem; + background: var(--wrn-color-primary); +} +.wrn-viewer-pager-count { + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; + color: var(--wrn-color-muted, inherit); +} +@media (prefers-reduced-motion: reduce) { + .wrn-viewer-dot { + transition: none; + } +} + +/* Workbook: a scrolling grid with the sheet bar fixed beneath it. */ +.wrn-viewer-workbook { + display: grid; + grid-template-rows: 1fr auto; + min-height: 0; + border: 1px solid var(--wrn-color-border); + border-radius: var(--wrn-radius); + overflow: hidden; +} +.wrn-viewer-workbook-surface { + overflow: auto; + max-height: 60vh; +} +.wrn-viewer-sheetbar { + display: flex; + align-items: center; + gap: 0.125rem; + padding: 0.25rem 0.5rem; + overflow-x: auto; + border-top: 1px solid var(--wrn-color-border); + background: color-mix(in srgb, var(--wrn-color-text) 6%, transparent); +} +.wrn-viewer-sheetbar.is-single .wrn-viewer-sheettab { + display: none; +} +.wrn-viewer-sheettab { + flex: 0 0 auto; + margin: 0; + padding: 0.3rem 0.85rem; + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.8125rem; + color: var(--wrn-color-muted, inherit); + background: transparent; + border: 1px solid transparent; + border-bottom: 0; + border-radius: var(--wrn-radius) var(--wrn-radius) 0 0; + cursor: pointer; +} +.wrn-viewer-sheettab:hover { + color: var(--wrn-color-text); +} +/* The active tab joins the sheet above it, as a spreadsheet tab does. */ +.wrn-viewer-sheettab[aria-selected="true"] { + color: var(--wrn-color-text); + font-weight: 600; + background: var(--wrn-color-surface); + border-color: var(--wrn-color-border); + box-shadow: 0 -2px 0 0 var(--wrn-color-primary) inset; +} +.wrn-viewer-sheetbar-status { + margin-left: auto; + padding-left: 0.75rem; + font-size: 0.75rem; + white-space: nowrap; + color: var(--wrn-color-muted, inherit); +} + +/* The spreadsheet grid: lettered columns, numbered rows, ruled cells. */ +.wrn-viewer-sheet table { + border-collapse: separate; + border-spacing: 0; + width: 100%; + font-size: 0.875rem; + font-variant-numeric: tabular-nums; +} +.wrn-viewer-sheet th, +.wrn-viewer-sheet td { + height: 28px; + padding: 0 0.6rem; + border-right: 1px solid color-mix(in srgb, var(--wrn-color-border) 60%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--wrn-color-border) 60%, transparent); + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 22rem; +} +.wrn-viewer-sheet thead th { + position: sticky; + top: 0; + z-index: 2; + height: 24px; + font-size: 0.75rem; + font-weight: 500; + text-align: center; + color: var(--wrn-color-muted, inherit); + background: color-mix(in srgb, var(--wrn-color-text) 8%, var(--wrn-color-surface)); + border-bottom: 1px solid var(--wrn-color-border); +} +/* The row-number gutter stays put while the grid scrolls sideways. */ +.wrn-viewer-sheet [data-viewer-index] { + position: sticky; + left: 0; + z-index: 1; + width: 1%; + min-width: 2.75rem; + text-align: center; + font-size: 0.75rem; + font-weight: 500; + color: var(--wrn-color-muted, inherit); + background: color-mix(in srgb, var(--wrn-color-text) 8%, var(--wrn-color-surface)); + border-right: 1px solid var(--wrn-color-border); +} +.wrn-viewer-sheet thead [data-viewer-index] { + z-index: 3; +} +.wrn-viewer-sheet [data-viewer-numeric] { + text-align: right; +} +.wrn-viewer-sheet [data-viewer-labels] td { + font-weight: 600; + background: color-mix(in srgb, var(--wrn-color-text) 4%, transparent); +} +.wrn-viewer-sheet tbody tr:hover td { + background: color-mix(in srgb, var(--wrn-color-primary) 9%, transparent); +} + +/* Image and video sit on a stage, so the media's bounds are visible. */ +.wrn-viewer-stage { + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-height: 12rem; + padding: 1rem; + border-radius: var(--wrn-radius); + background: + linear-gradient( + 45deg, + color-mix(in srgb, var(--wrn-color-text) 7%, transparent) 25%, + transparent 25% + ), + linear-gradient( + -45deg, + color-mix(in srgb, var(--wrn-color-text) 7%, transparent) 25%, + transparent 25% + ), + linear-gradient( + 45deg, + transparent 75%, + color-mix(in srgb, var(--wrn-color-text) 7%, transparent) 75% + ), + linear-gradient( + -45deg, + transparent 75%, + color-mix(in srgb, var(--wrn-color-text) 7%, transparent) 75% + ); + background-size: 18px 18px; + background-position: + 0 0, + 0 9px, + 9px -9px, + -9px 0; +} +.wrn-viewer-stage img { + display: block; + max-width: 100%; + box-shadow: 0 1px 12px rgb(0 0 0 / 0.35); +} +.wrn-viewer-stage video { + display: block; + max-width: 100%; + max-height: 60vh; + border-radius: var(--wrn-radius); + background: #000; +} +.wrn-viewer-zoom { + position: absolute; + right: 0.5rem; + bottom: 0.5rem; + padding: 0.15rem 0.45rem; + border-radius: var(--wrn-radius); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + color: var(--wrn-color-surface); + background: color-mix(in srgb, var(--wrn-color-text) 75%, transparent); + pointer-events: none; +} + +/* A preview opened over the page. The dialog itself is the backdrop area, so a + click that lands on it rather than on the viewer closes the overlay. */ +.wrn-viewer-dialog { + width: min(72rem, 94vw); + max-width: none; + max-height: 92vh; + padding: 0; + border: 0; + background: transparent; + overflow: visible; +} +.wrn-viewer-dialog::backdrop { + background: rgb(0 0 0 / 0.65); + backdrop-filter: blur(2px); +} +.wrn-viewer-dialog .wrn-viewer { + max-height: 92vh; + display: flex; + flex-direction: column; + box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.5); +} + +/* Covering the page without the Fullscreen API, so it needs no gesture. */ +.wrn-viewer-expanded { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + flex-direction: column; + margin: 0; + border-radius: 0; + border: 0; + background: var(--wrn-color-surface); +} + +/* A whole page given over to one preview: no card, fill what is available. */ +.wrn-viewer-as-page { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + border: 0; + border-radius: 0; + background: transparent; +} +.wrn-viewer-as-page .wrn-viewer-toolbar { + padding-left: 0; + padding-right: 0; +} + +/* `Viewer.wrn` renders the toolbar and content inside a shell, so the growing + box is two levels down: every wrapper between has to flex for the content to + fill the space these modes hand it. */ +.wrn-viewer-dialog .wrn-viewer > *:not(noscript), +.wrn-viewer-expanded > *:not(noscript), +.wrn-viewer-as-page > *:not(noscript), +.wrn-viewer-dialog .wrn-viewer-shell, +.wrn-viewer-expanded .wrn-viewer-shell, +.wrn-viewer-as-page .wrn-viewer-shell { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; +} +.wrn-viewer-dialog .wrn-viewer-content, +.wrn-viewer-expanded .wrn-viewer-content, +.wrn-viewer-as-page .wrn-viewer-content { + flex: 1 1 auto; + max-height: none; + min-height: 0; +} diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts index b34a405d..6e08f991 100644 --- a/scripts/publish-packages.ts +++ b/scripts/publish-packages.ts @@ -210,14 +210,16 @@ function publishManifest( for (const [k, v] of Object.entries(m.bin)) out.bin[k] = js(v as string); } - if (m.dependencies) { - out.dependencies = {}; - for (const [dep, range] of Object.entries(m.dependencies)) { - out.dependencies[dep] = String(range).startsWith("workspace:") + for (const field of ["dependencies", "optionalDependencies", "peerDependencies"]) { + if (!m[field]) continue; + out[field] = {}; + for (const [dep, range] of Object.entries(m[field])) { + out[field][dep] = String(range).startsWith("workspace:") ? `^${workspaceVersions.get(dep) ?? version}` : range; } } + if (m.peerDependenciesMeta) out.peerDependenciesMeta = structuredClone(m.peerDependenciesMeta); if (m.wrnexus) { out.wrnexus = structuredClone(m.wrnexus); @@ -264,8 +266,15 @@ async function main() { outDir: join(p.dir, "dist"), format: ["esm"], dts: true, - external: [/^@wrnexus\//, /^bun:/, /^node:/], - tsconfig: join(repoRoot, "tsconfig.json"), + external: [ + /^@wrnexus\//, + /^bun:/, + /^node:/, + ...Object.keys(p.manifest.peerDependencies ?? {}), + ], + tsconfig: existsSync(join(p.dir, "tsconfig.json")) + ? join(p.dir, "tsconfig.json") + : join(repoRoot, "tsconfig.json"), clean: true, // Shared modules must be emitted ONCE as a chunk both entries import, // not inlined into each. Several packages hold mutable module state -- diff --git a/scripts/release-changed.ts b/scripts/release-changed.ts index 4c076103..af5c47a8 100644 --- a/scripts/release-changed.ts +++ b/scripts/release-changed.ts @@ -28,6 +28,9 @@ function run(command: string, args: string[], capture = false, allowFailure = fa } function runNpm(args: string[], capture = false, allowFailure = false) { + if (process.env.WRNEXUS_NPM_REGISTRY) { + args = ["--registry", process.env.WRNEXUS_NPM_REGISTRY, ...args]; + } return process.platform === "win32" ? run( process.env.ComSpec ?? "cmd.exe", @@ -138,7 +141,17 @@ if (mode === "plan") { run(process.execPath, ["test", ...selected.map((pkg) => `packages/${pkg.dir}`)]); run(process.execPath, ["run", "scripts/publish-packages.ts", ...selected.map((pkg) => pkg.name)]); runNpm(["whoami"]); - for (const pkg of selected) { + // Staging records dependencies first; preserve that order during publication. + const integrity = JSON.parse( + readFileSync(join(root, ".publish", "PACKAGE-INTEGRITY.json"), "utf8"), + ); + const ordered = integrity.packages.map((entry: { name: string }) => { + const pkg = selected.find((pkg) => pkg.name === entry.name); + if (!pkg) throw new Error(`Unexpected staged package ${entry.name}`); + return pkg; + }) as PackageInfo[]; + if (ordered.length !== selected.length) throw new Error("Staged package selection is incomplete"); + for (const pkg of ordered) { const stage = join(root, ".publish", pkg.dir); if (!registryHas(pkg)) runNpm(["publish", stage, "--access", "restricted"]); console.log(` ✓ ${pkg.name}@${pkg.manifest.version}`); diff --git a/scripts/test-staged-consumers.mjs b/scripts/test-staged-consumers.mjs index 207e0051..15b74d3b 100644 --- a/scripts/test-staged-consumers.mjs +++ b/scripts/test-staged-consumers.mjs @@ -53,6 +53,12 @@ if ( const consumer = mkdtempSync(join(tmpdir(), "wrnexus-staged-consumer-")); try { + if (process.env.WRNEXUS_NPM_REGISTRY) { + writeFileSync( + join(consumer, ".npmrc"), + `@wrnexus:registry=${process.env.WRNEXUS_NPM_REGISTRY}\n`, + ); + } const tarballRoot = join(consumer, "tarballs"); mkdirSync(tarballRoot, { recursive: true }); const dependencies = {}; @@ -74,6 +80,12 @@ try { } dependencies[pkg.manifest.name] = `file:${join(tarballRoot, filename).replace(/\\/g, "/")}`; } + if (stagedPackageNames.has("@wrnexus/viewer")) { + const viewer = JSON.parse(readFileSync(join(root, "packages/viewer/package.json"), "utf8")); + for (const name of ["fflate", "mammoth", "pdfjs-dist", "xlsx"]) { + dependencies[name] = viewer.devDependencies[name]; + } + } writeFileSync( join(consumer, "package.json"), JSON.stringify( @@ -118,6 +130,33 @@ try { throw new Error(`Staged package import probe failed:\n${probe.stderr || probe.stdout}`); if (probe.stdout) process.stdout.write(probe.stdout); + if (stagedPackageNames.has("@wrnexus/viewer")) { + writeFileSync( + join(consumer, "viewer-probe.mjs"), + `import { existsSync, readFileSync } from "node:fs"; +import { viewerPlugin } from "@wrnexus/viewer/plugin"; +const manifest = JSON.parse(readFileSync("node_modules/@wrnexus/viewer/package.json", "utf8")); +if (!manifest.peerDependenciesMeta?.xlsx?.optional || manifest.peerDependencies?.["@wrnexus/uploader"]?.startsWith("workspace:")) throw new Error("Viewer peer metadata was lost"); +const plugin = viewerPlugin({ convert: { converters: [], resolveSource: async () => undefined }, uploads: {} }); +for (const asset of [...plugin.clientRuntimes, ...plugin.routeEntries, ...plugin.styleSources]) { + if (!existsSync(asset.entry)) throw new Error("Missing published viewer asset: " + asset.entry); +} +for (const runtime of plugin.clientRuntimes.filter((runtime) => runtime.bundle !== false)) { + const result = await Bun.build({ entrypoints: [runtime.entry], target: "browser" }); + if (!result.success) throw new AggregateError(result.logs, "Viewer browser runtime failed: " + runtime.id); +} +console.log("Published viewer routes, metadata, and browser runtimes passed."); +`, + ); + const viewer = spawnSync(bun, ["viewer-probe.mjs"], { + cwd: consumer, + encoding: "utf8", + }); + if (viewer.status !== 0) + throw new Error(`Staged viewer probe failed:\n${viewer.stderr || viewer.stdout}`); + if (viewer.stdout) process.stdout.write(viewer.stdout); + } + const browserPackages = ["@wrnexus/csr", "@wrnexus/reactive"].filter((name) => existsSync(join(consumer, "node_modules", ...name.split("/"))), ); diff --git a/tsconfig.json b/tsconfig.json index d8fa2d6f..8c6a5a23 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,9 @@ "jsx": "react-jsx", "jsxImportSource": "@wrnexus/core", "paths": { + "@wrnexus/viewer": ["./packages/viewer/src/index.ts"], + "@wrnexus/viewer/*": ["./packages/viewer/src/*.ts"], + "@wrnexus/block-editor/source-editor": ["./packages/block-editor/src/source-editor.ts"], "@wrnexus/core": ["./packages/core/src/index.ts"], "@wrnexus/core/jsx-runtime": ["./packages/core/src/jsx-runtime.ts"], "@wrnexus/core/jsx-dev-runtime": ["./packages/core/src/jsx-dev-runtime.ts"],