mcp-server-sfmc 1.9.2 → 2.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.
@@ -37,6 +37,48 @@ export const AMP_TO_PLATFORM_FUNCTION = Object.fromEntries(PLATFORM_FUNCTIONS.fi
37
37
  */
38
38
  const SSJS_ONLY_FUNCTIONS = new Set(PLATFORM_FUNCTIONS.filter((f) => f.ampscriptEquivalent === null).map((f) => f.name.toLowerCase()));
39
39
  // ---------------------------------------------------------------------------
40
+ // Dynamic AMPscript ↔ MCN Handlebars maps (built from ampscript-data at load time)
41
+ // ---------------------------------------------------------------------------
42
+ import { FUNCTIONS as AMPSCRIPT_FUNCTIONS } from 'ampscript-data';
43
+ import { getHelper as getHandlebarsHelper } from 'handlebars-data';
44
+ /**
45
+ * Maps an AMPscript function name (lowercase) to the canonical MCN Handlebars
46
+ * helper name it converts to. Built at module load time from ampscript-data's
47
+ * `handlebarsEquivalent` field — always in sync with the installed
48
+ * ampscript-data version, never hand-edited (`mcp-conversion-rules-sync.mdc`).
49
+ *
50
+ * Only Category A functions (non-null string `handlebarsEquivalent`) are
51
+ * included. The stored value is the bare helper name; the canonical casing is
52
+ * resolved through `handlebars-data` so emitted `{{helper}}` calls match the
53
+ * catalog exactly.
54
+ */
55
+ export const AMP_TO_HANDLEBARS = Object.fromEntries(AMPSCRIPT_FUNCTIONS.filter((f) => typeof f.handlebarsEquivalent === 'string' && f.handlebarsEquivalent.length > 0).map((f) => {
56
+ const canonical = getHandlebarsHelper(f.handlebarsEquivalent);
57
+ return [
58
+ f.name.toLowerCase(),
59
+ canonical ? canonical.name : f.handlebarsEquivalent,
60
+ ];
61
+ }));
62
+ /**
63
+ * Maps a canonical MCN Handlebars helper name (lowercase) to its AMPscript
64
+ * function name. Inverted from AMP_TO_HANDLEBARS at module load time. Used by
65
+ * convertHandlebarsToAmpscript.
66
+ */
67
+ export const HANDLEBARS_TO_AMP = Object.fromEntries(AMPSCRIPT_FUNCTIONS.filter((f) => typeof f.handlebarsEquivalent === 'string' && f.handlebarsEquivalent.length > 0).map((f) => {
68
+ const canonical = getHandlebarsHelper(f.handlebarsEquivalent);
69
+ return [
70
+ (canonical ? canonical.name : f.handlebarsEquivalent).toLowerCase(),
71
+ f.name,
72
+ ];
73
+ }));
74
+ /**
75
+ * Set of AMPscript function names (lowercase) flagged `mcnHandlebarsGap: true`
76
+ * in ampscript-data (Category C). These are documented as MCN-supported but
77
+ * currently fail at runtime and have no Handlebars helper — converting them
78
+ * must emit a MANUAL_REWRITE marker distinct from Category B (no counterpart).
79
+ */
80
+ export const AMP_MCN_HANDLEBARS_GAP = new Set(AMPSCRIPT_FUNCTIONS.filter((f) => f.mcnHandlebarsGap === true).map((f) => f.name.toLowerCase()));
81
+ // ---------------------------------------------------------------------------
40
82
  // AMP_NATIVE_JS_HINTS: AMPscript-only functions with clean native JS rewrites
41
83
  // ---------------------------------------------------------------------------
42
84
  /**
@@ -165,6 +207,17 @@ export const NON_MIGRATABLE_SSJS_PATTERNS = [
165
207
  reason: 'Query string / form field access requires CloudPages context (not available in MCN)',
166
208
  },
167
209
  ];
210
+ /**
211
+ * Returns the first non-migratable SSJS pattern that matches the given code, or undefined.
212
+ * @param code single line of trimmed SSJS source
213
+ */
214
+ function findNonMigratableSsjsPattern(code) {
215
+ return NON_MIGRATABLE_SSJS_PATTERNS.find(({ pattern }) => {
216
+ // Reset lastIndex for global regexes
217
+ pattern.lastIndex = 0;
218
+ return pattern.test(code);
219
+ });
220
+ }
168
221
  // ---------------------------------------------------------------------------
169
222
  // SSJS → AMPscript conversion
170
223
  // ---------------------------------------------------------------------------
@@ -195,8 +248,8 @@ export function ssjsToAmpscript(code) {
195
248
  .trim();
196
249
  const rawLines = inner.split('\n');
197
250
  const outputLines = [];
198
- for (const [i, original] of rawLines.entries()) {
199
- const lineNum = i + 1;
251
+ for (const [index, original] of rawLines.entries()) {
252
+ const lineNumber = index + 1;
200
253
  const trimmed = original.trim();
201
254
  // Skip blank lines, Platform.Load(), var-only declarations with no value
202
255
  if (!trimmed) {
@@ -206,111 +259,107 @@ export function ssjsToAmpscript(code) {
206
259
  // Skip Platform.Load() — no AMPscript equivalent, not needed in MCN
207
260
  if (/^Platform\.Load\s*\(/i.test(trimmed)) {
208
261
  changes.push({
209
- line: lineNum,
262
+ line: lineNumber,
210
263
  description: 'Removed Platform.Load() (not needed in AMPscript)',
211
264
  });
212
265
  continue;
213
266
  }
214
267
  // Check for non-migratable patterns first
215
- let isFlagged = false;
216
- for (const { pattern, reason } of NON_MIGRATABLE_SSJS_PATTERNS) {
217
- // Reset lastIndex for global regexes
218
- pattern.lastIndex = 0;
219
- if (pattern.test(trimmed)) {
220
- outputLines.push(`%%-- MANUAL_REWRITE_REQUIRED: ${reason} --%%`, `%%-- Original: ${trimmed} --%%`);
221
- flaggedSections.push({ line: lineNum, code: trimmed, reason });
222
- isFlagged = true;
223
- break;
224
- }
225
- }
226
- if (isFlagged) {
268
+ const nonMigratable = findNonMigratableSsjsPattern(trimmed);
269
+ if (nonMigratable) {
270
+ const { reason } = nonMigratable;
271
+ outputLines.push(`%%-- MANUAL_REWRITE_REQUIRED: ${reason} --%%`, `%%-- Original: ${trimmed} --%%`);
272
+ flaggedSections.push({ line: lineNumber, code: trimmed, reason });
227
273
  continue;
228
274
  }
229
275
  let line = original;
230
276
  // Platform.Variable.GetValue("name") → @name
231
277
  line = line.replaceAll(/Platform\.Variable\.GetValue\s*\(\s*["']([^"']+)["']\s*\)/gi, '@$1');
232
278
  // Platform.Variable.SetValue("name", value) → SET @name = value (strip ; at end if present)
233
- line = line.replaceAll(/Platform\.Variable\.SetValue\s*\(\s*["']([^"']+)["']\s*,\s*([^)]+)\)\s*;?/gi, (_, varName, value) => {
279
+ line = line.replaceAll(/Platform\.Variable\.SetValue\s*\(\s*["']([^"']+)["']\s*,\s*([^)]+)\)\s*;?/gi, (_, variableName, value) => {
234
280
  changes.push({
235
- line: lineNum,
236
- description: `Platform.Variable.SetValue → SET @${varName}`,
281
+ line: lineNumber,
282
+ description: `Platform.Variable.SetValue → SET @${variableName}`,
237
283
  });
238
- return `SET @${varName} = ${value.trim()}`;
284
+ return `SET @${variableName} = ${value.trim()}`;
239
285
  });
240
286
  // Platform.Response.Write(expr) → OutputLine(expr)
241
287
  line = line.replaceAll(/Platform\.Response\.Write\s*\(/gi, () => {
242
- changes.push({ line: lineNum, description: 'Platform.Response.Write → OutputLine' });
288
+ changes.push({ line: lineNumber, description: 'Platform.Response.Write → OutputLine' });
243
289
  return 'OutputLine(';
244
290
  });
245
291
  // Platform.Function.X(args) → X(args) using known function map
246
- line = line.replaceAll(/Platform\.Function\.(\w+)\s*\(/gi, (_, fnName) => {
247
- const key = fnName.toLowerCase();
292
+ line = line.replaceAll(/Platform\.Function\.(\w+)\s*\(/gi, (_, functionName) => {
293
+ const key = functionName.toLowerCase();
248
294
  if (SSJS_ONLY_FUNCTIONS.has(key)) {
249
295
  flaggedSections.push({
250
- line: lineNum,
251
- code: `Platform.Function.${fnName}(...)`,
252
- reason: `Platform.Function.${fnName} has no AMPscript equivalent`,
296
+ line: lineNumber,
297
+ code: `Platform.Function.${functionName}(...)`,
298
+ reason: `Platform.Function.${functionName} has no AMPscript equivalent`,
253
299
  });
254
- return `/* MANUAL_REWRITE_REQUIRED: Platform.Function.${fnName} has no AMPscript equivalent */ ${fnName}(`;
300
+ return `/* MANUAL_REWRITE_REQUIRED: Platform.Function.${functionName} has no AMPscript equivalent */ ${functionName}(`;
255
301
  }
256
302
  const ampName = PLATFORM_FUNCTION_TO_AMP[key];
257
303
  if (!ampName) {
258
304
  flaggedSections.push({
259
- line: lineNum,
260
- code: `Platform.Function.${fnName}(...)`,
261
- reason: `Platform.Function.${fnName} not found in ssjs-data catalog`,
305
+ line: lineNumber,
306
+ code: `Platform.Function.${functionName}(...)`,
307
+ reason: `Platform.Function.${functionName} not found in ssjs-data catalog`,
262
308
  });
263
- return `/* MANUAL_REWRITE_REQUIRED: unknown Platform.Function.${fnName} */ ${fnName}(`;
309
+ return `/* MANUAL_REWRITE_REQUIRED: unknown Platform.Function.${functionName} */ ${functionName}(`;
264
310
  }
265
311
  changes.push({
266
- line: lineNum,
267
- description: `Platform.Function.${fnName} → ${ampName}`,
312
+ line: lineNumber,
313
+ description: `Platform.Function.${functionName} → ${ampName}`,
268
314
  });
269
315
  return `${ampName}(`;
270
316
  });
271
317
  // var x = expr; → SET @x = expr
272
- line = line.replace(/\bvar\s+([A-Za-z_]\w*)\s*=\s*(.+?)\s*;?\s*$/, (_, varName, value) => {
318
+ line = line.replace(/\bvar\s+([A-Za-z_]\w*)\s*=\s*(.+?)\s*;?\s*$/, (_, variableName, value) => {
273
319
  changes.push({
274
- line: lineNum,
275
- description: `var ${varName} = ... → SET @${varName}`,
320
+ line: lineNumber,
321
+ description: `var ${variableName} = ... → SET @${variableName}`,
276
322
  });
277
- return `SET @${varName} = ${value.trim()}`;
323
+ return `SET @${variableName} = ${value.trim()}`;
278
324
  });
279
325
  // var x; → VAR @x
280
- line = line.replace(/\bvar\s+([A-Za-z_]\w*)\s*;?\s*$/, (_, varName) => {
281
- changes.push({ line: lineNum, description: `var ${varName} → VAR @${varName}` });
282
- return `VAR @${varName}`;
326
+ line = line.replace(/\bvar\s+([A-Za-z_]\w*)\s*;?\s*$/, (_, variableName) => {
327
+ changes.push({
328
+ line: lineNumber,
329
+ description: `var ${variableName} → VAR @${variableName}`,
330
+ });
331
+ return `VAR @${variableName}`;
283
332
  });
284
333
  // Control flow: if (cond) { → IF cond THEN
285
334
  line = line.replace(/^\s*if\s*\((.+)\)\s*\{\s*$/, (_, cond) => {
286
335
  const ampCond = ssjsCondToAmp(cond.trim());
287
- changes.push({ line: lineNum, description: 'if (...) { → IF ... THEN' });
336
+ changes.push({ line: lineNumber, description: 'if (...) { → IF ... THEN' });
288
337
  return `IF ${ampCond} THEN`;
289
338
  });
290
339
  // } else if (cond) { → ELSEIF cond THEN
291
340
  line = line.replace(/^\s*\}\s*else\s+if\s*\((.+)\)\s*\{\s*$/, (_, cond) => {
292
341
  const ampCond = ssjsCondToAmp(cond.trim());
293
- changes.push({ line: lineNum, description: '} else if (...) { → ELSEIF ... THEN' });
342
+ changes.push({ line: lineNumber, description: '} else if (...) { → ELSEIF ... THEN' });
294
343
  return `ELSEIF ${ampCond} THEN`;
295
344
  });
296
345
  // } else { → ELSE
297
346
  line = line.replace(/^\s*\}\s*else\s*\{\s*$/, () => {
298
- changes.push({ line: lineNum, description: '} else { → ELSE' });
347
+ changes.push({ line: lineNumber, description: '} else { → ELSE' });
299
348
  return 'ELSE';
300
349
  });
301
350
  // for (var i = start; i <= end; i++) { → FOR @i = start TO end DO
302
351
  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);
303
352
  if (forMatch) {
304
- const [, iterVar, start, end] = forMatch;
353
+ const [, iterVariable, start, end] = forMatch;
305
354
  changes.push({
306
- line: lineNum,
307
- description: `for (var ${iterVar}...) → FOR @${iterVar} = ${start} TO ${end} DO`,
355
+ line: lineNumber,
356
+ description: `for (var ${iterVariable}...) → FOR @${iterVariable} = ${start} TO ${end} DO`,
308
357
  });
309
- line = `FOR @${iterVar} = ${start} TO ${end} DO`;
358
+ line = `FOR @${iterVariable} = ${start} TO ${end} DO`;
310
359
  }
311
360
  // Standalone closing brace } → ENDIF (best-effort; may not always be correct)
312
361
  if (/^\s*\}\s*$/.test(line) && !/^\s*\}\s*(else|catch|finally)/.test(line)) {
313
- changes.push({ line: lineNum, description: '} → ENDIF' });
362
+ changes.push({ line: lineNumber, description: '} → ENDIF' });
314
363
  line = 'ENDIF';
315
364
  }
316
365
  // Strip trailing semicolons from non-SET lines (AMPscript doesn't use them)
@@ -366,8 +415,8 @@ export function ampscriptToSsjs(code) {
366
415
  const normalized = normalizeAmpscriptBlocks(code);
367
416
  const lines = normalized.split('\n');
368
417
  const lineOffset = 0;
369
- for (const [i, line] of lines.entries()) {
370
- const lineNum = i + 1 + lineOffset;
418
+ for (const [index, line] of lines.entries()) {
419
+ const lineNumber = index + 1 + lineOffset;
371
420
  const trimmed = line.trim();
372
421
  if (!trimmed) {
373
422
  outputLines.push('');
@@ -376,51 +425,52 @@ export function ampscriptToSsjs(code) {
376
425
  // %%=Output(@x)=%% or %%=OutputLine(@x)=%%
377
426
  const inlineOutputMatch = /^%%=\s*(?:Output|OutputLine)\s*\((.+)\)\s*=%%$/i.exec(trimmed);
378
427
  if (inlineOutputMatch) {
379
- const expr = stripAmpVars(inlineOutputMatch[1].trim());
428
+ const expression = stripAmpVars(inlineOutputMatch[1].trim());
380
429
  changes.push({
381
- line: lineNum,
430
+ line: lineNumber,
382
431
  description: '%%=Output(...)=%% → Platform.Response.Write(...)',
383
432
  });
384
- outputLines.push(`Platform.Response.Write(${expr});`);
433
+ outputLines.push(`Platform.Response.Write(${expression});`);
385
434
  continue;
386
435
  }
387
436
  // %%=FunctionName(args)=%% → Platform.Response.Write(Platform.Function.FunctionName(args))
388
- const inlineFnMatch = /^%%=\s*(\w+)\s*\((.*)?\)\s*=%%$/i.exec(trimmed);
389
- if (inlineFnMatch) {
390
- const fnName = inlineFnMatch[1];
391
- const args = inlineFnMatch[2]?.trim() ?? '';
392
- const key = fnName.toLowerCase();
437
+ const inlineFunctionMatch = /^%%=\s*(\w+)\s*\((.*)?\)\s*=%%$/i.exec(trimmed);
438
+ if (inlineFunctionMatch) {
439
+ const functionName = inlineFunctionMatch[1];
440
+ const arguments_ = inlineFunctionMatch[2]?.trim() ?? '';
441
+ const key = functionName.toLowerCase();
393
442
  const ssName = AMP_TO_PLATFORM_FUNCTION[key];
394
- const argsConverted = stripAmpVars(args);
443
+ const nativeHint = AMP_NATIVE_JS_HINTS[key];
444
+ const argumentsConverted = stripAmpVars(arguments_);
395
445
  if (ssName) {
396
446
  changes.push({
397
- line: lineNum,
398
- description: `%%=${fnName}(...)=%% → Platform.Response.Write(Platform.Function.${ssName}(...))`,
447
+ line: lineNumber,
448
+ description: `%%=${functionName}(...)=%% → Platform.Response.Write(Platform.Function.${ssName}(...))`,
399
449
  });
400
- outputLines.push(`Platform.Response.Write(Platform.Function.${ssName}(${argsConverted}));`);
450
+ outputLines.push(`Platform.Response.Write(Platform.Function.${ssName}(${argumentsConverted}));`);
401
451
  }
402
- else if (AMP_NATIVE_JS_HINTS[key]) {
452
+ else if (nativeHint) {
403
453
  changes.push({
404
- line: lineNum,
405
- description: `%%=${fnName}(...)=%% → native JS hint`,
454
+ line: lineNumber,
455
+ description: `%%=${functionName}(...)=%% → native JS hint`,
406
456
  });
407
- outputLines.push(`Platform.Response.Write(${AMP_NATIVE_JS_HINTS[key]} /* AMPscript: ${fnName}(${args}) */);`);
457
+ outputLines.push(`Platform.Response.Write(${nativeHint} /* AMPscript: ${functionName}(${arguments_}) */);`);
408
458
  }
409
459
  else if (CLOUDPAGES_ONLY_FUNCTIONS.has(key)) {
410
460
  // CloudPages-only function
411
- outputLines.push(`/* MANUAL_REWRITE_REQUIRED: %%=${fnName}(${args})=%% */`);
461
+ outputLines.push(`/* MANUAL_REWRITE_REQUIRED: %%=${functionName}(${arguments_})=%% */`);
412
462
  flaggedSections.push({
413
- line: lineNum,
463
+ line: lineNumber,
414
464
  code: trimmed,
415
- reason: `AMPscript function '${fnName}' is CloudPages-only — no SSJS equivalent`,
465
+ reason: `AMPscript function '${functionName}' is CloudPages-only — no SSJS equivalent`,
416
466
  });
417
467
  }
418
468
  else {
419
469
  // AMP-only function with no native hint → polyfill
420
470
  polyfillUsed.value = true;
421
471
  changes.push({
422
- line: lineNum,
423
- description: `%%=${fnName}(...)=%% → _ampScript polyfill`,
472
+ line: lineNumber,
473
+ description: `%%=${functionName}(...)=%% → _ampScript polyfill`,
424
474
  });
425
475
  outputLines.push(`Platform.Response.Write(_ampScript('${trimmed.replaceAll("'", String.raw `\'`)}'));`);
426
476
  }
@@ -430,9 +480,9 @@ export function ampscriptToSsjs(code) {
430
480
  const blockMatch = /^%%\[\s*([\s\S]*?)\s*\]%%$/i.exec(trimmed);
431
481
  if (blockMatch) {
432
482
  const blockContent = blockMatch[1].trim();
433
- const stmts = blockContent.split(/\n+/);
434
- for (const stmt of stmts) {
435
- const converted = convertAmpStatement(stmt.trim(), lineNum, changes, flaggedSections, polyfillUsed);
483
+ const statements = blockContent.split(/\n+/);
484
+ for (const statement of statements) {
485
+ const converted = convertAmpStatement(statement.trim(), lineNumber, changes, flaggedSections, polyfillUsed);
436
486
  if (converted !== null) {
437
487
  outputLines.push(converted);
438
488
  }
@@ -441,7 +491,7 @@ export function ampscriptToSsjs(code) {
441
491
  }
442
492
  // Bare AMPscript statement (already stripped of delimiters from normalizeAmpscriptBlocks)
443
493
  if (/^(SET|VAR|IF|ELSEIF|ELSE|ENDIF|FOR|NEXT|OUTPUT|OUTPUTLINE)\b/i.test(trimmed)) {
444
- const converted = convertAmpStatement(trimmed, lineNum, changes, flaggedSections, polyfillUsed);
494
+ const converted = convertAmpStatement(trimmed, lineNumber, changes, flaggedSections, polyfillUsed);
445
495
  if (converted !== null) {
446
496
  outputLines.push(converted);
447
497
  }
@@ -449,7 +499,7 @@ export function ampscriptToSsjs(code) {
449
499
  }
450
500
  // Bare function call or other AMPscript statement inside a normalized block
451
501
  if (/^\w+\s*\(/.test(trimmed)) {
452
- const converted = convertAmpStatement(trimmed, lineNum, changes, flaggedSections, polyfillUsed);
502
+ const converted = convertAmpStatement(trimmed, lineNumber, changes, flaggedSections, polyfillUsed);
453
503
  if (converted !== null) {
454
504
  outputLines.push(converted);
455
505
  }
@@ -505,40 +555,42 @@ function convertAmpStatement(stmt, lineNum, changes, flaggedSections, polyfillUs
505
555
  // SET @x = expr → var x = expr;
506
556
  const setMatch = /^SET\s+@(\w+)\s*=\s*(.+)$/i.exec(stmt);
507
557
  if (setMatch) {
508
- const [, varName, expr] = setMatch;
509
- const exprTrimmed = expr.trim();
558
+ const [, variableName, expression] = setMatch;
559
+ const expressionTrimmed = expression.trim();
510
560
  // Check if the expression is a single AMP-only function call with no Platform.Function equivalent
511
561
  // and no native JS hint — if so, emit _ampScript polyfill rather than a broken expression.
512
- const singleFnMatch = /^(\w+)\s*\(/.exec(exprTrimmed);
513
- if (singleFnMatch) {
514
- const key = singleFnMatch[1].toLowerCase();
562
+ const singleFunctionMatch = /^(\w+)\s*\(/.exec(expressionTrimmed);
563
+ if (singleFunctionMatch) {
564
+ const key = singleFunctionMatch[1].toLowerCase();
515
565
  const hasSsjsEquiv = AMP_TO_PLATFORM_FUNCTION[key] !== undefined;
516
566
  const hasNativeHint = AMP_NATIVE_JS_HINTS[key] !== undefined;
517
567
  if (!hasSsjsEquiv && !hasNativeHint && !CLOUDPAGES_ONLY_FUNCTIONS.has(key)) {
518
568
  polyfillUsed.value = true;
519
569
  changes.push({
520
570
  line: lineNum,
521
- description: `SET @${varName} = ${singleFnMatch[1]}(...) → _ampScript polyfill`,
571
+ description: `SET @${variableName} = ${singleFunctionMatch[1]}(...) → _ampScript polyfill`,
522
572
  });
523
- return `var ${varName} = _ampScript('${exprTrimmed.replaceAll("'", String.raw `\'`)}');`;
573
+ return `var ${variableName} = _ampScript('${expressionTrimmed.replaceAll("'", String.raw `\'`)}');`;
524
574
  }
525
575
  }
526
- const ssExpr = convertAmpExpr(exprTrimmed);
576
+ const ssExpression = convertAmpExpression(expressionTrimmed);
527
577
  changes.push({
528
578
  line: lineNum,
529
- description: `SET @${varName} = ... → var ${varName} = ...`,
579
+ description: `SET @${variableName} = ... → var ${variableName} = ...`,
530
580
  });
531
- return `var ${varName} = ${ssExpr};`;
581
+ return `var ${variableName} = ${ssExpression};`;
532
582
  }
533
583
  // VAR @x, @y → var x, y;
534
- const varMatch = /^VAR\s+(.+)$/i.exec(stmt);
535
- if (varMatch) {
536
- const vars = varMatch[1].split(',').map((v) => v.trim().replace(/^@/, ''));
584
+ const variableMatch = /^VAR\s+(.+)$/i.exec(stmt);
585
+ if (variableMatch) {
586
+ const variables = variableMatch[1]
587
+ .split(',')
588
+ .map((v) => v.trim().replace(/^@/, ''));
537
589
  changes.push({
538
590
  line: lineNum,
539
- description: `VAR @${vars.join(', @')} → var ${vars.join(', ')}`,
591
+ description: `VAR @${variables.join(', @')} → var ${variables.join(', ')}`,
540
592
  });
541
- return `var ${vars.join(', ')};`;
593
+ return `var ${variables.join(', ')};`;
542
594
  }
543
595
  // IF cond THEN → if (cond) {
544
596
  const ifMatch = /^IF\s+(.+?)\s+THEN$/i.exec(stmt);
@@ -567,14 +619,14 @@ function convertAmpStatement(stmt, lineNum, changes, flaggedSections, polyfillUs
567
619
  // FOR @i = start TO end DO → for (var i = start; i <= end; i++) {
568
620
  const forMatch = /^FOR\s+@(\w+)\s*=\s*(\S+?)\s+TO\s+(\S+?)(?:\s+STEP\s+\S+)?\s+DO$/i.exec(stmt);
569
621
  if (forMatch) {
570
- const [, iterVar, start, end] = forMatch;
622
+ const [, iterVariable, start, end] = forMatch;
571
623
  const ssStart = stripAmpVars(start);
572
624
  const ssEnd = stripAmpVars(end);
573
625
  changes.push({
574
626
  line: lineNum,
575
- description: `FOR @${iterVar} = ${start} TO ${end} DO → for loop`,
627
+ description: `FOR @${iterVariable} = ${start} TO ${end} DO → for loop`,
576
628
  });
577
- return `for (var ${iterVar} = ${ssStart}; ${iterVar} <= ${ssEnd}; ${iterVar}++) {`;
629
+ return `for (var ${iterVariable} = ${ssStart}; ${iterVariable} <= ${ssEnd}; ${iterVariable}++) {`;
578
630
  }
579
631
  // NEXT @i → }
580
632
  if (/^NEXT\s+@\w+$/i.test(stmt)) {
@@ -584,23 +636,23 @@ function convertAmpStatement(stmt, lineNum, changes, flaggedSections, polyfillUs
584
636
  // OUTPUT(expr) / OUTPUTLINE(expr) → Platform.Response.Write(expr)
585
637
  const outputMatch = /^(?:OUTPUT|OUTPUTLINE)\s*\((.+)\)$/i.exec(stmt);
586
638
  if (outputMatch) {
587
- const expr = convertAmpExpr(outputMatch[1].trim());
639
+ const expression = convertAmpExpression(outputMatch[1].trim());
588
640
  changes.push({ line: lineNum, description: 'Output/OutputLine → Platform.Response.Write' });
589
- return `Platform.Response.Write(${expr});`;
641
+ return `Platform.Response.Write(${expression});`;
590
642
  }
591
643
  // Known AMPscript function call → Platform.Function.X(args)
592
- const fnCallMatch = /^(\w+)\s*\((.*)?\)$/i.exec(stmt);
593
- if (fnCallMatch) {
594
- const [, fnName, args] = fnCallMatch;
595
- const key = fnName.toLowerCase();
644
+ const functionCallMatch = /^(\w+)\s*\((.*)?\)$/i.exec(stmt);
645
+ if (functionCallMatch) {
646
+ const [, functionName, arguments_] = functionCallMatch;
647
+ const key = functionName.toLowerCase();
596
648
  const ssName = AMP_TO_PLATFORM_FUNCTION[key];
597
649
  if (ssName) {
598
- const argsConverted = args ? convertAmpExpr(args.trim()) : '';
650
+ const argumentsConverted = arguments_ ? convertAmpExpression(arguments_.trim()) : '';
599
651
  changes.push({
600
652
  line: lineNum,
601
- description: `${fnName}(…) → Platform.Function.${ssName}(…)`,
653
+ description: `${functionName}(…) → Platform.Function.${ssName}(…)`,
602
654
  });
603
- return `Platform.Function.${ssName}(${argsConverted});`;
655
+ return `Platform.Function.${ssName}(${argumentsConverted});`;
604
656
  }
605
657
  // CloudPages-only functions have no SSJS/MCN equivalent
606
658
  if (CLOUDPAGES_ONLY_FUNCTIONS.has(key)) {
@@ -616,7 +668,7 @@ function convertAmpStatement(stmt, lineNum, changes, flaggedSections, polyfillUs
616
668
  if (hint) {
617
669
  changes.push({
618
670
  line: lineNum,
619
- description: `${fnName}(…) → native JS equivalent`,
671
+ description: `${functionName}(…) → native JS equivalent`,
620
672
  });
621
673
  return `${hint} /* AMPscript: ${stmt} */`;
622
674
  }
@@ -624,7 +676,7 @@ function convertAmpStatement(stmt, lineNum, changes, flaggedSections, polyfillUs
624
676
  polyfillUsed.value = true;
625
677
  changes.push({
626
678
  line: lineNum,
627
- description: `${fnName}(…) → _ampScript polyfill (no direct SSJS equivalent)`,
679
+ description: `${functionName}(…) → _ampScript polyfill (no direct SSJS equivalent)`,
628
680
  });
629
681
  return `_ampScript('${stmt.replaceAll("'", String.raw `\'`)}');`;
630
682
  }
@@ -655,21 +707,21 @@ function convertAmpStatement(stmt, lineNum, changes, flaggedSections, polyfillUs
655
707
  * @param expr - AMPscript expression string.
656
708
  * @returns {string} SSJS expression string.
657
709
  */
658
- function convertAmpExpr(expr) {
710
+ function convertAmpExpression(expr) {
659
711
  // Replace known AMPscript function calls with Platform.Function.X equivalents;
660
712
  // emit a hint comment for native-hint functions; leave others with a MANUAL_REWRITE comment.
661
713
  // Note: AMP-only functions used in a SET assignment are handled at the statement level
662
714
  // (convertAmpStatement SET handler) which emits _ampScript(...) for the whole expression.
663
- let result = expr.replaceAll(/\b(\w+)\s*\(/g, (match, fnName) => {
664
- const key = fnName.toLowerCase();
715
+ let result = expr.replaceAll(/\b(\w+)\s*\(/g, (match, functionName) => {
716
+ const key = functionName.toLowerCase();
665
717
  const ssName = AMP_TO_PLATFORM_FUNCTION[key];
666
718
  if (ssName)
667
719
  return `Platform.Function.${ssName}(`;
668
720
  const hint = AMP_NATIVE_JS_HINTS[key];
669
721
  if (hint)
670
- return `${hint} /* ${fnName}( */`;
722
+ return `${hint} /* ${functionName}( */`;
671
723
  // Unknown function in expression context — annotate
672
- return `/* MANUAL_REWRITE_REQUIRED: no SSJS equivalent for ${fnName} */ ${fnName}(`;
724
+ return `/* MANUAL_REWRITE_REQUIRED: no SSJS equivalent for ${functionName} */ ${functionName}(`;
673
725
  });
674
726
  // Strip @ from variable references
675
727
  result = stripAmpVars(result);
@@ -698,6 +750,335 @@ function ampCondToSsjs(cond) {
698
750
  export function stripAmpVars(expr) {
699
751
  return expr.replaceAll(/@(\w+)/g, '$1');
700
752
  }
753
+ // ---------------------------------------------------------------------------
754
+ // AMPscript ↔ MCN Handlebars conversion
755
+ // ---------------------------------------------------------------------------
756
+ /**
757
+ * Distinct MANUAL_REWRITE note for Category C functions (`mcnHandlebarsGap`).
758
+ * Must be visibly different from the Category B note so consumers can tell a
759
+ * documented-but-broken function apart from one with no counterpart at all.
760
+ */
761
+ export const HBS_GAP_NOTE = 'documented as supported in Marketing Cloud Next but currently fails at runtime — no Handlebars helper exists yet';
762
+ /**
763
+ * Fold a single character into the argument-splitting accumulator state.
764
+ * @param state - Mutable accumulator carried across characters.
765
+ * @param ch - The current character.
766
+ */
767
+ function foldArgumentChar(state, ch) {
768
+ if (state.quote) {
769
+ state.current += ch;
770
+ if (ch === state.quote)
771
+ state.quote = null;
772
+ return;
773
+ }
774
+ switch (ch) {
775
+ case '"':
776
+ case "'": {
777
+ state.quote = ch;
778
+ state.current += ch;
779
+ break;
780
+ }
781
+ case '(':
782
+ case '[': {
783
+ state.depth++;
784
+ state.current += ch;
785
+ break;
786
+ }
787
+ case ')':
788
+ case ']': {
789
+ state.depth--;
790
+ state.current += ch;
791
+ break;
792
+ }
793
+ default: {
794
+ if (ch === ',' && state.depth === 0) {
795
+ state.args.push(state.current.trim());
796
+ state.current = '';
797
+ }
798
+ else {
799
+ state.current += ch;
800
+ }
801
+ }
802
+ }
803
+ }
804
+ function splitArguments(argsStr) {
805
+ if (!argsStr.trim())
806
+ return [];
807
+ const state = { depth: 0, current: '', quote: null, args: [] };
808
+ for (const ch of argsStr) {
809
+ foldArgumentChar(state, ch);
810
+ }
811
+ if (state.current.trim())
812
+ state.args.push(state.current.trim());
813
+ return state.args;
814
+ }
815
+ /**
816
+ * Convert a single AMPscript call argument to its Handlebars form: string and
817
+ * numeric literals pass through unchanged; `@var` references lose the `@`.
818
+ * @param arg - A single AMPscript argument.
819
+ * @returns {string} The Handlebars argument.
820
+ */
821
+ function ampArgumentToHbs(arg) {
822
+ const t = arg.trim();
823
+ if (/^["']/.test(t))
824
+ return t;
825
+ return stripAmpVars(t);
826
+ }
827
+ /**
828
+ * Convert a single Handlebars argument to its AMPscript form: string/number/
829
+ * boolean literals pass through; bare identifiers become `@var` references;
830
+ * dotted paths are returned unchanged (caller decides how to flag them).
831
+ * @param arg - A single Handlebars argument.
832
+ * @returns {string} The AMPscript argument.
833
+ */
834
+ function hbsArgumentToAmp(arg) {
835
+ const t = arg.trim();
836
+ if (/^["']/.test(t))
837
+ return t;
838
+ if (/^-?\d/.test(t))
839
+ return t;
840
+ if (/^(true|false|null)$/i.test(t))
841
+ return t;
842
+ if (t.startsWith('@'))
843
+ return t;
844
+ if (/^[A-Za-z_]\w*$/.test(t))
845
+ return `@${t}`;
846
+ return t;
847
+ }
848
+ /**
849
+ * Convert the inner text of a single AMPscript inline expression (`%%=…=%%`)
850
+ * to Handlebars, classifying function calls by the three conversion categories.
851
+ * @param inner - The expression between `%%=` and `=%%` (already trimmed).
852
+ * @param lineNum - Source line number for change/flag tracking.
853
+ * @param changes - Mutable change log.
854
+ * @param flaggedSections - Mutable flagged-section log.
855
+ * @returns {string} The Handlebars replacement string.
856
+ */
857
+ function convertInlineAmpToHbs(inner, lineNum, changes, flaggedSections) {
858
+ // v(@x) / v(x) → {{x}}
859
+ const vMatch = /^v\s*\(\s*@?([A-Za-z_]\w*)\s*\)$/i.exec(inner);
860
+ if (vMatch) {
861
+ changes.push({ line: lineNum, description: `%%=v(@${vMatch[1]})=%% → {{${vMatch[1]}}}` });
862
+ return `{{${vMatch[1]}}}`;
863
+ }
864
+ // Bare @var / var → {{var}}
865
+ const variableMatch = /^@?([A-Za-z_]\w*)$/.exec(inner);
866
+ if (variableMatch) {
867
+ changes.push({ line: lineNum, description: `%%=${inner}=%% → {{${variableMatch[1]}}}` });
868
+ return `{{${variableMatch[1]}}}`;
869
+ }
870
+ // FunctionName(args)
871
+ const functionMatch = /^([A-Za-z_]\w*)\s*\(([\s\S]*)\)$/.exec(inner);
872
+ if (functionMatch) {
873
+ const functionName = functionMatch[1];
874
+ const key = functionName.toLowerCase();
875
+ const hbsArguments = splitArguments(functionMatch[2]).map((a) => ampArgumentToHbs(a));
876
+ // Category A — mapped helper.
877
+ const helper = AMP_TO_HANDLEBARS[key];
878
+ if (helper) {
879
+ changes.push({
880
+ line: lineNum,
881
+ description: `%%=${functionName}(…)=%% → {{${helper} …}}`,
882
+ });
883
+ return `{{${helper}${hbsArguments.length > 0 ? ' ' + hbsArguments.join(' ') : ''}}}`;
884
+ }
885
+ // Category C — mcnHandlebarsGap (distinct note from Category B).
886
+ if (AMP_MCN_HANDLEBARS_GAP.has(key)) {
887
+ flaggedSections.push({
888
+ line: lineNum,
889
+ code: `%%=${inner}=%%`,
890
+ reason: `${functionName} is ${HBS_GAP_NOTE}`,
891
+ });
892
+ return `{{!-- MANUAL_REWRITE_REQUIRED: ${functionName} is ${HBS_GAP_NOTE} --}}`;
893
+ }
894
+ // Category B — no Handlebars counterpart.
895
+ flaggedSections.push({
896
+ line: lineNum,
897
+ code: `%%=${inner}=%%`,
898
+ reason: `AMPscript function '${functionName}' has no Handlebars equivalent`,
899
+ });
900
+ return `{{!-- MANUAL_REWRITE_REQUIRED: AMPscript function '${functionName}' has no Handlebars equivalent --}}`;
901
+ }
902
+ // Anything else (complex expression).
903
+ flaggedSections.push({
904
+ line: lineNum,
905
+ code: `%%=${inner}=%%`,
906
+ reason: 'complex AMPscript expression has no direct Handlebars form',
907
+ });
908
+ return `{{!-- MANUAL_REWRITE_REQUIRED: ${inner} --}}`;
909
+ }
910
+ /**
911
+ * Convert AMPscript to MCN Handlebars using deterministic, data-driven rules.
912
+ *
913
+ * Inline expressions (`%%=Fn(args)=%%`, `%%=v(@x)=%%`, `%%var%%`) are mapped via
914
+ * the three conversion categories built from ampscript-data's
915
+ * `handlebarsEquivalent` / `mcnHandlebarsGap` fields:
916
+ * - **A** — mapped helper → `{{helper …}}`
917
+ * - **B** — no counterpart → `{{!-- MANUAL_REWRITE_REQUIRED … --}}`
918
+ * - **C** — `mcnHandlebarsGap` → `{{!-- MANUAL_REWRITE_REQUIRED … (distinct note) --}}`
919
+ *
920
+ * Procedural AMPscript blocks (`%%[ … ]%%`, `SET`/`VAR`/`IF`/`FOR`) have no
921
+ * Handlebars counterpart (Handlebars cannot assign variables or run imperative
922
+ * control flow) and are flagged MANUAL_REWRITE_REQUIRED.
923
+ * @param code - AMPscript source code (may include HTML context).
924
+ * @returns {ConversionResult} Converted Handlebars, change log, and flagged sections.
925
+ */
926
+ export function ampscriptToHandlebars(code) {
927
+ const changes = [];
928
+ const flaggedSections = [];
929
+ const lines = code.split('\n');
930
+ const outputLines = [];
931
+ let isInBlock = false;
932
+ for (const [index, line] of lines.entries()) {
933
+ const lineNumber = index + 1;
934
+ const trimmed = line.trim();
935
+ // Track multi-line %%[ … ]%% blocks — flag the whole block once.
936
+ if (isInBlock) {
937
+ if (/\]%%/.test(line))
938
+ isInBlock = false;
939
+ continue;
940
+ }
941
+ if (/%%\[/.test(line)) {
942
+ if (!/\]%%/.test(line))
943
+ isInBlock = true;
944
+ outputLines.push('{{!-- MANUAL_REWRITE_REQUIRED: AMPscript block (SET/VAR/IF/FOR) has no Handlebars equivalent — Handlebars cannot assign variables or run imperative control flow --}}');
945
+ flaggedSections.push({
946
+ line: lineNumber,
947
+ code: trimmed.slice(0, 80),
948
+ reason: 'AMPscript procedural block has no Handlebars counterpart',
949
+ });
950
+ continue;
951
+ }
952
+ // Convert inline expressions on this line.
953
+ const converted = line
954
+ .replaceAll(/%%=\s*([\s\S]*?)\s*=%%/g, (_full, raw) => convertInlineAmpToHbs(raw.trim(), lineNumber, changes, flaggedSections))
955
+ .replaceAll(/%%([A-Za-z_]\w*)%%/g, (_full, v) => {
956
+ changes.push({ line: lineNumber, description: `%%${v}%% → {{${v}}}` });
957
+ return `{{${v}}}`;
958
+ });
959
+ outputLines.push(converted);
960
+ }
961
+ return { convertedCode: outputLines.join('\n'), changes, flaggedSections };
962
+ }
963
+ /**
964
+ * Convert MCN Handlebars to AMPscript using deterministic, data-driven rules.
965
+ *
966
+ * Inline helper calls (`{{helper args}}`) whose helper maps back to an AMPscript
967
+ * function (via HANDLEBARS_TO_AMP) become `%%=Fn(args)=%%`; bare variables
968
+ * (`{{name}}`) become `%%=v(@name)=%%`. Block helpers (`{{#each}}`…), partials
969
+ * (`{{> …}}`), dotted binding paths, and unknown helpers are flagged
970
+ * MANUAL_REWRITE_REQUIRED.
971
+ * @param code - Handlebars source code (may include HTML context).
972
+ * @returns {ConversionResult} Converted AMPscript, change log, and flagged sections.
973
+ */
974
+ export function handlebarsToAmpscript(code) {
975
+ const changes = [];
976
+ const flaggedSections = [];
977
+ let lineCursor = 1;
978
+ let lastIndex = 0;
979
+ const convertedCode = code.replaceAll(/\{\{([\s\S]*?)\}\}/g, (full, raw, offset) => {
980
+ lineCursor += countNewlines(code.slice(lastIndex, offset));
981
+ lastIndex = offset;
982
+ const lineNumber = lineCursor;
983
+ const inner = raw.trim();
984
+ // Comments — preserve as an AMPscript comment.
985
+ if (inner.startsWith('!')) {
986
+ return full;
987
+ }
988
+ // Block helpers / partials / closing tags — no deterministic AMPscript form.
989
+ if (/^[#/>]/.test(inner)) {
990
+ flaggedSections.push({
991
+ line: lineNumber,
992
+ code: full,
993
+ reason: 'Handlebars block helper / partial has no direct AMPscript equivalent',
994
+ });
995
+ return `%%-- MANUAL_REWRITE_REQUIRED: ${full} --%%`;
996
+ }
997
+ // Strip a leading no-escape ampersand: {{& x}}.
998
+ const body = inner.replace(/^&\s*/, '');
999
+ // Helper with arguments: {{helper arg1 arg2 …}}
1000
+ const functionMatch = /^([A-Za-z_]\w*)\s+(.+)$/.exec(body);
1001
+ if (functionMatch) {
1002
+ const helperName = functionMatch[1];
1003
+ const key = helperName.toLowerCase();
1004
+ const ampName = HANDLEBARS_TO_AMP[key];
1005
+ const ampArguments = splitArguments(functionMatch[2]).map((a) => hbsArgumentToAmp(a));
1006
+ if (ampName) {
1007
+ changes.push({
1008
+ line: lineNumber,
1009
+ description: `{{${helperName} …}} → %%=${ampName}(…)=%%`,
1010
+ });
1011
+ return `%%=${ampName}(${ampArguments.join(', ')})=%%`;
1012
+ }
1013
+ flaggedSections.push({
1014
+ line: lineNumber,
1015
+ code: full,
1016
+ reason: `Handlebars helper '${helperName}' has no AMPscript equivalent`,
1017
+ });
1018
+ return `%%-- MANUAL_REWRITE_REQUIRED: Handlebars helper '${helperName}' has no AMPscript equivalent --%%`;
1019
+ }
1020
+ // Bare single identifier: {{name}} → %%=v(@name)=%%
1021
+ const bareMatch = /^([A-Za-z_]\w*)$/.exec(body);
1022
+ if (bareMatch) {
1023
+ changes.push({
1024
+ line: lineNumber,
1025
+ description: `{{${bareMatch[1]}}} → %%=v(@${bareMatch[1]})=%%`,
1026
+ });
1027
+ return `%%=v(@${bareMatch[1]})=%%`;
1028
+ }
1029
+ // Dotted binding path or other expression — context-specific.
1030
+ flaggedSections.push({
1031
+ line: lineNumber,
1032
+ code: full,
1033
+ reason: 'Handlebars binding path requires context-specific AMPscript mapping',
1034
+ });
1035
+ return `%%-- MANUAL_REWRITE_REQUIRED: ${full} --%%`;
1036
+ });
1037
+ return { convertedCode, changes, flaggedSections };
1038
+ }
1039
+ /**
1040
+ * Count newline characters in a string (helper for line tracking in
1041
+ * handlebarsToAmpscript).
1042
+ * @param s - Input string.
1043
+ * @returns {number} Number of `\n` characters.
1044
+ */
1045
+ function countNewlines(s) {
1046
+ let n = 0;
1047
+ for (const ch of s) {
1048
+ if (ch === '\n')
1049
+ n++;
1050
+ }
1051
+ return n;
1052
+ }
1053
+ /**
1054
+ * Convert SSJS to MCN Handlebars deterministically via a two-step chain:
1055
+ * SSJS → AMPscript (`ssjsToAmpscript`) → Handlebars (`ampscriptToHandlebars`).
1056
+ *
1057
+ * Because Handlebars is declarative, most imperative SSJS has no Handlebars
1058
+ * counterpart and is conservatively flagged MANUAL_REWRITE_REQUIRED; inline
1059
+ * `Platform.Function.X(…)` calls that map through AMPscript to a Handlebars
1060
+ * helper are converted.
1061
+ * @param code - SSJS source code (may include `<script runat="server">` tags).
1062
+ * @returns {ConversionResult} Converted Handlebars, combined change log, and flagged sections.
1063
+ */
1064
+ export function ssjsToHandlebars(code) {
1065
+ const toAmp = ssjsToAmpscript(code);
1066
+ const toHbs = ampscriptToHandlebars(toAmp.convertedCode);
1067
+ return {
1068
+ convertedCode: toHbs.convertedCode,
1069
+ changes: [
1070
+ ...toAmp.changes.map((c) => ({
1071
+ line: c.line,
1072
+ description: `[SSJS→AMPscript] ${c.description}`,
1073
+ })),
1074
+ ...toHbs.changes.map((c) => ({
1075
+ line: c.line,
1076
+ description: `[AMPscript→Handlebars] ${c.description}`,
1077
+ })),
1078
+ ],
1079
+ flaggedSections: [...toAmp.flaggedSections, ...toHbs.flaggedSections],
1080
+ };
1081
+ }
701
1082
  /**
702
1083
  * Rewrite AMPscript code to be compatible with Marketing Cloud Next.
703
1084
  *
@@ -717,15 +1098,15 @@ export function rewriteAmpForMcn(code, options) {
717
1098
  let rewrittenCode = code;
718
1099
  // 1. Remove StringToDate() wrapper inside FormatDate() first argument
719
1100
  // FormatDate(StringToDate(x), fmt) → FormatDate(x, fmt)
720
- const strToDatePattern = /FormatDate\s*\(\s*StringToDate\s*\(([^)]+)\)\s*,/gi;
721
- if (strToDatePattern.test(rewrittenCode)) {
1101
+ const stringToDatePattern = /FormatDate\s*\(\s*StringToDate\s*\(([^)]+)\)\s*,/gi;
1102
+ if (stringToDatePattern.test(rewrittenCode)) {
722
1103
  rewrittenCode = rewrittenCode.replaceAll(/FormatDate\s*\(\s*StringToDate\s*\(([^)]+)\)\s*,/gi, 'FormatDate($1,');
723
1104
  // Find affected lines for change tracking
724
1105
  const lines = code.split('\n');
725
- for (const [i, line] of lines.entries()) {
1106
+ for (const [index, line] of lines.entries()) {
726
1107
  if (/FormatDate\s*\(\s*StringToDate\s*\(/i.test(line)) {
727
1108
  changes.push({
728
- line: i + 1,
1109
+ line: index + 1,
729
1110
  type: 'rewritten',
730
1111
  description: 'Removed StringToDate() wrapper: FormatDate(StringToDate(x), fmt) → FormatDate(x, fmt)',
731
1112
  });
@@ -733,104 +1114,102 @@ export function rewriteAmpForMcn(code, options) {
733
1114
  }
734
1115
  }
735
1116
  // 2. Convert .NET format strings to Java SimpleDateFormat in FormatDate() calls
736
- rewrittenCode = rewrittenCode.replaceAll(/FormatDate\s*\(\s*([^,]+),\s*"([^"]+)"\s*\)/gi, (match, arg1, formatStr) => {
737
- let newFormat = formatStr;
738
- let changed = false;
739
- let hasShorthand = false;
1117
+ rewrittenCode = rewrittenCode.replaceAll(/FormatDate\s*\(\s*([^,]+),\s*"([^"]+)"\s*\)/gi, (match, argument1, formatString) => {
740
1118
  // Check for standard shorthands
741
- for (const shorthand of DOTNET_STANDARD_SHORTHANDS) {
742
- if (new RegExp(`^${shorthand}$`).test(formatStr.trim())) {
743
- hasShorthand = true;
744
- }
745
- }
1119
+ const trimmedFormat = formatString.trim();
1120
+ const hasShorthand = [...DOTNET_STANDARD_SHORTHANDS].some((shorthand) => new RegExp(`^${shorthand}$`).test(trimmedFormat));
746
1121
  if (hasShorthand) {
747
1122
  // Annotate with comment about needing explicit format
748
- return `FormatDate(${arg1}, "/* MANUAL_REWRITE_REQUIRED: Convert .NET standard shorthand '${formatStr}' to explicit Java SimpleDateFormat pattern */"${formatStr}")`;
1123
+ return `FormatDate(${argument1}, "/* MANUAL_REWRITE_REQUIRED: Convert .NET standard shorthand '${formatString}' to explicit Java SimpleDateFormat pattern */"${formatString}")`;
749
1124
  }
1125
+ let newFormat = formatString;
1126
+ let isChanged = false;
750
1127
  // Apply .NET → Java replacements
751
1128
  for (const [pattern, replacement] of DOTNET_TO_JAVA_FORMAT_REPLACEMENTS) {
752
1129
  const before = newFormat;
753
- newFormat = newFormat.replace(pattern, replacement);
1130
+ newFormat = newFormat.replace(pattern, () => replacement);
754
1131
  if (newFormat !== before) {
755
- changed = true;
1132
+ isChanged = true;
756
1133
  }
757
1134
  }
758
- if (changed) {
759
- return `FormatDate(${arg1}, "${newFormat}")`;
1135
+ if (isChanged) {
1136
+ return `FormatDate(${argument1}, "${newFormat}")`;
760
1137
  }
761
1138
  return match;
762
1139
  });
763
1140
  // Track format string changes
764
1141
  const codeLines = code.split('\n');
765
1142
  const rewrittenLines = rewrittenCode.split('\n');
766
- for (const [i, codeLine] of codeLines.entries()) {
767
- if (codeLine !== rewrittenLines[i] && /FormatDate/i.test(codeLine)) {
1143
+ for (const [index, codeLine] of codeLines.entries()) {
1144
+ if (codeLine !== rewrittenLines[index] && /FormatDate/i.test(codeLine)) {
768
1145
  changes.push({
769
- line: i + 1,
1146
+ line: index + 1,
770
1147
  type: 'rewritten',
771
1148
  description: `Converted .NET format string to Java SimpleDateFormat in FormatDate()`,
772
1149
  });
773
1150
  }
774
1151
  }
775
1152
  // 3. Annotate Lookup() calls with odd argument counts
776
- rewrittenCode = rewrittenCode.replaceAll(/\bLookup\s*\(([^)]+)\)/gi, (match, argsStr) => {
777
- const argCount = countArgs(argsStr);
1153
+ rewrittenCode = rewrittenCode.replaceAll(/\bLookup\s*\(([^)]+)\)/gi, (match, argumentsString) => {
1154
+ const argumentCount = countArgs(argumentsString);
778
1155
  // Lookup takes: DE, returnCol, [searchCol, searchVal, ...]
779
1156
  // Min 2 args, then pairs after that → should be even number > 2 or exactly 2
780
1157
  // Odd count after first 2 = problem
781
- if (argCount >= 3 && (argCount - 2) % 2 !== 0) {
782
- 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. --%% `;
1158
+ if (argumentCount >= 3 && (argumentCount - 2) % 2 !== 0) {
1159
+ return `${match} %%-- MCN NOTE: Lookup() requires search arguments in column/value pairs (even count after DE and return column). Current arg count (${argumentCount}) may cause an error in MCN. --%% `;
783
1160
  }
784
1161
  return match;
785
1162
  });
786
1163
  // 4. Mark MCE-only function calls with annotation
787
1164
  // Find all function calls and mark unsupported ones
788
- const funcCallPattern = /\b([A-Z][A-Za-z]+)\s*\(/g;
789
- let funcMatch;
1165
+ const functionCallPattern = /\b([A-Z][A-Za-z]+)\s*\(/g;
1166
+ let functionMatch;
790
1167
  const seenUnsupported = new Set();
791
- while ((funcMatch = funcCallPattern.exec(code)) !== null) {
792
- const fnName = funcMatch[1];
793
- if (!isMcnSupportedFn(fnName) && fnName.length > 1) {
794
- seenUnsupported.add(fnName);
1168
+ while ((functionMatch = functionCallPattern.exec(code)) !== null) {
1169
+ const functionName = functionMatch[1];
1170
+ if (!isMcnSupportedFn(functionName) && functionName.length > 1) {
1171
+ seenUnsupported.add(functionName);
795
1172
  }
796
1173
  }
797
- for (const fnName of seenUnsupported) {
798
- const mcnNotes = getMcnNotesFn(fnName);
799
- const annotationPattern = new RegExp(String.raw `\b${fnName}\s*\(`, 'gi');
1174
+ for (const functionName of seenUnsupported) {
1175
+ const mcnNotes = getMcnNotesFn(functionName);
1176
+ const annotationPattern = new RegExp(String.raw `\b${functionName}\s*\(`, 'gi');
800
1177
  rewrittenCode = rewrittenCode.replace(annotationPattern, (m) => {
801
- return `%%-- NOT SUPPORTED IN MCN: ${fnName}${mcnNotes ? ` — ${mcnNotes}` : ''} --%%\n${m}`;
1178
+ return `%%-- NOT SUPPORTED IN MCN: ${functionName}${mcnNotes ? ` — ${mcnNotes}` : ''} --%%\n${m}`;
802
1179
  });
803
1180
  // Find lines for tracking
804
- for (const [i, codeLine] of codeLines.entries()) {
805
- if (new RegExp(String.raw `\b${fnName}\s*\(`, 'i').test(codeLine)) {
806
- changes.push({
807
- line: i + 1,
808
- type: 'annotated',
809
- description: `${fnName}() is not supported in Marketing Cloud Next`,
810
- });
811
- nonMigratableItems.push({
812
- line: i + 1,
813
- code: codeLine.trim(),
814
- reason: `${fnName}() is not available in Marketing Cloud Next`,
815
- });
816
- }
1181
+ const linePattern = new RegExp(String.raw `\b${functionName}\s*\(`, 'i');
1182
+ const matchingLines = codeLines
1183
+ .map((codeLine, index) => ({ codeLine, index }))
1184
+ .filter(({ codeLine }) => linePattern.test(codeLine));
1185
+ for (const { codeLine, index } of matchingLines) {
1186
+ changes.push({
1187
+ line: index + 1,
1188
+ type: 'annotated',
1189
+ description: `${functionName}() is not supported in Marketing Cloud Next`,
1190
+ });
1191
+ nonMigratableItems.push({
1192
+ line: index + 1,
1193
+ code: codeLine.trim(),
1194
+ reason: `${functionName}() is not available in Marketing Cloud Next`,
1195
+ });
817
1196
  }
818
1197
  }
819
1198
  // 5. Check for CloudPages functions
820
- const cloudFnPattern = /\b(CloudPagesURL|RequestParameter|QueryParameter|Redirect|MicrositeURL)\s*\(/gi;
1199
+ const cloudFunctionPattern = /\b(CloudPagesURL|RequestParameter|QueryParameter|Redirect|MicrositeURL)\s*\(/gi;
821
1200
  let cloudMatch;
822
- while ((cloudMatch = cloudFnPattern.exec(code)) !== null) {
823
- const fnName = cloudMatch[1];
824
- const lineNum = code.slice(0, cloudMatch.index).split('\n').length;
1201
+ while ((cloudMatch = cloudFunctionPattern.exec(code)) !== null) {
1202
+ const functionName = cloudMatch[1];
1203
+ const lineNumber = code.slice(0, cloudMatch.index).split('\n').length;
825
1204
  nonMigratableItems.push({
826
- line: lineNum,
827
- code: codeLines[lineNum - 1]?.trim() ?? fnName,
828
- reason: `${fnName}() is a CloudPages-specific function and cannot run in Marketing Cloud Next`,
1205
+ line: lineNumber,
1206
+ code: codeLines[lineNumber - 1]?.trim() ?? functionName,
1207
+ reason: `${functionName}() is a CloudPages-specific function and cannot run in Marketing Cloud Next`,
829
1208
  });
830
1209
  }
831
1210
  // Assess difficulty
832
1211
  const hasNotMigratable = nonMigratableItems.some((item) => item.reason.includes('CloudPages') || item.reason.includes('NOT SUPPORTED'));
833
- const hasMcnNotes = Array.from(seenUnsupported).some((fn) => getMcnNotesFn(fn) !== null) ||
1212
+ const hasMcnNotes = Array.from(seenUnsupported).some((function_) => getMcnNotesFn(function_) !== null) ||
834
1213
  /FormatDate|StringToDate|Lookup/i.test(code);
835
1214
  const hasSsjs = /<script[^>]+runat/i.test(code);
836
1215
  let difficulty;