prettier-plugin-sfmc 0.5.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 +27 -4
- package/docs/options/handlebars-helper-case.md +102 -0
- package/docs/options/handlebars-spacing.md +69 -0
- package/package.json +7 -5
- package/src/index.js +352 -5
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
|
-
|
|
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: `%%=
|
|
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
|
|
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.
|
|
4
|
-
"description": "Prettier plugin for Salesforce Marketing Cloud
|
|
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",
|
|
@@ -46,10 +47,11 @@
|
|
|
46
47
|
],
|
|
47
48
|
"license": "MIT",
|
|
48
49
|
"dependencies": {
|
|
49
|
-
"ampscript-data": "^2.0.
|
|
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
|
-
"ssjs-data": "^0.
|
|
54
|
+
"ssjs-data": "^0.5.0"
|
|
53
55
|
},
|
|
54
56
|
"devDependencies": {
|
|
55
57
|
"@eslint/js": "^10.0.1",
|
|
@@ -69,7 +71,7 @@
|
|
|
69
71
|
"prettier": ">=3.7.0"
|
|
70
72
|
},
|
|
71
73
|
"engines": {
|
|
72
|
-
"node": ">=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 = {
|