@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 +16 -0
- package/src/build-helpers.ts +146 -0
- package/src/createGtkIcon.tsx +52 -0
- package/src/index.ts +2 -0
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