diff --git a/.changeset/add-octicons-react-symbols-package.md b/.changeset/add-octicons-react-symbols-package.md new file mode 100644 index 00000000000..54711a4237c --- /dev/null +++ b/.changeset/add-octicons-react-symbols-package.md @@ -0,0 +1,5 @@ +--- +'@primer/octicons-react-symbols': minor +--- + +Octicons React Symbols: Add a package that provides reusable SVG symbol sprites and matching icon components. diff --git a/.prettierignore b/.prettierignore index a7d223c884b..37a12d2e790 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,4 @@ docs/.cache docs/public **/.next/** packages/react/src/legacy-theme/ts/color-schemes.ts +packages/octicons-react-symbols/src/generated diff --git a/eslint.config.mjs b/eslint.config.mjs index e3534d09a44..ccde67b83c5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -35,26 +35,27 @@ const github = githubPlugin.getFlatConfigs() */ const config = defineConfig([ globalIgnores([ - '.agents/**/*', + '**/*.module.css.d.ts', '**/.cache', - 'coverage/**/*', - 'docs/public/**/*', - 'types/**/*', + '**/.next/**/*', + '**/.playwright/**', + '**/CHANGELOG.md', + '**/dist/**/*', + '**/node_modules/**/*', + '**/storybook-static', + '**/storybook-static/**/*', + '.agents/**/*', 'consumer-test/**/*', 'contributor-docs/adrs/*', + 'coverage/**/*', + 'dist/**/*', + 'docs/public/**/*', 'examples/codesandbox/**/*', + 'packages/octicons-react-symbols/src/generated/**/*', 'packages/react/src/utils/polymorphic.ts', 'packages/styled-react/src/polymorphic.ts', - '**/storybook-static', - '**/CHANGELOG.md', - '**/node_modules/**/*', - '**/storybook-static/**/*', - '**/.next/**/*', - 'dist/**/*', - '**/dist/**/*', 'script/**/*.ts', - '**/*.module.css.d.ts', - '**/.playwright/**', + 'types/**/*', ]), js.configs.recommended, diff --git a/package-lock.json b/package-lock.json index bcb66fcfd6f..ceb440fe9a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -497,6 +497,23 @@ "semver": "bin/semver.js" } }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", @@ -879,6 +896,37 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/plugin-transform-typescript": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", @@ -6558,7 +6606,9 @@ "link": true }, "node_modules/@primer/octicons": { - "version": "19.15.5", + "version": "19.29.1", + "resolved": "https://registry.npmjs.org/@primer/octicons/-/octicons-19.29.1.tgz", + "integrity": "sha512-XVRArUP8bN+IEjfNQSquXAJinVqAWRx36sVvhN5VzC+gOLfbx16EfFFvcFhSVaqQJ+57PjBrbj75oqm8VlOoYQ==", "license": "MIT", "dependencies": { "object-assign": "^4.1.1" @@ -6576,6 +6626,10 @@ "react": ">=16.3" } }, + "node_modules/@primer/octicons-react-symbols": { + "resolved": "packages/octicons-react-symbols", + "link": true + }, "node_modules/@primer/primitives": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-11.5.1.tgz", @@ -6882,11 +6936,14 @@ "link": true }, "node_modules/@publint/pack": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.2.tgz", - "integrity": "sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==", + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.5.tgz", + "integrity": "sha512-edgyN2pP07uXiP4tJs0s8KVmU8M8i60YPbbI0/WDeok1mIJHRXz+CgD8I0nelwDkoCh3EWL/G5kGfbuHjsdbvw==", "dev": true, "license": "MIT", + "dependencies": { + "tinyexec": "^1.2.4" + }, "engines": { "node": ">=18" }, @@ -8138,6 +8195,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/plugin-babel": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-7.1.0.tgz", + "integrity": "sha512-h9Y+xYha6p4wKO+FwdiPIkE+eIYCm8MzZPpX1iARIoFBnmKP9CnpT1p9dDf/DTFm6fyN8PmuLyRI5qZgchnitw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@rollup/pluginutils": "^5.0.1", + "workerpool": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -9129,7 +9214,9 @@ } }, "node_modules/@types/babel__generator": { - "version": "7.6.3", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, "license": "MIT", "dependencies": { @@ -10965,6 +11052,58 @@ "url": "https://dotenvx.com" } }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/babel-plugin-react-compiler": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", @@ -12287,6 +12426,20 @@ "hasInstallScript": true, "license": "MIT" }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.2", "dev": true, @@ -17676,6 +17829,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.get": { "version": "4.4.2", "dev": true, @@ -22018,14 +22178,14 @@ "license": "MIT" }, "node_modules/publint": { - "version": "0.3.15", - "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.15.tgz", - "integrity": "sha512-xPbRAPW+vqdiaKy5sVVY0uFAu3LaviaPO3pZ9FaRx59l9+U/RKR1OEbLhkug87cwiVKxPXyB4txsv5cad67u+A==", + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.21.tgz", + "integrity": "sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==", "dev": true, "license": "MIT", "dependencies": { - "@publint/pack": "^0.1.2", - "package-manager-detector": "^1.3.0", + "@publint/pack": "^0.1.4", + "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "sade": "^1.8.1" }, @@ -25297,9 +25457,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { @@ -27672,6 +27832,13 @@ "node": ">=0.10.0" } }, + "node_modules/workerpool": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "dev": true, @@ -28332,6 +28499,156 @@ "url": "https://github.com/sponsors/isaacs" } }, + "packages/octicons-react-symbols": { + "name": "@primer/octicons-react-symbols", + "version": "0.0.0", + "dependencies": { + "@babel/runtime": "^7.28.4", + "react-compiler-runtime": "^1.0.0" + }, + "devDependencies": { + "@babel/core": "^7.28.5", + "@babel/generator": "^7.28.5", + "@babel/plugin-transform-runtime": "^7.28.5", + "@babel/preset-react": "^7.28.5", + "@babel/preset-typescript": "^7.28.5", + "@primer/octicons": "^19.21.1", + "@primer/react-compiler-check": "^0.0.0", + "@primer/vitest-config": "^0.0.0", + "@rolldown/plugin-babel": "^0.2.3", + "@rollup/plugin-babel": "^7.1.0", + "@types/babel__core": "^7.20.5", + "@types/babel__generator": "^7.27.0", + "@vitejs/plugin-react": "^6.0.2", + "babel-plugin-react-compiler": "^1.0.0", + "change-case": "^5.4.4", + "publint": "^0.3.16", + "react": "18.x || 19.x", + "react-dom": "18.x || 19.x", + "rimraf": "^6.1.3", + "rolldown": "^1.1.2", + "rolldown-plugin-dts": "^0.26.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "@types/react": "18.x || 19.x", + "@types/react-dom": "18.x || 19.x", + "react": "18.x || 19.x", + "react-dom": "18.x || 19.x" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "packages/octicons-react-symbols/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "packages/octicons-react-symbols/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "packages/octicons-react-symbols/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/octicons-react-symbols/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "packages/octicons-react-symbols/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/octicons-react-symbols/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/octicons-react-symbols/node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "packages/postcss-preset-primer": { "version": "0.0.0", "dependencies": { diff --git a/packages/octicons-react-symbols/.gitignore b/packages/octicons-react-symbols/.gitignore new file mode 100644 index 00000000000..4150114be5f --- /dev/null +++ b/packages/octicons-react-symbols/.gitignore @@ -0,0 +1 @@ +src/generated/** diff --git a/packages/octicons-react-symbols/README.md b/packages/octicons-react-symbols/README.md new file mode 100644 index 00000000000..e71814f2e6e --- /dev/null +++ b/packages/octicons-react-symbols/README.md @@ -0,0 +1,48 @@ +# @primer/octicons-react-symbols + +> Optimized React components for rendering Octicons with shared SVG symbols + +## Getting started + +To install `@primer/octicons-react-symbols` in your project, you will need to +run the following command using [npm](https://www.npmjs.com/): + +```bash +npm install -S @primer/octicons-react-symbols +``` + +## Usage + +The `@primer/octicons-react-symbols` package provides Octicon components that +reference shared SVG symbols instead of re-creating the same SVG markup every +time an icon is rendered. This is useful in apps that render the same icons many +times, because the symbol definition can be shared while each icon renders a +small `` reference. + +In your project, add the `OcticonSymbols` component and include any symbol +references that you would like to include. + +```tsx +import {OcticonSymbols, CheckSymbol} from '@primer/octicons-react-symbols' + +function RootLayout() { + return ( + <> + + + + + + ) +} +``` + +Downstream components can render icons normally: + +```tsx +import {CheckIcon} from '@primer/octicons-react-symbols' + +function Status() { + return +} +``` diff --git a/packages/octicons-react-symbols/config/vitest/browser/setup.ts b/packages/octicons-react-symbols/config/vitest/browser/setup.ts new file mode 100644 index 00000000000..429e1e7546c --- /dev/null +++ b/packages/octicons-react-symbols/config/vitest/browser/setup.ts @@ -0,0 +1,7 @@ +import {beforeEach} from 'vitest' +import {cleanup} from '@testing-library/react' +import '@testing-library/jest-dom/vitest' + +beforeEach(() => { + cleanup() +}) diff --git a/packages/octicons-react-symbols/package.json b/packages/octicons-react-symbols/package.json new file mode 100644 index 00000000000..be788c97c41 --- /dev/null +++ b/packages/octicons-react-symbols/package.json @@ -0,0 +1,64 @@ +{ + "name": "@primer/octicons-react-symbols", + "version": "0.0.0", + "type": "module", + "private": true, + "exports": { + "types": "./dist/generated/index.d.ts", + "default": "./dist/generated/index.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "node script/build.ts && rolldown -c", + "clean": "rimraf dist", + "lint:npm": "publint --types", + "lint:react-compiler": "node script/lint-react-compiler.ts", + "type-check": "tsc --noEmit", + "watch": "rolldown -c -w" + }, + "dependencies": { + "@babel/runtime": "^7.28.4", + "react-compiler-runtime": "^1.0.0" + }, + "devDependencies": { + "@babel/core": "^7.28.5", + "@babel/generator": "^7.28.5", + "@babel/plugin-transform-runtime": "^7.28.5", + "@babel/preset-react": "^7.28.5", + "@babel/preset-typescript": "^7.28.5", + "@primer/octicons": "^19.21.1", + "@primer/react-compiler-check": "^0.0.0", + "@primer/vitest-config": "^0.0.0", + "@rolldown/plugin-babel": "^0.2.3", + "@rollup/plugin-babel": "^7.1.0", + "@types/babel__core": "^7.20.5", + "@types/babel__generator": "^7.27.0", + "@vitejs/plugin-react": "^6.0.2", + "babel-plugin-react-compiler": "^1.0.0", + "change-case": "^5.4.4", + "publint": "^0.3.16", + "react": "18.x || 19.x", + "react-dom": "18.x || 19.x", + "rimraf": "^6.1.3", + "rolldown": "^1.1.2", + "rolldown-plugin-dts": "^0.26.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "@types/react": "18.x || 19.x", + "@types/react-dom": "18.x || 19.x", + "react": "18.x || 19.x", + "react-dom": "18.x || 19.x" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + }, + "sideEffects": false +} diff --git a/packages/octicons-react-symbols/rolldown.config.js b/packages/octicons-react-symbols/rolldown.config.js new file mode 100644 index 00000000000..5763f1750ba --- /dev/null +++ b/packages/octicons-react-symbols/rolldown.config.js @@ -0,0 +1,50 @@ +import {defineConfig} from 'rolldown/config' +import babel from '@rollup/plugin-babel' +import {dts} from 'rolldown-plugin-dts' +import packageJson from './package.json' with {type: 'json'} + +const external = [ + ...Object.keys(packageJson.peerDependencies ?? {}), + ...Object.keys(packageJson.dependencies ?? {}), + ...Object.keys(packageJson.devDependencies ?? {}), +].map(name => new RegExp(`^${name}(/.*)?`)) + +export default defineConfig({ + input: ['./src/generated/index.ts'], + external, + plugins: [ + dts({ + emitDtsOnly: false, + oxc: false, + sourcemap: false, + tsconfig: 'tsconfig.build.json', + }), + babel({ + presets: [ + '@babel/preset-typescript', + [ + '@babel/preset-react', + { + runtime: 'automatic', + }, + ], + ], + plugins: [ + [ + 'babel-plugin-react-compiler', + { + target: '18', + }, + ], + '@babel/plugin-transform-runtime', + ], + extensions: ['.js', '.jsx', '.ts', '.tsx'], + babelHelpers: 'runtime', + }), + ], + output: { + dir: './dist', + preserveModules: true, + preserveModulesRoot: 'src', + }, +}) diff --git a/packages/octicons-react-symbols/script/build.ts b/packages/octicons-react-symbols/script/build.ts new file mode 100644 index 00000000000..4f9f2e6da12 --- /dev/null +++ b/packages/octicons-react-symbols/script/build.ts @@ -0,0 +1,242 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import babel from '@babel/core' +import {generate} from '@babel/generator' +import {pascalCase} from 'change-case' +import data from '@primer/octicons/build/data.json' with {type: 'json'} + +const {types: t} = babel +const SOURCE_DIRECTORY = path.resolve(import.meta.dirname, '../src') +const GENERATED_DIRECTORY = path.join(SOURCE_DIRECTORY, 'generated') + +await fs.mkdir(GENERATED_DIRECTORY, {recursive: true}) + +// Each icon is around 2-3kB in size, create modules around 100kB in size +const BUCKET_SIZE = 40 +const modules = partition(Object.values(data), BUCKET_SIZE).map((icons, index) => { + const filepath = path.join(GENERATED_DIRECTORY, `icons-${(index + 1).toString().padStart(2, '0')}.tsx`) + + const propsImportSpecifier = t.importSpecifier( + t.identifier('OcticonReferenceProps'), + t.identifier('OcticonReferenceProps'), + ) + propsImportSpecifier.importKind = 'type' + + const imports = [ + // import {forwardRef} from 'react' + t.importDeclaration( + [t.importSpecifier(t.identifier('forwardRef'), t.identifier('forwardRef'))], + t.stringLiteral('react'), + ), + + // import {Icon} from '../Icon' + t.importDeclaration([t.importSpecifier(t.identifier('Icon'), t.identifier('Icon'))], t.stringLiteral('../Icon')), + + // import type {OcticonReferenceProps} from '../types' + t.importDeclaration([propsImportSpecifier], t.stringLiteral('../types')), + ] + + const components = icons.flatMap(icon => { + const symbolName = `${pascalCase(icon.name)}Symbol` + const symbols = Object.entries(icon.heights).map(([height, size]) => { + const id = `symbol-octicon-${icon.name}-${height}` + + const jsx = t.jsxElement( + t.jsxOpeningElement(t.jsxIdentifier('symbol'), [ + t.jsxAttribute(t.jsxIdentifier('id'), t.stringLiteral(id)), + t.jsxAttribute(t.jsxIdentifier('viewBox'), t.stringLiteral(`0 0 ${size.width} ${height}`)), + ]), + t.jsxClosingElement(t.jsxIdentifier('symbol')), + svgToJSX(size.ast), + ) + + return { + id, + height, + width: size.width, + jsx, + } + }) + const symbolComponent = t.functionDeclaration( + t.identifier(symbolName), + [], + t.blockStatement([ + t.returnStatement( + symbols.length === 1 + ? symbols[0].jsx + : t.jsxFragment( + t.jsxOpeningFragment(), + t.jsxClosingFragment(), + symbols.map(symbol => symbol.jsx), + ), + ), + ]), + ) + + const referenceName = `${pascalCase(icon.name)}Icon` + const forwardRef = t.callExpression(t.identifier('forwardRef'), [ + t.functionExpression( + t.identifier(referenceName), + [t.identifier('props'), t.identifier('ref')], + t.blockStatement([ + t.returnStatement( + t.jsxElement( + t.jsxOpeningElement( + t.jsxIdentifier('Icon'), + [ + t.jsxSpreadAttribute(t.identifier('props')), + t.jsxAttribute(t.jsxIdentifier('ref'), t.jsxExpressionContainer(t.identifier('ref'))), + t.jsxAttribute( + t.jsxIdentifier('sizes'), + t.jsxExpressionContainer( + t.objectExpression( + symbols.map(symbol => + t.objectProperty( + t.stringLiteral(symbol.height), + t.objectExpression([ + t.objectProperty(t.stringLiteral('width'), t.numericLiteral(symbol.width)), + t.objectProperty(t.stringLiteral('id'), t.stringLiteral(symbol.id)), + ]), + ), + ), + ), + ), + ), + ], + true, + ), + t.jsxClosingElement(t.jsxIdentifier('Icon')), + [], + ), + ), + ]), + ), + ]) + + forwardRef.typeParameters = t.tsTypeParameterInstantiation([ + t.tsTypeReference(t.identifier('SVGSVGElement')), + t.tsTypeReference(t.identifier('OcticonReferenceProps')), + ]) + + const reference = t.variableDeclaration('const', [t.variableDeclarator(t.identifier(referenceName), forwardRef)]) + + return [t.exportNamedDeclaration(symbolComponent), t.exportNamedDeclaration(reference)] + }) + + const body = [...imports, ...components] + const program = t.addComment( + t.program(body), + 'leading', + `This file is auto-generated by 'script/build.ts'. Do not edit directly.`, + ) + + return { + filepath, + contents: generate(program).code, + exports: icons.flatMap(icon => [`${pascalCase(icon.name)}Icon`, `${pascalCase(icon.name)}Symbol`]), + } +}) + +for (const mod of modules) { + await fs.writeFile(mod.filepath, mod.contents) +} + +const indexFilePath = path.join(SOURCE_DIRECTORY, 'generated', 'index.ts') + +const octiconsSymbolsPropsExportSpecifier = t.exportSpecifier( + t.identifier('OcticonSymbolsProps'), + t.identifier('OcticonSymbolsProps'), +) +octiconsSymbolsPropsExportSpecifier.exportKind = 'type' + +const octiconsReferencePropsExportSpecifier = t.exportSpecifier( + t.identifier('OcticonReferenceProps'), + t.identifier('OcticonReferenceProps'), +) +octiconsReferencePropsExportSpecifier.exportKind = 'type' + +const index = t.addComment( + t.program([ + t.exportNamedDeclaration( + null, + [ + t.exportSpecifier(t.identifier('OcticonSymbols'), t.identifier('OcticonSymbols')), + octiconsSymbolsPropsExportSpecifier, + ], + t.stringLiteral('../OcticonSymbols'), + ), + t.exportNamedDeclaration(null, [octiconsReferencePropsExportSpecifier], t.stringLiteral('../types')), + ...modules.map(mod => { + return t.exportNamedDeclaration( + null, + mod.exports.map(exportName => t.exportSpecifier(t.identifier(exportName), t.identifier(exportName))), + t.stringLiteral(`./${path.basename(mod.filepath, path.extname(mod.filepath))}`), + ) + }), + ]), + 'leading', + `This file is auto-generated by 'script/build.ts'. Do not edit directly.`, +) + +await fs.writeFile(indexFilePath, generate(index).code) + +function partition(items: Array, size: number): Array> { + const result: Array> = [] + let bucket: Array = [] + let count = 0 + + for (const item of items) { + if (count >= size) { + result.push(bucket) + bucket = [] + count = 0 + } + + bucket.push(item) + count++ + } + + if (bucket.length > 0) { + result.push(bucket) + } + + return result +} + +type SVGASTNode = { + type: string + name: string + attributes: Record + children: Array +} + +function svgToJSX(node: SVGASTNode): Array { + if (node.type === 'element') { + const children = node.children.map(svgToJSX) + + if (node.name === 'svg') { + if (children.length === 0) { + throw new Error(`No children available for icon`) + } + + return children.flat() + } + + const attrs = Object.entries(node.attributes).map(([key, value]) => { + if (typeof value !== 'string') { + throw new Error(`Unknown value type: ${value}`) + } + return t.jsxAttribute(t.jsxIdentifier(key), t.stringLiteral(value)) + }) + const openingElement = t.jsxOpeningElement(t.jsxIdentifier(node.name), attrs, children.length === 0) + const closingElement = t.jsxClosingElement(t.jsxIdentifier(node.name)) + + if (children.length > 0) { + return [t.jsxElement(openingElement, closingElement, children.flat(), false)] + } + + return [t.jsxElement(openingElement, closingElement, [], true)] + } + + throw new Error(`Unknown type: ${node.type}`) +} diff --git a/packages/octicons-react-symbols/script/lint-react-compiler.ts b/packages/octicons-react-symbols/script/lint-react-compiler.ts new file mode 100644 index 00000000000..4de57bd59dc --- /dev/null +++ b/packages/octicons-react-symbols/script/lint-react-compiler.ts @@ -0,0 +1,56 @@ +import {globSync, readFileSync} from 'node:fs' +import path from 'node:path' +import {checkFile} from '@primer/react-compiler-check' +import type {CheckError, CheckResult} from '@primer/react-compiler-check' + +type CompilerFailure = Extract & { + filepath: string +} + +const packageDirectory = path.resolve(import.meta.dirname, '..') +const files = globSync('src/**/*.tsx', { + cwd: packageDirectory, + exclude: ['src/**/__tests__/**'], +}).map(filepath => path.join(packageDirectory, filepath)) +const failures = files.map(checkCompilerFile).filter((result): result is CompilerFailure => !result.ok) + +if (failures.length > 0) { + // eslint-disable-next-line no-console + console.error(`React Compiler failed for ${failures.length} file${failures.length === 1 ? '' : 's'}:\n`) + + for (const failure of failures) { + const relativePath = path.relative(packageDirectory, failure.filepath) + + for (const error of failure.errors) { + const line = getLocationLine(error.location) + const location = line === undefined ? relativePath : `${relativePath}:${line}` + + // eslint-disable-next-line no-console + console.error(`- ${location} ${formatReason(error.reason)}`) + } + } + + process.exitCode = 1 +} else { + // eslint-disable-next-line no-console + console.log(`React Compiler passed for ${files.length} files.`) +} + +function formatReason(reason: string): string { + return reason.replaceAll('\n', ' ') +} + +function getLocationLine(location: CheckError['location']): number | undefined { + if (location === null || typeof location !== 'object' || !('start' in location)) { + return undefined + } + + return location.start.line +} + +function checkCompilerFile(filepath: string): CheckResult & {filepath: string} { + return { + filepath, + ...checkFile(filepath, readFileSync(filepath, 'utf8')), + } +} diff --git a/packages/octicons-react-symbols/src/Icon.tsx b/packages/octicons-react-symbols/src/Icon.tsx new file mode 100644 index 00000000000..b6cf554c9b1 --- /dev/null +++ b/packages/octicons-react-symbols/src/Icon.tsx @@ -0,0 +1,67 @@ +import {forwardRef, type HTMLAttributes} from 'react' +import type {OcticonReferenceProps} from './types' + +const sizeMap = { + small: 16, + medium: 32, + large: 64, +} + +type Size = 'small' | 'medium' | 'large' + +type IconProps = HTMLAttributes & + OcticonReferenceProps & { + size?: Size | number + sizes: Record< + string, + { + id: string + width: number + } + > + } + +const Icon = forwardRef(function Icon( + {'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, size = 16, sizes, tabIndex, title, ...rest}, + ref, +) { + const height = typeof size === 'number' ? size : sizeMap[size] + const heights = Object.keys(sizes) + const naturalHeight = closestNaturalHeight(heights, height) + const naturalWidth = sizes[naturalHeight].width + const width = height * (naturalWidth / naturalHeight) + const id = sizes[naturalHeight].id + const labelled = ariaLabel || ariaLabelledBy + const role = labelled ? 'img' : undefined + + return ( + = 0 ? 'true' : 'false'} + aria-label={ariaLabel} + aria-labelledby={ariaLabelledBy} + className={className} + role={role} + viewBox={`0 0 ${naturalWidth} ${naturalHeight}`} + width={width} + height={height} + > + {title ? {title} : null} + + + ) +}) + +function closestNaturalHeight(naturalHeights: Array, height: number): number { + const parsed = naturalHeights.map(naturalHeight => parseInt(naturalHeight, 10)) + + return parsed.reduce((acc, naturalHeight) => { + return naturalHeight <= height ? naturalHeight : acc + }, parsed[0]) +} + +export {Icon} +export type {IconProps} diff --git a/packages/octicons-react-symbols/src/OcticonSymbols.tsx b/packages/octicons-react-symbols/src/OcticonSymbols.tsx new file mode 100644 index 00000000000..ba17d12746a --- /dev/null +++ b/packages/octicons-react-symbols/src/OcticonSymbols.tsx @@ -0,0 +1,44 @@ +import {createContext, Fragment, useMemo, useState} from 'react' +import type {PropsWithChildren} from 'react' + +type OcticonSymbolsProps = PropsWithChildren + +type OcticonSymbolsContextValue = { + registerSymbol(id: string, symbol: React.ReactNode): void +} + +const OcticonSymbolsContext = createContext(null) + +function OcticonSymbols({children}: OcticonSymbolsProps) { + const [symbols, setSymbols] = useState>(() => { + return new Map() + }) + const value: OcticonSymbolsContextValue = useMemo(() => { + return { + registerSymbol(id, symbol) { + setSymbols(symbols => { + if (!symbols.has(id)) { + const nextSymbols = new Map(symbols) + nextSymbols.set(id, symbol) + return nextSymbols + } + return symbols + }) + }, + } + }, []) + + return ( + + {children} + + + ) +} + +export {OcticonSymbols, OcticonSymbolsContext} +export type {OcticonSymbolsProps} diff --git a/packages/octicons-react-symbols/src/__tests__/Icon.test.tsx b/packages/octicons-react-symbols/src/__tests__/Icon.test.tsx new file mode 100644 index 00000000000..54528ea8200 --- /dev/null +++ b/packages/octicons-react-symbols/src/__tests__/Icon.test.tsx @@ -0,0 +1,92 @@ +import {describe, expect, test} from 'vitest' +import {render, screen} from '@testing-library/react' +import {createRef} from 'react' +import {Icon} from '../Icon' + +const sizes = { + '16': { + id: 'symbol-octicon-test-16', + width: 16, + }, + '24': { + id: 'symbol-octicon-test-24', + width: 30, + }, +} + +describe('Icon', () => { + test('renders a decorative icon by default', () => { + const {container} = render() + + const icon = screen.getByTestId('icon') + expect(icon.getAttribute('aria-hidden')).toBe('true') + expect(icon.getAttribute('focusable')).toBe('false') + expect(icon.hasAttribute('role')).toBe(false) + expect(icon.getAttribute('viewBox')).toBe('0 0 16 16') + expect(icon.getAttribute('width')).toBe('16') + expect(icon.getAttribute('height')).toBe('16') + expect(container.querySelector('use')?.getAttribute('href')).toBe('#symbol-octicon-test-16') + }) + + test('renders an accessible icon when labelled', () => { + render() + + const icon = screen.getByRole('img', {name: 'Issue opened'}) + expect(icon.hasAttribute('aria-hidden')).toBe(false) + expect(icon.getAttribute('focusable')).toBe('false') + expect(icon.querySelector('title')?.textContent).toBe('Issue opened') + }) + + test('supports aria-labelledby', () => { + render( + <> + Pull request + + , + ) + + expect(screen.getByRole('img', {name: 'Pull request'}).getAttribute('aria-labelledby')).toBe('icon-label') + }) + + test('forwards attributes and refs to the svg element', () => { + const ref = createRef() + + render() + + const icon = screen.getByTestId('icon') + expect(ref.current).toBe(icon) + expect(icon.getAttribute('class')).toBe('octicon') + expect(icon.getAttribute('fill')).toBe('currentColor') + expect(icon.getAttribute('id')).toBe('test-icon') + }) + + test('sets focusable when tabIndex allows keyboard focus', () => { + render() + + const icon = screen.getByTestId('icon') + expect(icon.getAttribute('focusable')).toBe('true') + expect(icon.getAttribute('tabindex')).toBe('0') + }) + + test('uses the closest natural height and scales width', () => { + const {container, rerender} = render() + + const icon = screen.getByTestId('icon') + expect(icon.getAttribute('viewBox')).toBe('0 0 16 16') + expect(icon.getAttribute('width')).toBe('20') + expect(icon.getAttribute('height')).toBe('20') + expect(container.querySelector('use')?.getAttribute('href')).toBe('#symbol-octicon-test-16') + + rerender() + expect(icon.getAttribute('viewBox')).toBe('0 0 30 24') + expect(icon.getAttribute('width')).toBe('30') + expect(icon.getAttribute('height')).toBe('24') + expect(container.querySelector('use')?.getAttribute('href')).toBe('#symbol-octicon-test-24') + + rerender() + expect(icon.getAttribute('viewBox')).toBe('0 0 30 24') + expect(icon.getAttribute('width')).toBe('40') + expect(icon.getAttribute('height')).toBe('32') + expect(container.querySelector('use')?.getAttribute('href')).toBe('#symbol-octicon-test-24') + }) +}) diff --git a/packages/octicons-react-symbols/src/__tests__/OcticonSymbols.test.tsx b/packages/octicons-react-symbols/src/__tests__/OcticonSymbols.test.tsx new file mode 100644 index 00000000000..93c2ec8529f --- /dev/null +++ b/packages/octicons-react-symbols/src/__tests__/OcticonSymbols.test.tsx @@ -0,0 +1,49 @@ +import {describe, expect, test} from 'vitest' +import {render, screen} from '@testing-library/react' +import {useContext, useEffect} from 'react' +import {OcticonSymbols, OcticonSymbolsContext} from '../OcticonSymbols' + +describe('OcticonSymbols', () => { + test('renders svg symbol children', async () => { + render( + + + , + ) + + expect(await screen.findByTestId('symbol-child')).toBeDefined() + }) + + test('renders registered symbols in the hidden sprite container', async () => { + render( + + + , + ) + + expect(await screen.findByTestId('registered-symbol')).toBeDefined() + }) +}) + +function SymbolRegistration() { + const context = useContext(OcticonSymbolsContext) + + useEffect(() => { + if (context === null) { + throw new Error('Expected OcticonSymbolsContext to be available') + } + + context.registerSymbol( + 'symbol-octicon-test-16', + + + , + ) + }, [context]) + + return null +} diff --git a/packages/octicons-react-symbols/src/types.ts b/packages/octicons-react-symbols/src/types.ts new file mode 100644 index 00000000000..f858bc412af --- /dev/null +++ b/packages/octicons-react-symbols/src/types.ts @@ -0,0 +1,13 @@ +import type {HTMLAttributes, PropsWithChildren, ReactElement} from 'react' + +export type OcticonReferenceProps = HTMLAttributes & + PropsWithChildren<{ + 'aria-label'?: string + 'aria-labelledby'?: string + className?: string + fill?: string + id?: string + size?: number | 'small' | 'medium' | 'large' + tabIndex?: number + title?: string | ReactElement + }> diff --git a/packages/octicons-react-symbols/tsconfig.build.json b/packages/octicons-react-symbols/tsconfig.build.json new file mode 100644 index 00000000000..9973ee22eb5 --- /dev/null +++ b/packages/octicons-react-symbols/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "emitDeclarationOnly": true, + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/octicons-react-symbols/tsconfig.json b/packages/octicons-react-symbols/tsconfig.json new file mode 100644 index 00000000000..92b654193c9 --- /dev/null +++ b/packages/octicons-react-symbols/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "script", "vitest.config.ts", "config/vitest"] +} diff --git a/packages/octicons-react-symbols/vitest.config.ts b/packages/octicons-react-symbols/vitest.config.ts new file mode 100644 index 00000000000..7ffbfaa307f --- /dev/null +++ b/packages/octicons-react-symbols/vitest.config.ts @@ -0,0 +1,32 @@ +import babel from '@rolldown/plugin-babel' +import react, {reactCompilerPreset} from '@vitejs/plugin-react' +import {playwright} from '@vitest/browser-playwright' +import {defineConfig} from '@primer/vitest-config/config' + +export default defineConfig({ + plugins: [ + react(), + babel({ + presets: [ + reactCompilerPreset({ + target: '18', + }), + ], + }), + ], + test: { + name: '@primer/octicons-react-symbols', + setupFiles: ['config/vitest/browser/setup.ts'], + browser: { + provider: playwright(), + enabled: true, + headless: process.env.DEBUG_BROWSER_TESTS === 'true' ? false : true, + instances: [ + { + browser: 'chromium', + }, + ], + screenshotFailures: false, + }, + }, +}) diff --git a/packages/react/script/components-json/build.ts b/packages/react/script/components-json/build.ts index b9046ded7a1..d0e28e4f9ce 100644 --- a/packages/react/script/components-json/build.ts +++ b/packages/react/script/components-json/build.ts @@ -1,4 +1,3 @@ -// @ts-expect-error there seems to be a mismatch in the types for this package import {generate} from '@babel/generator' import {parse} from '@babel/parser' import {traverse} from '@babel/core'