@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/README.md +56 -0
- package/icons.json +1 -0
- package/package.json +39 -0
- package/src/index.mjs +308 -0
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 })
|