feat(): unify icon build logic some more, add nonScalingStroke option, marking absoluteStrokeWidth as deprecated

This commit is contained in:
Karsa
2026-06-05 16:49:09 +02:00
parent 8d0fa2e218
commit 3d4b0e0878
20 changed files with 426 additions and 320 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ const {
...rest
} = Astro.props;
const [, builtIconAttributes, builtIconNode] = buildLucideIconNode(icon, {
const [, builtIconAttributes, builtIconNode = []] = buildLucideIconNode(icon, {
color,
width: width ?? size,
height: height ?? size,
+5 -6
View File
@@ -1,19 +1,18 @@
import { h, toChildArray } from 'preact';
import { buildLucideIconNode, hasA11yProp, mergeClasses } from '@lucide/shared';
import defaultAttributes from './defaultAttributes';
import type { LucideIconData, LucideIconNode, LucideProps } from './types';
import { useLucideContext } from './context';
type IconComponentProps = LucideProps &
(
| {
iconNode: LucideIconNode;
icon?: never;
}
| {
icon: LucideIconData;
iconNode?: never;
}
| {
iconNode: LucideIconNode[];
icon?: never;
}
);
/**
@@ -59,7 +58,7 @@ const Icon = ({
class: contextClass = '',
} = useLucideContext() ?? {};
const [name, svgAttributes, builtIconNode] = buildLucideIconNode(icon, {
const [name, svgAttributes, builtIconNode = []] = buildLucideIconNode(icon, {
color: color ?? contextColor,
width: width ?? size ?? contextSize,
height: height ?? size ?? contextSize,
+1 -1
View File
@@ -2,7 +2,7 @@ import { type FunctionComponent, type JSX } from 'preact';
import type {
LucideIconData as SharedLucideIconData,
LucideIconNode as SharedLucideIconNode,
} from '@lucide/shared';
} from '@lucide/shared/types';
export type LucideIconNode = SharedLucideIconNode;
+1 -1
View File
@@ -81,7 +81,7 @@ const Icon = forwardRef<SVGSVGElement, IconComponentProps>(
const hasAccessibleProp = Boolean(children) || hasA11yProp(rest);
const [, svgAttributes, builtIconNode] = buildLucideIconForReact(icon, {
const [, svgAttributes, builtIconNode = []] = buildLucideIconForReact(icon, {
color: color ?? contextColor,
width: width ?? size ?? contextSize,
height: height ?? size ?? contextSize,
+1 -1
View File
@@ -66,7 +66,7 @@ const Icon = forwardRef<SVGSVGElement, IconComponentProps>(
const hasAccessibleProp = Boolean(children) || hasA11yProp(rest);
const [name, svgAttributes, builtIconNode] = buildLucideIconForReact(icon, {
const [name, svgAttributes, builtIconNode = []] = buildLucideIconForReact(icon, {
color: color ?? contextColor,
width: width ?? size ?? contextSize,
height: height ?? size ?? contextSize,
+1 -1
View File
@@ -39,7 +39,7 @@ const Icon = (props: LucideProps & IconProps) => {
aliases: [],
};
const [, svgAttributes, builtIconNode] = buildLucideIconNode(icon, {
const [, svgAttributes, builtIconNode = []] = buildLucideIconNode(icon, {
color: localProps.color ?? globalProps.color,
width: localProps.width ?? localProps.size ?? globalProps.size,
height: localProps.height ?? localProps.size ?? globalProps.size,
@@ -17,8 +17,8 @@ function buildLucideIconNode(icon: LucideIconData, params: LucideBuildParams = {
const viewBoxHeight = icon.size ?? icon.height ?? defaultAttributes['height'];
const aliasClassNames =
icon.aliases
?.filter((alias): alias is string => typeof alias === 'string' && alias.trim() !== '')
.map((alias) => `lucide-${alias}`) ?? [];
?.filter((alias: string): alias is string => typeof alias === 'string' && alias.trim() !== '')
.map((alias: string) => `lucide-${alias}`) ?? [];
const iconClassNames = [...(icon.name ? [`lucide-${icon.name}`] : []), ...aliasClassNames];
const classNamesFromClassName = params.className?.split(' ').filter(Boolean) ?? [];
const className =
@@ -55,12 +55,14 @@ function buildLucideIconNode(icon: LucideIconData, params: LucideBuildParams = {
return [
'svg',
attributes,
icon.node.map(([name, attrs]) => [
name,
params.nonScalingStroke
icon.node.map((child): LucideIconNode => {
const [name, attrs, children] = child;
const nextAttrs = params.nonScalingStroke
? { [getAttributeName('vector-effect')]: 'non-scaling-stroke', ...attrs }
: attrs,
]),
: attrs;
return children ? [name, nextAttrs, children] : [name, nextAttrs];
}),
];
}
+4 -3
View File
@@ -1,4 +1,5 @@
export type SVGProps = Record<string, unknown>;
/* eslint-disable @typescript-eslint/no-explicit-any */
export type SVGProps = Record<string, any>;
/**
* A Lucide icon node (an svgson-like internal format)
@@ -70,6 +71,6 @@ export type LucideBuildParams<TProps extends Record<string, unknown> = SVGProps>
*/
attributeNames?: Record<string, string>;
} & (
| { size?: string | number; width?: never; height?: never }
| { size?: never; width?: string | number; height?: string | number }
| { size?: string | number | null; width?: never; height?: never }
| { size?: never; width?: string | number | null; height?: string | number | null }
);
+21 -4
View File
@@ -4,7 +4,24 @@
* @param {string} string
* @returns {string} A camelized string
*/
export const toCamelCase = <T extends string>(string: T) =>
string.replace(/^([A-Z])|[\s-_]+(\w)/g, (match, p1, p2) =>
p2 ? p2.toUpperCase() : p1.toLowerCase(),
);
export const toCamelCase = <T extends string>(string: T) => {
let out = '';
let upperNext = false;
for (const ch of string) {
if (ch === '-' || ch === '_' || ch <= ' ') {
upperNext = out.length > 0;
continue;
}
if (out.length === 0) {
out += ch.toLowerCase();
} else {
out += upperNext ? ch.toUpperCase() : ch;
}
upperNext = false;
}
return out as string;
};
+1 -1
View File
@@ -48,7 +48,7 @@
"scripts": {
"build": "pnpm clean && pnpm copy:license && pnpm copy:utils && pnpm build:icons && pnpm build:package && pnpm build:license",
"copy:license": "cp ../../LICENSE ./LICENSE",
"copy:utils": "cpy ../../packages/shared/src/build/{buildLucideIconNode.ts,defaultAttributes.ts,types.ts} ../../packages/shared/src/utils/{hasA11yProp.ts,mergeClasses.ts} ./src/utils/",
"copy:utils": "cpy ../../packages/shared/src/build/{buildLucideIconNode.ts,defaultAttributes.ts,types.ts} ../../packages/shared/src/utils/{hasA11yProp.ts,mergeClasses.ts} ./src/utils/ && node ./scripts/patchCopiedUtils.mts",
"clean": "rm -rf dist stats ./src/icons/*.{ts,svelte} ./src/aliases/{aliases,prefixed,suffixed}.ts",
"build:icons": "build-icons --output=./src --templateSrc=./scripts/exportTemplate.mts --exportFileName=index.ts --iconFileExtension=.svelte --importImportFileExtension=.svelte --separateIconFileExport --separateIconFileExportExtension=.ts --withAliases --aliasesFileExtension=.ts --separateAliasesFile --separateAliasesFileExtension=.ts --aliasImportFileExtension=.js --pretty=false",
"build:package": "svelte-package --input ./src",
+2 -2
View File
@@ -8,8 +8,8 @@ export default defineExportTemplate(
const svgBase64 = base64SVG(svgContents);
return `\
<script lang="ts">
${getHTMLBanner()}
<script lang="ts">
import Icon from '../Icon.svelte';
import type { LucideProps, LucideIconData } from '../types.js';
@@ -29,7 +29,7 @@ Lucide SVG icon component, renders SVG Element with children.
${deprecated ? `\n@deprecated ${deprecationReason}\n` : ''}\
-->
<Icon icon={iconData} {...props} />
<Icon {...props} icon={iconData} />
`;
},
);
@@ -0,0 +1,14 @@
import { readFile, writeFile } from 'node:fs/promises';
const filePath = new URL('../src/utils/buildLucideIconNode.ts', import.meta.url);
const source = await readFile(filePath, 'utf8');
const patched = source
.replace("from './types';", "from './types.js';")
.replace("from './defaultAttributes';", "from './defaultAttributes.js';")
.replace("from '../utils/mergeClasses';", "from '../utils/mergeClasses.js';");
if (patched !== source) {
await writeFile(filePath, patched);
}
+4 -4
View File
@@ -1,10 +1,10 @@
<script lang="ts">
import buildLucideIconNode from './utils/buildLucideIconNode.js';
import type {IconProps} from './types.js';
import { hasA11yProp } from './utils/hasA11yProp.js';
import { getLucideContext } from './context.js';
import {hasA11yProp} from './utils/hasA11yProp.js';
import {getLucideContext} from './context.js';
import {mergeClasses} from './utils/mergeClasses.js';
import type {ClassValue} from "svelte/elements";
import type {ClassValue} from 'svelte/elements';
const globalProps = getLucideContext() ?? {};
@@ -31,7 +31,7 @@
Boolean(children) || hasA11yProp(props as Record<string, any>),
);
const [, svgAttributes, builtIconNode] = $derived(
const [, svgAttributes, builtIconNode = []] = $derived(
buildLucideIconNode(
icon,
{
+1 -1
View File
@@ -5,7 +5,7 @@ import type {
LucideIconNode as SharedLucideIconNode,
} from '@lucide/shared/types';
export type Attrs = SVGAttributes<SVGSVGElement>;
export type Attrs = Record<string, unknown> & SVGAttributes<SVGSVGElement>;
type IconNodeElements =
| 'circle'
| 'ellipse'
@@ -114,6 +114,46 @@ exports[`Using lucide icon components > should adjust the size, stroke color and
</div>
`;
exports[`Using lucide icon components > should apply vector-effect when nonScalingStroke is set 1`] = `
<svg xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewbox="0 0 24 24"
fill="none"
stroke="red"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-smile lucide-icon"
aria-hidden="true"
>
<circle vector-effect="non-scaling-stroke"
cx="12"
cy="12"
r="10"
>
</circle>
<path vector-effect="non-scaling-stroke"
d="M8 14s1.5 2 4 2 4-2 4-2"
>
</path>
<line vector-effect="non-scaling-stroke"
x1="9"
x2="9.01"
y1="9"
y2="9"
>
</line>
<line vector-effect="non-scaling-stroke"
x1="15"
x2="15.01"
y1="9"
y2="9"
>
</line>
</svg>
`;
exports[`Using lucide icon components > should render an component 1`] = `
<div>
<svg
@@ -83,6 +83,24 @@ describe('Using lucide icon components', () => {
expect(container.innerHTML).toMatchSnapshot();
});
it('should apply vector-effect when nonScalingStroke is set', () => {
const { container } = render(Smile, {
color: 'red',
size: 48,
nonScalingStroke: true,
});
const IconComponent = container.firstElementChild;
expect(IconComponent).toHaveAttribute('width', '48');
expect(IconComponent).toHaveAttribute('height', '48');
expect(IconComponent).toHaveAttribute('stroke', 'red');
expect(IconComponent).toHaveAttribute('stroke-width', '2');
expect(IconComponent?.firstElementChild).toHaveAttribute('vector-effect', 'non-scaling-stroke');
expect(container.innerHTML).toMatchSnapshot();
});
it('should use context values from the global set properties', () => {
const { container } = render(ContextWrapper);
+2 -7
View File
@@ -6,7 +6,6 @@ import {
mergeClasses,
toKebabCase,
} from '@lucide/shared';
import defaultAttributes from './defaultAttributes';
import { LucideIconNode, LucideIconData, LucideProps } from './types';
import { useLucideProps } from './context';
@@ -72,15 +71,11 @@ const Icon: FunctionalComponent<LucideProps & IconProps> = (
const { class: propsClass, ...restProps } = props;
const [, svgAttributes, builtIconNode] = buildLucideIconNode(icon, {
const [, svgAttributes, builtIconNode = []] = buildLucideIconNode(icon, {
color: color ?? contextColor,
width: width ?? size ?? contextSize,
height: height ?? size ?? contextSize,
strokeWidth:
strokeWidth ??
strokeWidthKebabCase ??
contextStrokeWidth ??
defaultAttributes['stroke-width'],
strokeWidth: strokeWidth ?? strokeWidthKebabCase ?? contextStrokeWidth,
absoluteStrokeWidth: isAbsoluteStrokeWidth,
nonScalingStroke: isNonScalingStroke,
className: mergeClasses(contextClass, propsClass),
-25
View File
@@ -21,19 +21,6 @@ describe('Using Icon Component', () => {
expect(container.firstChild).toMatchSnapshot();
});
it('should render icon based on icon data', async () => {
const { container } = render(Icon, {
props: {
icon: airVentIcon,
size: 48,
color: 'red',
absoluteStrokeWidth: true,
},
});
expect(container.firstChild).toMatchSnapshot();
});
it('should render icon and match snapshot', async () => {
const { container } = render(Icon, {
props: {
@@ -46,16 +33,4 @@ describe('Using Icon Component', () => {
expect(container.firstChild).toMatchSnapshot();
});
it('should render icon based on icon data', async () => {
const { container } = render(Icon, {
props: {
icon: airVentIcon,
size: 48,
color: 'red',
},
});
expect(container.firstChild).toBeDefined();
});
});
@@ -55,31 +55,3 @@ exports[`Using Icon Component > should render icon based on an icon node 1`] = `
/>
</svg>
`;
exports[`Using Icon Component > should render icon based on icon data 1`] = `
<svg
class="lucide lucide-air-vent"
fill="none"
height="48"
stroke="red"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
viewBox="0 0 24 24"
width="48"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 12H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"
/>
<path
d="M6 8h12"
/>
<path
d="M18.3 17.7a2.5 2.5 0 0 1-3.16 3.83 2.53 2.53 0 0 1-1.14-2V12"
/>
<path
d="M6.6 15.6A2 2 0 1 0 10 17v-5"
/>
</svg>
`;
+300 -227
View File
File diff suppressed because it is too large Load Diff