Skip to content

Commit d57a427

Browse files
chrisbbreuerclaude
andcommitted
feat(rules): pure-CSS iconify rule for any @iconify-json/* collection
Adds an `i-{collection}-{name}` utility that resolves to a CSS mask-image of the SVG, painted with `currentColor` — UnoCSS's `presetIcons`-style API, antfu's "icons in pure CSS" technique (https://antfu.me/posts/icons-in-pure-css): -webkit-mask: url("data:image/svg+xml;…") no-repeat; mask: url("data:image/svg+xml;…") no-repeat; mask-size: 100% 100%; background-color: currentColor; Synchronously reads `node_modules/@iconify-json/{collection}/icons.json` on first reference, caches the parsed set, and returns CSS via the generator. No bundling cost for collections the project doesn't use, no network calls, works for every collection iconify ships (`i-mdi-home`, `i-heroicons-arrow-right`, `i-hugeicons-user-group`, …). Returns `undefined` when the collection isn't installed so the rest of the rule chain still gets to look at the class. Sits at the top of `builtInRules` so the unambiguous `i-…` pattern short-circuits the rest of the iteration on match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b506af1 commit d57a427

2 files changed

Lines changed: 163 additions & 0 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import type { CrosswindConfig, ParsedClass } from './types'
2+
import type { UtilityRule } from './rules'
3+
import { existsSync, readFileSync } from 'node:fs'
4+
import { join } from 'node:path'
5+
6+
/**
7+
* Pure-CSS iconify rule — `i-{collection}-{name}`.
8+
*
9+
* Behaves like UnoCSS's `presetIcons`: a class such as `i-hugeicons-user-group`
10+
* or `i-mdi-home` resolves to a CSS mask-image of the SVG, painted with
11+
* `currentColor`. Uses Antfu's "icons in pure CSS" technique
12+
* (https://antfu.me/posts/icons-in-pure-css):
13+
*
14+
* -webkit-mask: var(--svg) no-repeat;
15+
* mask: var(--svg) no-repeat;
16+
* mask-size: 100% 100%;
17+
* background-color: currentColor;
18+
*
19+
* Icon data is loaded synchronously from `@iconify-json/{collection}` packages
20+
* the project has installed. The first match for a collection caches the
21+
* entire icon set so subsequent lookups in the same collection are O(1) map
22+
* reads. No network calls; works offline; tree-shakes per-class because we
23+
* only emit CSS for classes that actually appeared in the scanned files.
24+
*
25+
* @example
26+
* class="i-hugeicons-user-group h-5 w-5 text-blue-500"
27+
*
28+
* → ships <span> sized 1.25rem × 1.25rem painted blue, no `<svg>` runtime.
29+
*/
30+
31+
interface IconifyJSONIcon {
32+
body: string
33+
width?: number
34+
height?: number
35+
left?: number
36+
top?: number
37+
rotate?: number
38+
hFlip?: boolean
39+
vFlip?: boolean
40+
}
41+
42+
interface IconifyJSONCollection {
43+
prefix?: string
44+
icons: Record<string, IconifyJSONIcon>
45+
aliases?: Record<string, { parent: string }>
46+
width?: number
47+
height?: number
48+
/** Icons grouped by category — some collections (like material-symbols) use
49+
* an `info` block plus a flat `icons` map; others ship a separate
50+
* `icons.json`. We only need `icons` + dimensions. */
51+
}
52+
53+
const collectionCache = new Map<string, IconifyJSONCollection | null>()
54+
55+
function loadCollection(collection: string): IconifyJSONCollection | null {
56+
if (collectionCache.has(collection)) return collectionCache.get(collection)!
57+
58+
// Project-relative resolution. We deliberately avoid Node's `require.resolve`
59+
// because it surprises with hoisting in monorepos; reading the JSON
60+
// directly from `node_modules` is faster and predictable.
61+
const candidates = [
62+
join(process.cwd(), 'node_modules', '@iconify-json', collection, 'icons.json'),
63+
join(process.cwd(), 'node_modules', '.bun', '@iconify-json', collection, 'icons.json'),
64+
]
65+
// Bun also hoists into per-package `.bun/<pkg>@<ver>/node_modules/<pkg>` —
66+
// walk one level if the first two miss.
67+
for (const path of candidates) {
68+
if (existsSync(path)) {
69+
try {
70+
const data = JSON.parse(readFileSync(path, 'utf-8')) as IconifyJSONCollection
71+
collectionCache.set(collection, data)
72+
return data
73+
}
74+
catch {
75+
// Corrupt JSON — treat as missing
76+
}
77+
}
78+
}
79+
collectionCache.set(collection, null)
80+
return null
81+
}
82+
83+
function lookupIcon(collection: IconifyJSONCollection, name: string): IconifyJSONIcon | null {
84+
const icon = collection.icons[name]
85+
if (icon) return icon
86+
// Resolve aliases (e.g. `arrow-back` → `arrow-left`).
87+
const alias = collection.aliases?.[name]
88+
if (alias) return collection.icons[alias.parent] ?? null
89+
return null
90+
}
91+
92+
/**
93+
* Build the SVG `<svg>` source string for an icon. Iconify body strings
94+
* embed raw `<path>` / `<g>` markup but no outer `<svg>` wrapper, so we
95+
* synthesise that here with the icon's viewBox.
96+
*/
97+
function svgFor(collection: IconifyJSONCollection, icon: IconifyJSONIcon): string {
98+
const w = icon.width ?? collection.width ?? 24
99+
const h = icon.height ?? collection.height ?? 24
100+
const left = icon.left ?? 0
101+
const top = icon.top ?? 0
102+
// Single-line, no extra whitespace — keeps the data URL short.
103+
return `<svg xmlns='http://www.w3.org/2000/svg' viewBox='${left} ${top} ${w} ${h}' width='${w}' height='${h}'>${icon.body}</svg>`
104+
}
105+
106+
/**
107+
* Encode an SVG string into a data URL safe for `mask-image: url(...)`.
108+
* We use percent-encoding for the few characters that would break a CSS
109+
* `url("...")` value when the URL is wrapped in single quotes — matches
110+
* the encoding UnoCSS uses so cached results are byte-identical.
111+
*/
112+
function svgToDataUrl(svg: string): string {
113+
const encoded = svg
114+
.replace(/"/g, '\'')
115+
.replace(/%/g, '%25')
116+
.replace(/#/g, '%23')
117+
.replace(/</g, '%3C')
118+
.replace(/>/g, '%3E')
119+
.replace(/\?/g, '%3F')
120+
.replace(/\s+/g, ' ')
121+
return `url("data:image/svg+xml;utf8,${encoded}")`
122+
}
123+
124+
/**
125+
* Match `i-{collection}-{name}`. Collection is a single segment of
126+
* lowercase letters/digits (matches every `@iconify-json/*` package).
127+
* Name is everything after the second hyphen (icon names contain hyphens).
128+
*/
129+
const ICON_RE = /^i-([a-z][a-z0-9]*)-(.+)$/
130+
131+
export const iconRule: UtilityRule = (parsed: ParsedClass, _config: CrosswindConfig) => {
132+
const m = parsed.raw.match(ICON_RE)
133+
if (!m) return undefined
134+
const [, collectionName, iconName] = m
135+
136+
const collection = loadCollection(collectionName)
137+
if (!collection) return undefined
138+
const icon = lookupIcon(collection, iconName)
139+
if (!icon) return undefined
140+
141+
const url = svgToDataUrl(svgFor(collection, icon))
142+
// Antfu's pattern — every property is set so the class is fully self-
143+
// contained (no need for a sidecar `.icon { ... }` reset rule). Both
144+
// `mask` and `-webkit-mask` for Safari < 17.
145+
return {
146+
'display': 'inline-block',
147+
'width': '1em',
148+
'height': '1em',
149+
'background-color': 'currentColor',
150+
'-webkit-mask': `${url} no-repeat`,
151+
'mask': `${url} no-repeat`,
152+
'-webkit-mask-size': '100% 100%',
153+
'mask-size': '100% 100%',
154+
'vertical-align': '-0.125em',
155+
}
156+
}

packages/crosswind/src/rules.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { advancedRules } from './rules-advanced'
33
import { effectsRules } from './rules-effects'
44
import { formsRules } from './rules-forms'
55
import { gridRules } from './rules-grid'
6+
import { iconRule } from './rules-icons'
67
import { interactivityRules } from './rules-interactivity'
78
import { layoutRules } from './rules-layout'
89
import { transformsRules } from './rules-transforms'
@@ -1012,6 +1013,12 @@ export const builtInRules: UtilityRule[] = [
10121013
// CRITICAL: Most common utilities first for O(1) lookup performance
10131014
// Rule order matters! More specific rules must come before more general ones.
10141015

1016+
// Iconify-style `i-{collection}-{name}` icons. Must be first because the
1017+
// pattern is unambiguous and skipping the rest of the chain on match keeps
1018+
// pages with lots of icons fast. Returns undefined when @iconify-json/<X>
1019+
// isn't installed, so the lookup is silently no-op for unknown collections.
1020+
iconRule,
1021+
10151022
// Spacing and sizing rules (w, h, p, m are extremely common)
10161023
spacingRule,
10171024
sizingRule,

0 commit comments

Comments
 (0)