eslint-plugin-sfmc 2.6.1 → 4.0.0
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/README.md +77 -65
- package/docs/rules/amp/no-mcn-unsupported.md +76 -0
- package/docs/rules/amp/no-unknown-function.md +2 -39
- package/docs/rules/hbs/helper-arity.md +55 -0
- package/docs/rules/hbs/no-mcn-unsupported.md +69 -0
- package/docs/rules/hbs/no-unknown-binding.md +49 -0
- package/docs/rules/hbs/no-unknown-helper.md +60 -0
- package/docs/rules/hbs/no-unsupported-construct.md +65 -0
- package/docs/rules/ssjs/no-mcn-unsupported.md +68 -0
- package/docs/rules/ssjs/no-unknown-function.md +4 -31
- package/package.json +14 -12
- package/src/ampscript-parser.js +1 -1
- package/src/ampscript-processor.js +22 -16
- package/src/handlebars-parser.js +195 -0
- package/src/index.js +108 -23
- package/src/processor.js +76 -16
- package/src/rules/amp/{arg-types.js → argument-types.js} +16 -12
- package/src/rules/amp/function-arity.js +9 -9
- package/src/rules/amp/no-inline-statement.js +5 -5
- package/src/rules/amp/no-loop-counter-assign.js +7 -7
- package/src/rules/amp/no-mcn-unsupported.js +102 -0
- package/src/rules/amp/no-nested-ampscript-delimiter.js +58 -66
- package/src/rules/amp/no-smart-quotes.js +41 -47
- package/src/rules/amp/no-unknown-function.js +2 -28
- package/src/rules/amp/prefer-attribute-value.js +7 -10
- package/src/rules/amp/require-rowcount-check.js +12 -15
- package/src/rules/amp/require-variable-declaration.js +3 -3
- package/src/rules/hbs/_shared.js +116 -0
- package/src/rules/hbs/helper-arity.js +97 -0
- package/src/rules/hbs/no-mcn-unsupported.js +164 -0
- package/src/rules/hbs/no-unknown-binding.js +74 -0
- package/src/rules/hbs/no-unknown-helper.js +79 -0
- package/src/rules/hbs/no-unsupported-construct.js +70 -0
- package/src/rules/ssjs/cache-loop-length.js +14 -17
- package/src/rules/ssjs/no-deprecated-function.js +8 -8
- package/src/rules/ssjs/no-hardcoded-credentials.js +3 -6
- package/src/rules/ssjs/no-mcn-unsupported.js +182 -0
- package/src/rules/ssjs/no-property-call.js +6 -9
- package/src/rules/ssjs/no-treatascontent-injection.js +6 -13
- package/src/rules/ssjs/no-unavailable-method.js +22 -22
- package/src/rules/ssjs/no-unknown-function.js +15 -59
- package/src/rules/ssjs/no-unsupported-syntax.js +5 -5
- package/src/rules/ssjs/{prefer-parsejson-safe-arg.js → prefer-parsejson-safe-argument.js} +13 -21
- package/src/rules/ssjs/require-hasownproperty.js +54 -42
- package/src/rules/ssjs/require-platform-load-order.js +1 -1
- package/src/rules/ssjs/require-platform-load.js +6 -6
- package/src/rules/ssjs/{ssjs-arg-types.js → ssjs-argument-types.js} +59 -46
- package/src/rules/ssjs/ssjs-core-method-arity.js +41 -15
- /package/src/rules/amp/{no-var-redeclaration.js → no-variable-redeclaration.js} +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { functionNames, functionLookup, getMcnApiVersion } from 'ampscript-data';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Flags AMPscript functions that cannot be used when targeting Marketing Cloud
|
|
5
|
+
* Next, and functions that work in MCN AMPscript but have no Handlebars
|
|
6
|
+
* equivalent (so they block a Handlebars migration).
|
|
7
|
+
*
|
|
8
|
+
* Each function carries an `mcnSince` value = the MCN API version it first
|
|
9
|
+
* became available in, or `null`/unset when it was never supported in MCN.
|
|
10
|
+
*
|
|
11
|
+
* `apiVersion` semantics (shared with the SSJS and Handlebars MCN rules):
|
|
12
|
+
* - Not set / null: flag every function whose `mcnSince` is null/unset. Any
|
|
13
|
+
* function supported at some point in MCN (numeric `mcnSince`) passes.
|
|
14
|
+
* - Set to N: flag everything the null case flags, PLUS everything whose
|
|
15
|
+
* `mcnSince` is greater than N (too new for the target). Pass iff
|
|
16
|
+
* `mcnSince != null && mcnSince <= N`.
|
|
17
|
+
*
|
|
18
|
+
* Unknown-function reporting stays in `amp-no-unknown-function`; this rule only
|
|
19
|
+
* evaluates functions that exist in the AMPscript catalog.
|
|
20
|
+
*/
|
|
21
|
+
export default {
|
|
22
|
+
meta: {
|
|
23
|
+
type: 'problem',
|
|
24
|
+
docs: {
|
|
25
|
+
description:
|
|
26
|
+
'Disallow AMPscript functions that are not available in the targeted Marketing Cloud Next API version',
|
|
27
|
+
recommended: true,
|
|
28
|
+
},
|
|
29
|
+
messages: {
|
|
30
|
+
notSupportedInMcn: "'{{name}}' is not supported in Marketing Cloud Next.",
|
|
31
|
+
tooNewForTarget:
|
|
32
|
+
"'{{name}}' was introduced in Marketing Cloud Next API version {{since}}, which is newer than the targeted version {{target}}.",
|
|
33
|
+
noHandlebarsEquivalent:
|
|
34
|
+
"'{{name}}' is supported by Marketing Cloud Next AMPscript but has no Handlebars for Marketing Cloud Next equivalent. It cannot be migrated to a Handlebars helper.",
|
|
35
|
+
},
|
|
36
|
+
schema: [
|
|
37
|
+
{
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
apiVersion: {
|
|
41
|
+
type: 'number',
|
|
42
|
+
description:
|
|
43
|
+
'The targeted Marketing Cloud Next API version (e.g. 65 = Winter \u{2019}26, 67 = Summer \u{2019}26). Functions newer than this are flagged.',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
create(context) {
|
|
52
|
+
const options = context.options[0] ?? {};
|
|
53
|
+
const apiVersion = typeof options.apiVersion === 'number' ? options.apiVersion : null;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
FunctionCall(node) {
|
|
57
|
+
const lower = node.name.toLowerCase();
|
|
58
|
+
|
|
59
|
+
// Unknown functions are handled by amp-no-unknown-function.
|
|
60
|
+
if (!functionNames.has(lower)) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const since = getMcnApiVersion(lower);
|
|
65
|
+
|
|
66
|
+
// Never supported in MCN → always flag.
|
|
67
|
+
if (since === null) {
|
|
68
|
+
context.report({
|
|
69
|
+
node,
|
|
70
|
+
messageId: 'notSupportedInMcn',
|
|
71
|
+
data: { name: node.name },
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Supported in MCN but newer than the targeted API version.
|
|
77
|
+
if (apiVersion !== null && since > apiVersion) {
|
|
78
|
+
context.report({
|
|
79
|
+
node,
|
|
80
|
+
messageId: 'tooNewForTarget',
|
|
81
|
+
data: {
|
|
82
|
+
name: node.name,
|
|
83
|
+
since: String(since),
|
|
84
|
+
target: String(apiVersion),
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Works in MCN AMPscript but has no Handlebars counterpart, so it
|
|
91
|
+
// cannot be migrated to a Handlebars helper.
|
|
92
|
+
if (functionLookup.get(lower)?.mcnHandlebarsGap === true) {
|
|
93
|
+
context.report({
|
|
94
|
+
node,
|
|
95
|
+
messageId: 'noHandlebarsEquivalent',
|
|
96
|
+
data: { name: node.name },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
};
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
// The processor extracts each AMPscript region into its own virtual .amp file,
|
|
2
2
|
// including the outer delimiters (%%[...]/%%] or <script>...</script>).
|
|
3
|
-
// Any %%[ or %%= found INSIDE
|
|
4
|
-
|
|
3
|
+
// Any %%[ or %%= found INSIDE an already-open block is nested and invalid.
|
|
4
|
+
//
|
|
5
|
+
// A raw .amp file may contain several *sibling* blocks (e.g. one %%[...]%% block
|
|
6
|
+
// followed by another). Those are not nested. To distinguish nesting from
|
|
7
|
+
// siblings we scan every open/close token in document order and track depth:
|
|
8
|
+
// an opener is only nested when depth > 0.
|
|
9
|
+
const TOKEN_RE = /%%\[|%%=|\]%%|=%%/g;
|
|
5
10
|
const SCRIPT_TAG_RE = /^[\n]*<script\b/i;
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const INLINE_CLOSE_SUFFIX = '=%%';
|
|
11
|
+
|
|
12
|
+
const OPEN_TOKENS = new Set(['%%[', '%%=']);
|
|
13
|
+
const CLOSE_FOR_OPEN = { '%%[': ']%%', '%%=': '=%%' };
|
|
10
14
|
|
|
11
15
|
export default {
|
|
12
16
|
meta: {
|
|
@@ -25,76 +29,64 @@ export default {
|
|
|
25
29
|
},
|
|
26
30
|
|
|
27
31
|
create(context) {
|
|
32
|
+
const sourceCode = context.sourceCode;
|
|
33
|
+
|
|
34
|
+
const reportNested = (text, match, isScriptBlock) => {
|
|
35
|
+
const delimiter = match[0];
|
|
36
|
+
const start = match.index;
|
|
37
|
+
const end = start + delimiter.length;
|
|
38
|
+
const closeToken = CLOSE_FOR_OPEN[delimiter];
|
|
39
|
+
const closeIndex = text.indexOf(closeToken, end);
|
|
40
|
+
|
|
41
|
+
context.report({
|
|
42
|
+
loc: {
|
|
43
|
+
start: sourceCode.getLocFromIndex(start),
|
|
44
|
+
end: sourceCode.getLocFromIndex(end),
|
|
45
|
+
},
|
|
46
|
+
messageId: isScriptBlock ? 'nestedDelimiterInScript' : 'nestedDelimiter',
|
|
47
|
+
data: { delimiter },
|
|
48
|
+
fix:
|
|
49
|
+
closeIndex === -1
|
|
50
|
+
? (fixer) => fixer.removeRange([start, end])
|
|
51
|
+
: (fixer) => [
|
|
52
|
+
fixer.removeRange([closeIndex, closeIndex + closeToken.length]),
|
|
53
|
+
fixer.removeRange([start, end]),
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
28
58
|
return {
|
|
29
59
|
Program() {
|
|
30
|
-
const sourceCode = context.sourceCode;
|
|
31
60
|
const text = sourceCode.getText();
|
|
32
61
|
const isScriptBlock = SCRIPT_TAG_RE.test(text);
|
|
33
62
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (blockOpenMatch) {
|
|
45
|
-
outerOpenStart = blockOpenMatch.index + blockOpenMatch[0].length - 3;
|
|
46
|
-
outerCloseSuffix = BLOCK_CLOSE_SUFFIX;
|
|
47
|
-
} else if (inlineOpenMatch) {
|
|
48
|
-
outerOpenStart = inlineOpenMatch.index + inlineOpenMatch[0].length - 3;
|
|
49
|
-
outerCloseSuffix = INLINE_CLOSE_SUFFIX;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// The outer close is the last occurrence of the matching close token
|
|
53
|
-
const lastClose = text.lastIndexOf(outerCloseSuffix);
|
|
54
|
-
if (lastClose !== -1) {
|
|
55
|
-
outerCloseStart = lastClose;
|
|
63
|
+
if (isScriptBlock) {
|
|
64
|
+
// Inside a <script language="ampscript"> block the whole body is
|
|
65
|
+
// already AMPscript; every %%[ / %%= delimiter is redundant.
|
|
66
|
+
TOKEN_RE.lastIndex = 0;
|
|
67
|
+
let match;
|
|
68
|
+
while ((match = TOKEN_RE.exec(text)) !== null) {
|
|
69
|
+
if (OPEN_TOKENS.has(match[0])) {
|
|
70
|
+
reportNested(text, match, true);
|
|
71
|
+
}
|
|
56
72
|
}
|
|
73
|
+
return;
|
|
57
74
|
}
|
|
58
75
|
|
|
59
|
-
|
|
76
|
+
// Depth-based scan for raw %%[...]%% / %%=...=%% content. Sibling blocks
|
|
77
|
+
// return depth to 0 between them, so their openers are not flagged.
|
|
78
|
+
let depth = 0;
|
|
79
|
+
TOKEN_RE.lastIndex = 0;
|
|
60
80
|
let match;
|
|
61
|
-
while ((match =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
while ((match = TOKEN_RE.exec(text)) !== null) {
|
|
82
|
+
if (OPEN_TOKENS.has(match[0])) {
|
|
83
|
+
if (depth > 0) {
|
|
84
|
+
reportNested(text, match, false);
|
|
85
|
+
}
|
|
86
|
+
depth++;
|
|
87
|
+
} else {
|
|
88
|
+
depth = Math.max(0, depth - 1);
|
|
69
89
|
}
|
|
70
|
-
|
|
71
|
-
const closeToken = delimiter === '%%[' ? ']%%' : '=%%';
|
|
72
|
-
|
|
73
|
-
// Find the matching close token for the fix (first occurrence after this open)
|
|
74
|
-
let closeIndex = text.indexOf(closeToken, end);
|
|
75
|
-
// Don't use the outer ]%% as the fix target for an inner %%[
|
|
76
|
-
if (!isScriptBlock && closeIndex === outerCloseStart) {
|
|
77
|
-
closeIndex = -1;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
context.report({
|
|
81
|
-
loc: {
|
|
82
|
-
start: sourceCode.getLocFromIndex(start),
|
|
83
|
-
end: sourceCode.getLocFromIndex(end),
|
|
84
|
-
},
|
|
85
|
-
messageId: isScriptBlock ? 'nestedDelimiterInScript' : 'nestedDelimiter',
|
|
86
|
-
data: { delimiter },
|
|
87
|
-
fix:
|
|
88
|
-
closeIndex === -1
|
|
89
|
-
? (fixer) => fixer.removeRange([start, end])
|
|
90
|
-
: (fixer) => [
|
|
91
|
-
fixer.removeRange([
|
|
92
|
-
closeIndex,
|
|
93
|
-
closeIndex + closeToken.length,
|
|
94
|
-
]),
|
|
95
|
-
fixer.removeRange([start, end]),
|
|
96
|
-
],
|
|
97
|
-
});
|
|
98
90
|
}
|
|
99
91
|
},
|
|
100
92
|
};
|
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
const SMART_QUOTE_RE = /[\
|
|
1
|
+
const SMART_QUOTE_RE = /[\u{2018}\u{2019}\u{201C}\u{201D}\u{201A}\u{201E}\u{2039}\u{203A}]/gu;
|
|
2
2
|
|
|
3
3
|
const SMART_QUOTES = {
|
|
4
|
-
'\
|
|
5
|
-
'\
|
|
6
|
-
'\
|
|
7
|
-
'\
|
|
8
|
-
'\
|
|
9
|
-
'\
|
|
10
|
-
'\
|
|
11
|
-
'\
|
|
4
|
+
'\u{2018}': 'left single curly quote \u{2018}',
|
|
5
|
+
'\u{2019}': 'right single curly quote \u{2019}',
|
|
6
|
+
'\u{201C}': 'left double curly quote \u{201C}',
|
|
7
|
+
'\u{201D}': 'right double curly quote \u{201D}',
|
|
8
|
+
'\u{201A}': 'single low-9 quote \u{201A}',
|
|
9
|
+
'\u{201E}': 'double low-9 quote \u{201E}',
|
|
10
|
+
'\u{2039}': 'single left angle quote \u{2039}',
|
|
11
|
+
'\u{203A}': 'single right angle quote \u{203A}',
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
// Maps each smart-quote char to its nearest ASCII equivalent.
|
|
15
15
|
// Single-flavour smart quotes -> ' ; double-flavour -> "
|
|
16
16
|
const SMART_TO_ASCII = {
|
|
17
|
-
'\
|
|
18
|
-
'\
|
|
19
|
-
'\
|
|
20
|
-
'\
|
|
21
|
-
'\
|
|
22
|
-
'\
|
|
23
|
-
'\
|
|
24
|
-
'\
|
|
17
|
+
'\u{2018}': "'",
|
|
18
|
+
'\u{2019}': "'",
|
|
19
|
+
'\u{201A}': "'",
|
|
20
|
+
'\u{2039}': "'",
|
|
21
|
+
'\u{203A}': "'",
|
|
22
|
+
'\u{201C}': '"',
|
|
23
|
+
'\u{201D}': '"',
|
|
24
|
+
'\u{201E}': '"',
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
export default {
|
|
@@ -30,7 +30,7 @@ export default {
|
|
|
30
30
|
fixable: 'code',
|
|
31
31
|
docs: {
|
|
32
32
|
description:
|
|
33
|
-
'Disallow smart/curly quotes in
|
|
33
|
+
'Disallow smart/curly quotes in AMPscript (AMPscript requires ASCII quotes)',
|
|
34
34
|
recommended: true,
|
|
35
35
|
},
|
|
36
36
|
messages: {
|
|
@@ -41,36 +41,30 @@ export default {
|
|
|
41
41
|
},
|
|
42
42
|
|
|
43
43
|
create(context) {
|
|
44
|
+
const sourceCode = context.sourceCode;
|
|
45
|
+
|
|
44
46
|
return {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
return fixer.replaceText(node, q + fixed + q);
|
|
69
|
-
},
|
|
70
|
-
});
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
47
|
+
// The AMPscript parser tokenizes string literals as RAW + IDENTIFIER
|
|
48
|
+
// tokens rather than a StringLiteral node, so a node listener never
|
|
49
|
+
// matches. Scan the virtual .amp region's raw text instead — the whole
|
|
50
|
+
// extracted file is AMPscript, mirroring the LSP smart-quote check.
|
|
51
|
+
Program() {
|
|
52
|
+
const text = sourceCode.getText();
|
|
53
|
+
SMART_QUOTE_RE.lastIndex = 0;
|
|
54
|
+
let match;
|
|
55
|
+
while ((match = SMART_QUOTE_RE.exec(text)) !== null) {
|
|
56
|
+
const char = match[0];
|
|
57
|
+
const start = match.index;
|
|
58
|
+
const end = start + char.length;
|
|
59
|
+
context.report({
|
|
60
|
+
loc: {
|
|
61
|
+
start: sourceCode.getLocFromIndex(start),
|
|
62
|
+
end: sourceCode.getLocFromIndex(end),
|
|
63
|
+
},
|
|
64
|
+
messageId: 'smartQuote',
|
|
65
|
+
data: { kind: SMART_QUOTES[char] },
|
|
66
|
+
fix: (fixer) => fixer.replaceTextRange([start, end], SMART_TO_ASCII[char]),
|
|
67
|
+
});
|
|
74
68
|
}
|
|
75
69
|
},
|
|
76
70
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { functionNames
|
|
1
|
+
import { functionNames } from 'ampscript-data';
|
|
2
2
|
|
|
3
3
|
export default {
|
|
4
4
|
meta: {
|
|
@@ -11,28 +11,11 @@ export default {
|
|
|
11
11
|
messages: {
|
|
12
12
|
unknownFunction:
|
|
13
13
|
"'{{name}}' is not a recognized AMPscript function. AMPscript does not support custom functions.",
|
|
14
|
-
notSupportedInMcn: "'{{name}}' is not supported in Marketing Cloud Next.",
|
|
15
14
|
},
|
|
16
|
-
schema: [
|
|
17
|
-
{
|
|
18
|
-
type: 'object',
|
|
19
|
-
properties: {
|
|
20
|
-
target: {
|
|
21
|
-
type: 'string',
|
|
22
|
-
enum: ['engagement', 'next'],
|
|
23
|
-
description:
|
|
24
|
-
"Target platform. Set to 'next' to additionally flag functions not available in Marketing Cloud Next.",
|
|
25
|
-
},
|
|
26
|
-
},
|
|
27
|
-
additionalProperties: false,
|
|
28
|
-
},
|
|
29
|
-
],
|
|
15
|
+
schema: [],
|
|
30
16
|
},
|
|
31
17
|
|
|
32
18
|
create(context) {
|
|
33
|
-
const options = context.options[0] ?? {};
|
|
34
|
-
const targetNext = options.target === 'next';
|
|
35
|
-
|
|
36
19
|
return {
|
|
37
20
|
FunctionCall(node) {
|
|
38
21
|
const lower = node.name.toLowerCase();
|
|
@@ -43,15 +26,6 @@ export default {
|
|
|
43
26
|
messageId: 'unknownFunction',
|
|
44
27
|
data: { name: node.name },
|
|
45
28
|
});
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (targetNext && !isMcnSupported(lower)) {
|
|
50
|
-
context.report({
|
|
51
|
-
node,
|
|
52
|
-
messageId: 'notSupportedInMcn',
|
|
53
|
-
data: { name: node.name },
|
|
54
|
-
});
|
|
55
29
|
}
|
|
56
30
|
},
|
|
57
31
|
};
|
|
@@ -39,10 +39,7 @@ function isLikelyPersonalization(node) {
|
|
|
39
39
|
if (functionNames.has(lower)) {
|
|
40
40
|
return false;
|
|
41
41
|
}
|
|
42
|
-
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
45
|
-
return true;
|
|
42
|
+
return !AMPSCRIPT_KEYWORDS.has(lower);
|
|
46
43
|
}
|
|
47
44
|
|
|
48
45
|
export default {
|
|
@@ -63,19 +60,19 @@ export default {
|
|
|
63
60
|
},
|
|
64
61
|
|
|
65
62
|
create(context) {
|
|
66
|
-
function reportWithSuggestion(
|
|
63
|
+
function reportWithSuggestion(identifierNode) {
|
|
67
64
|
context.report({
|
|
68
|
-
node:
|
|
65
|
+
node: identifierNode,
|
|
69
66
|
messageId: 'preferAttributeValue',
|
|
70
|
-
data: { name:
|
|
67
|
+
data: { name: identifierNode.value },
|
|
71
68
|
suggest: [
|
|
72
69
|
{
|
|
73
70
|
messageId: 'wrapWithAttributeValue',
|
|
74
|
-
data: { name:
|
|
71
|
+
data: { name: identifierNode.value },
|
|
75
72
|
fix(fixer) {
|
|
76
73
|
return fixer.replaceText(
|
|
77
|
-
|
|
78
|
-
`AttributeValue("${
|
|
74
|
+
identifierNode,
|
|
75
|
+
`AttributeValue("${identifierNode.value}")`,
|
|
79
76
|
);
|
|
80
77
|
},
|
|
81
78
|
},
|
|
@@ -35,12 +35,7 @@ function extractRowCountVariables(node, into) {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
if (node.type === 'BinaryExpression') {
|
|
39
|
-
extractRowCountVariables(node.left, into);
|
|
40
|
-
extractRowCountVariables(node.right, into);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (node.type === 'LogicalExpression') {
|
|
38
|
+
if (node.type === 'BinaryExpression' || node.type === 'LogicalExpression') {
|
|
44
39
|
extractRowCountVariables(node.left, into);
|
|
45
40
|
extractRowCountVariables(node.right, into);
|
|
46
41
|
}
|
|
@@ -86,19 +81,21 @@ export default {
|
|
|
86
81
|
},
|
|
87
82
|
|
|
88
83
|
AmpForStatement(node) {
|
|
89
|
-
if (
|
|
84
|
+
if (!(
|
|
90
85
|
node.endExpr &&
|
|
91
86
|
isRowCountCall(node.endExpr) &&
|
|
92
87
|
node.endExpr.arguments &&
|
|
93
88
|
node.endExpr.arguments.length > 0
|
|
94
|
-
) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
89
|
+
)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const argument = node.endExpr.arguments[0];
|
|
94
|
+
if (argument.type === 'Variable') {
|
|
95
|
+
forLoops.push({
|
|
96
|
+
varName: argument.value.toLowerCase(),
|
|
97
|
+
node,
|
|
98
|
+
});
|
|
102
99
|
}
|
|
103
100
|
},
|
|
104
101
|
|
|
@@ -25,7 +25,7 @@ export default {
|
|
|
25
25
|
|
|
26
26
|
create(context) {
|
|
27
27
|
const declared = new Set();
|
|
28
|
-
const
|
|
28
|
+
const fixedVariables = new Set();
|
|
29
29
|
|
|
30
30
|
return {
|
|
31
31
|
VarDeclaration(node) {
|
|
@@ -44,9 +44,9 @@ export default {
|
|
|
44
44
|
}
|
|
45
45
|
const lower = name.toLowerCase();
|
|
46
46
|
if (!declared.has(lower)) {
|
|
47
|
-
const isFirstFix = !
|
|
47
|
+
const isFirstFix = !fixedVariables.has(lower);
|
|
48
48
|
if (isFirstFix) {
|
|
49
|
-
|
|
49
|
+
fixedVariables.add(lower);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
context.report({
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the Handlebars (Marketing Cloud Next) ESLint rules.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the logic used by the sfmc-language-lsp MCN Handlebars validator so
|
|
5
|
+
* that ESLint diagnostics and editor diagnostics stay consistent.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Matches a `{!$namespace.Field}` built-in data binding token. */
|
|
9
|
+
export const BINDING_PATTERN = /\{!\$([A-Za-z0-9_.]+)\}/g;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns the bare helper name when a node's path is a simple, single-part
|
|
13
|
+
* identifier (e.g. `add`), or null when it is a property access (`foo.bar`),
|
|
14
|
+
* a data variable (`@index`), a literal, or `this`.
|
|
15
|
+
*
|
|
16
|
+
* @param {object | undefined} path - The node's path expression.
|
|
17
|
+
* @returns {string | null} The simple helper name, or null.
|
|
18
|
+
*/
|
|
19
|
+
export function simpleHelperName(path) {
|
|
20
|
+
if (!path || path.type !== 'PathExpression') {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (path.data) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
if ((path.depth ?? 0) > 0) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const parts = path.parts ?? [];
|
|
30
|
+
if (parts.length !== 1) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const name = parts[0];
|
|
34
|
+
if (!name || name === 'this') {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return name;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Returns true when a mustache/subexpression node is a helper invocation rather
|
|
42
|
+
* than a bare data binding. A `{{foo}}` mustache with no params/hash is a data
|
|
43
|
+
* binding; a subexpression `(foo ...)` is always an invocation.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} node - The AST node.
|
|
46
|
+
* @returns {boolean} True when the node invokes a helper.
|
|
47
|
+
*/
|
|
48
|
+
export function isInvocation(node) {
|
|
49
|
+
const hasArguments = (node.params?.length ?? 0) > 0 || (node.hash?.pairs?.length ?? 0) > 0;
|
|
50
|
+
return node.type === 'SubExpression' || hasArguments;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compute the Levenshtein edit distance between two strings.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} a - First string.
|
|
57
|
+
* @param {string} b - Second string.
|
|
58
|
+
* @returns {number} The minimum number of single-character edits.
|
|
59
|
+
*/
|
|
60
|
+
export function levenshtein(a, b) {
|
|
61
|
+
if (a === b) {
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
if (a.length === 0) {
|
|
65
|
+
return b.length;
|
|
66
|
+
}
|
|
67
|
+
if (b.length === 0) {
|
|
68
|
+
return a.length;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
72
|
+
const current = Array.from({ length: b.length + 1 }, () => 0);
|
|
73
|
+
|
|
74
|
+
for (let index = 1; index <= a.length; index++) {
|
|
75
|
+
current[0] = index;
|
|
76
|
+
for (let index_ = 1; index_ <= b.length; index_++) {
|
|
77
|
+
const cost = a[index - 1] === b[index_ - 1] ? 0 : 1;
|
|
78
|
+
current[index_] = Math.min(
|
|
79
|
+
current[index_ - 1] + 1,
|
|
80
|
+
previous[index_] + 1,
|
|
81
|
+
previous[index_ - 1] + cost,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
previous = current.slice();
|
|
85
|
+
}
|
|
86
|
+
return previous[b.length];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Return the candidate closest to `word` (case-insensitive) when it is within a
|
|
91
|
+
* conservative typo threshold, or null when nothing is close enough.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} word - The unknown word typed by the user.
|
|
94
|
+
* @param {Iterable<string>} candidates - Known valid names to match against.
|
|
95
|
+
* @returns {string | null} The closest candidate (original casing), or null.
|
|
96
|
+
*/
|
|
97
|
+
export function closestMatch(word, candidates) {
|
|
98
|
+
const lowerWord = word.toLowerCase();
|
|
99
|
+
const maxDistance = lowerWord.length <= 4 ? 1 : 2;
|
|
100
|
+
|
|
101
|
+
let best = null;
|
|
102
|
+
let bestDistance = Infinity;
|
|
103
|
+
|
|
104
|
+
for (const candidate of candidates) {
|
|
105
|
+
const distance = levenshtein(lowerWord, candidate.toLowerCase());
|
|
106
|
+
if (distance < bestDistance) {
|
|
107
|
+
bestDistance = distance;
|
|
108
|
+
best = candidate;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (best !== null && bestDistance > 0 && bestDistance <= maxDistance) {
|
|
113
|
+
return best;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|