diff --git a/.storybook/main.ts b/.storybook/main.ts index 713fe296..f7135296 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,6 +1,10 @@ +import { fileURLToPath } from 'node:url'; + import type { StorybookConfig } from '@storybook/react-vite'; import remarkGfm from 'remark-gfm'; +import { kibaIconsPlugin } from '../src/vitePlugin/index.ts'; + const config: StorybookConfig = { framework: '@storybook/react-vite', stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], @@ -23,6 +27,11 @@ const config: StorybookConfig = { docs: { autodocs: true, }, + // NOTE(krishan711): the stories use the source files, so the icons are registered with the source registry + viteFinal: (viteConfig) => ({ + ...viteConfig, + plugins: [...(viteConfig.plugins || []), kibaIconsPlugin({ registrationModule: fileURLToPath(new URL('../src/particles/kibaIcon/component.tsx', import.meta.url)) })], + }), }; export default config; diff --git a/CHANGELOG.md b/CHANGELOG.md index efd43090..70683081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] ### Added +- [MINOR] Added `@kibalabs/ui-react/vite-plugin` with `kibaIconsPlugin` to bundle the icons used with `KibaIcon` at build time, icon packages (`ionicons`, `@material-design-icons/svg`, `bootstrap-icons`, `feather-icons`, `remixicon`) are optional peer dependencies +- [MINOR] Added `kibaFontsPlugin` to `@kibalabs/ui-react/vite-plugin` to self-host the Google Fonts an app imports, downloading them at build time and preloading the main file of each family +- [MINOR] Added `registerKibaIcons` to register svgs that `KibaIcon` renders without fetching +- [MINOR] Added icon id props for the icons components show by default, so apps can use any icon set: `previousIconId`/`nextIconId` on `Carousel`, `expandIconId`/`collapseIconId` on `CollapsibleBox` and `TitledCollapsibleBox`, `selectedIconId` on `SelectableView`, `closeIconId` on `MessageDialog` and `iconId` on `Dropzone` - [MAJOR] Added `@kibalabs/ui-react/toast` (all of `@kibalabs/ui-react-toast`) and `@kibalabs/ui-react/dropzone` (all of `@kibalabs/ui-react-dropzone`) with their styles, `react-hot-toast` and `react-dropzone` are optional peer dependencies ### Changed +- [MINOR] Changed `KibaIcon` to fetch each icon once per page (shared between instances), retry once and ignore responses for a previous `iconId` +- [MINOR] Changed `AppDownloadButton` to use bundled badges (loaded lazily) instead of `assets-cdn.kiba.dev` +- [PATCH] Changed `Carousel` to use ionicons chevrons - [MAJOR] Use `@kibalabs/core/react` instead of `@kibalabs/core-react`, `react-router` is now a peer dependency - [MINOR] Correction for using `childSizeResponsive.base` size in `EqualGrid` - [MINOR] Fix `Box` and `Stack` to not inherit sizing props from parent when not intended diff --git a/README.md b/README.md index fb08d875..2f6de366 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,36 @@ Use `@kibalabs/ui-react/styles.css` instead if you don't use scss. Toast and dropzone styles only hold their own rules, so use them on top of the main styles. +## Icons + +`KibaIcon` shows icons by id (`ion-home`, `mui-home`, `bs-house`, `feather-home`, `remix-home-line`). To bundle the ones you use at build time, install the icon packages you need (optional peer dependencies: `ionicons`, `@material-design-icons/svg`, `bootstrap-icons`, `feather-icons`, `remixicon`) and add the vite plugin: + +```js +// config.build.js +import { kibaIconsPlugin } from '@kibalabs/ui-react/vite-plugin'; + +export default (config) => { + const newConfig = config; + newConfig.viteConfigModifier = (viteConfig) => { + viteConfig.plugins.push(kibaIconsPlugin()); + return viteConfig; + }; + return newConfig; +}; +``` + +It scans `src` (and ui-react) for quoted icon ids and registers their svgs before your app starts. Anything it can't see (e.g. `` `ion-${name}` ``) is fetched from `assets-cdn.kiba.dev` at runtime as before. + +## Fonts + +Keep loading Google Fonts the usual way (`@import url('https://fonts.googleapis.com/css2?family=...&display=swap');` in your css/scss, or a `` in `index.html`) and add `kibaFontsPlugin()` (from the same `@kibalabs/ui-react/vite-plugin`) to self-host them: + +```js +viteConfig.plugins.push(kibaFontsPlugin()); +``` + +At build time it downloads the stylesheet and its font files, emits them as hashed assets, replaces the `@import` with the `@font-face` rules and preloads the latin, normal style file of each family the page's css uses. Downloads are cached in `node_modules/.cache/kiba-fonts`. If they fail (e.g. offline) the `@import` is left as it is with a warning. In dev the fonts still load from Google. + ## Theming - **Variables:** everything is themed with CSS variables named `--kiba-{category}-{name}` (e.g. `--kiba-color-brand-primary`, `--kiba-padding`). Each color also gets `-light10/25`, `-dark10/25` and `-clear25/50/75` variants. diff --git a/config.build.js b/config.build.js index cdfafb78..a82eea69 100644 --- a/config.build.js +++ b/config.build.js @@ -4,6 +4,8 @@ export default (config) => { index: './src/index.ts', 'toast/index': './src/toast/index.ts', 'dropzone/index': './src/dropzone/index.ts', + // NOTE(krishan711): runs in node (inside vite) so it must not share code with the browser entries + 'vitePlugin/index': './src/vitePlugin/index.ts', }; return newConfig; }; diff --git a/package-lock.json b/package-lock.json index 5c48e711..c2657cc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,16 +15,22 @@ }, "devDependencies": { "@kibalabs/build": "0.14.2-next.16", + "@material-design-icons/svg": "0.14.15", "@storybook/addon-docs": "10.6.4", "@storybook/react-vite": "10.6.4", + "@types/node": "24.19.2", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", + "bootstrap-icons": "1.13.2", + "feather-icons": "4.29.2", + "ionicons": "8.1.0", "react": "19.3.1", "react-dom": "19.3.1", "react-dropzone": "20.1.4", "react-hot-toast": "2.6.1", "react-router": "8.5.0", "remark-gfm": "4.0.1", + "remixicon": "4.9.1", "storybook": "10.6.4" }, "peerDependencies": { @@ -1390,6 +1396,13 @@ } } }, + "node_modules/@material-design-icons/svg": { + "version": "0.14.15", + "resolved": "https://registry.npmjs.org/@material-design-icons/svg/-/svg-0.14.15.tgz", + "integrity": "sha512-6nbjwGwyJnphwQUscJAYqw1Tk6+W8KvsgOAeyVgzIFXVsHfgX5XyplTUcZ29wbcTUysMMyCUi1LYpmFKA/e61g==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@mdx-js/react": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", @@ -2483,7 +2496,7 @@ "version": "0.22.1", "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz", "integrity": "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@rolldown/binding-android-arm-eabi": { @@ -2789,6 +2802,130 @@ } } }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz", + "integrity": "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.0.tgz", + "integrity": "sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.0.tgz", + "integrity": "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.0.tgz", + "integrity": "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.0.tgz", + "integrity": "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.0.tgz", + "integrity": "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.0.tgz", + "integrity": "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.0.tgz", + "integrity": "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2809,6 +2946,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@stencil/core": { + "version": "4.45.2", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.45.2.tgz", + "integrity": "sha512-CNkPVZsqyNMjFMX9bEkMJsvBbQxjLw480OaoamoqiLPdQCFjJQReeOjnWX1A5ivSn3UYEIJ6MoN5VXDkaHGt8Q==", + "dev": true, + "license": "MIT", + "bin": { + "stencil": "bin/stencil" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.10.0" + }, + "optionalDependencies": { + "@rollup/rollup-darwin-arm64": "4.44.0", + "@rollup/rollup-darwin-x64": "4.44.0", + "@rollup/rollup-linux-arm64-gnu": "4.44.0", + "@rollup/rollup-linux-arm64-musl": "4.44.0", + "@rollup/rollup-linux-x64-gnu": "4.44.0", + "@rollup/rollup-linux-x64-musl": "4.44.0", + "@rollup/rollup-win32-arm64-msvc": "4.44.0", + "@rollup/rollup-win32-x64-msvc": "4.44.0" + } + }, "node_modules/@storybook/addon-docs": { "version": "10.6.4", "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.6.4.tgz", @@ -3214,11 +3375,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3228,7 +3399,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.3.0" @@ -4401,6 +4572,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bootstrap-icons": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.2.tgz", + "integrity": "sha512-fAU1Ly3dH7hwRg+CB8EZ0Vl0fF80zgXHeUVJyfUOKzoURr+TbwISbCW5tKUoQLERaWYG2emTCybcPbD96hdD1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", @@ -4658,6 +4846,13 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "dev": true, + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4805,7 +5000,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/cookie-signature": { @@ -4818,6 +5013,21 @@ "node": ">=6.6.0" } }, + "node_modules/core-js": { + "version": "3.51.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.51.0.tgz", + "integrity": "sha512-7bDKx/5jm5MxzsXxc93g6Z2+4ZsuT/MddrfRmTDJNAkWsYWHU8EVNEsBiIEWnMAMCHMmRfUEjkuo3jeNBOIHRw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cosmiconfig": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", @@ -4908,7 +5118,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -6218,6 +6428,17 @@ } } }, + "node_modules/feather-icons": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/feather-icons/-/feather-icons-4.29.2.tgz", + "integrity": "sha512-0TaCFTnBTVCz6U+baY2UJNKne5ifGh7sMG4ZC2LoBWCZdIyPa+y6UiR4lEYGws1JOFWdee8KAsAIvu0VcXqiqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "classnames": "^2.2.5", + "core-js": "^3.1.3" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6988,6 +7209,16 @@ "node": ">= 0.4" } }, + "node_modules/ionicons": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-8.1.0.tgz", + "integrity": "sha512-XSM2gYWTXxSYTwjmKWAoy4yR7AbAFDjpc5ZIivAiGXskM0fe5CdMPfs6InQRUcrYtVb2WZ/0HQ1/jeh3JW78SA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@stencil/core": "^4.43.5" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9931,7 +10162,7 @@ "version": "19.3.1", "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9986,7 +10217,7 @@ "version": "19.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.28.0" @@ -10050,7 +10281,7 @@ "version": "8.5.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.5.0.tgz", "integrity": "sha512-9nEd/deifcTJIIcUaNIyQGkyXD0D6QVB9XD2O5GgHpP3xu3U5RXm87YQ8++Wn8JQWgMBM/5+PjWPM/7n7O7WHA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@remix-run/route-pattern": "^0.22.1", @@ -10241,6 +10472,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remixicon": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/remixicon/-/remixicon-4.9.1.tgz", + "integrity": "sha512-36gLSoujkabnCFZFDyP17VNh9piuBA/rsXUb4auSJWLGsHVXtmxLj/EM5FjaEAGnk8oIAj1Azob/DZ2N+90lAQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -10510,7 +10748,7 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/semver": { @@ -11632,6 +11870,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "node_modules/unicorn-magic": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.1.tgz", diff --git a/package.json b/package.json index 25e4cf3a..dbf99b51 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,11 @@ "import": "./dist/dropzone/index.js", "default": "./dist/dropzone/index.js" }, + "./vite-plugin": { + "types": "./dist/vitePlugin/index.d.ts", + "import": "./dist/vitePlugin/index.js", + "default": "./dist/vitePlugin/index.js" + }, "./styles.css": "./dist/index.css", "./styles.scss": "./src/styles/all.scss", "./toast/styles.css": "./dist/toast/index.css", @@ -57,31 +62,57 @@ }, "devDependencies": { "@kibalabs/build": "0.14.2-next.16", + "@material-design-icons/svg": "0.14.15", "@storybook/addon-docs": "10.6.4", "@storybook/react-vite": "10.6.4", + "@types/node": "24.19.2", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", + "bootstrap-icons": "1.13.2", + "feather-icons": "4.29.2", + "ionicons": "8.1.0", "react": "19.3.1", "react-dom": "19.3.1", "react-dropzone": "20.1.4", "react-hot-toast": "2.6.1", "react-router": "8.5.0", "remark-gfm": "4.0.1", + "remixicon": "4.9.1", "storybook": "10.6.4" }, "peerDependencies": { + "@material-design-icons/svg": ">=0.14.0", + "bootstrap-icons": "^1.0.0", + "feather-icons": "^4.0.0", + "ionicons": ">=7.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-dropzone": "^20.1.4", "react-hot-toast": "^2.6.1", - "react-router": "^8.5.0" + "react-router": "^8.5.0", + "remixicon": ">=4.0.0" }, "peerDependenciesMeta": { + "@material-design-icons/svg": { + "optional": true + }, + "bootstrap-icons": { + "optional": true + }, + "feather-icons": { + "optional": true + }, + "ionicons": { + "optional": true + }, "react-dropzone": { "optional": true }, "react-hot-toast": { "optional": true + }, + "remixicon": { + "optional": true } } } diff --git a/src/atoms/collapsibleBox/component.tsx b/src/atoms/collapsibleBox/component.tsx index 65011b2d..965c0563 100644 --- a/src/atoms/collapsibleBox/component.tsx +++ b/src/atoms/collapsibleBox/component.tsx @@ -14,6 +14,8 @@ interface ICollapsibleBoxProps extends IComponentProps, ISingleAnyChildProps { isCollapsed: boolean; onCollapseToggled(): void; shouldSkipRenderingWhenCollapsed?: boolean; + expandIconId?: string; + collapseIconId?: string; shouldHideIndicator?: boolean; } @@ -46,7 +48,7 @@ export function CollapsibleBox({ > {props.headerView} {!props.shouldHideIndicator && ( - + )} {(!props.isCollapsed || !props.shouldSkipRenderingWhenCollapsed) && ( diff --git a/src/atoms/selectableView/component.tsx b/src/atoms/selectableView/component.tsx index b4a1c264..62a7237b 100644 --- a/src/atoms/selectableView/component.tsx +++ b/src/atoms/selectableView/component.tsx @@ -15,6 +15,7 @@ export interface ISelectableViewProps extends IComponentProps, ISingleAnyChildPr isDisabled?: boolean; selectedIndicator?: React.ReactElement; shouldHideDefaultSelectedIndicator?: boolean; + selectedIconId?: string; isFullWidth?: boolean; isFullHeight?: boolean; onClicked(): void; @@ -48,7 +49,7 @@ export function SelectableView({ ) : !props.shouldHideDefaultSelectedIndicator && ( - + )} diff --git a/src/atoms/titledCollapsibleBox/component.tsx b/src/atoms/titledCollapsibleBox/component.tsx index cd998cd5..4ae51513 100644 --- a/src/atoms/titledCollapsibleBox/component.tsx +++ b/src/atoms/titledCollapsibleBox/component.tsx @@ -14,6 +14,8 @@ interface ITitledCollapsibleBoxProps extends IComponentProps, ISingleAnyChildPro isCollapsed: boolean; onCollapseToggled(): void; shouldSkipRenderingWhenCollapsed?: boolean; + expandIconId?: string; + collapseIconId?: string; } export function TitledCollapsibleBox({ @@ -35,7 +37,7 @@ export function TitledCollapsibleBox({ onClick={onCollapseToggled} > {props.title} - + {(!props.isCollapsed || !props.shouldSkipRenderingWhenCollapsed) && ( diff --git a/src/dropzone/component.tsx b/src/dropzone/component.tsx index 896669f1..b7205c20 100644 --- a/src/dropzone/component.tsx +++ b/src/dropzone/component.tsx @@ -15,10 +15,11 @@ export interface IDropzoneProps extends IMultiAnyChildProps { fileMimeTypeExtensions?: Record; fileLimit?: number; isDisabled?: boolean; + iconId?: string; } export function Dropzone(props: IDropzoneProps): React.ReactElement { - const { onFilesChosen, fileLimit, isDisabled, fileMimeTypeExtensions, fileMimeTypes, id, className, style, children } = props; + const { onFilesChosen, fileLimit, isDisabled, fileMimeTypeExtensions, fileMimeTypes, id, className, style, children, iconId } = props; const onDrop = React.useCallback((files: File[]) => { onFilesChosen(files); }, [onFilesChosen]); @@ -61,7 +62,7 @@ export function Dropzone(props: IDropzoneProps): React.ReactElement { tabIndex={inputProps.tabIndex} style={inputProps.style} /> - {children ?? } + {children ?? } ); } diff --git a/src/molecules/appDownloadButton/badges/androidDark.ts b/src/molecules/appDownloadButton/badges/androidDark.ts new file mode 100644 index 00000000..1f471cf2 --- /dev/null +++ b/src/molecules/appDownloadButton/badges/androidDark.ts @@ -0,0 +1 @@ +export default ''; diff --git a/src/molecules/appDownloadButton/badges/androidDarkClear.ts b/src/molecules/appDownloadButton/badges/androidDarkClear.ts new file mode 100644 index 00000000..f9c4d7db --- /dev/null +++ b/src/molecules/appDownloadButton/badges/androidDarkClear.ts @@ -0,0 +1 @@ +export default ''; diff --git a/src/molecules/appDownloadButton/badges/androidLight.ts b/src/molecules/appDownloadButton/badges/androidLight.ts new file mode 100644 index 00000000..301a5382 --- /dev/null +++ b/src/molecules/appDownloadButton/badges/androidLight.ts @@ -0,0 +1 @@ +export default ''; diff --git a/src/molecules/appDownloadButton/badges/androidLightClear.ts b/src/molecules/appDownloadButton/badges/androidLightClear.ts new file mode 100644 index 00000000..04e64893 --- /dev/null +++ b/src/molecules/appDownloadButton/badges/androidLightClear.ts @@ -0,0 +1 @@ +export default ''; diff --git a/src/molecules/appDownloadButton/badges/appletvDark.ts b/src/molecules/appDownloadButton/badges/appletvDark.ts new file mode 100644 index 00000000..b464a835 --- /dev/null +++ b/src/molecules/appDownloadButton/badges/appletvDark.ts @@ -0,0 +1 @@ +export default ''; diff --git a/src/molecules/appDownloadButton/badges/appletvDarkClear.ts b/src/molecules/appDownloadButton/badges/appletvDarkClear.ts new file mode 100644 index 00000000..2ab06986 --- /dev/null +++ b/src/molecules/appDownloadButton/badges/appletvDarkClear.ts @@ -0,0 +1 @@ +export default ''; diff --git a/src/molecules/appDownloadButton/badges/appletvLight.ts b/src/molecules/appDownloadButton/badges/appletvLight.ts new file mode 100644 index 00000000..f3d868a6 --- /dev/null +++ b/src/molecules/appDownloadButton/badges/appletvLight.ts @@ -0,0 +1 @@ +export default ''; diff --git a/src/molecules/appDownloadButton/badges/appletvLightClear.ts b/src/molecules/appDownloadButton/badges/appletvLightClear.ts new file mode 100644 index 00000000..f918f998 --- /dev/null +++ b/src/molecules/appDownloadButton/badges/appletvLightClear.ts @@ -0,0 +1 @@ +export default ''; diff --git a/src/molecules/appDownloadButton/badges/iosDark.ts b/src/molecules/appDownloadButton/badges/iosDark.ts new file mode 100644 index 00000000..7e3ddd36 --- /dev/null +++ b/src/molecules/appDownloadButton/badges/iosDark.ts @@ -0,0 +1 @@ +export default ''; diff --git a/src/molecules/appDownloadButton/badges/iosDarkClear.ts b/src/molecules/appDownloadButton/badges/iosDarkClear.ts new file mode 100644 index 00000000..cd9ae4e4 --- /dev/null +++ b/src/molecules/appDownloadButton/badges/iosDarkClear.ts @@ -0,0 +1 @@ +export default ''; diff --git a/src/molecules/appDownloadButton/badges/iosLight.ts b/src/molecules/appDownloadButton/badges/iosLight.ts new file mode 100644 index 00000000..8df9feee --- /dev/null +++ b/src/molecules/appDownloadButton/badges/iosLight.ts @@ -0,0 +1 @@ +export default ''; diff --git a/src/molecules/appDownloadButton/badges/iosLightClear.ts b/src/molecules/appDownloadButton/badges/iosLightClear.ts new file mode 100644 index 00000000..74854258 --- /dev/null +++ b/src/molecules/appDownloadButton/badges/iosLightClear.ts @@ -0,0 +1 @@ +export default ''; diff --git a/src/molecules/appDownloadButton/badges/macDark.ts b/src/molecules/appDownloadButton/badges/macDark.ts new file mode 100644 index 00000000..c7ebb14f --- /dev/null +++ b/src/molecules/appDownloadButton/badges/macDark.ts @@ -0,0 +1 @@ +export default ''; diff --git a/src/molecules/appDownloadButton/badges/macDarkClear.ts b/src/molecules/appDownloadButton/badges/macDarkClear.ts new file mode 100644 index 00000000..58b18cb8 --- /dev/null +++ b/src/molecules/appDownloadButton/badges/macDarkClear.ts @@ -0,0 +1 @@ +export default ''; diff --git a/src/molecules/appDownloadButton/badges/macLight.ts b/src/molecules/appDownloadButton/badges/macLight.ts new file mode 100644 index 00000000..4c048066 --- /dev/null +++ b/src/molecules/appDownloadButton/badges/macLight.ts @@ -0,0 +1 @@ +export default ''; diff --git a/src/molecules/appDownloadButton/badges/macLightClear.ts b/src/molecules/appDownloadButton/badges/macLightClear.ts new file mode 100644 index 00000000..9c3d1841 --- /dev/null +++ b/src/molecules/appDownloadButton/badges/macLightClear.ts @@ -0,0 +1 @@ +export default ''; diff --git a/src/molecules/appDownloadButton/component.tsx b/src/molecules/appDownloadButton/component.tsx index 3dbf5ee8..8c2c61f4 100644 --- a/src/molecules/appDownloadButton/component.tsx +++ b/src/molecules/appDownloadButton/component.tsx @@ -6,6 +6,28 @@ import { IMoleculeProps } from '..'; import { LinkBase } from '../../atoms/linkBase'; import { Image } from '../../particles/image'; +// NOTE(krishan711): each badge is in its own chunk (they're 7-20kb each) so apps only load the ones they show +const BADGE_LOADERS: Record Promise<{ default: string }>> = { + 'android-dark': () => import('./badges/androidDark'), + 'android-dark-clear': () => import('./badges/androidDarkClear'), + 'android-light': () => import('./badges/androidLight'), + 'android-light-clear': () => import('./badges/androidLightClear'), + 'ios-dark': () => import('./badges/iosDark'), + 'ios-dark-clear': () => import('./badges/iosDarkClear'), + 'ios-light': () => import('./badges/iosLight'), + 'ios-light-clear': () => import('./badges/iosLightClear'), + 'mac-dark': () => import('./badges/macDark'), + 'mac-dark-clear': () => import('./badges/macDarkClear'), + 'mac-light': () => import('./badges/macLight'), + 'mac-light-clear': () => import('./badges/macLightClear'), + 'appletv-dark': () => import('./badges/appletvDark'), + 'appletv-dark-clear': () => import('./badges/appletvDarkClear'), + 'appletv-light': () => import('./badges/appletvLight'), + 'appletv-light-clear': () => import('./badges/appletvLightClear'), +}; + +const loadedBadgeSources = new Map(); + export interface AppDownloadButtonProps extends IMoleculeProps { appId: string; appType: 'android' | 'ios' | 'mac' | 'appletv'; @@ -27,6 +49,35 @@ export function AppDownloadButton({ throw new KibaException(`appType not recognized: ${props.appType}`); } + const badgeId = `${props.appType}-${buttonVariant}`; + const [badgeSource, setBadgeSource] = React.useState(loadedBadgeSources.get(badgeId) ?? null); + + React.useEffect((): (() => void) | undefined => { + const loadedBadgeSource = loadedBadgeSources.get(badgeId); + if (loadedBadgeSource) { + setBadgeSource(loadedBadgeSource); + return undefined; + } + setBadgeSource(null); + const badgeLoader = BADGE_LOADERS[badgeId]; + if (!badgeLoader) { + return undefined; + } + let isCancelled = false; + badgeLoader().then((badgeModule: { default: string }): void => { + const source = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(badgeModule.default)}`; + loadedBadgeSources.set(badgeId, source); + if (!isCancelled) { + setBadgeSource(source); + } + }).catch((error: unknown): void => { + console.error(`Failed to load the ${badgeId} badge`, error); + }); + return (): void => { + isCancelled = true; + }; + }, [badgeId]); + const getAppUrl = (): string => { if (props.appType === 'android') { return `https://play.google.com/store/apps/details?id=${props.appId}`; @@ -67,11 +118,13 @@ export function AppDownloadButton({ variant='image' target={getAppUrl()} > - + {badgeSource && ( + + )} ); } diff --git a/src/molecules/appDownloadButton/documentation.mdx b/src/molecules/appDownloadButton/documentation.mdx index 9e2eb359..991a4f92 100644 --- a/src/molecules/appDownloadButton/documentation.mdx +++ b/src/molecules/appDownloadButton/documentation.mdx @@ -5,7 +5,7 @@ import * as Stories from './documentation.stories'; # AppDownloadButton -**AppDownloadButton** is a `molecule` component for app store download badges. Supports Android, iOS, Mac, and Apple TV stores. +**AppDownloadButton** is a `molecule` component for app store download badges. Supports Android, iOS, Mac, and Apple TV stores. The badges are bundled with ui-react (each in its own chunk, loaded when shown). @@ -25,9 +25,9 @@ import * as Stories from './documentation.stories'; -### With Target Link +### With App Id - + ## Theming diff --git a/src/molecules/carousel/component.tsx b/src/molecules/carousel/component.tsx index a223dfaa..817d7410 100644 --- a/src/molecules/carousel/component.tsx +++ b/src/molecules/carousel/component.tsx @@ -20,6 +20,8 @@ export interface ICarouselProps extends IMoleculeProps, IMultiAnyChildProps { slidesPerPageResponsive?: ResponsiveField; onIndexProgressed?: (slideIndexProgress: number) => void; onIndexChanged?: (slideIndex: number) => void; + previousIconId?: string; + nextIconId?: string; } // NOTE(krishan711): the slider could potentially be its own component here! @@ -153,7 +155,7 @@ export function Carousel({ {shouldShowButtons && ( } + icon={} label='Previous' onClicked={onPreviousClicked} /> @@ -180,7 +182,7 @@ export function Carousel({ {shouldShowButtons && ( } + icon={} label='Next' onClicked={onNextClicked} /> diff --git a/src/molecules/messageDialog/component.tsx b/src/molecules/messageDialog/component.tsx index 86cd8ea5..13c14dc3 100644 --- a/src/molecules/messageDialog/component.tsx +++ b/src/molecules/messageDialog/component.tsx @@ -10,6 +10,7 @@ interface IMessageDialogProps extends IMoleculeProps { isOpen: boolean; maxHeight?: string; maxWidth?: string; + closeIconId?: string; title: string; message: string; confirmButtonText?: string; @@ -32,7 +33,7 @@ export function MessageDialog({ {props.title} {props.onCloseClicked && ( - } onClicked={props.onCloseClicked} /> + } onClicked={props.onCloseClicked} /> )} {props.message} diff --git a/src/particles/kibaIcon/component.tsx b/src/particles/kibaIcon/component.tsx index 5502fba7..eed016dc 100644 --- a/src/particles/kibaIcon/component.tsx +++ b/src/particles/kibaIcon/component.tsx @@ -16,75 +16,164 @@ interface IconData { shouldAddStroke: boolean; } +const getIconData = (iconId: string): IconData | null => { + if (iconId.startsWith('ion-')) { + return { + url: `https://assets-cdn.kiba.dev/ionicons/v5/${iconId.replace('ion-', '')}.svg`, + shouldAddFill: true, + shouldAddStroke: false, + }; + } + if (iconId.startsWith('mui-')) { + return { + url: `https://assets-cdn.kiba.dev/material-icons/v1/${iconId.replace('mui-', '')}.svg`, + shouldAddFill: true, + shouldAddStroke: false, + }; + } + if (iconId.startsWith('bs-')) { + return { + url: `https://assets-cdn.kiba.dev/bootstrap-icons/v1/${iconId.replace('bs-', '')}.svg`, + shouldAddFill: true, + shouldAddStroke: false, + }; + } + if (iconId.startsWith('feather-')) { + return { + url: `https://assets-cdn.kiba.dev/feather/v4/${iconId.replace('feather-', '')}.svg`, + shouldAddFill: false, + shouldAddStroke: true, + }; + } + if (iconId.startsWith('remix-')) { + return { + url: `https://assets-cdn.kiba.dev/remix-icons/v2/${iconId.replace('remix-', '')}.svg`, + shouldAddFill: true, + shouldAddStroke: false, + }; + } + return null; +}; + +// NOTE(krishan711): icons bundled into the app at build time by @kibalabs/ui-react/vite-plugin are registered +// here (svg content keyed by icon id, already normalised) so KibaIcon can render them without fetching. +const registeredIcons = new Map(); + +export const registerKibaIcons = (icons: Record): void => { + Object.entries(icons).forEach(([iconId, svgContent]) => { + registeredIcons.set(iconId, svgContent); + }); +}; + +const getRegisteredKibaIcon = (iconId: string): string | undefined => { + return registeredIcons.get(iconId); +}; + +const normalizeKibaIconSvg = (svgContent: string): string => { + return svgContent.replace(/.*<\/title>/gi, '').replace(/stroke:#000/gi, 'stroke:currentColor').replace(/fill:#000/gi, 'fill:currentColor'); +}; + +// NOTE(krishan711): icons that weren't bundled at build time are fetched at most once per page and shared by every KibaIcon +const fetchedSvgContents = new Map<string, string>(); +const inFlightSvgContents = new Map<string, Promise<string>>(); +const FETCH_RETRY_DELAY_MILLIS = 1000; + +const fetchSvgContent = async (url: string, retryCount: number): Promise<string> => { + try { + const response = await fetch(url); + if (response.status >= 300) { + throw new Error(`Failed to fetch icon from ${url}: ${response.status}`); + } + return normalizeKibaIconSvg(await response.text()); + } catch (error: unknown) { + if (retryCount <= 0) { + throw error; + } + await new Promise((resolve): void => { + setTimeout(resolve, FETCH_RETRY_DELAY_MILLIS); + }); + return fetchSvgContent(url, retryCount - 1); + } +}; + +const loadSvgContent = (url: string): Promise<string> => { + const fetchedSvgContent = fetchedSvgContents.get(url); + if (fetchedSvgContent !== undefined) { + return Promise.resolve(fetchedSvgContent); + } + let inFlightSvgContent = inFlightSvgContents.get(url); + if (!inFlightSvgContent) { + inFlightSvgContent = fetchSvgContent(url, 1) + .then((svgContent: string): string => { + fetchedSvgContents.set(url, svgContent); + return svgContent; + }) + .finally((): void => { + inFlightSvgContents.delete(url); + }); + inFlightSvgContents.set(url, inFlightSvgContent); + } + return inFlightSvgContent; +}; + +const getKnownSvgContent = (iconId: string): string | undefined => { + const registeredSvgContent = getRegisteredKibaIcon(iconId); + if (registeredSvgContent !== undefined) { + return registeredSvgContent; + } + const iconData = getIconData(iconId); + return iconData ? fetchedSvgContents.get(iconData.url) : undefined; +}; + +interface ILoadedSvgContent { + iconId: string; + svgContent: string; +} + export function KibaIcon({ className = '', variant = 'default', ...props }: IKibaIconProps): React.ReactElement { - const [svgContent, setSvgContent] = React.useState<string | null>(null); - const [shouldAddFill, setShouldAddFill] = React.useState<boolean | undefined>(undefined); - const [shouldAddStroke, setShouldAddStroke] = React.useState<boolean | undefined>(undefined); - - const getIconData = (iconId: string): IconData | null => { - if (iconId.startsWith('ion-')) { - return { - url: `https://assets-cdn.kiba.dev/ionicons/v5/${iconId.replace('ion-', '')}.svg`, - shouldAddFill: true, - shouldAddStroke: false, - }; - } - if (iconId.startsWith('mui-')) { - return { - url: `https://assets-cdn.kiba.dev/material-icons/v1/${iconId.replace('mui-', '')}.svg`, - shouldAddFill: true, - shouldAddStroke: false, - }; - } - if (iconId.startsWith('bs-')) { - return { - url: `https://assets-cdn.kiba.dev/bootstrap-icons/v1/${iconId.replace('bs-', '')}.svg`, - shouldAddFill: true, - shouldAddStroke: false, - }; - } - if (iconId.startsWith('feather-')) { - return { - url: `https://assets-cdn.kiba.dev/feather/v4/${iconId.replace('feather-', '')}.svg`, - shouldAddFill: false, - shouldAddStroke: true, - }; + const iconData = getIconData(props.iconId); + // NOTE(krishan711): icons bundled at build time (or already fetched) render straight away + const knownSvgContent = getKnownSvgContent(props.iconId); + const [loadedSvgContent, setLoadedSvgContent] = React.useState<ILoadedSvgContent | null>(null); + + React.useEffect((): (() => void) | undefined => { + if (knownSvgContent !== undefined) { + return undefined; } - if (iconId.startsWith('remix-')) { - return { - url: `https://assets-cdn.kiba.dev/remix-icons/v2/${iconId.replace('remix-', '')}.svg`, - shouldAddFill: true, - shouldAddStroke: false, - }; + const iconId = props.iconId; + // NOTE(krishan711): covers icons registered (or fetched by another KibaIcon) after this rendered + const currentKnownSvgContent = getKnownSvgContent(iconId); + if (currentKnownSvgContent !== undefined) { + setLoadedSvgContent({ iconId, svgContent: currentKnownSvgContent }); + return undefined; } - return null; - }; - - React.useEffect(() => { - setSvgContent(null); - const iconData = getIconData(props.iconId); - if (!iconData) { - console.warn(`Failed to identify icon type from iconId: ${props.iconId}`); - return; + const currentIconData = getIconData(iconId); + if (!currentIconData) { + console.warn(`Failed to identify icon type from iconId: ${iconId}`); + return undefined; } - setShouldAddFill(iconData.shouldAddFill); - setShouldAddStroke(iconData.shouldAddStroke); - fetch(iconData.url) - .then((result) => { - if (result.status >= 300) { - throw new Error(`Failed to fetch icon: ${result}`); + let isCancelled = false; + loadSvgContent(currentIconData.url) + .then((svgContent: string): void => { + if (!isCancelled) { + setLoadedSvgContent({ iconId, svgContent }); } - return result.text(); - }) - .then((resultText) => { - setSvgContent(resultText.replace(/<title>.*<\/title>/gi, '').replace(/stroke:#000/gi, 'stroke:currentColor').replace(/fill:#000/gi, 'fill:currentColor')); }) - .catch(() => setSvgContent(null)); - }, [props.iconId]); + .catch((): void => { + if (!isCancelled) { + setLoadedSvgContent(null); + } + }); + return (): void => { + isCancelled = true; + }; + }, [props.iconId, knownSvgContent]); + + const svgContent = knownSvgContent ?? (loadedSvgContent?.iconId === props.iconId ? loadedSvgContent.svgContent : null); return ( <Icon @@ -93,8 +182,8 @@ export function KibaIcon({ variant={variant} // eslint-disable-next-line no-underscore-dangle _color={props._color} - shouldAddFill={shouldAddFill} - shouldAddStroke={shouldAddStroke} + shouldAddFill={iconData?.shouldAddFill} + shouldAddStroke={iconData?.shouldAddStroke} svgContent={svgContent || '<svg></svg>'} /> ); diff --git a/src/particles/kibaIcon/documentation.mdx b/src/particles/kibaIcon/documentation.mdx index 65732dc7..222f78b4 100644 --- a/src/particles/kibaIcon/documentation.mdx +++ b/src/particles/kibaIcon/documentation.mdx @@ -5,7 +5,7 @@ import * as Stories from './documentation.stories'; # KibaIcon -**KibaIcon** is a convenience wrapper around `Icon` that fetches icons from Kiba's CDN by ID. Supports multiple popular icon libraries. +**KibaIcon** is a convenience wrapper around `Icon` that shows an icon by ID from one of several popular icon libraries. With the vite plugin (below) the icons are bundled into your app at build time. <Canvas of={Stories.Default} /> @@ -49,14 +49,36 @@ Inherits all styling from Icon. See Icon documentation for variants and customiz ### Supported Icon Sets -| Prefix | Icon Set | Example | -|--------|----------|---------| -| `ion-` | [Ionicons v5](https://ionic.io/ionicons) | `ion-home` | -| `mui-` | [Material Icons](https://fonts.google.com/icons) | `mui-home` | -| `bs-` | [Bootstrap Icons](https://icons.getbootstrap.com/) | `bs-house` | -| `feather-` | [Feather Icons](https://feathericons.com/) | `feather-home` | -| `remix-` | [Remix Icons](https://remixicon.com/) | `remix-home-line` | +| Prefix | Icon Set | Package | Example | +|--------|----------|---------|---------| +| `ion-` | [Ionicons](https://ionic.io/ionicons) | `ionicons` | `ion-home` | +| `mui-` | [Material Icons](https://fonts.google.com/icons) | `@material-design-icons/svg` | `mui-home`, `mui-account-box-outlined` | +| `bs-` | [Bootstrap Icons](https://icons.getbootstrap.com/) | `bootstrap-icons` | `bs-house` | +| `feather-` | [Feather Icons](https://feathericons.com/) | `feather-icons` | `feather-home` | +| `remix-` | [Remix Icons](https://remixicon.com/) | `remixicon` | `remix-home-line` | -### Note +## Bundling icons -Icons are fetched asynchronously from `assets-cdn.kiba.dev`. Consider using `Icon` with bundled SVGs for critical icons to avoid network delays. +Install the packages for the icon sets you use (they are optional peer dependencies; ui-react's own components default to `ionicons` but take icon id props, e.g. `Carousel`'s `previousIconId`/`nextIconId`, to use another set) and add the vite plugin in your `config.build.js`: + +```bash +npm install ionicons +``` + +```js +import { kibaIconsPlugin } from '@kibalabs/ui-react/vite-plugin'; + +export default (config) => { + const newConfig = config; + newConfig.viteConfigModifier = (viteConfig) => { + const newViteConfig = viteConfig; + newViteConfig.plugins.push(kibaIconsPlugin()); + return newViteConfig; + }; + return newConfig; +}; +``` + +At build time and in dev the plugin finds every quoted icon id (e.g. `'ion-home'`) in your `src` and in ui-react itself, reads those svgs from the installed packages and registers them before your app's entry runs, so they render straight away with no requests. Ids from a set whose package isn't installed are warned about once. Pass `sourceDirectories` (relative to the vite root) if your code isn't in `src`. + +Ids that aren't written out in full in the code (e.g. `` `ion-${name}` `` or ids from an api) and apps without the plugin still work: those icons are fetched from `assets-cdn.kiba.dev` once per page and shared by every `KibaIcon`. diff --git a/src/particles/kibaIcon/documentation.stories.tsx b/src/particles/kibaIcon/documentation.stories.tsx index bb73c16d..6f9e6b94 100644 --- a/src/particles/kibaIcon/documentation.stories.tsx +++ b/src/particles/kibaIcon/documentation.stories.tsx @@ -66,7 +66,7 @@ export const RemixIcons: Story = { <Stack direction={Direction.Horizontal} shouldAddGutters={true}> <KibaIcon iconId='remix-home-line' /> <KibaIcon iconId='remix-settings-line' /> - <KibaIcon iconId='remix-honour' /> + <KibaIcon iconId='remix-honour-line' /> </Stack> ), }; diff --git a/src/theming.mdx b/src/theming.mdx index dc79d81c..23b91057 100644 --- a/src/theming.mdx +++ b/src/theming.mdx @@ -138,6 +138,37 @@ import './theme.scss'; | `--kiba-font-weight-bold` | Bold font weight | | `--kiba-line-height` | Default line height | +### Self-hosting Google Fonts + +Load Google Fonts as usual and add the fonts vite plugin in your `config.build.js` to serve them from your own domain: + +```scss +// theme.scss +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap'); + +:root { + --kiba-font-family: 'Inter', system-ui, sans-serif; +} +``` + +```js +import { kibaFontsPlugin } from '@kibalabs/ui-react/vite-plugin'; + +export default (config) => { + const newConfig = config; + newConfig.viteConfigModifier = (viteConfig) => { + const newViteConfig = viteConfig; + newViteConfig.plugins.push(kibaFontsPlugin()); + return newViteConfig; + }; + return newConfig; +}; +``` + +At build time the plugin finds `fonts.googleapis.com` stylesheets in your css (after sass has compiled it, so partials count too) and in `<link rel="stylesheet">` tags in `index.html`, downloads them and their font files (only ever from `fonts.googleapis.com` and `fonts.gstatic.com`), emits the fonts as hashed assets and replaces the `@import` with the `@font-face` rules. The latin, normal style file (closest to weight 400) of each family that the page's css uses is preloaded; pass `shouldPreloadFonts: false` to turn that off. + +Downloads are cached in `node_modules/.cache/kiba-fonts` (or `cacheDirectory`). If downloading fails (e.g. an offline build) the build still succeeds: the `@import` is left as it is, so the browser loads the fonts from Google, and a warning is logged. The dev server always leaves the `@import` as it is. + ### Spacing | Variable | Description | diff --git a/src/vitePlugin/fontsPlugin.ts b/src/vitePlugin/fontsPlugin.ts new file mode 100644 index 00000000..35255c8c --- /dev/null +++ b/src/vitePlugin/fontsPlugin.ts @@ -0,0 +1,463 @@ +// NOTE(krishan711): this runs in node (inside vite), so it must only import node builtins and never the browser code +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import type { HtmlTagDescriptor, IndexHtmlTransformContext, IndexHtmlTransformResult, Logger, Plugin, ResolvedConfig } from 'vite'; + +export interface IKibaFontsPluginOptions { + // NOTE(krishan711): where downloaded stylesheets and fonts are kept between builds, defaults to node_modules/.cache/kiba-fonts in the vite root + cacheDirectory?: string; + // NOTE(krishan711): preloads the latin normal style file (closest to weight 400) of each family, defaults to true + shouldPreloadFonts?: boolean; +} + +interface IFontFormat { + extension: string; + mimeType: string; + cssFormat: string; + signatures: Buffer[]; +} + +interface IFontFace { + descriptors: [string, string][]; + family: string; + style: string; + weight: string; + subset: string | null; + hasUnicodeRange: boolean; + fontUrl: string; +} + +interface IFont { + content: Buffer; + format: IFontFormat; + hash: string; +} + +interface IStylesheet { + faces: IFontFace[]; + fonts: Map<string, IFont>; + preloadFontUrls: Set<string>; +} + +interface IEmittedFont { + referenceId: string; + mimeType: string; + shouldPreload: boolean; + fileName: string | null; +} + +const STYLESHEET_HOST = 'fonts.googleapis.com'; +const FONT_HOST = 'fonts.gstatic.com'; +const STYLESHEET_PATHS = new Set(['/css', '/css2']); +// NOTE(krishan711): google serves woff2 files split into unicode-range subsets only to modern browsers +const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'; +const FETCH_TIMEOUT_MS = 20_000; +const MAX_STYLESHEET_SIZE = 512 * 1024; +const MAX_FONT_SIZE = 5 * 1024 * 1024; +const FONT_FORMATS: IFontFormat[] = [ + { extension: 'woff2', mimeType: 'font/woff2', cssFormat: 'woff2', signatures: [Buffer.from('wOF2')] }, + { extension: 'woff', mimeType: 'font/woff', cssFormat: 'woff', signatures: [Buffer.from('wOFF')] }, + { extension: 'ttf', mimeType: 'font/ttf', cssFormat: 'truetype', signatures: [Buffer.from([0x00, 0x01, 0x00, 0x00]), Buffer.from('true')] }, +]; +const FONT_EXTENSIONS = new Set(FONT_FORMATS.map((format) => format.extension)); +const FONT_CONTENT_TYPES = new Set(['font/woff2', 'font/woff', 'font/ttf', 'font/sfnt', 'application/font-woff', 'application/font-woff2', 'application/x-font-ttf', 'application/font-sfnt']); +// NOTE(krishan711): only these descriptors are copied from google's css, so nothing else from it ends up in the app +const FONT_FACE_DESCRIPTORS = new Set(['font-family', 'font-style', 'font-weight', 'font-stretch', 'font-display', 'unicode-range']); +const SAFE_DESCRIPTOR_VALUE_REGEX = /^[\w\s'"+\-,.?%]+$/; +const FONT_FACE_REGEX = /(?:\/\*\s*([\w-]+)\s*\*\/\s*)?@font-face\s*\{([^{}]*)\}/g; +const FONT_SOURCE_REGEX = /^url\(\s*['"]?(https:\/\/fonts\.gstatic\.com\/[\w\-./]+)['"]?\s*\)/; +const CSS_IMPORT_REGEX = /@import\s+(?:url\(\s*(?:"([^"]*)"|'([^']*)'|([^"'()\s]+))\s*\)|"([^"]*)"|'([^']*)')\s*;/g; +const CSS_FILE_REGEX = /\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/; +const LINK_TAG_REGEX = /<link\b[^>]*>/gi; +const PLACEHOLDER_REGEX = /__KIBA_FONT_([a-f0-9]{16})__/g; + +const hashContent = (content: string | Buffer): string => { + return crypto.createHash('sha256').update(content).digest('hex'); +}; + +const getStylesheetUrl = (value: string): string | null => { + let url: URL; + try { + url = new URL(value.startsWith('//') ? `https:${value}` : value); + } catch { + return null; + } + if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.hostname !== STYLESHEET_HOST || url.port || url.username || url.password || !STYLESHEET_PATHS.has(url.pathname)) { + return null; + } + url.protocol = 'https:'; + return url.href; +}; + +const getContentType = (headers: Headers): string => { + return (headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase(); +}; + +const fetchContent = async (url: string, allowedHost: string, maxSize: number): Promise<{ content: Buffer; contentType: string }> => { + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== 'https:' || parsedUrl.hostname !== allowedHost) { + throw new Error(`only https://${allowedHost} is allowed, not ${url}`); + } + // NOTE(krishan711): redirects are refused so nothing is fetched from any other host + const response = await fetch(parsedUrl, { headers: { 'User-Agent': USER_AGENT }, redirect: 'error', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!response.ok || !response.body) { + throw new Error(`${url} responded with ${response.status}`); + } + if (Number(response.headers.get('content-length') ?? 0) > maxSize) { + await response.body.cancel(); + throw new Error(`${url} is larger than ${maxSize} bytes`); + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read(); + if (done) { + break; + } + size += value.byteLength; + if (size > maxSize) { + // eslint-disable-next-line no-await-in-loop + await reader.cancel(); + throw new Error(`${url} is larger than ${maxSize} bytes`); + } + chunks.push(value); + } + return { content: Buffer.concat(chunks), contentType: getContentType(response.headers) }; +}; + +const getFontFormat = (content: Buffer): IFontFormat | null => { + if (content.length > MAX_FONT_SIZE) { + return null; + } + return FONT_FORMATS.find((format) => format.signatures.some((signature) => content.subarray(0, signature.length).equals(signature))) ?? null; +}; + +// NOTE(krishan711): google's css only has (commented) @font-face rules, anything else means it isn't what was expected +const parseStylesheet = (css: string): IFontFace[] => { + const faces = [...css.matchAll(FONT_FACE_REGEX)].map((match): IFontFace => { + const descriptors: [string, string][] = []; + let fontUrl: string | null = null; + match[2].split(';').map((declaration) => declaration.trim()).filter((declaration) => declaration.length > 0).forEach((declaration) => { + const separatorIndex = declaration.indexOf(':'); + if (separatorIndex < 0) { + throw new Error(`unexpected declaration in @font-face: ${declaration}`); + } + const name = declaration.slice(0, separatorIndex).trim().toLowerCase(); + const value = declaration.slice(separatorIndex + 1).trim(); + if (name === 'src') { + fontUrl = value.match(FONT_SOURCE_REGEX)?.[1] ?? null; + if (!fontUrl) { + throw new Error(`unexpected @font-face src: ${value}`); + } + } else if (FONT_FACE_DESCRIPTORS.has(name)) { + if (!SAFE_DESCRIPTOR_VALUE_REGEX.test(value)) { + throw new Error(`unexpected @font-face ${name}: ${value}`); + } + descriptors.push([name, value]); + } + }); + const getDescriptor = (name: string): string | undefined => descriptors.find(([descriptorName]) => descriptorName === name)?.[1]; + const family = getDescriptor('font-family')?.replace(/['"]/g, '').trim(); + if (!family || !fontUrl) { + throw new Error('@font-face without a font-family or src'); + } + return { + descriptors, + family, + style: getDescriptor('font-style') ?? 'normal', + weight: getDescriptor('font-weight') ?? '400', + subset: match[1] ?? null, + hasUnicodeRange: getDescriptor('unicode-range') !== undefined, + fontUrl, + }; + }); + if (faces.length === 0 || css.replace(FONT_FACE_REGEX, '').replace(/\/\*[\s\S]*?\*\//g, '').trim().length > 0) { + throw new Error('the stylesheet has something other than @font-face rules'); + } + return faces; +}; + +const getWeightDistance = (weight: string): number => { + const weights = weight.split(/\s+/).map((value) => (value === 'bold' ? 700 : Number(value) || 400)); + const minimumWeight = Math.min(...weights); + const maximumWeight = Math.max(...weights); + return Math.max(minimumWeight - 400, 400 - maximumWeight, 0); +}; + +// NOTE(krishan711): preloading too much slows the page down, so only the file most text needs is preloaded for each family +const getPreloadFontUrls = (faces: IFontFace[]): Set<string> => { + const familyFaces = new Map<string, IFontFace>(); + faces.filter((face) => face.style === 'normal' && (face.subset === 'latin' || !face.hasUnicodeRange)).forEach((face) => { + const currentFace = familyFaces.get(face.family); + if (!currentFace || getWeightDistance(face.weight) < getWeightDistance(currentFace.weight)) { + familyFaces.set(face.family, face); + } + }); + return new Set([...familyFaces.values()].map((face) => face.fontUrl)); +}; + +const getFontAssetName = (face: IFontFace, format: IFontFormat): string => { + const name = [face.family, face.weight, face.style, face.subset ?? ''].join('-').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + return `${name}.${format.extension}`; +}; + +const readCacheFile = (filePath: string): Buffer | null => { + try { + return fs.readFileSync(filePath); + } catch { + return null; + } +}; + +const writeCacheFile = (filePath: string, content: Buffer): void => { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const temporaryFilePath = `${filePath}.${process.pid}.tmp`; + fs.writeFileSync(temporaryFilePath, content); + fs.renameSync(temporaryFilePath, filePath); + } catch { + // NOTE(krishan711): the cache only saves downloads, so failing to write it is fine + } +}; + +export const kibaFontsPlugin = (options: IKibaFontsPluginOptions = {}): Plugin => { + const shouldPreloadFonts = options.shouldPreloadFonts ?? true; + let rootDirectory = process.cwd(); + let cacheDirectory = path.join(rootDirectory, 'node_modules', '.cache', 'kiba-fonts'); + let base = '/'; + let logger: Logger | null = null; + let stylesheetPromises = new Map<string, Promise<IStylesheet | null>>(); + const emittedFonts = new Map<string, IEmittedFont>(); + + const loadFont = async (fontUrl: string): Promise<IFont> => { + const extension = path.posix.extname(new URL(fontUrl).pathname).slice(1); + const cacheFilePath = path.join(cacheDirectory, `${hashContent(fontUrl).slice(0, 32)}.${FONT_EXTENSIONS.has(extension) ? extension : 'font'}`); + let content = readCacheFile(cacheFilePath); + let format = content ? getFontFormat(content) : null; + if (!content || !format) { + const response = await fetchContent(fontUrl, FONT_HOST, MAX_FONT_SIZE); + if (!FONT_CONTENT_TYPES.has(response.contentType) && !FONT_EXTENSIONS.has(extension)) { + throw new Error(`${fontUrl} is not a font (${response.contentType})`); + } + content = response.content; + format = getFontFormat(content); + if (!format) { + throw new Error(`${fontUrl} is not a woff2, woff or ttf file`); + } + writeCacheFile(cacheFilePath, content); + } + return { content, format, hash: hashContent(content) }; + }; + + const loadStylesheet = async (url: string): Promise<IStylesheet> => { + const cacheFilePath = path.join(cacheDirectory, `${hashContent(`${USER_AGENT}\n${url}`).slice(0, 32)}.css`); + const cachedContent = readCacheFile(cacheFilePath); + let faces: IFontFace[] | null = null; + if (cachedContent) { + try { + faces = parseStylesheet(cachedContent.toString('utf-8')); + } catch { + faces = null; + } + } + if (!faces) { + const response = await fetchContent(url, STYLESHEET_HOST, MAX_STYLESHEET_SIZE); + if (response.contentType !== 'text/css') { + throw new Error(`${url} is not css (${response.contentType})`); + } + faces = parseStylesheet(response.content.toString('utf-8')); + writeCacheFile(cacheFilePath, response.content); + } + const fontUrls = [...new Set(faces.map((face) => face.fontUrl))]; + const fonts = await Promise.all(fontUrls.map((fontUrl) => loadFont(fontUrl))); + return { faces, fonts: new Map(fontUrls.map((fontUrl, index) => [fontUrl, fonts[index]])), preloadFontUrls: getPreloadFontUrls(faces) }; + }; + + // NOTE(krishan711): a failed download leaves the @import as it is (so the browser loads it from google) and is warned about once + const getStylesheet = (url: string): Promise<IStylesheet | null> => { + let stylesheetPromise = stylesheetPromises.get(url); + if (!stylesheetPromise) { + stylesheetPromise = loadStylesheet(url).catch((error: unknown): null => { + // NOTE(krishan711): fetch's own errors only say "fetch failed" with the reason in the cause + const reason = error instanceof Error ? [error.message, 'cause' in error && error.cause instanceof Error ? error.cause.message : null].filter(Boolean).join(': ') : String(error); + logger?.warn(`[kiba-fonts] Leaving ${url} to be loaded from Google at runtime because downloading it failed: ${reason}`); + return null; + }); + stylesheetPromises.set(url, stylesheetPromise); + } + return stylesheetPromise; + }; + + const getFontUrl = (hostFileName: string, fontFileName: string): string => { + if (base === './' || base === '') { + const relativePath = path.posix.relative(path.posix.dirname(hostFileName), fontFileName); + return relativePath.startsWith('.') ? relativePath : `./${relativePath}`; + } + return `${base}${fontFileName}`; + }; + + const replacePlaceholders = (content: string, hostFileName: string): string => { + return content.replace(PLACEHOLDER_REGEX, (placeholder: string, key: string): string => { + const fileName = emittedFonts.get(key)?.fileName; + return fileName ? getFontUrl(hostFileName, fileName) : placeholder; + }); + }; + + const getUsedFontKeys = (content: string): string[] => { + return [...content.matchAll(PLACEHOLDER_REGEX)].map((match) => match[1]); + }; + + const hasStylesheetUrl = (content: string): boolean => { + return [...content.matchAll(CSS_IMPORT_REGEX)].some((match) => getStylesheetUrl(match[1] ?? match[2] ?? match[3] ?? match[4] ?? match[5] ?? '') !== null); + }; + + return { + name: 'kiba-fonts', + // NOTE(krishan711): in dev the @import is left as it is, so the browser loads the fonts from google + apply: 'build', + configResolved(config: ResolvedConfig): void { + rootDirectory = config.root; + cacheDirectory = options.cacheDirectory ? path.resolve(config.root, options.cacheDirectory) : path.join(config.root, 'node_modules', '.cache', 'kiba-fonts'); + base = config.base; + logger = config.logger; + }, + buildStart(): void { + stylesheetPromises = new Map(); + emittedFonts.clear(); + }, + // NOTE(krishan711): runs after vite:css, so the code is css even for sass files (sass leaves @import url(...) as a plain css @import) + async transform(code: string, id: string) { + if (this.environment?.config.consumer === 'server' || !code.includes(STYLESHEET_HOST)) { + return null; + } + // NOTE(krishan711): the html's inline styles have ids like index.html?html-proxy&inline-css&index=0.css, so those are css + if (id.endsWith('.html')) { + // NOTE(krishan711): stylesheet links become inline @imports, which vite then passes through the css transform below + const links = [...code.matchAll(LINK_TAG_REGEX)].map((match) => { + const getAttribute = (name: string): string | null => { + const attributeMatch = match[0].match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i')); + return attributeMatch ? (attributeMatch[1] ?? attributeMatch[2] ?? attributeMatch[3]) : null; + }; + const isStylesheet = (getAttribute('rel') ?? '').toLowerCase().split(/\s+/).includes('stylesheet') && getAttribute('media') === null; + return { tag: match[0], url: isStylesheet ? getStylesheetUrl((getAttribute('href') ?? '').replace(/&/g, '&')) : null }; + }).filter((link): link is { tag: string; url: string } => link.url !== null); + const stylesheets = await Promise.all(links.map((link) => getStylesheet(link.url))); + let html = code; + links.forEach((link, index) => { + if (stylesheets[index]) { + html = html.replace(link.tag, (): string => `<style>@import url("${link.url}");</style>`); + } + }); + return html === code ? null : { code: html, map: null }; + } + // NOTE(krishan711): ?inline css ends up in js strings where the placeholders can't be replaced + if (!CSS_FILE_REGEX.test(id) || (!/[?&]html-proxy\b/.test(id) && /[?&](?:inline|raw)\b/.test(id)) || /[?&]style-attr\b/.test(id)) { + return null; + } + const imports = [...code.matchAll(CSS_IMPORT_REGEX)].map((match) => ({ statement: match[0], url: getStylesheetUrl(match[1] ?? match[2] ?? match[3] ?? match[4] ?? match[5] ?? '') })); + const stylesheetUrls = [...new Set(imports.map((cssImport) => cssImport.url).filter((url): url is string => url !== null))]; + if (stylesheetUrls.length === 0) { + return null; + } + const stylesheets = await Promise.all(stylesheetUrls.map((url) => getStylesheet(url))); + const stylesheetCss = new Map<string, string>(); + stylesheetUrls.forEach((url, index) => { + const stylesheet = stylesheets[index]; + if (!stylesheet) { + return; + } + const fontFaces = stylesheet.faces.map((face) => { + const font = stylesheet.fonts.get(face.fontUrl); + if (!font) { + return ''; + } + const key = font.hash.slice(0, 16); + if (!emittedFonts.has(key)) { + const referenceId = this.emitFile({ type: 'asset', name: getFontAssetName(face, font.format), source: font.content }); + emittedFonts.set(key, { referenceId, mimeType: font.format.mimeType, shouldPreload: false, fileName: null }); + } + const emittedFont = emittedFonts.get(key); + if (emittedFont && stylesheet.preloadFontUrls.has(face.fontUrl)) { + emittedFont.shouldPreload = true; + } + // NOTE(krishan711): the placeholder has the font's hash, so the css file's hash changes when the font does + const declarations = [...face.descriptors.map(([name, value]) => `${name}: ${value};`), `src: url(__KIBA_FONT_${key}__) format('${font.format.cssFormat}');`]; + return `@font-face {\n ${declarations.join('\n ')}\n}\n`; + }); + stylesheetCss.set(url, fontFaces.join('')); + }); + // NOTE(krishan711): @font-face rules can go anywhere, so they replace the @import where it was + let css = code; + imports.forEach((cssImport) => { + const fontFaceCss = cssImport.url ? stylesheetCss.get(cssImport.url) : undefined; + if (fontFaceCss !== undefined) { + css = css.replace(cssImport.statement, (): string => fontFaceCss); + } + }); + return css === code ? null : { code: css, map: null }; + }, + // NOTE(krishan711): the file names are needed in transformIndexHtml, which runs (in vite:build-html's generateBundle) before this plugin's generateBundle + renderChunk(): null { + emittedFonts.forEach((emittedFont) => { + if (!emittedFont.fileName) { + // eslint-disable-next-line no-param-reassign + emittedFont.fileName = this.getFileName(emittedFont.referenceId); + } + }); + return null; + }, + // NOTE(krishan711): post so the css vite emits in its own generateBundle (when cssCodeSplit is off) is included + generateBundle: { + order: 'post', + handler(_, bundle): void { + Object.values(bundle).forEach((output) => { + if (output.type !== 'asset' || !(output.fileName.endsWith('.css') || output.fileName.endsWith('.html'))) { + return; + } + const source = typeof output.source === 'string' ? output.source : Buffer.from(output.source).toString('utf-8'); + if (source.includes('__KIBA_FONT_')) { + // eslint-disable-next-line no-param-reassign + output.source = replacePlaceholders(source, output.fileName); + } + }); + }, + }, + transformIndexHtml: { + order: 'post', + handler(html: string, context: IndexHtmlTransformContext): IndexHtmlTransformResult | undefined { + if (!context.bundle) { + return undefined; + } + const htmlFileName = path.relative(rootDirectory, context.filename).split(path.sep).join('/'); + const linkedCss = Object.values(context.bundle).map((output): string => { + if (output.type !== 'asset' || !output.fileName.endsWith('.css') || !html.includes(output.fileName)) { + return ''; + } + return typeof output.source === 'string' ? output.source : Buffer.from(output.source).toString('utf-8'); + }).join('\n'); + const tags: HtmlTagDescriptor[] = []; + if (shouldPreloadFonts) { + const usedFontKeys = new Set([...getUsedFontKeys(html), ...getUsedFontKeys(linkedCss)]); + usedFontKeys.forEach((key) => { + const emittedFont = emittedFonts.get(key); + if (emittedFont?.shouldPreload && emittedFont.fileName) { + tags.push({ tag: 'link', attrs: { rel: 'preload', href: getFontUrl(htmlFileName, emittedFont.fileName), as: 'font', type: emittedFont.mimeType, crossorigin: true }, injectTo: 'head' }); + } + }); + } + let newHtml = replacePlaceholders(html, htmlFileName); + // NOTE(krishan711): connecting to google early is pointless once nothing loads from it + if (!hasStylesheetUrl(newHtml) && !hasStylesheetUrl(linkedCss)) { + newHtml = newHtml.replace(LINK_TAG_REGEX, (tag: string): string => { + return /\srel\s*=\s*["']?(?:preconnect|dns-prefetch)\b/i.test(tag) && /\shref\s*=\s*["']?https:\/\/fonts\.(?:googleapis|gstatic)\.com\/?["'\s>]/i.test(tag) ? '' : tag; + }); + } + return { html: newHtml, tags }; + }, + }, + }; +}; diff --git a/src/vitePlugin/iconsPlugin.ts b/src/vitePlugin/iconsPlugin.ts new file mode 100644 index 00000000..2a858fcd --- /dev/null +++ b/src/vitePlugin/iconsPlugin.ts @@ -0,0 +1,344 @@ +// NOTE(krishan711): this runs in node (inside vite), so it must only import node builtins and never the browser code +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { HmrContext, Logger, Plugin, ResolvedConfig, ViteDevServer } from 'vite'; + +export interface IKibaIconsPluginOptions { + // NOTE(krishan711): directories (relative to the vite root) scanned for icon ids, defaults to src (or the root if there is no src) + sourceDirectories?: string[]; + // NOTE(krishan711): the module that exports registerKibaIcons, only needs changing when building ui-react itself + registrationModule?: string; +} + +interface IIconSet { + prefix: string; + packageName: string; + iconsDirectory: string; + getIconFilePaths: (iconsDirectoryPath: string, iconName: string) => string[]; +} + +const VIRTUAL_MODULE_ID = 'virtual:kiba-icons'; +const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`; +const ICON_ID_REGEX = /(?<=['"`])(ion|mui|bs|feather|remix)-([a-z0-9]+(?:-[a-z0-9]+)*)(?=['"`])/g; +const SOURCE_FILE_EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts', '.mdx']); +const IGNORED_DIRECTORY_NAMES = new Set(['node_modules', 'dist', 'build', 'tmp']); +const MATERIAL_ICON_STYLES = ['outlined', 'round', 'sharp', 'two-tone']; + +const remixIconFilePaths = new Map<string, Map<string, string>>(); + +const getRemixIconFilePaths = (iconsDirectoryPath: string): Map<string, string> => { + let iconFilePaths = remixIconFilePaths.get(iconsDirectoryPath); + if (!iconFilePaths) { + iconFilePaths = new Map<string, string>(); + // NOTE(krishan711): remixicon puts icons in category directories, e.g. icons/Buildings/home-line.svg + fs.readdirSync(iconsDirectoryPath, { withFileTypes: true }).filter((entry) => entry.isDirectory()).forEach((categoryEntry) => { + fs.readdirSync(path.join(iconsDirectoryPath, categoryEntry.name)).filter((fileName) => fileName.endsWith('.svg')).forEach((fileName) => { + iconFilePaths?.set(fileName.slice(0, -'.svg'.length), path.join(iconsDirectoryPath, categoryEntry.name, fileName)); + }); + }); + remixIconFilePaths.set(iconsDirectoryPath, iconFilePaths); + } + return iconFilePaths; +}; + +const ICON_SETS: IIconSet[] = [{ + prefix: 'ion', + packageName: 'ionicons', + // NOTE(krishan711): the svg files are used (not the ionicons/icons data uris) because those style outlines with classes from ion-icon's css + iconsDirectory: 'dist/svg', + getIconFilePaths: (iconsDirectoryPath, iconName) => [path.join(iconsDirectoryPath, `${iconName}.svg`)], +}, { + prefix: 'mui', + packageName: '@material-design-icons/svg', + iconsDirectory: '.', + // NOTE(krishan711): mui-account-box is filled/account_box.svg and mui-account-box-outlined is outlined/account_box.svg + getIconFilePaths: (iconsDirectoryPath, iconName) => { + const filePaths = [path.join(iconsDirectoryPath, 'filled', `${iconName.replace(/-/g, '_')}.svg`)]; + MATERIAL_ICON_STYLES.filter((style) => iconName.endsWith(`-${style}`)).forEach((style) => { + filePaths.push(path.join(iconsDirectoryPath, style, `${iconName.slice(0, -(style.length + 1)).replace(/-/g, '_')}.svg`)); + }); + return filePaths; + }, +}, { + prefix: 'bs', + packageName: 'bootstrap-icons', + iconsDirectory: 'icons', + getIconFilePaths: (iconsDirectoryPath, iconName) => [path.join(iconsDirectoryPath, `${iconName}.svg`)], +}, { + prefix: 'feather', + packageName: 'feather-icons', + iconsDirectory: 'dist/icons', + getIconFilePaths: (iconsDirectoryPath, iconName) => [path.join(iconsDirectoryPath, `${iconName}.svg`)], +}, { + prefix: 'remix', + packageName: 'remixicon', + iconsDirectory: 'icons', + getIconFilePaths: (iconsDirectoryPath, iconName) => { + const filePath = getRemixIconFilePaths(iconsDirectoryPath).get(iconName); + return filePath ? [filePath] : []; + }, +}]; + +const ICON_SETS_BY_PREFIX = new Map(ICON_SETS.map((iconSet) => [iconSet.prefix, iconSet])); + +// NOTE(krishan711): the same normalisation KibaIcon does to fetched icons, plus removing whitespace between tags +const normalizeSvgContent = (svgContent: string): string => { + return svgContent + .replace(/<\?xml[^>]*\?>/gi, '') + .replace(/<!--[\s\S]*?-->/g, '') + .replace(/<title>.*<\/title>/gi, '') + .replace(/stroke:#000/gi, 'stroke:currentColor') + .replace(/fill:#000/gi, 'fill:currentColor') + .replace(/>\s+</g, '><') + .trim(); +}; + +const findIconIds = (content: string): string[] => { + return [...new Set([...content.matchAll(ICON_ID_REGEX)].map((match) => match[0]))]; +}; + +const findPackageDirectory = (rootDirectory: string, packageName: string): string | null => { + let directory = path.resolve(rootDirectory); + + while (true) { + const packageDirectory = path.join(directory, 'node_modules', ...packageName.split('/')); + if (fs.existsSync(path.join(packageDirectory, 'package.json'))) { + return packageDirectory; + } + const parentDirectory = path.dirname(directory); + if (parentDirectory === directory) { + return null; + } + directory = parentDirectory; + } +}; + +const listSourceFiles = (directory: string, fileExtensions: Set<string>, ignoredDirectories: Set<string>): string[] => { + if (!fs.existsSync(directory) || ignoredDirectories.has(directory)) { + return []; + } + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry): string[] => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (entry.name.startsWith('.') || IGNORED_DIRECTORY_NAMES.has(entry.name)) { + return []; + } + return listSourceFiles(entryPath, fileExtensions, ignoredDirectories); + } + return entry.isFile() && fileExtensions.has(path.extname(entry.name)) ? [entryPath] : []; + }); +}; + +// NOTE(krishan711): ui-react's own components use KibaIcon too, so its built files are scanned along with the app's source +const getUiReactDirectories = (): { libraryDirectory: string | null; pluginDirectory: string | null } => { + try { + const pluginDirectory = path.dirname(fileURLToPath(import.meta.url)); + let directory = pluginDirectory; + while (path.dirname(directory) !== directory) { + const packageFilePath = path.join(directory, 'package.json'); + if (fs.existsSync(packageFilePath)) { + const packageData = JSON.parse(fs.readFileSync(packageFilePath, 'utf-8')); + return { libraryDirectory: packageData.name === '@kibalabs/ui-react' ? path.join(directory, 'dist') : null, pluginDirectory }; + } + directory = path.dirname(directory); + } + return { libraryDirectory: null, pluginDirectory }; + } catch { + return { libraryDirectory: null, pluginDirectory: null }; + } +}; + +export const kibaIconsPlugin = (options: IKibaIconsPluginOptions = {}): Plugin => { + const registrationModule = options.registrationModule ?? '@kibalabs/ui-react'; + let rootDirectory = process.cwd(); + let logger: Logger | null = null; + let sourceDirectories: string[] = []; + let libraryDirectory: string | null = null; + let pluginDirectory: string | null = null; + const fileIconIds = new Map<string, string[]>(); + const iconSvgContents = new Map<string, string | null>(); + const packageDirectories = new Map<string, string | null>(); + const warnedIconIds = new Set<string>(); + const entryModuleIds = new Set<string>(); + + const isSourceFile = (filePath: string): boolean => { + return SOURCE_FILE_EXTENSIONS.has(path.extname(filePath)) && sourceDirectories.some((directory) => filePath.startsWith(`${directory}${path.sep}`)) && !filePath.split(path.sep).includes('node_modules'); + }; + + const scanFile = (filePath: string, content?: string): boolean => { + let fileContent = content; + if (fileContent === undefined) { + try { + fileContent = fs.readFileSync(filePath, 'utf-8'); + } catch { + fileContent = ''; + } + } + const previousIconIds = fileIconIds.get(filePath) ?? []; + const iconIds = findIconIds(fileContent); + fileIconIds.set(filePath, iconIds); + return iconIds.length !== previousIconIds.length || iconIds.some((iconId) => !previousIconIds.includes(iconId)); + }; + + const scanAllFiles = (): void => { + fileIconIds.clear(); + const ignoredDirectories = new Set(pluginDirectory ? [pluginDirectory] : []); + sourceDirectories.forEach((directory) => { + listSourceFiles(directory, SOURCE_FILE_EXTENSIONS, ignoredDirectories).forEach((filePath) => scanFile(filePath)); + }); + if (libraryDirectory) { + listSourceFiles(libraryDirectory, new Set(['.js']), ignoredDirectories).forEach((filePath) => scanFile(filePath)); + } + }; + + const getPackageDirectory = (packageName: string): string | null => { + if (!packageDirectories.has(packageName)) { + packageDirectories.set(packageName, findPackageDirectory(rootDirectory, packageName)); + } + return packageDirectories.get(packageName) ?? null; + }; + + const loadIconSvgContent = (iconId: string): string | null => { + if (!iconSvgContents.has(iconId)) { + const separatorIndex = iconId.indexOf('-'); + const iconSet = ICON_SETS_BY_PREFIX.get(iconId.slice(0, separatorIndex)); + const packageDirectory = iconSet ? getPackageDirectory(iconSet.packageName) : null; + let svgContent: string | null = null; + if (iconSet && packageDirectory) { + const iconsDirectoryPath = path.join(packageDirectory, iconSet.iconsDirectory); + const iconFilePath = fs.existsSync(iconsDirectoryPath) ? iconSet.getIconFilePaths(iconsDirectoryPath, iconId.slice(separatorIndex + 1)).find((filePath) => fs.existsSync(filePath)) : undefined; + svgContent = iconFilePath ? normalizeSvgContent(fs.readFileSync(iconFilePath, 'utf-8')) : null; + } + iconSvgContents.set(iconId, svgContent); + } + return iconSvgContents.get(iconId) ?? null; + }; + + const getIcons = (): Record<string, string> => { + const iconIds = [...new Set([...fileIconIds.values()].flat())].sort(); + // NOTE(krishan711): ui-react's own default icons (e.g. the carousel's chevrons) can be swapped with props, so a missing package is only warned about for ids the app uses itself + const appIconIds = new Set([...fileIconIds.entries()].filter(([filePath]) => !libraryDirectory || !filePath.startsWith(`${libraryDirectory}${path.sep}`)).flatMap(([, fileIds]) => fileIds)); + const missingIconIds = new Map<IIconSet, string[]>(); + const icons: Record<string, string> = {}; + iconIds.forEach((iconId) => { + const iconSet = ICON_SETS_BY_PREFIX.get(iconId.slice(0, iconId.indexOf('-'))); + if (!iconSet) { + return; + } + const packageDirectory = getPackageDirectory(iconSet.packageName); + if (!packageDirectory || !fs.existsSync(path.join(packageDirectory, iconSet.iconsDirectory))) { + if (appIconIds.has(iconId) && !warnedIconIds.has(iconId)) { + missingIconIds.set(iconSet, [...(missingIconIds.get(iconSet) ?? []), iconId]); + warnedIconIds.add(iconId); + } + return; + } + const svgContent = loadIconSvgContent(iconId); + // NOTE(krishan711): strings that look like icon ids but aren't icons are skipped + if (svgContent) { + icons[iconId] = svgContent; + } + }); + if (missingIconIds.size > 0) { + const missingDescriptions = [...missingIconIds.entries()].map(([iconSet, setIconIds]) => { + const reason = getPackageDirectory(iconSet.packageName) ? `the installed version has no ${iconSet.iconsDirectory} directory` : 'not installed'; + return `${iconSet.packageName} (${reason}): ${setIconIds.join(', ')}`; + }); + logger?.warn(`[kiba-icons] KibaIcon will fetch these icons at runtime because their icon package is missing:\n ${missingDescriptions.join('\n ')}`); + } + return icons; + }; + + // NOTE(krishan711): returns whether the file uses icons that weren't registered yet + const rescanFile = (filePath: string, content?: string): boolean => { + const previousIconIds = new Set(Object.keys(getIcons())); + if (!scanFile(filePath, content)) { + return false; + } + return Object.keys(getIcons()).some((iconId) => !previousIconIds.has(iconId)); + }; + + // NOTE(krishan711): reloads the page so new icons are registered before anything renders them + const reloadWithNewIcons = (server: ViteDevServer): void => { + const virtualModule = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID); + if (virtualModule) { + server.moduleGraph.invalidateModule(virtualModule); + } + server.ws.send({ type: 'full-reload' }); + }; + + return { + name: 'kiba-icons', + enforce: 'pre', + configResolved(config: ResolvedConfig): void { + rootDirectory = config.root; + logger = config.logger; + const directories = options.sourceDirectories ?? (fs.existsSync(path.join(config.root, 'src')) ? ['src'] : ['.']); + sourceDirectories = directories.map((directory) => path.resolve(config.root, directory)); + ({ libraryDirectory, pluginDirectory } = getUiReactDirectories()); + }, + buildStart(): void { + iconSvgContents.clear(); + packageDirectories.clear(); + warnedIconIds.clear(); + scanAllFiles(); + // NOTE(krishan711): called here so missing packages are warned about once at the start + getIcons(); + }, + async resolveId(source, importer, resolveOptions) { + if (source === VIRTUAL_MODULE_ID) { + return RESOLVED_VIRTUAL_MODULE_ID; + } + // NOTE(krishan711): non-html entries (e.g. the server entry of an ssr build) import the icons directly + if (resolveOptions.isEntry && !source.endsWith('.html')) { + const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true }); + if (resolved && !resolved.external && !resolved.id.endsWith('.html')) { + entryModuleIds.add(resolved.id); + } + return resolved; + } + return null; + }, + load(id: string): string | null { + if (id !== RESOLVED_VIRTUAL_MODULE_ID) { + return null; + } + return `import { registerKibaIcons } from ${JSON.stringify(registrationModule)};\nregisterKibaIcons(${JSON.stringify(getIcons())});\n`; + }, + transform(code: string, id: string): { code: string; map: null } | null { + if (!entryModuleIds.has(id)) { + return null; + } + return { code: `import ${JSON.stringify(VIRTUAL_MODULE_ID)};\n${code}`, map: null }; + }, + // NOTE(krishan711): pre so the build bundles the script (and it runs before the app's entry script) + transformIndexHtml: { + order: 'pre', + handler: () => [{ + tag: 'script', + attrs: { type: 'module' }, + children: `import ${JSON.stringify(VIRTUAL_MODULE_ID)};`, + injectTo: 'head-prepend', + }], + }, + configureServer(server: ViteDevServer): void { + server.watcher.on('add', (filePath: string): void => { + if (isSourceFile(filePath) && rescanFile(filePath)) { + reloadWithNewIcons(server); + } + }); + server.watcher.on('unlink', (filePath: string): void => { + fileIconIds.delete(filePath); + }); + }, + async handleHotUpdate(context: HmrContext) { + if (!isSourceFile(context.file) || !rescanFile(context.file, await context.read())) { + return undefined; + } + reloadWithNewIcons(context.server); + return []; + }, + }; +}; diff --git a/src/vitePlugin/index.ts b/src/vitePlugin/index.ts new file mode 100644 index 00000000..d214d26f --- /dev/null +++ b/src/vitePlugin/index.ts @@ -0,0 +1,3 @@ +// NOTE(krishan711): this runs in node (inside vite), so it must only import node builtins and never the browser code +export * from './iconsPlugin'; +export * from './fontsPlugin';