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.
- package/README.md +58 -8
- package/dist/cli/reviewDiff.d.ts +1 -17
- package/dist/cli/reviewDiff.d.ts.map +1 -1
- package/dist/cli/reviewDiff.js +25 -53
- package/dist/cli/reviewDiff.js.map +1 -1
- package/dist/cli/reviewSeverity.d.ts +23 -0
- package/dist/cli/reviewSeverity.d.ts.map +1 -0
- package/dist/cli/reviewSeverity.js +36 -0
- package/dist/cli/reviewSeverity.js.map +1 -0
- package/dist/conversion-rules.d.ts +72 -0
- package/dist/conversion-rules.d.ts.map +1 -1
- package/dist/conversion-rules.js +544 -165
- package/dist/conversion-rules.js.map +1 -1
- package/dist/index.js +729 -129
- package/dist/index.js.map +1 -1
- package/dist/mce-help-search.js +5 -5
- package/dist/mce-help-search.js.map +1 -1
- package/dist/mcn-help-search.js +5 -5
- package/dist/mcn-help-search.js.map +1 -1
- package/package.json +23 -10
package/dist/conversion-rules.js
CHANGED
|
@@ -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 [
|
|
199
|
-
const
|
|
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:
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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, (_,
|
|
279
|
+
line = line.replaceAll(/Platform\.Variable\.SetValue\s*\(\s*["']([^"']+)["']\s*,\s*([^)]+)\)\s*;?/gi, (_, variableName, value) => {
|
|
234
280
|
changes.push({
|
|
235
|
-
line:
|
|
236
|
-
description: `Platform.Variable.SetValue → SET @${
|
|
281
|
+
line: lineNumber,
|
|
282
|
+
description: `Platform.Variable.SetValue → SET @${variableName}`,
|
|
237
283
|
});
|
|
238
|
-
return `SET @${
|
|
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:
|
|
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, (_,
|
|
247
|
-
const key =
|
|
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:
|
|
251
|
-
code: `Platform.Function.${
|
|
252
|
-
reason: `Platform.Function.${
|
|
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.${
|
|
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:
|
|
260
|
-
code: `Platform.Function.${
|
|
261
|
-
reason: `Platform.Function.${
|
|
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.${
|
|
309
|
+
return `/* MANUAL_REWRITE_REQUIRED: unknown Platform.Function.${functionName} */ ${functionName}(`;
|
|
264
310
|
}
|
|
265
311
|
changes.push({
|
|
266
|
-
line:
|
|
267
|
-
description: `Platform.Function.${
|
|
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*$/, (_,
|
|
318
|
+
line = line.replace(/\bvar\s+([A-Za-z_]\w*)\s*=\s*(.+?)\s*;?\s*$/, (_, variableName, value) => {
|
|
273
319
|
changes.push({
|
|
274
|
-
line:
|
|
275
|
-
description: `var ${
|
|
320
|
+
line: lineNumber,
|
|
321
|
+
description: `var ${variableName} = ... → SET @${variableName}`,
|
|
276
322
|
});
|
|
277
|
-
return `SET @${
|
|
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*$/, (_,
|
|
281
|
-
changes.push({
|
|
282
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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 [,
|
|
353
|
+
const [, iterVariable, start, end] = forMatch;
|
|
305
354
|
changes.push({
|
|
306
|
-
line:
|
|
307
|
-
description: `for (var ${
|
|
355
|
+
line: lineNumber,
|
|
356
|
+
description: `for (var ${iterVariable}...) → FOR @${iterVariable} = ${start} TO ${end} DO`,
|
|
308
357
|
});
|
|
309
|
-
line = `FOR @${
|
|
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:
|
|
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 [
|
|
370
|
-
const
|
|
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
|
|
428
|
+
const expression = stripAmpVars(inlineOutputMatch[1].trim());
|
|
380
429
|
changes.push({
|
|
381
|
-
line:
|
|
430
|
+
line: lineNumber,
|
|
382
431
|
description: '%%=Output(...)=%% → Platform.Response.Write(...)',
|
|
383
432
|
});
|
|
384
|
-
outputLines.push(`Platform.Response.Write(${
|
|
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
|
|
389
|
-
if (
|
|
390
|
-
const
|
|
391
|
-
const
|
|
392
|
-
const key =
|
|
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
|
|
443
|
+
const nativeHint = AMP_NATIVE_JS_HINTS[key];
|
|
444
|
+
const argumentsConverted = stripAmpVars(arguments_);
|
|
395
445
|
if (ssName) {
|
|
396
446
|
changes.push({
|
|
397
|
-
line:
|
|
398
|
-
description: `%%=${
|
|
447
|
+
line: lineNumber,
|
|
448
|
+
description: `%%=${functionName}(...)=%% → Platform.Response.Write(Platform.Function.${ssName}(...))`,
|
|
399
449
|
});
|
|
400
|
-
outputLines.push(`Platform.Response.Write(Platform.Function.${ssName}(${
|
|
450
|
+
outputLines.push(`Platform.Response.Write(Platform.Function.${ssName}(${argumentsConverted}));`);
|
|
401
451
|
}
|
|
402
|
-
else if (
|
|
452
|
+
else if (nativeHint) {
|
|
403
453
|
changes.push({
|
|
404
|
-
line:
|
|
405
|
-
description: `%%=${
|
|
454
|
+
line: lineNumber,
|
|
455
|
+
description: `%%=${functionName}(...)=%% → native JS hint`,
|
|
406
456
|
});
|
|
407
|
-
outputLines.push(`Platform.Response.Write(${
|
|
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: %%=${
|
|
461
|
+
outputLines.push(`/* MANUAL_REWRITE_REQUIRED: %%=${functionName}(${arguments_})=%% */`);
|
|
412
462
|
flaggedSections.push({
|
|
413
|
-
line:
|
|
463
|
+
line: lineNumber,
|
|
414
464
|
code: trimmed,
|
|
415
|
-
reason: `AMPscript function '${
|
|
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:
|
|
423
|
-
description: `%%=${
|
|
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
|
|
434
|
-
for (const
|
|
435
|
-
const converted = convertAmpStatement(
|
|
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,
|
|
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,
|
|
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 [,
|
|
509
|
-
const
|
|
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
|
|
513
|
-
if (
|
|
514
|
-
const key =
|
|
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 @${
|
|
571
|
+
description: `SET @${variableName} = ${singleFunctionMatch[1]}(...) → _ampScript polyfill`,
|
|
522
572
|
});
|
|
523
|
-
return `var ${
|
|
573
|
+
return `var ${variableName} = _ampScript('${expressionTrimmed.replaceAll("'", String.raw `\'`)}');`;
|
|
524
574
|
}
|
|
525
575
|
}
|
|
526
|
-
const
|
|
576
|
+
const ssExpression = convertAmpExpression(expressionTrimmed);
|
|
527
577
|
changes.push({
|
|
528
578
|
line: lineNum,
|
|
529
|
-
description: `SET @${
|
|
579
|
+
description: `SET @${variableName} = ... → var ${variableName} = ...`,
|
|
530
580
|
});
|
|
531
|
-
return `var ${
|
|
581
|
+
return `var ${variableName} = ${ssExpression};`;
|
|
532
582
|
}
|
|
533
583
|
// VAR @x, @y → var x, y;
|
|
534
|
-
const
|
|
535
|
-
if (
|
|
536
|
-
const
|
|
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 @${
|
|
591
|
+
description: `VAR @${variables.join(', @')} → var ${variables.join(', ')}`,
|
|
540
592
|
});
|
|
541
|
-
return `var ${
|
|
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 [,
|
|
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 @${
|
|
627
|
+
description: `FOR @${iterVariable} = ${start} TO ${end} DO → for loop`,
|
|
576
628
|
});
|
|
577
|
-
return `for (var ${
|
|
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
|
|
639
|
+
const expression = convertAmpExpression(outputMatch[1].trim());
|
|
588
640
|
changes.push({ line: lineNum, description: 'Output/OutputLine → Platform.Response.Write' });
|
|
589
|
-
return `Platform.Response.Write(${
|
|
641
|
+
return `Platform.Response.Write(${expression});`;
|
|
590
642
|
}
|
|
591
643
|
// Known AMPscript function call → Platform.Function.X(args)
|
|
592
|
-
const
|
|
593
|
-
if (
|
|
594
|
-
const [,
|
|
595
|
-
const key =
|
|
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
|
|
650
|
+
const argumentsConverted = arguments_ ? convertAmpExpression(arguments_.trim()) : '';
|
|
599
651
|
changes.push({
|
|
600
652
|
line: lineNum,
|
|
601
|
-
description: `${
|
|
653
|
+
description: `${functionName}(…) → Platform.Function.${ssName}(…)`,
|
|
602
654
|
});
|
|
603
|
-
return `Platform.Function.${ssName}(${
|
|
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: `${
|
|
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: `${
|
|
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
|
|
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,
|
|
664
|
-
const key =
|
|
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} /* ${
|
|
722
|
+
return `${hint} /* ${functionName}( */`;
|
|
671
723
|
// Unknown function in expression context — annotate
|
|
672
|
-
return `/* MANUAL_REWRITE_REQUIRED: no SSJS equivalent for ${
|
|
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
|
|
721
|
-
if (
|
|
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 [
|
|
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:
|
|
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,
|
|
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
|
-
|
|
742
|
-
|
|
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(${
|
|
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
|
-
|
|
1132
|
+
isChanged = true;
|
|
756
1133
|
}
|
|
757
1134
|
}
|
|
758
|
-
if (
|
|
759
|
-
return `FormatDate(${
|
|
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 [
|
|
767
|
-
if (codeLine !== rewrittenLines[
|
|
1143
|
+
for (const [index, codeLine] of codeLines.entries()) {
|
|
1144
|
+
if (codeLine !== rewrittenLines[index] && /FormatDate/i.test(codeLine)) {
|
|
768
1145
|
changes.push({
|
|
769
|
-
line:
|
|
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,
|
|
777
|
-
const
|
|
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 (
|
|
782
|
-
return `${match} %%-- MCN NOTE: Lookup() requires search arguments in column/value pairs (even count after DE and return column). Current arg count (${
|
|
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
|
|
789
|
-
let
|
|
1165
|
+
const functionCallPattern = /\b([A-Z][A-Za-z]+)\s*\(/g;
|
|
1166
|
+
let functionMatch;
|
|
790
1167
|
const seenUnsupported = new Set();
|
|
791
|
-
while ((
|
|
792
|
-
const
|
|
793
|
-
if (!isMcnSupportedFn(
|
|
794
|
-
seenUnsupported.add(
|
|
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
|
|
798
|
-
const mcnNotes = getMcnNotesFn(
|
|
799
|
-
const annotationPattern = new RegExp(String.raw `\b${
|
|
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: ${
|
|
1178
|
+
return `%%-- NOT SUPPORTED IN MCN: ${functionName}${mcnNotes ? ` — ${mcnNotes}` : ''} --%%\n${m}`;
|
|
802
1179
|
});
|
|
803
1180
|
// Find lines for tracking
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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
|
|
1199
|
+
const cloudFunctionPattern = /\b(CloudPagesURL|RequestParameter|QueryParameter|Redirect|MicrositeURL)\s*\(/gi;
|
|
821
1200
|
let cloudMatch;
|
|
822
|
-
while ((cloudMatch =
|
|
823
|
-
const
|
|
824
|
-
const
|
|
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:
|
|
827
|
-
code: codeLines[
|
|
828
|
-
reason: `${
|
|
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((
|
|
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;
|