From 99baf6dfba29881377085a9ed375f611bfe60ac1 Mon Sep 17 00:00:00 2001 From: Peter Petrov Date: Wed, 31 Jul 2024 13:14:32 +0300 Subject: [PATCH] JS Package: Add Critical CSS Gen (#38429) * Add initial files * Fix types * Add builds for browser and node * Only build for node (for now) * Update package.json type to module * Unfix types (these are the original source files) * Add rollup for browser version (not working properly) * WIP * Ping all package versions * Fix build for browser * add changelogs * Remove postinstall scripts related to yalc * Add missing dependency * Don't specify moduleResolution in tsconfig * Don't set module directly. Jetpack tooling already sets that * Add build for browser version * Fix JSDoc blocks * Remove lock file * Remove test package * Update root pnpm-lock * No useless constructor * Fix root lock file out of date * Fix pointing to unminified file for css gen package * Add back-end/node build * Fix map files * Fix package entrypoints * Remove unused dependencies * Add tests * Package resolution * Fix lint issues * Try to fix tests * Remove global setup * Add test env for jest * Install chrome before running project tests * Setup puppeteer envs * Fix tests * Update imports * Fix incorrectly ignoring directories * Remove build from npm test command * I guess building is necessary... Otherwise GH tests fail because the build is missing. * Cleanup package dependencies * Cleanup babel dependencies * Cleanup root lock file * More dependency cleanup * Remove unused babel footprint * Fix to remove duplicate puppeteer related dependencies * Remove unused dependency * Add express back Update puppeteer to fix lock files check * Fix incorrect package usage * Fix jetpack entry file * Remove unnecessary tsconfig * Remove unused dependencies * Update rollup to use the same version as everything else in the monorepo * Update package vesion * Update package to match monorepo * Update package to match monorepo * Update package version * Update package version * Cleanup eslint dependencies, since they are handled at monorepo level * Dedupe packages * Update build to use pnpm instead of npm Co-authored-by: Brad Jorsch * Update test script to use pnpm instead of npm Co-authored-by: Brad Jorsch * Use playwright instead of puppeteer * Fix browser-interface-iframe tests * Fix generate-critical-css not working with local files * Add build before tests * Remove chrome setup as puppeteer is no longer present * Build before running tests * Fix incorrectly parsing file paths * Remove unused dependencies * Remove peer dependencies meta property * Update dependencies versions * Replace node-fetch in favor of node 18.0.0s fetch * Install browsers before running tests * Update gitattributes Co-authored-by: Brad Jorsch * Rename build folders to make sense at a glance * Revert update to boost to use new package We should release the package first then update Boost. That way we can be sure that doing a new Boost release will not ship broken. * Add a separate entry point for playwright interface * Update node/browser entry files names * Fix using incorrect entry file for browser build * Remove unused globals from browser build * Exclude browser config from production build * Make playwright-core a required peer dependency * Make playwright-core a production dependency * Remove separate entry point for playwright * Make playwright a dev depepdency * Make playwright-core an optional peer dependency --------- Co-authored-by: Brad Jorsch --- pnpm-lock.yaml | 611 +++++++++++------ .../critical-css-gen/.gitattributes | 15 + .../js-packages/critical-css-gen/.gitignore | 4 + .../js-packages/critical-css-gen/CHANGELOG.md | 7 + .../js-packages/critical-css-gen/README.md | 24 + .../critical-css-gen/changelog/.gitkeep | 0 .../changelog/add-js-package-critical-css-gen | 4 + .../changelog/initial-version | 4 + .../critical-css-gen/composer.json | 44 ++ .../critical-css-gen/jest.config.cjs | 5 + .../js-packages/critical-css-gen/package.json | 78 +++ .../critical-css-gen/rollup.config.js | 47 ++ .../src/browser-interface-iframe.ts | 211 ++++++ .../src/browser-interface-playwright.ts | 123 ++++ .../critical-css-gen/src/browser-interface.ts | 169 +++++ .../critical-css-gen/src/browser.ts | 7 + .../critical-css-gen/src/css-file-set.ts | 264 ++++++++ .../critical-css-gen/src/errors.ts | 142 ++++ .../src/generate-critical-css.ts | 231 +++++++ .../src/ignored-pseudo-elements.ts | 39 ++ .../critical-css-gen/src/minify-css.ts | 21 + .../js-packages/critical-css-gen/src/node.ts | 7 + .../src/object-promise-all.ts | 21 + .../critical-css-gen/src/style-ast.ts | 615 ++++++++++++++++++ .../js-packages/critical-css-gen/src/types.ts | 14 + .../critical-css-gen/tests/babel.config.json | 12 + .../tests/config/jest-setup.js | 3 + .../tests/config/jest.config.js | 9 + .../tests/data/page-a/complex-rules.css | 4 + .../tests/data/page-a/index.html | 29 + .../tests/data/page-a/min-width.css | 6 + .../tests/data/page-a/print.css | 6 + .../tests/data/page-a/style.css | 54 ++ .../tests/lib/data-directory.js | 10 + .../critical-css-gen/tests/lib/mock-fetch.js | 34 + .../critical-css-gen/tests/lib/test-server.js | 56 ++ .../unit/browser-interface-iframe.test.js | 210 ++++++ .../tests/unit/generate-critical-css.test.js | 142 ++++ .../critical-css-gen/tsconfig.browser.json | 7 + .../critical-css-gen/tsconfig.json | 14 + 40 files changed, 3089 insertions(+), 214 deletions(-) create mode 100644 projects/js-packages/critical-css-gen/.gitattributes create mode 100644 projects/js-packages/critical-css-gen/.gitignore create mode 100644 projects/js-packages/critical-css-gen/CHANGELOG.md create mode 100644 projects/js-packages/critical-css-gen/README.md create mode 100644 projects/js-packages/critical-css-gen/changelog/.gitkeep create mode 100644 projects/js-packages/critical-css-gen/changelog/add-js-package-critical-css-gen create mode 100644 projects/js-packages/critical-css-gen/changelog/initial-version create mode 100644 projects/js-packages/critical-css-gen/composer.json create mode 100644 projects/js-packages/critical-css-gen/jest.config.cjs create mode 100644 projects/js-packages/critical-css-gen/package.json create mode 100644 projects/js-packages/critical-css-gen/rollup.config.js create mode 100644 projects/js-packages/critical-css-gen/src/browser-interface-iframe.ts create mode 100644 projects/js-packages/critical-css-gen/src/browser-interface-playwright.ts create mode 100644 projects/js-packages/critical-css-gen/src/browser-interface.ts create mode 100644 projects/js-packages/critical-css-gen/src/browser.ts create mode 100644 projects/js-packages/critical-css-gen/src/css-file-set.ts create mode 100644 projects/js-packages/critical-css-gen/src/errors.ts create mode 100644 projects/js-packages/critical-css-gen/src/generate-critical-css.ts create mode 100644 projects/js-packages/critical-css-gen/src/ignored-pseudo-elements.ts create mode 100644 projects/js-packages/critical-css-gen/src/minify-css.ts create mode 100644 projects/js-packages/critical-css-gen/src/node.ts create mode 100644 projects/js-packages/critical-css-gen/src/object-promise-all.ts create mode 100644 projects/js-packages/critical-css-gen/src/style-ast.ts create mode 100644 projects/js-packages/critical-css-gen/src/types.ts create mode 100644 projects/js-packages/critical-css-gen/tests/babel.config.json create mode 100644 projects/js-packages/critical-css-gen/tests/config/jest-setup.js create mode 100644 projects/js-packages/critical-css-gen/tests/config/jest.config.js create mode 100644 projects/js-packages/critical-css-gen/tests/data/page-a/complex-rules.css create mode 100644 projects/js-packages/critical-css-gen/tests/data/page-a/index.html create mode 100644 projects/js-packages/critical-css-gen/tests/data/page-a/min-width.css create mode 100644 projects/js-packages/critical-css-gen/tests/data/page-a/print.css create mode 100644 projects/js-packages/critical-css-gen/tests/data/page-a/style.css create mode 100644 projects/js-packages/critical-css-gen/tests/lib/data-directory.js create mode 100644 projects/js-packages/critical-css-gen/tests/lib/mock-fetch.js create mode 100644 projects/js-packages/critical-css-gen/tests/lib/test-server.js create mode 100644 projects/js-packages/critical-css-gen/tests/unit/browser-interface-iframe.test.js create mode 100644 projects/js-packages/critical-css-gen/tests/unit/generate-critical-css.test.js create mode 100644 projects/js-packages/critical-css-gen/tsconfig.browser.json create mode 100644 projects/js-packages/critical-css-gen/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da873bc9d4b4b..5d163c92cb441 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -515,6 +515,88 @@ importers: specifier: 18.3.1 version: 18.3.1(react@18.3.1) + projects/js-packages/critical-css-gen: + dependencies: + clean-css: + specifier: ^5.3.1 + version: 5.3.1 + css-tree: + specifier: ^2.3.1 + version: 2.3.1 + devDependencies: + '@babel/core': + specifier: 7.24.7 + version: 7.24.7 + '@babel/preset-env': + specifier: 7.24.7 + version: 7.24.7(@babel/core@7.24.7) + '@babel/preset-typescript': + specifier: 7.24.7 + version: 7.24.7(@babel/core@7.24.7) + '@rollup/plugin-commonjs': + specifier: 26.0.1 + version: 26.0.1(rollup@2.79.1) + '@rollup/plugin-json': + specifier: 4.1.0 + version: 4.1.0(rollup@2.79.1) + '@rollup/plugin-node-resolve': + specifier: 13.3.0 + version: 13.3.0(rollup@2.79.1) + '@rollup/plugin-terser': + specifier: 0.4.3 + version: 0.4.3(rollup@2.79.1) + '@rollup/plugin-typescript': + specifier: 8.3.3 + version: 8.3.3(rollup@2.79.1)(tslib@2.5.0)(typescript@5.0.4) + '@types/clean-css': + specifier: 4.2.5 + version: 4.2.5 + '@types/css-tree': + specifier: 2.0.1 + version: 2.0.1 + '@types/node': + specifier: ^20.4.2 + version: 20.14.10 + express: + specifier: 4.19.2 + version: 4.19.2 + jest: + specifier: 29.7.0 + version: 29.7.0(@types/node@20.14.10) + playwright: + specifier: 1.45.1 + version: 1.45.1 + playwright-core: + specifier: ^1.45.1 + version: 1.45.1 + prettier: + specifier: npm:wp-prettier@3.0.3 + version: wp-prettier@3.0.3 + rollup: + specifier: 2.79.1 + version: 2.79.1 + rollup-plugin-polyfill-node: + specifier: 0.13.0 + version: 0.13.0(rollup@2.79.1) + source-map: + specifier: 0.7.4 + version: 0.7.4 + source-map-js: + specifier: 1.2.0 + version: 1.2.0 + tslib: + specifier: 2.5.0 + version: 2.5.0 + typescript: + specifier: 5.0.4 + version: 5.0.4 + webpack: + specifier: 5.76.0 + version: 5.76.0 + webpack-dev-middleware: + specifier: 5.3.4 + version: 5.3.4(webpack@5.76.0) + projects/js-packages/eslint-changed: dependencies: chalk: @@ -4949,8 +5031,8 @@ packages: '@babel/core': ^7.11.0 eslint: ^7.5.0 || ^8.0.0 || ^9.0.0 - '@babel/generator@7.24.7': - resolution: {integrity: sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==} + '@babel/generator@7.24.10': + resolution: {integrity: sha512-o9HBZL1G2129luEUlG1hB4N/nlYNWHnpwlND9eOMclRqqu1YDy2sSYVCFUZwl8I1Gxh+QSRrP2vD7EpUmFVXxg==} engines: {node: '>=6.9.0'} '@babel/helper-annotate-as-pure@7.24.7': @@ -5002,8 +5084,8 @@ packages: resolution: {integrity: sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.24.7': - resolution: {integrity: sha512-1fuJEwIrp+97rM4RWdO+qrRsZlAeL1lQJoPqtCYWv0NL115XM93hIH4CSRln2w52SqvmY5hqdtauB6QFCDiZNQ==} + '@babel/helper-module-transforms@7.24.9': + resolution: {integrity: sha512-oYbh+rtFKj/HwBQkFlUzvcybzklmVdVV3UU+mN7n2t/q3yGHbuVdNxyFvSBO1tfvjyArpHNcWMAzsSPdyI46hw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 @@ -5012,8 +5094,8 @@ packages: resolution: {integrity: sha512-jKiTsW2xmWwxT1ixIdfXUZp+P5yURx2suzLZr5Hi64rURpDYdMW0pv+Uf17EYk2Rd428Lx4tLsnjGJzYKDM/6A==} engines: {node: '>=6.9.0'} - '@babel/helper-plugin-utils@7.24.7': - resolution: {integrity: sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg==} + '@babel/helper-plugin-utils@7.24.8': + resolution: {integrity: sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==} engines: {node: '>=6.9.0'} '@babel/helper-remap-async-to-generator@7.24.7': @@ -5040,32 +5122,32 @@ packages: resolution: {integrity: sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.24.7': - resolution: {integrity: sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==} + '@babel/helper-string-parser@7.24.8': + resolution: {integrity: sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==} engines: {node: '>=6.9.0'} '@babel/helper-validator-identifier@7.24.7': resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.24.7': - resolution: {integrity: sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw==} + '@babel/helper-validator-option@7.24.8': + resolution: {integrity: sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==} engines: {node: '>=6.9.0'} '@babel/helper-wrap-function@7.24.7': resolution: {integrity: sha512-N9JIYk3TD+1vq/wn77YnJOqMtfWhNewNE+DJV4puD2X7Ew9J4JvrzrFDfTfyv5EgEXVy9/Wt8QiOErzEmv5Ifw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.24.7': - resolution: {integrity: sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg==} + '@babel/helpers@7.24.8': + resolution: {integrity: sha512-gV2265Nkcz7weJJfvDoAEVzC1e2OTDpkGbEsebse8koXUJUXPsCMi7sRo/+SPMuMZ9MtUPnGwITTnQnU5YjyaQ==} engines: {node: '>=6.9.0'} '@babel/highlight@7.24.7': resolution: {integrity: sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.24.7': - resolution: {integrity: sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==} + '@babel/parser@7.24.8': + resolution: {integrity: sha512-WzfbgXOkGzZiXXCqk43kKwZjzwx4oulxZi3nq2TYL9mOjQv6kYwul9mz6ID36njuL7Xkp6nJEfok848Zj10j/w==} engines: {node: '>=6.0.0'} hasBin: true @@ -5416,8 +5498,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-optional-chaining@7.24.7': - resolution: {integrity: sha512-tK+0N9yd4j+x/4hxF3F0e0fu/VdcxU18y5SevtyM/PCFlQvXbR0Zmlo2eBrKtVipGNFzpq56o8WsIIKcJFUCRQ==} + '@babel/plugin-transform-optional-chaining@7.24.8': + resolution: {integrity: sha512-5cTOLSMs9eypEy8JUVvIKOu6NgvbJMnpG62VpIHrTmROdQ+L5mDAaI40g25k5vXti55JWNX5jCkq3HZxXBQANw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -5600,12 +5682,12 @@ packages: resolution: {integrity: sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.24.7': - resolution: {integrity: sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA==} + '@babel/traverse@7.24.8': + resolution: {integrity: sha512-t0P1xxAPzEDcEPmjprAQq19NWum4K0EQPjMwZQZbHt+GiZqvjCHjj755Weq1YRPVzBI+3zSfvScfpnuIecVFJQ==} engines: {node: '>=6.9.0'} - '@babel/types@7.24.7': - resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==} + '@babel/types@7.24.9': + resolution: {integrity: sha512-xm8XrMKz0IlUdocVbYJe0Z9xEgidU7msskG8BbhnTPK/HZ2z/7FP7ykqPgrUH+C+r414mNfNWam1f2vqOjqjYQ==} engines: {node: '>=6.9.0'} '@base2/pretty-print-object@1.0.1': @@ -6629,6 +6711,15 @@ packages: rollup: optional: true + '@rollup/plugin-inject@5.0.5': + resolution: {integrity: sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/plugin-json@4.1.0': resolution: {integrity: sha512-yfLbTdNS6amI/2OpmbiBoW12vngr5NW2jCJVZSBEz+H5KfUJZ2M7sDjk0U6GOOdCWFVScShte29o9NezJ53TPw==} peerDependencies: @@ -7307,6 +7398,9 @@ packages: '@types/body-parser@1.19.5': resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + '@types/clean-css@4.2.5': + resolution: {integrity: sha512-NEzjkGGpbs9S9fgC4abuBvTpVwE3i+Acu9BBod3PUyjDVZcNsGx61b8r2PphR61QGPnn0JHVs5ey6/I4eTrkxw==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -7316,6 +7410,9 @@ packages: '@types/cross-spawn@6.0.6': resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==} + '@types/css-tree@2.0.1': + resolution: {integrity: sha512-eeRN9rsZK/ZD5nmJCeZXxyTwq+gsvN1EljeCPEyXk+vLOAwsgpsrdXio4lPBzxAuhIKu3MK7QvZxWUw9xDX8Bg==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -8759,8 +8856,8 @@ packages: cjs-module-lexer@1.3.1: resolution: {integrity: sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==} - clean-css@5.3.3: - resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} + clean-css@5.3.1: + resolution: {integrity: sha512-lCr8OHhiWCTw4v8POJovCoh4T7I9U11yVsPjMWWnnMmp9ZowCxyad1Pathle/9HjaDp+fdQKjO9fQydE6RHTZg==} engines: {node: '>= 10.0'} clean-stack@2.2.0: @@ -13148,6 +13245,11 @@ packages: resolution: {integrity: sha512-vqQZ/UQowTW7VoiKEM5ouNW90wE5/GZLfdWuR0ELxyKOJUIaj+uismPZZaICU4DnWPVjnpCDDxEqwU7pcKY/PA==} engines: {node: '>=8.3'} + rollup-plugin-polyfill-node@0.13.0: + resolution: {integrity: sha512-FYEvpCaD5jGtyBuBFcQImEGmTxDTPbiHjJdrYIp+mFIwgXiXabxvKUK7ZT9P31ozu2Tqm9llYQMRWsfvTMTAOw==} + peerDependencies: + rollup: ^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 + rollup-plugin-postcss@4.0.2: resolution: {integrity: sha512-05EaY6zvZdmvPUDi3uCcAQoESDcYnv8ogJJQRp6V5kZ6J6P7uAVJlrTZcaaA20wTH527YTnKfkAoPxWI/jPp4w==} engines: {node: '>=10'} @@ -13281,8 +13383,8 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.6.2: - resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==} + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} hasBin: true @@ -13431,6 +13533,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -14311,6 +14417,12 @@ packages: webpack-dev-server: optional: true + webpack-dev-middleware@5.3.4: + resolution: {integrity: sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==} + engines: {node: '>= 12.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + webpack-dev-middleware@6.1.3: resolution: {integrity: sha512-A4ChP0Qj8oGociTs6UdlRUGANIGrCDL3y+pmQMc+dSsraXHCatFpmMey4mYELA+juqwUqwQsUgJJISXl1KWmiw==} engines: {node: '>= 14.15.0'} @@ -14770,14 +14882,14 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@babel/code-frame': 7.24.7 - '@babel/generator': 7.24.7 + '@babel/generator': 7.24.10 '@babel/helper-compilation-targets': 7.24.7 - '@babel/helper-module-transforms': 7.24.7(@babel/core@7.24.7) - '@babel/helpers': 7.24.7 - '@babel/parser': 7.24.7 + '@babel/helper-module-transforms': 7.24.9(@babel/core@7.24.7) + '@babel/helpers': 7.24.8 + '@babel/parser': 7.24.8 '@babel/template': 7.24.7 - '@babel/traverse': 7.24.7 - '@babel/types': 7.24.7 + '@babel/traverse': 7.24.8 + '@babel/types': 7.24.9 convert-source-map: 2.0.0 debug: 4.3.4 gensync: 1.0.0-beta.2 @@ -14794,28 +14906,28 @@ snapshots: eslint-visitor-keys: 2.1.0 semver: 6.3.1 - '@babel/generator@7.24.7': + '@babel/generator@7.24.10': dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 jsesc: 2.5.2 '@babel/helper-annotate-as-pure@7.24.7': dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 '@babel/helper-builder-binary-assignment-operator-visitor@7.24.7': dependencies: - '@babel/traverse': 7.24.7 - '@babel/types': 7.24.7 + '@babel/traverse': 7.24.8 + '@babel/types': 7.24.9 transitivePeerDependencies: - supports-color '@babel/helper-compilation-targets@7.24.7': dependencies: '@babel/compat-data': 7.24.7 - '@babel/helper-validator-option': 7.24.7 + '@babel/helper-validator-option': 7.24.8 browserslist: 4.23.1 lru-cache: 5.1.1 semver: 6.3.1 @@ -14846,7 +14958,7 @@ snapshots: dependencies: '@babel/core': 7.24.7 '@babel/helper-compilation-targets': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 debug: 4.3.4 lodash.debounce: 4.0.8 resolve: 1.22.8 @@ -14855,32 +14967,32 @@ snapshots: '@babel/helper-environment-visitor@7.24.7': dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 '@babel/helper-function-name@7.24.7': dependencies: '@babel/template': 7.24.7 - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 '@babel/helper-hoist-variables@7.24.7': dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 '@babel/helper-member-expression-to-functions@7.24.7': dependencies: - '@babel/traverse': 7.24.7 - '@babel/types': 7.24.7 + '@babel/traverse': 7.24.8 + '@babel/types': 7.24.9 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.24.7': dependencies: - '@babel/traverse': 7.24.7 - '@babel/types': 7.24.7 + '@babel/traverse': 7.24.8 + '@babel/types': 7.24.9 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.24.7(@babel/core@7.24.7)': + '@babel/helper-module-transforms@7.24.9(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 '@babel/helper-environment-visitor': 7.24.7 @@ -14893,9 +15005,9 @@ snapshots: '@babel/helper-optimise-call-expression@7.24.7': dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 - '@babel/helper-plugin-utils@7.24.7': {} + '@babel/helper-plugin-utils@7.24.8': {} '@babel/helper-remap-async-to-generator@7.24.7(@babel/core@7.24.7)': dependencies: @@ -14917,41 +15029,41 @@ snapshots: '@babel/helper-simple-access@7.24.7': dependencies: - '@babel/traverse': 7.24.7 - '@babel/types': 7.24.7 + '@babel/traverse': 7.24.8 + '@babel/types': 7.24.9 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.24.7': dependencies: - '@babel/traverse': 7.24.7 - '@babel/types': 7.24.7 + '@babel/traverse': 7.24.8 + '@babel/types': 7.24.9 transitivePeerDependencies: - supports-color '@babel/helper-split-export-declaration@7.24.7': dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 - '@babel/helper-string-parser@7.24.7': {} + '@babel/helper-string-parser@7.24.8': {} '@babel/helper-validator-identifier@7.24.7': {} - '@babel/helper-validator-option@7.24.7': {} + '@babel/helper-validator-option@7.24.8': {} '@babel/helper-wrap-function@7.24.7': dependencies: '@babel/helper-function-name': 7.24.7 '@babel/template': 7.24.7 - '@babel/traverse': 7.24.7 - '@babel/types': 7.24.7 + '@babel/traverse': 7.24.8 + '@babel/types': 7.24.9 transitivePeerDependencies: - supports-color - '@babel/helpers@7.24.7': + '@babel/helpers@7.24.8': dependencies: '@babel/template': 7.24.7 - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 '@babel/highlight@7.24.7': dependencies: @@ -14960,27 +15072,27 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.0.1 - '@babel/parser@7.24.7': + '@babel/parser@7.24.8': dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 '@babel/helper-environment-visitor': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/helper-skip-transparent-expression-wrappers': 7.24.7 - '@babel/plugin-transform-optional-chaining': 7.24.7(@babel/core@7.24.7) + '@babel/plugin-transform-optional-chaining': 7.24.8(@babel/core@7.24.7) transitivePeerDependencies: - supports-color @@ -14988,7 +15100,7 @@ snapshots: dependencies: '@babel/core': 7.24.7 '@babel/helper-environment-visitor': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.24.7)': dependencies: @@ -14997,124 +15109,124 @@ snapshots: '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-export-namespace-from@7.8.3(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-flow@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-import-assertions@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-import-attributes@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-typescript@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 '@babel/helper-create-regexp-features-plugin': 7.24.7(@babel/core@7.24.7) - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-arrow-functions@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-async-generator-functions@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 '@babel/helper-environment-visitor': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/helper-remap-async-to-generator': 7.24.7(@babel/core@7.24.7) '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.7) transitivePeerDependencies: @@ -15124,7 +15236,7 @@ snapshots: dependencies: '@babel/core': 7.24.7 '@babel/helper-module-imports': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/helper-remap-async-to-generator': 7.24.7(@babel/core@7.24.7) transitivePeerDependencies: - supports-color @@ -15132,18 +15244,18 @@ snapshots: '@babel/plugin-transform-block-scoped-functions@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-block-scoping@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-class-properties@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 '@babel/helper-create-class-features-plugin': 7.24.7(@babel/core@7.24.7) - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 transitivePeerDependencies: - supports-color @@ -15151,7 +15263,7 @@ snapshots: dependencies: '@babel/core': 7.24.7 '@babel/helper-create-class-features-plugin': 7.24.7(@babel/core@7.24.7) - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.24.7) transitivePeerDependencies: - supports-color @@ -15163,7 +15275,7 @@ snapshots: '@babel/helper-compilation-targets': 7.24.7 '@babel/helper-environment-visitor': 7.24.7 '@babel/helper-function-name': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/helper-replace-supers': 7.24.7(@babel/core@7.24.7) '@babel/helper-split-export-declaration': 7.24.7 globals: 11.12.0 @@ -15173,55 +15285,55 @@ snapshots: '@babel/plugin-transform-computed-properties@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/template': 7.24.7 '@babel/plugin-transform-destructuring@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-dotall-regex@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 '@babel/helper-create-regexp-features-plugin': 7.24.7(@babel/core@7.24.7) - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-duplicate-keys@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-dynamic-import@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.24.7) '@babel/plugin-transform-exponentiation-operator@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 '@babel/helper-builder-binary-assignment-operator-visitor': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 transitivePeerDependencies: - supports-color '@babel/plugin-transform-export-namespace-from@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.24.7) '@babel/plugin-transform-flow-strip-types@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-flow': 7.24.7(@babel/core@7.24.7) '@babel/plugin-transform-for-of@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/helper-skip-transparent-expression-wrappers': 7.24.7 transitivePeerDependencies: - supports-color @@ -15231,43 +15343,43 @@ snapshots: '@babel/core': 7.24.7 '@babel/helper-compilation-targets': 7.24.7 '@babel/helper-function-name': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-json-strings@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.24.7) '@babel/plugin-transform-literals@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-logical-assignment-operators@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.24.7) '@babel/plugin-transform-member-expression-literals@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-modules-amd@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-module-transforms': 7.24.7(@babel/core@7.24.7) - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-module-transforms': 7.24.9(@babel/core@7.24.7) + '@babel/helper-plugin-utils': 7.24.8 transitivePeerDependencies: - supports-color '@babel/plugin-transform-modules-commonjs@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-module-transforms': 7.24.7(@babel/core@7.24.7) - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-module-transforms': 7.24.9(@babel/core@7.24.7) + '@babel/helper-plugin-utils': 7.24.8 '@babel/helper-simple-access': 7.24.7 transitivePeerDependencies: - supports-color @@ -15276,8 +15388,8 @@ snapshots: dependencies: '@babel/core': 7.24.7 '@babel/helper-hoist-variables': 7.24.7 - '@babel/helper-module-transforms': 7.24.7(@babel/core@7.24.7) - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-module-transforms': 7.24.9(@babel/core@7.24.7) + '@babel/helper-plugin-utils': 7.24.8 '@babel/helper-validator-identifier': 7.24.7 transitivePeerDependencies: - supports-color @@ -15285,8 +15397,8 @@ snapshots: '@babel/plugin-transform-modules-umd@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-module-transforms': 7.24.7(@babel/core@7.24.7) - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-module-transforms': 7.24.9(@babel/core@7.24.7) + '@babel/helper-plugin-utils': 7.24.8 transitivePeerDependencies: - supports-color @@ -15294,37 +15406,37 @@ snapshots: dependencies: '@babel/core': 7.24.7 '@babel/helper-create-regexp-features-plugin': 7.24.7(@babel/core@7.24.7) - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-new-target@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-nullish-coalescing-operator@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.7) '@babel/plugin-transform-numeric-separator@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.7) '@babel/plugin-transform-object-rest-spread@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 '@babel/helper-compilation-targets': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.7) '@babel/plugin-transform-parameters': 7.24.7(@babel/core@7.24.7) '@babel/plugin-transform-object-super@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/helper-replace-supers': 7.24.7(@babel/core@7.24.7) transitivePeerDependencies: - supports-color @@ -15332,13 +15444,13 @@ snapshots: '@babel/plugin-transform-optional-catch-binding@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.7) - '@babel/plugin-transform-optional-chaining@7.24.7(@babel/core@7.24.7)': + '@babel/plugin-transform-optional-chaining@7.24.8(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/helper-skip-transparent-expression-wrappers': 7.24.7 '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.7) transitivePeerDependencies: @@ -15347,13 +15459,13 @@ snapshots: '@babel/plugin-transform-parameters@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-private-methods@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 '@babel/helper-create-class-features-plugin': 7.24.7(@babel/core@7.24.7) - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 transitivePeerDependencies: - supports-color @@ -15362,7 +15474,7 @@ snapshots: '@babel/core': 7.24.7 '@babel/helper-annotate-as-pure': 7.24.7 '@babel/helper-create-class-features-plugin': 7.24.7(@babel/core@7.24.7) - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.24.7) transitivePeerDependencies: - supports-color @@ -15370,17 +15482,17 @@ snapshots: '@babel/plugin-transform-property-literals@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-react-constant-elements@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-react-display-name@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-react-jsx-development@7.24.7(@babel/core@7.24.7)': dependencies: @@ -15394,9 +15506,9 @@ snapshots: '@babel/core': 7.24.7 '@babel/helper-annotate-as-pure': 7.24.7 '@babel/helper-module-imports': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.24.7) - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 transitivePeerDependencies: - supports-color @@ -15404,24 +15516,24 @@ snapshots: dependencies: '@babel/core': 7.24.7 '@babel/helper-annotate-as-pure': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-regenerator@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 regenerator-transform: 0.15.2 '@babel/plugin-transform-reserved-words@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-runtime@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 '@babel/helper-module-imports': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 babel-plugin-polyfill-corejs2: 0.4.11(@babel/core@7.24.7) babel-plugin-polyfill-corejs3: 0.10.4(@babel/core@7.24.7) babel-plugin-polyfill-regenerator: 0.6.2(@babel/core@7.24.7) @@ -15432,12 +15544,12 @@ snapshots: '@babel/plugin-transform-shorthand-properties@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-spread@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/helper-skip-transparent-expression-wrappers': 7.24.7 transitivePeerDependencies: - supports-color @@ -15445,24 +15557,24 @@ snapshots: '@babel/plugin-transform-sticky-regex@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-template-literals@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-typeof-symbol@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-typescript@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 '@babel/helper-annotate-as-pure': 7.24.7 '@babel/helper-create-class-features-plugin': 7.24.7(@babel/core@7.24.7) - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-typescript': 7.24.7(@babel/core@7.24.7) transitivePeerDependencies: - supports-color @@ -15470,33 +15582,33 @@ snapshots: '@babel/plugin-transform-unicode-escapes@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-unicode-property-regex@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 '@babel/helper-create-regexp-features-plugin': 7.24.7(@babel/core@7.24.7) - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-unicode-regex@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 '@babel/helper-create-regexp-features-plugin': 7.24.7(@babel/core@7.24.7) - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-transform-unicode-sets-regex@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 '@babel/helper-create-regexp-features-plugin': 7.24.7(@babel/core@7.24.7) - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/preset-env@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/compat-data': 7.24.7 '@babel/core': 7.24.7 '@babel/helper-compilation-targets': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 - '@babel/helper-validator-option': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-validator-option': 7.24.8 '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.24.7(@babel/core@7.24.7) '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.24.7(@babel/core@7.24.7) '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.24.7(@babel/core@7.24.7) @@ -15552,7 +15664,7 @@ snapshots: '@babel/plugin-transform-object-rest-spread': 7.24.7(@babel/core@7.24.7) '@babel/plugin-transform-object-super': 7.24.7(@babel/core@7.24.7) '@babel/plugin-transform-optional-catch-binding': 7.24.7(@babel/core@7.24.7) - '@babel/plugin-transform-optional-chaining': 7.24.7(@babel/core@7.24.7) + '@babel/plugin-transform-optional-chaining': 7.24.8(@babel/core@7.24.7) '@babel/plugin-transform-parameters': 7.24.7(@babel/core@7.24.7) '@babel/plugin-transform-private-methods': 7.24.7(@babel/core@7.24.7) '@babel/plugin-transform-private-property-in-object': 7.24.7(@babel/core@7.24.7) @@ -15580,22 +15692,22 @@ snapshots: '@babel/preset-flow@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 - '@babel/helper-validator-option': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-validator-option': 7.24.8 '@babel/plugin-transform-flow-strip-types': 7.24.7(@babel/core@7.24.7) '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 - '@babel/types': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/types': 7.24.9 esutils: 2.0.3 '@babel/preset-react@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 - '@babel/helper-validator-option': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-validator-option': 7.24.8 '@babel/plugin-transform-react-display-name': 7.24.7(@babel/core@7.24.7) '@babel/plugin-transform-react-jsx': 7.24.7(@babel/core@7.24.7) '@babel/plugin-transform-react-jsx-development': 7.24.7(@babel/core@7.24.7) @@ -15606,8 +15718,8 @@ snapshots: '@babel/preset-typescript@7.24.7(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 - '@babel/helper-validator-option': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-validator-option': 7.24.8 '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.24.7) '@babel/plugin-transform-modules-commonjs': 7.24.7(@babel/core@7.24.7) '@babel/plugin-transform-typescript': 7.24.7(@babel/core@7.24.7) @@ -15632,27 +15744,27 @@ snapshots: '@babel/template@7.24.7': dependencies: '@babel/code-frame': 7.24.7 - '@babel/parser': 7.24.7 - '@babel/types': 7.24.7 + '@babel/parser': 7.24.8 + '@babel/types': 7.24.9 - '@babel/traverse@7.24.7': + '@babel/traverse@7.24.8': dependencies: '@babel/code-frame': 7.24.7 - '@babel/generator': 7.24.7 + '@babel/generator': 7.24.10 '@babel/helper-environment-visitor': 7.24.7 '@babel/helper-function-name': 7.24.7 '@babel/helper-hoist-variables': 7.24.7 '@babel/helper-split-export-declaration': 7.24.7 - '@babel/parser': 7.24.7 - '@babel/types': 7.24.7 + '@babel/parser': 7.24.8 + '@babel/types': 7.24.9 debug: 4.3.4 globals: 11.12.0 transitivePeerDependencies: - supports-color - '@babel/types@7.24.7': + '@babel/types@7.24.9': dependencies: - '@babel/helper-string-parser': 7.24.7 + '@babel/helper-string-parser': 7.24.8 '@babel/helper-validator-identifier': 7.24.7 to-fast-properties: 2.0.0 @@ -16926,6 +17038,14 @@ snapshots: optionalDependencies: rollup: 2.79.1 + '@rollup/plugin-inject@5.0.5(rollup@2.79.1)': + dependencies: + '@rollup/pluginutils': 5.1.0(rollup@2.79.1) + estree-walker: 2.0.2 + magic-string: 0.30.10 + optionalDependencies: + rollup: 2.79.1 + '@rollup/plugin-json@4.1.0(rollup@2.79.1)': dependencies: '@rollup/pluginutils': 3.1.0(rollup@2.79.1) @@ -17416,7 +17536,7 @@ snapshots: '@storybook/cli@8.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/core': 7.24.7 - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 '@ndelangen/get-tarball': 3.0.9 '@storybook/codemod': 8.1.6 '@storybook/core-common': 8.1.6(prettier@3.3.2) @@ -17472,7 +17592,7 @@ snapshots: dependencies: '@babel/core': 7.24.7 '@babel/preset-env': 7.24.7(@babel/core@7.24.7) - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 '@storybook/csf': 0.1.11 '@storybook/csf-tools': 8.1.6 '@storybook/node-logger': 8.1.6 @@ -17700,7 +17820,7 @@ snapshots: dependencies: '@aw-web-design/x-default-browser': 1.4.126 '@babel/core': 7.24.7 - '@babel/parser': 7.24.7 + '@babel/parser': 7.24.8 '@discoveryjs/json-ext': 0.5.7 '@storybook/builder-manager': 8.1.6(prettier@3.3.2) '@storybook/channels': 8.1.6 @@ -17773,10 +17893,10 @@ snapshots: '@storybook/csf-tools@8.1.11': dependencies: - '@babel/generator': 7.24.7 - '@babel/parser': 7.24.7 - '@babel/traverse': 7.24.7 - '@babel/types': 7.24.7 + '@babel/generator': 7.24.10 + '@babel/parser': 7.24.8 + '@babel/traverse': 7.24.8 + '@babel/types': 7.24.9 '@storybook/csf': 0.1.11 '@storybook/types': 8.1.11 fs-extra: 11.2.0 @@ -17787,10 +17907,10 @@ snapshots: '@storybook/csf-tools@8.1.6': dependencies: - '@babel/generator': 7.24.7 - '@babel/parser': 7.24.7 - '@babel/traverse': 7.24.7 - '@babel/types': 7.24.7 + '@babel/generator': 7.24.10 + '@babel/parser': 7.24.8 + '@babel/traverse': 7.24.8 + '@babel/types': 7.24.9 '@storybook/csf': 0.1.11 '@storybook/types': 8.1.6 fs-extra: 11.2.0 @@ -18054,9 +18174,9 @@ snapshots: '@storybook/test-runner@0.18.1': dependencies: '@babel/core': 7.24.7 - '@babel/generator': 7.24.7 + '@babel/generator': 7.24.10 '@babel/template': 7.24.7 - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 '@jest/types': 29.6.3 '@storybook/core-common': 8.1.11 '@storybook/csf': 0.1.11 @@ -18164,7 +18284,7 @@ snapshots: '@svgr/hast-util-to-babel-ast@7.0.0': dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 entities: 4.5.0 '@svgr/plugin-jsx@7.0.0': @@ -18382,30 +18502,35 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.24.7 - '@babel/types': 7.24.7 + '@babel/parser': 7.24.8 + '@babel/types': 7.24.9 '@types/babel__generator': 7.6.8 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.20.6 '@types/babel__generator@7.6.8': dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.24.7 - '@babel/types': 7.24.7 + '@babel/parser': 7.24.8 + '@babel/types': 7.24.9 '@types/babel__traverse@7.20.6': dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 '@types/body-parser@1.19.5': dependencies: '@types/connect': 3.4.38 '@types/node': 20.14.10 + '@types/clean-css@4.2.5': + dependencies: + '@types/node': 20.14.10 + source-map: 0.6.1 + '@types/connect@3.4.38': dependencies: '@types/node': 20.14.10 @@ -18416,6 +18541,8 @@ snapshots: dependencies: '@types/node': 20.14.10 + '@types/css-tree@2.0.1': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 0.7.34 @@ -18742,7 +18869,7 @@ snapshots: graphemer: 1.4.0 ignore: 5.3.1 natural-compare: 1.4.0 - semver: 7.6.2 + semver: 7.6.3 ts-api-utils: 1.3.0(typescript@5.0.4) optionalDependencies: typescript: 5.0.4 @@ -18810,7 +18937,7 @@ snapshots: globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 - semver: 7.6.2 + semver: 7.6.3 ts-api-utils: 1.3.0(typescript@5.0.4) optionalDependencies: typescript: 5.0.4 @@ -18841,7 +18968,7 @@ snapshots: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.0.4) eslint: 8.57.0 - semver: 7.6.2 + semver: 7.6.3 transitivePeerDependencies: - supports-color - typescript @@ -20772,7 +20899,7 @@ snapshots: babel-plugin-istanbul@6.1.1: dependencies: - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-instrument: 5.2.1 @@ -20783,7 +20910,7 @@ snapshots: babel-plugin-jest-hoist@29.6.3: dependencies: '@babel/template': 7.24.7 - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.20.6 @@ -21174,7 +21301,7 @@ snapshots: cjs-module-lexer@1.3.1: {} - clean-css@5.3.3: + clean-css@5.3.1: dependencies: source-map: 0.6.1 @@ -21514,7 +21641,7 @@ snapshots: postcss-modules-scope: 3.2.0(postcss@8.4.39) postcss-modules-values: 4.0.0(postcss@8.4.39) postcss-value-parser: 4.2.0 - semver: 7.6.2 + semver: 7.6.3 optionalDependencies: webpack: 5.76.0(webpack-cli@4.9.1) @@ -22210,7 +22337,7 @@ snapshots: eslint-compat-utils@0.5.1(eslint@8.57.0): dependencies: eslint: 8.57.0 - semver: 7.6.2 + semver: 7.6.3 eslint-config-prettier@9.1.0(eslint@8.57.0): dependencies: @@ -22301,7 +22428,7 @@ snapshots: eslint: 8.57.0 esquery: 1.5.0 is-builtin-module: 3.2.1 - semver: 7.6.2 + semver: 7.6.3 spdx-expression-parse: 4.0.0 transitivePeerDependencies: - supports-color @@ -22385,7 +22512,7 @@ snapshots: postcss-load-config: 3.1.4(postcss@8.4.39) postcss-safe-parser: 6.0.0(postcss@8.4.39) postcss-selector-parser: 6.1.0 - semver: 7.6.2 + semver: 7.6.3 svelte-eslint-parser: 0.39.2(svelte@4.2.18) optionalDependencies: svelte: 4.2.18 @@ -23202,7 +23329,7 @@ snapshots: html-minifier-terser@6.1.0: dependencies: camel-case: 4.1.2 - clean-css: 5.3.3 + clean-css: 5.3.1 commander: 8.3.0 he: 1.2.0 param-case: 3.0.4 @@ -23587,7 +23714,7 @@ snapshots: istanbul-lib-instrument@5.2.1: dependencies: '@babel/core': 7.24.7 - '@babel/parser': 7.24.7 + '@babel/parser': 7.24.8 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 6.3.1 @@ -23597,10 +23724,10 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.24.7 - '@babel/parser': 7.24.7 + '@babel/parser': 7.24.8 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 - semver: 7.6.2 + semver: 7.6.3 transitivePeerDependencies: - supports-color @@ -24003,10 +24130,10 @@ snapshots: jest-snapshot@29.7.0: dependencies: '@babel/core': 7.24.7 - '@babel/generator': 7.24.7 + '@babel/generator': 7.24.10 '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.24.7) '@babel/plugin-syntax-typescript': 7.24.7(@babel/core@7.24.7) - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 '@jest/expect-utils': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 @@ -24021,7 +24148,7 @@ snapshots: jest-util: 29.7.0 natural-compare: 1.4.0 pretty-format: 29.7.0 - semver: 7.6.2 + semver: 7.6.3 transitivePeerDependencies: - supports-color @@ -24104,7 +24231,7 @@ snapshots: jetpack-boost-critical-css-gen@https://codeload.github.com/automattic/jetpack-boost-critical-css-gen/tar.gz/56adf5a550475fd30962cd4e8f8bfcaf71f84177: dependencies: - clean-css: 5.3.3 + clean-css: 5.3.1 css-tree: 2.3.1 install: 0.13.0 npm: 8.19.4 @@ -24139,11 +24266,11 @@ snapshots: jscodeshift@0.15.2: dependencies: '@babel/core': 7.24.7 - '@babel/parser': 7.24.7 + '@babel/parser': 7.24.8 '@babel/plugin-transform-class-properties': 7.24.7(@babel/core@7.24.7) '@babel/plugin-transform-modules-commonjs': 7.24.7(@babel/core@7.24.7) '@babel/plugin-transform-nullish-coalescing-operator': 7.24.7(@babel/core@7.24.7) - '@babel/plugin-transform-optional-chaining': 7.24.7(@babel/core@7.24.7) + '@babel/plugin-transform-optional-chaining': 7.24.8(@babel/core@7.24.7) '@babel/plugin-transform-private-methods': 7.24.7(@babel/core@7.24.7) '@babel/preset-flow': 7.24.7(@babel/core@7.24.7) '@babel/preset-typescript': 7.24.7(@babel/core@7.24.7) @@ -24164,11 +24291,11 @@ snapshots: jscodeshift@0.15.2(@babel/preset-env@7.24.7(@babel/core@7.24.7)): dependencies: '@babel/core': 7.24.7 - '@babel/parser': 7.24.7 + '@babel/parser': 7.24.8 '@babel/plugin-transform-class-properties': 7.24.7(@babel/core@7.24.7) '@babel/plugin-transform-modules-commonjs': 7.24.7(@babel/core@7.24.7) '@babel/plugin-transform-nullish-coalescing-operator': 7.24.7(@babel/core@7.24.7) - '@babel/plugin-transform-optional-chaining': 7.24.7(@babel/core@7.24.7) + '@babel/plugin-transform-optional-chaining': 7.24.8(@babel/core@7.24.7) '@babel/plugin-transform-private-methods': 7.24.7(@babel/core@7.24.7) '@babel/preset-flow': 7.24.7(@babel/core@7.24.7) '@babel/preset-typescript': 7.24.7(@babel/core@7.24.7) @@ -24525,7 +24652,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.6.2 + semver: 7.6.3 make-error@1.3.6: {} @@ -25918,8 +26045,8 @@ snapshots: react-docgen@7.0.3: dependencies: '@babel/core': 7.24.7 - '@babel/traverse': 7.24.7 - '@babel/types': 7.24.7 + '@babel/traverse': 7.24.8 + '@babel/types': 7.24.9 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.20.6 '@types/doctrine': 0.0.9 @@ -26440,6 +26567,11 @@ snapshots: - bufferutil - utf-8-validate + rollup-plugin-polyfill-node@0.13.0(rollup@2.79.1): + dependencies: + '@rollup/plugin-inject': 5.0.5(rollup@2.79.1) + rollup: 2.79.1 + rollup-plugin-postcss@4.0.2(postcss@8.4.31): dependencies: chalk: 4.1.2 @@ -26579,7 +26711,7 @@ snapshots: dependencies: lru-cache: 6.0.0 - semver@7.6.2: {} + semver@7.6.3: {} send@0.18.0: dependencies: @@ -26758,6 +26890,8 @@ snapshots: source-map@0.6.1: {} + source-map@0.7.4: {} + space-separated-tokens@2.0.2: {} spawn-command@0.0.2-1: {} @@ -27199,6 +27333,15 @@ snapshots: terser: 5.31.1 webpack: 5.76.0(webpack-cli@4.9.1) + terser-webpack-plugin@5.3.3(webpack@5.76.0): + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + jest-worker: 27.5.1 + schema-utils: 3.3.0 + serialize-javascript: 6.0.2 + terser: 5.31.1 + webpack: 5.76.0 + terser@5.31.1: dependencies: '@jridgewell/source-map': 0.3.6 @@ -27296,7 +27439,7 @@ snapshots: json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 - semver: 7.6.2 + semver: 7.6.3 typescript: 5.0.4 yargs-parser: 21.1.1 @@ -27701,6 +27844,15 @@ snapshots: webpack: 5.76.0(webpack-cli@4.9.1) webpack-merge: 5.10.0 + webpack-dev-middleware@5.3.4(webpack@5.76.0): + dependencies: + colorette: 2.0.20 + memfs: 3.5.3 + mime-types: 2.1.35 + range-parser: 1.2.1 + schema-utils: 4.2.0 + webpack: 5.76.0 + webpack-dev-middleware@6.1.3(webpack@5.76.0(webpack-cli@4.9.1)): dependencies: colorette: 2.0.20 @@ -27734,6 +27886,37 @@ snapshots: webpack-virtual-modules@0.6.2: {} + webpack@5.76.0: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 0.0.51 + '@webassemblyjs/ast': 1.11.1 + '@webassemblyjs/wasm-edit': 1.11.1 + '@webassemblyjs/wasm-parser': 1.11.1 + acorn: 8.12.1 + acorn-import-assertions: 1.9.0(acorn@8.12.1) + browserslist: 4.23.1 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.17.0 + es-module-lexer: 0.9.3 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.3(webpack@5.76.0) + watchpack: 2.4.1 + webpack-sources: 3.2.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + webpack@5.76.0(webpack-cli@4.9.1): dependencies: '@types/eslint-scope': 3.7.7 diff --git a/projects/js-packages/critical-css-gen/.gitattributes b/projects/js-packages/critical-css-gen/.gitattributes new file mode 100644 index 0000000000000..f81461551988a --- /dev/null +++ b/projects/js-packages/critical-css-gen/.gitattributes @@ -0,0 +1,15 @@ +# Files not needed to be distributed in the package. +.gitattributes export-ignore +node_modules export-ignore + +# Files to include in the mirror repo +/build-browser/** production-include +/build-node/** production-include + +# Files to exclude from the mirror repo +/changelog/** production-exclude +/src/** production-exclude +/tests/** production-exclude +/rollup.config.js production-exclude +/jest.config.cjs production-exclude +/tsconfig.browser.json production-exclude diff --git a/projects/js-packages/critical-css-gen/.gitignore b/projects/js-packages/critical-css-gen/.gitignore new file mode 100644 index 0000000000000..3c55ef1574d22 --- /dev/null +++ b/projects/js-packages/critical-css-gen/.gitignore @@ -0,0 +1,4 @@ +/vendor +/node_modules +/build-node +/build-browser diff --git a/projects/js-packages/critical-css-gen/CHANGELOG.md b/projects/js-packages/critical-css-gen/CHANGELOG.md new file mode 100644 index 0000000000000..721294abd00ad --- /dev/null +++ b/projects/js-packages/critical-css-gen/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + diff --git a/projects/js-packages/critical-css-gen/README.md b/projects/js-packages/critical-css-gen/README.md new file mode 100644 index 0000000000000..aea81013b26e3 --- /dev/null +++ b/projects/js-packages/critical-css-gen/README.md @@ -0,0 +1,24 @@ +# critical-css-gen + +A flexible Critical CSS Generator that supports multiple URLs and viewports, with both server-side and client-side generation capabilities. + +## How to install critical-css-gen + +### Installation From Git Repo + +## Contribute + +## Get Help + +## Using this package in your WordPress plugin + +If you plan on using this package in your WordPress plugin, we would recommend that you use [Jetpack Autoloader](https://packagist.org/packages/automattic/jetpack-autoloader) as your autoloader. This will allow for maximum interoperability with other plugins that use this package as well. + +## Security + +Need to report a security vulnerability? Go to [https://automattic.com/security/](https://automattic.com/security/) or directly to our security bug bounty site [https://hackerone.com/automattic](https://hackerone.com/automattic). + +## License + +critical-css-gen is licensed under [GNU General Public License v2 (or later)](./LICENSE.txt) + diff --git a/projects/js-packages/critical-css-gen/changelog/.gitkeep b/projects/js-packages/critical-css-gen/changelog/.gitkeep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/projects/js-packages/critical-css-gen/changelog/add-js-package-critical-css-gen b/projects/js-packages/critical-css-gen/changelog/add-js-package-critical-css-gen new file mode 100644 index 0000000000000..fc2b9d31809d5 --- /dev/null +++ b/projects/js-packages/critical-css-gen/changelog/add-js-package-critical-css-gen @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add package files. diff --git a/projects/js-packages/critical-css-gen/changelog/initial-version b/projects/js-packages/critical-css-gen/changelog/initial-version new file mode 100644 index 0000000000000..fb1837c901e51 --- /dev/null +++ b/projects/js-packages/critical-css-gen/changelog/initial-version @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Initial version. diff --git a/projects/js-packages/critical-css-gen/composer.json b/projects/js-packages/critical-css-gen/composer.json new file mode 100644 index 0000000000000..ffd91b86a9f81 --- /dev/null +++ b/projects/js-packages/critical-css-gen/composer.json @@ -0,0 +1,44 @@ +{ + "name": "automattic/jetpack-critical-css-gen", + "description": "A flexible Critical CSS Generator that supports multiple URLs and viewports, with both server-side and client-side generation capabilities.", + "type": "library", + "license": "GPL-2.0-or-later", + "require": {}, + "require-dev": { + "automattic/jetpack-changelogger": "@dev" + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "scripts": { + "build-development": [ + "pnpm run build" + ], + "build-production": [ + "NODE_ENV=production pnpm run build" + ], + "test-js": [ + "pnpm exec playwright install && pnpm run test" + ] + }, + "repositories": [ + { + "type": "path", + "url": "../../packages/*", + "options": { + "monorepo": true + } + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "autotagger": true, + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-critical-css-gen/compare/v${old}...v${new}" + }, + "mirror-repo": "Automattic/jetpack-critical-css-gen" + } +} diff --git a/projects/js-packages/critical-css-gen/jest.config.cjs b/projects/js-packages/critical-css-gen/jest.config.cjs new file mode 100644 index 0000000000000..1b43920c97941 --- /dev/null +++ b/projects/js-packages/critical-css-gen/jest.config.cjs @@ -0,0 +1,5 @@ +const baseConfig = require( 'jetpack-js-tools/jest/config.base.js' ); + +module.exports = { + ...baseConfig, +}; diff --git a/projects/js-packages/critical-css-gen/package.json b/projects/js-packages/critical-css-gen/package.json new file mode 100644 index 0000000000000..0f157849498a0 --- /dev/null +++ b/projects/js-packages/critical-css-gen/package.json @@ -0,0 +1,78 @@ +{ + "private": true, + "type": "module", + "name": "@automattic/jetpack-critical-css-gen", + "version": "0.1.0-alpha", + "description": "A flexible Critical CSS Generator that supports multiple URLs and viewports, with both server-side and client-side generation capabilities.", + "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/critical-css-gen/#readme", + "bugs": { + "url": "https://github.com/Automattic/jetpack/labels/[JS Package] Critical Css Gen" + }, + "repository": { + "type": "git", + "url": "https://github.com/Automattic/jetpack.git", + "directory": "projects/js-packages/critical-css-gen" + }, + "license": "GPL-2.0-or-later", + "author": "Automattic", + "scripts": { + "build:browser": "rollup -c", + "build:node": "tsc", + "build": "pnpm run clean && pnpm run build:browser && pnpm run build:node", + "clean": "rm -rf build-node/ && rm -rf build-browser/", + "test": "pnpm build && NODE_ENV=test NODE_PATH=./node_modules jest --forceExit --config=tests/config/jest.config.js" + }, + "main": "./build-node/node.js", + "browser": "./build-browser/bundle.js", + "devDependencies": { + "@babel/core": "7.24.7", + "@babel/preset-env": "7.24.7", + "@babel/preset-typescript": "7.24.7", + "@rollup/plugin-commonjs": "26.0.1", + "@rollup/plugin-json": "4.1.0", + "@rollup/plugin-node-resolve": "13.3.0", + "@rollup/plugin-terser": "0.4.3", + "@rollup/plugin-typescript": "8.3.3", + "@types/clean-css": "4.2.5", + "@types/css-tree": "2.0.1", + "@types/node": "^20.4.2", + "express": "4.19.2", + "jest": "29.7.0", + "playwright": "1.45.1", + "playwright-core": "^1.45.1", + "prettier": "npm:wp-prettier@3.0.3", + "rollup": "2.79.1", + "rollup-plugin-polyfill-node": "0.13.0", + "source-map": "0.7.4", + "source-map-js": "1.2.0", + "tslib": "2.5.0", + "typescript": "5.0.4", + "webpack": "5.76.0", + "webpack-dev-middleware": "5.3.4" + }, + "exports": { + ".": { + "jetpack:src": "./src/node.ts", + "types": "./build-node/node.d.ts", + "browser": "./build-browser/bundle.js", + "import": "./build-node/node.js", + "require": "./build-node/node.js", + "default": "./build-node/node.js" + } + }, + "dependencies": { + "clean-css": "^5.3.1", + "css-tree": "^2.3.1" + }, + "peerDependencies": { + "playwright-core": "^1.45.1" + }, + "peerDependenciesMeta": { + "playwright-core": { + "optional": true + } + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/projects/js-packages/critical-css-gen/rollup.config.js b/projects/js-packages/critical-css-gen/rollup.config.js new file mode 100644 index 0000000000000..25f20558cafd1 --- /dev/null +++ b/projects/js-packages/critical-css-gen/rollup.config.js @@ -0,0 +1,47 @@ +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import resolve from '@rollup/plugin-node-resolve'; +import terser from '@rollup/plugin-terser'; +import typescript from '@rollup/plugin-typescript'; +import nodePolyfills from 'rollup-plugin-polyfill-node'; + +const sharedPlugins = [ + resolve( { + browser: true, + preferBuiltins: false, + modulesOnly: false, + } ), + typescript( { + tsconfig: 'tsconfig.browser.json', + sourceMap: true, + inlineSources: false, + declaration: false, + } ), + commonjs(), + nodePolyfills(), + json(), +]; + +export default { + input: 'src/browser.ts', + output: [ + { + sourcemap: true, + format: 'iife', + name: 'CriticalCSSGenerator', + file: 'build-browser/bundle.full.js', + }, + { + sourcemap: true, + format: 'iife', + name: 'CriticalCSSGenerator', + file: 'build-browser/bundle.js', + plugins: [ terser() ], + }, + ], + plugins: sharedPlugins, + preserveSymlinks: true, + watch: { + clearScreen: false, + }, +}; diff --git a/projects/js-packages/critical-css-gen/src/browser-interface-iframe.ts b/projects/js-packages/critical-css-gen/src/browser-interface-iframe.ts new file mode 100644 index 0000000000000..083cf25184b78 --- /dev/null +++ b/projects/js-packages/critical-css-gen/src/browser-interface-iframe.ts @@ -0,0 +1,211 @@ +import { BrowserInterface, BrowserRunnable } from './browser-interface.js'; +import { + CrossDomainError, + HttpError, + LoadTimeoutError, + RedirectError, + UrlVerifyError, + UnknownError, + XFrameDenyError, + UrlError, +} from './errors.js'; +import { Viewport, NullableViewport } from './types.js'; + +const defaultLoadTimeout = 60 * 1000; + +type VerifyMethod = ( rawUrl: string, contentWindow: Window, contentDocument: Document ) => boolean; + +type BrowserInterfaceIframeOptions = { + requestGetParameters?: { [ key: string ]: string }; + loadTimeout?: number; + verifyPage: VerifyMethod; + allowScripts?: boolean; +}; + +export class BrowserInterfaceIframe extends BrowserInterface { + private requestGetParameters: { [ key: string ]: string }; + private loadTimeout: number; + private verifyPage: VerifyMethod; + + private currentUrl: string | null; + private currentSize: NullableViewport; + + private wrapperDiv: HTMLDivElement; + private iframe: HTMLIFrameElement; + + constructor( { + requestGetParameters, + loadTimeout, + verifyPage, + allowScripts, + }: BrowserInterfaceIframeOptions ) { + super(); + + this.requestGetParameters = requestGetParameters || {}; + this.loadTimeout = loadTimeout || defaultLoadTimeout; + this.verifyPage = verifyPage; + + // Default 'allowScripts' to true if not specified. + allowScripts = allowScripts !== false; + + this.currentUrl = null; + this.currentSize = { width: null, height: null }; + + // Create a wrapper div to keep the iframe invisible. + this.wrapperDiv = document.createElement( 'div' ); + this.wrapperDiv.setAttribute( + 'style', + 'position:fixed; z-index: -1000; opacity: 0; top: 50px;' + ); + document.body.append( this.wrapperDiv ); + + // Create iframe itself. + this.iframe = document.createElement( 'iframe' ); + this.iframe.setAttribute( 'style', 'max-width: none; max-height: none; border: 0px;' ); + this.iframe.setAttribute( 'aria-hidden', 'true' ); + this.iframe.setAttribute( + 'sandbox', + 'allow-same-origin ' + ( allowScripts ? 'allow-scripts' : '' ) + ); + this.wrapperDiv.append( this.iframe ); + } + + async cleanup() { + this.iframe.remove(); + this.wrapperDiv.remove(); + } + + async fetch( url: string, options: RequestInit, _role: 'css' | 'html' ) { + return window.fetch( url, options ); + } + + async runInPage< ReturnType >( + pageUrl: string, + viewport: Viewport | null, + method: BrowserRunnable< ReturnType >, + ...args: unknown[] + ): Promise< ReturnType > { + await this.loadPage( pageUrl ); + + if ( viewport ) { + await this.resize( viewport ); + } + + // The inner window in the iframe is separate from the main window object. + // Pass the iframe window object to the evaluating method. + return method( { innerWindow: this.iframe.contentWindow, args } ); + } + + addGetParameters( rawUrl: string ): string { + const urlObject = new URL( rawUrl ); + for ( const key of Object.keys( this.requestGetParameters ) ) { + urlObject.searchParams.append( key, this.requestGetParameters[ key ] ); + } + + return urlObject.toString(); + } + + async diagnoseUrlError( url: string ): Promise< UrlError | null > { + try { + const response = await this.fetch( url, { redirect: 'manual' }, 'html' ); + const headers = response.headers; + + if ( headers.get( 'x-frame-options' ) === 'DENY' ) { + return new XFrameDenyError( { url } ); + } + + if ( response.type === 'opaqueredirect' ) { + return new RedirectError( { + url, + redirectUrl: response.url, + } ); + } + + if ( response.status === 200 ) { + return null; + } + + return new HttpError( { url, code: response.status } ); + } catch ( err ) { + return new UnknownError( { url, message: err.message } ); + } + } + + sameOrigin( url: string ): boolean { + return new URL( url ).origin === window.location.origin; + } + + async loadPage( rawUrl: string ): Promise< void > { + if ( rawUrl === this.currentUrl ) { + return; + } + + const fullUrl = this.addGetParameters( rawUrl ); + + return new Promise( ( resolve, rawReject ) => { + // Track all URL errors. + const reject = err => { + this.trackUrlError( rawUrl, err ); + rawReject( err ); + }; + + // Catch cross-domain errors before they occur. + if ( ! this.sameOrigin( fullUrl ) ) { + reject( new CrossDomainError( { url: fullUrl } ) ); + return; + } + + // Set a timeout. + const timeoutId = setTimeout( () => { + this.iframe.onload = null; + reject( new LoadTimeoutError( { url: fullUrl } ) ); + }, this.loadTimeout ); + + // Catch load event. + this.iframe.onload = async () => { + try { + this.iframe.onload = null; + clearTimeout( timeoutId ); + + // Verify the inner document is readable. + if ( ! this.iframe.contentDocument || ! this.iframe.contentWindow ) { + throw ( + ( await this.diagnoseUrlError( fullUrl ) ) || new CrossDomainError( { url: fullUrl } ) + ); + } + + if ( + ! this.verifyPage( rawUrl, this.iframe.contentWindow, this.iframe.contentDocument ) + ) { + // Diagnose and throw an appropriate error. + throw ( + ( await this.diagnoseUrlError( fullUrl ) ) || new UrlVerifyError( { url: fullUrl } ) + ); + } + + this.currentUrl = rawUrl; + resolve(); + } catch ( err ) { + reject( err ); + } + }; + + this.iframe.src = fullUrl; + } ); + } + + async resize( { width, height }: Viewport ) { + if ( this.currentSize.width === width && this.currentSize.height === height ) { + return; + } + + return new Promise( resolve => { + // Set iframe size. + this.iframe.width = width.toString(); + this.iframe.height = height.toString(); + + // Bounce to browser main loop to allow resize to complete. + setTimeout( resolve, 1 ); + } ); + } +} diff --git a/projects/js-packages/critical-css-gen/src/browser-interface-playwright.ts b/projects/js-packages/critical-css-gen/src/browser-interface-playwright.ts new file mode 100644 index 0000000000000..8355608d7945e --- /dev/null +++ b/projects/js-packages/critical-css-gen/src/browser-interface-playwright.ts @@ -0,0 +1,123 @@ +import { BrowserContext, Page } from 'playwright-core'; +import { BrowserInterface, BrowserRunnable, FetchOptions } from './browser-interface.js'; +import { HttpError } from './errors.js'; +import { objectPromiseAll } from './object-promise-all.js'; +import { Viewport } from './types.js'; + +export type Tab = { page: Page; statusCode: number | null }; +export type TabsByUrl = { [ url: string ]: Tab }; + +const PAGE_GOTO_TIMEOUT_MS = 5 * 60 * 1000; + +export class BrowserInterfacePlaywright extends BrowserInterface { + private tabs: TabsByUrl; + + /** + * Creates a new BrowserInterfacePlaywright instance. + * + * @param {BrowserContext} context - The playwright browser context to work with. + * @param {string[]} urls - Array of urls to evaluate. The reason we are taking this as an argument is because we want to load all of them in parallel. + */ + constructor( + private context: BrowserContext, + private urls: string[] + ) { + super(); + } + + private async getTabs() { + if ( typeof this.tabs === 'undefined' ) { + await this.openUrls( this.context, this.urls ); + } + + return this.tabs; + } + + /** + * Open an array of urls in a new browser context. + * + * Take a browser instance and an array of urls to open in new tabs. + * + * @param {BrowserContext} context - Browser context to use. + * @param {string[]} urls - Array of urls to open. + * @returns {Promise< TabsByUrl >} Promise resolving to the browser context. + */ + private async openUrls( context: BrowserContext, urls: string[] ): Promise< void > { + this.tabs = await objectPromiseAll< Tab >( + urls.reduce( ( set, url ) => { + set[ url ] = this.newTab( context, url ); + return set; + }, {} ) + ); + } + + /** + * Open url in a new tab in a given browserContext. + * + * @param {BrowserContext} browserContext - Browser context to use. + * @param {string} url - Url to open. + * @returns {Promise} Promise resolving to the page instance. + */ + private async newTab( browserContext: BrowserContext, url: string ): Promise< Tab > { + const tab = { + page: await browserContext.newPage(), + statusCode: null, + }; + tab.page.on( 'response', async response => { + if ( response.url() === url ) { + tab.statusCode = response.status(); + } + } ); + + await tab.page.goto( url, { timeout: PAGE_GOTO_TIMEOUT_MS } ); + + return tab; + } + + async runInPage< ReturnType >( + pageUrl: string, + viewport: Viewport | null, + method: BrowserRunnable< ReturnType >, + ...args: unknown[] + ): Promise< ReturnType > { + const tabs = await this.getTabs(); + const tab = tabs[ pageUrl ]; + + if ( ! tab || ! tab.page ) { + throw new Error( `Playwright interface does not include URL ${ pageUrl }` ); + } + + // Bail early if the page returned a non-200 status code. + if ( ! tab.statusCode || ! this.isOkStatus( tab.statusCode ) ) { + const error = new HttpError( { url: pageUrl, code: tab.statusCode } ); + this.trackUrlError( pageUrl, error ); + throw error; + } + + if ( viewport ) { + await tab.page.setViewportSize( viewport ); + } + + // The inner window in Playwright is the directly accessible main window object. + // The evaluating method does not need a separate window object. + // Call inner method within the Playwright context. + return tab.page.evaluate( method, { innerWindow: null, args } ); + } + + /** + * Replacement for browser.fetch, uses node's fetch to simulate the same + * interface. + * + * @param {string} url - URL to fetch. + * @param {object} options - Fetch options. + * @param {string} _role - 'css' or 'html' indicating what kind of thing is being fetched. + * @returns {Promise} A promise that resolves to the fetch response. + */ + async fetch( url: string, options: FetchOptions, _role: 'css' | 'html' ) { + return fetch( url, options ); + } + + private isOkStatus( statusCode: number ) { + return statusCode >= 200 && statusCode < 300; + } +} diff --git a/projects/js-packages/critical-css-gen/src/browser-interface.ts b/projects/js-packages/critical-css-gen/src/browser-interface.ts new file mode 100644 index 0000000000000..a8393827258b2 --- /dev/null +++ b/projects/js-packages/critical-css-gen/src/browser-interface.ts @@ -0,0 +1,169 @@ +import type { Viewport } from './types.js'; + +export type BrowserRunnable< ReturnType > = ( arg: unknown ) => ReturnType; + +// Wrappers around parts of fetch that we rely on, to allow multiple stand-in implementations. +export interface FetchOptions { + method?: 'POST' | 'GET'; +} +export interface FetchResponse { + ok: boolean; + status: number; + text: () => Promise< string >; +} + +export class BrowserInterface { + private urlErrors: { [ url: string ]: Error }; + + constructor() { + this.urlErrors = {}; + } + + trackUrlError( url: string, error: Error ) { + this.urlErrors[ url ] = error; + } + + filterValidUrls( urls: string[] ): string[] { + return urls.filter( url => ! this.urlErrors[ url ] ); + } + + async runInPage< ReturnType >( + _pageUrl: string, + _viewport: Viewport | null, + _method: BrowserRunnable< ReturnType >, + ..._args: unknown[] + ): Promise< ReturnType > { + throw new Error( 'Undefined interface method: BrowserInterface.runInPage()' ); + } + + /** + * Context-specific wrapper for fetch; uses window.fetch in browsers, or a + * node library when using Puppeteer. + * + * @param {string} _url - The URL to fetch + * @param {FetchOptions} _options - Fetch options + * @param {'css' | 'html'} _role - Role of the fetch operation + */ + async fetch( + _url: string, + _options: FetchOptions, + _role: 'css' | 'html' + ): Promise< FetchResponse > { + throw new Error( 'Undefined interface method: BrowserInterface.fetch()' ); + } + + async cleanup() { + // No-op. + } + + async getCssIncludes( pageUrl: string ): Promise< { [ url: string ]: { media: string } } > { + return await this.runInPage( pageUrl, null, BrowserInterface.innerGetCssIncludes ); + } + + static innerGetCssIncludes( { innerWindow } ) { + innerWindow = null === innerWindow ? window : innerWindow; + return [ ...innerWindow.document.getElementsByTagName( 'link' ) ] + .filter( link => link.rel === 'stylesheet' ) + .reduce( ( set, link ) => { + set[ link.href ] = { + media: link.media || null, + }; + + return set; + }, {} ); + } + + async getInternalStyles( pageUrl: string ): Promise< string > { + return await this.runInPage( pageUrl, null, BrowserInterface.innerGetInternalStyles ); + } + + /** + * Get all internal styles as a combined string from the window. + * + * @param {object} wrappedArgs - Object containing the inner window. + * @param {Window} wrappedArgs.innerWindow - Window inside the browser interface. + * @returns {string} Combined internal styles as a string. + */ + static innerGetInternalStyles( { innerWindow } ): string { + innerWindow = null === innerWindow ? window : innerWindow; + const styleElements = Array.from( innerWindow.document.getElementsByTagName( 'style' ) ); + + return styleElements.reduce( ( styles: string, style: HTMLStyleElement ) => { + styles += style.innerText; + return styles; + }, '' ) as string; + } + + /** + * Given a set of CSS selectors (as object keys), along with "simplified" versions + * for easy querySelector calling (values), return an array of selectors which match + * _any_ element on the page. + * + * @param {object} wrappedArgs - Object containing the inner window and arguments. + * @param {Window} wrappedArgs.innerWindow - Window inside the browser interface. + * @param {Object[]} wrappedArgs.args - Array of arguments. + * {Object} wrappedArgs.args[selectors] - Map containing selectors (object keys), and simplified versions for easy matching (values). + * @returns {string[]} Array of selectors matching above-the-fold elements. + */ + public static innerFindMatchingSelectors( { innerWindow, args: [ selectors ] } ) { + innerWindow = null === innerWindow ? window : innerWindow; + return Object.keys( selectors ).filter( selector => { + try { + return !! innerWindow.document.querySelector( selectors[ selector ] ); + } catch ( err ) { + // Ignore invalid selectors. + return false; + } + } ); + } + + /** + * Given a set of CSS selectors (as object keys), along with "simplified" versions + * for easy querySelector calling (values), return an array of selectors which match + * any above-the-fold element on the page. + * + * @param {object} wrappedArgs - Object containing the inner window and arguments. + * @param {Window} wrappedArgs.innerWindow - Window inside the browser interface. + * @param {Object[]} wrappedArgs.args - Array of arguments. + * {Object} wrappedArgs.args[selectors] - Map containing selectors (object keys), and simplified versions for easy matching (values). + * {string[]} wrappedArgs.args[pageSelectors] - String array containing selectors that appear anywhere on this page (as returned by innerFindMatchingSelectors) - should be a subset of keys in selectors. + * @returns {string[]} Array of selectors matching above-the-fold elements. + */ + public static innerFindAboveFoldSelectors( { + innerWindow, + args: [ selectors, pageSelectors ], + } ): string[] { + /** + * Inner helper function used inside browser / iframe to check if the given + * element is "above the fold". + * + * @param {HTMLElement} element - Element to check. + */ + innerWindow = null === innerWindow ? window : innerWindow; + const isAboveFold = element => { + const originalClearStyle = element.style.clear || ''; + element.style.clear = 'none'; + + const rect = element.getBoundingClientRect(); + + element.style.clear = originalClearStyle; + + return rect.top < innerWindow.innerHeight; + }; + + return pageSelectors.filter( s => { + if ( '*' === selectors[ s ] ) { + return true; + } + + const matches = innerWindow.document.querySelectorAll( selectors[ s ] ); + for ( const match of matches ) { + if ( isAboveFold( match ) ) { + return true; + } + } + + return false; + } ); + } +} diff --git a/projects/js-packages/critical-css-gen/src/browser.ts b/projects/js-packages/critical-css-gen/src/browser.ts new file mode 100644 index 0000000000000..3aec50088d942 --- /dev/null +++ b/projects/js-packages/critical-css-gen/src/browser.ts @@ -0,0 +1,7 @@ +export { BrowserInterfaceIframe } from './browser-interface-iframe.js'; +export { BrowserInterface } from './browser-interface.js'; +export { generateCriticalCSS } from './generate-critical-css.js'; + +export * from './errors.js'; + +export const version = '0.0.11'; diff --git a/projects/js-packages/critical-css-gen/src/css-file-set.ts b/projects/js-packages/critical-css-gen/src/css-file-set.ts new file mode 100644 index 0000000000000..924a32ef70600 --- /dev/null +++ b/projects/js-packages/critical-css-gen/src/css-file-set.ts @@ -0,0 +1,264 @@ +import { BrowserInterface } from './browser-interface.js'; +import { HttpError, UnknownError, UrlError } from './errors.js'; +import { StyleAST } from './style-ast.js'; +import { FilterSpec } from './types.js'; + +// Maximum number of iterations when pruning unused variables. +const maxVarPruneIterations = 10; + +type CSSFile = { + css: string; + ast: StyleAST; + pages: string[]; + urls: string[]; +}; + +/** + * Represents a set of CSS files found on one or more HTML page. Automatically de-duplicates + * CSS files by URL and by content, and parses each into an Abstract Syntax Tree. Also tracks + * all errors that occur while loading or parsing CSS. + */ +export class CSSFileSet { + private knownUrls: { [ url: string ]: CSSFile | Error }; + private cssFiles: CSSFile[]; + private errors: Error[]; + private internalStyles: { [ url: string ]: StyleAST } = {}; + + constructor( private browserInterface: BrowserInterface ) { + this.knownUrls = {}; + this.cssFiles = []; + this.errors = []; + } + + /** + * Add a set of CSS URLs from an HTML page to this set. + * + * @param {string} page - URL of the page the CSS URLs were found on. + * @param {object} cssIncludes - Included CSS Files. Keyed by URL. + */ + async addMultiple( page: string, cssIncludes: { [ url: string ]: { media: string } } ) { + await Promise.all( + Object.keys( cssIncludes ).map( url => this.add( page, url, cssIncludes[ url ] ) ) + ); + } + + async addInternalStyles( page: string, internalStyles: string ) { + this.internalStyles[ page ] = StyleAST.parse( internalStyles ); + } + + /** + * Add a CSS URL from an HTML page to this set. + * + * @param {string} page - URL of the page the CSS URL was found on. + * @param {string} cssUrl - The CSS file URL. + * @param {object} settings - Additional settings for the CSS file. + */ + async add( + page: string, + cssUrl: string, + settings: { [ url: string ]: string } = {} + ): Promise< void > { + // Add by reference if we already know this file. + if ( Object.prototype.hasOwnProperty.call( this.knownUrls, cssUrl ) ) { + if ( this.knownUrls[ cssUrl ] instanceof Error ) { + // We already know this URL failed. Bail early. + return; + } + + this.addExtraReference( page, cssUrl, this.knownUrls[ cssUrl ] as CSSFile ); + return; + } + + // Try to load this URL. + try { + const response = await this.browserInterface.fetch( cssUrl, {}, 'css' ); + if ( ! response.ok ) { + throw new HttpError( { code: response.status, url: cssUrl } ); + } + + let css = await response.text(); + + // If there is an implied media query from the css's tag, wrap the CSS in it. + if ( settings.media ) { + css = '@media ' + settings.media + ' {\n' + css + '\n}'; + } + + this.storeCss( page, cssUrl, css ); + } catch ( err ) { + let wrappedError = err; + + // Wrap any unfamiliar fetch errors in an unknown error. + if ( ! ( err instanceof UrlError ) ) { + wrappedError = new UnknownError( { + url: cssUrl, + message: err.message, + } ); + } + + this.storeError( cssUrl, wrappedError ); + } + } + + /** + * Collates an object describing the selectors found in the CSS files in this set, and which + * HTML page URLs include them (via CSS files) + * + * @returns {object} - An object with selector text keys, each containing a Set of page URLs (strings) + */ + collateSelectorPages(): { [ selector: string ]: Set< string > } { + const selectors = {}; + + for ( const file of this.cssFiles ) { + file.ast.forEachSelector( selector => { + if ( ! selectors[ selector ] ) { + selectors[ selector ] = new Set(); + } + + file.pages.forEach( pageUrl => selectors[ selector ].add( pageUrl ) ); + } ); + } + + return selectors; + } + + /** + * Applies filters to the properties or atRules in each AST in this set of CSS files. + * Mutates each AST in-place. + * + * @param {FilterSpec} filters - Object containing property and atRule filter functions. + */ + applyFilters( filters: FilterSpec ): void { + for ( const file of this.cssFiles ) { + file.ast.applyFilters( filters ); + } + } + + /** + * Returns a new AST which is pruned appropriately for the specified contentWindow, and the + * set of selectors that are worth keeping. (i.e.: appear above the fold). + * + * @param {Set} usefulSelectors - Set of selectors to keep. + * @returns {StyleAST[]} Array of pruned StyleAST objects. + */ + prunedAsts( usefulSelectors: Set< string > ): StyleAST[] { + // Perform basic pruning. + let asts = this.cssFiles.map( file => { + return file.ast.pruned( usefulSelectors ); + } ); + + const internallyUsedVariables = new Set< string >(); + Object.values( this.internalStyles ).reduce( ( set, ast ) => { + ast.getUsedVariables().forEach( v => set.add( v ) ); + return set; + }, internallyUsedVariables ); + + // Repeatedly prune unused variables (up to maxVarPruneIterations), to catch vars which are + // only used to define other vars which aren't used. + let prevUsedVariables; + for ( let i = 0; i < maxVarPruneIterations; i++ ) { + // Gather the set of used variables. + const usedVariables = asts.reduce( ( set, ast ) => { + ast.getUsedVariables().forEach( v => set.add( v ) ); + return set; + }, new Set< string >() ); + + // If the number of used vars hasn't changed since last iteration, stop early. + if ( prevUsedVariables && prevUsedVariables.size === usedVariables.size ) { + break; + } + + // Prune unused variables, keep a sum of pruned variables. + const prunedCount = asts.reduce( ( sum, ast ) => { + sum += ast.pruneUnusedVariables( + new Set( [ ...usedVariables, ...internallyUsedVariables ] ) + ); + return sum; + }, 0 ); + + // If no variables were pruned this iteration, stop early. + if ( prunedCount === 0 ) { + break; + } + + prevUsedVariables = usedVariables; + } + + // Find all fonts used across all ASTs, and prune all that are not referenced. + const fontWhitelist = asts.reduce( ( set, ast ) => { + ast.getUsedFontFamilies().forEach( font => set.add( font ) ); + return set; + }, new Set< string >() ); + + // Remove any fonts that aren't used above the fold. + asts.forEach( ast => ast.pruneNonCriticalFonts( fontWhitelist ) ); + + // Throw away any ASTs without rules. + asts = asts.filter( ast => ast.ruleCount() > 0 ); + + return asts; + } + + /** + * Internal method: Store the specified css found at the cssUrl for an HTML page, + * de-duplicating CSS files by content along the way. + * + * @param {string} page - URL of HTML page this CSS file was found on. + * @param {string} cssUrl - URL of the CSS file. + * @param {string} css - Content of the CSS File. + */ + storeCss( page: string, cssUrl: string, css: string ): void { + // De-duplicate css contents in case cache busters in URLs or WAFs, etc confound URL de-duplication. + const matchingFile = this.cssFiles.find( file => file.css === css ); + if ( matchingFile ) { + this.addExtraReference( page, cssUrl, matchingFile ); + return; + } + + // Parse the CSS into an AST. + const ast = StyleAST.parse( css ); + + // Make sure relative URLs in the AST are absolute. + ast.absolutifyUrls( cssUrl ); + + const file = { css, ast, pages: [ page ], urls: [ cssUrl ] }; + this.knownUrls[ cssUrl ] = file; + this.cssFiles.push( file ); + } + + /** + * Internal method: Add an extra reference to a previously known CSS file found either + * on a new HTML page, or at a new URL. + * + * @param {string} page - URL of the page this CSS file was found on. + * @param {string} cssUrl - URL of the CSS File. + * @param {object} matchingFile - Internal CSS File object. + */ + addExtraReference( page: string, cssUrl: string, matchingFile: CSSFile ): void { + this.knownUrls[ cssUrl ] = matchingFile; + matchingFile.pages.push( page ); + + if ( ! matchingFile.urls.includes( cssUrl ) ) { + matchingFile.urls.push( cssUrl ); + } + } + + /** + * Stores an error that occurred while fetching or parsing CSS at the given URL. + * + * @param {string} url - CSS URL that failed to fetch or parse. + * @param {Error} err - Error object describing the problem. + */ + storeError( url: string, err: Error ): void { + this.knownUrls[ url ] = err; + this.errors.push( err ); + } + + /** + * Returns a list of errors that occurred while fetching or parsing these CSS files. + * + * @returns {Error[]} - List of errors that occurred. + */ + getErrors() { + return this.errors; + } +} diff --git a/projects/js-packages/critical-css-gen/src/errors.ts b/projects/js-packages/critical-css-gen/src/errors.ts new file mode 100644 index 0000000000000..0c2b398822955 --- /dev/null +++ b/projects/js-packages/critical-css-gen/src/errors.ts @@ -0,0 +1,142 @@ +export type MetaType = { + [ key: string ]: string | number; +}; + +export type ErrorSpec = { + message: string; + type: string; + meta: MetaType; +}; + +/** + * SuccessTargetError - Indicates that insufficient pages loaded to meet + * a specified success target. Contains information about each error that caused + * problems and the URLs they affect. + */ +export class SuccessTargetError extends Error { + public readonly isSuccessTargetError: boolean; + public readonly urlErrors: { [ url: string ]: ErrorSpec }; + + constructor( urlErrors: { [ url: string ]: UrlError } ) { + super( + 'Insufficient pages loaded to meet success target. Errors:\n' + + Object.values( urlErrors ) + .map( e => e.message ) + .join( '\n' ) + ); + + // Mark this as a SuccessTargetError in an easy way for other code to check. + this.isSuccessTargetError = true; + + // Convert any Error object into reliable {message,type,meta} objects. + this.urlErrors = {}; + for ( const [ url, errorObject ] of Object.entries( urlErrors ) ) { + this.urlErrors[ url ] = { + message: errorObject.message, + type: errorObject.type || 'UnknownError', + meta: errorObject.meta || {}, + }; + } + } +} + +/** + * Base class for URL specific errors, which can be bundled inside a + * SuccessTargetError. + */ +export class UrlError extends Error { + constructor( + public readonly type: string, + public readonly meta: MetaType, + message: string + ) { + super( message ); + } +} + +/** + * HttpError - Indicates an HTTP request has failed with a non-2xx status code. + */ +export class HttpError extends UrlError { + constructor( { url, code }: { url: string; code: number } ) { + super( 'HttpError', { url, code }, `HTTP error ${ code } on URL ${ url }` ); + } +} + +/** + * UnknownError - Indicates that fetch() threw an error with its own error string. + * Contains a raw (and difficult to translate) error message generated by fetch. + */ +export class UnknownError extends UrlError { + constructor( { url, message }: { url: string; message: string } ) { + super( 'UnknownError', { url, message }, `Error while loading ${ url }: ${ message }` ); + } +} + +/** + * CrossDomainError - Indicates that a requested URL failed due to CORS / security + * limitations imposed by the browser. + */ +export class CrossDomainError extends UrlError { + constructor( { url }: { url: string } ) { + super( 'CrossDomainError', { url }, `Failed to fetch cross-domain content at ${ url }` ); + } +} + +/** + * LoadTimeoutError - Indicates that an HTTP request failed due to a timeout. + */ +export class LoadTimeoutError extends UrlError { + constructor( { url }: { url: string } ) { + super( 'LoadTimeoutError', { url }, `Timeout while reading ${ url }` ); + } +} + +/** + * RedirectError - Indicates that a requested URL failed due to an HTTP redirection of that url. + */ +export class RedirectError extends UrlError { + constructor( { url, redirectUrl }: { url: string; redirectUrl: string } ) { + super( + 'RedirectError', + { url, redirectUrl }, + `Failed to process ${ url } because it redirects to ${ redirectUrl } which cannot be verified` + ); + } +} + +/** + * UrlVerifyError - Indicates that a provided BrowserInterface verifyUrl + * callback returned false for a page which was otherwise loaded successfully. + */ +export class UrlVerifyError extends UrlError { + constructor( { url }: { url: string } ) { + super( 'UrlVerifyError', { url }, `Failed to verify page at ${ url }` ); + } +} + +/** + * EmptyCSSError - Indicates that a requested URL does not have any CSS in its external style sheet(s) and therefore Critical CSS could not be generated. + */ +export class EmptyCSSError extends UrlError { + constructor( { url }: { url: string } ) { + super( + 'EmptyCSSError', + { url }, + `The ${ url } does not have any CSS in its external style sheet(s).` + ); + } +} + +/** + * XFrameDenyError - Indicates that a requested URL failed due to x-frame-options deny configuration + */ +export class XFrameDenyError extends UrlError { + constructor( { url } ) { + super( + 'XFrameDenyError', + { url }, + `Failed to load ${ url } due to the "X-Frame-Options: DENY" header` + ); + } +} diff --git a/projects/js-packages/critical-css-gen/src/generate-critical-css.ts b/projects/js-packages/critical-css-gen/src/generate-critical-css.ts new file mode 100644 index 0000000000000..d11436f015f47 --- /dev/null +++ b/projects/js-packages/critical-css-gen/src/generate-critical-css.ts @@ -0,0 +1,231 @@ +import { BrowserInterface } from './browser-interface.js'; +import { CSSFileSet } from './css-file-set.js'; +import { SuccessTargetError, EmptyCSSError, UrlError } from './errors.js'; +import { removeIgnoredPseudoElements } from './ignored-pseudo-elements.js'; +import { minifyCss } from './minify-css.js'; +import { FilterSpec, Viewport } from './types.js'; + +const noop = () => { + // No op. +}; + +/** + * Collate and return a CSSFileSet object describing all the CSS files used by + * the set of URLs provided. + * + * Errors that occur during this process are collated, but not thrown yet. + * + * @param {BrowserInterface} browserInterface - interface to access pages + * @param {string[]} urls - list of URLs to scan for CSS files + * @param {number} maxPages - number of pages to process at most + * @returns {Array} - Two member array; CSSFileSet, and an object containing errors that occurred at each URL. + */ +async function collateCssFiles( + browserInterface: BrowserInterface, + urls: string[], + maxPages: number +): Promise< [ CSSFileSet, { [ url: string ]: UrlError } ] > { + const cssFiles = new CSSFileSet( browserInterface ); + const errors = {}; + let successes = 0; + + for ( const url of urls ) { + try { + const cssIncludes = await browserInterface.getCssIncludes( url ); + + // Convert relative URLs to absolute. + const relativeUrls = Object.keys( cssIncludes ); + const absoluteIncludes = relativeUrls.reduce( + ( set, relative ) => { + try { + const absolute = new URL( relative, url ).toString(); + set[ absolute ] = cssIncludes[ relative ]; + } catch ( err ) { + // Ignore invalid URLs. + // eslint-disable-next-line no-console + console.log( `Could not absolutify URL: ${ relative }` ); + } + + return set; + }, + {} as typeof cssIncludes + ); + + await cssFiles.addMultiple( url, absoluteIncludes ); + + const internalStyles = await browserInterface.getInternalStyles( url ); + await cssFiles.addInternalStyles( url, internalStyles ); + + // Abort early if we hit the critical mass + successes++; + if ( successes >= maxPages ) { + break; + } + } catch ( err ) { + errors[ url ] = err; + } + } + + return [ cssFiles, errors ]; +} + +/** + * Get CSS selectors for above the fold content for the valid URLs. + * + * @param {object} param - All the parameters as object. + * @param {BrowserInterface} param.browserInterface - Interface to access pages + * @param {object} param.selectorPages - All the CSS selectors to URLs map object + * @param {string[]} param.validUrls - List of all the valid URLs + * @param {Array} param.viewports - Browser viewports + * @param {number} param.maxPages - Maximum number of pages to process + * @param {Function} param.updateProgress - Update progress callback function + * + * @returns {Set} - List of above the fold selectors. + */ +async function getAboveFoldSelectors( { + browserInterface, + selectorPages, + validUrls, + viewports, + maxPages, + updateProgress, +}: { + browserInterface: BrowserInterface; + selectorPages: { [ selector: string ]: Set< string > }; + validUrls: string[]; + viewports: Viewport[]; + maxPages: number; + updateProgress: () => void; +} ): Promise< Set< string > > { + // For each selector string, create a "trimmed" version with the stuff JavaScript can't handle cut out. + const trimmedSelectors = Object.keys( selectorPages ).reduce( ( set, selector ) => { + set[ selector ] = removeIgnoredPseudoElements( selector ); + return set; + }, {} ); + + // Go through all the URLs looking for above-the-fold selectors, and selectors which may be "dangerous" + // i.e.: may match elements on pages that do not include their CSS file. + const aboveFoldSelectors = new Set< string >(); + const dangerousSelectors = new Set< string >(); + + for ( const url of validUrls.slice( 0, maxPages ) ) { + // Work out which CSS selectors match any element on this page. + const pageSelectors = await browserInterface.runInPage< + ReturnType< typeof BrowserInterface.innerFindMatchingSelectors > + >( url, null, BrowserInterface.innerFindMatchingSelectors, trimmedSelectors ); + + // Check for selectors which may match this page, but are not included in this page's CSS. + pageSelectors + .filter( s => ! selectorPages[ s ].has( url ) ) + .forEach( s => dangerousSelectors.add( s ) ); + + // Collate all above-fold selectors for all viewport sizes. + for ( const size of viewports ) { + updateProgress(); + + const pageAboveFold = await browserInterface.runInPage< + ReturnType< typeof BrowserInterface.innerFindAboveFoldSelectors > + >( url, size, BrowserInterface.innerFindAboveFoldSelectors, trimmedSelectors, pageSelectors ); + + pageAboveFold.forEach( s => aboveFoldSelectors.add( s ) ); + } + } + + // Remove dangerous selectors from above fold set. + for ( const dangerousSelector of dangerousSelectors ) { + aboveFoldSelectors.delete( dangerousSelector ); + } + + return aboveFoldSelectors; +} + +/** + * Generates critical CSS for the given URLs and viewports. + * + * @param {object} root0 - The options object + * @param {BrowserInterface} root0.browserInterface - Interface to interact with the browser + * @param {Function} root0.progressCallback - Optional callback function to report progress + * @param {string[]} root0.urls - Array of URLs to generate critical CSS for + * @param {Viewport[]} root0.viewports - Array of viewport sizes to consider + * @param {FilterSpec} root0.filters - Optional filters to apply to the CSS + * @param {number} root0.successRatio - Ratio of successful URLs required (default: 1) + * @param {number} root0.maxPages - Maximum number of pages to process (default: 10) + * @returns {Promise<[string, Error[]]>} A promise that resolves to an array containing the critical CSS string and an array of errors. + */ +export async function generateCriticalCSS( { + browserInterface, + progressCallback, + urls, + viewports, + filters, + successRatio = 1, + maxPages = 10, +}: { + browserInterface: BrowserInterface; + progressCallback?: ( step: number, total: number ) => void; + urls: string[]; + viewports: Viewport[]; + filters?: FilterSpec; + successRatio?: number; + maxPages?: number; +} ): Promise< [ string, Error[] ] > { + // Success threshold is calculated based on the success ratio of "the number of URLs provided", or "maxPages" whichever is lower. + // See 268-gh-Automattic/boost-cloud + const successUrlsThreshold = Math.ceil( Math.min( urls.length, maxPages ) * successRatio ); + + try { + progressCallback = progressCallback || noop; + let progress = 0; + const progressSteps = 1 + urls.length * viewports.length; + const updateProgress = () => progressCallback( ++progress, progressSteps ); + + // Collate all CSS Files used by all valid URLs. + const [ cssFiles, cssFileErrors ] = await collateCssFiles( browserInterface, urls, maxPages ); + updateProgress(); + + // Verify there are enough valid URLs to carry on with. + const validUrls = browserInterface.filterValidUrls( urls ); + + if ( validUrls.length < successUrlsThreshold ) { + throw new SuccessTargetError( cssFileErrors ); + } + + // Trim ignored rules out of all CSS ASTs. + cssFiles.applyFilters( filters || {} ); + + // Gather a record of all selectors, and which page URLs each is referenced by. + const selectorPages = cssFiles.collateSelectorPages(); + + // Get CSS selectors for above the fold. + const aboveFoldSelectors = await getAboveFoldSelectors( { + browserInterface, + selectorPages, + validUrls, + viewports, + maxPages, + updateProgress, + } ); + + // Prune each AST for above-fold selector list. Note: this prunes a clone. + const asts = cssFiles.prunedAsts( aboveFoldSelectors ); + + // Convert ASTs to CSS. + const [ css, cssErrors ] = minifyCss( asts.map( ast => ast.toCSS() ).join( '\n' ) ); + + // If there is no Critical CSS, it means the URLs did not have any CSS in their external style sheet(s). + if ( ! css ) { + const emptyCSSErrors = {}; + for ( const url of validUrls ) { + emptyCSSErrors[ url ] = new EmptyCSSError( { url } ); + } + throw new SuccessTargetError( emptyCSSErrors ); + } + + // Collect warnings / errors together. + const warnings = cssFiles.getErrors().concat( cssErrors.map( s => new Error( s ) ) ); + + return [ css, warnings ]; + } finally { + browserInterface.cleanup(); + } +} diff --git a/projects/js-packages/critical-css-gen/src/ignored-pseudo-elements.ts b/projects/js-packages/critical-css-gen/src/ignored-pseudo-elements.ts new file mode 100644 index 0000000000000..c68947883212f --- /dev/null +++ b/projects/js-packages/critical-css-gen/src/ignored-pseudo-elements.ts @@ -0,0 +1,39 @@ +const ignoredPseudoElements = [ + 'after', + 'before', + 'first-(line|letter)', + '(input-)?placeholder', + 'scrollbar', + 'search(results-)?decoration', + 'search-(cancel|results)-button', +]; + +let removePseudoElementRegex: RegExp; + +/** + * Builds a RegExp for finding pseudo elements that should be ignored while matching + * elements that are above the fold. + * + * @returns {RegExp} A RegExp to use when removing unwanted pseudo elements. + */ +function getRemovePseudoElementRegex(): RegExp { + if ( removePseudoElementRegex ) { + return removePseudoElementRegex; + } + + const allIgnored = ignoredPseudoElements.join( '|' ); + removePseudoElementRegex = new RegExp( '::?(-(moz|ms|webkit)-)?(' + allIgnored + ')' ); + + return removePseudoElementRegex; +} + +/** + * Remove pseudo elements that are ignored while matching elements above the fold. + * + * @param {string} selector - selector to filter. + * + * @returns {string} selector with ignored pseudo elements removed. + */ +export function removeIgnoredPseudoElements( selector: string ): string { + return selector.replace( getRemovePseudoElementRegex(), '' ).trim(); +} diff --git a/projects/js-packages/critical-css-gen/src/minify-css.ts b/projects/js-packages/critical-css-gen/src/minify-css.ts new file mode 100644 index 0000000000000..ed6032121ac0c --- /dev/null +++ b/projects/js-packages/critical-css-gen/src/minify-css.ts @@ -0,0 +1,21 @@ +import CleanCSS from 'clean-css'; + +/** + * Minifies the given CSS, returning it as a string. Any errors that occur are returned + * in the second positional return value. + * + * If the CSS fails to minify, the original content will be returned instead. + * + * @param {string} css - CSS to minify. + * + * @returns {[ string, string[] ]} - Minified CSS and a list of errors returned. + */ +export function minifyCss( css: string ): [ string, string[] ] { + const result = new CleanCSS().minify( css ); + + if ( ! result.styles ) { + return [ css, result.errors ]; + } + + return [ result.styles, result.errors ]; +} diff --git a/projects/js-packages/critical-css-gen/src/node.ts b/projects/js-packages/critical-css-gen/src/node.ts new file mode 100644 index 0000000000000..705050fffea45 --- /dev/null +++ b/projects/js-packages/critical-css-gen/src/node.ts @@ -0,0 +1,7 @@ +export { BrowserInterfacePlaywright } from './browser-interface-playwright.js'; +export { BrowserInterface } from './browser-interface.js'; +export { generateCriticalCSS } from './generate-critical-css.js'; + +export * from './errors.js'; + +export const version = '0.0.11'; diff --git a/projects/js-packages/critical-css-gen/src/object-promise-all.ts b/projects/js-packages/critical-css-gen/src/object-promise-all.ts new file mode 100644 index 0000000000000..016cc07f3a49c --- /dev/null +++ b/projects/js-packages/critical-css-gen/src/object-promise-all.ts @@ -0,0 +1,21 @@ +/** + * Given an object full of promises, resolves all of them and returns an object containing resultant values. + * Roughly equivalent of Promise.all, but applies to an object. + * + * @param {object} object - containing promises to resolve + * @returns {object} - Promise which resolves to an object containing resultant values + */ +export async function objectPromiseAll< ValueType >( object: { + [ key: string ]: Promise< ValueType >; +} ): Promise< { [ key: string ]: ValueType } > { + const keys = Object.keys( object ); + const values = await Promise.all( keys.map( key => object[ key ] ) ); + + return keys.reduce( + ( acc, key, index ) => { + acc[ key ] = values[ index ]; + return acc; + }, + {} as { [ key: string ]: ValueType } + ); +} diff --git a/projects/js-packages/critical-css-gen/src/style-ast.ts b/projects/js-packages/critical-css-gen/src/style-ast.ts new file mode 100644 index 0000000000000..bde637e0dc4e0 --- /dev/null +++ b/projects/js-packages/critical-css-gen/src/style-ast.ts @@ -0,0 +1,615 @@ +import * as csstree from 'css-tree'; +import { AtRuleFilter, FilterSpec, PropertiesFilter } from './types.js'; + +const validMediaTypes = [ 'all', 'print', 'screen', 'speech' ]; +const base64Pattern = /data:[^,]*;base64,/; +const stringPattern = /^(["']).*\1$/; +const maxBase64Length = 1000; +const excludedSelectors = [ /::?(?:-moz-)?selection/ ]; +const excludedProperties = [ + /(.*)animation/, + /(.*)transition(.*)/, + /cursor/, + /pointer-events/, + /(-webkit-)?tap-highlight-color/, + /(.*)user-select/, +]; + +/** + * Checks if the given node is a CSS declaration. + * @param {csstree.CssNode} node - The CSS node to check. + * @returns {boolean} True if the node is a CSS declaration, false otherwise. + */ +function isDeclaration( node: csstree.CssNode ): node is csstree.Declaration { + return node.type === 'Declaration'; +} + +/** + * Checks if the given node has an empty child list. + * @param {csstree.CssNode} node - The CSS node to check. + * @returns {boolean} True if the node has an empty child list, false otherwise. + */ +function hasEmptyChildList( node: csstree.CssNode ): boolean { + if ( 'children' in node && node.children instanceof csstree.List ) { + return node.children.isEmpty; + } + + return false; +} + +/** + * Represents an Abstract Syntax Tree for a CSS file (as generated by css-tree) and contains helper + * methods for pruning and rearranging it. + */ +export class StyleAST { + // eslint-disable-next-line no-useless-constructor + constructor( + private css: string, + private ast: csstree.CssNode, + private errors: Error[] + ) {} + + /** + * Given a base URL (where the CSS file this AST was built from), find all relative URLs and + * convert them to absolute. + * + * @param {string} base - base URL for relative URLs. + */ + absolutifyUrls( base: string ): void { + csstree.walk( this.ast, { + visit: 'Url', + enter: url => { + if ( url.value ) { + const value = StyleAST.readValue( url ); + const absolute = new URL( value, base ).toString(); + + if ( absolute !== value ) { + url.value = absolute; + } + } + }, + } ); + } + + /** + * Returns a new StyleAST with content from this one pruned based on the specified contentWindow + * and criticalSelectors to keep. + * + * Removes: + * - Irrelevant media queries + * - Selectors not included in criticalSelectors + * - Excluded properties + * - Large embeds + * - Empty rules + * + * @param {Set< string >} criticalSelectors - Set of selectors to keep in the new AST. + * + * @returns {StyleAST} - New AST with pruned contents. + */ + pruned( criticalSelectors: Set< string > ): StyleAST { + const clone = new StyleAST( this.css, csstree.clone( this.ast ), this.errors ); + + clone.pruneMediaQueries(); + clone.pruneAtRules( [ 'keyframes', 'charset', 'import' ] ); + clone.pruneNonCriticalSelectors( criticalSelectors ); + clone.pruneExcludedProperties(); + clone.pruneLargeBase64Embeds(); + clone.pruneComments(); + + return clone; + } + + /** + * Given an AST node, returns the original text it was compiled from in the source CSS. + * + * @param {object} node - Node from the AST. + * @returns {string} original text the node was compiled from. + */ + originalText( node: csstree.CssNode ): string { + if ( node.loc && node.loc.start && node.loc.end ) { + return this.css.substring( node.loc.start.offset, node.loc.end.offset ); + } + return ''; + } + + /** + * Applies filters to the properties or atRules in this AST. Mutates the AST in-place. + * + * @param {FilterSpec} filters - Object containing property and atRule filter functions. + */ + applyFilters( filters: FilterSpec ): void { + if ( ! filters ) { + return; + } + + if ( filters.properties ) { + this.applyPropertiesFilter( filters.properties ); + } + + if ( filters.atRules ) { + this.applyAtRulesFilter( filters.atRules ); + } + } + + /** + * Applies a filter to the properties in this AST. Mutates the AST in-place. + * + * @param {Function} filter - to apply. + */ + applyPropertiesFilter( filter: PropertiesFilter ): void { + csstree.walk( this.ast, { + visit: 'Declaration', + enter: ( declaration, item, list ) => { + if ( filter( declaration.property, this.originalText( declaration.value ) ) === false ) { + list.remove( item ); + } + }, + } ); + } + + /** + * Applies a filter to the atRules in this AST. Mutates the AST in-place. + * + * @param {Function} filter - to apply. + */ + applyAtRulesFilter( filter: AtRuleFilter ): void { + csstree.walk( this.ast, { + visit: 'Atrule', + enter: ( atrule, item, list ) => { + if ( filter( atrule.name ) === false ) { + list.remove( item ); + } + }, + } ); + } + + /** + * Remove variables that do not appear in the usedVariables set. Returns a count of variables + * that were removed. + * + * @param {Set< string >} usedVariables - Set of used variables to keep. + * @returns {number} variables pruned. + */ + pruneUnusedVariables( usedVariables: Set< string > ): number { + let pruned = 0; + + csstree.walk( this.ast, { + visit: 'Declaration', + enter: ( declaration, item, list ) => { + // Ignore declarations that aren't defining variables. + if ( ! declaration.property.startsWith( '--' ) ) { + return; + } + + // Check if this declared variable is used. + if ( usedVariables.has( declaration.property ) ) { + return; + } + + // Prune unused variable. + list.remove( item ); + pruned++; + }, + } ); + + return pruned; + } + + /** + * Find all variables that are used and return them as a Set. + * @returns {Set< string >} Set of used variables. + */ + getUsedVariables(): Set< string > { + const usedVariables = new Set< string >(); + + csstree.walk( this.ast, { + visit: 'Function', + enter: func => { + // Ignore functions that aren't var() + if ( csstree.keyword( func.name ).name !== 'var' ) { + return; + } + + const names = func.children.map( StyleAST.readValue ); + names.forEach( name => usedVariables.add( name ) ); + }, + } ); + + return usedVariables; + } + + /** + * Remove all comments from the syntax tree. + */ + pruneComments(): void { + csstree.walk( this.ast, { + visit: 'Comment', + enter: ( _, item, list ) => { + list.remove( item ); + }, + } ); + } + + /** + * Remove media queries that only apply to print. + */ + pruneMediaQueries(): void { + csstree.walk( this.ast, { + visit: 'Atrule', + enter: ( atrule, atitem, atlist ) => { + // Ignore non-media and invalid atrules. + if ( csstree.keyword( atrule.name ).name !== 'media' || ! atrule.prelude ) { + return; + } + + // Go through all MediaQueryLists (should be one, but let's be sure). + csstree.walk( atrule, { + visit: 'MediaQueryList', + enter: ( mqrule, mqitem, mqlist ) => { + // Filter out MediaQueries that aren't interesting. + csstree.walk( mqrule, { + visit: 'MediaQuery', + enter: ( mediaQuery, mediaItem, mediaList ) => { + if ( ! StyleAST.isUsefulMediaQuery( mediaQuery ) ) { + mediaList.remove( mediaItem ); + } + }, + } ); + + // If empty MQ, remove from parent. + if ( hasEmptyChildList( mqrule ) ) { + mqlist.remove( mqitem ); + } + }, + } ); + + // If there are no useful media query lists left, throw away the block. + if ( hasEmptyChildList( atrule.prelude ) ) { + atlist.remove( atitem ); + } + }, + } ); + } + + /** + * Remove unwanted at-rules. + * + * @param { string[] } names - Names of at-rules to remove, excluding the at symbol. + */ + pruneAtRules( names: string[] ): void { + csstree.walk( this.ast, { + visit: 'Atrule', + enter: ( atrule, atitem, atlist ) => { + if ( names.includes( csstree.keyword( atrule.name ).basename ) ) { + atlist.remove( atitem ); + } + }, + } ); + } + + /** + * Returns true if the given CSS rule object relates to animation keyframes. + * + * @param {csstree.WalkContext} rule - CSS rule. + * @returns {boolean} True if the rule is a keyframe rule, false otherwise. + */ + static isKeyframeRule( rule: csstree.WalkContext ): boolean { + return ( rule.atrule && csstree.keyword( rule.atrule.name ).basename === 'keyframes' ) || false; + } + + /** + * Walks this AST and calls the specified callback with each selector found (as text). + * Skips any selectors in the excludedSelectors constant. + * + * @param {Function} callback - Callback to call with each selector. + */ + forEachSelector( callback: ( selector: string ) => void ): void { + csstree.walk( this.ast, { + visit: 'Rule', + enter( rule ) { + // Ignore rules inside @keyframes. + if ( StyleAST.isKeyframeRule( this ) ) { + return; + } + + // Ignore invalid rules. + if ( rule.prelude.type !== 'SelectorList' ) { + return; + } + + // Go through all selectors, filtering out unwanted ones. + rule.prelude.children.forEach( child => { + const selector = csstree.generate( child ); + + if ( ! excludedSelectors.some( s => s.test( selector ) ) ) { + callback( selector ); + } + } ); + }, + } ); + } + + /** + * Remove any selectors not listed in the criticalSelectors set, deleting any + * rules that no longer have any selectors in their prelude. + * + * @param {Set< string >} criticalSelector - Set of critical selectors. + */ + pruneNonCriticalSelectors( criticalSelector: Set< string > ): void { + csstree.walk( this.ast, { + visit: 'Rule', + enter( rule, item, list ) { + // Ignore rules inside @keyframes... until later. + if ( this.atrule && csstree.keyword( this.atrule.name ).basename === 'keyframes' ) { + return; + } + + // Remove invalid rules. + if ( rule.prelude.type !== 'SelectorList' ) { + list.remove( item ); + return; + } + + // Always include any rule that uses the grid-area property. + if ( + rule.block.children.some( + propertyNode => isDeclaration( propertyNode ) && propertyNode.property === 'grid-area' + ) + ) { + return; + } + + // Prune any selectors that aren't used. + rule.prelude.children = rule.prelude.children.filter( selector => { + // Prune selectors marked to always remove. + if ( excludedSelectors.some( s => s.test( csstree.generate( selector ) ) ) ) { + return false; + } + + const selectorText = csstree.generate( selector ); + return criticalSelector.has( selectorText ); + } ); + + // If the selector list is empty, prune the whole rule. + if ( hasEmptyChildList( rule.prelude ) ) { + list.remove( item ); + } + }, + } ); + } + + /** + * Remove any Base64 embedded content which exceeds maxBase64Length. + */ + pruneLargeBase64Embeds(): void { + csstree.walk( this.ast, { + visit: 'Declaration', + enter: ( declaration, item, list ) => { + let tooLong = false; + + csstree.walk( declaration, { + visit: 'Url', + enter( url ) { + const value = url.value; + if ( base64Pattern.test( value ) && value.length > maxBase64Length ) { + tooLong = true; + } + }, + } ); + + if ( tooLong ) { + list.remove( item ); + } + }, + } ); + } + + /** + * Remove any properties that match the regular expressions in the excludedProperties constant. + */ + pruneExcludedProperties(): void { + csstree.walk( this.ast, { + visit: 'Declaration', + enter: ( declaration, item, list ) => { + if ( declaration.property ) { + const property = csstree.property( declaration.property ).name; + if ( excludedProperties.some( e => e.test( property ) ) ) { + list.remove( item ); + } + } + }, + } ); + } + + /** + * Remove any fonts which are not in the specified whitelist. + * + * @param {Set< string >} fontWhitelist - Whitelisted font. + */ + pruneNonCriticalFonts( fontWhitelist: Set< string > ): void { + csstree.walk( this.ast, { + visit: 'Atrule', + enter: ( atrule, item, list ) => { + // Skip rules that aren't @font-face... + if ( csstree.keyword( atrule.name ).basename !== 'font-face' ) { + return; + } + + // Find src and font-family. + const properties: { [ key: string ]: string[] } = {}; + csstree.walk( atrule, { + visit: 'Declaration', + enter: ( declaration, decItem, decList ) => { + const property = csstree.property( declaration.property ).name; + if ( + [ 'src', 'font-family' ].includes( property ) && + 'children' in declaration.value + ) { + const values = declaration.value.children.toArray(); + properties[ property ] = values.map( StyleAST.readValue ); + } + + // Prune out src from result. + if ( property === 'src' ) { + decList.remove( decItem ); + } + }, + } ); + + // Remove font-face rules without a src and font-family. + if ( ! properties.src || ! properties[ 'font-family' ] ) { + list.remove( item ); + return; + } + + // Prune if none of the font-family values are in the whitelist. + if ( ! properties[ 'font-family' ].some( family => fontWhitelist.has( family ) ) ) { + list.remove( item ); + } + }, + } ); + } + + /** + * Returns a count of the rules in this Style AST. + * + * @returns {number} rules in this AST. + */ + ruleCount(): number { + let rules = 0; + + csstree.walk( this.ast, { + visit: 'Rule', + enter: () => { + rules++; + }, + } ); + + return rules; + } + + /** + * Returns a list of font families that are used by any rule in this AST. + * + * @returns {Set} Set of used fonts. + */ + getUsedFontFamilies(): Set< string > { + const fontFamilies = new Set< string >(); + + csstree.walk( this.ast, { + visit: 'Declaration', + enter( node ) { + // Ignore declarations not inside rules. + if ( ! this.rule ) { + return; + } + + // Pull the lexer out of csstree. Note: the types don't include + // this, so we have to hack it with any :( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const lexer = ( csstree as any ).lexer; + + // Gather family-name values. + const frags = lexer.findDeclarationValueFragments( node, 'Type', 'family-name' ); + const nodes = frags.map( frag => frag.nodes.toArray() ).flat(); + const names = nodes.map( StyleAST.readValue ) as string[]; + names.forEach( name => fontFamilies.add( name ) ); + }, + } ); + + return fontFamilies; + } + + /** + * Given an AST node, read it as a value based on its type. Removes quote marks from + * string types if present. + * + * @param {csstree.CssNode} node - AST node. + * @returns {string} The value of the node as a string. + */ + static readValue( node: csstree.CssNode ): string { + if ( node.type === 'String' && stringPattern.test( node.value ) ) { + return node.value.substr( 1, node.value.length - 2 ); + } else if ( node.type === 'Identifier' ) { + return node.name; + } else if ( 'value' in node ) { + return node.value as string; + } + + return ''; + } + + /** + * Returns true if the specified media query node is relevant to screen rendering. + * + * @param {object} mediaQueryNode - Media Query AST node to examine. + * + * @returns {boolean} true if the media query is relevant to screens. + */ + static isUsefulMediaQuery( mediaQueryNode: csstree.MediaQuery ): boolean { + // Find media types. + let lastIdentifierNot = false; + const mediaTypes = {}; + csstree.walk( mediaQueryNode, { + visit: 'Identifier', + enter: node => { + const identifier = csstree.keyword( node.name ).name; + + if ( identifier === 'not' ) { + lastIdentifierNot = true; + return; + } + + if ( validMediaTypes.includes( identifier ) ) { + mediaTypes[ identifier ] = ! lastIdentifierNot; + } + + lastIdentifierNot = false; + }, + } ); + + // If no media types specified, assume screen. + if ( Object.keys( mediaTypes ).length === 0 ) { + return true; + } + + // If 'screen' or 'all' explicitly specified, use those (preference screen). + for ( const mediaType of [ 'screen', 'all' ] ) { + if ( Object.prototype.hasOwnProperty.call( mediaTypes, mediaType ) ) { + return mediaTypes[ mediaType ]; + } + } + + // If any other media type specified, only true if 'not'. e.g.: 'not print'. + return Object.values( mediaTypes ).some( value => ! value ); + } + + /** + * Returns this AST converted to CSS. + * + * @returns {string} this AST represented in CSS. + */ + toCSS(): string { + return csstree.generate( this.ast ); + } + + /** + * Static method to parse a block of CSS and return a new StyleAST object which represents it. + * + * @param {string} css - CSS to parse. + * + * @returns {StyleAST} new parse AST based on the CSS. + */ + static parse( css: string ): StyleAST { + const errors: Error[] = []; + const ast = csstree.parse( css, { + parseCustomProperty: true, + positions: true, + onParseError: err => { + errors.push( err ); + }, + } ); + + return new StyleAST( css, ast, errors ); + } +} diff --git a/projects/js-packages/critical-css-gen/src/types.ts b/projects/js-packages/critical-css-gen/src/types.ts new file mode 100644 index 0000000000000..d0c4f1131accc --- /dev/null +++ b/projects/js-packages/critical-css-gen/src/types.ts @@ -0,0 +1,14 @@ +export type Viewport = { + width: number; + height: number; +}; + +export type NullableViewport = Viewport | { width: null; height: null }; + +export type PropertiesFilter = ( name: string, value: string ) => boolean; +export type AtRuleFilter = ( name: string ) => boolean; + +export type FilterSpec = { + properties?: PropertiesFilter; + atRules?: AtRuleFilter; +}; diff --git a/projects/js-packages/critical-css-gen/tests/babel.config.json b/projects/js-packages/critical-css-gen/tests/babel.config.json new file mode 100644 index 0000000000000..9e9ed77c53ff2 --- /dev/null +++ b/projects/js-packages/critical-css-gen/tests/babel.config.json @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "node": "current" + } + } + ] + ] +} diff --git a/projects/js-packages/critical-css-gen/tests/config/jest-setup.js b/projects/js-packages/critical-css-gen/tests/config/jest-setup.js new file mode 100644 index 0000000000000..154d786a282b4 --- /dev/null +++ b/projects/js-packages/critical-css-gen/tests/config/jest-setup.js @@ -0,0 +1,3 @@ +jest.setTimeout( 60000 ); +jest.spyOn( global.console, 'log' ).mockImplementation( () => jest.fn() ); +jest.spyOn( global.console, 'debug' ).mockImplementation( () => jest.fn() ); diff --git a/projects/js-packages/critical-css-gen/tests/config/jest.config.js b/projects/js-packages/critical-css-gen/tests/config/jest.config.js new file mode 100644 index 0000000000000..e250f6c00f30d --- /dev/null +++ b/projects/js-packages/critical-css-gen/tests/config/jest.config.js @@ -0,0 +1,9 @@ +export default { + rootDir: '../', + testEnvironment: 'jest-environment-node', + testMatch: [ '**/?(*.)+(spec|test).js' ], + setupFilesAfterEnv: [ './config/jest-setup.js' ], + collectCoverageFrom: [ '../build-node/*.js' ], + testPathIgnorePatterns: [ '/node_modules/', 'config/jest-setup.js', 'build-node/*' ], + moduleDirectories: [ 'build-node', 'node_modules' ], +}; diff --git a/projects/js-packages/critical-css-gen/tests/data/page-a/complex-rules.css b/projects/js-packages/critical-css-gen/tests/data/page-a/complex-rules.css new file mode 100644 index 0000000000000..696b5ad376f80 --- /dev/null +++ b/projects/js-packages/critical-css-gen/tests/data/page-a/complex-rules.css @@ -0,0 +1,4 @@ +/* This rule is included using complex media="" rules, so should be appropriately wrapped */ +div.complex_media_rules { + color: purple; +} diff --git a/projects/js-packages/critical-css-gen/tests/data/page-a/index.html b/projects/js-packages/critical-css-gen/tests/data/page-a/index.html new file mode 100644 index 0000000000000..251621692d318 --- /dev/null +++ b/projects/js-packages/critical-css-gen/tests/data/page-a/index.html @@ -0,0 +1,29 @@ + + + Test page + + + + + " + + + + +
+
+
+
+
+
+ + + + diff --git a/projects/js-packages/critical-css-gen/tests/data/page-a/min-width.css b/projects/js-packages/critical-css-gen/tests/data/page-a/min-width.css new file mode 100644 index 0000000000000..aac2deee4f699 --- /dev/null +++ b/projects/js-packages/critical-css-gen/tests/data/page-a/min-width.css @@ -0,0 +1,6 @@ +@media screen { + /* This rule should appear inside two media rules; min-width and screen */ + div.min_width_screen { + color: pink; + } +} diff --git a/projects/js-packages/critical-css-gen/tests/data/page-a/print.css b/projects/js-packages/critical-css-gen/tests/data/page-a/print.css new file mode 100644 index 0000000000000..389f6e7bff152 --- /dev/null +++ b/projects/js-packages/critical-css-gen/tests/data/page-a/print.css @@ -0,0 +1,6 @@ +/* These rules are included in a media="print" link, and so should not be +included in the end result, ever. */ + +div.sir_not_appearing_in_this_film { + color: red; +} diff --git a/projects/js-packages/critical-css-gen/tests/data/page-a/style.css b/projects/js-packages/critical-css-gen/tests/data/page-a/style.css new file mode 100644 index 0000000000000..19e464cb34bfd --- /dev/null +++ b/projects/js-packages/critical-css-gen/tests/data/page-a/style.css @@ -0,0 +1,54 @@ +body { + padding: 0px; + margin: 0px; +} + +div.top { + position: absolute; + top: 0px; +} + +div.four_eighty { + position: absolute; + top: 480px; +} + +div.six_hundred { + position: absolute; + top: 600px; +} + +div.seven_sixty_eight { + position: absolute; + top: 768px; +} + +@media print { + div.top::before { + content: "print"; + } +} + +@media not screen { + div.top::before { + content: "not screen"; + } +} + +@media print { + div.top::before { + content: "print"; + } +} + +@media screen { + div.top::before { + content: "screen"; + } +} + +@media all { + div.top::before { + content: "all"; + } +} diff --git a/projects/js-packages/critical-css-gen/tests/lib/data-directory.js b/projects/js-packages/critical-css-gen/tests/lib/data-directory.js new file mode 100644 index 0000000000000..63cfcc54007c9 --- /dev/null +++ b/projects/js-packages/critical-css-gen/tests/lib/data-directory.js @@ -0,0 +1,10 @@ +const fs = require( 'fs' ); + +// Figure out data directory. +const dataDirectory = fs.realpathSync( __dirname + '/../data/' ); +const dataUrl = 'file://' + dataDirectory; + +module.exports = { + dataDirectory, + dataUrl, +}; diff --git a/projects/js-packages/critical-css-gen/tests/lib/mock-fetch.js b/projects/js-packages/critical-css-gen/tests/lib/mock-fetch.js new file mode 100644 index 0000000000000..d09fc397d0316 --- /dev/null +++ b/projects/js-packages/critical-css-gen/tests/lib/mock-fetch.js @@ -0,0 +1,34 @@ +const fs = require( 'fs' ); +const { dataDirectory } = require( './data-directory.js' ); + +/** + * Mocked out version of node-fetch; allows fetching local resources from the data directory. + * + * @param {string} url - to fetch. + * @returns {Promise} - A Promise that resolves to an object with 'ok' and 'text' properties. + */ +const mockFetch = async url => { + return new Promise( ( resolve, reject ) => { + const pathname = new URL( url ).pathname; + const domain = new URL( url ).toString().replace( pathname, '' ); + const localPath = url.replace( domain, dataDirectory ); + + if ( ! localPath.startsWith( dataDirectory ) ) { + throw new Error( 'Invalid URL: ' + url ); + } + + fs.readFile( localPath, ( err, data ) => { + if ( err ) { + reject( err ); + return; + } + + resolve( { + ok: true, + text: async () => data.toString(), + } ); + } ); + } ); +}; + +module.exports = mockFetch; diff --git a/projects/js-packages/critical-css-gen/tests/lib/test-server.js b/projects/js-packages/critical-css-gen/tests/lib/test-server.js new file mode 100644 index 0000000000000..253dc69477b07 --- /dev/null +++ b/projects/js-packages/critical-css-gen/tests/lib/test-server.js @@ -0,0 +1,56 @@ +const express = require( 'express' ); + +const index = ` + + + + + + +`; + +/** + * Test server , used to test client-side / iframe version of Critical CSS generation. + */ +class TestServer { + constructor( staticPaths ) { + this.port = null; + this.app = null; + this.server = null; + this.staticPaths = staticPaths || []; + } + + async start() { + this.app = express(); + + this.app.use( + '/bundle.js', + express.static( require.resolve( '../../build-browser/bundle.full.js' ) ) + ); + + for ( const [ virtualPath, realDirectory ] of Object.entries( this.staticPaths ) ) { + this.app.use( '/' + virtualPath, express.static( realDirectory ) ); + } + + this.app.use( '/', ( req, res ) => res.send( index ) ); + + return new Promise( resolve => { + this.server = this.app.listen( () => { + this.port = this.server.address().port; + resolve(); + } ); + } ); + } + + async stop() { + if ( this.app && this.server ) { + this.server.close(); + } + } + + getUrl() { + return 'http://localhost:' + this.port; + } +} + +module.exports = TestServer; diff --git a/projects/js-packages/critical-css-gen/tests/unit/browser-interface-iframe.test.js b/projects/js-packages/critical-css-gen/tests/unit/browser-interface-iframe.test.js new file mode 100644 index 0000000000000..8341e45b5fad0 --- /dev/null +++ b/projects/js-packages/critical-css-gen/tests/unit/browser-interface-iframe.test.js @@ -0,0 +1,210 @@ +/* global CriticalCSSGenerator */ +const path = require( 'path' ); +const { chromium } = require( 'playwright' ); +const { dataDirectory } = require( '../lib/data-directory.js' ); +const TestServer = require( '../lib/test-server.js' ); + +let testServer = null; +let browser; + +describe( 'Iframe interface', () => { + // Start test server to serve wrapped content. + beforeAll( async () => { + testServer = new TestServer( { + 'page-a': path.join( dataDirectory, 'page-a' ), + } ); + await testServer.start(); + + browser = await chromium.launch(); + } ); + + // Kill test server. + afterAll( async () => { + if ( testServer ) { + await testServer.stop(); + testServer = null; + } + if ( browser ) { + await browser.close(); + } + } ); + + it( 'Successfully generates via iframes', async () => { + const page = await browser.newPage(); + page.on( 'console', msg => process.stderr.write( msg.text() + '\n\n' ) ); + await page.goto( testServer.getUrl() ); + + const innerUrl = path.join( testServer.getUrl(), 'page-a' ); + + const [ css, warnings ] = await page.evaluate( url => { + return CriticalCSSGenerator.generateCriticalCSS( { + urls: [ url ], + viewports: [ { width: 640, height: 480 } ], + browserInterface: new CriticalCSSGenerator.BrowserInterfaceIframe( { + verifyPage: ( _url, innerWindow, innerDocument ) => { + return !! innerDocument.querySelector( 'meta[name="testing-page"]' ); + }, + } ), + } ); + }, innerUrl ); + + expect( warnings ).toHaveLength( 0 ); + expect( css ).toContain( 'div.top' ); + + await page.close(); + } ); + + // eslint-disable-next-line jest/expect-expect + it( 'Allows scripts if not explicitly turned off', async () => { + const page = await browser.newPage(); + await page.goto( testServer.getUrl() ); + + const innerUrl = path.join( testServer.getUrl(), 'page-a' ); + + // Will throw an error if the inner page does not contain + // 'script-created-content'; a string appended to page-a by a script. + await page.evaluate( async url => { + const iframeInterface = new CriticalCSSGenerator.BrowserInterfaceIframe( { + verifyPage: ( _url, innerWindow, innerDocument ) => { + return innerDocument.documentElement.innerHTML.includes( 'script-created-content' ); + }, + } ); + + await iframeInterface.loadPage( url ); + }, innerUrl ); + + await page.close(); + } ); + + // eslint-disable-next-line jest/expect-expect + it( 'Blocks scripts if turned off', async () => { + const page = await browser.newPage(); + await page.goto( testServer.getUrl() ); + + const innerUrl = path.join( testServer.getUrl(), 'page-a' ); + + // Will throw an error if the inner page contains + // 'script-created-content'; a string appended to page-a by a script. + await page.evaluate( async url => { + const iframeInterface = new CriticalCSSGenerator.BrowserInterfaceIframe( { + verifyPage: ( _url, innerWindow, innerDocument ) => { + return ! innerDocument.documentElement.innerHTML.includes( 'script-created-content' ); + }, + allowScripts: false, + } ); + + await iframeInterface.loadPage( url ); + }, innerUrl ); + + await page.close(); + } ); + + it( 'Can successfully generate using an iframe with JavaScript off', async () => { + const page = await browser.newPage(); + await page.goto( testServer.getUrl() ); + + const innerUrl = path.join( testServer.getUrl(), 'page-a' ); + + const [ css, warnings ] = await page.evaluate( url => { + return CriticalCSSGenerator.generateCriticalCSS( { + urls: [ url ], + viewports: [ { width: 640, height: 480 } ], + browserInterface: new CriticalCSSGenerator.BrowserInterfaceIframe( { + verifyPage: ( _url, innerWindow, innerDocument ) => { + return !! innerDocument.querySelector( 'meta[name="testing-page"]' ); + }, + allowScripts: false, + } ), + } ); + }, innerUrl ); + + expect( warnings ).toHaveLength( 0 ); + expect( css ).toContain( 'div.top' ); + + await page.close(); + } ); + + it( 'Throws an error if a successRatio is not met', async () => { + const page = await browser.newPage(); + await page.goto( testServer.getUrl() ); + + await expect( async () => { + await page.evaluate( () => { + return CriticalCSSGenerator.generateCriticalCSS( { + urls: [ 'about:blank', 'about:blank' ], + viewports: [ { width: 640, height: 480 } ], + browserInterface: new CriticalCSSGenerator.BrowserInterfaceIframe( { + verifyPage: () => false, + } ), + successRatio: 0.5, + } ); + } ); + } ).rejects.toThrow( /Insufficient pages loaded/ ); + + await page.close(); + } ); + + it( 'Does not throw an error if successRatio is met', async () => { + const page = await browser.newPage(); + await page.goto( testServer.getUrl() ); + + const innerUrl = path.join( testServer.getUrl(), 'page-a' ); + + const [ css, warnings ] = await page.evaluate( url => { + return CriticalCSSGenerator.generateCriticalCSS( { + urls: [ 'about:blank', url ], + viewports: [ { width: 640, height: 480 } ], + browserInterface: new CriticalCSSGenerator.BrowserInterfaceIframe( { + verifyPage: ( _url, innerWindow, innerDocument ) => { + return !! innerDocument.querySelector( 'meta[name="testing-page"]' ); + }, + } ), + successRatio: 0.5, + } ); + }, innerUrl ); + + expect( warnings ).toHaveLength( 0 ); + expect( css ).toContain( 'div.top' ); + + await page.close(); + } ); + + it( 'Does not load more pages than the maxPages specifies', async () => { + const page = await browser.newPage(); + await page.goto( testServer.getUrl() ); + + const pageA = path.join( testServer.getUrl(), 'page-a' ); + const pageB = path.join( testServer.getUrl(), 'page-b' ); + + const result = await page.evaluate( + async ( { pA, pB } ) => { + const pagesVerified = []; + const criticalCSSResult = await CriticalCSSGenerator.generateCriticalCSS( { + urls: [ 'about:blank', pA, pB, 'about:blank' ], + viewports: [ { width: 640, height: 480 } ], + browserInterface: new CriticalCSSGenerator.BrowserInterfaceIframe( { + verifyPage: ( url, innerWindow, innerDocument ) => { + pagesVerified.push( url ); + return !! innerDocument.querySelector( 'meta[name="testing-page"]' ); + }, + } ), + successRatio: 0.25, + maxPages: 1, + } ); + + return { + css: criticalCSSResult[ 0 ], + warnings: criticalCSSResult[ 1 ], + pagesVerified, + }; + }, + { pA: pageA, pB: pageB } + ); + + expect( result.pagesVerified ).not.toContain( pageB ); + expect( result.warnings ).toHaveLength( 0 ); + expect( result.css ).toContain( 'div.top' ); + + await page.close(); + } ); +} ); diff --git a/projects/js-packages/critical-css-gen/tests/unit/generate-critical-css.test.js b/projects/js-packages/critical-css-gen/tests/unit/generate-critical-css.test.js new file mode 100644 index 0000000000000..af4dfddaa15f0 --- /dev/null +++ b/projects/js-packages/critical-css-gen/tests/unit/generate-critical-css.test.js @@ -0,0 +1,142 @@ +const path = require( 'path' ); +const { chromium } = require( 'playwright' ); +const { generateCriticalCSS, BrowserInterfacePlaywright } = require( '../../build-node/node.js' ); +const { dataDirectory } = require( '../lib/data-directory.js' ); +const mockFetch = require( '../lib/mock-fetch.js' ); +const TestServer = require( '../lib/test-server.js' ); + +let testServer = null; + +let testPageUrls; +let browser; + +class MockedFetchInterface extends BrowserInterfacePlaywright { + fetch( url, options ) { + return mockFetch( url, options ); + } +} + +const testPages = {}; + +/** + * Run a batch of CSS generation test runs, verify the results contain (and do not contain) specific substrings. + * Verifies no warnings get generated. + * + * @param {Object[]} testSets - Sets of tests to run, and strings the result should / should not contain. + */ +async function runTestSet( testSets ) { + for ( const { urls, viewports, shouldContain, shouldNotContain, shouldMatch } of testSets ) { + const urlsToGenerateFor = urls || Object.values( testPageUrls ); + const [ css, warnings ] = await generateCriticalCSS( { + urls: urlsToGenerateFor, + viewports: viewports || [ { width: 640, height: 480 } ], + browserInterface: new MockedFetchInterface( browser, urlsToGenerateFor ), + } ); + + expect( warnings ).toHaveLength( 0 ); + + for ( const should of shouldContain || [] ) { + expect( css ).toContain( should ); + } + + for ( const shouldNot of shouldNotContain || [] ) { + expect( css ).not.toContain( shouldNot ); + } + + for ( const regexp of shouldMatch || [] ) { + expect( css ).toMatch( regexp ); + } + } +} + +describe( 'Generate Critical CSS', () => { + // Open test pages in tabs ready for tests. + beforeAll( async () => { + testServer = new TestServer( { + 'page-a': path.resolve( dataDirectory, 'page-a' ), + } ); + await testServer.start(); + + testPageUrls = { + pageA: testServer.getUrl() + '/page-a/', + }; + + browser = await chromium.launch(); + + for ( const url of Object.values( testPageUrls ) ) { + testPages[ url ] = await browser.newPage(); + await testPages[ url ].goto( url ); + } + } ); + + // Clean up test pages. + afterAll( async () => { + for ( const page of Object.values( testPages ) ) { + await page.close(); + } + if ( browser ) { + await browser.close(); + } + if ( testServer ) { + await testServer.stop(); + } + } ); + + describe( 'Inclusions and Exclusions', () => { + // eslint-disable-next-line jest/expect-expect + it( 'Excludes elements below the fold', async () => { + await runTestSet( [ + { + viewports: [ { width: 640, height: 480 } ], + shouldContain: [ 'div.top' ], + shouldNotContain: [ 'div.four_eighty', 'div.six_hundred', 'div.seven_sixty_eight' ], + }, + + { + viewports: [ { width: 800, height: 600 } ], + shouldContain: [ 'div.top', 'div.four_eighty' ], + shouldNotContain: [ 'div.eight_hundred', 'div.seven_sixty_eight' ], + }, + ] ); + } ); + + // eslint-disable-next-line jest/expect-expect + it( 'Excludes irrelevant media queries', async () => { + await runTestSet( [ + { + shouldContain: [ '@media screen', '@media all' ], + shouldNotContain: [ '@media print', '@media not screen' ], + }, + ] ); + } ); + + // eslint-disable-next-line jest/expect-expect + it( 'Excludes Critical CSS from a tag', async () => { + await runTestSet( [ + { + shouldNotContain: [ 'sir_not_appearing_in_this_film' ], + }, + ] ); + } ); + + // eslint-disable-next-line jest/expect-expect + it( 'Includes implicit @media rules inherited from tags', async () => { + await runTestSet( [ + { + shouldMatch: [ /@media\s+\(\s*min-width:\s*50px\s*\)\s*{\s*@media\s+screen\s*{/ ], + }, + ] ); + } ); + + // eslint-disable-next-line jest/expect-expect + it( 'Can manage complex implicit @media rules inherited from tags', async () => { + await runTestSet( [ + { + shouldContain: [ + '@media only screen and (max-device-width:480px) and (orientation:landscape){div.complex_media_rules{', + ], + }, + ] ); + } ); + } ); +} ); diff --git a/projects/js-packages/critical-css-gen/tsconfig.browser.json b/projects/js-packages/critical-css-gen/tsconfig.browser.json new file mode 100644 index 0000000000000..c9e55e8858add --- /dev/null +++ b/projects/js-packages/critical-css-gen/tsconfig.browser.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": [ "src/browser.ts" ], + "compilerOptions": { + "outDir": "./build-browser" + } +} diff --git a/projects/js-packages/critical-css-gen/tsconfig.json b/projects/js-packages/critical-css-gen/tsconfig.json new file mode 100644 index 0000000000000..42e5c2a17fbde --- /dev/null +++ b/projects/js-packages/critical-css-gen/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "jetpack-js-tools/tsconfig.tsc.json", + "include": [ "src/node.ts" ], + "exclude": [ "node_modules/**/*" ], + "compilerOptions": { + "outDir": "./build-node", + "target": "es2019", + "sourceMap": true, + "allowJs": false, + "allowSyntheticDefaultImports": true, + "typeRoots": [ "node_modules/@types" ], + "declaration": true + } +}