wrec 0.23.2 → 0.24.1

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 (2) hide show
  1. package/package.json +6 -2
  2. package/scripts/used-by.js +584 -0
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.23.2",
5
+ "version": "0.24.1",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -26,8 +26,12 @@
26
26
  "import": "./dist/wrec-ssr.es.js"
27
27
  }
28
28
  },
29
+ "bin": {
30
+ "wrec-usedby": "./scripts/used-by.js"
31
+ },
29
32
  "files": [
30
33
  "dist",
34
+ "scripts/used-by.js",
31
35
  "README.md"
32
36
  ],
33
37
  "scripts": {
@@ -46,11 +50,11 @@
46
50
  "get-port": "^7.1.0",
47
51
  "node-html-parser": "^7.1.0",
48
52
  "oxlint": "^1.51.0",
49
- "typescript": "^5.9.3",
50
53
  "vite": "^7.3.1",
51
54
  "vite-plugin-dts": "^4.5.4"
52
55
  },
53
56
  "dependencies": {
57
+ "typescript": "^5.9.3",
54
58
  "xss": "^1.0.15"
55
59
  }
56
60
  }
@@ -0,0 +1,584 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import ts from 'typescript';
6
+
7
+ const args = process.argv.slice(2);
8
+ const dry = args.includes('--dry');
9
+ const verbose = args.includes('--verbose');
10
+ const inputPaths = args.filter(arg => !arg.startsWith('--'));
11
+
12
+ if (args.includes('--write')) {
13
+ console.error('--write is no longer supported; writing is now the default.');
14
+ process.exit(1);
15
+ }
16
+
17
+ if (args.includes('--check')) {
18
+ console.error('Use --dry instead of --check.');
19
+ process.exit(1);
20
+ }
21
+
22
+ if (inputPaths.length !== 1) {
23
+ console.error(
24
+ 'Specify a single source file, e.g. npx wrec-usedby src/examples/radio-group.js'
25
+ );
26
+ process.exit(1);
27
+ }
28
+
29
+ const cwd = process.cwd();
30
+ const targets = inputPaths.map(file => path.resolve(cwd, file));
31
+
32
+ for (const target of targets) {
33
+ if (!fs.existsSync(target)) {
34
+ console.error(`File not found: ${path.relative(cwd, target)}`);
35
+ process.exit(1);
36
+ }
37
+
38
+ const stat = fs.statSync(target);
39
+ if (!stat.isFile()) {
40
+ console.error(`Not a file: ${path.relative(cwd, target)}`);
41
+ process.exit(1);
42
+ }
43
+
44
+ if (!/\.(js|ts)$/.test(target) || /\.d\.ts$/.test(target)) {
45
+ console.error(
46
+ `Unsupported file type: ${path.relative(cwd, target)}`
47
+ );
48
+ process.exit(1);
49
+ }
50
+ }
51
+
52
+ function collectFiles(startPath, files = []) {
53
+ if (!fs.existsSync(startPath)) return files;
54
+
55
+ const stat = fs.statSync(startPath);
56
+ if (stat.isFile()) {
57
+ if (/\.(js|ts)$/.test(startPath) && !/\.d\.ts$/.test(startPath)) {
58
+ files.push(startPath);
59
+ }
60
+ return files;
61
+ }
62
+
63
+ for (const entry of fs.readdirSync(startPath, {withFileTypes: true})) {
64
+ const fullPath = path.join(startPath, entry.name);
65
+ if (entry.isDirectory()) {
66
+ if (entry.name === 'node_modules' || entry.name === 'dist') continue;
67
+ collectFiles(fullPath, files);
68
+ } else if (
69
+ entry.isFile() &&
70
+ /\.(js|ts)$/.test(entry.name) &&
71
+ !entry.name.endsWith('.d.ts') &&
72
+ !entry.name.includes('.test.')
73
+ ) {
74
+ files.push(fullPath);
75
+ }
76
+ }
77
+
78
+ return files;
79
+ }
80
+
81
+ const files = targets.flatMap(target => collectFiles(target));
82
+
83
+ function getNameText(name) {
84
+ if (
85
+ ts.isIdentifier(name) ||
86
+ ts.isStringLiteral(name) ||
87
+ ts.isPrivateIdentifier(name)
88
+ ) {
89
+ return name.text;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function hasStaticModifier(node) {
95
+ return Boolean(
96
+ node.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword)
97
+ );
98
+ }
99
+
100
+ function getWrecImportInfo(sourceFile) {
101
+ const names = new Set(['Wrec']);
102
+ let quote = "'";
103
+
104
+ for (const statement of sourceFile.statements) {
105
+ if (
106
+ !ts.isImportDeclaration(statement) ||
107
+ !statement.importClause ||
108
+ !ts.isStringLiteral(statement.moduleSpecifier)
109
+ ) {
110
+ continue;
111
+ }
112
+
113
+ const moduleName = statement.moduleSpecifier.text;
114
+ const isWrecModule =
115
+ moduleName === 'wrec' ||
116
+ moduleName === 'wrec/ssr' ||
117
+ moduleName.endsWith('/wrec') ||
118
+ moduleName.endsWith('/wrec-ssr');
119
+ if (!isWrecModule) continue;
120
+
121
+ const namedBindings = statement.importClause.namedBindings;
122
+ if (!namedBindings || !ts.isNamedImports(namedBindings)) continue;
123
+
124
+ for (const element of namedBindings.elements) {
125
+ const importedName = element.propertyName?.text ?? element.name.text;
126
+ if (importedName === 'Wrec') {
127
+ names.add(element.name.text);
128
+
129
+ const moduleText = statement.moduleSpecifier.getText(sourceFile);
130
+ if (moduleText.startsWith('"') || moduleText.startsWith("'")) {
131
+ quote = moduleText[0];
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ return {names, quote};
138
+ }
139
+
140
+ function extendsWrec(node, wrecNames) {
141
+ return Boolean(
142
+ node.heritageClauses?.some(
143
+ clause =>
144
+ clause.token === ts.SyntaxKind.ExtendsKeyword &&
145
+ clause.types.some(
146
+ type =>
147
+ ts.isExpressionWithTypeArguments(type) &&
148
+ ts.isIdentifier(type.expression) &&
149
+ wrecNames.has(type.expression.text)
150
+ )
151
+ )
152
+ );
153
+ }
154
+
155
+ function getTemplateCalledMethods(classNode) {
156
+ const methodNames = new Set();
157
+ const CALL_RE = /this\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
158
+
159
+ function visit(node) {
160
+ if (
161
+ ts.isPropertyAccessExpression(node) &&
162
+ node.expression.kind === ts.SyntaxKind.ThisKeyword &&
163
+ ts.isCallExpression(node.parent) &&
164
+ node.parent.expression === node
165
+ ) {
166
+ methodNames.add(node.name.text);
167
+ }
168
+
169
+ ts.forEachChild(node, visit);
170
+ }
171
+
172
+ function addTemplateTextMethods(template) {
173
+ const text = template.getText();
174
+ for (const match of text.matchAll(CALL_RE)) {
175
+ methodNames.add(match[1]);
176
+ }
177
+ }
178
+
179
+ for (const member of classNode.members) {
180
+ if (
181
+ ts.isPropertyDeclaration(member) &&
182
+ hasStaticModifier(member) &&
183
+ getNameText(member.name) === 'html' &&
184
+ member.initializer
185
+ ) {
186
+ if (
187
+ ts.isTaggedTemplateExpression(member.initializer) &&
188
+ ts.isIdentifier(member.initializer.tag) &&
189
+ member.initializer.tag.text === 'html'
190
+ ) {
191
+ addTemplateTextMethods(member.initializer.template);
192
+ }
193
+ ts.forEachChild(member.initializer, visit);
194
+ }
195
+ }
196
+
197
+ return methodNames;
198
+ }
199
+
200
+ function getComputedCalledMethods(classNode) {
201
+ const methodNames = new Set();
202
+ const CALL_RE = /this\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
203
+
204
+ for (const member of classNode.members) {
205
+ if (
206
+ !ts.isPropertyDeclaration(member) ||
207
+ !hasStaticModifier(member) ||
208
+ getNameText(member.name) !== 'properties' ||
209
+ !member.initializer ||
210
+ !ts.isObjectLiteralExpression(member.initializer)
211
+ ) {
212
+ continue;
213
+ }
214
+
215
+ for (const property of member.initializer.properties) {
216
+ if (
217
+ !ts.isPropertyAssignment(property) ||
218
+ !ts.isObjectLiteralExpression(property.initializer)
219
+ ) {
220
+ continue;
221
+ }
222
+
223
+ for (const configProperty of property.initializer.properties) {
224
+ if (
225
+ !ts.isPropertyAssignment(configProperty) ||
226
+ getNameText(configProperty.name) !== 'computed'
227
+ ) {
228
+ continue;
229
+ }
230
+
231
+ if (!ts.isStringLiteralLike(configProperty.initializer)) continue;
232
+ const computed = configProperty.initializer.text;
233
+ for (const match of computed.matchAll(CALL_RE)) {
234
+ methodNames.add(match[1]);
235
+ }
236
+ }
237
+ }
238
+ }
239
+
240
+ return methodNames;
241
+ }
242
+
243
+ function getMethodUsages(classNode, propertyNames) {
244
+ const methodInfo = new Map();
245
+ for (const member of classNode.members) {
246
+ if (hasStaticModifier(member)) continue;
247
+
248
+ if (
249
+ (ts.isMethodDeclaration(member) ||
250
+ ts.isGetAccessorDeclaration(member) ||
251
+ ts.isSetAccessorDeclaration(member)) &&
252
+ member.body
253
+ ) {
254
+ const methodName = getNameText(member.name);
255
+ if (!methodName) continue;
256
+
257
+ const props = new Set();
258
+ const calledMethods = new Set();
259
+ const isPrivate = ts.isPrivateIdentifier(member.name);
260
+
261
+ function addBindingName(name) {
262
+ if (ts.isIdentifier(name)) {
263
+ props.add(name.text);
264
+ } else if (ts.isObjectBindingPattern(name)) {
265
+ addObjectBindingProps(name);
266
+ }
267
+ }
268
+
269
+ function addObjectBindingProps(bindingPattern) {
270
+ for (const element of bindingPattern.elements) {
271
+ if (element.dotDotDotToken) continue;
272
+
273
+ if (element.propertyName) {
274
+ const name = getNameText(element.propertyName);
275
+ if (name) props.add(name);
276
+ continue;
277
+ }
278
+
279
+ if (ts.isIdentifier(element.name)) {
280
+ props.add(element.name.text);
281
+ } else if (ts.isObjectBindingPattern(element.name)) {
282
+ addObjectBindingProps(element.name);
283
+ }
284
+ }
285
+ }
286
+
287
+ function visit(child) {
288
+ if (
289
+ ts.isPropertyAccessExpression(child) &&
290
+ child.expression.kind === ts.SyntaxKind.ThisKeyword
291
+ ) {
292
+ const name = child.name.text;
293
+ props.add(name);
294
+ if (
295
+ ts.isCallExpression(child.parent) &&
296
+ child.parent.expression === child
297
+ ) {
298
+ calledMethods.add(name);
299
+ }
300
+ } else if (
301
+ ts.isElementAccessExpression(child) &&
302
+ child.expression.kind === ts.SyntaxKind.ThisKeyword &&
303
+ child.argumentExpression &&
304
+ ts.isStringLiteralLike(child.argumentExpression)
305
+ ) {
306
+ const name = child.argumentExpression.text;
307
+ props.add(name);
308
+ if (
309
+ ts.isCallExpression(child.parent) &&
310
+ child.parent.expression === child
311
+ ) {
312
+ calledMethods.add(name);
313
+ }
314
+ } else if (
315
+ ts.isVariableDeclaration(child) &&
316
+ child.initializer &&
317
+ child.initializer.kind === ts.SyntaxKind.ThisKeyword
318
+ ) {
319
+ if (ts.isObjectBindingPattern(child.name)) {
320
+ addObjectBindingProps(child.name);
321
+ } else {
322
+ addBindingName(child.name);
323
+ }
324
+ } else if (
325
+ ts.isBinaryExpression(child) &&
326
+ child.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
327
+ child.right.kind === ts.SyntaxKind.ThisKeyword
328
+ ) {
329
+ if (ts.isObjectLiteralExpression(child.left)) {
330
+ for (const property of child.left.properties) {
331
+ if (
332
+ ts.isShorthandPropertyAssignment(property) ||
333
+ ts.isPropertyAssignment(property)
334
+ ) {
335
+ const name = getNameText(property.name);
336
+ if (name) props.add(name);
337
+ }
338
+ }
339
+ } else if (ts.isObjectBindingPattern(child.left)) {
340
+ addObjectBindingProps(child.left);
341
+ }
342
+ }
343
+
344
+ ts.forEachChild(child, visit);
345
+ }
346
+
347
+ ts.forEachChild(member.body, visit);
348
+ methodInfo.set(methodName, {calledMethods, isPrivate, props});
349
+ }
350
+ }
351
+
352
+ const entryMethods = new Set([
353
+ ...getTemplateCalledMethods(classNode),
354
+ ...getComputedCalledMethods(classNode)
355
+ ]);
356
+ const memo = new Map();
357
+
358
+ function getTransitiveProps(methodName, seen = new Set()) {
359
+ if (memo.has(methodName)) return memo.get(methodName);
360
+ if (seen.has(methodName)) return new Set();
361
+
362
+ seen.add(methodName);
363
+ const info = methodInfo.get(methodName);
364
+ const props = new Set(info?.props ?? []);
365
+
366
+ if (info) {
367
+ for (const calledMethod of info.calledMethods) {
368
+ const calledProps = getTransitiveProps(calledMethod, seen);
369
+ for (const propName of calledProps) props.add(propName);
370
+ }
371
+ }
372
+
373
+ seen.delete(methodName);
374
+ memo.set(methodName, props);
375
+ return props;
376
+ }
377
+
378
+ const propToMethods = new Map();
379
+ for (const methodName of entryMethods) {
380
+ const info = methodInfo.get(methodName);
381
+ if (!info || info.isPrivate) continue;
382
+
383
+ if (info.isPrivate) continue;
384
+
385
+ for (const propName of getTransitiveProps(methodName)) {
386
+ if (!propertyNames.has(propName)) continue;
387
+
388
+ let methods = propToMethods.get(propName);
389
+ if (!methods) {
390
+ methods = new Set();
391
+ propToMethods.set(propName, methods);
392
+ }
393
+ methods.add(methodName);
394
+ }
395
+ }
396
+
397
+ return propToMethods;
398
+ }
399
+
400
+ function createUsedByProperty(methodNames, quote) {
401
+ return `usedBy: [${methodNames.map(name => `${quote}${name}${quote}`).join(', ')}]`;
402
+ }
403
+
404
+ function getIndent(text, pos) {
405
+ const lineStart = text.lastIndexOf('\n', pos - 1) + 1;
406
+ const match = /^[ \t]*/.exec(text.slice(lineStart));
407
+ return match ? match[0] : '';
408
+ }
409
+
410
+ function buildConfigText(sourceFile, member, methodNames, quote) {
411
+ const {text} = sourceFile;
412
+ const configObject = member.initializer;
413
+ const existingMembers = configObject.properties.filter(
414
+ property =>
415
+ !(
416
+ ts.isPropertyAssignment(property) &&
417
+ getNameText(property.name) === 'usedBy'
418
+ )
419
+ );
420
+ const existingTexts = existingMembers.map(property =>
421
+ text.slice(property.getStart(sourceFile), property.end).trim()
422
+ );
423
+ if (methodNames.length > 0)
424
+ existingTexts.push(createUsedByProperty(methodNames, quote));
425
+
426
+ const original = text.slice(
427
+ configObject.getStart(sourceFile),
428
+ configObject.end
429
+ );
430
+ const multiline = original.includes('\n');
431
+ if (!multiline) return `{${existingTexts.join(', ')}}`;
432
+
433
+ const memberIndent = getIndent(text, member.getStart(sourceFile));
434
+ const firstExisting = existingMembers[0];
435
+ const innerIndent = firstExisting
436
+ ? getIndent(text, firstExisting.getStart(sourceFile))
437
+ : memberIndent + ' ';
438
+ return `{\n${existingTexts.map(part => `${innerIndent}${part}`).join(',\n')}\n${memberIndent}}`;
439
+ }
440
+
441
+ function transformSourceFile(sourceFile) {
442
+ const edits = [];
443
+ const {names: wrecNames, quote} = getWrecImportInfo(sourceFile);
444
+ let foundWrecSubclass = false;
445
+
446
+ for (const node of sourceFile.statements) {
447
+ if (!ts.isClassDeclaration(node) || !extendsWrec(node, wrecNames)) continue;
448
+ foundWrecSubclass = true;
449
+
450
+ let propertiesObject = null;
451
+ for (const member of node.members) {
452
+ if (
453
+ ts.isPropertyDeclaration(member) &&
454
+ hasStaticModifier(member) &&
455
+ getNameText(member.name) === 'properties' &&
456
+ member.initializer &&
457
+ ts.isObjectLiteralExpression(member.initializer)
458
+ ) {
459
+ propertiesObject = member.initializer;
460
+ break;
461
+ }
462
+ }
463
+
464
+ if (!propertiesObject) continue;
465
+
466
+ const propertyNames = new Set(
467
+ propertiesObject.properties
468
+ .filter(ts.isPropertyAssignment)
469
+ .map(property => getNameText(property.name))
470
+ .filter(Boolean)
471
+ );
472
+
473
+ const propToMethods = getMethodUsages(node, propertyNames);
474
+ for (const member of propertiesObject.properties) {
475
+ if (!ts.isPropertyAssignment(member)) continue;
476
+
477
+ const propName = getNameText(member.name);
478
+ if (!propName || !ts.isObjectLiteralExpression(member.initializer))
479
+ continue;
480
+
481
+ const methodNames = [
482
+ ...(propToMethods.get(propName) ?? new Set())
483
+ ].sort();
484
+ const configObject = member.initializer;
485
+ const existingMembers = configObject.properties.filter(
486
+ property =>
487
+ !(
488
+ ts.isPropertyAssignment(property) &&
489
+ getNameText(property.name) === 'usedBy'
490
+ )
491
+ );
492
+ const hadUsedBy =
493
+ existingMembers.length !== configObject.properties.length;
494
+ const needsUsedBy = methodNames.length > 0;
495
+ if (!hadUsedBy && !needsUsedBy) continue;
496
+
497
+ const nextText = buildConfigText(sourceFile, member, methodNames, quote);
498
+ const currentText = sourceFile.text.slice(
499
+ configObject.getStart(sourceFile),
500
+ configObject.end
501
+ );
502
+ if (nextText !== currentText) {
503
+ edits.push({
504
+ start: configObject.getStart(sourceFile),
505
+ end: configObject.end,
506
+ propName,
507
+ oldText: currentText,
508
+ text: nextText
509
+ });
510
+ }
511
+ }
512
+ }
513
+
514
+ if (!foundWrecSubclass) {
515
+ return {
516
+ changed: false,
517
+ edits: [],
518
+ foundWrecSubclass: false,
519
+ text: sourceFile.text
520
+ };
521
+ }
522
+
523
+ if (edits.length === 0) {
524
+ return {
525
+ changed: false,
526
+ edits: [],
527
+ foundWrecSubclass: true,
528
+ text: sourceFile.text
529
+ };
530
+ }
531
+
532
+ let nextSource = sourceFile.text;
533
+ edits.sort((a, b) => b.start - a.start);
534
+ for (const edit of edits) {
535
+ nextSource =
536
+ nextSource.slice(0, edit.start) + edit.text + nextSource.slice(edit.end);
537
+ }
538
+
539
+ return {changed: true, edits, foundWrecSubclass: true, text: nextSource};
540
+ }
541
+
542
+ let changedCount = 0;
543
+
544
+ for (const file of files) {
545
+ const text = fs.readFileSync(file, 'utf8');
546
+ const scriptKind = file.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS;
547
+ const sourceFile = ts.createSourceFile(
548
+ file,
549
+ text,
550
+ ts.ScriptTarget.Latest,
551
+ true,
552
+ scriptKind
553
+ );
554
+
555
+ const {
556
+ changed,
557
+ edits,
558
+ foundWrecSubclass,
559
+ text: nextText
560
+ } = transformSourceFile(sourceFile);
561
+ if (!foundWrecSubclass) {
562
+ console.error('No class extending Wrec was found.');
563
+ process.exit(1);
564
+ }
565
+ if (!changed) continue;
566
+
567
+ changedCount++;
568
+ if (dry) {
569
+ for (const edit of edits.toReversed()) {
570
+ const match = edit.text.match(/usedBy:\s*\[[^\]]*\]/);
571
+ const suggestion = match ? match[0] : 'remove usedBy';
572
+ console.log(`${edit.propName} - ${suggestion}`);
573
+ }
574
+ } else {
575
+ fs.writeFileSync(file, nextText);
576
+ }
577
+ if (verbose && !dry) console.log('updated');
578
+ }
579
+
580
+ if (dry && changedCount > 0) process.exit(1);
581
+
582
+ if (dry && changedCount === 0) {
583
+ console.log('usedBy is already up to date.');
584
+ }