@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.
Files changed (3) hide show
  1. package/icons.json +1 -1
  2. package/package.json +1 -1
  3. package/src/index.mjs +104 -32
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keyline-icons/cli",
3
- "version": "0.1.4",
3
+ "version": "0.3.0",
4
4
  "description": "Search Keyline Icons and copy them into your project from the terminal.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
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
- const { icons, styles, keywords = {} } = data
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.1.4"
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
- function svgFor(name, style) {
95
- const art = icons[name]?.[style]
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
- function search(query, style, limit) {
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 || icons[n][style]) &&
153
- (n.includes(q) ||
154
- words.every((w) => n.includes(w)) ||
155
- words.every((w) => haystackFor(n).includes(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
- i === -1
161
- ? words.every((w) => n.includes(w))
162
- ? 4
163
- : 5
164
- : n === q
165
- ? 0
166
- : n.startsWith(q)
167
- ? 1
168
- : /(^|-)/.test(n[i - 1] ?? "-")
169
- ? 2
170
- : 3
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: ${Object.keys(icons[name]).join(", ")}.\n` +
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 names = style ? NAMES.filter((n) => icons[n][style]) : NAMES
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) => icons[n][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) => [name, resolveOrDie(name, style)])
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} ${style} icon${files.length === 1 ? "" : "s"} to ${dir}`
404
+ `\n${files.length} ${treatment === "sharp" ? "sharp " : ""}${style} ` +
405
+ `icon${files.length === 1 ? "" : "s"} to ${dir}`
334
406
  )
335
407
  )
336
408
  },