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();
+const inFlightSvgContents = new Map>();
+const FETCH_RETRY_DELAY_MILLIS = 1000;
+
+const fetchSvgContent = async (url: string, retryCount: number): Promise => {
+ 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 => {
+ 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(null);
- const [shouldAddFill, setShouldAddFill] = React.useState(undefined);
- const [shouldAddStroke, setShouldAddStroke] = React.useState(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(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>/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 (
'}
/>
);
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.
@@ -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 = {
-
+
),
};
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 `` 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;
+ preloadFontUrls: Set;
+}
+
+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 = /]*>/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 => {
+ const familyFaces = new Map();
+ 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>();
+ const emittedFonts = new Map();
+
+ const loadFont = async (fontUrl: string): Promise => {
+ 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 => {
+ 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 => {
+ 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 => ``);
+ }
+ });
+ 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();
+ 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>();
+
+const getRemixIconFilePaths = (iconsDirectoryPath: string): Map => {
+ let iconFilePaths = remixIconFilePaths.get(iconsDirectoryPath);
+ if (!iconFilePaths) {
+ iconFilePaths = new Map();
+ // 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(//g, '')
+ .replace(/.*<\/title>/gi, '')
+ .replace(/stroke:#000/gi, 'stroke:currentColor')
+ .replace(/fill:#000/gi, 'fill:currentColor')
+ .replace(/>\s+<')
+ .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, ignoredDirectories: Set): 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();
+ const iconSvgContents = new Map();
+ const packageDirectories = new Map();
+ const warnedIconIds = new Set();
+ const entryModuleIds = new Set();
+
+ 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 => {
+ 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();
+ const icons: Record = {};
+ 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';