Skip to content

Commit 2584e82

Browse files
authored
Formatter: Fix punctuation separating from ERB tags in mixed content (#1266)
Resolves #931
1 parent 9c52f1a commit 2584e82

File tree

2 files changed

+183
-0
lines changed

2 files changed

+183
-0
lines changed

javascript/packages/formatter/src/format-printer.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,6 +1099,117 @@ export class FormatPrinter extends Printer {
10991099
})
11001100
}
11011101

1102+
/**
1103+
* Check if there's a blank line (double newline) in the nodes at the given index
1104+
*/
1105+
private hasBlankLineBetween(body: Node[], index: number): boolean {
1106+
for (let lookbackIndex = index - 1; lookbackIndex >= 0 && lookbackIndex >= index - 2; lookbackIndex--) {
1107+
const node = body[lookbackIndex]
1108+
1109+
if (isNode(node, HTMLTextNode) && node.content.includes('\n\n')) {
1110+
return true
1111+
}
1112+
1113+
if (isNode(node, WhitespaceNode)) {
1114+
continue
1115+
}
1116+
1117+
break
1118+
}
1119+
1120+
for (let lookaheadIndex = index; lookaheadIndex < body.length && lookaheadIndex <= index + 1; lookaheadIndex++) {
1121+
const node = body[lookaheadIndex]
1122+
1123+
if (isNode(node, HTMLTextNode) && node.content.includes('\n\n')) {
1124+
return true
1125+
}
1126+
1127+
if (isNode(node, WhitespaceNode)) {
1128+
continue
1129+
}
1130+
1131+
break
1132+
}
1133+
1134+
return false
1135+
}
1136+
1137+
/**
1138+
* Check if a node is part of a text flow run (text, ERB, or inline element)
1139+
*/
1140+
private isTextFlowNode(node: Node): boolean {
1141+
if (isNode(node, ERBContentNode)) return true
1142+
if (isNode(node, HTMLTextNode) && node.content.trim() !== "") return true
1143+
if (isNode(node, HTMLElementNode) && isInlineElement(getTagName(node))) return true
1144+
1145+
return false
1146+
}
1147+
1148+
/**
1149+
* Check if a node is whitespace that can appear within a text flow run
1150+
*/
1151+
private isTextFlowWhitespace(node: Node): boolean {
1152+
if (isNode(node, WhitespaceNode)) return true
1153+
if (isNode(node, HTMLTextNode) && node.content.trim() === "" && !node.content.includes('\n\n')) return true
1154+
1155+
return false
1156+
}
1157+
1158+
/**
1159+
* Collect a run of text flow nodes starting at the given index.
1160+
* Returns the nodes in the run and the index after the last node.
1161+
*/
1162+
private collectTextFlowRun(body: Node[], startIndex: number): { nodes: Node[], endIndex: number } | null {
1163+
const nodes: Node[] = []
1164+
let index = startIndex
1165+
let textFlowCount = 0
1166+
1167+
while (index < body.length) {
1168+
const child = body[index]
1169+
1170+
if (this.isTextFlowNode(child)) {
1171+
nodes.push(child)
1172+
textFlowCount++
1173+
index++
1174+
} else if (this.isTextFlowWhitespace(child)) {
1175+
let hasMoreTextFlow = false
1176+
1177+
for (let lookaheadIndex = index + 1; lookaheadIndex < body.length; lookaheadIndex++) {
1178+
if (this.isTextFlowNode(body[lookaheadIndex])) {
1179+
hasMoreTextFlow = true
1180+
break
1181+
}
1182+
1183+
if (this.isTextFlowWhitespace(body[lookaheadIndex])) {
1184+
continue
1185+
}
1186+
1187+
break
1188+
}
1189+
1190+
if (hasMoreTextFlow) {
1191+
nodes.push(child)
1192+
index++
1193+
} else {
1194+
break
1195+
}
1196+
} else {
1197+
break
1198+
}
1199+
}
1200+
1201+
if (textFlowCount >= 2) {
1202+
const hasText = nodes.some(node => isNode(node, HTMLTextNode) && node.content.trim() !== "")
1203+
const hasAtomicContent = nodes.some(node => isNode(node, ERBContentNode) || (isNode(node, HTMLElementNode) && isInlineElement(getTagName(node))))
1204+
1205+
if (hasText && hasAtomicContent) {
1206+
return { nodes, endIndex: index }
1207+
}
1208+
}
1209+
1210+
return null
1211+
}
1212+
11021213
/**
11031214
* Visit element children with intelligent spacing logic
11041215
*
@@ -1130,6 +1241,41 @@ export class FormatPrinter extends Printer {
11301241

11311242
if (!isNonWhitespaceNode(child)) continue
11321243

1244+
if (this.isTextFlowNode(child)) {
1245+
const run = this.collectTextFlowRun(body, index)
1246+
1247+
if (run) {
1248+
if (lastMeaningfulNode && !hasHandledSpacing) {
1249+
const hasBlankLineBefore = this.hasBlankLineBetween(body, index)
1250+
1251+
if (hasBlankLineBefore) {
1252+
this.push("")
1253+
}
1254+
}
1255+
1256+
this.visitTextFlowChildren(run.nodes)
1257+
1258+
const lastRunNode = run.nodes[run.nodes.length - 1]
1259+
const hasBlankLineInTrailing = isNode(lastRunNode, HTMLTextNode) && lastRunNode.content.includes('\n\n')
1260+
const hasBlankLineAfter = hasBlankLineInTrailing || this.hasBlankLineBetween(body, run.endIndex)
1261+
1262+
if (hasBlankLineAfter) {
1263+
this.push("")
1264+
hasHandledSpacing = true
1265+
}
1266+
1267+
lastMeaningfulNode = run.nodes[run.nodes.length - 1]
1268+
1269+
if (!hasBlankLineAfter) {
1270+
hasHandledSpacing = false
1271+
}
1272+
1273+
index = run.endIndex - 1
1274+
1275+
continue
1276+
}
1277+
}
1278+
11331279
let hasTrailingHerbDisable = false
11341280

11351281
if (isNode(child, HTMLElementNode) && child.close_tag) {

javascript/packages/formatter/test/erb/whitespace-formatting.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,43 @@ describe("ERB whitespace formatting", () => {
495495
})
496496
})
497497

498+
describe("issue 931: punctuation after ERB link_to tags", () => {
499+
test("issue 931: keeps commas and periods attached to ERB link_to tags (@adrianthedev)", () => {
500+
const formatter120 = new Formatter(Herb, { indentWidth: 2, maxLineLength: 120 })
501+
502+
const source = dedent`
503+
<div class="py-6 min-h-24">
504+
<div class="px-6 space-y-4">
505+
<h3>What a nice new tool</h3>
506+
507+
Go to <%= link_to "new comment", app.new_resources_comment_path %>, <%= link_to 'the first user', app.resources_user_path(1) %>, or <%= link_to 'hey on main app', main_app.hey_path %>.
508+
509+
<p>
510+
You can edit this file here <code class="p-1 rounded-sm bg-gray-500 text-white text-sm">app/views/app/tools/custom_tool.html.erb</code>.
511+
</p>
512+
</div>
513+
</div>
514+
`
515+
const result = formatter120.format(source)
516+
517+
expect(result).toBe(dedent`
518+
<div class="py-6 min-h-24">
519+
<div class="px-6 space-y-4">
520+
<h3>What a nice new tool</h3>
521+
522+
Go to <%= link_to "new comment", app.new_resources_comment_path %>,
523+
<%= link_to 'the first user', app.resources_user_path(1) %>, or <%= link_to 'hey on main app', main_app.hey_path %>.
524+
525+
<p>
526+
You can edit this file here
527+
<code class="p-1 rounded-sm bg-gray-500 text-white text-sm">app/views/app/tools/custom_tool.html.erb</code>.
528+
</p>
529+
</div>
530+
</div>
531+
`)
532+
})
533+
})
534+
498535
describe("shared utility validation", () => {
499536
test("demonstrates consistent ERB content formatting where it applies", () => {
500537
const erbContentCases = [

0 commit comments

Comments
 (0)