From 57754b35feee55642d42a6a04a98bfa0d9988917 Mon Sep 17 00:00:00 2001
From: Marco Roth
Date: Sun, 9 Nov 2025 01:14:20 +0100
Subject: [PATCH] Formatter: Improve Text Content Formatting
---
.../packages/formatter/src/format-helpers.ts | 62 +---
.../packages/formatter/src/format-printer.ts | 307 +++++++++++++---
.../packages/formatter/test/erb/erb.test.ts | 5 +-
.../packages/formatter/test/erb/if.test.ts | 21 ++
.../packages/formatter/test/html/tags.test.ts | 19 +-
.../formatter/test/html/text-content.test.ts | 327 ++++++++++++++++++
6 files changed, 636 insertions(+), 105 deletions(-)
diff --git a/javascript/packages/formatter/src/format-helpers.ts b/javascript/packages/formatter/src/format-helpers.ts
index 5a75f0853..1c592b4b0 100644
--- a/javascript/packages/formatter/src/format-helpers.ts
+++ b/javascript/packages/formatter/src/format-helpers.ts
@@ -42,7 +42,7 @@ export const FORMATTABLE_ATTRIBUTES: Record = {
export const INLINE_ELEMENTS = new Set([
'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
- 'dfn', 'em', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
+ 'dfn', 'em', 'hr', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
'samp', 'small', 'span', 'strong', 'sub', 'sup',
'tt', 'var', 'del', 'ins', 'mark', 's', 'u', 'time', 'wbr'
])
@@ -139,15 +139,6 @@ export function filterSignificantChildren(body: Node[]): Node[] {
})
}
-/**
- * Filter out empty text nodes and whitespace nodes
- */
-export function filterEmptyNodes(nodes: Node[]): Node[] {
- return nodes.filter(child =>
- !isNode(child, WhitespaceNode) && !(isNode(child, HTMLTextNode) && child.content.trim() === "")
- )
-}
-
/**
* Smart filter that preserves exactly ONE whitespace before herb:disable comments
*/
@@ -220,6 +211,7 @@ export function needsSpaceBetween(currentLine: string, word: string): boolean {
if (isClosingPunctuation(word)) return false
if (lineEndsWithOpeningPunctuation(currentLine)) return false
if (currentLine.endsWith(' ')) return false
+ if (word.startsWith(' ')) return false
if (endsWithERBTag(currentLine) && startsWithERBTag(word)) return false
return true
@@ -335,12 +327,8 @@ export function hasMultilineTextContent(children: Node[]): boolean {
return child.content.includes('\n')
}
- if (isNode(child, HTMLElementNode)) {
- const nestedChildren = filterEmptyNodes(child.body)
-
- if (hasMultilineTextContent(nestedChildren)) {
- return true
- }
+ if (isNode(child, HTMLElementNode) && hasMultilineTextContent(child.body)) {
+ return true
}
}
@@ -357,9 +345,7 @@ export function areAllNestedElementsInline(children: Node[]): boolean {
return false
}
- const nestedChildren = filterEmptyNodes(child.body)
-
- if (!areAllNestedElementsInline(nestedChildren)) {
+ if (!areAllNestedElementsInline(child.body)) {
return false
}
} else if (isAnyOf(child, HTMLDoctypeNode, HTMLCommentNode, isERBControlFlowNode)) {
@@ -451,16 +437,6 @@ export function countAdjacentInlineElements(children: Node[]): number {
return count
}
-/**
- * Determine if we should wrap to the next line
- */
-export function shouldWrapToNextLine(testLine: string, currentLine: string, word: string, wrapWidth: number): boolean {
- if (!currentLine) return false
- if (isClosingPunctuation(word)) return false
-
- return testLine.length >= wrapWidth
-}
-
/**
* Check if a node represents a block-level element
*/
@@ -479,27 +455,25 @@ export function isBlockLevelNode(node: Node): boolean {
}
/**
- * Normalize text by replacing multiple spaces with single space and trim
- * Then split into words
+ * Check if an element is a line-breaking element (br or hr)
*/
-export function normalizeAndSplitWords(text: string): string[] {
- const normalized = text.replace(/\s+/g, ' ')
- return normalized.trim().split(' ')
-}
+export function isLineBreakingElement(node: Node): boolean {
+ if (!isNode(node, HTMLElementNode)) {
+ return false
+ }
-/**
- * Check if text starts with an alphanumeric character (not punctuation)
- */
-export function startsWithAlphanumeric(text: string): boolean {
- const trimmed = text.trim()
- return /^[a-zA-Z0-9]/.test(trimmed)
+ const tagName = getTagName(node)
+
+ return tagName === 'br' || tagName === 'hr'
}
/**
- * Check if text ends with an alphanumeric character (not punctuation)
+ * Normalize text by replacing multiple spaces with single space and trim
+ * Then split into words
*/
-export function endsWithAlphanumeric(text: string): boolean {
- return /[a-zA-Z0-9]$/.test(text)
+export function normalizeAndSplitWords(text: string): string[] {
+ const normalized = text.replace(/\s+/g, ' ')
+ return normalized.trim().split(' ')
}
/**
diff --git a/javascript/packages/formatter/src/format-printer.ts b/javascript/packages/formatter/src/format-printer.ts
index 08a4b1bee..f19f9fe30 100644
--- a/javascript/packages/formatter/src/format-printer.ts
+++ b/javascript/packages/formatter/src/format-printer.ts
@@ -1,5 +1,7 @@
import dedent from "dedent"
+import { Printer, IdentityPrinter } from "@herb-tools/printer"
+
import {
getTagName,
getCombinedAttributeName,
@@ -14,15 +16,11 @@ import {
filterNodes,
} from "@herb-tools/core"
-import { Printer, IdentityPrinter } from "@herb-tools/printer"
-
import {
areAllNestedElementsInline,
buildLineWithWord,
countAdjacentInlineElements,
- endsWithAlphanumeric,
endsWithWhitespace,
- filterEmptyNodes,
filterEmptyNodesForHerbDisable,
filterSignificantChildren,
findPreviousMeaningfulSibling,
@@ -36,14 +34,13 @@ import {
isFrontmatter,
isHerbDisableComment,
isInlineElement,
+ isLineBreakingElement,
isNonWhitespaceNode,
isPureWhitespaceNode,
needsSpaceBetween,
normalizeAndSplitWords,
shouldAppendToLastLine,
shouldPreserveUserSpacing,
- shouldWrapToNextLine,
- startsWithAlphanumeric,
} from "./format-helpers.js"
import {
@@ -57,7 +54,6 @@ import {
} from "./format-helpers.js"
import type {
- ContentUnit,
ContentUnitWithNode,
ElementFormattingAnalysis,
} from "./format-helpers.js"
@@ -646,12 +642,10 @@ export class FormatPrinter extends Printer {
const hasTextFlow = this.isInTextFlowContext(null, children)
if (hasTextFlow) {
- const filteredChildren = filterSignificantChildren(children)
-
const wasInlineMode = this.inlineMode
this.inlineMode = true
- this.visitTextFlowChildren(filteredChildren)
+ this.visitTextFlowChildren(children)
this.inlineMode = wasInlineMode
@@ -720,6 +714,8 @@ export class FormatPrinter extends Printer {
}
visitHTMLElementBody(body: Node[], element: HTMLElementNode) {
+ const tagName = getTagName(element)
+
if (isContentPreserving(element)) {
element.body.map(child => {
if (isNode(child, HTMLElementNode)) {
@@ -748,6 +744,9 @@ export class FormatPrinter extends Printer {
const oldInlineMode = this.inlineMode
const nodesToRender = hasTextFlow ? body : children
+ const hasOnlyTextContent = nodesToRender.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode))
+ const shouldPreserveSpaces = hasOnlyTextContent && isInlineElement(tagName)
+
this.inlineMode = true
const lines = this.capture(() => {
@@ -763,12 +762,17 @@ export class FormatPrinter extends Printer {
}
} else {
const normalizedContent = child.content.replace(/\s+/g, ' ')
- const trimmedContent = normalizedContent.trim()
- if (trimmedContent) {
- this.push(trimmedContent)
- } else if (normalizedContent === ' ') {
- this.push(' ')
+ if (shouldPreserveSpaces && normalizedContent) {
+ this.push(normalizedContent)
+ } else {
+ const trimmedContent = normalizedContent.trim()
+
+ if (trimmedContent) {
+ this.push(trimmedContent)
+ } else if (normalizedContent === ' ') {
+ this.push(' ')
+ }
}
}
} else if (isNode(child, WhitespaceNode)) {
@@ -780,7 +784,10 @@ export class FormatPrinter extends Printer {
})
const content = lines.join('')
- const inlineContent = hasTextFlow ? content.replace(/\s+/g, ' ').trim() : content.trim()
+
+ const inlineContent = shouldPreserveSpaces
+ ? (hasTextFlow ? content.replace(/\s+/g, ' ') : content)
+ : (hasTextFlow ? content.replace(/\s+/g, ' ').trim() : content.trim())
if (inlineContent) {
this.pushToLastLine(inlineContent)
@@ -797,6 +804,7 @@ export class FormatPrinter extends Printer {
let leadingHerbDisableIndex = -1
let firstWhitespaceIndex = -1
let remainingChildren = children
+ let remainingBodyUnfiltered = body
for (let i = 0; i < children.length; i++) {
const child = children[i]
@@ -831,6 +839,20 @@ export class FormatPrinter extends Printer {
return true
})
+
+ remainingBodyUnfiltered = body.filter((_, index) => {
+ if (index === leadingHerbDisableIndex) return false
+
+ if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
+ const child = body[index]
+
+ if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
+ return false
+ }
+ }
+
+ return true
+ })
}
if (leadingHerbDisableComment) {
@@ -852,7 +874,7 @@ export class FormatPrinter extends Printer {
this.withIndent(() => {
if (hasTextFlow) {
- this.visitTextFlowChildren(remainingChildren)
+ this.visitTextFlowChildren(remainingBodyUnfiltered)
} else {
this.visitElementChildren(leadingHerbDisableComment ? remainingChildren : body, element)
}
@@ -1205,8 +1227,7 @@ export class FormatPrinter extends Printer {
const hasTextFlow = this.isInTextFlowContext(null, node.body)
if (hasTextFlow) {
- const children = filterSignificantChildren(node.body)
- this.visitTextFlowChildren(children)
+ this.visitTextFlowChildren(node.body)
} else {
this.visitElementChildren(node.body, null)
}
@@ -1528,8 +1549,8 @@ export class FormatPrinter extends Printer {
const adjacentInlineCount = countAdjacentInlineElements(children)
if (adjacentInlineCount >= 2) {
- this.renderAdjacentInlineElements(children, adjacentInlineCount)
- this.visitRemainingChildren(children, adjacentInlineCount)
+ const { processedIndices } = this.renderAdjacentInlineElements(children, adjacentInlineCount)
+ this.visitRemainingChildren(children, processedIndices)
return
}
@@ -1537,15 +1558,114 @@ export class FormatPrinter extends Printer {
this.buildAndWrapTextFlow(children)
}
+ /**
+ * Wrap remaining words that don't fit on the current line
+ * Returns the wrapped lines with proper indentation
+ */
+ private wrapRemainingWords(words: string[], wrapWidth: number): string[] {
+ const lines: string[] = []
+ let line = ""
+
+ for (const word of words) {
+ const testLine = line + (line ? " " : "") + word
+
+ if (testLine.length > wrapWidth && line) {
+ lines.push(this.indent + line)
+ line = word
+ } else {
+ line = testLine
+ }
+ }
+
+ if (line) {
+ lines.push(this.indent + line)
+ }
+
+ return lines
+ }
+
+ /**
+ * Try to merge text starting with punctuation to inline content
+ * Returns object with merged content and whether processing should stop
+ */
+ private tryMergePunctuationText(inlineContent: string, trimmedText: string, wrapWidth: number): { mergedContent: string, shouldStop: boolean, wrappedLines: string[] } {
+ const combined = inlineContent + trimmedText
+
+ if (combined.length <= wrapWidth) {
+ return {
+ mergedContent: inlineContent + trimmedText,
+ shouldStop: false,
+ wrappedLines: []
+ }
+ }
+
+ const match = trimmedText.match(/^[.!?:;]+/)
+
+ if (!match) {
+ return {
+ mergedContent: inlineContent,
+ shouldStop: false,
+ wrappedLines: []
+ }
+ }
+
+ const punctuation = match[0]
+ const restText = trimmedText.substring(punctuation.length).trim()
+
+ if (!restText) {
+ return {
+ mergedContent: inlineContent + punctuation,
+ shouldStop: false,
+ wrappedLines: []
+ }
+ }
+
+ const words = restText.split(/\s+/)
+ let toMerge = punctuation
+ let mergedWordCount = 0
+
+ for (const word of words) {
+ const testMerge = toMerge + ' ' + word
+
+ if ((inlineContent + testMerge).length <= wrapWidth) {
+ toMerge = testMerge
+ mergedWordCount++
+ } else {
+ break
+ }
+ }
+
+ const mergedContent = inlineContent + toMerge
+
+ if (mergedWordCount >= words.length) {
+ return {
+ mergedContent,
+ shouldStop: false,
+ wrappedLines: []
+ }
+ }
+
+ const remainingWords = words.slice(mergedWordCount)
+ const wrappedLines = this.wrapRemainingWords(remainingWords, wrapWidth)
+
+ return {
+ mergedContent,
+ shouldStop: true,
+ wrappedLines
+ }
+ }
+
/**
* Render adjacent inline elements together on one line
*/
- private renderAdjacentInlineElements(children: Node[], count: number): void {
+ private renderAdjacentInlineElements(children: Node[], count: number): { processedIndices: Set } {
let inlineContent = ""
let processedCount = 0
+ let lastProcessedIndex = -1
+ const processedIndices = new Set()
- for (let i = 0; i < children.length && processedCount < count; i++) {
- const child = children[i]
+ for (let index = 0; index < children.length && processedCount < count; index++) {
+ const child = children[index]
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
continue
@@ -1554,15 +1674,66 @@ export class FormatPrinter extends Printer {
if (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) {
inlineContent += this.renderInlineElementAsString(child)
processedCount++
+ lastProcessedIndex = index
+ processedIndices.add(index)
+
+ if (inlineContent && isLineBreakingElement(child)) {
+ this.pushWithIndent(inlineContent)
+ inlineContent = ""
+ }
} else if (isNode(child, ERBContentNode)) {
inlineContent += this.renderERBAsString(child)
processedCount++
+ lastProcessedIndex = index
+ processedIndices.add(index)
+ }
+ }
+
+ if (lastProcessedIndex >= 0) {
+ for (let index = lastProcessedIndex + 1; index < children.length; index++) {
+ const child = children[index]
+
+ if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
+ continue
+ }
+
+ if (isNode(child, ERBContentNode)) {
+ inlineContent += this.renderERBAsString(child)
+ processedIndices.add(index)
+ continue
+ }
+
+ if (isNode(child, HTMLTextNode)) {
+ const trimmed = child.content.trim()
+
+ if (trimmed && /^[.!?:;]/.test(trimmed)) {
+ const wrapWidth = this.maxLineLength - this.indent.length
+ const result = this.tryMergePunctuationText(inlineContent, trimmed, wrapWidth)
+
+ inlineContent = result.mergedContent
+ processedIndices.add(index)
+
+ if (result.shouldStop) {
+ if (inlineContent) {
+ this.pushWithIndent(inlineContent)
+ }
+
+ result.wrappedLines.forEach(line => this.push(line))
+
+ return { processedIndices }
+ }
+ }
+ }
+
+ break
}
}
if (inlineContent) {
this.pushWithIndent(inlineContent)
}
+
+ return { processedIndices }
}
/**
@@ -1602,16 +1773,15 @@ export class FormatPrinter extends Printer {
/**
* Visit remaining children after processing adjacent inline elements
*/
- private visitRemainingChildren(children: Node[], skipCount: number): void {
- let skipped = 0
+ private visitRemainingChildren(children: Node[], processedIndices: Set): void {
+ for (let index = 0; index < children.length; index++) {
+ const child = children[index]
- for (const child of children) {
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
continue
}
- if (skipped < skipCount) {
- skipped++
+ if (processedIndices.has(index)) {
continue
}
@@ -1688,12 +1858,22 @@ export class FormatPrinter extends Printer {
const words = normalizeAndSplitWords(textNode.content)
if (words.length === 0 || !words[0]) return false
- if (!startsWithAlphanumeric(words[0])) return false
- lastUnit.unit.content += words[0]
+ const firstWord = words[0]
+ const firstChar = firstWord[0]
+
+ if (!/[a-zA-Z0-9.!?:;]/.test(firstChar)) {
+ return false
+ }
+
+ lastUnit.unit.content += firstWord
if (words.length > 1) {
- const remainingText = words.slice(1).join(' ')
+ let remainingText = words.slice(1).join(' ')
+
+ if (endsWithWhitespace(textNode.content)) {
+ remainingText += ' '
+ }
result.push({
unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false },
@@ -1771,10 +1951,14 @@ export class FormatPrinter extends Printer {
private processTextNode(result: ContentUnitWithNode[], children: Node[], child: HTMLTextNode, index: number, lastProcessedIndex: number): void {
const isAtomic = child.content === ' '
- if (!isAtomic && lastProcessedIndex >= 0) {
+ if (!isAtomic && lastProcessedIndex >= 0 && result.length > 0) {
const hasWhitespace = this.hasWhitespaceBeforeNode(children, lastProcessedIndex, index, child)
+ const lastUnit = result[result.length - 1]
+ const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'erb' || lastUnit.unit.type === 'inline')
+ const trimmed = child.content.trim()
+ const startsWithClosingPunct = trimmed.length > 0 && /^[.!?:;]/.test(trimmed)
- if (!hasWhitespace && this.tryMergeTextAfterAtomic(result, child)) {
+ if (lastIsAtomic && (!hasWhitespace || startsWithClosingPunct) && this.tryMergeTextAfterAtomic(result, child)) {
return
}
}
@@ -1831,6 +2015,18 @@ export class FormatPrinter extends Printer {
if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, erbContent, 'erb', child)) {
return true
}
+
+ if (hasWhitespace && result.length > 0) {
+ const lastUnit = result[result.length - 1]
+ const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'inline' || lastUnit.unit.type === 'erb')
+
+ if (lastIsAtomic && !this.lastUnitEndsWithWhitespace(result)) {
+ result.push({
+ unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
+ node: null
+ })
+ }
+ }
}
result.push({
@@ -1852,7 +2048,27 @@ export class FormatPrinter extends Printer {
const child = children[i]
if (isNode(child, WhitespaceNode)) continue
- if (isPureWhitespaceNode(child) && !(isNode(child, HTMLTextNode) && child.content === ' ')) continue
+
+ if (isPureWhitespaceNode(child) && !(isNode(child, HTMLTextNode) && child.content === ' ')) {
+ if (lastProcessedIndex >= 0) {
+ const hasNonWhitespaceAfter = children.slice(i + 1).some(node =>
+ !isNode(node, WhitespaceNode) && !isPureWhitespaceNode(node)
+ )
+
+ if (hasNonWhitespaceAfter) {
+ const previousNode = children[lastProcessedIndex]
+
+ if (!isLineBreakingElement(previousNode)) {
+ result.push({
+ unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
+ node: child
+ })
+ }
+ }
+ }
+
+ continue
+ }
if (isNode(child, HTMLTextNode)) {
this.processTextNode(result, children, child, i, lastProcessedIndex)
@@ -2085,13 +2301,13 @@ export class FormatPrinter extends Printer {
/**
* Try to render a complete element inline including opening tag, children, and closing tag
*/
- private tryRenderInlineFull(node: HTMLElementNode, tagName: string, attributes: HTMLAttributeNode[], children: Node[]): string | null {
+ private tryRenderInlineFull(_node: HTMLElementNode, tagName: string, attributes: HTMLAttributeNode[], children: Node[]): string | null {
let result = `<${tagName}`
result += this.renderAttributesString(attributes)
result += ">"
- const childrenContent = this.tryRenderChildrenInline(children)
+ const childrenContent = this.tryRenderChildrenInline(children, tagName)
if (!childrenContent) return null
@@ -2119,12 +2335,14 @@ export class FormatPrinter extends Printer {
/**
* Try to render just the children inline (without tags)
*/
- private tryRenderChildrenInline(children: Node[]): string | null {
+ private tryRenderChildrenInline(children: Node[], tagName?: string): string | null {
let result = ""
let hasInternalWhitespace = false
+ let addedLeadingSpace = false
const hasHerbDisable = this.hasLeadingHerbDisable(children)
- let addedLeadingSpace = false
+ const hasOnlyTextContent = children.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode))
+ const shouldPreserveSpaces = hasOnlyTextContent && tagName && isInlineElement(tagName)
for (const child of children) {
if (isNode(child, HTMLTextNode)) {
@@ -2134,7 +2352,7 @@ export class FormatPrinter extends Printer {
const trimmedContent = normalizedContent.trim()
if (trimmedContent) {
- if (hasLeadingSpace && result && !result.endsWith(' ')) {
+ if (hasLeadingSpace && (result || shouldPreserveSpaces) && !result.endsWith(' ')) {
result += ' '
}
@@ -2185,11 +2403,11 @@ export class FormatPrinter extends Printer {
}
}
- if (hasHerbDisable && result.startsWith(' ')) {
- return result.trimEnd()
+ if (shouldPreserveSpaces) {
+ return result
}
- if (hasInternalWhitespace) {
+ if (hasHerbDisable && result.startsWith(' ') || hasInternalWhitespace) {
return result.trimEnd()
}
@@ -2234,11 +2452,12 @@ export class FormatPrinter extends Printer {
isNode(child, ERBContentNode) && isHerbDisableComment(child)
)
- return hasHerbDisable ? filterEmptyNodesForHerbDisable(body) : filterEmptyNodes(body)
+ return hasHerbDisable ? filterEmptyNodesForHerbDisable(body) : body
}
private renderElementInline(element: HTMLElementNode): string {
const children = this.getFilteredChildren(element.body)
+
return this.renderChildrenInline(children)
}
diff --git a/javascript/packages/formatter/test/erb/erb.test.ts b/javascript/packages/formatter/test/erb/erb.test.ts
index 8e5eb8e32..a2f505e7f 100644
--- a/javascript/packages/formatter/test/erb/erb.test.ts
+++ b/javascript/packages/formatter/test/erb/erb.test.ts
@@ -294,7 +294,8 @@ describe("@herb-tools/formatter", () => {
This will be the all-in-one home for everything to do with
Hanami,
- Dry and Rom.
+ Dry and
+ Rom.
`)
@@ -328,7 +329,7 @@ describe("@herb-tools/formatter", () => {
Here is some text.
Tel:
- 08-123 456 78
+ 08-123 456 78
`)
diff --git a/javascript/packages/formatter/test/erb/if.test.ts b/javascript/packages/formatter/test/erb/if.test.ts
index 40f2054fe..7df0551af 100644
--- a/javascript/packages/formatter/test/erb/if.test.ts
+++ b/javascript/packages/formatter/test/erb/if.test.ts
@@ -274,6 +274,27 @@ describe("@herb-tools/formatter", () => {
expect(output).toEqual(expected)
})
+ test("if/elsif/else inside ", () => {
+ const input = dedent`
+
+ <% if status == 'active' %>
+ Active
+ <% elsif status == 'pending' %>
+ Pending
+ <% else %>
+ Inactive
+ <% end %>
+
+ `
+
+ const expected = dedent`
+ <% if status == 'active' %>Active<% elsif status == 'pending' %>Pending<% else %>Inactive<% end %>
+ `
+
+ const output = formatter.format(input)
+ expect(output).toEqual(expected)
+ })
+
test("if/elseif inside ", () => {
const input = dedent`
diff --git a/javascript/packages/formatter/test/html/tags.test.ts b/javascript/packages/formatter/test/html/tags.test.ts
index 9c2cf0ad9..97c2fa276 100644
--- a/javascript/packages/formatter/test/html/tags.test.ts
+++ b/javascript/packages/formatter/test/html/tags.test.ts
@@ -86,21 +86,10 @@ describe("@herb-tools/formatter", () => {
`
const result = formatter.format(source)
expect(result).toEqual(dedent`
- One
-
-
-
- Two
-
-
-
- Three
-
-
-
- Four
-
-
+ One
+ Two
+ Three
+ Four
`)
})
diff --git a/javascript/packages/formatter/test/html/text-content.test.ts b/javascript/packages/formatter/test/html/text-content.test.ts
index ad7e5f2fb..a8eef8fd7 100644
--- a/javascript/packages/formatter/test/html/text-content.test.ts
+++ b/javascript/packages/formatter/test/html/text-content.test.ts
@@ -167,4 +167,331 @@ describe("@herb-tools/formatter", () => {
const result = formatter.format(source)
expect(result).toEqual(source)
})
+
+ test("period after ERB tag", () => {
+ const source = dedent`
+ Today is <%= Date.current %>.
+ `
+ const result = formatter.format(source)
+ expect(result).toEqual(source)
+ })
+
+ test("exclamation mark after ERB tag", () => {
+ const source = dedent`
+ Welcome <%= @user.name %>!
+ `
+ const result = formatter.format(source)
+ expect(result).toEqual(source)
+ })
+
+ test("question mark after ERB tag", () => {
+ const source = dedent`
+ Is this <%= @status %>?
+ `
+ const result = formatter.format(source)
+ expect(result).toEqual(source)
+ })
+
+ test("semicolon after ERB tag", () => {
+ const source = dedent`
+ First item: <%= @item %>;
+ `
+ const result = formatter.format(source)
+ expect(result).toEqual(source)
+ })
+
+ test("comma should not merge - maintains space", () => {
+ const source = dedent`
+ Hello <%= @first %>, how are you?
+ `
+ const result = formatter.format(source)
+ expect(result).toEqual(source)
+ })
+
+ test("colon after ERB with newline with
", () => {
+ const input = dedent`
+
+
+ <%= Date.current %>: Hello
+
+ `
+
+ const expected = dedent`
+
+
+ <%= Date.current %>: Hello
+
+ `
+
+ const result = formatter.format(input)
+ expect(result).toEqual(expected)
+ })
+
+ test("colon after ERB with newline with
", () => {
+ const input = dedent`
+
+
+ <%= Date.current %>: Hello
+
+ `
+
+ const expected = dedent`
+
+
+ <%= Date.current %>: Hello
+
+ `
+
+ const result = formatter.format(input)
+ expect(result).toEqual(expected)
+ })
+
+ test("colon after ERB with newline (after
)", () => {
+ const result = formatter.format(dedent`
+
+
+
+
+
+ Bold Heading:
+ <%= Date.current %>: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
+ magna aliqua.
+
+
+
+
+ `)
+
+ expect(result).toEqual(dedent`
+
+
+
+
+
+ Bold Heading:
+ <%= Date.current %>: Lorem ipsum dolor sit amet, consectetur adipiscing
+ elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+
+
+
+
+ `)
+ })
+
+ test("multiple inline elements with punctuation preserve spacing", () => {
+ const result = formatter.format(dedent`
+ Visit our store; buy great products!
+ `)
+
+ expect(result).toEqual(dedent`
+
+ Visit our store; buy great products!
+
+ `)
+ })
+
+ test("inline element at line end with punctuation on next line", () => {
+ const result = formatter.format(dedent`
+
+ Check this
+ : it works!
+
+ `)
+
+ expect(result).toEqual(dedent`
+ Check this : it works!
+ `)
+ })
+
+ test("ERB between inline elements with trailing punctuation", () => {
+ const result = formatter.format(dedent`
+ Hello world <%= @greeting %>!
+ `)
+
+ expect(result).toEqual(dedent`
+ Hello world <%= @greeting %>!
+ `)
+ })
+
+ test("semicolon after inline element in long text", () => {
+ const result = formatter.format(dedent`
+ Download
the app; install quickly; then restart your
device.
+ `)
+
+ expect(result).toEqual(dedent`
+
+ Download
the app; install quickly; then restart your
+
device.
+
+ `)
+ })
+
+ test("exclamation after ERB following inline element", () => {
+ const result = formatter.format(dedent`
+ See bold text <%= @value %>!
+ `)
+
+ expect(result).toEqual(dedent`
+ See bold text <%= @value %>!
+ `)
+ })
+
+ test("question mark after nested inline elements", () => {
+ const result = formatter.format(dedent`
+ Is this correct?
+ `)
+
+ expect(result).toEqual(dedent`
+ Is this correct?
+ `)
+ })
+
+ test("long text with strong and em in between", () => {
+ const result = formatter.format(dedent`
+ This is a super long text before the strong and em element to check if this works, even if there's a long text after the strong and em element!
+ `)
+
+ expect(result).toEqual(dedent`
+
+ This is a super long text before the strong and em element to check if
+ this works, even if there's a long text after the
+ strong and em element!
+
+ `)
+ })
+
+ test("ERB block with non-output tag followed by text without space", () => {
+ const source = dedent`
+ <%= link_to "/" do %>
+ <% icon("icon") %>can not insert whitespace here
+ <% end %>
+ `
+
+ const result = formatter.format(source)
+ expect(result).toEqual(source)
+ })
+
+ test("text with hyphen before inline bold element preserves no-space boundary", () => {
+ const source = dedent`
+
+ This is a div where we still can assume that whitespace can be inserted-infront or after of this bold you can not insert whitespace. Next senctence.
+
+ `
+
+ const result = formatter.format(source)
+ expect(result).toEqual(dedent`
+
+ This is a div where we still can assume that whitespace can be
+ inserted-infront or after of this bold you can not insert whitespace.
+ Next senctence.
+
+ `)
+ })
+
+ // TODO: we need to wait for the parser to transform this as a HTMLElementNode
+ test("ERB block tag with inline content should stay on one line", () => {
+ const source = dedent`
+ <%= tag.span do %>This should stay on one line<% end %>
+ `
+
+ const result = formatter.format(source)
+ // TODO: expect(result).toEqual(source)
+
+ expect(result).toEqual(dedent`
+ <%= tag.span do %>
+ This should stay on one line
+ <% end %>
+ `)
+ })
+
+ test("multiline span with text collapses to inline with spaces", () => {
+ const source = dedent`
+
+ And on the other hand one can not remove whitespace entirely
+
+ `
+
+ const result = formatter.format(source)
+ expect(result).toEqual(dedent`
+ And on the other hand one can not remove whitespace entirely
+ `)
+ })
+
+ test("inline span with text content on single line preserves format", () => {
+ const source = dedent`
+ And on the other hand one can not remove whitespace entirely
+ `
+
+ const result = formatter.format(source)
+ expect(result).toEqual(source)
+ })
+
+ test("inline span with leading and trailing spaces preserves them", () => {
+ const source = dedent`
+ And on the other hand one can not remove whitespace entirely
+ `
+
+ const result = formatter.format(source)
+ expect(result).toEqual(source)
+ })
+
+ test("div with multiline text preserves leading and trailing whitespace", () => {
+ const source = dedent`
+
+ Here the whitespace will not be removed
+
+ `
+
+ const result = formatter.format(source)
+ expect(result).toEqual(source)
+ })
+
+ test("div with inline text content preserves format", () => {
+ const source = dedent`
+ Here the whitespace will not be removed
+ `
+
+ const result = formatter.format(source)
+ expect(result).toEqual(source)
+ })
+
+ test("div with leading and trailing spaces trims them for inline content", () => {
+ const source = dedent`
+ Here the whitespace will not be removed
+ `
+
+ const result = formatter.format(source)
+ expect(result).toEqual(dedent`
+ Here the whitespace will not be removed
+ `)
+ })
+
+ test("inline bold with ERB collapses when alone but preserves when adjacent", () => {
+ const source = dedent`
+
+ <%= a_thing %> <%= another_thing %>
+
+
+
+ <%= a_thing %> <%= another_thing %>a
+
+
+
+ <%= a_thing %> <%= another_thing %>
+ <%= a_thing %> <%= another_thing %>a
+
+ `
+
+ const result = formatter.format(source)
+ expect(result).toEqual(dedent`
+ <%= a_thing %> <%= another_thing %>
+
+ <%= a_thing %> <%= another_thing %>a
+
+
+ <%= a_thing %> <%= another_thing %>
+ <%= a_thing %> <%= another_thing %>a
+
+ `)
+ })
})