Repository: ony3000/prettier-plugin-classnames (ts)

Issue:
Broken consistency
## Dependency information

- prettier@2.8.4
- prettier-plugin-classnames@0.6.2

## Prettier configuration

```json
{
  "singleQuote": true,
  "trailingComma": "all",
  "singleAttributePerLine": true,
  "plugins": ["prettier-plugin-classnames"],
  "endingPosition": "absolute-with-indent"
}
```

## Steps to reproduce

Try repeating the formatting for one of the following two components:

```javascript
export default function MyComponent() {
  return (
    <div>
      <div>
        <div>
          <div className="card grid h-full flex-grow place-items-center bg-base-300">
            content
          </div>
        </div>
      </div>
    </div>
  );
}
```

```javascript
export default function MyComponent() {
  return (
    <div>
      <div>
        <div>
          <div
            className="card grid h-full flex-grow place-items-center
              bg-base-300"
          >
            content
          </div>
        </div>
      </div>
    </div>
  );
}
```

## The current behavior

Two outputs appear alternating.

## The expected behavior

It must be output in one consistent format (whatever that format may be).

Pull request description:
Makes output consistent for attribute type class names
This PR closes #41.

## Side effects on attribute type class names:

If formatting results in the attribute type class name being placed on a different line than the JSX opening element, the available width is calculated based on the result.

This side effect only applies to javascript and typescript code, and requires that the `endingPosition` option be set to `absolute` or `absolute-with-indent`.

For example, if you apply formatting to code like this:

```
--------------------------------------------------------------------------------| printWidth=80
export function Callout({ children }) {
  return (
    <div className="rounded-xl border border-zinc-400/30 bg-gray-100/50 px-4 py-4 dark:border-neutral-500/30 dark:bg-neutral-900/50">
      {children}
    </div>
  );
}
```

Until now, for attributes located on the same line as the JSX opening element, the available width was less than the `printWidth` value.

```
export function Callout({ children }) {
  return (
    <div
--------------------------------------------------------------------------------| printWidth=80
      className="rounded-xl border border-zinc-400/30 bg-gray-100/50 px-4 ______
                                                                          ^
                                               There is enough space for `py-4`, but it is not used.
        py-4 dark:border-neutral-500/30 dark:bg-neutral-900/50"
    >
      {children}
    </div>
  );
}
```

After this PR is merged, attributes located on the same line as the JSX opening element will also use a width equal to the `printWidth` value.

```
export function Callout({ children }) {
  return (
    <div
--------------------------------------------------------------------------------| printWidth=80
      className="rounded-xl border border-zinc-400/30 bg-gray-100/50 px-4 py-4
        dark:border-neutral-500/30 dark:bg-neutral-900/50"
    >
      {children}
    </div>
  );
}
```

Patch:
diff --git a/src/packages/core-parts/finder.ts b/src/packages/core-parts/finder.ts
index 5728f71..9393e65 100644
--- a/src/packages/core-parts/finder.ts
+++ b/src/packages/core-parts/finder.ts
@@ -168,6 +168,9 @@ export function findTargetClassNameNodes(
                   line: z.unknown(),
                 }),
               }),
+              name: z.object({
+                name: z.string(),
+              }),
             }),
           ) &&
           parentNode.type === 'JSXOpeningElement' &&
@@ -206,6 +209,8 @@ export function findTargetClassNameNodes(
                   parentNodeStartLineNumber === currentNodeStartLineNumber
                     ? ClassNameType.ASL
                     : ClassNameType.AOL;
+                // eslint-disable-next-line no-param-reassign
+                classNameNode.elementName = parentNode.name.name;
               }
             }
           });
diff --git a/src/packages/core-parts/index.ts b/src/packages/core-parts/index.ts
index 44a1ed7..11ec6a0 100644
--- a/src/packages/core-parts/index.ts
+++ b/src/packages/core-parts/index.ts
@@ -105,7 +105,7 @@ function sha1(input: string): string {
   return createHash('sha1').update(input).digest('hex');
 }
 
-function freezeIndent(input: string): string {
+function freezeNonClassName(input: string): string {
   const charCodeForUpperCaseAlpha = 913;
   const greekPlaceholder = [...Array(16)].map((_, index) =>
     String.fromCharCode(charCodeForUpperCaseAlpha + index),
@@ -122,7 +122,7 @@ function freezeIndent(input: string): string {
   return `${prefix}${rest}`;
 }
 
-function freezeString(input: string): string {
+function freezeClassName(input: string): string {
   const charCodeForLowerCaseAlpha = 945;
   const greekPlaceholder = [...Array(16)].map((_, index) =>
     String.fromCharCode(charCodeForLowerCaseAlpha + index),
@@ -139,6 +139,9 @@ function freezeString(input: string): string {
   return `${prefix}${rest}`;
 }
 
+const frozenAttributeName = freezeNonClassName('attribute');
+const doubleFrozenAttributeName = freezeNonClassName(frozenAttributeName);
+
 function replaceClassName({
   formattedText,
   indentUnit,
@@ -162,7 +165,7 @@ function replaceClassName({
   const icedFormattedText = targetClassNameNodes.reduce(
     (
       formattedPrevText,
-      { type, range: [rangeStart, rangeEnd], startLineIndex },
+      { type, range: [rangeStart, rangeEnd], startLineIndex, elementName },
       classNameNodeIndex,
     ) => {
       if (targetClassNameTypes && !targetClassNameTypes.includes(type)) {
@@ -258,22 +261,46 @@ function replaceClassName({
         options.parser,
       );
 
-      const rawIndent = indentUnit.repeat(multiLineIndentLevel);
-      const frozenIndent = freezeIndent(rawIndent);
-      const substitute = `${quoteStart}${classNameWithOriginalSpaces}${quoteEnd}`
-        .split(EOL)
-        .map((raw) => {
-          const frozen = freezeString(raw);
-
-          freezer.push({
-            type: 'string',
-            from: frozen,
-            to: raw,
-          });
+      const elementOpener = '<';
+      const spaceAfterElementName = ' ';
+      const conditionForSameLineAttribute =
+        isEndingPositionAbsolute &&
+        type === ClassNameType.ASL &&
+        isMultiLineClassName &&
+        formattedClassName.length +
+          options.tabWidth -
+          (elementName ? `${elementOpener}${elementName}${spaceAfterElementName}`.length : 0) <=
+          options.printWidth;
+      const conditionForOwnLineAttribute =
+        isEndingPositionAbsolute &&
+        type === ClassNameType.AOL &&
+        !isMultiLineClassName &&
+        classNameWithOriginalSpaces !== classNameBase &&
+        formattedClassName.length -
+          options.tabWidth +
+          (elementName ? `${elementOpener}${elementName}${spaceAfterElementName}`.length : 0) >
+          options.printWidth;
 
-          return frozen;
-        })
-        .join(`${EOL}${frozenIndent}`);
+      const rawIndent = indentUnit.repeat(multiLineIndentLevel);
+      const frozenIndent = freezeNonClassName(rawIndent);
+      const substitute =
+        `${quoteStart}${classNameWithOriginalSpaces}${quoteEnd}`
+          .split(EOL)
+          .map((raw) => {
+            const frozen = freezeClassName(raw);
+
+            freezer.push({
+              type: 'string',
+              from: frozen,
+              to: raw,
+            });
+
+            return frozen;
+          })
+          .join(`${EOL}${frozenIndent}`) +
+        (conditionForSameLineAttribute || conditionForOwnLineAttribute
+          ? `${EOL}${frozenAttributeName}${EOL}`
+          : '');
 
       if (isStartingPositionRelative && isMultiLineClassName) {
         freezer.push({
@@ -308,13 +335,16 @@ function replaceClassName({
     formattedText,
   );
 
-  return freezer.reduceRight(
-    (prevText, { type, from, to }) =>
-      type === 'indent'
-        ? prevText.replace(new RegExp(`^\\s*${from}`, 'gm'), to)
-        : prevText.replace(from, to),
-    icedFormattedText,
-  );
+  return freezer
+    .reduceRight(
+      (prevText, { type, from, to }) =>
+        type === 'indent'
+          ? prevText.replace(new RegExp(`^\\s*${from}`, 'gm'), to)
+          : prevText.replace(from, to),
+      icedFormattedText,
+    )
+    .replace(new RegExp(`^\\s*${doubleFrozenAttributeName}${EOL}`, 'gm'), '')
+    .replace(new RegExp(`${frozenAttributeName}`, 'gm'), doubleFrozenAttributeName);
 }
 
 export function parseLineByLineAndReplace({
@@ -356,7 +386,7 @@ export function parseLineByLineAndReplace({
 
   const lineNodes = parseLineByLine(formattedText, indentUnit);
 
-  return replaceClassName({
+  const classNameWrappedText = replaceClassName({
     formattedText,
     indentUnit,
     targetClassNameNodes,
@@ -365,6 +395,12 @@ export function parseLineByLineAndReplace({
     format,
     targetClassNameTypes,
   });
+  // Note: This is a temporary use condition. I plan to improve it in the next minor update.
+  const conditionForSecondFormat = targetClassNameTypes !== undefined;
+
+  return conditionForSecondFormat
+    ? classNameWrappedText.replace(new RegExp(`^\\s*${doubleFrozenAttributeName}${EOL}`, 'gm'), '')
+    : classNameWrappedText;
 }
 
 async function replaceClassNameAsync({
@@ -390,7 +426,7 @@ async function replaceClassNameAsync({
   const icedFormattedText = await targetClassNameNodes.reduce(
     async (
       formattedPrevTextPromise,
-      { type, range: [rangeStart, rangeEnd], startLineIndex },
+      { type, range: [rangeStart, rangeEnd], startLineIndex, elementName },
       classNameNodeIndex,
     ) => {
       if (targetClassNameTypes && !targetClassNameTypes.includes(type)) {
@@ -492,22 +528,46 @@ async function replaceClassNameAsync({
         options.parser,
       );
 
-      const rawIndent = indentUnit.repeat(multiLineIndentLevel);
-      const frozenIndent = freezeIndent(rawIndent);
-      const substitute = `${quoteStart}${classNameWithOriginalSpaces}${quoteEnd}`
-        .split(EOL)
-        .map((raw) => {
-          const frozen = freezeString(raw);
+      const elementOpener = '<';
+      const spaceAfterElementName = ' ';
+      const conditionForSameLineAttribute =
+        isEndingPositionAbsolute &&
+        type === ClassNameType.ASL &&
+        isMultiLineClassName &&
+        formattedClassName.length +
+          options.tabWidth -
+          (elementName ? `${elementOpener}${elementName}${spaceAfterElementName}`.length : 0) <=
+          options.printWidth;
+      const conditionForOwnLineAttribute =
+        isEndingPositionAbsolute &&
+        type === ClassNameType.AOL &&
+        !isMultiLineClassName &&
+        classNameWithOriginalSpaces !== classNameBase &&
+        formattedClassName.length -
+          options.tabWidth +
+          (elementName ? `${elementOpener}${elementName}${spaceAfterElementName}`.length : 0) >
+          options.printWidth;
 
-          freezer.push({
-            type: 'string',
-            from: frozen,
-            to: raw,
-          });
-
-          return frozen;
-        })
-        .join(`${EOL}${frozenIndent}`);
+      const rawIndent = indentUnit.repeat(multiLineIndentLevel);
+      const frozenIndent = freezeNonClassName(rawIndent);
+      const substitute =
+        `${quoteStart}${classNameWithOriginalSpaces}${quoteEnd}`
+          .split(EOL)
+          .map((raw) => {
+            const frozen = freezeClassName(raw);
+
+            freezer.push({
+              type: 'string',
+              from: frozen,
+              to: raw,
+            });
+
+            return frozen;
+          })
+          .join(`${EOL}${frozenIndent}`) +
+        (conditionForSameLineAttribute || conditionForOwnLineAttribute
+          ? `${EOL}${frozenAttributeName}${EOL}`
+          : '');
 
       if (isStartingPositionRelative && isMultiLineClassName) {
         freezer.push({
@@ -542,13 +602,16 @@ async function replaceClassNameAsync({
     Promise.resolve(formattedText),
   );
 
-  return freezer.reduceRight(
-    (prevText, { type, from, to }) =>
-      type === 'indent'
-        ? prevText.replace(new RegExp(`^\\s*${from}`, 'gm'), to)
-        : prevText.replace(from, to),
-    icedFormattedText,
-  );
+  return freezer
+    .reduceRight(
+      (prevText, { type, from, to }) =>
+        type === 'indent'
+          ? prevText.replace(new RegExp(`^\\s*${from}`, 'gm'), to)
+          : prevText.replace(from, to),
+      icedFormattedText,
+    )
+    .replace(new RegExp(`^\\s*${doubleFrozenAttributeName}${EOL}`, 'gm'), '')
+    .replace(new RegExp(`${frozenAttributeName}`, 'gm'), doubleFrozenAttributeName);
 }
 
 export async function parseLineByLineAndReplaceAsync({
@@ -590,7 +653,7 @@ export async function parseLineByLineAndReplaceAsync({
 
   const lineNodes = parseLineByLine(formattedText, indentUnit);
 
-  return replaceClassNameAsync({
+  const classNameWrappedText = await replaceClassNameAsync({
     formattedText,
     indentUnit,
     targetClassNameNodes,
@@ -599,4 +662,10 @@ export async function parseLineByLineAndReplaceAsync({
     format,
     targetClassNameTypes,
   });
+  // Note: This is a temporary use condition. I plan to improve it in the next minor update.
+  const conditionForSecondFormat = targetClassNameTypes !== undefined;
+
+  return conditionForSecondFormat
+    ? classNameWrappedText.replace(new RegExp(`^\\s*${doubleFrozenAttributeName}${EOL}`, 'gm'), '')
+    : classNameWrappedText;
 }
diff --git a/src/packages/core-parts/shared.ts b/src/packages/core-parts/shared.ts
index 05e1289..70c155a 100644
--- a/src/packages/core-parts/shared.ts
+++ b/src/packages/core-parts/shared.ts
@@ -94,6 +94,7 @@ export type ClassNameNode = {
   type: ClassNameType;
   range: NodeRange;
   startLineIndex: number;
+  elementName?: string;
 };
 
 export type NarrowedParserOptions = {
diff --git a/src/packages/v2-plugin/parsers.ts b/src/packages/v2-plugin/parsers.ts
index 02fa237..84a2c1f 100644
--- a/src/packages/v2-plugin/parsers.ts
+++ b/src/packages/v2-plugin/parsers.ts
@@ -90,7 +90,7 @@ function transformParser(
         targetClassNameTypes:
           parserName === 'vue' || parserName === 'astro'
             ? [ClassNameType.ASL, ClassNameType.AOL]
-            : [ClassNameType.CTL, ClassNameType.TLSL],
+            : [ClassNameType.ASL, ClassNameType.AOL, ClassNameType.CTL, ClassNameType.TLSL],
       });
 
       return {
diff --git a/src/packages/v3-plugin/parsers.ts b/src/packages/v3-plugin/parsers.ts
index a175f6a..d4a47bd 100644
--- a/src/packages/v3-plugin/parsers.ts
+++ b/src/packages/v3-plugin/parsers.ts
@@ -85,7 +85,7 @@ function transformParser(
         targetClassNameTypes:
           parserName === 'vue' || parserName === 'astro'
             ? [ClassNameType.ASL, ClassNameType.AOL]
-            : [ClassNameType.CTL, ClassNameType.TLSL],
+            : [ClassNameType.ASL, ClassNameType.AOL, ClassNameType.CTL, ClassNameType.TLSL],
       });
 
       return {


Test patch:
diff --git a/tests/v2-test/babel/attribute-without-expression.test.ts b/tests/v2-test/babel/attribute-without-expression.test.ts
index f658a31..175d074 100644
--- a/tests/v2-test/babel/attribute-without-expression.test.ts
+++ b/tests/v2-test/babel/attribute-without-expression.test.ts
@@ -185,8 +185,8 @@ export function Callout({ children }) {
     output: `export function Callout({ children }) {
   return (
     <div
-      className="rounded-xl border border-zinc-400/30 bg-gray-100/50 px-4
-py-4 dark:border-neutral-500/30 dark:bg-neutral-900/50"
+      className="rounded-xl border border-zinc-400/30 bg-gray-100/50 px-4 py-4
+dark:border-neutral-500/30 dark:bg-neutral-900/50"
     >
       {children}
     </div>
diff --git a/tests/v2-test/babel/consistency.test.ts b/tests/v2-test/babel/consistency.test.ts
new file mode 100644
index 0000000..229faca
--- /dev/null
+++ b/tests/v2-test/babel/consistency.test.ts
@@ -0,0 +1,193 @@
+import { format } from 'prettier';
+import type { Fixture } from 'test-settings';
+import { baseOptions } from 'test-settings';
+import { describe, expect, test } from 'vitest';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import * as thisPlugin from '@/packages/v2-plugin';
+
+const options = {
+  ...baseOptions,
+  plugins: [thisPlugin],
+  parser: 'babel',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'issue #41 (1) - relative',
+    input: `
+export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'relative',
+    },
+  },
+  {
+    name: 'issue #41 (2) - relative',
+    input: `
+export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit
+        aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'relative',
+    },
+  },
+  {
+    name: 'issue #41 (3) - absolute',
+    input: `
+export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'absolute',
+    },
+  },
+  {
+    name: 'issue #41 (4) - absolute',
+    input: `
+export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit
+        aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'absolute',
+    },
+  },
+  {
+    name: 'issue #41 (5) - ideal',
+    input: `
+export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'absolute-with-indent',
+    },
+  },
+  {
+    name: 'issue #41 (6) - ideal',
+    input: `
+export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit
+        aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'absolute-with-indent',
+    },
+  },
+];
+
+describe('babel/consistency', () => {
+  for (const fixture of fixtures) {
+    const fixedOptions = {
+      ...options,
+      ...(fixture.options ?? {}),
+    };
+    const formattedText = format(fixture.input, fixedOptions);
+
+    describe(fixture.name, () => {
+      test('expectation', () => {
+        expect(formattedText).toBe(fixture.output);
+      });
+
+      test('consistency', () => {
+        const doubleFormattedText = format(formattedText, fixedOptions);
+
+        expect(doubleFormattedText).toBe(formattedText);
+      });
+    });
+  }
+});
diff --git a/tests/v2-test/typescript/attribute-without-expression.test.ts b/tests/v2-test/typescript/attribute-without-expression.test.ts
index 1aaa810..6b9bd0e 100644
--- a/tests/v2-test/typescript/attribute-without-expression.test.ts
+++ b/tests/v2-test/typescript/attribute-without-expression.test.ts
@@ -185,8 +185,8 @@ export function Callout({ children }) {
     output: `export function Callout({ children }) {
   return (
     <div
-      className="rounded-xl border border-zinc-400/30 bg-gray-100/50 px-4
-py-4 dark:border-neutral-500/30 dark:bg-neutral-900/50"
+      className="rounded-xl border border-zinc-400/30 bg-gray-100/50 px-4 py-4
+dark:border-neutral-500/30 dark:bg-neutral-900/50"
     >
       {children}
     </div>
diff --git a/tests/v2-test/typescript/consistency.test.ts b/tests/v2-test/typescript/consistency.test.ts
new file mode 100644
index 0000000..8fc9367
--- /dev/null
+++ b/tests/v2-test/typescript/consistency.test.ts
@@ -0,0 +1,193 @@
+import { format } from 'prettier';
+import type { Fixture } from 'test-settings';
+import { baseOptions } from 'test-settings';
+import { describe, expect, test } from 'vitest';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import * as thisPlugin from '@/packages/v2-plugin';
+
+const options = {
+  ...baseOptions,
+  plugins: [thisPlugin],
+  parser: 'typescript',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'issue #41 (1) - relative',
+    input: `
+export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'relative',
+    },
+  },
+  {
+    name: 'issue #41 (2) - relative',
+    input: `
+export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit
+        aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'relative',
+    },
+  },
+  {
+    name: 'issue #41 (3) - absolute',
+    input: `
+export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'absolute',
+    },
+  },
+  {
+    name: 'issue #41 (4) - absolute',
+    input: `
+export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit
+        aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'absolute',
+    },
+  },
+  {
+    name: 'issue #41 (5) - ideal',
+    input: `
+export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'absolute-with-indent',
+    },
+  },
+  {
+    name: 'issue #41 (6) - ideal',
+    input: `
+export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit
+        aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'absolute-with-indent',
+    },
+  },
+];
+
+describe('typescript/consistency', () => {
+  for (const fixture of fixtures) {
+    const fixedOptions = {
+      ...options,
+      ...(fixture.options ?? {}),
+    };
+    const formattedText = format(fixture.input, fixedOptions);
+
+    describe(fixture.name, () => {
+      test('expectation', () => {
+        expect(formattedText).toBe(fixture.output);
+      });
+
+      test('consistency', () => {
+        const doubleFormattedText = format(formattedText, fixedOptions);
+
+        expect(doubleFormattedText).toBe(formattedText);
+      });
+    });
+  }
+});
diff --git a/tests/v3-test/babel/attribute-without-expression.test.ts b/tests/v3-test/babel/attribute-without-expression.test.ts
index b67180d..eb50ed3 100644
--- a/tests/v3-test/babel/attribute-without-expression.test.ts
+++ b/tests/v3-test/babel/attribute-without-expression.test.ts
@@ -185,8 +185,8 @@ export function Callout({ children }) {
     output: `export function Callout({ children }) {
   return (
     <div
-      className="rounded-xl border border-zinc-400/30 bg-gray-100/50 px-4
-py-4 dark:border-neutral-500/30 dark:bg-neutral-900/50"
+      className="rounded-xl border border-zinc-400/30 bg-gray-100/50 px-4 py-4
+dark:border-neutral-500/30 dark:bg-neutral-900/50"
     >
       {children}
     </div>
diff --git a/tests/v3-test/babel/consistency.test.ts b/tests/v3-test/babel/consistency.test.ts
new file mode 100644
index 0000000..54035fd
--- /dev/null
+++ b/tests/v3-test/babel/consistency.test.ts
@@ -0,0 +1,196 @@
+import { format } from 'prettier';
+import type { Fixture } from 'test-settings';
+import { baseOptions } from 'test-settings';
+import { describe, expect, test } from 'vitest';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import * as thisPlugin from '@/packages/v3-plugin';
+
+const options = {
+  ...baseOptions,
+  plugins: [thisPlugin],
+  parser: 'babel',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'issue #41 (1) - relative',
+    input: `
+export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'relative',
+    },
+  },
+  {
+    name: 'issue #41 (2) - relative',
+    input: `
+export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit
+        aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'relative',
+    },
+  },
+  {
+    name: 'issue #41 (3) - absolute',
+    input: `
+export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'absolute',
+    },
+  },
+  {
+    name: 'issue #41 (4) - absolute',
+    input: `
+export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit
+        aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'absolute',
+    },
+  },
+  {
+    name: 'issue #41 (5) - ideal',
+    input: `
+export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'absolute-with-indent',
+    },
+  },
+  {
+    name: 'issue #41 (6) - ideal',
+    input: `
+export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit
+        aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'absolute-with-indent',
+    },
+  },
+];
+
+describe('babel/consistency', () => {
+  for (const fixture of fixtures) {
+    const fixedOptions = {
+      ...options,
+      ...(fixture.options ?? {}),
+    };
+    const promise = format(fixture.input, fixedOptions);
+
+    describe(fixture.name, () => {
+      test('expectation', async () => {
+        const formattedText = await promise;
+
+        expect(formattedText).toBe(fixture.output);
+      });
+
+      test('consistency', async () => {
+        const formattedText = await promise;
+        const doubleFormattedText = await format(formattedText, fixedOptions);
+
+        expect(doubleFormattedText).toBe(formattedText);
+      });
+    });
+  }
+});
diff --git a/tests/v3-test/typescript/attribute-without-expression.test.ts b/tests/v3-test/typescript/attribute-without-expression.test.ts
index 3035d70..90033c6 100644
--- a/tests/v3-test/typescript/attribute-without-expression.test.ts
+++ b/tests/v3-test/typescript/attribute-without-expression.test.ts
@@ -185,8 +185,8 @@ export function Callout({ children }) {
     output: `export function Callout({ children }) {
   return (
     <div
-      className="rounded-xl border border-zinc-400/30 bg-gray-100/50 px-4
-py-4 dark:border-neutral-500/30 dark:bg-neutral-900/50"
+      className="rounded-xl border border-zinc-400/30 bg-gray-100/50 px-4 py-4
+dark:border-neutral-500/30 dark:bg-neutral-900/50"
     >
       {children}
     </div>
diff --git a/tests/v3-test/typescript/consistency.test.ts b/tests/v3-test/typescript/consistency.test.ts
new file mode 100644
index 0000000..382c0f5
--- /dev/null
+++ b/tests/v3-test/typescript/consistency.test.ts
@@ -0,0 +1,196 @@
+import { format } from 'prettier';
+import type { Fixture } from 'test-settings';
+import { baseOptions } from 'test-settings';
+import { describe, expect, test } from 'vitest';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import * as thisPlugin from '@/packages/v3-plugin';
+
+const options = {
+  ...baseOptions,
+  plugins: [thisPlugin],
+  parser: 'typescript',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'issue #41 (1) - relative',
+    input: `
+export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'relative',
+    },
+  },
+  {
+    name: 'issue #41 (2) - relative',
+    input: `
+export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit
+        aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'relative',
+    },
+  },
+  {
+    name: 'issue #41 (3) - absolute',
+    input: `
+export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'absolute',
+    },
+  },
+  {
+    name: 'issue #41 (4) - absolute',
+    input: `
+export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit
+        aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'absolute',
+    },
+  },
+  {
+    name: 'issue #41 (5) - ideal',
+    input: `
+export default function MyComponent() {
+  return (
+    <section className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p">
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'absolute-with-indent',
+    },
+  },
+  {
+    name: 'issue #41 (6) - ideal',
+    input: `
+export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit
+        aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    output: `export default function MyComponent() {
+  return (
+    <section
+      className="lorem ipsum dolor sit amet consectetur adipiscing elit aenean p"
+    >
+      content
+    </section>
+  );
+}
+`,
+    options: {
+      endingPosition: 'absolute-with-indent',
+    },
+  },
+];
+
+describe('typescript/consistency', () => {
+  for (const fixture of fixtures) {
+    const fixedOptions = {
+      ...options,
+      ...(fixture.options ?? {}),
+    };
+    const promise = format(fixture.input, fixedOptions);
+
+    describe(fixture.name, () => {
+      test('expectation', async () => {
+        const formattedText = await promise;
+
+        expect(formattedText).toBe(fixture.output);
+      });
+
+      test('consistency', async () => {
+        const formattedText = await promise;
+        const doubleFormattedText = await format(formattedText, fixedOptions);
+
+        expect(doubleFormattedText).toBe(formattedText);
+      });
+    });
+  }
+});