eslint-plugin-sfmc 2.6.1 → 3.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 +72 -63
- package/docs/rules/hbs/helper-arity.md +55 -0
- package/docs/rules/hbs/helper-too-new-for-target.md +62 -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/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 +90 -11
- 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-nested-ampscript-delimiter.js +1 -1
- package/src/rules/amp/no-smart-quotes.js +18 -18
- package/src/rules/amp/no-unknown-function.js +16 -3
- 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/helper-too-new-for-target.js +94 -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-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 +16 -12
- 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 +19 -15
- /package/src/rules/amp/{no-var-redeclaration.js → no-variable-redeclaration.js} +0 -0
|
@@ -23,21 +23,21 @@ export default {
|
|
|
23
23
|
const counterStack = [];
|
|
24
24
|
|
|
25
25
|
function checkBody(body) {
|
|
26
|
-
for (const
|
|
27
|
-
if (
|
|
26
|
+
for (const statement of body) {
|
|
27
|
+
if (statement.type === 'SetStatement' && statement.target) {
|
|
28
28
|
const current = counterStack.at(-1);
|
|
29
|
-
if (current &&
|
|
29
|
+
if (current && statement.target.value.toLowerCase() === current.toLowerCase()) {
|
|
30
30
|
context.report({
|
|
31
|
-
node:
|
|
31
|
+
node: statement.target,
|
|
32
32
|
messageId: 'counterAssign',
|
|
33
|
-
data: { name:
|
|
33
|
+
data: { name: statement.target.value },
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
-
if (
|
|
37
|
+
if (statement.type === 'VarDeclaration') {
|
|
38
38
|
const current = counterStack.at(-1);
|
|
39
39
|
if (current) {
|
|
40
|
-
for (const v of
|
|
40
|
+
for (const v of statement.variables) {
|
|
41
41
|
if (v.value.toLowerCase() === current.toLowerCase()) {
|
|
42
42
|
context.report({
|
|
43
43
|
node: v,
|
|
@@ -61,13 +61,13 @@ export default {
|
|
|
61
61
|
while ((match = DELIMITER_RE.exec(text)) !== null) {
|
|
62
62
|
const delimiter = match[0];
|
|
63
63
|
const start = match.index;
|
|
64
|
-
const end = start + delimiter.length;
|
|
65
64
|
|
|
66
65
|
// Skip the outermost %%[ opener (it is the block wrapper, not nested)
|
|
67
66
|
if (!isScriptBlock && start === outerOpenStart) {
|
|
68
67
|
continue;
|
|
69
68
|
}
|
|
70
69
|
|
|
70
|
+
const end = start + delimiter.length;
|
|
71
71
|
const closeToken = delimiter === '%%[' ? ']%%' : '=%%';
|
|
72
72
|
|
|
73
73
|
// Find the matching close token for the fix (first occurrence after this open)
|
|
@@ -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}]/u;
|
|
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 {
|
|
@@ -52,7 +52,7 @@ export default {
|
|
|
52
52
|
data: { kind },
|
|
53
53
|
fix(fixer) {
|
|
54
54
|
const fixed = node.value.replaceAll(
|
|
55
|
-
/[\
|
|
55
|
+
/[\u{2018}\u{2019}\u{201C}\u{201D}\u{201A}\u{201E}\u{2039}\u{203A}]/gu,
|
|
56
56
|
(c) => SMART_TO_ASCII[c],
|
|
57
57
|
);
|
|
58
58
|
const q = node.quote;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { functionNames, isMcnSupported } from 'ampscript-data';
|
|
1
|
+
import { functionNames, functionLookup, isMcnSupported } from 'ampscript-data';
|
|
2
2
|
|
|
3
3
|
export default {
|
|
4
4
|
meta: {
|
|
@@ -12,6 +12,8 @@ export default {
|
|
|
12
12
|
unknownFunction:
|
|
13
13
|
"'{{name}}' is not a recognized AMPscript function. AMPscript does not support custom functions.",
|
|
14
14
|
notSupportedInMcn: "'{{name}}' is not supported in Marketing Cloud Next.",
|
|
15
|
+
noHandlebarsEquivalent:
|
|
16
|
+
"'{{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.",
|
|
15
17
|
},
|
|
16
18
|
schema: [
|
|
17
19
|
{
|
|
@@ -31,7 +33,7 @@ export default {
|
|
|
31
33
|
|
|
32
34
|
create(context) {
|
|
33
35
|
const options = context.options[0] ?? {};
|
|
34
|
-
const
|
|
36
|
+
const isTargetNext = options.target === 'next';
|
|
35
37
|
|
|
36
38
|
return {
|
|
37
39
|
FunctionCall(node) {
|
|
@@ -46,12 +48,23 @@ export default {
|
|
|
46
48
|
return;
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
if (
|
|
51
|
+
if (isTargetNext && !isMcnSupported(lower)) {
|
|
50
52
|
context.report({
|
|
51
53
|
node,
|
|
52
54
|
messageId: 'notSupportedInMcn',
|
|
53
55
|
data: { name: node.name },
|
|
54
56
|
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Category C: the function works in MCN AMPscript but has no
|
|
61
|
+
// Handlebars counterpart, so it cannot be migrated to Handlebars.
|
|
62
|
+
if (isTargetNext && functionLookup.get(lower)?.mcnHandlebarsGap === true) {
|
|
63
|
+
context.report({
|
|
64
|
+
node,
|
|
65
|
+
messageId: 'noHandlebarsEquivalent',
|
|
66
|
+
data: { name: node.name },
|
|
67
|
+
});
|
|
55
68
|
}
|
|
56
69
|
},
|
|
57
70
|
};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { getHelper } from 'handlebars-data';
|
|
2
|
+
|
|
3
|
+
import { simpleHelperName } from './_shared.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Enforces correct positional-argument counts for known Marketing Cloud Next
|
|
7
|
+
* Handlebars helpers, derived from each helper's `params` definition.
|
|
8
|
+
*
|
|
9
|
+
* Only positional `params` are checked. Helpers with a variadic trailing param
|
|
10
|
+
* (e.g. `concat`, `and`, `set`) have no upper bound. Hash (named) arguments are
|
|
11
|
+
* not counted as positional arguments.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Computes the required and maximum positional argument counts for a helper.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} helper - The helper definition from handlebars-data.
|
|
18
|
+
* @returns {{required: number, max: number}} Required and max positional counts.
|
|
19
|
+
*/
|
|
20
|
+
function arityOf(helper) {
|
|
21
|
+
const parameters = helper.params ?? [];
|
|
22
|
+
const hasVariadic = parameters.some((p) => p.variadic);
|
|
23
|
+
const required = parameters.filter((p) => !p.optional && !p.variadic).length;
|
|
24
|
+
const max = hasVariadic ? Infinity : parameters.length;
|
|
25
|
+
return { required, max };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default {
|
|
29
|
+
meta: {
|
|
30
|
+
type: 'problem',
|
|
31
|
+
docs: {
|
|
32
|
+
description: 'Enforce correct argument counts for known Marketing Cloud Next helpers',
|
|
33
|
+
recommended: true,
|
|
34
|
+
},
|
|
35
|
+
messages: {
|
|
36
|
+
tooFewArgs:
|
|
37
|
+
"'{{name}}' requires at least {{min}} argument(s) but was called with {{actual}}.",
|
|
38
|
+
tooManyArgs:
|
|
39
|
+
"'{{name}}' accepts at most {{max}} argument(s) but was called with {{actual}}.",
|
|
40
|
+
},
|
|
41
|
+
schema: [],
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
create(context) {
|
|
45
|
+
/**
|
|
46
|
+
* Validates the positional argument count of a helper invocation.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} node - The AST node (mustache, subexpression, block).
|
|
49
|
+
* @returns {void}
|
|
50
|
+
*/
|
|
51
|
+
function check(node) {
|
|
52
|
+
const helperName = simpleHelperName(node.path);
|
|
53
|
+
if (!helperName) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const helper = getHelper(helperName);
|
|
57
|
+
if (!helper) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const actual = node.params?.length ?? 0;
|
|
62
|
+
|
|
63
|
+
// A bare `{{foo}}` mustache with no args is a data binding reference,
|
|
64
|
+
// not an invocation — leave arity validation to actual calls.
|
|
65
|
+
if (
|
|
66
|
+
actual === 0 &&
|
|
67
|
+
node.type !== 'HbsBlockStatement' &&
|
|
68
|
+
node.type !== 'SubExpression'
|
|
69
|
+
) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { required, max } = arityOf(helper);
|
|
74
|
+
if (actual < required) {
|
|
75
|
+
context.report({
|
|
76
|
+
node,
|
|
77
|
+
messageId: 'tooFewArgs',
|
|
78
|
+
data: { name: helper.name, min: String(required), actual: String(actual) },
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (actual > max) {
|
|
83
|
+
context.report({
|
|
84
|
+
node,
|
|
85
|
+
messageId: 'tooManyArgs',
|
|
86
|
+
data: { name: helper.name, max: String(max), actual: String(actual) },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
MustacheStatement: check,
|
|
93
|
+
SubExpression: check,
|
|
94
|
+
HbsBlockStatement: check,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { getHelper } from 'handlebars-data';
|
|
2
|
+
|
|
3
|
+
import { simpleHelperName, isInvocation } from './_shared.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Flags known Handlebars helpers whose introducing Marketing Cloud Next API
|
|
7
|
+
* version (`mcnSince`) is newer than the API version the template targets.
|
|
8
|
+
*
|
|
9
|
+
* For example, `dateAdd` first shipped in MCN API version 67 (Summer '26); using
|
|
10
|
+
* it while targeting API version 65 (Winter '26) is reported.
|
|
11
|
+
*
|
|
12
|
+
* The target API version is configured via the `apiVersion` option. When it is
|
|
13
|
+
* omitted, no helper is flagged (all helpers are considered available).
|
|
14
|
+
*/
|
|
15
|
+
export default {
|
|
16
|
+
meta: {
|
|
17
|
+
type: 'problem',
|
|
18
|
+
docs: {
|
|
19
|
+
description:
|
|
20
|
+
'Disallow Handlebars helpers newer than the targeted Marketing Cloud Next API version',
|
|
21
|
+
recommended: true,
|
|
22
|
+
},
|
|
23
|
+
messages: {
|
|
24
|
+
helperTooNew:
|
|
25
|
+
"'{{name}}' was introduced in Marketing Cloud Next API version {{since}}, which is newer than the targeted version {{target}}.",
|
|
26
|
+
},
|
|
27
|
+
schema: [
|
|
28
|
+
{
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
apiVersion: {
|
|
32
|
+
type: 'number',
|
|
33
|
+
description:
|
|
34
|
+
'The targeted Marketing Cloud Next API version (e.g. 65 = Winter \u{2019}26, 67 = Summer \u{2019}26). Helpers newer than this are flagged.',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
create(context) {
|
|
43
|
+
const options = context.options[0] ?? {};
|
|
44
|
+
const apiVersion = typeof options.apiVersion === 'number' ? options.apiVersion : null;
|
|
45
|
+
if (apiVersion === null) {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reports a helper when its introducing version exceeds the target.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} node - The AST node.
|
|
53
|
+
* @param {string | null} helperName - The simple helper name, when any.
|
|
54
|
+
* @returns {void}
|
|
55
|
+
*/
|
|
56
|
+
function check(node, helperName) {
|
|
57
|
+
if (!helperName) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const helper = getHelper(helperName);
|
|
61
|
+
if (helper && helper.mcnSince > apiVersion) {
|
|
62
|
+
context.report({
|
|
63
|
+
node,
|
|
64
|
+
messageId: 'helperTooNew',
|
|
65
|
+
data: {
|
|
66
|
+
name: helper.name,
|
|
67
|
+
since: String(helper.mcnSince),
|
|
68
|
+
target: String(apiVersion),
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Checks an inline mustache or subexpression invocation.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} node - The AST node.
|
|
78
|
+
* @returns {void}
|
|
79
|
+
*/
|
|
80
|
+
function checkInline(node) {
|
|
81
|
+
if (isInvocation(node)) {
|
|
82
|
+
check(node, simpleHelperName(node.path));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
MustacheStatement: checkInline,
|
|
88
|
+
SubExpression: checkInline,
|
|
89
|
+
HbsBlockStatement(node) {
|
|
90
|
+
check(node, simpleHelperName(node.path));
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { isBuiltinBinding, BUILTIN_BINDINGS } from 'handlebars-data';
|
|
2
|
+
|
|
3
|
+
import { BINDING_PATTERN, closestMatch } from './_shared.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Flags `{!$namespace.Field}` built-in data bindings that are not recognized
|
|
7
|
+
* Marketing Cloud Next data bindings.
|
|
8
|
+
*
|
|
9
|
+
* The Handlebars parser treats `{!$...}` tokens as literal content, so this rule
|
|
10
|
+
* scans the raw source text with a regex pass (mirroring the
|
|
11
|
+
* `handlebars/unknown-binding` diagnostic emitted by sfmc-language-lsp) rather
|
|
12
|
+
* than walking the AST.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Proper-cased built-in binding names, used for "did you mean" suggestions. */
|
|
16
|
+
const BINDING_NAME_LIST = BUILTIN_BINDINGS.map((b) => b.name);
|
|
17
|
+
/** Map from lowercase binding name to its `{!$...}` token, for suggestion text. */
|
|
18
|
+
const BINDING_TOKEN_BY_NAME = new Map(BUILTIN_BINDINGS.map((b) => [b.name.toLowerCase(), b.token]));
|
|
19
|
+
|
|
20
|
+
export default {
|
|
21
|
+
meta: {
|
|
22
|
+
type: 'problem',
|
|
23
|
+
docs: {
|
|
24
|
+
description:
|
|
25
|
+
'Disallow unknown {!$...} built-in data bindings in Marketing Cloud Next Handlebars',
|
|
26
|
+
recommended: true,
|
|
27
|
+
},
|
|
28
|
+
messages: {
|
|
29
|
+
unknownBinding:
|
|
30
|
+
"Unknown built-in binding '{{token}}'. It is not a recognized Marketing Cloud Next data binding.",
|
|
31
|
+
unknownBindingSuggest:
|
|
32
|
+
"Unknown built-in binding '{{token}}'. It is not a recognized Marketing Cloud Next data binding. Did you mean '{{suggestion}}'?",
|
|
33
|
+
},
|
|
34
|
+
schema: [],
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
create(context) {
|
|
38
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
'Program:exit'() {
|
|
42
|
+
const text = sourceCode.getText();
|
|
43
|
+
BINDING_PATTERN.lastIndex = 0;
|
|
44
|
+
let match;
|
|
45
|
+
while ((match = BINDING_PATTERN.exec(text)) !== null) {
|
|
46
|
+
const bindingName = match[1];
|
|
47
|
+
if (isBuiltinBinding(bindingName)) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const token = match[0];
|
|
51
|
+
const start = match.index;
|
|
52
|
+
const end = start + token.length;
|
|
53
|
+
const loc = {
|
|
54
|
+
start: sourceCode.getLocFromIndex(start),
|
|
55
|
+
end: sourceCode.getLocFromIndex(end),
|
|
56
|
+
};
|
|
57
|
+
const suggestionName = closestMatch(bindingName, BINDING_NAME_LIST);
|
|
58
|
+
const suggestion = suggestionName
|
|
59
|
+
? BINDING_TOKEN_BY_NAME.get(suggestionName.toLowerCase())
|
|
60
|
+
: undefined;
|
|
61
|
+
if (suggestion) {
|
|
62
|
+
context.report({
|
|
63
|
+
loc,
|
|
64
|
+
messageId: 'unknownBindingSuggest',
|
|
65
|
+
data: { token, suggestion },
|
|
66
|
+
});
|
|
67
|
+
} else {
|
|
68
|
+
context.report({ loc, messageId: 'unknownBinding', data: { token } });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|