diff --git a/.eslintrc.js b/.eslintrc.js index 62b27799a..16fc10361 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -43,6 +43,7 @@ module.exports = { '**/__mocks__/snapshot*', '**/storybook-static/**', '**/examples/example-cli/src/**', + '**/devtools-extension/extension/**', ], globals: { $Call: 'readonly', @@ -142,7 +143,7 @@ module.exports = { 'no-unexpected-multiline': 2, 'no-unmodified-loop-condition': 2, 'no-unneeded-ternary': [2, { defaultAssignment: false }], - 'no-unreachable': 2, + 'no-unreachable': 0, 'no-unsafe-finally': 2, 'no-unused-vars': [ 2, diff --git a/.flowconfig b/.flowconfig index 002210a78..476b2cdf7 100644 --- a/.flowconfig +++ b/.flowconfig @@ -11,6 +11,7 @@ [options] experimental.pattern_matching=true +component_syntax=true enums=true emoji=true casting_syntax=as diff --git a/.prettierignore b/.prettierignore index 2cfbe37fa..3a2fee7da 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,4 +12,5 @@ node_modules packages/benchmarks/size/fixtures/lotsOfStyles.js flow-typed examples/example-storybook/storybook-static -examples/example-cli/src \ No newline at end of file +examples/example-cli/src +devtools-extension/extension \ No newline at end of file diff --git a/examples/example-redwoodsdk/src/app/components/Copy.tsx b/examples/example-redwoodsdk/src/app/components/Copy.tsx index d06537957..ec431fe37 100644 --- a/examples/example-redwoodsdk/src/app/components/Copy.tsx +++ b/examples/example-redwoodsdk/src/app/components/Copy.tsx @@ -22,7 +22,16 @@ export function Copy({ textToCopy }: { textToCopy: string }) { const styles = stylex.create({ copyButton: { - backgroundColor: 'transparent', + backgroundColor: { + default: 'transparent', + ':hover': 'rgba(255,255,255,0.1)', + ':focus-visible': 'rgba(255,255,255,0.1)', + '@media not (hover: hover)': { + default: 'rgba(255,255,255,0.1)', + ':hover': 'rgba(255,255,255,0.3)', + ':focus-visible': 'rgba(255,255,255,0.3)', + }, + }, color: '#ffad48', borderWidth: 0, borderRadius: 4, @@ -31,6 +40,5 @@ const styles = stylex.create({ cursor: 'pointer', fontSize: 16, fontWeight: 700, - ':hover': { backgroundColor: 'rgba(255,255,255,0.1)' }, }, }); diff --git a/package-lock.json b/package-lock.json index 27399de79..f16bc0d04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,6 @@ "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -80,7 +79,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -588,7 +586,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -903,7 +900,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -1158,7 +1154,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -1218,7 +1213,6 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -1286,7 +1280,6 @@ "version": "19.2.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1509,7 +1502,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1553,7 +1545,6 @@ "examples/example-react-router/node_modules/react": { "version": "19.2.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -1561,7 +1552,6 @@ "examples/example-react-router/node_modules/react-dom": { "version": "19.2.0", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -1649,7 +1639,6 @@ "version": "7.2.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -1746,7 +1735,6 @@ "examples/example-redwoodsdk/node_modules/@cloudflare/vite-plugin": { "version": "1.15.2", "license": "MIT", - "peer": true, "dependencies": { "@cloudflare/unenv-preset": "2.7.11", "@remix-run/node-fetch-server": "^0.8.0", @@ -1824,7 +1812,6 @@ "version": "19.2.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2142,7 +2129,6 @@ "examples/example-redwoodsdk/node_modules/picomatch": { "version": "4.0.3", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2153,7 +2139,6 @@ "examples/example-redwoodsdk/node_modules/react": { "version": "19.3.0-canary-561ee24d-20251101", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2161,7 +2146,6 @@ "examples/example-redwoodsdk/node_modules/react-dom": { "version": "19.3.0-canary-561ee24d-20251101", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "0.28.0-canary-561ee24d-20251101" }, @@ -2274,7 +2258,6 @@ "examples/example-redwoodsdk/node_modules/rwsdk/node_modules/@types/react": { "version": "19.1.17", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2416,7 +2399,6 @@ "examples/example-redwoodsdk/node_modules/vite": { "version": "7.2.4", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -2539,7 +2521,6 @@ "examples/example-rollup/node_modules/react": { "version": "19.2.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2609,7 +2590,6 @@ "examples/example-rspack/node_modules/react": { "version": "19.2.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2826,7 +2806,6 @@ "version": "8.44.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.0", "@typescript-eslint/types": "8.44.0", @@ -3048,7 +3027,6 @@ "version": "9.36.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3150,7 +3128,6 @@ "version": "3.0.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "^1.0.0" } @@ -3259,7 +3236,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3368,7 +3344,6 @@ "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -3675,7 +3650,6 @@ "version": "19.2.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3700,7 +3674,6 @@ "version": "9.39.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3862,7 +3835,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3873,7 +3845,6 @@ "examples/example-vite-react/node_modules/react": { "version": "19.2.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3996,7 +3967,6 @@ "version": "19.2.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4050,7 +4020,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4061,7 +4030,6 @@ "examples/example-vite-rsc/node_modules/react": { "version": "19.2.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4094,7 +4062,6 @@ "version": "7.1.12", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4212,7 +4179,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4223,7 +4189,6 @@ "examples/example-vite/node_modules/react": { "version": "19.2.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4256,7 +4221,6 @@ "version": "7.2.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4353,7 +4317,6 @@ "version": "19.2.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4424,7 +4387,6 @@ "examples/example-waku/node_modules/picomatch": { "version": "4.0.3", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4435,7 +4397,6 @@ "examples/example-waku/node_modules/react": { "version": "19.2.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4443,7 +4404,6 @@ "examples/example-waku/node_modules/react-dom": { "version": "19.2.0", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4465,7 +4425,6 @@ "examples/example-waku/node_modules/vite": { "version": "7.2.2", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4627,7 +4586,6 @@ "version": "8.17.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4790,7 +4748,6 @@ "examples/example-webpack/node_modules/react": { "version": "19.1.1", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5109,7 +5066,6 @@ "node_modules/@algolia/client-search": { "version": "5.34.0", "license": "MIT", - "peer": true, "dependencies": { "@algolia/client-common": "5.34.0", "@algolia/requester-browser-xhr": "5.34.0", @@ -5418,7 +5374,6 @@ "node_modules/@babel/core": { "version": "7.28.5", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -7423,8 +7378,7 @@ "node_modules/@cloudflare/workers-types": { "version": "4.20251121.0", "devOptional": true, - "license": "MIT OR Apache-2.0", - "peer": true + "license": "MIT OR Apache-2.0" }, "node_modules/@codemirror/autocomplete": { "version": "6.20.0", @@ -7732,7 +7686,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -7753,7 +7706,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -9037,7 +8989,6 @@ "node_modules/@fortawesome/fontawesome-svg-core": { "version": "6.7.2", "license": "MIT", - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.2" }, @@ -10590,7 +10541,6 @@ "node_modules/@mdx-js/mdx/node_modules/@babel/core": { "version": "7.12.9", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/generator": "^7.12.5", @@ -11173,7 +11123,6 @@ "version": "3.6.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^2.4.4", "@octokit/graphql": "^4.5.8", @@ -11376,7 +11325,6 @@ "version": "8.17.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11734,7 +11682,6 @@ "version": "5.2.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", @@ -12047,7 +11994,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rspack/cli": { "version": "1.6.5", @@ -12072,7 +12020,6 @@ "version": "1.6.4", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@module-federation/runtime-tools": "0.21.4", "@rspack/binding": "1.6.4", @@ -12128,7 +12075,6 @@ "version": "8.17.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12379,7 +12325,6 @@ "version": "8.13.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", @@ -12852,6 +12797,10 @@ "resolved": "packages/@stylexjs/cli", "link": true }, + "node_modules/@stylexjs/devtools-extension": { + "resolved": "packages/@stylexjs/devtools-extension", + "link": true + }, "node_modules/@stylexjs/eslint-plugin": { "resolved": "packages/@stylexjs/eslint-plugin", "link": true @@ -13019,7 +12968,6 @@ "node_modules/@svgr/core": { "version": "6.5.1", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.19.6", "@svgr/babel-preset": "^6.5.1", @@ -13841,7 +13789,6 @@ "node_modules/@types/react": { "version": "18.3.23", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -13960,7 +13907,8 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/unist": { "version": "2.0.11", @@ -14671,7 +14619,6 @@ "version": "3.2.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", @@ -14867,6 +14814,7 @@ "version": "3.2.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", @@ -15202,7 +15150,6 @@ "node_modules/acorn": { "version": "8.15.0", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -15275,7 +15222,6 @@ "node_modules/ajv": { "version": "6.12.6", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -15330,7 +15276,6 @@ "node_modules/algoliasearch": { "version": "4.25.2", "license": "MIT", - "peer": true, "dependencies": { "@algolia/cache-browser-local-storage": "4.25.2", "@algolia/cache-common": "4.25.2", @@ -15787,7 +15732,6 @@ "node_modules/astring": { "version": "1.9.0", "license": "MIT", - "peer": true, "bin": { "astring": "bin/astring" } @@ -16460,7 +16404,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -16555,6 +16498,7 @@ "version": "6.7.14", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -17215,8 +17159,7 @@ }, "node_modules/codemirror": { "version": "5.65.19", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/collapse-white-space": { "version": "1.0.6", @@ -17496,7 +17439,6 @@ "node_modules/copy-webpack-plugin/node_modules/ajv": { "version": "8.17.1", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -17792,7 +17734,6 @@ "node_modules/css-minimizer-webpack-plugin/node_modules/ajv": { "version": "8.17.1", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -18669,8 +18610,7 @@ }, "node_modules/devtools-protocol": { "version": "0.0.1495869", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dir-glob": { "version": "3.0.1", @@ -18768,6 +18708,7 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -19343,7 +19284,6 @@ "node_modules/eslint": { "version": "8.57.1", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -19565,7 +19505,6 @@ "version": "2.32.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -19617,7 +19556,6 @@ "version": "6.10.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -19646,7 +19584,6 @@ "version": "7.37.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -19678,7 +19615,6 @@ "version": "4.6.2", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -20858,7 +20794,6 @@ "node_modules/flow-api-translator/node_modules/typescript": { "version": "5.3.2", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22416,7 +22351,6 @@ "node_modules/hermes-eslint": { "version": "0.32.1", "license": "MIT", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "hermes-estree": "0.32.1", @@ -22531,7 +22465,6 @@ "node_modules/hono": { "version": "4.10.6", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -25068,7 +25001,6 @@ "version": "26.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -25201,7 +25133,6 @@ "node_modules/kysely": { "version": "0.28.8", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" } @@ -25268,7 +25199,6 @@ "node_modules/lightningcss": { "version": "1.30.2", "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -26017,6 +25947,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -27326,7 +27257,6 @@ "node_modules/mini-css-extract-plugin/node_modules/ajv": { "version": "8.17.1", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -28741,7 +28671,6 @@ "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.57.0" }, @@ -28804,7 +28733,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -29212,7 +29140,6 @@ "version": "7.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -29414,7 +29341,6 @@ "node_modules/postcss-selector-parser": { "version": "6.1.2", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -29494,7 +29420,6 @@ "node_modules/prettier": { "version": "3.5.3", "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -29509,7 +29434,6 @@ "version": "0.32.0", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "prettier": "^3.0.0" } @@ -29967,7 +29891,6 @@ "node_modules/react": { "version": "17.0.2", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -30104,7 +30027,6 @@ "node_modules/react-dom": { "version": "17.0.2", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -30164,7 +30086,6 @@ "name": "@docusaurus/react-loadable", "version": "5.5.2", "license": "MIT", - "peer": true, "dependencies": { "@types/react": "*", "prop-types": "^15.6.2" @@ -30190,7 +30111,6 @@ "node_modules/react-refresh": { "version": "0.17.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -30198,7 +30118,6 @@ "node_modules/react-router": { "version": "5.3.4", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -30250,7 +30169,6 @@ "resolved": "https://registry.npmjs.org/react-server-dom-webpack/-/react-server-dom-webpack-19.2.1.tgz", "integrity": "sha512-6z3FuEtZ7wVWyPYRhKKRo4TF7IyQ7XrQZRW1fTjzK2mLVmcv7xJtoANSixaUJ+EFjCh2ekNOaBSoI9spiMSnNA==", "license": "MIT", - "peer": true, "dependencies": { "acorn-loose": "^8.3.0", "neo-async": "^2.6.1", @@ -31071,7 +30989,6 @@ "node_modules/remark-mdx/node_modules/@babel/core": { "version": "7.12.9", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/generator": "^7.12.5", @@ -31702,7 +31619,6 @@ "node_modules/rollup": { "version": "4.45.1", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -33218,7 +33134,6 @@ "integrity": "sha512-kfr6kxQAjA96ADlH6FMALJwJ+eM80UqXy106yVHNgdsAP/CdzkkicglRAhZAvUycXK9AeadF6KZ00CWLtVMN4w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@testing-library/jest-dom": "^6.6.3", @@ -34181,7 +34096,6 @@ "node_modules/terser-webpack-plugin/node_modules/ajv": { "version": "8.17.1", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -34394,7 +34308,6 @@ "node_modules/tinyglobby/node_modules/picomatch": { "version": "4.0.3", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -34406,6 +34319,7 @@ "version": "1.1.1", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^18.0.0 || >=20.0.0" } @@ -34644,8 +34558,7 @@ }, "node_modules/tslib": { "version": "2.8.1", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/turbo-stream": { "version": "3.1.0", @@ -34781,7 +34694,6 @@ "node_modules/typescript": { "version": "5.9.3", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -34849,7 +34761,6 @@ "version": "8.46.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -35155,7 +35066,6 @@ "node_modules/unenv": { "version": "2.0.0-rc.24", "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -35382,6 +35292,7 @@ "node_modules/unplugin": { "version": "2.3.11", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", @@ -35395,6 +35306,7 @@ "node_modules/unplugin/node_modules/picomatch": { "version": "4.0.3", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -35947,6 +35859,7 @@ "version": "3.2.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", @@ -35968,6 +35881,7 @@ "version": "6.5.0", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12.0.0" }, @@ -35996,6 +35910,7 @@ "version": "7.1.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -36200,6 +36115,7 @@ "version": "3.2.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", @@ -36225,6 +36141,7 @@ "version": "3.0.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0" } @@ -36233,6 +36150,7 @@ "version": "6.5.0", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12.0.0" }, @@ -36421,7 +36339,6 @@ "node_modules/webpack": { "version": "5.101.3", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -36647,7 +36564,6 @@ "node_modules/webpack-dev-middleware/node_modules/ajv": { "version": "8.17.1", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -36757,7 +36673,6 @@ "node_modules/webpack-dev-server/node_modules/ajv": { "version": "8.17.1", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -36857,7 +36772,6 @@ "node_modules/webpack/node_modules/ajv": { "version": "8.17.1", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -37123,7 +37037,6 @@ "version": "1.20251118.0", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -37141,7 +37054,6 @@ "node_modules/wrangler": { "version": "4.50.0", "license": "MIT OR Apache-2.0", - "peer": true, "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.7.11", @@ -37815,7 +37727,6 @@ "version": "2.3.1", "devOptional": true, "license": "ISC", - "peer": true, "engines": { "node": ">= 14" } @@ -38089,7 +38000,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -38119,6 +38029,186 @@ "scripts": "0.17.4" } }, + "packages/@stylexjs/devtools-extension": { + "version": "0.17.3", + "license": "MIT", + "dependencies": { + "@stylexjs/stylex": "0.17.3", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@babel/plugin-transform-flow-strip-types": "^7.27.1", + "@rollup/plugin-babel": "^6.0.4", + "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.3.0", + "@rollup/plugin-replace": "^6.0.1", + "@stylexjs/unplugin": "0.17.3", + "rollup": "^4.24.0" + } + }, + "packages/@stylexjs/devtools-extension/node_modules/@rollup/plugin-commonjs": { + "version": "28.0.9", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz", + "integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "packages/@stylexjs/devtools-extension/node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "packages/@stylexjs/devtools-extension/node_modules/@stylexjs/babel-plugin": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@stylexjs/babel-plugin/-/babel-plugin-0.17.3.tgz", + "integrity": "sha512-C9KOQUa+PS7Ef6KGLfEuevzP5lA183g5iu+xito14tN1Tx4OfQe5oEo6O+ntHSVdyZGsnLj6aCr9elVg3yLoOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.8", + "@babel/helper-module-imports": "^7.25.9", + "@babel/traverse": "^7.26.8", + "@babel/types": "^7.26.8", + "@dual-bundle/import-meta-resolve": "^4.1.0", + "@stylexjs/shared": "0.17.3", + "@stylexjs/stylex": "0.17.3", + "postcss-value-parser": "^4.1.0" + } + }, + "packages/@stylexjs/devtools-extension/node_modules/@stylexjs/shared": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@stylexjs/shared/-/shared-0.17.3.tgz", + "integrity": "sha512-cnGYrskfEa8QGFFJG1JYCqPAstxFqe/aU7xuTDIIbL+38DQ4MDqSJOQFOWrqqma72UZXuGHAcJ7NqqnCVTKXTA==", + "dev": true, + "license": "MIT" + }, + "packages/@stylexjs/devtools-extension/node_modules/@stylexjs/stylex": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@stylexjs/stylex/-/stylex-0.17.3.tgz", + "integrity": "sha512-uJKi6sWvsZo7A0Hku0SjnnxsYkQVyi/e1g34/Uik3PXLBVnGMZ+6N9NiwVGdfGQ+/ulqUqEHktROG02279Ug0Q==", + "license": "MIT", + "dependencies": { + "css-mediaquery": "^0.1.2", + "invariant": "^2.2.4", + "styleq": "0.2.1" + } + }, + "packages/@stylexjs/devtools-extension/node_modules/@stylexjs/unplugin": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@stylexjs/unplugin/-/unplugin-0.17.3.tgz", + "integrity": "sha512-v0b7CaLq1qD1qtOQtwly0ERPH+B9dAXwrNUpEFP4XupPRXAhcusmDnDtKddP2tOgS/aCsKgb0ElxOJ9ufJ2a/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.8", + "@babel/plugin-syntax-flow": "^7.26.0", + "@babel/plugin-syntax-jsx": "^7.25.9", + "@babel/plugin-syntax-typescript": "^7.25.9", + "@stylexjs/babel-plugin": "0.17.3", + "browserslist": "^4.24.0", + "lightningcss": "^1.29.1" + }, + "peerDependencies": { + "unplugin": "^2.3.11" + } + }, + "packages/@stylexjs/devtools-extension/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "packages/@stylexjs/devtools-extension/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "packages/@stylexjs/devtools-extension/node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "packages/@stylexjs/devtools-extension/node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "packages/@stylexjs/devtools-extension/node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, "packages/@stylexjs/eslint-plugin": { "version": "0.17.4", "license": "MIT", @@ -38201,7 +38291,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -38310,7 +38399,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/@stylexjs/devtools-extension/.babelrc.js b/packages/@stylexjs/devtools-extension/.babelrc.js new file mode 100644 index 000000000..58429fc00 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/.babelrc.js @@ -0,0 +1,28 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +const BABEL_ENV = process.env['BABEL_ENV']; + +module.exports = { + assumptions: { + iterableIsArray: true, + }, + presets: [ + // [ + // '@babel/preset-env', + // { + // exclude: ['@babel/plugin-transform-typeof-symbol'], + // targets: 'defaults', + // // Convert files to cjs for jest testing + // modules: BABEL_ENV === 'test' ? 'cjs' : false, + // }, + // ], + '@babel/preset-flow', + '@babel/preset-react', + ], + plugins: [['babel-plugin-syntax-hermes-parser', { flow: 'detect' }]], +}; diff --git a/packages/@stylexjs/devtools-extension/.gitignore b/packages/@stylexjs/devtools-extension/.gitignore new file mode 100644 index 000000000..6f722e973 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/.gitignore @@ -0,0 +1,3 @@ + +extension/assets +extension/*.html diff --git a/packages/@stylexjs/devtools-extension/README.md b/packages/@stylexjs/devtools-extension/README.md new file mode 100644 index 000000000..734577798 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/README.md @@ -0,0 +1,35 @@ +# @stylexjs/devtools-extension + +Chrome DevTools extension for debugging StyleX in development. + +## Develop / Build + +Source lives in `src/` (React + StyleX). The loadable Chrome extension is the +build output in `extension/`. + +```sh +npm run build -w @stylexjs/devtools-extension +``` + +## Load in Chrome + +1. Open `chrome://extensions` +2. Enable **Developer mode** +3. Click **Load unpacked** +4. Select `packages/@stylexjs/devtools-extension/extension` + +## Use + +1. Open DevTools → **Elements** +2. Select an element that has `data-style-src` +3. Open the **StyleX** tab in the Elements sidebar + +For best results, enable StyleX dev mode so the DOM includes `data-style-src` +and dev marker classNames: + +```js +// @stylexjs/babel-plugin +{ + dev: true, +} +``` diff --git a/packages/@stylexjs/devtools-extension/devtools.html b/packages/@stylexjs/devtools-extension/devtools.html new file mode 100644 index 000000000..b6a6982ba --- /dev/null +++ b/packages/@stylexjs/devtools-extension/devtools.html @@ -0,0 +1,11 @@ + + + + + + StyleX DevTools + + + + + diff --git a/packages/@stylexjs/devtools-extension/extension/manifest.json b/packages/@stylexjs/devtools-extension/extension/manifest.json new file mode 100644 index 000000000..515e97937 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/extension/manifest.json @@ -0,0 +1,8 @@ +{ + "manifest_version": 3, + "name": "StyleX DevTools", + "version": "0.1.0", + "description": "Inspect StyleX-applied styles and their sources in the Elements panel.", + "devtools_page": "devtools.html" +} + diff --git a/packages/@stylexjs/devtools-extension/flow-types/chrome.js b/packages/@stylexjs/devtools-extension/flow-types/chrome.js new file mode 100644 index 000000000..e57ef677e --- /dev/null +++ b/packages/@stylexjs/devtools-extension/flow-types/chrome.js @@ -0,0 +1,81 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +type Devtools = { + inspectedWindow: InspectedWindow, + panels: Panels, + network: Network, + ... +}; + +export type InspectedWindowEvalOptions = { + includeCommandLineAPI?: boolean, + ... +}; + +export type ExceptionInfo = { + isException: boolean, + value?: mixed, + ... +}; + +export type Resource = { + url: string, + getContent: ( + callback: (content: ?string, encoding: ?string) => mixed, + ) => void, + ... +}; + +export type InspectedWindow = { + eval: ( + expression: string, + options: InspectedWindowEvalOptions, + callback: (result: mixed, exceptionInfo?: ExceptionInfo) => mixed, + ) => void, + getResources: (callback: (resources: Array) => mixed) => void, + ... +}; + +export type DevtoolsEvent = { + addListener: (callback: (...args: any[]) => mixed) => void, + removeListener: (callback: (...args: any[]) => mixed) => void, + ... +}; + +export type SidebarPane = { + setPage: (page: string) => void, + setHeight: (height: number) => void, + ... +}; + +export type Panels = { + elements: { + createSidebarPane: ( + title: string, + callback: (pane: SidebarPane) => mixed, + ) => void, + onSelectionChanged: DevtoolsEvent, + ... + }, + openResource: (url: string, lineNumber?: number) => void, + ... +}; + +export type Network = { + onNavigated: DevtoolsEvent, + ... +}; + +declare var chrome: { + devtools: Devtools, + ... +}; + +export const devtools: Devtools = chrome.devtools; diff --git a/packages/@stylexjs/devtools-extension/package.json b/packages/@stylexjs/devtools-extension/package.json new file mode 100644 index 000000000..29d983fd7 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/package.json @@ -0,0 +1,33 @@ +{ + "name": "@stylexjs/devtools-extension", + "version": "0.17.3", + "private": true, + "description": "Chrome DevTools extension for debugging StyleX.", + "repository": { + "type": "git", + "url": "git+https://github.com/facebook/stylex.git" + }, + "license": "MIT", + "scripts": { + "dev": "rollup -c --watch", + "build": "rollup -c" + }, + "files": [ + "extension/**" + ], + "dependencies": { + "@stylexjs/stylex": "0.17.3", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@babel/plugin-transform-flow-strip-types": "^7.27.1", + "@stylexjs/unplugin": "0.17.3", + "@rollup/plugin-babel": "^6.0.4", + "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.3.0", + "@rollup/plugin-replace": "^6.0.1", + "rollup": "^4.24.0" + } +} diff --git a/packages/@stylexjs/devtools-extension/panel.html b/packages/@stylexjs/devtools-extension/panel.html new file mode 100644 index 000000000..3a5f86030 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/panel.html @@ -0,0 +1,13 @@ + + + + + + StyleX + + + +
+ + + diff --git a/packages/@stylexjs/devtools-extension/public/manifest.json b/packages/@stylexjs/devtools-extension/public/manifest.json new file mode 100644 index 000000000..515e97937 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/public/manifest.json @@ -0,0 +1,8 @@ +{ + "manifest_version": 3, + "name": "StyleX DevTools", + "version": "0.1.0", + "description": "Inspect StyleX-applied styles and their sources in the Elements panel.", + "devtools_page": "devtools.html" +} + diff --git a/packages/@stylexjs/devtools-extension/rollup.config.mjs b/packages/@stylexjs/devtools-extension/rollup.config.mjs new file mode 100644 index 000000000..676d0c877 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/rollup.config.mjs @@ -0,0 +1,142 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs/promises'; +import { babel } from '@rollup/plugin-babel'; +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import replace from '@rollup/plugin-replace'; +import { browserslistToTargets } from 'lightningcss'; +import browserslist from 'browserslist'; +import stylex from '@stylexjs/unplugin'; + +const rootDir = path.dirname(fileURLToPath(import.meta.url)); +const outDir = path.resolve(rootDir, 'extension'); +const extensions = ['.js', '.jsx']; +const isWatch = Boolean(process.env.ROLLUP_WATCH); + +function cssBundle({ fileName = 'assets/style.css' } = {}) { + let styles = new Map(); + return { + name: 'css-bundle', + buildStart() { + styles = new Map(); + }, + resolveId(source, importer) { + if (!source.endsWith('.css')) return null; + const resolved = importer + ? path.resolve(path.dirname(importer), source) + : path.resolve(source); + return { id: resolved, moduleSideEffects: true }; + }, + async load(id) { + if (!id.endsWith('.css')) return null; + const css = await fs.readFile(id, 'utf8'); + styles.set(id, css); + this.addWatchFile(id); + return 'export default ""'; + }, + generateBundle() { + if (styles.size === 0) return; + const combined = Array.from(styles.values()).join('\n'); + this.emitFile({ + type: 'asset', + fileName, + source: combined, + }); + }, + }; +} + +function copyStatic({ outDir, targets }) { + const resolved = targets.map(({ src, dest }) => ({ + src: path.resolve(rootDir, src), + dest: path.resolve(outDir, dest), + })); + async function copyAll() { + await fs.mkdir(outDir, { recursive: true }); + await Promise.all( + resolved.map(async ({ src, dest }) => { + await fs.mkdir(path.dirname(dest), { recursive: true }); + await fs.copyFile(src, dest); + }), + ); + } + return { + name: 'copy-static', + buildStart() { + for (const { src } of resolved) { + this.addWatchFile(src); + } + }, + async generateBundle() { + await copyAll(); + }, + }; +} + +export default { + input: { + devtools: path.resolve(rootDir, 'src/devtools/main.js'), + panel: path.resolve(rootDir, 'src/panel/main.js'), + }, + output: { + dir: outDir, + format: 'es', + sourcemap: true, + entryFileNames: 'assets/[name].js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name][extname]', + }, + plugins: [ + cssBundle(), + replace({ + preventAssignment: true, + values: { + 'process.env.NODE_ENV': JSON.stringify( + isWatch ? 'development' : 'production', + ), + }, + }), + stylex.rollup({ + devMode: 'off', + useCSSLayers: true, + lightningcssOptions: { + targets: browserslistToTargets(browserslist('>= 2%')), + }, + }), + babel({ + babelHelpers: 'bundled', + extensions, + babelrc: true, + configFile: path.resolve(rootDir, '.babelrc.js'), + include: [ + path.resolve(rootDir, 'src/**/*'), + path.resolve(rootDir, 'flow-types/**/*'), + ], + exclude: ['**/node_modules/**'], + }), + nodeResolve({ + browser: true, + extensions, + preferBuiltins: false, + }), + json(), + commonjs({ include: /node_modules/ }), + copyStatic({ + outDir, + targets: [ + { src: 'devtools.html', dest: 'devtools.html' }, + { src: 'panel.html', dest: 'panel.html' }, + { src: 'public/manifest.json', dest: 'manifest.json' }, + ], + }), + ], +}; diff --git a/packages/@stylexjs/devtools-extension/src/devtools/api.js b/packages/@stylexjs/devtools-extension/src/devtools/api.js new file mode 100644 index 000000000..784494325 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/devtools/api.js @@ -0,0 +1,106 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import { devtools } from '../../flow-types/chrome.js'; +import type { + ExceptionInfo, + InspectedWindowEvalOptions, + Resource, +} from '../../flow-types/chrome.js'; + +export { devtools }; +export type { ExceptionInfo, InspectedWindowEvalOptions, Resource }; + +export function evalInInspectedWindow( + fn: () => T, + options?: InspectedWindowEvalOptions, +): Promise { + const expression = `(${fn.toString()})()`; + const mergedOptions = { + // includeCommandLineAPI: true, + ...options, + }; + + return new Promise((resolve, reject) => { + devtools.inspectedWindow.eval( + expression, + mergedOptions as any, + (result, exceptionInfo) => { + if (exceptionInfo && exceptionInfo.isException) { + const msg = + exceptionInfo.value != null + ? `Error: ${String(exceptionInfo.value)}` + : 'Error evaluating in inspected window.'; + reject(new Error(msg)); + return; + } + resolve(result as any as T); + }, + ); + }); +} + +export function evalInInspectedWindowWithArgs( + fn: (args: any) => T, + args: mixed, + options?: InspectedWindowEvalOptions, +): Promise { + const serializedArgs = JSON.stringify(args); + const expression = `(${fn.toString()})(${serializedArgs ?? ''})`; + const mergedOptions = { + // includeCommandLineAPI: true, + ...options, + }; + + return new Promise((resolve, reject) => { + devtools.inspectedWindow.eval( + expression, + mergedOptions as any, + (result, exceptionInfo) => { + if (exceptionInfo && exceptionInfo.isException) { + const msg = + exceptionInfo.value != null + ? `Error: ${String(exceptionInfo.value)}` + : 'Error evaluating in inspected window.'; + reject(new Error(msg)); + return; + } + resolve(result as any as T); + }, + ); + }); +} + +export function getResources(): Promise> { + return new Promise((resolve) => { + devtools.inspectedWindow.getResources((resources) => resolve(resources)); + }); +} + +export function getResourceText(resource: Resource): Promise { + return new Promise((resolve) => { + resource.getContent((content, encoding) => { + if (encoding === 'base64' && typeof content === 'string') { + try { + resolve(atob(content)); + } catch { + resolve(null); + } + return; + } + resolve(content); + }); + }); +} + +export function openResource(url: string, lineZeroBased?: number): void { + devtools.panels.openResource(url, lineZeroBased); +} diff --git a/packages/@stylexjs/devtools-extension/src/devtools/createSidebarPane.js b/packages/@stylexjs/devtools-extension/src/devtools/createSidebarPane.js new file mode 100644 index 000000000..7bf958dd8 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/devtools/createSidebarPane.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import { devtools } from '../../flow-types/chrome.js'; + +export function createStylexSidebarPane(): void { + devtools.panels.elements.createSidebarPane('StyleX', (pane) => { + pane.setPage('panel.html'); + pane.setHeight(400); + }); +} diff --git a/packages/@stylexjs/devtools-extension/src/devtools/events.js b/packages/@stylexjs/devtools-extension/src/devtools/events.js new file mode 100644 index 000000000..8fbcc95ae --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/devtools/events.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import { devtools } from './api.js'; + +export function subscribeToSelectionAndNavigation( + callback: () => mixed, +): () => void { + devtools.panels.elements.onSelectionChanged.addListener(callback); + devtools.network.onNavigated.addListener(callback); + + return () => { + devtools.panels.elements.onSelectionChanged.removeListener(callback); + devtools.network.onNavigated.removeListener(callback); + }; +} diff --git a/packages/@stylexjs/devtools-extension/src/devtools/main.js b/packages/@stylexjs/devtools-extension/src/devtools/main.js new file mode 100644 index 000000000..35a6be9af --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/devtools/main.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import { createStylexSidebarPane } from './createSidebarPane.js'; + +createStylexSidebarPane(); diff --git a/packages/@stylexjs/devtools-extension/src/devtools/overrides.js b/packages/@stylexjs/devtools-extension/src/devtools/overrides.js new file mode 100644 index 000000000..4d1c42884 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/devtools/overrides.js @@ -0,0 +1,166 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import { evalInInspectedWindowWithArgs } from './api.js'; +import type { StylexOverride } from '../types.js'; + +type SwapClassArgs = { + from: string, + to: string, +}; + +type InlineStyleArgs = { + property: string, + value: string, + important?: boolean, +}; + +type ClearInlineArgs = { + property: string, +}; + +type SetOverridesArgs = { + overrides: Array, +}; + +declare const window: any; + +function swapClassNameInInspectedWindow({ from, to }: SwapClassArgs): boolean { + const overrideElementKey = '__stylexDevtoolsOverrideElement__'; + // $FlowExpectedError[cannot-resolve-name] + const current = typeof $0 !== 'undefined' ? $0 : null; + const stored = window[overrideElementKey]; + const sameNode = + stored && + current && + typeof stored.isSameNode === 'function' && + stored.isSameNode(current); + if (!sameNode && current) { + window[overrideElementKey] = current; + } + const element = sameNode + ? stored + : current || + (stored && typeof stored.isSameNode === 'function' ? stored : null); + if (!element || !from || !to) return false; + element.classList.remove(from); + element.classList.add(to); + return true; +} + +function setInlineStyleInInspectedWindow({ + property, + value, + important, +}: InlineStyleArgs): boolean { + const overrideElementKey = '__stylexDevtoolsOverrideElement__'; + // $FlowExpectedError[cannot-resolve-name] + const current = typeof $0 !== 'undefined' ? $0 : null; + const stored = window[overrideElementKey]; + const sameNode = + stored && + current && + typeof stored.isSameNode === 'function' && + stored.isSameNode(current); + if (!sameNode && current) { + window[overrideElementKey] = current; + } + const element = sameNode + ? stored + : current || + (stored && typeof stored.isSameNode === 'function' ? stored : null); + if (!element || !property) return false; + element.style.setProperty(property, value, important ? 'important' : ''); + return true; +} + +function clearInlineStyleInInspectedWindow({ + property, +}: ClearInlineArgs): boolean { + const overrideElementKey = '__stylexDevtoolsOverrideElement__'; + // $FlowExpectedError[cannot-resolve-name] + const current = typeof $0 !== 'undefined' ? $0 : null; + const stored = window[overrideElementKey]; + const sameNode = + stored && + current && + typeof stored.isSameNode === 'function' && + stored.isSameNode(current); + if (!sameNode && current) { + window[overrideElementKey] = current; + } + const element = sameNode + ? stored + : current || + (stored && typeof stored.isSameNode === 'function' ? stored : null); + if (!element || !property) return false; + element.style.removeProperty(property); + return true; +} + +function setStylexOverridesInInspectedWindow({ + overrides, +}: SetOverridesArgs): boolean { + try { + const overrideElementKey = '__stylexDevtoolsOverrideElement__'; + const overrideStoreKey = '__stylexDevtoolsOverrides__'; + // $FlowExpectedError[cannot-resolve-name] + const current = typeof $0 !== 'undefined' ? $0 : null; + const stored = window[overrideElementKey]; + const sameNode = + stored && + current && + typeof stored.isSameNode === 'function' && + stored.isSameNode(current); + if (!sameNode && current) { + window[overrideElementKey] = current; + } + const element = sameNode + ? stored + : current || + (stored && typeof stored.isSameNode === 'function' ? stored : null); + if (!element) return false; + const existing = window[overrideStoreKey]; + const store: WeakMap = + existing && typeof existing.get === 'function' ? existing : new WeakMap(); + if (existing == null || existing !== store) { + window[overrideStoreKey] = store; + } + if (!Array.isArray(overrides) || overrides.length === 0) { + store.delete(element); + } else { + store.set(element, overrides); + } + return true; + } catch { + return false; + } +} + +export function swapClassName(args: SwapClassArgs): Promise { + return evalInInspectedWindowWithArgs(swapClassNameInInspectedWindow, args); +} + +export function setInlineStyle(args: InlineStyleArgs): Promise { + return evalInInspectedWindowWithArgs(setInlineStyleInInspectedWindow, args); +} + +export function clearInlineStyle(args: ClearInlineArgs): Promise { + return evalInInspectedWindowWithArgs(clearInlineStyleInInspectedWindow, args); +} + +export function setStylexOverrides( + overrides: Array, +): Promise { + return evalInInspectedWindowWithArgs(setStylexOverridesInInspectedWindow, { + overrides, + }); +} diff --git a/packages/@stylexjs/devtools-extension/src/devtools/resources.js b/packages/@stylexjs/devtools-extension/src/devtools/resources.js new file mode 100644 index 000000000..efea22f69 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/devtools/resources.js @@ -0,0 +1,66 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import { getResourceText, getResources, openResource } from './api.js'; +import type { Resource } from './api.js'; +import { findBestMatchingResource } from '../utils/resourceMatching.js'; +import { formatSourceSnippet } from '../utils/sourceSnippet.js'; +import type { SourcePreview } from '../types.js'; + +function normalizeLineToZeroBased(line: ?number | null): number { + if (typeof line !== 'number') return 0; + return Math.max(line - 1, 0); +} + +export async function findBestResourceForFile( + file: string, +): Promise { + const resources = await getResources(); + return findBestMatchingResource(resources, file); +} + +export async function openSourceBestEffort( + file: string, + line: number | null, +): Promise { + const best = await findBestResourceForFile(file); + if (!best) { + throw new Error(`Could not find a loaded resource matching: ${file}`); + } + openResource(best.url, normalizeLineToZeroBased(line)); +} + +export async function getSourcePreview( + file: string, + line: number | null, +): Promise { + const best = await findBestResourceForFile(file); + if (!best) { + return { + url: '', + snippet: `Could not find a DevTools resource matching:\n${file}`, + }; + } + + const content = await getResourceText(best); + if (typeof content !== 'string' || content.length === 0) { + return { + url: best.url, + snippet: + 'DevTools did not provide file contents for this resource.\nTry opening it in the Sources panel once, then retry.', + }; + } + + return { + url: best.url, + snippet: formatSourceSnippet(content, line), + }; +} diff --git a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js new file mode 100644 index 000000000..4d4cc3428 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js @@ -0,0 +1,533 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import type { AtomicStyleRule, StylexDebugData } from '../types.js'; + +type RuleData = $ReadOnly<{ + selectorText: string, + classNames: Array, + conditions: Array, + cssText: string, + order: number, +}>; + +declare const window: any; + +// NOTE: +// This function is stringified and used using `evalInInspectedWindow` in the panel. +// So it must be a completely self-contained function that doesn't rely on any external variables or functions. +export function collectStylexDebugData(): StylexDebugData { + function safeString(value: mixed): string { + if (typeof value === 'string') return value; + if (value == null) return ''; + return String(value); + } + + function isCSSStyleRule(rule: CSSRule): implies rule is CSSStyleRule { + // $FlowExpectedError[incompatible-type-guard] + return rule.type === 1; + } + + function parseDataStyleSrc(raw: string): Array { + if (typeof raw !== 'string' || raw.trim() === '') return []; + return raw + .split(';') + .map((s) => s.trim()) + .filter(Boolean); + } + + const OVERRIDE_STORE_KEY = '__stylexDevtoolsOverrides__'; + const OVERRIDE_ELEMENT_KEY = '__stylexDevtoolsOverrideElement__'; + + function getOverridesForElement(element: ?HTMLElement): Array { + if (!element) return []; + const store = window[OVERRIDE_STORE_KEY]; + if (!store || typeof store.get !== 'function') { + return []; + } + const stored = window[OVERRIDE_ELEMENT_KEY]; + const hasStored = stored && typeof stored.isSameNode === 'function'; + const sameNode = hasStored && stored.isSameNode(element); + const elementKey = sameNode ? stored : element; + if (!sameNode) { + window[OVERRIDE_ELEMENT_KEY] = element; + } + const value = store.get(elementKey); + if (!Array.isArray(value)) return []; + return value.map((item) => + item && typeof item === 'object' ? { ...item } : item, + ); + } + + function parseSourceEntry(raw: mixed): { + raw: string, + file: string, + line: number | null, + } { + const text = safeString(raw).trim(); + const match = text.match(/:(\d+)\s*$/); + if (!match || match.index == null) { + return { raw: text, file: text, line: null }; + } + const line = Number(match[1]); + const file = text.slice(0, match.index); + return { raw: text, file, line: Number.isFinite(line) ? line : null }; + } + + function splitSelectors(selectorText: string): Array { + return selectorText + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + } + + const LAYER_POLYFILL_RE = /:not\(#\\#\)/g; + + function stripLayerPolyfill(selectorText: string): { + cleaned: string, + } { + if (!selectorText) { + return { cleaned: selectorText }; + } + const cleaned = selectorText.replace(LAYER_POLYFILL_RE, ''); + return { + cleaned, + }; + } + + const SIMPLE_CLASS_SELECTOR = /^\.[_a-zA-Z0-9-]+$/; + + function getAtRuleCondition(rule: CSSRule): string | null { + if (!rule || typeof rule.cssText !== 'string') return null; + const braceIndex = rule.cssText.indexOf('{'); + if (braceIndex === -1) return null; + const prelude = rule.cssText.slice(0, braceIndex).trim(); + if (!prelude.startsWith('@')) return null; + if (prelude.startsWith('@layer')) return null; + return prelude; + } + + function parseSelectorCondition(selectorText: string): null | { + baseSelector: string, + pseudoCondition: string | null, + pseudoElementKey: string | null, + } { + const trimmed = selectorText.trim(); + if (!trimmed || trimmed[0] !== '.') return null; + const { cleaned } = stripLayerPolyfill(trimmed); + const firstColonIndex = cleaned.indexOf(':'); + if (firstColonIndex === -1) { + return { + baseSelector: cleaned, + pseudoCondition: null, + pseudoElementKey: null, + }; + } + const baseSelector = cleaned.slice(0, firstColonIndex).trim(); + const suffix = cleaned.slice(firstColonIndex).trim(); + const pseudoElementIndex = suffix.indexOf('::'); + if (pseudoElementIndex !== -1) { + return { + baseSelector, + pseudoCondition: null, + pseudoElementKey: suffix, + }; + } + return { + baseSelector, + pseudoCondition: suffix || null, + pseudoElementKey: null, + }; + } + + function isSimpleClassSelector(baseSelector: string): boolean { + return SIMPLE_CLASS_SELECTOR.test(baseSelector); + } + + function extractClassNames(selectorText: string): Array { + const out = []; + const re = /\.([_a-zA-Z0-9-]+)/g; + let m = re.exec(selectorText); + while (m != null) { + out.push(m[1]); + m = re.exec(selectorText); + } + return out; + } + + function collectStyleRulesFromSheet( + sheet: CSSStyleSheet, + elementClassSet: $ReadOnlySet, + out: Array, + state: { ruleOrder: number, skippedSheets: number }, + ) { + let rules: ?CSSRuleList; + try { + rules = sheet.cssRules; + } catch { + state.skippedSheets += 1; + return; + } + if (!rules) return; + + function walkRules(ruleList: CSSRuleList, conditions: Array) { + for (let i = 0; i < ruleList.length; i += 1) { + const rule = ruleList[i]; + if (!rule) continue; + + // CSSRule.STYLE_RULE === 1 + if (isCSSStyleRule(rule) && rule.selectorText && rule.cssText) { + const selectorText = rule.selectorText; + const classNames = extractClassNames(selectorText); + if (classNames.length === 0) continue; + + let hasIntersection = false; + for (const cls of classNames) { + if (elementClassSet.has(cls)) { + hasIntersection = true; + break; + } + } + if (!hasIntersection) continue; + + out.push({ + selectorText, + classNames, + conditions, + cssText: rule.cssText, + order: state.ruleOrder++, + }); + continue; + } + + if ('cssRules' in rule) { + const atCondition = getAtRuleCondition(rule); + const nextConditions = atCondition + ? [...conditions, atCondition] + : conditions; + // $FlowFixMe[prop-missing] + walkRules(rule.cssRules, nextConditions); + } + } + } + + walkRules(rules, []); + } + + function collectAtomicRulesFromSheet( + sheet: CSSStyleSheet, + out: Array, + state: { skippedSheets: number }, + ) { + let rules: ?CSSRuleList; + try { + rules = sheet.cssRules; + } catch { + state.skippedSheets += 1; + return; + } + if (!rules) return; + + function walkRules(ruleList: CSSRuleList, conditions: Array) { + for (let i = 0; i < ruleList.length; i += 1) { + const rule = ruleList[i]; + if (!rule) continue; + + if (isCSSStyleRule(rule) && rule.selectorText && rule.cssText) { + const decls = parseDeclarationsFromRuleCssText(rule.cssText); + if (decls.length !== 1) continue; + const [decl] = decls; + const selectors = splitSelectors(rule.selectorText); + for (const selector of selectors) { + const selectorInfo = parseSelectorCondition(selector); + if (!selectorInfo) continue; + const { baseSelector, pseudoCondition, pseudoElementKey } = + selectorInfo; + if (!isSimpleClassSelector(baseSelector)) continue; + const className = baseSelector.slice(1); + + const conditionParts: Array = []; + for (const entry of conditions) { + if (!conditionParts.includes(entry)) conditionParts.push(entry); + } + if (pseudoCondition && !conditionParts.includes(pseudoCondition)) { + conditionParts.push(pseudoCondition); + } + + out.push({ + className, + property: decl.property, + value: decl.value, + important: decl.important, + conditions: conditionParts, + ...(pseudoElementKey ? { pseudoElement: pseudoElementKey } : {}), + }); + } + continue; + } + + if ('cssRules' in rule) { + const atCondition = getAtRuleCondition(rule); + const nextConditions = atCondition + ? [...conditions, atCondition] + : conditions; + // $FlowFixMe[prop-missing] + walkRules(rule.cssRules, nextConditions); + } + } + } + + walkRules(rules, []); + } + + function matchesSelector(element: HTMLElement, selectorText: string) { + try { + return element.matches(selectorText); + } catch { + // ignore invalid selectors (e.g. some pseudo-elements) + return false; + } + } + + function stripCssComments(cssText: string) { + let out = ''; + let i = 0; + let inString = false; + let quote = ''; + while (i < cssText.length) { + const ch = cssText[i]; + + if (!inString && ch === '/' && cssText[i + 1] === '*') { + const end = cssText.indexOf('*/', i + 2); + if (end === -1) { + break; + } + i = end + 2; + continue; + } + + out += ch; + if (inString) { + if (ch === quote) { + inString = false; + quote = ''; + } else if (ch === '\\\\') { + // skip escaped char + i += 1; + if (i < cssText.length) out += cssText[i]; + } + } else if (ch === '"' || ch === "'") { + inString = true; + quote = ch; + } + + i += 1; + } + return out; + } + + function splitTopLevel(text: string, delimiterChar: string) { + const parts = []; + let current = ''; + let depth = 0; + let inString = false; + let quote = ''; + + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + + if (inString) { + current += ch; + if (ch === quote) { + inString = false; + quote = ''; + } else if (ch === '\\\\') { + i += 1; + if (i < text.length) current += text[i]; + } + continue; + } + + if (ch === '"' || ch === "'") { + inString = true; + quote = ch; + current += ch; + continue; + } + + if (ch === '(') depth += 1; + if (ch === ')') depth = Math.max(depth - 1, 0); + + if (ch === delimiterChar && depth === 0) { + parts.push(current); + current = ''; + continue; + } + + current += ch; + } + + if (current) parts.push(current); + return parts; + } + + function parseDeclarationsFromRuleCssText(ruleCssText: unknown) { + if (typeof ruleCssText !== 'string') return []; + const cleaned = stripCssComments(ruleCssText); + const start = cleaned.indexOf('{'); + const end = cleaned.lastIndexOf('}'); + if (start === -1 || end === -1 || end <= start) return []; + const body = cleaned.slice(start + 1, end).trim(); + if (!body) return []; + + const decls = []; + const statements = splitTopLevel(body, ';') + .map((s) => s.trim()) + .filter(Boolean); + + for (const stmt of statements) { + const [propPart, ...rest] = splitTopLevel(stmt, ':'); + if (!propPart || rest.length === 0) continue; + const property = propPart.trim(); + const valueRaw = rest.join(':').trim(); + if (!property || !valueRaw) continue; + + let value = valueRaw; + let important = false; + if (/\s!important\s*$/i.test(value)) { + important = true; + value = value.replace(/\s!important\s*$/i, '').trim(); + } + decls.push({ property, value, important }); + } + + return decls; + } + + // $FlowExpectedError[cannot-resolve-name] - $0 helps get the currently selected item + const element = typeof $0 !== 'undefined' ? $0 : null; + if (!element) { + return { + element: { tagName: '—' }, + sources: [], + computed: {}, + atomicRules: [], + overrides: [], + applied: { classes: [] }, + }; + } + + const tagName = safeString(element.tagName).toLowerCase(); + const computedStyle = window.getComputedStyle(element); + const computed: { [string]: string } = {}; + for (let i = 0; i < computedStyle.length; i += 1) { + const prop = computedStyle[i]; + if (!prop) continue; + const value = computedStyle.getPropertyValue(prop); + computed[prop] = value ? value.trim() : ''; + } + const classAttr: string = safeString(element.getAttribute('class')); + const classesOrdered = classAttr.trim() ? classAttr.trim().split(/\s+/) : []; + const elementClassSet = new Set(classesOrdered); + + const dataStyleSrcRaw = safeString(element.getAttribute('data-style-src')); + const sourcesRaw = parseDataStyleSrc(dataStyleSrcRaw); + const sources = sourcesRaw.map(parseSourceEntry); + const overrides = getOverridesForElement(element); + + const state = { ruleOrder: 0, skippedSheets: 0 }; + const rules: Array = []; + const atomicRules: Array = []; + const atomicState = { skippedSheets: 0 }; + + const sheets: Array = Array.from( + document.styleSheets, + ) as $FlowFixMe; + + for (const sheet of sheets) { + collectStyleRulesFromSheet(sheet, elementClassSet, rules, state); + collectAtomicRulesFromSheet(sheet, atomicRules, atomicState); + } + + const classToDecls = new Map>(); + for (const rule of rules) { + const decls = parseDeclarationsFromRuleCssText(rule.cssText); + if (decls.length === 0) continue; + + const selectors = splitSelectors(rule.selectorText); + for (const selector of selectors) { + const selectorInfo = parseSelectorCondition(selector); + if (!selectorInfo) continue; + const { baseSelector, pseudoCondition, pseudoElementKey } = selectorInfo; + + const matchedClasses = extractClassNames(baseSelector).filter( + (cls: string) => elementClassSet.has(cls), + ); + const uniqueMatchedClasses = Array.from(new Set(matchedClasses)); + if (uniqueMatchedClasses.length === 0) continue; + + if (!baseSelector || !matchesSelector(element, baseSelector)) continue; + + const conditionParts: Array = []; + for (const entry of rule.conditions) { + if (!conditionParts.includes(entry)) conditionParts.push(entry); + } + if (pseudoCondition && !conditionParts.includes(pseudoCondition)) { + conditionParts.push(pseudoCondition); + } + const condition = + conditionParts.length > 0 ? conditionParts.join(', ') : 'default'; + + for (const cls of uniqueMatchedClasses) { + const pseudoElementValue = pseudoElementKey || undefined; + const declsWithCondition = decls.map((decl): $FlowFixMe => ({ + ...decl, + condition, + className: cls, + ...((conditionParts.length > 0 + ? { conditions: conditionParts } + : {}) as $FlowFixMe), + ...((pseudoElementValue + ? { pseudoElement: pseudoElementValue } + : {}) as $FlowFixMe), + })); + const declList = classToDecls.get(cls); + if (declList == null) { + classToDecls.set(cls, [...declsWithCondition]); + } else { + declList.push(...declsWithCondition); + } + for (const decl of decls) { + if (computed[decl.property] == null) { + const value = computedStyle.getPropertyValue(decl.property); + computed[decl.property] = value ? value.trim() : ''; + } + } + } + } + } + + const classes = []; + for (const cls of classesOrdered) { + const decls = classToDecls.get(cls); + if (!decls) continue; + classes.push({ name: cls, declarations: decls }); + } + + return { + element: { tagName }, + sources, + computed, + atomicRules, + overrides, + applied: { classes }, + }; +} diff --git a/packages/@stylexjs/devtools-extension/src/panel/App.jsx b/packages/@stylexjs/devtools-extension/src/panel/App.jsx new file mode 100644 index 000000000..8f5286808 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/App.jsx @@ -0,0 +1,254 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import * as React from 'react'; +import { + useState, + useCallback, + useEffect, + use, + startTransition, + Suspense, +} from 'react'; +import * as stylex from '@stylexjs/stylex'; + +import type { StylexDebugData } from '../types.js'; +import { subscribeToSelectionAndNavigation } from '../devtools/events.js'; +import { evalInInspectedWindow } from '../devtools/api.js'; +import { collectStylexDebugData } from '../inspected/collectStylexDebugData.js'; +import { Button } from './components/Button'; +import { DeclarationsList } from './components/DeclarationsList'; +import { SourcesList } from './components/SourcesList'; +import { Section } from './components/Section'; +import { colors } from './theme.stylex'; +import Logo from './components/Logo'; +import { ErrorBoundary } from './components/ErrorBoundary'; + +export function App(): React.Node { + const [count, setCount] = useState(0); + + const refresh = useCallback(() => { + startTransition(async () => { + setCount((x) => x + 1); + }); + }, []); + + const handleSelectionChange = useCallback(() => { + refresh(); + }, [refresh]); + + useEffect( + () => subscribeToSelectionAndNavigation(handleSelectionChange), + [handleSelectionChange], + ); + + return ( + }> + ( + + )} + key={count} + > + + + + ); +} + +function Loading() { + return ( +
+ +
+ ); +} + +function ErrorFallback({ + errorMessage, + retry, +}: { + errorMessage: string, + retry: () => void, +}) { + return ( +
+
{errorMessage}
+ +
+ ); +} + +let cache: ?[number, Promise] = null; +const debugDataPromise = (id: number): Promise => { + if (cache != null && cache[0] === id) { + return cache[1]; + } + const promise = evalInInspectedWindow(collectStylexDebugData); + cache = [id, promise]; + return promise; +}; + +function Panel({ + id, + refresh, +}: { + id: number, + refresh: () => void, +}): React.Node { + const data = use(debugDataPromise(id)); + + const tagName = data?.element?.tagName ?? '—'; + + const classes = data?.applied?.classes ?? []; + const computed = data?.computed ?? {}; + const atomicRules = data?.atomicRules ?? []; + const overrides = data?.overrides ?? []; + + const hasSources = data?.sources?.length > 0; + const hasClasses = classes.length > 0; + const hasOverrides = overrides.length > 0; + const showAppliedSection = hasClasses || hasOverrides; + const showEmptyState = !hasSources && !hasClasses && !hasOverrides; + + return ( +
+
+
+ +
+
+ {tagName} + +
+
+ + {/*
+ {status.message} +
*/} + + {hasSources && ( +
+ {}} sources={data.sources} /> +
+ )} + + {showAppliedSection && ( +
+ +
+ )} + + {showEmptyState && ( +
No styles found
+ )} +
+ ); +} + +const styles = stylex.create({ + fallbackContainer: { + height: '100%', + display: 'flex', + flexDirection: 'column', + gap: 16, + alignItems: 'center', + justifyContent: 'center', + }, + errorMessage: { + boxSizing: 'border-box', + color: 'tomato', + fontSize: '0.8rem', + fontWeight: 400, + padding: 16, + width: '100%', + whiteSpace: 'pre-wrap', + }, + emptyState: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + textAlign: 'center', + color: colors.textMuted, + padding: 16, + }, + root: { + backgroundColor: colors.bg, + color: colors.textPrimary, + fontFamily: + 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif', + fontSize: 12, + minHeight: '100%', + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + }, + logo: { + height: '2rem', + color: colors.textPrimary, + }, + mono: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: 8, + }, + title: { + display: 'flex', + alignItems: 'center', + gap: 8, + fontWeight: 600, + fontSize: 13, + }, + + status: { + color: colors.textMuted, + marginTop: 6, + marginBottom: 10, + }, + statusError: { + color: '#cf222e', + }, + + pill: { + display: 'inline-block', + paddingTop: 1, + paddingRight: 6, + paddingBottom: 1, + paddingLeft: 6, + borderRadius: 999, + borderWidth: 1, + borderStyle: 'solid', + borderColor: colors.border, + backgroundColor: colors.bgRaised, + color: colors.textMuted, + fontSize: 11, + }, +}); diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/Button.js b/packages/@stylexjs/devtools-extension/src/panel/components/Button.js new file mode 100644 index 000000000..8ad2a60bf --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/components/Button.js @@ -0,0 +1,53 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ +'use strict'; + +import * as React from 'react'; +import * as stylex from '@stylexjs/stylex'; +import { colors } from '../theme.stylex'; + +export function Button({ + onClick, + children, + title, + xstyle, +}: { + onClick: (e: MouseEvent) => mixed, + children: React.Node, + title?: string, + xstyle?: stylex.StyleXStyles<>, +}): React.Node { + return ( + + ); +} + +const styles = stylex.create({ + button: { + borderWidth: 1, + borderStyle: 'solid', + borderColor: colors.border, + backgroundColor: { + default: colors.bgRaised, + ':hover': colors.bg, + ':active': colors.bg, + }, + transform: { default: null, ':active': 'scale(0.95)' }, + paddingBlock: 4, + paddingInline: 8, + borderRadius: 8, + }, +}); diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js b/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js new file mode 100644 index 000000000..82b016951 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js @@ -0,0 +1,1880 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import * as React from 'react'; +import { + useState, + useMemo, + useEffect, + useCallback, + useRef, + useId, +} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import { colors } from '../theme.stylex'; +import type { AtomicStyleRule, StylexOverride } from '../../types.js'; +import { + swapClassName, + setInlineStyle, + clearInlineStyle, + setStylexOverrides, +} from '../../devtools/overrides.js'; + +type TDeclaration = $ReadOnly<{ + property: string, + value: string, + important: boolean, + condition?: string, + conditions?: $ReadOnlyArray, + pseudoElement?: string, + className?: string, + ... +}>; + +type TPropertyGroup = $ReadOnly<{ + property: string, + entries: $ReadOnlyArray, +}>; + +type TSection = $ReadOnly<{ + key: string, + properties: $ReadOnlyArray, +}>; + +type TConditionNode = { + key: string, + label: string, + entries: Array, + children: Array, +}; + +type TOverrideValue = { + value: string, + important: boolean, +}; + +type TOverridesByEntry = { [string]: TOverrideValue, ... }; + +type TOverrideUpdater = (override: StylexOverride) => Promise; + +type TOverrideRemover = (id: string) => Promise; + +type TClassOverrideMap = { [string]: StylexOverride, ... }; + +type TAtomicValueIndex = { + values: Array, + valueToClassName: { [string]: string, ... }, +}; + +type TAtomicIndex = { [string]: TAtomicValueIndex, ... }; + +function getConditionParts(entry: TDeclaration): Array { + if (entry.conditions && entry.conditions.length > 0) { + return [...entry.conditions]; + } + if (!entry.condition || entry.condition === 'default') return []; + return entry.condition + .split(',') + .map((part) => part.trim()) + .filter(Boolean); +} + +function moveKeyToFront(list: Array, key: string): Array { + if (!list.includes(key)) { + return list.slice(); + } + return [key, ...list.filter((item) => item !== key)]; +} + +function formatConditionLabel(label: string): string { + if (label === 'default') return 'default'; + return `'${label}'`; +} + +function isAtRuleLabel(label: string): boolean { + return label.startsWith('@'); +} + +function isDefaultLabel(label: string): boolean { + return label.trim() === 'default'; +} + +function collapseDefaultChild(node: TConditionNode): TConditionNode { + if (node.children.length !== 1) return node; + const child = node.children[0]; + if (!isDefaultLabel(child.label)) return node; + return { + ...node, + entries: [...node.entries, ...child.entries], + children: child.children, + }; +} + +function formatValue(value: string, important: boolean): string { + return important ? `${value} !important` : value; +} + +function parseValueInput(raw: string): TOverrideValue { + const trimmed = raw.trim(); + if (trimmed === '') { + return { value: '', important: false }; + } + if (!/\s!important\s*$/i.test(trimmed)) { + return { value: trimmed, important: false }; + } + return { + value: trimmed.replace(/\s!important\s*$/i, '').trim(), + important: true, + }; +} + +function buildInlineOverrideId( + property: string, + pseudoElement?: string, +): string { + return ['inline', property, pseudoElement ?? ''].join('::'); +} + +function buildClassOverrideId(originalClassName: string): string { + return `class::${originalClassName}`; +} + +function createInlineOverride( + entry: TDeclaration, + override: TOverrideValue, + entryKey: string, +): StylexOverride { + const conditions = getConditionParts(entry); + const base: StylexOverride = { + id: buildInlineOverrideId(entry.property, entry.pseudoElement), + kind: 'inline', + property: entry.property, + value: override.value, + important: override.important, + conditions, + sourceEntryKey: entryKey, + }; + return entry.pseudoElement + ? { ...base, pseudoElement: entry.pseudoElement } + : base; +} + +function createClassOverride( + entry: TDeclaration, + override: TOverrideValue, + entryKey: string, + nextClassName: string, + originalClassNameOverride?: string, +): StylexOverride { + const originalClassName = originalClassNameOverride ?? entry.className ?? ''; + const conditions = getConditionParts(entry); + const base: StylexOverride = { + id: buildClassOverrideId(originalClassName), + kind: 'class', + property: entry.property, + value: override.value, + important: override.important, + conditions, + className: nextClassName, + originalClassName, + sourceEntryKey: entryKey, + }; + return entry.pseudoElement + ? { ...base, pseudoElement: entry.pseudoElement } + : base; +} + +function upsertOverride( + overrides: $ReadOnlyArray, + nextOverride: StylexOverride, +): Array { + const index = overrides.findIndex((item) => item.id === nextOverride.id); + if (index === -1) { + return [...overrides, nextOverride]; + } + return overrides.map((item) => + item.id === nextOverride.id ? nextOverride : item, + ); +} + +function removeOverride( + overrides: $ReadOnlyArray, + id: string, +): Array { + return overrides.filter((item) => item.id !== id); +} + +function overridesToEntryMap( + overrides: $ReadOnlyArray, +): TOverridesByEntry { + return overrides.reduce( + (acc, override) => + override.kind === 'inline' && override.sourceEntryKey + ? { + ...acc, + [override.sourceEntryKey]: { + value: override.value, + important: override.important, + }, + } + : acc, + {}, + ); +} + +function buildClassOverrideMap( + overrides: $ReadOnlyArray, +): TClassOverrideMap { + return overrides.reduce( + (acc, override) => + override.kind === 'class' && override.className + ? { ...acc, [override.className]: override } + : acc, + {}, + ); +} + +function buildPropertyValues(rules: $ReadOnlyArray): { + [string]: Array, + ... +} { + return rules.reduce<{ [string]: Array, ... }>((acc, rule) => { + const displayValue = formatValue(rule.value, rule.important); + const key = rule.property.toLowerCase(); + const existing = acc[key] ?? []; + const values = existing.includes(displayValue) + ? existing + : [...existing, displayValue]; + return { ...acc, [key]: values }; + }, {}); +} + +function filterSuggestions( + values: $ReadOnlyArray, + query: string, +): Array { + const normalizedQuery = query.trim().toLowerCase(); + const filtered = + normalizedQuery === '' + ? values + : values.filter((value) => value.toLowerCase().includes(normalizedQuery)); + return filtered.slice(0, 8); +} + +function getNextIndex(current: number, delta: number, length: number): number { + if (length === 0) return -1; + if (current === -1) { + return delta > 0 ? 0 : length - 1; + } + return (current + delta + length) % length; +} + +function normalizeConditions(conditions: Array): Array { + return conditions + .map((condition) => condition.trim()) + .filter(Boolean) + .reduce( + (acc, condition) => (acc.includes(condition) ? acc : [...acc, condition]), + [], + ); +} + +function buildConditionKey(conditions: Array): string { + return normalizeConditions(conditions).join('||'); +} + +function buildAtomicKey( + property: string, + conditions: Array, + pseudoElement?: string, +): string { + return [property, pseudoElement ?? '', buildConditionKey(conditions)].join( + '::', + ); +} + +function buildEntryKey(entry: TDeclaration): string { + const conditions = getConditionParts(entry); + return [ + entry.className ?? '', + entry.property, + entry.pseudoElement ?? '', + buildConditionKey(conditions), + ].join('::'); +} + +function buildAtomicIndex( + rules: $ReadOnlyArray, +): TAtomicIndex { + const empty: TAtomicIndex = {}; + return rules.reduce((acc, rule) => { + const key = buildAtomicKey( + rule.property, + rule.conditions, + rule.pseudoElement, + ); + const displayValue = formatValue(rule.value, rule.important); + const existing = acc[key]; + const existingValues = existing?.values ?? []; + const existingMap = existing?.valueToClassName ?? {}; + const values = existingValues.includes(displayValue) + ? existingValues + : [...existingValues, displayValue]; + const valueToClassName = existingMap[displayValue] + ? existingMap + : { ...existingMap, [displayValue]: rule.className }; + return { + ...acc, + [key]: { + values, + valueToClassName, + }, + }; + }, empty); +} + +function getAtomicGroupForEntry( + atomicIndex: TAtomicIndex, + entry: TDeclaration, +): ?TAtomicValueIndex { + const key = buildAtomicKey( + entry.property, + getConditionParts(entry), + entry.pseudoElement, + ); + return atomicIndex[key]; +} + +function buildSections( + classes: $ReadOnlyArray< + $ReadOnly<{ + name: string, + declarations: $ReadOnlyArray, + ... + }>, + >, +): Array { + const sectionOrder: Array = []; + const sectionMap: Map< + string, + { + propertyOrder: Array, + propertyToEntries: Map>, + }, + > = new Map(); + + for (const entry of classes) { + for (const decl of entry.declarations) { + const sectionKey = decl.pseudoElement ?? ''; + let section = sectionMap.get(sectionKey); + if (section == null) { + section = { + propertyOrder: [], + propertyToEntries: new Map(), + }; + sectionMap.set(sectionKey, section); + sectionOrder.push(sectionKey); + } + const bucket = section.propertyToEntries.get(decl.property); + if (bucket == null) { + section.propertyOrder.push(decl.property); + section.propertyToEntries.set(decl.property, [decl]); + } else { + bucket.push(decl); + } + } + } + + const orderedSections = moveKeyToFront(sectionOrder, ''); + const result: Array = []; + for (const sectionKey of orderedSections) { + const section = sectionMap.get(sectionKey); + if (!section) continue; + const properties = section.propertyOrder.map((property) => ({ + property, + entries: section.propertyToEntries.get(property) ?? [], + })); + result.push({ key: sectionKey, properties }); + } + return result; +} + +type TConditionSplit = { + atRuleKey: string, + conditionKey: string, + entry: TDeclaration, +}; + +type TGroupData = { + atRuleKey: string, + conditionOrder: Array, + conditions: { [string]: TConditionNode, ... }, +}; + +type TGroupState = { + atRuleOrder: Array, + groups: { [string]: TGroupData, ... }, +}; + +function ensureUnique(list: Array, value: string): Array { + return list.includes(value) ? list : [...list, value]; +} + +function partitionParts(parts: Array): { + atRules: Array, + others: Array, +} { + return parts.reduce( + (acc, part) => + part.startsWith('@') + ? { atRules: [...acc.atRules, part], others: acc.others } + : part + ? { atRules: acc.atRules, others: [...acc.others, part] } + : acc, + { atRules: [], others: [] }, + ); +} + +function splitConditions(entry: TDeclaration): TConditionSplit { + const parts = getConditionParts(entry); + const { atRules, others } = partitionParts(parts); + const atRuleKey = atRules.join(', '); + const conditionKey = others.length > 0 ? others.join(', ') : 'default'; + return { atRuleKey, conditionKey, entry }; +} + +function createConditionNode( + atRuleKey: string, + conditionKey: string, +): TConditionNode { + return { + key: `cond:${atRuleKey}:${conditionKey}`, + label: conditionKey, + entries: [], + children: [], + }; +} + +function updateGroupWithEntry( + group: TGroupData, + split: TConditionSplit, +): TGroupData { + const existingNode = group.conditions[split.conditionKey]; + const nextNode = { + ...(existingNode ?? + createConditionNode(split.atRuleKey, split.conditionKey)), + entries: [...(existingNode?.entries ?? []), split.entry], + }; + + return { + ...group, + conditionOrder: ensureUnique(group.conditionOrder, split.conditionKey), + conditions: { ...group.conditions, [split.conditionKey]: nextNode }, + }; +} + +function updateStateWithEntry( + state: TGroupState, + split: TConditionSplit, +): TGroupState { + const existingGroup = state.groups[split.atRuleKey]; + const baseGroup = existingGroup ?? { + atRuleKey: split.atRuleKey, + conditionOrder: [], + conditions: {}, + }; + const nextGroup = updateGroupWithEntry(baseGroup, split); + + return { + atRuleOrder: ensureUnique(state.atRuleOrder, split.atRuleKey), + groups: { ...state.groups, [split.atRuleKey]: nextGroup }, + }; +} + +function toConditionNodes(state: TGroupState): Array { + const orderedAtRules = moveKeyToFront(state.atRuleOrder, ''); + // $FlowFixMe[incompatible-type] + return orderedAtRules.reduce( + (acc: Array, atRuleKey: string) => { + const group = state.groups[atRuleKey]; + if (!group) return acc; + const orderedConditions = moveKeyToFront(group.conditionOrder, 'default'); + const children = orderedConditions.reduce( + (childAcc: Array, conditionKey: string) => { + const node = group.conditions[conditionKey]; + return node ? [...childAcc, node] : childAcc; + }, + [], + ); + + if (atRuleKey === '') { + return [...acc, ...children]; + } + + return [ + ...acc, + { + key: `at:${atRuleKey || 'base'}`, + label: atRuleKey, + entries: [], + children, + }, + ]; + }, + [], + ); +} + +function buildConditionNodes( + entries: $ReadOnlyArray, +): Array { + const splits = entries.map(splitConditions); + // $FlowFixMe[incompatible-type] + const grouped = splits.reduce(updateStateWithEntry, { + atRuleOrder: [], + groups: {}, + }); + return toConditionNodes(grouped); +} + +export function DeclarationsList({ + classes, + computed, + atomicRules, + onRefresh, + overrides, +}: { + classes: $ReadOnlyArray< + $ReadOnly<{ + name: string, + declarations: $ReadOnlyArray, + ... + }>, + >, + computed: { [string]: string, ... }, + atomicRules: $ReadOnlyArray, + onRefresh: () => void, + overrides: $ReadOnlyArray, +}): React.Node { + const atomicIndex = useMemo( + () => buildAtomicIndex(atomicRules), + [atomicRules], + ); + const propertyValues = useMemo( + () => buildPropertyValues(atomicRules), + [atomicRules], + ); + const overrideValues = useMemo( + () => overridesToEntryMap(overrides), + [overrides], + ); + const classOverrides = useMemo( + () => buildClassOverrideMap(overrides), + [overrides], + ); + const overridesRef = useRef(overrides); + overridesRef.current = overrides; + + const persistOverrides = useCallback( + async (nextOverrides: Array) => { + overridesRef.current = nextOverrides; + await setStylexOverrides(nextOverrides); + }, + [], + ); + + const upsertOverrideEntry = useCallback( + async (override: StylexOverride) => { + const nextOverrides = upsertOverride(overridesRef.current, override); + await persistOverrides(nextOverrides); + }, + [persistOverrides], + ); + const removeOverrideEntry = useCallback( + async (id: string) => { + const nextOverrides = removeOverride(overridesRef.current, id); + await persistOverrides(nextOverrides); + }, + [persistOverrides], + ); + const handleAddOverride = useCallback( + async (property: string, rawValue: string) => { + const normalizedProperty = property.trim(); + if (!normalizedProperty) return; + const parsed = parseValueInput(rawValue); + if (!parsed.value) return; + let didMutate = false; + try { + await setInlineStyle({ + property: normalizedProperty, + value: parsed.value, + important: parsed.important, + }); + didMutate = true; + await upsertOverrideEntry({ + id: buildInlineOverrideId(normalizedProperty), + kind: 'inline', + property: normalizedProperty, + value: parsed.value, + important: parsed.important, + conditions: [], + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } finally { + if (didMutate) { + onRefresh(); + } + } + }, + [onRefresh, upsertOverrideEntry], + ); + const handleRemoveOverride = useCallback( + async (override: StylexOverride) => { + let didMutate = false; + try { + if (override.kind === 'inline') { + await clearInlineStyle({ property: override.property }); + didMutate = true; + } + if ( + override.kind === 'class' && + override.className && + override.originalClassName + ) { + await swapClassName({ + from: override.className, + to: override.originalClassName, + }); + didMutate = true; + } + await removeOverrideEntry(override.id); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } finally { + if (didMutate) { + onRefresh(); + } + } + }, + [onRefresh, removeOverrideEntry], + ); + + const sections = buildSections(classes); + const hasSections = sections.length > 0; + + return ( +
+ {hasSections ? ( + sections.map((section) => ( + + )) + ) : ( +
+ No matching StyleX CSS rules found for the selected element. +
+ )} + +
+ ); +} + +function PseudoSection({ + atomicIndex, + classOverrides, + computed, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, + section, +}: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, + computed: { [string]: string, ... }, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, + section: TSection, +}): React.Node { + if (section.key === '') { + return ( + + ); + } + return ( +
+
{section.key}
+ +
+ ); +} + +function PropertyList({ + atomicIndex, + classOverrides, + computed, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, + properties, +}: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, + computed: { [string]: string, ... }, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, + properties: $ReadOnlyArray, +}): React.Node { + return ( +
+ {properties.map((group) => ( + + ))} +
+ ); +} + +function PropertyGroup({ + atomicIndex, + classOverrides, + computedValue, + group, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, +}: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, + computedValue?: string, + group: TPropertyGroup, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, +}): React.Node { + if (group.entries.length === 1) { + return ( + + ); + } + + return ( + + ); +} + +function SingleDeclaration({ + atomicIndex, + classOverrides, + computedValue, + entry, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, + property, +}: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, + computedValue?: string, + entry: TDeclaration, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, + property: string, +}): React.Node { + const computedTitle = computedValue ? computedValue.trim() : ''; + const prefix: React.Node = ( + + {property} + + ); + + return ( + + ); +} + +function GroupedDeclaration({ + atomicIndex, + classOverrides, + computedValue, + entries, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, + property, +}: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, + computedValue?: string, + entries: $ReadOnlyArray, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, + property: string, +}): React.Node { + const nodes = buildConditionNodes(entries); + const computedTitle = computedValue ? computedValue.trim() : ''; + const line: React.Node = ( + <> + + {property} + + : + + ); + + return ( +
+
{line}
+ +
+ ); +} + +function ConditionList({ + atomicIndex, + classOverrides, + depth, + nodes, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, +}: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, + depth: number, + nodes: $ReadOnlyArray, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, +}): React.Node { + if (nodes.length === 0) return null; + const listStyle = depth === 0 ? styles.declSubList : styles.atRuleList; + + return ( +
+ {nodes.map((node) => ( + + ))} +
+ ); +} + +function ConditionNode({ + atomicIndex, + classOverrides, + node, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, + depth = 0, +}: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, + node: TConditionNode, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, + depth: number, +}): React.Node { + const collapsedNode = collapseDefaultChild(node); + const displayNode = + isAtRuleLabel(collapsedNode.label) && + collapsedNode.entries.length === 0 && + collapsedNode.children.length === 1 && + isDefaultLabel(collapsedNode.children[0].label) + ? { + ...collapsedNode, + entries: collapsedNode.children[0].entries, + children: collapsedNode.children[0].children, + } + : collapsedNode; + const label = displayNode.label; + const isAtRule = isAtRuleLabel(label); + const hasEntries = displayNode.entries.length > 0; + const formattedLabel = label !== '' ? formatConditionLabel(label) : null; + const shouldInlineAtRule = isAtRule && hasEntries; + const showLabel = + isAtRule && formattedLabel != null && displayNode.entries.length === 0; + const labelText = isAtRule + ? shouldInlineAtRule + ? formattedLabel + : null + : formattedLabel; + + return ( +
+ {showLabel ? ( +
+ {formattedLabel} +
+ ) : null} + {displayNode.entries.length > 0 ? ( + + ) : null} + {displayNode.children.length > 0 ? ( + + ) : null} +
+ ); +} + +function DeclarationEntries({ + atomicIndex, + classOverrides, + entries, + label, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, +}: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, + entries: $ReadOnlyArray, + label: string | null, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, +}): React.Node { + const prefix = label ? ( + {label} + ) : null; + const showColon = label != null; + + return entries.map((entry, index) => ( + + )); +} + +function DeclarationEntryRow({ + atomicIndex, + classOverrides, + entry, + isSubLine, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, + prefix, + showColon, +}: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, + entry: TDeclaration, + isSubLine: boolean, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, + prefix: React.Node | null, + showColon: boolean, +}): React.Node { + const entryKey = buildEntryKey(entry); + const override = overrideValues[entryKey]; + const baseValue = formatValue(entry.value, entry.important); + const displayValue = override + ? formatValue(override.value, override.important) + : baseValue; + const existingClassOverride = + entry.className && classOverrides[entry.className] + ? classOverrides[entry.className] + : null; + const group = getAtomicGroupForEntry(atomicIndex, entry); + const suggestions = group?.values ?? []; + const valueToClassName = group?.valueToClassName ?? {}; + + const [isEditing, setIsEditing] = useState(false); + const [draftValue, setDraftValue] = useState(''); + const [isPending, setIsPending] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const listId = useId(); + + // useEffect(() => { + // if (!isEditing) { + // setDraftValue(displayValue); + // } + // }, [displayValue, isEditing]); + + const filteredSuggestions = useMemo( + () => filterSuggestions(suggestions, draftValue), + [draftValue, suggestions], + ); + + useEffect(() => { + if (!isEditing || filteredSuggestions.length === 0) { + setActiveIndex(-1); + return; + } + setActiveIndex((prev) => + prev >= filteredSuggestions.length + ? filteredSuggestions.length - 1 + : prev, + ); + }, [filteredSuggestions, isEditing]); + + const commitChange = useCallback( + async (nextValue?: string) => { + const rawValue = nextValue ?? draftValue; + if (isPending || rawValue === '') { + return; + } + + const trimmed = rawValue.trim(); + const current = displayValue.trim(); + setIsEditing(false); + if (trimmed === current) { + return; + } + + const parsed = parseValueInput(trimmed); + const nextFormatted = formatValue(parsed.value, parsed.important); + // $FlowFixMe[invalid-computed-prop] + const nextClassName = valueToClassName[nextFormatted]; + + setIsPending(true); + let didMutate = false; + try { + if (parsed.value === '') { + await clearInlineStyle({ property: entry.property }); + didMutate = true; + await onOverrideRemove( + buildInlineOverrideId(entry.property, entry.pseudoElement), + ); + return; + } + + if (nextClassName && entry.className) { + const originalClassName = + existingClassOverride?.originalClassName ?? entry.className; + const shouldRemoveClassOverride = + existingClassOverride != null && + nextClassName === originalClassName; + + if (nextClassName !== entry.className) { + await swapClassName({ from: entry.className, to: nextClassName }); + didMutate = true; + } + await clearInlineStyle({ property: entry.property }); + didMutate = true; + await onOverrideRemove( + buildInlineOverrideId(entry.property, entry.pseudoElement), + ); + if (shouldRemoveClassOverride && existingClassOverride) { + await onOverrideRemove(existingClassOverride.id); + return; + } + await onOverrideUpsert( + createClassOverride( + entry, + parsed, + entryKey, + nextClassName, + existingClassOverride?.originalClassName, + ), + ); + return; + } + + await setInlineStyle({ + property: entry.property, + value: parsed.value, + important: parsed.important, + }); + didMutate = true; + await onOverrideUpsert(createInlineOverride(entry, parsed, entryKey)); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } finally { + setIsPending(false); + if (didMutate) { + onRefresh(); + } + } + }, + [ + displayValue, + draftValue, + entry, + entryKey, + existingClassOverride, + isPending, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + valueToClassName, + ], + ); + + const handleKeyDown = useCallback( + ( + event: KeyboardEvent & { + currentTarget: HTMLInputElement | HTMLButtonElement, + }, + ) => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (filteredSuggestions.length === 0) return; + event.preventDefault(); + const delta = event.key === 'ArrowDown' ? 1 : -1; + setActiveIndex((prev) => + getNextIndex(prev, delta, filteredSuggestions.length), + ); + return; + } + if (event.key === 'Enter') { + const activeValue = filteredSuggestions[activeIndex]; + if (activeValue) { + event.preventDefault(); + commitChange(activeValue); + return; + } + event.preventDefault(); + // $FlowFixMe[prop-missing] + event.currentTarget.blur(); + } + if (event.key === 'Escape') { + event.preventDefault(); + setIsEditing(false); + setDraftValue(displayValue); + setActiveIndex(-1); + } + }, + [activeIndex, commitChange, displayValue, filteredSuggestions], + ); + + const prefixContent = + prefix && showColon ? ( + <> + {prefix} + {': '} + + ) : ( + prefix + ); + const hasSuggestions = filteredSuggestions.length > 0; + const activeDescendant = + activeIndex >= 0 && activeIndex < filteredSuggestions.length + ? `${listId}-option-${activeIndex}` + : undefined; + + return ( +
+
+ {prefixContent} + {isEditing ? ( +
+ commitChange()} + onChange={(event) => setDraftValue(event.currentTarget.value)} + onKeyDown={handleKeyDown} + placeholder={draftValue} + role="combobox" + spellCheck={false} + /> + { + commitChange(value); + }} + suggestions={filteredSuggestions} + /> +
+ ) : ( + + )} +
+ {entry.className ? ( + {entry.className} + ) : null} +
+ ); +} + +function OverridesSection({ + onAddOverride, + onRemoveOverride, + overrides, + propertyValues, +}: { + onAddOverride: (property: string, rawValue: string) => Promise | void, + onRemoveOverride: (override: StylexOverride) => Promise | void, + overrides: $ReadOnlyArray, + propertyValues: { [string]: Array, ... }, +}): React.Node { + return ( +
+
Overrides
+ {overrides.length === 0 ? ( +
No overrides yet.
+ ) : ( +
+ {overrides.map((override) => ( + + ))} +
+ )} + +
+ ); +} + +function OverrideRow({ + onRemove, + override, +}: { + onRemove: (override: StylexOverride) => Promise | void, + override: StylexOverride, +}): React.Node { + const [isPending, setIsPending] = useState(false); + + const handleRemove = useCallback(async () => { + if (isPending) return; + setIsPending(true); + try { + await onRemove(override); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } finally { + setIsPending(false); + } + }, [isPending, onRemove, override]); + + const value = formatValue(override.value, override.important); + const line: React.Node = ( + <> + {override.property} + {`: ${value}`} + + ); + + return ( +
+
{line}
+
+ {override.className ? ( + {override.className} + ) : null} + +
+
+ ); +} + +function OverrideComposer({ + onAddOverride, + propertyValues, +}: { + onAddOverride: (property: string, rawValue: string) => Promise | void, + propertyValues: { [string]: Array, ... }, +}): React.Node { + const [property, setProperty] = useState(''); + const [value, setValue] = useState(''); + const [isPending, setIsPending] = useState(false); + const [isValueFocused, setIsValueFocused] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const listId = useId(); + + const normalizedProperty = property.trim().toLowerCase(); + const suggestions = normalizedProperty + ? (propertyValues[normalizedProperty] ?? []) + : []; + const filteredSuggestions = useMemo( + () => filterSuggestions(suggestions, value), + [suggestions, value], + ); + const showSuggestions = isValueFocused && filteredSuggestions.length > 0; + + useEffect(() => { + if (!showSuggestions) { + setActiveIndex(-1); + return; + } + setActiveIndex((prev) => + prev >= filteredSuggestions.length + ? filteredSuggestions.length - 1 + : prev, + ); + }, [filteredSuggestions, showSuggestions]); + + const commitAdd = useCallback( + async (nextValue?: string) => { + if (isPending) return; + const prop = property.trim(); + const next = (nextValue ?? value).trim(); + if (!prop || !next) return; + setIsPending(true); + try { + await onAddOverride(prop, nextValue ?? value); + setValue(''); + } finally { + setIsPending(false); + } + }, + [isPending, onAddOverride, property, value], + ); + + const handleKeyDown = useCallback( + ( + event: KeyboardEvent & { + currentTarget: HTMLInputElement | HTMLButtonElement, + }, + ) => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (!showSuggestions) return; + event.preventDefault(); + const delta = event.key === 'ArrowDown' ? 1 : -1; + setActiveIndex((prev) => + getNextIndex(prev, delta, filteredSuggestions.length), + ); + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + const activeValue = filteredSuggestions[activeIndex]; + if (activeValue) { + commitAdd(activeValue); + return; + } + commitAdd(); + } + if (event.key === 'Escape') { + setActiveIndex(-1); + } + }, + [activeIndex, commitAdd, filteredSuggestions, showSuggestions], + ); + + return ( +
+ setProperty(event.currentTarget.value)} + placeholder="property" + spellCheck={false} + value={property} + /> +
+ = 0 ? `${listId}-option-${activeIndex}` : undefined + } + aria-autocomplete="list" + aria-controls={showSuggestions ? listId : undefined} + aria-expanded={showSuggestions} + aria-haspopup="listbox" + onBlur={() => setIsValueFocused(false)} + onChange={(event) => setValue(event.currentTarget.value)} + onFocus={() => setIsValueFocused(true)} + onKeyDown={handleKeyDown} + placeholder="value" + role="combobox" + spellCheck={false} + value={value} + /> + { + commitAdd(nextValue); + }} + suggestions={showSuggestions ? filteredSuggestions : []} + /> +
+ +
+ ); +} + +function SuggestionsList({ + activeIndex, + listId, + onActiveIndexChange, + onSelect, + suggestions, +}: { + activeIndex: number, + listId: string, + onActiveIndexChange?: (index: number) => void, + onSelect: (value: string) => void, + suggestions: $ReadOnlyArray, +}): React.Node { + if (suggestions.length === 0) return null; + return ( +
+ {suggestions.map((value, index) => { + const isActive = index === activeIndex; + return ( +
{ + event.preventDefault(); + onSelect(value); + }} + onMouseEnter={() => { + if (onActiveIndexChange) { + onActiveIndexChange(index); + } + }} + > + {value} +
+ ); + })} +
+ ); +} + +const styles = stylex.create({ + muted: { + color: colors.textMuted, + }, + declList: { + display: 'flex', + flexDirection: 'column', + gap: 8, + paddingBlock: 8, + }, + declRow: { + display: 'flex', + alignItems: 'baseline', + justifyContent: 'space-between', + gap: 12, + }, + declText: { + display: 'flex', + flex: 1, + minWidth: 0, + }, + declLine: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }, + declProperty: { + color: colors.textAccent, + }, + declGroup: { + display: 'grid', + gap: 4, + }, + declSubList: { + display: 'grid', + gap: 2, + paddingLeft: 12, + }, + declSubLine: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }, + overridesSection: { + display: 'grid', + gap: 6, + paddingTop: 12, + borderTopWidth: 1, + borderTopStyle: 'solid', + borderTopColor: colors.border, + }, + overridesTitle: { + fontWeight: 600, + fontSize: 12, + }, + overridesList: { + display: 'grid', + gap: 6, + }, + overrideMeta: { + display: 'flex', + alignItems: 'center', + gap: 8, + flexShrink: 0, + }, + overrideButton: { + appearance: 'none', + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderStyle: 'solid', + borderColor: colors.border, + borderRadius: 6, + paddingInline: 6, + paddingBlock: 2, + cursor: 'pointer', + color: { + default: colors.textPrimary, + ':hover': colors.textAccent, + ':focus-visible': colors.textAccent, + }, + ':disabled': { + opacity: 0.6, + cursor: 'default', + }, + }, + overrideButtonPending: { + opacity: 0.6, + cursor: 'default', + }, + overrideComposer: { + display: 'flex', + alignItems: 'center', + gap: 8, + flexWrap: 'wrap', + flexGrow: 1, + }, + overrideInput: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + fontSize: 12, + lineHeight: '1.4', + color: 'inherit', + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderStyle: 'solid', + borderColor: colors.border, + borderRadius: 4, + paddingInline: 6, + paddingBlock: 2, + minWidth: 0, + flexGrow: 1, + }, + overrideValueWrap: { + position: 'relative', + flex: 1, + minWidth: 0, + }, + valueButton: { + appearance: 'none', + backgroundColor: 'transparent', + borderStyle: 'none', + padding: 0, + margin: 0, + color: { + default: 'inherit', + ':hover': colors.textAccent, + ':focus-visible': colors.textAccent, + }, + cursor: 'text', + textAlign: 'left', + fontFamily: 'inherit', + fontSize: 'inherit', + lineHeight: 'inherit', + }, + valueInput: { + fontFamily: 'inherit', + fontSize: 'inherit', + lineHeight: 'inherit', + color: 'inherit', + backgroundColor: colors.bgRaised, + borderWidth: 1, + marginBlock: -2, + borderStyle: 'solid', + borderColor: colors.border, + borderRadius: 4, + paddingInline: 4, + paddingBlock: 1, + minWidth: 0, + flexGrow: 1, + boxSizing: 'border-box', + }, + valuePending: { + opacity: 0.6, + }, + suggestionWrap: { + display: 'flex', + position: 'relative', + flexGrow: 1, + }, + suggestionList: { + position: 'absolute', + top: '100%', + left: 0, + zIndex: 2, + marginTop: 4, + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderStyle: 'solid', + borderColor: colors.border, + borderRadius: 6, + paddingBlock: 4, + minWidth: '100%', + maxHeight: 160, + overflowY: 'auto', + boxShadow: '0 6px 16px rgba(0, 0, 0, 0.18)', + }, + suggestionItem: { + appearance: 'none', + width: '100%', + textAlign: 'left', + borderStyle: 'none', + backgroundColor: 'transparent', + cursor: 'pointer', + paddingBlock: 4, + paddingInline: 8, + color: { + default: colors.textPrimary, + ':hover': colors.textAccent, + }, + fontFamily: 'inherit', + fontSize: 'inherit', + }, + suggestionItemActive: { + backgroundColor: colors.bg, + color: colors.textAccent, + }, + declCondition: { + color: colors.secondaryAccent, + }, + pseudoSection: { + display: 'grid', + gap: 6, + }, + pseudoTitle: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + color: colors.textMuted, + }, + sectionList: { + display: 'flex', + flexDirection: 'column', + gap: 8, + }, + atRuleGroup: { + display: 'grid', + gap: 2, + }, + atRuleTitle: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + }, + atRuleList: { + display: 'grid', + gap: 2, + paddingLeft: 12, + }, + className: { + color: colors.textMuted, + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + '::before': { + content: '.', + }, + }, +}); diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/ErrorBoundary.js b/packages/@stylexjs/devtools-extension/src/panel/components/ErrorBoundary.js new file mode 100644 index 000000000..3520f9556 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/components/ErrorBoundary.js @@ -0,0 +1,42 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import * as React from 'react'; + +type Props = { + children: React.Node, + fallback?: React.Node | ((error: Error) => React.Node), +}; + +type State = { + error: Error | null, +}; + +export class ErrorBoundary extends React.Component { + constructor(props: Props) { + super(props); + this.state = { error: null }; + } + + componentDidCatch(error: Error) { + this.setState({ error }); + } + + render(): React.Node { + const { fallback, children } = this.props; + if (this.state.error) { + return typeof fallback === 'function' + ? fallback(this.state.error) + : (fallback ?? null); + } + return children; + } +} diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/EyeIcon.js b/packages/@stylexjs/devtools-extension/src/panel/components/EyeIcon.js new file mode 100644 index 000000000..21f73545c --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/components/EyeIcon.js @@ -0,0 +1,33 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +import * as React from 'react'; +import * as stylex from '@stylexjs/stylex'; + +export function EyeIcon({ + xstyle, +}: { + xstyle?: stylex.StyleXStyles<>, +}): React.Node { + return ( + + + + + + ); +} diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/Logo.js b/packages/@stylexjs/devtools-extension/src/panel/components/Logo.js new file mode 100644 index 000000000..69b46e6c8 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/components/Logo.js @@ -0,0 +1,179 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +import * as React from 'react'; +import * as stylex from '@stylexjs/stylex'; +import type { StyleXStyles } from '@stylexjs/stylex'; + +export const viewBox = '0 0 644 435'; + +export function LogoText(): React.Node { + return ( + + + + ); +} + +export default function Logo({ + xstyle, +}: { + xstyle: StyleXStyles<>, +}): React.Node { + const idA = 'a'; + const idB = 'b'; + const idC = 'c'; + const idD = 'd'; + const idE = 'e'; + const idF = 'f'; + const idG = 'g'; + const idH = 'h'; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/Section.js b/packages/@stylexjs/devtools-extension/src/panel/components/Section.js new file mode 100644 index 000000000..e4c6074fe --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/components/Section.js @@ -0,0 +1,41 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import * as React from 'react'; +import * as stylex from '@stylexjs/stylex'; + +export function Section({ + title, + children, +}: { + title: string, + children: React.Node, +}): React.Node { + return ( +
+

{title}

+ {children} +
+ ); +} + +const styles = stylex.create({ + section: { + marginTop: 16, + padding: 8, + }, + sectionTitle: { + marginTop: 0, + marginBottom: 8, + fontSize: '1rem', + fontWeight: 800, + }, +}); diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/SourceRow.js b/packages/@stylexjs/devtools-extension/src/panel/components/SourceRow.js new file mode 100644 index 000000000..76bc56972 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/components/SourceRow.js @@ -0,0 +1,215 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import * as React from 'react'; +import { useState, useCallback, use, Suspense, useTransition } from 'react'; +import * as stylex from '@stylexjs/stylex'; +import type { SourcePreview } from '../../types'; +// import { openInVsCodeFromStylexSource } from '../../utils/vscode'; + +import { + getSourcePreview, + openSourceBestEffort, +} from '../../devtools/resources'; +// import { Button } from './Button'; +import { colors } from '../theme.stylex'; +import { EyeIcon } from './EyeIcon'; + +export function SourceRow({ + src, + onError, +}: { + src: { raw: string, file: string, line: number | null, ... }, + onError: (message: string) => void, +}): React.Node { + const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [isPending, startTransition] = useTransition(); + + const togglePreview = useCallback(() => { + startTransition(() => { + setIsPreviewOpen((open) => !open); + }); + }, []); + + return ( +
+
+ + + {/* */} + {/* */} +
+ + {isPreviewOpen ? ( +
+ +
+ ) : null} +
+ ); +} + +const cache: { [string]: Promise } = {}; +function getSourcePreviewPromise(file: string, line: number) { + const cacheKey = `${file}:${line}`; + if (cache[cacheKey]) { + return cache[cacheKey]; + } + const promise = getSourcePreview(file, line); + cache[cacheKey] = promise; + return promise; +} + +function SourceSnippet({ file, line }: { file: string, line: number }) { + const preview = use(getSourcePreviewPromise(file, line)); + + return ( +
+      {preview?.snippet ?? 'no source found'}
+    
+ ); +} + +function SourceSnippetSuspense({ file, line }: { file: string, line: number }) { + return ( + }> + + + ); +} + +function SourceSnippetFallback() { + return ( +
+ Loading… +
+ ); +} + +const styles = stylex.create({ + icon: { + width: 16, + height: 16, + }, + pill: { + appearance: 'none', + backgroundColor: { + default: 'transparent', + ':hover': colors.bgRaised, + ':focus-visible': colors.bgRaised, + }, + transform: { + default: null, + ':active': 'scale(0.95)', + }, + display: 'inline-block', + paddingTop: 8, + paddingBottom: 2, + paddingInline: 4, + borderRadius: 8, + borderWidth: 1, + borderStyle: 'none', + }, + pillActive: { + color: colors.textAccent, + }, + loading: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: colors.textMuted, + }, + sourceEntry: { + width: '100%', + maxWidth: '100%', + display: 'flex', + flexDirection: 'column', + gap: 4, + }, + sourceRow: { + width: '100%', + display: 'flex', + alignItems: 'center', + }, + sourcePath: { + appearance: 'none', + textAlign: 'start', + backgroundColor: 'transparent', + display: 'inline', + borderStyle: 'none', + cursor: 'pointer', + flexGrow: 1, + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + color: { + default: colors.textPrimary, + ':hover': colors.textAccent, + ':focus-visible': colors.textAccent, + }, + textDecoration: { + default: 'none', + ':hover': 'underline', + ':focus-visible': 'underline', + }, + wordBreak: 'break-word', + }, + buttonPending: { + opacity: 0.5, + }, + sourcePreview: {}, + sourcePreviewCode: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + whiteSpace: 'pre', + width: '100%', + overflow: 'auto', + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderStyle: 'solid', + borderColor: colors.border, + borderRadius: 6, + margin: 0, + padding: 8, + }, +}); diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/SourcesList.js b/packages/@stylexjs/devtools-extension/src/panel/components/SourcesList.js new file mode 100644 index 000000000..13fbb150e --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/components/SourcesList.js @@ -0,0 +1,46 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +import * as React from 'react'; +import * as stylex from '@stylexjs/stylex'; +import { SourceRow } from './SourceRow'; + +export function SourcesList({ + sources, + onError, +}: { + sources: $ReadOnlyArray<{ + raw: string, + file: string, + line: number | null, + ... + }>, + onError: (message: string) => void, +}): React.Node { + return ( +
+ {sources.map((src, index) => ( + + ))} +
+ ); +} + +const styles = stylex.create({ + sourcesList: { + width: '100%', + display: 'flex', + flexDirection: 'column', + gap: 6, + }, +}); diff --git a/packages/@stylexjs/devtools-extension/src/panel/index.css b/packages/@stylexjs/devtools-extension/src/panel/index.css new file mode 100644 index 000000000..f710d7ade --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/index.css @@ -0,0 +1,16 @@ +@layer reset { + html, + body, + #root { + height: 100%; + color-scheme: light dark; + } + + body { + margin: 0; + } + + * { + box-sizing: border-box; + } +} diff --git a/packages/@stylexjs/devtools-extension/src/panel/main.js b/packages/@stylexjs/devtools-extension/src/panel/main.js new file mode 100644 index 000000000..6e7da8fa8 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/main.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App.jsx'; + +import './index.css'; + +const rootEl = document.getElementById('root'); +if (rootEl) { + createRoot(rootEl).render(); +} diff --git a/packages/@stylexjs/devtools-extension/src/panel/theme.stylex.js b/packages/@stylexjs/devtools-extension/src/panel/theme.stylex.js new file mode 100644 index 000000000..9ada3340b --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/theme.stylex.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import * as stylex from '@stylexjs/stylex'; + +const colorsValue = { + bg: 'light-dark(#ffffff, #282828)', + bgRaised: 'light-dark(#f6f8fa, #282828)', + textPrimary: 'light-dark(#000000, #ffffff)', + textMuted: 'light-dark(#757575, #999999)', + textAccent: 'light-dark(#dc362e, rgb(92 213 251))', + secondaryAccent: 'light-dark(#0F7913, #73C89C)', + border: 'light-dark(#d3e3fd, #5e5e5eff)', +}; + +export const colors: typeof colorsValue = stylex.defineConsts(colorsValue); diff --git a/packages/@stylexjs/devtools-extension/src/types.js b/packages/@stylexjs/devtools-extension/src/types.js new file mode 100644 index 000000000..a13ee7add --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/types.js @@ -0,0 +1,79 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +export type StatusKind = 'info' | 'error'; + +export type StatusState = { + message: string, + kind: StatusKind, +}; + +export type StylexSource = { + raw: string, + file: string, + line: number | null, +}; + +export type StylexDeclaration = { + property: string, + value: string, + important: boolean, + condition?: string, + conditions?: $ReadOnlyArray, + pseudoElement?: string, + className?: string, + ... +}; + +export type AppliedStylexClass = { + name: string, + declarations: Array, +}; + +export type AtomicStyleRule = { + className: string, + property: string, + value: string, + important: boolean, + conditions: Array, + pseudoElement?: string, +}; + +export type StylexDebugData = $ReadOnly<{ + element: { + tagName: string, + }, + sources: Array, + computed: { [string]: string, ... }, + atomicRules: Array, + overrides: Array, + applied: { + classes: Array, + }, +}>; + +export type SourcePreview = { + url: string, + snippet: string, +}; + +export type StylexOverride = { + id: string, + kind: 'inline' | 'class', + property: string, + value: string, + important: boolean, + conditions: Array, + pseudoElement?: string, + className?: string, + originalClassName?: string, + sourceEntryKey?: string, +}; diff --git a/packages/@stylexjs/devtools-extension/src/utils/resourceMatching.js b/packages/@stylexjs/devtools-extension/src/utils/resourceMatching.js new file mode 100644 index 000000000..3fe8ce27c --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/utils/resourceMatching.js @@ -0,0 +1,117 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +function isProbablySourceMappedUrl(url: string): boolean { + return ( + url.startsWith('webpack://') || + url.startsWith('vite://') || + url.startsWith('rollup://') || + url.startsWith('parcel://') || + url.startsWith('ng://') + ); +} + +function normalizeForMatching(text: string): string { + const noHash = text.split('#')[0]; + const noQuery = noHash.split('?')[0]; + let decoded = noQuery; + try { + decoded = decodeURIComponent(noQuery); + } catch { + // ignore + } + return decoded.replace(/\\\\/g, '/'); +} + +function buildFileMatchCandidates(file: string): Array { + const candidates = new Set(); + const raw = normalizeForMatching(file).trim(); + if (!raw) return []; + candidates.add(raw); + + const colonIndex = raw.indexOf(':'); + const possiblePrefix = colonIndex !== -1 ? raw.slice(0, colonIndex) : ''; + const possiblePath = colonIndex !== -1 ? raw.slice(colonIndex + 1) : ''; + const looksLikePackagePrefix = + colonIndex !== -1 && + possiblePrefix && + !possiblePrefix.includes('/') && + !/^[a-zA-Z]$/.test(possiblePrefix); + + if (looksLikePackagePrefix) { + const withoutPrefix = possiblePath.replace(/^\.?\//, ''); + if (withoutPrefix) candidates.add(withoutPrefix); + candidates.add(`${possiblePrefix}/${withoutPrefix}`); + candidates.add( + `packages/${possiblePrefix.replace(/\/+$/, '')}/${withoutPrefix}`, + ); + candidates.add( + `node_modules/${possiblePrefix.replace(/\/+$/, '')}/${withoutPrefix}`, + ); + } + + const parts = raw.split('/').filter(Boolean); + if (parts.length >= 3) { + candidates.add(parts.slice(-3).join('/')); + } + if (parts.length >= 2) { + candidates.add(parts.slice(-2).join('/')); + } + if (parts.length >= 1) { + candidates.add(parts.slice(-1)[0]); + } + + return Array.from(candidates) + .map((s) => normalizeForMatching(s).replace(/^\/+/, '')) + .filter(Boolean); +} + +export function findBestMatchingResource( + resources: $ReadOnlyArray, + file: string, +): T | null { + const suffixes = buildFileMatchCandidates(file); + if (suffixes.length === 0) return null; + + function scoreResourceUrl(url: string): number | null { + const u = normalizeForMatching(url); + if (!u) return null; + + let matchScore: number | null = null; + for (const s of suffixes) { + if (u === s) matchScore = Math.max(matchScore ?? -1, 10_000); + else if (u.endsWith(`/${s}`)) + matchScore = Math.max(matchScore ?? -1, 9_000); + else if (u.endsWith(s)) matchScore = Math.max(matchScore ?? -1, 8_000); + else if (u.includes(`/${s}`)) + matchScore = Math.max(matchScore ?? -1, 7_000); + else if (u.includes(s)) matchScore = Math.max(matchScore ?? -1, 6_000); + } + if (matchScore == null) return null; + + const schemeBonus = isProbablySourceMappedUrl(url) ? 500 : 0; + const hasQuery = url.split('#')[0].includes('?'); + const queryPenalty = hasQuery ? -250 : 0; + return matchScore + schemeBonus + queryPenalty; + } + + let best: T | null = null; + let bestScore: number | null = null; + for (const r of resources) { + const score = scoreResourceUrl(r.url); + if (score == null) continue; + if (bestScore == null || score > bestScore) { + best = r; + bestScore = score; + } + } + return best; +} diff --git a/packages/@stylexjs/devtools-extension/src/utils/sourceSnippet.js b/packages/@stylexjs/devtools-extension/src/utils/sourceSnippet.js new file mode 100644 index 000000000..19b3c95cb --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/utils/sourceSnippet.js @@ -0,0 +1,213 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +function findMatchingClosingCurlyBraceLine( + lines: Array, + startLine: number, +): number | null { + const startIndex = Math.max(0, startLine - 1); + const firstLine = lines[startIndex] ?? ''; + const startColumn = Math.max(0, firstLine.lastIndexOf('{')); + + let depth = 0; + let started = false; + let inSingle = false; + let inDouble = false; + let inLineComment = false; + let inBlockComment = false; + let escapeNext = false; + const templateStack = []; + + for (let li = startIndex; li < lines.length; li += 1) { + const text = lines[li] ?? ''; + inLineComment = false; + const colStart = li === startIndex ? startColumn : 0; + + for (let ci = colStart; ci < text.length; ci += 1) { + const ch = text[ci]; + const next = text[ci + 1]; + + if (inLineComment) break; + + if (inBlockComment) { + if (ch === '*' && next === '/') { + inBlockComment = false; + ci += 1; + } + continue; + } + + if (inSingle) { + if (escapeNext) { + escapeNext = false; + continue; + } + if (ch === '\\') { + escapeNext = true; + continue; + } + if (ch === "'") { + inSingle = false; + } + continue; + } + + if (inDouble) { + if (escapeNext) { + escapeNext = false; + continue; + } + if (ch === '\\') { + escapeNext = true; + continue; + } + if (ch === '"') { + inDouble = false; + } + continue; + } + + const templateTop = + templateStack.length > 0 + ? templateStack[templateStack.length - 1] + : null; + const inTemplateText = templateTop && templateTop.inExpression === false; + + if (inTemplateText) { + if (escapeNext) { + escapeNext = false; + continue; + } + if (ch === '\\') { + escapeNext = true; + continue; + } + if (ch === '`') { + templateStack.pop(); + continue; + } + if (ch === '$' && next === '{' && templateTop != null) { + templateTop.inExpression = true; + templateTop.exprDepth = 1; + + if (!started) { + started = true; + depth = 1; + } else { + depth += 1; + } + + ci += 1; + continue; + } + continue; + } + + if (ch === '/' && next === '/') { + inLineComment = true; + ci += 1; + continue; + } + if (ch === '/' && next === '*') { + inBlockComment = true; + ci += 1; + continue; + } + if (ch === "'") { + inSingle = true; + escapeNext = false; + continue; + } + if (ch === '"') { + inDouble = true; + escapeNext = false; + continue; + } + if (ch === '`') { + templateStack.push({ inExpression: false, exprDepth: 0 }); + escapeNext = false; + continue; + } + + if (ch === '{') { + if (!started) { + started = true; + depth = 1; + } else { + depth += 1; + } + if (templateTop && templateTop.inExpression) { + templateTop.exprDepth += 1; + } + continue; + } + + if (ch === '}') { + if (!started) continue; + depth -= 1; + if (templateTop && templateTop.inExpression) { + templateTop.exprDepth -= 1; + if (templateTop.exprDepth === 0) { + templateTop.inExpression = false; + templateTop.exprDepth = 0; + } + } + if (depth === 0) { + return li + 1; + } + } + } + } + + return null; +} + +export function formatSourceSnippet( + content: string, + line: number | null, +): string { + const normalized = content.replace(/\r\n/g, '\n'); + const lines = normalized.split('\n'); + if (lines.length === 0) return ''; + + const targetLine = + typeof line === 'number' && Number.isFinite(line) ? line : null; + if (targetLine == null) { + return lines.slice(0, Math.min(40, lines.length)).join('\n'); + } + + const start = Math.max(targetLine, 1); + const startLineText = lines[targetLine - 1] ?? ''; + const hasOpeningBrace = startLineText.includes('{'); + const braceEnd = hasOpeningBrace + ? findMatchingClosingCurlyBraceLine(lines, targetLine) + : null; + + let end = + braceEnd != null ? braceEnd : Math.min(targetLine + 6, lines.length); + const maxPreviewLines = 200; + const truncated = end - start + 1 > maxPreviewLines; + if (truncated) { + end = Math.min(start + maxPreviewLines - 1, lines.length); + } + + const width = String(end).length; + const out = []; + for (let i = start; i <= end; i += 1) { + const prefix = targetLine === i ? '>' : ' '; + const num = String(i).padStart(width, ' '); + out.push(`${prefix} ${num} | ${lines[i - 1] ?? ''}`); + } + if (truncated) { + out.push('… (preview truncated)'); + } + return out.join('\n'); +} diff --git a/packages/@stylexjs/devtools-extension/src/utils/vscode.js b/packages/@stylexjs/devtools-extension/src/utils/vscode.js new file mode 100644 index 000000000..7416bc5a6 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/utils/vscode.js @@ -0,0 +1,99 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +function openExternalUrl(url: string): void { + const a = document.createElement('a'); + a.href = url; + a.target = '_blank'; + a.rel = 'noreferrer'; + a.click(); +} + +function ensureLeadingSlash(path: string): string { + if (!path) return '/'; + if (path.startsWith('/')) return path; + return `/${path}`; +} + +function joinPaths(root: string, rel: string): string { + const left = root.replace(/\\\\/g, '/').replace(/\/+$/, ''); + const right = rel.replace(/\\\\/g, '/').replace(/^\/+/, ''); + return `${left}/${right}`; +} + +function stylexFileToEditorRelativePath(file: string): string { + const normalized = file.replace(/\\\\/g, '/').replace(/^\.?\//, ''); + + const nmIndex = normalized.indexOf('node_modules/'); + if (nmIndex !== -1) { + return normalized.slice(nmIndex); + } + + const colonIndex = normalized.indexOf(':'); + if (colonIndex !== -1 && colonIndex !== 1) { + const prefix = normalized.slice(0, colonIndex); + const rest = normalized.slice(colonIndex + 1).replace(/^\/+/, ''); + if (prefix && rest) { + return `packages/${prefix.replace(/\/+$/, '')}/${rest}`; + } + } + + return normalized; +} + +function getOrPromptForVsCodeRoot(): string | null { + const key = 'stylex_vscode_root'; + const existing = (localStorage.getItem(key) ?? '').trim(); + if (existing) return existing; + + // eslint-disable-next-line no-alert + const next = window.prompt( + 'VS Code root path (absolute). Example: /Users/you/project', + '', + ); + if (!next) return null; + const value = next.trim(); + if (!value) return null; + localStorage.setItem(key, value); + return value; +} + +function resolveStylexSourceToAbsolutePath(file: string): string | null { + const normalized = file.replace(/\\\\/g, '/').trim(); + if (!normalized) return null; + + if (normalized.startsWith('/') || /^[a-zA-Z]:\//.test(normalized)) { + return normalized; + } + + const root = getOrPromptForVsCodeRoot(); + if (!root) return null; + + const rel = stylexFileToEditorRelativePath(normalized); + return joinPaths(root, rel); +} + +export function openInVsCodeFromStylexSource( + file: string, + line: number | null, +): void { + const absPath = resolveStylexSourceToAbsolutePath(file); + if (!absPath) { + return; + } + + const normalizedPath = absPath.replace(/\\\\/g, '/'); + const lineSuffix = typeof line === 'number' ? `:${line}:1` : ''; + const url = encodeURI( + `vscode://file${ensureLeadingSlash(normalizedPath)}${lineSuffix}`, + ); + openExternalUrl(url); +}