diff --git a/packages/react-icons-atomic-webpack-loader/README.md b/packages/react-icons-atomic-webpack-loader/README.md index adfb587439..37228319a5 100644 --- a/packages/react-icons-atomic-webpack-loader/README.md +++ b/packages/react-icons-atomic-webpack-loader/README.md @@ -68,6 +68,8 @@ module.exports = { | `@fluentui/react-icons` | `svg`, `fonts`, `svg-sprite` | `svg`, `fonts` | Has `/providers` and `/utils` | | `@fluentui/react-brand-icons` | `svg` | `svg` | Has `/utils`; no `/providers` | +> **Color icons are SVG-only.** Color variants (`*Color`) rely on gradients that cannot be represented in an icon font, so they ship only in the `svg` and `svg-sprite` builds — never `fonts`. The loader reroutes color imports off font variants automatically (see [Color icons](#color-icons) below). + ## Options | Option | Type | Default | Description | @@ -98,6 +100,26 @@ Resolution is lazy and per file: only modules actually imported in a given file } ``` +### Color icons + +Color variants (`*Color`, e.g. `AddCircleColor`) are **SVG-only** — their gradients cannot be represented in an icon font, so the `fonts` build ships no color glyphs. When a color icon is imported under a color-less variant (`iconVariant: 'fonts'`), the loader reroutes just that import to a color-capable variant, following the same precedence as above (constrained to `svg` / `svg-sprite`), and emits a warning: + +1. If `iconVariant` is already color-capable (`svg` / `svg-sprite`), the color import is left on it — no reroute, no warning. +2. Otherwise, if `fallbackVariant` is set and color-capable, it is used (e.g. `svg-sprite`). +3. Otherwise the loader falls back to `svg`. + +Rerouting is per specifier, so color and non-color icons in the same statement resolve independently: + +```js +// iconVariant: 'fonts' +import { AddFilled, AddCircleColor } from '@fluentui/react-icons'; +// → +import { AddFilled } from '@fluentui/react-icons/fonts/add'; +import { AddCircleColor } from '@fluentui/react-icons/svg/add-circle'; +``` + +> Color icons are deprecated. See the [user guidance](https://microsoft.github.io/fluentui-system-icons/?path=/docs/icons-user-guidance--docs#color-variants-deprecated). + ### Using font icons ```js @@ -117,9 +139,9 @@ Resolution is lazy and per file: only modules actually imported in a given file This changes icon resolution from `@fluentui/react-icons/svg/*` to `@fluentui/react-icons/fonts/*`. Non-icon exports (`utils`, `providers`) are unaffected. -### Using the headless API +> Color icons have no font build and are rerouted to `svg` (or `svg-sprite`) automatically — see [Color icons](#color-icons). -Set `headless: true` to resolve to the Griffel-free headless build. It composes with `iconVariant`: +### Using the headless API ```js { @@ -174,6 +196,40 @@ This changes icon resolution from `@fluentui/react-icons/svg/*` to `@fluentui/re The loader parses each module and rewrites import and re-export declarations that reference a supported module. Each named specifier is routed to an atomic subpath based on its name: +### Resolution flow + +Each named specifier is resolved independently, so color and non-color icons — even within the same statement — can land on different variants. + +```mermaid +flowchart TD + A["Named specifier from a supported module"] --> B{"Icon name? ends in Regular / Filled / Light / Color"} + B -->|"context / hook"| P["/providers"] + B -->|"utility"| U["/utils"] + B -->|"yes"| V{"Module supports iconVariant?"} + + V -->|"yes"| R["variant = iconVariant"] + V -->|"no"| F{"fallbackVariant set?"} + F -->|"no"| ERR["Error: import left untouched, set fallbackVariant"] + F -->|"yes, supported"| R2["variant = fallbackVariant"] + F -->|"yes, unsupported"| RS["variant = svg (warning)"] + + R --> C{"Color icon?"} + R2 --> C + RS --> C + + C -->|"no"| H{"headless requested and available for variant?"} + C -->|"yes, already color-capable: svg / svg-sprite"| H + C -->|"yes, color-less variant: fonts"| CC["reroute to first color-capable of iconVariant, fallbackVariant, svg (warning)"] + CC --> H + + H -->|"yes"| HP["prefix with /headless"] + H -->|"no / not available (warning)"| STD["standard build"] + HP --> OUT["resolved atomic path"] + STD --> OUT +``` + +> Steps marked "(warning)" emit a build warning — a best-effort degrade rather than a hard failure. + ### `@fluentui/react-icons` | Export type | Example | Resolved path | diff --git a/packages/react-icons-atomic-webpack-loader/src/index.ts b/packages/react-icons-atomic-webpack-loader/src/index.ts index f9f3ee3518..8653ed7c76 100644 --- a/packages/react-icons-atomic-webpack-loader/src/index.ts +++ b/packages/react-icons-atomic-webpack-loader/src/index.ts @@ -12,12 +12,21 @@ export interface FluentIconsAtomicImportLoaderOptions { * Not every module supports every variant (e.g. `@fluentui/react-brand-icons` * only ships `svg`). When a referenced module does not support this variant, * `fallbackVariant` is used instead. + * + * Color icons are an exception: they are SVG-only (gradients cannot live in an + * icon font), so a `*Color` import under `iconVariant: 'fonts'` is rerouted to + * a color-capable variant (`svg` / `svg-sprite`) following the same + * `iconVariant → fallbackVariant → svg` precedence, with a warning. */ iconVariant?: IconVariant; /** * The variant to use for a referenced module that does not support * `iconVariant`. When omitted and a module cannot honor `iconVariant`, the * loader fails with a descriptive error. + * + * Also used as the preferred target when rerouting SVG-only color icons off a + * color-less `iconVariant` (e.g. `fonts`), provided the fallback itself is + * color-capable; otherwise the loader degrades to `svg`. */ fallbackVariant?: IconVariant; /** diff --git a/packages/react-icons-atomic-webpack-loader/src/modules.ts b/packages/react-icons-atomic-webpack-loader/src/modules.ts index ed4b1dd1dd..a198764479 100644 --- a/packages/react-icons-atomic-webpack-loader/src/modules.ts +++ b/packages/react-icons-atomic-webpack-loader/src/modules.ts @@ -13,6 +13,17 @@ function isIconName(importName: string): boolean { return ICON_SUFFIX_REGEX.test(importName); } +/** + * Whether an import name is a *color* icon variant, i.e. its style suffix is + * `Color` (e.g. `AddCircleColor`, `AddCircle20Color`). Every icon export carries + * exactly one trailing style suffix, so a `Color` suffix unambiguously marks a + * color variant — icons whose base name merely contains the word "Color" (e.g. + * `TextColorRegular`) end in a different style suffix. + */ +export function isColorIconName(importName: string): boolean { + return ICON_SUFFIX_REGEX.exec(importName)?.[2] === 'Color'; +} + function toKebabCase(value: string): string { return value.replace(/[a-z\d](?=[A-Z])|[a-zA-Z](?=\d)|[A-Z](?=[A-Z][a-z])/g, '$&-').toLowerCase(); } @@ -35,6 +46,14 @@ export interface ModuleDescriptor { * Empty when the module has no headless build at all. */ headlessVariants: IconVariant[]; + /** + * Icon variants for which this module ships *color* icon atoms. + * + * Color icons are SVG-only by nature — their gradients cannot be represented + * in an icon font — so the font builds contain no color glyphs. Empty when the + * module has no color icons at all. + */ + colorVariants: IconVariant[]; /** * Resolves the atomic subpath for a single named import. * @@ -50,6 +69,8 @@ const reactIcons: ModuleDescriptor = { supportedVariants: ['svg', 'fonts', 'svg-sprite'], // Headless ships svg + fonts today; headless svg-sprite is not generated yet. headlessVariants: ['svg', 'fonts'], + // Color icons ship in svg + svg-sprite; the font build has no color glyphs. + colorVariants: ['svg', 'svg-sprite'], resolve(importName, variant, headless) { if (importName === 'useIconContext' || importName === 'IconDirectionContextProvider') { // Context is framework-agnostic and shared by both APIs. @@ -71,6 +92,8 @@ const reactBrandIcons: ModuleDescriptor = { supportedVariants: ['svg'], // Brand icons ship a headless (Griffel-free) svg build. headlessVariants: ['svg'], + // Brand icons ship a single svg build, which already includes color icons. + colorVariants: ['svg'], resolve(importName, _variant, headless) { const pkg = headless ? '@fluentui/react-brand-icons/headless' : '@fluentui/react-brand-icons'; @@ -142,6 +165,47 @@ export function resolveModuleVariant( }; } +/** + * Refines an already module-resolved `variant` for a single *color* icon import. + * + * Color icons are SVG-only (gradients cannot live in an icon font), so the font + * builds ship no color glyphs. When the module-resolved `variant` has no color + * atoms, the color import is rerouted using the same + * `iconVariant → fallbackVariant → svg` precedence as {@link resolveModuleVariant}, + * constrained to variants that are both supported *and* color-capable. `svg` is + * always both, so a resolution always exists. + * + * Callers must only invoke this for color imports (see {@link isColorIconName}) + * and only after module resolution has succeeded. + */ +export function resolveColorVariant( + descriptor: ModuleDescriptor, + variant: IconVariant, + iconVariant: IconVariant, + fallbackVariant: IconVariant | undefined, +): { variant: IconVariant; warning?: string } { + // Already color-capable — nothing to reroute (e.g. svg, svg-sprite). + if (descriptor.colorVariants.includes(variant)) { + return { variant }; + } + + const candidates = [iconVariant, fallbackVariant, DEFAULT_SAFETY_VARIANT].filter( + (candidate): candidate is IconVariant => candidate !== undefined, + ); + + const colorVariant = + candidates.find( + (candidate) => descriptor.supportedVariants.includes(candidate) && descriptor.colorVariants.includes(candidate), + ) ?? DEFAULT_SAFETY_VARIANT; + + return { + variant: colorVariant, + warning: + `"${descriptor.name}" has no color icons for variant "${variant}" ` + + `(color icons are SVG-only). Resolving Color imports to "${colorVariant}".`, + }; +} + /** * Resolves whether to use a module's headless build, given the requested * `headless` flag and the already-resolved `variant`. diff --git a/packages/react-icons-atomic-webpack-loader/src/transform.ts b/packages/react-icons-atomic-webpack-loader/src/transform.ts index ab9912444b..e60993fbe3 100644 --- a/packages/react-icons-atomic-webpack-loader/src/transform.ts +++ b/packages/react-icons-atomic-webpack-loader/src/transform.ts @@ -1,7 +1,13 @@ import { parseSync } from 'oxc-parser'; import MagicString from 'magic-string'; -import { getModuleDescriptor, resolveModuleVariant, resolveModuleHeadless } from './modules'; +import { + getModuleDescriptor, + resolveModuleVariant, + resolveColorVariant, + resolveModuleHeadless, + isColorIconName, +} from './modules'; import type { IconVariant, ModuleDescriptor } from './modules'; interface TransformOptions { @@ -48,40 +54,67 @@ export function transformSource(source: string, options: TransformOptions): Tran const src = new MagicString(source); const diagnostics: Diagnostic[] = []; - // Resolve (and diagnose) each referenced module at most once. + // Dedupe diagnostics by message so a module's variant / color / headless + // concern surfaces at most once, even though resolution now runs per + // (module, color-ness) rather than per module. + const seenDiagnostics = new Set(); + const pushDiagnostic = (diagnostic: Diagnostic): void => { + const key = `${diagnostic.level}:${diagnostic.message}`; + if (seenDiagnostics.has(key)) return; + seenDiagnostics.add(key); + diagnostics.push(diagnostic); + }; + + // Resolve each referenced module at most once per color-ness: color icons may + // route to a different variant than their non-color siblings, so the cache key + // is `${name}:${isColor}`. Resolution stays O(#modules × 2) regardless of how + // many icons a file imports. const resolvedTargets = new Map(); /** - * Returns the target (variant + headless) to rewrite a referenced module - * with, or `null` when it could not be resolved (an error diagnostic has been - * recorded and the module should be left untouched). + * Returns the target (variant + headless) to rewrite a single referenced + * import with, or `null` when the module could not be resolved (an error + * diagnostic has been recorded and the import should be left untouched). */ - const targetFor = (descriptor: ModuleDescriptor): ResolvedTarget | null => { - if (resolvedTargets.has(descriptor.name)) { - return resolvedTargets.get(descriptor.name)!; + const targetFor = (descriptor: ModuleDescriptor, isColor: boolean): ResolvedTarget | null => { + const cacheKey = `${descriptor.name}:${isColor}`; + if (resolvedTargets.has(cacheKey)) { + return resolvedTargets.get(cacheKey)!; } const resolution = resolveModuleVariant(descriptor, iconVariant, fallbackVariant); if (resolution.warning) { - diagnostics.push({ level: 'warning', message: resolution.warning }); + pushDiagnostic({ level: 'warning', message: resolution.warning }); } if (resolution.error) { - diagnostics.push({ level: 'error', message: resolution.error }); + pushDiagnostic({ level: 'error', message: resolution.error }); } if (!resolution.variant) { - resolvedTargets.set(descriptor.name, null); + resolvedTargets.set(cacheKey, null); return null; } - const headlessResolution = resolveModuleHeadless(descriptor, resolution.variant, headless); + let variant = resolution.variant; + + // Color icons are SVG-only; reroute them off any color-less variant (fonts) + // to a color-capable one, honoring the fallback precedence. + if (isColor) { + const colorResolution = resolveColorVariant(descriptor, variant, iconVariant, fallbackVariant); + if (colorResolution.warning) { + pushDiagnostic({ level: 'warning', message: colorResolution.warning }); + } + variant = colorResolution.variant; + } + + const headlessResolution = resolveModuleHeadless(descriptor, variant, headless); if (headlessResolution.warning) { - diagnostics.push({ level: 'warning', message: headlessResolution.warning }); + pushDiagnostic({ level: 'warning', message: headlessResolution.warning }); } - const target: ResolvedTarget = { variant: resolution.variant, headless: headlessResolution.headless }; - resolvedTargets.set(descriptor.name, target); + const target: ResolvedTarget = { variant, headless: headlessResolution.headless }; + resolvedTargets.set(cacheKey, target); return target; }; @@ -90,12 +123,21 @@ export function transformSource(source: string, options: TransformOptions): Tran const descriptor = getModuleDescriptor(moduleName); if (!descriptor) continue; - const variant = targetFor(descriptor); - if (!variant) continue; - const namedEntries = imp.entries.filter((e) => e.importName.kind === 'Name'); if (namedEntries.length === 0) continue; + // Resolve each named specifier independently — color icons may route to a + // different variant than their non-color siblings in the same statement. + const resolvedEntries = namedEntries.map((entry) => ({ + entry, + importedName: entry.importName.name!, + target: targetFor(descriptor, isColorIconName(entry.importName.name!)), + })); + + // A module-level resolution error is independent of color-ness, so if any + // specifier is unresolved they all are — leave the whole statement untouched. + if (resolvedEntries.some(({ target }) => !target)) continue; + const otherEntries = imp.entries.filter((e) => e.importName.kind !== 'Name'); const lines: string[] = []; @@ -106,10 +148,9 @@ export function transformSource(source: string, options: TransformOptions): Tran lines.push(`import ${names} from '${moduleName}';`); } - for (const entry of namedEntries) { - const importedName = entry.importName.name!; + for (const { entry, importedName, target } of resolvedEntries) { const localName = entry.localName.value; - const newSource = descriptor.resolve(importedName, variant.variant, variant.headless); + const newSource = descriptor.resolve(importedName, target!.variant, target!.headless); const spec = importedName === localName ? importedName : `${importedName} as ${localName}`; lines.push(`import { ${spec} } from '${newSource}';`); } @@ -133,12 +174,12 @@ export function transformSource(source: string, options: TransformOptions): Tran for (const entry of relevantEntries) { const moduleName = entry.moduleRequest!.value; const descriptor = getModuleDescriptor(moduleName)!; - const variant = targetFor(descriptor); - if (!variant) continue; - const importedName = entry.importName.name!; + const target = targetFor(descriptor, isColorIconName(importedName)); + if (!target) continue; + const exportedName = entry.exportName.name!; - const newSource = descriptor.resolve(importedName, variant.variant, variant.headless); + const newSource = descriptor.resolve(importedName, target.variant, target.headless); const spec = importedName === exportedName ? importedName : `${importedName} as ${exportedName}`; lines.push(`export { ${spec} } from '${newSource}';`); } diff --git a/packages/react-icons-atomic-webpack-loader/test/src/color-fonts-imports.js b/packages/react-icons-atomic-webpack-loader/test/src/color-fonts-imports.js new file mode 100644 index 0000000000..f2a1e7fcfd --- /dev/null +++ b/packages/react-icons-atomic-webpack-loader/test/src/color-fonts-imports.js @@ -0,0 +1,5 @@ +import { AddFilled, AddCircleColor } from '@fluentui/react-icons'; + +// Under iconVariant: 'fonts', AddFilled stays on the font build while the +// SVG-only AddCircleColor reroutes to the svg atom. +console.log(AddFilled, AddCircleColor); diff --git a/packages/react-icons-atomic-webpack-loader/test/src/color-headless-fonts-imports.js b/packages/react-icons-atomic-webpack-loader/test/src/color-headless-fonts-imports.js new file mode 100644 index 0000000000..72c4e5b5d4 --- /dev/null +++ b/packages/react-icons-atomic-webpack-loader/test/src/color-headless-fonts-imports.js @@ -0,0 +1,5 @@ +import { AddFilled, AddCircleColor } from '@fluentui/react-icons'; + +// Under iconVariant: 'fonts' + headless, AddFilled stays on the headless font +// build while the SVG-only AddCircleColor reroutes to the headless svg atom. +console.log(AddFilled, AddCircleColor); diff --git a/packages/react-icons-atomic-webpack-loader/test/transform.test.ts b/packages/react-icons-atomic-webpack-loader/test/transform.test.ts index 83d25d3f74..ca91c5b76e 100644 --- a/packages/react-icons-atomic-webpack-loader/test/transform.test.ts +++ b/packages/react-icons-atomic-webpack-loader/test/transform.test.ts @@ -305,6 +305,107 @@ describe('transformSource', () => { }); }); + describe('color icons (SVG-only fallback)', () => { + it('reroutes a color icon from fonts to svg (no font color glyphs)', () => { + const { code, diagnostics } = transformSource(`import { AddCircleColor } from '@fluentui/react-icons';`, { + iconVariant: 'fonts', + path: 'input.js', + }); + + expect(code).toBe(`import { AddCircleColor } from '@fluentui/react-icons/svg/add-circle';`); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].level).toBe('warning'); + expect(diagnostics[0].message).toContain('color icons are SVG-only'); + }); + + it('reroutes a sized color icon from fonts to svg', () => { + expect(transform(`import { AddCircle20Color } from '@fluentui/react-icons';`, 'fonts')).toBe( + `import { AddCircle20Color } from '@fluentui/react-icons/svg/add-circle';`, + ); + }); + + it('honors a color-capable fallbackVariant (svg-sprite) for color icons under fonts', () => { + const { code, diagnostics } = transformSource(`import { AddCircleColor } from '@fluentui/react-icons';`, { + iconVariant: 'fonts', + fallbackVariant: 'svg-sprite', + path: 'input.js', + }); + + expect(code).toBe(`import { AddCircleColor } from '@fluentui/react-icons/svg-sprite/add-circle';`); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].level).toBe('warning'); + }); + + it('falls back to svg when neither iconVariant nor fallbackVariant is color-capable', () => { + expect(transform(`import { AddCircleColor } from '@fluentui/react-icons';`, 'fonts', 'fonts')).toBe( + `import { AddCircleColor } from '@fluentui/react-icons/svg/add-circle';`, + ); + }); + + it('leaves color icons untouched when iconVariant is svg-sprite (sprites support color)', () => { + const { code, diagnostics } = transformSource(`import { AddCircleColor } from '@fluentui/react-icons';`, { + iconVariant: 'svg-sprite', + path: 'input.js', + }); + + expect(code).toBe(`import { AddCircleColor } from '@fluentui/react-icons/svg-sprite/add-circle';`); + expect(diagnostics).toEqual([]); + }); + + it('leaves color icons on svg when iconVariant is svg', () => { + const { code, diagnostics } = transformSource(`import { AddCircleColor } from '@fluentui/react-icons';`, { + iconVariant: 'svg', + path: 'input.js', + }); + + expect(code).toBe(`import { AddCircleColor } from '@fluentui/react-icons/svg/add-circle';`); + expect(diagnostics).toEqual([]); + }); + + it('routes color and non-color specifiers in the same statement independently', () => { + const { code } = transformSource(`import { AddFilled, AddCircleColor } from '@fluentui/react-icons';`, { + iconVariant: 'fonts', + path: 'input.js', + }); + + expect(code).toBe( + [ + `import { AddFilled } from '@fluentui/react-icons/fonts/add';`, + `import { AddCircleColor } from '@fluentui/react-icons/svg/add-circle';`, + ].join('\n'), + ); + }); + + it('reroutes color icons in direct re-exports too', () => { + expect(transform(`export { AddCircleColor } from '@fluentui/react-icons';`, 'fonts')).toBe( + `export { AddCircleColor } from '@fluentui/react-icons/svg/add-circle';`, + ); + }); + + it('resolves the headless svg build for color icons under headless fonts', () => { + const { code, diagnostics } = transformSource(`import { AddCircleColor } from '@fluentui/react-icons';`, { + iconVariant: 'fonts', + headless: true, + path: 'input.js', + }); + + expect(code).toBe(`import { AddCircleColor } from '@fluentui/react-icons/headless/svg/add-circle';`); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].message).toContain('color icons are SVG-only'); + }); + + it('emits the color warning only once per module', () => { + const source = [ + `import { AddCircleColor } from '@fluentui/react-icons';`, + `import { AlbumColor } from '@fluentui/react-icons';`, + ].join('\n'); + + const { diagnostics } = transformSource(source, { iconVariant: 'fonts', path: 'input.js' }); + + expect(diagnostics).toHaveLength(1); + }); + }); + describe('diagnostics', () => { it('does not diagnose a module that only appears in a comment', () => { const source = [ diff --git a/packages/react-icons-atomic-webpack-loader/test/webpack.config.js b/packages/react-icons-atomic-webpack-loader/test/webpack.config.js index 50028a06ba..4988839ebc 100644 --- a/packages/react-icons-atomic-webpack-loader/test/webpack.config.js +++ b/packages/react-icons-atomic-webpack-loader/test/webpack.config.js @@ -71,6 +71,14 @@ const entries = { ], mustExclude: ['"@fluentui/react-icons"', '@fluentui/react-icons/svg/'], }, + 'color-fonts-imports': { + src: './src/color-fonts-imports.js', + // Color icons are SVG-only; under 'fonts' they reroute to svg while their + // non-color siblings stay on the font build. + loaderOptions: { iconVariant: 'fonts' }, + mustInclude: ['@fluentui/react-icons/fonts/add', '@fluentui/react-icons/svg/add-circle'], + mustExclude: ['"@fluentui/react-icons"', '@fluentui/react-icons/fonts/add-circle'], + }, 'svg-sprite-imports': { src: './src/svg-sprite-imports.js', loaderOptions: { iconVariant: 'svg-sprite' }, @@ -98,6 +106,15 @@ const entries = { mustExclude: ['"@fluentui/react-icons"', '@fluentui/react-icons/svg/', '@fluentui/react-icons/utils'], }, + 'color-headless-fonts-imports': { + src: './src/color-headless-fonts-imports.js', + // Under headless fonts, color icons reroute to the headless svg build while + // their non-color siblings stay on the headless font build. + loaderOptions: { iconVariant: 'fonts', headless: true }, + mustInclude: ['@fluentui/react-icons/headless/fonts/add', '@fluentui/react-icons/headless/svg/add-circle'], + mustExclude: ['"@fluentui/react-icons"', '@fluentui/react-icons/headless/fonts/add-circle'], + }, + 'brand-icons-imports': { src: './src/brand-icons-imports.js', loaderOptions: {},