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