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 +

+ `) + }) })