eslint-plugin-sfmc 0.1.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/LICENSE +21 -0
- package/README.md +100 -0
- package/docs/rules/amp/function-arity.md +53 -0
- package/docs/rules/amp/naming-convention.md +76 -0
- package/docs/rules/amp/no-deprecated-function.md +78 -0
- package/docs/rules/amp/no-email-excluded-function.md +69 -0
- package/docs/rules/amp/no-empty-block.md +59 -0
- package/docs/rules/amp/no-empty-then.md +62 -0
- package/docs/rules/amp/no-html-comment.md +67 -0
- package/docs/rules/amp/no-inline-statement.md +51 -0
- package/docs/rules/amp/no-js-line-comment.md +54 -0
- package/docs/rules/amp/no-loop-counter-assign.md +52 -0
- package/docs/rules/amp/no-nested-ampscript-delimiter.md +69 -0
- package/docs/rules/amp/no-nested-script-tag.md +58 -0
- package/docs/rules/amp/no-smart-quotes.md +60 -0
- package/docs/rules/amp/no-unknown-function.md +50 -0
- package/docs/rules/amp/no-var-redeclaration.md +52 -0
- package/docs/rules/amp/prefer-attribute-value.md +64 -0
- package/docs/rules/amp/require-rowcount-check.md +62 -0
- package/docs/rules/amp/require-variable-declaration.md +51 -0
- package/docs/rules/amp/set-requires-target.md +47 -0
- package/docs/rules/ssjs/cache-loop-length.md +59 -0
- package/docs/rules/ssjs/no-hardcoded-credentials.md +52 -0
- package/docs/rules/ssjs/no-switch-default.md +37 -0
- package/docs/rules/ssjs/no-treatascontent-injection.md +38 -0
- package/docs/rules/ssjs/no-unavailable-method.md +134 -0
- package/docs/rules/ssjs/no-unknown-core-method.md +46 -0
- package/docs/rules/ssjs/no-unknown-http-method.md +44 -0
- package/docs/rules/ssjs/no-unknown-platform-client-browser.md +44 -0
- package/docs/rules/ssjs/no-unknown-platform-function.md +44 -0
- package/docs/rules/ssjs/no-unknown-platform-request.md +43 -0
- package/docs/rules/ssjs/no-unknown-platform-response.md +43 -0
- package/docs/rules/ssjs/no-unknown-platform-variable.md +43 -0
- package/docs/rules/ssjs/no-unknown-wsproxy-method.md +46 -0
- package/docs/rules/ssjs/no-unsupported-syntax.md +83 -0
- package/docs/rules/ssjs/platform-function-arity.md +45 -0
- package/docs/rules/ssjs/prefer-parsejson-safe-arg.md +42 -0
- package/docs/rules/ssjs/prefer-platform-load-version.md +68 -0
- package/docs/rules/ssjs/require-hasownproperty.md +57 -0
- package/docs/rules/ssjs/require-platform-load-order.md +46 -0
- package/docs/rules/ssjs/require-platform-load.md +57 -0
- package/package.json +52 -0
- package/src/ampscript-parser.js +159 -0
- package/src/ampscript-processor.js +132 -0
- package/src/index.js +355 -0
- package/src/processor.js +147 -0
- package/src/rules/amp/function-arity.js +51 -0
- package/src/rules/amp/naming-convention.js +58 -0
- package/src/rules/amp/no-deprecated-function.js +75 -0
- package/src/rules/amp/no-email-excluded-function.js +72 -0
- package/src/rules/amp/no-empty-block.js +37 -0
- package/src/rules/amp/no-empty-then.js +34 -0
- package/src/rules/amp/no-html-comment.js +57 -0
- package/src/rules/amp/no-inline-statement.js +45 -0
- package/src/rules/amp/no-js-line-comment.js +54 -0
- package/src/rules/amp/no-loop-counter-assign.js +71 -0
- package/src/rules/amp/no-nested-ampscript-delimiter.js +102 -0
- package/src/rules/amp/no-nested-script-tag.js +65 -0
- package/src/rules/amp/no-smart-quotes.js +78 -0
- package/src/rules/amp/no-unknown-function.js +31 -0
- package/src/rules/amp/no-var-redeclaration.js +36 -0
- package/src/rules/amp/prefer-attribute-value.js +94 -0
- package/src/rules/amp/require-rowcount-check.js +116 -0
- package/src/rules/amp/require-variable-declaration.js +49 -0
- package/src/rules/amp/set-requires-target.js +27 -0
- package/src/rules/ssjs/cache-loop-length.js +92 -0
- package/src/rules/ssjs/no-hardcoded-credentials.js +66 -0
- package/src/rules/ssjs/no-switch-default.js +32 -0
- package/src/rules/ssjs/no-treatascontent-injection.js +76 -0
- package/src/rules/ssjs/no-unavailable-method.js +190 -0
- package/src/rules/ssjs/no-unknown-core-method.js +103 -0
- package/src/rules/ssjs/no-unknown-http-method.js +41 -0
- package/src/rules/ssjs/no-unknown-platform-client-browser.js +50 -0
- package/src/rules/ssjs/no-unknown-platform-function.js +49 -0
- package/src/rules/ssjs/no-unknown-platform-request.js +50 -0
- package/src/rules/ssjs/no-unknown-platform-response.js +50 -0
- package/src/rules/ssjs/no-unknown-platform-variable.js +50 -0
- package/src/rules/ssjs/no-unknown-wsproxy-method.js +71 -0
- package/src/rules/ssjs/no-unsupported-syntax.js +122 -0
- package/src/rules/ssjs/platform-function-arity.js +70 -0
- package/src/rules/ssjs/prefer-parsejson-safe-arg.js +94 -0
- package/src/rules/ssjs/prefer-platform-load-version.js +96 -0
- package/src/rules/ssjs/require-hasownproperty.js +142 -0
- package/src/rules/ssjs/require-platform-load-order.js +112 -0
- package/src/rules/ssjs/require-platform-load.js +124 -0
- package/src/ssjs-processor.js +48 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enforces a naming convention for @variable names in AMPscript.
|
|
3
|
+
*
|
|
4
|
+
* AMPscript variables are case-insensitive, but consistent casing improves
|
|
5
|
+
* readability and reduces confusion during code reviews.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const CAMEL_CASE = /^@[a-z][a-zA-Z0-9]*$/;
|
|
9
|
+
const PASCAL_CASE = /^@[A-Z][a-zA-Z0-9]*$/;
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
meta: {
|
|
13
|
+
type: 'suggestion',
|
|
14
|
+
docs: {
|
|
15
|
+
description: 'Enforce a consistent naming convention for @variable names',
|
|
16
|
+
recommended: false,
|
|
17
|
+
},
|
|
18
|
+
messages: {
|
|
19
|
+
badName: "Variable '{{name}}' does not match the required {{format}} format.",
|
|
20
|
+
},
|
|
21
|
+
schema: [
|
|
22
|
+
{
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
format: {
|
|
26
|
+
enum: ['camelCase', 'PascalCase'],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
create(context) {
|
|
35
|
+
const format = (context.options[0] && context.options[0].format) || 'camelCase';
|
|
36
|
+
const pattern = format === 'PascalCase' ? PASCAL_CASE : CAMEL_CASE;
|
|
37
|
+
const reported = new Set();
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
Variable(node) {
|
|
41
|
+
const name = node.value;
|
|
42
|
+
if (!name.startsWith('@') || name.startsWith('@@')) return;
|
|
43
|
+
|
|
44
|
+
const key = name.toLowerCase();
|
|
45
|
+
if (reported.has(key)) return;
|
|
46
|
+
|
|
47
|
+
if (!pattern.test(name)) {
|
|
48
|
+
reported.add(key);
|
|
49
|
+
context.report({
|
|
50
|
+
node,
|
|
51
|
+
messageId: 'badName',
|
|
52
|
+
data: { name, format },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: no-deprecated-function
|
|
3
|
+
*
|
|
4
|
+
* Flags usage of deprecated AMPscript functions and suggests their
|
|
5
|
+
* modern replacements.
|
|
6
|
+
*
|
|
7
|
+
* For 1:1 replacements (e.g. InsertDE -> InsertData) an auto-fix is provided.
|
|
8
|
+
* For ambiguous replacements (e.g. ContentArea -> ContentBlockByKey OR
|
|
9
|
+
* ContentBlockByName) two manual suggestions are offered instead.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { deprecatedFunctionLookup } from 'ampscript-data';
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
meta: {
|
|
16
|
+
type: 'suggestion',
|
|
17
|
+
fixable: 'code',
|
|
18
|
+
hasSuggestions: true,
|
|
19
|
+
docs: {
|
|
20
|
+
description: 'Disallow deprecated AMPscript functions and suggest replacements',
|
|
21
|
+
},
|
|
22
|
+
messages: {
|
|
23
|
+
deprecated: "'{{name}}' is deprecated. Use {{replacement}} instead. {{reason}}",
|
|
24
|
+
replaceWith: "Replace '{{name}}' with '{{replacement}}'",
|
|
25
|
+
},
|
|
26
|
+
schema: [],
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
create(context) {
|
|
30
|
+
return {
|
|
31
|
+
FunctionCall(node) {
|
|
32
|
+
const functionName = node.name || (node.callee && node.callee.name) || '';
|
|
33
|
+
if (!functionName) return;
|
|
34
|
+
|
|
35
|
+
const entry = deprecatedFunctionLookup.get(functionName.toLowerCase());
|
|
36
|
+
if (!entry) return;
|
|
37
|
+
|
|
38
|
+
const report = {
|
|
39
|
+
node,
|
|
40
|
+
messageId: 'deprecated',
|
|
41
|
+
data: {
|
|
42
|
+
name: functionName,
|
|
43
|
+
replacement: entry.replacement,
|
|
44
|
+
reason: entry.reason,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Replacement strings that contain " or " have multiple options —
|
|
49
|
+
// offer them as manual suggestions rather than a single auto-fix.
|
|
50
|
+
const isMulti = entry.replacement.includes(' or ');
|
|
51
|
+
|
|
52
|
+
if (isMulti) {
|
|
53
|
+
const options = entry.replacement.split(' or ').map((s) => s.trim());
|
|
54
|
+
report.suggest = options.map((opt) => ({
|
|
55
|
+
messageId: 'replaceWith',
|
|
56
|
+
data: { name: functionName, replacement: opt },
|
|
57
|
+
fix: (fixer) =>
|
|
58
|
+
fixer.replaceTextRange(
|
|
59
|
+
[node.range[0], node.range[0] + functionName.length],
|
|
60
|
+
opt,
|
|
61
|
+
),
|
|
62
|
+
}));
|
|
63
|
+
} else {
|
|
64
|
+
report.fix = (fixer) =>
|
|
65
|
+
fixer.replaceTextRange(
|
|
66
|
+
[node.range[0], node.range[0] + functionName.length],
|
|
67
|
+
entry.replacement,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
context.report(report);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: no-email-excluded-function
|
|
3
|
+
*
|
|
4
|
+
* Flags calls to AMPscript functions that are not available in the email
|
|
5
|
+
* execution context. These functions only work on CloudPages, landing pages,
|
|
6
|
+
* and other non-email contexts. Using them in emails causes runtime errors.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { isEmailExcluded } from 'ampscript-data';
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
meta: {
|
|
13
|
+
type: 'problem',
|
|
14
|
+
docs: {
|
|
15
|
+
description: 'Disallow AMPscript functions that are not available in email context',
|
|
16
|
+
},
|
|
17
|
+
messages: {
|
|
18
|
+
emailExcluded:
|
|
19
|
+
"'{{name}}' is not available in the email execution context. This function only works on CloudPages and landing pages.",
|
|
20
|
+
},
|
|
21
|
+
schema: [
|
|
22
|
+
{
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
context: {
|
|
26
|
+
enum: ['email', 'cloudpage', 'auto'],
|
|
27
|
+
default: 'email',
|
|
28
|
+
description:
|
|
29
|
+
'Set to "email" to flag email-excluded functions, "cloudpage" to skip checking, "auto" to infer from file extension.',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
create(context) {
|
|
38
|
+
const options = context.options[0] || {};
|
|
39
|
+
const contextMode = options.context || 'email';
|
|
40
|
+
|
|
41
|
+
if (contextMode === 'cloudpage') {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (contextMode === 'auto') {
|
|
46
|
+
const filename = context.filename || '';
|
|
47
|
+
const lower = filename.toLowerCase();
|
|
48
|
+
if (
|
|
49
|
+
lower.includes('cloudpage') ||
|
|
50
|
+
lower.includes('landing') ||
|
|
51
|
+
lower.endsWith('.html')
|
|
52
|
+
) {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
FunctionCall(node) {
|
|
59
|
+
const functionName = node.name || (node.callee && node.callee.name) || '';
|
|
60
|
+
if (!functionName) return;
|
|
61
|
+
|
|
62
|
+
if (isEmailExcluded(functionName)) {
|
|
63
|
+
context.report({
|
|
64
|
+
node,
|
|
65
|
+
messageId: 'emailExcluded',
|
|
66
|
+
data: { name: functionName },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
meta: {
|
|
3
|
+
type: 'suggestion',
|
|
4
|
+
hasSuggestions: true,
|
|
5
|
+
docs: {
|
|
6
|
+
description: 'Disallow empty AMPscript blocks that produce no output or side effects',
|
|
7
|
+
recommended: true,
|
|
8
|
+
},
|
|
9
|
+
messages: {
|
|
10
|
+
emptyBlock: 'Empty AMPscript block. Remove it or add content.',
|
|
11
|
+
removeEmptyBlock: 'Remove the empty AMPscript block',
|
|
12
|
+
},
|
|
13
|
+
schema: [],
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
create(context) {
|
|
17
|
+
return {
|
|
18
|
+
Block(node) {
|
|
19
|
+
const meaningful = (node.statements || []).filter((s) => s.type !== 'Comment');
|
|
20
|
+
if (meaningful.length === 0) {
|
|
21
|
+
context.report({
|
|
22
|
+
node,
|
|
23
|
+
messageId: 'emptyBlock',
|
|
24
|
+
suggest: [
|
|
25
|
+
{
|
|
26
|
+
messageId: 'removeEmptyBlock',
|
|
27
|
+
fix(fixer) {
|
|
28
|
+
return fixer.removeRange(node.range);
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags IF statements whose THEN branch (consequent) is empty.
|
|
3
|
+
*
|
|
4
|
+
* An empty consequent is almost always a mistake — either the condition
|
|
5
|
+
* should be inverted (logic moved to ELSE) or the body was accidentally
|
|
6
|
+
* deleted.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
meta: {
|
|
11
|
+
type: 'problem',
|
|
12
|
+
docs: {
|
|
13
|
+
description: 'Disallow IF statements with an empty THEN consequent',
|
|
14
|
+
recommended: true,
|
|
15
|
+
},
|
|
16
|
+
messages: {
|
|
17
|
+
emptyThen: 'IF block has an empty THEN consequent.',
|
|
18
|
+
},
|
|
19
|
+
schema: [],
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
create(context) {
|
|
23
|
+
return {
|
|
24
|
+
AmpIfStatement(node) {
|
|
25
|
+
if (node.consequent && node.consequent.length === 0) {
|
|
26
|
+
context.report({
|
|
27
|
+
node,
|
|
28
|
+
messageId: 'emptyThen',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
2
|
+
const WRAPPED_BLOCK_COMMENT_RE = /^<!--(\/\*[\s\S]*?\*\/)-->$/;
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
meta: {
|
|
6
|
+
type: 'problem',
|
|
7
|
+
fixable: 'code',
|
|
8
|
+
docs: {
|
|
9
|
+
description:
|
|
10
|
+
'Disallow HTML comment syntax (<!-- ... -->) inside AMPscript regions. Use /* ... */ instead.',
|
|
11
|
+
recommended: true,
|
|
12
|
+
},
|
|
13
|
+
messages: {
|
|
14
|
+
htmlWrappedComment:
|
|
15
|
+
'HTML comment wrapper around an AMPscript comment is not valid. Use /* ... */ directly.',
|
|
16
|
+
htmlComment:
|
|
17
|
+
'HTML comment syntax is not valid inside AMPscript. Use /* ... */ instead.',
|
|
18
|
+
},
|
|
19
|
+
schema: [],
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
create(context) {
|
|
23
|
+
return {
|
|
24
|
+
Program() {
|
|
25
|
+
const sourceCode = context.sourceCode;
|
|
26
|
+
const text = sourceCode.getText();
|
|
27
|
+
|
|
28
|
+
HTML_COMMENT_RE.lastIndex = 0;
|
|
29
|
+
let match;
|
|
30
|
+
while ((match = HTML_COMMENT_RE.exec(text)) !== null) {
|
|
31
|
+
const fullMatch = match[0];
|
|
32
|
+
const start = match.index;
|
|
33
|
+
const end = start + fullMatch.length;
|
|
34
|
+
|
|
35
|
+
const wrappedMatch = WRAPPED_BLOCK_COMMENT_RE.exec(fullMatch);
|
|
36
|
+
const isWrapped = wrappedMatch !== null;
|
|
37
|
+
const innerBlockComment = isWrapped ? wrappedMatch[1] : null;
|
|
38
|
+
|
|
39
|
+
context.report({
|
|
40
|
+
loc: {
|
|
41
|
+
start: sourceCode.getLocFromIndex(start),
|
|
42
|
+
end: sourceCode.getLocFromIndex(end),
|
|
43
|
+
},
|
|
44
|
+
messageId: isWrapped ? 'htmlWrappedComment' : 'htmlComment',
|
|
45
|
+
fix(fixer) {
|
|
46
|
+
if (isWrapped) {
|
|
47
|
+
return fixer.replaceTextRange([start, end], innerBlockComment);
|
|
48
|
+
}
|
|
49
|
+
const inner = fullMatch.slice(4, -3).trim();
|
|
50
|
+
return fixer.replaceTextRange([start, end], `/* ${inner} */`);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline expressions (%%=...=%%) should contain a single output expression,
|
|
3
|
+
* not control-flow statements like `if`, `for`, `set`, or `var`.
|
|
4
|
+
*
|
|
5
|
+
* These constructs belong in block AMPscript (%%[...]%%).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const STATEMENT_TYPES = new Set([
|
|
9
|
+
'AmpIfStatement',
|
|
10
|
+
'AmpForStatement',
|
|
11
|
+
'SetStatement',
|
|
12
|
+
'VarDeclaration',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
export default {
|
|
16
|
+
meta: {
|
|
17
|
+
type: 'suggestion',
|
|
18
|
+
docs: {
|
|
19
|
+
description: 'Disallow control-flow statements inside inline expressions (%%=...=%%)',
|
|
20
|
+
recommended: true,
|
|
21
|
+
},
|
|
22
|
+
messages: {
|
|
23
|
+
inlineStatement:
|
|
24
|
+
'Inline expressions (%%=...=%%) should contain a single output expression. Move {{kind}} to a block (%%[...]%%).',
|
|
25
|
+
},
|
|
26
|
+
schema: [],
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
create(context) {
|
|
30
|
+
return {
|
|
31
|
+
InlineExpression(node) {
|
|
32
|
+
const expr = node.expression;
|
|
33
|
+
if (!expr) return;
|
|
34
|
+
|
|
35
|
+
if (STATEMENT_TYPES.has(expr.type)) {
|
|
36
|
+
context.report({
|
|
37
|
+
node: expr,
|
|
38
|
+
messageId: 'inlineStatement',
|
|
39
|
+
data: { kind: expr.type },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Matches // comments but avoids :// (URL protocol separators).
|
|
2
|
+
// Strings are not a concern here because the processor feeds only AMPscript
|
|
3
|
+
// source to the parser/rules, but the ampscript-parser does not blank string
|
|
4
|
+
// contents before rule execution. To be safe, we skip matches that are clearly
|
|
5
|
+
// inside quoted strings by checking surrounding context via the source text.
|
|
6
|
+
const JS_LINE_COMMENT_RE = /(?<!:)\/\/.*/g;
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
meta: {
|
|
10
|
+
type: 'problem',
|
|
11
|
+
fixable: 'code',
|
|
12
|
+
docs: {
|
|
13
|
+
description:
|
|
14
|
+
'Disallow JavaScript-style single-line comments (// ...) inside AMPscript regions. Use /* ... */ instead.',
|
|
15
|
+
recommended: true,
|
|
16
|
+
},
|
|
17
|
+
messages: {
|
|
18
|
+
jsLineComment:
|
|
19
|
+
'Single-line // comments are not valid AMPscript syntax. Use /* ... */ instead.',
|
|
20
|
+
},
|
|
21
|
+
schema: [],
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
create(context) {
|
|
25
|
+
return {
|
|
26
|
+
Program() {
|
|
27
|
+
const sourceCode = context.sourceCode;
|
|
28
|
+
const text = sourceCode.getText();
|
|
29
|
+
|
|
30
|
+
JS_LINE_COMMENT_RE.lastIndex = 0;
|
|
31
|
+
let match;
|
|
32
|
+
while ((match = JS_LINE_COMMENT_RE.exec(text)) !== null) {
|
|
33
|
+
const start = match.index;
|
|
34
|
+
const end = start + match[0].length;
|
|
35
|
+
const commentText = match[0].slice(2).trim();
|
|
36
|
+
|
|
37
|
+
context.report({
|
|
38
|
+
loc: {
|
|
39
|
+
start: sourceCode.getLocFromIndex(start),
|
|
40
|
+
end: sourceCode.getLocFromIndex(end),
|
|
41
|
+
},
|
|
42
|
+
messageId: 'jsLineComment',
|
|
43
|
+
fix(fixer) {
|
|
44
|
+
return fixer.replaceTextRange(
|
|
45
|
+
[start, end],
|
|
46
|
+
commentText.length > 0 ? `/* ${commentText} */` : '/**/',
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags `set` statements inside a `for` loop that target the loop counter
|
|
3
|
+
* variable. Modifying the counter is documented as causing validation or
|
|
4
|
+
* runtime errors in AMPscript.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
meta: {
|
|
9
|
+
type: 'suggestion',
|
|
10
|
+
docs: {
|
|
11
|
+
description:
|
|
12
|
+
'Disallow assigning to the `for` loop counter variable inside the loop body',
|
|
13
|
+
recommended: true,
|
|
14
|
+
},
|
|
15
|
+
messages: {
|
|
16
|
+
counterAssign:
|
|
17
|
+
"Do not assign to loop counter '{{name}}' inside the for loop. AMPscript does not allow modifying the counter variable.",
|
|
18
|
+
},
|
|
19
|
+
schema: [],
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
create(context) {
|
|
23
|
+
const counterStack = [];
|
|
24
|
+
|
|
25
|
+
function checkBody(body) {
|
|
26
|
+
for (const stmt of body) {
|
|
27
|
+
if (stmt.type === 'SetStatement' && stmt.target) {
|
|
28
|
+
const current = counterStack.at(-1);
|
|
29
|
+
if (current && stmt.target.value.toLowerCase() === current.toLowerCase()) {
|
|
30
|
+
context.report({
|
|
31
|
+
node: stmt.target,
|
|
32
|
+
messageId: 'counterAssign',
|
|
33
|
+
data: { name: stmt.target.value },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (stmt.type === 'VarDeclaration') {
|
|
38
|
+
const current = counterStack.at(-1);
|
|
39
|
+
if (current) {
|
|
40
|
+
for (const v of stmt.variables) {
|
|
41
|
+
if (v.value.toLowerCase() === current.toLowerCase()) {
|
|
42
|
+
context.report({
|
|
43
|
+
node: v,
|
|
44
|
+
messageId: 'counterAssign',
|
|
45
|
+
data: { name: v.value },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
AmpForStatement(node) {
|
|
56
|
+
if (node.counter) {
|
|
57
|
+
counterStack.push(node.counter.value);
|
|
58
|
+
}
|
|
59
|
+
if (node.body) {
|
|
60
|
+
checkBody(node.body);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
'AmpForStatement:exit'(node) {
|
|
65
|
+
if (node.counter) {
|
|
66
|
+
counterStack.pop();
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The processor extracts each AMPscript region into its own virtual .amp file,
|
|
2
|
+
// including the outer delimiters (%%[...]/%%] or <script>...</script>).
|
|
3
|
+
// Any %%[ or %%= found INSIDE the outer delimiters is nested and invalid.
|
|
4
|
+
const DELIMITER_RE = /%%\[|%%=/g;
|
|
5
|
+
const SCRIPT_TAG_RE = /^[\n]*<script\b/i;
|
|
6
|
+
const BLOCK_OPEN_RE = /^[\n]*(%%\[)/;
|
|
7
|
+
const INLINE_OPEN_RE = /^[\n]*(%%=)/;
|
|
8
|
+
const BLOCK_CLOSE_SUFFIX = ']%%';
|
|
9
|
+
const INLINE_CLOSE_SUFFIX = '=%%';
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
meta: {
|
|
13
|
+
type: 'problem',
|
|
14
|
+
fixable: 'code',
|
|
15
|
+
docs: {
|
|
16
|
+
description:
|
|
17
|
+
'Disallow %%[ or %%= delimiters inside an already-open AMPscript region.',
|
|
18
|
+
recommended: true,
|
|
19
|
+
},
|
|
20
|
+
messages: {
|
|
21
|
+
nestedDelimiterInScript:
|
|
22
|
+
'AMPscript delimiter {{delimiter}} is not needed inside a <script language="ampscript"> block.',
|
|
23
|
+
nestedDelimiter:
|
|
24
|
+
'Nested {{delimiter}} inside an already-open AMPscript block.',
|
|
25
|
+
},
|
|
26
|
+
schema: [],
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
create(context) {
|
|
30
|
+
return {
|
|
31
|
+
Program() {
|
|
32
|
+
const sourceCode = context.sourceCode;
|
|
33
|
+
const text = sourceCode.getText();
|
|
34
|
+
const isScriptBlock = SCRIPT_TAG_RE.test(text);
|
|
35
|
+
|
|
36
|
+
// For %%[...]%% and %%=...=%% blocks the processor includes the outer
|
|
37
|
+
// delimiters. Record their positions so we can skip the outermost ones.
|
|
38
|
+
let outerOpenStart = -1;
|
|
39
|
+
let outerCloseStart = -1;
|
|
40
|
+
let outerCloseSuffix = BLOCK_CLOSE_SUFFIX;
|
|
41
|
+
|
|
42
|
+
if (!isScriptBlock) {
|
|
43
|
+
const blockOpenMatch = BLOCK_OPEN_RE.exec(text);
|
|
44
|
+
const inlineOpenMatch = INLINE_OPEN_RE.exec(text);
|
|
45
|
+
|
|
46
|
+
if (blockOpenMatch) {
|
|
47
|
+
outerOpenStart = blockOpenMatch.index + blockOpenMatch[0].length - 3;
|
|
48
|
+
outerCloseSuffix = BLOCK_CLOSE_SUFFIX;
|
|
49
|
+
} else if (inlineOpenMatch) {
|
|
50
|
+
outerOpenStart = inlineOpenMatch.index + inlineOpenMatch[0].length - 3;
|
|
51
|
+
outerCloseSuffix = INLINE_CLOSE_SUFFIX;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The outer close is the last occurrence of the matching close token
|
|
55
|
+
const lastClose = text.lastIndexOf(outerCloseSuffix);
|
|
56
|
+
if (lastClose !== -1) {
|
|
57
|
+
outerCloseStart = lastClose;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
DELIMITER_RE.lastIndex = 0;
|
|
62
|
+
let match;
|
|
63
|
+
while ((match = DELIMITER_RE.exec(text)) !== null) {
|
|
64
|
+
const delimiter = match[0];
|
|
65
|
+
const start = match.index;
|
|
66
|
+
const end = start + delimiter.length;
|
|
67
|
+
|
|
68
|
+
// Skip the outermost %%[ opener (it is the block wrapper, not nested)
|
|
69
|
+
if (!isScriptBlock && start === outerOpenStart) continue;
|
|
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
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The processor extracts each <script language="ampscript">...</script> block
|
|
2
|
+
// as a virtual .amp file, including the opening tag in the emitted text.
|
|
3
|
+
// Therefore, scanning the virtual source for a *second* opening tag catches
|
|
4
|
+
// the case where a developer forgot a closing </script> and started a new block.
|
|
5
|
+
const SCRIPT_OPEN_RE =
|
|
6
|
+
/<script\b(?=[^>]*\brunat\s*=\s*['"]server['"])(?=[^>]*\blanguage\s*=\s*['"]ampscript['"])[^>]*>/gi;
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
meta: {
|
|
10
|
+
type: 'problem',
|
|
11
|
+
fixable: 'code',
|
|
12
|
+
docs: {
|
|
13
|
+
description:
|
|
14
|
+
'Disallow nested <script language="ampscript"> tags inside an already-open AMPscript script block.',
|
|
15
|
+
recommended: true,
|
|
16
|
+
},
|
|
17
|
+
messages: {
|
|
18
|
+
nestedScriptTag:
|
|
19
|
+
'Nested <script language="ampscript"> inside an already-open AMPscript block. Did you forget a </script> closing tag?',
|
|
20
|
+
},
|
|
21
|
+
schema: [],
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
create(context) {
|
|
25
|
+
return {
|
|
26
|
+
Program() {
|
|
27
|
+
const sourceCode = context.sourceCode;
|
|
28
|
+
const text = sourceCode.getText();
|
|
29
|
+
|
|
30
|
+
// Only relevant when the virtual block starts with a <script> tag.
|
|
31
|
+
// %%[...]%% blocks cannot contain a nested <script> opening tag in
|
|
32
|
+
// a way the processor would expose, so skip non-script-tag blocks.
|
|
33
|
+
SCRIPT_OPEN_RE.lastIndex = 0;
|
|
34
|
+
if (!SCRIPT_OPEN_RE.test(text)) return;
|
|
35
|
+
|
|
36
|
+
// Reset and find all script open tags in this virtual block.
|
|
37
|
+
SCRIPT_OPEN_RE.lastIndex = 0;
|
|
38
|
+
const openTags = [];
|
|
39
|
+
let match;
|
|
40
|
+
while ((match = SCRIPT_OPEN_RE.exec(text)) !== null) {
|
|
41
|
+
openTags.push({ index: match.index, length: match[0].length });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The first tag is the expected outer opener.
|
|
45
|
+
// Any tag beyond the first is nested (invalid).
|
|
46
|
+
for (let index = 1; index < openTags.length; index++) {
|
|
47
|
+
const tag = openTags[index];
|
|
48
|
+
const start = tag.index;
|
|
49
|
+
const end = start + tag.length;
|
|
50
|
+
|
|
51
|
+
context.report({
|
|
52
|
+
loc: {
|
|
53
|
+
start: sourceCode.getLocFromIndex(start),
|
|
54
|
+
end: sourceCode.getLocFromIndex(end),
|
|
55
|
+
},
|
|
56
|
+
messageId: 'nestedScriptTag',
|
|
57
|
+
fix(fixer) {
|
|
58
|
+
return fixer.replaceTextRange([start, start], '</script>\n');
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
};
|