@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
@@ -723,20 +723,405 @@ export function modulePrefix(file, options) {
723
723
  return libraryPrefix(prefixNames(iconCandidates(file, options)));
724
724
  }
725
725
 
726
+ /**
727
+ * How a module names its icons: the library prefix every icon export shares
728
+ * ({@link modulePrefix}) and the look words its names end in
729
+ * ({@link lookPositions}), read from its icon exports once. A large module
730
+ * reports its reading, as {@link libraryIcons} does.
731
+ * @param {string} file
732
+ * @param {{adapter?: boolean, module?: string, progress?: (line: string) => void}} [options]
733
+ * `module`: the module as the author named it, for progress; `progress`:
734
+ * hears the reading of a large module
735
+ * @returns {{prefix: string[], positions: Array<Set<string>>}}
736
+ */
737
+ export function moduleNaming(file, options = {}) {
738
+ const label = options.module ?? path.basename(file);
739
+ let count = 0;
740
+ return readingLibrary(
741
+ file,
742
+ {label, progress: options.progress},
743
+ () => {
744
+ const candidates = iconCandidates(file, options);
745
+ count = candidates.length;
746
+ const prefix = libraryPrefix(prefixNames(candidates));
747
+ const positions = lookPositions(
748
+ candidates.map(each => iconNaming(each.name, prefix).id),
749
+ );
750
+ return {prefix, positions};
751
+ },
752
+ () => `Found ${count} icon exports in ${label}.`,
753
+ );
754
+ }
755
+
756
+ /**
757
+ * An export's id and set name ({@link iconNaming}) without the look words
758
+ * its library's names end in ({@link lookPositions}):
759
+ * `XOutline24WGHT500Icon` is `x`, `XIcon`.
760
+ * @param {string} name
761
+ * @param {{prefix: string[], positions: Array<Set<string>>}} naming
762
+ * @returns {{id: string, name: string}}
763
+ */
764
+ export function iconNamingWithoutLook(name, {prefix, positions}) {
765
+ const named = iconNaming(name, prefix);
766
+ const {base} = splitLook(named.id.split('-'), positions);
767
+ if (base.length === named.id.split('-').length) return named;
768
+ const pascal = base
769
+ .map(word => word[0].toUpperCase() + word.slice(1))
770
+ .join('');
771
+ return {id: base.join('-'), name: iconExportName(pascal)};
772
+ }
773
+
774
+ /** A library with at least this many exports reports its reading. */
775
+ const PROGRESS_FROM = 1000;
776
+
777
+ /** Fewer icons than this never show a library's look positions. */
778
+ const LOOK_MIN_ICONS = 20;
779
+
780
+ /** More words than this in one trailing position are glyph names. */
781
+ const LOOK_MAX_VALUES = 12;
782
+
783
+ /** Words that name a look's style, never a glyph. */
784
+ const LOOK_STYLE_WORDS = new Set([
785
+ 'default',
786
+ 'regular',
787
+ 'outline',
788
+ 'outlined',
789
+ 'line',
790
+ 'linear',
791
+ 'stroke',
792
+ 'filled',
793
+ 'fill',
794
+ 'solid',
795
+ 'bold',
796
+ 'light',
797
+ 'thin',
798
+ 'duotone',
799
+ 'twotone',
800
+ 'mini',
801
+ 'micro',
802
+ 'sharp',
803
+ 'round',
804
+ 'rounded',
805
+ 'bulk',
806
+ 'broken',
807
+ ]);
808
+
809
+ /** Words that name a glyph's direction (`ArrowUp`), never a look. */
810
+ const DIRECTION_WORDS = new Set([
811
+ 'up',
812
+ 'down',
813
+ 'left',
814
+ 'right',
815
+ 'top',
816
+ 'bottom',
817
+ 'start',
818
+ 'end',
819
+ 'north',
820
+ 'south',
821
+ 'east',
822
+ 'west',
823
+ ]);
824
+
825
+ /**
826
+ * Whether the words that fill a trailing position name a look: each is
827
+ * numbered (`24`, `wght500`) or one is a style word, and none is a
828
+ * direction word. `ArrowUp` and `ArrowDown` are two glyphs, not two looks.
829
+ * @param {string[]} words
830
+ */
831
+ function namesLook(words) {
832
+ if (words.some(word => DIRECTION_WORDS.has(word))) return false;
833
+ return (
834
+ words.every(word => /^[a-z]*\d+$/u.test(word)) ||
835
+ words.some(word => LOOK_STYLE_WORDS.has(word))
836
+ );
837
+ }
838
+
839
+ /**
840
+ * The trailing words a library encodes its looks with: a style, a size, a
841
+ * weight (`XOutline24WGHT500Icon` is `x` in the `outline`, `24`, `wght500`
842
+ * look). A trailing position is a look position when, across at least
843
+ * {@link LOOK_MIN_ICONS} icons, at most {@link LOOK_MAX_VALUES} words fill
844
+ * it, each on at least 5% of the icons and together on at least 90%, and
845
+ * those words name a look ({@link namesLook}); the positions are read from
846
+ * the end while they hold. Glyph names never meet that bar (`trash-2` and
847
+ * `clock-10` are a few icons each, and `up` and `down` are directions), so
848
+ * a library that encodes no looks has none. Reads each id once per
849
+ * position and keeps only counts.
850
+ * @param {string[]} ids every icon's id
851
+ * @returns {Array<Set<string>>} the look positions, the last word first
852
+ */
853
+ export function lookPositions(ids) {
854
+ if (ids.length < LOOK_MIN_ICONS) return [];
855
+ /** @type {Array<Set<string>>} */
856
+ const positions = [];
857
+ const least = Math.max(2, Math.ceil(ids.length * 0.05));
858
+ while (positions.length < 4) {
859
+ /** @type {Map<string, number>} */
860
+ const counts = new Map();
861
+ for (const id of ids) {
862
+ const words = id.split('-');
863
+ const {base} = splitLook(words, positions);
864
+ if (base.length < 2 || base.length < words.length - positions.length) {
865
+ continue;
866
+ }
867
+ const last = /** @type {string} */ (base.at(-1));
868
+ counts.set(last, (counts.get(last) ?? 0) + 1);
869
+ }
870
+ const values = [...counts].filter(([, count]) => count >= least);
871
+ if (values.length === 0 || values.length > LOOK_MAX_VALUES) break;
872
+ const covered = values.reduce((sum, [, count]) => sum + count, 0);
873
+ if (covered < ids.length * 0.9) break;
874
+ if (!namesLook(values.map(([value]) => value))) break;
875
+ positions.push(new Set(values.map(([value]) => value)));
876
+ }
877
+ return positions;
878
+ }
879
+
880
+ /**
881
+ * An id's glyph words and its look words, by {@link lookPositions}:
882
+ * `x-outline-24-wght500` is `x` with `[wght500, 24, outline]`, null where
883
+ * the id has no look word.
884
+ * @param {string[]} words
885
+ * @param {Array<Set<string>>} positions
886
+ * @returns {{base: string[], look: Array<string | null>}}
887
+ */
888
+ export function splitLook(words, positions) {
889
+ let end = words.length;
890
+ /** @type {Array<string | null>} */
891
+ const look = positions.map(() => null);
892
+ for (const [index, position] of positions.entries()) {
893
+ const last = words[end - 1];
894
+ if (end < 2 || last == null || !position.has(last)) break;
895
+ look[index] = last;
896
+ end--;
897
+ }
898
+ return {base: end === words.length ? words : words.slice(0, end), look};
899
+ }
900
+
901
+ /** Style words a library's default look is usually named by. */
902
+ const DEFAULT_STYLE_WORDS = ['default', 'regular', 'outline', 'outlined'];
903
+
904
+ /**
905
+ * The word each look position prefers: a word of `prefer` it has (the set's
906
+ * default variant), else a usual default style word, else the middle of a
907
+ * series of numbered words (`wght300` … `wght700` gives `wght500`), else
908
+ * its first word in order.
909
+ * @param {Array<Set<string>>} positions
910
+ * @param {string[]} [prefer]
911
+ * @returns {string[]}
912
+ */
913
+ export function preferredLook(positions, prefer = []) {
914
+ const wanted = prefer.map(word => word.toLowerCase());
915
+ return positions.map(position => {
916
+ const values = [...position].sort();
917
+ const chosen =
918
+ wanted.find(word => position.has(word)) ??
919
+ DEFAULT_STYLE_WORDS.find(word => position.has(word));
920
+ if (chosen) return chosen;
921
+ const numbered = values.map(value => /^([a-z]*)(\d+)$/u.exec(value));
922
+ const lead = numbered[0]?.[1];
923
+ if (
924
+ values.length > 1 &&
925
+ numbered.every(match => match != null && match[1] === lead)
926
+ ) {
927
+ const series = values
928
+ .map((value, index) => ({
929
+ value,
930
+ n: Number(/** @type {RegExpExecArray} */ (numbered[index])[2]),
931
+ }))
932
+ .sort((a, b) => a.n - b.n);
933
+ return series[Math.floor((series.length - 1) / 2)].value;
934
+ }
935
+ return values[0];
936
+ });
937
+ }
938
+
939
+ /**
940
+ * One icon a library gives.
941
+ * @typedef {object} LibraryIcon
942
+ * @property {string} export the library export it reads
943
+ * @property {string} id
944
+ * @property {string} name the name the set exports it as
945
+ * @property {boolean} adapt
946
+ * @property {string[]} aliases the ids of the other names the library
947
+ * exports the icon as, leaving out the library's own name (`lucide-trash`)
948
+ * @property {Array<string | null>} [look] in a library that encodes looks:
949
+ * this export's look words by position, the last word first
950
+ * @property {string[]} [looks] in a library that encodes looks: every
951
+ * export that draws the glyph, this one included
952
+ */
953
+
954
+ /**
955
+ * The words a library's looks are named by, and the look a set takes from
956
+ * it: what {@link libraryIcons} found, for the receipt.
957
+ * @typedef {object} LibraryLookSummary
958
+ * @property {string[][]} positions each position's words, sorted, the last
959
+ * word of a name first
960
+ * @property {string[]} preferred the word taken in each position
961
+ * @property {number} exports how many exports draw the icons
962
+ */
963
+
726
964
  /**
727
965
  * Every icon a whole library exports, for wrapping it at once: one entry per
728
966
  * component (and, with an adapter, per data export), read by its `…Icon`
729
967
  * export when it has one (an alias of the same value is recorded once), with
730
968
  * its id and the name the set exports it as from the name, the library
731
969
  * prefix left out ({@link libraryPrefix}).
970
+ *
971
+ * A library that encodes looks in its export names ({@link lookPositions})
972
+ * gives one icon per glyph: the look {@link preferredLook} prefers, its id
973
+ * and name without the look words (`XOutline24WGHT500Icon` is `x`,
974
+ * `XIcon`), and its `looks`, every export that draws the glyph.
732
975
  * @param {string} file
733
- * @param {{adapter?: boolean, exclude?: string[]}} [options] `adapter`:
734
- * whether the set declares one; `exclude`: export names that are not icons
735
- * @returns {Array<{export: string, id: string, name: string, adapt: boolean, aliases: string[]}>}
736
- * `aliases`: the ids of the other names the library exports the icon as,
737
- * leaving out the library's own name (`lucide-trash`)
976
+ * @param {{adapter?: boolean, exclude?: string[], prefer?: string[] | ((positions: Array<Set<string>>) => string[]), module?: string, progress?: (line: string) => void, summary?: (summary: LibraryLookSummary) => void}} [options]
977
+ * `adapter`: whether the set declares one; `exclude`: export names that
978
+ * are not icons; `prefer`: look words to prefer (the set's default
979
+ * variant), or a function that gives them from the library's look
980
+ * positions before any glyph is picked; `module`: the module as the author named it, for progress;
981
+ * `progress`: hears the reading of a large module;
982
+ * `summary`: hears the looks found, when the library has any
983
+ * @returns {LibraryIcon[]}
738
984
  */
739
985
  export function libraryIcons(file, options = {}) {
986
+ const label = options.module ?? path.basename(file);
987
+ /** @type {LibraryIcon[]} */
988
+ let all = [];
989
+ return readingLibrary(
990
+ file,
991
+ {label, progress: options.progress},
992
+ () => {
993
+ all = collectLibraryIcons(file, options);
994
+ const prefer =
995
+ typeof options.prefer === 'function'
996
+ ? options.prefer(lookPositions(all.map(icon => icon.id)))
997
+ : options.prefer;
998
+ const grouped = libraryLooks(all, prefer);
999
+ if (grouped.positions.length > 0) {
1000
+ options.summary?.({
1001
+ positions: grouped.positions.map(position => [...position].sort()),
1002
+ preferred: grouped.preferred,
1003
+ exports: all.length,
1004
+ });
1005
+ }
1006
+ return grouped.icons;
1007
+ },
1008
+ icons =>
1009
+ icons.length === all.length
1010
+ ? `Found ${icons.length} icons in ${label}.`
1011
+ : `Found ${icons.length} icons in ${label}, drawn by ${all.length} exports in their looks.`,
1012
+ );
1013
+ }
1014
+
1015
+ /**
1016
+ * Read a library with its progress reported: when `progress` is given and
1017
+ * the module has {@link PROGRESS_FROM} exports or more, a line before `read`
1018
+ * runs and the line `found` gives after.
1019
+ * @template T
1020
+ * @param {string} file
1021
+ * @param {{label: string, progress?: (line: string) => void}} reading
1022
+ * `label`: the module as the author named it
1023
+ * @param {() => T} read
1024
+ * @param {(result: T) => string} found
1025
+ * @returns {T}
1026
+ */
1027
+ function readingLibrary(file, {label, progress}, read, found) {
1028
+ const exportCount = progress ? followedExportNames(file).length : 0;
1029
+ if (progress == null || exportCount < PROGRESS_FROM) return read();
1030
+ progress(`Reading ${exportCount} exports of ${label}...`);
1031
+ const result = read();
1032
+ progress(found(result));
1033
+ return result;
1034
+ }
1035
+
1036
+ /**
1037
+ * The library's icons grouped by glyph when its export names encode looks
1038
+ * ({@link libraryIcons}), with the look positions and the words preferred.
1039
+ * Without look positions the icons are returned as they are.
1040
+ * @param {LibraryIcon[]} all every icon, one per export
1041
+ * @param {string[]} [prefer]
1042
+ * @returns {{icons: LibraryIcon[], positions: Array<Set<string>>, preferred: string[]}}
1043
+ */
1044
+ export function libraryLooks(all, prefer = []) {
1045
+ const positions = lookPositions(all.map(icon => icon.id));
1046
+ if (positions.length === 0) return {icons: all, positions, preferred: []};
1047
+ const preferred = preferredLook(positions, prefer);
1048
+ // How far a look is from the preferred one, as a sortable key: the
1049
+ // position first in the name (the style) counts most.
1050
+ const distance = (/** @type {Array<string | null>} */ look) => {
1051
+ let key = '';
1052
+ for (let index = look.length - 1; index >= 0; index--) {
1053
+ key += look[index] === preferred[index] ? '0' : '1';
1054
+ }
1055
+ return key;
1056
+ };
1057
+ /** @type {Map<string, {best: LibraryIcon, look: Array<string | null>, key: string, looks: string[]}>} */
1058
+ const byGlyph = new Map();
1059
+ for (const icon of all) {
1060
+ const {base, look} = splitLook(icon.id.split('-'), positions);
1061
+ const id = base.join('-');
1062
+ const key = distance(look);
1063
+ const group = byGlyph.get(id);
1064
+ if (!group) {
1065
+ byGlyph.set(id, {best: icon, look, key, looks: [icon.export]});
1066
+ continue;
1067
+ }
1068
+ group.looks.push(icon.export);
1069
+ if (
1070
+ key < group.key ||
1071
+ (key === group.key && icon.export < group.best.export)
1072
+ ) {
1073
+ group.best = icon;
1074
+ group.look = look;
1075
+ group.key = key;
1076
+ }
1077
+ }
1078
+ /** @type {LibraryIcon[]} */
1079
+ const icons = [];
1080
+ for (const [id, {best, look, looks}] of byGlyph) {
1081
+ const pascal = id
1082
+ .split('-')
1083
+ .map(word => word[0].toUpperCase() + word.slice(1))
1084
+ .join('');
1085
+ icons.push({
1086
+ ...best,
1087
+ id,
1088
+ name: iconExportName(pascal),
1089
+ aliases: best.aliases.filter(alias => !byGlyph.has(alias)),
1090
+ look,
1091
+ looks: looks.sort(),
1092
+ });
1093
+ }
1094
+ icons.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1095
+ return {icons, positions, preferred};
1096
+ }
1097
+
1098
+ /**
1099
+ * Every icon a library exports, one per export: {@link libraryIcons} before
1100
+ * looks are grouped. A large module reports its reading, as
1101
+ * {@link libraryIcons} does.
1102
+ * @param {string} file
1103
+ * @param {{adapter?: boolean, exclude?: string[], module?: string, progress?: (line: string) => void}} [options]
1104
+ * `module`: the module as the author named it, for progress; `progress`:
1105
+ * hears the reading of a large module
1106
+ * @returns {LibraryIcon[]}
1107
+ */
1108
+ export function everyLibraryIcon(file, options = {}) {
1109
+ const label = options.module ?? path.basename(file);
1110
+ return readingLibrary(
1111
+ file,
1112
+ {label, progress: options.progress},
1113
+ () => collectLibraryIcons(file, options),
1114
+ icons => `Found ${icons.length} exports in ${label}.`,
1115
+ );
1116
+ }
1117
+
1118
+ /**
1119
+ * {@link everyLibraryIcon} without progress.
1120
+ * @param {string} file
1121
+ * @param {{adapter?: boolean, exclude?: string[]}} [options]
1122
+ * @returns {LibraryIcon[]}
1123
+ */
1124
+ function collectLibraryIcons(file, options = {}) {
740
1125
  const candidates = iconCandidates(file, options);
741
1126
  /** @type {Map<string, {names: string[], adapt: boolean, scalar: string | null}>} */
742
1127
  const byOrigin = new Map();
@@ -830,16 +1215,29 @@ export function iconDefinitionSource(doc) {
830
1215
  }
831
1216
 
832
1217
  /**
833
- * Check a new icon definition for a set: a camelCase name no Astryx
834
- * definition has, a description, and at least one lower-case word.
1218
+ * A new definition's name as written in a command: camelCase as given, or
1219
+ * kebab-case as the docs list definitions (`ai-helper` is `aiHelper`).
1220
+ * @param {string} name
1221
+ */
1222
+ export function definitionNameFromInput(name) {
1223
+ return /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/u.test(name)
1224
+ ? name.replace(/-([a-z0-9])/gu, (_, c) => c.toUpperCase())
1225
+ : name;
1226
+ }
1227
+
1228
+ /**
1229
+ * Check a new icon definition for a set: a camelCase name (or its kebab-case
1230
+ * form, {@link definitionNameFromInput}) no Astryx definition has, a
1231
+ * description, and at least one lower-case word.
835
1232
  * @param {DiscoveredIconSet} set
836
- * @param {{name: string, description?: string, words?: string[]}} doc
1233
+ * @param {{name: string, description?: string, words?: string[]}} given
837
1234
  * @returns {{name: string, description: string, words: string[]}}
838
1235
  */
839
- export function checkNewDefinition(set, doc) {
1236
+ export function checkNewDefinition(set, given) {
1237
+ const doc = {...given, name: definitionNameFromInput(given.name)};
840
1238
  if (!/^[a-z][a-zA-Z0-9]*$/u.test(doc.name)) {
841
1239
  throw new IconSetAuthoringError(
842
- `Invalid icon definition name "${doc.name}": use camelCase starting with a lower-case letter.`,
1240
+ `Invalid icon definition name "${doc.name}": use camelCase (aiHelper) or kebab-case (ai-helper), starting with a lower-case letter.`,
843
1241
  'ERR_INVALID_ARGUMENT',
844
1242
  );
845
1243
  }
@@ -919,8 +1317,11 @@ const GENERATED_HEADER = [
919
1317
 
920
1318
  /**
921
1319
  * The top-level statements of a generated module, one normalized line each,
922
- * however the file was formatted: whitespace collapsed, no padding inside
923
- * braces, no trailing comma. Comments are dropped.
1320
+ * however a formatter left the file (Prettier's line breaks, double quotes,
1321
+ * trailing commas, semicolons or none): whitespace collapsed, no padding
1322
+ * inside braces or parentheses, no trailing comma, type members separated by
1323
+ * `; ` with none before the closing brace, no statement semicolon, and
1324
+ * single-quoted strings. Comments are dropped.
924
1325
  * @param {string} text
925
1326
  * @param {string} label the module's path, for the error
926
1327
  * @returns {string[]}
@@ -935,17 +1336,110 @@ function generatedStatements(text, label) {
935
1336
  'ERR_INVALID_ARGUMENT',
936
1337
  );
937
1338
  }
938
- return ast.program.body.map(node =>
939
- text
940
- .slice(
941
- /** @type {number} */ (node.start),
942
- /** @type {number} */ (node.end),
943
- )
944
- .replace(/\s+/gu, ' ')
945
- .replace(/\{ /gu, '{')
946
- .replace(/,? \}/gu, '}')
947
- .replace(/,\}/gu, '}'),
948
- );
1339
+ return ast.program.body.map(node => {
1340
+ const start = /** @type {number} */ (node.start);
1341
+ // A formatter without semicolons ends each type member with a line
1342
+ // break, which collapses to a space: give each member its separator.
1343
+ const separators = typeMemberEnds(node)
1344
+ .filter(end => !/^\s*[;,]/u.test(text.slice(end, end + 1)))
1345
+ .filter(end => !/[;,]$/u.test(text.slice(end - 1, end)))
1346
+ .sort((a, b) => b - a);
1347
+ let statement = text.slice(start, /** @type {number} */ (node.end));
1348
+ for (const end of separators) {
1349
+ const at = end - start;
1350
+ statement = `${statement.slice(0, at)};${statement.slice(at)}`;
1351
+ }
1352
+ return normalizedStatement(statement);
1353
+ });
1354
+ }
1355
+
1356
+ /**
1357
+ * One statement's text in the normalized form {@link generatedStatements}
1358
+ * gives.
1359
+ * @param {string} statement
1360
+ */
1361
+ function normalizedStatement(statement) {
1362
+ return statement
1363
+ .replace(/\s+/gu, ' ')
1364
+ .replace(/"((?:[^"'\\]|\\.)*)"/gu, "'$1'")
1365
+ .replace(/([{(]) /gu, '$1')
1366
+ .replace(/[,;]? ?([})])/gu, '$1')
1367
+ .trim()
1368
+ .replace(/ ?;$/u, '');
1369
+ }
1370
+
1371
+ /**
1372
+ * Where each type member of a statement ends (the members of every type
1373
+ * literal and interface in it).
1374
+ * @param {any} node
1375
+ * @returns {number[]}
1376
+ */
1377
+ function typeMemberEnds(node) {
1378
+ /** @type {number[]} */
1379
+ const ends = [];
1380
+ /** @param {any} each */
1381
+ const visit = each => {
1382
+ if (each == null || typeof each !== 'object') return;
1383
+ if (Array.isArray(each)) {
1384
+ for (const item of each) visit(item);
1385
+ return;
1386
+ }
1387
+ if (typeof each.type !== 'string') return;
1388
+ const members =
1389
+ each.type === 'TSTypeLiteral'
1390
+ ? each.members
1391
+ : each.type === 'TSInterfaceBody'
1392
+ ? each.body
1393
+ : null;
1394
+ if (members) {
1395
+ for (const member of members) ends.push(member.end);
1396
+ }
1397
+ for (const key of Object.keys(each)) {
1398
+ if (
1399
+ key === 'loc' ||
1400
+ key === 'leadingComments' ||
1401
+ key === 'trailingComments' ||
1402
+ key === 'innerComments'
1403
+ ) {
1404
+ continue;
1405
+ }
1406
+ visit(each[key]);
1407
+ }
1408
+ };
1409
+ visit(node);
1410
+ return ends;
1411
+ }
1412
+
1413
+ /**
1414
+ * A normalized statement as the generated module writes it: with its
1415
+ * semicolon, except after a declaration that ends in its own block.
1416
+ * @param {string} statement
1417
+ */
1418
+ function terminated(statement) {
1419
+ return /^(?:export (?:default )?)?(?:declare )?(?:abstract )?(?:async )?(?:function\b|class\b|interface\b|enum\b|namespace\b|module\b)/u.test(
1420
+ statement,
1421
+ )
1422
+ ? statement
1423
+ : `${statement};`;
1424
+ }
1425
+
1426
+ /**
1427
+ * Whether a generated declaration file declares what its generated module
1428
+ * gives, statement by statement however a formatter left either file
1429
+ * ({@link generatedStatements}); the header comment is ignored.
1430
+ * @param {string} actual the declaration file as it is
1431
+ * @param {string} expected {@link generatedTypesText} for the module
1432
+ * @param {string} label the declaration file's path, for a parse error
1433
+ */
1434
+ export function generatedTypesMatch(actual, expected, label) {
1435
+ let have;
1436
+ try {
1437
+ have = generatedStatements(actual, label);
1438
+ } catch {
1439
+ return false;
1440
+ }
1441
+ const want = generatedStatements(expected, label);
1442
+ return have.length === want.length && have.every((s, i) => s === want[i]);
949
1443
  }
950
1444
 
951
1445
  /** The local name the generated module gives the set's adapter. */
@@ -955,7 +1449,7 @@ const ADAPTER_LOCAL = 'adaptIcon';
955
1449
  export const THEME_MAP_NAME = 'themeIcons';
956
1450
 
957
1451
  /** How the generated module imports React for its theme map. */
958
- const REACT_IMPORT = "import {createElement} from 'react';";
1452
+ const REACT_IMPORT = "import {createElement} from 'react'";
959
1453
 
960
1454
  /** The built-in icon definitions: the shared names a theme map covers. */
961
1455
  const BUILT_IN = new Set(ICON_NAMES);
@@ -987,10 +1481,15 @@ export function generatedModulePath(set, packageDir) {
987
1481
  * component
988
1482
  */
989
1483
 
990
- /** The props every theme-map element gets: sized to its box, hidden from assistive tech. */
1484
+ /**
1485
+ * The props every theme-map element gets: hidden from assistive tech, and
1486
+ * sized to its box by CSS as well as by attributes. A theme's icon sits in a
1487
+ * box whose font size is the icon size; CSS width and height override the
1488
+ * svg's own size attributes, so a component that writes its own (`size`
1489
+ * 24, ignoring `width` and `height`) still draws at the box's size.
1490
+ */
991
1491
  const THEME_ELEMENT_PROPS =
992
- "{'aria-hidden': true, 'width': '1em', 'height': '1em'}";
993
-
1492
+ "{'aria-hidden': true, 'width': '1em', 'height': '1em', 'style': {'width': '1em', 'height': '1em'}}";
994
1493
 
995
1494
  /**
996
1495
  * The declaration file's text for a generated module's statements: each
@@ -1003,10 +1502,11 @@ export function generatedTypesText(statements) {
1003
1502
  /** @type {string[]} */
1004
1503
  const lines = [];
1005
1504
  const react = new Set();
1006
- for (const statement of statements) {
1505
+ for (const given of statements) {
1506
+ const statement = normalizedStatement(given);
1007
1507
  if (statement.startsWith('import ')) continue;
1008
1508
  if (/^export (?:\*|\{[^}]*\}) from /u.test(statement)) {
1009
- lines.push(statement);
1509
+ lines.push(terminated(statement));
1010
1510
  continue;
1011
1511
  }
1012
1512
  const adapted = new RegExp(
@@ -1020,7 +1520,7 @@ export function generatedTypesText(statements) {
1020
1520
  );
1021
1521
  continue;
1022
1522
  }
1023
- const theme = /^export const (\w+) = \{(.*)\};$/u.exec(statement);
1523
+ const theme = /^export const (\w+) = \{(.*)\}$/u.exec(statement);
1024
1524
  if (
1025
1525
  theme &&
1026
1526
  (theme[1] === THEME_MAP_NAME || statement.includes('createElement('))
@@ -1091,46 +1591,47 @@ export function generatedModuleText(
1091
1591
  };
1092
1592
  const themeName = data.themeMap === THEME_MAP_NAME ? THEME_MAP_NAME : null;
1093
1593
  const lines = before == null ? [] : generatedStatements(before, generated);
1594
+ const lineSet = new Set(lines);
1094
1595
  // Written once, below: the adapter's import, React's createElement, and
1095
- // the theme map.
1096
- const adapterImport = new RegExp(`\\bas ${ADAPTER_LOCAL}\\b`, 'u');
1097
- const dropped = [...new Set(remove)].map(
1098
- name =>
1099
- new RegExp(
1100
- `(?:\\bas ${name}(?:Source)?\\}|^export const ${name} = |^export \\{${name}\\} |^import ${name}Source )`,
1101
- 'u',
1102
- ),
1103
- );
1596
+ // the theme map. Every lookup below is by name, so a library of twenty
1597
+ // thousand icons is written in one pass.
1598
+ const removed = new Set(remove);
1104
1599
  // A re-export binds no local name, so the theme map reads each re-exported
1105
1600
  // icon through an import that mirrors its re-export. Those imports are
1106
1601
  // rewritten with the theme map.
1107
1602
  const themeImport = (/** @type {string} */ line) =>
1108
1603
  themeName != null &&
1109
1604
  line.startsWith('import {') &&
1110
- lines.includes(`export${line.slice('import'.length)}`);
1111
- const managed = (/** @type {string} */ line) =>
1112
- dropped.some(pattern => pattern.test(line)) ||
1113
- adapterImport.test(line) ||
1114
- line === REACT_IMPORT ||
1115
- themeImport(line) ||
1116
- (themeName != null && line.startsWith(`export const ${themeName} = `));
1605
+ lineSet.has(`export${line.slice('import'.length)}`);
1606
+ const managed = (/** @type {string} */ line) => {
1607
+ const name = statementName(line);
1608
+ if (name != null) {
1609
+ if (removed.has(name)) return true;
1610
+ if (name.endsWith('Source') && removed.has(name.slice(0, -6))) {
1611
+ return true;
1612
+ }
1613
+ if (name === ADAPTER_LOCAL) return true;
1614
+ }
1615
+ return (
1616
+ line === REACT_IMPORT ||
1617
+ themeImport(line) ||
1618
+ (themeName != null && line.startsWith(`export const ${themeName} = `))
1619
+ );
1620
+ };
1117
1621
  const imports = new Set(
1118
1622
  lines.filter(line => line.startsWith('import ') && !managed(line)),
1119
1623
  );
1120
1624
  const exports = new Set(
1121
1625
  lines.filter(line => !line.startsWith('import ') && !managed(line)),
1122
1626
  );
1627
+ /** @type {Map<string, string>} each exported name → its statement */
1628
+ const exported = new Map();
1629
+ for (const line of exports) {
1630
+ const name = statementName(line);
1631
+ if (name != null && !exported.has(name)) exported.set(name, line);
1632
+ }
1123
1633
  /** @param {string} name */
1124
- const existing = name =>
1125
- [...exports].find(line =>
1126
- // The name the module exports: after `as`, alone in braces, or a
1127
- // const; never the source name of another export (`TrashIcon as
1128
- // TrashSolidIcon` exports TrashSolidIcon).
1129
- new RegExp(
1130
- `(?:\\bas\\s+${name}\\s*\\}|^export\\s+\\{\\s*${name}\\s*\\}|^export const\\s+${name}\\b)`,
1131
- 'u',
1132
- ).test(line),
1133
- );
1634
+ const existing = name => exported.get(name);
1134
1635
  for (const icon of icons) {
1135
1636
  const specifier = specifierOf(icon.from);
1136
1637
  /** @type {string[]} */
@@ -1141,29 +1642,32 @@ export function generatedModuleText(
1141
1642
  const local = `${icon.name}Source`;
1142
1643
  wantImports.push(
1143
1644
  icon.source === 'default'
1144
- ? `import ${local} from '${specifier}';`
1145
- : `import {${icon.source === local ? local : `${icon.source} as ${local}`}} from '${specifier}';`,
1645
+ ? `import ${local} from '${specifier}'`
1646
+ : `import {${icon.source === local ? local : `${icon.source} as ${local}`}} from '${specifier}'`,
1146
1647
  );
1147
- want.push(`export const ${icon.name} = ${ADAPTER_LOCAL}(${local});`);
1648
+ want.push(`export const ${icon.name} = ${ADAPTER_LOCAL}(${local})`);
1148
1649
  } else {
1149
1650
  want.push(
1150
1651
  icon.source === icon.name
1151
- ? `export {${icon.name}} from '${specifier}';`
1152
- : `export {${icon.source} as ${icon.name}} from '${specifier}';`,
1652
+ ? `export {${icon.name}} from '${specifier}'`
1653
+ : `export {${icon.source} as ${icon.name}} from '${specifier}'`,
1153
1654
  );
1154
1655
  }
1155
1656
  const prior = existing(icon.name);
1156
1657
  if (prior) {
1157
1658
  if (prior !== want[0]) {
1158
1659
  throw new IconSetAuthoringError(
1159
- `The generated icons module already exports ${icon.name} as \`${prior}\`. Pass another name, or edit ${generated}.`,
1660
+ `The generated icons module already exports ${icon.name} as \`${terminated(prior)}\`. Pass another name, or edit ${generated}.`,
1160
1661
  'ERR_FILE_EXISTS',
1161
1662
  );
1162
1663
  }
1163
1664
  continue;
1164
1665
  }
1165
1666
  for (const line of wantImports) imports.add(line);
1166
- for (const line of want) exports.add(line);
1667
+ for (const line of want) {
1668
+ exports.add(line);
1669
+ exported.set(icon.name, line);
1670
+ }
1167
1671
  }
1168
1672
  const needsAdapter = [...exports].some(line =>
1169
1673
  line.includes(`= ${ADAPTER_LOCAL}(`),
@@ -1186,12 +1690,9 @@ export function generatedModuleText(
1186
1690
  theme.push(
1187
1691
  ` ${name}: createElement(${exportName}, ${THEME_ELEMENT_PROPS}),`,
1188
1692
  );
1189
- const reexport = [...exports].find(line =>
1190
- new RegExp(
1191
- `^export \\{(?:\\w+ as )?${exportName}\\} from `,
1192
- 'u',
1193
- ).test(line),
1194
- );
1693
+ const line = exported.get(exportName);
1694
+ const reexport =
1695
+ line != null && line.startsWith('export {') ? line : undefined;
1195
1696
  if (reexport) {
1196
1697
  themeImports.add(`import${reexport.slice('export'.length)}`);
1197
1698
  }
@@ -1201,15 +1702,17 @@ export function generatedModuleText(
1201
1702
  const adapterLine =
1202
1703
  needsAdapter && data.adapter
1203
1704
  ? [
1204
- `import {${data.adapter.export} as ${ADAPTER_LOCAL}} from '${specifierOf(data.adapter.module)}';`,
1705
+ `import {${data.adapter.export} as ${ADAPTER_LOCAL}} from '${specifierOf(data.adapter.module)}'`,
1205
1706
  ]
1206
1707
  : [];
1207
1708
  const js = [
1208
1709
  ...GENERATED_HEADER,
1209
- ...(themeName ? [REACT_IMPORT] : []),
1210
- ...adapterLine,
1211
- ...[...imports, ...themeImports].sort(),
1212
- ...[...exports].sort(),
1710
+ ...[
1711
+ ...(themeName ? [REACT_IMPORT] : []),
1712
+ ...adapterLine,
1713
+ ...[...imports, ...themeImports].sort(),
1714
+ ...[...exports].sort(),
1715
+ ].map(terminated),
1213
1716
  ...(themeName
1214
1717
  ? theme.length > 0
1215
1718
  ? [`export const ${themeName} = {`, ...theme, '};']
@@ -1226,6 +1729,25 @@ export function generatedModuleText(
1226
1729
  };
1227
1730
  }
1228
1731
 
1732
+ /**
1733
+ * The name one generated statement binds: the name an export exports
1734
+ * (`export {A as B} from …` is `B`, `export {A} from …` is `A`, `export
1735
+ * const A = …` is `A`), or the local name an import binds (`import {A as
1736
+ * BSource} from …` is `BSource`, `import BSource from …` is `BSource`).
1737
+ * Null for anything else.
1738
+ * @param {string} line
1739
+ * @returns {string | null}
1740
+ */
1741
+ function statementName(line) {
1742
+ const named =
1743
+ /^(?:export|import) \{\s*([$\w]+)(?:\s+as\s+([$\w]+))?\s*\}/u.exec(line);
1744
+ if (named) return named[2] ?? named[1];
1745
+ const constant = /^export const ([$\w]+)\b/u.exec(line);
1746
+ if (constant) return constant[1];
1747
+ const plain = /^import ([$\w]+) from /u.exec(line);
1748
+ return plain ? plain[1] : null;
1749
+ }
1750
+
1229
1751
  /**
1230
1752
  * Every name the generated module already exports one library export as: `export {featherSun as SunIcon} from '…'` or the import an adapted
1231
1753
  * icon reads (`import {featherSun as SunIconSource} from '…'`). Adding the
@@ -1256,7 +1778,7 @@ export function generatedNamesOf(packageDir, generated, from, source) {
1256
1778
  .join('/');
1257
1779
  if (!specifier.startsWith('.')) specifier = `./${specifier}`;
1258
1780
  }
1259
- const tail = ` from '${specifier}';`;
1781
+ const tail = ` from '${specifier}'`;
1260
1782
  for (const statement of statements) {
1261
1783
  if (!statement.endsWith(tail)) continue;
1262
1784
  const head = statement.slice(0, -tail.length);
@@ -1318,7 +1840,7 @@ export function generatedOrigins(packageDir, generated) {
1318
1840
  return `./${rel}`;
1319
1841
  };
1320
1842
  for (const statement of statements) {
1321
- const reexport = /^export \{(\w+)(?: as (\w+))?\} from '([^']+)';$/u.exec(
1843
+ const reexport = /^export \{(\w+)(?: as (\w+))?\} from '([^']+)'$/u.exec(
1322
1844
  statement,
1323
1845
  );
1324
1846
  if (reexport) {
@@ -1328,7 +1850,7 @@ export function generatedOrigins(packageDir, generated) {
1328
1850
  });
1329
1851
  continue;
1330
1852
  }
1331
- const adapted = /^import \{(\w+) as (\w+)Source\} from '([^']+)';$/u.exec(
1853
+ const adapted = /^import \{(\w+) as (\w+)Source\} from '([^']+)'$/u.exec(
1332
1854
  statement,
1333
1855
  );
1334
1856
  if (adapted) {
@@ -1338,7 +1860,7 @@ export function generatedOrigins(packageDir, generated) {
1338
1860
  });
1339
1861
  continue;
1340
1862
  }
1341
- const adaptedDefault = /^import (\w+)Source from '([^']+)';$/u.exec(
1863
+ const adaptedDefault = /^import (\w+)Source from '([^']+)'$/u.exec(
1342
1864
  statement,
1343
1865
  );
1344
1866
  if (adaptedDefault) {
@@ -1376,6 +1898,63 @@ export function iconModulePackage(specifier) {
1376
1898
  return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
1377
1899
  }
1378
1900
 
1901
+ /**
1902
+ * The set's exports that read one file of a package, where a refresh from
1903
+ * that package's root can read them from the root instead. An export moves
1904
+ * when the root exports the same icon: an export that resolves to the same
1905
+ * file and binding. When the subpath export cannot be resolved, a root
1906
+ * export of the same name counts. Each maps to the root's
1907
+ * export and the module it reads now. Empty unless `root` is a package
1908
+ * root, not a subpath or a ./ path.
1909
+ * @param {Map<string, {module: string, source: string}>} origins the
1910
+ * set's exports ({@link generatedOrigins})
1911
+ * @param {{file: string, module: string}} root the refreshed module
1912
+ * @param {string[]} rootExports the root's icon exports
1913
+ * @param {{packageDir: string, pkg: Record<string, any>}} where
1914
+ * @returns {Map<string, {source: string, from: string}>}
1915
+ */
1916
+ export function packageRootMoves(origins, root, rootExports, where) {
1917
+ /** @type {Map<string, {source: string, from: string}>} */
1918
+ const moves = new Map();
1919
+ if (iconModulePackage(root.module) !== root.module) return moves;
1920
+ if (root.module.startsWith('.')) return moves;
1921
+ const subpaths = [...origins].filter(
1922
+ ([, origin]) =>
1923
+ !origin.module.startsWith('.') &&
1924
+ origin.module !== root.module &&
1925
+ iconModulePackage(origin.module) === root.module,
1926
+ );
1927
+ if (subpaths.length === 0) return moves;
1928
+ /** @param {{file: string, name: string}} origin */
1929
+ const keyOf = origin => `${origin.file}\0${origin.name}`;
1930
+ /** @type {Map<string, string>} */
1931
+ const byOrigin = new Map();
1932
+ for (const name of rootExports) {
1933
+ const origin = moduleExportOrigin(root.file, name);
1934
+ if (origin && !byOrigin.has(keyOf(origin))) {
1935
+ byOrigin.set(keyOf(origin), name);
1936
+ }
1937
+ }
1938
+ const names = new Set(rootExports);
1939
+ const self = {
1940
+ name: typeof where.pkg.name === 'string' ? where.pkg.name : '',
1941
+ dir: where.packageDir,
1942
+ };
1943
+ for (const [name, origin] of subpaths) {
1944
+ const file = resolveIconModule(origin.module, where.packageDir, self);
1945
+ const resolved = file ? moduleExportOrigin(file, origin.source) : null;
1946
+ // A name match counts only when the subpath export cannot be resolved.
1947
+ // A resolved export is the same icon only when it reads the same file.
1948
+ const source = resolved
1949
+ ? byOrigin.get(keyOf(resolved))
1950
+ : names.has(origin.source)
1951
+ ? origin.source
1952
+ : undefined;
1953
+ if (source) moves.set(name, {source, from: origin.module});
1954
+ }
1955
+ return moves;
1956
+ }
1957
+
1379
1958
  /**
1380
1959
  * Write the set's generated module and its declaration file
1381
1960
  * ({@link generatedModuleText}). Returns whether either changed, and a
@@ -1562,12 +2141,30 @@ export function entryWiringNote(packageDir, pkg, generated, options = {}) {
1562
2141
  inside(typesFile) &&
1563
2142
  fs.existsSync(typesFile) &&
1564
2143
  !isBuiltOrCommonJs(packageDir, typesFile) &&
1565
- !fs.readFileSync(typesFile, 'utf-8').includes(lineFor(typesFile))
2144
+ !hasStatement(typesFile, lineFor(typesFile))
1566
2145
  ? ` and \`${lineFor(typesFile)}\` to ${rel(typesFile)}`
1567
2146
  : '';
1568
2147
  return `Add \`${lineFor(entry)}\` to ${rel(entry)}${alsoTypes}, so the icons import from ${module}.`;
1569
2148
  }
1570
2149
 
2150
+ /**
2151
+ * Whether a file has a statement, however a formatter left it
2152
+ * ({@link generatedStatements}); a file that does not parse is searched for
2153
+ * the text as written.
2154
+ * @param {string} file
2155
+ * @param {string} statement
2156
+ */
2157
+ function hasStatement(file, statement) {
2158
+ const text = fs.readFileSync(file, 'utf-8');
2159
+ try {
2160
+ return generatedStatements(text, file).includes(
2161
+ normalizedStatement(statement),
2162
+ );
2163
+ } catch {
2164
+ return text.includes(statement);
2165
+ }
2166
+ }
2167
+
1571
2168
  /**
1572
2169
  * Whether the set's own module exports `name`, read without running it, and
1573
2170
  * the line its entry needs when it does not ({@link entryWiringNote}).