wrec 0.24.9 → 0.24.11

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 +1 -1
  2. package/scripts/lint.js +119 -57
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.24.9",
5
+ "version": "0.24.11",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
package/scripts/lint.js CHANGED
@@ -20,6 +20,7 @@
20
20
  // - invalid `form-assoc` values
21
21
  // - invalid `useState` map entries
22
22
  // - unsupported HTML attributes in templates
23
+ // - invalid HTML element nesting in templates
23
24
 
24
25
  import fs from 'node:fs';
25
26
  import path from 'node:path';
@@ -79,6 +80,24 @@ const HTML_TAG_ATTRIBUTES = new Map([
79
80
  ['tr', new Set([])],
80
81
  ['ul', new Set([])]
81
82
  ]);
83
+ const HTML_ALLOWED_PARENTS = new Map([
84
+ ['legend', new Set(['fieldset'])],
85
+ ['li', new Set(['ol', 'ul'])],
86
+ ['option', new Set(['select'])],
87
+ ['tbody', new Set(['table'])],
88
+ ['td', new Set(['tr'])],
89
+ ['th', new Set(['tr'])],
90
+ ['thead', new Set(['table'])],
91
+ ['tr', new Set(['table', 'tbody', 'thead'])]
92
+ ]);
93
+ const HTML_ALLOWED_CHILDREN = new Map([
94
+ ['select', new Set(['option'])],
95
+ ['table', new Set(['tbody', 'thead', 'tr'])],
96
+ ['tbody', new Set(['tr'])],
97
+ ['thead', new Set(['tr'])],
98
+ ['tr', new Set(['td', 'th'])],
99
+ ['ul', new Set(['li'])]
100
+ ]);
82
101
  const THIS_CALL_RE = /this\.([A-Za-z_$][\w$]*)\s*\(/g;
83
102
  const THIS_REF_RE = /this\.([A-Za-z_$][\w$]*)(\.[A-Za-z_$][\w$]*)*/g;
84
103
  const PLACEHOLDER_PREFIX = '__WREC_PLACEHOLDER__';
@@ -348,6 +367,32 @@ function collectWrecClasses(sourceFile) {
348
367
  return classes;
349
368
  }
350
369
 
370
+ function collectSupportedPropertyNames(classNode) {
371
+ const supportedProps = new Set();
372
+
373
+ for (const member of classNode.members) {
374
+ if (!isStaticMember(member)) continue;
375
+ if (!ts.isPropertyDeclaration(member)) continue;
376
+
377
+ const name = getMemberName(member);
378
+ if (
379
+ name !== 'properties' ||
380
+ !member.initializer ||
381
+ !ts.isObjectLiteralExpression(member.initializer)
382
+ ) {
383
+ continue;
384
+ }
385
+
386
+ for (const property of member.initializer.properties) {
387
+ if (!ts.isPropertyAssignment(property)) continue;
388
+ const propName = getMemberName(property);
389
+ if (propName) supportedProps.add(propName);
390
+ }
391
+ }
392
+
393
+ return supportedProps;
394
+ }
395
+
351
396
  function findDefinedTagNames(sourceFile) {
352
397
  const tagNames = new Map();
353
398
 
@@ -698,6 +743,7 @@ function formatReport(
698
743
  findings.extraArguments.length > 0 ||
699
744
  findings.incompatibleArguments.length > 0 ||
700
745
  findings.invalidEventHandlers.length > 0 ||
746
+ findings.invalidHtmlNesting.length > 0 ||
701
747
  findings.unsupportedHtmlAttributes.length > 0 ||
702
748
  findings.unsupportedEventNames.length > 0 ||
703
749
  findings.typeErrors.length > 0;
@@ -814,6 +860,11 @@ function formatReport(
814
860
  findings.invalidUseStateMaps.forEach(message => lines.push(` ${message}`));
815
861
  }
816
862
 
863
+ if (findings.invalidHtmlNesting.length > 0) {
864
+ lines.push('invalid html nesting:');
865
+ findings.invalidHtmlNesting.forEach(message => lines.push(` ${message}`));
866
+ }
867
+
817
868
  if (findings.extraArguments.length > 0) {
818
869
  lines.push('extra arguments:');
819
870
  findings.extraArguments.forEach(finding => {
@@ -867,11 +918,16 @@ function getComponentPropertyMaps(filePath, sourceText, seen = new Set()) {
867
918
  seen.add(resolved);
868
919
 
869
920
  const text = sourceText ?? fs.readFileSync(resolved, 'utf8');
870
- const program = createProgram(resolved, text);
871
- const sourceFile = program.getSourceFile(resolved);
872
- if (!sourceFile) return new Map();
873
-
874
- const checker = program.getTypeChecker();
921
+ const scriptKind = resolved.endsWith('.ts')
922
+ ? ts.ScriptKind.TS
923
+ : ts.ScriptKind.JS;
924
+ const sourceFile = ts.createSourceFile(
925
+ resolved,
926
+ text,
927
+ ts.ScriptTarget.ESNext,
928
+ true,
929
+ scriptKind
930
+ );
875
931
  const tagNames = findDefinedTagNames(sourceFile);
876
932
  const propertyMaps = new Map();
877
933
 
@@ -880,8 +936,7 @@ function getComponentPropertyMaps(filePath, sourceText, seen = new Set()) {
880
936
  ? tagNames.get(classNode.name.text)
881
937
  : undefined;
882
938
  if (!tagName) continue;
883
- const {supportedProps} = extractProperties(sourceFile, checker, classNode);
884
- propertyMaps.set(tagName, new Set(supportedProps.keys()));
939
+ propertyMaps.set(tagName, collectSupportedPropertyNames(classNode));
885
940
  }
886
941
 
887
942
  for (const statement of sourceFile.statements) {
@@ -908,51 +963,11 @@ function getComponentPropertyMaps(filePath, sourceText, seen = new Set()) {
908
963
  return propertyMaps;
909
964
  }
910
965
 
911
- function findWrecClass(sourceFile, checker) {
912
- let found;
913
-
914
- function visit(node) {
915
- if (found) return;
916
- if (ts.isClassDeclaration(node) && node.name) {
917
- const heritage = node.heritageClauses?.find(
918
- clause => clause.token === ts.SyntaxKind.ExtendsKeyword
919
- );
920
- const typeNode = heritage?.types[0];
921
- if (typeNode) {
922
- const baseType = checker.getTypeAtLocation(typeNode.expression);
923
- const baseSymbol = baseType.symbol ?? baseType.aliasSymbol;
924
- if (baseSymbol?.getName() === 'Wrec') {
925
- found = node;
926
- return;
927
- }
928
- }
929
- }
930
- ts.forEachChild(node, visit);
931
- }
932
-
933
- visit(sourceFile);
934
- return found;
935
- }
936
-
937
966
  function formatLocation(location) {
938
967
  if (!location) return '';
939
968
  return `:${location.line + 1}:${location.character + 1}`;
940
969
  }
941
970
 
942
- function getArgPaths() {
943
- const [, , filePath, ...rest] = process.argv;
944
- if (rest.length > 0) {
945
- fail('usage: node scripts/wrec-lint.js [file.js|file.ts]');
946
- }
947
-
948
- try {
949
- if (filePath) return [validateFilePath(filePath)];
950
- return findWrecFiles(process.cwd());
951
- } catch (error) {
952
- fail(error instanceof Error ? error.message : String(error));
953
- }
954
- }
955
-
956
971
  function getExpressionText(sourceFile, expression) {
957
972
  return expression.getText(sourceFile).trim();
958
973
  }
@@ -1128,10 +1143,17 @@ function isNumericLikeType(type) {
1128
1143
 
1129
1144
  function isWrecComponentFile(filePath) {
1130
1145
  const sourceText = fs.readFileSync(filePath, 'utf8');
1131
- const program = createProgram(filePath, sourceText);
1132
- const sourceFile = program.getSourceFile(filePath);
1133
- if (!sourceFile) return false;
1134
- return Boolean(findWrecClass(sourceFile, program.getTypeChecker()));
1146
+ const scriptKind = filePath.endsWith('.ts')
1147
+ ? ts.ScriptKind.TS
1148
+ : ts.ScriptKind.JS;
1149
+ const sourceFile = ts.createSourceFile(
1150
+ filePath,
1151
+ sourceText,
1152
+ ts.ScriptTarget.ESNext,
1153
+ true,
1154
+ scriptKind
1155
+ );
1156
+ return collectWrecClasses(sourceFile).length > 0;
1135
1157
  }
1136
1158
 
1137
1159
  function isStaticMember(node) {
@@ -1183,7 +1205,7 @@ export function lintSource(filePath, sourceText, options = {}) {
1183
1205
  if (!sourceFile) throw new Error(`unable to parse ${filePath}`);
1184
1206
 
1185
1207
  const checker = baseProgram.getTypeChecker();
1186
- const classNode = findWrecClass(sourceFile, checker);
1208
+ const classNode = collectWrecClasses(sourceFile)[0];
1187
1209
  if (!classNode) throw new Error('file must define a subclass of Wrec');
1188
1210
  const componentPropertyMaps = getComponentPropertyMaps(filePath, sourceText);
1189
1211
 
@@ -1205,6 +1227,7 @@ export function lintSource(filePath, sourceText, options = {}) {
1205
1227
  invalidDefaultValues: [],
1206
1228
  invalidEventHandlers: [],
1207
1229
  invalidFormAssocValues: [],
1230
+ invalidHtmlNesting: [],
1208
1231
  invalidUseStateMaps: [],
1209
1232
  invalidUsedByReferences: [],
1210
1233
  invalidValuesConfigurations: [],
@@ -1243,10 +1266,7 @@ export function lintSource(filePath, sourceText, options = {}) {
1243
1266
  if (!augmentedSourceFile) throw new Error(`unable to analyze ${filePath}`);
1244
1267
 
1245
1268
  const augmentedChecker = augmentedProgram.getTypeChecker();
1246
- const augmentedClassNode = findWrecClass(
1247
- augmentedSourceFile,
1248
- augmentedChecker
1249
- );
1269
+ const augmentedClassNode = collectWrecClasses(augmentedSourceFile)[0];
1250
1270
  if (!augmentedClassNode) {
1251
1271
  throw new Error('unable to find Wrec subclass after augmentation');
1252
1272
  }
@@ -1306,6 +1326,7 @@ export function lintSource(filePath, sourceText, options = {}) {
1306
1326
  findings.invalidDefaultValues.sort();
1307
1327
  findings.invalidEventHandlers.sort();
1308
1328
  findings.invalidFormAssocValues.sort();
1329
+ findings.invalidHtmlNesting.sort();
1309
1330
  findings.invalidUseStateMaps.sort();
1310
1331
  findings.invalidUsedByReferences.sort();
1311
1332
  findings.invalidValuesConfigurations.sort();
@@ -1415,7 +1436,6 @@ function validateDefaultValue(checker, typeExpression, valueExpression) {
1415
1436
 
1416
1437
  const typeKind = typeExpressionKind(typeExpression);
1417
1438
  const valueType = checker.getTypeAtLocation(valueExpression);
1418
- const typeName = typeKind ?? typeExpression.getText();
1419
1439
  const valueTypeName = checker.typeToString(valueType);
1420
1440
 
1421
1441
  if (typeKind === 'String') {
@@ -1677,8 +1697,50 @@ function validateValueBindingEvent(node, attrName, findings) {
1677
1697
  );
1678
1698
  }
1679
1699
 
1700
+ function getHtmlTagName(node) {
1701
+ const tagName = node.rawTagName || node.tagName;
1702
+ return typeof tagName === 'string' ? tagName.toLowerCase() : '';
1703
+ }
1704
+
1705
+ function validateHtmlNesting(node, findings) {
1706
+ const tagName = getHtmlTagName(node);
1707
+ if (!tagName || tagName.includes('-')) return;
1708
+
1709
+ const parentNode = node.parentNode?.nodeType === 1 ? node.parentNode : null;
1710
+ const parentTagName = parentNode ? getHtmlTagName(parentNode) : '';
1711
+ const allowedParents = HTML_ALLOWED_PARENTS.get(tagName);
1712
+ if (
1713
+ allowedParents &&
1714
+ (!parentTagName || !allowedParents.has(parentTagName))
1715
+ ) {
1716
+ findings.invalidHtmlNesting.push(
1717
+ `<${tagName}> must be nested inside ${[...allowedParents]
1718
+ .map(name => `<${name}>`)
1719
+ .join(
1720
+ ' or '
1721
+ )}, but parent is ${parentTagName ? `<${parentTagName}>` : 'the document root'}`
1722
+ );
1723
+ }
1724
+
1725
+ const allowedChildren = HTML_ALLOWED_CHILDREN.get(tagName);
1726
+ if (!allowedChildren) return;
1727
+
1728
+ for (const child of node.childNodes ?? []) {
1729
+ if (child.nodeType !== 1) continue;
1730
+ const childTagName = getHtmlTagName(child);
1731
+ if (!childTagName || childTagName.includes('-')) continue;
1732
+ if (allowedChildren.has(childTagName)) continue;
1733
+
1734
+ findings.invalidHtmlNesting.push(
1735
+ `<${childTagName}> is not allowed directly inside <${tagName}>`
1736
+ );
1737
+ }
1738
+ }
1739
+
1680
1740
  function walkHtmlNode(node, expressions, findings, componentPropertyMaps) {
1681
1741
  if (node.nodeType === 1) {
1742
+ validateHtmlNesting(node, findings);
1743
+
1682
1744
  for (const [attrName, attrValue] of Object.entries(node.attributes)) {
1683
1745
  if (!attrValue) continue;
1684
1746
  validateFormAssocAttribute(attrName, attrValue, findings);