eslint-plugin-sfmc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/docs/rules/amp/function-arity.md +53 -0
  4. package/docs/rules/amp/naming-convention.md +76 -0
  5. package/docs/rules/amp/no-deprecated-function.md +78 -0
  6. package/docs/rules/amp/no-email-excluded-function.md +69 -0
  7. package/docs/rules/amp/no-empty-block.md +59 -0
  8. package/docs/rules/amp/no-empty-then.md +62 -0
  9. package/docs/rules/amp/no-html-comment.md +67 -0
  10. package/docs/rules/amp/no-inline-statement.md +51 -0
  11. package/docs/rules/amp/no-js-line-comment.md +54 -0
  12. package/docs/rules/amp/no-loop-counter-assign.md +52 -0
  13. package/docs/rules/amp/no-nested-ampscript-delimiter.md +69 -0
  14. package/docs/rules/amp/no-nested-script-tag.md +58 -0
  15. package/docs/rules/amp/no-smart-quotes.md +60 -0
  16. package/docs/rules/amp/no-unknown-function.md +50 -0
  17. package/docs/rules/amp/no-var-redeclaration.md +52 -0
  18. package/docs/rules/amp/prefer-attribute-value.md +64 -0
  19. package/docs/rules/amp/require-rowcount-check.md +62 -0
  20. package/docs/rules/amp/require-variable-declaration.md +51 -0
  21. package/docs/rules/amp/set-requires-target.md +47 -0
  22. package/docs/rules/ssjs/cache-loop-length.md +59 -0
  23. package/docs/rules/ssjs/no-hardcoded-credentials.md +52 -0
  24. package/docs/rules/ssjs/no-switch-default.md +37 -0
  25. package/docs/rules/ssjs/no-treatascontent-injection.md +38 -0
  26. package/docs/rules/ssjs/no-unavailable-method.md +134 -0
  27. package/docs/rules/ssjs/no-unknown-core-method.md +46 -0
  28. package/docs/rules/ssjs/no-unknown-http-method.md +44 -0
  29. package/docs/rules/ssjs/no-unknown-platform-client-browser.md +44 -0
  30. package/docs/rules/ssjs/no-unknown-platform-function.md +44 -0
  31. package/docs/rules/ssjs/no-unknown-platform-request.md +43 -0
  32. package/docs/rules/ssjs/no-unknown-platform-response.md +43 -0
  33. package/docs/rules/ssjs/no-unknown-platform-variable.md +43 -0
  34. package/docs/rules/ssjs/no-unknown-wsproxy-method.md +46 -0
  35. package/docs/rules/ssjs/no-unsupported-syntax.md +83 -0
  36. package/docs/rules/ssjs/platform-function-arity.md +45 -0
  37. package/docs/rules/ssjs/prefer-parsejson-safe-arg.md +42 -0
  38. package/docs/rules/ssjs/prefer-platform-load-version.md +68 -0
  39. package/docs/rules/ssjs/require-hasownproperty.md +57 -0
  40. package/docs/rules/ssjs/require-platform-load-order.md +46 -0
  41. package/docs/rules/ssjs/require-platform-load.md +57 -0
  42. package/package.json +52 -0
  43. package/src/ampscript-parser.js +159 -0
  44. package/src/ampscript-processor.js +132 -0
  45. package/src/index.js +355 -0
  46. package/src/processor.js +147 -0
  47. package/src/rules/amp/function-arity.js +51 -0
  48. package/src/rules/amp/naming-convention.js +58 -0
  49. package/src/rules/amp/no-deprecated-function.js +75 -0
  50. package/src/rules/amp/no-email-excluded-function.js +72 -0
  51. package/src/rules/amp/no-empty-block.js +37 -0
  52. package/src/rules/amp/no-empty-then.js +34 -0
  53. package/src/rules/amp/no-html-comment.js +57 -0
  54. package/src/rules/amp/no-inline-statement.js +45 -0
  55. package/src/rules/amp/no-js-line-comment.js +54 -0
  56. package/src/rules/amp/no-loop-counter-assign.js +71 -0
  57. package/src/rules/amp/no-nested-ampscript-delimiter.js +102 -0
  58. package/src/rules/amp/no-nested-script-tag.js +65 -0
  59. package/src/rules/amp/no-smart-quotes.js +78 -0
  60. package/src/rules/amp/no-unknown-function.js +31 -0
  61. package/src/rules/amp/no-var-redeclaration.js +36 -0
  62. package/src/rules/amp/prefer-attribute-value.js +94 -0
  63. package/src/rules/amp/require-rowcount-check.js +116 -0
  64. package/src/rules/amp/require-variable-declaration.js +49 -0
  65. package/src/rules/amp/set-requires-target.js +27 -0
  66. package/src/rules/ssjs/cache-loop-length.js +92 -0
  67. package/src/rules/ssjs/no-hardcoded-credentials.js +66 -0
  68. package/src/rules/ssjs/no-switch-default.js +32 -0
  69. package/src/rules/ssjs/no-treatascontent-injection.js +76 -0
  70. package/src/rules/ssjs/no-unavailable-method.js +190 -0
  71. package/src/rules/ssjs/no-unknown-core-method.js +103 -0
  72. package/src/rules/ssjs/no-unknown-http-method.js +41 -0
  73. package/src/rules/ssjs/no-unknown-platform-client-browser.js +50 -0
  74. package/src/rules/ssjs/no-unknown-platform-function.js +49 -0
  75. package/src/rules/ssjs/no-unknown-platform-request.js +50 -0
  76. package/src/rules/ssjs/no-unknown-platform-response.js +50 -0
  77. package/src/rules/ssjs/no-unknown-platform-variable.js +50 -0
  78. package/src/rules/ssjs/no-unknown-wsproxy-method.js +71 -0
  79. package/src/rules/ssjs/no-unsupported-syntax.js +122 -0
  80. package/src/rules/ssjs/platform-function-arity.js +70 -0
  81. package/src/rules/ssjs/prefer-parsejson-safe-arg.js +94 -0
  82. package/src/rules/ssjs/prefer-platform-load-version.js +96 -0
  83. package/src/rules/ssjs/require-hasownproperty.js +142 -0
  84. package/src/rules/ssjs/require-platform-load-order.js +112 -0
  85. package/src/rules/ssjs/require-platform-load.js +124 -0
  86. package/src/ssjs-processor.js +48 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * ESLint processor that extracts AMPscript regions from HTML files.
3
+ *
4
+ * Detects:
5
+ * 1. %%[ ... ]%% blocks (with nesting support)
6
+ * 2. %%= ... =%% inline expressions
7
+ * 3. <script runat="server" language="ampscript"> ... </script> tags
8
+ *
9
+ * Returns extracted regions as virtual .amp files for the AMPscript parser.
10
+ * Line offsets are preserved so ESLint reports errors at correct locations.
11
+ */
12
+
13
+ const SCRIPT_OPEN_RE =
14
+ /^<script\b(?=[^>]*\brunat\s*=\s*['"]server['"])(?=[^>]*\blanguage\s*=\s*['"]ampscript['"])[^>]*>/i;
15
+ const SCRIPT_CLOSE_G = /<\/script\s*>/gi;
16
+
17
+ function countNewlinesBefore(text, pos) {
18
+ let count = 0;
19
+ for (let index = 0; index < pos; index++) {
20
+ if (text[index] === '\n') count++;
21
+ }
22
+ return count;
23
+ }
24
+
25
+ export function preprocess(text, filename) {
26
+ const blocks = [];
27
+ let index = 0;
28
+
29
+ while (index < text.length) {
30
+ // <script runat="server" language="ampscript"> ... </script>
31
+ if (text[index] === '<') {
32
+ const openMatch = SCRIPT_OPEN_RE.exec(text.slice(index));
33
+ if (openMatch) {
34
+ const openTagEnd = index + openMatch[0].length;
35
+
36
+ SCRIPT_CLOSE_G.lastIndex = openTagEnd;
37
+ const closeMatch = SCRIPT_CLOSE_G.exec(text);
38
+ if (closeMatch) {
39
+ const blockEnd = closeMatch.index + closeMatch[0].length;
40
+ const fullBlock = text.slice(index, blockEnd);
41
+ const padding = '\n'.repeat(countNewlinesBefore(text, index));
42
+
43
+ blocks.push({
44
+ text: padding + fullBlock,
45
+ filename: `${filename}/ampscript-block-${blocks.length}.amp`,
46
+ });
47
+
48
+ index = blockEnd;
49
+ continue;
50
+ }
51
+ }
52
+ }
53
+
54
+ // %%[ ... ]%%
55
+ if (text[index] === '%' && text[index + 1] === '%' && text[index + 2] === '[') {
56
+ const blockStart = index;
57
+ index += 3;
58
+ let depth = 1;
59
+
60
+ while (index < text.length && depth > 0) {
61
+ if (text[index] === '%' && text[index + 1] === '%' && text[index + 2] === '[') {
62
+ depth++;
63
+ index += 3;
64
+ } else if (
65
+ text[index] === ']' &&
66
+ text[index + 1] === '%' &&
67
+ text[index + 2] === '%'
68
+ ) {
69
+ depth--;
70
+ index += 3;
71
+ } else {
72
+ index++;
73
+ }
74
+ }
75
+
76
+ if (depth !== 0) continue;
77
+
78
+ const fullBlock = text.slice(blockStart, index);
79
+ const padding = '\n'.repeat(countNewlinesBefore(text, blockStart));
80
+
81
+ blocks.push({
82
+ text: padding + fullBlock,
83
+ filename: `${filename}/ampscript-block-${blocks.length}.amp`,
84
+ });
85
+ continue;
86
+ }
87
+
88
+ // %%= ... =%%
89
+ if (text[index] === '%' && text[index + 1] === '%' && text[index + 2] === '=') {
90
+ const exprStart = index;
91
+ index += 3;
92
+ let found = false;
93
+
94
+ while (index < text.length) {
95
+ if (text[index] === '=' && text[index + 1] === '%' && text[index + 2] === '%') {
96
+ index += 3;
97
+ found = true;
98
+ break;
99
+ }
100
+ index++;
101
+ }
102
+
103
+ if (!found) continue;
104
+
105
+ // Wrap in %%[ ]%% so the AMPscript parser receives valid block syntax.
106
+ // %%[ is 3 chars, same as %%= — column offsets for inner code are preserved.
107
+ const innerCode = text.slice(exprStart + 3, index - 3);
108
+ const wrappedBlock = `%%[${innerCode}]%%`;
109
+ const padding = '\n'.repeat(countNewlinesBefore(text, exprStart));
110
+
111
+ blocks.push({
112
+ text: padding + wrappedBlock,
113
+ filename: `${filename}/ampscript-block-${blocks.length}.amp`,
114
+ });
115
+ continue;
116
+ }
117
+
118
+ index++;
119
+ }
120
+
121
+ if (blocks.length === 0) {
122
+ return [text];
123
+ }
124
+
125
+ return blocks;
126
+ }
127
+
128
+ export function postprocess(messages) {
129
+ return messages.flat();
130
+ }
131
+
132
+ export default { preprocess, postprocess, supportsAutofix: false };
package/src/index.js ADDED
@@ -0,0 +1,355 @@
1
+ /**
2
+ * eslint-plugin-sfmc
3
+ *
4
+ * Unified ESLint plugin for Salesforce Marketing Cloud.
5
+ * Provides linting rules for both AMPscript and Server-Side JavaScript (SSJS).
6
+ *
7
+ * Designed to complement:
8
+ * - prettier-plugin-sfmc (formatting)
9
+ * - vscode-sfmc-language (IntelliSense, diagnostics)
10
+ */
11
+
12
+ import { SSJS_GLOBALS_MAP } from 'ssjs-data';
13
+ import * as ampscriptParser from './ampscript-parser.js';
14
+
15
+ // ── AMPscript rules ───────────────────────────────────────────────────────────
16
+
17
+ import ampNoUnknownFunction from './rules/amp/no-unknown-function.js';
18
+ import ampNoVariableRedeclaration from './rules/amp/no-var-redeclaration.js';
19
+ import ampSetRequiresTarget from './rules/amp/set-requires-target.js';
20
+ import ampNoEmptyBlock from './rules/amp/no-empty-block.js';
21
+ import ampNoSmartQuotes from './rules/amp/no-smart-quotes.js';
22
+ import ampPreferAttributeValue from './rules/amp/prefer-attribute-value.js';
23
+ import ampNoLoopCounterAssign from './rules/amp/no-loop-counter-assign.js';
24
+ import ampNoInlineStatement from './rules/amp/no-inline-statement.js';
25
+ import ampRequireVariableDeclaration from './rules/amp/require-variable-declaration.js';
26
+ import ampFunctionArity from './rules/amp/function-arity.js';
27
+ import ampNoEmailExcludedFunction from './rules/amp/no-email-excluded-function.js';
28
+ import ampNoDeprecatedFunction from './rules/amp/no-deprecated-function.js';
29
+ import ampNamingConvention from './rules/amp/naming-convention.js';
30
+ import ampNoEmptyThen from './rules/amp/no-empty-then.js';
31
+ import ampRequireRowcountCheck from './rules/amp/require-rowcount-check.js';
32
+ import ampNoHtmlComment from './rules/amp/no-html-comment.js';
33
+ import ampNoJsLineComment from './rules/amp/no-js-line-comment.js';
34
+ import ampNoNestedScriptTag from './rules/amp/no-nested-script-tag.js';
35
+ import ampNoNestedAmpscriptDelimiter from './rules/amp/no-nested-ampscript-delimiter.js';
36
+
37
+ // ── SSJS rules ────────────────────────────────────────────────────────────────
38
+
39
+ import ssjsRequirePlatformLoad from './rules/ssjs/require-platform-load.js';
40
+ import ssjsNoUnsupportedSyntax from './rules/ssjs/no-unsupported-syntax.js';
41
+ import ssjsNoUnknownPlatformFunction from './rules/ssjs/no-unknown-platform-function.js';
42
+ import ssjsNoUnknownCoreMethod from './rules/ssjs/no-unknown-core-method.js';
43
+ import ssjsPlatformFunctionArity from './rules/ssjs/platform-function-arity.js';
44
+ import ssjsNoUnknownHttpMethod from './rules/ssjs/no-unknown-http-method.js';
45
+ import ssjsNoUnknownWsproxyMethod from './rules/ssjs/no-unknown-wsproxy-method.js';
46
+ import ssjsNoUnknownPlatformVariable from './rules/ssjs/no-unknown-platform-variable.js';
47
+ import ssjsNoUnknownPlatformResponse from './rules/ssjs/no-unknown-platform-response.js';
48
+ import ssjsNoUnknownPlatformRequest from './rules/ssjs/no-unknown-platform-request.js';
49
+ import ssjsNoUnknownPlatformClientBrowser from './rules/ssjs/no-unknown-platform-client-browser.js';
50
+ import ssjsCacheLoopLength from './rules/ssjs/cache-loop-length.js';
51
+ import ssjsRequireHasownproperty from './rules/ssjs/require-hasownproperty.js';
52
+ import ssjsRequirePlatformLoadOrder from './rules/ssjs/require-platform-load-order.js';
53
+ import ssjsNoHardcodedCredentials from './rules/ssjs/no-hardcoded-credentials.js';
54
+ import ssjsPreferPlatformLoadVersion from './rules/ssjs/prefer-platform-load-version.js';
55
+ import ssjsNoUnavailableMethod from './rules/ssjs/no-unavailable-method.js';
56
+ import ssjsPreferParsejsonSafeArg from './rules/ssjs/prefer-parsejson-safe-arg.js';
57
+ import ssjsNoSwitchDefault from './rules/ssjs/no-switch-default.js';
58
+ import ssjsNoTreatAsContentInjection from './rules/ssjs/no-treatascontent-injection.js';
59
+
60
+ // ── Processors ────────────────────────────────────────────────────────────────
61
+
62
+ import ampscriptProcessor from './ampscript-processor.js';
63
+ import ssjsProcessor from './ssjs-processor.js';
64
+ import combinedProcessor from './processor.js';
65
+
66
+ // ── Plugin definition ─────────────────────────────────────────────────────────
67
+
68
+ const plugin = {
69
+ meta: {
70
+ name: 'eslint-plugin-sfmc',
71
+ version: '0.1.0',
72
+ },
73
+
74
+ rules: {
75
+ // AMPscript rules (amp- prefix)
76
+ 'amp-no-unknown-function': ampNoUnknownFunction,
77
+ 'amp-no-var-redeclaration': ampNoVariableRedeclaration,
78
+ 'amp-set-requires-target': ampSetRequiresTarget,
79
+ 'amp-no-empty-block': ampNoEmptyBlock,
80
+ 'amp-no-smart-quotes': ampNoSmartQuotes,
81
+ 'amp-prefer-attribute-value': ampPreferAttributeValue,
82
+ 'amp-no-loop-counter-assign': ampNoLoopCounterAssign,
83
+ 'amp-no-inline-statement': ampNoInlineStatement,
84
+ 'amp-require-variable-declaration': ampRequireVariableDeclaration,
85
+ 'amp-function-arity': ampFunctionArity,
86
+ 'amp-no-email-excluded-function': ampNoEmailExcludedFunction,
87
+ 'amp-no-deprecated-function': ampNoDeprecatedFunction,
88
+ 'amp-naming-convention': ampNamingConvention,
89
+ 'amp-no-empty-then': ampNoEmptyThen,
90
+ 'amp-require-rowcount-check': ampRequireRowcountCheck,
91
+ 'amp-no-html-comment': ampNoHtmlComment,
92
+ 'amp-no-js-line-comment': ampNoJsLineComment,
93
+ 'amp-no-nested-script-tag': ampNoNestedScriptTag,
94
+ 'amp-no-nested-ampscript-delimiter': ampNoNestedAmpscriptDelimiter,
95
+
96
+ // SSJS rules (ssjs- prefix)
97
+ 'ssjs-require-platform-load': ssjsRequirePlatformLoad,
98
+ 'ssjs-no-unsupported-syntax': ssjsNoUnsupportedSyntax,
99
+ 'ssjs-no-unknown-platform-function': ssjsNoUnknownPlatformFunction,
100
+ 'ssjs-no-unknown-core-method': ssjsNoUnknownCoreMethod,
101
+ 'ssjs-platform-function-arity': ssjsPlatformFunctionArity,
102
+ 'ssjs-no-unknown-http-method': ssjsNoUnknownHttpMethod,
103
+ 'ssjs-no-unknown-wsproxy-method': ssjsNoUnknownWsproxyMethod,
104
+ 'ssjs-no-unknown-platform-variable': ssjsNoUnknownPlatformVariable,
105
+ 'ssjs-no-unknown-platform-response': ssjsNoUnknownPlatformResponse,
106
+ 'ssjs-no-unknown-platform-request': ssjsNoUnknownPlatformRequest,
107
+ 'ssjs-no-unknown-platform-client-browser': ssjsNoUnknownPlatformClientBrowser,
108
+ 'ssjs-cache-loop-length': ssjsCacheLoopLength,
109
+ 'ssjs-require-hasownproperty': ssjsRequireHasownproperty,
110
+ 'ssjs-require-platform-load-order': ssjsRequirePlatformLoadOrder,
111
+ 'ssjs-no-hardcoded-credentials': ssjsNoHardcodedCredentials,
112
+ 'ssjs-prefer-platform-load-version': ssjsPreferPlatformLoadVersion,
113
+ 'ssjs-no-unavailable-method': ssjsNoUnavailableMethod,
114
+ 'ssjs-prefer-parsejson-safe-arg': ssjsPreferParsejsonSafeArg,
115
+ 'ssjs-no-switch-default': ssjsNoSwitchDefault,
116
+ 'ssjs-no-treatascontent-injection': ssjsNoTreatAsContentInjection,
117
+ },
118
+
119
+ processors: {
120
+ ampscript: ampscriptProcessor,
121
+ ssjs: ssjsProcessor,
122
+ sfmc: combinedProcessor,
123
+ },
124
+
125
+ configs: {},
126
+ };
127
+
128
+ // ── AMPscript rule sets ───────────────────────────────────────────────────────
129
+
130
+ const ampRecommendedRules = {
131
+ 'sfmc/amp-no-unknown-function': 'error',
132
+ 'sfmc/amp-no-var-redeclaration': 'warn',
133
+ 'sfmc/amp-set-requires-target': 'error',
134
+ 'sfmc/amp-no-empty-block': 'warn',
135
+ 'sfmc/amp-no-smart-quotes': 'error',
136
+ 'sfmc/amp-no-loop-counter-assign': 'warn',
137
+ 'sfmc/amp-no-inline-statement': 'warn',
138
+ 'sfmc/amp-function-arity': 'error',
139
+ 'sfmc/amp-no-deprecated-function': 'warn',
140
+ 'sfmc/amp-naming-convention': 'warn',
141
+ 'sfmc/amp-no-empty-then': 'warn',
142
+ 'sfmc/amp-require-rowcount-check': 'warn',
143
+ 'sfmc/amp-no-html-comment': 'warn',
144
+ 'sfmc/amp-no-js-line-comment': 'warn',
145
+ 'sfmc/amp-no-nested-script-tag': 'error',
146
+ 'sfmc/amp-no-nested-ampscript-delimiter': 'error',
147
+ };
148
+
149
+ const ampStrictRules = {
150
+ 'sfmc/amp-no-unknown-function': 'error',
151
+ 'sfmc/amp-no-var-redeclaration': 'error',
152
+ 'sfmc/amp-set-requires-target': 'error',
153
+ 'sfmc/amp-no-empty-block': 'error',
154
+ 'sfmc/amp-no-smart-quotes': 'error',
155
+ 'sfmc/amp-prefer-attribute-value': 'warn',
156
+ 'sfmc/amp-no-loop-counter-assign': 'error',
157
+ 'sfmc/amp-no-inline-statement': 'error',
158
+ 'sfmc/amp-require-variable-declaration': 'warn',
159
+ 'sfmc/amp-function-arity': 'error',
160
+ 'sfmc/amp-no-email-excluded-function': ['error', { context: 'email' }],
161
+ 'sfmc/amp-no-deprecated-function': 'error',
162
+ 'sfmc/amp-naming-convention': ['error', { format: 'camelCase' }],
163
+ 'sfmc/amp-no-empty-then': 'error',
164
+ 'sfmc/amp-require-rowcount-check': 'error',
165
+ 'sfmc/amp-no-html-comment': 'error',
166
+ 'sfmc/amp-no-js-line-comment': 'error',
167
+ 'sfmc/amp-no-nested-script-tag': 'error',
168
+ 'sfmc/amp-no-nested-ampscript-delimiter': 'error',
169
+ };
170
+
171
+ // ── SSJS rule sets ────────────────────────────────────────────────────────────
172
+
173
+ const ssjsRecommendedRules = {
174
+ 'sfmc/ssjs-require-platform-load': 'error',
175
+ 'sfmc/ssjs-no-unsupported-syntax': 'error',
176
+ 'sfmc/ssjs-no-unknown-platform-function': 'error',
177
+ 'sfmc/ssjs-no-unknown-core-method': 'warn',
178
+ 'sfmc/ssjs-platform-function-arity': 'error',
179
+ 'sfmc/ssjs-no-unknown-http-method': 'error',
180
+ 'sfmc/ssjs-no-unknown-wsproxy-method': 'warn',
181
+ 'sfmc/ssjs-no-unknown-platform-variable': 'error',
182
+ 'sfmc/ssjs-no-unknown-platform-response': 'error',
183
+ 'sfmc/ssjs-no-unknown-platform-request': 'error',
184
+ 'sfmc/ssjs-no-unknown-platform-client-browser': 'error',
185
+ 'sfmc/ssjs-cache-loop-length': 'warn',
186
+ 'sfmc/ssjs-require-hasownproperty': 'warn',
187
+ 'sfmc/ssjs-require-platform-load-order': 'error',
188
+ 'sfmc/ssjs-no-hardcoded-credentials': 'error',
189
+ 'sfmc/ssjs-prefer-platform-load-version': 'warn',
190
+ 'sfmc/ssjs-no-unavailable-method': 'warn',
191
+ 'sfmc/ssjs-prefer-parsejson-safe-arg': 'warn',
192
+ 'sfmc/ssjs-no-switch-default': 'warn',
193
+ 'sfmc/ssjs-no-treatascontent-injection': 'warn',
194
+ 'no-cond-assign': 'error',
195
+ };
196
+
197
+ const ssjsStrictRules = {
198
+ 'sfmc/ssjs-require-platform-load': 'error',
199
+ 'sfmc/ssjs-no-unsupported-syntax': 'error',
200
+ 'sfmc/ssjs-no-unknown-platform-function': 'error',
201
+ 'sfmc/ssjs-no-unknown-core-method': 'error',
202
+ 'sfmc/ssjs-platform-function-arity': 'error',
203
+ 'sfmc/ssjs-no-unknown-http-method': 'error',
204
+ 'sfmc/ssjs-no-unknown-wsproxy-method': 'error',
205
+ 'sfmc/ssjs-no-unknown-platform-variable': 'error',
206
+ 'sfmc/ssjs-no-unknown-platform-response': 'error',
207
+ 'sfmc/ssjs-no-unknown-platform-request': 'error',
208
+ 'sfmc/ssjs-no-unknown-platform-client-browser': 'error',
209
+ 'sfmc/ssjs-cache-loop-length': 'error',
210
+ 'sfmc/ssjs-require-hasownproperty': 'error',
211
+ 'sfmc/ssjs-require-platform-load-order': 'error',
212
+ 'sfmc/ssjs-no-hardcoded-credentials': 'error',
213
+ 'sfmc/ssjs-prefer-platform-load-version': 'error',
214
+ 'sfmc/ssjs-no-unavailable-method': 'warn',
215
+ 'sfmc/ssjs-prefer-parsejson-safe-arg': 'error',
216
+ 'sfmc/ssjs-no-switch-default': 'error',
217
+ 'sfmc/ssjs-no-treatascontent-injection': 'error',
218
+ 'no-cond-assign': 'error',
219
+ };
220
+
221
+ // ── Configs (defined after plugin so they can reference it) ───────────────────
222
+
223
+ Object.assign(plugin.configs, {
224
+ /**
225
+ * AMPscript-only config for standalone .ampscript/.amp files.
226
+ */
227
+ ampscript: {
228
+ name: 'sfmc/ampscript',
229
+ plugins: { sfmc: plugin },
230
+ languageOptions: { parser: ampscriptParser },
231
+ files: ['**/*.ampscript', '**/*.amp'],
232
+ rules: { ...ampRecommendedRules },
233
+ },
234
+
235
+ /**
236
+ * SSJS-only config for standalone .ssjs files.
237
+ */
238
+ ssjs: {
239
+ name: 'sfmc/ssjs',
240
+ plugins: { sfmc: plugin },
241
+ files: ['**/*.ssjs'],
242
+ languageOptions: {
243
+ ecmaVersion: 5,
244
+ sourceType: 'script',
245
+ globals: SSJS_GLOBALS_MAP,
246
+ },
247
+ rules: { ...ssjsRecommendedRules },
248
+ },
249
+
250
+ /**
251
+ * Recommended config: both AMPscript and SSJS for standalone files.
252
+ * Returns an array of config objects.
253
+ */
254
+ recommended: [
255
+ {
256
+ name: 'sfmc/recommended-ampscript',
257
+ plugins: { sfmc: plugin },
258
+ languageOptions: { parser: ampscriptParser },
259
+ files: ['**/*.ampscript', '**/*.amp'],
260
+ rules: { ...ampRecommendedRules },
261
+ },
262
+ {
263
+ name: 'sfmc/recommended-ssjs',
264
+ plugins: { sfmc: plugin },
265
+ files: ['**/*.ssjs'],
266
+ languageOptions: {
267
+ ecmaVersion: 5,
268
+ sourceType: 'script',
269
+ globals: SSJS_GLOBALS_MAP,
270
+ },
271
+ rules: { ...ssjsRecommendedRules },
272
+ },
273
+ ],
274
+
275
+ /**
276
+ * Config for AMPscript and SSJS embedded in HTML files.
277
+ * Uses the combined processor to extract both language regions.
278
+ */
279
+ embedded: [
280
+ {
281
+ name: 'sfmc/embedded-processor',
282
+ plugins: { sfmc: plugin },
283
+ files: ['**/*.html'],
284
+ processor: 'sfmc/sfmc',
285
+ },
286
+ {
287
+ name: 'sfmc/embedded-ampscript-rules',
288
+ plugins: { sfmc: plugin },
289
+ languageOptions: { parser: ampscriptParser },
290
+ files: ['**/*.html/*.amp'],
291
+ rules: { ...ampRecommendedRules },
292
+ },
293
+ {
294
+ name: 'sfmc/embedded-ssjs-rules',
295
+ plugins: { sfmc: plugin },
296
+ files: ['**/*.html/*.js'],
297
+ languageOptions: {
298
+ ecmaVersion: 5,
299
+ sourceType: 'script',
300
+ globals: SSJS_GLOBALS_MAP,
301
+ },
302
+ rules: { ...ssjsRecommendedRules },
303
+ },
304
+ ],
305
+
306
+ /**
307
+ * Strict config: all rules at error severity for standalone + embedded.
308
+ */
309
+ strict: [
310
+ {
311
+ name: 'sfmc/strict-ampscript',
312
+ plugins: { sfmc: plugin },
313
+ languageOptions: { parser: ampscriptParser },
314
+ files: ['**/*.ampscript', '**/*.amp'],
315
+ rules: { ...ampStrictRules },
316
+ },
317
+ {
318
+ name: 'sfmc/strict-ssjs',
319
+ plugins: { sfmc: plugin },
320
+ files: ['**/*.ssjs'],
321
+ languageOptions: {
322
+ ecmaVersion: 5,
323
+ sourceType: 'script',
324
+ globals: SSJS_GLOBALS_MAP,
325
+ },
326
+ rules: { ...ssjsStrictRules },
327
+ },
328
+ {
329
+ name: 'sfmc/strict-embedded-processor',
330
+ plugins: { sfmc: plugin },
331
+ files: ['**/*.html'],
332
+ processor: 'sfmc/sfmc',
333
+ },
334
+ {
335
+ name: 'sfmc/strict-embedded-ampscript-rules',
336
+ plugins: { sfmc: plugin },
337
+ languageOptions: { parser: ampscriptParser },
338
+ files: ['**/*.html/*.amp'],
339
+ rules: { ...ampStrictRules },
340
+ },
341
+ {
342
+ name: 'sfmc/strict-embedded-ssjs-rules',
343
+ plugins: { sfmc: plugin },
344
+ files: ['**/*.html/*.js'],
345
+ languageOptions: {
346
+ ecmaVersion: 5,
347
+ sourceType: 'script',
348
+ globals: SSJS_GLOBALS_MAP,
349
+ },
350
+ rules: { ...ssjsStrictRules },
351
+ },
352
+ ],
353
+ });
354
+
355
+ export default plugin;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Combined SFMC processor that extracts both AMPscript and SSJS regions
3
+ * from HTML files.
4
+ *
5
+ * Detects:
6
+ * 1. %%[ ... ]%% blocks (with nesting support) → virtual .amp files
7
+ * 2. %%= ... =%% inline expressions → virtual .amp files
8
+ * 3. <script runat="server" language="ampscript"> → virtual .amp files
9
+ * 4. <script runat="server"> (non-ampscript) → virtual .js files
10
+ *
11
+ * Returns extracted regions as virtual files for ESLint to lint with the
12
+ * appropriate parser/rules based on file extension matching.
13
+ * Line offsets are preserved so ESLint reports errors at correct locations.
14
+ */
15
+
16
+ const AMPSCRIPT_SCRIPT_OPEN_RE =
17
+ /^<script\b(?=[^>]*\brunat\s*=\s*['"]server['"])(?=[^>]*\blanguage\s*=\s*['"]ampscript['"])[^>]*>/i;
18
+ const SSJS_SCRIPT_OPEN_RE =
19
+ /<script\b(?=[^>]*\brunat\s*=\s*['"]server['"])(?![^>]*\blanguage\s*=\s*['"]ampscript['"])[^>]*>/gi;
20
+ const SCRIPT_CLOSE_G = /<\/script\s*>/gi;
21
+
22
+ function countNewlinesBefore(text, pos) {
23
+ let count = 0;
24
+ for (let index = 0; index < pos; index++) {
25
+ if (text[index] === '\n') count++;
26
+ }
27
+ return count;
28
+ }
29
+
30
+ export function preprocess(text, filename) {
31
+ const blocks = [];
32
+ let ampCount = 0;
33
+ let ssjsCount = 0;
34
+
35
+ // --- Pass 1: extract AMPscript regions ---
36
+ let index = 0;
37
+ while (index < text.length) {
38
+ // <script runat="server" language="ampscript"> ... </script>
39
+ if (text[index] === '<') {
40
+ const openMatch = AMPSCRIPT_SCRIPT_OPEN_RE.exec(text.slice(index));
41
+ if (openMatch) {
42
+ const openTagEnd = index + openMatch[0].length;
43
+ SCRIPT_CLOSE_G.lastIndex = openTagEnd;
44
+ const closeMatch = SCRIPT_CLOSE_G.exec(text);
45
+ if (closeMatch) {
46
+ const blockEnd = closeMatch.index + closeMatch[0].length;
47
+ const fullBlock = text.slice(index, blockEnd);
48
+ const padding = '\n'.repeat(countNewlinesBefore(text, index));
49
+ blocks.push({
50
+ text: padding + fullBlock,
51
+ filename: `${filename}/ampscript-block-${ampCount++}.amp`,
52
+ });
53
+ index = blockEnd;
54
+ continue;
55
+ }
56
+ }
57
+ }
58
+
59
+ // %%[ ... ]%%
60
+ if (text[index] === '%' && text[index + 1] === '%' && text[index + 2] === '[') {
61
+ const blockStart = index;
62
+ index += 3;
63
+ let depth = 1;
64
+ while (index < text.length && depth > 0) {
65
+ if (text[index] === '%' && text[index + 1] === '%' && text[index + 2] === '[') {
66
+ depth++;
67
+ index += 3;
68
+ } else if (
69
+ text[index] === ']' &&
70
+ text[index + 1] === '%' &&
71
+ text[index + 2] === '%'
72
+ ) {
73
+ depth--;
74
+ index += 3;
75
+ } else {
76
+ index++;
77
+ }
78
+ }
79
+ if (depth !== 0) continue;
80
+ const fullBlock = text.slice(blockStart, index);
81
+ const padding = '\n'.repeat(countNewlinesBefore(text, blockStart));
82
+ blocks.push({
83
+ text: padding + fullBlock,
84
+ filename: `${filename}/ampscript-block-${ampCount++}.amp`,
85
+ });
86
+ continue;
87
+ }
88
+
89
+ // %%= ... =%%
90
+ if (text[index] === '%' && text[index + 1] === '%' && text[index + 2] === '=') {
91
+ const exprStart = index;
92
+ index += 3;
93
+ let found = false;
94
+ while (index < text.length) {
95
+ if (text[index] === '=' && text[index + 1] === '%' && text[index + 2] === '%') {
96
+ index += 3;
97
+ found = true;
98
+ break;
99
+ }
100
+ index++;
101
+ }
102
+ if (!found) continue;
103
+ const innerCode = text.slice(exprStart + 3, index - 3);
104
+ const wrappedBlock = `%%[${innerCode}]%%`;
105
+ const padding = '\n'.repeat(countNewlinesBefore(text, exprStart));
106
+ blocks.push({
107
+ text: padding + wrappedBlock,
108
+ filename: `${filename}/ampscript-block-${ampCount++}.amp`,
109
+ });
110
+ continue;
111
+ }
112
+
113
+ index++;
114
+ }
115
+
116
+ // --- Pass 2: extract SSJS regions ---
117
+ let match;
118
+ SSJS_SCRIPT_OPEN_RE.lastIndex = 0;
119
+ while ((match = SSJS_SCRIPT_OPEN_RE.exec(text)) !== null) {
120
+ const openEnd = match.index + match[0].length;
121
+ SCRIPT_CLOSE_G.lastIndex = openEnd;
122
+ const closeMatch = SCRIPT_CLOSE_G.exec(text);
123
+ if (!closeMatch) break;
124
+
125
+ const jsCode = text.slice(openEnd, closeMatch.index);
126
+ const linesBefore = text.slice(0, openEnd).split('\n');
127
+ const startLine = linesBefore.length - 1;
128
+ const padding = '\n'.repeat(startLine);
129
+
130
+ blocks.push({
131
+ text: padding + jsCode,
132
+ filename: `${filename}/ssjs-block-${ssjsCount++}.js`,
133
+ });
134
+ }
135
+
136
+ if (blocks.length === 0) {
137
+ return [text];
138
+ }
139
+
140
+ return blocks;
141
+ }
142
+
143
+ export function postprocess(messages) {
144
+ return messages.flat();
145
+ }
146
+
147
+ export default { preprocess, postprocess, supportsAutofix: false };
@@ -0,0 +1,51 @@
1
+ import { functionLookup } from 'ampscript-data';
2
+
3
+ export default {
4
+ meta: {
5
+ type: 'problem',
6
+ docs: {
7
+ description: 'Enforce correct argument counts for known AMPscript functions',
8
+ recommended: true,
9
+ },
10
+ messages: {
11
+ tooFewArgs:
12
+ "'{{name}}' requires at least {{min}} argument(s) but was called with {{actual}}.",
13
+ tooManyArgs:
14
+ "'{{name}}' accepts at most {{max}} argument(s) but was called with {{actual}}.",
15
+ },
16
+ schema: [],
17
+ },
18
+
19
+ create(context) {
20
+ return {
21
+ FunctionCall(node) {
22
+ const entry = functionLookup.get(node.name.toLowerCase());
23
+ if (!entry) return;
24
+
25
+ const actual = node.arguments.length;
26
+
27
+ if (actual < entry.minArgs) {
28
+ context.report({
29
+ node,
30
+ messageId: 'tooFewArgs',
31
+ data: {
32
+ name: entry.name,
33
+ min: String(entry.minArgs),
34
+ actual: String(actual),
35
+ },
36
+ });
37
+ } else if (actual > entry.maxArgs) {
38
+ context.report({
39
+ node,
40
+ messageId: 'tooManyArgs',
41
+ data: {
42
+ name: entry.name,
43
+ max: String(entry.maxArgs),
44
+ actual: String(actual),
45
+ },
46
+ });
47
+ }
48
+ },
49
+ };
50
+ },
51
+ };