Skip to content

Commit d6c87e3

Browse files
authored
Formatter: Preserve Frontmatter when formatting documents (marcoroth#797)
This pull request adds support for preserving frontmatter when formatting HTML+ERB documents. When the first Node within the `DocumentNode` is a `HTMLTextNode` and starts/ends with `---` we treat that `HTMLTextNode` as frontmatter content and preserve/format it exactly as-is. **Example** The following document is now being preserved: ```erb --- title: My Page layout: application published: true --- <div class="container"> <h1><%= @title %></h1> </div> ``` Previously it was formatted as: ```erb --- title: My Page layout: application published: true --- <div class="container"> <h1><%= @title %></h1> </div> ``` Thanks to @joshuap for the nudge!
1 parent dc40828 commit d6c87e3

File tree

4 files changed

+269
-9
lines changed

4 files changed

+269
-9
lines changed

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,3 +521,14 @@ export function isHerbDisableComment(node: Node): boolean {
521521

522522
return trimmed.startsWith("herb:disable")
523523
}
524+
525+
/**
526+
* Check if a text node is YAML frontmatter (starts and ends with ---)
527+
*/
528+
export function isFrontmatter(node: Node): node is HTMLTextNode {
529+
if (!isNode(node, HTMLTextNode)) return false
530+
531+
const content = node.content.trim()
532+
533+
return content.startsWith("---") && /---\s*$/.test(content)
534+
}

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

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
isBlockLevelNode,
3434
isClosingPunctuation,
3535
isContentPreserving,
36+
isFrontmatter,
3637
isHerbDisableComment,
3738
isInlineElement,
3839
isNonWhitespaceNode,
@@ -641,15 +642,16 @@ export class FormatPrinter extends Printer {
641642
// --- Visitor methods ---
642643

643644
visitDocumentNode(node: DocumentNode) {
644-
const hasTextFlow = this.isInTextFlowContext(null, node.children)
645+
const children = this.formatFrontmatter(node)
646+
const hasTextFlow = this.isInTextFlowContext(null, children)
645647

646648
if (hasTextFlow) {
647-
const children = filterSignificantChildren(node.children)
649+
const filteredChildren = filterSignificantChildren(children)
648650

649651
const wasInlineMode = this.inlineMode
650652
this.inlineMode = true
651653

652-
this.visitTextFlowChildren(children)
654+
this.visitTextFlowChildren(filteredChildren)
653655

654656
this.inlineMode = wasInlineMode
655657

@@ -659,10 +661,10 @@ export class FormatPrinter extends Printer {
659661
let lastWasMeaningful = false
660662
let hasHandledSpacing = false
661663

662-
for (let i = 0; i < node.children.length; i++) {
663-
const child = node.children[i]
664+
for (let i = 0; i < children.length; i++) {
665+
const child = children[i]
664666

665-
if (shouldPreserveUserSpacing(child, node.children, i)) {
667+
if (shouldPreserveUserSpacing(child, children, i)) {
666668
this.push("")
667669
hasHandledSpacing = true
668670
continue
@@ -672,8 +674,8 @@ export class FormatPrinter extends Printer {
672674
continue
673675
}
674676

675-
if (shouldAppendToLastLine(child, node.children, i)) {
676-
this.appendChildToLastLine(child, node.children, i)
677+
if (shouldAppendToLastLine(child, children, i)) {
678+
this.appendChildToLastLine(child, children, i)
677679
lastWasMeaningful = true
678680
hasHandledSpacing = false
679681
continue
@@ -1473,6 +1475,21 @@ export class FormatPrinter extends Printer {
14731475

14741476
// --- Utility methods ---
14751477

1478+
private formatFrontmatter(node: DocumentNode): Node[] {
1479+
const firstChild = node.children[0]
1480+
const hasFrontmatter = firstChild && isFrontmatter(firstChild)
1481+
1482+
if (!hasFrontmatter) return node.children
1483+
1484+
this.push(firstChild.content.trimEnd())
1485+
1486+
const remaining = node.children.slice(1)
1487+
1488+
if (remaining.length > 0) this.push("")
1489+
1490+
return remaining
1491+
}
1492+
14761493
/**
14771494
* Append a child node to the last output line
14781495
*/

javascript/packages/formatter/test/erb-formatter/erb-formatter-fixtures.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,10 @@ describe("ERB Formatter Fixture Tests", () => {
590590
const result = formatter.format(source)
591591

592592
expect(result).toBe(dedent`
593-
--- title: "My Page" layout: "application" ---
593+
---
594+
title: "My Page"
595+
layout: "application"
596+
---
594597
595598
<div class="page">
596599
<h1><%= @title || "Default Title" %></h1>
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { describe, test, expect, beforeAll } from "vitest"
2+
import { Herb } from "@herb-tools/node-wasm"
3+
import { Formatter } from "../src"
4+
5+
import dedent from "dedent"
6+
7+
let formatter: Formatter
8+
9+
describe("@herb-tools/formatter", () => {
10+
beforeAll(async () => {
11+
await Herb.load()
12+
13+
formatter = new Formatter(Herb, {
14+
indentWidth: 2,
15+
maxLineLength: 80
16+
})
17+
})
18+
19+
test("preserves YAML frontmatter with no formatting", () => {
20+
const source = dedent`
21+
---
22+
title: My Page
23+
layout: application
24+
published: true
25+
---
26+
27+
<div class="container">
28+
<h1><%= @title %></h1>
29+
</div>
30+
`
31+
const result = formatter.format(source)
32+
expect(result).toEqual(source)
33+
})
34+
35+
test("preserves frontmatter with ERB content after it", () => {
36+
const source = dedent`
37+
---
38+
title: Test
39+
---
40+
41+
<% if Rails.env.development? %>
42+
<p>Debug mode</p>
43+
<% end %>
44+
`
45+
const result = formatter.format(source)
46+
expect(result).toEqual(source)
47+
})
48+
49+
test("preserves frontmatter indentation as-is", () => {
50+
const source = dedent`
51+
---
52+
nested:
53+
key: value
54+
another:
55+
deep: true
56+
---
57+
58+
<div>Content</div>
59+
`
60+
const result = formatter.format(source)
61+
expect(result).toEqual(source)
62+
})
63+
64+
test("normalizes whitespace after frontmatter", () => {
65+
const source = dedent`
66+
---
67+
title: Test
68+
---
69+
70+
71+
72+
73+
<div>Content</div>
74+
`
75+
const result = formatter.format(source)
76+
expect(result).toEqual(dedent`
77+
---
78+
title: Test
79+
---
80+
81+
<div>Content</div>
82+
`)
83+
})
84+
85+
test("handles frontmatter with arrays and objects", () => {
86+
const source = dedent`
87+
---
88+
tags:
89+
- ruby
90+
- rails
91+
- erb
92+
metadata:
93+
author: John Doe
94+
date: 2024-01-01
95+
---
96+
97+
<article>
98+
<h1>Title</h1>
99+
</article>
100+
`
101+
const result = formatter.format(source)
102+
expect(result).toEqual(source)
103+
})
104+
105+
test("formats HTML but preserves frontmatter when HTML is messy", () => {
106+
const source = dedent`
107+
---
108+
title: Test
109+
---
110+
111+
<div class="container" >
112+
<h1>Title</h1>
113+
<p>Text</p>
114+
</div>
115+
`
116+
const result = formatter.format(source)
117+
expect(result).toEqual(dedent`
118+
---
119+
title: Test
120+
---
121+
122+
<div class="container">
123+
<h1>Title</h1>
124+
<p>Text</p>
125+
</div>
126+
`)
127+
})
128+
129+
test("does not treat --- in the middle of document as frontmatter", () => {
130+
const source = dedent`
131+
<div>
132+
<p>Some content</p>
133+
---
134+
<p>More content</p>
135+
</div>
136+
`
137+
const result = formatter.format(source)
138+
139+
expect(result).toEqual(source)
140+
})
141+
142+
test("frontmatter must end with --- on its own line", () => {
143+
const source = dedent`
144+
---
145+
title: Test --- not closing
146+
<div>Content</div>
147+
`
148+
const result = formatter.format(source)
149+
150+
expect(result).toEqual(dedent`
151+
--- title: Test --- not closing
152+
153+
<div>Content</div>
154+
`)
155+
})
156+
157+
test("empty frontmatter block", () => {
158+
const source = dedent`
159+
---
160+
---
161+
162+
<div>Content</div>
163+
`
164+
const result = formatter.format(source)
165+
expect(result).toEqual(source)
166+
})
167+
168+
test("frontmatter with comments", () => {
169+
const source = dedent`
170+
---
171+
# This is a YAML comment
172+
title: My Page
173+
# Another comment
174+
layout: application
175+
---
176+
177+
<div>Content</div>
178+
`
179+
const result = formatter.format(source)
180+
expect(result).toEqual(source)
181+
})
182+
183+
test("frontmatter adds newline", () => {
184+
const source = dedent`
185+
---
186+
title: My Page
187+
---
188+
<div>Content</div>
189+
`
190+
const result = formatter.format(source)
191+
expect(result).toEqual(dedent`
192+
---
193+
title: My Page
194+
---
195+
196+
<div>Content</div>
197+
`)
198+
})
199+
200+
test("frontmatter with no newline after ---", () => {
201+
const source = dedent`
202+
---
203+
title: My Page
204+
---<div>Content</div>
205+
`
206+
const result = formatter.format(source)
207+
expect(result).toEqual(dedent`
208+
---
209+
title: My Page
210+
---
211+
212+
<div>Content</div>
213+
`)
214+
})
215+
216+
// TODO: maybe we can improve this in the future
217+
test("frontmatter with text after ---", () => {
218+
const source = dedent`
219+
---
220+
title: My Page
221+
---
222+
Content
223+
`
224+
const result = formatter.format(source)
225+
expect(result).toEqual(dedent`
226+
--- title: My Page --- Content
227+
`)
228+
})
229+
})

0 commit comments

Comments
 (0)