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

Issue:
Special Characters Corrupt Formatting
## Dependency information
Here is my config
```js
export default {
  plugins: [
    "prettier-plugin-astro", // ^0.13.0
    "prettier-plugin-tailwindcss", // ^0.5.11
    "prettier-plugin-classnames", // ^0.6.4
    "prettier-plugin-merge", // ^0.6.0
  ],
  trailingComma: "none",
  semi: false,
  printWidth: 100,
  tailwindFunctions: ["tw"],
  customAttributes: ["class", "className", "style", "classList"],
  overrides: [
    {
      files: "*.astro",
      options: {
        parser: "astro",
      },
    },
  ],
};
```

## Steps to reproduce
Format a file with this character '•' (there are others) 
`page.astro`
```astro
---
import SettingsLayout from "@layouts/settings.astro";
import SecurityForm from "./_security";
---

<SettingsLayout
  title="Settings • Details"
  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
>
  <SecurityForm client:only />
</SettingsLayout>
```

## The current behavior
Becomes this
```astro
---
import SettingsLayout from "@layouts/settings.astro";
import SecurityForm from "./_security";
---

<SettingsLayout
  title="Settings • Details"
  class="f"μα"
    " δξξπξκκγαεζμξζμγηκκοιζιζξηιθοημιγνθλ______________________
  γ
  <SecurityForm
  client:only
/>
```
The curruption depends on the character. For example, the string `δξξπξκκγαεζμξζμγηκκοιζιζξηιθοημιγνθλ______________________` is always observed when the character `•` is present. 

## The expected behavior
Nothing should happen in this case as the className is not long enough.


Pull request:
Ensures that class names written after non-ASCII characters in Astro code do not leave placeholder fragments
This PR closes #50.

Patch:
diff --git a/src/packages/core-parts/finder.ts b/src/packages/core-parts/finder.ts
index 85a9d80..c28095b 100644
--- a/src/packages/core-parts/finder.ts
+++ b/src/packages/core-parts/finder.ts
@@ -2,7 +2,7 @@ import type { ZodTypeAny, infer as ZodInfer } from 'zod';
 import { z } from 'zod';
 
 import type { Dict, NodeRange, ClassNameNode, NarrowedParserOptions } from './shared';
-import { ClassNameType } from './shared';
+import { EOL, ClassNameType } from './shared';
 
 type ASTNode = {
   type: string;
@@ -769,6 +769,7 @@ export function findTargetClassNameNodesForVue(
 }
 
 export function findTargetClassNameNodesForAstro(
+  formattedText: string,
   ast: any,
   options: NarrowedParserOptions,
   addon: Dict<(text: string, options: any) => any>,
@@ -797,6 +798,17 @@ export function findTargetClassNameNodesForAstro(
    */
   const classNameNodes: ClassNameNode[] = [];
 
+  const totalTextLengthUptoSpecificIndexLine = formattedText
+    .split(EOL)
+    .slice(0, -1)
+    .map((line) => `${line}${EOL}`.length)
+    .map((_, index, array) =>
+      array
+        .slice(0, index + 1)
+        .reduce((prevResult, currentLength) => prevResult + currentLength, 0),
+    );
+  totalTextLengthUptoSpecificIndexLine.unshift(0);
+
   function recursion(node: unknown, parentNode?: { type?: unknown }): void {
     if (!isTypeof(node, z.object({ type: z.string() }))) {
       return;
@@ -823,11 +835,13 @@ export function findTargetClassNameNodesForAstro(
         z.object({
           position: z.object({
             start: z.object({
-              offset: z.number(),
+              line: z.number(),
+              column: z.number(),
             }),
             end: z
               .object({
-                offset: z.number(),
+                line: z.number(),
+                column: z.number(),
               })
               .optional(),
           }),
@@ -839,10 +853,13 @@ export function findTargetClassNameNodesForAstro(
       return;
     }
 
-    const currentNodeRangeStart = node.position.start.offset;
+    const currentNodeRangeStart =
+      totalTextLengthUptoSpecificIndexLine[node.position.start.line - 1] +
+      (node.position.start.column - 1);
     const currentNodeRangeEnd = node.position.end
-      ? node.position.end.offset
-      : node.position.start.offset +
+      ? totalTextLengthUptoSpecificIndexLine[node.position.end.line - 1] +
+        (node.position.end.column - 1)
+      : currentNodeRangeStart +
         (node.type === 'attribute'
           ? `${node.name}=?${node.value}?`.length
           : `${node.value}`.length);
@@ -920,11 +937,6 @@ export function findTargetClassNameNodesForAstro(
               kind: z.string(),
               name: z.string(),
               value: z.string(),
-              position: z.object({
-                start: z.object({
-                  line: z.number(),
-                }),
-              }),
             }),
           ) &&
           supportedAttributes.includes(node.name)
@@ -958,7 +970,7 @@ export function findTargetClassNameNodesForAstro(
                   }
 
                   const attributeOffset =
-                    -jsxStart.length + node.position.start.offset + attributeStart.length;
+                    -jsxStart.length + currentNodeRangeStart + attributeStart.length;
 
                   return {
                     type,
diff --git a/src/packages/core-parts/index.ts b/src/packages/core-parts/index.ts
index 11ec6a0..cd24887 100644
--- a/src/packages/core-parts/index.ts
+++ b/src/packages/core-parts/index.ts
@@ -371,7 +371,7 @@ export function parseLineByLineAndReplace({
   let targetClassNameNodes: ClassNameNode[] = [];
   switch (options.parser) {
     case 'astro': {
-      targetClassNameNodes = findTargetClassNameNodesForAstro(ast, options, addon);
+      targetClassNameNodes = findTargetClassNameNodesForAstro(formattedText, ast, options, addon);
       break;
     }
     case 'vue': {
@@ -638,7 +638,7 @@ export async function parseLineByLineAndReplaceAsync({
   let targetClassNameNodes: ClassNameNode[] = [];
   switch (options.parser) {
     case 'astro': {
-      targetClassNameNodes = findTargetClassNameNodesForAstro(ast, options, addon);
+      targetClassNameNodes = findTargetClassNameNodesForAstro(formattedText, ast, options, addon);
       break;
     }
     case 'vue': {


Test patch:
diff --git a/tests/v2-test/astro/issue-50/absolute.test.ts b/tests/v2-test/astro/issue-50/absolute.test.ts
new file mode 100644
index 0000000..74f051f
--- /dev/null
+++ b/tests/v2-test/astro/issue-50/absolute.test.ts
@@ -0,0 +1,119 @@
+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: ['prettier-plugin-astro', thisPlugin],
+  parser: 'astro',
+  endingPosition: 'absolute',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'bullet character',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings • Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings • Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+  {
+    name: 'emoji',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings 😂 Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings 😂 Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+  {
+    name: 'i18n - Korean',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="설정 * 세부정보"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="설정 * 세부정보"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+];
+
+describe.each(fixtures)('$name', ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', () => {
+    const doubleFormattedText = format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});
diff --git a/tests/v2-test/astro/issue-50/ideal.test.ts b/tests/v2-test/astro/issue-50/ideal.test.ts
new file mode 100644
index 0000000..0ff5bdd
--- /dev/null
+++ b/tests/v2-test/astro/issue-50/ideal.test.ts
@@ -0,0 +1,119 @@
+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: ['prettier-plugin-astro', thisPlugin],
+  parser: 'astro',
+  endingPosition: 'absolute-with-indent',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'bullet character',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings • Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings • Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+  {
+    name: 'emoji',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings 😂 Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings 😂 Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+  {
+    name: 'i18n - Korean',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="설정 * 세부정보"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="설정 * 세부정보"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+];
+
+describe.each(fixtures)('$name', ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', () => {
+    const doubleFormattedText = format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});
diff --git a/tests/v2-test/astro/issue-50/relative.test.ts b/tests/v2-test/astro/issue-50/relative.test.ts
new file mode 100644
index 0000000..cc56552
--- /dev/null
+++ b/tests/v2-test/astro/issue-50/relative.test.ts
@@ -0,0 +1,119 @@
+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: ['prettier-plugin-astro', thisPlugin],
+  parser: 'astro',
+  endingPosition: 'relative',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'bullet character',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings • Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings • Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+  {
+    name: 'emoji',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings 😂 Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings 😂 Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+  {
+    name: 'i18n - Korean',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="설정 * 세부정보"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="설정 * 세부정보"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+];
+
+describe.each(fixtures)('$name', ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', () => {
+    const doubleFormattedText = format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});
diff --git a/tests/v3-test/astro/issue-50/absolute.test.ts b/tests/v3-test/astro/issue-50/absolute.test.ts
new file mode 100644
index 0000000..6521396
--- /dev/null
+++ b/tests/v3-test/astro/issue-50/absolute.test.ts
@@ -0,0 +1,119 @@
+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: ['prettier-plugin-astro', thisPlugin],
+  parser: 'astro',
+  endingPosition: 'absolute',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'bullet character',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings • Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings • Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+  {
+    name: 'emoji',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings 😂 Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings 😂 Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+  {
+    name: 'i18n - Korean',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="설정 * 세부정보"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="설정 * 세부정보"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+];
+
+describe.each(fixtures)('$name', async ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = await format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', async () => {
+    const doubleFormattedText = await format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});
diff --git a/tests/v3-test/astro/issue-50/ideal.test.ts b/tests/v3-test/astro/issue-50/ideal.test.ts
new file mode 100644
index 0000000..3a24954
--- /dev/null
+++ b/tests/v3-test/astro/issue-50/ideal.test.ts
@@ -0,0 +1,119 @@
+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: ['prettier-plugin-astro', thisPlugin],
+  parser: 'astro',
+  endingPosition: 'absolute-with-indent',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'bullet character',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings • Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings • Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+  {
+    name: 'emoji',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings 😂 Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings 😂 Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+  {
+    name: 'i18n - Korean',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="설정 * 세부정보"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="설정 * 세부정보"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+];
+
+describe.each(fixtures)('$name', async ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = await format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', async () => {
+    const doubleFormattedText = await format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});
diff --git a/tests/v3-test/astro/issue-50/relative.test.ts b/tests/v3-test/astro/issue-50/relative.test.ts
new file mode 100644
index 0000000..a3fc314
--- /dev/null
+++ b/tests/v3-test/astro/issue-50/relative.test.ts
@@ -0,0 +1,119 @@
+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: ['prettier-plugin-astro', thisPlugin],
+  parser: 'astro',
+  endingPosition: 'relative',
+};
+
+const fixtures: Fixture[] = [
+  {
+    name: 'bullet character',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings • Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings • Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+  {
+    name: 'emoji',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings 😂 Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="Settings 😂 Details"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+  {
+    name: 'i18n - Korean',
+    input: `
+---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="설정 * 세부정보"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+    output: `---
+import SettingsLayout from "@layouts/settings.astro";
+import SecurityForm from "./_security";
+---
+
+<SettingsLayout
+  title="설정 * 세부정보"
+  class="flex max-w-[1100px] flex-col gap-4 overflow-x-hidden text-left"
+>
+  <SecurityForm client:only />
+</SettingsLayout>
+`,
+  },
+];
+
+describe.each(fixtures)('$name', async ({ input, output, options: fixtureOptions }) => {
+  const fixedOptions = {
+    ...options,
+    ...(fixtureOptions ?? {}),
+  };
+  const formattedText = await format(input, fixedOptions);
+
+  test('expectation', () => {
+    expect(formattedText).toBe(output);
+  });
+
+  test.runIf(formattedText === output)('consistency', async () => {
+    const doubleFormattedText = await format(formattedText, fixedOptions);
+
+    expect(doubleFormattedText).toBe(formattedText);
+  });
+});