@astryxdesign/cli 0.7.12 → 0.7.13-canary.1719285

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
@@ -19,6 +19,7 @@ export const doc = {
19
19
  Image: 'image',
20
20
  Plus: 'add',
21
21
  Sparkles: 'sparkles',
22
+ Type: 'text',
22
23
  Video: 'video',
23
24
  Zap: 'bolt',
24
25
  },
@@ -68,7 +68,7 @@ export const doc = {
68
68
  description:
69
69
  'Tell `{@command discover}` which integrations this project could add: an async function that returns a catalog. An integration can provide one too, as a `discover` named export from its manifest. Discover calls every source, yours first, and one that fails never hides the others. Discover only reads; your package manager installs.',
70
70
  example:
71
- "async ({signal, package: name, version}) => fetchCatalog({signal, name, version})",
71
+ 'async ({signal, package: name, version}) => fetchCatalog({signal, name, version})',
72
72
  },
73
73
  {
74
74
  name: 'experimental',
@@ -77,11 +77,11 @@ export const doc = {
77
77
  fields: [
78
78
  {
79
79
  name: 'experimental.iconSet',
80
- type: 'string',
80
+ type: 'string | string[]',
81
81
  stability: 'experimental',
82
82
  description:
83
- "Experimental: may change or be removed in a patch release. The project's icon set, by set name or as `<package>/<set>`. It is the opt-in: copied templates use its icons, and `astryx discover` and `astryx doctor` report icon sets, only when it is set. `astryx experimental icon` ranks its icons first; without it, the only installed set is the project's there. `astryx search` never returns icons.",
84
- example: "'@acme/outline-icons/outline'",
83
+ "Experimental: may change or be removed in a patch release. The project's main icon set, by set name or as `<package>/<set>`; or a list of them, the main set first and then backup sets in order. A copied template takes each icon from the first set in the list that has its meaning, and draws a placeholder the copied file declares when none has it. It is the opt-in: copied templates use these icons, and `astryx discover` and `astryx doctor` report icon sets, only when it is set. `astryx experimental icon use <main> --backup <set>` writes it. `astryx experimental icon` ranks the main set's icons first; without it, the only installed set is the project's there. `astryx search` never returns icons.",
84
+ example: "['@acme/outline-icons/outline', '@acme/brand-icons/brand']",
85
85
  },
86
86
  {
87
87
  name: 'experimental.xle.components',
@@ -58,7 +58,7 @@ declare const configSchema: z.ZodObject<{
58
58
  default: z.ZodOptional<z.ZodBoolean>;
59
59
  }, z.core.$strict>>>;
60
60
  }, z.core.$strict>>;
61
- iconSet: z.ZodOptional<z.ZodString>;
61
+ iconSet: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
62
62
  }, z.core.$strict>>;
63
63
  }, z.core.$strict>;
64
64
  export {};
@@ -103,7 +103,9 @@ const configSchema = z
103
103
  })
104
104
  .strict()
105
105
  .optional(),
106
- iconSet: z.string().min(1).optional(),
106
+ iconSet: z
107
+ .union([z.string().min(1), z.array(z.string().min(1)).min(1)])
108
+ .optional(),
107
109
  })
108
110
  .strict()
109
111
  .optional(),
@@ -123,12 +123,14 @@ export interface AstryxConfig {
123
123
  components?: Record<string, XleComponent>;
124
124
  };
125
125
  /**
126
- * Experimental icon sets (spec:AST-084): the project's icon set, a set
127
- * name (`"outline"`) or `"<package>/<set>"`, and the opt-in. Copied
128
- * templates use its icons, and discover and Doctor report icon sets, only
129
- * when it is set.
126
+ * Experimental icon sets (spec:AST-084): the project's main icon set, a
127
+ * set name (`"outline"`) or `"<package>/<set>"`, and the opt-in. A list
128
+ * (`["outline", "@acme/icons/brand"]`) names the main set first, then
129
+ * backup sets in order: a copied template takes each icon from the first
130
+ * set that has its meaning. Copied templates use these icons, and
131
+ * discover and Doctor report icon sets, only when it is set.
130
132
  * @experimental May change or be removed in a patch release.
131
133
  */
132
- iconSet?: string;
134
+ iconSet?: string | string[];
133
135
  };
134
136
  }
@@ -69,7 +69,7 @@ export const doc = {
69
69
  type: 'Record<string, string>',
70
70
  stability: 'experimental',
71
71
  description:
72
- "The icon definition each icon the source imports draws, such as `{TrashIcon: 'delete'}`: a named import is keyed by the name it imports (`{Trash as TrashIcon}` is `Trash`), a default import by its local name (`import DeleteIcon from …` is `DeleteIcon`). When the project sets `experimental.iconSet`, `astryx template` (shown or copied) replaces each declared icon with the set's icon for that definition (`astryx docs cli/experimental/icon-definitions`) and reports every key as mapped or kept; an undeclared import is left alone. A name imported from two modules (`TrashIcon` from an outline and a solid module) needs one key, and each import keeps its variant when its module belongs to an installed set.",
72
+ "The icon definition each icon the source imports draws, such as `{TrashIcon: 'delete'}`: a named import is keyed by the name it imports (`{Trash as TrashIcon}` is `Trash`), a default import by its local name (`import DeleteIcon from …` is `DeleteIcon`). When the project sets `experimental.iconSet`, `astryx template` (shown or copied) replaces each declared icon with the icon for that definition (`astryx docs cli/experimental/icon-definitions`) from the first set in the project's list that maps it, or with a placeholder the copied file declares when no set does, and reports every key as mapped, a placeholder, or kept; an undeclared import is left alone, and `astryx integration check` notes it (`template_icon_undeclared`, failing `--strict --next`) in a package whose templates declare `icons`. A name imported from two modules (`TrashIcon` from an outline and a solid module) needs one key, and each import keeps its variant when its module belongs to an installed set.",
73
73
  },
74
74
  {
75
75
  name: 'isReady',
@@ -55,9 +55,11 @@ export interface BaseTemplateDoc extends AuthoredDocGraphFields {
55
55
  * (`{TrashIcon: 'delete'}`), keyed by the name a named import reads
56
56
  * (`{Trash as TrashIcon}` is `Trash`) or a default import's local name
57
57
  * (`import DeleteIcon from …` is `DeleteIcon`). When the project has an
58
- * icon set, each declared icon is replaced by the set's icon for that
59
- * definition (`astryx docs cli/experimental/icon-definitions`), and every
60
- * key is reported as mapped or kept. A name imported from two modules
58
+ * icon set, each declared icon is replaced by the icon for that
59
+ * definition (`astryx docs cli/experimental/icon-definitions`) from the
60
+ * first set in the project's list that maps it, or by a placeholder the
61
+ * copied file declares when no set does, and every key is reported as mapped, a
62
+ * placeholder, or kept. A name imported from two modules
61
63
  * (`TrashIcon` from an outline and a solid module) needs one key.
62
64
  * @experimental May change or be removed in a patch release. */
63
65
  icons?: Record<string, string>;
@@ -256,10 +256,17 @@ export function registerExperimentalIcon(group, program) {
256
256
  return answered;
257
257
  }
258
258
 
259
- const {status, projectSet, reason, sets, withdrawn} = result.data;
259
+ const {status, projectSet, reason, sets, withdrawn, order, next} =
260
+ result.data;
260
261
  emit(
261
262
  section('Icon sets'),
262
- record({status, projectSet, reason}),
263
+ record({
264
+ status,
265
+ projectSet,
266
+ ...(order.length > 1 ? {order: order.join(' > ')} : {}),
267
+ reason,
268
+ }),
269
+ ...(next ? [text(`[warn] ${next}`)] : []),
263
270
  ...(sets.length > 0
264
271
  ? [
265
272
  records(
@@ -283,6 +290,7 @@ export function registerExperimentalIcon(group, program) {
283
290
  'definitions',
284
291
  'own',
285
292
  'missing',
293
+ 'role',
286
294
  'project',
287
295
  'description',
288
296
  ],
@@ -346,21 +354,42 @@ export function registerExperimentalIcon(group, program) {
346
354
 
347
355
  defineCommand(iconCli, iconUseCommand, {
348
356
  fn: iconUseFn,
349
- action: (/** @type {string} */ set) =>
357
+ action: (
358
+ /** @type {string} */ set,
359
+ /** @type {{backup?: string[]}} */ options,
360
+ ) =>
350
361
  run(
351
- () => iconUse(set, {cwd: process.cwd()}),
352
- ({data}) =>
362
+ () => iconUse(set, {cwd: process.cwd(), backups: options.backup ?? []}),
363
+ ({data}) => {
364
+ /** @type {Array<{iconSet: string, set: string}>} */
365
+ const backups = data.backups;
366
+ const shown = (/** @type {string | string[]} */ value) =>
367
+ Array.isArray(value) ? JSON.stringify(value) : `"${value}"`;
368
+ const main = `"${data.iconSet}" (${data.set})`;
369
+ const order =
370
+ backups.length > 0
371
+ ? `main set ${main}, then ${backups.map(each => `"${each.iconSet}" (${each.set})`).join(', then ')}`
372
+ : main;
353
373
  emit(
354
374
  text(
355
375
  data.changed
356
- ? `[ok] experimental.iconSet is now "${data.iconSet}" (${data.set}) in ${data.config}${data.created ? ', created' : ''}.`
357
- : `[ok] experimental.iconSet already names "${data.iconSet}" in ${data.config}.`,
376
+ ? `[ok] experimental.iconSet is now ${order} in ${data.config}${data.created ? ', created' : ''}.`
377
+ : `[ok] experimental.iconSet already names ${order} in ${data.config}.`,
358
378
  ),
379
+ ...(backups.length > 0
380
+ ? [
381
+ text(
382
+ 'A copied template takes each icon from the first set in this order that has its meaning.',
383
+ ),
384
+ ]
385
+ : []),
359
386
  ...(data.previous && data.changed
360
- ? [text(`It was "${data.previous}".`)]
387
+ ? [text(`It was ${shown(data.previous)}.`)]
361
388
  : []),
389
+ ...(data.next ? [text(`[warn] ${data.next}`)] : []),
362
390
  ...(data.warning ? [text(`[warn] ${data.warning}`)] : []),
363
- ),
391
+ );
392
+ },
364
393
  ),
365
394
  });
366
395
 
@@ -9,13 +9,18 @@ export const doc = {
9
9
  displayName: 'astryx experimental icon use',
10
10
  namespace: 'cli/commands',
11
11
  stability: 'experimental',
12
- summary: "Choose the project's icon set",
12
+ summary: "Choose the project's main icon set and backups",
13
13
  description:
14
- "Writes `experimental.iconSet` in astryx.config, so copied templates and blocks use that set's icons. The set must be installed. Experimental: it may change or be removed in a patch release.",
14
+ "Writes `experimental.iconSet` in astryx.config, so copied templates and blocks use that set's icons. With `--backup`, it writes a list: the main set first, then each backup in the order given. A copied template takes each icon from the first set in the list that has its meaning. Every set must be installed. Experimental: it may change or be removed in a patch release.",
15
15
  fn: 'iconUse',
16
16
  args: [{name: 'set', param: 'set', required: true}],
17
+ options: [{flag: '--backup <set...>', param: 'options.backups'}],
17
18
  examples: [
18
19
  {label: 'Use a set', cli: 'astryx experimental icon use outline'},
20
+ {
21
+ label: 'Use a main set with a backup',
22
+ cli: 'astryx experimental icon use outline --backup @acme/brand-icons/brand',
23
+ },
19
24
  {
20
25
  label: 'Name its package',
21
26
  cli: 'astryx experimental icon use @acme/icons/outline',
@@ -25,11 +30,11 @@ export const doc = {
25
30
  exitCodes: [
26
31
  {
27
32
  code: 0,
28
- when: 'experimental.iconSet is written, or already names the set',
33
+ when: 'experimental.iconSet is written, or already names the sets',
29
34
  },
30
35
  {
31
36
  code: 1,
32
- when: 'the set is not installed, two packages ship it, the config cannot be updated (not a static object literal, or it spreads another object, which you then edit by hand), or the config is protected (a symbolic link, or marked generated, vendored, or ignored: `ERR_CODEMOD_PROTECTED`, naming the declaration)',
37
+ when: 'a set is not installed, two packages ship it, a set is named twice, the config cannot be updated (not a static object literal, or it spreads another object, which you then edit by hand), or the config is protected (a symbolic link, or marked generated, vendored, or ignored: `ERR_CODEMOD_PROTECTED`, naming the declaration)',
33
38
  },
34
39
  ],
35
40
  related: ['experimental icon', 'template'],
@@ -57,9 +57,10 @@ function demoContentNote(count, file) {
57
57
  }
58
58
 
59
59
  /**
60
- * The icon report as text lines (AST-084 FR10): one line per mapped or kept
61
- * icon, or one line saying why nothing was mapped. Nothing when the project
62
- * has no icon set at all (no report: icon sets are experimental).
60
+ * The icon report as text lines (AST-084 FR10): one line per mapped,
61
+ * placeholder, or kept icon, or one line saying why nothing was mapped.
62
+ * Nothing when the project has no icon set at all (no report: icon sets are
63
+ * experimental). A placeholder line names its meaning and both fixes.
63
64
  * @param {import('../../../api/template/template.type.mjs').TemplateIconsReport | undefined} icons
64
65
  * @returns {string[]}
65
66
  */
@@ -74,10 +75,18 @@ function iconLines(icons) {
74
75
  ...icons.mapped.map(
75
76
  each =>
76
77
  `icons: mapped ${each.source.export} (${each.source.module}) -> ${each.target.export} (${each.target.module}) by ${each.by}` +
78
+ (icons.order ? ` from ${each.target.package}/${each.target.set}` : '') +
79
+ (each.variant
80
+ ? `; ${each.variant.asked} look not in the set, drew ${each.variant.used ?? 'its default look'}`
81
+ : '') +
77
82
  (each.dropped && each.dropped.length > 0
78
83
  ? `; dropped ${each.dropped.join(', ')} (size comes from CSS)`
79
84
  : ''),
80
85
  ),
86
+ ...(icons.placeholder ?? []).map(
87
+ each =>
88
+ `icons: placeholder ${each.source.export} (${each.source.module}) -> ${each.target.local}: no set in your list has "${each.definitions.join('", "')}", so the file draws a placeholder it declares. Fix: add a backup set that has it, \`${each.fixes[1]}\`; or, if you author your main set, map it there, \`${each.fixes[0]}\`.`,
89
+ ),
81
90
  ...icons.kept.map(
82
91
  each =>
83
92
  `icons: kept ${each.export}${each.module == null ? '' : ` (${each.module})`}: ${each.reason}` +
@@ -14,6 +14,20 @@ export function setConfigString(projectDir: string, key: string, value: string):
14
14
  previous: string | null;
15
15
  changed: boolean;
16
16
  };
17
+ /**
18
+ * Set `key` to `value`, a string or a list of strings, in the project's
19
+ * astryx.config.
20
+ * @param {string} projectDir
21
+ * @param {string} key a top-level key, or a dotted path (`experimental.iconSet`)
22
+ * @param {string | readonly string[]} value
23
+ * @returns {{file: string, created: boolean, previous: string | string[] | null, changed: boolean}}
24
+ */
25
+ export function setConfigValue(projectDir: string, key: string, value: string | readonly string[]): {
26
+ file: string;
27
+ created: boolean;
28
+ previous: string | string[] | null;
29
+ changed: boolean;
30
+ };
17
31
  /** A protected astryx.config the writer refused to touch. */
18
32
  export class ConfigProtectedError extends Error {
19
33
  /**
@@ -1,7 +1,8 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
3
  /**
4
- * @file Write one string field of a project's astryx.config.
4
+ * @file Write one string field, or one list of strings, in a project's
5
+ * astryx.config.
5
6
  *
6
7
  * The field is a top-level key or a dotted path into nested object literals
7
8
  * (`experimental.iconSet`); a missing object on the path is created.
@@ -18,7 +19,8 @@
18
19
  * `ERR_CODEMOD_PROTECTED` naming the protecting declaration, and an unreadable
19
20
  * protection source fails with `ERR_CODEMOD_PROTECTION_SOURCE`.
20
21
  *
21
- * @input A project directory, a field name, and its string value
22
+ * @input A project directory, a field name, and its value: a string or a
23
+ * list of strings
22
24
  * @output The config file written and the value it replaced
23
25
  * @position packages/cli/foundation/config — read by `astryx experimental icon use`
24
26
  */
@@ -87,6 +89,47 @@ function propertyNamed(property, name) {
87
89
  );
88
90
  }
89
91
 
92
+ /** @param {string} text */
93
+ const quote = text => `'${text.replace(/\\/gu, '\\\\').replace(/'/gu, "\\'")}'`;
94
+
95
+ /** @param {string | readonly string[]} value */
96
+ const written = value =>
97
+ typeof value === 'string'
98
+ ? quote(value)
99
+ : `[${value.map(each => quote(each)).join(', ')}]`;
100
+
101
+ /**
102
+ * The string, or list of strings, a config property holds; null for any
103
+ * other value.
104
+ * @param {any} node
105
+ * @returns {string | string[] | null}
106
+ */
107
+ function literalValue(node) {
108
+ if (node?.type === 'StringLiteral' || node?.type === 'Literal') {
109
+ return typeof node.value === 'string' ? node.value : null;
110
+ }
111
+ if (node?.type === 'ArrayExpression') {
112
+ const items = node.elements.map((/** @type {any} */ each) =>
113
+ each?.type === 'StringLiteral' ||
114
+ (each?.type === 'Literal' && typeof each.value === 'string')
115
+ ? String(each.value)
116
+ : null,
117
+ );
118
+ return items.every((/** @type {string | null} */ each) => each != null)
119
+ ? /** @type {string[]} */ (items)
120
+ : null;
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * @param {string | string[] | null} a
127
+ * @param {string | readonly string[]} b
128
+ */
129
+ function sameValue(a, b) {
130
+ return JSON.stringify(a) === JSON.stringify(b);
131
+ }
132
+
90
133
  /**
91
134
  * Set `key` to `value` in the project's astryx.config.
92
135
  * @param {string} projectDir
@@ -95,6 +138,22 @@ function propertyNamed(property, name) {
95
138
  * @returns {{file: string, created: boolean, previous: string | null, changed: boolean}}
96
139
  */
97
140
  export function setConfigString(projectDir, key, value) {
141
+ const result = setConfigValue(projectDir, key, value);
142
+ return {
143
+ ...result,
144
+ previous: typeof result.previous === 'string' ? result.previous : null,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Set `key` to `value`, a string or a list of strings, in the project's
150
+ * astryx.config.
151
+ * @param {string} projectDir
152
+ * @param {string} key a top-level key, or a dotted path (`experimental.iconSet`)
153
+ * @param {string | readonly string[]} value
154
+ * @returns {{file: string, created: boolean, previous: string | string[] | null, changed: boolean}}
155
+ */
156
+ export function setConfigValue(projectDir, key, value) {
98
157
  const keys = key.split('.');
99
158
  const leaf = /** @type {string} */ (keys[keys.length - 1]);
100
159
  const existing = CONFIG_BASENAMES.map(name =>
@@ -103,7 +162,6 @@ export function setConfigString(projectDir, key, value) {
103
162
  if (!existing) {
104
163
  const file = path.join(projectDir, 'astryx.config.mjs');
105
164
  assertWritable(projectDir, file);
106
- const quoted = `'${value.replace(/\\/gu, '\\\\').replace(/'/gu, "\\'")}'`;
107
165
  // Each key on the path opens one nested object: `experimental: {`.
108
166
  const open = keys
109
167
  .slice(0, -1)
@@ -116,7 +174,7 @@ export function setConfigString(projectDir, key, value) {
116
174
  .join('');
117
175
  writeAtomically(
118
176
  file,
119
- `export default {\n${open}${' '.repeat(keys.length)}${leaf}: ${quoted},\n${close}};\n`,
177
+ `export default {\n${open}${' '.repeat(keys.length)}${leaf}: ${written(value)},\n${close}};\n`,
120
178
  );
121
179
  return {file, created: true, previous: null, changed: true};
122
180
  }
@@ -127,7 +185,7 @@ export function setConfigString(projectDir, key, value) {
127
185
  let object = findManifestObject(j, ast);
128
186
  const refuse = () =>
129
187
  new Error(
130
- `Could not update ${path.basename(existing)} safely: its default export is not a static object literal. Set "${key}: '${value}'" in it yourself.`,
188
+ `Could not update ${path.basename(existing)} safely: its default export is not a static object literal. Set "${key}: ${written(value)}" in it yourself.`,
131
189
  );
132
190
  if (!object) throw refuse();
133
191
  // A spread on the path can hold the same keys: a key added beside it would
@@ -137,7 +195,7 @@ export function setConfigString(projectDir, key, value) {
137
195
  const assertNoSpread = (holder, where) => {
138
196
  if (holder.properties.some((/** @type {any} */ each) => isSpread(each))) {
139
197
  throw new Error(
140
- `Could not update ${path.basename(existing)} safely: ${where} spreads another object (\`...\`), whose keys a written "${key}" could replace. Set "${key}: '${value}'" in it yourself.`,
198
+ `Could not update ${path.basename(existing)} safely: ${where} spreads another object (\`...\`), whose keys a written "${key}" could replace. Set "${key}: ${written(value)}" in it yourself.`,
141
199
  );
142
200
  }
143
201
  };
@@ -160,22 +218,20 @@ export function setConfigString(projectDir, key, value) {
160
218
  const property = object.properties.find((/** @type {any} */ each) =>
161
219
  propertyNamed(each, leaf),
162
220
  );
163
- /** @type {string | null} */
221
+ const node =
222
+ typeof value === 'string'
223
+ ? j.stringLiteral(value)
224
+ : j.arrayExpression(value.map(each => j.stringLiteral(each)));
225
+ /** @type {string | string[] | null} */
164
226
  let previous = null;
165
227
  if (property) {
166
- const current = property.value;
167
- previous =
168
- current?.type === 'StringLiteral' || current?.type === 'Literal'
169
- ? String(current.value)
170
- : null;
171
- if (previous === value) {
228
+ previous = literalValue(property.value);
229
+ if (sameValue(previous, value)) {
172
230
  return {file: existing, created: false, previous, changed: false};
173
231
  }
174
- property.value = j.stringLiteral(value);
232
+ property.value = node;
175
233
  } else {
176
- object.properties.push(
177
- j.objectProperty(j.identifier(leaf), j.stringLiteral(value)),
178
- );
234
+ object.properties.push(j.objectProperty(j.identifier(leaf), node));
179
235
  }
180
236
  let next = ast.toSource({quote: 'single', reuseWhitespace: true});
181
237
  if (source.endsWith('\n') && !next.endsWith('\n')) next += '\n';
@@ -43,6 +43,14 @@ export function hasLicenseNotice(dir: string): boolean;
43
43
  * file is invalid; `unresolved`: its module does not resolve from the project
44
44
  * @property {string} reason
45
45
  */
46
+ /**
47
+ * One backup set the project's list names, and what it resolves to.
48
+ * @typedef {object} IconSetBackup
49
+ * @property {string} selector the name as the list writes it
50
+ * @property {'selected' | 'ambiguous' | 'missing' | 'withdrawn'} status
51
+ * @property {DiscoveredIconSet} [set] the set, when selected
52
+ * @property {string} reason one sentence saying why
53
+ */
46
54
  /**
47
55
  * The project's icon set (FR8).
48
56
  * @typedef {object} IconSetChoice
@@ -53,7 +61,13 @@ export function hasLicenseNotice(dir: string): boolean;
53
61
  * `withdrawn`: it names a set that is installed but withdrawn (invalid, or
54
62
  * its module does not resolve), and `reason` says why
55
63
  * @property {DiscoveredIconSet} [set] the project's set, when selected
56
- * @property {string} [configured] the `experimental.iconSet` config value, when set
64
+ * @property {string} [configured] the main set `experimental.iconSet` names
65
+ * (its first entry, when it is a list), when set
66
+ * @property {IconSetBackup[]} [backups] the backup sets the list names after
67
+ * the main set, in order, when it names any
68
+ * @property {DiscoveredIconSet[]} [order] the sets a template icon is looked
69
+ * up in, in order: the main set, then each backup that resolves. Present
70
+ * only when the main set is selected
57
71
  * @property {string} reason one sentence saying why
58
72
  */
59
73
  /**
@@ -120,16 +134,39 @@ export function discoverIntegrationIconSets(integration: import("../integrations
120
134
  */
121
135
  export function withdrawnNamed(withdrawn: WithdrawnIconSet[], selector: string): WithdrawnIconSet[];
122
136
  /**
123
- * Pick the project's icon set (FR8, FR17): the set `experimental.iconSet`
124
- * names, as `"<set>"` or `"<package>/<set>"`; otherwise the only installed
137
+ * The entries of an `experimental.iconSet` value, main set first: a string is
138
+ * a one-entry list.
139
+ * @param {string | readonly string[] | undefined} configured
140
+ * @returns {string[]}
141
+ */
142
+ export function iconSetList(configured: string | readonly string[] | undefined): string[];
143
+ /**
144
+ * Installed sets the project's list leaves out: the problem, and the fix that
145
+ * adds them as backups after the list's own. Doctor and `experimental icon
146
+ * use` print the same words.
147
+ * @param {number} installed how many sets are installed
148
+ * @param {string} main the main set as the list writes it
149
+ * @param {string[]} backups the list's backups, in order
150
+ * @param {string[]} unlisted the installed sets the list leaves out
151
+ * @returns {{problem: string, command: string, fix: string}}
152
+ */
153
+ export function unlistedIconSets(installed: number, main: string, backups: string[], unlisted: string[]): {
154
+ problem: string;
155
+ command: string;
156
+ fix: string;
157
+ };
158
+ /**
159
+ * Pick the project's icon set (FR8, FR17): the main set `experimental.iconSet`
160
+ * names, as `"<set>"` or `"<package>/<set>"`, or as the first entry of a list
161
+ * whose other entries are backup sets in order; otherwise the only installed
125
162
  * set; otherwise none. A configured name that matches no valid set but an
126
163
  * installed set that was withdrawn says so, with why.
127
164
  * @param {DiscoveredIconSet[]} sets installed sets, in integration load order
128
- * @param {string | undefined} configured the `experimental.iconSet` config value
165
+ * @param {string | readonly string[] | undefined} configured the `experimental.iconSet` config value
129
166
  * @param {WithdrawnIconSet[]} [withdrawn] installed sets that were withdrawn
130
167
  * @returns {IconSetChoice}
131
168
  */
132
- export function chooseProjectIconSet(sets: DiscoveredIconSet[], configured: string | undefined, withdrawn?: WithdrawnIconSet[]): IconSetChoice;
169
+ export function chooseProjectIconSet(sets: DiscoveredIconSet[], configured: string | readonly string[] | undefined, withdrawn?: WithdrawnIconSet[]): IconSetChoice;
133
170
  /**
134
171
  * A set's full name, `<package>/<set>`.
135
172
  * @param {{package: string, name: string}} set
@@ -139,7 +176,8 @@ export function iconSetKey(set: {
139
176
  name: string;
140
177
  }): string;
141
178
  /**
142
- * Sets in tie order (FR6, FR8): the project's set first, then load order.
179
+ * Sets in tie order (FR6, FR8): the project's set and its backups first, in
180
+ * list order, then load order.
143
181
  * @param {DiscoveredIconSet[]} sets
144
182
  * @param {IconSetChoice} choice
145
183
  * @returns {DiscoveredIconSet[]}
@@ -147,6 +185,8 @@ export function iconSetKey(set: {
147
185
  export function orderIconSets(sets: DiscoveredIconSet[], choice: IconSetChoice): DiscoveredIconSet[];
148
186
  /** A license or notice file a set ships beside its data (FR15). */
149
187
  export const NOTICE_FILE_RE: RegExp;
188
+ /** The command that chooses a main set and backups, as the CLI prints it. */
189
+ export const ICON_USE_ORDER_COMMAND: "astryx experimental icon use <main> --backup <set>";
150
190
  /**
151
191
  * One installed icon set: its descriptor, its data, and where it came from.
152
192
  */
@@ -213,6 +253,24 @@ export type WithdrawnIconSet = {
213
253
  kind: "invalid" | "unresolved";
214
254
  reason: string;
215
255
  };
256
+ /**
257
+ * One backup set the project's list names, and what it resolves to.
258
+ */
259
+ export type IconSetBackup = {
260
+ /**
261
+ * the name as the list writes it
262
+ */
263
+ selector: string;
264
+ status: "selected" | "ambiguous" | "missing" | "withdrawn";
265
+ /**
266
+ * the set, when selected
267
+ */
268
+ set?: DiscoveredIconSet | undefined;
269
+ /**
270
+ * one sentence saying why
271
+ */
272
+ reason: string;
273
+ };
216
274
  /**
217
275
  * The project's icon set (FR8).
218
276
  */
@@ -230,9 +288,21 @@ export type IconSetChoice = {
230
288
  */
231
289
  set?: DiscoveredIconSet | undefined;
232
290
  /**
233
- * the `experimental.iconSet` config value, when set
291
+ * the main set `experimental.iconSet` names
292
+ * (its first entry, when it is a list), when set
234
293
  */
235
294
  configured?: string | undefined;
295
+ /**
296
+ * the backup sets the list names after
297
+ * the main set, in order, when it names any
298
+ */
299
+ backups?: IconSetBackup[] | undefined;
300
+ /**
301
+ * the sets a template icon is looked
302
+ * up in, in order: the main set, then each backup that resolves. Present
303
+ * only when the main set is selected
304
+ */
305
+ order?: DiscoveredIconSet[] | undefined;
236
306
  /**
237
307
  * one sentence saying why
238
308
  */