mcp-server-sfmc 0.4.3 → 1.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.
@@ -0,0 +1,845 @@
1
+ /**
2
+ * Shared conversion rules for SSJS ↔ AMPscript and MCN rewriting.
3
+ *
4
+ * Single source of truth for mapping tables and deterministic transformation
5
+ * logic used by:
6
+ * - rewrite_for_mcn tool
7
+ * - convertSsjsToAmpscript tool
8
+ * - convertAmpscriptToSsjs tool
9
+ */
10
+ // ---------------------------------------------------------------------------
11
+ // Platform.Function.X → AMPscript function name (lowercase key)
12
+ // ---------------------------------------------------------------------------
13
+ /**
14
+ * Maps a Platform.Function.X name (lowercase) to the equivalent AMPscript
15
+ * canonical function name. Only functions with a direct 1:1 equivalent are
16
+ * included.
17
+ */
18
+ export const PLATFORM_FUNCTION_TO_AMP = {
19
+ lookup: 'Lookup',
20
+ lookuprows: 'LookupRows',
21
+ lookuporderedrows: 'LookupOrderedRows',
22
+ lookuporderedrowscs: 'LookupOrderedRowsCS',
23
+ insertde: 'InsertDE',
24
+ updatede: 'UpdateDE',
25
+ upsertde: 'UpsertDE',
26
+ deletede: 'DeleteDE',
27
+ rowcount: 'RowCount',
28
+ contentblockbyid: 'ContentBlockById',
29
+ contentblockbyname: 'ContentBlockByName',
30
+ contentblockbykey: 'ContentBlockByKey',
31
+ now: 'Now',
32
+ dateadd: 'DateAdd',
33
+ datediff: 'DateDiff',
34
+ dateparse: 'DateParse',
35
+ formatdate: 'FormatDate',
36
+ stringtodate: 'StringToDate',
37
+ concat: 'Concat',
38
+ substring: 'Substring',
39
+ trim: 'Trim',
40
+ lowercase: 'Lowercase',
41
+ uppercase: 'Uppercase',
42
+ propercase: 'ProperCase',
43
+ replace: 'Replace',
44
+ replacelist: 'ReplaceList',
45
+ indexof: 'IndexOf',
46
+ length: 'Length',
47
+ add: 'Add',
48
+ subtract: 'Subtract',
49
+ multiply: 'Multiply',
50
+ divide: 'Divide',
51
+ mod: 'Mod',
52
+ iif: 'Iif',
53
+ empty: 'Empty',
54
+ isnull: 'IsNull',
55
+ format: 'Format',
56
+ formatcurrency: 'FormatCurrency',
57
+ formatnumber: 'FormatNumber',
58
+ random: 'Random',
59
+ guid: 'GUID',
60
+ v: 'v',
61
+ output: 'Output',
62
+ outputline: 'OutputLine',
63
+ raiseerror: 'RaiseError',
64
+ httpget: 'HTTPGet',
65
+ httppost: 'HTTPPost',
66
+ httpgetwithcacheability: 'HTTPGetWithCacheability',
67
+ };
68
+ // ---------------------------------------------------------------------------
69
+ // AMPscript function name → Platform.Function.X SSJS name (lowercase key)
70
+ // ---------------------------------------------------------------------------
71
+ /**
72
+ * Maps an AMPscript function name (lowercase) to its SSJS Platform.Function
73
+ * equivalent name (the part after "Platform.Function.").
74
+ */
75
+ export const AMP_TO_PLATFORM_FUNCTION = {
76
+ lookup: 'Lookup',
77
+ lookuprows: 'LookupRows',
78
+ lookuporderedrows: 'LookupOrderedRows',
79
+ lookuporderedrowscs: 'LookupOrderedRowsCS',
80
+ insertde: 'InsertDE',
81
+ updatede: 'UpdateDE',
82
+ upsertde: 'UpsertDE',
83
+ deletede: 'DeleteDE',
84
+ rowcount: 'RowCount',
85
+ contentblockbyid: 'ContentBlockById',
86
+ contentblockbyname: 'ContentBlockByName',
87
+ contentblockbykey: 'ContentBlockByKey',
88
+ now: 'Now',
89
+ dateadd: 'DateAdd',
90
+ datediff: 'DateDiff',
91
+ dateparse: 'DateParse',
92
+ formatdate: 'FormatDate',
93
+ stringtodate: 'StringToDate',
94
+ concat: 'Concat',
95
+ substring: 'Substring',
96
+ trim: 'Trim',
97
+ lowercase: 'Lowercase',
98
+ uppercase: 'Uppercase',
99
+ propercase: 'ProperCase',
100
+ replace: 'Replace',
101
+ replacelist: 'ReplaceList',
102
+ indexof: 'IndexOf',
103
+ length: 'Length',
104
+ add: 'Add',
105
+ subtract: 'Subtract',
106
+ multiply: 'Multiply',
107
+ divide: 'Divide',
108
+ mod: 'Mod',
109
+ iif: 'Iif',
110
+ empty: 'Empty',
111
+ isnull: 'IsNull',
112
+ format: 'Format',
113
+ formatcurrency: 'FormatCurrency',
114
+ formatnumber: 'FormatNumber',
115
+ random: 'Random',
116
+ guid: 'GUID',
117
+ };
118
+ // ---------------------------------------------------------------------------
119
+ // .NET → Java SimpleDateFormat format string replacements
120
+ // ---------------------------------------------------------------------------
121
+ /**
122
+ * Ordered list of [RegExp, replacement] pairs to convert .NET format specifiers
123
+ * in FormatDate() calls to Java SimpleDateFormat equivalents.
124
+ * Applied sequentially so more specific patterns match before general ones.
125
+ */
126
+ export const DOTNET_TO_JAVA_FORMAT_REPLACEMENTS = [
127
+ // AM/PM marker: .NET 'tt' → Java 'a'
128
+ [/\btt\b/g, 'a'],
129
+ ];
130
+ /**
131
+ * Set of .NET standard format shorthands that have no direct Java equivalent
132
+ * and must be replaced with an explicit pattern.
133
+ */
134
+ export const DOTNET_STANDARD_SHORTHANDS = new Set([
135
+ 'G',
136
+ 'g',
137
+ 'D',
138
+ 'F',
139
+ 'f',
140
+ 'T',
141
+ 't',
142
+ 'R',
143
+ 'r',
144
+ 'U',
145
+ 'u',
146
+ 's',
147
+ 'o',
148
+ 'O',
149
+ ]);
150
+ // ---------------------------------------------------------------------------
151
+ // CloudPages-specific AMPscript functions (not available in MCN)
152
+ // ---------------------------------------------------------------------------
153
+ /**
154
+ * Set of AMPscript function names (lowercase) that are available only in
155
+ * CloudPages / web content context and are not supported in Marketing Cloud Next.
156
+ */
157
+ export const CLOUDPAGES_ONLY_FUNCTIONS = new Set([
158
+ 'cloudpagesurl',
159
+ 'requestparameter',
160
+ 'queryparameter',
161
+ 'redirect',
162
+ 'micrositeurl',
163
+ 'isprimarycontext',
164
+ 'cloudpageurl',
165
+ 'ampscriptnow',
166
+ ]);
167
+ // ---------------------------------------------------------------------------
168
+ // Non-migratable SSJS patterns
169
+ // ---------------------------------------------------------------------------
170
+ /**
171
+ * Patterns in SSJS code that indicate constructs with no AMPscript equivalent.
172
+ * Any SSJS block containing these patterns is classified as "Not migratable"
173
+ * in check_mcn_compatibility and marked MANUAL_REWRITE_REQUIRED in rewriting tools.
174
+ */
175
+ export const NON_MIGRATABLE_SSJS_PATTERNS = [
176
+ { pattern: /\btry\s*\{/, reason: 'try/catch has no AMPscript equivalent' },
177
+ { pattern: /\bcatch\s*\(/, reason: 'try/catch has no AMPscript equivalent' },
178
+ {
179
+ pattern: /\bfinally\s*\{/,
180
+ reason: 'try/catch/finally has no AMPscript equivalent',
181
+ },
182
+ {
183
+ pattern: /\.forEach\s*\(/,
184
+ reason: 'Array.forEach() has no AMPscript equivalent',
185
+ },
186
+ {
187
+ pattern: /\.map\s*\(/,
188
+ reason: 'Array.map() has no AMPscript equivalent',
189
+ },
190
+ {
191
+ pattern: /\.filter\s*\(/,
192
+ reason: 'Array.filter() has no AMPscript equivalent',
193
+ },
194
+ {
195
+ pattern: /\.reduce\s*\(/,
196
+ reason: 'Array.reduce() has no AMPscript equivalent',
197
+ },
198
+ {
199
+ pattern: /JSON\.stringify\s*\(/,
200
+ reason: 'JSON.stringify() has no AMPscript equivalent',
201
+ },
202
+ {
203
+ pattern: /new\s+RegExp\s*\(/,
204
+ reason: 'Regular expressions have no AMPscript equivalent',
205
+ },
206
+ {
207
+ pattern: /Platform\.Request\.(GetQueryStringParameter|GetFormField)\s*\(/,
208
+ reason: 'Query string / form field access requires CloudPages context (not available in MCN)',
209
+ },
210
+ ];
211
+ // ---------------------------------------------------------------------------
212
+ // SSJS → AMPscript conversion
213
+ // ---------------------------------------------------------------------------
214
+ /**
215
+ * Convert a SSJS code block to equivalent AMPscript using deterministic rules.
216
+ *
217
+ * Handles:
218
+ * - `Platform.Function.X(args)` → `X(args)`
219
+ * - `Platform.Variable.GetValue("name")` → `\@name`
220
+ * - `Platform.Variable.SetValue("name", val)` → `SET \@name = val`
221
+ * - `Platform.Response.Write(expr)` → `OutputLine(expr)`
222
+ * - `var x = expr;` → `SET \@x = expr`
223
+ * - `var x;` → `VAR \@x`
224
+ * - Control flow: if/else if/else/} → IF/ELSEIF/ELSE/ENDIF
225
+ * - `for (var i = start; i <= end; i++) {` → `FOR \@i = start TO end DO`
226
+ *
227
+ * Flags non-migratable constructs as MANUAL_REWRITE_REQUIRED.
228
+ * @param code - SSJS source code (may include `<script runat="server">` tags).
229
+ * @returns {ConversionResult} Conversion result with converted code, change log, and flagged sections.
230
+ */
231
+ export function ssjsToAmpscript(code) {
232
+ const changes = [];
233
+ const flaggedSections = [];
234
+ // Strip <script runat="server"> wrappers if present
235
+ const inner = code
236
+ .replaceAll(/<script[^>]+runat=['"]?server['"]?[^>]*>/gi, '')
237
+ .replaceAll(/<\/script>/gi, '')
238
+ .trim();
239
+ const rawLines = inner.split('\n');
240
+ const outputLines = [];
241
+ for (const [i, original] of rawLines.entries()) {
242
+ const lineNum = i + 1;
243
+ const trimmed = original.trim();
244
+ // Skip blank lines, Platform.Load(), var-only declarations with no value
245
+ if (!trimmed) {
246
+ outputLines.push('');
247
+ continue;
248
+ }
249
+ // Skip Platform.Load() — no AMPscript equivalent, not needed in MCN
250
+ if (/^Platform\.Load\s*\(/i.test(trimmed)) {
251
+ changes.push({
252
+ line: lineNum,
253
+ description: 'Removed Platform.Load() (not needed in AMPscript)',
254
+ });
255
+ continue;
256
+ }
257
+ // Check for non-migratable patterns first
258
+ let isFlagged = false;
259
+ for (const { pattern, reason } of NON_MIGRATABLE_SSJS_PATTERNS) {
260
+ // Reset lastIndex for global regexes
261
+ pattern.lastIndex = 0;
262
+ if (pattern.test(trimmed)) {
263
+ outputLines.push(`%%-- MANUAL_REWRITE_REQUIRED: ${reason} --%%`, `%%-- Original: ${trimmed} --%%`);
264
+ flaggedSections.push({ line: lineNum, code: trimmed, reason });
265
+ isFlagged = true;
266
+ break;
267
+ }
268
+ }
269
+ if (isFlagged) {
270
+ continue;
271
+ }
272
+ let line = original;
273
+ // Platform.Variable.GetValue("name") → @name
274
+ line = line.replaceAll(/Platform\.Variable\.GetValue\s*\(\s*["']([^"']+)["']\s*\)/gi, '@$1');
275
+ // Platform.Variable.SetValue("name", value) → SET @name = value (strip ; at end if present)
276
+ line = line.replaceAll(/Platform\.Variable\.SetValue\s*\(\s*["']([^"']+)["']\s*,\s*([^)]+)\)\s*;?/gi, (_, varName, value) => {
277
+ changes.push({
278
+ line: lineNum,
279
+ description: `Platform.Variable.SetValue → SET @${varName}`,
280
+ });
281
+ return `SET @${varName} = ${value.trim()}`;
282
+ });
283
+ // Platform.Response.Write(expr) → OutputLine(expr)
284
+ line = line.replaceAll(/Platform\.Response\.Write\s*\(/gi, () => {
285
+ changes.push({ line: lineNum, description: 'Platform.Response.Write → OutputLine' });
286
+ return 'OutputLine(';
287
+ });
288
+ // Platform.Function.X(args) → X(args) using known function map
289
+ line = line.replaceAll(/Platform\.Function\.(\w+)\s*\(/gi, (_, fnName) => {
290
+ const ampName = PLATFORM_FUNCTION_TO_AMP[fnName.toLowerCase()] ?? fnName;
291
+ changes.push({
292
+ line: lineNum,
293
+ description: `Platform.Function.${fnName} → ${ampName}`,
294
+ });
295
+ return `${ampName}(`;
296
+ });
297
+ // var x = expr; → SET @x = expr
298
+ line = line.replace(/\bvar\s+([A-Za-z_]\w*)\s*=\s*(.+?)\s*;?\s*$/, (_, varName, value) => {
299
+ changes.push({
300
+ line: lineNum,
301
+ description: `var ${varName} = ... → SET @${varName}`,
302
+ });
303
+ return `SET @${varName} = ${value.trim()}`;
304
+ });
305
+ // var x; → VAR @x
306
+ line = line.replace(/\bvar\s+([A-Za-z_]\w*)\s*;?\s*$/, (_, varName) => {
307
+ changes.push({ line: lineNum, description: `var ${varName} → VAR @${varName}` });
308
+ return `VAR @${varName}`;
309
+ });
310
+ // Control flow: if (cond) { → IF cond THEN
311
+ line = line.replace(/^\s*if\s*\((.+)\)\s*\{\s*$/, (_, cond) => {
312
+ const ampCond = ssjsCondToAmp(cond.trim());
313
+ changes.push({ line: lineNum, description: 'if (...) { → IF ... THEN' });
314
+ return `IF ${ampCond} THEN`;
315
+ });
316
+ // } else if (cond) { → ELSEIF cond THEN
317
+ line = line.replace(/^\s*\}\s*else\s+if\s*\((.+)\)\s*\{\s*$/, (_, cond) => {
318
+ const ampCond = ssjsCondToAmp(cond.trim());
319
+ changes.push({ line: lineNum, description: '} else if (...) { → ELSEIF ... THEN' });
320
+ return `ELSEIF ${ampCond} THEN`;
321
+ });
322
+ // } else { → ELSE
323
+ line = line.replace(/^\s*\}\s*else\s*\{\s*$/, () => {
324
+ changes.push({ line: lineNum, description: '} else { → ELSE' });
325
+ return 'ELSE';
326
+ });
327
+ // for (var i = start; i <= end; i++) { → FOR @i = start TO end DO
328
+ const forMatch = /^\s*for\s*\(\s*var\s+(\w+)\s*=\s*(\S+?)\s*;\s*\w+\s*<=?\s*(\S+?)\s*;\s*\w+\+\+\s*\)\s*\{\s*$/.exec(line);
329
+ if (forMatch) {
330
+ const [, iterVar, start, end] = forMatch;
331
+ changes.push({
332
+ line: lineNum,
333
+ description: `for (var ${iterVar}...) → FOR @${iterVar} = ${start} TO ${end} DO`,
334
+ });
335
+ line = `FOR @${iterVar} = ${start} TO ${end} DO`;
336
+ }
337
+ // Standalone closing brace } → ENDIF (best-effort; may not always be correct)
338
+ if (/^\s*\}\s*$/.test(line) && !/^\s*\}\s*(else|catch|finally)/.test(line)) {
339
+ changes.push({ line: lineNum, description: '} → ENDIF' });
340
+ line = 'ENDIF';
341
+ }
342
+ // Strip trailing semicolons from non-SET lines (AMPscript doesn't use them)
343
+ if (!/^\s*SET /i.test(line.trim()) && /;\s*$/.test(line)) {
344
+ line = line.replace(/;\s*$/, '');
345
+ }
346
+ outputLines.push(line.trim());
347
+ }
348
+ // Wrap in AMPscript block
349
+ const convertedCode = `%%[\n${outputLines.filter((l) => l !== undefined).join('\n')}\n]%%`;
350
+ return { convertedCode, changes, flaggedSections };
351
+ }
352
+ /**
353
+ * Convert simple SSJS conditional expression to AMPscript syntax.
354
+ * @param cond - JS condition string.
355
+ * @returns {string} AMPscript condition string.
356
+ */
357
+ function ssjsCondToAmp(cond) {
358
+ // @variable references from previous conversions stay as-is
359
+ // Convert JS == / === to AMPscript == and != / !== to !=
360
+ return cond
361
+ .replaceAll(/!==\s*/g, '!= ')
362
+ .replaceAll(/===\s*/g, '== ')
363
+ .replaceAll('||', 'OR')
364
+ .replaceAll('&&', 'AND')
365
+ .replaceAll('!', 'NOT ');
366
+ }
367
+ // ---------------------------------------------------------------------------
368
+ // AMPscript → SSJS conversion
369
+ // ---------------------------------------------------------------------------
370
+ /**
371
+ * Convert AMPscript code to equivalent SSJS using deterministic rules.
372
+ *
373
+ * Handles:
374
+ * - `%%[ SET \@x = expr ]%%` → `var x = expr;`
375
+ * - `%%[ VAR \@x, \@y ]%%` → `var x, y;`
376
+ * - `%%[ IF cond THEN / ELSEIF / ELSE / ENDIF ]%%` → JS control flow
377
+ * - `%%[ FOR \@i = start TO end DO / NEXT \@i ]%%` → for loop
378
+ * - `%%=Output(\@x)=%%` / `%%=OutputLine(\@x)=%%` → `Platform.Response.Write(x)`
379
+ * - `%%=FunctionName(args)=%%` → `Platform.Response.Write(Platform.Function.FunctionName(args))`
380
+ * - Known AMPscript functions → Platform.Function.X equivalents
381
+ * - `\@variable` references → bare variable names
382
+ * @param code - AMPscript source code.
383
+ * @returns {ConversionResult} Conversion result with converted SSJS, change log, and flagged sections.
384
+ */
385
+ export function ampscriptToSsjs(code) {
386
+ const changes = [];
387
+ const flaggedSections = [];
388
+ const outputLines = ['<script runat="server">', 'Platform.Load("Core", "1.1.5");'];
389
+ // Normalize: combine multi-line %%[ ... ]%% blocks into single pseudo-lines
390
+ // then process line by line
391
+ const normalized = normalizeAmpscriptBlocks(code);
392
+ const lines = normalized.split('\n');
393
+ const lineOffset = 0;
394
+ for (const [i, line] of lines.entries()) {
395
+ const lineNum = i + 1 + lineOffset;
396
+ const trimmed = line.trim();
397
+ if (!trimmed) {
398
+ outputLines.push('');
399
+ continue;
400
+ }
401
+ // %%=Output(@x)=%% or %%=OutputLine(@x)=%%
402
+ const inlineOutputMatch = /^%%=\s*(?:Output|OutputLine)\s*\((.+)\)\s*=%%$/i.exec(trimmed);
403
+ if (inlineOutputMatch) {
404
+ const expr = stripAmpVars(inlineOutputMatch[1].trim());
405
+ changes.push({
406
+ line: lineNum,
407
+ description: '%%=Output(...)=%% → Platform.Response.Write(...)',
408
+ });
409
+ outputLines.push(`Platform.Response.Write(${expr});`);
410
+ continue;
411
+ }
412
+ // %%=FunctionName(args)=%% → Platform.Response.Write(Platform.Function.FunctionName(args))
413
+ const inlineFnMatch = /^%%=\s*(\w+)\s*\((.*)?\)\s*=%%$/i.exec(trimmed);
414
+ if (inlineFnMatch) {
415
+ const fnName = inlineFnMatch[1];
416
+ const args = inlineFnMatch[2]?.trim() ?? '';
417
+ const ssName = AMP_TO_PLATFORM_FUNCTION[fnName.toLowerCase()];
418
+ const argsConverted = stripAmpVars(args);
419
+ if (ssName) {
420
+ changes.push({
421
+ line: lineNum,
422
+ description: `%%=${fnName}(...)=%% → Platform.Response.Write(Platform.Function.${ssName}(...))`,
423
+ });
424
+ outputLines.push(`Platform.Response.Write(Platform.Function.${ssName}(${argsConverted}));`);
425
+ }
426
+ else {
427
+ // Unknown function — pass through as comment for AI
428
+ outputLines.push(`/* MANUAL_REWRITE_REQUIRED: %%=${fnName}(${args})=%% */`);
429
+ flaggedSections.push({
430
+ line: lineNum,
431
+ code: trimmed,
432
+ reason: `Unknown AMPscript function '${fnName}' — no SSJS equivalent in catalog`,
433
+ });
434
+ }
435
+ continue;
436
+ }
437
+ // %%[ block content ]%%
438
+ const blockMatch = /^%%\[\s*([\s\S]*?)\s*\]%%$/i.exec(trimmed);
439
+ if (blockMatch) {
440
+ const blockContent = blockMatch[1].trim();
441
+ const stmts = blockContent.split(/\n+/);
442
+ for (const stmt of stmts) {
443
+ const converted = convertAmpStatement(stmt.trim(), lineNum, changes, flaggedSections);
444
+ if (converted !== null) {
445
+ outputLines.push(converted);
446
+ }
447
+ }
448
+ continue;
449
+ }
450
+ // Bare AMPscript statement (already stripped of delimiters from normalizeAmpscriptBlocks)
451
+ if (/^(SET|VAR|IF|ELSEIF|ELSE|ENDIF|FOR|NEXT|OUTPUT|OUTPUTLINE)\b/i.test(trimmed)) {
452
+ const converted = convertAmpStatement(trimmed, lineNum, changes, flaggedSections);
453
+ if (converted !== null) {
454
+ outputLines.push(converted);
455
+ }
456
+ continue;
457
+ }
458
+ // Pass through non-AMPscript content as an HTML comment
459
+ if (trimmed) {
460
+ outputLines.push(`/* HTML content: ${trimmed.slice(0, 80)} */`);
461
+ }
462
+ }
463
+ outputLines.push('</script>');
464
+ return {
465
+ convertedCode: outputLines.join('\n'),
466
+ changes,
467
+ flaggedSections,
468
+ };
469
+ }
470
+ /**
471
+ * Normalize AMPscript block delimiters so each AMPscript statement is on its
472
+ * own line, with `%%[` and `]%%` stripped.
473
+ * @param code - Raw AMPscript source.
474
+ * @returns {string} Normalized code with one statement per line.
475
+ */
476
+ function normalizeAmpscriptBlocks(code) {
477
+ // Expand %%[ ... ]%% to individual statements
478
+ let result = code;
479
+ result = result.replaceAll(/%%\[\s*([\s\S]*?)\s*\]%%/g, (_, inner) => inner
480
+ .split('\n')
481
+ .map((l) => l.trim())
482
+ .filter(Boolean)
483
+ .join('\n'));
484
+ return result;
485
+ }
486
+ /**
487
+ * Convert a single AMPscript statement to SSJS.
488
+ * @param stmt - AMPscript statement string (no delimiters).
489
+ * @param lineNum - Source line number for change tracking.
490
+ * @param changes - Mutable changes array.
491
+ * @param flaggedSections - Mutable flagged sections array.
492
+ * @returns {string | null} SSJS statement string, or null to skip.
493
+ */
494
+ function convertAmpStatement(stmt, lineNum, changes, flaggedSections) {
495
+ if (!stmt)
496
+ return null;
497
+ const upper = stmt.toUpperCase();
498
+ // SET @x = expr → var x = expr;
499
+ const setMatch = /^SET\s+@(\w+)\s*=\s*(.+)$/i.exec(stmt);
500
+ if (setMatch) {
501
+ const [, varName, expr] = setMatch;
502
+ const ssExpr = convertAmpExpr(expr.trim());
503
+ changes.push({
504
+ line: lineNum,
505
+ description: `SET @${varName} = ... → var ${varName} = ...`,
506
+ });
507
+ return `var ${varName} = ${ssExpr};`;
508
+ }
509
+ // VAR @x, @y → var x, y;
510
+ const varMatch = /^VAR\s+(.+)$/i.exec(stmt);
511
+ if (varMatch) {
512
+ const vars = varMatch[1].split(',').map((v) => v.trim().replace(/^@/, ''));
513
+ changes.push({
514
+ line: lineNum,
515
+ description: `VAR @${vars.join(', @')} → var ${vars.join(', ')}`,
516
+ });
517
+ return `var ${vars.join(', ')};`;
518
+ }
519
+ // IF cond THEN → if (cond) {
520
+ const ifMatch = /^IF\s+(.+?)\s+THEN$/i.exec(stmt);
521
+ if (ifMatch) {
522
+ const cond = ampCondToSsjs(ifMatch[1].trim());
523
+ changes.push({ line: lineNum, description: 'IF ... THEN → if (...) {' });
524
+ return `if (${cond}) {`;
525
+ }
526
+ // ELSEIF cond THEN → } else if (cond) {
527
+ const elseifMatch = /^ELSEIF\s+(.+?)\s+THEN$/i.exec(stmt);
528
+ if (elseifMatch) {
529
+ const cond = ampCondToSsjs(elseifMatch[1].trim());
530
+ changes.push({ line: lineNum, description: 'ELSEIF ... THEN → } else if (...) {' });
531
+ return `} else if (${cond}) {`;
532
+ }
533
+ // ELSE → } else {
534
+ if (/^ELSE$/i.test(stmt)) {
535
+ changes.push({ line: lineNum, description: 'ELSE → } else {' });
536
+ return '} else {';
537
+ }
538
+ // ENDIF → }
539
+ if (/^ENDIF$/i.test(stmt)) {
540
+ changes.push({ line: lineNum, description: 'ENDIF → }' });
541
+ return '}';
542
+ }
543
+ // FOR @i = start TO end DO → for (var i = start; i <= end; i++) {
544
+ const forMatch = /^FOR\s+@(\w+)\s*=\s*(\S+?)\s+TO\s+(\S+?)(?:\s+STEP\s+\S+)?\s+DO$/i.exec(stmt);
545
+ if (forMatch) {
546
+ const [, iterVar, start, end] = forMatch;
547
+ const ssStart = stripAmpVars(start);
548
+ const ssEnd = stripAmpVars(end);
549
+ changes.push({
550
+ line: lineNum,
551
+ description: `FOR @${iterVar} = ${start} TO ${end} DO → for loop`,
552
+ });
553
+ return `for (var ${iterVar} = ${ssStart}; ${iterVar} <= ${ssEnd}; ${iterVar}++) {`;
554
+ }
555
+ // NEXT @i → }
556
+ if (/^NEXT\s+@\w+$/i.test(stmt)) {
557
+ changes.push({ line: lineNum, description: 'NEXT @i → }' });
558
+ return '}';
559
+ }
560
+ // OUTPUT(expr) / OUTPUTLINE(expr) → Platform.Response.Write(expr)
561
+ const outputMatch = /^(?:OUTPUT|OUTPUTLINE)\s*\((.+)\)$/i.exec(stmt);
562
+ if (outputMatch) {
563
+ const expr = convertAmpExpr(outputMatch[1].trim());
564
+ changes.push({ line: lineNum, description: 'Output/OutputLine → Platform.Response.Write' });
565
+ return `Platform.Response.Write(${expr});`;
566
+ }
567
+ // Known AMPscript function call → Platform.Function.X(args)
568
+ const fnCallMatch = /^(\w+)\s*\((.*)?\)$/i.exec(stmt);
569
+ if (fnCallMatch) {
570
+ const [, fnName, args] = fnCallMatch;
571
+ const ssName = AMP_TO_PLATFORM_FUNCTION[fnName.toLowerCase()];
572
+ if (ssName) {
573
+ const argsConverted = args ? convertAmpExpr(args.trim()) : '';
574
+ changes.push({
575
+ line: lineNum,
576
+ description: `${fnName}(…) → Platform.Function.${ssName}(…)`,
577
+ });
578
+ return `Platform.Function.${ssName}(${argsConverted});`;
579
+ }
580
+ }
581
+ // Check for AMPscript-only constructs that can't be converted
582
+ if (/\bCLOUDPAGESURL\b|\bREQUESTPARAMETER\b|\bQUERYPARAMETER\b|\bREDIRECT\b/i.test(stmt)) {
583
+ flaggedSections.push({
584
+ line: lineNum,
585
+ code: stmt,
586
+ reason: 'CloudPages-specific function — not available in SSJS/MCN context',
587
+ });
588
+ return `/* MANUAL_REWRITE_REQUIRED: ${stmt} */`;
589
+ }
590
+ // Unknown statement — pass through as comment
591
+ if (upper !== stmt.toUpperCase() || /[A-Za-z]/.test(stmt)) {
592
+ // Has alphabetic content — flag it
593
+ flaggedSections.push({
594
+ line: lineNum,
595
+ code: stmt,
596
+ reason: 'Could not automatically convert this AMPscript statement',
597
+ });
598
+ return `/* MANUAL_REWRITE_REQUIRED: ${stmt} */`;
599
+ }
600
+ return stmt;
601
+ }
602
+ /**
603
+ * Convert an AMPscript expression to its SSJS equivalent.
604
+ * Strips `@` from variable references and maps known function names.
605
+ * @param expr - AMPscript expression string.
606
+ * @returns {string} SSJS expression string.
607
+ */
608
+ function convertAmpExpr(expr) {
609
+ // Replace known AMPscript function calls with Platform.Function.X equivalents
610
+ let result = expr.replaceAll(/\b(\w+)\s*\(/g, (match, fnName) => {
611
+ const ssName = AMP_TO_PLATFORM_FUNCTION[fnName.toLowerCase()];
612
+ return ssName ? `Platform.Function.${ssName}(` : match;
613
+ });
614
+ // Strip @ from variable references
615
+ result = stripAmpVars(result);
616
+ return result;
617
+ }
618
+ /**
619
+ * Convert an AMPscript condition expression to SSJS syntax.
620
+ * @param cond - AMPscript condition string.
621
+ * @returns {string} SSJS condition string.
622
+ */
623
+ function ampCondToSsjs(cond) {
624
+ return stripAmpVars(cond)
625
+ .replaceAll(/\bAND\b/gi, '&&')
626
+ .replaceAll(/\bOR\b/gi, '||')
627
+ .replaceAll(/\bNOT\b/gi, '!')
628
+ .replaceAll(/\bEQUAL\s+TO\b/gi, '===')
629
+ .replaceAll(/\bNOT\s+EQUAL\s+TO\b/gi, '!==')
630
+ .replaceAll(/\bGREATER\s+THAN\b/gi, '>')
631
+ .replaceAll(/\bLESS\s+THAN\b/gi, '<');
632
+ }
633
+ /**
634
+ * Strip `@` prefix from AMPscript variable references in an expression.
635
+ * @param expr - Expression possibly containing `@varName` references.
636
+ * @returns {string} Expression with `@` prefixes removed.
637
+ */
638
+ export function stripAmpVars(expr) {
639
+ return expr.replaceAll(/@(\w+)/g, '$1');
640
+ }
641
+ /**
642
+ * Rewrite AMPscript code to be compatible with Marketing Cloud Next.
643
+ *
644
+ * Performs deterministic rewrites:
645
+ * - FormatDate(StringToDate(x), fmt) → FormatDate(x, fmt)
646
+ * - .NET → Java SimpleDateFormat format string conversions in FormatDate()
647
+ * - Lookup with odd arg count → annotated with comment
648
+ * - MCE-only functions → marked with %%-- NOT SUPPORTED IN MCN --%% annotation
649
+ * @param code - AMPscript source code to rewrite.
650
+ * @param options - Functions for MCN support checking and note retrieval.
651
+ * @returns {McnRewriteResult} Rewrite result with rewritten code, change log, and difficulty assessment.
652
+ */
653
+ export function rewriteAmpForMcn(code, options) {
654
+ const { isMcnSupportedFn, getMcnNotesFn } = options;
655
+ const changes = [];
656
+ const nonMigratableItems = [];
657
+ let rewrittenCode = code;
658
+ // 1. Remove StringToDate() wrapper inside FormatDate() first argument
659
+ // FormatDate(StringToDate(x), fmt) → FormatDate(x, fmt)
660
+ const strToDatePattern = /FormatDate\s*\(\s*StringToDate\s*\(([^)]+)\)\s*,/gi;
661
+ if (strToDatePattern.test(rewrittenCode)) {
662
+ rewrittenCode = rewrittenCode.replaceAll(/FormatDate\s*\(\s*StringToDate\s*\(([^)]+)\)\s*,/gi, 'FormatDate($1,');
663
+ // Find affected lines for change tracking
664
+ const lines = code.split('\n');
665
+ for (const [i, line] of lines.entries()) {
666
+ if (/FormatDate\s*\(\s*StringToDate\s*\(/i.test(line)) {
667
+ changes.push({
668
+ line: i + 1,
669
+ type: 'rewritten',
670
+ description: 'Removed StringToDate() wrapper: FormatDate(StringToDate(x), fmt) → FormatDate(x, fmt)',
671
+ });
672
+ }
673
+ }
674
+ }
675
+ // 2. Convert .NET format strings to Java SimpleDateFormat in FormatDate() calls
676
+ rewrittenCode = rewrittenCode.replaceAll(/FormatDate\s*\(\s*([^,]+),\s*"([^"]+)"\s*\)/gi, (match, arg1, formatStr) => {
677
+ let newFormat = formatStr;
678
+ let changed = false;
679
+ let hasShorthand = false;
680
+ // Check for standard shorthands
681
+ for (const shorthand of DOTNET_STANDARD_SHORTHANDS) {
682
+ if (new RegExp(`^${shorthand}$`).test(formatStr.trim())) {
683
+ hasShorthand = true;
684
+ }
685
+ }
686
+ if (hasShorthand) {
687
+ // Annotate with comment about needing explicit format
688
+ return `FormatDate(${arg1}, "/* MANUAL_REWRITE_REQUIRED: Convert .NET standard shorthand '${formatStr}' to explicit Java SimpleDateFormat pattern */"${formatStr}")`;
689
+ }
690
+ // Apply .NET → Java replacements
691
+ for (const [pattern, replacement] of DOTNET_TO_JAVA_FORMAT_REPLACEMENTS) {
692
+ const before = newFormat;
693
+ newFormat = newFormat.replace(pattern, replacement);
694
+ if (newFormat !== before) {
695
+ changed = true;
696
+ }
697
+ }
698
+ if (changed) {
699
+ return `FormatDate(${arg1}, "${newFormat}")`;
700
+ }
701
+ return match;
702
+ });
703
+ // Track format string changes
704
+ const codeLines = code.split('\n');
705
+ const rewrittenLines = rewrittenCode.split('\n');
706
+ for (const [i, codeLine] of codeLines.entries()) {
707
+ if (codeLine !== rewrittenLines[i] && /FormatDate/i.test(codeLine)) {
708
+ changes.push({
709
+ line: i + 1,
710
+ type: 'rewritten',
711
+ description: `Converted .NET format string to Java SimpleDateFormat in FormatDate()`,
712
+ });
713
+ }
714
+ }
715
+ // 3. Annotate Lookup() calls with odd argument counts
716
+ rewrittenCode = rewrittenCode.replaceAll(/\bLookup\s*\(([^)]+)\)/gi, (match, argsStr) => {
717
+ const argCount = countArgs(argsStr);
718
+ // Lookup takes: DE, returnCol, [searchCol, searchVal, ...]
719
+ // Min 2 args, then pairs after that → should be even number > 2 or exactly 2
720
+ // Odd count after first 2 = problem
721
+ if (argCount >= 3 && (argCount - 2) % 2 !== 0) {
722
+ return `${match} %%-- MCN NOTE: Lookup() requires search arguments in column/value pairs (even count after DE and return column). Current arg count (${argCount}) may cause an error in MCN. --%% `;
723
+ }
724
+ return match;
725
+ });
726
+ // 4. Mark MCE-only function calls with annotation
727
+ // Find all function calls and mark unsupported ones
728
+ const funcCallPattern = /\b([A-Z][A-Za-z]+)\s*\(/g;
729
+ let funcMatch;
730
+ const seenUnsupported = new Set();
731
+ while ((funcMatch = funcCallPattern.exec(code)) !== null) {
732
+ const fnName = funcMatch[1];
733
+ if (!isMcnSupportedFn(fnName) && fnName.length > 1) {
734
+ seenUnsupported.add(fnName);
735
+ }
736
+ }
737
+ for (const fnName of seenUnsupported) {
738
+ const mcnNotes = getMcnNotesFn(fnName);
739
+ const annotationPattern = new RegExp(String.raw `\b${fnName}\s*\(`, 'gi');
740
+ rewrittenCode = rewrittenCode.replace(annotationPattern, (m) => {
741
+ return `%%-- NOT SUPPORTED IN MCN: ${fnName}${mcnNotes ? ` — ${mcnNotes}` : ''} --%%\n${m}`;
742
+ });
743
+ // Find lines for tracking
744
+ for (const [i, codeLine] of codeLines.entries()) {
745
+ if (new RegExp(String.raw `\b${fnName}\s*\(`, 'i').test(codeLine)) {
746
+ changes.push({
747
+ line: i + 1,
748
+ type: 'annotated',
749
+ description: `${fnName}() is not supported in Marketing Cloud Next`,
750
+ });
751
+ nonMigratableItems.push({
752
+ line: i + 1,
753
+ code: codeLine.trim(),
754
+ reason: `${fnName}() is not available in Marketing Cloud Next`,
755
+ });
756
+ }
757
+ }
758
+ }
759
+ // 5. Check for CloudPages functions
760
+ const cloudFnPattern = /\b(CloudPagesURL|RequestParameter|QueryParameter|Redirect|MicrositeURL)\s*\(/gi;
761
+ let cloudMatch;
762
+ while ((cloudMatch = cloudFnPattern.exec(code)) !== null) {
763
+ const fnName = cloudMatch[1];
764
+ const lineNum = code.slice(0, cloudMatch.index).split('\n').length;
765
+ nonMigratableItems.push({
766
+ line: lineNum,
767
+ code: codeLines[lineNum - 1]?.trim() ?? fnName,
768
+ reason: `${fnName}() is a CloudPages-specific function and cannot run in Marketing Cloud Next`,
769
+ });
770
+ }
771
+ // Assess difficulty
772
+ const hasNotMigratable = nonMigratableItems.some((item) => item.reason.includes('CloudPages') || item.reason.includes('NOT SUPPORTED'));
773
+ const hasMcnNotes = Array.from(seenUnsupported).some((fn) => getMcnNotesFn(fn) !== null) ||
774
+ /FormatDate|StringToDate|Lookup/i.test(code);
775
+ const hasSsjs = /<script[^>]+runat/i.test(code);
776
+ let difficulty;
777
+ if (hasNotMigratable) {
778
+ difficulty = 'not-migratable';
779
+ }
780
+ else if (seenUnsupported.size > 0 || hasSsjs) {
781
+ difficulty = 'significant';
782
+ }
783
+ else if (hasMcnNotes) {
784
+ difficulty = 'minor';
785
+ }
786
+ else {
787
+ difficulty = 'ready';
788
+ }
789
+ const summaryParts = [];
790
+ if (seenUnsupported.size > 0) {
791
+ summaryParts.push(`${seenUnsupported.size} MCE-only function(s) flagged`);
792
+ }
793
+ if (hasMcnNotes) {
794
+ summaryParts.push('behavioral differences noted (FormatDate, Lookup, or StringToDate)');
795
+ }
796
+ if (hasSsjs) {
797
+ summaryParts.push('SSJS blocks detected');
798
+ }
799
+ const summary = summaryParts.length > 0 ? summaryParts.join('; ') : 'No MCN issues found';
800
+ return { rewrittenCode, changes, nonMigratableItems, difficulty, summary };
801
+ }
802
+ // ---------------------------------------------------------------------------
803
+ // Utility helpers
804
+ // ---------------------------------------------------------------------------
805
+ /**
806
+ * Count the number of top-level comma-separated arguments in a function
807
+ * argument string (respects nested parentheses).
808
+ * @param argsStr - The argument string (contents between outer parens).
809
+ * @returns {number} Number of top-level arguments.
810
+ */
811
+ export function countArgs(argsStr) {
812
+ if (!argsStr.trim())
813
+ return 0;
814
+ let depth = 0;
815
+ let count = 1;
816
+ for (const ch of argsStr) {
817
+ if (ch === '(' || ch === '[') {
818
+ depth++;
819
+ }
820
+ else if (ch === ')' || ch === ']') {
821
+ depth--;
822
+ }
823
+ else if (ch === ',' && depth === 0) {
824
+ count++;
825
+ }
826
+ }
827
+ return count;
828
+ }
829
+ /**
830
+ * Determine whether a SSJS code block contains only patterns that can be
831
+ * automatically converted to AMPscript. Returns true when no non-migratable
832
+ * patterns are found.
833
+ * @param blockCode - SSJS block code (without `<script>` tags).
834
+ * @returns {boolean} True when the block is likely convertible.
835
+ */
836
+ export function isSsjsBlockConvertible(blockCode) {
837
+ for (const { pattern } of NON_MIGRATABLE_SSJS_PATTERNS) {
838
+ pattern.lastIndex = 0;
839
+ if (pattern.test(blockCode)) {
840
+ return false;
841
+ }
842
+ }
843
+ return true;
844
+ }
845
+ //# sourceMappingURL=conversion-rules.js.map