@keyline-icons/cli 0.2.0 → 0.4.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 +107 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keyline-icons/cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.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.2.0"
29
+ const VERSION = "0.4.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,6 +159,54 @@ function keywordsFor(name) {
142
159
 
143
160
  const haystackFor = (name) => `${name} ${keywordsFor(name).join(" ")}`
144
161
 
162
+ /**
163
+ * A word reduced to its singular, so a plural finds the family.
164
+ *
165
+ * Every name in the set is singular: `arrow-down`, `file`, `bar-chart`. Every
166
+ * heading on the site's category rail is plural. So "Arrows" sat on the screen
167
+ * while typing `arrows` returned one drawing out of 585, `git-compare-arrows`,
168
+ * the only name carrying the letter. `charts`, `stars` and `bells` returned
169
+ * nothing at all. The plural is what someone reads off our own sidebar and
170
+ * what every other set answers, and here it was the one word that could not
171
+ * work.
172
+ *
173
+ * Applied to both sides, the query word and each word of the haystack, which
174
+ * also carries the other direction: `chevron` reaches `chevrons-left`, which
175
+ * whole words alone could not.
176
+ *
177
+ * Deliberately blunt: no dictionary, no suffix ladder, one `s`. The guards are
178
+ * the whole design, and they are read off this vocabulary rather than guessed.
179
+ * Every `-ss`, `-us` and `-is` word in it is already a word: `progress`,
180
+ * `compass`, `plus`, `minus`, `status`, `axis`. Past those, `lens`, `atlas`
181
+ * and `sideways` are the only three that end in `s` without being a plural.
182
+ * `lens` is the one that mattered. Cut to `len` it lands inside `calendar` and
183
+ * `silence`, and the word someone types for the camera came back with
184
+ * seventeen drawings that are not it.
185
+ *
186
+ * `arrows` is in the list for the opposite reason. The rule would not damage
187
+ * it, it would erase it: the plural asks for the drawings carrying more than
188
+ * one arrowhead, `fullscreen`, `chevrons-up-down`, `refresh-cw`, and folding it
189
+ * into `arrow` answers with the ninety-nine single arrows instead. The word is
190
+ * a keyword on those drawings, in lib/icon-aliases.json, and this is what keeps
191
+ * the two questions apart.
192
+ *
193
+ * Same rule as the site, the MCP server and the Figma plugin, checked by
194
+ * pipeline/check-search.mjs.
195
+ */
196
+ const singular = (w) =>
197
+ w.length < 4 ||
198
+ !w.endsWith("s") ||
199
+ /(ss|us|is|arrows|lens|atlas|sideways)$/.test(w)
200
+ ? w
201
+ : w.endsWith("ies")
202
+ ? `${w.slice(0, -3)}y`
203
+ : /(ch|sh|x|z)es$/.test(w)
204
+ ? w.slice(0, -2)
205
+ : w.slice(0, -1)
206
+
207
+ /** Every word in `hay` singular, delimiters kept so `wholeWord` still works. */
208
+ const stemmed = (hay) => hay.replace(/[a-z0-9]+/g, singular)
209
+
145
210
  /**
146
211
  * What another set calls a drawing here: `message-square` for `message`,
147
212
  * `arrow-big-up` for `arrow-up`, `loader-2` for `loader`.
@@ -181,18 +246,24 @@ const wholeWord = (hay, needle) => {
181
246
  return false
182
247
  }
183
248
 
184
- function search(query, style, limit) {
249
+ /** Whether one haystack answers every word of a query, whole words only. */
250
+ const answers = (hay, words) =>
251
+ words.every(
252
+ (w) => wholeWord(hay, w) || wholeWord(stemmed(hay), singular(w))
253
+ )
254
+
255
+ function search(query, style, limit, corners = "regular") {
185
256
  const q = query.toLowerCase().trim()
186
257
  const words = wordsOf(query)
187
258
  if (!words.length) return []
188
259
 
189
260
  return NAMES.filter(
190
261
  (n) =>
191
- (!style || icons[n][style]) &&
262
+ (!style || svgFor(n, style, corners)) &&
192
263
  (foreign[q] === n ||
193
264
  wholeWord(n, q) ||
194
- words.every((w) => wholeWord(n, w)) ||
195
- words.every((w) => wholeWord(haystackFor(n), w)))
265
+ answers(n, words) ||
266
+ answers(haystackFor(n), words))
196
267
  )
197
268
  .map((n) => {
198
269
  const rank =
@@ -204,7 +275,7 @@ function search(query, style, limit) {
204
275
  ? 2
205
276
  : wholeWord(n, q)
206
277
  ? 3
207
- : words.every((w) => wholeWord(n, w))
278
+ : answers(n, words)
208
279
  ? 4
209
280
  : 5
210
281
  return { n, rank }
@@ -255,17 +326,20 @@ function nearest(name) {
255
326
  .map((h) => h.n)
256
327
  }
257
328
 
258
- function resolveOrDie(name, style) {
329
+ function resolveOrDie(name, style, corners = "regular") {
259
330
  if (!icons[name]) {
260
331
  const near = nearest(name)
261
332
  die(
262
333
  `no icon named \`${name}\`.${near.length ? ` Did you mean: ${near.join(", ")}?` : ""}`
263
334
  )
264
335
  }
265
- const svg = svgFor(name, style)
336
+ const svg = svgFor(name, style, corners)
266
337
  if (!svg) {
338
+ // `Object.keys` would list `sharp` here as though it were a fourth style,
339
+ // which is the axis leaking into a sentence about coverage.
340
+ const has = styles.filter((s) => svgFor(name, s, corners))
267
341
  die(
268
- `\`${name}\` has no ${style} style. It has: ${Object.keys(icons[name]).join(", ")}.\n` +
342
+ `\`${name}\` has no ${style} style. It has: ${has.join(", ")}.\n` +
269
343
  ` Duotone and fill need a fillable region, and this glyph has none.`
270
344
  )
271
345
  }
@@ -288,13 +362,14 @@ ${bold("COMMANDS")}
288
362
 
289
363
  ${bold("OPTIONS")}
290
364
  -s, --style <s> ${styles.join(" | ")} (default: stroke)
365
+ -c, --corners <c> ${corners.join(" | ")} (default: regular)
291
366
  -o, --out <dir> Where \`add\` writes (default: ./icons)
292
367
  -l, --limit <n> Max results for \`search\` (default: 25)
293
368
 
294
369
  ${bold("EXAMPLES")}
295
370
  keyline-icons search arrow
296
371
  keyline-icons search mail --style fill
297
- keyline-icons show check > check.svg
372
+ keyline-icons show check --corners sharp > check-sharp.svg
298
373
  keyline-icons add circle-arrow-down bell --out src/icons
299
374
  keyline-icons list | grep chart
300
375
 
@@ -309,7 +384,8 @@ const commands = {
309
384
 
310
385
  list({ flags }) {
311
386
  const style = flags.style ? styleOf(flags) : null
312
- const names = style ? NAMES.filter((n) => icons[n][style]) : NAMES
387
+ const treatment = cornersOf(flags)
388
+ const names = style ? NAMES.filter((n) => svgFor(n, style, treatment)) : NAMES
313
389
  for (const n of names) out(n)
314
390
  note(dim(`\n${names.length} icons${style ? ` in ${style}` : ""}`))
315
391
  },
@@ -329,7 +405,7 @@ const commands = {
329
405
  is not a number at all earns the default. */
330
406
  const raw = parseInt(flags.limit ?? "25", 10)
331
407
  const limit = Math.min(Math.max(Number.isNaN(raw) ? 25 : raw, 1), 200)
332
- const hits = search(query, style, limit)
408
+ const hits = search(query, style, limit, cornersOf(flags))
333
409
  if (!hits.length) {
334
410
  const near = nearest(query)
335
411
  die(
@@ -337,7 +413,7 @@ const commands = {
337
413
  )
338
414
  }
339
415
  for (const n of hits) {
340
- const has = styles.filter((s) => icons[n][s])
416
+ const has = styles.filter((s) => svgFor(n, s, cornersOf(flags)))
341
417
  out(`${n}${tty ? " " + dim(`[${has.join(", ")}]`) : ""}`)
342
418
  }
343
419
  note(dim(`\n${hits.length} shown${style ? ` in ${style}` : ""}`))
@@ -346,17 +422,27 @@ const commands = {
346
422
  show({ flags, positional }) {
347
423
  const [name] = positional
348
424
  if (!name) die("show needs an icon name. Try: keyline-icons show check")
349
- process.stdout.write(resolveOrDie(name, styleOf(flags)))
425
+ process.stdout.write(resolveOrDie(name, styleOf(flags), cornersOf(flags)))
350
426
  },
351
427
 
352
428
  async add({ flags, positional }) {
353
429
  if (!positional.length) die("add needs at least one icon name.")
354
430
  const style = styleOf(flags)
431
+ const treatment = cornersOf(flags)
355
432
  const dir = resolve(flags.out === true || !flags.out ? "icons" : flags.out)
356
433
 
434
+ // The treatment rides in the filename, for the reason the style does not:
435
+ // two treatments of one drawing are the same name and the same style, so
436
+ // adding both would otherwise write one file twice and leave whichever ran
437
+ // last. Rounded keeps the bare name, which is what everyone means by it.
438
+ const suffix = treatment === "regular" ? "" : `-${treatment}`
439
+
357
440
  // Every name is resolved before anything is written, so a typo in the
358
441
  // third of five does not leave two files on disk and an error.
359
- const files = positional.map((name) => [name, resolveOrDie(name, style)])
442
+ const files = positional.map((name) => [
443
+ `${name}${suffix}`,
444
+ resolveOrDie(name, style, treatment),
445
+ ])
360
446
 
361
447
  await mkdir(dir, { recursive: true })
362
448
  for (const [name, svg] of files) {
@@ -369,7 +455,8 @@ const commands = {
369
455
  }
370
456
  note(
371
457
  dim(
372
- `\n${files.length} ${style} icon${files.length === 1 ? "" : "s"} to ${dir}`
458
+ `\n${files.length} ${treatment === "sharp" ? "sharp " : ""}${style} ` +
459
+ `icon${files.length === 1 ? "" : "s"} to ${dir}`
373
460
  )
374
461
  )
375
462
  },