prettier-plugin-sfmc 0.6.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # prettier-plugin-sfmc
2
2
 
3
- Unified Prettier plugin for **Salesforce Marketing Cloud** — formats **AMPscript** (`.ampscript`, `.amp`, `.html`), registers **SSJS** (`.ssjs`) with Prettier’s JavaScript formatter, and formats **SQL** (`.sql`) via embedded [prettier-plugin-sql](https://www.npmjs.com/package/prettier-plugin-sql).
3
+ Unified Prettier plugin for **Salesforce Marketing Cloud** — formats **AMPscript** (`.ampscript`, `.amp`, `.html`), normalizes **Marketing Cloud Next Handlebars** (`.hbs` and `{{…}}` inside `.html`), registers **SSJS** (`.ssjs`) with Prettier’s JavaScript formatter, and formats **SQL** (`.sql`) via embedded [prettier-plugin-sql](https://www.npmjs.com/package/prettier-plugin-sql).
4
4
 
5
5
  ## Installation
6
6
 
@@ -26,11 +26,11 @@ To use explicitly in `.prettierrc`:
26
26
 
27
27
  ## Options
28
28
 
29
- All options use the `ampscript` prefix they control AMPscript formatting behavior.
29
+ AMPscript options use the `ampscript` prefix; Handlebars options use the `handlebars` prefix.
30
30
 
31
31
  | Option | Type | Default | Description |
32
32
  | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------- | ---------------------------------------------- |
33
- | [`ampscriptSpacing`](docs/options/ampscript-spacing.md) | boolean | `true` | Spacing in inline expressions: `%%= V(@x) =%%` |
33
+ | [`ampscriptSpacing`](docs/options/ampscript-spacing.md) | boolean | `true` | Spacing in inline expressions: `%%= v(@x) =%%` |
34
34
  | [`ampscriptEnforceVariableCasing`](docs/options/ampscript-enforce-variable-casing.md) | boolean | `true` | Normalize variable casing to first occurrence |
35
35
  | [`ampscriptRemoveUnnecessaryBrackets`](docs/options/ampscript-remove-unnecessary-brackets.md) | boolean | `true` | Remove needless parentheses |
36
36
  | [`ampscriptQuoteStyle`](docs/options/ampscript-quote-style.md) | `"single"` \| `"double"` | `"single"` | String quote style |
@@ -38,6 +38,8 @@ All options use the `ampscript` prefix — they control AMPscript formatting beh
38
38
  | [`ampscriptFunctionCase`](docs/options/ampscript-function-case.md) | `"upper-camel"` \| `"lower-camel"` \| `"upper"` \| `"lower"` \| `"preserve"` | `"upper-camel"` | Function name casing |
39
39
  | [`ampscriptBlockLineBreaks`](docs/options/ampscript-block-line-breaks.md) | boolean | `false` | Optional line breaks around `%%[ ]%%` when not already at a line boundary |
40
40
  | [`ampscriptVarDeclarationStyle`](docs/options/ampscript-var-declaration-style.md) | `"auto"` \| `"single-line"` \| `"multi-line"` | `"multi-line"` | Var declaration formatting |
41
+ | [`handlebarsSpacing`](docs/options/handlebars-spacing.md) | boolean | `false` | Pad simple/triple `{{…}}`; sigil mustaches stay tight |
42
+ | [`handlebarsHelperCase`](docs/options/handlebars-helper-case.md) | `"upper-camel"` \| `"lower-camel"` \| `"upper"` \| `"lower"` \| `"preserve"` | `"lower-camel"` | Casing of known MCN Handlebars helper names |
41
43
 
42
44
  ### Example `.prettierrc`
43
45
 
@@ -56,7 +58,8 @@ All options use the `ampscript` prefix — they control AMPscript formatting beh
56
58
  | ------------ | ------------------- | ------------------ | ------------------------------------------------------------------------------- |
57
59
  | `.ampscript` | `ampscript` | `ampscript-parse` | Full AMPscript formatting |
58
60
  | `.amp` | `ampscript` | `ampscript-parse` | Full AMPscript formatting |
59
- | `.html` | `sfmc` | `ampscript-parse` | AMPscript formatted; HTML and `<script runat="server">` delegated to Prettier's built-in HTML formatter |
61
+ | `.html` | `sfmc` | `ampscript-parse` | AMPscript formatted; HTML and `<script runat="server">` delegated to Prettier; MCN `{{…}}` Handlebars normalized (see [Handlebars](#handlebars-marketing-cloud-next)) |
62
+ | `.hbs` | `handlebars` | `ampscript-parse` | Marketing Cloud Next Handlebars normalized (see [Handlebars](#handlebars-marketing-cloud-next)); embedded HTML delegated to Prettier |
60
63
  | `.ssjs` | `ssjs` | `babel` (built-in) | Standard JavaScript formatting |
61
64
  | `.sql` | — | `sql` | SQL via composed `prettier-plugin-sql` |
62
65
 
@@ -106,9 +109,29 @@ When formatting `.html` files, the plugin:
106
109
  1. Parses AMPscript regions (`%%[ ]%%`, `%%= =%%`, `<script language="ampscript">`)
107
110
  2. Delegates HTML content to Prettier's built-in HTML formatter
108
111
  3. Prettier's HTML formatter handles `<script runat="server">` SSJS blocks as JavaScript
112
+ 4. Normalizes Marketing Cloud Next `{{…}}` Handlebars expressions (see [Handlebars](#handlebars-marketing-cloud-next))
109
113
 
110
114
  This means a single plugin handles all SFMC formatting in HTML email templates.
111
115
 
116
+ ## Handlebars (Marketing Cloud Next)
117
+
118
+ Marketing Cloud Next templates use [Handlebars](https://developer.salesforce.com/docs/marketing/handlebars-for-marketing-cloud-next) `{{…}}` expressions. These can co-exist with classic AMPscript and HTML in a single `.html` file, or stand alone in a dedicated `.hbs` file. The plugin normalizes each Handlebars expression it finds in both file types.
119
+
120
+ Normalization is driven by two options:
121
+
122
+ - [`handlebarsSpacing`](docs/options/handlebars-spacing.md) (default `false`) — internal whitespace runs always collapse to a single space (`{{ formatCurrency x }}` becomes `{{formatCurrency x}}`). When `true`, simple mustaches and triple-stache also get one space of padding (`{{ formatCurrency x }}`, `{{{ raw }}}`); sigil mustaches (`{{#each}}`, `{{/each}}`, `{{^x}}`, `{{>p}}`, `{{&x}}`) always stay tight.
123
+ - [`handlebarsHelperCase`](docs/options/handlebars-helper-case.md) (default `"lower-camel"`) — recases **known** MCN Handlebars helper names (from the [`handlebars-data`](https://www.npmjs.com/package/handlebars-data) catalog) at the mustache head, block open/close, and subexpression heads. Unknown paths such as `{{firstName}}` or `{{item.Title}}` are always preserved.
124
+
125
+ Everything else is left exactly as written:
126
+
127
+ - **String literals are preserved byte-for-byte**, including their whitespace and quote character: `{{concat "a b"}}` and `{{concat 'x y'}}` are unchanged.
128
+ - **Handlebars comments are preserved verbatim**: `{{! … }}` and `{{!-- … --}}` keep their inner spacing.
129
+ - **Malformed or unbalanced expressions are left untouched** (for example a `{{#if}}` with no matching `{{/if}}` in the fragment) and never cause a formatting error.
130
+ - **`{!$…}` merge-field bindings are never touched** — these are SFMC personalization bindings, not Handlebars syntax, and pass through byte-for-byte.
131
+ - Formatting is **idempotent**: running it a second time produces identical output.
132
+
133
+ AMPscript delimiters (`%%[`, `]%%`, `%%=`, `=%%`) and their contents are unaffected.
134
+
112
135
  ## Ignoring Code
113
136
 
114
137
  Prettier provides two ways to exclude code from formatting: ignore comments for specific code sections, and `.prettierignore` for entire files.
@@ -0,0 +1,102 @@
1
+ # `handlebarsHelperCase`
2
+
3
+ > Control the casing of Marketing Cloud Next Handlebars helper names.
4
+
5
+ | | |
6
+ |---|---|
7
+ | **Type** | `"upper-camel"` \| `"lower-camel"` \| `"upper"` \| `"lower"` \| `"preserve"` |
8
+ | **Default** | `"lower-camel"` |
9
+ | **Applied by** | `prettier --write` · VS Code format-on-save (requires the [Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)) |
10
+
11
+ ## What It Controls
12
+
13
+ Normalises the casing of every **known** Marketing Cloud Next Handlebars helper name. MCN Handlebars helper names are case-insensitive at runtime; this option chooses a consistent form for the formatted output. The canonical names come from the [`handlebars-data`](https://www.npmjs.com/package/handlebars-data) catalog (e.g. `formatCurrency`, `getContentBlock`, `each`).
14
+
15
+ Recasing is applied at every head position of an expression:
16
+
17
+ - the mustache head — `{{formatCurrency …}}`
18
+ - a block open — `{{#each …}}`
19
+ - a block close — `{{/each}}`
20
+ - a subexpression head — `{{iif (isEmpty x) a b}}`
21
+
22
+ **Only names present in the catalog are recased.** Unknown paths — data fields, personalization strings, and dotted property access such as `{{firstName}}` or `{{item.Title}}` — are always preserved exactly as written, in every mode (including their casing).
23
+
24
+ ## Settings
25
+
26
+ | Value | Effect (for the known helper `getContentBlock`) |
27
+ |-------|--------|
28
+ | `"lower-camel"` (default) | camelCase: `getContentBlock` |
29
+ | `"upper-camel"` | Canonical PascalCase: `GetContentBlock` |
30
+ | `"upper"` | ALL CAPS: `GETCONTENTBLOCK` |
31
+ | `"lower"` | all lowercase: `getcontentblock` |
32
+ | `"preserve"` | Helper casing is left exactly as written |
33
+
34
+ ### `"lower-camel"` (default)
35
+
36
+ **Input:**
37
+
38
+ ```handlebars
39
+ {{GetContentBlock key="my-block"}}
40
+ {{#EACH items}}{{firstName}}{{/EACH}}
41
+ ```
42
+
43
+ **Output:**
44
+
45
+ ```handlebars
46
+ {{getContentBlock key="my-block"}}
47
+ {{#each items}}{{firstName}}{{/each}}
48
+ ```
49
+
50
+ Note that `firstName` is not a catalog helper, so its casing is left untouched.
51
+
52
+ ### `"upper-camel"`
53
+
54
+ **Output:**
55
+
56
+ ```handlebars
57
+ {{GetContentBlock key="my-block"}}
58
+ {{#Each items}}{{firstName}}{{/Each}}
59
+ ```
60
+
61
+ ### `"upper"`
62
+
63
+ **Output:**
64
+
65
+ ```handlebars
66
+ {{GETCONTENTBLOCK key="my-block"}}
67
+ {{#EACH items}}{{firstName}}{{/EACH}}
68
+ ```
69
+
70
+ ### `"lower"`
71
+
72
+ **Output:**
73
+
74
+ ```handlebars
75
+ {{getcontentblock key="my-block"}}
76
+ {{#each items}}{{firstName}}{{/each}}
77
+ ```
78
+
79
+ ### `"preserve"`
80
+
81
+ Helper names are emitted exactly as they appeared in the source. Whitespace is still normalised.
82
+
83
+ ## Unknown paths are never touched
84
+
85
+ In every mode, a path that is not a known helper keeps its exact casing:
86
+
87
+ ```handlebars
88
+ {{firstName}}
89
+ {{item.Title}}
90
+ {{Contact.EmailAddress}}
91
+ ```
92
+
93
+ These are personalization fields, not helpers, so recasing them would change their meaning. They pass through unchanged.
94
+
95
+ ## Configuration Example
96
+
97
+ ```json
98
+ {
99
+ "plugins": ["prettier-plugin-sfmc"],
100
+ "handlebarsHelperCase": "upper-camel"
101
+ }
102
+ ```
@@ -0,0 +1,69 @@
1
+ # `handlebarsSpacing`
2
+
3
+ > Pad the inside of Marketing Cloud Next `{{…}}` expressions.
4
+
5
+ | | |
6
+ |---|---|
7
+ | **Type** | `boolean` |
8
+ | **Default** | `false` |
9
+ | **Applied by** | `prettier --write` · VS Code format-on-save (requires the [Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)) |
10
+
11
+ ## What It Controls
12
+
13
+ Controls whether one space of padding is emitted just inside the delimiters of a Marketing Cloud Next Handlebars expression. When `true`, simple mustaches (`{{ … }}`) and triple-stache (`{{{ … }}}`) are padded. Sigil mustaches — block open (`{{#each}}`), block close (`{{/each}}`), inverse (`{{^x}}`), partial (`{{>p}}`), and unescaped (`{{&x}}`) — always stay tight regardless of this option.
14
+
15
+ Internal runs of whitespace are collapsed to a single space either way, and the contents of quoted string literals are preserved byte-for-byte.
16
+
17
+ ## Settings
18
+
19
+ | Value | Effect |
20
+ |-------|--------|
21
+ | `false` (default) | Tight: `{{foo bar}}`, `{{{raw}}}` |
22
+ | `true` | Padded simple/triple, tight sigils: `{{ foo bar }}`, `{{{ raw }}}`, `{{#each items}}` |
23
+
24
+ ### `false` (default)
25
+
26
+ **Input:**
27
+
28
+ ```handlebars
29
+ {{ foo bar }}
30
+ {{{ raw }}}
31
+ {{#each items}}{{ item.title }}{{/each}}
32
+ ```
33
+
34
+ **Output:**
35
+
36
+ ```handlebars
37
+ {{foo bar}}
38
+ {{{raw}}}
39
+ {{#each items}}{{item.title}}{{/each}}
40
+ ```
41
+
42
+ ### `true`
43
+
44
+ **Input:**
45
+
46
+ ```handlebars
47
+ {{foo bar}}
48
+ {{{raw}}}
49
+ {{#each items}}{{item.title}}{{/each}}
50
+ ```
51
+
52
+ **Output:**
53
+
54
+ ```handlebars
55
+ {{ foo bar }}
56
+ {{{ raw }}}
57
+ {{#each items}}{{ item.title }}{{/each}}
58
+ ```
59
+
60
+ Note how the block open/close (`{{#each}}` / `{{/each}}`) remain tight while the simple mustache (`{{ item.title }}`) is padded.
61
+
62
+ ## Configuration Example
63
+
64
+ ```json
65
+ {
66
+ "plugins": ["prettier-plugin-sfmc"],
67
+ "handlebarsSpacing": true
68
+ }
69
+ ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "prettier-plugin-sfmc",
3
- "version": "0.6.0",
4
- "description": "Prettier plugin for Salesforce Marketing Cloud — AMPscript, SSJS, and SQL formatting",
3
+ "version": "1.0.0",
4
+ "description": "Prettier plugin for Salesforce Marketing Cloud - AMPscript, Marketing Cloud Next Handlebars, SSJS, and SQL formatting",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
@@ -35,6 +35,7 @@
35
35
  "prettier",
36
36
  "ampscript",
37
37
  "ssjs",
38
+ "handlebars",
38
39
  "sql",
39
40
  "tsql",
40
41
  "transact-sql",
@@ -48,6 +49,7 @@
48
49
  "dependencies": {
49
50
  "ampscript-data": "^2.0.3",
50
51
  "ampscript-parser": "^0.1.3",
52
+ "handlebars-data": "^0.3.0",
51
53
  "prettier-plugin-sql": "^0.20.0",
52
54
  "ssjs-data": "^0.5.0"
53
55
  },
@@ -69,7 +71,7 @@
69
71
  "prettier": ">=3.7.0"
70
72
  },
71
73
  "engines": {
72
- "node": ">=18.0.0"
74
+ "node": ">=22.0.0"
73
75
  },
74
76
  "lint-staged": {
75
77
  "*.{js,mjs,cjs}": [
package/src/index.js CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { parse } from 'ampscript-parser';
14
+ import { getHelper } from 'handlebars-data';
14
15
  import sqlPlugin from 'prettier-plugin-sql';
15
16
  import { printers as prettierEstreePrinters } from 'prettier/plugins/estree';
16
17
  import { printAmpscriptNode, collectVariableMap } from './printer.js';
@@ -43,6 +44,292 @@ function mergeSqlOptionsWithSfmcDefaults(sqlOptions) {
43
44
  return merged;
44
45
  }
45
46
 
47
+ // ── Handlebars (MCN) inner normalization ─────────────────────────────────────
48
+
49
+ /**
50
+ * Recase a Handlebars helper name using the `handlebars-data` catalog.
51
+ *
52
+ * Only names that exist in the catalog (via `getHelper`) are recased; unknown
53
+ * paths (`firstName`, `item.title`) are returned unchanged in every mode. MCN
54
+ * Handlebars is case-insensitive at runtime, so recasing a known helper is safe.
55
+ * Catalog names are lower-camel canonical (e.g. `formatDate`), so `upper-camel`
56
+ * simply capitalizes the first character of the canonical form.
57
+ *
58
+ * @param {string} name Head token as written in the source.
59
+ * @param {'upper-camel'|'lower-camel'|'upper'|'lower'|'preserve'} mode Casing mode.
60
+ * @returns {string} Recased name, or the original `name` when not a known helper.
61
+ */
62
+ function applyHelperCase(name, mode) {
63
+ if (mode === 'preserve') {
64
+ return name;
65
+ }
66
+ const helper = getHelper(name);
67
+ if (!helper) {
68
+ return name;
69
+ }
70
+ const canonical = helper.name;
71
+ if (mode === 'upper') {
72
+ return canonical.toUpperCase();
73
+ }
74
+ if (mode === 'lower') {
75
+ return canonical.toLowerCase();
76
+ }
77
+ if (mode === 'lower-camel') {
78
+ return canonical[0].toLowerCase() + canonical.slice(1);
79
+ }
80
+ // upper-camel
81
+ return canonical[0].toUpperCase() + canonical.slice(1);
82
+ }
83
+
84
+ /** Leading sigils that may precede the head token of a mustache. */
85
+ const MUSTACHE_SIGILS = new Set(['#', '/', '^', '>', '&']);
86
+
87
+ /**
88
+ * A head token is eligible for recasing only when it is a bare helper name:
89
+ * a single identifier segment with no path separators (`.` / `/`), no `@`
90
+ * data-variable prefix, and no `=` (hash key). This keeps `item.title`,
91
+ * `@index`, and `key=value` untouched while still recasing `formatDate`.
92
+ *
93
+ * @param {string} token Candidate head token.
94
+ * @returns {boolean} True when the token is a bare, recasable name.
95
+ */
96
+ function isBareName(token) {
97
+ return /^[A-Za-z_]\w*$/.test(token);
98
+ }
99
+
100
+ /**
101
+ * Normalize the inner text of a single Handlebars expression.
102
+ *
103
+ * Whitespace outside quoted string literals is collapsed to a single space and
104
+ * trimmed. When `helperCase` is not `preserve`, the head token (the first token
105
+ * after an optional leading sigil) and the head token of every subexpression
106
+ * (first token after each `(`) are recased via {@link applyHelperCase} — but
107
+ * only when they are known catalog helpers. String literals, paths, hash keys,
108
+ * block params (`as |x y|`), and unknown names are preserved byte-for-byte.
109
+ *
110
+ * Padding of the outer expression (per `handlebarsSpacing`) is applied by the
111
+ * caller, not here.
112
+ *
113
+ * @param {string} inner Raw inner text of a mustache expression.
114
+ * @param {{ helperCase?: 'upper-camel'|'lower-camel'|'upper'|'lower'|'preserve' }} [opts] Options.
115
+ * @returns {string} Normalized inner text (collapsed whitespace, recased heads).
116
+ */
117
+ function normalizeMustacheInner(inner, opts = {}) {
118
+ const helperCase = opts.helperCase || 'lower-camel';
119
+
120
+ // First pass: collapse whitespace outside string literals, preserving quotes.
121
+ let collapsed = '';
122
+ /** @type {string|null} */
123
+ let quote = null;
124
+ for (const ch of inner) {
125
+ if (quote) {
126
+ collapsed += ch;
127
+ if (ch === quote) {
128
+ quote = null;
129
+ }
130
+ continue;
131
+ }
132
+ if (ch === '"' || ch === "'") {
133
+ quote = ch;
134
+ collapsed += ch;
135
+ continue;
136
+ }
137
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f' || ch === '\v') {
138
+ if (collapsed.length > 0 && collapsed.at(-1) !== ' ') {
139
+ collapsed += ' ';
140
+ }
141
+ continue;
142
+ }
143
+ collapsed += ch;
144
+ }
145
+ collapsed = collapsed.trim();
146
+
147
+ if (helperCase === 'preserve' || collapsed.length === 0) {
148
+ return collapsed;
149
+ }
150
+
151
+ // Second pass: recase the head token and each subexpression head token.
152
+ // A head is expected at the very start (after an optional sigil) and
153
+ // immediately after each `(`. We walk the collapsed string, tracking string
154
+ // literals so `(` / heads inside quotes are ignored.
155
+ let result = '';
156
+ quote = null;
157
+ let expectHead = true;
158
+ let leadingSigilConsumed = false;
159
+ let i = 0;
160
+ const n = collapsed.length;
161
+ while (i < n) {
162
+ const ch = collapsed[i];
163
+ if (quote) {
164
+ result += ch;
165
+ if (ch === quote) {
166
+ quote = null;
167
+ }
168
+ i++;
169
+ continue;
170
+ }
171
+ if (ch === '"' || ch === "'") {
172
+ quote = ch;
173
+ result += ch;
174
+ expectHead = false;
175
+ i++;
176
+ continue;
177
+ }
178
+ // At the outermost head position, skip a single leading sigil.
179
+ if (expectHead && !leadingSigilConsumed && result.length === 0 && MUSTACHE_SIGILS.has(ch)) {
180
+ result += ch;
181
+ leadingSigilConsumed = true;
182
+ i++;
183
+ continue;
184
+ }
185
+ if (ch === '(') {
186
+ result += ch;
187
+ expectHead = true;
188
+ i++;
189
+ continue;
190
+ }
191
+ if (ch === ' ') {
192
+ result += ch;
193
+ i++;
194
+ continue;
195
+ }
196
+ if (expectHead) {
197
+ // Read the head token up to the next whitespace, `(`, `)`, or quote.
198
+ let j = i;
199
+ while (j < n && !/[\s()'"]/.test(collapsed[j])) {
200
+ j++;
201
+ }
202
+ const token = collapsed.slice(i, j);
203
+ result += isBareName(token) ? applyHelperCase(token, helperCase) : token;
204
+ expectHead = false;
205
+ i = j;
206
+ continue;
207
+ }
208
+ result += ch;
209
+ i++;
210
+ }
211
+ return result;
212
+ }
213
+
214
+ /**
215
+ * Token placeholder prefix used to shield MCN Handlebars `{{…}}` regions from
216
+ * Prettier's HTML parser (which would otherwise collapse whitespace inside string
217
+ * literals and reflow mustaches). Mirrors the `AMPSCRIPTPH…END` scheme.
218
+ */
219
+ const HANDLEBARS_PH = 'HANDLEBARSPH';
220
+
221
+ /**
222
+ * Extract every well-formed MCN Handlebars `{{…}}` region from a string, replacing
223
+ * each with a `HANDLEBARSPH<n>END` placeholder and recording the normalized
224
+ * replacement text. The HTML parser then never sees mustache internals, so it
225
+ * cannot corrupt string literals or reflow them; normalization happens here.
226
+ *
227
+ * Guarantees:
228
+ * - `{!$…}` merge-field bindings are never matched (they use single braces).
229
+ * - AMPscript delimiters and `AMPSCRIPTPH…END` placeholders are never matched
230
+ * (they contain no `{{`).
231
+ * - Handlebars comments (`{{! … }}`, `{{!-- … --}}`) are recorded verbatim.
232
+ * - Triple-stache (`{{{ … }}}`) and the `&` sigil are preserved (not rewritten).
233
+ * - String literals inside an expression keep their exact bytes and quote style.
234
+ * - Unbalanced/never-closed mustaches are left verbatim in the returned string
235
+ * (no throw, no change), so the HTML parser passes them through untouched.
236
+ *
237
+ * @param {string} text Joined HTML/text (AMPscript already placeholdered).
238
+ * @param {{ spacing?: boolean, helperCase?: 'upper-camel'|'lower-camel'|'upper'|'lower'|'preserve' }} [opts]
239
+ * Formatting options: `spacing` pads simple/triple mustaches (sigil mustaches stay
240
+ * tight); `helperCase` recases known catalog helpers at head positions.
241
+ * @returns {{ html: string, tokens: string[] }} Text with Handlebars placeholders
242
+ * and the ordered list of normalized mustache replacements.
243
+ */
244
+ function extractHandlebarsRegions(text, opts = {}) {
245
+ const spacing = opts.spacing === true;
246
+ const helperCase = opts.helperCase || 'lower-camel';
247
+ /** @type {string[]} */
248
+ const tokens = [];
249
+ if (typeof text !== 'string' || !text.includes('{{')) {
250
+ return { html: text, tokens };
251
+ }
252
+
253
+ let out = '';
254
+ let i = 0;
255
+ const n = text.length;
256
+ while (i < n) {
257
+ const open = text.indexOf('{{', i);
258
+ if (open === -1) {
259
+ out += text.slice(i);
260
+ break;
261
+ }
262
+ out += text.slice(i, open);
263
+
264
+ const isTriple = text[open + 2] === '{';
265
+ const openLen = isTriple ? 3 : 2;
266
+ const afterOpen = text.slice(open + openLen);
267
+
268
+ // Comments: record verbatim, never normalize their contents.
269
+ if (!isTriple && afterOpen.startsWith('!')) {
270
+ const isBlock = afterOpen.startsWith('!--');
271
+ const closeStr = isBlock ? '--}}' : '}}';
272
+ const close = text.indexOf(closeStr, open + openLen);
273
+ if (close === -1) {
274
+ out += text.slice(open);
275
+ break;
276
+ }
277
+ const end = close + closeStr.length;
278
+ tokens.push(text.slice(open, end));
279
+ out += `${HANDLEBARS_PH}${tokens.length - 1}END`;
280
+ i = end;
281
+ continue;
282
+ }
283
+
284
+ // Scan for the closing delimiter, ignoring `}}` inside string literals.
285
+ const closeDelim = isTriple ? '}}}' : '}}';
286
+ /** @type {string|null} */
287
+ let quote = null;
288
+ let j = open + openLen;
289
+ let foundClose = -1;
290
+ while (j < n) {
291
+ const ch = text[j];
292
+ if (quote) {
293
+ if (ch === quote) {
294
+ quote = null;
295
+ }
296
+ j++;
297
+ continue;
298
+ }
299
+ if (ch === '"' || ch === "'") {
300
+ quote = ch;
301
+ j++;
302
+ continue;
303
+ }
304
+ if (text.startsWith(closeDelim, j)) {
305
+ foundClose = j;
306
+ break;
307
+ }
308
+ j++;
309
+ }
310
+
311
+ if (foundClose === -1) {
312
+ // Unbalanced — leave the remainder exactly as-is for the HTML parser.
313
+ out += text.slice(open);
314
+ break;
315
+ }
316
+
317
+ const inner = text.slice(open + openLen, foundClose);
318
+ const opener = text.slice(open, open + openLen);
319
+ const normalized = normalizeMustacheInner(inner, { helperCase });
320
+ // Pad simple mustaches (`{{ … }}`) and triple-stache (`{{{ … }}}`) when
321
+ // `spacing` is enabled; sigil mustaches (`{{#…}}`, `{{/…}}`, `{{^…}}`,
322
+ // `{{>…}}`, `{{&…}}`) always stay tight.
323
+ const hasSigil = MUSTACHE_SIGILS.has(normalized[0]);
324
+ const pad = spacing && !hasSigil && normalized.length > 0 ? ' ' : '';
325
+ tokens.push(`${opener}${pad}${normalized}${pad}${closeDelim}`);
326
+ out += `${HANDLEBARS_PH}${tokens.length - 1}END`;
327
+ i = foundClose + closeDelim.length;
328
+ }
329
+
330
+ return { html: out, tokens };
331
+ }
332
+
46
333
  // ── Languages ────────────────────────────────────────────────────────────────
47
334
 
48
335
  export const languages = [
@@ -58,6 +345,12 @@ export const languages = [
58
345
  extensions: ['.html'],
59
346
  vscodeLanguageIds: ['sfmc'],
60
347
  },
348
+ {
349
+ name: 'Handlebars (MCN)',
350
+ parsers: ['ampscript-parse'],
351
+ extensions: ['.hbs'],
352
+ vscodeLanguageIds: ['handlebars'],
353
+ },
61
354
  {
62
355
  name: 'SSJS',
63
356
  parsers: ['babel'],
@@ -125,7 +418,12 @@ export const printers = {
125
418
  const hasHtml = node.children.some(
126
419
  (c) => c.type === 'Content' && /<[a-zA-Z!/]/.test(c.value),
127
420
  );
128
- if (!hasHtml) {
421
+ // Pure `.hbs` documents may contain no HTML tags at all — still run
422
+ // the embed path so `{{…}}` mustaches are normalized.
423
+ const hasHandlebars = node.children.some(
424
+ (c) => c.type === 'Content' && c.value.includes('{{'),
425
+ );
426
+ if (!hasHtml && !hasHandlebars) {
129
427
  return;
130
428
  }
131
429
 
@@ -146,7 +444,17 @@ export const printers = {
146
444
  }
147
445
  }
148
446
 
149
- const html = htmlParts.join('');
447
+ // Shield MCN Handlebars {{…}} from the HTML parser (which would
448
+ // otherwise collapse whitespace inside string literals). The
449
+ // mustache text is normalized (whitespace, helper casing, and
450
+ // optional padding) during extraction.
451
+ const { html, tokens: handlebarsTokens } = extractHandlebarsRegions(
452
+ htmlParts.join(''),
453
+ {
454
+ spacing: options.handlebarsSpacing === true,
455
+ helperCase: options.handlebarsHelperCase || 'lower-camel',
456
+ },
457
+ );
150
458
 
151
459
  let htmlDocument;
152
460
  try {
@@ -155,12 +463,12 @@ export const printers = {
155
463
  return;
156
464
  }
157
465
 
158
- const re = new RegExp(String.raw`${PH}(\d+)END`, 'g');
466
+ const re = new RegExp(String.raw`(?:${PH}(\d+)END|${HANDLEBARS_PH}(\d+)END)`, 'g');
159
467
  return prettier.doc.utils.mapDoc(htmlDocument, (d) => {
160
468
  if (typeof d !== 'string') {
161
469
  return d;
162
470
  }
163
- if (!d.includes(PH)) {
471
+ if (!d.includes(PH) && !d.includes(HANDLEBARS_PH)) {
164
472
  return d;
165
473
  }
166
474
 
@@ -172,7 +480,11 @@ export const printers = {
172
480
  if (m.index > last) {
173
481
  parts.push(d.slice(last, m.index));
174
482
  }
175
- parts.push(print(['children', Number.parseInt(m[1], 10)]));
483
+ if (m[1] === undefined) {
484
+ parts.push(handlebarsTokens[Number.parseInt(m[2], 10)]);
485
+ } else {
486
+ parts.push(print(['children', Number.parseInt(m[1], 10)]));
487
+ }
176
488
  last = m.index + m[0].length;
177
489
  }
178
490
  if (last < d.length) {
@@ -368,6 +680,41 @@ const sfmcOptions = {
368
680
  ],
369
681
  description: 'Control how var declarations with multiple variables are formatted.',
370
682
  },
683
+ handlebarsSpacing: {
684
+ type: 'boolean',
685
+ category: 'Handlebars',
686
+ default: false,
687
+ description:
688
+ 'Pad the inside of Marketing Cloud Next `{{…}}` expressions. ' +
689
+ 'When true, simple mustaches and triple-stache get one space of padding ' +
690
+ '(`{{ foo bar }}`, `{{{ raw }}}`); sigil mustaches (`{{#each}}`, `{{/each}}`, ' +
691
+ '`{{^x}}`, `{{>p}}`, `{{&x}}`) always stay tight. When false (default), all ' +
692
+ 'mustaches are tight. Internal runs of whitespace collapse to a single space ' +
693
+ 'either way, and string literals are preserved.',
694
+ },
695
+ handlebarsHelperCase: {
696
+ type: 'choice',
697
+ category: 'Handlebars',
698
+ default: 'lower-camel',
699
+ choices: [
700
+ {
701
+ value: 'upper-camel',
702
+ description: 'Canonical PascalCase: FormatDate, TruncateWords',
703
+ },
704
+ {
705
+ value: 'lower-camel',
706
+ description: 'camelCase: formatDate, truncateWords',
707
+ },
708
+ { value: 'upper', description: 'UPPERCASE: FORMATDATE, TRUNCATEWORDS' },
709
+ { value: 'lower', description: 'lowercase: formatdate, truncatewords' },
710
+ { value: 'preserve', description: 'Keep original helper casing' },
711
+ ],
712
+ description:
713
+ 'Control the casing of Marketing Cloud Next Handlebars helper names. ' +
714
+ 'Only names found in the handlebars-data catalog are recased, at the ' +
715
+ 'mustache head, block open/close, and subexpression heads. Unknown paths ' +
716
+ '(e.g. `firstName`, `item.title`) are always preserved.',
717
+ },
371
718
  };
372
719
 
373
720
  export const options = {