wrec 0.29.3 → 0.29.4

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "wrec",
3
3
  "description": "a small library that greatly simplifies building web components",
4
4
  "author": "R. Mark Volkmann",
5
- "version": "0.29.3",
5
+ "version": "0.29.4",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
package/scripts/lint.js CHANGED
@@ -31,7 +31,6 @@
31
31
 
32
32
  import fs from 'node:fs';
33
33
  import path from 'node:path';
34
- import {fileURLToPath} from 'node:url';
35
34
  import ts from 'typescript';
36
35
  import {parse} from 'node-html-parser';
37
36
 
@@ -1869,20 +1868,7 @@ function walkHtmlNode(
1869
1868
  }
1870
1869
  }
1871
1870
 
1872
- const isCliEntry = (() => {
1873
- if (!process.argv[1]) return false;
1874
-
1875
- try {
1876
- return (
1877
- fs.realpathSync(process.argv[1]) ===
1878
- fs.realpathSync(fileURLToPath(import.meta.url))
1879
- );
1880
- } catch {
1881
- return path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
1882
- }
1883
- })();
1884
-
1885
- if (isCliEntry) {
1871
+ if (import.meta.main) {
1886
1872
  try {
1887
1873
  main();
1888
1874
  } catch (error) {
@@ -3,7 +3,8 @@
3
3
  // determines the proper values for property config `usedBy` properties.
4
4
  // Each value is a list of methods that use the property
5
5
  // or a single method name.
6
- // It uses the TypeScript compiler to parse the file,
6
+ //
7
+ // This uses the TypeScript compiler to parse the source file,
7
8
  // discover which expressions call which methods,
8
9
  // trace property usage through those call chains, and
9
10
  // output or update the `usedBy` properties`.
@@ -17,12 +18,201 @@
17
18
 
18
19
  import fs from 'node:fs';
19
20
  import path from 'node:path';
20
- import {fileURLToPath} from 'node:url';
21
21
  import ts from 'typescript';
22
22
 
23
+ const cwd = process.cwd();
24
+
25
+ // Records property names introduced by
26
+ // an identifier or object binding pattern.
27
+ // An object binding pattern is destructuring syntax
28
+ // used in a binding position, like a variable declaration,
29
+ // parameter list, or assignment target.
30
+ function addBindingName(props, name) {
31
+ if (ts.isIdentifier(name)) {
32
+ props.add(name.text);
33
+ } else if (ts.isObjectBindingPattern(name)) {
34
+ addObjectBindingProps(props, name);
35
+ }
36
+ }
37
+
38
+ // Adds property names referenced by an object binding pattern.
39
+ function addObjectBindingProps(props, bindingPattern) {
40
+ for (const element of bindingPattern.elements) {
41
+ if (element.dotDotDotToken) continue;
42
+
43
+ if (element.propertyName) {
44
+ const name = getNameText(element.propertyName);
45
+ if (name) props.add(name);
46
+ continue;
47
+ }
48
+
49
+ addBindingName(props, element.name);
50
+ }
51
+ }
52
+
53
+ // Analyzes a parsed source file and returns any proposed `usedBy` edits.
54
+ // This is the heart of the functionality in this script.
55
+ function analyzeSourceFile(sourceFile) {
56
+ const edits = [];
57
+ const {names: wrecNames, quote} = getWrecImportInfo(sourceFile);
58
+ const suggestions = [];
59
+ let foundWrecSubclass = false;
60
+
61
+ // Find the statement that defines a class that extends Wrec.
62
+ for (const node of sourceFile.statements) {
63
+ if (!ts.isClassDeclaration(node) || !extendsWrec(node, wrecNames)) continue;
64
+ foundWrecSubclass = true;
65
+
66
+ let propertiesObject = null;
67
+ // Find the static property named "properties".
68
+ for (const member of node.members) {
69
+ if (
70
+ ts.isPropertyDeclaration(member) &&
71
+ hasStaticModifier(member) &&
72
+ getNameText(member.name) === 'properties' &&
73
+ member.initializer &&
74
+ ts.isObjectLiteralExpression(member.initializer)
75
+ ) {
76
+ propertiesObject = member.initializer;
77
+ break;
78
+ }
79
+ }
80
+
81
+ // Bail out if no static property named "properties" was found.
82
+ if (!propertiesObject) continue;
83
+
84
+ // Get a Set of the defined property names.
85
+ const propertyNames = new Set(
86
+ propertiesObject.properties
87
+ .filter(ts.isPropertyAssignment)
88
+ .map(property => getNameText(property.name))
89
+ .filter(name => name !== null)
90
+ );
91
+
92
+ // Get a map where the keys are property names and
93
+ // the values are Sets of public methods that use it transitively.
94
+ const propToMethods = getMethodUsages(node, propertyNames);
95
+
96
+ // For each member that represents a component property ...
97
+ for (const member of propertiesObject.properties) {
98
+ // Skip the member if not a property assignment.
99
+ if (!ts.isPropertyAssignment(member)) continue;
100
+
101
+ // Skip the member if we can't gets its name
102
+ // or if its value isn't an object literal.
103
+ const propName = getNameText(member.name);
104
+ if (!propName || !ts.isObjectLiteralExpression(member.initializer))
105
+ continue;
106
+
107
+ // Convert the Set of methods that use the property into a sorted array.
108
+ const methodNames = [
109
+ ...(propToMethods.get(propName) ?? new Set())
110
+ ].sort();
111
+
112
+ // Get an array of all the NodeObjects that represent
113
+ // properties in the configuration object, except the one for "usedBy".
114
+ const configObject = member.initializer;
115
+ const existingMembers = configObject.properties.filter(
116
+ property =>
117
+ !(
118
+ ts.isPropertyAssignment(property) &&
119
+ getNameText(property.name) === 'usedBy'
120
+ )
121
+ );
122
+
123
+ // If the property is used by any methods ...
124
+ if (methodNames.length > 0) {
125
+ suggestions.push({
126
+ propName,
127
+ suggestion: createUsedByProperty(methodNames, quote)
128
+ });
129
+ } else {
130
+ // Determine if the configuration object already had a "usedBy" property.
131
+ const hadUsedBy =
132
+ existingMembers.length !== configObject.properties.length;
133
+ if (hadUsedBy) {
134
+ suggestions.push({propName, suggestion: 'remove usedBy'});
135
+ } else {
136
+ continue;
137
+ }
138
+ }
139
+
140
+ // Build the new text for the property configuration object.
141
+ const nextText = buildConfigText(sourceFile, member, methodNames, quote);
142
+ // Get the existing text for the property configuration object.
143
+ const currentText = sourceFile.text.slice(
144
+ configObject.getStart(sourceFile),
145
+ configObject.end
146
+ );
147
+ // If they differ, add an object describing
148
+ // the desired edit to the edits array.
149
+ if (nextText !== currentText) {
150
+ edits.push({
151
+ start: configObject.getStart(sourceFile),
152
+ end: configObject.end,
153
+ propName,
154
+ oldText: currentText,
155
+ text: nextText
156
+ });
157
+ }
158
+ }
159
+ }
160
+
161
+ // If we didn't find a Wrec subclass, then there are no changes to make.
162
+ if (!foundWrecSubclass) {
163
+ return {
164
+ changed: false,
165
+ edits: [],
166
+ foundWrecSubclass: false,
167
+ suggestions,
168
+ text: sourceFile.text
169
+ };
170
+ }
171
+
172
+ // If we found a Wrec subclass,
173
+ // but no edits for usedBy properties are needed ...
174
+ if (edits.length === 0) {
175
+ return {
176
+ changed: false,
177
+ edits: [],
178
+ foundWrecSubclass: true,
179
+ suggestions,
180
+ text: sourceFile.text
181
+ };
182
+ }
183
+
184
+ // Get the current text in the source file.
185
+ let nextSource = sourceFile.text;
186
+
187
+ // Sort the edits to be made from last to first.
188
+ edits.sort((a, b) => b.start - a.start);
189
+
190
+ // Make each of the edits to the source file.
191
+ for (const edit of edits) {
192
+ nextSource =
193
+ nextSource.slice(0, edit.start) + edit.text + nextSource.slice(edit.end);
194
+ }
195
+
196
+ return {
197
+ changed: true,
198
+ edits,
199
+ foundWrecSubclass: true,
200
+ suggestions,
201
+ text: nextSource
202
+ };
203
+ }
204
+
205
+ // Builds a new property config object
206
+ // with an updated `usedBy` entry if needed.
207
+ // - member is an AST node for the initialization of a property
208
+ // inside the "static properties" object.
209
+ // - methodsNames is an array of the methods that use the property.
210
+ // - quote is either a single or double quote.
23
211
  function buildConfigText(sourceFile, member, methodNames, quote) {
24
- const {text} = sourceFile;
25
212
  const configObject = member.initializer;
213
+
214
+ // Get an array of AST nodes for all the config object properties
215
+ // except `usedBy`.
26
216
  const existingMembers = configObject.properties.filter(
27
217
  property =>
28
218
  !(
@@ -30,67 +220,169 @@ function buildConfigText(sourceFile, member, methodNames, quote) {
30
220
  getNameText(property.name) === 'usedBy'
31
221
  )
32
222
  );
33
- const existingTexts = existingMembers.map(property =>
223
+
224
+ // Create property assignment strings from the AST nodes.
225
+ const {text} = sourceFile;
226
+ const propertyStrings = existingMembers.map(property =>
34
227
  text.slice(property.getStart(sourceFile), property.end).trim()
35
228
  );
36
- if (methodNames.length > 0)
37
- existingTexts.push(createUsedByProperty(methodNames, quote));
38
229
 
39
- const original = text.slice(
230
+ // If this property is used by any methods,
231
+ // add a usedBy property that lists them.
232
+ if (methodNames.length > 0) {
233
+ propertyStrings.push(createUsedByProperty(methodNames, quote));
234
+ }
235
+
236
+ const existingPropertiesString = text.slice(
40
237
  configObject.getStart(sourceFile),
41
238
  configObject.end
42
239
  );
43
- const multiline = original.includes('\n');
44
- if (!multiline) {
45
- const openMatch = original.match(/^\{(\s*)/);
46
- const closeMatch = original.match(/(\s*)\}$/);
47
- const openSpacing = openMatch ? openMatch[1] : ' ';
48
- const closeSpacing = closeMatch ? closeMatch[1] : ' ';
49
- return `{${openSpacing}${existingTexts.join(', ')}${closeSpacing}}`;
240
+
241
+ const multiline = existingPropertiesString.includes('\n');
242
+ if (multiline) {
243
+ // Build and return a multi-line config object string
244
+ // that preserves the existing indentation.
245
+ const memberIndent = getIndent(text, member.getStart(sourceFile));
246
+ const [firstMember] = existingMembers;
247
+ const propertyIndent = firstMember
248
+ ? getIndent(text, firstMember.getStart(sourceFile))
249
+ : memberIndent + ' ';
250
+ const content = propertyStrings
251
+ .map(part => `${propertyIndent}${part}`)
252
+ .join(',\n');
253
+ return `{\n${content}\n${memberIndent}}`;
50
254
  }
51
255
 
52
- // Preserve the surrounding formatting style so the update feels like a
53
- // minimal edit instead of reformatting the whole object.
54
- const memberIndent = getIndent(text, member.getStart(sourceFile));
55
- const firstExisting = existingMembers[0];
56
- const innerIndent = firstExisting
57
- ? getIndent(text, firstExisting.getStart(sourceFile))
58
- : memberIndent + ' ';
59
- return `{\n${existingTexts.map(part => `${innerIndent}${part}`).join(',\n')}\n${memberIndent}}`;
256
+ // Build and return a single line config object string
257
+ // that preserves the existing spacing around curly braces.
258
+ const openMatch = existingPropertiesString.match(/^\{(\s*)/);
259
+ const closeMatch = existingPropertiesString.match(/(\s*)\}$/);
260
+ const openSpacing = openMatch ? openMatch[1] : ' ';
261
+ const closeSpacing = closeMatch ? closeMatch[1] : ' ';
262
+ return `{${openSpacing}${propertyStrings.join(', ')}${closeSpacing}}`;
60
263
  }
61
264
 
62
- function collectFiles(startPath, files = []) {
63
- if (!fs.existsSync(startPath)) return files;
64
-
65
- const stat = fs.statSync(startPath);
66
- if (stat.isFile()) {
67
- if (isSupportedSourceFile(startPath)) files.push(startPath);
68
- return files;
69
- }
70
-
71
- for (const entry of fs.readdirSync(startPath, {withFileTypes: true})) {
72
- const fullPath = path.join(startPath, entry.name);
73
- if (entry.isDirectory()) {
74
- if (entry.name === 'node_modules' || entry.name === 'dist') continue;
75
- collectFiles(fullPath, files);
76
- } else if (entry.isFile() && isSupportedSourceFile(entry.name, true)) {
77
- files.push(fullPath);
265
+ // Walks a method body to collect component property reads
266
+ // and method calls made through `this`.
267
+ // This is only called by getMethodUsages.
268
+ function collectMethodBodyUsage(node, props, calledMethods) {
269
+ if (
270
+ ts.isPropertyAccessExpression(node) &&
271
+ node.expression.kind === ts.SyntaxKind.ThisKeyword
272
+ ) {
273
+ // Handles direct property access like `this.foo`
274
+ // and records method calls like `this.foo()`.
275
+ recordThisAccess(props, calledMethods, node, node.name.text);
276
+ } else if (
277
+ ts.isElementAccessExpression(node) &&
278
+ node.expression.kind === ts.SyntaxKind.ThisKeyword &&
279
+ node.argumentExpression &&
280
+ ts.isStringLiteralLike(node.argumentExpression)
281
+ ) {
282
+ // Handles string-based element access like `this['foo']`
283
+ // and records method calls like `this['foo']()`.
284
+ recordThisAccess(props, calledMethods, node, node.argumentExpression.text);
285
+ } else if (
286
+ ts.isVariableDeclaration(node) &&
287
+ node.initializer &&
288
+ node.initializer.kind === ts.SyntaxKind.ThisKeyword
289
+ ) {
290
+ // Handles variable declarations initialized from `this`,
291
+ // such as `const {foo} = this` or `const self = this`.
292
+ addBindingName(props, node.name);
293
+ } else if (
294
+ ts.isBinaryExpression(node) &&
295
+ node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
296
+ node.right.kind === ts.SyntaxKind.ThisKeyword
297
+ ) {
298
+ // Handles assignments from `this`, such as destructuring
299
+ // or object-literal patterns that pull named properties from it.
300
+ if (ts.isObjectLiteralExpression(node.left)) {
301
+ for (const property of node.left.properties) {
302
+ if (
303
+ ts.isShorthandPropertyAssignment(property) ||
304
+ ts.isPropertyAssignment(property)
305
+ ) {
306
+ const name = getNameText(property.name);
307
+ if (name) props.add(name);
308
+ }
309
+ }
310
+ } else if (ts.isObjectBindingPattern(node.left)) {
311
+ addObjectBindingProps(props, node.left);
78
312
  }
79
313
  }
80
314
 
81
- return files;
315
+ ts.forEachChild(node, child =>
316
+ collectMethodBodyUsage(child, props, calledMethods)
317
+ );
82
318
  }
83
319
 
320
+ // Returns a string for a usedBy property.
84
321
  function createUsedByProperty(methodNames, quote) {
85
- if (methodNames.length === 1) {
86
- return `usedBy: ${quote}${methodNames[0]}${quote}`;
322
+ const value =
323
+ methodNames.length === 1
324
+ ? `${quote}${methodNames[0]}${quote}`
325
+ : `[${methodNames.map(name => `${quote}${name}${quote}`).join(', ')}]`;
326
+ return `usedBy: ${value}`;
327
+ }
328
+
329
+ // Determines what changes, if any, should be made in
330
+ // the usedBy properties in property configuration objects
331
+ // found in the file at a given relative path.
332
+ export function evaluateSourceFile(filePath, options = {}) {
333
+ const {dry = false} = options;
334
+ const absFilePath = path.resolve(cwd, filePath);
335
+ validateFile(absFilePath);
336
+
337
+ // The file is read in this function instead of in evaluateSourceText
338
+ // so unit tests can pass hard-coded text to that function.
339
+ const text = fs.readFileSync(absFilePath, 'utf8');
340
+ let {
341
+ changed,
342
+ foundWrecSubclass,
343
+ suggestions,
344
+ text: nextText
345
+ } = evaluateSourceText(absFilePath, text);
346
+
347
+ // If we didn't find the definition of a class that extends Wrec ...
348
+ if (!foundWrecSubclass) {
349
+ throw new Error('No class extending Wrec was found.');
350
+ }
351
+
352
+ // If this isn't a dry run and changes were made,
353
+ // write the modified source code back to the file.
354
+ if (!dry && changed) {
355
+ fs.writeFileSync(absFilePath, nextText);
356
+ suggestions = []; // all the suggestions have been applied
87
357
  }
88
- return `usedBy: [${methodNames.map(name => `${quote}${name}${quote}`).join(', ')}]`;
358
+
359
+ return {changed, foundWrecSubclass, suggestions, text: nextText};
360
+ }
361
+
362
+ // Determines what changes, if any, should be made in
363
+ // the usedBy properties in property configuration objects
364
+ // found in source file text.
365
+ // This function was factored out of evaluateSourceFile
366
+ // to support unit tests.
367
+ export function evaluateSourceText(filePath, text) {
368
+ const scriptKind = filePath.endsWith('.ts')
369
+ ? ts.ScriptKind.TS
370
+ : ts.ScriptKind.JS;
371
+ const sourceFile = ts.createSourceFile(
372
+ filePath,
373
+ text,
374
+ ts.ScriptTarget.Latest,
375
+ true,
376
+ scriptKind
377
+ );
378
+ return analyzeSourceFile(sourceFile);
89
379
  }
90
380
 
91
- function extendsWrec(node, wrecNames) {
381
+ // Determines whether a class declaration extends one of the known Wrec imports.
382
+ // This is only called by analyzeSourceFile.
383
+ function extendsWrec(classNode, wrecNames) {
92
384
  return Boolean(
93
- node.heritageClauses?.some(
385
+ classNode.heritageClauses?.some(
94
386
  clause =>
95
387
  clause.token === ts.SyntaxKind.ExtendsKeyword &&
96
388
  clause.types.some(
@@ -103,42 +395,103 @@ function extendsWrec(node, wrecNames) {
103
395
  );
104
396
  }
105
397
 
398
+ // Gets the names of all methods that are called from
399
+ // JavaScript expressions in the component's static CSS template.
400
+ // This is only called by getMethodUsages.
401
+ function getCssCalledMethods(classNode) {
402
+ const methodNames = new Set();
403
+ const CALL_RE = /this\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
404
+ let template;
405
+
406
+ for (const member of classNode.members) {
407
+ // If this member isn't the AST node for "static css = ..."
408
+ // then skip it.
409
+ if (
410
+ ts.isPropertyDeclaration(member) &&
411
+ hasStaticModifier(member) &&
412
+ getNameText(member.name) === 'css' &&
413
+ member.initializer &&
414
+ ts.isTaggedTemplateExpression(member.initializer) &&
415
+ ts.isIdentifier(member.initializer.tag) &&
416
+ member.initializer.tag.text === 'css'
417
+ ) {
418
+ // Keep the last matching declaration because
419
+ // JavaScript class fields use last-one-wins semantics
420
+ // when the same static field is declared twice.
421
+ template = member.initializer.template;
422
+ }
423
+ }
424
+
425
+ // If no matching declaration was found, return an empty Set.
426
+ if (!template) return methodNames;
427
+
428
+ // Finds all method names called in CSS property value expressions
429
+ // matching `this.method()`.
430
+ const text = template.getText();
431
+ for (const match of text.matchAll(CALL_RE)) {
432
+ methodNames.add(match[1]);
433
+ }
434
+
435
+ return methodNames;
436
+ }
437
+
438
+ // Gets the names of all methods that are called from
439
+ // the "computed" JavaScript expression of an component property.
440
+ // This is only called by getMethodUsages.
106
441
  function getComputedCalledMethods(classNode) {
107
442
  const methodNames = new Set();
108
443
  const CALL_RE = /this\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
444
+ let propertiesNode;
109
445
 
110
446
  for (const member of classNode.members) {
447
+ // If this member isn't the AST node for "static properties = ..."
448
+ // then skip it.
111
449
  if (
112
- !ts.isPropertyDeclaration(member) ||
113
- !hasStaticModifier(member) ||
114
- getNameText(member.name) !== 'properties' ||
115
- !member.initializer ||
116
- !ts.isObjectLiteralExpression(member.initializer)
450
+ ts.isPropertyDeclaration(member) &&
451
+ hasStaticModifier(member) &&
452
+ getNameText(member.name) === 'properties' &&
453
+ member.initializer &&
454
+ ts.isObjectLiteralExpression(member.initializer)
455
+ ) {
456
+ // Keep the last matching declaration because
457
+ // JavaScript class fields use last-one-wins semantics
458
+ // when the same static field is declared twice.
459
+ propertiesNode = member.initializer;
460
+ }
461
+ }
462
+
463
+ // If no matching declaration was found, return an empty Set.
464
+ if (!propertiesNode) return methodNames;
465
+
466
+ // For each property in the last "static properties" object ...
467
+ for (const property of propertiesNode.properties) {
468
+ // If it isn't a property assignment or
469
+ // the property value isn't an object literal then skip it.
470
+ if (
471
+ !ts.isPropertyAssignment(property) ||
472
+ !ts.isObjectLiteralExpression(property.initializer)
117
473
  ) {
118
474
  continue;
119
475
  }
120
476
 
121
- for (const property of member.initializer.properties) {
477
+ // For each property in the configuration object ...
478
+ for (const configProperty of property.initializer.properties) {
479
+ // If it isn't a property assignment or
480
+ // the property name isn't "computed" then skip it.
122
481
  if (
123
- !ts.isPropertyAssignment(property) ||
124
- !ts.isObjectLiteralExpression(property.initializer)
482
+ !ts.isPropertyAssignment(configProperty) ||
483
+ getNameText(configProperty.name) !== 'computed'
125
484
  ) {
126
485
  continue;
127
486
  }
128
487
 
129
- for (const configProperty of property.initializer.properties) {
130
- if (
131
- !ts.isPropertyAssignment(configProperty) ||
132
- getNameText(configProperty.name) !== 'computed'
133
- ) {
134
- continue;
135
- }
488
+ // If the property value isn't a string then skip it.
489
+ if (!ts.isStringLiteralLike(configProperty.initializer)) continue;
136
490
 
137
- if (!ts.isStringLiteralLike(configProperty.initializer)) continue;
138
- const computed = configProperty.initializer.text;
139
- for (const match of computed.matchAll(CALL_RE)) {
140
- methodNames.add(match[1]);
141
- }
491
+ // Find all the method calls in the string JavaScript expression.
492
+ const computed = configProperty.initializer.text;
493
+ for (const match of computed.matchAll(CALL_RE)) {
494
+ methodNames.add(match[1]);
142
495
  }
143
496
  }
144
497
  }
@@ -146,161 +499,53 @@ function getComputedCalledMethods(classNode) {
146
499
  return methodNames;
147
500
  }
148
501
 
502
+ // Returns the leading indentation in the line
503
+ // that begins at a given position (`pos`) inside `text`.
149
504
  function getIndent(text, pos) {
150
505
  const lineStart = text.lastIndexOf('\n', pos - 1) + 1;
151
506
  const match = /^[ \t]*/.exec(text.slice(lineStart));
152
507
  return match ? match[0] : '';
153
508
  }
154
509
 
510
+ // Returns a map where the keys are property names and
511
+ // the values are Sets of public methods that use it transitively.
155
512
  function getMethodUsages(classNode, propertyNames) {
156
513
  const methodInfo = new Map();
157
- for (const member of classNode.members) {
158
- if (hasStaticModifier(member)) continue;
159
514
 
160
- if (
161
- (ts.isMethodDeclaration(member) ||
162
- ts.isGetAccessorDeclaration(member) ||
163
- ts.isSetAccessorDeclaration(member)) &&
164
- member.body
165
- ) {
166
- const methodName = getNameText(member.name);
167
- if (!methodName) continue;
168
-
169
- const props = new Set();
170
- const calledMethods = new Set();
171
- const isPrivate = ts.isPrivateIdentifier(member.name);
172
-
173
- function addBindingName(name) {
174
- if (ts.isIdentifier(name)) {
175
- props.add(name.text);
176
- } else if (ts.isObjectBindingPattern(name)) {
177
- addObjectBindingProps(name);
178
- }
179
- }
180
-
181
- function addObjectBindingProps(bindingPattern) {
182
- for (const element of bindingPattern.elements) {
183
- if (element.dotDotDotToken) continue;
184
-
185
- if (element.propertyName) {
186
- const name = getNameText(element.propertyName);
187
- if (name) props.add(name);
188
- continue;
189
- }
190
-
191
- if (ts.isIdentifier(element.name)) {
192
- props.add(element.name.text);
193
- } else if (ts.isObjectBindingPattern(element.name)) {
194
- addObjectBindingProps(element.name);
195
- }
196
- }
197
- }
198
-
199
- function visit(child) {
200
- // Record both direct property reads like `this.foo` and method calls
201
- // like `this.renderFoo()` so we can later propagate property usage
202
- // through method-to-method call chains.
203
- if (
204
- ts.isPropertyAccessExpression(child) &&
205
- child.expression.kind === ts.SyntaxKind.ThisKeyword
206
- ) {
207
- const name = child.name.text;
208
- props.add(name);
209
- if (
210
- ts.isCallExpression(child.parent) &&
211
- child.parent.expression === child
212
- ) {
213
- calledMethods.add(name);
214
- }
215
- } else if (
216
- ts.isElementAccessExpression(child) &&
217
- child.expression.kind === ts.SyntaxKind.ThisKeyword &&
218
- child.argumentExpression &&
219
- ts.isStringLiteralLike(child.argumentExpression)
220
- ) {
221
- const name = child.argumentExpression.text;
222
- props.add(name);
223
- if (
224
- ts.isCallExpression(child.parent) &&
225
- child.parent.expression === child
226
- ) {
227
- calledMethods.add(name);
228
- }
229
- } else if (
230
- ts.isVariableDeclaration(child) &&
231
- child.initializer &&
232
- child.initializer.kind === ts.SyntaxKind.ThisKeyword
233
- ) {
234
- if (ts.isObjectBindingPattern(child.name)) {
235
- addObjectBindingProps(child.name);
236
- } else {
237
- addBindingName(child.name);
238
- }
239
- } else if (
240
- ts.isBinaryExpression(child) &&
241
- child.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
242
- child.right.kind === ts.SyntaxKind.ThisKeyword
243
- ) {
244
- if (ts.isObjectLiteralExpression(child.left)) {
245
- for (const property of child.left.properties) {
246
- if (
247
- ts.isShorthandPropertyAssignment(property) ||
248
- ts.isPropertyAssignment(property)
249
- ) {
250
- const name = getNameText(property.name);
251
- if (name) props.add(name);
252
- }
253
- }
254
- } else if (ts.isObjectBindingPattern(child.left)) {
255
- addObjectBindingProps(child.left);
256
- }
257
- }
258
-
259
- ts.forEachChild(child, visit);
260
- }
261
-
262
- ts.forEachChild(member.body, visit);
263
- methodInfo.set(methodName, {calledMethods, isPrivate, props});
264
- }
515
+ for (const member of classNode.members) {
516
+ // If the member doesn't represent an instance method, skip it.
517
+ if (!isInstanceMethodMember(member)) continue;
518
+
519
+ // If the member doesn't have a string name, skip it.
520
+ const methodName = getNameText(member.name);
521
+ if (!methodName) continue;
522
+
523
+ const props = new Set();
524
+ const calledMethods = new Set();
525
+ collectMethodBodyUsage(member.body, props, calledMethods);
526
+ methodInfo.set(methodName, {
527
+ calledMethods,
528
+ isPrivate: ts.isPrivateIdentifier(member.name),
529
+ props
530
+ });
265
531
  }
266
532
 
533
+ // Build a Set of method names that are called from
534
+ // the "static html" template, the "static css" template,
535
+ // or computed property expressions.
267
536
  const entryMethods = new Set([
537
+ ...getCssCalledMethods(classNode),
268
538
  ...getTemplateCalledMethods(classNode),
269
539
  ...getComputedCalledMethods(classNode)
270
540
  ]);
271
541
  const memo = new Map();
272
542
 
273
- function getTransitiveProps(methodName, seen = new Set()) {
274
- // Starting from methods that are reachable from the template/computed
275
- // properties, walk through nested method calls and accumulate every
276
- // component property touched along the way.
277
- if (memo.has(methodName)) return memo.get(methodName);
278
- if (seen.has(methodName)) return new Set();
279
-
280
- seen.add(methodName);
281
- const info = methodInfo.get(methodName);
282
- const props = new Set(info?.props ?? []);
283
-
284
- if (info) {
285
- for (const calledMethod of info.calledMethods) {
286
- const calledProps = getTransitiveProps(calledMethod, seen);
287
- for (const propName of calledProps) props.add(propName);
288
- }
289
- }
290
-
291
- seen.delete(methodName);
292
- memo.set(methodName, props);
293
- return props;
294
- }
295
-
296
543
  const propToMethods = new Map();
297
544
  for (const methodName of entryMethods) {
298
545
  const info = methodInfo.get(methodName);
299
546
  if (!info || info.isPrivate) continue;
300
547
 
301
- if (info.isPrivate) continue;
302
-
303
- for (const propName of getTransitiveProps(methodName)) {
548
+ for (const propName of getTransitiveProps(methodInfo, memo, methodName)) {
304
549
  if (!propertyNames.has(propName)) continue;
305
550
 
306
551
  let methods = propToMethods.get(propName);
@@ -315,72 +560,100 @@ function getMethodUsages(classNode, propertyNames) {
315
560
  return propToMethods;
316
561
  }
317
562
 
318
- function getNameText(name) {
319
- if (
320
- ts.isIdentifier(name) ||
321
- ts.isStringLiteral(name) ||
322
- ts.isPrivateIdentifier(name)
323
- ) {
324
- return name.text;
325
- }
326
- return null;
327
- }
328
-
563
+ // Gets the text value of an AST name node.
564
+ const getNameText = name =>
565
+ ts.isIdentifier(name) ||
566
+ ts.isStringLiteral(name) ||
567
+ ts.isPrivateIdentifier(name)
568
+ ? name.text
569
+ : null;
570
+
571
+ // Gets the names of all methods that are called
572
+ // from the component's static HTML template.
573
+ // This is only called by getMethodUsages.
329
574
  function getTemplateCalledMethods(classNode) {
330
- const methodNames = new Set();
331
- const CALL_RE = /this\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
332
-
333
- function visit(node) {
334
- if (
335
- ts.isPropertyAccessExpression(node) &&
336
- node.expression.kind === ts.SyntaxKind.ThisKeyword &&
337
- ts.isCallExpression(node.parent) &&
338
- node.parent.expression === node
339
- ) {
340
- methodNames.add(node.name.text);
341
- }
342
-
343
- ts.forEachChild(node, visit);
344
- }
345
-
346
- function addTemplateTextMethods(template) {
347
- // Template expressions can hide method calls inside raw template text,
348
- // so use a regex in addition to AST traversal to catch those names.
349
- const text = template.getText();
350
- for (const match of text.matchAll(CALL_RE)) {
351
- methodNames.add(match[1]);
352
- }
353
- }
354
-
575
+ // Find the last `static html = html\`...\`` declaration
576
+ // because duplicate static class fields use last-one-wins semantics.
577
+ let template;
355
578
  for (const member of classNode.members) {
579
+ // If it's a "static html =" property assignment node ...
356
580
  if (
357
581
  ts.isPropertyDeclaration(member) &&
358
582
  hasStaticModifier(member) &&
359
583
  getNameText(member.name) === 'html' &&
360
584
  member.initializer
361
585
  ) {
586
+ // If the value is a tagged template literal with the "html" tag ...
362
587
  if (
363
588
  ts.isTaggedTemplateExpression(member.initializer) &&
364
589
  ts.isIdentifier(member.initializer.tag) &&
365
590
  member.initializer.tag.text === 'html'
366
591
  ) {
367
- addTemplateTextMethods(member.initializer.template);
592
+ template = member.initializer.template;
368
593
  }
369
- ts.forEachChild(member.initializer, visit);
370
594
  }
371
595
  }
372
596
 
597
+ const methodNames = new Set();
598
+ if (template) {
599
+ // Finds all method names called in the HTML template
600
+ // matching `this.method()`.
601
+ const text = template.getText();
602
+ const CALL_RE = /this\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
603
+ for (const match of text.matchAll(CALL_RE)) {
604
+ methodNames.add(match[1]);
605
+ }
606
+ }
373
607
  return methodNames;
374
608
  }
375
609
 
610
+ // Recursively follow method-call chains to accumulate all accessed properties.
611
+ // This is only called by getMethodUsages.
612
+ function getTransitiveProps(methodInfo, memo, methodName, seen = new Set()) {
613
+ // Starting from methods that are reachable from
614
+ // css/template/computed properties, walk through nested method calls
615
+ // and accumulate every component property touched along the way.
616
+ if (memo.has(methodName)) return memo.get(methodName);
617
+ if (seen.has(methodName)) return new Set();
618
+
619
+ seen.add(methodName);
620
+ const info = methodInfo.get(methodName);
621
+ const props = new Set(info?.props ?? []);
622
+
623
+ if (info) {
624
+ for (const calledMethod of info.calledMethods) {
625
+ const calledProps = getTransitiveProps(
626
+ methodInfo,
627
+ memo,
628
+ calledMethod,
629
+ seen
630
+ );
631
+ for (const propName of calledProps) props.add(propName);
632
+ }
633
+ }
634
+
635
+ seen.delete(methodName);
636
+ memo.set(methodName, props);
637
+ return props;
638
+ }
639
+
640
+ // Collects imported Wrec class names. For example,
641
+ // the following line would treat "MyWrec" as a class
642
+ // that can be extended in order to implement a wrec component:
643
+ // import { Wrec as MyWrec } from 'wrec`
644
+ // This also determines the quote character (single or double)
645
+ // used for those imports.
376
646
  function getWrecImportInfo(sourceFile) {
647
+ // Start with the unaliased class name and a default quote style
648
+ // in case the file imports Wrec later or not at all.
377
649
  const names = new Set(['Wrec']);
378
650
  let quote = "'";
379
651
 
380
652
  // Support aliased imports such as `import {Wrec as Base} from 'wrec'`
381
- // so subclass detection still works and generated text matches the
382
- // file's existing quote style.
653
+ // so subclass detection still works.
383
654
  for (const statement of sourceFile.statements) {
655
+ // Ignore anything that is not an import declaration
656
+ // with a string module path.
384
657
  if (
385
658
  !ts.isImportDeclaration(statement) ||
386
659
  !statement.importClause ||
@@ -389,26 +662,28 @@ function getWrecImportInfo(sourceFile) {
389
662
  continue;
390
663
  }
391
664
 
665
+ // Only inspect imports that come from Wrec itself or its supported variants.
392
666
  const moduleName = statement.moduleSpecifier.text;
393
- const isWrecModule =
394
- moduleName === 'wrec' ||
395
- moduleName === 'wrec/ssr' ||
396
- moduleName.endsWith('/wrec') ||
397
- moduleName.endsWith('/wrec-ssr');
667
+ const isWrecModule = moduleName === 'wrec' || moduleName.endsWith('/wrec');
398
668
  if (!isWrecModule) continue;
399
669
 
670
+ // Skip default or namespace imports
671
+ // because we only care about named imports.
400
672
  const namedBindings = statement.importClause.namedBindings;
401
673
  if (!namedBindings || !ts.isNamedImports(namedBindings)) continue;
402
674
 
403
675
  for (const element of namedBindings.elements) {
676
+ // Resolve the original imported name so aliased imports
677
+ // like `Wrec as Base` still count as Wrec imports.
404
678
  const importedName = element.propertyName?.text ?? element.name.text;
405
679
  if (importedName === 'Wrec') {
680
+ // Store the local identifier that this file uses to refer to Wrec.
406
681
  names.add(element.name.text);
407
682
 
683
+ // Capture whether the source file uses single or double quotes
684
+ // so generated code can follow the file's existing style.
408
685
  const moduleText = statement.moduleSpecifier.getText(sourceFile);
409
- if (moduleText.startsWith('"') || moduleText.startsWith("'")) {
410
- quote = moduleText[0];
411
- }
686
+ quote = moduleText[0];
412
687
  }
413
688
  }
414
689
  }
@@ -416,12 +691,26 @@ function getWrecImportInfo(sourceFile) {
416
691
  return {names, quote};
417
692
  }
418
693
 
419
- function hasStaticModifier(node) {
694
+ // Determines if a class member is static.
695
+ function hasStaticModifier(member) {
420
696
  return Boolean(
421
- node.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword)
697
+ member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword)
698
+ );
699
+ }
700
+
701
+ // Determines if a class member represents an instance method.
702
+ // This is only called by getMethodUsages.
703
+ function isInstanceMethodMember(member) {
704
+ return (
705
+ !hasStaticModifier(member) &&
706
+ (ts.isMethodDeclaration(member) ||
707
+ ts.isGetAccessorDeclaration(member) ||
708
+ ts.isSetAccessorDeclaration(member)) &&
709
+ member.body
422
710
  );
423
711
  }
424
712
 
713
+ // Determines if a path refers to a JavaScript or TypeScript source file.
425
714
  function isSupportedSourceFile(filePath, excludeTests = false) {
426
715
  return (
427
716
  /\.(js|ts)$/.test(filePath) &&
@@ -430,235 +719,61 @@ function isSupportedSourceFile(filePath, excludeTests = false) {
430
719
  );
431
720
  }
432
721
 
433
- function transformSourceFile(sourceFile) {
434
- const edits = [];
435
- const {names: wrecNames, quote} = getWrecImportInfo(sourceFile);
436
- const suggestions = [];
437
- let foundWrecSubclass = false;
438
-
439
- // Each matching class contributes text replacements for the specific
440
- // property config objects that need `usedBy` added, updated, or removed.
441
- for (const node of sourceFile.statements) {
442
- if (!ts.isClassDeclaration(node) || !extendsWrec(node, wrecNames)) continue;
443
- foundWrecSubclass = true;
444
-
445
- let propertiesObject = null;
446
- for (const member of node.members) {
447
- if (
448
- ts.isPropertyDeclaration(member) &&
449
- hasStaticModifier(member) &&
450
- getNameText(member.name) === 'properties' &&
451
- member.initializer &&
452
- ts.isObjectLiteralExpression(member.initializer)
453
- ) {
454
- propertiesObject = member.initializer;
455
- break;
456
- }
457
- }
458
-
459
- if (!propertiesObject) continue;
460
-
461
- const propertyNames = new Set(
462
- propertiesObject.properties
463
- .filter(ts.isPropertyAssignment)
464
- .map(property => getNameText(property.name))
465
- .filter(Boolean)
466
- );
467
-
468
- const propToMethods = getMethodUsages(node, propertyNames);
469
- for (const member of propertiesObject.properties) {
470
- if (!ts.isPropertyAssignment(member)) continue;
471
-
472
- const propName = getNameText(member.name);
473
- if (!propName || !ts.isObjectLiteralExpression(member.initializer))
474
- continue;
475
-
476
- const methodNames = [
477
- ...(propToMethods.get(propName) ?? new Set())
478
- ].sort();
479
- const configObject = member.initializer;
480
- const existingMembers = configObject.properties.filter(
481
- property =>
482
- !(
483
- ts.isPropertyAssignment(property) &&
484
- getNameText(property.name) === 'usedBy'
485
- )
486
- );
487
- const hadUsedBy =
488
- existingMembers.length !== configObject.properties.length;
489
- if (methodNames.length > 0 || hadUsedBy) {
490
- suggestions.push({
491
- propName,
492
- suggestion:
493
- methodNames.length > 0
494
- ? createUsedByProperty(methodNames, quote)
495
- : 'remove usedBy'
496
- });
497
- }
498
- const needsUsedBy = methodNames.length > 0;
499
- if (!hadUsedBy && !needsUsedBy) continue;
500
-
501
- const nextText = buildConfigText(sourceFile, member, methodNames, quote);
502
- const currentText = sourceFile.text.slice(
503
- configObject.getStart(sourceFile),
504
- configObject.end
505
- );
506
- if (nextText !== currentText) {
507
- edits.push({
508
- start: configObject.getStart(sourceFile),
509
- end: configObject.end,
510
- propName,
511
- oldText: currentText,
512
- text: nextText
513
- });
514
- }
515
- }
516
- }
517
-
518
- if (!foundWrecSubclass) {
519
- return {
520
- changed: false,
521
- edits: [],
522
- foundWrecSubclass: false,
523
- suggestions,
524
- text: sourceFile.text
525
- };
526
- }
527
-
528
- if (edits.length === 0) {
529
- return {
530
- changed: false,
531
- edits: [],
532
- foundWrecSubclass: true,
533
- suggestions,
534
- text: sourceFile.text
535
- };
536
- }
537
-
538
- let nextSource = sourceFile.text;
539
- edits.sort((a, b) => b.start - a.start);
540
- for (const edit of edits) {
541
- nextSource =
542
- nextSource.slice(0, edit.start) + edit.text + nextSource.slice(edit.end);
543
- }
544
-
545
- return {
546
- changed: true,
547
- edits,
548
- foundWrecSubclass: true,
549
- suggestions,
550
- text: nextSource
551
- };
552
- }
553
-
554
- function validateTargetFile(target, cwd = process.cwd()) {
555
- if (!fs.existsSync(target)) {
556
- throw new Error(`File not found: ${path.relative(cwd, target)}`);
557
- }
558
-
559
- const stat = fs.statSync(target);
560
- if (!stat.isFile()) {
561
- throw new Error(`Not a file: ${path.relative(cwd, target)}`);
562
- }
563
-
564
- if (!/\.(js|ts)$/.test(target) || /\.d\.ts$/.test(target)) {
565
- throw new Error(`Unsupported file type: ${path.relative(cwd, target)}`);
566
- }
567
- }
568
-
569
- export function updateUsedBySource(filePath, text) {
570
- const scriptKind = filePath.endsWith('.ts')
571
- ? ts.ScriptKind.TS
572
- : ts.ScriptKind.JS;
573
- const sourceFile = ts.createSourceFile(
574
- filePath,
575
- text,
576
- ts.ScriptTarget.Latest,
577
- true,
578
- scriptKind
579
- );
580
-
581
- return transformSourceFile(sourceFile);
582
- }
583
-
584
- export function updateUsedByFile(filePath, options = {}) {
585
- const {dry = false, quiet = false} = options;
586
- const cwd = process.cwd();
587
- const resolved = path.resolve(cwd, filePath);
588
- validateTargetFile(resolved, cwd);
589
-
590
- const text = fs.readFileSync(resolved, 'utf8');
591
- const {
592
- changed,
593
- foundWrecSubclass,
594
- suggestions,
595
- text: nextText
596
- } = updateUsedBySource(resolved, text);
597
- if (!foundWrecSubclass) {
598
- throw new Error('No class extending Wrec was found.');
599
- }
600
- if (dry) {
601
- return {changed, foundWrecSubclass, suggestions, text: nextText};
602
- }
603
-
604
- if (changed) {
605
- // Otherwise, apply the rewritten source text back to disk.
606
- fs.writeFileSync(resolved, nextText);
607
- }
608
- return {changed, foundWrecSubclass, suggestions: [], text: nextText, quiet};
609
- }
610
-
611
- function fail(message) {
612
- console.error(message);
613
- process.exit(1);
614
- }
615
-
722
+ // Handles CLI arguments and runs the script.
616
723
  function main() {
617
724
  const args = process.argv.slice(2);
618
- const dry = args.includes('--dry');
619
- const quiet = args.includes('--quiet');
620
725
  const inputPaths = args.filter(arg => !arg.startsWith('--'));
621
726
 
622
- if (args.includes('--check')) {
623
- throw new Error('Use --dry instead of --check.');
624
- }
625
-
626
727
  if (inputPaths.length !== 1) {
627
- throw new Error(
628
- 'Specify a single source file, e.g. npx wrec-usedby src/examples/radio-group.js'
629
- );
728
+ throw new Error('Specify a single source file');
630
729
  }
631
730
 
632
- const result = updateUsedByFile(inputPaths[0], {dry, quiet});
731
+ const dry = args.includes('--dry');
732
+ const result = evaluateSourceFile(inputPaths[0], {dry});
633
733
  if (dry) {
634
- // In dry mode, report the inferred change and exit non-zero when at
635
- // least one update would be needed so the script can be used in checks.
734
+ // Report the proposed changes.
636
735
  for (const {propName, suggestion} of result.suggestions) {
637
- console.log(`${propName} - ${suggestion}`);
736
+ console.info(`${propName} - ${suggestion}`);
638
737
  }
639
- if (!result.changed) return;
640
- process.exit(1);
738
+
739
+ // Exit with a non-zero when there is at least one change
740
+ // so that condition can be checked.
741
+ if (result.changed) process.exit(1);
742
+ } else if (result.changed) {
743
+ console.info('updated source file');
744
+ } else {
745
+ console.info('no changes needed');
641
746
  }
747
+ }
642
748
 
643
- if (result.changed) console.log('updated');
749
+ // Records a `this` property access and
750
+ // tracks it as a method call when applicable.
751
+ function recordThisAccess(props, calledMethods, node, name) {
752
+ props.add(name);
753
+ if (ts.isCallExpression(node.parent) && node.parent.expression === node) {
754
+ calledMethods.add(name);
755
+ }
644
756
  }
645
757
 
646
- const isCliEntry = (() => {
647
- if (!process.argv[1]) return false;
648
- try {
649
- return (
650
- fs.realpathSync(process.argv[1]) ===
651
- fs.realpathSync(fileURLToPath(import.meta.url))
652
- );
653
- } catch {
654
- return path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
758
+ // Validates that a source file exists and has a supported extension.
759
+ function validateFile(absFilePath) {
760
+ if (!fs.existsSync(absFilePath)) throw new Error('File not found');
761
+
762
+ const stat = fs.statSync(absFilePath);
763
+ if (!stat.isFile()) throw new Error('Not a file');
764
+
765
+ if (!/\.(js|ts)$/.test(absFilePath) || /\.d\.ts$/.test(absFilePath)) {
766
+ throw new Error('Unsupported file type');
655
767
  }
656
- })();
768
+ }
657
769
 
658
- if (isCliEntry) {
770
+ // If this is being run as a script,
771
+ // versus being imported (likely by test code) ...
772
+ if (import.meta.main) {
659
773
  try {
660
774
  main();
661
775
  } catch (error) {
662
- fail(error instanceof Error ? error.message : String(error));
776
+ console.error(error instanceof Error ? error.message : String(error));
777
+ process.exit(1);
663
778
  }
664
779
  }