diff --git a/.eslintrc.json b/.eslintrc.json
index 9eac8d7..01f066d 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -22,7 +22,7 @@
"no-this-before-super": "warn",
"no-undef": "warn",
"no-unreachable": "warn",
- "no-unused-vars": "warn",
+ "no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"constructor-super": "warn",
"valid-typeof": "warn",
"indent": [
diff --git a/.github/workflows/release-prettier.yml b/.github/workflows/release-prettier.yml
new file mode 100644
index 0000000..31e7cbf
--- /dev/null
+++ b/.github/workflows/release-prettier.yml
@@ -0,0 +1,42 @@
+name: Release Prettier Plugin
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'prettier-plugin-blits/**'
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v2
+ with:
+ node-version: '20'
+
+ - name: Install Dependencies
+ run: npm install
+
+ - name: Install monorepo dependencies
+ run: npm install --workspaces
+
+ - name: Run Tests
+ run: npm run test --workspace=prettier-plugin-blits
+
+ - name: Read package.json
+ run: |
+ plugin_name=$(node -p "require('./prettier-plugin-blits/package.json').name")
+ plugin_version=$(node -p "require('./prettier-plugin-blits/package.json').version")
+ echo "PLUGIN_NAME=$plugin_name" >> $GITHUB_ENV
+ echo "PLUGIN_VERSION=$plugin_version" >> $GITHUB_ENV
+
+ - name: Create GitHub Pre Release
+ run: |
+ gh auth login --with-token <<< "${{ secrets.GITHUB_TOKEN }}"
+ gh release create prettier-v${{ env.PLUGIN_VERSION }} --prerelease -t "prettier-plugin-blits v${{ env.PLUGIN_VERSION }}" -n "Version ${{ env.PLUGIN_VERSION }} of ${{ env.PLUGIN_NAME }}"
diff --git a/package-lock.json b/package-lock.json
index 027215a..6218195 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,7 +24,23 @@
}
},
"eslint-plugin-blits": {
- "extraneous": true
+ "name": "@lightningjs/eslint-plugin-blits",
+ "version": "0.1.0",
+ "extraneous": true,
+ "license": "Apache-2.0",
+ "devDependencies": {
+ "eslint": "^9.26.0",
+ "eslint-config-prettier": "^10.1.3",
+ "eslint-plugin-prettier": "^5.4.0",
+ "globals": "^16.1.0",
+ "prettier": "^3.5.3"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^8.0.0 || ^9.0.0"
+ }
},
"node_modules/@azu/format-text": {
"version": "1.0.2",
@@ -595,6 +611,10 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@lightningjs/prettier-plugin-blits": {
+ "resolved": "prettier-plugin-blits",
+ "link": true
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"dev": true,
@@ -1069,9 +1089,7 @@
}
},
"node_modules/ajv": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
- "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
+ "version": "6.12.6",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1085,16 +1103,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
- "node_modules/ansi-colors": {
- "version": "4.1.3",
- "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
- "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/ansi-regex": {
"version": "5.0.1",
"dev": true,
@@ -2194,9 +2202,7 @@
}
},
"node_modules/flatted": {
- "version": "3.4.1",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
- "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
+ "version": "3.3.3",
"dev": true,
"license": "ISC"
},
@@ -3097,9 +3103,7 @@
}
},
"node_modules/minimatch": {
- "version": "3.1.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
- "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "version": "3.1.2",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -3967,13 +3971,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/signal-exit": {
- "version": "3.0.7",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
- "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
- "dev": true,
- "license": "ISC"
- },
"node_modules/simple-concat": {
"version": "1.0.1",
"dev": true,
@@ -4560,6 +4557,36 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "prettier-plugin-blits": {
+ "name": "@lightningjs/prettier-plugin-blits",
+ "version": "0.1.0",
+ "license": "Apache-2.0",
+ "devDependencies": {
+ "prettier": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "prettier": ">=3.0.0"
+ }
+ },
+ "prettier-plugin-blits/node_modules/prettier": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
+ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"vscode-extension": {
"name": "lightning-blits",
"version": "1.6.0",
@@ -4822,6 +4849,14 @@
"node": ">=16"
}
},
+ "vscode-extension/node_modules/ansi-colors": {
+ "version": "4.1.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"vscode-extension/node_modules/binary-extensions": {
"version": "2.3.0",
"dev": true,
@@ -5180,6 +5215,70 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "vscode-extension/node_modules/log-symbols/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "vscode-extension/node_modules/log-symbols/node_modules/chalk": {
+ "version": "4.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "vscode-extension/node_modules/log-symbols/node_modules/color-convert": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "vscode-extension/node_modules/log-symbols/node_modules/color-name": {
+ "version": "1.1.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "vscode-extension/node_modules/log-symbols/node_modules/has-flag": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "vscode-extension/node_modules/log-symbols/node_modules/supports-color": {
+ "version": "7.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"vscode-extension/node_modules/mocha": {
"version": "10.7.3",
"dev": true,
@@ -5214,6 +5313,17 @@
"node": ">= 14.0.0"
}
},
+ "vscode-extension/node_modules/mocha/node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"vscode-extension/node_modules/mocha/node_modules/glob": {
"version": "8.1.0",
"dev": true,
@@ -5232,6 +5342,14 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "vscode-extension/node_modules/mocha/node_modules/has-flag": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"vscode-extension/node_modules/mocha/node_modules/minimatch": {
"version": "5.1.6",
"dev": true,
@@ -5445,6 +5563,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "vscode-extension/node_modules/restore-cursor/node_modules/signal-exit": {
+ "version": "3.0.7",
+ "dev": true,
+ "license": "ISC"
+ },
"vscode-extension/node_modules/serialize-javascript": {
"version": "6.0.2",
"dev": true,
@@ -5606,6 +5729,36 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "vscode-extension/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "vscode-extension/node_modules/wrap-ansi-cjs/node_modules/color-convert": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "vscode-extension/node_modules/wrap-ansi-cjs/node_modules/color-name": {
+ "version": "1.1.4",
+ "dev": true,
+ "license": "MIT"
+ },
"vscode-extension/node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"dev": true,
diff --git a/prettier-plugin-blits/CHANGELOG.md b/prettier-plugin-blits/CHANGELOG.md
new file mode 100644
index 0000000..554370e
--- /dev/null
+++ b/prettier-plugin-blits/CHANGELOG.md
@@ -0,0 +1,16 @@
+# Change Log
+
+## v1.0.0
+
+- First release of `@lightningjs/prettier-plugin-blits`
+- Formats Blits template strings inside `Blits.Component()` and `Blits.Application()`
+- Adds `blitsWrapAttributes` to wrap long attribute lists across multiple lines
+- Adds `blitsClosingBacktick` to control where the closing backtick is placed
+- Adds `blitsClosingBracketSameLine` to keep the closing `>` on the last attribute line in multiline tags
+- Adds `blitsPreserveBlankLines` to preserve blank lines between sibling elements
+- Adds `blitsNormalizeComments` to normalize HTML comment spacing
+- Adds `blitsTrimAttributeValues` to trim leading and trailing whitespace in attribute values
+- Adds `blitsSelfClosingTags` to collapse empty tags to self-closing form
+- Adds `blitsCollapseSingleElement` to collapse eligible single-element multiline templates
+- Preserves escape sequences in attribute values correctly
+- Preserves multiline template structure by default
diff --git a/prettier-plugin-blits/README.md b/prettier-plugin-blits/README.md
new file mode 100644
index 0000000..3128ab4
--- /dev/null
+++ b/prettier-plugin-blits/README.md
@@ -0,0 +1,250 @@
+# Prettier Plugin for Blits
+
+Formats the `template` string inside `Blits.Component()` and `Blits.Application()` calls, in JavaScript and TypeScript files.
+
+## Requirements
+
+- Node.js 18+
+- Prettier 3.x
+
+## Installation
+
+```bash
+npm install --save-dev @lightningjs/prettier-plugin-blits
+```
+
+### If you use a `.prettierrc`
+
+Add the plugin to your Prettier config. If you already use other plugins, just include this one in the same list:
+
+```json
+{
+ "plugins": ["@lightningjs/prettier-plugin-blits"],
+ "blitsWrapAttributes": true,
+ "blitsClosingBacktick": "newline",
+ "blitsPreserveBlankLines": true
+}
+```
+
+### If you run Prettier through ESLint
+
+If your setup uses `eslint-plugin-prettier`, add the plugin to the inline Prettier options in your ESLint config:
+
+```js
+// .eslintrc.cjs
+rules: {
+ 'prettier/prettier': [
+ 'error',
+ {
+ singleQuote: true,
+ semi: false,
+ // ... your other prettier options
+ plugins: ['@lightningjs/prettier-plugin-blits'],
+ // you can change plugin options like this
+ blitsWrapAttributes: true,
+ blitsClosingBacktick: 'newline',
+ blitsPreserveBlankLines: true,
+ },
+ ],
+}
+```
+
+> `eslint-plugin-prettier` v5+ is required for Prettier 3. If you are upgrading from Prettier 2, also update `eslint-plugin-prettier` to `^5.0.0` and `eslint-config-prettier` to `^9.0.0`.
+
+### If you use a `prettier.config.js`
+
+```js
+export default {
+ plugins: ['@lightningjs/prettier-plugin-blits'],
+ // you can change plugin options like this
+ blitsWrapAttributes: true,
+ blitsClosingBacktick: 'newline',
+ blitsPreserveBlankLines: true,
+}
+```
+
+For CommonJS projects:
+
+```js
+module.exports = {
+ plugins: ['@lightningjs/prettier-plugin-blits'],
+ // you can change plugin options like this
+ blitsWrapAttributes: true,
+ blitsClosingBacktick: 'newline',
+ blitsPreserveBlankLines: true,
+}
+```
+
+## What the plugin formats
+
+### Where it applies
+
+The plugin only touches the value of the `template` property inside `Blits.Component()` or `Blits.Application()`. Everything else in the file is still handled by Prettier as usual.
+
+Template literals with `${...}` interpolations are left alone.
+
+### Inline vs multi-line
+
+Short templates that fit within `printWidth` stay on one line:
+
+```js
+template: ``
+```
+
+Longer templates expand to multiple lines by default, with the content indented:
+
+```js
+template: `
+
+
+
+`
+```
+
+### Attribute wrapping
+
+When a tag and its attributes fit within `printWidth`, they stay on one line. If they do not fit, each attribute moves to its own line:
+
+```js
+// fits on one line — stays inline
+
+
+// too long — each attribute on its own line
+
+```
+
+### Children
+
+Child elements are indented relative to their parent using your configured `tabWidth`:
+
+```js
+template: `
+
+
+
+
+
+`
+```
+
+### Blank lines
+
+Blank lines between sibling elements are preserved. If there are several in a row, they are collapsed to a single blank line:
+
+```js
+template: `
+
+
+
+
+
+
+
+
+
+`
+```
+
+### Comment normalization
+
+Comments are formatted to a consistent style: one space after ``, and no extra dashes:
+
+```js
+ →
+ →
+ →
+```
+
+### Attribute value trimming
+
+Leading or trailing spaces inside quoted attribute values are removed:
+
+```js
+:w=" 354 -14 " → :w="354 -14"
+w="100" → w="100"
+```
+
+Multiline attribute values such as `:transition="{\n prop: 'x'\n}"` are left as they are. Only leading and trailing spaces or tabs are trimmed, never newlines.
+
+### What is never changed
+
+- **Attribute values** — reactive bindings (`:color="$myColor"`), event handlers (`@loaded="$onLoad"`), `:for` expressions, `$variable` references, arrow functions, and `:transition` objects are preserved as written
+- **Attribute order** — attributes are never reordered or sorted
+
+### Configuration
+
+Standard Prettier options apply:
+
+| Option | Effect |
+|---|---|
+| `printWidth` | Controls when attribute lists and nested templates wrap (default: 80) |
+| `tabWidth` | Controls indentation inside templates (default: 2) |
+
+The plugin adds these Blits-specific options on top:
+
+| Option | Default | Description |
+|---|---|---|
+| `blitsWrapAttributes` | `true` | Wrap attributes onto separate lines when the tag exceeds `printWidth`. Set to `false` to keep attributes inline. |
+| `blitsClosingBacktick` | `"newline"` | Controls where the closing backtick goes in multi-line templates. `"newline"` puts it on its own line, while `"inline"` keeps it at the end of the final content line. |
+| `blitsPreserveBlankLines` | `true` | Keeps blank lines between sibling elements. Consecutive blank lines are collapsed to one. |
+| `blitsNormalizeComments` | `true` | Normalizes comment spacing by enforcing one space after ``, and by collapsing extra dashes. |
+| `blitsTrimAttributeValues` | `true` | Trims leading and trailing spaces or tabs inside attribute values. Newlines are preserved, so multiline values stay safe. |
+| `blitsClosingBracketSameLine` | `false` | Places the closing `>` of a multi-line opening tag on the same line as the last attribute. |
+| `blitsSelfClosingTags` | `false` | Converts empty open/close tag pairs like `` into ``. This is off by default because `` can also communicate intent. |
+| `blitsCollapseSingleElement` | `false` | Allows a single-root-element template to collapse to one line when it fits within `printWidth`. This is off by default so existing multi-line intent is preserved. |
+
+**`blitsClosingBacktick: "newline"` (default):**
+```js
+template: `
+
+
+
+`
+```
+
+**`blitsClosingBacktick: "inline"`:**
+```js
+template: `
+
+
+ `
+```
+
+**`blitsClosingBracketSameLine: false` (default):**
+```js
+
+
+
+```
+
+**`blitsClosingBracketSameLine: true`:**
+```js
+
+
+
+```
+
+**`blitsCollapseSingleElement: false` (default) — multi-line preserved:**
+```js
+template: `
+
+`,
+```
+
+**`blitsCollapseSingleElement: true` — collapsed when fits in printWidth:**
+```js
+template: ``,
+```
diff --git a/prettier-plugin-blits/package.json b/prettier-plugin-blits/package.json
new file mode 100644
index 0000000..ddf01d7
--- /dev/null
+++ b/prettier-plugin-blits/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "@lightningjs/prettier-plugin-blits",
+ "version": "1.0.0",
+ "author": "Ugur Aslan ",
+ "license": "Apache-2.0",
+ "description": "Prettier plugin for Blits template formatting",
+ "main": "src/index.js",
+ "files": [
+ "src",
+ "README.md",
+ "CHANGELOG.md"
+ ],
+ "scripts": {
+ "lint": "eslint .",
+ "test": "node --test 'tests/**/*.test.js'"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/lightning-js/blits-dev-tools.git",
+ "directory": "prettier-plugin-blits"
+ },
+ "bugs": {
+ "url": "https://github.com/lightning-js/blits-dev-tools/issues"
+ },
+ "homepage": "https://lightningjs.io/",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "keywords": [
+ "prettier", "plugin", "blits", "template", "formatter"
+ ],
+ "category": "Prettier Plugin",
+ "peerDependencies": {
+ "prettier": ">=3.0.0"
+ },
+ "devDependencies": {
+ "prettier": "^3.0.0"
+ }
+}
diff --git a/prettier-plugin-blits/src/blitsParser.js b/prettier-plugin-blits/src/blitsParser.js
new file mode 100644
index 0000000..8637aba
--- /dev/null
+++ b/prettier-plugin-blits/src/blitsParser.js
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2023 Comcast Cable Communications Management, LLC
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// Sourced from eslint-plugin-blits/lib/parser.js (release/eslint-plugin-blits-v1.0.0)
+// Kept as a local copy to avoid a cross-package runtime dependency
+const parseTemplate = require('./parser.cjs')
+
+function buildTree(flatTree, text, preserveBlankLines = true) {
+ const nodes = Object.values(flatTree)
+ const stack = []
+ const roots = []
+
+ for (const node of nodes) {
+ if (node.type === 'whitespace') continue
+ if (node.type === 'tag' && node.tagType === 'closing') continue
+
+ let hierarchicalNode
+
+ if (node.type === 'comment') {
+ hierarchicalNode = {
+ type: 'comment',
+ text: node.nodeText,
+ start: node.start,
+ end: node.end,
+ }
+ } else if (node.type === 'tag') {
+ hierarchicalNode = {
+ type: 'element',
+ tag: node.tag,
+ selfClosing: node.tagType === 'self-closing',
+ attrs: (node.attrs || []).map((a) => ({
+ name: a.name.text,
+ value: a.value.text,
+ })),
+ children: [],
+ start: node.start,
+ end: node.end,
+ }
+
+ if (node.content && node.content.node) {
+ hierarchicalNode.children.push({
+ type: 'text',
+ value: text ? text.slice(node.content.start, node.content.end) : node.content.node,
+ start: node.content.start,
+ end: node.content.end,
+ })
+ }
+ }
+
+ if (!hierarchicalNode) continue
+
+ while (stack.length > 0 && stack[stack.length - 1]._level >= node.level) {
+ stack.pop()
+ }
+
+ const targetArray = stack.length === 0 ? roots : stack[stack.length - 1].children
+ const prev = targetArray[targetArray.length - 1]
+ if (preserveBlankLines && prev && prev.end != null && /\n[ \t]*\n/.test(text.slice(prev.end, node.start))) {
+ hierarchicalNode.blankBefore = true
+ }
+ targetArray.push(hierarchicalNode)
+
+ if (hierarchicalNode.type === 'element' && !hierarchicalNode.selfClosing) {
+ hierarchicalNode._level = node.level
+ stack.push(hierarchicalNode)
+ }
+ }
+
+ function cleanNode(n) {
+ delete n._level
+ if (n.children) n.children.forEach(cleanNode)
+ }
+ roots.forEach(cleanNode)
+
+ return roots
+}
+
+function parse(text, options) {
+ const result = parseTemplate(text)
+ if (!result.status || !result.tree) {
+ throw new Error(result.error?.info ?? 'Failed to parse Blits template')
+ }
+ const roots = buildTree(result.tree, text, options?.blitsPreserveBlankLines !== false)
+ return { type: 'root', children: roots, start: 0, end: text.length }
+}
+
+module.exports = { parse }
diff --git a/prettier-plugin-blits/src/embed.js b/prettier-plugin-blits/src/embed.js
new file mode 100644
index 0000000..dc758cb
--- /dev/null
+++ b/prettier-plugin-blits/src/embed.js
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2023 Comcast Cable Communications Management, LLC
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const { doc } = require('prettier')
+
+const { hardline, softline, indent, group } = doc.builders
+
+function isBlitsTemplate(path) {
+ const node = path.getValue()
+ const isTemplateLiteral = node.type === 'TemplateLiteral'
+ const isSingleQuotedLiteral = node.type === 'Literal' && typeof node.value === 'string' && node.raw?.startsWith("'")
+
+ if (!isTemplateLiteral && !isSingleQuotedLiteral) return false
+
+ return path.match(
+ () => true,
+
+ (node, name) =>
+ (node.type === 'ObjectProperty' || node.type === 'Property') &&
+ !node.computed &&
+ node.key.type === 'Identifier' &&
+ node.key.name === 'template' &&
+ name === 'value',
+
+ (node, name) => node.type === 'ObjectExpression' && name === 'properties',
+
+ (node, name) =>
+ name === 'arguments' &&
+ node.type === 'CallExpression' &&
+ node.callee.type === 'MemberExpression' &&
+ node.callee.object.type === 'Identifier' &&
+ node.callee.object.name === 'Blits' &&
+ node.callee.property.type === 'Identifier' &&
+ (node.callee.property.name === 'Component' || node.callee.property.name === 'Application')
+ )
+}
+
+function embed(path, _options) {
+ if (!isBlitsTemplate(path)) return undefined
+
+ return async (textToDoc, print, path, options) => {
+ const node = path.getValue()
+
+ if (node.type === 'TemplateLiteral' && (node.quasis.length !== 1 || node.expressions.length !== 0)) {
+ return undefined
+ }
+
+ const text = node.type === 'TemplateLiteral' ? node.quasis[0].value.raw : node.value
+
+ if (!text || text.trim() === '') return undefined
+
+ let formattedDoc
+ try {
+ formattedDoc = await textToDoc(text, { ...options, parser: 'blits-template' })
+ } catch {
+ return undefined
+ }
+
+ if (node.type === 'TemplateLiteral') {
+ const isMultiLine = text.startsWith('\n') || text.startsWith('\r\n')
+ const collapseAllowed = options.blitsCollapseSingleElement === true
+
+ if (isMultiLine && !collapseAllowed) {
+ const closingBacktick = options.blitsClosingBacktick === 'inline' ? '`' : [hardline, '`']
+ return ['`', indent([hardline, formattedDoc]), closingBacktick]
+ }
+
+ const closingBacktick = options.blitsClosingBacktick === 'inline' ? '`' : [softline, '`']
+ return group(['`', indent([softline, formattedDoc]), closingBacktick])
+ } else {
+ return ["'", formattedDoc, "'"]
+ }
+ }
+}
+
+module.exports = { embed }
diff --git a/prettier-plugin-blits/src/index.js b/prettier-plugin-blits/src/index.js
new file mode 100644
index 0000000..1bce95d
--- /dev/null
+++ b/prettier-plugin-blits/src/index.js
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2023 Comcast Cable Communications Management, LLC
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const estreePlugin = require('prettier/plugins/estree.js')
+const { embed } = require('./embed.js')
+const { parse } = require('./blitsParser.js')
+const { print } = require('./printer.js')
+
+const builtinEstreePrinter = estreePlugin.printers.estree
+const builtinEmbed = builtinEstreePrinter.embed
+
+const options = {
+ blitsWrapAttributes: {
+ type: 'boolean',
+ category: 'Blits',
+ default: true,
+ description:
+ 'Wrap element attributes to individual lines when the tag exceeds printWidth. Set to false to keep all attributes inline.',
+ },
+ blitsClosingBracketSameLine: {
+ type: 'boolean',
+ category: 'Blits',
+ default: false,
+ description: 'Put the closing > of a multi-line opening tag on the same line as the last attribute.',
+ },
+ blitsPreserveBlankLines: {
+ type: 'boolean',
+ category: 'Blits',
+ default: true,
+ description:
+ 'Preserve blank lines between sibling elements. Multiple consecutive blank lines are collapsed to one.',
+ },
+ blitsTrimAttributeValues: {
+ type: 'boolean',
+ category: 'Blits',
+ default: true,
+ description: 'Trim leading/trailing whitespace from attribute values.',
+ },
+ blitsNormalizeComments: {
+ type: 'boolean',
+ category: 'Blits',
+ default: true,
+ description:
+ 'Normalize comment whitespace — ensures one space after . Also collapses triple-dash comments ( → ).',
+ },
+ blitsSelfClosingTags: {
+ type: 'boolean',
+ category: 'Blits',
+ default: false,
+ description:
+ 'Collapse empty open/close tag pairs () into self-closing form (). Disabled by default to preserve developer intent.',
+ },
+ blitsCollapseSingleElement: {
+ type: 'boolean',
+ category: 'Blits',
+ default: false,
+ description:
+ "When enabled, a single-root-element template that fits within printWidth is collapsed to a single-line backtick template. Disabled by default to preserve the developer's multi-line formatting intent.",
+ },
+ blitsClosingBacktick: {
+ type: 'choice',
+ category: 'Blits',
+ default: 'newline',
+ choices: [
+ { value: 'newline', description: 'Closing backtick on its own line.' },
+ { value: 'inline', description: 'Closing backtick at the end of the last content line.' },
+ ],
+ description: 'Position of the closing backtick in multi-line template literals.',
+ },
+}
+
+module.exports = {
+ options,
+ parsers: {
+ 'blits-template': {
+ parse,
+ astFormat: 'blits-template-ast',
+ locStart: (node) => node.start ?? 0,
+ locEnd: (node) => node.end ?? 0,
+ },
+ },
+ printers: {
+ estree: {
+ ...builtinEstreePrinter,
+ embed(path, options) {
+ const result = embed(path, options)
+ if (result !== undefined) return result
+ return builtinEmbed?.call(builtinEstreePrinter, path, options)
+ },
+ },
+ 'blits-template-ast': {
+ print,
+ },
+ },
+}
diff --git a/prettier-plugin-blits/src/parser.cjs b/prettier-plugin-blits/src/parser.cjs
new file mode 100644
index 0000000..cb01f1c
--- /dev/null
+++ b/prettier-plugin-blits/src/parser.cjs
@@ -0,0 +1,557 @@
+/*
+ * Copyright 2023 Comcast Cable Communications Management, LLC
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const PATTERNS = {
+ TAG_START: /^<\/?([a-zA-Z0-9_\-.]+)\s*/,
+ TAG_END: /^\s*(\/?>)/,
+ ATTR_NAME: /^([A-Za-z0-9:.\-_@$]+)/,
+ ATTR_EQUALS: /^\s*=/,
+ ATTR_QUOTE: /^\s*(["'])/,
+ EMPTY_TAG_START: /^<>/,
+ EMPTY_TAG_END: /^\s*(<\/>)/,
+ COMMENT: /^/,
+ WHITESPACE: /^\s+/,
+}
+
+function parseTemplate(template = '') {
+ let cursor = 0
+ let prevCursor = 0
+ let currentTag = null
+ let currentNode = null
+ let currentLevel = 0
+ const tree = {}
+ const tagStack = []
+ let rootTagAdded = false
+ let nodeCounter = 0
+ let response = {
+ status: true,
+ error: null,
+ tree: null,
+ }
+
+ function moveCursorOnMatch(regex) {
+ const match = template.slice(cursor).match(regex)
+ if (match) {
+ prevCursor = cursor
+ cursor += match[0].length
+ }
+ return match
+ }
+
+ function parseLoop(next) {
+ if (cursor >= template.length || !response.status) {
+ return response
+ }
+ // Process whitespace/comments first
+ parseCommentsAndWhiteSpace()
+ next()
+ }
+
+ function addNode({
+ tag,
+ start = prevCursor,
+ end = cursor,
+ type,
+ level = currentLevel,
+ nodeText,
+ tagType = 'opening',
+ partial = false,
+ isClosed = false,
+ openingNode = null,
+ closingNode = null,
+ attrs = [],
+ content = '',
+ tokens = [],
+ }) {
+ nodeCounter++
+
+ tag = String(tag)
+ .replace(/[>]+/g, '')
+ .trim()
+
+ if (tagType === 'self-closing') {
+ openingNode = nodeCounter
+ closingNode = nodeCounter
+ }
+
+ if (tagType === 'opening') {
+ openingNode = nodeCounter
+ }
+
+ if (type === 'tag' && tagType === 'opening') {
+ tagStack.push(nodeCounter)
+ }
+
+ if (level === 0 && (tagType === 'opening' || tagType === 'self-closing') && type === 'tag') {
+ if (rootTagAdded) {
+ // We've already encountered a root tag, so this is a second one
+ return processError({
+ type: 'MultipleRootElements',
+ message: 'Templates must have exactly one root element.',
+ ranges: [{ start: start, end: end }],
+ })
+ }
+ rootTagAdded = true
+ }
+
+ if (tagType === 'closing') {
+ if (tagStack.length === 0) {
+ return handleUnmatchedClosingTag()
+ }
+
+ const lastTag = tagStack[tagStack.length - 1]
+ if (tag !== tree[lastTag].tag || level !== tree[lastTag].level) {
+ return handleMismatchedTagPair(lastTag, tag)
+ }
+
+ tagStack.pop()
+ openingNode = lastTag
+ closingNode = nodeCounter
+ tree[lastTag].closingNode = nodeCounter
+ tree[lastTag].isClosed = true
+ }
+
+ tree[nodeCounter] = {
+ tag,
+ start,
+ end,
+ type,
+ level,
+ tagType,
+ isClosed,
+ openingNode,
+ closingNode,
+ nodeText,
+ partial,
+ tokens,
+ attrs,
+ content,
+ }
+ return nodeCounter
+ }
+
+ function parseCommentsAndWhiteSpace() {
+ parseWhitespace()
+ parseComments()
+ parseWhitespace() // Check for whitespace after comments
+ }
+
+ function parseWhitespace() {
+ const whitespaceMatch = moveCursorOnMatch(PATTERNS.WHITESPACE)
+ if (whitespaceMatch) {
+ addNode({
+ tag: '-',
+ type: 'whitespace',
+ nodeText: whitespaceMatch[0],
+ tagType: null,
+ isClosed: true,
+ tokens: [
+ {
+ type: 'whitespace',
+ value: whitespaceMatch[0],
+ start: prevCursor,
+ end: cursor,
+ },
+ ],
+ })
+ }
+ }
+
+ function parseComments() {
+ const match = moveCursorOnMatch(PATTERNS.COMMENT)
+ if (match) {
+ addNode({
+ tag: '+',
+ type: 'comment',
+ nodeText: match[0],
+ tagType: 'self-closing',
+ isClosed: true,
+ tokens: [
+ {
+ type: 'comment',
+ value: match[0],
+ start: prevCursor,
+ end: cursor,
+ },
+ ],
+ })
+ parseCommentsAndWhiteSpace()
+ }
+ }
+
+ function parseEmptyTagStart() {
+ const match = moveCursorOnMatch(PATTERNS.EMPTY_TAG_START)
+ if (match) {
+ addNode({
+ tag: 'empty',
+ type: 'tag',
+ nodeText: match[0],
+ tokens: [
+ {
+ type: 'openEmptyTag',
+ value: match[0],
+ start: prevCursor,
+ end: cursor,
+ },
+ ],
+ })
+ currentLevel++
+ parseLoop(parseEmptyTagStart)
+ } else {
+ parseLoop(parseEmptyTagEnd)
+ }
+ }
+
+ function parseEmptyTagEnd() {
+ const match = moveCursorOnMatch(PATTERNS.EMPTY_TAG_END)
+ if (match) {
+ currentLevel--
+ addNode({
+ tag: 'empty',
+ type: 'tag',
+ level: currentLevel,
+ nodeText: match[0],
+ tagType: 'closing',
+ tokens: [
+ {
+ type: 'closeEmptyTag',
+ value: match[0],
+ start: prevCursor,
+ end: cursor,
+ },
+ ],
+ })
+ parseLoop(parseEmptyTagStart)
+ } else {
+ parseLoop(parseTag)
+ }
+ }
+
+ function parseTag() {
+ const match = moveCursorOnMatch(PATTERNS.TAG_START)
+ if (match) {
+ currentTag = {
+ level: currentLevel,
+ type: 'opening',
+ }
+ let level = currentLevel
+ if (match[0].startsWith('')) {
+ currentLevel--
+ level--
+ currentTag.type = 'closing'
+ currentTag.level = currentLevel
+ } else {
+ currentTag.type = 'opening'
+ currentLevel++
+ }
+ currentNode = addNode({
+ tag: String(match[1]),
+ type: 'tag',
+ level,
+ nodeText: match[0],
+ tagType: currentTag.type,
+ partial: true,
+ tokens: [
+ {
+ type: 'tagStart',
+ value: match[0],
+ start: prevCursor,
+ end: cursor,
+ },
+ ],
+ })
+ parseLoop(parseTagEnd)
+ } else {
+ return processError({
+ type: 'InvalidTag',
+ message: 'This tag is not valid according to Blits syntax.',
+ ranges: [{ start: prevCursor, end: cursor }],
+ })
+ }
+ }
+
+ function parseTagEnd() {
+ const match = moveCursorOnMatch(PATTERNS.TAG_END)
+ if (match) {
+ handleTagEnd(match)
+ parseLoop(parseEmptyTagStart)
+ } else {
+ parseLoop(parseAttributes)
+ }
+ }
+
+ function handleTagEnd(match) {
+ if (match[1] === '/>') {
+ handleSelfClosingTag()
+ }
+ updateCurrentNode(match)
+ if (currentTag.type === 'opening') {
+ handleTagContent()
+ }
+ }
+
+ function handleSelfClosingTag() {
+ if (currentTag.type === 'closing') {
+ // For InvalidClosingTag, highlight from the start of the current node.
+ return processError({
+ type: 'InvalidClosingTag',
+ message: 'Closing tags cannot be self-closing. Remove the "/" at the end.',
+ ranges: [{ start: tree[currentNode].start, end: cursor }],
+ })
+ }
+ currentTag.type = 'self-closing'
+ tree[currentNode].tagType = 'self-closing'
+ tree[currentNode].closingNode = currentNode
+ tree[currentNode].isClosed = true
+ tagStack.pop()
+ currentLevel--
+ }
+
+ function updateCurrentNode(match) {
+ tree[currentNode].nodeText += match[0]
+ tree[currentNode].end = cursor
+ tree[currentNode].partial = false
+ tree[currentNode].tokens.push({
+ type: 'tagEnd',
+ value: match[0],
+ start: prevCursor,
+ end: cursor,
+ })
+ }
+
+ function handleTagContent() {
+ const nextTagIndex = template.indexOf('<', cursor)
+ const tagContent = nextTagIndex !== -1 ? template.slice(cursor, nextTagIndex) : template.slice(cursor)
+ if (tagContent) {
+ const tagContentTrimmed = tagContent.trim()
+ prevCursor = cursor
+ cursor += tagContent.length
+ if (tagContentTrimmed.length > 0) {
+ currentTag.content = tagContentTrimmed
+ tree[currentNode].content = {
+ start: prevCursor,
+ end: cursor,
+ level: currentTag.level,
+ node: tagContentTrimmed,
+ }
+ tree[currentNode].tokens.push({
+ type: 'tagContent',
+ value: tagContent,
+ start: prevCursor,
+ end: cursor,
+ })
+ }
+ }
+ }
+
+ function parseAttributes() {
+ const attrNameMatch = moveCursorOnMatch(PATTERNS.ATTR_NAME)
+
+ if (attrNameMatch) {
+ if (currentTag.type === 'closing') {
+ // In a closing tag, gather all attribute ranges safely for error reporting
+ const attrRanges = [{ start: prevCursor, end: cursor }]
+
+ let nextAttr = moveCursorOnMatch(PATTERNS.ATTR_NAME)
+ while (nextAttr) {
+ attrRanges.push({ start: prevCursor, end: cursor })
+ nextAttr = moveCursorOnMatch(PATTERNS.ATTR_NAME)
+ }
+
+ return processError({
+ type: 'AttributesInClosingTag',
+ message: 'Closing tags cannot have attributes. Remove the attributes from the closing tag.',
+ ranges: attrRanges,
+ })
+ }
+
+ // Check for whitespace before attributes (only for 2nd+ attributes)
+ const tokens = tree[currentNode].tokens || []
+ const attributeCount = tokens.filter((token) => token.type === 'attributeName').length
+
+ // If this isn't the first attribute, the last token should be whitespace
+ if (attributeCount > 0 && tokens[tokens.length - 1].type !== 'whitespace') {
+ return processError({
+ type: 'MissingWhitespace',
+ message: 'Attributes must be separated by whitespace.',
+ ranges: [{ start: prevCursor, end: cursor }],
+ })
+ }
+
+ let attribute = {
+ name: { text: attrNameMatch[1], start: prevCursor, end: cursor },
+ }
+
+ tree[currentNode].tokens.push({
+ type: 'attributeName',
+ value: attrNameMatch[0],
+ start: prevCursor,
+ end: cursor,
+ })
+
+ // Check for redundant attributes
+ const attrName = attrNameMatch[1]
+ const existingAttr = tree[currentNode].attrs.find((attr) => attr.name.text === attrName)
+ if (existingAttr) {
+ return processError({
+ type: 'RedundantAttribute',
+ message: `Attribute "${attrName}" is already defined on this element.`,
+ ranges: [
+ { start: existingAttr.name.start, end: existingAttr.value.end }, // First occurrence
+ { start: prevCursor, end: cursor }, // Current occurrence
+ ],
+ })
+ }
+
+ const equalsMatch = moveCursorOnMatch(PATTERNS.ATTR_EQUALS)
+
+ if (!equalsMatch) {
+ // No equals sign - attribute needs a value
+ return processError({
+ type: 'MissingAttributeValue',
+ message: 'Attribute must have a value. Add ="value" or remove the attribute.',
+ ranges: [{ start: attribute.name.start, end: cursor + 3 }], // +3 chars for visibility
+ })
+ }
+
+ // Update tokens with equals sign
+ tree[currentNode].tokens.push({
+ type: 'attributeEquals',
+ value: equalsMatch[0],
+ start: prevCursor,
+ end: cursor,
+ })
+
+ const quoteMatch = moveCursorOnMatch(PATTERNS.ATTR_QUOTE)
+
+ if (!quoteMatch) {
+ // No opening quote - invalid attribute format
+ return processError({
+ type: 'MissingAttributeValue',
+ message: 'Attribute must have a value. Add ="value" or remove the attribute.',
+ ranges: [{ start: attribute.name.start, end: cursor + 3 }], // +3 chars for visibility
+ })
+ }
+
+ const quoteChar = quoteMatch[1]
+
+ const valueRegex = new RegExp(`^(.*?)${quoteChar}(\\s*)`, 's')
+ const valueMatch = moveCursorOnMatch(valueRegex)
+
+ if (!valueMatch) {
+ // No closing quote - unclosed attribute value
+ return processError({
+ type: 'UnclosedAttributeValue',
+ message: 'Attribute value is not properly closed. Add a matching closing quote.',
+ ranges: [{ start: attribute.name.start, end: cursor + 5 }], // +5 chars for visibility
+ })
+ }
+
+ attribute.value = {
+ text: valueMatch[1],
+ start: prevCursor,
+ end: cursor - (valueMatch[2] ? valueMatch[2].length : 0),
+ }
+
+ tree[currentNode].end = cursor
+ tree[currentNode].nodeText += attrNameMatch[0] + equalsMatch[0] + quoteMatch[0] + valueMatch[0]
+ tree[currentNode].attrs.push(attribute)
+
+ // Add the value token (excluding trailing whitespace)
+ const valueEnd = cursor - (valueMatch[2] ? valueMatch[2].length : 0)
+ tree[currentNode].tokens.push({
+ type: 'attributeValue',
+ value: valueMatch[1] + quoteChar, // Include the closing quote but not trailing whitespace
+ start: prevCursor,
+ end: valueEnd,
+ })
+
+ // Add the trailing whitespace as a separate token if present
+ if (valueMatch[2] && valueMatch[2].length > 0) {
+ tree[currentNode].tokens.push({
+ type: 'whitespace',
+ value: valueMatch[2],
+ start: valueEnd,
+ end: cursor,
+ })
+ }
+
+ parseLoop(parseTagEnd)
+ } else {
+ // No valid attribute name found, try to see if there's something that might be an invalid attribute
+ const invalidAttrMatch = template.slice(cursor).match(/^(\S+?)(?=[\s=/>]|$)/)
+
+ if (invalidAttrMatch && invalidAttrMatch[1]) {
+ const startPos = cursor
+ const endPos = cursor + invalidAttrMatch[1].length
+ prevCursor = cursor
+ cursor += invalidAttrMatch[0].length
+
+ return processError({
+ type: 'InvalidAttribute',
+ message:
+ 'Invalid attribute name. Attribute names must contain only letters, numbers, and these special characters: : . - _ @ $',
+ ranges: [{ start: startPos, end: endPos }],
+ })
+ }
+
+ parseLoop(parseTagEnd)
+ }
+ }
+
+ function processError(err) {
+ response.status = false
+ response.error = {
+ type: err.type,
+ info: err.message,
+ ranges: err.ranges || [
+ {
+ start: err.start !== undefined ? err.start : prevCursor,
+ end: err.end !== undefined ? err.end : cursor,
+ },
+ ],
+ }
+ }
+
+ // Error Handlers
+ function handleUnmatchedClosingTag() {
+ return processError({
+ type: 'UnmatchedClosingTag',
+ message: 'No matching opening tag found for this closing tag.',
+ ranges: [{ start: prevCursor, end: cursor }],
+ })
+ }
+
+ function handleMismatchedTagPair(openingNodeIndex, closingTag) {
+ let openingTagNode = tree[openingNodeIndex]
+ return processError({
+ type: 'MismatchedTagPair',
+ message: `Expected closing tag for <${openingTagNode.tag}> but found ${closingTag}>. Tags must be properly nested.`,
+ ranges: [
+ { start: openingTagNode.start, end: openingTagNode.end },
+ { start: prevCursor, end: cursor },
+ ],
+ })
+ }
+
+ // Start parsing and return result
+ parseLoop(parseEmptyTagStart)
+ response.tree = tree
+ return response
+}
+
+module.exports = parseTemplate
diff --git a/prettier-plugin-blits/src/printer.js b/prettier-plugin-blits/src/printer.js
new file mode 100644
index 0000000..cc4064b
--- /dev/null
+++ b/prettier-plugin-blits/src/printer.js
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2023 Comcast Cable Communications Management, LLC
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const { doc } = require('prettier')
+
+const { hardline, softline, line, group, indent, ifBreak } = doc.builders
+
+function joinChildren(childNodes, childDocs) {
+ return childDocs.flatMap((doc, i) => {
+ if (i === 0) return [doc]
+ return childNodes[i].blankBefore ? [hardline, hardline, doc] : [hardline, doc]
+ })
+}
+
+function printAttrs(attrs, wrap, options) {
+ if (!attrs || attrs.length === 0) return []
+ return attrs.map((a) => {
+ const val = options.blitsTrimAttributeValues ? a.value.replace(/^[ \t]+|[ \t]+$/g, '') : a.value
+ return [wrap ? line : ' ', `${a.name}="${val}"`]
+ })
+}
+
+function printElement(path, options, print) {
+ const node = path.getValue()
+ const wrap = options.blitsWrapAttributes
+ const attrs = printAttrs(node.attrs, wrap, options)
+ const hasChildren = node.children && node.children.length > 0
+
+ if (node.tag === 'empty') {
+ if (!hasChildren) return '<>>'
+ const emptyChildDocs = joinChildren(node.children, path.map(print, 'children'))
+ return ['<>', indent([hardline, emptyChildDocs]), hardline, '>']
+ }
+
+ if (node.selfClosing) {
+ if (wrap) {
+ return group(['<', node.tag, indent(attrs), ifBreak('', ' '), softline, '/>'])
+ }
+ return ['<', node.tag, attrs, ' />']
+ }
+
+ const closingAngle = options.blitsClosingBracketSameLine ? '>' : [softline, '>']
+ const openTag = wrap ? group(['<', node.tag, indent(attrs), closingAngle]) : ['<', node.tag, attrs, '>']
+
+ if (!hasChildren) {
+ if (options.blitsSelfClosingTags) {
+ if (wrap) {
+ return group(['<', node.tag, indent(attrs), ifBreak('', ' '), softline, '/>'])
+ }
+ return ['<', node.tag, attrs, ' />']
+ }
+ return [openTag, '', node.tag, '>']
+ }
+
+ if (node.children.length === 1 && node.children[0].type === 'text') {
+ return [openTag, node.children[0].value, '', node.tag, '>']
+ }
+
+ const childDocs = joinChildren(node.children, path.map(print, 'children'))
+ return [openTag, indent([hardline, childDocs]), hardline, '', node.tag, '>']
+}
+
+function print(path, options, print) {
+ const node = path.getValue()
+
+ switch (node.type) {
+ case 'root': {
+ const rootNode = path.getValue()
+ return joinChildren(rootNode.children, path.map(print, 'children'))
+ }
+ case 'element':
+ return printElement(path, options, print)
+ case 'comment': {
+ if (!options.blitsNormalizeComments) return node.text
+ const inner = node.text
+ .replace(/^` : ''
+ }
+ case 'text':
+ return node.value
+ default:
+ return ''
+ }
+}
+
+module.exports = { print }
diff --git a/prettier-plugin-blits/tests/format.test.js b/prettier-plugin-blits/tests/format.test.js
new file mode 100644
index 0000000..756ab41
--- /dev/null
+++ b/prettier-plugin-blits/tests/format.test.js
@@ -0,0 +1,403 @@
+/*
+ * Copyright 2023 Comcast Cable Communications Management, LLC
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const { test, describe } = require('node:test')
+const assert = require('node:assert/strict')
+const prettier = require('prettier')
+const plugin = require('../src/index.js')
+
+const format = (code, opts = {}) =>
+ prettier.format(code, { parser: 'babel', plugins: [plugin], printWidth: 80, ...opts })
+
+const formatTs = (code, opts = {}) =>
+ prettier.format(code, { parser: 'babel-ts', plugins: [plugin], printWidth: 80, ...opts })
+
+describe('format: self-closing elements', () => {
+ test('short tag stays on one line', async () => {
+ const input = 'Blits.Component(\'X\', { template: `` })'
+ const output = await format(input)
+ assert.ok(output.includes(''))
+ })
+
+ test('long tag breaks attributes across lines', async () => {
+ const input =
+ 'Blits.Component(\'X\', { template: `` })'
+ const output = await format(input)
+ assert.ok(output.includes('color="red"'))
+ // when broken, /> appears on its own line with no attribute on the same line
+ assert.match(output, /\n\s+\/>/)
+ assert.doesNotMatch(output, /[a-z0-9"'] \/>/)
+ })
+})
+
+describe('format: nested elements', () => {
+ test('parent with single child', async () => {
+ const input = "Blits.Component('X', { template: `` })"
+ const output = await format(input)
+ assert.ok(output.includes(''))
+ assert.ok(output.includes(''))
+ assert.ok(output.includes(''))
+ })
+
+ test('deeply nested', async () => {
+ const input = 'Blits.Component(\'X\', { template: `` })'
+ const output = await format(input)
+ assert.ok(output.includes(''))
+ assert.ok(output.includes(''))
+ assert.ok(output.includes(''))
+ })
+})
+
+describe('format: reactive and event attributes', () => {
+ test('reactive binding preserved', async () => {
+ const input = 'Blits.Component(\'X\', { template: `` })'
+ const output = await format(input)
+ assert.ok(output.includes(':color="$myColor"'))
+ })
+
+ test('event handler preserved', async () => {
+ const input = 'Blits.Component(\'X\', { template: `` })'
+ const output = await format(input)
+ assert.ok(output.includes('@loaded="$onLoaded"'))
+ })
+
+ test(':for with key preserved', async () => {
+ const input = 'Blits.Component(\'X\', { template: `` })'
+ const output = await format(input)
+ assert.ok(output.includes(':for="item in $items"'))
+ assert.ok(output.includes(':key="item.id"'))
+ })
+})
+
+describe('format: empty fragment', () => {
+ test('empty fragment renders as <>>', async () => {
+ const input = "Blits.Component('X', { template: `<>>` })"
+ const output = await format(input)
+ assert.ok(output.includes('<>'))
+ assert.ok(output.includes('>'))
+ })
+})
+
+describe('format: comments', () => {
+ test('HTML comment preserved', async () => {
+ const input = "Blits.Component('X', { template: `` })"
+ const output = await format(input)
+ assert.ok(output.includes(''))
+ })
+})
+
+describe('format: blitsClosingBracketSameLine option', () => {
+ const longTag =
+ 'Blits.Component(\'X\', { template: `` })'
+
+ test('false (default) — closing > on its own line', async () => {
+ const output = await format(longTag, { blitsClosingBracketSameLine: false })
+ assert.match(output, /mountY="0\.5"\n\s+>/)
+ })
+
+ test('true — closing > on last attribute line', async () => {
+ const output = await format(longTag, { blitsClosingBracketSameLine: true })
+ assert.match(output, /mountY="0\.5">/)
+ assert.doesNotMatch(output, /mountY="0\.5"\n/)
+ })
+
+ test('false — already multiline input, closing > stays on its own line', async () => {
+ const multilineTag =
+ 'Blits.Component(\'X\', { template: `\n \n \n \n` })'
+ const output = await format(multilineTag, { blitsClosingBracketSameLine: false })
+ assert.match(output, /mountY="0\.5"\n\s+>/)
+ })
+
+ test('true — already multiline input, closing > moves to last attribute line', async () => {
+ const multilineTag =
+ 'Blits.Component(\'X\', { template: `\n \n \n \n` })'
+ const output = await format(multilineTag, { blitsClosingBracketSameLine: true })
+ assert.match(output, /mountY="0\.5">/)
+ assert.doesNotMatch(output, /mountY="0\.5"\n/)
+ })
+
+ test('self-closing tags unaffected', async () => {
+ const output = await format(longTag, { blitsClosingBracketSameLine: true })
+ assert.match(output, //)
+ })
+
+ test('inline tag unaffected — short tag stays on one line', async () => {
+ const shortTag = 'Blits.Component(\'X\', { template: `` })'
+ const output = await format(shortTag, { blitsClosingBracketSameLine: true })
+ assert.match(output, //)
+ })
+})
+
+describe('format: blitsPreserveBlankLines option', () => {
+ test('blank line between siblings is preserved', async () => {
+ const input = "Blits.Component('X', { template: `\n\n` })"
+ const output = await format(input)
+ assert.match(output, /Text \/>[\s\S]*\n\n[\s\S]* {
+ const input = "Blits.Component('X', { template: `\n\n\n\n` })"
+ const output = await format(input)
+ assert.match(output, /Text \/>[\s\S]*\n\n[\s\S]*[\s\S]*\n\n\n[\s\S]* {
+ const input = "Blits.Component('X', { template: `\n \n` })"
+ const output = await format(input)
+ assert.match(output, /Text \/>[\s\S]*\n\n[\s\S]* {
+ const input = "Blits.Component('X', { template: `` })"
+ const output = await format(input)
+ assert.doesNotMatch(output, /Text \/>[\s\S]*\n\n[\s\S]* {
+ const input = "Blits.Component('X', { template: `\n\n` })"
+ const output = await format(input, { blitsPreserveBlankLines: false })
+ assert.doesNotMatch(output, /Text \/>[\s\S]*\n\n[\s\S]* {
+ test('true (default) — whitespace padding around value is trimmed', async () => {
+ const input = 'Blits.Component(\'X\', { template: `` })'
+ const output = await format(input)
+ assert.ok(output.includes(':w="354 -14"'))
+ })
+
+ test('false — value is passed through unchanged', async () => {
+ const input = 'Blits.Component(\'X\', { template: `` })'
+ const output = await format(input, { blitsTrimAttributeValues: false })
+ assert.ok(output.includes(':w=" 354 -14 "'))
+ })
+
+ test('multiline object value — internal newlines and indentation preserved', async () => {
+ const input =
+ "Blits.Component('X', { template: `` })"
+ const output = await format(input)
+ assert.ok(output.includes('prop:'))
+ assert.ok(output.includes('duration:'))
+ assert.match(output, /prop:.*\n.*duration:/s)
+ })
+})
+
+describe('format: blitsNormalizeComments option', () => {
+ test('missing leading space is added', async () => {
+ const input = "Blits.Component('X', { template: `` })"
+ const output = await format(input)
+ assert.ok(output.includes(''))
+ })
+
+ test('triple-dash comment is normalized', async () => {
+ const input = "Blits.Component('X', { template: `` })"
+ const output = await format(input)
+ assert.ok(output.includes(''))
+ })
+
+ test('already-correct comment is unchanged', async () => {
+ const input = "Blits.Component('X', { template: `` })"
+ const output = await format(input)
+ assert.ok(output.includes(''))
+ })
+
+ test('empty comment normalizes to single space on each side', async () => {
+ const input = "Blits.Component('X', { template: `` })"
+ const output = await format(input)
+ assert.ok(output.includes(''))
+ assert.ok(!output.includes(''))
+ })
+
+ test('false — comment passed through raw', async () => {
+ const input = "Blits.Component('X', { template: `` })"
+ const output = await format(input, { blitsNormalizeComments: false })
+ assert.ok(output.includes(''))
+ })
+})
+
+describe('format: text content', () => {
+ test('inline text content stays inline', async () => {
+ const input =
+ 'Blits.Component(\'X\', { template: `Text with alpha applied directly` })'
+ const output = await format(input)
+ assert.ok(output.includes('Text with alpha applied directly'))
+ })
+
+ test('multiline text content is preserved exactly', async () => {
+ const input = "Blits.Component('X', { template: `\n Line one\n Line two\n` })"
+ const output = await format(input)
+ assert.ok(output.includes('\n Line one\n Line two\n'))
+ })
+
+ test('text content with internal spacing is not touched', async () => {
+ const input = "Blits.Component('X', { template: ` spaced content ` })"
+ const output = await format(input)
+ assert.ok(output.includes(' spaced content '))
+ })
+})
+
+describe('format: string literal template', () => {
+ test('single-quoted string formats inline', async () => {
+ const input = "Blits.Component('X', { template: '' })"
+ const output = await format(input)
+ assert.ok(output.includes(''))
+ })
+})
+
+describe('format: TypeScript parser', () => {
+ test('formats template in .ts file', async () => {
+ const input = 'Blits.Component(\'X\', { template: `` })'
+ const output = await formatTs(input)
+ assert.ok(output.includes(''))
+ })
+})
+
+describe('format: Blits.Application', () => {
+ test('formats template inside Blits.Application', async () => {
+ const input = 'Blits.Application({ template: `` })'
+ const output = await format(input)
+ assert.ok(output.includes(''))
+ })
+})
+
+describe('format: blitsClosingBacktick option', () => {
+ const nested = 'Blits.Component(\'X\', { template: `` })'
+
+ test('newline (default) — closing backtick on its own line', async () => {
+ const output = await format(nested, { blitsClosingBacktick: 'newline' })
+ assert.match(output, /\n\s*`/)
+ })
+
+ test('inline — closing backtick at end of last content line', async () => {
+ const output = await format(nested, { blitsClosingBacktick: 'inline' })
+ assert.match(output, />`/)
+ assert.doesNotMatch(output, /\n`/)
+ })
+})
+
+describe('format: blitsWrapAttributes option', () => {
+ // template long enough to exceed printWidth when indented
+ const longTag =
+ 'Blits.Component(\'X\', { template: `` })'
+
+ test('false — attributes stay on one line regardless of printWidth', async () => {
+ const output = await format(longTag, { blitsWrapAttributes: false })
+ assert.ok(output.includes('color="red"'))
+ assert.doesNotMatch(output, /\n\s+color/)
+ assert.ok(output.includes(' />'))
+ })
+
+ test('true (default) — attributes wrap when tag exceeds printWidth', async () => {
+ const output = await format(longTag, { blitsWrapAttributes: true })
+ assert.match(output, /\n\s+color/)
+ })
+})
+
+describe('format: blitsSelfClosingTags option', () => {
+ const emptyTag = 'Blits.Component(\'X\', { template: `` })'
+
+ test('false (default) — empty open/close tag preserved', async () => {
+ const output = await format(emptyTag)
+ assert.ok(output.includes(''))
+ })
+
+ test('true — empty open/close tag collapsed to self-closing', async () => {
+ const output = await format(emptyTag, { blitsSelfClosingTags: true })
+ assert.ok(output.includes(''))
+ })
+})
+
+describe('format: idempotency', () => {
+ test('formatting twice gives the same result', async () => {
+ const input =
+ 'Blits.Component(\'X\', { template: `` })'
+ const first = await format(input)
+ const second = await format(first)
+ assert.equal(first, second)
+ })
+})
+
+describe('format: non-template strings not touched', () => {
+ test('plain object template is not formatted as Blits', async () => {
+ const input = 'const obj = { template: `` }'
+ // should not throw — default printer handles it
+ const output = await format(input)
+ assert.equal(typeof output, 'string')
+ assert.ok(output.length > 0)
+ })
+})
+
+describe('format: multi-line template preservation', () => {
+ test('multi-line template stays multi-line by default', async () => {
+ const input = 'Blits.Component(\'X\', { template: `\n \n` })'
+ const output = await format(input)
+ assert.match(output, /`\n\s+ {
+ const input = 'Blits.Component(\'X\', { template: `` })'
+ const output = await format(input)
+ assert.doesNotMatch(output, /`\n/)
+ assert.ok(output.includes(''))
+ })
+
+ test('blitsCollapseSingleElement: true — multi-line template collapsed when it fits in printWidth', async () => {
+ const input = 'Blits.Component(\'X\', { template: `\n \n` })'
+ const output = await format(input, { blitsCollapseSingleElement: true })
+ assert.doesNotMatch(output, /`\n\s+'))
+ })
+
+ test('blitsCollapseSingleElement: true — does not collapse when template exceeds printWidth', async () => {
+ const input =
+ 'Blits.Component(\'X\', { template: `\n \n` })'
+ const output = await format(input, { blitsCollapseSingleElement: true })
+ assert.match(output, /`\n/)
+ })
+
+ test('single-quoted string template unaffected by blitsCollapseSingleElement', async () => {
+ const input = "Blits.Component('X', { template: '' })"
+ const output = await format(input, { blitsCollapseSingleElement: true })
+ assert.ok(output.includes(''))
+ assert.doesNotMatch(output, /`/)
+ })
+})
+
+describe('format: escape sequences in attribute values', () => {
+ test('\\n in attribute value is preserved as two characters, not a literal newline', async () => {
+ const input = "Blits.Component('X', { template: `` })"
+ const output = await format(input)
+ assert.ok(output.includes(':content="$status.join(\'\\n\')"'), 'escape sequence must be preserved')
+ assert.doesNotMatch(output, /:content="[^"]*\n[^"]*"/, 'literal newline must not appear inside attribute value')
+ })
+
+ test('\\t in attribute value is preserved as two characters', async () => {
+ const input = "Blits.Component('X', { template: `` })"
+ const output = await format(input)
+ assert.ok(output.includes(':content="\'label:\\t\' + $value"'), 'tab escape sequence must be preserved')
+ assert.doesNotMatch(output, /:content="[^"]*\t[^"]*"/, 'literal tab must not appear inside attribute value')
+ })
+
+ test('multiple escape sequences in same attribute are all preserved', async () => {
+ const input = "Blits.Component('X', { template: `` })"
+ const output = await format(input)
+ assert.ok(output.includes(":content=\"'line1:\\n' + 'line2:\\n' + $val\""))
+ assert.doesNotMatch(output, /:content="[^"]*\n[^"]*"/)
+ })
+})
diff --git a/prettier-plugin-blits/tests/parser.test.js b/prettier-plugin-blits/tests/parser.test.js
new file mode 100644
index 0000000..ab12281
--- /dev/null
+++ b/prettier-plugin-blits/tests/parser.test.js
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2023 Comcast Cable Communications Management, LLC
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const { test, describe } = require('node:test')
+const assert = require('node:assert/strict')
+const { parse } = require('../src/blitsParser.js')
+
+describe('parse: AST shape', () => {
+ test('returns root node', () => {
+ const ast = parse('')
+ assert.equal(ast.type, 'root')
+ assert.ok(Array.isArray(ast.children))
+ assert.equal(ast.start, 0)
+ })
+
+ test('single self-closing element', () => {
+ const ast = parse('')
+ assert.equal(ast.children.length, 1)
+ const el = ast.children[0]
+ assert.equal(el.type, 'element')
+ assert.equal(el.tag, 'Element')
+ assert.equal(el.selfClosing, true)
+ assert.deepEqual(el.attrs, [])
+ assert.deepEqual(el.children, [])
+ })
+
+ test('element with attributes', () => {
+ const ast = parse('')
+ const el = ast.children[0]
+ assert.deepEqual(el.attrs, [
+ { name: 'x', value: '10' },
+ { name: 'y', value: '20' },
+ ])
+ })
+
+ test('reactive and event attributes', () => {
+ const ast = parse('')
+ const el = ast.children[0]
+ assert.equal(el.attrs[0].name, ':color')
+ assert.equal(el.attrs[0].value, '$myColor')
+ assert.equal(el.attrs[1].name, '@click')
+ assert.equal(el.attrs[1].value, '$handleClick')
+ })
+
+ test('open/close element with no children', () => {
+ const ast = parse('')
+ const el = ast.children[0]
+ assert.equal(el.selfClosing, false)
+ assert.deepEqual(el.children, [])
+ })
+
+ test('nested elements', () => {
+ const ast = parse('')
+ const el = ast.children[0]
+ assert.equal(el.children.length, 1)
+ assert.equal(el.children[0].tag, 'Text')
+ })
+
+ test('comment node', () => {
+ const ast = parse('')
+ assert.equal(ast.children[0].type, 'comment')
+ assert.ok(ast.children[0].text.includes('a comment'))
+ })
+
+ test('end position equals text length', () => {
+ const text = ''
+ const ast = parse(text)
+ assert.equal(ast.end, text.length)
+ })
+})
+
+describe('parse: errors', () => {
+ test('throws on invalid template', () => {
+ assert.throws(() => parse(''), Error)
+ })
+})