@keyline-icons/cli 0.1.4 → 0.3.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/icons.json +1 -1
- package/package.json +1 -1
- package/src/index.mjs +104 -32
package/package.json
CHANGED
package/src/index.mjs
CHANGED
|
@@ -22,9 +22,11 @@ import { existsSync } from "node:fs"
|
|
|
22
22
|
const data = JSON.parse(
|
|
23
23
|
await readFile(new URL("../icons.json", import.meta.url), "utf8")
|
|
24
24
|
)
|
|
25
|
-
|
|
25
|
+
// `corners` defaults for a bundle generated before the axis existed, so an
|
|
26
|
+
// older icons.json still runs rather than reading `undefined.includes`.
|
|
27
|
+
const { icons, styles, corners = ["regular"], keywords = {} } = data
|
|
26
28
|
const NAMES = Object.keys(icons)
|
|
27
|
-
const VERSION = "0.
|
|
29
|
+
const VERSION = "0.3.0"
|
|
28
30
|
|
|
29
31
|
/** Colour only when a human is looking. Piped output stays clean. */
|
|
30
32
|
const tty = process.stdout.isTTY
|
|
@@ -49,7 +51,7 @@ function die(msg) {
|
|
|
49
51
|
* positional, which is what lets `add a b c --style fill` work in any order.
|
|
50
52
|
*/
|
|
51
53
|
function parseArgs(argv) {
|
|
52
|
-
const alias = { s: "style", o: "out", l: "limit", h: "help", v: "version" }
|
|
54
|
+
const alias = { s: "style", c: "corners", o: "out", l: "limit", h: "help", v: "version" }
|
|
53
55
|
const flags = {}
|
|
54
56
|
const positional = []
|
|
55
57
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -89,10 +91,25 @@ const styleOf = (flags) => {
|
|
|
89
91
|
return s
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
const cornersOf = (flags) => {
|
|
95
|
+
const k = flags.corners ?? "regular"
|
|
96
|
+
if (!corners.includes(k))
|
|
97
|
+
die(`unknown corner treatment \`${k}\`. One of: ${corners.join(", ")}`)
|
|
98
|
+
return k
|
|
99
|
+
}
|
|
100
|
+
|
|
92
101
|
/* ------------------------------------------------------------------ icons */
|
|
93
102
|
|
|
94
|
-
|
|
95
|
-
|
|
103
|
+
/**
|
|
104
|
+
* A drawing in one style and one treatment.
|
|
105
|
+
*
|
|
106
|
+
* The rounded half sits at `icons[name][style]` where it has always sat, and
|
|
107
|
+
* the sharp half one level down under `sharp`, so a project pinned to an older
|
|
108
|
+
* bundle reads the same shape it read before.
|
|
109
|
+
*/
|
|
110
|
+
function svgFor(name, style, corners = "regular") {
|
|
111
|
+
const art =
|
|
112
|
+
corners === "sharp" ? icons[name]?.sharp?.[style] : icons[name]?.[style]
|
|
96
113
|
if (!art) return null
|
|
97
114
|
const attrs = Object.entries(art.root)
|
|
98
115
|
.map(([k, v]) => ` ${k}="${v}"`)
|
|
@@ -142,32 +159,71 @@ function keywordsFor(name) {
|
|
|
142
159
|
|
|
143
160
|
const haystackFor = (name) => `${name} ${keywordsFor(name).join(" ")}`
|
|
144
161
|
|
|
145
|
-
|
|
162
|
+
/**
|
|
163
|
+
* What another set calls a drawing here: `message-square` for `message`,
|
|
164
|
+
* `arrow-big-up` for `arrow-up`, `loader-2` for `loader`.
|
|
165
|
+
*
|
|
166
|
+
* Matched against the whole query and never split into words, which is the
|
|
167
|
+
* entire reason it is a map of its own rather than more keywords. Folded into
|
|
168
|
+
* the vocabulary instead, `message-square` puts "square" in the message icon's
|
|
169
|
+
* words, and a search for `square` answers with every message in the set —
|
|
170
|
+
* measured on the site's own grid, where the first row of a `square` search
|
|
171
|
+
* came back holding no squares at all.
|
|
172
|
+
*/
|
|
173
|
+
const foreign = data.names ?? {}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Whether `hay` carries `needle` as a whole word, hyphen or space delimited.
|
|
177
|
+
*
|
|
178
|
+
* The rule here used to be a plain substring, and a substring lands inside
|
|
179
|
+
* words that have nothing to do with the query: `box` matched `inbox`, `ad`
|
|
180
|
+
* matched `upload`, `car` matched `caret-up`, `monitor` matched `activity`
|
|
181
|
+
* through its words. Run another set's 1775 names through this and 140 came back
|
|
182
|
+
* holding an icon that shares no word with what was asked for. That reads as a
|
|
183
|
+
* hit, which is worse than an empty result twice over: it hides the gap from
|
|
184
|
+
* anyone counting, and it hands the caller a drawing they did not ask for.
|
|
185
|
+
*
|
|
186
|
+
* The site keeps the looser rule on purpose. It answers every keystroke, and
|
|
187
|
+
* someone typing `arro` has not finished the word yet. Nothing types here. An
|
|
188
|
+
* agent sends a whole name, and the honest answer to a name this set does not
|
|
189
|
+
* have is nothing at all.
|
|
190
|
+
*/
|
|
191
|
+
const wholeWord = (hay, needle) => {
|
|
192
|
+
for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + 1)) {
|
|
193
|
+
const before = hay[i - 1] ?? "-"
|
|
194
|
+
const after = hay[i + needle.length] ?? "-"
|
|
195
|
+
if ((before === "-" || before === " ") && (after === "-" || after === " "))
|
|
196
|
+
return true
|
|
197
|
+
}
|
|
198
|
+
return false
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function search(query, style, limit, corners = "regular") {
|
|
146
202
|
const q = query.toLowerCase().trim()
|
|
147
203
|
const words = wordsOf(query)
|
|
148
204
|
if (!words.length) return []
|
|
149
205
|
|
|
150
206
|
return NAMES.filter(
|
|
151
207
|
(n) =>
|
|
152
|
-
(!style ||
|
|
153
|
-
(
|
|
154
|
-
|
|
155
|
-
words.every((w) =>
|
|
208
|
+
(!style || svgFor(n, style, corners)) &&
|
|
209
|
+
(foreign[q] === n ||
|
|
210
|
+
wholeWord(n, q) ||
|
|
211
|
+
words.every((w) => wholeWord(n, w)) ||
|
|
212
|
+
words.every((w) => wholeWord(haystackFor(n), w)))
|
|
156
213
|
)
|
|
157
214
|
.map((n) => {
|
|
158
|
-
const i = n.indexOf(q)
|
|
159
215
|
const rank =
|
|
160
|
-
|
|
161
|
-
?
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
216
|
+
n === q
|
|
217
|
+
? 0
|
|
218
|
+
: foreign[q] === n
|
|
219
|
+
? 1
|
|
220
|
+
: n.startsWith(q + "-")
|
|
221
|
+
? 2
|
|
222
|
+
: wholeWord(n, q)
|
|
223
|
+
? 3
|
|
224
|
+
: words.every((w) => wholeWord(n, w))
|
|
225
|
+
? 4
|
|
226
|
+
: 5
|
|
171
227
|
return { n, rank }
|
|
172
228
|
})
|
|
173
229
|
.sort(
|
|
@@ -216,17 +272,20 @@ function nearest(name) {
|
|
|
216
272
|
.map((h) => h.n)
|
|
217
273
|
}
|
|
218
274
|
|
|
219
|
-
function resolveOrDie(name, style) {
|
|
275
|
+
function resolveOrDie(name, style, corners = "regular") {
|
|
220
276
|
if (!icons[name]) {
|
|
221
277
|
const near = nearest(name)
|
|
222
278
|
die(
|
|
223
279
|
`no icon named \`${name}\`.${near.length ? ` Did you mean: ${near.join(", ")}?` : ""}`
|
|
224
280
|
)
|
|
225
281
|
}
|
|
226
|
-
const svg = svgFor(name, style)
|
|
282
|
+
const svg = svgFor(name, style, corners)
|
|
227
283
|
if (!svg) {
|
|
284
|
+
// `Object.keys` would list `sharp` here as though it were a fourth style,
|
|
285
|
+
// which is the axis leaking into a sentence about coverage.
|
|
286
|
+
const has = styles.filter((s) => svgFor(name, s, corners))
|
|
228
287
|
die(
|
|
229
|
-
`\`${name}\` has no ${style} style. It has: ${
|
|
288
|
+
`\`${name}\` has no ${style} style. It has: ${has.join(", ")}.\n` +
|
|
230
289
|
` Duotone and fill need a fillable region, and this glyph has none.`
|
|
231
290
|
)
|
|
232
291
|
}
|
|
@@ -249,13 +308,14 @@ ${bold("COMMANDS")}
|
|
|
249
308
|
|
|
250
309
|
${bold("OPTIONS")}
|
|
251
310
|
-s, --style <s> ${styles.join(" | ")} (default: stroke)
|
|
311
|
+
-c, --corners <c> ${corners.join(" | ")} (default: regular)
|
|
252
312
|
-o, --out <dir> Where \`add\` writes (default: ./icons)
|
|
253
313
|
-l, --limit <n> Max results for \`search\` (default: 25)
|
|
254
314
|
|
|
255
315
|
${bold("EXAMPLES")}
|
|
256
316
|
keyline-icons search arrow
|
|
257
317
|
keyline-icons search mail --style fill
|
|
258
|
-
keyline-icons show check > check.svg
|
|
318
|
+
keyline-icons show check --corners sharp > check-sharp.svg
|
|
259
319
|
keyline-icons add circle-arrow-down bell --out src/icons
|
|
260
320
|
keyline-icons list | grep chart
|
|
261
321
|
|
|
@@ -270,7 +330,8 @@ const commands = {
|
|
|
270
330
|
|
|
271
331
|
list({ flags }) {
|
|
272
332
|
const style = flags.style ? styleOf(flags) : null
|
|
273
|
-
const
|
|
333
|
+
const treatment = cornersOf(flags)
|
|
334
|
+
const names = style ? NAMES.filter((n) => svgFor(n, style, treatment)) : NAMES
|
|
274
335
|
for (const n of names) out(n)
|
|
275
336
|
note(dim(`\n${names.length} icons${style ? ` in ${style}` : ""}`))
|
|
276
337
|
},
|
|
@@ -290,7 +351,7 @@ const commands = {
|
|
|
290
351
|
is not a number at all earns the default. */
|
|
291
352
|
const raw = parseInt(flags.limit ?? "25", 10)
|
|
292
353
|
const limit = Math.min(Math.max(Number.isNaN(raw) ? 25 : raw, 1), 200)
|
|
293
|
-
const hits = search(query, style, limit)
|
|
354
|
+
const hits = search(query, style, limit, cornersOf(flags))
|
|
294
355
|
if (!hits.length) {
|
|
295
356
|
const near = nearest(query)
|
|
296
357
|
die(
|
|
@@ -298,7 +359,7 @@ const commands = {
|
|
|
298
359
|
)
|
|
299
360
|
}
|
|
300
361
|
for (const n of hits) {
|
|
301
|
-
const has = styles.filter((s) =>
|
|
362
|
+
const has = styles.filter((s) => svgFor(n, s, cornersOf(flags)))
|
|
302
363
|
out(`${n}${tty ? " " + dim(`[${has.join(", ")}]`) : ""}`)
|
|
303
364
|
}
|
|
304
365
|
note(dim(`\n${hits.length} shown${style ? ` in ${style}` : ""}`))
|
|
@@ -307,17 +368,27 @@ const commands = {
|
|
|
307
368
|
show({ flags, positional }) {
|
|
308
369
|
const [name] = positional
|
|
309
370
|
if (!name) die("show needs an icon name. Try: keyline-icons show check")
|
|
310
|
-
process.stdout.write(resolveOrDie(name, styleOf(flags)))
|
|
371
|
+
process.stdout.write(resolveOrDie(name, styleOf(flags), cornersOf(flags)))
|
|
311
372
|
},
|
|
312
373
|
|
|
313
374
|
async add({ flags, positional }) {
|
|
314
375
|
if (!positional.length) die("add needs at least one icon name.")
|
|
315
376
|
const style = styleOf(flags)
|
|
377
|
+
const treatment = cornersOf(flags)
|
|
316
378
|
const dir = resolve(flags.out === true || !flags.out ? "icons" : flags.out)
|
|
317
379
|
|
|
380
|
+
// The treatment rides in the filename, for the reason the style does not:
|
|
381
|
+
// two treatments of one drawing are the same name and the same style, so
|
|
382
|
+
// adding both would otherwise write one file twice and leave whichever ran
|
|
383
|
+
// last. Rounded keeps the bare name, which is what everyone means by it.
|
|
384
|
+
const suffix = treatment === "regular" ? "" : `-${treatment}`
|
|
385
|
+
|
|
318
386
|
// Every name is resolved before anything is written, so a typo in the
|
|
319
387
|
// third of five does not leave two files on disk and an error.
|
|
320
|
-
const files = positional.map((name) => [
|
|
388
|
+
const files = positional.map((name) => [
|
|
389
|
+
`${name}${suffix}`,
|
|
390
|
+
resolveOrDie(name, style, treatment),
|
|
391
|
+
])
|
|
321
392
|
|
|
322
393
|
await mkdir(dir, { recursive: true })
|
|
323
394
|
for (const [name, svg] of files) {
|
|
@@ -330,7 +401,8 @@ const commands = {
|
|
|
330
401
|
}
|
|
331
402
|
note(
|
|
332
403
|
dim(
|
|
333
|
-
`\n${files.length} ${
|
|
404
|
+
`\n${files.length} ${treatment === "sharp" ? "sharp " : ""}${style} ` +
|
|
405
|
+
`icon${files.length === 1 ? "" : "s"} to ${dir}`
|
|
334
406
|
)
|
|
335
407
|
)
|
|
336
408
|
},
|