Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions packages/react-icons-atomic-webpack-loader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand All @@ -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
{
Expand Down Expand Up @@ -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 |
Expand Down
9 changes: 9 additions & 0 deletions packages/react-icons-atomic-webpack-loader/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
64 changes: 64 additions & 0 deletions packages/react-icons-atomic-webpack-loader/src/modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -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.
*
Expand All @@ -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.
Expand All @@ -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';

Expand Down Expand Up @@ -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`.
Expand Down
91 changes: 66 additions & 25 deletions packages/react-icons-atomic-webpack-loader/src/transform.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<string>();
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<string, ResolvedTarget | null>();

/**
* 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;
};

Expand All @@ -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[] = [];

Expand All @@ -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}';`);
}
Expand All @@ -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}';`);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading