wrec 0.24.9 → 0.24.10

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 +113 -16
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.10",
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,14 @@ 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') ? ts.ScriptKind.TS : ts.ScriptKind.JS;
922
+ const sourceFile = ts.createSourceFile(
923
+ resolved,
924
+ text,
925
+ ts.ScriptTarget.ESNext,
926
+ true,
927
+ scriptKind
928
+ );
875
929
  const tagNames = findDefinedTagNames(sourceFile);
876
930
  const propertyMaps = new Map();
877
931
 
@@ -880,8 +934,7 @@ function getComponentPropertyMaps(filePath, sourceText, seen = new Set()) {
880
934
  ? tagNames.get(classNode.name.text)
881
935
  : undefined;
882
936
  if (!tagName) continue;
883
- const {supportedProps} = extractProperties(sourceFile, checker, classNode);
884
- propertyMaps.set(tagName, new Set(supportedProps.keys()));
937
+ propertyMaps.set(tagName, collectSupportedPropertyNames(classNode));
885
938
  }
886
939
 
887
940
  for (const statement of sourceFile.statements) {
@@ -1128,10 +1181,15 @@ function isNumericLikeType(type) {
1128
1181
 
1129
1182
  function isWrecComponentFile(filePath) {
1130
1183
  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()));
1184
+ const scriptKind = filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS;
1185
+ const sourceFile = ts.createSourceFile(
1186
+ filePath,
1187
+ sourceText,
1188
+ ts.ScriptTarget.ESNext,
1189
+ true,
1190
+ scriptKind
1191
+ );
1192
+ return collectWrecClasses(sourceFile).length > 0;
1135
1193
  }
1136
1194
 
1137
1195
  function isStaticMember(node) {
@@ -1183,7 +1241,7 @@ export function lintSource(filePath, sourceText, options = {}) {
1183
1241
  if (!sourceFile) throw new Error(`unable to parse ${filePath}`);
1184
1242
 
1185
1243
  const checker = baseProgram.getTypeChecker();
1186
- const classNode = findWrecClass(sourceFile, checker);
1244
+ const classNode = collectWrecClasses(sourceFile)[0];
1187
1245
  if (!classNode) throw new Error('file must define a subclass of Wrec');
1188
1246
  const componentPropertyMaps = getComponentPropertyMaps(filePath, sourceText);
1189
1247
 
@@ -1205,6 +1263,7 @@ export function lintSource(filePath, sourceText, options = {}) {
1205
1263
  invalidDefaultValues: [],
1206
1264
  invalidEventHandlers: [],
1207
1265
  invalidFormAssocValues: [],
1266
+ invalidHtmlNesting: [],
1208
1267
  invalidUseStateMaps: [],
1209
1268
  invalidUsedByReferences: [],
1210
1269
  invalidValuesConfigurations: [],
@@ -1243,10 +1302,7 @@ export function lintSource(filePath, sourceText, options = {}) {
1243
1302
  if (!augmentedSourceFile) throw new Error(`unable to analyze ${filePath}`);
1244
1303
 
1245
1304
  const augmentedChecker = augmentedProgram.getTypeChecker();
1246
- const augmentedClassNode = findWrecClass(
1247
- augmentedSourceFile,
1248
- augmentedChecker
1249
- );
1305
+ const augmentedClassNode = collectWrecClasses(augmentedSourceFile)[0];
1250
1306
  if (!augmentedClassNode) {
1251
1307
  throw new Error('unable to find Wrec subclass after augmentation');
1252
1308
  }
@@ -1306,6 +1362,7 @@ export function lintSource(filePath, sourceText, options = {}) {
1306
1362
  findings.invalidDefaultValues.sort();
1307
1363
  findings.invalidEventHandlers.sort();
1308
1364
  findings.invalidFormAssocValues.sort();
1365
+ findings.invalidHtmlNesting.sort();
1309
1366
  findings.invalidUseStateMaps.sort();
1310
1367
  findings.invalidUsedByReferences.sort();
1311
1368
  findings.invalidValuesConfigurations.sort();
@@ -1677,8 +1734,48 @@ function validateValueBindingEvent(node, attrName, findings) {
1677
1734
  );
1678
1735
  }
1679
1736
 
1737
+ function getHtmlTagName(node) {
1738
+ const tagName = node.rawTagName || node.tagName;
1739
+ return typeof tagName === 'string' ? tagName.toLowerCase() : '';
1740
+ }
1741
+
1742
+ function validateHtmlNesting(node, findings) {
1743
+ const tagName = getHtmlTagName(node);
1744
+ if (!tagName || tagName.includes('-')) return;
1745
+
1746
+ const parentNode = node.parentNode?.nodeType === 1 ? node.parentNode : null;
1747
+ const parentTagName = parentNode ? getHtmlTagName(parentNode) : '';
1748
+ const allowedParents = HTML_ALLOWED_PARENTS.get(tagName);
1749
+ if (
1750
+ allowedParents &&
1751
+ (!parentTagName || !allowedParents.has(parentTagName))
1752
+ ) {
1753
+ findings.invalidHtmlNesting.push(
1754
+ `<${tagName}> must be nested inside ${[...allowedParents]
1755
+ .map(name => `<${name}>`)
1756
+ .join(' or ')}, but parent is ${parentTagName ? `<${parentTagName}>` : 'the document root'}`
1757
+ );
1758
+ }
1759
+
1760
+ const allowedChildren = HTML_ALLOWED_CHILDREN.get(tagName);
1761
+ if (!allowedChildren) return;
1762
+
1763
+ for (const child of node.childNodes ?? []) {
1764
+ if (child.nodeType !== 1) continue;
1765
+ const childTagName = getHtmlTagName(child);
1766
+ if (!childTagName || childTagName.includes('-')) continue;
1767
+ if (allowedChildren.has(childTagName)) continue;
1768
+
1769
+ findings.invalidHtmlNesting.push(
1770
+ `<${childTagName}> is not allowed directly inside <${tagName}>`
1771
+ );
1772
+ }
1773
+ }
1774
+
1680
1775
  function walkHtmlNode(node, expressions, findings, componentPropertyMaps) {
1681
1776
  if (node.nodeType === 1) {
1777
+ validateHtmlNesting(node, findings);
1778
+
1682
1779
  for (const [attrName, attrValue] of Object.entries(node.attributes)) {
1683
1780
  if (!attrValue) continue;
1684
1781
  validateFormAssocAttribute(attrName, attrValue, findings);