2
0
mirror of https://github.com/iconify/iconify.git synced 2025-01-04 14:35:20 +00:00

feat: dynamic selectors plugin for Tailwind CSS 4

This commit is contained in:
Vjacheslav Trushkin 2024-12-25 16:51:21 +02:00
parent bfdd3f021d
commit fdb8263c46
40 changed files with 930 additions and 1302 deletions

View File

@ -1,3 +0,0 @@
.DS_Store
node_modules
dist

View File

@ -1,19 +0,0 @@
{
"name": "@iconify-demo/tailwind",
"version": "1.0.0",
"private": true,
"description": "",
"main": "index.js",
"scripts": {
"build": "tailwindcss -i ./src/input.css -o ./dist/output.css"
},
"keywords": [],
"devDependencies": {
"@iconify-json/fa6-regular": "^1.2.2",
"@iconify-json/mdi-light": "^1.2.1",
"@iconify-json/vscode-icons": "^1.2.3",
"@iconify/tailwind": "workspace:*",
"@iconify/tools": "3.0.0-beta.3",
"tailwindcss": "^3.4.16"
}
}

View File

@ -1,122 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="../dist/output.css" rel="stylesheet" />
</head>
<body class="m-0 p-2">
<section class="mb-2">
<h1 class="text-2xl">Main plugin</h1>
<p>
Monotone icons (changes color, blue):
<span class="text-2xl demo">
<span class="iconify mdi-light--home"></span>
<span
class="iconify mdi-light--arrow-left text-blue-600"
></span>
<span
class="iconify vscode-icons--file-type-light-ini text-blue-600"
></span>
</span>
</p>
<p>
Color icons:
<span class="text-2xl demo">
<span class="iconify-color mdi-light--home"></span>
<span
class="iconify-color vscode-icons--file-type-firebase"
></span>
<span
class="iconify-color vscode-icons--file-type-js-official"
></span>
</span>
</p>
<p>
Using width/height to resize icon:
<span
class="iconify-nosize mdi-light--home h-12 w-12 text-blue-600"
></span>
<span
class="iconify mdi-light--home h-12 w-12 text-red-600"
></span>
</p>
<p>
Scaled non-square icons:
<span
class="fa6-mask fa6-regular-bookmark text-blue-600"
></span>
</p>
</section>
<section class="mb-2">
<h1 class="text-2xl">Dynamic selectors plugin, custom options</h1>
<p>Icons should scale to 1.5em, which is about 24px</p>
<p>
Monotone icons (red, blue):
<span
class="custom-monotone i-mdi-light-home text-red-600"
></span>
<span
class="custom-monotone i-custom-spinner1 text-blue-600"
></span>
</p>
<p>
Failed icon (should not be pre-rendered):
<span
class="custom-monotone i-mdi-light-arrow-left text-red-600"
></span>
</p>
<p>
Colored icons:
<span class="custom-background i-mdi-light-home"></span>
<span class="custom-background i-custom-spinner2"></span>
</p>
</section>
<section class="mb-2">
<h1 class="text-2xl">Dynamic selectors plugin</h1>
<p>
Few icons that change color on hover (first icon also changes
icon on hover):
<span class="text-2xl demo">
<span
class="icon-[mdi-light--arrow-left] hover:icon-hover-[mdi-light--arrow-right]"
></span>
<span class="icon-[mdi-light--forum]"></span>
</span>
</p>
<p>
Custom icons, imported from "svg" directory (first icon's
animation slowed down using "customise" option):
<span class="text-2xl demo">
<span class="icon-[custom--spinner1]"></span>
<span class="icon-[custom--spinner2]"></span>
</span>
</p>
<p>
Icons with hardcoded palette:
<span class="text-3xl demo">
<span
class="icon-[vscode-icons--file-type-access] hover:icon-hover-[vscode-icons--file-type-access2]"
></span>
<span class="icon-[vscode-icons--file-type-vue]"></span>
</span>
</p>
</section>
<section class="mb-2">
<h1 class="text-2xl">Clean selectors plugin (deprecated)</h1>
<p>
Monotone icon: 1em size (changing color), text-2xl size (red):
<span class="icon--mdi-light icon--mdi-light--home demo"></span>
<span
class="icon--mdi-light icon--mdi-light--home text-2xl text-red-600"
></span>
</p>
<p>
Custom size set via width/height:
<span
class="h-12 w-12 scaled-icon-[mdi-light--forum] text-red-600"
></span>
</p>
</section>
</body>
</html>

View File

@ -1,11 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.demo {
color: #16a34a;
vertical-align: -6px;
}
.demo:hover {
color: #b91c1c;
}

View File

@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<style>
.spin-path {
animation: 0.75s linear infinite rotate;
transform-origin: center;
}
@keyframes rotate {
from {
transform: rotate(0deg)
}
to {
transform: rotate(360deg)
}
}
</style>
<path d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" opacity=".25"/>
<path class="spin-path" d="M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z" />
</svg>

Before

Width:  |  Height:  |  Size: 725 B

View File

@ -1,14 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<circle cx="12" cy="3.5" r="1.5" fill="currentColor" opacity="0">
<animateTransform attributeName="transform" calcMode="discrete" dur="2.4s" repeatCount="indefinite" type="rotate" values="0 12 12;90 12 12;180 12 12;270 12 12"/>
<animate attributeName="opacity" dur="0.6s" keyTimes="0;0.5;1" repeatCount="indefinite" values="1;1;0"/>
</circle>
<circle cx="12" cy="3.5" r="1.5" fill="currentColor" opacity="0">
<animateTransform attributeName="transform" begin="0.2s" calcMode="discrete" dur="2.4s" repeatCount="indefinite" type="rotate" values="30 12 12;120 12 12;210 12 12;300 12 12"/>
<animate attributeName="opacity" begin="0.2s" dur="0.6s" keyTimes="0;0.5;1" repeatCount="indefinite" values="1;1;0"/>
</circle>
<circle cx="12" cy="3.5" r="1.5" fill="currentColor" opacity="0">
<animateTransform attributeName="transform" begin="0.4s" calcMode="discrete" dur="2.4s" repeatCount="indefinite" type="rotate" values="60 12 12;150 12 12;240 12 12;330 12 12"/>
<animate attributeName="opacity" begin="0.4s" dur="0.6s" keyTimes="0;0.5;1" repeatCount="indefinite" values="1;1;0"/>
</circle>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,132 +0,0 @@
const {
addCleanIconSelectors,
addDynamicIconSelectors,
addIconSelectors,
} = require('@iconify/tailwind');
const {
importDirectorySync,
cleanupSVG,
parseColorsSync,
runSVGO,
isEmptyColor,
} = require('@iconify/tools');
// Import icons from directory 'svg'
const customSet = importDirectorySync('svg');
// Clean up all icons
customSet.forEachSync((name, type) => {
if (type !== 'icon') {
return;
}
// Get SVG object for icon
const svg = customSet.toSVG(name);
if (!svg) {
// Invalid icon
customSet.remove(name);
return;
}
try {
// Clean up icon
cleanupSVG(svg);
// This is a monotone icon, change color to `currentColor`, add it if missing
// Skip this step if icons have palette
parseColorsSync(svg, {
defaultColor: 'currentColor',
callback: (attr, colorStr, color) => {
return !color || isEmptyColor(color)
? colorStr
: 'currentColor';
},
});
// Optimise icon
runSVGO(svg);
} catch (err) {
// Something went wrong when parsing icon: remove it
console.error(`Error parsing ${name}:`, err);
customSet.remove(name);
return;
}
// Update icon in icon set from SVG object
customSet.fromSVG(name, svg);
});
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/*.html'],
plugins: [
// Main plugin, default options
addIconSelectors(['mdi-light', 'vscode-icons']),
// Main plugin, custom options
addIconSelectors({
maskSelector: '.custom-monotone',
backgroundSelector: '.custom-background',
// Like UnoCSS
iconSelector: '.i-{prefix}-{name}',
scale: 1.5,
prefixes: [
{
prefix: 'mdi-light',
icons: ['home'],
customise: (content) =>
content.replace(/currentColor/g, '#40f'),
},
{
prefix: 'custom',
source: customSet.export(),
customise: (content) =>
content.replace(/currentColor/g, '#f20'),
},
],
}),
// Main plugin, no size
addIconSelectors({
maskSelector: '.iconify-nosize',
backgroundSelector: '',
scale: 0,
// No prefixes list: reusing data from plugin above
}),
// Main plugin, no square and scale
addIconSelectors({
maskSelector: '.fa6-mask',
backgroundSelector: '.fa6-bg', // unused
iconSelector: '.{prefix}-{name}',
square: false,
scale: 2,
prefixes: ['fa6-regular'],
}),
// Plugin with clean selectors: requires writing all used icons in first parameter
addCleanIconSelectors(['mdi-light:home']),
// Plugin with dynamic selectors
addDynamicIconSelectors({
iconSets: {
custom: customSet.export(),
},
customise: (content, name, prefix) => {
switch (name) {
case 'spinner1':
return content.replace(
'animation:0.75s',
'animation:5s'
);
}
return content;
},
}),
// Plugin with dynamic selectors that contains only css for overriding icon
addDynamicIconSelectors({
prefix: 'icon-hover',
overrideOnly: true,
}),
// Icons without size
addDynamicIconSelectors({
prefix: 'scaled-icon',
scale: 0,
}),
],
};

30
plugins-demo/tailwind4-demo/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

View File

@ -0,0 +1,33 @@
# tailwind4-demo
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```

1
plugins-demo/tailwind4-demo/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tailwind CSS Test</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,33 @@
{
"name": "tailwind4-demo",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@iconify/tailwind4": "workspace:*",
"@iconify-json/fa6-regular": "^1.2.2",
"@iconify-json/mdi-light": "^1.2.1",
"@iconify-json/vscode-icons": "^1.2.6",
"@tailwindcss/vite": "4.0.0-beta.8",
"@tsconfig/node22": "^22.0.0",
"@types/node": "^22.9.3",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"npm-run-all2": "^7.0.1",
"tailwindcss": "4.0.0-beta.8",
"typescript": "~5.6.3",
"vite": "^6.0.1",
"vite-plugin-vue-devtools": "^7.6.5",
"vue-tsc": "^2.1.10"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,27 @@
<template>
<div class="container mx-auto">
<h1 class="text-4xl font-bold text-left mt-10">
<span class="icon-[vscode-icons--file-type-tailwind]"></span>
Tailwind CSS test file
</h1>
<section class="mt-2 mb-2">
<h1 class="text-2xl">Dynamic selectors plugin</h1>
<p>
Monotone icons, arrow changes on hover:
<span class="text-2xl demo">
<span
class="icon-[mdi-light--arrow-left] hover:icon-[mdi-light--arrow-right]"
></span>
<span class="icon-[mdi-light--forum]"></span>
</span>
</p>
<p>
Icons with hardcoded palette:
<span class="text-3xl demo">
<span class="icon-[vscode-icons--file-type-access]"></span>
<span class="icon-[vscode-icons--file-type-vue]"></span>
</span>
</p>
</section>
</div>
</template>

View File

@ -0,0 +1,10 @@
@import 'tailwindcss';
@plugin "@iconify/tailwind4";
.demo {
color: var(--color-blue-600);
}
.demo:hover {
color: var(--color-red-600);
}

View File

@ -0,0 +1,6 @@
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

View File

@ -0,0 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

View File

@ -0,0 +1,15 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueDevTools from 'vite-plugin-vue-devtools';
import tailwindcss from '@tailwindcss/vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [tailwindcss(), vue(), vueDevTools()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
});

View File

@ -1,98 +0,0 @@
/* eslint-disable */
const fs = require('fs');
const child_process = require('child_process');
// List of commands to run
const commands = [];
// Parse command line
const compile = {
lib: true,
dist: true,
api: true,
};
process.argv.slice(2).forEach((cmd) => {
if (cmd.slice(0, 2) !== '--') {
return;
}
const parts = cmd.slice(2).split('-');
if (parts.length === 2) {
// Parse 2 part commands like --with-lib
const key = parts.pop();
if (compile[key] === void 0) {
return;
}
switch (parts.shift()) {
case 'with':
// enable module
compile[key] = true;
break;
case 'without':
// disable module
compile[key] = false;
break;
case 'only':
// disable other modules
Object.keys(compile).forEach((key2) => {
compile[key2] = key2 === key;
});
break;
}
}
});
// Check if required modules in same monorepo are available
const fileExists = (file) => {
try {
fs.statSync(file);
} catch (e) {
return false;
}
return true;
};
if (compile.dist && !fileExists('./lib/index.js')) {
compile.lib = true;
}
if (compile.api && !fileExists('./lib/index.d.ts')) {
compile.lib = true;
}
// Compile packages
Object.keys(compile).forEach((key) => {
if (compile[key]) {
commands.push({
cmd: 'npm',
args: ['run', 'build:' + key],
});
}
});
/**
* Run next command
*/
const next = () => {
const item = commands.shift();
if (item === void 0) {
process.exit(0);
}
if (item.cwd === void 0) {
item.cwd = __dirname;
}
const result = child_process.spawnSync(item.cmd, item.args, {
cwd: item.cwd,
stdio: 'inherit',
});
if (result.status === 0) {
process.nextTick(next);
} else {
process.exit(result.status);
}
};
next();

View File

@ -1,7 +0,0 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
verbose: true,
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/tests/*-test.ts'],
};

View File

@ -1,8 +1,9 @@
{
"name": "@iconify/tailwind",
"name": "@iconify/tailwind4",
"description": "Iconify plugin for Tailwind CSS",
"type": "module",
"author": "Vjacheslav Trushkin <cyberalien@gmail.com> (https://iconify.design)",
"version": "1.2.0",
"version": "1.0.0",
"license": "MIT",
"main": "./dist/plugin.js",
"types": "./dist/plugin.d.ts",
@ -18,11 +19,11 @@
"clean": "rimraf lib dist tsconfig.tsbuildinfo",
"lint": "eslint src/**/*.ts",
"prebuild": "pnpm run lint && pnpm run clean",
"build": "node build",
"build": "npm run build:lib && npm run build:dist && npm run build:api",
"build:api": "api-extractor run --local --verbose",
"build:lib": "tsc -b",
"build:dist": "rollup -c rollup.config.mjs",
"test": "jest --runInBand"
"build:dist": "rollup -c rollup.config.js",
"test": "vitest --config vitest.config.js"
},
"dependencies": {
"@iconify/types": "workspace:^"
@ -42,11 +43,10 @@
"@typescript-eslint/eslint-plugin": "^8.17.0",
"eslint": "^9.16.0",
"globals": "^15.13.0",
"jest": "^29.7.0",
"rimraf": "^6.0.1",
"rollup": "^4.28.1",
"tailwindcss": "^3.4.16",
"ts-jest": "^29.2.5",
"typescript": "^5.7.2"
"tailwindcss": "4.0.0-beta.8",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}

View File

@ -1,50 +0,0 @@
import { getIconsCSSData } from '@iconify/utils/lib/css/icons';
import { loadIconSet } from './helpers/loader';
import { getIconNames } from './helpers/names';
import type { CleanIconifyPluginOptions } from './helpers/options';
/**
* Get CSS rules for icons list
*/
export function getCSSRulesForIcons(
icons: string[] | string,
options: CleanIconifyPluginOptions = {}
): Record<string, Record<string, string>> {
const rules = Object.create(null) as Record<string, Record<string, string>>;
// Get all icons
const prefixes = getIconNames(icons);
// Parse all icon sets
for (const prefix in prefixes) {
const iconSet = loadIconSet(options.iconSets?.[prefix] || prefix);
if (!iconSet) {
throw new Error(
`Cannot load icon set for "${prefix}". Install "@iconify-json/${prefix}" as dev dependency?`
);
}
const generated = getIconsCSSData(
iconSet,
Array.from(prefixes[prefix]),
{
...options,
customise: (content, name) =>
options.customise?.(content, name, prefix) ?? content,
}
);
const result = generated.common
? [generated.common, ...generated.css]
: generated.css;
result.forEach((item) => {
const selector =
item.selector instanceof Array
? item.selector.join(', ')
: item.selector;
rules[selector] = item.rules;
});
}
return rules;
}

View File

@ -1,7 +1,7 @@
import { getIconsCSSData } from '@iconify/utils/lib/css/icons';
import { matchIconName } from '@iconify/utils/lib/icon/name';
import { loadIconSet } from './helpers/loader';
import type { DynamicIconifyPluginOptions } from './helpers/options';
import { loadIconSet } from './helpers/loader.js';
import type { DynamicIconifyPluginOptions } from './helpers/options.js';
/**
* Get dynamic CSS rules
@ -10,7 +10,7 @@ export function getDynamicCSSRules(
icon: string,
options: DynamicIconifyPluginOptions = {}
): Record<string, string> {
const nameParts = icon.split(/--|\:/);
const nameParts = icon.split(/--|:/);
if (nameParts.length !== 2) {
throw new Error(`Invalid icon name: "${icon}"`);
}

View File

@ -1,6 +1,6 @@
import { readFileSync } from 'node:fs';
import type { IconifyJSON } from '@iconify/types';
import { IconifyIconSetSource } from './options';
import type { IconifyIconSetSource } from './options.js';
import { matchIconName } from '@iconify/utils/lib/icon/name';
/**

View File

@ -1,73 +0,0 @@
import { matchIconName } from '@iconify/utils/lib/icon/name';
/**
* Get icon names from list
*/
export function getIconNames(
icons: string[] | string
): Record<string, Set<string>> | undefined {
const prefixes = Object.create(null) as Record<string, Set<string>>;
// Add entry
const add = (prefix: string, name: string) => {
if (
typeof prefix === 'string' &&
prefix.match(matchIconName) &&
typeof name === 'string' &&
name.match(matchIconName)
) {
(prefixes[prefix] || (prefixes[prefix] = new Set())).add(name);
}
};
// Comma or space separated string
let iconNames: string[] | undefined;
if (typeof icons === 'string') {
iconNames = icons.split(/[\s,.]/);
} else if (icons instanceof Array) {
iconNames = [];
// Split each array entry
icons.forEach((item) => {
item.split(/[\s,.]/).forEach((name) => iconNames.push(name));
});
} else {
return;
}
// Parse array
if (iconNames?.length) {
iconNames.forEach((icon) => {
if (!icon.trim()) {
return;
}
// Attempt prefix:name split
const nameParts = icon.split(':');
if (nameParts.length === 2) {
add(nameParts[0], nameParts[1]);
return;
}
// Attempt icon class: .icon--{prefix}--{name}
// with or without dot
const classParts = icon.split('--');
if (classParts[0].match(/^\.?icon$/)) {
if (classParts.length === 3) {
add(classParts[1], classParts[2]);
return;
}
if (classParts.length === 2) {
// Partial match
return;
}
}
// Throw error
throw new Error(`Cannot resolve icon: "${icon}"`);
});
} else {
return;
}
return prefixes;
}

View File

@ -1,4 +1,3 @@
import type { IconCSSIconSetOptions } from '@iconify/utils/lib/css/types';
import type { IconifyJSON } from '@iconify/types';
// Source for icon set: icon set, filename, or synchronous callback that loads icon set
@ -16,15 +15,6 @@ export interface CommonIconifyPluginOptions {
customise?: (content: string, name: string, prefix: string) => string;
}
/**
* Options for clean class names
*/
export interface CleanIconifyPluginOptions
extends CommonIconifyPluginOptions,
Omit<IconCSSIconSetOptions, 'customise'> {
//
}
/**
* Options for dynamic class names
*/
@ -39,72 +29,3 @@ export interface DynamicIconifyPluginOptions
// Sets the default height/width value (ex. scale: 2 = 2em)
scale?: number;
}
/**
* Options for main plugin
*/
// Icons to include: array of names or callback
export type IconsListOption = string[] | ((name: string) => boolean);
// Source filename or icon set
type IconSetSource = string | IconifyJSON;
// Full icon set options
interface IconSetOptions {
// Prefix, required if `source` is not set
// If both `source` and `prefix` are set, `prefix` will be used
prefix?: string;
// Source
source?: IconSetSource;
// Icons to load
icons?: IconsListOption;
// Customise callback. If set, it will be used instead of global customise callback
customise?: (content: string, name: string) => string;
}
// Array of icon sets to load
type IconifyPluginListOptions = (string | IconSetOptions)[];
// Full object
export interface IconifyPluginOptionsObject {
// Selector for mask, defaults to ".iconify"
// If empty string, mask selector will not be generated
maskSelector?: string;
// Extra rules to add to mask selector
extraMaskRules?: Record<string, string>;
// Selector for background, defaults to ".iconify-color"
// If empty string, background selector will not be generated
backgroundSelector?: string;
// Extra rules to add to background selector
extraBackgroundRules?: Record<string, string>;
// Selector for icons, default is `.{prefix}--{name}`
iconSelector?: string;
// Variable name that contains icon, defaults to "svg"
varName?: string;
// Scale for icons, defaults to 1
scale?: number;
// Make icons square, defaults to true
square?: boolean;
// Prefixes to load
prefixes?: IconifyPluginListOptions;
// Customise callback
customise?: (content: string, name: string, prefix: string) => string;
}
// Full options
export type IconifyPluginOptions =
| IconifyPluginOptionsObject
| IconifyPluginListOptions;

View File

@ -1,80 +1,29 @@
/* eslint-disable @typescript-eslint/unbound-method */
import plugin from 'tailwindcss/plugin';
import { getCSSRulesForIcons } from './clean';
import { getDynamicCSSRules } from './dynamic';
import type {
CleanIconifyPluginOptions,
DynamicIconifyPluginOptions,
IconifyPluginOptions,
} from './helpers/options';
import {
cleanupIconifyPluginOptions,
getCSSComponentsForPlugin,
getCSSRulesForPlugin,
} from './preparsed';
import { getDynamicCSSRules } from './dynamic.js';
/**
* Generate styles for dynamic selector
*
* Usage in HTML: <span class="icon-[mdi-light--home]" />
*/
export function addDynamicIconSelectors(options?: DynamicIconifyPluginOptions) {
const prefix = options?.prefix || 'icon';
function addDynamicIconSelectors(): ReturnType<typeof plugin> {
const prefix = 'icon';
return plugin(({ matchComponents }) => {
matchComponents({
[prefix]: (icon: string) => {
try {
return getDynamicCSSRules(icon, options);
return getDynamicCSSRules(icon);
} catch (err) {
// Log error, but do not throw it
console.error((err as Error).message);
return {};
}
},
});
});
}
/**
* Generate rules for mask, background and selected icon sets
*
* Icons should combine either mask or background selector and icon selector
*
* This plugin generates only square icons. Icons that are not square will be resized to fit square.
*
* Usage in HTML: <span class="iconify mdi-light--home" />
*/
export function addIconSelectors(options: IconifyPluginOptions) {
const fullOptions = cleanupIconifyPluginOptions(options);
return plugin(({ addComponents, addUtilities }) => {
addComponents(getCSSComponentsForPlugin(fullOptions));
addUtilities(getCSSRulesForPlugin(fullOptions));
});
}
/**
* Generate styles for preset list of icons
*
* Requires knowing full list of icons
*
* Usage in HTML: <span class="icon--mdi-light icon--mdi-light--home" />
*
* @deprecated Use addIconSelectors instead
*/
export function addCleanIconSelectors(
icons: string[] | string,
options?: CleanIconifyPluginOptions
) {
const rules = getCSSRulesForIcons(icons, options);
return plugin(({ addUtilities }) => {
addUtilities(rules);
});
}
/**
* Export types
*/
export type {
CleanIconifyPluginOptions,
DynamicIconifyPluginOptions,
IconifyPluginOptions,
};
// Export generated plugin. No TypeScript support yet, so export as unknown
const exportedPlugin = addDynamicIconSelectors() as unknown;
export default exportedPlugin;

View File

@ -1,193 +0,0 @@
import type { IconifyJSON } from '@iconify/types';
import {
generateItemCSSRules,
getCommonCSSRules,
} from '@iconify/utils/lib/css/common';
import { defaultIconProps } from '@iconify/utils/lib/icon/defaults';
import { parseIconSet } from '@iconify/utils/lib/icon-set/parse';
import { calculateSize } from '@iconify/utils/lib/svg/size';
import type {
IconifyPluginOptions,
IconifyPluginOptionsObject,
IconsListOption,
} from './helpers/options';
import { loadIconSet } from './helpers/loader';
/**
* Convert plugin options to object
*/
export function cleanupIconifyPluginOptions(
options: IconifyPluginOptions
): IconifyPluginOptionsObject {
return Array.isArray(options)
? {
prefixes: options,
}
: options;
}
/**
* Get CSS rules for main plugin (components)
*/
export function getCSSComponentsForPlugin(options: IconifyPluginOptionsObject) {
const rules = Object.create(null) as Record<string, Record<string, string>>;
// Variable name, default to 'svg' (cannot be empty string)
const varName = options.varName || 'svg';
// Scale icons
const scale = options.scale ?? 1;
const adjustScale = (obj: Record<string, string>) => {
if (!scale) {
// Delete width and height
delete obj['width'];
delete obj['height'];
} else if (scale !== 1) {
// Set custom width and height
obj['width'] = scale + 'em';
obj['height'] = scale + 'em';
}
return obj;
};
// Add common rules
const maskSelector = options.maskSelector ?? '.iconify';
const backgroundSelector = options.backgroundSelector ?? '.iconify-color';
if (maskSelector) {
rules[maskSelector] = Object.assign(
adjustScale(
getCommonCSSRules({
mode: 'mask',
varName,
})
),
options.extraMaskRules || {}
);
}
if (backgroundSelector) {
rules[backgroundSelector] = Object.assign(
adjustScale(
getCommonCSSRules({
mode: 'background',
varName,
})
),
options.extraBackgroundRules || {}
);
}
return rules;
}
/**
* Get CSS rules for main plugin (utilities)
*/
export function getCSSRulesForPlugin(options: IconifyPluginOptionsObject) {
const rules = Object.create(null) as Record<string, Record<string, string>>;
// Variable name, default to 'svg' (cannot be empty string)
const varName = options.varName || 'svg';
// Add icon sets
const iconSelector = options.iconSelector || '.{prefix}--{name}';
// Make icons square
const square = options.square !== false;
// Scale
const scale = options.scale ?? 1;
options.prefixes?.forEach((item) => {
let prefix: string;
let iconSet: IconifyJSON | undefined;
let iconsList: IconsListOption | undefined;
let customise: ((content: string, name: string) => string) | undefined;
// Load icon set
if (typeof item === 'string') {
// Prefix
prefix = item;
iconSet = loadIconSet(prefix);
} else if (item.source) {
// Source, possibly with prefix
iconSet = loadIconSet(item.source);
prefix = item.prefix || iconSet?.prefix;
iconsList = item.icons;
customise = item.customise;
if (!prefix) {
throw new Error(
'Custom icon set does not have a prefix. Please set "prefix" property'
);
}
} else {
// Prefix
prefix = item.prefix || iconSet?.prefix;
iconSet = prefix ? loadIconSet(prefix) : undefined;
iconsList = item.icons;
customise = item.customise;
}
// Validate it
if (!iconSet) {
throw new Error(
`Cannot load icon set for "${prefix}". Install "@iconify-json/${prefix}" as dev dependency?`
);
}
if (!prefix) {
throw new Error(
'Bad icon set entry, must have either "prefix" or "source" set'
);
}
// Load icons
parseIconSet(iconSet, (name, data) => {
// Check if icon should be rendered
if (iconsList) {
if (Array.isArray(iconsList)) {
if (!iconsList.includes(name)) {
return;
}
} else if (!iconsList(name)) {
return;
}
}
// Customise icon
const body = customise
? customise(data.body, name)
: options.customise
? options.customise(data.body, name, prefix)
: data.body;
// Generate CSS
const iconRules = generateItemCSSRules(
{
...defaultIconProps,
...data,
body,
},
{
mode: 'mask', // not used because varName is set, but required
varName,
forceSquare: square,
}
);
// Generate selector
const selector = iconSelector
.replace('{prefix}', prefix)
.replace('{name}', name);
// Scale non-square icons
if (!square && scale > 0 && scale !== 1 && iconRules.width) {
iconRules.width = calculateSize(iconRules.width, scale);
}
// Add to rules
rules[selector] = iconRules;
});
});
// Return
return rules;
}

View File

@ -1,68 +0,0 @@
import { getCSSRulesForIcons } from '../src/clean';
describe('Testing clean CSS rules', () => {
it('One icon', () => {
const data = getCSSRulesForIcons('mdi-light:home');
expect(Object.keys(data)).toEqual([
'.icon--mdi-light',
'.icon--mdi-light--home',
]);
expect(Object.keys(data['.icon--mdi-light--home'])).toEqual(['--svg']);
});
it('Multiple icons from same icon set', () => {
const data = getCSSRulesForIcons([
// By name
'mdi-light:home',
// By selector
'.icon--mdi-light--arrow-left',
'.icon--mdi-light.icon--mdi-light--arrow-down',
// By class name
'icon--mdi-light--file',
'icon--mdi-light icon--mdi-light--format-clear',
]);
expect(Object.keys(data)).toEqual([
'.icon--mdi-light',
'.icon--mdi-light--home',
'.icon--mdi-light--arrow-left',
'.icon--mdi-light--arrow-down',
'.icon--mdi-light--file',
'.icon--mdi-light--format-clear',
]);
});
it('Multiple icon sets', () => {
const data = getCSSRulesForIcons([
// MDI Light
'mdi-light:home',
// Line MD
'line-md:home',
]);
expect(Object.keys(data)).toEqual([
'.icon--mdi-light',
'.icon--mdi-light--home',
'.icon--line-md',
'.icon--line-md--home',
]);
});
it('Bad class name', () => {
let threw = false;
try {
getCSSRulesForIcons(['icon--mdi-light--home test']);
} catch {
threw = true;
}
expect(threw).toBe(true);
});
it('Bad icon set', () => {
let threw = false;
try {
getCSSRulesForIcons('test123:home');
} catch {
threw = true;
}
expect(threw).toBe(true);
});
});

View File

@ -1,4 +1,4 @@
import { getDynamicCSSRules } from '../src/dynamic';
import { getDynamicCSSRules } from '../lib/dynamic.js';
describe('Testing dynamic CSS rules', () => {
it('One icon', () => {

View File

@ -3,6 +3,8 @@
"compilerOptions": {
"types": ["node", "jest"],
"rootDir": ".",
"outDir": "../tests-compiled"
"outDir": "../tests-compiled",
"module": "ESNext",
"moduleResolution": "bundler"
}
}

View File

@ -7,7 +7,6 @@
"sourceMap": false,
"composite": true,
"strict": false,
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true

View File

@ -4,6 +4,8 @@
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"module": "NodeNext",
"moduleResolution": "nodenext",
"lib": ["ESNext", "DOM"]
}
}

View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
watch: false,
include: ['**/tests/*-test.ts'],
},
});

1018
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff