@gtk-js/icon-helpers 0.0.1

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/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@gtk-js/icon-helpers",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./build": "./src/build-helpers.ts"
9
+ },
10
+ "peerDependencies": {
11
+ "react": "^18 || ^19"
12
+ },
13
+ "devDependencies": {
14
+ "@types/react": "^19"
15
+ }
16
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Shared helpers for building GTK icon packages.
3
+ * Used by both @gtk-js/gtk4-icons and @gtk-js/adwaita-icons build scripts.
4
+ */
5
+
6
+ import { existsSync, mkdirSync, readdirSync, statSync } from "fs";
7
+
8
+ // Attributes to strip (GTK/namespace-specific)
9
+ const STRIP_ATTR_PREFIXES = ["gpa:", "xmlns:", "xml:", "cc:", "dc:", "rdf:"];
10
+ const STRIP_ATTRS = new Set(["id", "class", "xmlns"]);
11
+
12
+ export function toPascalCase(str: string): string {
13
+ return str
14
+ .replace(/-symbolic$/, "")
15
+ .replace(/[^a-zA-Z0-9-_]/g, "-") // replace non-alphanumeric chars with hyphens
16
+ .split(/[-_]+/)
17
+ .filter(Boolean)
18
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
19
+ .join("");
20
+ }
21
+
22
+ export function toKebabCase(str: string): string {
23
+ return str
24
+ .replace(/-symbolic$/, "")
25
+ .replace(/[^a-zA-Z0-9-]/g, "-") // replace non-alphanumeric chars with hyphens
26
+ .replace(/-+/g, "-") // collapse multiple hyphens
27
+ .replace(/^-|-$/g, ""); // trim leading/trailing hyphens
28
+ }
29
+
30
+ interface SvgChild {
31
+ tag: string;
32
+ attrs: Record<string, string>;
33
+ }
34
+
35
+ export function parseSvgChildren(svg: string): SvgChild[] {
36
+ const children: SvgChild[] = [];
37
+
38
+ const tagRegex = /<(path|circle|rect|ellipse|line|polyline|polygon)\s([^>]*?)\/?\s*>/g;
39
+ let match;
40
+
41
+ while ((match = tagRegex.exec(svg)) !== null) {
42
+ const tag = match[1]!;
43
+ const attrString = match[2]!;
44
+ const attrs: Record<string, string> = {};
45
+
46
+ const attrRegex = /([a-zA-Z][a-zA-Z0-9:_-]*)\s*=\s*"([^"]*)"/g;
47
+ let attrMatch;
48
+ while ((attrMatch = attrRegex.exec(attrString)) !== null) {
49
+ const name = attrMatch[1]!;
50
+ const value = attrMatch[2]!;
51
+
52
+ if (STRIP_ATTRS.has(name)) continue;
53
+ if (STRIP_ATTR_PREFIXES.some((p) => name.startsWith(p))) continue;
54
+
55
+ // Normalize fill colors to currentColor
56
+ if (name === "fill" && value !== "none") {
57
+ attrs[name] = "currentColor";
58
+ continue;
59
+ }
60
+
61
+ // Normalize stroke to currentColor
62
+ if (name === "stroke" && value !== "none") {
63
+ attrs[name] = "currentColor";
64
+ continue;
65
+ }
66
+
67
+ // Convert kebab-case attrs to camelCase for React
68
+ const reactName = name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
69
+ attrs[reactName] = value;
70
+ }
71
+
72
+ children.push({ tag, attrs });
73
+ }
74
+
75
+ return children;
76
+ }
77
+
78
+ /**
79
+ * Recursively find all .svg files in a directory.
80
+ */
81
+ export function findSvgFiles(dir: string): string[] {
82
+ const results: string[] = [];
83
+
84
+ for (const entry of readdirSync(dir)) {
85
+ const full = `${dir}/${entry}`;
86
+ if (statSync(full).isDirectory()) {
87
+ results.push(...findSvgFiles(full));
88
+ } else if (entry.endsWith(".svg")) {
89
+ results.push(full);
90
+ }
91
+ }
92
+
93
+ return results;
94
+ }
95
+
96
+ /**
97
+ * Build icon components from a directory of SVGs.
98
+ *
99
+ * @param iconsDir - directory containing SVG files (can be nested)
100
+ * @param outDir - output directory for generated .ts files
101
+ * @param createGtkIconImport - import path for createGtkIcon
102
+ * @returns array of export statements for index.ts
103
+ */
104
+ export async function buildIconComponents(
105
+ iconsDir: string,
106
+ outDir: string,
107
+ createGtkIconImport: string,
108
+ ): Promise<string[]> {
109
+ if (!existsSync(outDir)) {
110
+ mkdirSync(outDir, { recursive: true });
111
+ }
112
+
113
+ const svgFiles = findSvgFiles(iconsDir);
114
+ const exports: string[] = [];
115
+ const seenNames = new Set<string>();
116
+
117
+ for (const filePath of svgFiles) {
118
+ const fileName = filePath.split("/").pop()!;
119
+ const iconName = fileName.replace(".svg", "");
120
+ const kebabName = toKebabCase(iconName);
121
+ const pascalName = toPascalCase(iconName);
122
+
123
+ // Skip duplicates (same icon name in different subdirectories)
124
+ if (seenNames.has(kebabName)) continue;
125
+ seenNames.add(kebabName);
126
+
127
+ const svg = await Bun.file(filePath).text();
128
+ const children = parseSvgChildren(svg);
129
+
130
+ if (children.length === 0) {
131
+ continue;
132
+ }
133
+
134
+ const childrenLiteral = JSON.stringify(children.map((c) => [c.tag, c.attrs]));
135
+
136
+ const code = `import { createGtkIcon } from "${createGtkIconImport}";
137
+
138
+ export const ${pascalName} = createGtkIcon("${kebabName}", ${childrenLiteral});
139
+ `;
140
+
141
+ await Bun.write(`${outDir}/${kebabName}.ts`, code);
142
+ exports.push(`export { ${pascalName} } from "./icons/${kebabName}.ts";`);
143
+ }
144
+
145
+ return exports;
146
+ }
@@ -0,0 +1,52 @@
1
+ import {
2
+ createElement,
3
+ type ForwardRefExoticComponent,
4
+ forwardRef,
5
+ type RefAttributes,
6
+ type SVGProps,
7
+ } from "react";
8
+
9
+ export interface GtkIconProps extends SVGProps<SVGSVGElement> {
10
+ /** Icon size in pixels. Default: 16 (matches GTK's default symbolic icon size). */
11
+ size?: number | string;
12
+ }
13
+
14
+ export type GtkIcon = ForwardRefExoticComponent<
15
+ Omit<GtkIconProps, "ref"> & RefAttributes<SVGSVGElement>
16
+ >;
17
+
18
+ /**
19
+ * Factory to create a GTK icon React component from SVG child nodes.
20
+ *
21
+ * GTK symbolic icons use `fill="currentColor"` so they inherit the
22
+ * text color of their parent, matching native GTK behavior.
23
+ */
24
+ export function createGtkIcon(
25
+ displayName: string,
26
+ children: [tag: string, attrs: Record<string, string>][],
27
+ ): GtkIcon {
28
+ const Component = forwardRef<SVGSVGElement, GtkIconProps>(
29
+ ({ size = 16, className, ...props }, ref) => {
30
+ return createElement(
31
+ "svg",
32
+ {
33
+ ref,
34
+ xmlns: "http://www.w3.org/2000/svg",
35
+ width: size,
36
+ height: size,
37
+ viewBox: "0 0 16 16",
38
+ fill: "currentColor",
39
+ className: className
40
+ ? `gtk-icon gtk-icon-${displayName} ${className}`
41
+ : `gtk-icon gtk-icon-${displayName}`,
42
+ "aria-hidden": true,
43
+ ...props,
44
+ },
45
+ ...children.map(([tag, attrs], i) => createElement(tag, { key: i, ...attrs })),
46
+ );
47
+ },
48
+ );
49
+
50
+ Component.displayName = displayName;
51
+ return Component;
52
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export type { GtkIcon, GtkIconProps } from "./createGtkIcon.tsx";
2
+ export { createGtkIcon } from "./createGtkIcon.tsx";