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.
Files changed (49) hide show
  1. package/README.md +77 -65
  2. package/docs/rules/amp/no-mcn-unsupported.md +76 -0
  3. package/docs/rules/amp/no-unknown-function.md +2 -39
  4. package/docs/rules/hbs/helper-arity.md +55 -0
  5. package/docs/rules/hbs/no-mcn-unsupported.md +69 -0
  6. package/docs/rules/hbs/no-unknown-binding.md +49 -0
  7. package/docs/rules/hbs/no-unknown-helper.md +60 -0
  8. package/docs/rules/hbs/no-unsupported-construct.md +65 -0
  9. package/docs/rules/ssjs/no-mcn-unsupported.md +68 -0
  10. package/docs/rules/ssjs/no-unknown-function.md +4 -31
  11. package/package.json +14 -12
  12. package/src/ampscript-parser.js +1 -1
  13. package/src/ampscript-processor.js +22 -16
  14. package/src/handlebars-parser.js +195 -0
  15. package/src/index.js +108 -23
  16. package/src/processor.js +76 -16
  17. package/src/rules/amp/{arg-types.js → argument-types.js} +16 -12
  18. package/src/rules/amp/function-arity.js +9 -9
  19. package/src/rules/amp/no-inline-statement.js +5 -5
  20. package/src/rules/amp/no-loop-counter-assign.js +7 -7
  21. package/src/rules/amp/no-mcn-unsupported.js +102 -0
  22. package/src/rules/amp/no-nested-ampscript-delimiter.js +58 -66
  23. package/src/rules/amp/no-smart-quotes.js +41 -47
  24. package/src/rules/amp/no-unknown-function.js +2 -28
  25. package/src/rules/amp/prefer-attribute-value.js +7 -10
  26. package/src/rules/amp/require-rowcount-check.js +12 -15
  27. package/src/rules/amp/require-variable-declaration.js +3 -3
  28. package/src/rules/hbs/_shared.js +116 -0
  29. package/src/rules/hbs/helper-arity.js +97 -0
  30. package/src/rules/hbs/no-mcn-unsupported.js +164 -0
  31. package/src/rules/hbs/no-unknown-binding.js +74 -0
  32. package/src/rules/hbs/no-unknown-helper.js +79 -0
  33. package/src/rules/hbs/no-unsupported-construct.js +70 -0
  34. package/src/rules/ssjs/cache-loop-length.js +14 -17
  35. package/src/rules/ssjs/no-deprecated-function.js +8 -8
  36. package/src/rules/ssjs/no-hardcoded-credentials.js +3 -6
  37. package/src/rules/ssjs/no-mcn-unsupported.js +182 -0
  38. package/src/rules/ssjs/no-property-call.js +6 -9
  39. package/src/rules/ssjs/no-treatascontent-injection.js +6 -13
  40. package/src/rules/ssjs/no-unavailable-method.js +22 -22
  41. package/src/rules/ssjs/no-unknown-function.js +15 -59
  42. package/src/rules/ssjs/no-unsupported-syntax.js +5 -5
  43. package/src/rules/ssjs/{prefer-parsejson-safe-arg.js → prefer-parsejson-safe-argument.js} +13 -21
  44. package/src/rules/ssjs/require-hasownproperty.js +54 -42
  45. package/src/rules/ssjs/require-platform-load-order.js +1 -1
  46. package/src/rules/ssjs/require-platform-load.js +6 -6
  47. package/src/rules/ssjs/{ssjs-arg-types.js → ssjs-argument-types.js} +59 -46
  48. package/src/rules/ssjs/ssjs-core-method-arity.js +41 -15
  49. /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 ampNoVariableRedeclaration from './rules/amp/no-var-redeclaration.js';
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 ampArgTypes from './rules/amp/arg-types.js';
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 ssjsPreferParsejsonSafeArg from './rules/ssjs/prefer-parsejson-safe-arg.js';
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 ssjsArgTypes from './rules/ssjs/ssjs-arg-types.js';
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': ampArgTypes,
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': ssjsPreferParsejsonSafeArg,
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': ssjsArgTypes,
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 deleted when targeting Marketing Cloud Next.
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-unknown-function': ['error', { target: 'next' }],
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': 'error',
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': ['error', { context: 'email' }],
189
- 'sfmc/amp-no-deprecated-function': 'error',
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': 'error',
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
- Object.assign(plugin.configs, {
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-unknown-function': ['error', { target: 'next' }],
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-unknown-function': ['error', { target: 'next' }],
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-unknown-function': ['error', { target: 'next' }],
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-unknown-function': ['error', { target: 'next' }],
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-unknown-function': ['error', { target: 'next' }],
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 both AMPscript and SSJS regions
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 exprStart = index;
96
- index += 3;
97
- let found = false;
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
- const innerCode = text.slice(exprStart + 3, index - 3);
153
+ index = expressionEnd;
154
+ const innerCode = text.slice(expressionStart + 3, index - 3);
110
155
  const wrappedBlock = `%%[${innerCode}]%%`;
111
- const padding = '\n'.repeat(countNewlinesBefore(text, exprStart));
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} arg - AMPscript argument AST node.
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(arg) {
32
- if (!arg || !STATIC_LITERAL_TYPES.has(arg.type)) {
31
+ function staticLiteralValue(argument) {
32
+ if (!argument || !STATIC_LITERAL_TYPES.has(argument.type)) {
33
33
  return null;
34
34
  }
35
- return String(arg.value);
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 [i, arg] of node.arguments.entries()) {
62
- const param = entry.params[i];
63
- if (!param || !Array.isArray(param.enum) || param.enum.length === 0) {
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(arg);
72
+ const actual = staticLiteralValue(argument);
69
73
  if (actual === null) {
70
74
  continue;
71
75
  }
72
- const isAllowed = param.enum.some(
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: arg,
81
+ node: argument,
78
82
  messageId: 'invalidEnumValue',
79
83
  data: {
80
84
  name: entry.name,
81
- param: param.name,
82
- allowed: param.enum.join(', '),
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.isInteger(n) && n >= 0 ? n : null;
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[]} args - the call's argument AST nodes.
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, args) {
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 = args.length;
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 countArg = g1.countParam
61
- ? readIntLiteral(args[entry.params.findIndex((p) => p.name === g1.countParam)])
60
+ const countArgument = g1.countParam
61
+ ? readIntLiteral(callArguments[entry.params.findIndex((p) => p.name === g1.countParam)])
62
62
  : null;
63
63
 
64
- if (countArg === null) {
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 group1Args = countArg * g1.groupSize;
72
- const group2Start = g1.startIndex + group1Args;
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 expr = node.expression;
33
- if (!expr) {
32
+ const expression = node.expression;
33
+ if (!expression) {
34
34
  return;
35
35
  }
36
36
 
37
- if (STATEMENT_TYPES.has(expr.type)) {
37
+ if (STATEMENT_TYPES.has(expression.type)) {
38
38
  context.report({
39
- node: expr,
39
+ node: expression,
40
40
  messageId: 'inlineStatement',
41
- data: { kind: expr.type },
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 stmt of body) {
27
- if (stmt.type === 'SetStatement' && stmt.target) {
26
+ for (const statement of body) {
27
+ if (statement.type === 'SetStatement' && statement.target) {
28
28
  const current = counterStack.at(-1);
29
- if (current && stmt.target.value.toLowerCase() === current.toLowerCase()) {
29
+ if (current && statement.target.value.toLowerCase() === current.toLowerCase()) {
30
30
  context.report({
31
- node: stmt.target,
31
+ node: statement.target,
32
32
  messageId: 'counterAssign',
33
- data: { name: stmt.target.value },
33
+ data: { name: statement.target.value },
34
34
  });
35
35
  }
36
36
  }
37
- if (stmt.type === 'VarDeclaration') {
37
+ if (statement.type === 'VarDeclaration') {
38
38
  const current = counterStack.at(-1);
39
39
  if (current) {
40
- for (const v of stmt.variables) {
40
+ for (const v of statement.variables) {
41
41
  if (v.value.toLowerCase() === current.toLowerCase()) {
42
42
  context.report({
43
43
  node: v,