eslint-plugin-sfmc 2.6.0 → 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.
Files changed (43) hide show
  1. package/README.md +72 -63
  2. package/docs/rules/hbs/helper-arity.md +55 -0
  3. package/docs/rules/hbs/helper-too-new-for-target.md +62 -0
  4. package/docs/rules/hbs/no-unknown-binding.md +49 -0
  5. package/docs/rules/hbs/no-unknown-helper.md +60 -0
  6. package/docs/rules/hbs/no-unsupported-construct.md +65 -0
  7. package/package.json +14 -12
  8. package/src/ampscript-parser.js +1 -1
  9. package/src/ampscript-processor.js +22 -16
  10. package/src/handlebars-parser.js +195 -0
  11. package/src/index.js +90 -11
  12. package/src/processor.js +76 -16
  13. package/src/rules/amp/{arg-types.js → argument-types.js} +16 -12
  14. package/src/rules/amp/function-arity.js +9 -9
  15. package/src/rules/amp/no-inline-statement.js +5 -5
  16. package/src/rules/amp/no-loop-counter-assign.js +7 -7
  17. package/src/rules/amp/no-nested-ampscript-delimiter.js +1 -1
  18. package/src/rules/amp/no-smart-quotes.js +18 -18
  19. package/src/rules/amp/no-unknown-function.js +16 -3
  20. package/src/rules/amp/prefer-attribute-value.js +7 -10
  21. package/src/rules/amp/require-rowcount-check.js +12 -15
  22. package/src/rules/amp/require-variable-declaration.js +3 -3
  23. package/src/rules/hbs/_shared.js +116 -0
  24. package/src/rules/hbs/helper-arity.js +97 -0
  25. package/src/rules/hbs/helper-too-new-for-target.js +94 -0
  26. package/src/rules/hbs/no-unknown-binding.js +74 -0
  27. package/src/rules/hbs/no-unknown-helper.js +79 -0
  28. package/src/rules/hbs/no-unsupported-construct.js +70 -0
  29. package/src/rules/ssjs/cache-loop-length.js +14 -17
  30. package/src/rules/ssjs/no-deprecated-function.js +8 -8
  31. package/src/rules/ssjs/no-hardcoded-credentials.js +3 -6
  32. package/src/rules/ssjs/no-property-call.js +6 -9
  33. package/src/rules/ssjs/no-treatascontent-injection.js +6 -13
  34. package/src/rules/ssjs/no-unavailable-method.js +22 -22
  35. package/src/rules/ssjs/no-unknown-function.js +16 -12
  36. package/src/rules/ssjs/no-unsupported-syntax.js +5 -5
  37. package/src/rules/ssjs/{prefer-parsejson-safe-arg.js → prefer-parsejson-safe-argument.js} +13 -21
  38. package/src/rules/ssjs/require-hasownproperty.js +54 -42
  39. package/src/rules/ssjs/require-platform-load-order.js +1 -1
  40. package/src/rules/ssjs/require-platform-load.js +6 -6
  41. package/src/rules/ssjs/{ssjs-arg-types.js → ssjs-argument-types.js} +59 -46
  42. package/src/rules/ssjs/ssjs-core-method-arity.js +19 -15
  43. /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-var-redeclaration.js';
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 ampArgTypes from './rules/amp/arg-types.js';
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 ssjsPreferParsejsonSafeArg from './rules/ssjs/prefer-parsejson-safe-arg.js';
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 ssjsArgTypes from './rules/ssjs/ssjs-arg-types.js';
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': ampArgTypes,
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': ssjsPreferParsejsonSafeArg,
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': ssjsArgTypes,
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
- Object.assign(plugin.configs, {
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 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
  },