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
package/src/index.js
CHANGED
|
@@ -10,11 +10,13 @@
|
|
|
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
|
|
18
|
+
import ampNoMcnUnsupported from './rules/amp/no-mcn-unsupported.js';
|
|
19
|
+
import ampNoVariableRedeclaration from './rules/amp/no-variable-redeclaration.js';
|
|
18
20
|
import ampSetRequiresTarget from './rules/amp/set-requires-target.js';
|
|
19
21
|
import ampNoEmptyBlock from './rules/amp/no-empty-block.js';
|
|
20
22
|
import ampNoSmartQuotes from './rules/amp/no-smart-quotes.js';
|
|
@@ -23,7 +25,7 @@ import ampNoLoopCounterAssign from './rules/amp/no-loop-counter-assign.js';
|
|
|
23
25
|
import ampNoInlineStatement from './rules/amp/no-inline-statement.js';
|
|
24
26
|
import ampRequireVariableDeclaration from './rules/amp/require-variable-declaration.js';
|
|
25
27
|
import ampFunctionArity from './rules/amp/function-arity.js';
|
|
26
|
-
import
|
|
28
|
+
import ampArgumentTypes from './rules/amp/argument-types.js';
|
|
27
29
|
import ampNoEmailExcludedFunction from './rules/amp/no-email-excluded-function.js';
|
|
28
30
|
import ampNoDeprecatedFunction from './rules/amp/no-deprecated-function.js';
|
|
29
31
|
import ampNamingConvention from './rules/amp/naming-convention.js';
|
|
@@ -39,6 +41,7 @@ import ampNoNestedAmpscriptDelimiter from './rules/amp/no-nested-ampscript-delim
|
|
|
39
41
|
import ssjsRequirePlatformLoad from './rules/ssjs/require-platform-load.js';
|
|
40
42
|
import ssjsNoUnsupportedSyntax from './rules/ssjs/no-unsupported-syntax.js';
|
|
41
43
|
import ssjsNoUnknownFunction from './rules/ssjs/no-unknown-function.js';
|
|
44
|
+
import ssjsNoMcnUnsupported from './rules/ssjs/no-mcn-unsupported.js';
|
|
42
45
|
import ssjsNoDeprecatedFunction from './rules/ssjs/no-deprecated-function.js';
|
|
43
46
|
import ssjsNoPropertyCall from './rules/ssjs/no-property-call.js';
|
|
44
47
|
import ssjsPlatformFunctionArity from './rules/ssjs/platform-function-arity.js';
|
|
@@ -48,12 +51,20 @@ import ssjsRequirePlatformLoadOrder from './rules/ssjs/require-platform-load-ord
|
|
|
48
51
|
import ssjsNoHardcodedCredentials from './rules/ssjs/no-hardcoded-credentials.js';
|
|
49
52
|
import ssjsPreferPlatformLoadVersion from './rules/ssjs/prefer-platform-load-version.js';
|
|
50
53
|
import ssjsNoUnavailableMethod from './rules/ssjs/no-unavailable-method.js';
|
|
51
|
-
import
|
|
54
|
+
import ssjsPreferParsejsonSafeArgument from './rules/ssjs/prefer-parsejson-safe-argument.js';
|
|
52
55
|
import ssjsNoSwitchDefault from './rules/ssjs/no-switch-default.js';
|
|
53
56
|
import ssjsNoTreatAsContentInjection from './rules/ssjs/no-treatascontent-injection.js';
|
|
54
|
-
import
|
|
57
|
+
import ssjsArgumentTypes from './rules/ssjs/ssjs-argument-types.js';
|
|
55
58
|
import ssjsCoreMethodArity from './rules/ssjs/ssjs-core-method-arity.js';
|
|
56
59
|
|
|
60
|
+
// ── Handlebars (MCN) rules ──────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
import hbsNoUnknownHelper from './rules/hbs/no-unknown-helper.js';
|
|
63
|
+
import hbsNoMcnUnsupported from './rules/hbs/no-mcn-unsupported.js';
|
|
64
|
+
import hbsNoUnknownBinding from './rules/hbs/no-unknown-binding.js';
|
|
65
|
+
import hbsHelperArity from './rules/hbs/helper-arity.js';
|
|
66
|
+
import hbsNoUnsupportedConstruct from './rules/hbs/no-unsupported-construct.js';
|
|
67
|
+
|
|
57
68
|
// ── Processors ────────────────────────────────────────────────────────────────
|
|
58
69
|
|
|
59
70
|
import ampscriptProcessor from './ampscript-processor.js';
|
|
@@ -74,6 +85,7 @@ const plugin = {
|
|
|
74
85
|
rules: {
|
|
75
86
|
// AMPscript rules (amp- prefix)
|
|
76
87
|
'amp-no-unknown-function': ampNoUnknownFunction,
|
|
88
|
+
'amp-no-mcn-unsupported': ampNoMcnUnsupported,
|
|
77
89
|
'amp-no-var-redeclaration': ampNoVariableRedeclaration,
|
|
78
90
|
'amp-set-requires-target': ampSetRequiresTarget,
|
|
79
91
|
'amp-no-empty-block': ampNoEmptyBlock,
|
|
@@ -83,7 +95,7 @@ const plugin = {
|
|
|
83
95
|
'amp-no-inline-statement': ampNoInlineStatement,
|
|
84
96
|
'amp-require-variable-declaration': ampRequireVariableDeclaration,
|
|
85
97
|
'amp-function-arity': ampFunctionArity,
|
|
86
|
-
'amp-arg-types':
|
|
98
|
+
'amp-arg-types': ampArgumentTypes,
|
|
87
99
|
'amp-no-email-excluded-function': ampNoEmailExcludedFunction,
|
|
88
100
|
'amp-no-deprecated-function': ampNoDeprecatedFunction,
|
|
89
101
|
'amp-naming-convention': ampNamingConvention,
|
|
@@ -98,6 +110,7 @@ const plugin = {
|
|
|
98
110
|
'ssjs-require-platform-load': ssjsRequirePlatformLoad,
|
|
99
111
|
'ssjs-no-unsupported-syntax': ssjsNoUnsupportedSyntax,
|
|
100
112
|
'ssjs-no-unknown-function': ssjsNoUnknownFunction,
|
|
113
|
+
'ssjs-no-mcn-unsupported': ssjsNoMcnUnsupported,
|
|
101
114
|
'ssjs-no-deprecated-function': ssjsNoDeprecatedFunction,
|
|
102
115
|
'ssjs-no-property-call': ssjsNoPropertyCall,
|
|
103
116
|
'ssjs-platform-function-arity': ssjsPlatformFunctionArity,
|
|
@@ -107,11 +120,18 @@ const plugin = {
|
|
|
107
120
|
'ssjs-no-hardcoded-credentials': ssjsNoHardcodedCredentials,
|
|
108
121
|
'ssjs-prefer-platform-load-version': ssjsPreferPlatformLoadVersion,
|
|
109
122
|
'ssjs-no-unavailable-method': ssjsNoUnavailableMethod,
|
|
110
|
-
'ssjs-prefer-parsejson-safe-arg':
|
|
123
|
+
'ssjs-prefer-parsejson-safe-arg': ssjsPreferParsejsonSafeArgument,
|
|
111
124
|
'ssjs-no-switch-default': ssjsNoSwitchDefault,
|
|
112
125
|
'ssjs-no-treatascontent-injection': ssjsNoTreatAsContentInjection,
|
|
113
|
-
'ssjs-arg-types':
|
|
126
|
+
'ssjs-arg-types': ssjsArgumentTypes,
|
|
114
127
|
'ssjs-core-method-arity': ssjsCoreMethodArity,
|
|
128
|
+
|
|
129
|
+
// Handlebars (MCN) rules (hbs- prefix)
|
|
130
|
+
'hbs-no-unknown-helper': hbsNoUnknownHelper,
|
|
131
|
+
'hbs-no-mcn-unsupported': hbsNoMcnUnsupported,
|
|
132
|
+
'hbs-no-unknown-binding': hbsNoUnknownBinding,
|
|
133
|
+
'hbs-helper-arity': hbsHelperArity,
|
|
134
|
+
'hbs-no-unsupported-construct': hbsNoUnsupportedConstruct,
|
|
115
135
|
},
|
|
116
136
|
|
|
117
137
|
processors: {
|
|
@@ -119,19 +139,19 @@ const plugin = {
|
|
|
119
139
|
ssjs: ssjsProcessor,
|
|
120
140
|
sfmc: combinedProcessor,
|
|
121
141
|
},
|
|
122
|
-
|
|
123
|
-
configs: {},
|
|
124
142
|
};
|
|
125
143
|
|
|
126
144
|
// ── MCN SSJS rule set (all SSJS rules off except ssjs-no-unknown-function) ────
|
|
127
145
|
|
|
128
146
|
/**
|
|
129
147
|
* SSJS rules for MCN targets.
|
|
130
|
-
* All quality rules are disabled — only the presence of SSJS is flagged,
|
|
131
|
-
* because SSJS as a whole must be
|
|
148
|
+
* All quality rules are disabled — only the presence of SSJS is flagged, via the
|
|
149
|
+
* dedicated `ssjs-no-mcn-unsupported` rule, because SSJS as a whole must be
|
|
150
|
+
* deleted when targeting Marketing Cloud Next.
|
|
132
151
|
*/
|
|
133
152
|
const ssjsMcnRules = {
|
|
134
|
-
'sfmc/ssjs-no-
|
|
153
|
+
'sfmc/ssjs-no-mcn-unsupported': 'error',
|
|
154
|
+
'sfmc/ssjs-no-unknown-function': 'off',
|
|
135
155
|
'sfmc/ssjs-require-platform-load': 'off',
|
|
136
156
|
'sfmc/ssjs-no-unsupported-syntax': 'off',
|
|
137
157
|
'sfmc/ssjs-no-deprecated-function': 'off',
|
|
@@ -177,7 +197,7 @@ const ampStrictRules = {
|
|
|
177
197
|
'sfmc/amp-no-unknown-function': 'error',
|
|
178
198
|
'sfmc/amp-no-var-redeclaration': 'error',
|
|
179
199
|
'sfmc/amp-set-requires-target': 'error',
|
|
180
|
-
'sfmc/amp-no-empty-block': '
|
|
200
|
+
'sfmc/amp-no-empty-block': 'warn',
|
|
181
201
|
'sfmc/amp-no-smart-quotes': 'error',
|
|
182
202
|
'sfmc/amp-prefer-attribute-value': 'warn',
|
|
183
203
|
'sfmc/amp-no-loop-counter-assign': 'error',
|
|
@@ -185,8 +205,8 @@ const ampStrictRules = {
|
|
|
185
205
|
'sfmc/amp-require-variable-declaration': 'warn',
|
|
186
206
|
'sfmc/amp-function-arity': 'error',
|
|
187
207
|
'sfmc/amp-arg-types': 'error',
|
|
188
|
-
'sfmc/amp-no-email-excluded-function':
|
|
189
|
-
'sfmc/amp-no-deprecated-function': '
|
|
208
|
+
'sfmc/amp-no-email-excluded-function': 'off',
|
|
209
|
+
'sfmc/amp-no-deprecated-function': 'warn',
|
|
190
210
|
'sfmc/amp-naming-convention': ['error', { format: 'camelCase' }],
|
|
191
211
|
'sfmc/amp-no-empty-then': 'error',
|
|
192
212
|
'sfmc/amp-require-rowcount-check': 'error',
|
|
@@ -230,7 +250,7 @@ const ssjsStrictRules = {
|
|
|
230
250
|
'sfmc/ssjs-require-hasownproperty': 'error',
|
|
231
251
|
'sfmc/ssjs-require-platform-load-order': 'error',
|
|
232
252
|
'sfmc/ssjs-no-hardcoded-credentials': 'error',
|
|
233
|
-
'sfmc/ssjs-prefer-platform-load-version': '
|
|
253
|
+
'sfmc/ssjs-prefer-platform-load-version': 'warn',
|
|
234
254
|
'sfmc/ssjs-no-unavailable-method': 'warn',
|
|
235
255
|
'sfmc/ssjs-prefer-parsejson-safe-arg': 'error',
|
|
236
256
|
'sfmc/ssjs-no-switch-default': 'error',
|
|
@@ -240,9 +260,40 @@ const ssjsStrictRules = {
|
|
|
240
260
|
'no-cond-assign': 'error',
|
|
241
261
|
};
|
|
242
262
|
|
|
263
|
+
// ── Handlebars (MCN) rule sets ────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Handlebars rules for Marketing Cloud Next targets. Handlebars only exists when
|
|
267
|
+
* targeting MCN, so these are enabled only in `-next` configs and applied to the
|
|
268
|
+
* virtual `.hbs` files the combined processor extracts from HTML.
|
|
269
|
+
*/
|
|
270
|
+
const hbsNextRules = {
|
|
271
|
+
'sfmc/hbs-no-unknown-helper': 'error',
|
|
272
|
+
'sfmc/hbs-no-unknown-binding': 'error',
|
|
273
|
+
'sfmc/hbs-helper-arity': 'error',
|
|
274
|
+
'sfmc/hbs-no-unsupported-construct': 'error',
|
|
275
|
+
'sfmc/hbs-no-mcn-unsupported': 'error',
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Handlebars rules for non-MCN (classic) targets — all disabled. Classic SFMC
|
|
280
|
+
* does not run Handlebars, so any `{{...}}` is plain content and must not be
|
|
281
|
+
* flagged.
|
|
282
|
+
*/
|
|
283
|
+
const hbsOffRules = {
|
|
284
|
+
'sfmc/hbs-no-unknown-helper': 'off',
|
|
285
|
+
'sfmc/hbs-no-unknown-binding': 'off',
|
|
286
|
+
'sfmc/hbs-helper-arity': 'off',
|
|
287
|
+
'sfmc/hbs-no-unsupported-construct': 'off',
|
|
288
|
+
'sfmc/hbs-no-mcn-unsupported': 'off',
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/** Shared languageOptions for linting virtual `.hbs` files. */
|
|
292
|
+
const hbsLanguageOptions = { parser: handlebarsParser };
|
|
293
|
+
|
|
243
294
|
// ── Configs (defined after plugin so they can reference it) ───────────────────
|
|
244
295
|
|
|
245
|
-
|
|
296
|
+
plugin.configs = {
|
|
246
297
|
/**
|
|
247
298
|
* AMPscript-only config for standalone .ampscript/.amp files.
|
|
248
299
|
*/
|
|
@@ -323,6 +374,16 @@ Object.assign(plugin.configs, {
|
|
|
323
374
|
},
|
|
324
375
|
rules: { ...ssjsRecommendedRules },
|
|
325
376
|
},
|
|
377
|
+
{
|
|
378
|
+
// Classic (non-MCN) HTML: Handlebars is not executed, so all hbs
|
|
379
|
+
// rules are off. The parser is still required so the extracted .hbs
|
|
380
|
+
// virtual file is not handed to espree.
|
|
381
|
+
name: 'sfmc/embedded-handlebars-rules',
|
|
382
|
+
plugins: { sfmc: plugin },
|
|
383
|
+
languageOptions: { ...hbsLanguageOptions },
|
|
384
|
+
files: ['**/*.html/*.hbs'],
|
|
385
|
+
rules: { ...hbsOffRules },
|
|
386
|
+
},
|
|
326
387
|
],
|
|
327
388
|
|
|
328
389
|
/**
|
|
@@ -371,6 +432,14 @@ Object.assign(plugin.configs, {
|
|
|
371
432
|
},
|
|
372
433
|
rules: { ...ssjsStrictRules },
|
|
373
434
|
},
|
|
435
|
+
{
|
|
436
|
+
// Classic (non-MCN) HTML: Handlebars rules off, parser still wired.
|
|
437
|
+
name: 'sfmc/strict-embedded-handlebars-rules',
|
|
438
|
+
plugins: { sfmc: plugin },
|
|
439
|
+
languageOptions: { ...hbsLanguageOptions },
|
|
440
|
+
files: ['**/*.html/*.hbs'],
|
|
441
|
+
rules: { ...hbsOffRules },
|
|
442
|
+
},
|
|
374
443
|
],
|
|
375
444
|
|
|
376
445
|
// ── Marketing Cloud Next configs ──────────────────────────────────────────
|
|
@@ -386,7 +455,7 @@ Object.assign(plugin.configs, {
|
|
|
386
455
|
files: ['**/*.ampscript', '**/*.amp'],
|
|
387
456
|
rules: {
|
|
388
457
|
...ampRecommendedRules,
|
|
389
|
-
'sfmc/amp-no-
|
|
458
|
+
'sfmc/amp-no-mcn-unsupported': 'error',
|
|
390
459
|
},
|
|
391
460
|
},
|
|
392
461
|
|
|
@@ -417,7 +486,7 @@ Object.assign(plugin.configs, {
|
|
|
417
486
|
files: ['**/*.ampscript', '**/*.amp'],
|
|
418
487
|
rules: {
|
|
419
488
|
...ampRecommendedRules,
|
|
420
|
-
'sfmc/amp-no-
|
|
489
|
+
'sfmc/amp-no-mcn-unsupported': 'error',
|
|
421
490
|
},
|
|
422
491
|
},
|
|
423
492
|
{
|
|
@@ -450,7 +519,7 @@ Object.assign(plugin.configs, {
|
|
|
450
519
|
files: ['**/*.html/*.amp'],
|
|
451
520
|
rules: {
|
|
452
521
|
...ampRecommendedRules,
|
|
453
|
-
'sfmc/amp-no-
|
|
522
|
+
'sfmc/amp-no-mcn-unsupported': 'error',
|
|
454
523
|
},
|
|
455
524
|
},
|
|
456
525
|
{
|
|
@@ -464,6 +533,14 @@ Object.assign(plugin.configs, {
|
|
|
464
533
|
},
|
|
465
534
|
rules: { ...ssjsMcnRules },
|
|
466
535
|
},
|
|
536
|
+
{
|
|
537
|
+
// MCN HTML: Handlebars is the templating language — enable hbs rules.
|
|
538
|
+
name: 'sfmc/embedded-next-handlebars-rules',
|
|
539
|
+
plugins: { sfmc: plugin },
|
|
540
|
+
languageOptions: { ...hbsLanguageOptions },
|
|
541
|
+
files: ['**/*.html/*.hbs'],
|
|
542
|
+
rules: { ...hbsNextRules },
|
|
543
|
+
},
|
|
467
544
|
],
|
|
468
545
|
|
|
469
546
|
/**
|
|
@@ -477,7 +554,7 @@ Object.assign(plugin.configs, {
|
|
|
477
554
|
files: ['**/*.ampscript', '**/*.amp'],
|
|
478
555
|
rules: {
|
|
479
556
|
...ampStrictRules,
|
|
480
|
-
'sfmc/amp-no-
|
|
557
|
+
'sfmc/amp-no-mcn-unsupported': 'error',
|
|
481
558
|
},
|
|
482
559
|
},
|
|
483
560
|
{
|
|
@@ -504,7 +581,7 @@ Object.assign(plugin.configs, {
|
|
|
504
581
|
files: ['**/*.html/*.amp'],
|
|
505
582
|
rules: {
|
|
506
583
|
...ampStrictRules,
|
|
507
|
-
'sfmc/amp-no-
|
|
584
|
+
'sfmc/amp-no-mcn-unsupported': 'error',
|
|
508
585
|
},
|
|
509
586
|
},
|
|
510
587
|
{
|
|
@@ -518,7 +595,15 @@ Object.assign(plugin.configs, {
|
|
|
518
595
|
},
|
|
519
596
|
rules: { ...ssjsMcnRules },
|
|
520
597
|
},
|
|
598
|
+
{
|
|
599
|
+
// MCN HTML (strict): all Handlebars rules at error severity.
|
|
600
|
+
name: 'sfmc/strict-next-embedded-handlebars-rules',
|
|
601
|
+
plugins: { sfmc: plugin },
|
|
602
|
+
languageOptions: { ...hbsLanguageOptions },
|
|
603
|
+
files: ['**/*.html/*.hbs'],
|
|
604
|
+
rules: { ...hbsNextRules },
|
|
605
|
+
},
|
|
521
606
|
],
|
|
522
|
-
}
|
|
607
|
+
};
|
|
523
608
|
|
|
524
609
|
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
|
},
|
|
@@ -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,
|