@astryxdesign/cli 0.7.12-canary.ccb4a6d → 0.7.12-canary.da3dbdc

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 (85) hide show
  1. package/api/component/__fixtures__/resolutions/modules.txt +5 -5
  2. package/api/docs/_adapter.d.mts +22 -0
  3. package/api/docs/_adapter.mjs +81 -3
  4. package/api/docs/docs.doc.mjs +5 -5
  5. package/api/docs/docs.type.d.mts +21 -0
  6. package/api/docs/docs.type.mjs +11 -0
  7. package/api/docs/list/list.d.mts +5 -1
  8. package/api/docs/list/list.mjs +72 -6
  9. package/api/docs/node/node.mjs +2 -40
  10. package/api/index.d.mts +1 -0
  11. package/api/index.mjs +1 -0
  12. package/api/render/_adapter.mjs +9 -0
  13. package/api/render/render.doc.mjs +2 -2
  14. package/api/search/_adapter.d.mts +22 -1
  15. package/api/search/_adapter.mjs +37 -20
  16. package/api/search/exportSearchIndex.doc.d.mts +10 -0
  17. package/api/search/exportSearchIndex.doc.mjs +58 -0
  18. package/api/search/rankSearchIndex.doc.d.mts +10 -0
  19. package/api/search/rankSearchIndex.doc.mjs +79 -0
  20. package/api/search/search-index.d.mts +44 -0
  21. package/api/search/search-index.mjs +270 -0
  22. package/api/search/search.type.d.mts +28 -0
  23. package/api/search/search.type.mjs +17 -0
  24. package/api/swizzle/_moduleGraph.d.mts +7 -0
  25. package/api/swizzle/_moduleGraph.mjs +191 -83
  26. package/api/swizzle/copy/copy.mjs +100 -9
  27. package/api/swizzle/swizzle.doc.mjs +7 -3
  28. package/api/swizzle/swizzle.type.d.mts +5 -0
  29. package/api/swizzle/swizzle.type.mjs +1 -0
  30. package/api/template/template.mjs +1 -0
  31. package/assets/discover/official-packages.json +16 -0
  32. package/assets/docs/browser-support.doc.mjs +2 -2
  33. package/assets/docs/styling-overview.doc.mjs +6 -2
  34. package/assets/docs/tree/render-pages.doc.mjs +2 -2
  35. package/assets/docs/tree/tailwind.doc.mjs +41 -1
  36. package/assets/docs/tree/type-scale.doc.mjs +19 -2
  37. package/assets/templates/blocks/components/Calendar/useCalendarConstraintsHookUsage.doc.mjs +14 -0
  38. package/assets/templates/blocks/components/Calendar/useCalendarConstraintsHookUsage.tsx +146 -0
  39. package/assets/templates/blocks/components/Calendar/useCalendarNavigationHookUsage.doc.mjs +14 -0
  40. package/assets/templates/blocks/components/Calendar/useCalendarNavigationHookUsage.tsx +173 -0
  41. package/assets/templates/blocks/components/Hooks/useInputStatusIconHookUsage.tsx +1 -0
  42. package/assets/templates/blocks/components/Hooks/useInteractiveRoleHookUsage.doc.mjs +14 -0
  43. package/assets/templates/blocks/components/Hooks/useInteractiveRoleHookUsage.tsx +56 -0
  44. package/assets/templates/blocks/components/Hooks/useStreamingTextHookUsage.doc.mjs +2 -2
  45. package/assets/templates/blocks/components/Hooks/useStreamingTextHookUsage.tsx +66 -10
  46. package/assets/templates/blocks/components/Link/useLinkifyHookUsage.doc.mjs +14 -0
  47. package/assets/templates/blocks/components/Link/useLinkifyHookUsage.tsx +39 -0
  48. package/assets/templates/blocks/components/PowerSearch/usePowerSearchConfigHookUsage.doc.mjs +14 -0
  49. package/assets/templates/blocks/components/PowerSearch/usePowerSearchConfigHookUsage.tsx +83 -0
  50. package/assets/templates/blocks/components/Table/RowsVirtualizationHookUsage.doc.mjs +14 -0
  51. package/assets/templates/blocks/components/Table/RowsVirtualizationHookUsage.tsx +35 -0
  52. package/assets/templates/blocks/components/Table/useTableColumnSettingsStateHookUsage.doc.mjs +14 -0
  53. package/assets/templates/blocks/components/Table/useTableColumnSettingsStateHookUsage.tsx +97 -0
  54. package/assets/templates/pages/table-filter/page.tsx +15 -23
  55. package/assets/templates/pages/table-inbox/page.tsx +3 -6
  56. package/assets/templates/themes/butter/butterTheme.ts +2 -2
  57. package/assets/templates/themes/stone/stoneTheme.ts +1 -1
  58. package/assets/templates/themes/y2k/y2kTheme.ts +1 -1
  59. package/clients/cli/commands/docs.doc.mjs +5 -1
  60. package/clients/cli/commands/docs.mjs +30 -2
  61. package/clients/cli/commands/swizzle.doc.mjs +4 -0
  62. package/clients/cli/commands/swizzle.mjs +12 -0
  63. package/clients/cli/lib/resolve-theme.mjs +16 -7
  64. package/foundation/discovery/hook-discovery.mjs +31 -8
  65. package/foundation/doc-compiler/import.d.mts +5 -1
  66. package/foundation/doc-compiler/import.mjs +40 -6
  67. package/foundation/fs/import-url.d.mts +20 -0
  68. package/foundation/fs/import-url.mjs +22 -0
  69. package/foundation/fs/module-loader.d.mts +24 -3
  70. package/foundation/fs/module-loader.mjs +69 -47
  71. package/foundation/render/charts.d.mts +59 -0
  72. package/foundation/render/charts.mjs +216 -0
  73. package/foundation/render/embedded-imports.d.mts +7 -7
  74. package/foundation/render/embedded-imports.mjs +63 -54
  75. package/foundation/render/page-worker.mjs +339 -52
  76. package/foundation/render/render-page.mjs +26 -10
  77. package/foundation/render/ts-paths.d.mts +63 -0
  78. package/foundation/render/ts-paths.mjs +138 -0
  79. package/foundation/response/error-codes.doc.mjs +2 -2
  80. package/foundation/response/error-codes.mjs +3 -2
  81. package/foundation/text/levenshtein.d.mts +6 -1
  82. package/foundation/text/levenshtein.mjs +25 -9
  83. package/foundation/text/suggest-order.d.mts +8 -1
  84. package/foundation/text/suggest-order.mjs +72 -6
  85. package/package.json +7 -6
@@ -0,0 +1,138 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file A project's `compilerOptions.paths` (tsconfig.json or jsconfig.json),
5
+ * read and matched by TypeScript's rules, for `astryx experimental render`:
6
+ * an exact key matches only itself; a key may hold one `*` anywhere, and the
7
+ * longest matching prefix wins; a target may hold one `*`, which takes what
8
+ * the key's `*` matched; targets are tried in order, and when none exists the
9
+ * import resolves as a package. Only bare specifiers are matched. Targets
10
+ * resolve from `baseUrl` (default: the config's folder).
11
+ *
12
+ * @position foundation/render — used by page-worker.mjs and embedded-imports.mjs
13
+ */
14
+
15
+ import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
17
+
18
+ /**
19
+ * @typedef {object} PathPattern
20
+ * @property {string} key - The key as written.
21
+ * @property {string} prefix - What comes before the key's `*` (all of it, for an exact key).
22
+ * @property {string} suffix - What comes after the key's `*`.
23
+ * @property {boolean} wildcard - Whether the key holds a `*`.
24
+ * @property {string[]} targets - Absolute target patterns, in order.
25
+ */
26
+
27
+ /** Extensions an extensionless candidate may stand for, in resolution order. */
28
+ const EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.mts', '.cts', '.json'];
29
+
30
+ /**
31
+ * The path patterns of the config in `dir` (tsconfig.json, else
32
+ * jsconfig.json), or none. Comments and trailing commas are allowed.
33
+ * @param {string} dir
34
+ * @returns {PathPattern[] | null} null when neither file is there
35
+ */
36
+ export function readPathsIn(dir) {
37
+ for (const name of ['tsconfig.json', 'jsconfig.json']) {
38
+ const file = path.join(dir, name);
39
+ if (!fs.existsSync(file)) continue;
40
+ try {
41
+ const text = fs
42
+ .readFileSync(file, 'utf8')
43
+ .replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (_, str) => str ?? '')
44
+ .replace(/,(\s*[}\]])/g, '$1');
45
+ const options = JSON.parse(text)?.compilerOptions ?? {};
46
+ const base = path.resolve(dir, typeof options.baseUrl === 'string' ? options.baseUrl : '.');
47
+ /** @type {PathPattern[]} */
48
+ const out = [];
49
+ for (const [key, targets] of Object.entries(options.paths ?? {})) {
50
+ if (!Array.isArray(targets)) continue;
51
+ const star = key.indexOf('*');
52
+ if (star !== key.lastIndexOf('*')) continue;
53
+ out.push({
54
+ key,
55
+ prefix: star === -1 ? key : key.slice(0, star),
56
+ suffix: star === -1 ? '' : key.slice(star + 1),
57
+ wildcard: star !== -1,
58
+ targets: targets
59
+ .filter(t => typeof t === 'string' && t.indexOf('*') === t.lastIndexOf('*'))
60
+ .map(t => path.resolve(base, t)),
61
+ });
62
+ }
63
+ return out;
64
+ } catch {
65
+ return [];
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * The pattern a specifier matches by TypeScript's rules, and what its `*`
73
+ * matched: an exact key first, else the wildcard key with the longest prefix.
74
+ * @param {PathPattern[]} patterns
75
+ * @param {string} spec
76
+ * @returns {{pattern: PathPattern, capture: string} | null}
77
+ */
78
+ export function matchPath(patterns, spec) {
79
+ // TypeScript applies `paths` only to bare specifiers.
80
+ if (/^\.{1,2}(\/|$)|^\//.test(spec)) return null;
81
+ const exact = patterns.find(p => !p.wildcard && p.key === spec);
82
+ if (exact) return {pattern: exact, capture: ''};
83
+ /** @type {{pattern: PathPattern, capture: string} | null} */
84
+ let best = null;
85
+ for (const p of patterns) {
86
+ if (!p.wildcard) continue;
87
+ if (spec.length < p.prefix.length + p.suffix.length) continue;
88
+ if (!spec.startsWith(p.prefix) || !spec.endsWith(p.suffix)) continue;
89
+ if (best && best.pattern.prefix.length >= p.prefix.length) continue;
90
+ best = {pattern: p, capture: spec.slice(p.prefix.length, spec.length - p.suffix.length)};
91
+ }
92
+ return best;
93
+ }
94
+
95
+ /**
96
+ * The absolute paths a match names, one per target, in order.
97
+ * @param {{pattern: PathPattern, capture: string}} match
98
+ * @returns {string[]}
99
+ */
100
+ export const candidatesOf = match => match.pattern.targets.map(t => t.replace('*', match.capture));
101
+
102
+ /**
103
+ * The file a candidate path stands for: itself, with an extension, or its
104
+ * folder's index; null when none exists.
105
+ * @param {string} candidate
106
+ * @returns {string | null}
107
+ */
108
+ export function existingFile(candidate) {
109
+ const isFile = (/** @type {string} */ f) => {
110
+ try {
111
+ return fs.statSync(f).isFile();
112
+ } catch {
113
+ return false;
114
+ }
115
+ };
116
+ if (isFile(candidate)) return candidate;
117
+ for (const ext of EXTENSIONS) if (isFile(candidate + ext)) return candidate + ext;
118
+ for (const ext of EXTENSIONS) if (isFile(path.join(candidate, `index${ext}`))) return path.join(candidate, `index${ext}`);
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Resolve a specifier through path patterns: the first target that names an
124
+ * existing file wins.
125
+ * @param {PathPattern[]} patterns
126
+ * @param {string} spec
127
+ * @returns {string | null} the file, or null when the specifier matches no
128
+ * pattern or no target exists
129
+ */
130
+ export function resolvePath(patterns, spec) {
131
+ const match = matchPath(patterns, spec);
132
+ if (!match) return null;
133
+ for (const candidate of candidatesOf(match)) {
134
+ const file = existingFile(candidate);
135
+ if (file) return file;
136
+ }
137
+ return null;
138
+ }
@@ -338,13 +338,13 @@ export const doc = {
338
338
  value: 'ERR_RENDER_NOT_ASTRYX',
339
339
  stability: 'experimental',
340
340
  description:
341
- '`render --reverse` was given a file with no embedded source and no Astryx markup. Nothing was written. Raised only by the experimental render command.',
341
+ '`render --reverse` was given a file that a render did not write, with no embedded source and no Astryx markup. Nothing was written. Raised only by the experimental render command.',
342
342
  },
343
343
  {
344
344
  value: 'ERR_RENDER_SOURCE_DAMAGED',
345
345
  stability: 'experimental',
346
346
  description:
347
- '`render --reverse` refused the received file, and nothing was written: its embedded source is damaged (not valid, or a file does not match its checksum), lists a path twice, carries a module, stylesheet or JSON file the page does not reach through its own imports, or an image, font or media file whose bytes are not one, or carries a file a render never embeds: a dotfile or dot-folder, anything under node_modules, a package manifest, lockfile or tool configuration, a file a test, story or benchmark runner would run, or any kind but a page module, stylesheet, JSON file or image. Raised only by the experimental render command.',
347
+ '`render --reverse` refused the received file, and nothing was written: its embedded source was removed (a file a render wrote, with no Astryx components left to rebuild from) or is damaged (not valid, or a file does not match its checksum), lists a path twice, carries a module, stylesheet or JSON file the page does not reach through its own imports, or an image, font or media file whose bytes are not one, or carries a file a render never embeds: a dotfile or dot-folder, anything under node_modules, a package manifest, lockfile or tool configuration, a file a test, story or benchmark runner would run, or any kind but a page module, stylesheet, JSON file or image. Raised only by the experimental render command.',
348
348
  },
349
349
  ],
350
350
  };
@@ -298,8 +298,9 @@ export const ERROR_CODES = Object.freeze({
298
298
  */
299
299
  ERR_RENDER_FAILED: 'ERR_RENDER_FAILED',
300
300
  /**
301
- * `render --reverse` was given an HTML file that holds no Astryx markup and
302
- * no embedded source, so there is nothing to rebuild. Nothing was written.
301
+ * `render --reverse` was given an HTML file that a render did not write and
302
+ * that holds no Astryx markup and no embedded source, so there is nothing
303
+ * to rebuild. Nothing was written.
303
304
  */
304
305
  ERR_RENDER_NOT_ASTRYX: 'ERR_RENDER_NOT_ASTRYX',
305
306
  /**
@@ -19,6 +19,11 @@
19
19
  * @param {number} [max] - The largest distance the caller can use. When
20
20
  * given, a larger distance answers `max + 1`, and only the cells within
21
21
  * `max` of the diagonal are computed.
22
+ * @param {{swaps?: boolean}} [options] - With `swaps` and a `max`, two
23
+ * adjacent letters swapped cost one edit, not two (the optimal string
24
+ * alignment distance).
22
25
  * @returns {number}
23
26
  */
24
- export function levenshteinDistance(a: string, b: string, max?: number): number;
27
+ export function levenshteinDistance(a: string, b: string, max?: number, { swaps }?: {
28
+ swaps?: boolean;
29
+ }): number;
@@ -19,11 +19,15 @@
19
19
  * @param {number} [max] - The largest distance the caller can use. When
20
20
  * given, a larger distance answers `max + 1`, and only the cells within
21
21
  * `max` of the diagonal are computed.
22
+ * @param {{swaps?: boolean}} [options] - With `swaps` and a `max`, two
23
+ * adjacent letters swapped cost one edit, not two (the optimal string
24
+ * alignment distance).
22
25
  * @returns {number}
23
26
  */
24
- export function levenshteinDistance(a, b, max) {
25
- if (max !== undefined) return distanceWithin(a, b, max);
26
- const m = a.length, n = b.length;
27
+ export function levenshteinDistance(a, b, max, {swaps = false} = {}) {
28
+ if (max !== undefined) return distanceWithin(a, b, max, swaps);
29
+ const m = a.length,
30
+ n = b.length;
27
31
  // Early exit: the distance is always ≥ |m − n|, so once the length gap
28
32
  // exceeds the loosest fuzzy-match threshold any caller uses (the hook
29
33
  // suggester keeps distance ≤ 5) the pair can never match — skip the O(m·n)
@@ -36,9 +40,10 @@ export function levenshteinDistance(a, b, max) {
36
40
  for (let j = 0; j <= n; j++) dp[0][j] = j;
37
41
  for (let i = 1; i <= m; i++) {
38
42
  for (let j = 1; j <= n; j++) {
39
- dp[i][j] = a[i-1] === b[j-1]
40
- ? dp[i-1][j-1]
41
- : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
43
+ dp[i][j] =
44
+ a[i - 1] === b[j - 1]
45
+ ? dp[i - 1][j - 1]
46
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
42
47
  }
43
48
  }
44
49
  return dp[m][n];
@@ -51,11 +56,13 @@ export function levenshteinDistance(a, b, max) {
51
56
  * @param {string} a
52
57
  * @param {string} b
53
58
  * @param {number} max
59
+ * @param {boolean} swaps - two adjacent letters swapped cost one edit
54
60
  * @returns {number}
55
61
  */
56
- function distanceWithin(a, b, max) {
62
+ function distanceWithin(a, b, max, swaps) {
57
63
  const over = max + 1;
58
64
  if (Math.abs(a.length - b.length) > max) return over;
65
+ let before = new Array(b.length + 1).fill(over);
59
66
  let previous = new Array(b.length + 1).fill(over);
60
67
  let current = new Array(b.length + 1).fill(over);
61
68
  for (let j = 0; j <= Math.min(b.length, max); j++) previous[j] = j;
@@ -67,18 +74,27 @@ function distanceWithin(a, b, max) {
67
74
  let rowMin = current[0];
68
75
  for (let j = from; j <= to; j++) {
69
76
  const cost = a[i - 1] === b[j - 1] ? 0 : 1;
70
- const value = Math.min(
77
+ let value = Math.min(
71
78
  previous[j - 1] + cost,
72
79
  previous[j] + 1,
73
80
  current[j - 1] + 1,
74
81
  over,
75
82
  );
83
+ if (
84
+ swaps &&
85
+ i > 1 &&
86
+ j > 1 &&
87
+ a[i - 1] === b[j - 2] &&
88
+ a[i - 2] === b[j - 1]
89
+ ) {
90
+ value = Math.min(value, before[j - 2] + 1);
91
+ }
76
92
  current[j] = value;
77
93
  if (value < rowMin) rowMin = value;
78
94
  }
79
95
  // Every later cell costs at least this row's least.
80
96
  if (rowMin >= over) return over;
81
- [previous, current] = [current, previous];
97
+ [before, previous, current] = [previous, current, before];
82
98
  }
83
99
  return Math.min(previous[b.length], over);
84
100
  }
@@ -18,15 +18,22 @@ export function nameWords(text: string): string[];
18
18
  * when it carries some of them, the items that carry none are left out. When
19
19
  * no item carries one, nothing is left out. The list only ever gets shorter,
20
20
  * and its order does not change.
21
+ *
22
+ * With `typos`, a near name (one or two slips from the typed name, as
23
+ * `ButonGroup` or `ButtonGorup` for ButtonGroup) ranks just below the names
24
+ * that carry every typed word and above the rest. With `closest` too, near
25
+ * names stay in the list whatever the cut leaves out, and the list is never
26
+ * longer than it is without `typos`.
21
27
  * @template T
22
28
  * @param {string} typedName
23
29
  * @param {T[]} items
24
30
  * @param {(item: T) => Array<string | undefined>} namesOf
25
- * @param {{closest?: boolean}} [options]
31
+ * @param {{closest?: boolean, typos?: boolean}} [options]
26
32
  * @returns {T[]}
27
33
  */
28
34
  export function orderByAffinity<T>(typedName: string, items: T[], namesOf: (item: T) => Array<string | undefined>, options?: {
29
35
  closest?: boolean;
36
+ typos?: boolean;
30
37
  }): T[];
31
38
  /**
32
39
  * Docs route suggestions, closest to the typed route first. When every
@@ -19,6 +19,12 @@
19
19
 
20
20
  import {levenshteinDistance} from './levenshtein.mjs';
21
21
 
22
+ /**
23
+ * The score of a near name (nearNameSlips), less a little per slip: above any
24
+ * name that carries only some of the typed words, below one that carries all
25
+ * of them.
26
+ */
27
+ const NEAR_NAME_SCORE = 19.6;
22
28
  /** The score a synonym match gets: an exact keyword's. */
23
29
  const SYNONYM_SCORE = 90;
24
30
  /** The score a name with exactly the typed words gets: an exact name's. */
@@ -59,17 +65,22 @@ export function nameWords(text) {
59
65
  * closer. Tiers, best first: the same words; every typed word present; some
60
66
  * typed word present; none. Within a tier, more of the typed words and more of
61
67
  * the candidate's own words matched rank higher, and a name that starts with
62
- * the typed name (or the reverse) gets a small lift.
68
+ * the typed name (or the reverse) gets a small lift. With `typos`, a near name
69
+ * (one or two slips from the typed name) ranks just below the names that
70
+ * carry every typed word.
63
71
  * @param {string[]} typed - {@link nameWords} of the typed name
64
72
  * @param {string} name
73
+ * @param {{typos?: boolean}} [options]
65
74
  * @returns {number}
66
75
  */
67
- function affinityOf(typed, name) {
76
+ function affinityOf(typed, name, {typos = false} = {}) {
68
77
  const words = nameWords(name);
69
78
  if (typed.length === 0 || words.length === 0) return 0;
70
79
  const typedJoined = typed.join('');
71
80
  const joined = words.join('');
72
81
  if (joined === typedJoined) return 40;
82
+ const slips = typos ? nearNameSlips(typedJoined, joined) : 0;
83
+ const near = slips > 0 ? NEAR_NAME_SCORE - slips * 0.2 : 0;
73
84
  const have = new Set(words);
74
85
  const want = new Set(typed);
75
86
  const covered = typed.filter(w => have.has(w)).length / typed.length;
@@ -77,7 +88,29 @@ function affinityOf(typed, name) {
77
88
  const prefix =
78
89
  joined.startsWith(typedJoined) || typedJoined.startsWith(joined) ? 1 : 0;
79
90
  const tier = covered === 1 ? 2 : covered > 0 ? 1 : 0;
80
- return tier * 10 + covered * 4 + precise * 2 + prefix + precise * 0.01;
91
+ // A near name keeps its word score when that is higher: a name that also
92
+ // carries every typed word ranks among the other full matches.
93
+ return Math.max(
94
+ near,
95
+ tier * 10 + covered * 4 + precise * 2 + prefix + precise * 0.01,
96
+ );
97
+ }
98
+
99
+ /**
100
+ * How many slips separate a typed name from a candidate, both with their
101
+ * words joined, when it is a near name: an edit or two adjacent letters
102
+ * swapped count as one slip, one slip is allowed from five letters and two from
103
+ * eight. Zero when it is not a near name.
104
+ * @param {string} typed
105
+ * @param {string} name
106
+ * @returns {number}
107
+ */
108
+ function nearNameSlips(typed, name) {
109
+ const shorter = Math.min(typed.length, name.length);
110
+ const cap = shorter >= 8 ? 2 : shorter >= 5 ? 1 : 0;
111
+ if (cap === 0 || Math.abs(typed.length - name.length) > cap) return 0;
112
+ const slips = levenshteinDistance(typed, name, cap, {swaps: true});
113
+ return slips <= cap ? slips : 0;
81
114
  }
82
115
 
83
116
  /**
@@ -87,14 +120,15 @@ function affinityOf(typed, name) {
87
120
  * @param {string} typedName
88
121
  * @param {T[]} items
89
122
  * @param {(item: T) => Array<string | undefined>} namesOf
123
+ * @param {{typos?: boolean}} [options]
90
124
  * @returns {Array<{item: T, best: number}>}
91
125
  */
92
- function scoreByAffinity(typedName, items, namesOf) {
126
+ function scoreByAffinity(typedName, items, namesOf, options = {}) {
93
127
  const typed = nameWords(typedName);
94
128
  const scored = items.map((item, index) => {
95
129
  let best = 0;
96
130
  for (const name of namesOf(item)) {
97
- if (name) best = Math.max(best, affinityOf(typed, name));
131
+ if (name) best = Math.max(best, affinityOf(typed, name, options));
98
132
  }
99
133
  return {item, index, best};
100
134
  });
@@ -112,14 +146,46 @@ function scoreByAffinity(typedName, items, namesOf) {
112
146
  * when it carries some of them, the items that carry none are left out. When
113
147
  * no item carries one, nothing is left out. The list only ever gets shorter,
114
148
  * and its order does not change.
149
+ *
150
+ * With `typos`, a near name (one or two slips from the typed name, as
151
+ * `ButonGroup` or `ButtonGorup` for ButtonGroup) ranks just below the names
152
+ * that carry every typed word and above the rest. With `closest` too, near
153
+ * names stay in the list whatever the cut leaves out, and the list is never
154
+ * longer than it is without `typos`.
115
155
  * @template T
116
156
  * @param {string} typedName
117
157
  * @param {T[]} items
118
158
  * @param {(item: T) => Array<string | undefined>} namesOf
119
- * @param {{closest?: boolean}} [options]
159
+ * @param {{closest?: boolean, typos?: boolean}} [options]
120
160
  * @returns {T[]}
121
161
  */
122
162
  export function orderByAffinity(typedName, items, namesOf, options = {}) {
163
+ if (!options.typos) return orderByWords(typedName, items, namesOf, options);
164
+ const scored = scoreByAffinity(typedName, items, namesOf, {typos: true});
165
+ if (!options.closest) return scored.map(s => s.item);
166
+ const kept = new Set(orderByWords(typedName, items, namesOf, options));
167
+ return scored
168
+ .filter(s => kept.has(s.item) || isNearScore(s.best))
169
+ .slice(0, kept.size)
170
+ .map(s => s.item);
171
+ }
172
+
173
+ /**
174
+ * Whether a score is a near name's.
175
+ * @param {number} score
176
+ */
177
+ const isNearScore = score => score < 20 && score > NEAR_NAME_SCORE - 1;
178
+
179
+ /**
180
+ * {@link orderByAffinity} without `typos`.
181
+ * @template T
182
+ * @param {string} typedName
183
+ * @param {T[]} items
184
+ * @param {(item: T) => Array<string | undefined>} namesOf
185
+ * @param {{closest?: boolean}} options
186
+ * @returns {T[]}
187
+ */
188
+ function orderByWords(typedName, items, namesOf, options) {
123
189
  const scored = scoreByAffinity(typedName, items, namesOf);
124
190
  if (!options.closest) return scored.map(s => s.item);
125
191
  // One-letter words (`a` in `a11y`, `use-a-theme`) say nothing about which
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.7.12-canary.ccb4a6d",
3
+ "version": "0.7.12-canary.da3dbdc",
4
4
  "displayName": "CLI",
5
5
  "description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
6
6
  "author": "Meta Open Source",
@@ -117,6 +117,7 @@
117
117
  "ignore": "^7.0.5",
118
118
  "jiti": "^2.7.0",
119
119
  "jscodeshift": "^17.3.0",
120
+ "linkedom": "^0.18.14",
120
121
  "node-diff3": "3.2.1",
121
122
  "postcss": "^8.5.23",
122
123
  "postcss-selector-parser": "^7.1.6",
@@ -124,7 +125,7 @@
124
125
  "zod": "^4.4.3"
125
126
  },
126
127
  "peerDependencies": {
127
- "@astryxdesign/core": "0.7.12-canary.ccb4a6d"
128
+ "@astryxdesign/core": "0.7.12-canary.da3dbdc"
128
129
  },
129
130
  "peerDependenciesMeta": {
130
131
  "@astryxdesign/core": {
@@ -132,10 +133,10 @@
132
133
  }
133
134
  },
134
135
  "devDependencies": {
135
- "@astryxdesign/charts": "0.7.12-canary.ccb4a6d",
136
- "@astryxdesign/core": "0.7.12-canary.ccb4a6d",
137
- "@astryxdesign/lab": "0.7.12-canary.ccb4a6d",
138
- "@astryxdesign/theme-neutral": "0.7.12-canary.ccb4a6d",
136
+ "@astryxdesign/charts": "0.7.12-canary.da3dbdc",
137
+ "@astryxdesign/core": "0.7.12-canary.da3dbdc",
138
+ "@astryxdesign/lab": "0.7.12-canary.da3dbdc",
139
+ "@astryxdesign/theme-neutral": "0.7.12-canary.da3dbdc",
139
140
  "@heroicons/react": "^2.2.0",
140
141
  "@stylexjs/stylex": "^0.19.0",
141
142
  "@types/babel__core": "^7.20.5",