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
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint-compatible parser for Marketing Cloud Next Handlebars.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the `@handlebars/parser` package and adds the metadata ESLint needs:
|
|
5
|
+
* - `range: [start, end]` on every node (derived from the parser's
|
|
6
|
+
* 1-based-line / 0-based-column `loc` via a line-start table)
|
|
7
|
+
* - `loc: { start: {line,column}, end: {line,column} }` (kept from the parser,
|
|
8
|
+
* whose convention already matches ESLint: 1-based line, 0-based column)
|
|
9
|
+
* - Root node typed as "Program" with `body`, `tokens`, `comments`
|
|
10
|
+
* - `visitorKeys` so ESLint knows how to traverse Handlebars node types
|
|
11
|
+
*
|
|
12
|
+
* AMPscript regions inside the source must be blanked out before this parser
|
|
13
|
+
* runs (the processor does this) so Handlebars never sees `%%[ ... ]%%` syntax.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { parse as hbsParse } from '@handlebars/parser';
|
|
17
|
+
|
|
18
|
+
// ── Node renaming for ESTree collisions ───────────────────────────────────
|
|
19
|
+
//
|
|
20
|
+
// `BlockStatement` is also an ESTree node type, so ESLint's built-in scope
|
|
21
|
+
// analysis (eslint-scope) tries to open a lexical scope for it and crashes on
|
|
22
|
+
// the Handlebars shape. Likewise, a Handlebars `BlockStatement` contains nested
|
|
23
|
+
// `Program` nodes (its `program`/`inverse` bodies); eslint-scope treats every
|
|
24
|
+
// `Program` as a global scope and asserts when they nest. Rename both to
|
|
25
|
+
// Hbs-prefixed types so the JS analyzer leaves them alone — only the single
|
|
26
|
+
// synthetic root keeps the real `Program` type. The hbs rules and visitorKeys
|
|
27
|
+
// reference the renamed types.
|
|
28
|
+
|
|
29
|
+
const NODE_RENAME = {
|
|
30
|
+
BlockStatement: 'HbsBlockStatement',
|
|
31
|
+
Program: 'HbsProgram',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ── Visitor keys for all Handlebars AST node types ────────────────────────
|
|
35
|
+
|
|
36
|
+
export const visitorKeys = {
|
|
37
|
+
Program: ['body'],
|
|
38
|
+
HbsProgram: ['body'],
|
|
39
|
+
MustacheStatement: ['path', 'params', 'hash'],
|
|
40
|
+
HbsBlockStatement: ['path', 'params', 'hash', 'program', 'inverse'],
|
|
41
|
+
PartialStatement: ['name', 'params', 'hash'],
|
|
42
|
+
PartialBlockStatement: ['name', 'params', 'hash', 'program'],
|
|
43
|
+
DecoratorBlock: ['path', 'params', 'hash', 'program'],
|
|
44
|
+
Decorator: ['path', 'params', 'hash'],
|
|
45
|
+
ContentStatement: [],
|
|
46
|
+
CommentStatement: [],
|
|
47
|
+
SubExpression: ['path', 'params', 'hash'],
|
|
48
|
+
PathExpression: [],
|
|
49
|
+
StringLiteral: [],
|
|
50
|
+
NumberLiteral: [],
|
|
51
|
+
BooleanLiteral: [],
|
|
52
|
+
UndefinedLiteral: [],
|
|
53
|
+
NullLiteral: [],
|
|
54
|
+
Hash: ['pairs'],
|
|
55
|
+
HashPair: ['value'],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ── line/column → offset mapping ──────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function buildLineTable(text) {
|
|
61
|
+
const starts = [0];
|
|
62
|
+
for (let index = 0; index < text.length; index++) {
|
|
63
|
+
if (text[index] === '\n') {
|
|
64
|
+
starts.push(index + 1);
|
|
65
|
+
} else if (text[index] === '\r') {
|
|
66
|
+
if (text[index + 1] === '\n') {
|
|
67
|
+
starts.push(index + 2);
|
|
68
|
+
index++;
|
|
69
|
+
} else {
|
|
70
|
+
starts.push(index + 1);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return starts;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function locToOffset(position, lineStarts, textLength) {
|
|
78
|
+
if (!position || typeof position.line !== 'number') {
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
const lineIndex = Math.max(0, position.line - 1);
|
|
82
|
+
const base = lineIndex < lineStarts.length ? lineStarts[lineIndex] : textLength;
|
|
83
|
+
const offset = base + Math.max(0, position.column);
|
|
84
|
+
return Math.min(offset, textLength);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── AST annotation ────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function annotateNode(node, lineStarts, textLength, fallback) {
|
|
90
|
+
if (!node || typeof node !== 'object') {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Rename colliding ESTree types so ESLint's JS scope analysis ignores them.
|
|
95
|
+
if (node.type && Object.hasOwn(NODE_RENAME, node.type)) {
|
|
96
|
+
node.type = NODE_RENAME[node.type];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ESLint requires every traversed node to expose a numeric `range` and a
|
|
100
|
+
// matching `loc`. `@handlebars/parser` attaches `loc` to most nodes but a
|
|
101
|
+
// few (e.g. Hash, HashPair) can omit it — inherit the parent's span so the
|
|
102
|
+
// traverser never sees an undefined range.
|
|
103
|
+
let span = fallback;
|
|
104
|
+
if (node.loc && node.loc.start && node.loc.end) {
|
|
105
|
+
const start = locToOffset(node.loc.start, lineStarts, textLength);
|
|
106
|
+
const end = locToOffset(node.loc.end, lineStarts, textLength);
|
|
107
|
+
node.range = [start, end];
|
|
108
|
+
node.start = start;
|
|
109
|
+
node.end = end;
|
|
110
|
+
node.loc = {
|
|
111
|
+
start: { line: node.loc.start.line, column: node.loc.start.column },
|
|
112
|
+
end: { line: node.loc.end.line, column: node.loc.end.column },
|
|
113
|
+
};
|
|
114
|
+
span = { range: node.range, loc: node.loc };
|
|
115
|
+
} else if (fallback) {
|
|
116
|
+
node.range = [...fallback.range];
|
|
117
|
+
node.start = fallback.range[0];
|
|
118
|
+
node.end = fallback.range[1];
|
|
119
|
+
node.loc = {
|
|
120
|
+
start: { ...fallback.loc.start },
|
|
121
|
+
end: { ...fallback.loc.end },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const keys = visitorKeys[node.type];
|
|
126
|
+
if (!keys) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const key of keys) {
|
|
131
|
+
const child = node[key];
|
|
132
|
+
if (Array.isArray(child)) {
|
|
133
|
+
for (const item of child) {
|
|
134
|
+
if (item && typeof item === 'object') {
|
|
135
|
+
annotateNode(item, lineStarts, textLength, span);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} else if (child && typeof child === 'object') {
|
|
139
|
+
annotateNode(child, lineStarts, textLength, span);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Public ESLint parser interface ────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
export function parseForESLint(code) {
|
|
147
|
+
const lineStarts = buildLineTable(code);
|
|
148
|
+
|
|
149
|
+
let handlebarsAst;
|
|
150
|
+
try {
|
|
151
|
+
handlebarsAst = hbsParse(code);
|
|
152
|
+
} catch (ex) {
|
|
153
|
+
const loc = ex && ex.hash && ex.hash.loc;
|
|
154
|
+
const rawMessage = (ex && ex.message) || 'Handlebars syntax error.';
|
|
155
|
+
const messageLines = rawMessage.split('\n').filter((line) => line.trim().length > 0);
|
|
156
|
+
const error = new SyntaxError(messageLines.at(-1) || rawMessage);
|
|
157
|
+
error.lineNumber = loc ? Math.max(1, loc.first_line) : 1;
|
|
158
|
+
error.column = loc ? Math.max(1, loc.first_column + 1) : 1;
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const ast = {
|
|
163
|
+
type: 'Program',
|
|
164
|
+
body: handlebarsAst.body || [],
|
|
165
|
+
start: 0,
|
|
166
|
+
end: code.length,
|
|
167
|
+
range: [0, code.length],
|
|
168
|
+
loc: {
|
|
169
|
+
start: { line: 1, column: 0 },
|
|
170
|
+
end: handlebarsAst.loc
|
|
171
|
+
? { line: handlebarsAst.loc.end.line, column: handlebarsAst.loc.end.column }
|
|
172
|
+
: {
|
|
173
|
+
line: lineStarts.length,
|
|
174
|
+
column: code.length - lineStarts.at(-1),
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
tokens: [],
|
|
178
|
+
comments: [],
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
for (const child of ast.body) {
|
|
182
|
+
annotateNode(child, lineStarts, code.length);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
ast,
|
|
187
|
+
visitorKeys,
|
|
188
|
+
services: {},
|
|
189
|
+
scopeManager: null,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function parse(code) {
|
|
194
|
+
return parseForESLint(code).ast;
|
|
195
|
+
}
|
package/src/index.js
CHANGED
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import * as ampscriptParser from './ampscript-parser.js';
|
|
13
|
+
import * as handlebarsParser from './handlebars-parser.js';
|
|
13
14
|
|
|
14
15
|
// ── AMPscript rules ───────────────────────────────────────────────────────────
|
|
15
16
|
|
|
16
17
|
import ampNoUnknownFunction from './rules/amp/no-unknown-function.js';
|
|
17
|
-
import ampNoVariableRedeclaration from './rules/amp/no-
|
|
18
|
+
import ampNoVariableRedeclaration from './rules/amp/no-variable-redeclaration.js';
|
|
18
19
|
import ampSetRequiresTarget from './rules/amp/set-requires-target.js';
|
|
19
20
|
import ampNoEmptyBlock from './rules/amp/no-empty-block.js';
|
|
20
21
|
import ampNoSmartQuotes from './rules/amp/no-smart-quotes.js';
|
|
@@ -23,7 +24,7 @@ import ampNoLoopCounterAssign from './rules/amp/no-loop-counter-assign.js';
|
|
|
23
24
|
import ampNoInlineStatement from './rules/amp/no-inline-statement.js';
|
|
24
25
|
import ampRequireVariableDeclaration from './rules/amp/require-variable-declaration.js';
|
|
25
26
|
import ampFunctionArity from './rules/amp/function-arity.js';
|
|
26
|
-
import
|
|
27
|
+
import ampArgumentTypes from './rules/amp/argument-types.js';
|
|
27
28
|
import ampNoEmailExcludedFunction from './rules/amp/no-email-excluded-function.js';
|
|
28
29
|
import ampNoDeprecatedFunction from './rules/amp/no-deprecated-function.js';
|
|
29
30
|
import ampNamingConvention from './rules/amp/naming-convention.js';
|
|
@@ -48,12 +49,20 @@ import ssjsRequirePlatformLoadOrder from './rules/ssjs/require-platform-load-ord
|
|
|
48
49
|
import ssjsNoHardcodedCredentials from './rules/ssjs/no-hardcoded-credentials.js';
|
|
49
50
|
import ssjsPreferPlatformLoadVersion from './rules/ssjs/prefer-platform-load-version.js';
|
|
50
51
|
import ssjsNoUnavailableMethod from './rules/ssjs/no-unavailable-method.js';
|
|
51
|
-
import
|
|
52
|
+
import ssjsPreferParsejsonSafeArgument from './rules/ssjs/prefer-parsejson-safe-argument.js';
|
|
52
53
|
import ssjsNoSwitchDefault from './rules/ssjs/no-switch-default.js';
|
|
53
54
|
import ssjsNoTreatAsContentInjection from './rules/ssjs/no-treatascontent-injection.js';
|
|
54
|
-
import
|
|
55
|
+
import ssjsArgumentTypes from './rules/ssjs/ssjs-argument-types.js';
|
|
55
56
|
import ssjsCoreMethodArity from './rules/ssjs/ssjs-core-method-arity.js';
|
|
56
57
|
|
|
58
|
+
// ── Handlebars (MCN) rules ──────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
import hbsNoUnknownHelper from './rules/hbs/no-unknown-helper.js';
|
|
61
|
+
import hbsHelperTooNewForTarget from './rules/hbs/helper-too-new-for-target.js';
|
|
62
|
+
import hbsNoUnknownBinding from './rules/hbs/no-unknown-binding.js';
|
|
63
|
+
import hbsHelperArity from './rules/hbs/helper-arity.js';
|
|
64
|
+
import hbsNoUnsupportedConstruct from './rules/hbs/no-unsupported-construct.js';
|
|
65
|
+
|
|
57
66
|
// ── Processors ────────────────────────────────────────────────────────────────
|
|
58
67
|
|
|
59
68
|
import ampscriptProcessor from './ampscript-processor.js';
|
|
@@ -83,7 +92,7 @@ const plugin = {
|
|
|
83
92
|
'amp-no-inline-statement': ampNoInlineStatement,
|
|
84
93
|
'amp-require-variable-declaration': ampRequireVariableDeclaration,
|
|
85
94
|
'amp-function-arity': ampFunctionArity,
|
|
86
|
-
'amp-arg-types':
|
|
95
|
+
'amp-arg-types': ampArgumentTypes,
|
|
87
96
|
'amp-no-email-excluded-function': ampNoEmailExcludedFunction,
|
|
88
97
|
'amp-no-deprecated-function': ampNoDeprecatedFunction,
|
|
89
98
|
'amp-naming-convention': ampNamingConvention,
|
|
@@ -107,11 +116,18 @@ const plugin = {
|
|
|
107
116
|
'ssjs-no-hardcoded-credentials': ssjsNoHardcodedCredentials,
|
|
108
117
|
'ssjs-prefer-platform-load-version': ssjsPreferPlatformLoadVersion,
|
|
109
118
|
'ssjs-no-unavailable-method': ssjsNoUnavailableMethod,
|
|
110
|
-
'ssjs-prefer-parsejson-safe-arg':
|
|
119
|
+
'ssjs-prefer-parsejson-safe-arg': ssjsPreferParsejsonSafeArgument,
|
|
111
120
|
'ssjs-no-switch-default': ssjsNoSwitchDefault,
|
|
112
121
|
'ssjs-no-treatascontent-injection': ssjsNoTreatAsContentInjection,
|
|
113
|
-
'ssjs-arg-types':
|
|
122
|
+
'ssjs-arg-types': ssjsArgumentTypes,
|
|
114
123
|
'ssjs-core-method-arity': ssjsCoreMethodArity,
|
|
124
|
+
|
|
125
|
+
// Handlebars (MCN) rules (hbs- prefix)
|
|
126
|
+
'hbs-no-unknown-helper': hbsNoUnknownHelper,
|
|
127
|
+
'hbs-helper-too-new-for-target': hbsHelperTooNewForTarget,
|
|
128
|
+
'hbs-no-unknown-binding': hbsNoUnknownBinding,
|
|
129
|
+
'hbs-helper-arity': hbsHelperArity,
|
|
130
|
+
'hbs-no-unsupported-construct': hbsNoUnsupportedConstruct,
|
|
115
131
|
},
|
|
116
132
|
|
|
117
133
|
processors: {
|
|
@@ -119,8 +135,6 @@ const plugin = {
|
|
|
119
135
|
ssjs: ssjsProcessor,
|
|
120
136
|
sfmc: combinedProcessor,
|
|
121
137
|
},
|
|
122
|
-
|
|
123
|
-
configs: {},
|
|
124
138
|
};
|
|
125
139
|
|
|
126
140
|
// ── MCN SSJS rule set (all SSJS rules off except ssjs-no-unknown-function) ────
|
|
@@ -240,9 +254,40 @@ const ssjsStrictRules = {
|
|
|
240
254
|
'no-cond-assign': 'error',
|
|
241
255
|
};
|
|
242
256
|
|
|
257
|
+
// ── Handlebars (MCN) rule sets ────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Handlebars rules for Marketing Cloud Next targets. Handlebars only exists when
|
|
261
|
+
* targeting MCN, so these are enabled only in `-next` configs and applied to the
|
|
262
|
+
* virtual `.hbs` files the combined processor extracts from HTML.
|
|
263
|
+
*/
|
|
264
|
+
const hbsNextRules = {
|
|
265
|
+
'sfmc/hbs-no-unknown-helper': 'error',
|
|
266
|
+
'sfmc/hbs-no-unknown-binding': 'error',
|
|
267
|
+
'sfmc/hbs-helper-arity': 'error',
|
|
268
|
+
'sfmc/hbs-no-unsupported-construct': 'error',
|
|
269
|
+
'sfmc/hbs-helper-too-new-for-target': 'off',
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Handlebars rules for non-MCN (classic) targets — all disabled. Classic SFMC
|
|
274
|
+
* does not run Handlebars, so any `{{...}}` is plain content and must not be
|
|
275
|
+
* flagged.
|
|
276
|
+
*/
|
|
277
|
+
const hbsOffRules = {
|
|
278
|
+
'sfmc/hbs-no-unknown-helper': 'off',
|
|
279
|
+
'sfmc/hbs-no-unknown-binding': 'off',
|
|
280
|
+
'sfmc/hbs-helper-arity': 'off',
|
|
281
|
+
'sfmc/hbs-no-unsupported-construct': 'off',
|
|
282
|
+
'sfmc/hbs-helper-too-new-for-target': 'off',
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/** Shared languageOptions for linting virtual `.hbs` files. */
|
|
286
|
+
const hbsLanguageOptions = { parser: handlebarsParser };
|
|
287
|
+
|
|
243
288
|
// ── Configs (defined after plugin so they can reference it) ───────────────────
|
|
244
289
|
|
|
245
|
-
|
|
290
|
+
plugin.configs = {
|
|
246
291
|
/**
|
|
247
292
|
* AMPscript-only config for standalone .ampscript/.amp files.
|
|
248
293
|
*/
|
|
@@ -323,6 +368,16 @@ Object.assign(plugin.configs, {
|
|
|
323
368
|
},
|
|
324
369
|
rules: { ...ssjsRecommendedRules },
|
|
325
370
|
},
|
|
371
|
+
{
|
|
372
|
+
// Classic (non-MCN) HTML: Handlebars is not executed, so all hbs
|
|
373
|
+
// rules are off. The parser is still required so the extracted .hbs
|
|
374
|
+
// virtual file is not handed to espree.
|
|
375
|
+
name: 'sfmc/embedded-handlebars-rules',
|
|
376
|
+
plugins: { sfmc: plugin },
|
|
377
|
+
languageOptions: { ...hbsLanguageOptions },
|
|
378
|
+
files: ['**/*.html/*.hbs'],
|
|
379
|
+
rules: { ...hbsOffRules },
|
|
380
|
+
},
|
|
326
381
|
],
|
|
327
382
|
|
|
328
383
|
/**
|
|
@@ -371,6 +426,14 @@ Object.assign(plugin.configs, {
|
|
|
371
426
|
},
|
|
372
427
|
rules: { ...ssjsStrictRules },
|
|
373
428
|
},
|
|
429
|
+
{
|
|
430
|
+
// Classic (non-MCN) HTML: Handlebars rules off, parser still wired.
|
|
431
|
+
name: 'sfmc/strict-embedded-handlebars-rules',
|
|
432
|
+
plugins: { sfmc: plugin },
|
|
433
|
+
languageOptions: { ...hbsLanguageOptions },
|
|
434
|
+
files: ['**/*.html/*.hbs'],
|
|
435
|
+
rules: { ...hbsOffRules },
|
|
436
|
+
},
|
|
374
437
|
],
|
|
375
438
|
|
|
376
439
|
// ── Marketing Cloud Next configs ──────────────────────────────────────────
|
|
@@ -464,6 +527,14 @@ Object.assign(plugin.configs, {
|
|
|
464
527
|
},
|
|
465
528
|
rules: { ...ssjsMcnRules },
|
|
466
529
|
},
|
|
530
|
+
{
|
|
531
|
+
// MCN HTML: Handlebars is the templating language — enable hbs rules.
|
|
532
|
+
name: 'sfmc/embedded-next-handlebars-rules',
|
|
533
|
+
plugins: { sfmc: plugin },
|
|
534
|
+
languageOptions: { ...hbsLanguageOptions },
|
|
535
|
+
files: ['**/*.html/*.hbs'],
|
|
536
|
+
rules: { ...hbsNextRules },
|
|
537
|
+
},
|
|
467
538
|
],
|
|
468
539
|
|
|
469
540
|
/**
|
|
@@ -518,7 +589,15 @@ Object.assign(plugin.configs, {
|
|
|
518
589
|
},
|
|
519
590
|
rules: { ...ssjsMcnRules },
|
|
520
591
|
},
|
|
592
|
+
{
|
|
593
|
+
// MCN HTML (strict): all Handlebars rules at error severity.
|
|
594
|
+
name: 'sfmc/strict-next-embedded-handlebars-rules',
|
|
595
|
+
plugins: { sfmc: plugin },
|
|
596
|
+
languageOptions: { ...hbsLanguageOptions },
|
|
597
|
+
files: ['**/*.html/*.hbs'],
|
|
598
|
+
rules: { ...hbsNextRules },
|
|
599
|
+
},
|
|
521
600
|
],
|
|
522
|
-
}
|
|
601
|
+
};
|
|
523
602
|
|
|
524
603
|
export default plugin;
|
package/src/processor.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Combined SFMC processor that extracts
|
|
3
|
-
* from HTML files.
|
|
2
|
+
* Combined SFMC processor that extracts AMPscript, SSJS, and Handlebars
|
|
3
|
+
* (Marketing Cloud Next) regions from HTML files.
|
|
4
4
|
*
|
|
5
5
|
* Detects:
|
|
6
6
|
* 1. %%[ ... ]%% blocks (with nesting support) → virtual .amp files
|
|
7
7
|
* 2. %%= ... =%% inline expressions → virtual .amp files
|
|
8
8
|
* 3. <script runat="server" language="ampscript"> → virtual .amp files
|
|
9
9
|
* 4. <script runat="server"> (non-ampscript) → virtual .js files
|
|
10
|
+
* 5. {{ ... }} Handlebars expressions and {!$...} bindings → one virtual .hbs
|
|
11
|
+
* file holding the whole document with AMPscript regions blanked out
|
|
10
12
|
*
|
|
11
13
|
* Returns extracted regions as virtual files for ESLint to lint with the
|
|
12
14
|
* appropriate parser/rules based on file extension matching.
|
|
@@ -19,6 +21,13 @@ const SSJS_SCRIPT_OPEN_RE =
|
|
|
19
21
|
/<script\b(?=[^>]*\brunat\s*=\s*['"]server['"])(?![^>]*\blanguage\s*=\s*['"]ampscript['"])[^>]*>/gi;
|
|
20
22
|
const SCRIPT_CLOSE_G = /<\/script\s*>/gi;
|
|
21
23
|
|
|
24
|
+
/** AMPscript region patterns blanked before the Handlebars parser runs. */
|
|
25
|
+
const AMPSCRIPT_REGION_PATTERNS = [
|
|
26
|
+
/%%\[[\s\S]*?\]%%/g,
|
|
27
|
+
/%%=[\s\S]*?=%%/g,
|
|
28
|
+
/<script\s[^>]*language\s*=\s*["']ampscript["'][^>]*>[\s\S]*?<\/script>/gi,
|
|
29
|
+
];
|
|
30
|
+
|
|
22
31
|
function countNewlinesBefore(text, pos) {
|
|
23
32
|
let count = 0;
|
|
24
33
|
for (let index = 0; index < pos; index++) {
|
|
@@ -29,6 +38,50 @@ function countNewlinesBefore(text, pos) {
|
|
|
29
38
|
return count;
|
|
30
39
|
}
|
|
31
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Scans forward for the closing `=%%` of an inline AMPscript expression.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} text - Full document text.
|
|
45
|
+
* @param {number} from - Index to start scanning from (just past the opening `%%=`).
|
|
46
|
+
* @returns {number} Index just past the closing `=%%`, or -1 when not found.
|
|
47
|
+
*/
|
|
48
|
+
function findInlineExpressionEnd(text, from) {
|
|
49
|
+
for (let index = from; index < text.length; index++) {
|
|
50
|
+
if (text[index] === '=' && text[index + 1] === '%' && text[index + 2] === '%') {
|
|
51
|
+
return index + 3;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return -1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Returns a copy of the document where every AMPscript region is replaced with
|
|
59
|
+
* spaces, preserving newlines and overall offsets. HTML, Handlebars `{{...}}`
|
|
60
|
+
* expressions, and `{!$...}` bindings are kept verbatim so the Handlebars
|
|
61
|
+
* parser can run over a mixed document without choking on AMPscript syntax.
|
|
62
|
+
*
|
|
63
|
+
* Mirrors `getSanitizedHandlebarsText` in sfmc-language-lsp.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} text - Full document text.
|
|
66
|
+
* @returns {string} Text with AMPscript regions blanked out.
|
|
67
|
+
*/
|
|
68
|
+
function sanitizeAmpscriptRegions(text) {
|
|
69
|
+
const chars = Array.from(text);
|
|
70
|
+
for (const pattern of AMPSCRIPT_REGION_PATTERNS) {
|
|
71
|
+
pattern.lastIndex = 0;
|
|
72
|
+
let match;
|
|
73
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
74
|
+
const end = match.index + match[0].length;
|
|
75
|
+
for (let index = match.index; index < end && index < chars.length; index++) {
|
|
76
|
+
if (chars[index] !== '\n' && chars[index] !== '\r') {
|
|
77
|
+
chars[index] = ' ';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return chars.join('');
|
|
83
|
+
}
|
|
84
|
+
|
|
32
85
|
export function preprocess(text, filename) {
|
|
33
86
|
const blocks = [];
|
|
34
87
|
let ampCount = 0;
|
|
@@ -92,23 +145,15 @@ export function preprocess(text, filename) {
|
|
|
92
145
|
|
|
93
146
|
// %%= ... =%%
|
|
94
147
|
if (text[index] === '%' && text[index + 1] === '%' && text[index + 2] === '=') {
|
|
95
|
-
const
|
|
96
|
-
index
|
|
97
|
-
|
|
98
|
-
while (index < text.length) {
|
|
99
|
-
if (text[index] === '=' && text[index + 1] === '%' && text[index + 2] === '%') {
|
|
100
|
-
index += 3;
|
|
101
|
-
found = true;
|
|
102
|
-
break;
|
|
103
|
-
}
|
|
104
|
-
index++;
|
|
105
|
-
}
|
|
106
|
-
if (!found) {
|
|
148
|
+
const expressionStart = index;
|
|
149
|
+
const expressionEnd = findInlineExpressionEnd(text, index + 3);
|
|
150
|
+
if (expressionEnd === -1) {
|
|
107
151
|
continue;
|
|
108
152
|
}
|
|
109
|
-
|
|
153
|
+
index = expressionEnd;
|
|
154
|
+
const innerCode = text.slice(expressionStart + 3, index - 3);
|
|
110
155
|
const wrappedBlock = `%%[${innerCode}]%%`;
|
|
111
|
-
const padding = '\n'.repeat(countNewlinesBefore(text,
|
|
156
|
+
const padding = '\n'.repeat(countNewlinesBefore(text, expressionStart));
|
|
112
157
|
blocks.push({
|
|
113
158
|
text: padding + wrappedBlock,
|
|
114
159
|
filename: `${filename}/ampscript-block-${ampCount++}.amp`,
|
|
@@ -141,6 +186,21 @@ export function preprocess(text, filename) {
|
|
|
141
186
|
});
|
|
142
187
|
}
|
|
143
188
|
|
|
189
|
+
// --- Pass 3: extract Handlebars (MCN) regions ---
|
|
190
|
+
// Handlebars expressions and {!$...} bindings can appear anywhere in the
|
|
191
|
+
// HTML. The Handlebars parser treats surrounding HTML as content, so we emit
|
|
192
|
+
// the whole document (with AMPscript regions blanked, offsets preserved) as
|
|
193
|
+
// a single virtual .hbs file. Only emit it when the document actually
|
|
194
|
+
// contains Handlebars/binding syntax to avoid paying the parse cost on plain
|
|
195
|
+
// HTML/AMPscript content.
|
|
196
|
+
const sanitizedForHbs = sanitizeAmpscriptRegions(text);
|
|
197
|
+
if (sanitizedForHbs.includes('{{') || sanitizedForHbs.includes('{!$')) {
|
|
198
|
+
blocks.push({
|
|
199
|
+
text: sanitizedForHbs,
|
|
200
|
+
filename: `${filename}/handlebars-block-0.hbs`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
144
204
|
if (blocks.length === 0) {
|
|
145
205
|
return [text];
|
|
146
206
|
}
|
|
@@ -25,14 +25,14 @@ const STATIC_LITERAL_TYPES = new Set(['StringLiteral', 'NumberLiteral', 'Boolean
|
|
|
25
25
|
* literal (e.g. a variable or expression) and cannot be validated against an
|
|
26
26
|
* enum.
|
|
27
27
|
*
|
|
28
|
-
* @param {object}
|
|
28
|
+
* @param {object} argument - AMPscript argument AST node.
|
|
29
29
|
* @returns {string | null} The literal value as a string, or null.
|
|
30
30
|
*/
|
|
31
|
-
function staticLiteralValue(
|
|
32
|
-
if (!
|
|
31
|
+
function staticLiteralValue(argument) {
|
|
32
|
+
if (!argument || !STATIC_LITERAL_TYPES.has(argument.type)) {
|
|
33
33
|
return null;
|
|
34
34
|
}
|
|
35
|
-
return String(
|
|
35
|
+
return String(argument.value);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export default {
|
|
@@ -58,28 +58,32 @@ export default {
|
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
for (const [
|
|
62
|
-
const
|
|
63
|
-
if (
|
|
61
|
+
for (const [index, argument] of node.arguments.entries()) {
|
|
62
|
+
const parameter = entry.params[index];
|
|
63
|
+
if (
|
|
64
|
+
!parameter ||
|
|
65
|
+
!Array.isArray(parameter.enum) ||
|
|
66
|
+
parameter.enum.length === 0
|
|
67
|
+
) {
|
|
64
68
|
continue;
|
|
65
69
|
}
|
|
66
70
|
// Only validate static literals (string, number, boolean);
|
|
67
71
|
// variables/expressions cannot be resolved statically.
|
|
68
|
-
const actual = staticLiteralValue(
|
|
72
|
+
const actual = staticLiteralValue(argument);
|
|
69
73
|
if (actual === null) {
|
|
70
74
|
continue;
|
|
71
75
|
}
|
|
72
|
-
const isAllowed =
|
|
76
|
+
const isAllowed = parameter.enum.some(
|
|
73
77
|
(v) => String(v).toLowerCase() === actual.toLowerCase(),
|
|
74
78
|
);
|
|
75
79
|
if (!isAllowed) {
|
|
76
80
|
context.report({
|
|
77
|
-
node:
|
|
81
|
+
node: argument,
|
|
78
82
|
messageId: 'invalidEnumValue',
|
|
79
83
|
data: {
|
|
80
84
|
name: entry.name,
|
|
81
|
-
param:
|
|
82
|
-
allowed:
|
|
85
|
+
param: parameter.name,
|
|
86
|
+
allowed: parameter.enum.join(', '),
|
|
83
87
|
actual,
|
|
84
88
|
},
|
|
85
89
|
});
|
|
@@ -14,7 +14,7 @@ function readIntLiteral(argument) {
|
|
|
14
14
|
// NumberLiteral / StringLiteral nodes both carry the raw value as a string.
|
|
15
15
|
if (argument.type === 'NumberLiteral' || argument.type === 'StringLiteral') {
|
|
16
16
|
const n = Number(argument.value);
|
|
17
|
-
return Number.
|
|
17
|
+
return Number.isSafeInteger(n) && n >= 0 ? n : null;
|
|
18
18
|
}
|
|
19
19
|
return null;
|
|
20
20
|
}
|
|
@@ -25,15 +25,15 @@ function readIntLiteral(argument) {
|
|
|
25
25
|
* complete repeating groups, otherwise null.
|
|
26
26
|
*
|
|
27
27
|
* @param {object} entry - ampscript-data function entry (with `repeat`).
|
|
28
|
-
* @param {object[]}
|
|
28
|
+
* @param {object[]} callArguments - the call's argument AST nodes.
|
|
29
29
|
* @returns {{messageId: string, data: object} | null} Violation descriptor or null.
|
|
30
30
|
*/
|
|
31
|
-
function checkRepeatGroups(entry,
|
|
31
|
+
function checkRepeatGroups(entry, callArguments) {
|
|
32
32
|
const groups = entry.repeat;
|
|
33
33
|
if (!Array.isArray(groups) || groups.length === 0) {
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
36
|
-
const actual =
|
|
36
|
+
const actual = callArguments.length;
|
|
37
37
|
|
|
38
38
|
// Single repeating group: trailing args must be a whole multiple of groupSize.
|
|
39
39
|
if (groups.length === 1) {
|
|
@@ -57,19 +57,19 @@ function checkRepeatGroups(entry, args) {
|
|
|
57
57
|
// Two repeating groups (DataExtension Update/Upsert family): the first group's
|
|
58
58
|
// size is dictated by a countParam literal; the second group fills the rest.
|
|
59
59
|
const [g1, g2] = groups;
|
|
60
|
-
const
|
|
61
|
-
? readIntLiteral(
|
|
60
|
+
const countArgument = g1.countParam
|
|
61
|
+
? readIntLiteral(callArguments[entry.params.findIndex((p) => p.name === g1.countParam)])
|
|
62
62
|
: null;
|
|
63
63
|
|
|
64
|
-
if (
|
|
64
|
+
if (countArgument === null) {
|
|
65
65
|
// countParam is not a literal we can evaluate; fall back to a parity check:
|
|
66
66
|
// every trailing arg pair must be even across both groups.
|
|
67
67
|
const trailing = actual - g1.startIndex;
|
|
68
68
|
return trailing % g1.groupSize === 0 ? null : incompleteGroup(entry, g1.groupSize);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
const
|
|
72
|
-
const group2Start = g1.startIndex +
|
|
71
|
+
const group1Arguments = countArgument * g1.groupSize;
|
|
72
|
+
const group2Start = g1.startIndex + group1Arguments;
|
|
73
73
|
const group2Count = actual - group2Start;
|
|
74
74
|
if (group2Count <= 0 || group2Count % g2.groupSize !== 0) {
|
|
75
75
|
return incompleteGroup(entry, g2.groupSize);
|
|
@@ -29,16 +29,16 @@ export default {
|
|
|
29
29
|
create(context) {
|
|
30
30
|
return {
|
|
31
31
|
InlineExpression(node) {
|
|
32
|
-
const
|
|
33
|
-
if (!
|
|
32
|
+
const expression = node.expression;
|
|
33
|
+
if (!expression) {
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
if (STATEMENT_TYPES.has(
|
|
37
|
+
if (STATEMENT_TYPES.has(expression.type)) {
|
|
38
38
|
context.report({
|
|
39
|
-
node:
|
|
39
|
+
node: expression,
|
|
40
40
|
messageId: 'inlineStatement',
|
|
41
|
-
data: { kind:
|
|
41
|
+
data: { kind: expression.type },
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
44
|
},
|