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.
- package/package.json +1 -1
- package/scripts/lint.js +119 -57
package/package.json
CHANGED
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
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
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
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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 =
|
|
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 =
|
|
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);
|