From c07a4d3062cbd8f9eaea41d4773a72d56930a688 Mon Sep 17 00:00:00 2001 From: Ugur Aslan Date: Mon, 23 Mar 2026 17:15:38 +0100 Subject: [PATCH 01/12] initial prettier plugin --- prettier-plugin-blits/README.md | 146 ++++++ prettier-plugin-blits/package.json | 33 ++ prettier-plugin-blits/src/blitsParser.js | 100 ++++ prettier-plugin-blits/src/embed.js | 83 +++ prettier-plugin-blits/src/index.js | 69 +++ prettier-plugin-blits/src/parser.cjs | 557 +++++++++++++++++++++ prettier-plugin-blits/src/printer.js | 80 +++ prettier-plugin-blits/tests/format.test.js | 180 +++++++ prettier-plugin-blits/tests/parser.test.js | 90 ++++ 9 files changed, 1338 insertions(+) create mode 100644 prettier-plugin-blits/README.md create mode 100644 prettier-plugin-blits/package.json create mode 100644 prettier-plugin-blits/src/blitsParser.js create mode 100644 prettier-plugin-blits/src/embed.js create mode 100644 prettier-plugin-blits/src/index.js create mode 100644 prettier-plugin-blits/src/parser.cjs create mode 100644 prettier-plugin-blits/src/printer.js create mode 100644 prettier-plugin-blits/tests/format.test.js create mode 100644 prettier-plugin-blits/tests/parser.test.js diff --git a/prettier-plugin-blits/README.md b/prettier-plugin-blits/README.md new file mode 100644 index 0000000..d9306e0 --- /dev/null +++ b/prettier-plugin-blits/README.md @@ -0,0 +1,146 @@ +# Prettier Plugin for Blits + +Formats Blits template strings in JavaScript and TypeScript files. + +Blits components define their UI in a `template` string inside `Blits.Component()` or `Blits.Application()`. This plugin teaches Prettier to format those templates — indenting nested elements, breaking long attribute lists across lines, and keeping short templates inline. + +## Requirements + +- Node.js 18+ +- Prettier 3.x + +## Installation + +```bash +npm install --save-dev @lightningjs/prettier-plugin-blits +``` + +### With a `.prettierrc` file + +Add the plugin to your Prettier config. If you have other plugins, keep them alongside it: + +```json +{ + "plugins": ["@lightningjs/prettier-plugin-blits"] +} +``` + +### With `eslint-plugin-prettier` + +If your project runs Prettier through ESLint via `eslint-plugin-prettier`, add the plugin to the inline options in your ESLint config instead: + +```js +// .eslintrc.cjs +rules: { + 'prettier/prettier': [ + 'error', + { + singleQuote: true, + semi: false, + // ... your other prettier options + plugins: ['@lightningjs/prettier-plugin-blits'], + }, + ], +} +``` + +> **Note:** `eslint-plugin-prettier` v5+ is required for Prettier 3 compatibility. If you are upgrading from Prettier 2, also update `eslint-plugin-prettier` to `^5.0.0` and `eslint-config-prettier` to `^9.0.0`. + +## Formatting rules + +### Where it applies + +Only the `template` property value inside `Blits.Component()` or `Blits.Application()` calls. Everything else in your file is formatted normally by Prettier. Template literals that contain `${...}` interpolations are left untouched. + +### Inline vs multi-line + +Short templates that fit within `printWidth` stay on one line: + +```js +template: `` +``` + +Longer templates break to multi-line with the content indented: + +```js +template: ` + + + +` +``` + +### Attribute wrapping + +When a tag's attributes fit within `printWidth`, they stay on one line. When they don't, each attribute gets its own indented line and `/>` moves to the next line: + +```js +// fits on one line — stays inline + + +// too long — each attribute on its own line + +``` + +### Children + +Child elements are indented by `tabWidth` (default: 2) relative to their parent: + +```js +template: ` + + + + + +` +``` + +### What is never changed + +- **Attribute values** — reactive bindings (`:color="$myColor"`), event handlers (`@loaded="$onLoad"`), `:for` expressions, `$variable` references, arrow functions, and `:transition` objects all pass through exactly as written +- **Attribute order** — attributes are never reordered or sorted +- **Comments** — HTML comments (``) are preserved as-is + +### 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 also exposes its own formatting rules. Each rule can be enabled or disabled independently: + +| Option | Default | Description | +|---|---|---| +| `blitsWrapAttributes` | `true` | Wrap element attributes to individual lines when the tag exceeds `printWidth`. Set to `false` to always keep attributes inline. | +| `blitsClosingBacktick` | `"newline"` | Position of the closing backtick in multi-line templates. `"newline"` puts it on its own line; `"inline"` puts it at the end of the last content line. | + +**`blitsClosingBacktick: "newline"` (default):** +```js +template: ` + + + +` +``` + +**`blitsClosingBacktick: "inline"`:** +```js +template: ` + + + ` +``` + +## License + +Apache 2.0 — see [LICENSE](../LICENSE) diff --git a/prettier-plugin-blits/package.json b/prettier-plugin-blits/package.json new file mode 100644 index 0000000..1be482c --- /dev/null +++ b/prettier-plugin-blits/package.json @@ -0,0 +1,33 @@ +{ + "name": "@lightningjs/prettier-plugin-blits", + "version": "0.1.0", + "author": "Ugur Aslan ", + "license": "Apache-2.0", + "description": "Prettier plugin for Blits template formatting", + "main": "src/index.js", + "scripts": { + "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": ">=16.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..e108d80 --- /dev/null +++ b/prettier-plugin-blits/src/blitsParser.js @@ -0,0 +1,100 @@ +/* + * 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) { + 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: 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() + } + + if (stack.length === 0) { + roots.push(hierarchicalNode) + } else { + stack[stack.length - 1].children.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) { + 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) + 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..0d45fad --- /dev/null +++ b/prettier-plugin-blits/src/embed.js @@ -0,0 +1,83 @@ +/* + * 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.cooked : 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 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..64d3ec6 --- /dev/null +++ b/prettier-plugin-blits/src/index.js @@ -0,0 +1,69 @@ +/* + * 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.', + }, + 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('') { + 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 . 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..8f95f23 --- /dev/null +++ b/prettier-plugin-blits/src/printer.js @@ -0,0 +1,80 @@ +/* + * 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, join, ifBreak } = doc.builders + +function printAttrs(attrs, wrap) { + if (!attrs || attrs.length === 0) return [] + return attrs.map((a) => [wrap ? line : ' ', `${a.name}="${a.value}"`]) +} + +function printElement(path, options, print) { + const node = path.getValue() + const wrap = options.blitsWrapAttributes + const attrs = printAttrs(node.attrs, wrap) + const hasChildren = node.children && node.children.length > 0 + + if (node.tag === 'empty') { + if (!hasChildren) return '<>' + return ['<>', indent([hardline, join(hardline, path.map(print, 'children'))]), hardline, ''] + } + + if (node.selfClosing) { + if (wrap) { + return group(['<', node.tag, indent(attrs), ifBreak('', ' '), softline, '/>']) + } + return ['<', node.tag, attrs, ' />'] + } + + const openTag = wrap + ? group(['<', node.tag, indent(attrs), softline, '>']) + : ['<', node.tag, attrs, '>'] + + if (!hasChildren) { + return [openTag, ''] + } + + return [ + openTag, + indent([hardline, join(hardline, path.map(print, 'children'))]), + hardline, + '', + ] +} + +function print(path, options, print) { + const node = path.getValue() + + switch (node.type) { + case 'root': + return join(hardline, path.map(print, 'children')) + case 'element': + return printElement(path, options, print) + case 'comment': + return node.text + 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..363ff22 --- /dev/null +++ b/prettier-plugin-blits/tests/format.test.js @@ -0,0 +1,180 @@ +/* + * 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: 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: 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) + }) +}) 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) + }) +}) From 20bdc2d926a27c45750b2bb5c133d40ee54aec77 Mon Sep 17 00:00:00 2001 From: Ugur Aslan Date: Mon, 23 Mar 2026 17:16:03 +0100 Subject: [PATCH 02/12] package-lock update --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index e69e54a..a969b12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7081,7 +7081,7 @@ "node": ">=16.0.0" }, "peerDependencies": { - "prettier": ">=2.0.0" + "prettier": ">=3.0.0" } }, "vscode-extension": { From 3386bc365cdbd4ba66c5dcc7fd4f18f93f072fb3 Mon Sep 17 00:00:00 2001 From: Ugur Aslan Date: Thu, 2 Apr 2026 13:15:36 +0200 Subject: [PATCH 03/12] [prettier] feature blitsSelfClosingTags --- prettier-plugin-blits/src/index.js | 7 +++++++ prettier-plugin-blits/src/printer.js | 6 ++++++ prettier-plugin-blits/tests/format.test.js | 14 ++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/prettier-plugin-blits/src/index.js b/prettier-plugin-blits/src/index.js index 64d3ec6..3d154f4 100644 --- a/prettier-plugin-blits/src/index.js +++ b/prettier-plugin-blits/src/index.js @@ -31,6 +31,13 @@ const options = { description: 'Wrap element attributes to individual lines when the tag exceeds printWidth. Set to false to keep all attributes inline.', }, + 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.', + }, blitsClosingBacktick: { type: 'choice', category: 'Blits', diff --git a/prettier-plugin-blits/src/printer.js b/prettier-plugin-blits/src/printer.js index 8f95f23..2c48526 100644 --- a/prettier-plugin-blits/src/printer.js +++ b/prettier-plugin-blits/src/printer.js @@ -47,6 +47,12 @@ function printElement(path, options, print) { : ['<', node.tag, attrs, '>'] if (!hasChildren) { + if (options.blitsSelfClosingTags) { + if (wrap) { + return group(['<', node.tag, indent(attrs), ifBreak('', ' '), softline, '/>']) + } + return ['<', node.tag, attrs, ' />'] + } return [openTag, ''] } diff --git a/prettier-plugin-blits/tests/format.test.js b/prettier-plugin-blits/tests/format.test.js index 363ff22..2d67535 100644 --- a/prettier-plugin-blits/tests/format.test.js +++ b/prettier-plugin-blits/tests/format.test.js @@ -159,6 +159,20 @@ describe('format: blitsWrapAttributes option', () => { }) }) +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 = From 8b948d3e6cffa38b0fc6190f2e259e6c6cc8a82f Mon Sep 17 00:00:00 2001 From: Ugur Aslan Date: Thu, 2 Apr 2026 13:17:41 +0200 Subject: [PATCH 04/12] [prettier] feature blitsNormalizeComments --- prettier-plugin-blits/src/index.js | 7 ++++++ prettier-plugin-blits/src/printer.js | 7 ++++-- prettier-plugin-blits/tests/format.test.js | 26 ++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/prettier-plugin-blits/src/index.js b/prettier-plugin-blits/src/index.js index 3d154f4..c971d64 100644 --- a/prettier-plugin-blits/src/index.js +++ b/prettier-plugin-blits/src/index.js @@ -31,6 +31,13 @@ const options = { description: 'Wrap element attributes to individual lines when the tag exceeds printWidth. Set to false to keep all attributes inline.', }, + 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', diff --git a/prettier-plugin-blits/src/printer.js b/prettier-plugin-blits/src/printer.js index 2c48526..3f655fe 100644 --- a/prettier-plugin-blits/src/printer.js +++ b/prettier-plugin-blits/src/printer.js @@ -74,8 +74,11 @@ function print(path, options, print) { return join(hardline, path.map(print, 'children')) case 'element': return printElement(path, options, print) - case 'comment': - return node.text + case 'comment': { + if (!options.blitsNormalizeComments) return node.text + const inner = node.text.replace(/^` + } case 'text': return node.value default: diff --git a/prettier-plugin-blits/tests/format.test.js b/prettier-plugin-blits/tests/format.test.js index 2d67535..e2ae885 100644 --- a/prettier-plugin-blits/tests/format.test.js +++ b/prettier-plugin-blits/tests/format.test.js @@ -102,6 +102,32 @@ describe('format: comments', () => { }) }) +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('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: string literal template', () => { test('single-quoted string formats inline', async () => { const input = "Blits.Component('X', { template: '' })" From 1b76b3b32035c0f69a26a374bcbf55d90912ee84 Mon Sep 17 00:00:00 2001 From: Ugur Aslan Date: Thu, 2 Apr 2026 13:35:23 +0200 Subject: [PATCH 05/12] [prettier] feature blitsTrimAttributeValues --- prettier-plugin-blits/src/index.js | 6 ++++++ prettier-plugin-blits/src/printer.js | 9 ++++++--- prettier-plugin-blits/tests/format.test.js | 23 ++++++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/prettier-plugin-blits/src/index.js b/prettier-plugin-blits/src/index.js index c971d64..c6d91b6 100644 --- a/prettier-plugin-blits/src/index.js +++ b/prettier-plugin-blits/src/index.js @@ -31,6 +31,12 @@ const options = { description: 'Wrap element attributes to individual lines when the tag exceeds printWidth. Set to false to keep all attributes inline.', }, + blitsTrimAttributeValues: { + type: 'boolean', + category: 'Blits', + default: true, + description: 'Trim leading/trailing whitespace from attribute values.', + }, blitsNormalizeComments: { type: 'boolean', category: 'Blits', diff --git a/prettier-plugin-blits/src/printer.js b/prettier-plugin-blits/src/printer.js index 3f655fe..c9c25bb 100644 --- a/prettier-plugin-blits/src/printer.js +++ b/prettier-plugin-blits/src/printer.js @@ -19,15 +19,18 @@ const { doc } = require('prettier') const { hardline, softline, line, group, indent, join, ifBreak } = doc.builders -function printAttrs(attrs, wrap) { +function printAttrs(attrs, wrap, options) { if (!attrs || attrs.length === 0) return [] - return attrs.map((a) => [wrap ? line : ' ', `${a.name}="${a.value}"`]) + 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) + const attrs = printAttrs(node.attrs, wrap, options) const hasChildren = node.children && node.children.length > 0 if (node.tag === 'empty') { diff --git a/prettier-plugin-blits/tests/format.test.js b/prettier-plugin-blits/tests/format.test.js index e2ae885..7ec0247 100644 --- a/prettier-plugin-blits/tests/format.test.js +++ b/prettier-plugin-blits/tests/format.test.js @@ -102,6 +102,29 @@ describe('format: comments', () => { }) }) +describe('format: blitsTrimAttributeValues option', () => { + 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: `` })" From 7acc9cd4b9e2be8280099cb96da8a0aa7a43225d Mon Sep 17 00:00:00 2001 From: Ugur Aslan Date: Thu, 2 Apr 2026 15:44:42 +0200 Subject: [PATCH 06/12] [prettier] blitsPreserveBlankLines & blitsBracketSameLine --- prettier-plugin-blits/src/blitsParser.js | 16 ++++---- prettier-plugin-blits/src/index.js | 13 +++++++ prettier-plugin-blits/src/printer.js | 29 +++++++++------ prettier-plugin-blits/tests/format.test.js | 43 ++++++++++++++++++++++ 4 files changed, 82 insertions(+), 19 deletions(-) diff --git a/prettier-plugin-blits/src/blitsParser.js b/prettier-plugin-blits/src/blitsParser.js index e108d80..46b25c3 100644 --- a/prettier-plugin-blits/src/blitsParser.js +++ b/prettier-plugin-blits/src/blitsParser.js @@ -19,7 +19,7 @@ // Kept as a local copy to avoid a cross-package runtime dependency const parseTemplate = require('./parser.cjs') -function buildTree(flatTree) { +function buildTree(flatTree, text) { const nodes = Object.values(flatTree) const stack = [] const roots = [] @@ -67,11 +67,12 @@ function buildTree(flatTree) { stack.pop() } - if (stack.length === 0) { - roots.push(hierarchicalNode) - } else { - stack[stack.length - 1].children.push(hierarchicalNode) + const targetArray = stack.length === 0 ? roots : stack[stack.length - 1].children + const prev = targetArray[targetArray.length - 1] + if (prev && prev.end != null && text && text.slice(prev.end, node.start).includes('\n\n')) { + hierarchicalNode.blankBefore = true } + targetArray.push(hierarchicalNode) if (hierarchicalNode.type === 'element' && !hierarchicalNode.selfClosing) { hierarchicalNode._level = node.level @@ -88,12 +89,13 @@ function buildTree(flatTree) { return roots } -function parse(text) { +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) + const sourceText = options?.blitsPreserveBlankLines !== false ? text : null + const roots = buildTree(result.tree, sourceText) return { type: 'root', children: roots, start: 0, end: text.length } } diff --git a/prettier-plugin-blits/src/index.js b/prettier-plugin-blits/src/index.js index c6d91b6..798489a 100644 --- a/prettier-plugin-blits/src/index.js +++ b/prettier-plugin-blits/src/index.js @@ -31,6 +31,19 @@ const options = { description: 'Wrap element attributes to individual lines when the tag exceeds printWidth. Set to false to keep all attributes inline.', }, + blitsBracketSameLine: { + 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', diff --git a/prettier-plugin-blits/src/printer.js b/prettier-plugin-blits/src/printer.js index c9c25bb..c105d07 100644 --- a/prettier-plugin-blits/src/printer.js +++ b/prettier-plugin-blits/src/printer.js @@ -19,6 +19,13 @@ const { doc } = require('prettier') const { hardline, softline, line, group, indent, join, 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) => { @@ -35,7 +42,8 @@ function printElement(path, options, print) { if (node.tag === 'empty') { if (!hasChildren) return '<>' - return ['<>', indent([hardline, join(hardline, path.map(print, 'children'))]), hardline, ''] + const emptyChildDocs = joinChildren(node.children, path.map(print, 'children')) + return ['<>', indent([hardline, emptyChildDocs]), hardline, ''] } if (node.selfClosing) { @@ -45,8 +53,9 @@ function printElement(path, options, print) { return ['<', node.tag, attrs, ' />'] } + const closingAngle = options.blitsBracketSameLine ? '>' : [softline, '>'] const openTag = wrap - ? group(['<', node.tag, indent(attrs), softline, '>']) + ? group(['<', node.tag, indent(attrs), closingAngle]) : ['<', node.tag, attrs, '>'] if (!hasChildren) { @@ -59,22 +68,18 @@ function printElement(path, options, print) { return [openTag, ''] } - return [ - openTag, - indent([hardline, join(hardline, path.map(print, 'children'))]), - hardline, - '', - ] + const childDocs = joinChildren(node.children, path.map(print, 'children')) + return [openTag, indent([hardline, childDocs]), hardline, ''] } function print(path, options, print) { const node = path.getValue() switch (node.type) { - case 'root': - return join(hardline, path.map(print, 'children')) + case 'root': { + const rootNode = path.getValue() + return joinChildren(rootNode.children, path.map(print, 'children')) + } case 'element': return printElement(path, options, print) case 'comment': { diff --git a/prettier-plugin-blits/tests/format.test.js b/prettier-plugin-blits/tests/format.test.js index 7ec0247..d6b85b0 100644 --- a/prettier-plugin-blits/tests/format.test.js +++ b/prettier-plugin-blits/tests/format.test.js @@ -102,6 +102,49 @@ describe('format: comments', () => { }) }) +describe('format: blitsBracketSameLine option', () => { + const longTag = + "Blits.Component('X', { template: `` })" + + test('false (default) — closing > on its own line', async () => { + const output = await format(longTag, { blitsBracketSameLine: false }) + assert.match(output, /\n\s+>/) + }) + + test('true — closing > on last attribute line', async () => { + const output = await format(longTag, { blitsBracketSameLine: true }) + assert.match(output, /mountY="0\.5">/) + assert.doesNotMatch(output, /\n\s+>/) + }) +}) + +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: `` })" + 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: `` })" From 8869affa99f32e115daf3a5843eb0228e23f9d1d Mon Sep 17 00:00:00 2001 From: Ugur Aslan Date: Thu, 9 Apr 2026 13:01:38 +0200 Subject: [PATCH 07/12] [prettier] multiple fixes and improvements --- prettier-plugin-blits/README.md | 66 ++++++++++++++++- prettier-plugin-blits/package.json | 2 +- prettier-plugin-blits/src/blitsParser.js | 9 +-- prettier-plugin-blits/src/embed.js | 2 +- prettier-plugin-blits/src/index.js | 2 +- prettier-plugin-blits/src/printer.js | 6 +- prettier-plugin-blits/tests/format.test.js | 86 ++++++++++++++++++++-- 7 files changed, 158 insertions(+), 15 deletions(-) diff --git a/prettier-plugin-blits/README.md b/prettier-plugin-blits/README.md index d9306e0..5461f16 100644 --- a/prettier-plugin-blits/README.md +++ b/prettier-plugin-blits/README.md @@ -102,11 +102,49 @@ template: ` ` ``` +### Blank lines + +Blank lines between sibling elements are preserved. Multiple consecutive blank lines are collapsed to one: + +```js +template: ` + + + + + + +