@tarve/react-icons 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # @tarve/react-icons
2
+
3
+ Optional adapters for using React SVG icon libraries with Tarve while keeping `@tarve/core` library-agnostic.
4
+
5
+ ```powershell
6
+ bun add @tarve/core @tarve/react-icons
7
+ ```
8
+
9
+ ```ts
10
+ import { lucideReactAdapter, phosphorReactAdapter, reactSvgAdapter } from "@tarve/react-icons";
11
+
12
+ await render(App, {
13
+ componentAdapters: [reactSvgAdapter, lucideReactAdapter, phosphorReactAdapter],
14
+ });
15
+ ```
16
+
17
+ `reactSvgAdapter` handles libraries whose icon component directly renders a static SVG, including Heroicons and Tabler. Lucide and Phosphor use their dedicated adapters because their exported wrappers carry icon data in library-specific structures.
18
+
19
+ The package does not depend on React or on any icon library. Install only the icon libraries used by your application.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@tarve/react-icons",
3
+ "version": "0.1.2",
4
+ "description": "React icon-library adapters for Tarve",
5
+ "license": "Apache-2.0",
6
+ "keywords": [
7
+ "tarve",
8
+ "icons",
9
+ "lucide",
10
+ "phosphor",
11
+ "heroicons",
12
+ "tabler"
13
+ ],
14
+ "homepage": "https://github.com/omarcosr/tarve/tree/main/packages/react-icons#readme",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/omarcosr/tarve.git",
18
+ "directory": "packages/react-icons"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/omarcosr/tarve/issues"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "type": "module",
28
+ "exports": {
29
+ ".": "./src/index.ts",
30
+ "./react-svg": "./src/react-svg.ts",
31
+ "./lucide": "./src/lucide.ts",
32
+ "./phosphor": "./src/phosphor.ts"
33
+ },
34
+ "files": [
35
+ "src",
36
+ "README.md"
37
+ ],
38
+ "peerDependencies": {
39
+ "@tarve/core": "^0.1.0"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "@tarve/core": {
43
+ "optional": true
44
+ }
45
+ },
46
+ "engines": {
47
+ "bun": ">=1.4.0"
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { reactSvgAdapter, reactSvgElementToTarve } from "./react-svg";
2
+ export { lucideReactAdapter } from "./lucide";
3
+ export { phosphorReactAdapter } from "./phosphor";
package/src/lucide.ts ADDED
@@ -0,0 +1,104 @@
1
+ import { REACT_FORWARD_REF, finiteNumber, reactSvgElementToTarve, record, type ComponentAdapter, type SvgAttributes, type SvgNode } from "./react-svg";
2
+
3
+ const SVG_ELEMENTS = new Set(["path", "circle", "ellipse", "g", "line", "polygon", "polyline", "rect"]);
4
+ type LucideData = {
5
+ node: readonly unknown[];
6
+ size?: number;
7
+ width?: number;
8
+ height?: number;
9
+ };
10
+
11
+ function normalizeNode(value: unknown, nonScalingStroke: boolean): SvgNode | undefined {
12
+ if (!Array.isArray(value) || value.length < 2) return undefined;
13
+ const [tag, rawAttrs, rawChildren] = value;
14
+ if (typeof tag !== "string" || !SVG_ELEMENTS.has(tag)) return undefined;
15
+ const attrsRecord = record(rawAttrs);
16
+ if (!attrsRecord) return undefined;
17
+ const attrs: SvgAttributes = {};
18
+ for (const [name, rawValue] of Object.entries(attrsRecord)) {
19
+ if (name === "key") continue;
20
+ if (typeof rawValue === "string" || typeof rawValue === "number") attrs[name] = rawValue;
21
+ }
22
+ if (nonScalingStroke) attrs.vectorEffect = "non-scaling-stroke";
23
+
24
+ if (rawChildren === undefined) return [tag as SvgNode[0], attrs];
25
+ if (!Array.isArray(rawChildren)) return undefined;
26
+ const children: SvgNode[] = [];
27
+ for (const child of rawChildren) {
28
+ const normalized = normalizeNode(child, nonScalingStroke);
29
+ if (!normalized) return undefined;
30
+ children.push(normalized);
31
+ }
32
+ return [tag as SvgNode[0], attrs, children];
33
+ }
34
+
35
+ function lucideData(value: unknown): LucideData | undefined {
36
+ const candidate = record(value);
37
+ if (!candidate || !Array.isArray(candidate.node)) return undefined;
38
+ return {
39
+ node: candidate.node,
40
+ size: finiteNumber(candidate.size),
41
+ width: finiteNumber(candidate.width),
42
+ height: finiteNumber(candidate.height),
43
+ };
44
+ }
45
+
46
+ export const lucideReactAdapter: ComponentAdapter = ({ type, props }) => {
47
+ const component = record(type);
48
+ if (component?.$$typeof !== REACT_FORWARD_REF || typeof component.render !== "function") return undefined;
49
+
50
+ let rendered: unknown;
51
+ try {
52
+ rendered = (component.render as (props: Record<string, unknown>, ref: null) => unknown)({ ...props }, null);
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ const renderedProps = record(record(rendered)?.props);
57
+ const icon = lucideData(renderedProps?.icon);
58
+ if (!renderedProps || !icon) return undefined;
59
+
60
+ const nodes: SvgNode[] = [];
61
+ for (const child of icon.node) {
62
+ const node = normalizeNode(child, renderedProps.nonScalingStroke === true);
63
+ if (!node) return undefined;
64
+ nodes.push(node);
65
+ }
66
+
67
+ const baseWidth = icon.size ?? icon.width ?? 24;
68
+ const baseHeight = icon.size ?? icon.height ?? 24;
69
+ const size = finiteNumber(renderedProps.size);
70
+ const width = finiteNumber(renderedProps.width) ?? size ?? baseWidth;
71
+ const height = finiteNumber(renderedProps.height) ?? size ?? baseHeight;
72
+ const requestedStrokeWidth = finiteNumber(renderedProps.strokeWidth) ?? 2;
73
+ const strokeWidth = renderedProps.absoluteStrokeWidth === true
74
+ ? requestedStrokeWidth * baseWidth / (size ?? width)
75
+ : requestedStrokeWidth;
76
+ const explicitColor = typeof renderedProps.color === "string" ? renderedProps.color : undefined;
77
+
78
+ return reactSvgElementToTarve({
79
+ type: "svg",
80
+ props: {
81
+ ...(typeof renderedProps.id === "string" ? { id: renderedProps.id } : {}),
82
+ width,
83
+ height,
84
+ viewBox: `0 0 ${baseWidth} ${baseHeight}`,
85
+ color: explicitColor ?? "currentColor",
86
+ fill: typeof renderedProps.fill === "string" ? renderedProps.fill : "none",
87
+ stroke: typeof renderedProps.stroke === "string" ? renderedProps.stroke : "currentColor",
88
+ strokeWidth,
89
+ strokeLinecap: renderedProps.strokeLinecap === "butt" || renderedProps.strokeLinecap === "square" ? renderedProps.strokeLinecap : "round",
90
+ strokeLinejoin: renderedProps.strokeLinejoin === "miter" || renderedProps.strokeLinejoin === "bevel" ? renderedProps.strokeLinejoin : "round",
91
+ children: nodes.map(([tag, attrs, children]) => ({
92
+ type: tag,
93
+ props: { ...attrs, ...(children ? { children: toReactLike(children) } : {}) },
94
+ })),
95
+ },
96
+ });
97
+ };
98
+
99
+ function toReactLike(nodes: readonly SvgNode[]): unknown[] {
100
+ return nodes.map(([tag, attrs, children]) => ({
101
+ type: tag,
102
+ props: { ...attrs, ...(children ? { children: toReactLike(children) } : {}) },
103
+ }));
104
+ }
@@ -0,0 +1,39 @@
1
+ import { REACT_FORWARD_REF, finiteNumber, reactSvgElementToTarve, record, type ComponentAdapter } from "./react-svg";
2
+
3
+ const WEIGHTS = new Set(["thin", "light", "regular", "bold", "fill", "duotone"]);
4
+
5
+ export const phosphorReactAdapter: ComponentAdapter = ({ type, props }) => {
6
+ const component = record(type);
7
+ if (component?.$$typeof !== REACT_FORWARD_REF || typeof component.render !== "function") return undefined;
8
+
9
+ let rendered: unknown;
10
+ try {
11
+ rendered = (component.render as (props: Record<string, unknown>, ref: null) => unknown)({ ...props }, null);
12
+ } catch {
13
+ return undefined;
14
+ }
15
+ const renderedProps = record(record(rendered)?.props);
16
+ if (!renderedProps || !(renderedProps.weights instanceof Map)) return undefined;
17
+ const requestedWeight = typeof renderedProps.weight === "string" && WEIGHTS.has(renderedProps.weight)
18
+ ? renderedProps.weight
19
+ : "regular";
20
+ const weightNode = renderedProps.weights.get(requestedWeight);
21
+ if (!weightNode) return undefined;
22
+ const size = finiteNumber(renderedProps.size) ?? 24;
23
+ const color = typeof renderedProps.color === "string" ? renderedProps.color : "currentColor";
24
+ const mirrored = renderedProps.mirrored === true;
25
+
26
+ return reactSvgElementToTarve({
27
+ type: "svg",
28
+ props: {
29
+ ...(typeof renderedProps.id === "string" ? { id: renderedProps.id } : {}),
30
+ width: size,
31
+ height: size,
32
+ viewBox: "0 0 256 256",
33
+ fill: color,
34
+ color,
35
+ ...(mirrored ? { transform: "translate(256 0) scale(-1 1)" } : {}),
36
+ children: [renderedProps.children, weightNode],
37
+ },
38
+ });
39
+ };
@@ -0,0 +1,175 @@
1
+ export type SvgElementName = "path" | "circle" | "ellipse" | "g" | "line" | "polygon" | "polyline" | "rect";
2
+ export type SvgAttributes = Record<string, string | number>;
3
+ export type SvgNode =
4
+ | readonly [SvgElementName, SvgAttributes]
5
+ | readonly [SvgElementName, SvgAttributes, readonly SvgNode[]];
6
+ export interface TarveVNode {
7
+ type: string | ((props: Record<string, any>) => TarveVNode);
8
+ props: Record<string, any>;
9
+ key?: string | number;
10
+ }
11
+ export interface ComponentAdapterInput {
12
+ type: unknown;
13
+ props: Readonly<Record<string, unknown>>;
14
+ key?: string | number;
15
+ }
16
+ export type ComponentAdapter = (component: ComponentAdapterInput) => TarveVNode | undefined;
17
+
18
+ export const REACT_FORWARD_REF = Symbol.for("react.forward_ref");
19
+ const REACT_FRAGMENT = Symbol.for("react.fragment");
20
+ const SVG_ELEMENTS = new Set(["path", "circle", "ellipse", "g", "line", "polygon", "polyline", "rect"]);
21
+
22
+ export type RecordLike = Record<PropertyKey, unknown>;
23
+
24
+ export function record(value: unknown): RecordLike | undefined {
25
+ return value !== null && typeof value === "object" ? value as RecordLike : undefined;
26
+ }
27
+
28
+ export function finiteNumber(value: unknown): number | undefined {
29
+ if (typeof value === "number" && Number.isFinite(value)) return value;
30
+ if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) return Number(value);
31
+ return undefined;
32
+ }
33
+
34
+ function normalizeAttributes(value: unknown): SvgAttributes | undefined {
35
+ const source = record(value);
36
+ if (!source) return undefined;
37
+ const attrs: SvgAttributes = {};
38
+ for (const [name, rawValue] of Object.entries(source)) {
39
+ if (name === "key" || name === "ref" || name === "children" || name === "className") continue;
40
+ if (typeof rawValue === "string" || typeof rawValue === "number") attrs[name] = rawValue;
41
+ }
42
+ return attrs;
43
+ }
44
+
45
+ function collectReactSvgNodes(value: unknown, result: SvgNode[]): boolean {
46
+ if (value == null || typeof value === "boolean") return true;
47
+ if (Array.isArray(value)) return value.every(child => collectReactSvgNodes(child, result));
48
+ const element = record(value);
49
+ if (!element) return false;
50
+ const type = element.type;
51
+ const props = record(element.props);
52
+ if (!props) return false;
53
+ if (type === REACT_FRAGMENT) return collectReactSvgNodes(props.children, result);
54
+ if (type === "title") return true;
55
+ if (typeof type !== "string" || !SVG_ELEMENTS.has(type)) return false;
56
+ const attrs = normalizeAttributes(props);
57
+ if (!attrs) return false;
58
+ const children: SvgNode[] = [];
59
+ if (!collectReactSvgNodes(props.children, children)) return false;
60
+ result.push(children.length
61
+ ? [type as SvgNode[0], attrs, children]
62
+ : [type as SvgNode[0], attrs]);
63
+ return true;
64
+ }
65
+
66
+ function linecap(value: unknown): "butt" | "round" | "square" {
67
+ return value === "round" || value === "square" ? value : "butt";
68
+ }
69
+
70
+ function linejoin(value: unknown): "miter" | "round" | "bevel" {
71
+ return value === "round" || value === "bevel" ? value : "miter";
72
+ }
73
+
74
+ function xmlEscape(value: string | number): string {
75
+ return String(value)
76
+ .replaceAll("&", "&amp;")
77
+ .replaceAll("\"", "&quot;")
78
+ .replaceAll("<", "&lt;")
79
+ .replaceAll(">", "&gt;");
80
+ }
81
+
82
+ function svgAttributeName(name: string): string {
83
+ return name.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`);
84
+ }
85
+
86
+ function serializeSvgNode([tag, attrs, children]: SvgNode): string {
87
+ const attributes = Object.entries(attrs)
88
+ .filter(([name]) => name !== "key")
89
+ .map(([name, value]) => `${svgAttributeName(name)}="${xmlEscape(value)}"`)
90
+ .join(" ");
91
+ if (children?.length) {
92
+ return `<${tag}${attributes ? ` ${attributes}` : ""}>${children.map(serializeSvgNode).join("")}</${tag}>`;
93
+ }
94
+ return `<${tag}${attributes ? ` ${attributes}` : ""}/>`;
95
+ }
96
+
97
+ function createSvgVNode(options: {
98
+ id?: string;
99
+ width: number;
100
+ height: number;
101
+ viewBox: string;
102
+ color: string;
103
+ fill: string;
104
+ stroke: string;
105
+ strokeWidth: number;
106
+ strokeLinecap: "butt" | "round" | "square";
107
+ strokeLinejoin: "miter" | "round" | "bevel";
108
+ nodes: readonly SvgNode[];
109
+ }): TarveVNode {
110
+ const viewBox = options.viewBox.trim().split(/[\s,]+/).map(Number);
111
+ if (viewBox.length !== 4 || viewBox.some(value => !Number.isFinite(value)) || !(viewBox[2] > 0) || !(viewBox[3] > 0)) {
112
+ throw new TypeError(`Invalid SVG viewBox: ${options.viewBox}`);
113
+ }
114
+ const svg = [
115
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${viewBox[2]}" height="${viewBox[3]}"`,
116
+ ` viewBox="${viewBox.join(" ")}" color="#000000" data-tarve-current-color="1"`,
117
+ ` fill="${xmlEscape(options.fill)}" stroke="${xmlEscape(options.stroke)}" stroke-width="${options.strokeWidth}"`,
118
+ ` stroke-linecap="${options.strokeLinecap}" stroke-linejoin="${options.strokeLinejoin}">`,
119
+ options.nodes.map(serializeSvgNode).join(""),
120
+ "</svg>",
121
+ ].join("");
122
+ return {
123
+ type: "svg",
124
+ props: {
125
+ ...(options.id ? { id: options.id } : {}),
126
+ style: { width: options.width, height: options.height, shrink: 0, foreground: options.color },
127
+ svg,
128
+ },
129
+ };
130
+ }
131
+
132
+ export function reactSvgElementToTarve(value: unknown): TarveVNode | undefined {
133
+ const element = record(value);
134
+ const props = record(element?.props);
135
+ if (element?.type !== "svg" || !props) return undefined;
136
+
137
+ const nodes: SvgNode[] = [];
138
+ if (!collectReactSvgNodes(props.children, nodes)) return undefined;
139
+ const transform = typeof props.transform === "string" ? props.transform : undefined;
140
+ const drawableNodes: readonly SvgNode[] = transform ? [["g", { transform }, nodes]] : nodes;
141
+ const viewBox = typeof props.viewBox === "string" ? props.viewBox : "0 0 24 24";
142
+ const viewBoxParts = viewBox.trim().split(/[\s,]+/).map(Number);
143
+ const intrinsicWidth = viewBoxParts.length === 4 && Number.isFinite(viewBoxParts[2]) ? viewBoxParts[2] : 24;
144
+ const intrinsicHeight = viewBoxParts.length === 4 && Number.isFinite(viewBoxParts[3]) ? viewBoxParts[3] : 24;
145
+ const width = finiteNumber(props.width) ?? intrinsicWidth;
146
+ const height = finiteNumber(props.height) ?? intrinsicHeight;
147
+ const explicitColor = typeof props.color === "string" ? props.color : undefined;
148
+
149
+ return createSvgVNode({
150
+ ...(typeof props.id === "string" ? { id: props.id } : {}),
151
+ width,
152
+ height,
153
+ viewBox,
154
+ color: explicitColor && explicitColor !== "currentColor" ? explicitColor : "var(--foreground)",
155
+ fill: typeof props.fill === "string" ? props.fill : "black",
156
+ stroke: typeof props.stroke === "string" ? props.stroke : "none",
157
+ strokeWidth: finiteNumber(props.strokeWidth) ?? 1,
158
+ strokeLinecap: linecap(props.strokeLinecap),
159
+ strokeLinejoin: linejoin(props.strokeLinejoin),
160
+ nodes: drawableNodes,
161
+ });
162
+ }
163
+
164
+ /** Generic adapter for React icon components whose forwardRef renders a static <svg>. */
165
+ export const reactSvgAdapter: ComponentAdapter = ({ type, props }) => {
166
+ const component = record(type);
167
+ if (component?.$$typeof !== REACT_FORWARD_REF || typeof component.render !== "function") return undefined;
168
+ try {
169
+ return reactSvgElementToTarve(
170
+ (component.render as (props: Record<string, unknown>, ref: null) => unknown)({ ...props }, null),
171
+ );
172
+ } catch {
173
+ return undefined;
174
+ }
175
+ };