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,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,164 @@
|
|
|
1
|
+
import { getHelper, bindingLookup } from 'handlebars-data';
|
|
2
|
+
|
|
3
|
+
import { simpleHelperName, isInvocation, BINDING_PATTERN } from './_shared.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Flags Handlebars helpers and `{!$...}` data bindings that are not available in
|
|
7
|
+
* the targeted Marketing Cloud Next API version.
|
|
8
|
+
*
|
|
9
|
+
* Each helper/binding carries an `mcnSince` value = the MCN API version it first
|
|
10
|
+
* became available in, or `null`/unset when it was never supported in MCN.
|
|
11
|
+
*
|
|
12
|
+
* `apiVersion` semantics (shared with the AMPscript and SSJS MCN rules):
|
|
13
|
+
* - Not set / null: flag every helper/binding whose `mcnSince` is null/unset.
|
|
14
|
+
* Any item supported at some point in MCN (numeric `mcnSince`) passes.
|
|
15
|
+
* - Set to N: flag everything the null case flags, PLUS everything whose
|
|
16
|
+
* `mcnSince` is greater than N (too new for the target). Pass iff
|
|
17
|
+
* `mcnSince != null && mcnSince <= N`.
|
|
18
|
+
*
|
|
19
|
+
* The MCN Handlebars catalog currently has no null-`mcnSince` items, so with no
|
|
20
|
+
* `apiVersion` set nothing is flagged; e.g. `apiVersion: 65` flags the helpers
|
|
21
|
+
* introduced in 67, and `apiVersion: 40` flags every helper and binding.
|
|
22
|
+
*/
|
|
23
|
+
export default {
|
|
24
|
+
meta: {
|
|
25
|
+
type: 'problem',
|
|
26
|
+
docs: {
|
|
27
|
+
description:
|
|
28
|
+
'Disallow Handlebars helpers and bindings not available in the targeted Marketing Cloud Next API version',
|
|
29
|
+
recommended: true,
|
|
30
|
+
},
|
|
31
|
+
messages: {
|
|
32
|
+
helperNotSupported: "'{{name}}' is not supported in Marketing Cloud Next Handlebars.",
|
|
33
|
+
helperTooNew:
|
|
34
|
+
"'{{name}}' was introduced in Marketing Cloud Next API version {{since}}, which is newer than the targeted version {{target}}.",
|
|
35
|
+
bindingNotSupported: "'{{token}}' is not supported in Marketing Cloud Next Handlebars.",
|
|
36
|
+
bindingTooNew:
|
|
37
|
+
"'{{token}}' was introduced in Marketing Cloud Next API version {{since}}, which is newer than the targeted version {{target}}.",
|
|
38
|
+
},
|
|
39
|
+
schema: [
|
|
40
|
+
{
|
|
41
|
+
type: 'object',
|
|
42
|
+
properties: {
|
|
43
|
+
apiVersion: {
|
|
44
|
+
type: 'number',
|
|
45
|
+
description:
|
|
46
|
+
'The targeted Marketing Cloud Next API version (e.g. 65 = Winter \u{2019}26, 67 = Summer \u{2019}26). Helpers and bindings newer than this are flagged.',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
create(context) {
|
|
55
|
+
const options = context.options[0] ?? {};
|
|
56
|
+
const apiVersion = typeof options.apiVersion === 'number' ? options.apiVersion : null;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Returns true when an item's `mcnSince` fails the target: it is null
|
|
60
|
+
* (never supported) or newer than the targeted API version.
|
|
61
|
+
*
|
|
62
|
+
* @param {number | null | undefined} since - The item's `mcnSince`.
|
|
63
|
+
* @returns {boolean} True when the item must be flagged.
|
|
64
|
+
*/
|
|
65
|
+
function isUnsupported(since) {
|
|
66
|
+
if (since === null || since === undefined) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return apiVersion !== null && since > apiVersion;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reports a helper when it is unavailable for the target.
|
|
74
|
+
*
|
|
75
|
+
* @param {object} node - The AST node.
|
|
76
|
+
* @param {string | null} helperName - The simple helper name, when any.
|
|
77
|
+
* @returns {void}
|
|
78
|
+
*/
|
|
79
|
+
function checkHelper(node, helperName) {
|
|
80
|
+
if (!helperName) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const helper = getHelper(helperName);
|
|
84
|
+
if (!helper) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const since = helper.mcnSince ?? null;
|
|
88
|
+
if (!isUnsupported(since)) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (since === null) {
|
|
92
|
+
context.report({
|
|
93
|
+
node,
|
|
94
|
+
messageId: 'helperNotSupported',
|
|
95
|
+
data: { name: helper.name },
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
context.report({
|
|
99
|
+
node,
|
|
100
|
+
messageId: 'helperTooNew',
|
|
101
|
+
data: {
|
|
102
|
+
name: helper.name,
|
|
103
|
+
since: String(since),
|
|
104
|
+
target: String(apiVersion),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Checks an inline mustache or subexpression helper invocation.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} node - The AST node.
|
|
114
|
+
* @returns {void}
|
|
115
|
+
*/
|
|
116
|
+
function checkInline(node) {
|
|
117
|
+
if (isInvocation(node)) {
|
|
118
|
+
checkHelper(node, simpleHelperName(node.path));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
MustacheStatement: checkInline,
|
|
124
|
+
SubExpression: checkInline,
|
|
125
|
+
HbsBlockStatement(node) {
|
|
126
|
+
checkHelper(node, simpleHelperName(node.path));
|
|
127
|
+
},
|
|
128
|
+
'Program:exit'() {
|
|
129
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
130
|
+
const text = sourceCode.getText();
|
|
131
|
+
BINDING_PATTERN.lastIndex = 0;
|
|
132
|
+
let match;
|
|
133
|
+
while ((match = BINDING_PATTERN.exec(text)) !== null) {
|
|
134
|
+
const bindingName = match[1];
|
|
135
|
+
const binding = bindingLookup.get(bindingName.toLowerCase());
|
|
136
|
+
// Unknown bindings are handled by hbs-no-unknown-binding.
|
|
137
|
+
if (!binding) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const since = binding.mcnSince ?? null;
|
|
141
|
+
if (!isUnsupported(since)) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const token = match[0];
|
|
145
|
+
const start = match.index;
|
|
146
|
+
const end = start + token.length;
|
|
147
|
+
const loc = {
|
|
148
|
+
start: sourceCode.getLocFromIndex(start),
|
|
149
|
+
end: sourceCode.getLocFromIndex(end),
|
|
150
|
+
};
|
|
151
|
+
if (since === null) {
|
|
152
|
+
context.report({ loc, messageId: 'bindingNotSupported', data: { token } });
|
|
153
|
+
} else {
|
|
154
|
+
context.report({
|
|
155
|
+
loc,
|
|
156
|
+
messageId: 'bindingTooNew',
|
|
157
|
+
data: { token, since: String(since), target: String(apiVersion) },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { isHelper, helperNames } from 'handlebars-data';
|
|
2
|
+
|
|
3
|
+
import { simpleHelperName, isInvocation, closestMatch } from './_shared.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Flags Handlebars helper invocations whose name is not part of the Marketing
|
|
7
|
+
* Cloud Next catalog. The MCN engine is locked down and cannot register custom
|
|
8
|
+
* helpers, so any unknown helper is reported.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors the `handlebars/unknown-helper` diagnostic emitted by
|
|
11
|
+
* sfmc-language-lsp. A bare `{{foo}}` mustache with no arguments is treated as a
|
|
12
|
+
* data binding (not a helper) and is not flagged here.
|
|
13
|
+
*/
|
|
14
|
+
export default {
|
|
15
|
+
meta: {
|
|
16
|
+
type: 'problem',
|
|
17
|
+
docs: {
|
|
18
|
+
description:
|
|
19
|
+
'Disallow Handlebars helper invocations that are not part of the Marketing Cloud Next catalog',
|
|
20
|
+
recommended: true,
|
|
21
|
+
},
|
|
22
|
+
messages: {
|
|
23
|
+
unknownHelper:
|
|
24
|
+
"Unknown Handlebars {{kind}} '{{name}}'. It is not part of the Marketing Cloud Next catalog, and the MCN engine cannot register custom helpers.",
|
|
25
|
+
unknownHelperSuggest:
|
|
26
|
+
"Unknown Handlebars {{kind}} '{{name}}'. It is not part of the Marketing Cloud Next catalog, and the MCN engine cannot register custom helpers. Did you mean '{{suggestion}}'?",
|
|
27
|
+
},
|
|
28
|
+
schema: [],
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
create(context) {
|
|
32
|
+
/**
|
|
33
|
+
* Reports an unknown helper, attaching a "did you mean" suggestion when
|
|
34
|
+
* a close catalog match exists.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} node - The offending AST node.
|
|
37
|
+
* @param {string} name - The unknown helper name.
|
|
38
|
+
* @param {boolean} isBlock - True when the node is a block helper.
|
|
39
|
+
* @returns {void}
|
|
40
|
+
*/
|
|
41
|
+
function reportUnknown(node, name, isBlock) {
|
|
42
|
+
const kind = isBlock ? 'block helper' : 'helper';
|
|
43
|
+
const suggestion = closestMatch(name, helperNames);
|
|
44
|
+
if (suggestion) {
|
|
45
|
+
context.report({
|
|
46
|
+
node,
|
|
47
|
+
messageId: 'unknownHelperSuggest',
|
|
48
|
+
data: { kind, name, suggestion },
|
|
49
|
+
});
|
|
50
|
+
} else {
|
|
51
|
+
context.report({ node, messageId: 'unknownHelper', data: { kind, name } });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Checks an inline mustache or subexpression invocation.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} node - The AST node.
|
|
59
|
+
* @returns {void}
|
|
60
|
+
*/
|
|
61
|
+
function checkInline(node) {
|
|
62
|
+
const helperName = simpleHelperName(node.path);
|
|
63
|
+
if (helperName && isInvocation(node) && !isHelper(helperName)) {
|
|
64
|
+
reportUnknown(node, helperName, false);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
MustacheStatement: checkInline,
|
|
70
|
+
SubExpression: checkInline,
|
|
71
|
+
HbsBlockStatement(node) {
|
|
72
|
+
const helperName = simpleHelperName(node.path);
|
|
73
|
+
if (helperName && !isHelper(helperName)) {
|
|
74
|
+
reportUnknown(node, helperName, true);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { unsupportedByNodeType } from 'handlebars-data';
|
|
2
|
+
|
|
3
|
+
import { simpleHelperName } from './_shared.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Flags Handlebars constructs that the locked-down Marketing Cloud Next engine
|
|
7
|
+
* cannot support: partials, partial blocks, decorators, inline partials, and the
|
|
8
|
+
* handlebars.js-only `{{log}}` debugging helper.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors the `handlebars/unsupported-construct` diagnostic emitted by
|
|
11
|
+
* sfmc-language-lsp.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** AST node types that may carry an unsupported construct. */
|
|
15
|
+
const NODE_TYPES = unsupportedByNodeType.keys().toArray();
|
|
16
|
+
|
|
17
|
+
// The handlebars-data messages contain literal `{{> ...}}`, `{{log}}`, etc. as
|
|
18
|
+
// examples. ESLint treats `{{...}}` in a message template as an interpolation
|
|
19
|
+
// placeholder, so the literal text is passed through a single `{{text}}`
|
|
20
|
+
// placeholder instead of being baked into the template.
|
|
21
|
+
/** messageId per construct id → passthrough template, declared statically. */
|
|
22
|
+
const MESSAGES = Object.fromEntries(
|
|
23
|
+
unsupportedByNodeType
|
|
24
|
+
.values()
|
|
25
|
+
.toArray()
|
|
26
|
+
.flat()
|
|
27
|
+
.map((entry) => [entry.id, '{{text}}']),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export default {
|
|
31
|
+
meta: {
|
|
32
|
+
type: 'problem',
|
|
33
|
+
docs: {
|
|
34
|
+
description:
|
|
35
|
+
'Disallow Handlebars constructs unsupported by the Marketing Cloud Next engine (partials, decorators, log)',
|
|
36
|
+
recommended: true,
|
|
37
|
+
},
|
|
38
|
+
messages: MESSAGES,
|
|
39
|
+
schema: [],
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
create(context) {
|
|
43
|
+
/**
|
|
44
|
+
* Reports the first unsupported-construct entry matching a node.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} node - The Handlebars AST node.
|
|
47
|
+
* @returns {void}
|
|
48
|
+
*/
|
|
49
|
+
function check(node) {
|
|
50
|
+
const candidates = unsupportedByNodeType.get(node.type);
|
|
51
|
+
if (!candidates) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const helperName = simpleHelperName(node.path);
|
|
55
|
+
for (const entry of candidates) {
|
|
56
|
+
if (entry.helperName !== null && entry.helperName !== helperName) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
context.report({ node, messageId: entry.id, data: { text: entry.message } });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const visitors = {};
|
|
65
|
+
for (const nodeType of NODE_TYPES) {
|
|
66
|
+
visitors[nodeType] = check;
|
|
67
|
+
}
|
|
68
|
+
return visitors;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -34,23 +34,23 @@ export default {
|
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
let
|
|
37
|
+
let lengthExpression = null;
|
|
38
38
|
if (containsMemberLength(test.right)) {
|
|
39
|
-
|
|
39
|
+
lengthExpression = test.right;
|
|
40
40
|
} else if (containsMemberLength(test.left)) {
|
|
41
|
-
|
|
41
|
+
lengthExpression = test.left;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
if (!
|
|
44
|
+
if (!lengthExpression) {
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
const fix = buildCacheFix(node,
|
|
48
|
+
const fix = buildCacheFix(node, lengthExpression, context);
|
|
49
49
|
|
|
50
50
|
context.report({
|
|
51
51
|
node: test,
|
|
52
52
|
messageId: 'cacheLength',
|
|
53
|
-
...(fix
|
|
53
|
+
...(fix && { fix }),
|
|
54
54
|
});
|
|
55
55
|
},
|
|
56
56
|
};
|
|
@@ -61,17 +61,14 @@ function containsMemberLength(node) {
|
|
|
61
61
|
if (!node) {
|
|
62
62
|
return false;
|
|
63
63
|
}
|
|
64
|
-
|
|
64
|
+
return (
|
|
65
65
|
node.type === 'MemberExpression' &&
|
|
66
66
|
node.property.type === 'Identifier' &&
|
|
67
67
|
node.property.name === 'length'
|
|
68
|
-
)
|
|
69
|
-
return true;
|
|
70
|
-
}
|
|
71
|
-
return false;
|
|
68
|
+
);
|
|
72
69
|
}
|
|
73
70
|
|
|
74
|
-
function buildCacheFix(forNode,
|
|
71
|
+
function buildCacheFix(forNode, lengthExpression, context) {
|
|
75
72
|
const init = forNode.init;
|
|
76
73
|
// Only fix when init is a VariableDeclaration so we can safely
|
|
77
74
|
// append a new declarator without restructuring the loop header.
|
|
@@ -79,14 +76,14 @@ function buildCacheFix(forNode, lengthExpr, context) {
|
|
|
79
76
|
return null;
|
|
80
77
|
}
|
|
81
78
|
|
|
82
|
-
const
|
|
83
|
-
const
|
|
79
|
+
const objectText = context.sourceCode.getText(lengthExpression.object);
|
|
80
|
+
const lengthVariable = '_len';
|
|
84
81
|
|
|
85
82
|
return function fix(fixer) {
|
|
86
|
-
const
|
|
83
|
+
const lastDeclaration = init.declarations.at(-1);
|
|
87
84
|
return [
|
|
88
|
-
fixer.insertTextAfter(
|
|
89
|
-
fixer.replaceText(
|
|
85
|
+
fixer.insertTextAfter(lastDeclaration, `, ${lengthVariable} = ${objectText}.length`),
|
|
86
|
+
fixer.replaceText(lengthExpression, lengthVariable),
|
|
90
87
|
];
|
|
91
88
|
};
|
|
92
89
|
}
|
|
@@ -62,20 +62,20 @@ export default {
|
|
|
62
62
|
|
|
63
63
|
return {
|
|
64
64
|
VariableDeclaration(node) {
|
|
65
|
-
for (const
|
|
65
|
+
for (const declaration of node.declarations) {
|
|
66
66
|
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
declaration.init &&
|
|
68
|
+
declaration.id &&
|
|
69
|
+
declaration.id.type === 'Identifier' &&
|
|
70
|
+
isContentAreaObjectInit(declaration.init)
|
|
71
71
|
) {
|
|
72
|
-
contentAreaVariables.add(
|
|
72
|
+
contentAreaVariables.add(declaration.id.name);
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
},
|
|
76
76
|
|
|
77
77
|
AssignmentExpression(node) {
|
|
78
|
-
if (node.left.type === 'Identifier' &&
|
|
78
|
+
if (node.left.type === 'Identifier' && isContentAreaObjectInit(node.right)) {
|
|
79
79
|
contentAreaVariables.add(node.left.name);
|
|
80
80
|
}
|
|
81
81
|
},
|
|
@@ -166,7 +166,7 @@ export default {
|
|
|
166
166
|
* @param {import('eslint').Rule.Node} node - AST node to inspect
|
|
167
167
|
* @returns {boolean} true when the node is a ContentAreaObj.Init call
|
|
168
168
|
*/
|
|
169
|
-
function
|
|
169
|
+
function isContentAreaObjectInit(node) {
|
|
170
170
|
if (!node || node.type !== 'CallExpression') {
|
|
171
171
|
return false;
|
|
172
172
|
}
|
|
@@ -29,9 +29,7 @@ export default {
|
|
|
29
29
|
CallExpression(node) {
|
|
30
30
|
const callee = node.callee;
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (
|
|
32
|
+
const functionName =
|
|
35
33
|
callee.type === 'MemberExpression' &&
|
|
36
34
|
callee.object.type === 'MemberExpression' &&
|
|
37
35
|
callee.object.object.type === 'Identifier' &&
|
|
@@ -39,9 +37,8 @@ export default {
|
|
|
39
37
|
callee.object.property.type === 'Identifier' &&
|
|
40
38
|
callee.object.property.name === 'Function' &&
|
|
41
39
|
callee.property.type === 'Identifier'
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
40
|
+
? callee.property.name
|
|
41
|
+
: null;
|
|
45
42
|
|
|
46
43
|
if (!functionName || !ENCRYPT_FUNCTIONS.has(functionName.toLowerCase())) {
|
|
47
44
|
return;
|