@astryxdesign/cli 0.7.12 → 0.7.13-canary.a5ad1f2

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 (82) hide show
  1. package/api/doctor/_adapter.d.mts +17 -0
  2. package/api/doctor/_adapter.mjs +40 -0
  3. package/api/doctor/doctor.d.mts +34 -4
  4. package/api/doctor/doctor.mjs +108 -21
  5. package/api/icon/_adapter.d.mts +6 -3
  6. package/api/icon/_adapter.mjs +11 -3
  7. package/api/icon/icon.type.d.mts +22 -1
  8. package/api/icon/icon.type.mjs +12 -4
  9. package/api/icon/iconUse.doc.mjs +27 -7
  10. package/api/icon/list/list.mjs +29 -3
  11. package/api/icon/use/use.d.mts +7 -3
  12. package/api/icon/use/use.mjs +68 -18
  13. package/api/integration/validate-integration.d.mts +4 -0
  14. package/api/integration/validate-integration.mjs +32 -2
  15. package/api/integration/validate-integration.type.d.mts +15 -0
  16. package/api/integration/validate-integration.type.mjs +6 -1
  17. package/api/template/copy/copy.mjs +4 -2
  18. package/api/template/template.type.d.mts +42 -4
  19. package/api/template/template.type.mjs +9 -6
  20. package/assets/discover/official-packages.json +4 -0
  21. package/assets/docs/tree/icon-sets.doc.mjs +91 -7
  22. package/assets/icons/definitions/alignBottom.doc.mjs +9 -0
  23. package/assets/icons/definitions/alignJustify.doc.mjs +9 -0
  24. package/assets/icons/definitions/alignMiddle.doc.mjs +9 -0
  25. package/assets/icons/definitions/alignTop.doc.mjs +9 -0
  26. package/assets/icons/definitions/angle.doc.mjs +9 -0
  27. package/assets/icons/definitions/cloud.doc.mjs +9 -0
  28. package/assets/icons/definitions/collection.doc.mjs +9 -0
  29. package/assets/icons/definitions/contrast.doc.mjs +9 -0
  30. package/assets/icons/definitions/cornerRadius.doc.mjs +9 -0
  31. package/assets/icons/definitions/cut.doc.mjs +9 -0
  32. package/assets/icons/definitions/fingerprint.doc.mjs +9 -0
  33. package/assets/icons/definitions/fire.doc.mjs +9 -0
  34. package/assets/icons/definitions/frame.doc.mjs +9 -0
  35. package/assets/icons/definitions/gift.doc.mjs +9 -0
  36. package/assets/icons/definitions/group.doc.mjs +9 -0
  37. package/assets/icons/definitions/integration.doc.mjs +9 -0
  38. package/assets/icons/definitions/lowercase.doc.mjs +9 -0
  39. package/assets/icons/definitions/paddingHorizontal.doc.mjs +9 -0
  40. package/assets/icons/definitions/paddingPerSide.doc.mjs +9 -0
  41. package/assets/icons/definitions/paddingVertical.doc.mjs +9 -0
  42. package/assets/icons/definitions/panelBottom.doc.mjs +9 -0
  43. package/assets/icons/definitions/photoEffect.doc.mjs +9 -0
  44. package/assets/icons/definitions/radiusPerCorner.doc.mjs +9 -0
  45. package/assets/icons/definitions/rotateCounterclockwise.doc.mjs +9 -0
  46. package/assets/icons/definitions/tablet.doc.mjs +9 -0
  47. package/assets/icons/definitions/text.doc.mjs +9 -0
  48. package/assets/icons/definitions/textRegular.doc.mjs +9 -0
  49. package/assets/icons/definitions/transfer.doc.mjs +9 -0
  50. package/assets/icons/definitions/uppercase.doc.mjs +9 -0
  51. package/assets/icons/definitions/userRemove.doc.mjs +9 -0
  52. package/assets/templates/blocks/components/BaseTypeahead/BaseTypeaheadMenu.doc.mjs +23 -0
  53. package/assets/templates/blocks/components/BaseTypeahead/BaseTypeaheadMenu.tsx +199 -0
  54. package/assets/templates/blocks/components/ButtonGroup/ButtonGroupShowcase.doc.mjs +1 -0
  55. package/assets/templates/blocks/components/ChatSystemMessage/ChatSystemMessageStatusUpdates.doc.mjs +7 -2
  56. package/assets/templates/pages/canvas-editor/template.doc.mjs +20 -0
  57. package/assets/templates/pages/editor/template.doc.mjs +1 -0
  58. package/assets/templates/pages/form-wizard-vertical/template.doc.mjs +1 -0
  59. package/assets/templates/pages/settings-dialog/template.doc.mjs +4 -0
  60. package/assets/templates/pages/shell-top-nav/template.doc.mjs +3 -0
  61. package/assets/templates/pages/table-collapsible/template.doc.mjs +1 -0
  62. package/assets/templates/pages/table-comparison/template.doc.mjs +1 -0
  63. package/authoring/config/config.doc.mjs +4 -4
  64. package/authoring/config/parse.d.mts +1 -1
  65. package/authoring/config/parse.mjs +3 -1
  66. package/authoring/config/type.ts +7 -5
  67. package/authoring/doctypes/template/template.doc.mjs +1 -1
  68. package/authoring/doctypes/template/type.ts +5 -3
  69. package/clients/cli/commands/experimental/icon.mjs +38 -9
  70. package/clients/cli/commands/experimental-icon-use.doc.mjs +9 -4
  71. package/clients/cli/commands/template.mjs +12 -3
  72. package/foundation/config/config-writer.d.mts +14 -0
  73. package/foundation/config/config-writer.mjs +73 -17
  74. package/foundation/discovery/icon-set-discovery.d.mts +77 -7
  75. package/foundation/discovery/icon-set-discovery.mjs +128 -42
  76. package/foundation/discovery/template-icon-notes.d.mts +46 -0
  77. package/foundation/discovery/template-icon-notes.mjs +94 -16
  78. package/foundation/discovery/template-icons.d.mts +247 -0
  79. package/foundation/discovery/template-icons.mjs +591 -37
  80. package/foundation/integrations/cli-requirement.d.mts +13 -4
  81. package/foundation/integrations/cli-requirement.mjs +18 -6
  82. package/package.json +6 -6
@@ -15,7 +15,12 @@
15
15
  * 2. otherwise is the icon the project set's `map` names for the Astryx
16
16
  * icon definition the source icon draws.
17
17
  * Nothing is guessed from names or words. A `likely` reviewed match is never
18
- * applied: a kept icon names it as a suggestion.
18
+ * applied: a kept icon names it as a suggestion. The project's list names a
19
+ * main set and backup sets in order: each icon maps to the first set in the
20
+ * list that has a match, and never mixes sets within one icon. An icon with
21
+ * a meaning that no set in the list has loses its library import: each of its
22
+ * uses renders a placeholder component that the file declares once, with a
23
+ * comment that names the meaning, so the copied code needs no new package.
19
24
  * A declared key names a named import by the name it imports
20
25
  * (`{Trash as TrashIcon}` is `Trash`) and a default import by its local name
21
26
  * (`import DeleteIcon from …` is `DeleteIcon`). A mapped named import and
@@ -24,10 +29,11 @@
24
29
  * becomes the set's named import aliased to its local name. An icon from an
25
30
  * installed set that draws a variant (such as solid) maps to the target's
26
31
  * variant of that name, else its default. With a project icon set, every
27
- * declared key ends up mapped or kept (FR10): an icon with no match keeps its
28
- * original import, and a key bound only by a namespace import, a type-only
29
- * import, a re-export, an import from an Astryx package or from the project's
30
- * own set, or by no import at all, is kept with the reason. A side-effect
32
+ * declared key ends up mapped, a placeholder, or kept (FR10): an icon that
33
+ * draws no Astryx definition and has no match keeps its original import, and
34
+ * a key bound only by a namespace import, a type-only import, a re-export, an
35
+ * import from an Astryx package or from a set in the project's list, or by no
36
+ * import at all, is kept with the reason. A side-effect
31
37
  * import binds nothing. Without a project icon set the source is unchanged
32
38
  * and the report says why.
33
39
  *
@@ -75,6 +81,33 @@ import {astryxDefinitionsByName} from './icon-definition-discovery.mjs';
75
81
  * `exact` or `strong` match in the project set's data names the source
76
82
  * icon; `definition`: the target's `map` names it for the Astryx icon
77
83
  * definition the source draws
84
+ * @property {{asked: string, used: string | null}} [variant] present when the
85
+ * source draws a look the target set's icon does not have: the look it
86
+ * asked for, and the look used (the set's default look)
87
+ */
88
+
89
+ /**
90
+ * One icon no set in the project's list has: each of its uses in the copied
91
+ * code renders the placeholder component that the file declares.
92
+ * @typedef {object} PlaceholderIcon
93
+ * @property {{module: string, export: string}} source
94
+ * @property {{local: string}} target the name of the placeholder component
95
+ * in the file
96
+ * @property {string[]} definitions the icon definitions the icon draws
97
+ * @property {string[]} fixes the commands that give it a real icon: map the
98
+ * definition in the main set, or add a backup set that has it
99
+ * @property {string[]} [dropped] the library's sizing props removed from its
100
+ * elements, as for a mapped icon
101
+ * @property {{set: string, id: string, module: string, export: string, confidence: 'likely'}} [suggestion]
102
+ * a `likely` reviewed match in the main set, suggested and never applied
103
+ */
104
+
105
+ /**
106
+ * One set a template's mapped icons come from.
107
+ * @typedef {object} UsedIconSet
108
+ * @property {string} set `<package>/<set>`
109
+ * @property {string} license
110
+ * @property {number} icons how many mapped icons it gives
78
111
  */
79
112
 
80
113
  /**
@@ -95,10 +128,201 @@ import {astryxDefinitionsByName} from './icon-definition-discovery.mjs';
95
128
  * @property {'selected' | 'none' | 'ambiguous' | 'missing' | 'withdrawn'} status the
96
129
  * project's icon-set state; only `selected` maps anything
97
130
  * @property {string | null} set the project set as `<package>/<set>`
131
+ * @property {string[]} [order] the main set and each backup, as
132
+ * `<package>/<set>`, in the order icons are looked up; present when the
133
+ * project lists backups
98
134
  * @property {string} reason
99
135
  * @property {MappedIcon[]} mapped
136
+ * @property {PlaceholderIcon[]} placeholder
100
137
  * @property {KeptIcon[]} kept
138
+ * @property {UsedIconSet[]} sets each set the mapped icons come from, with
139
+ * its license
140
+ */
141
+
142
+ /** The name of the placeholder component, when the file does not use it. */
143
+ export const PLACEHOLDER_NAME = 'MissingIcon';
144
+
145
+ /**
146
+ * The placeholder component the CLI declares once in a copied file. It sizes
147
+ * like an icon (1em, in the current text color). It is hidden from assistive
148
+ * technology unless a use gives it a label.
149
+ * @param {string} name
150
+ */
151
+ export function placeholderDeclaration(name) {
152
+ return `/** Stands in for an icon that no icon set in this project has. Replace each use with a real icon. */
153
+ function ${name}(props: React.SVGProps<SVGSVGElement>) {
154
+ const labelled =
155
+ props['aria-label'] != null || props['aria-labelledby'] != null;
156
+ return (
157
+ <svg
158
+ viewBox="0 0 24 24"
159
+ width="1em"
160
+ height="1em"
161
+ fill="none"
162
+ stroke="currentColor"
163
+ strokeWidth={1.5}
164
+ aria-hidden={labelled ? undefined : true}
165
+ role={labelled ? 'img' : undefined}
166
+ {...props}>
167
+ <rect x="4" y="4" width="16" height="16" rx="2" strokeDasharray="3 2.33" />
168
+ </svg>
169
+ );
170
+ }`;
171
+ }
172
+
173
+ /**
174
+ * The comment beside each use of the placeholder, so `astryx doctor` can name
175
+ * the meaning it stands for.
176
+ * @param {string[]} definitions
101
177
  */
178
+ export function placeholderNote(definitions) {
179
+ return `/* missing icon: ${definitions.join(', ')} */`;
180
+ }
181
+
182
+ /**
183
+ * Read the meanings back from a placeholder use's comment. Null for a
184
+ * comment that is not one.
185
+ * @param {string} comment the comment's text, with or without its markers
186
+ * @returns {string[] | null}
187
+ */
188
+ export function placeholderMeanings(comment) {
189
+ const found = /^(?:\/\*)?\s*missing icon:\s*([^*]*?)\s*(?:\*\/)?$/u.exec(
190
+ comment.trim(),
191
+ );
192
+ return found
193
+ ? found[1]
194
+ .split(',')
195
+ .map(each => each.trim())
196
+ .filter(Boolean)
197
+ : null;
198
+ }
199
+
200
+ /** Node keys that position or format a node and never change its shape. */
201
+ const FORMAT_KEYS = new Set([
202
+ 'start',
203
+ 'end',
204
+ 'loc',
205
+ 'range',
206
+ 'extra',
207
+ 'leadingComments',
208
+ 'trailingComments',
209
+ 'innerComments',
210
+ ]);
211
+
212
+ /**
213
+ * A declaration's shape: its tree without positions, comments, quote style,
214
+ * whitespace-only JSX text, or its own name. Formatting a file never changes
215
+ * it.
216
+ * @param {any} node a function declaration
217
+ */
218
+ function declarationShape(node) {
219
+ const name = node.id?.name;
220
+ return JSON.stringify(node, (key, value) => {
221
+ if (FORMAT_KEYS.has(key)) return undefined;
222
+ if (key === 'id' && value?.name === name) return '<name>';
223
+ if (key === 'children' && Array.isArray(value)) {
224
+ return value.filter(
225
+ child => child?.type !== 'JSXText' || child.value.trim() !== '',
226
+ );
227
+ }
228
+ return value;
229
+ });
230
+ }
231
+
232
+ /** @type {string | undefined} */
233
+ let placeholderShape;
234
+
235
+ /**
236
+ * Whether a top-level statement declares the placeholder component exactly
237
+ * as the CLI writes it, under any name. Returns the name, or null.
238
+ * @param {any} statement
239
+ * @returns {string | null}
240
+ */
241
+ function placeholderDeclared(statement) {
242
+ const node =
243
+ statement.type === 'ExportNamedDeclaration'
244
+ ? statement.declaration
245
+ : statement;
246
+ if (node?.type !== 'FunctionDeclaration' || !node.id) return null;
247
+ placeholderShape ??= declarationShape(
248
+ parseSource(placeholderDeclaration(PLACEHOLDER_NAME)).program.body[0],
249
+ );
250
+ return declarationShape(node) === placeholderShape ? node.id.name : null;
251
+ }
252
+
253
+ /**
254
+ * Each use of the placeholder component in a file: the name it is declared
255
+ * under and the meanings its comment names (null when it names none). Only
256
+ * the declaration the CLI writes counts, and only code: commented-out code
257
+ * and text in strings are never uses. A closing JSX tag is part of its
258
+ * element's use. Null when the source does not parse.
259
+ * @param {string} source
260
+ * @returns {Array<{local: string, meanings: string[] | null}> | null}
261
+ */
262
+ export function placeholderUses(source) {
263
+ let ast;
264
+ try {
265
+ ast = parseSource(source);
266
+ } catch {
267
+ return null;
268
+ }
269
+ /** @type {Set<any>} */
270
+ const declarations = new Set();
271
+ /** @type {Set<string>} */
272
+ const names = new Set();
273
+ for (const statement of ast.program.body) {
274
+ const name = placeholderDeclared(statement);
275
+ if (name) {
276
+ names.add(name);
277
+ declarations.add(statement.declaration ?? statement);
278
+ }
279
+ }
280
+ if (names.size === 0) return [];
281
+ /** @type {Array<{local: string, meanings: string[] | null}>} */
282
+ const uses = [];
283
+ walk(ast.program, (node, parent, key) => {
284
+ if (node.type !== 'Identifier' && node.type !== 'JSXIdentifier') return;
285
+ if (!names.has(node.name)) return;
286
+ if (declarations.has(parent) && key === 'id') return;
287
+ if (parent?.type === 'JSXClosingElement') return;
288
+ if (!isReference(node, parent, key)) return;
289
+ const note = (ast.comments ?? []).find(
290
+ (/** @type {any} */ comment) =>
291
+ comment.start >= node.end &&
292
+ /^\s*$/u.test(source.slice(node.end, comment.start)),
293
+ );
294
+ uses.push({
295
+ local: node.name,
296
+ meanings: note
297
+ ? placeholderMeanings(source.slice(note.start, note.end))
298
+ : null,
299
+ });
300
+ });
301
+ return uses;
302
+ }
303
+
304
+ /**
305
+ * The text of one import specifier the rewrite adds.
306
+ * @param {{imported: string, local: string}} entry
307
+ */
308
+ function specifierText(entry) {
309
+ return entry.local === entry.imported
310
+ ? entry.imported
311
+ : `${entry.imported} as ${entry.local}`;
312
+ }
313
+
314
+ /**
315
+ * The commands that give a placeholder icon a real one.
316
+ * @param {string[]} definitions
317
+ * @param {string} main the main set's name as the list writes it
318
+ * @returns {string[]}
319
+ */
320
+ export function placeholderFixes(definitions, main) {
321
+ return [
322
+ `astryx experimental icon map ${definitions[0] ?? '<definition>'} <icon> --set ${main}`,
323
+ `astryx experimental icon use ${main} --backup <set>`,
324
+ ];
325
+ }
102
326
 
103
327
  /**
104
328
  * Index every installed set's exports: `<module>\0<export>` → the icon it
@@ -224,6 +448,25 @@ function targetExport(icon, set, variant) {
224
448
  return {module, export: icon.export};
225
449
  }
226
450
 
451
+ /**
452
+ * The look a mapped icon draws when the source asked for one the target's
453
+ * icon does not have: what it asked for and the target's default look.
454
+ * Undefined when the look is kept, or when the source names no look.
455
+ * @param {IconSetIcon} icon
456
+ * @param {DiscoveredIconSet} set
457
+ * @param {string | undefined} variant
458
+ * @returns {{asked: string, used: string | null} | undefined}
459
+ */
460
+ function lookChange(icon, set, variant) {
461
+ if (!variant) return undefined;
462
+ const fallback = set.data.default?.variant ?? null;
463
+ if (variant === fallback) return undefined;
464
+ if ((icon.variants ?? []).some(each => each.variant === variant)) {
465
+ return undefined;
466
+ }
467
+ return {asked: variant, used: fallback};
468
+ }
469
+
227
470
  /**
228
471
  * Whether a module is one of Astryx's own packages (`@astryxdesign/core`,
229
472
  * its subpaths, and every other `@astryxdesign/` package).
@@ -257,12 +500,13 @@ function declaredKey(specifier) {
257
500
 
258
501
  /**
259
502
  * Every binding in a parsed module that a declared key can name: each import
260
- * specifier (with whether it is type-only) and each named re-export.
503
+ * specifier (with its local name and whether it is type-only) and each
504
+ * named re-export.
261
505
  * @param {any} ast
262
- * @returns {Array<{key: string, module: string, export: string, form: 'named' | 'default' | 'namespace' | 'type' | 're-export'}>}
506
+ * @returns {Array<{key: string, local?: string, module: string, export: string, form: 'named' | 'default' | 'namespace' | 'type' | 're-export'}>}
263
507
  */
264
508
  function iconBindings(ast) {
265
- /** @type {Array<{key: string, module: string, export: string, form: 'named' | 'default' | 'namespace' | 'type' | 're-export'}>} */
509
+ /** @type {Array<{key: string, local?: string, module: string, export: string, form: 'named' | 'default' | 'namespace' | 'type' | 're-export'}>} */
266
510
  const found = [];
267
511
  for (const node of ast.program.body) {
268
512
  if (node.type === 'ImportDeclaration') {
@@ -272,6 +516,7 @@ function iconBindings(ast) {
272
516
  node.importKind === 'type' || specifier.importKind === 'type';
273
517
  found.push({
274
518
  key: declaredKey(specifier),
519
+ local: specifier.local.name,
275
520
  module,
276
521
  export: importedName(specifier),
277
522
  form: typeOnly
@@ -318,6 +563,196 @@ export function iconsNotImported(source, declared) {
318
563
  return Object.keys(declared).filter(key => !keys.has(key));
319
564
  }
320
565
 
566
+ /**
567
+ * Whether a module is a package import: not relative, not absolute, and not
568
+ * a Node built-in.
569
+ * @param {string} module
570
+ */
571
+ function isPackageModule(module) {
572
+ return !/^(?:\.|\/|node:)/u.test(module);
573
+ }
574
+
575
+ /** The props that choose which icon a generic renderer draws. */
576
+ const ICON_CHOOSING_PROPS = new Set(['icon', 'iconNode', 'name', 'src']);
577
+
578
+ /**
579
+ * The local names every JSX use of which passes an icon-choosing prop, as
580
+ * `<DynamicIcon name="trash" />` does. A name with no JSX use is left out.
581
+ * @param {any} ast
582
+ * @returns {Set<string>}
583
+ */
584
+ function iconPropRenderers(ast) {
585
+ /** @type {Map<string, boolean>} local name → every use passes one */
586
+ const uses = new Map();
587
+ walk(ast.program, node => {
588
+ if (node.type !== 'JSXOpeningElement' || node.name.type !== 'JSXIdentifier')
589
+ return;
590
+ const chooses = node.attributes.some(
591
+ (/** @type {any} */ attribute) =>
592
+ attribute.type === 'JSXAttribute' &&
593
+ attribute.name.type === 'JSXIdentifier' &&
594
+ ICON_CHOOSING_PROPS.has(attribute.name.name),
595
+ );
596
+ const name = node.name.name;
597
+ uses.set(name, (uses.get(name) ?? true) && chooses);
598
+ });
599
+ return new Set([...uses].filter(([, every]) => every).map(([name]) => name));
600
+ }
601
+
602
+ /** Parents under which a reference passes the name on as a value. */
603
+ const VALUE_POSITIONS = new Set([
604
+ 'JSXExpressionContainer',
605
+ 'ArrayExpression',
606
+ 'ObjectProperty',
607
+ 'VariableDeclarator',
608
+ 'AssignmentExpression',
609
+ 'AssignmentPattern',
610
+ 'ConditionalExpression',
611
+ 'LogicalExpression',
612
+ 'ReturnStatement',
613
+ 'ArrowFunctionExpression',
614
+ 'TSAsExpression',
615
+ 'TSSatisfiesExpression',
616
+ 'TSNonNullExpression',
617
+ 'ParenthesizedExpression',
618
+ 'ExportDefaultDeclaration',
619
+ ]);
620
+
621
+ /**
622
+ * Whether a JSX element has no children. Text that is only white space is
623
+ * layout, not a child.
624
+ * @param {any} element
625
+ */
626
+ function isChildless(element) {
627
+ return element.children.every(
628
+ (/** @type {any} */ child) =>
629
+ child.type === 'JSXText' && child.value.trim() === '',
630
+ );
631
+ }
632
+
633
+ /**
634
+ * The local names a template uses as an icon: it renders the name as a
635
+ * childless JSX element that passes no icon-choosing prop (`<Trash />`), or it
636
+ * passes the name as a value (`icon={Trash}`, `[Trash]`, `{icon: Trash}`). A
637
+ * type position, a call or its arguments, a spread, a member access, and an
638
+ * element with children are not icon uses: a record of every icon passes
639
+ * through those too.
640
+ * @param {any} ast
641
+ * @returns {Set<string>}
642
+ */
643
+ function iconUses(ast) {
644
+ /** @type {Set<string>} */
645
+ const used = new Set();
646
+ walk(ast.program, (node, parent, key) => {
647
+ if (node.type === 'JSXElement') {
648
+ const opening = node.openingElement;
649
+ if (opening.name.type !== 'JSXIdentifier' || !isChildless(node)) return;
650
+ const chooses = opening.attributes.some(
651
+ (/** @type {any} */ attribute) =>
652
+ attribute.type === 'JSXAttribute' &&
653
+ attribute.name.type === 'JSXIdentifier' &&
654
+ ICON_CHOOSING_PROPS.has(attribute.name.name),
655
+ );
656
+ if (!chooses) used.add(opening.name.name);
657
+ return;
658
+ }
659
+ if (node.type !== 'Identifier' || !parent) return;
660
+ if (!VALUE_POSITIONS.has(parent.type) || !isReference(node, parent, key)) {
661
+ return;
662
+ }
663
+ if (parent.type === 'VariableDeclarator' && key !== 'init') return;
664
+ if (parent.type === 'AssignmentExpression' && key !== 'right') return;
665
+ if (parent.type === 'AssignmentPattern' && key !== 'right') return;
666
+ if (parent.type === 'ArrowFunctionExpression' && key !== 'body') return;
667
+ if (parent.type === 'ConditionalExpression' && key === 'test') return;
668
+ used.add(node.name);
669
+ });
670
+ return used;
671
+ }
672
+
673
+ /**
674
+ * The value imports a template takes from other packages, as the keys its
675
+ * `icons` uses: a named import by the name it imports, a default import by
676
+ * its local name. Astryx's own packages, relative files, namespace imports,
677
+ * and type-only imports are left out. `renderer` marks a generic renderer:
678
+ * the key is `Icon`, or every JSX use passes an icon-choosing prop. `icon`
679
+ * marks an import the template uses as an icon: it renders it as a childless
680
+ * element or passes it as a value. Null when the source does not parse.
681
+ * @param {string} source
682
+ * @returns {Array<{key: string, module: string, renderer: boolean, icon: boolean}> | null}
683
+ */
684
+ export function packageImports(source) {
685
+ let ast;
686
+ try {
687
+ ast = parseSource(source);
688
+ } catch {
689
+ return null;
690
+ }
691
+ const renderers = iconPropRenderers(ast);
692
+ const uses = iconUses(ast);
693
+ return iconBindings(ast)
694
+ .filter(
695
+ binding =>
696
+ (binding.form === 'named' || binding.form === 'default') &&
697
+ isPackageModule(binding.module) &&
698
+ !isAstryxModule(binding.module),
699
+ )
700
+ .map(({key, local, module}) => ({
701
+ key,
702
+ module,
703
+ renderer: key === 'Icon' || renderers.has(local ?? key),
704
+ icon: uses.has(local ?? key),
705
+ }));
706
+ }
707
+
708
+ /**
709
+ * The icons each template imports but does not declare in its `icons`. An
710
+ * import is undeclared when it comes from a module that some template
711
+ * declares an icon from (an icon library) and the template uses it as an
712
+ * icon: it renders it as a childless element or passes it as a value. Any
713
+ * other import from that module, such as a type, a factory it calls, or a
714
+ * provider with children, is listed as not checked (`use`): declaring it
715
+ * would make a template swap it for an icon. The check also cannot tell that
716
+ * an import draws one icon definition when it is a generic renderer, or when
717
+ * it comes from any other package and only its key ends in `Icon`, so it
718
+ * lists those as not checked (`architecture:cli-surface` DEC-7). Only
719
+ * templates that parse are read.
720
+ * @param {Array<{id: string, source: string, declared: Record<string, string>}>} templates
721
+ * @returns {{undeclared: Array<{id: string, key: string, module: string}>, unchecked: Array<{id: string, key: string, module: string, why: 'renderer' | 'use' | 'name'}>}}
722
+ */
723
+ export function undeclaredTemplateIcons(templates) {
724
+ const read = templates.flatMap(template => {
725
+ const imports = packageImports(template.source);
726
+ return imports ? [{...template, imports}] : [];
727
+ });
728
+ /** Modules some template declares an icon from. */
729
+ const iconModules = new Set(
730
+ read.flatMap(template =>
731
+ template.imports
732
+ .filter(each => Object.hasOwn(template.declared, each.key))
733
+ .map(each => each.module),
734
+ ),
735
+ );
736
+ /** @type {Array<{id: string, key: string, module: string}>} */
737
+ const undeclared = [];
738
+ /** @type {Array<{id: string, key: string, module: string, why: 'renderer' | 'use' | 'name'}>} */
739
+ const unchecked = [];
740
+ for (const template of read) {
741
+ for (const each of template.imports) {
742
+ if (Object.hasOwn(template.declared, each.key)) continue;
743
+ const iconLike = iconModules.has(each.module) || /Icon$/u.test(each.key);
744
+ if (!iconLike) continue;
745
+ const found = {id: template.id, key: each.key, module: each.module};
746
+ if (each.renderer) unchecked.push({...found, why: 'renderer'});
747
+ else if (!iconModules.has(each.module))
748
+ unchecked.push({...found, why: 'name'});
749
+ else if (each.icon) undeclared.push(found);
750
+ else unchecked.push({...found, why: 'use'});
751
+ }
752
+ }
753
+ return {undeclared, unchecked};
754
+ }
755
+
321
756
  /**
322
757
  * A library's own sizing props, dropped from a mapped icon: an icon set's
323
758
  * icons take their size from CSS.
@@ -350,15 +785,23 @@ export function mapTemplateIcons(source, {sets, choice}, declared = {}) {
350
785
  return {source, icons: undefined};
351
786
  }
352
787
  const target = choice.set ?? null;
788
+ /** The sets an icon is looked up in, in order: the main set first. */
789
+ const targets = target ? (choice.order ?? [target]) : [];
353
790
  /** @type {TemplateIconsReport} */
354
791
  const icons = {
355
792
  status: choice.status,
356
793
  set: target ? `${target.package}/${target.name}` : null,
794
+ ...(targets.length > 1
795
+ ? {order: targets.map(each => `${each.package}/${each.name}`)}
796
+ : {}),
357
797
  reason: choice.reason,
358
798
  mapped: [],
799
+ placeholder: [],
359
800
  kept: [],
801
+ sets: [],
360
802
  };
361
803
  if (!target) return {source, icons};
804
+ const main = choice.configured ?? target.name;
362
805
 
363
806
  /** @type {any} */
364
807
  let ast;
@@ -369,12 +812,14 @@ export function mapTemplateIcons(source, {sets, choice}, declared = {}) {
369
812
  }
370
813
  const index = indexSets(sets);
371
814
  const targetModules = new Set(
372
- target.data.icons.flatMap(icon => [
373
- icon.module ?? target.data.module,
374
- ...(icon.variants ?? []).map(
375
- variant => variant.module ?? icon.module ?? target.data.module,
376
- ),
377
- ]),
815
+ targets.flatMap(each =>
816
+ each.data.icons.flatMap(icon => [
817
+ icon.module ?? each.data.module,
818
+ ...(icon.variants ?? []).map(
819
+ variant => variant.module ?? icon.module ?? each.data.module,
820
+ ),
821
+ ]),
822
+ ),
378
823
  );
379
824
 
380
825
  /** Every name the file writes, to keep aliases collision-free. */
@@ -386,6 +831,10 @@ export function mapTemplateIcons(source, {sets, choice}, declared = {}) {
386
831
  const renames = new Map();
387
832
  /** @type {Map<string, {module: string, imported: string, local: string}>} */
388
833
  const added = new Map();
834
+ /** @type {Map<string, string>} a placeholder's old local name → the comment each use carries */
835
+ const notes = new Map();
836
+ /** The placeholder component's name, once an icon needs it. */
837
+ let placeholderName = '';
389
838
  /**
390
839
  * Every local name whose import maps away, found first, so a mapped name
391
840
  * may reuse a name another mapped import frees.
@@ -393,8 +842,9 @@ export function mapTemplateIcons(source, {sets, choice}, declared = {}) {
393
842
  */
394
843
  const freed = new Set();
395
844
  /**
396
- * The source record and the match of each mapped import specifier.
397
- * @type {Map<any, {record: SourceIcon, match: ReturnType<typeof matchIcon>}>}
845
+ * The source record of each mapped import specifier, the first set in the
846
+ * list that matches it, and the match.
847
+ * @type {Map<any, {record: SourceIcon, set: DiscoveredIconSet, match: ReturnType<typeof matchIcon>}>}
398
848
  */
399
849
  const planned = new Map();
400
850
  for (const node of ast.program.body) {
@@ -437,10 +887,20 @@ export function mapTemplateIcons(source, {sets, choice}, declared = {}) {
437
887
  definitions: [meant],
438
888
  }
439
889
  : null;
440
- if (!record || record.set === target) continue;
441
- const match = matchIcon(record, target);
442
- planned.set(specifier, {record, match});
443
- if (match) freed.add(specifier.local.name);
890
+ if (!record || (record.set && targets.includes(record.set))) continue;
891
+ /** @type {{set: DiscoveredIconSet, match: ReturnType<typeof matchIcon>}} */
892
+ let found = {set: target, match: null};
893
+ for (const each of targets) {
894
+ const match = matchIcon(record, each);
895
+ if (match) {
896
+ found = {set: each, match};
897
+ break;
898
+ }
899
+ }
900
+ planned.set(specifier, {record, ...found});
901
+ if (found.match || record.definitions.length > 0) {
902
+ freed.add(specifier.local.name);
903
+ }
444
904
  }
445
905
  }
446
906
  /** Where the mapped imports go: where the first rewritten import was. */
@@ -449,7 +909,7 @@ export function mapTemplateIcons(source, {sets, choice}, declared = {}) {
449
909
  const accounted = new Set();
450
910
  /** The local names of mapped imports, as the template wrote them. */
451
911
  const mappedLocals = new Set();
452
- /** @type {Array<[MappedIcon, string]>} each mapped icon and its local name */
912
+ /** @type {Array<[MappedIcon | PlaceholderIcon, string]>} each mapped or placeholder icon and its local name */
453
913
  const mappedEntries = [];
454
914
 
455
915
  for (const node of ast.program.body) {
@@ -471,8 +931,44 @@ export function mapTemplateIcons(source, {sets, choice}, declared = {}) {
471
931
  }
472
932
  const imported = importedName(specifier);
473
933
  const local = specifier.local.name;
474
- const {record, match} = plan;
934
+ const {record, set, match} = plan;
475
935
  accounted.add(declaredKey(specifier));
936
+ if (!match && record.definitions.length > 0) {
937
+ // No set in the list has this meaning: each use renders the local
938
+ // placeholder, so the file imports nothing new.
939
+ const likely = reviewedMatch(record, target, ['likely']);
940
+ if (!placeholderName) {
941
+ let name = PLACEHOLDER_NAME;
942
+ const taken = (/** @type {string} */ candidate) =>
943
+ written.has(candidate) ||
944
+ [...added.values()].some(each => each.local === candidate);
945
+ for (let n = 2; taken(name); n++) name = `${PLACEHOLDER_NAME}${n}`;
946
+ placeholderName = name;
947
+ }
948
+ renames.set(local, placeholderName);
949
+ notes.set(local, placeholderNote(record.definitions));
950
+ mappedLocals.add(local);
951
+ /** @type {PlaceholderIcon} */
952
+ const placeholderIcon = {
953
+ source: {module, export: imported},
954
+ target: {local: placeholderName},
955
+ definitions: record.definitions,
956
+ fixes: placeholderFixes(record.definitions, main),
957
+ ...(likely
958
+ ? {
959
+ suggestion: {
960
+ set: target.name,
961
+ id: likely.id,
962
+ ...targetExport(likely, target, record.variant),
963
+ confidence: /** @type {const} */ ('likely'),
964
+ },
965
+ }
966
+ : {}),
967
+ };
968
+ icons.placeholder.push(placeholderIcon);
969
+ mappedEntries.push([placeholderIcon, local]);
970
+ continue;
971
+ }
476
972
  if (!match) {
477
973
  keep.push(specifier);
478
974
  const likely = reviewedMatch(record, target, ['likely']);
@@ -482,7 +978,7 @@ export function mapTemplateIcons(source, {sets, choice}, declared = {}) {
482
978
  reason:
483
979
  record.definitions.length > 0
484
980
  ? `${icons.set} maps no icon to the icon definition "${record.definitions.join('", "')}"${record.setName ? ', and none is a reviewed match for it' : ''}`
485
- : `no icon in ${icons.set} is a reviewed match for it`,
981
+ : `no icon in ${targets.length > 1 ? 'any set in your list' : icons.set} is a reviewed match for it`,
486
982
  ...(likely
487
983
  ? {
488
984
  suggestion: {
@@ -496,7 +992,8 @@ export function mapTemplateIcons(source, {sets, choice}, declared = {}) {
496
992
  });
497
993
  continue;
498
994
  }
499
- const chosen = targetExport(match.icon, target, record.variant);
995
+ const chosen = targetExport(match.icon, set, record.variant);
996
+ const look = lookChange(match.icon, set, record.variant);
500
997
  const key = `${chosen.module}\0${chosen.export}`;
501
998
  let entry = added.get(key);
502
999
  if (!entry) {
@@ -507,6 +1004,7 @@ export function mapTemplateIcons(source, {sets, choice}, declared = {}) {
507
1004
  const base = name;
508
1005
  const taken = (/** @type {string} */ candidate) =>
509
1006
  (written.has(candidate) && !freed.has(candidate)) ||
1007
+ candidate === placeholderName ||
510
1008
  [...added.values()].some(each => each.local === candidate);
511
1009
  for (let n = 2; taken(name); n++) name = `${base}${n}`;
512
1010
  entry = {module: chosen.module, imported: chosen.export, local: name};
@@ -518,13 +1016,14 @@ export function mapTemplateIcons(source, {sets, choice}, declared = {}) {
518
1016
  const mappedIcon = {
519
1017
  source: {module, export: imported},
520
1018
  target: {
521
- set: target.name,
522
- package: target.package,
1019
+ set: set.name,
1020
+ package: set.package,
523
1021
  id: match.icon.id,
524
1022
  module: chosen.module,
525
1023
  export: chosen.export,
526
1024
  },
527
1025
  by: match.by,
1026
+ ...(look ? {variant: look} : {}),
528
1027
  };
529
1028
  icons.mapped.push(mappedIcon);
530
1029
  mappedEntries.push([mappedIcon, local]);
@@ -611,7 +1110,10 @@ export function mapTemplateIcons(source, {sets, choice}, declared = {}) {
611
1110
  const typeOnly = pick(each => each.form === 'type');
612
1111
  const reExport = pick(each => each.form === 're-export');
613
1112
  const [binding, reason] = fromTarget
614
- ? [fromTarget, `it is already imported from ${icons.set}`]
1113
+ ? [
1114
+ fromTarget,
1115
+ `it is already imported from ${targets.length > 1 ? 'a set in your list' : icons.set}`,
1116
+ ]
615
1117
  : namespace
616
1118
  ? [
617
1119
  namespace,
@@ -637,13 +1139,32 @@ export function mapTemplateIcons(source, {sets, choice}, declared = {}) {
637
1139
  });
638
1140
  }
639
1141
 
640
- if (added.size === 0) return {source, icons};
1142
+ /** @type {Map<DiscoveredIconSet, number>} */
1143
+ const used = new Map();
1144
+ for (const plan of planned.values()) {
1145
+ if (plan.match) used.set(plan.set, (used.get(plan.set) ?? 0) + 1);
1146
+ }
1147
+ icons.sets = targets
1148
+ .filter(each => used.has(each))
1149
+ .map(each => ({
1150
+ set: `${each.package}/${each.name}`,
1151
+ license: each.license,
1152
+ icons: /** @type {number} */ (used.get(each)),
1153
+ }));
1154
+
1155
+ if (added.size === 0 && !placeholderName) return {source, icons};
641
1156
 
642
- // Every reference to a renamed binding uses the mapped name.
1157
+ // Every reference to a renamed binding uses the mapped name. A use of the
1158
+ // placeholder carries a comment that names its meaning.
643
1159
  walk(ast.program, (node, parent, key) => {
644
1160
  if (node.type === 'Identifier' || node.type === 'JSXIdentifier') {
645
- const next = renames.get(node.name);
646
- if (!next || !isReference(node, parent, key)) return;
1161
+ const renamed = renames.get(node.name);
1162
+ if (!renamed || !isReference(node, parent, key)) return;
1163
+ const note = notes.get(node.name);
1164
+ const next =
1165
+ note && parent?.type !== 'JSXClosingElement'
1166
+ ? `${renamed} ${note}`
1167
+ : renamed;
647
1168
  if (parent?.type === 'ObjectProperty' && parent.shorthand) {
648
1169
  edits.push({
649
1170
  start: node.start,
@@ -692,21 +1213,54 @@ export function mapTemplateIcons(source, {sets, choice}, declared = {}) {
692
1213
  if (!prior || edit.text.length > prior.text.length) unique.set(at, edit);
693
1214
  }
694
1215
 
1216
+ // The placeholder is declared once, above the file's first statement that
1217
+ // is not an import, and after any comment that leads that statement.
1218
+ if (placeholderName) {
1219
+ const first = ast.program.body.find(
1220
+ (/** @type {any} */ node) => node.type !== 'ImportDeclaration',
1221
+ );
1222
+ const declaration = placeholderDeclaration(placeholderName);
1223
+ if (first) {
1224
+ const previous = ast.program.body[ast.program.body.indexOf(first) - 1];
1225
+ const after = previous ? previous.loc.end.line : 0;
1226
+ // A comment on the line of the statement before it trails that
1227
+ // statement, so the declaration goes after it.
1228
+ const at = Math.min(
1229
+ first.start,
1230
+ ...(first.leadingComments ?? [])
1231
+ .filter(
1232
+ (/** @type {any} */ comment) => comment.loc.start.line > after,
1233
+ )
1234
+ .map((/** @type {any} */ comment) => comment.start),
1235
+ );
1236
+ unique.set('placeholder', {
1237
+ start: at,
1238
+ end: at,
1239
+ text: `${declaration}\n\n`,
1240
+ });
1241
+ } else {
1242
+ const at = source.length;
1243
+ unique.set('placeholder', {
1244
+ start: at,
1245
+ end: at,
1246
+ text: `${source.endsWith('\n') ? '' : '\n'}\n${declaration}\n`,
1247
+ });
1248
+ }
1249
+ }
1250
+
695
1251
  /** @type {Map<string, string[]>} */
696
1252
  const byModule = new Map();
697
1253
  for (const entry of added.values()) {
698
1254
  const list = byModule.get(entry.module) ?? [];
699
- list.push(
700
- entry.local === entry.imported
701
- ? entry.imported
702
- : `${entry.imported} as ${entry.local}`,
703
- );
1255
+ list.push(specifierText(entry));
704
1256
  byModule.set(entry.module, list);
705
1257
  }
706
1258
  const lines = [...byModule.entries()]
707
1259
  .map(([module, names]) => `import {${names.join(', ')}} from '${module}';`)
708
1260
  .join('\n');
709
- unique.set('insert', {start: insertAt, end: insertAt, text: `${lines}\n`});
1261
+ if (lines) {
1262
+ unique.set('insert', {start: insertAt, end: insertAt, text: `${lines}\n`});
1263
+ }
710
1264
 
711
1265
  let out = source;
712
1266
  for (const edit of [...unique.values()].sort(