@astryxdesign/cli 0.7.11 → 0.7.12-canary.ccb4a6d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/api/build/kit/rank.mjs +21 -4
  2. package/api/build/kit/words.d.mts +1 -0
  3. package/api/build/kit/words.mjs +1 -1
  4. package/api/icon/_adapter.d.mts +3 -2
  5. package/api/icon/_adapter.mjs +8 -2
  6. package/api/icon/_authoring.d.mts +1 -0
  7. package/api/icon/_authoring.mjs +586 -66
  8. package/api/icon/_default-look.d.mts +155 -0
  9. package/api/icon/_default-look.mjs +474 -0
  10. package/api/icon/_records.d.mts +1 -1
  11. package/api/icon/_records.mjs +1 -0
  12. package/api/icon/add/add.mjs +224 -38
  13. package/api/icon/icon.type.d.mts +22 -2
  14. package/api/icon/icon.type.mjs +6 -2
  15. package/api/icon/iconAdd.doc.mjs +2 -2
  16. package/api/icon/iconNewDefinition.doc.mjs +3 -2
  17. package/api/icon/iconNewSet.doc.mjs +4 -4
  18. package/api/icon/search/search.mjs +10 -9
  19. package/api/integration/add-helpers.d.mts +11 -1
  20. package/api/integration/add-helpers.mjs +5 -2
  21. package/api/integration/authoring-checks.d.mts +0 -2
  22. package/api/integration/authoring-checks.mjs +1 -6
  23. package/api/logger.mjs +2 -1
  24. package/api/swizzle/_adapter.d.mts +1 -0
  25. package/api/swizzle/_adapter.mjs +4 -1
  26. package/api/swizzle/_moduleGraph.d.mts +171 -0
  27. package/api/swizzle/_moduleGraph.mjs +795 -0
  28. package/api/swizzle/copy/copy.d.mts +2 -43
  29. package/api/swizzle/copy/copy.mjs +195 -314
  30. package/api/swizzle/swizzle.d.mts +0 -2
  31. package/api/swizzle/swizzle.doc.mjs +9 -4
  32. package/api/swizzle/swizzle.mjs +3 -7
  33. package/api/swizzle/swizzle.type.d.mts +2 -1
  34. package/api/swizzle/swizzle.type.mjs +3 -2
  35. package/assets/discover/official-packages.json +20 -0
  36. package/assets/docs/tree/icon-sets.doc.mjs +11 -5
  37. package/assets/templates/blocks/components/Table/TableRowIndexTable.doc.mjs +1 -1
  38. package/assets/templates/blocks/components/Table/TableRowIndexTable.tsx +2 -2
  39. package/assets/templates/pages/course-detail/page.tsx +699 -0
  40. package/assets/templates/pages/course-detail/template.doc.mjs +37 -0
  41. package/assets/templates/pages/file-explorer/page.tsx +103 -79
  42. package/assets/templates/pages/table-grouped/page.tsx +160 -232
  43. package/assets/templates/pages/table-grouped/template.doc.mjs +0 -2
  44. package/assets/templates/pages/theme-showcase/page.tsx +724 -792
  45. package/assets/templates/pages/theme-showcase/template.doc.mjs +4 -5
  46. package/authoring/doctypes/template/type.ts +1 -0
  47. package/clients/cli/commands/experimental/icon.mjs +31 -10
  48. package/clients/cli/commands/experimental-icon-add.doc.mjs +1 -1
  49. package/clients/cli/commands/experimental-icon-new-definition.doc.mjs +2 -1
  50. package/clients/cli/commands/experimental-icon-new-set.doc.mjs +2 -2
  51. package/clients/cli/commands/swizzle.doc.mjs +8 -3
  52. package/clients/cli/commands/swizzle.mjs +21 -9
  53. package/foundation/discovery/component-loader.d.mts +1 -1
  54. package/foundation/discovery/doc-types.mjs +9 -2
  55. package/foundation/discovery/icon-ranking.d.mts +27 -3
  56. package/foundation/discovery/icon-ranking.mjs +125 -8
  57. package/foundation/discovery/icon-set-checks.mjs +44 -35
  58. package/foundation/discovery/icon-set-discovery.d.mts +13 -0
  59. package/foundation/discovery/icon-set-discovery.mjs +25 -0
  60. package/foundation/discovery/icon-set-writer.d.mts +238 -16
  61. package/foundation/discovery/icon-set-writer.mjs +676 -79
  62. package/foundation/discovery/module-exports.d.mts +3 -2
  63. package/foundation/discovery/module-exports.mjs +167 -37
  64. package/foundation/fs/paths.d.mts +2 -1
  65. package/foundation/fs/paths.mjs +3 -4
  66. package/foundation/text/shell-arg.d.mts +14 -0
  67. package/foundation/text/shell-arg.mjs +17 -0
  68. package/package.json +6 -6
@@ -13,6 +13,7 @@
13
13
  import * as fs from 'node:fs';
14
14
  import * as path from 'node:path';
15
15
  import {AstryxError} from '../error.mjs';
16
+ import {logger} from '../logger.mjs';
16
17
  import {ERROR_CODES} from '../../foundation/response/error-codes.mjs';
17
18
  import {
18
19
  assertWithin,
@@ -41,8 +42,13 @@ import {
41
42
  iconSetDataText,
42
43
  journalFile,
43
44
  librarySource,
45
+ everyLibraryIcon,
44
46
  libraryIcons,
47
+ libraryLooks,
48
+ lookPositions,
49
+ moduleNaming,
45
50
  nonIconExports,
51
+ packageRootMoves,
46
52
  resolveIconSource,
47
53
  peerNote,
48
54
  typecheckNote,
@@ -50,6 +56,16 @@ import {
50
56
  writeGeneratedIcons,
51
57
  writeIconSetData,
52
58
  } from '../../foundation/discovery/icon-set-writer.mjs';
59
+ import {
60
+ describedLooks,
61
+ drawnLooks,
62
+ resolveDefaultLook,
63
+ } from './_default-look.mjs';
64
+ import {shellArg} from '../../foundation/text/shell-arg.mjs';
65
+ import {
66
+ hasLicenseNotice,
67
+ licenseNoticeFix,
68
+ } from '../../foundation/discovery/icon-set-discovery.mjs';
53
69
  import {astryxIconDefinitions} from '../../foundation/discovery/icon-definition-discovery.mjs';
54
70
  import {suggestedIcons} from '../../foundation/discovery/icon-set-checks.mjs';
55
71
  import {
@@ -99,7 +115,7 @@ function quoteJs(value) {
99
115
  */
100
116
  export async function addIconSet(name, options) {
101
117
  const {cwd = process.cwd(), dryRun = false, from} = options;
102
- const looks = variantOptions(options);
118
+ const given = variantOptions(options);
103
119
  const exclude =
104
120
  typeof options.exclude === 'string'
105
121
  ? options.exclude
@@ -192,7 +208,30 @@ export async function addIconSet(name, options) {
192
208
  dryRun,
193
209
  from,
194
210
  exclude,
195
- looks,
211
+ looks: given,
212
+ describe: {
213
+ displayName: options.displayName,
214
+ description: options.description,
215
+ },
216
+ packageDir,
217
+ rootPath,
218
+ manifestPath: projectPath(path.relative(packageDir, manifestFile)),
219
+ });
220
+ }
221
+ // Without --from, an existing set takes new variants, a default variant, a
222
+ // display name or a description in place, and nothing else changes.
223
+ if (
224
+ from == null &&
225
+ fs.existsSync(docFile) &&
226
+ fs.existsSync(dataFile) &&
227
+ (given != null ||
228
+ options.displayName?.trim() ||
229
+ options.description?.trim())
230
+ ) {
231
+ return redeclareIconSet(name, {
232
+ cwd,
233
+ dryRun,
234
+ looks: given,
196
235
  describe: {
197
236
  displayName: options.displayName,
198
237
  description: options.description,
@@ -227,14 +266,48 @@ export async function addIconSet(name, options) {
227
266
  let skipped = [];
228
267
  /** @type {ReturnType<typeof librarySource>} */
229
268
  let library = null;
269
+ /** @type {{summary: import('../../foundation/discovery/icon-set-writer.mjs').LibraryLookSummary | null}} */
270
+ const lookFound = {summary: null};
271
+ // Without --from the set has no icons yet, so no look to match.
272
+ let looks =
273
+ from == null
274
+ ? resolveDefaultLook({
275
+ name,
276
+ given,
277
+ current: undefined,
278
+ icons: 0,
279
+ drawn: null,
280
+ library: null,
281
+ }).looks
282
+ : null;
230
283
  if (from != null) {
231
284
  const source = await iconAuthoring(() =>
232
285
  resolveIconSource(from, {packageDir, pkg}),
233
286
  );
234
- const found = libraryIcons(source.file, {exclude});
287
+ const found = libraryIcons(source.file, {
288
+ exclude,
289
+ // The library's look words decide the default before any glyph is
290
+ // picked, so each icon's export draws it.
291
+ prefer: positions => {
292
+ looks = resolveDefaultLook({
293
+ name,
294
+ given,
295
+ current: undefined,
296
+ icons: 0,
297
+ drawn: null,
298
+ library: {module: source.module, positions},
299
+ }).looks;
300
+ return looks ? [looks.default] : [];
301
+ },
302
+ module: source.module,
303
+ progress: line => logger.warn(line),
304
+ summary: each => {
305
+ lookFound.summary = each;
306
+ },
307
+ });
235
308
  if (found.length === 0) {
236
309
  throw new AstryxError(
237
- `"${from}" exports no icon components this command can read. If its icons are data (objects or SVG strings), run \`astryx experimental icon new-set ${name}\`, declare an "adapter" in ${name}.icons.json, and run this command again; or add icons one at a time with \`astryx experimental icon add <Export> --from ${from}\`.`,
310
+ `"${from}" exports no icon components this command can read. If its icons are data (objects or SVG strings), run \`astryx experimental icon new-set ${name}\`, declare an "adapter" in ${name}.icons.json, and run this command again; or add icons one at a time with \`astryx experimental icon add <Export> --from ${from} --set ${name}\`.`,
238
311
  undefined,
239
312
  ERROR_CODES.ERR_NOT_FOUND,
240
313
  );
@@ -413,6 +486,11 @@ export async function addIconSet(name, options) {
413
486
  writtenFiles,
414
487
  dryRun,
415
488
  );
489
+ const missingNotice = noticeToAdd(
490
+ library,
491
+ packageDir,
492
+ projectPath(path.relative(packageDir, setDir)),
493
+ );
416
494
  {
417
495
  result.data.icons = icons.length;
418
496
  if (from != null) {
@@ -452,14 +530,220 @@ export async function addIconSet(name, options) {
452
530
  `Left out ${skipped.length} exports that are not icons: ${skipped.slice(0, 12).join(', ')}${skipped.length > 12 ? ', …' : ''}.`,
453
531
  ]
454
532
  : []),
455
- ...mappedNotes(map),
456
- ...suggestionNotes(suggestions),
533
+ ...(from != null && lookFound.summary == null
534
+ ? [`Found ${icons.length} icons in ${from}.`]
535
+ : []),
536
+ ...(from != null && lookFound.summary != null
537
+ ? lookNotes(lookFound.summary, icons.length, from, looks, name)
538
+ : []),
539
+ ...(missingNotice ? [missingNotice.note] : []),
540
+ ...mappedNotes(map, name),
541
+ ...suggestionNotes(suggestions, name),
457
542
  'Run `astryx integration check` to see the icon definitions still to map.',
458
543
  ];
544
+ if (missingNotice) {
545
+ result.data.licenseNoticeMissing = missingNotice.receipt;
546
+ }
459
547
  }
460
548
  return result;
461
549
  }
462
550
 
551
+ /**
552
+ * The license notice the author must add when the wrapped library ships no
553
+ * license file to copy and the package root has none. The CLI never writes
554
+ * it: the license text is the author's to state. Null when nothing is
555
+ * missing.
556
+ * @param {{package: string, version: string, notices: string[]} | null} library
557
+ * @param {string} packageDir
558
+ * @param {string} setDir the set directory, from the package root
559
+ * @returns {{note: string, receipt: {library: string, path: string}} | null}
560
+ */
561
+ function noticeToAdd(library, packageDir, setDir) {
562
+ if (!library || library.notices.length > 0) return null;
563
+ if (hasLicenseNotice(packageDir)) return null;
564
+ const named = `${library.package}@${library.version}`;
565
+ return {
566
+ note: `${named} ships no LICENSE or NOTICE file to copy, so the set has no license notice. ${licenseNoticeFix(setDir, named)}`,
567
+ receipt: {library: named, path: `${setDir}/NOTICE`},
568
+ };
569
+ }
570
+
571
+ /**
572
+ * A set descriptor's source with `--display-name` and `--description`
573
+ * written into its fields; the rest as it was.
574
+ * @param {string} source
575
+ * @param {{displayName?: string, description?: string}} describe
576
+ */
577
+ function describedDoc(source, describe) {
578
+ let out = source;
579
+ for (const [field, value] of /** @type {const} */ ([
580
+ ['displayName', describe.displayName],
581
+ ['description', describe.description],
582
+ ])) {
583
+ if (!value?.trim()) continue;
584
+ out = out.replace(
585
+ new RegExp(`^(\\s*${field}:\\s*)(['"\`])(?:\\\\.|(?!\\2).)*\\2,?$`, 'mu'),
586
+ (_, lead) => `${lead}${quoteJs(value.trim())},`,
587
+ );
588
+ }
589
+ return out;
590
+ }
591
+
592
+ /**
593
+ * `new-set <name>` without `--from` over an existing set: declare its
594
+ * variants and default variant, and rewrite its display name and
595
+ * description, in place. Its icons, map and generated module stay as they
596
+ * are. A variant an icon still has must stay declared, and the default
597
+ * variant is one no icon lists as an extra look. A set with icons keeps its
598
+ * default variant: only a refresh with `--from` from a library whose names
599
+ * name its looks moves it. With none declared,
600
+ * its default is the look its icons' exports draw.
601
+ * @param {string} name
602
+ * @param {{cwd: string, dryRun: boolean, looks: Looks | null, describe: {displayName?: string, description?: string}, packageDir: string, rootPath: string, manifestPath: string}} options
603
+ */
604
+ async function redeclareIconSet(
605
+ name,
606
+ {cwd, dryRun, looks: given, describe, packageDir, rootPath, manifestPath},
607
+ ) {
608
+ return iconAuthoring(async () => {
609
+ const where = await findLocalIconSet(cwd, name);
610
+ const {set} = where;
611
+ const current = set.data.default?.variant;
612
+ const drawn =
613
+ given && set.data.icons.length > 0
614
+ ? drawnLooks(set, packageDir, module =>
615
+ libraryPositions(module, where, Boolean(set.data.adapter)),
616
+ )
617
+ : null;
618
+ const {looks} = resolveDefaultLook({
619
+ name,
620
+ given,
621
+ current,
622
+ icons: set.data.icons.length,
623
+ drawn,
624
+ library: null,
625
+ });
626
+ if (looks) {
627
+ const declared = new Set(looks.variants);
628
+ const dropped = set.data.icons.flatMap(icon =>
629
+ (icon.variants ?? [])
630
+ .filter(each => !declared.has(each.variant))
631
+ .map(each => `${icon.id} (${each.variant})`),
632
+ );
633
+ if (dropped.length > 0) {
634
+ throw new AstryxError(
635
+ `These icons still have a variant --variants leaves out: ${dropped.slice(0, 8).join(', ')}${dropped.length > 8 ? ', …' : ''}. Keep it in --variants.`,
636
+ [
637
+ {
638
+ name: `--variants ${[...new Set([...looks.variants, ...set.data.icons.flatMap(icon => (icon.variants ?? []).map(each => each.variant))])].join(',')}`,
639
+ reason: 'keep every variant an icon has',
640
+ },
641
+ ],
642
+ ERROR_CODES.ERR_INVALID_ARGUMENT,
643
+ );
644
+ }
645
+ const asExtra = set.data.icons
646
+ .filter(icon =>
647
+ (icon.variants ?? []).some(each => each.variant === looks.default),
648
+ )
649
+ .map(icon => icon.id);
650
+ if (asExtra.length > 0) {
651
+ throw new AstryxError(
652
+ `"${looks.default}" can't be the default variant: ${asExtra.length} icons list it as another look (${asExtra.slice(0, 8).join(', ')}${asExtra.length > 8 ? ', …' : ''}), and each icon's own export draws the default.`,
653
+ undefined,
654
+ ERROR_CODES.ERR_INVALID_ARGUMENT,
655
+ );
656
+ }
657
+ }
658
+ const next = {
659
+ ...set.data,
660
+ ...(looks
661
+ ? {variants: looks.variants, default: {variant: looks.default}}
662
+ : {}),
663
+ };
664
+ const docFile = path.join(set.dir, `${set.name}.doc.mjs`);
665
+ const docBefore = fs.readFileSync(docFile, 'utf-8');
666
+ const docAfter = describedDoc(docBefore, describe);
667
+ const dataChanged =
668
+ iconSetDataText(next) !== fs.readFileSync(set.dataPath, 'utf-8');
669
+ /** @type {string[]} */
670
+ const files = [];
671
+ if (dataChanged) {
672
+ files.push(projectPath(path.relative(packageDir, set.dataPath)));
673
+ }
674
+ if (docAfter !== docBefore) {
675
+ files.push(projectPath(path.relative(packageDir, docFile)));
676
+ }
677
+ if (!dryRun) {
678
+ if (dataChanged) writeIconSetData(set, next);
679
+ if (docAfter !== docBefore) {
680
+ journalFile(docFile);
681
+ fs.writeFileSync(docFile, docAfter);
682
+ }
683
+ }
684
+ const result = receipt(
685
+ 'icon-set',
686
+ name,
687
+ {path: rootPath, created: false},
688
+ manifestPath,
689
+ files,
690
+ dryRun,
691
+ );
692
+ result.data.icons = set.data.icons.length;
693
+ result.data.updated = true;
694
+ // A look the icons' own library draws comes from that library, so its
695
+ // command runs as printed; any other look comes from a module the
696
+ // author names.
697
+ /** @param {string} variant */
698
+ const fromFor = variant =>
699
+ drawn != null && drawn.positions.some(each => each.has(variant))
700
+ ? shellArg(drawn.module)
701
+ : '<module>';
702
+ const added = (looks?.variants ?? []).filter(
703
+ variant =>
704
+ variant !== looks?.default &&
705
+ !(set.data.variants ?? []).includes(variant),
706
+ );
707
+ result.data.notes = [
708
+ files.length === 0
709
+ ? `Icon set "${name}" already declares this; nothing changed.`
710
+ : `Updated icon set "${name}" in place; its icons, map and generated module are unchanged.`,
711
+ ...(added.length > 0
712
+ ? [
713
+ `Give each icon its ${added.join(', ')} look: ${added.map(variant => `\`astryx experimental icon add --variant ${variant} --from ${fromFor(variant)} --all --set ${name}\``).join(', ')}, or one icon with \`astryx experimental icon add <Export> --from <module> --id <icon> --variant <name> --set ${name}\`.`,
714
+ ]
715
+ : []),
716
+ ];
717
+ return result;
718
+ });
719
+ }
720
+
721
+ /**
722
+ * The receipt's notes for a library that draws each icon in several looks
723
+ * (a style, a size, a weight): what it found, the look the set took, and
724
+ * how to add another look as a variant.
725
+ * @param {import('../../foundation/discovery/icon-set-writer.mjs').LibraryLookSummary} summary
726
+ * @param {number} count icons in the set
727
+ * @param {string} from
728
+ * @param {{variants: string[], default: string} | null} looks
729
+ * @param {string} name the set's name
730
+ * @returns {string[]}
731
+ */
732
+ function lookNotes(summary, count, from, looks, name) {
733
+ const described = describedLooks(summary.positions);
734
+ const taken = [...summary.preferred].reverse().join(' ');
735
+ const styles = new Set(summary.positions.flat());
736
+ const others = (looks?.variants ?? []).filter(
737
+ variant => variant !== looks?.default && styles.has(variant),
738
+ );
739
+ return [
740
+ `Found ${count} icons in ${from}, each drawn in several looks (${described}) by ${summary.exports} exports. The set wraps each icon once, in the ${taken} look, named without the look words (\`x\`, \`XIcon\`).`,
741
+ others.length > 0
742
+ ? `Add the ${others.join(', ')} look${others.length > 1 ? 's' : ''} as variant${others.length > 1 ? 's' : ''}: ${others.map(variant => `\`astryx experimental icon add --variant ${variant} --from ${from} --all --set ${name}\``).join(', ')}.`
743
+ : `To offer another look as a variant, declare it with \`astryx experimental icon new-set ${name} --variants ${[...summary.preferred].reverse()[0]},<look>\` (no --from: the set's icons stay as they are) and run \`astryx experimental icon add --variant <look> --from ${from} --all --set ${name}\`.`,
744
+ ];
745
+ }
746
+
463
747
  /**
464
748
  * An icon's words from its id: each part, leaving out a bare number
465
749
  * (`volume-2` is `volume`) unless the id is only numbers.
@@ -520,36 +804,40 @@ function definitionSuggestions(icons, map) {
520
804
  /**
521
805
  * The receipt's note for the suggestions, if any.
522
806
  * @param {Array<{definition: string, icon: string}>} suggestions
807
+ * @param {string} name the set's name
523
808
  */
524
- function suggestionNotes(suggestions) {
809
+ function suggestionNotes(suggestions, name) {
525
810
  if (suggestions.length === 0) return [];
526
811
  const count = new Set(suggestions.map(each => each.definition)).size;
527
812
  return [
528
- `${count} unmapped icon definitions have possible icons (listed under Suggestions; nothing was written for them). Check each meaning, then map the ones that fit: \`astryx experimental icon map <definition> <icon>\`.`,
813
+ `${count} unmapped icon definitions have possible icons (listed under Suggestions; nothing was written for them). Check each meaning, then map the ones that fit: \`astryx experimental icon map <definition> <icon> --set ${name}\`.`,
529
814
  ];
530
815
  }
531
816
 
532
817
  /**
533
818
  * The receipt's note for the entries an exact name wrote, if any.
534
819
  * @param {Record<string, string>} map
820
+ * @param {string} name the set's name
535
821
  */
536
- function mappedNotes(map) {
822
+ function mappedNotes(map, name) {
537
823
  const count = Object.keys(map).length;
538
824
  if (count === 0) return [];
539
825
  return [
540
- `${count} icon definitions were mapped because an icon has their exact name (listed under Mapped by name). A library can draw another meaning under the same name, such as a backspace key named delete: check each, and remap with \`astryx experimental icon map <definition> <icon>\`.`,
826
+ `${count} icon definitions were mapped because an icon has their exact name (listed under Mapped by name). A library can draw another meaning under the same name, such as a backspace key named delete: check each, and remap with \`astryx experimental icon map <definition> <icon> --set ${name}\`.`,
541
827
  ];
542
828
  }
543
829
 
830
+ /** @typedef {import('./_default-look.mjs').Looks} Looks */
831
+
544
832
  /** A variant name: lower-kebab, such as `outline` or `mini-solid`. */
545
833
  const VARIANT_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
546
834
 
547
835
  /**
548
836
  * The variants and default variant `--variants` and `--default-variant`
549
- * declare, or
550
- * null when neither is given. The default is the first variant unless named.
837
+ * declare, or null when neither is given. Unless named, `default` is the
838
+ * first variant until {@link resolveDefaultLook} decides it.
551
839
  * @param {{variants?: string[] | string, defaultVariant?: string}} options
552
- * @returns {{variants: string[], default: string} | null}
840
+ * @returns {Looks | null}
553
841
  */
554
842
  function variantOptions(options) {
555
843
  const given =
@@ -594,7 +882,28 @@ function variantOptions(options) {
594
882
  ERROR_CODES.ERR_INVALID_ARGUMENT,
595
883
  );
596
884
  }
597
- return {variants, default: chosen || variants[0]};
885
+ return {variants, default: chosen || variants[0], named: Boolean(chosen)};
886
+ }
887
+
888
+ /**
889
+ * The look positions of a library the set's generated module names, or
890
+ * null when it can't be read.
891
+ * @param {string} module
892
+ * @param {{packageDir: string, pkg: Record<string, any>}} where
893
+ * @param {boolean} adapter
894
+ * @returns {Array<Set<string>> | null}
895
+ */
896
+ function libraryPositions(module, where, adapter) {
897
+ try {
898
+ const source = resolveIconSource(module, where);
899
+ return moduleNaming(source.file, {
900
+ adapter,
901
+ module,
902
+ progress: line => logger.warn(line),
903
+ }).positions;
904
+ } catch {
905
+ return null;
906
+ }
598
907
  }
599
908
 
600
909
  /**
@@ -626,13 +935,77 @@ async function iconAuthoring(step) {
626
935
  }
627
936
  }
628
937
 
938
+ /**
939
+ * Whether an id is one an id rule derives from a library glyph: the glyph id,
940
+ * or its library export, broken into words another way (`squares-2-x2` for
941
+ * `squares-2x2`, `glyph-eye` for `glyphEye`). Any other id is the author's.
942
+ * @param {string} id
943
+ * @param {{id: string, export: string}} glyph
944
+ */
945
+ function derivedId(id, glyph) {
946
+ const flat = (/** @type {string} */ text) =>
947
+ text.toLowerCase().replace(/[^a-z0-9]/gu, '');
948
+ return [glyph.id, glyph.export, glyph.export.replace(/Icon$/u, '')]
949
+ .map(flat)
950
+ .includes(flat(id));
951
+ }
952
+
953
+ /**
954
+ * Keep the root moves that give each root export to one export of the set.
955
+ * A root export that an export of the set already reads, or that an earlier
956
+ * move takes, stays where it is. The icon that would move keeps its own file,
957
+ * and its variants stay with it. Returns the kept moves and the ids of the
958
+ * icons that stay (with `(<variant>)` for a variant that stays).
959
+ * @param {{data: {module: string, icons: Array<{id: string, export: string, module?: string, variants?: Array<{variant: string, export: string}>}>}}} set
960
+ * @param {Map<string, {module: string, source: string}>} origins
961
+ * @param {string} root
962
+ * @param {Map<string, {source: string, from: string}>} moves
963
+ */
964
+ function claimRootMoves(set, origins, root, moves) {
965
+ /** @type {Map<string, {source: string, from: string}>} */
966
+ const kept = new Map();
967
+ /** @type {string[]} */
968
+ const skipped = [];
969
+ const claimed = new Set(
970
+ [...origins.values()]
971
+ .filter(origin => sameIconModule(origin.module, root))
972
+ .map(origin => origin.source),
973
+ );
974
+ for (const icon of set.data.icons) {
975
+ if ((icon.module ?? set.data.module) !== set.data.module) continue;
976
+ const own = moves.get(icon.export);
977
+ const origin = origins.get(icon.export);
978
+ const readsRoot = origin != null && sameIconModule(origin.module, root);
979
+ if (own) {
980
+ if (claimed.has(own.source)) {
981
+ skipped.push(icon.id);
982
+ continue;
983
+ }
984
+ claimed.add(own.source);
985
+ kept.set(icon.export, own);
986
+ } else if (!readsRoot) continue;
987
+ for (const each of icon.variants ?? []) {
988
+ const move = moves.get(each.export);
989
+ if (!move) continue;
990
+ if (claimed.has(move.source)) {
991
+ skipped.push(`${icon.id} (${each.variant})`);
992
+ continue;
993
+ }
994
+ claimed.add(move.source);
995
+ kept.set(each.export, move);
996
+ }
997
+ }
998
+ skipped.sort();
999
+ return {moves: kept, skipped};
1000
+ }
1001
+
629
1002
  /**
630
1003
  * Wrap a library into an existing set again: its icons are replaced by what
631
1004
  * the library exports now (an icon whose words the author extended keeps
632
1005
  * them), the generated module gains the new exports, `map` keeps every entry
633
1006
  * whose icon still exists, and the receipt names the entries that lost theirs.
634
1007
  * @param {string} name
635
- * @param {{cwd: string, dryRun: boolean, from: string, exclude: string[], looks: {variants: string[], default: string} | null, describe: {displayName?: string, description?: string}, packageDir: string, rootPath: string, manifestPath: string}} options
1008
+ * @param {{cwd: string, dryRun: boolean, from: string, exclude: string[], looks: Looks | null, describe: {displayName?: string, description?: string}, packageDir: string, rootPath: string, manifestPath: string}} options
636
1009
  */
637
1010
  async function refreshIconSet(
638
1011
  name,
@@ -641,7 +1014,7 @@ async function refreshIconSet(
641
1014
  dryRun,
642
1015
  from,
643
1016
  exclude,
644
- looks,
1017
+ looks: given,
645
1018
  describe,
646
1019
  packageDir,
647
1020
  rootPath,
@@ -651,24 +1024,73 @@ async function refreshIconSet(
651
1024
  return iconAuthoring(async () => {
652
1025
  const where = await findLocalIconSet(cwd, name);
653
1026
  const {set} = where;
1027
+ const current = set.data.default?.variant;
654
1028
  const source = resolveIconSource(from, where);
655
1029
  // Exports left out before stay out; --exclude adds to them.
656
1030
  const excluded = [
657
1031
  ...new Set([...(set.data.exclude ?? []), ...exclude]),
658
1032
  ].sort();
659
- const found = libraryIcons(source.file, {
1033
+ const everyIcon = everyLibraryIcon(source.file, {
660
1034
  adapter: Boolean(set.data.adapter),
661
1035
  exclude: excluded,
1036
+ module: source.module,
1037
+ progress: line => logger.warn(line),
662
1038
  });
1039
+ const libraryLookPositions = lookPositions(everyIcon.map(icon => icon.id));
1040
+ // The look the icons from this library draw now decides whether a named
1041
+ // default moves them.
1042
+ const {looks, moved} = resolveDefaultLook({
1043
+ name,
1044
+ given,
1045
+ current,
1046
+ icons: set.data.icons.length,
1047
+ drawn: drawnLooks(set, packageDir, () => libraryLookPositions, {
1048
+ module: source.module,
1049
+ }),
1050
+ library: {module: source.module, positions: libraryLookPositions},
1051
+ });
1052
+ if (looks) {
1053
+ const asExtra = set.data.icons
1054
+ .filter(icon =>
1055
+ (icon.variants ?? []).some(each => each.variant === looks.default),
1056
+ )
1057
+ .map(icon => icon.id);
1058
+ if (asExtra.length > 0) {
1059
+ throw new AstryxError(
1060
+ `"${looks.default}" can't be the default variant: ${asExtra.length} icons list it as another look (${asExtra.slice(0, 8).join(', ')}${asExtra.length > 8 ? ', …' : ''}), and each icon's own export draws the default.`,
1061
+ undefined,
1062
+ ERROR_CODES.ERR_INVALID_ARGUMENT,
1063
+ );
1064
+ }
1065
+ }
1066
+
663
1067
  // A refresh changes only the icons the refreshed module supplies: those
664
1068
  // the generated module re-exports or adapts from it. Every other icon
665
1069
  // (your own `./` artwork, another library's) is kept as it is, with its
666
1070
  // map entries and variants.
667
1071
  const generatedBefore = generatedModulePath(set, packageDir);
668
1072
  const origins = generatedOrigins(packageDir, generatedBefore);
1073
+ // A refresh from a package root also supplies the exports that read one
1074
+ // file of that package, when the root exports the same icon. They move to
1075
+ // the root import, so the set has one import style.
1076
+ const {moves: rootMoves, skipped: skippedMoves} = claimRootMoves(
1077
+ set,
1078
+ origins,
1079
+ source.module,
1080
+ packageRootMoves(
1081
+ origins,
1082
+ source,
1083
+ everyIcon.map(icon => icon.export),
1084
+ where,
1085
+ ),
1086
+ );
1087
+ /** @param {string} name the set's export */
1088
+ const libraryExportOf = name =>
1089
+ rootMoves.get(name)?.source ?? origins.get(name)?.source;
669
1090
  /** @param {{export: string, module?: string}} icon */
670
1091
  const suppliedHere = icon => {
671
1092
  if ((icon.module ?? set.data.module) !== set.data.module) return false;
1093
+ if (rootMoves.has(icon.export)) return true;
672
1094
  const origin = origins.get(icon.export);
673
1095
  return origin != null && sameIconModule(origin.module, source.module);
674
1096
  };
@@ -676,39 +1098,93 @@ async function refreshIconSet(
676
1098
  const others = set.data.icons.filter(icon => !suppliedHere(icon));
677
1099
  // An existing icon is the same icon when the library still exports it:
678
1100
  // its identity is the library export its generated module re-exports
679
- // (`HiSquares2X2Icon`), then its public export, then its id. Ids and
680
- // public names are derived, and a newer rule may spell them differently
681
- // (`squares-2-x2` is now `squares-2x2`); an icon matched this way keeps
682
- // its public export byte for byte, every field it had (its variants, the
683
- // words the author added, its module), and its map entries.
1101
+ // (`HiSquares2X2Icon`, or the root export a per-file icon moves to), then
1102
+ // its public export, then its id. Ids and public names are derived, and a
1103
+ // newer rule may spell them differently (`squares-2-x2` is now
1104
+ // `squares-2x2`); an icon matched this way keeps its public export byte
1105
+ // for byte, every field it had (its variants, the words the author added,
1106
+ // its module), and its map entries. The same rule holds on every refresh,
1107
+ // so a second refresh changes nothing the first one wrote.
1108
+ const moving = new Set(supplied.filter(icon => rootMoves.has(icon.export)));
684
1109
  const byId = new Map(supplied.map(icon => [icon.id, icon]));
685
1110
  const byExport = new Map(supplied.map(icon => [icon.export, icon]));
686
1111
  const bySource = new Map(
687
- supplied.map(icon => [origins.get(icon.export)?.source, icon]),
1112
+ supplied.map(icon => [libraryExportOf(icon.export), icon]),
1113
+ );
1114
+ // One icon per glyph. A look the set already wraps keeps its icon and id.
1115
+ // A new glyph comes in once, in the preferred look. A new default variant
1116
+ // moves each glyph's icon to that look.
1117
+ const preferred = looks?.default ?? current;
1118
+ const {icons: looksOf} = libraryLooks(
1119
+ everyIcon,
1120
+ preferred ? [preferred] : [],
688
1121
  );
689
- /** @param {{export: string, name: string, id: string}} icon */
690
- const priorOf = icon =>
691
- bySource.get(icon.export) ??
692
- byExport.get(icon.name) ??
693
- byId.get(icon.id) ??
694
- null;
695
- const otherIds = new Set(others.map(icon => icon.id));
696
- const otherExports = new Set(others.map(icon => icon.export));
697
- /** @type {string[]} library icons left out: an icon of yours has the id or name */
1122
+ const iconByExport = new Map(everyIcon.map(icon => [icon.export, icon]));
1123
+ /** @type {string[]} the set's exports whose statement now names another look */
1124
+ const repicked = [];
1125
+ /** @type {Map<string, (typeof supplied)[number]>} the new look's export → the icon it now draws */
1126
+ const repickedBy = new Map();
1127
+ const found = looksOf.flatMap(icon => {
1128
+ const wrapped = (icon.looks ?? []).filter(look => bySource.has(look));
1129
+ if (wrapped.length === 0) return [icon];
1130
+ return wrapped.map(look => {
1131
+ const prior = /** @type {(typeof supplied)[number]} */ (
1132
+ bySource.get(look)
1133
+ );
1134
+ // A moved icon is the glyph its root export draws, whatever its id.
1135
+ const sameGlyph = moving.has(prior) || prior.id === icon.id;
1136
+ if (moved && sameGlyph && !bySource.has(icon.export)) {
1137
+ repicked.push(prior.export);
1138
+ repickedBy.set(icon.export, prior);
1139
+ return icon;
1140
+ }
1141
+ const own = /** @type {(typeof everyIcon)[number]} */ (
1142
+ iconByExport.get(look)
1143
+ );
1144
+ // Read by the glyph's id when the set already names it so.
1145
+ return prior.id === icon.id
1146
+ ? {...own, id: icon.id, name: icon.name}
1147
+ : own;
1148
+ });
1149
+ });
1150
+ /** @param {{export: string}} glyph */
1151
+ const resolvedPrior = glyph =>
1152
+ repickedBy.get(glyph.export) ?? bySource.get(glyph.export) ?? null;
1153
+ // An icon its library export already gives to one glyph is never given to
1154
+ // a second glyph by name or id: that glyph is left out.
1155
+ const resolved = new Set(found.map(resolvedPrior).filter(Boolean));
1156
+ const takenIds = new Set(others.map(icon => icon.id));
1157
+ const takenExports = new Set(others.map(icon => icon.export));
1158
+ /** @type {string[]} library icons left out: an icon of the set has the id or name */
698
1159
  const shadowed = [];
699
1160
  /** @type {Map<string, string>} old id → new id */
700
1161
  const renames = new Map();
701
1162
  /** @type {Map<string, string>} the library export → the set's export */
702
1163
  const publicName = new Map();
703
1164
  const fromLibrary = found.flatMap(icon => {
704
- const prior = priorOf(icon);
1165
+ const byOrigin = resolvedPrior(icon);
1166
+ const byName = byOrigin
1167
+ ? null
1168
+ : (byExport.get(icon.name) ?? byId.get(icon.id) ?? null);
1169
+ const prior = byOrigin ?? (resolved.has(byName) ? null : byName);
705
1170
  const name = prior?.export ?? icon.name;
706
- if (!prior && (otherIds.has(icon.id) || otherExports.has(name))) {
1171
+ if (
1172
+ !prior &&
1173
+ (byName || takenIds.has(icon.id) || takenExports.has(name))
1174
+ ) {
707
1175
  shadowed.push(icon.id);
708
1176
  return [];
709
1177
  }
710
- if (prior && prior.id !== icon.id) renames.set(prior.id, icon.id);
711
1178
  publicName.set(icon.export, name);
1179
+ if (prior && byOrigin && !derivedId(prior.id, icon)) {
1180
+ return [
1181
+ {
1182
+ ...prior,
1183
+ words: [...new Set([...(prior.words ?? []), ...icon.aliases])],
1184
+ },
1185
+ ];
1186
+ }
1187
+ if (prior && prior.id !== icon.id) renames.set(prior.id, icon.id);
712
1188
  // The words its old id gave are the old rule's, not the author's.
713
1189
  const oldIdWords =
714
1190
  prior && prior.id !== icon.id ? new Set(idWords(prior.id)) : new Set();
@@ -728,17 +1204,42 @@ async function refreshIconSet(
728
1204
  ];
729
1205
  });
730
1206
  const icons = [...fromLibrary, ...others];
1207
+ // An icon is listed under `moved` when it or one of its variants moves.
1208
+ /** @type {Array<{id: string, from: string, to: string}>} */
1209
+ const movedIcons = [];
1210
+ /** @type {import('../../foundation/discovery/icon-set-writer.mjs').GeneratedIcon[]} */
1211
+ const movedVariants = [];
1212
+ for (const icon of fromLibrary) {
1213
+ const variantMoves = (icon.variants ?? []).filter(each =>
1214
+ rootMoves.has(each.export),
1215
+ );
1216
+ for (const each of variantMoves) {
1217
+ const move = /** @type {{source: string, from: string}} */ (
1218
+ rootMoves.get(each.export)
1219
+ );
1220
+ movedVariants.push({
1221
+ name: each.export,
1222
+ from: source.module,
1223
+ source: move.source,
1224
+ adapt: iconByExport.get(move.source)?.adapt ?? false,
1225
+ });
1226
+ }
1227
+ const from = (
1228
+ rootMoves.get(icon.export) ?? rootMoves.get(variantMoves[0]?.export)
1229
+ )?.from;
1230
+ if (from) movedIcons.push({id: icon.id, from, to: source.module});
1231
+ }
1232
+ movedIcons.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
731
1233
  // The icons this module supplied that it no longer exports: they leave
732
1234
  // the set and its generated module, and the receipt names them.
733
1235
  // Each is named for why it left: `--exclude` (or the data file's
734
1236
  // `exclude`) names its library export, or the library no longer exports
735
1237
  // it.
736
1238
  const excludedNames = new Set(excluded);
737
- const gone = supplied.filter(
738
- icon => !fromLibrary.some(each => each.export === icon.export),
739
- );
1239
+ const keptExports = new Set(fromLibrary.map(each => each.export));
1240
+ const gone = supplied.filter(icon => !keptExports.has(icon.export));
740
1241
  const isExcluded = (/** @type {{export: string}} */ icon) =>
741
- excludedNames.has(origins.get(icon.export)?.source ?? '') ||
1242
+ excludedNames.has(libraryExportOf(icon.export) ?? '') ||
742
1243
  excludedNames.has(icon.export);
743
1244
  const goneExcluded = gone.filter(isExcluded);
744
1245
  const goneMissing = gone.filter(icon => !isExcluded(icon));
@@ -782,20 +1283,7 @@ async function refreshIconSet(
782
1283
  // --display-name and --description rewrite the descriptor's fields.
783
1284
  const docFile = path.join(set.dir, `${set.name}.doc.mjs`);
784
1285
  const docBefore = fs.readFileSync(docFile, 'utf-8');
785
- let docAfter = docBefore;
786
- for (const [field, value] of /** @type {const} */ ([
787
- ['displayName', describe.displayName],
788
- ['description', describe.description],
789
- ])) {
790
- if (!value?.trim()) continue;
791
- docAfter = docAfter.replace(
792
- new RegExp(
793
- `^(\\s*${field}:\\s*)(['"\`])(?:\\\\.|(?!\\2).)*\\2,?$`,
794
- 'mu',
795
- ),
796
- (_, lead) => `${lead}${quoteJs(value.trim())},`,
797
- );
798
- }
1286
+ let docAfter = describedDoc(docBefore, describe);
799
1287
  // A refresh that first records the library as the set's source does what
800
1288
  // `new-set --from` does: the descriptor takes the library's license, but
801
1289
  // only over the value `new-set` wrote by default (the package's license,
@@ -840,21 +1328,31 @@ async function refreshIconSet(
840
1328
  files.push(projectPath(path.relative(packageDir, target)));
841
1329
  }
842
1330
  if (!dryRun) {
1331
+ const rewritten = found
1332
+ .map(icon => ({
1333
+ name: publicName.get(icon.export) ?? icon.name,
1334
+ from: source.module,
1335
+ source: icon.export,
1336
+ adapt: icon.adapt,
1337
+ }))
1338
+ .filter(icon => publicName.has(icon.source))
1339
+ .concat(movedVariants);
1340
+ const rewrittenNames = new Set(rewritten.map(icon => icon.name));
843
1341
  const written = writeGeneratedIcons(
844
1342
  packageDir,
845
1343
  generated,
846
1344
  next,
847
- found
848
- .map(icon => ({
849
- name: publicName.get(icon.export) ?? icon.name,
850
- from: source.module,
851
- source: icon.export,
852
- adapt: icon.adapt,
853
- }))
854
- .filter(icon => publicName.has(icon.source)),
1345
+ rewritten,
855
1346
  // The icons the library no longer exports leave the module too; a
856
- // renamed icon keeps its export.
857
- gone.map(icon => icon.export),
1347
+ // renamed icon keeps its export. An icon moved to the new default
1348
+ // look keeps its export, which now names that look. An export moved
1349
+ // to the root is written again with the root import. Every other
1350
+ // export stays as it is, because the data file still lists it.
1351
+ [
1352
+ ...gone.map(icon => icon.export),
1353
+ ...repicked,
1354
+ ...[...rootMoves.keys()].filter(name => rewrittenNames.has(name)),
1355
+ ],
858
1356
  );
859
1357
  files.push(...written.files);
860
1358
  try {
@@ -906,15 +1404,32 @@ async function refreshIconSet(
906
1404
  if (renames.size > 0) {
907
1405
  result.data.renamed = [...renames].map(([from, to]) => ({from, to}));
908
1406
  }
1407
+ if (movedIcons.length > 0) result.data.moved = movedIcons;
1408
+ const missingNotice = hasLicenseNotice(set.dir)
1409
+ ? null
1410
+ : noticeToAdd(
1411
+ library,
1412
+ packageDir,
1413
+ projectPath(path.relative(packageDir, set.dir)),
1414
+ );
1415
+ if (missingNotice) {
1416
+ result.data.licenseNoticeMissing = missingNotice.receipt;
1417
+ }
909
1418
  result.data.notes = [
910
1419
  ...(licenseNote ? [licenseNote] : []),
1420
+ ...(missingNotice ? [missingNotice.note] : []),
911
1421
  ...(renames.size > 0
912
1422
  ? [
913
1423
  `${renames.size} icons have a new id under this release's id rule, and keep their map entries, variants, and words: ${[...renames].map(([from, to]) => `${from} -> ${to}`).join(', ')}.`,
914
1424
  ]
915
1425
  : []),
916
- ...mappedNotes(byName),
917
- ...suggestionNotes(suggestions),
1426
+ ...(movedIcons.length > 0
1427
+ ? [
1428
+ `${movedIcons.length} icons now import from ${source.module} instead of one file each, and keep their ids, exports, map entries, variants, and words: ${movedIcons.map(each => each.id).join(', ')}.`,
1429
+ ]
1430
+ : []),
1431
+ ...mappedNotes(byName, name),
1432
+ ...suggestionNotes(suggestions, name),
918
1433
  ...(goneMissing.length > 0
919
1434
  ? [
920
1435
  `${goneMissing.length} icons the library no longer exports were removed from the set: ${goneMissing.map(icon => icon.id).join(', ')}.`,
@@ -937,7 +1452,12 @@ async function refreshIconSet(
937
1452
  : []),
938
1453
  ...(shadowed.length > 0
939
1454
  ? [
940
- `Left out ${shadowed.length} library icons whose id or name an icon of yours from another module already has: ${shadowed.join(', ')}.`,
1455
+ `Left out ${shadowed.length} library icons whose id or name an icon of the set already has: ${shadowed.join(', ')}.`,
1456
+ ]
1457
+ : []),
1458
+ ...(skippedMoves.length > 0
1459
+ ? [
1460
+ `${skippedMoves.length} icons stay on their own file, because another icon of the set already reads the same export of ${source.module}: ${skippedMoves.join(', ')}.`,
941
1461
  ]
942
1462
  : []),
943
1463
  astryxIconDefinitions().definitions.every(({doc}) => map[doc.name])