diff --git a/javascript/packages/formatter/src/format-helpers.ts b/javascript/packages/formatter/src/format-helpers.ts index 8fc9f1974..5a75f0853 100644 --- a/javascript/packages/formatter/src/format-helpers.ts +++ b/javascript/packages/formatter/src/format-helpers.ts @@ -521,3 +521,14 @@ export function isHerbDisableComment(node: Node): boolean { return trimmed.startsWith("herb:disable") } + +/** + * Check if a text node is YAML frontmatter (starts and ends with ---) + */ +export function isFrontmatter(node: Node): node is HTMLTextNode { + if (!isNode(node, HTMLTextNode)) return false + + const content = node.content.trim() + + return content.startsWith("---") && /---\s*$/.test(content) +} diff --git a/javascript/packages/formatter/src/format-printer.ts b/javascript/packages/formatter/src/format-printer.ts index ceca30000..7c20838bc 100644 --- a/javascript/packages/formatter/src/format-printer.ts +++ b/javascript/packages/formatter/src/format-printer.ts @@ -33,6 +33,7 @@ import { isBlockLevelNode, isClosingPunctuation, isContentPreserving, + isFrontmatter, isHerbDisableComment, isInlineElement, isNonWhitespaceNode, @@ -641,15 +642,16 @@ export class FormatPrinter extends Printer { // --- Visitor methods --- visitDocumentNode(node: DocumentNode) { - const hasTextFlow = this.isInTextFlowContext(null, node.children) + const children = this.formatFrontmatter(node) + const hasTextFlow = this.isInTextFlowContext(null, children) if (hasTextFlow) { - const children = filterSignificantChildren(node.children) + const filteredChildren = filterSignificantChildren(children) const wasInlineMode = this.inlineMode this.inlineMode = true - this.visitTextFlowChildren(children) + this.visitTextFlowChildren(filteredChildren) this.inlineMode = wasInlineMode @@ -659,10 +661,10 @@ export class FormatPrinter extends Printer { let lastWasMeaningful = false let hasHandledSpacing = false - for (let i = 0; i < node.children.length; i++) { - const child = node.children[i] + for (let i = 0; i < children.length; i++) { + const child = children[i] - if (shouldPreserveUserSpacing(child, node.children, i)) { + if (shouldPreserveUserSpacing(child, children, i)) { this.push("") hasHandledSpacing = true continue @@ -672,8 +674,8 @@ export class FormatPrinter extends Printer { continue } - if (shouldAppendToLastLine(child, node.children, i)) { - this.appendChildToLastLine(child, node.children, i) + if (shouldAppendToLastLine(child, children, i)) { + this.appendChildToLastLine(child, children, i) lastWasMeaningful = true hasHandledSpacing = false continue @@ -1473,6 +1475,21 @@ export class FormatPrinter extends Printer { // --- Utility methods --- + private formatFrontmatter(node: DocumentNode): Node[] { + const firstChild = node.children[0] + const hasFrontmatter = firstChild && isFrontmatter(firstChild) + + if (!hasFrontmatter) return node.children + + this.push(firstChild.content.trimEnd()) + + const remaining = node.children.slice(1) + + if (remaining.length > 0) this.push("") + + return remaining + } + /** * Append a child node to the last output line */ diff --git a/javascript/packages/formatter/test/erb-formatter/erb-formatter-fixtures.test.ts b/javascript/packages/formatter/test/erb-formatter/erb-formatter-fixtures.test.ts index ad83354c0..0c4ab6a8a 100644 --- a/javascript/packages/formatter/test/erb-formatter/erb-formatter-fixtures.test.ts +++ b/javascript/packages/formatter/test/erb-formatter/erb-formatter-fixtures.test.ts @@ -590,7 +590,10 @@ describe("ERB Formatter Fixture Tests", () => { const result = formatter.format(source) expect(result).toBe(dedent` - --- title: "My Page" layout: "application" --- + --- + title: "My Page" + layout: "application" + ---

<%= @title || "Default Title" %>

diff --git a/javascript/packages/formatter/test/frontmatter.test.ts b/javascript/packages/formatter/test/frontmatter.test.ts new file mode 100644 index 000000000..3ff8c0f27 --- /dev/null +++ b/javascript/packages/formatter/test/frontmatter.test.ts @@ -0,0 +1,229 @@ +import { describe, test, expect, beforeAll } from "vitest" +import { Herb } from "@herb-tools/node-wasm" +import { Formatter } from "../src" + +import dedent from "dedent" + +let formatter: Formatter + +describe("@herb-tools/formatter", () => { + beforeAll(async () => { + await Herb.load() + + formatter = new Formatter(Herb, { + indentWidth: 2, + maxLineLength: 80 + }) + }) + + test("preserves YAML frontmatter with no formatting", () => { + const source = dedent` + --- + title: My Page + layout: application + published: true + --- + +
+

<%= @title %>

+
+ ` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves frontmatter with ERB content after it", () => { + const source = dedent` + --- + title: Test + --- + + <% if Rails.env.development? %> +

Debug mode

+ <% end %> + ` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("preserves frontmatter indentation as-is", () => { + const source = dedent` + --- + nested: + key: value + another: + deep: true + --- + +
Content
+ ` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("normalizes whitespace after frontmatter", () => { + const source = dedent` + --- + title: Test + --- + + + + +
Content
+ ` + const result = formatter.format(source) + expect(result).toEqual(dedent` + --- + title: Test + --- + +
Content
+ `) + }) + + test("handles frontmatter with arrays and objects", () => { + const source = dedent` + --- + tags: + - ruby + - rails + - erb + metadata: + author: John Doe + date: 2024-01-01 + --- + +
+

Title

+
+ ` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("formats HTML but preserves frontmatter when HTML is messy", () => { + const source = dedent` + --- + title: Test + --- + +
+

Title

+

Text

+
+ ` + const result = formatter.format(source) + expect(result).toEqual(dedent` + --- + title: Test + --- + +
+

Title

+

Text

+
+ `) + }) + + test("does not treat --- in the middle of document as frontmatter", () => { + const source = dedent` +
+

Some content

+ --- +

More content

+
+ ` + const result = formatter.format(source) + + expect(result).toEqual(source) + }) + + test("frontmatter must end with --- on its own line", () => { + const source = dedent` + --- + title: Test --- not closing +
Content
+ ` + const result = formatter.format(source) + + expect(result).toEqual(dedent` + --- title: Test --- not closing + +
Content
+ `) + }) + + test("empty frontmatter block", () => { + const source = dedent` + --- + --- + +
Content
+ ` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("frontmatter with comments", () => { + const source = dedent` + --- + # This is a YAML comment + title: My Page + # Another comment + layout: application + --- + +
Content
+ ` + const result = formatter.format(source) + expect(result).toEqual(source) + }) + + test("frontmatter adds newline", () => { + const source = dedent` + --- + title: My Page + --- +
Content
+ ` + const result = formatter.format(source) + expect(result).toEqual(dedent` + --- + title: My Page + --- + +
Content
+ `) + }) + + test("frontmatter with no newline after ---", () => { + const source = dedent` + --- + title: My Page + ---
Content
+ ` + const result = formatter.format(source) + expect(result).toEqual(dedent` + --- + title: My Page + --- + +
Content
+ `) + }) + + // TODO: maybe we can improve this in the future + test("frontmatter with text after ---", () => { + const source = dedent` + --- + title: My Page + --- + Content + ` + const result = formatter.format(source) + expect(result).toEqual(dedent` + --- title: My Page --- Content + `) + }) +})