diff --git a/lib/Dashboard/CatalogWidget.php b/lib/Dashboard/CatalogWidget.php index f8bca8a1e..d5b156d56 100644 --- a/lib/Dashboard/CatalogWidget.php +++ b/lib/Dashboard/CatalogWidget.php @@ -21,6 +21,7 @@ use OCP\Util; use OCA\OpenCatalogi\AppInfo\Application; +use OCA\OpenCatalogi\Service\ScriptManifestLoader; /** * Widget showing catalog overview on the dashboard. @@ -102,7 +103,11 @@ public function getUrl(): ?string */ public function load(): void { - Util::addScript(application: Application::APP_ID, file: Application::APP_ID.'-catalogiWidget'); + ScriptManifestLoader::addEntryScripts( + appId: Application::APP_ID, + entry: 'catalogiWidget', + fallbackScript: Application::APP_ID.'-catalogiWidget' + ); Util::addStyle(application: Application::APP_ID, file: 'dashboardWidgets'); }//end load() diff --git a/lib/Dashboard/UnpublishedAttachmentsWidget.php b/lib/Dashboard/UnpublishedAttachmentsWidget.php index f86daeca2..156a9129f 100644 --- a/lib/Dashboard/UnpublishedAttachmentsWidget.php +++ b/lib/Dashboard/UnpublishedAttachmentsWidget.php @@ -21,6 +21,7 @@ use OCP\Util; use OCA\OpenCatalogi\AppInfo\Application; +use OCA\OpenCatalogi\Service\ScriptManifestLoader; /** * Widget showing unpublished attachments on the dashboard. @@ -102,7 +103,11 @@ public function getUrl(): ?string */ public function load(): void { - Util::addScript(application: Application::APP_ID, file: Application::APP_ID.'-unpublishedAttachmentsWidget'); + ScriptManifestLoader::addEntryScripts( + appId: Application::APP_ID, + entry: 'unpublishedAttachmentsWidget', + fallbackScript: Application::APP_ID.'-unpublishedAttachmentsWidget' + ); Util::addStyle(application: Application::APP_ID, file: 'dashboardWidgets'); }//end load() diff --git a/lib/Dashboard/UnpublishedPublicationsWidget.php b/lib/Dashboard/UnpublishedPublicationsWidget.php index 60fc3b3ef..3d00d34ee 100644 --- a/lib/Dashboard/UnpublishedPublicationsWidget.php +++ b/lib/Dashboard/UnpublishedPublicationsWidget.php @@ -21,6 +21,7 @@ use OCP\Util; use OCA\OpenCatalogi\AppInfo\Application; +use OCA\OpenCatalogi\Service\ScriptManifestLoader; /** * Widget showing unpublished publications on the dashboard. @@ -102,7 +103,11 @@ public function getUrl(): ?string */ public function load(): void { - Util::addScript(application: Application::APP_ID, file: Application::APP_ID.'-unpublishedPublicationsWidget'); + ScriptManifestLoader::addEntryScripts( + appId: Application::APP_ID, + entry: 'unpublishedPublicationsWidget', + fallbackScript: Application::APP_ID.'-unpublishedPublicationsWidget' + ); Util::addStyle(application: Application::APP_ID, file: 'dashboardWidgets'); }//end load() diff --git a/lib/Service/ScriptManifestLoader.php b/lib/Service/ScriptManifestLoader.php new file mode 100644 index 000000000..d2ef4e6e4 --- /dev/null +++ b/lib/Service/ScriptManifestLoader.php @@ -0,0 +1,134 @@ + + * @copyright 2025 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenCatalogi.nl + */ + +namespace OCA\OpenCatalogi\Service; + +use OCP\Util; + +/** + * Resolves and registers webpack entrypoint chunks from the build manifest. + */ +class ScriptManifestLoader +{ + + /** + * In-memory cache of decoded manifests, keyed by absolute file path. + * + * @var array> $manifestCache Decoded manifests per path. + */ + private static array $manifestCache = []; + + /** + * Register all JS chunks for an entrypoint, or a fallback script. + * + * @param string $appId The application identifier. + * @param string $entry The webpack entrypoint name (e.g. 'main'). + * @param string $fallbackScript Script name to load when the manifest is missing. + * + * @return void + * + * @SuppressWarnings(PHPMD.StaticAccess) — Nextcloud Util API is static by design + */ + public static function addEntryScripts( + string $appId, + string $entry, + string $fallbackScript + ): void { + $chunks = self::resolveEntryScripts(appId: $appId, entry: $entry); + + if ($chunks === null) { + Util::addScript(application: $appId, file: $fallbackScript); + return; + } + + foreach ($chunks as $chunk) { + Util::addScript(application: $appId, file: $chunk); + } + + }//end addEntryScripts() + + /** + * Resolve the ordered list of script names for an entrypoint. + * + * @param string $appId The application identifier. + * @param string $entry The webpack entrypoint name. + * @param string|null $jsDirectory Optional override of the js directory (used in tests). + * + * @return string[]|null Ordered script names without the .js suffix, or null when unavailable. + */ + public static function resolveEntryScripts( + string $appId, + string $entry, + ?string $jsDirectory=null + ): ?array { + $manifest = self::loadManifest(appId: $appId, jsDirectory: $jsDirectory); + + if ($manifest === null || isset($manifest[$entry]) === false) { + return null; + } + + $chunks = []; + foreach ($manifest[$entry] as $file) { + $chunks[] = preg_replace('/\.js$/', '', $file); + } + + return $chunks; + + }//end resolveEntryScripts() + + /** + * Load and decode the entrypoints manifest for an application. + * + * @param string $appId The application identifier. + * @param string|null $jsDirectory Optional override of the js directory (used in tests). + * + * @return array|null The decoded manifest, or null when unavailable. + */ + private static function loadManifest( + string $appId, + ?string $jsDirectory=null + ): ?array { + $directory = ($jsDirectory ?? __DIR__.'/../../js'); + $path = $directory.'/'.$appId.'-entrypoints.json'; + + if (array_key_exists($path, self::$manifestCache) === true) { + return self::$manifestCache[$path]; + } + + if (is_file($path) === false) { + return null; + } + + $contents = file_get_contents($path); + if ($contents === false) { + return null; + } + + $decoded = json_decode($contents, true); + if (is_array($decoded) === false) { + return null; + } + + self::$manifestCache[$path] = $decoded; + return $decoded; + + }//end loadManifest() +}//end class diff --git a/package-lock.json b/package-lock.json index efb01340e..723098ff6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "@uiw/codemirror-theme-github": "^4.25.9", "@vue/test-utils": "^1.3.6", "@vue/vue2-jest": "^29.2.6", + "esbuild": "^0.28.1", "eslint": "^8.57.0", "eslint-import-resolver-alias": "^1.1.2", "eslint-webpack-plugin": "^4.2.0", @@ -80,6 +81,7 @@ "sass": "^1.98.0", "sass-loader": "^16.0.7", "stylelint-webpack-plugin": "^5.0.1", + "terser-webpack-plugin": "^5.6.1", "ts-jest": "^29.2.4", "ts-loader": "^9.5.1", "typescript": "^5.5.4", @@ -2385,29 +2387,6 @@ "@floating-ui/core": "^1.1.0" } }, - "node_modules/@conduction/nextcloud-vue/node_modules/@nextcloud/dialogs/node_modules/pinia": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", - "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@vue/devtools-api": "^7.7.7" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "typescript": ">=4.5.0", - "vue": "^3.5.11" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@conduction/nextcloud-vue/node_modules/@nextcloud/dialogs/node_modules/splitpanes": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/splitpanes/-/splitpanes-4.0.4.tgz", @@ -2612,17 +2591,6 @@ "source-map-js": "^1.2.1" } }, - "node_modules/@conduction/nextcloud-vue/node_modules/@vue/devtools-api": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", - "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@vue/devtools-kit": "^7.7.9" - } - }, "node_modules/@conduction/nextcloud-vue/node_modules/@vue/devtools-shared": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.1.tgz", @@ -3117,6 +3085,448 @@ "node": ">=16" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -4429,7 +4839,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -8721,34 +9130,6 @@ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "license": "MIT" }, - "node_modules/@vue/devtools-kit": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", - "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@vue/devtools-shared": "^7.7.9", - "birpc": "^2.3.0", - "hookable": "^5.5.3", - "mitt": "^3.0.1", - "perfect-debounce": "^1.0.0", - "speakingurl": "^14.0.1", - "superjson": "^2.2.2" - } - }, - "node_modules/@vue/devtools-shared": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", - "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "rfdc": "^1.4.1" - } - }, "node_modules/@vue/eslint-config-typescript": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-13.0.0.tgz", @@ -11803,23 +12184,6 @@ "license": "MIT", "peer": true }, - "node_modules/copy-anything": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", - "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "is-what": "^5.2.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, "node_modules/copy-to-clipboard": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", @@ -13802,6 +14166,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -17677,20 +18083,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-what": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", - "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, "node_modules/is-whitespace": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/is-whitespace/-/is-whitespace-0.3.0.tgz", @@ -21733,14 +22125,6 @@ "node": ">= 18" } }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -23025,14 +23409,6 @@ "node": ">= 0.10" } }, - "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -25395,14 +25771,6 @@ "node": ">=0.10.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -26558,17 +26926,6 @@ "node": ">= 6" } }, - "node_modules/speakingurl": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/splitpanes": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/splitpanes/-/splitpanes-2.4.1.tgz", @@ -27296,20 +27653,6 @@ "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", "license": "MIT" }, - "node_modules/superjson": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "copy-anything": "^4" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -27519,7 +27862,6 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.2.tgz", "integrity": "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -27534,11 +27876,10 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.5.0.tgz", - "integrity": "sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -27556,12 +27897,39 @@ "webpack": "^5.1.0" }, "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, "@swc/core": { "optional": true }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, "esbuild": { "optional": true }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, "uglify-js": { "optional": true } @@ -27572,7 +27940,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -27582,7 +27949,6 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -27597,7 +27963,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -27612,15 +27977,13 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/terser/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -27630,7 +27993,6 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "license": "MIT", - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" diff --git a/package.json b/package.json index eef939a2b..3e1955664 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@uiw/codemirror-theme-github": "^4.25.9", "@vue/test-utils": "^1.3.6", "@vue/vue2-jest": "^29.2.6", + "esbuild": "^0.28.1", "eslint": "^8.57.0", "eslint-import-resolver-alias": "^1.1.2", "eslint-webpack-plugin": "^4.2.0", @@ -94,6 +95,7 @@ "sass": "^1.98.0", "sass-loader": "^16.0.7", "stylelint-webpack-plugin": "^5.0.1", + "terser-webpack-plugin": "^5.6.1", "ts-jest": "^29.2.4", "ts-loader": "^9.5.1", "typescript": "^5.5.4", diff --git a/templates/index.php b/templates/index.php index 2ead5cd7a..ab89e28a5 100644 --- a/templates/index.php +++ b/templates/index.php @@ -1,9 +1,10 @@ diff --git a/templates/settings/admin.php b/templates/settings/admin.php index 4ba6c1042..86b4a5eab 100644 --- a/templates/settings/admin.php +++ b/templates/settings/admin.php @@ -1,8 +1,9 @@ diff --git a/tests/Unit/Service/ScriptManifestLoaderTest.php b/tests/Unit/Service/ScriptManifestLoaderTest.php new file mode 100644 index 000000000..02c137c59 --- /dev/null +++ b/tests/Unit/Service/ScriptManifestLoaderTest.php @@ -0,0 +1,95 @@ +tmpDir = sys_get_temp_dir() . '/octest-manifest-' . uniqid('', true); + mkdir($this->tmpDir, 0777, true); + } + + protected function tearDown(): void + { + $manifest = $this->tmpDir . '/opencatalogi-entrypoints.json'; + if (is_file($manifest)) { + unlink($manifest); + } + + if (is_dir($this->tmpDir)) { + rmdir($this->tmpDir); + } + } + + private function writeManifest(string $contents): void + { + file_put_contents($this->tmpDir . '/opencatalogi-entrypoints.json', $contents); + } + + public function testReturnsOrderedChunksWithoutJsSuffixForKnownEntry(): void + { + $this->writeManifest(json_encode([ + 'main' => [ + 'opencatalogi-vendor.js', + 'opencatalogi-shared.js', + 'opencatalogi-main.js', + ], + ])); + + $chunks = ScriptManifestLoader::resolveEntryScripts('opencatalogi', 'main', $this->tmpDir); + + $this->assertSame( + ['opencatalogi-vendor', 'opencatalogi-shared', 'opencatalogi-main'], + $chunks, + ); + } + + public function testReturnsNullWhenManifestFileIsMissing(): void + { + $chunks = ScriptManifestLoader::resolveEntryScripts('opencatalogi', 'main', $this->tmpDir); + + $this->assertNull($chunks); + } + + public function testReturnsNullWhenEntryIsNotInManifest(): void + { + $this->writeManifest(json_encode(['main' => ['opencatalogi-main.js']])); + + $chunks = ScriptManifestLoader::resolveEntryScripts('opencatalogi', 'adminSettings', $this->tmpDir); + + $this->assertNull($chunks); + } + + public function testReturnsNullWhenManifestIsInvalidJson(): void + { + $this->writeManifest('{ not valid json'); + + $chunks = ScriptManifestLoader::resolveEntryScripts('opencatalogi', 'main', $this->tmpDir); + + $this->assertNull($chunks); + } + + public function testReturnsNullWhenManifestIsNotAnArray(): void + { + $this->writeManifest('123'); + + $chunks = ScriptManifestLoader::resolveEntryScripts('opencatalogi', 'main', $this->tmpDir); + + $this->assertNull($chunks); + } +} diff --git a/webpack.config.js b/webpack.config.js index 167f6e3b3..8280237e9 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -4,10 +4,12 @@ const webpack = require('webpack') const webpackConfig = require('@nextcloud/webpack-vue-config') const { VueLoaderPlugin } = require('vue-loader') const NodePolyfillPlugin = require('node-polyfill-webpack-plugin') +const TerserPlugin = require('terser-webpack-plugin') const buildMode = process.env.NODE_ENV const isDev = buildMode === 'development' -webpackConfig.devtool = isDev ? 'cheap-source-map' : 'source-map' +// No production source maps: they inflate output size and heap usage dramatically. +webpackConfig.devtool = isDev ? 'cheap-source-map' : false webpackConfig.stats = { colors: true, @@ -38,8 +40,6 @@ webpackConfig.entry = { }, } -webpackConfig.devtool = 'inline-source-map' - webpackConfig.module = { rules: [ { @@ -86,7 +86,65 @@ webpackConfig.resolve.alias = { vue$: path.resolve(__dirname, 'node_modules/vue'), pinia$: path.resolve(__dirname, 'node_modules/pinia'), '@nextcloud/vue$': path.resolve(__dirname, 'node_modules/@nextcloud/vue'), - '@nextcloud/dialogs': path.resolve(__dirname, 'node_modules/@nextcloud/dialogs'), + '@nextcloud/dialogs$': path.resolve(__dirname, 'node_modules/@nextcloud/dialogs'), } +if (isDev === false) { + webpackConfig.optimization = webpackConfig.optimization || {} + + // Minify with esbuild instead of Terser: far lower peak RAM and much faster. + // esbuild parallelises internally (Go), so disable webpack-worker parallelism + // to avoid spawning redundant Node processes. + webpackConfig.optimization.minimizer = [ + new TerserPlugin({ + minify: TerserPlugin.esbuildMinify, + parallel: false, + terserOptions: { + legalComments: 'eof', + }, + }), + ] + + // The in-memory build cache is unused in a single-shot production build and + // only adds heap pressure. + webpackConfig.cache = false + + // De-duplicate node_modules across the five entrypoints into a single shared + // vendor chunk instead of bundling vue/@nextcloud/vue/pinia/etc. once per entry. + // This is the main output-size and RAM win. The emitted vendor chunk must be + // loaded before each entry script (handled PHP-side by ScriptManifestLoader). + webpackConfig.optimization.splitChunks = { + chunks: 'all', + cacheGroups: { + vendor: { + test: /[\\/]node_modules[\\/]/, + name: 'vendor', + priority: 10, + }, + }, + } +} + +// Emit a manifest mapping each entrypoint to its ordered list of initial JS chunks +// (runtime, shared vendor, entry). ScriptManifestLoader.php reads this so every entry +// loads its split chunks in the correct order, with a fallback to the single-script +// name when the manifest is absent. +webpackConfig.plugins.push({ + apply(compiler) { + compiler.hooks.afterEmit.tap('OpenCatalogiEntrypointsManifest', (compilation) => { + const manifest = {} + for (const [name, entrypoint] of compilation.entrypoints) { + manifest[name] = entrypoint + .getFiles() + .map((file) => file.split('?')[0]) + .filter((file) => file.endsWith('.js')) + } + fs.writeFileSync( + path.join(compiler.options.output.path, 'opencatalogi-entrypoints.json'), + JSON.stringify(manifest, null, '\t') + '\n', + ) + }) + }, +}) + module.exports = webpackConfig