@keyline-icons/cli 0.1.0

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,39 @@
1
+ {
2
+ "name": "@keyline-icons/cli",
3
+ "version": "0.1.0",
4
+ "description": "Search Keyline Icons and copy them into your project from the terminal.",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "author": "Keyline Icons (https://keylineicons.com)",
10
+ "homepage": "https://keylineicons.com",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/keyline-icons/keyline-icons.git",
14
+ "directory": "packages/cli"
15
+ },
16
+ "bugs": "https://github.com/keyline-icons/keyline-icons/issues",
17
+ "keywords": [
18
+ "icons",
19
+ "svg",
20
+ "cli",
21
+ "shadcn",
22
+ "icon-set"
23
+ ],
24
+ "type": "module",
25
+ "bin": {
26
+ "keyline-icons": "./src/index.mjs"
27
+ },
28
+ "exports": {
29
+ "./package.json": "./package.json"
30
+ },
31
+ "files": [
32
+ "src",
33
+ "icons.json",
34
+ "README.md"
35
+ ],
36
+ "engines": {
37
+ "node": ">=20.9.0"
38
+ }
39
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,308 @@
1
+ #!/usr/bin/env node
2
+ // The Keyline Icons CLI.
3
+ //
4
+ // npx @keyline-icons/cli search arrow
5
+ // npx @keyline-icons/cli add circle-arrow-down --style fill --out src/icons
6
+ // npx @keyline-icons/cli show check | pbcopy
7
+ //
8
+ // No dependencies. An argument parser and a colour helper are twenty lines
9
+ // each, and a CLI whose whole job is copying files out of a bundle should not
10
+ // pull a dependency tree in to do it.
11
+ //
12
+ // `show` and `list` write only their payload to stdout, so they pipe. Every
13
+ // heading, count and hint goes to stderr, which is what keeps
14
+ // `keyline-icons show check > check.svg` from writing a file with a banner
15
+ // stuck to the top of it.
16
+
17
+ import { mkdir, writeFile } from "node:fs/promises"
18
+ import { readFile } from "node:fs/promises"
19
+ import { dirname, join, resolve } from "node:path"
20
+ import { existsSync } from "node:fs"
21
+
22
+ const data = JSON.parse(
23
+ await readFile(new URL("../icons.json", import.meta.url), "utf8")
24
+ )
25
+ const { icons, styles } = data
26
+ const NAMES = Object.keys(icons)
27
+ const VERSION = "0.1.0"
28
+
29
+ /** Colour only when a human is looking. Piped output stays clean. */
30
+ const tty = process.stdout.isTTY
31
+ const c = (n, s) => (tty ? `\x1b[${n}m${s}\x1b[0m` : s)
32
+ const dim = (s) => c(2, s)
33
+ const bold = (s) => c(1, s)
34
+ const red = (s) => c(31, s)
35
+ const green = (s) => c(32, s)
36
+
37
+ const out = (s) => process.stdout.write(s + "\n")
38
+ const note = (s) => process.stderr.write(s + "\n")
39
+
40
+ function die(msg) {
41
+ note(`${red("error")} ${msg}`)
42
+ process.exit(1)
43
+ }
44
+
45
+ /* ------------------------------------------------------------------- args */
46
+
47
+ /**
48
+ * Supports `--style fill`, `--style=fill` and `-s fill`. Anything left over is
49
+ * positional, which is what lets `add a b c --style fill` work in any order.
50
+ */
51
+ function parseArgs(argv) {
52
+ const alias = { s: "style", o: "out", l: "limit", h: "help", v: "version" }
53
+ const flags = {}
54
+ const positional = []
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const a = argv[i]
57
+ if (a === "--") {
58
+ positional.push(...argv.slice(i + 1))
59
+ break
60
+ }
61
+ if (a.startsWith("--") || (a.startsWith("-") && a.length === 2)) {
62
+ const raw = a.replace(/^--?/, "")
63
+ const [k, inline] = raw.includes("=")
64
+ ? raw.split(/=(.*)/)
65
+ : [raw, undefined]
66
+ const key = alias[k] ?? k
67
+ if (inline !== undefined) flags[key] = inline
68
+ else if (key === "help" || key === "version") flags[key] = true
69
+ else if (argv[i + 1] && !argv[i + 1].startsWith("-"))
70
+ flags[key] = argv[++i]
71
+ else flags[key] = true
72
+ } else positional.push(a)
73
+ }
74
+ return { flags, positional }
75
+ }
76
+
77
+ const styleOf = (flags) => {
78
+ const s = flags.style ?? "stroke"
79
+ if (!styles.includes(s))
80
+ die(`unknown style \`${s}\`. One of: ${styles.join(", ")}`)
81
+ return s
82
+ }
83
+
84
+ /* ------------------------------------------------------------------ icons */
85
+
86
+ function svgFor(name, style) {
87
+ const art = icons[name]?.[style]
88
+ if (!art) return null
89
+ const attrs = Object.entries(art.root)
90
+ .map(([k, v]) => ` ${k}="${v}"`)
91
+ .join("")
92
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"${attrs}>${art.body}</svg>\n`
93
+ }
94
+
95
+ /** Kept in step with `@keyline-icons/mcp`, which carries the reasoning. */
96
+ function wordsOf(query) {
97
+ const identifier = /[a-z][A-Z]/.test(query)
98
+ const split = identifier
99
+ ? query
100
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
101
+ .replace(/([a-zA-Z])(\d)/g, "$1 $2")
102
+ : query
103
+ return split
104
+ .toLowerCase()
105
+ .split(/[^a-z0-9]+/)
106
+ .filter((w) => w && !(identifier && /^\d+$/.test(w)))
107
+ }
108
+
109
+ /**
110
+ * Matched whole first, then word by word, so a name in another set's word order
111
+ * still lands: compounds here read base-first, and lucide's `CheckCircle2` asks
112
+ * for "check-circle" when the icon is `circle-check`. Kept in step with the
113
+ * copy in `@keyline-icons/mcp`, which carries the full reasoning.
114
+ */
115
+ function search(query, style, limit) {
116
+ const q = query.toLowerCase().trim()
117
+ const words = wordsOf(query)
118
+ if (!words.length) return []
119
+
120
+ return NAMES.filter(
121
+ (n) =>
122
+ (!style || icons[n][style]) &&
123
+ (n.includes(q) || words.every((w) => n.includes(w)))
124
+ )
125
+ .map((n) => {
126
+ const i = n.indexOf(q)
127
+ const rank =
128
+ i === -1
129
+ ? 4
130
+ : n === q
131
+ ? 0
132
+ : n.startsWith(q)
133
+ ? 1
134
+ : /(^|-)/.test(n[i - 1] ?? "-")
135
+ ? 2
136
+ : 3
137
+ return { n, rank }
138
+ })
139
+ .sort(
140
+ (a, b) =>
141
+ a.rank - b.rank || a.n.length - b.n.length || a.n.localeCompare(b.n)
142
+ )
143
+ .slice(0, limit)
144
+ .map((h) => h.n)
145
+ }
146
+
147
+ /** Only reached when a name misses, so it can afford to walk every name. */
148
+ function nearest(name) {
149
+ const q = name.toLowerCase()
150
+ const hit = search(q, null, 5)
151
+ if (hit.length) return hit
152
+ const d = (a, b) => {
153
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
154
+ for (let i = 1; i <= a.length; i++) {
155
+ const cur = [i]
156
+ for (let j = 1; j <= b.length; j++)
157
+ cur[j] = Math.min(
158
+ prev[j] + 1,
159
+ cur[j - 1] + 1,
160
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
161
+ )
162
+ prev = cur
163
+ }
164
+ return prev[b.length]
165
+ }
166
+ const head = q.split("-")[0]
167
+ return NAMES.map((n) => ({
168
+ n,
169
+ d: Math.min(d(q, n), d(head, n.split("-")[0]) + 0.5),
170
+ }))
171
+ .filter(({ d }) => d <= 2)
172
+ .sort((a, b) => a.d - b.d || a.n.length - b.n.length)
173
+ .slice(0, 5)
174
+ .map((h) => h.n)
175
+ }
176
+
177
+ function resolveOrDie(name, style) {
178
+ if (!icons[name]) {
179
+ const near = nearest(name)
180
+ die(
181
+ `no icon named \`${name}\`.${near.length ? ` Did you mean: ${near.join(", ")}?` : ""}`
182
+ )
183
+ }
184
+ const svg = svgFor(name, style)
185
+ if (!svg) {
186
+ die(
187
+ `\`${name}\` has no ${style} style. It has: ${Object.keys(icons[name]).join(", ")}.\n` +
188
+ ` Duotone and fill need a fillable region, and this glyph has none.`
189
+ )
190
+ }
191
+ return svg
192
+ }
193
+
194
+ /* --------------------------------------------------------------- commands */
195
+
196
+ const HELP = `${bold("keyline-icons")} ${dim(VERSION)} ${data.count} icons, MIT licensed
197
+
198
+ ${bold("USAGE")}
199
+ keyline-icons <command> [options]
200
+
201
+ ${bold("COMMANDS")}
202
+ search <query> Find icons by name
203
+ show <name> Print one icon's SVG to stdout
204
+ add <name...> Write icons into a directory
205
+ list Print every icon name
206
+ help This
207
+
208
+ ${bold("OPTIONS")}
209
+ -s, --style <s> ${styles.join(" | ")} (default: stroke)
210
+ -o, --out <dir> Where \`add\` writes (default: ./icons)
211
+ -l, --limit <n> Max results for \`search\` (default: 25)
212
+
213
+ ${bold("EXAMPLES")}
214
+ keyline-icons search arrow
215
+ keyline-icons search mail --style fill
216
+ keyline-icons show check > check.svg
217
+ keyline-icons add circle-arrow-down bell --out src/icons
218
+ keyline-icons list | grep chart
219
+
220
+ ${dim("Every icon takes its colour from currentColor.")}
221
+ ${dim("https://keylineicons.com")}
222
+ `
223
+
224
+ const commands = {
225
+ help: () => note(HELP),
226
+
227
+ version: () => out(VERSION),
228
+
229
+ list({ flags }) {
230
+ const style = flags.style ? styleOf(flags) : null
231
+ const names = style ? NAMES.filter((n) => icons[n][style]) : NAMES
232
+ for (const n of names) out(n)
233
+ note(dim(`\n${names.length} icons${style ? ` in ${style}` : ""}`))
234
+ },
235
+
236
+ search({ flags, positional }) {
237
+ const query = positional.join(" ")
238
+ if (!query) die("search needs a query. Try: keyline-icons search arrow")
239
+ const style = flags.style ? styleOf(flags) : null
240
+ const limit = Math.min(
241
+ Math.max(parseInt(flags.limit ?? "25", 10) || 25, 1),
242
+ 200
243
+ )
244
+ const hits = search(query, style, limit)
245
+ if (!hits.length) {
246
+ const near = nearest(query)
247
+ die(
248
+ `nothing matches \`${query}\`.${near.length ? ` Did you mean: ${near.join(", ")}?` : ""}`
249
+ )
250
+ }
251
+ for (const n of hits) {
252
+ const has = styles.filter((s) => icons[n][s])
253
+ out(`${n}${tty ? " " + dim(`[${has.join(", ")}]`) : ""}`)
254
+ }
255
+ note(dim(`\n${hits.length} shown${style ? ` in ${style}` : ""}`))
256
+ },
257
+
258
+ show({ flags, positional }) {
259
+ const [name] = positional
260
+ if (!name) die("show needs an icon name. Try: keyline-icons show check")
261
+ process.stdout.write(resolveOrDie(name, styleOf(flags)))
262
+ },
263
+
264
+ async add({ flags, positional }) {
265
+ if (!positional.length) die("add needs at least one icon name.")
266
+ const style = styleOf(flags)
267
+ const dir = resolve(flags.out === true || !flags.out ? "icons" : flags.out)
268
+
269
+ // Every name is resolved before anything is written, so a typo in the
270
+ // third of five does not leave two files on disk and an error.
271
+ const files = positional.map((name) => [name, resolveOrDie(name, style)])
272
+
273
+ await mkdir(dir, { recursive: true })
274
+ for (const [name, svg] of files) {
275
+ const path = join(dir, `${name}.svg`)
276
+ const existed = existsSync(path)
277
+ await writeFile(path, svg, "utf8")
278
+ note(
279
+ ` ${existed ? c(33, "overwrote") : green("wrote")} ${join(dirname(path), `${name}.svg`)}`
280
+ )
281
+ }
282
+ note(
283
+ dim(
284
+ `\n${files.length} ${style} icon${files.length === 1 ? "" : "s"} to ${dir}`
285
+ )
286
+ )
287
+ },
288
+ }
289
+
290
+ /* ------------------------------------------------------------------- main */
291
+
292
+ const argv = process.argv.slice(2)
293
+ const { flags, positional } = parseArgs(argv)
294
+
295
+ if (flags.version) {
296
+ commands.version()
297
+ process.exit(0)
298
+ }
299
+ if (flags.help || !positional.length) {
300
+ commands.help()
301
+ process.exit(positional.length ? 0 : 1)
302
+ }
303
+
304
+ const name = positional.shift()
305
+ const cmd = commands[name]
306
+ if (!cmd) die(`unknown command \`${name}\`. Try: keyline-icons help`)
307
+
308
+ await cmd({ flags, positional })