@astryxdesign/cli 0.7.11 → 0.7.12-canary.ccb4a6d

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 (68) hide show
  1. package/api/build/kit/rank.mjs +21 -4
  2. package/api/build/kit/words.d.mts +1 -0
  3. package/api/build/kit/words.mjs +1 -1
  4. package/api/icon/_adapter.d.mts +3 -2
  5. package/api/icon/_adapter.mjs +8 -2
  6. package/api/icon/_authoring.d.mts +1 -0
  7. package/api/icon/_authoring.mjs +586 -66
  8. package/api/icon/_default-look.d.mts +155 -0
  9. package/api/icon/_default-look.mjs +474 -0
  10. package/api/icon/_records.d.mts +1 -1
  11. package/api/icon/_records.mjs +1 -0
  12. package/api/icon/add/add.mjs +224 -38
  13. package/api/icon/icon.type.d.mts +22 -2
  14. package/api/icon/icon.type.mjs +6 -2
  15. package/api/icon/iconAdd.doc.mjs +2 -2
  16. package/api/icon/iconNewDefinition.doc.mjs +3 -2
  17. package/api/icon/iconNewSet.doc.mjs +4 -4
  18. package/api/icon/search/search.mjs +10 -9
  19. package/api/integration/add-helpers.d.mts +11 -1
  20. package/api/integration/add-helpers.mjs +5 -2
  21. package/api/integration/authoring-checks.d.mts +0 -2
  22. package/api/integration/authoring-checks.mjs +1 -6
  23. package/api/logger.mjs +2 -1
  24. package/api/swizzle/_adapter.d.mts +1 -0
  25. package/api/swizzle/_adapter.mjs +4 -1
  26. package/api/swizzle/_moduleGraph.d.mts +171 -0
  27. package/api/swizzle/_moduleGraph.mjs +795 -0
  28. package/api/swizzle/copy/copy.d.mts +2 -43
  29. package/api/swizzle/copy/copy.mjs +195 -314
  30. package/api/swizzle/swizzle.d.mts +0 -2
  31. package/api/swizzle/swizzle.doc.mjs +9 -4
  32. package/api/swizzle/swizzle.mjs +3 -7
  33. package/api/swizzle/swizzle.type.d.mts +2 -1
  34. package/api/swizzle/swizzle.type.mjs +3 -2
  35. package/assets/discover/official-packages.json +20 -0
  36. package/assets/docs/tree/icon-sets.doc.mjs +11 -5
  37. package/assets/templates/blocks/components/Table/TableRowIndexTable.doc.mjs +1 -1
  38. package/assets/templates/blocks/components/Table/TableRowIndexTable.tsx +2 -2
  39. package/assets/templates/pages/course-detail/page.tsx +699 -0
  40. package/assets/templates/pages/course-detail/template.doc.mjs +37 -0
  41. package/assets/templates/pages/file-explorer/page.tsx +103 -79
  42. package/assets/templates/pages/table-grouped/page.tsx +160 -232
  43. package/assets/templates/pages/table-grouped/template.doc.mjs +0 -2
  44. package/assets/templates/pages/theme-showcase/page.tsx +724 -792
  45. package/assets/templates/pages/theme-showcase/template.doc.mjs +4 -5
  46. package/authoring/doctypes/template/type.ts +1 -0
  47. package/clients/cli/commands/experimental/icon.mjs +31 -10
  48. package/clients/cli/commands/experimental-icon-add.doc.mjs +1 -1
  49. package/clients/cli/commands/experimental-icon-new-definition.doc.mjs +2 -1
  50. package/clients/cli/commands/experimental-icon-new-set.doc.mjs +2 -2
  51. package/clients/cli/commands/swizzle.doc.mjs +8 -3
  52. package/clients/cli/commands/swizzle.mjs +21 -9
  53. package/foundation/discovery/component-loader.d.mts +1 -1
  54. package/foundation/discovery/doc-types.mjs +9 -2
  55. package/foundation/discovery/icon-ranking.d.mts +27 -3
  56. package/foundation/discovery/icon-ranking.mjs +125 -8
  57. package/foundation/discovery/icon-set-checks.mjs +44 -35
  58. package/foundation/discovery/icon-set-discovery.d.mts +13 -0
  59. package/foundation/discovery/icon-set-discovery.mjs +25 -0
  60. package/foundation/discovery/icon-set-writer.d.mts +238 -16
  61. package/foundation/discovery/icon-set-writer.mjs +676 -79
  62. package/foundation/discovery/module-exports.d.mts +3 -2
  63. package/foundation/discovery/module-exports.mjs +167 -37
  64. package/foundation/fs/paths.d.mts +2 -1
  65. package/foundation/fs/paths.mjs +3 -4
  66. package/foundation/text/shell-arg.d.mts +14 -0
  67. package/foundation/text/shell-arg.mjs +17 -0
  68. package/package.json +6 -6
@@ -6,7 +6,8 @@ export const doc = {
6
6
  name: 'Theme Showcase',
7
7
  displayName: 'Theme Showcase',
8
8
  description:
9
- 'Several unrelated product surfaces rendered together on one canvas so a token change can be judged across contexts at once. A comparison harness rather than a layout to ship.',
9
+ 'Multi-surface comparison shell: preview palettes, typography, icons and control states across a storefront, checkout, chat and inventory on one responsive canvas. A theme preview and design-system showcase, with an embeddable content view for an existing app shell.',
10
+ category: 'Shell - Top Nav',
10
11
  keywords: [
11
12
  'theme',
12
13
  'tokens',
@@ -16,10 +17,8 @@ export const doc = {
16
17
  'design system preview',
17
18
  ],
18
19
  isReady: true,
19
- // Surfaced via the Themes page "Open in Playground" action, not the
20
- // Templates gallery, so keep it out of the overview + playground menu.
21
- // No `category` — it's hidden from the overview gallery (the only consumer
22
- // of `category`), and 'Showcase' isn't part of the TemplateCategory taxonomy.
20
+ // The standalone entry demonstrates an application shell; the named preview
21
+ // composes into its host without nesting AppShell. Keep it out of the gallery.
23
22
  isHiddenFromOverview: true,
24
23
  icons: {
25
24
  Banknote: 'money',
@@ -186,6 +186,7 @@ export type TemplateCategory =
186
186
  | 'Content - Order Detail'
187
187
  | 'Content - Product Detail'
188
188
  | 'Content - Work Item Detail'
189
+ | 'Content - Course Detail'
189
190
  | 'Content - Product List'
190
191
  | 'Content - Documentation Catalog'
191
192
  | 'Content - Documentation Design'
@@ -20,6 +20,7 @@ import {
20
20
  getCliInvocation,
21
21
  } from '../../../../foundation/env/package-manager.mjs';
22
22
  import {jsonOut} from '../../../../foundation/response/json.mjs';
23
+ import {logger} from '../../../../api/logger.mjs';
23
24
  import {resultSet} from '../../../../foundation/debug/index.mjs';
24
25
  import {
25
26
  emit,
@@ -140,7 +141,7 @@ export function registerExperimentalIcon(group, program) {
140
141
  ),
141
142
  ),
142
143
  text(
143
- `Built-in definitions are the ones every set maps. Map one: ${run} experimental icon map <definition> <icon id>`,
144
+ `Built-in definitions are the ones every set maps. Map one: ${run} experimental icon map <definition> <icon id> --set <set>`,
144
145
  ),
145
146
  );
146
147
  return answered;
@@ -157,7 +158,9 @@ export function registerExperimentalIcon(group, program) {
157
158
  `${each.id}: ${each.words.join(', ')}${each.definitions.length > 0 ? ` = ${each.definitions.join(', ')}` : ''}`,
158
159
  ),
159
160
  ),
160
- text(`Map one: ${run} experimental icon map <definition> <id>`),
161
+ text(
162
+ `Map one: ${run} experimental icon map <definition> <id> --set ${set}`,
163
+ ),
161
164
  );
162
165
  return answered;
163
166
  }
@@ -308,6 +311,9 @@ export function registerExperimentalIcon(group, program) {
308
311
  * @param {(result: any) => void} print
309
312
  */
310
313
  const run = async (call, print) => {
314
+ // Reading a large library reports its progress through the shared
315
+ // logger: on stderr in text mode, never under --json.
316
+ logger.setSilent(Boolean(program.opts().json));
311
317
  let result;
312
318
  try {
313
319
  result = await call();
@@ -436,7 +442,7 @@ export function registerExperimentalIcon(group, program) {
436
442
  return;
437
443
  }
438
444
  if (result.type === 'icon.definitionMatches') {
439
- const {matches, newDefinition} = result.data;
445
+ const {matches, newDefinition, variant} = result.data;
440
446
  emit(
441
447
  section(
442
448
  `Icon definitions for ${result.data.export} (${matches.length})`,
@@ -466,6 +472,13 @@ export function registerExperimentalIcon(group, program) {
466
472
  ),
467
473
  ]
468
474
  : []),
475
+ ...(variant
476
+ ? [
477
+ text(
478
+ `The set already has this glyph in another look. Add this one as its variant: ${formatCliCommand(variant)}`,
479
+ ),
480
+ ]
481
+ : []),
469
482
  text(
470
483
  `All icon definitions: ${cli} docs cli/experimental/icon-definitions`,
471
484
  ),
@@ -500,17 +513,25 @@ export function registerExperimentalIcon(group, program) {
500
513
  const {root, manifest, files, written, dryRun} = data;
501
514
  const state = dryRun ? 'plan' : written ? 'ok' : 'unchanged';
502
515
  const refreshed = data.refreshed === true;
516
+ const updated = data.updated === true;
517
+ const What = `${what[0].toUpperCase()}${what.slice(1)}`;
503
518
  emit(
504
519
  section(
505
520
  refreshed
506
521
  ? dryRun
507
522
  ? `Refresh ${what} plan`
508
- : `${what[0].toUpperCase()}${what.slice(1)} refreshed`
509
- : dryRun
510
- ? `New ${what} plan`
511
- : written
512
- ? `New ${what} added`
513
- : `New ${what} unchanged`,
523
+ : `${What} refreshed`
524
+ : updated
525
+ ? dryRun
526
+ ? `${What} update plan`
527
+ : written && files.length > 0
528
+ ? `${What} updated`
529
+ : `${What} unchanged`
530
+ : dryRun
531
+ ? `New ${what} plan`
532
+ : written
533
+ ? `New ${what} added`
534
+ : `New ${what} unchanged`,
514
535
  ),
515
536
  text(`[${state}] ${data.name}`),
516
537
  // What the command did (or, in a dry run, would do): never a step
@@ -542,7 +563,7 @@ export function registerExperimentalIcon(group, program) {
542
563
  list(
543
564
  data.suggestions.map(
544
565
  each =>
545
- `${each.definition} -> ${each.icon} (${each.word}): ${formatCliCommand(`astryx experimental icon map ${each.definition} ${each.icon}`)}`,
566
+ `${each.definition} -> ${each.icon} (${each.word}): ${formatCliCommand(`astryx experimental icon map ${each.definition} ${each.icon} --set ${data.name}`)}`,
546
567
  ),
547
568
  ),
548
569
  ]
@@ -30,7 +30,7 @@ export const doc = {
30
30
  flag: '--new-definition <name>',
31
31
  param: 'options.newDefinition',
32
32
  description:
33
- "Add your set's own definition, then map the icon to it; needs --description and --words",
33
+ "Add your set's own definition, named in camelCase (aiHelper) or kebab-case (ai-helper), then map the icon to it; needs --description and --words",
34
34
  },
35
35
  {
36
36
  flag: '--description <text>',
@@ -18,7 +18,8 @@ export const doc = {
18
18
  name: 'name',
19
19
  param: 'name',
20
20
  required: true,
21
- description: 'The definition name, in camelCase (rocketLaunch)',
21
+ description:
22
+ 'The definition name, in camelCase (rocketLaunch) or kebab-case (rocket-launch)',
22
23
  },
23
24
  ],
24
25
  options: [
@@ -11,7 +11,7 @@ export const doc = {
11
11
  stability: 'experimental',
12
12
  summary: 'Start an icon set in your integration package',
13
13
  description:
14
- "Run inside an integration package. Writes the set's folder with its descriptor, its data file, and its generated module, and declares the manifest's `experimental.iconSets` root on first use. With --from it wraps every icon component of a library, records the library and its version as the set's source, copies its license notice, and maps each Astryx icon definition whose name is an icon's id; definitions an icon only shares a word with are listed as suggestions, never written. Run it again to refresh. --variants and --default-variant declare the set's variants. Experimental: it may change or be removed in a patch release.",
14
+ "Run inside an integration package. Writes the set's folder with its descriptor, its data file, and its generated module, and declares the manifest's `experimental.iconSets` root on first use. With --from it wraps every icon component of a library, records the library and its version as the set's source, copies its license notice, and maps each Astryx icon definition whose name is an icon's id; definitions an icon only shares a word with are listed as suggestions, never written. A library whose export names end in looks (XOutline24WGHT500Icon) gives one icon per glyph, by its glyph id (x), in the look the default variant names, else the library's usual default. Run it again with --from to refresh. --variants and --default-variant declare the set's variants; over an existing set without --from, they, --display-name and --description update the set in place and leave its icons as they are; a set with icons changes its default variant only with --from, and only in a library whose export names encode looks. Experimental: it may change or be removed in a patch release.",
15
15
  fn: 'iconNewSet',
16
16
  args: [
17
17
  {
@@ -49,7 +49,7 @@ export const doc = {
49
49
  flag: '--default-variant <variant>',
50
50
  param: 'options.defaultVariant',
51
51
  description:
52
- "With --variants: the variant each icon's own export draws (the first variant when omitted)",
52
+ "With --variants: the variant each icon's own export draws. When omitted: in a library whose export names encode looks, the look the icons draw (the library's usual default for a new set), which --variants must list; otherwise the set's current default, which --variants must list when the set has icons, else, on a set without icons, the first variant. A set with icons changes it only with --from, and only in a library whose export names encode looks",
53
53
  },
54
54
  {
55
55
  flag: '--display-name <text>',
@@ -16,8 +16,13 @@ export const doc = {
16
16
  summary: 'Copy component source for customization',
17
17
  description:
18
18
  "Ejects a component's source from the resolved @astryxdesign/core (or its owning " +
19
- 'integration) into your project for deep customization, rewriting imports that ' +
20
- 'escape the component directory. With no name it lists the swizzlable components. ' +
19
+ 'integration) into your project for deep customization. Every import in the copy ' +
20
+ 'names a file the copy holds or a path the package exports: names the package ' +
21
+ 'exports come from the package, and every internal module the component needs ' +
22
+ "is copied beside it in the package's folder layout. " +
23
+ 'Tests, stories and docs are not copied. It lists the npm packages the copy imports, ' +
24
+ 'with the ranges the owner package declares, to install if your project lacks them. ' +
25
+ 'With no name it lists the swizzlable components. ' +
21
26
  'An integration component that replaces a Core component is what that Core name ' +
22
27
  'copies; --package @astryxdesign/core copies the original. --list lists Core ' +
23
28
  'components, including one an integration replaces.',
@@ -46,7 +51,7 @@ export const doc = {
46
51
  flag: '-f, --overwrite',
47
52
  param: 'options.overwrite',
48
53
  description:
49
- 'Replace existing files. Without it, existing files fail the command with ERR_FILE_EXISTS and nothing is written',
54
+ "Replace the component's existing files. Without it, existing files fail the command with ERR_FILE_EXISTS and nothing is written. A helper an earlier swizzle wrote is reused when it is the same and is never replaced by a copy that carries it: a different one stays, and this copy writes its own under a version-suffixed name. Swizzling the component whose folder holds that helper counts it as one of that component's own files. A changed private attribute owner module, or a style module it imports, fails the command without --overwrite and is replaced with it",
50
55
  },
51
56
  ],
52
57
  examples: [
@@ -3,11 +3,10 @@
3
3
  /**
4
4
  * @file swizzle command — thin wrapper around api/swizzle.
5
5
  *
6
- * Copies a component's source into the consumer project for customization
7
- * (imports rewritten to the owner package's subpaths). All logic — owner
8
- * resolution, copy, import rewriting, StyleX detection, maintainer feedback —
9
- * lives in api/swizzle; this file only parses flags, prints, and prefixes
10
- * commands for the caller's package manager.
6
+ * Copies a component's source into the consumer project for customization,
7
+ * with the internal modules it needs. All logic — owner resolution, the copy,
8
+ * StyleX detection, maintainer feedback — lives in api/swizzle; this file only
9
+ * parses flags, prints, and prefixes commands for the caller's package manager.
11
10
  */
12
11
 
13
12
  import {jsonOut} from '../../../foundation/response/json.mjs';
@@ -85,18 +84,31 @@ export function registerSwizzle(program) {
85
84
  text(`[ok] Copied ${filesCopied} files to ${outputDir}/`),
86
85
  text(
87
86
  [
88
- `Relative imports have been rewritten to use ${ownerPackage}.`,
87
+ `What ${ownerPackage} exports is imported from ${ownerPackage}; the internal modules the component needs are copied beside it.`,
89
88
  'You can now customize the component source freely.',
90
89
  ].join('\n'),
91
90
  ),
92
91
  ];
93
- // Files the copy wrote outside the component folder: the owner modules
94
- // and their unexported style modules (spec:AST-110 FR4).
92
+ // Files the copy wrote outside the component folder: the internal
93
+ // modules it needs, in the package's folder layout.
95
94
  const shared = (result.data.files ?? []).filter(f => f.startsWith('../'));
96
95
  if (shared.length > 0) {
97
96
  out.push(
98
97
  text(
99
- `Shared modules: ${shared.join(', ')} (copied whole; their imports between each other stay relative)`,
98
+ `Copied beside it: ${shared.join(', ')} (in the package's folder layout; imported by relative path)`,
99
+ ),
100
+ );
101
+ }
102
+
103
+ // npm packages the copy imports, each range quoted so the list pastes
104
+ // safely after an install command.
105
+ const dependencies = Object.entries(result.data.dependencies ?? {});
106
+ if (dependencies.length > 0) {
107
+ out.push(
108
+ text(
109
+ `It imports these npm packages; install any your project lacks: ${dependencies
110
+ .map(([name, range]) => (range === '*' ? name : `${name}@"${range}"`))
111
+ .join(' ')}`,
100
112
  ),
101
113
  );
102
114
  }
@@ -43,5 +43,5 @@ export type LoadDocOptions = {
43
43
  * which root reads the doc
44
44
  * (default: components)
45
45
  */
46
- root?: "hooks" | "components" | undefined;
46
+ root?: "components" | "hooks" | undefined;
47
47
  };
@@ -27,7 +27,8 @@
27
27
  * none), and a `<topic>.doc.<lang>.mjs` overlay against
28
28
  * `ReferenceTranslationDoc`;
29
29
  * - themes root: `ThemeDoc`;
30
- * - iconSets root: `IconSetDoc`.
30
+ * - iconSets root: `IconSetDoc`, and a set's own definition (stamped
31
+ * `type: 'icon-definition'`) `IconDefinitionDoc`.
31
32
  * Every finding is a warning, names the field, and says how to fix it.
32
33
  *
33
34
  * @input Doc files with the root each was found in, the package root, and the
@@ -312,7 +313,13 @@ function expectedType(ts, root, exportName, node, core = false) {
312
313
  return {typeExpr: authoring('ThemeDoc'), typeName: 'ThemeDoc'};
313
314
  }
314
315
  if (root === 'icon-sets') {
315
- return {typeExpr: authoring('IconSetDoc'), typeName: 'IconSetDoc'};
316
+ // A set's own definitions live in its folder too.
317
+ return shape?.stamp === 'icon-definition'
318
+ ? {
319
+ typeExpr: authoring('IconDefinitionDoc'),
320
+ typeName: 'IconDefinitionDoc',
321
+ }
322
+ : {typeExpr: authoring('IconSetDoc'), typeName: 'IconSetDoc'};
316
323
  }
317
324
  if (translation) {
318
325
  return {
@@ -30,7 +30,9 @@ export function iconRecord(set: DiscoveredIconSet, icon: IconSetIcon, definition
30
30
  * @property {boolean} meaning the query is one of the icon's tags (never
31
31
  * both: a tag is a word that is not the id or one of its parts)
32
32
  * @property {number} rank within-tier key, lower first: for a name match 0
33
- * (whole id) or 1 (an id part); for a meaning match the tag's position
33
+ * (whole id), 1 (the whole id's plural or singular), 2 (an id part), or 3
34
+ * (an id part's plural or singular); for a meaning match the tag's
35
+ * position
34
36
  * @property {string} reason
35
37
  */
36
38
  /**
@@ -41,6 +43,24 @@ export function iconRecord(set: DiscoveredIconSet, icon: IconSetIcon, definition
41
43
  * @returns {IconMatch | null}
42
44
  */
43
45
  export function matchIcon(query: string, icon: IconSetIcon, definitions?: string[]): IconMatch | null;
46
+ /**
47
+ * The query without the words that only phrase it, when it has several
48
+ * words and some word names something; else the query as typed.
49
+ * @param {string} query
50
+ */
51
+ export function iconQuery(query: string): string;
52
+ /**
53
+ * The matches that answer a query: the whole-word ones (a definition or an
54
+ * exact match, 80 and up) when there are any, else word starts (60) when
55
+ * there are any, else partial matches. A longer word that only starts with
56
+ * the query (`start` for `star`) never stands beside a whole-word answer.
57
+ * @template {{score: number}} T
58
+ * @param {T[]} ranked best first
59
+ * @returns {T[]}
60
+ */
61
+ export function answeringMatches<T extends {
62
+ score: number;
63
+ }>(ranked: T[]): T[];
44
64
  /**
45
65
  * Which exact reading leads for a set of matches: meaning, unless more icons
46
66
  * carry the query as a name than as a tag.
@@ -78,7 +98,9 @@ export function withDefinitionWords(icon: IconSetIcon, definitions: string[], lo
78
98
  words: string[];
79
99
  } | undefined): IconSetIcon;
80
100
  /**
81
- * Every icon in `sets` that answers the query, ranked (FR6).
101
+ * Every icon in `sets` that answers the query, ranked. The query is
102
+ * scored as typed; when nothing answers it with a whole word (80 and up),
103
+ * it is scored again without its filler words ({@link iconQuery}).
82
104
  * @param {DiscoveredIconSet[]} sets in tie order
83
105
  * @param {string} query
84
106
  * @returns {IconMatchEntry[]}
@@ -106,7 +128,9 @@ export type IconMatch = {
106
128
  meaning: boolean;
107
129
  /**
108
130
  * within-tier key, lower first: for a name match 0
109
- * (whole id) or 1 (an id part); for a meaning match the tag's position
131
+ * (whole id), 1 (the whole id's plural or singular), 2 (an id part), or 3
132
+ * (an id part's plural or singular); for a meaning match the tag's
133
+ * position
110
134
  */
111
135
  rank: number;
112
136
  reason: string;
@@ -9,8 +9,18 @@
9
9
  * 90 an exact match in the reading the searched sets favor (below)
10
10
  * 80 an exact match in the other reading
11
11
  * 60 one of its words starts with the query
12
- * 40 a partial match: the id or a word contains the query, or every
13
- * word of the query starts one of the icon's words or id parts
12
+ * 40 a partial match: the id or a word contains the query (three
13
+ * letters or more), or every word of the query starts one of the
14
+ * icon's words or id parts
15
+ *
16
+ * A word matches a whole word: a word's plural or singular (`stars` for
17
+ * `star`) is an exact match, while a longer word that only starts with it
18
+ * (`start`) is a word-start match, and {@link answeringMatches} keeps
19
+ * word-start and partial matches only when nothing matches a whole word.
20
+ * A query is scored as typed first. Only when nothing answers it with a
21
+ * whole word are the words that only phrase it (`go`, `the`, `icon`) left
22
+ * out of a query of several words, so `go home` asks for `home` while
23
+ * `arrow up to line` still names `arrow-up-to-line`.
14
24
  *
15
25
  * An exact match is a NAME match when the query is the icon's id, or one of
16
26
  * its words that is also a part of its id (`trash` for `trash-2`); it is a
@@ -26,7 +36,8 @@
26
36
  *
27
37
  * Equal scores keep the set order first (the project's set, then load
28
38
  * order), so the project's set always leads at a score. Within one set, a
29
- * name match on the whole id comes before one on a part of it, and a meaning
39
+ * name match on the whole id (or its plural or singular) comes before one on
40
+ * a part of it, and a meaning
30
41
  * match whose query sits earlier among the icon's tags comes first (sets list
31
42
  * an icon's most telling tags first). Remaining ties keep id order, so the
32
43
  * same installation always answers the same way.
@@ -93,7 +104,9 @@ const squash = value => value.toLowerCase().replace(/[\s_-]+/g, '');
93
104
  * @property {boolean} meaning the query is one of the icon's tags (never
94
105
  * both: a tag is a word that is not the id or one of its parts)
95
106
  * @property {number} rank within-tier key, lower first: for a name match 0
96
- * (whole id) or 1 (an id part); for a meaning match the tag's position
107
+ * (whole id), 1 (the whole id's plural or singular), 2 (an id part), or 3
108
+ * (an id part's plural or singular); for a meaning match the tag's
109
+ * position
97
110
  * @property {string} reason
98
111
  */
99
112
 
@@ -127,10 +140,27 @@ export function matchIcon(query, icon, definitions = []) {
127
140
  tier: 'exact',
128
141
  name: wholeId || partWord,
129
142
  meaning: !wholeId && !partWord,
130
- rank: wholeId ? 0 : partWord ? 1 : tagAt,
143
+ rank: wholeId ? 0 : partWord ? 2 : tagAt,
131
144
  reason: wholeId ? `id "${icon.id}"` : `word "${query}"`,
132
145
  };
133
146
  }
147
+ const plural = icon.words.find(word => sameWord(word, query));
148
+ if (plural) {
149
+ const named = parts.has(plural);
150
+ return {
151
+ tier: 'exact',
152
+ name: named,
153
+ meaning: !named,
154
+ // The whole id's plural or singular right after the whole id, then
155
+ // after the word itself, wherever it sits.
156
+ rank: named
157
+ ? plural === icon.id
158
+ ? 1
159
+ : 3
160
+ : tags.length + tags.indexOf(plural),
161
+ reason: `word "${plural}"`,
162
+ };
163
+ }
134
164
  const prefix = icon.words.find(word => word.startsWith(query));
135
165
  if (prefix) {
136
166
  return {
@@ -141,7 +171,11 @@ export function matchIcon(query, icon, definitions = []) {
141
171
  reason: `word "${prefix}" starts with "${query}"`,
142
172
  };
143
173
  }
144
- const contains = [icon.id, ...icon.words].find(text => text.includes(query));
174
+ // A letter or two inside a word (`x` in `box`) says nothing about it.
175
+ const contains =
176
+ query.replace(/\s+/gu, '').length >= 3
177
+ ? [icon.id, ...icon.words].find(text => text.includes(query))
178
+ : undefined;
145
179
  if (contains) {
146
180
  return {
147
181
  tier: 'partial',
@@ -170,6 +204,72 @@ export function matchIcon(query, icon, definitions = []) {
170
204
  return null;
171
205
  }
172
206
 
207
+ /**
208
+ * Whether two words are one word's singular and plural (`star`, `stars`;
209
+ * `box`, `boxes`), never the same word.
210
+ * @param {string} a
211
+ * @param {string} b
212
+ */
213
+ function sameWord(a, b) {
214
+ if (a === b || /\s/u.test(a) || /\s/u.test(b)) return false;
215
+ const [short, long] = a.length < b.length ? [a, b] : [b, a];
216
+ return short.length >= 2 && (long === `${short}s` || long === `${short}es`);
217
+ }
218
+
219
+ /**
220
+ * Words that phrase a query without naming anything: left out of a query of
221
+ * several words that nothing answers as typed (`go home` asks for `home`).
222
+ */
223
+ const FILLER_WORDS = new Set([
224
+ 'a',
225
+ 'an',
226
+ 'the',
227
+ 'go',
228
+ 'to',
229
+ 'my',
230
+ 'of',
231
+ 'for',
232
+ 'icon',
233
+ 'icons',
234
+ ]);
235
+
236
+ /**
237
+ * A query as typed: trimmed and lower-case, one space between words.
238
+ * @param {string} query
239
+ */
240
+ function typedQuery(query) {
241
+ return query.trim().toLowerCase().split(/\s+/u).filter(Boolean).join(' ');
242
+ }
243
+
244
+ /**
245
+ * The query without the words that only phrase it, when it has several
246
+ * words and some word names something; else the query as typed.
247
+ * @param {string} query
248
+ */
249
+ export function iconQuery(query) {
250
+ const words = typedQuery(query).split(' ').filter(Boolean);
251
+ const named = words.filter(word => !FILLER_WORDS.has(word));
252
+ return (words.length > 1 && named.length > 0 ? named : words).join(' ');
253
+ }
254
+
255
+ /**
256
+ * The matches that answer a query: the whole-word ones (a definition or an
257
+ * exact match, 80 and up) when there are any, else word starts (60) when
258
+ * there are any, else partial matches. A longer word that only starts with
259
+ * the query (`start` for `star`) never stands beside a whole-word answer.
260
+ * @template {{score: number}} T
261
+ * @param {T[]} ranked best first
262
+ * @returns {T[]}
263
+ */
264
+ export function answeringMatches(ranked) {
265
+ for (const floor of [80, 60]) {
266
+ if (ranked.some(each => each.score >= floor)) {
267
+ return ranked.filter(each => each.score >= floor);
268
+ }
269
+ }
270
+ return ranked;
271
+ }
272
+
173
273
  /**
174
274
  * The score of one match, given which exact reading leads.
175
275
  * @param {IconMatch} match
@@ -249,13 +349,30 @@ export function withDefinitionWords(icon, definitions, lookup) {
249
349
  }
250
350
 
251
351
  /**
252
- * Every icon in `sets` that answers the query, ranked (FR6).
352
+ * Every icon in `sets` that answers the query, ranked. The query is
353
+ * scored as typed; when nothing answers it with a whole word (80 and up),
354
+ * it is scored again without its filler words ({@link iconQuery}).
253
355
  * @param {DiscoveredIconSet[]} sets in tie order
254
356
  * @param {string} query
255
357
  * @returns {IconMatchEntry[]}
256
358
  */
257
359
  export function rankIcons(sets, query) {
258
- const term = query.trim().toLowerCase();
360
+ const typed = typedQuery(query);
361
+ const asTyped = rankTerm(sets, typed);
362
+ if (asTyped.some(each => each.score >= 80)) return asTyped;
363
+ const named = iconQuery(query);
364
+ if (named === typed) return asTyped;
365
+ const withoutFiller = rankTerm(sets, named);
366
+ return withoutFiller.length > 0 ? withoutFiller : asTyped;
367
+ }
368
+
369
+ /**
370
+ * {@link rankIcons} for one normalized query.
371
+ * @param {DiscoveredIconSet[]} sets
372
+ * @param {string} term
373
+ * @returns {IconMatchEntry[]}
374
+ */
375
+ function rankTerm(sets, term) {
259
376
  const astryx = astryxDefinitionsByName();
260
377
  /** @type {Array<{set: DiscoveredIconSet, icon: IconSetIcon, definitions: string[], match: IconMatch, setIndex: number}>} */
261
378
  const hits = [];