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

Issue:
Formatter does not work in Svelte file using TypeScript with any type annotation
## Dependency information
In a svelte file using TypeScript, when there is any type annotation within the <script> tag, the formatter does not break tailwindcss class line  properly.

## Steps to reproduce
Step1 (issue): In vscode, in an package that has `prettier-plugin-classnames` correctly setup, create a new file named `test.svelte` with following content:

```
<script lang="ts">
  let inputElement: HTMLInputElement;
</script>

<div class="relative">
  <input
    class="text-primary-700 w-full rounded-md border border-gray-300 px-3 py-2 font-semibold focus:outline-none focus:ring-2 focus:ring-blue-500"
  />
</div>
```

Execute the `Format Document` command in vscode, does not break the tailwindcss class line.

Step2 (positive control): edit the `test.svelte` file by removing the `HTMLInputElement` type annotation, now the file becomes:

```
<script lang="ts">
  let inputElement;
</script>

<div class="relative">
  <input
    class="text-primary-700 w-full rounded-md border border-gray-300 px-3 py-2 font-semibold focus:outline-none focus:ring-2 focus:ring-blue-500"
  />
</div>
```

Now execute the `Format Document` command will correctly break the class line.

## The current behavior
Step1 in reproduce section does not break class line.

## The expected behavior
Step1 in reproduce section should break class line.

I'm willing to contribute PR when we reach a proper solution. Let's use this info as a starting point for discussion. Thanks!

Pull request:
Performs line wrapping even when including TypeScript syntax inside script tags
This PR closes #76.

Patch:
diff --git a/src/packages/core-parts/finder.ts b/src/packages/core-parts/finder.ts
index 8e5b188..c35a920 100644
--- a/src/packages/core-parts/finder.ts
+++ b/src/packages/core-parts/finder.ts
@@ -1,8 +1,7 @@
-import type { ZodTypeAny, infer as ZodInfer } from 'zod';
 import { z } from 'zod';
 
 import type { Dict, NodeRange, ExpressionNode, ClassNameNode } from './shared';
-import { EOL, SINGLE_QUOTE, DOUBLE_QUOTE, BACKTICK, UNKNOWN_DELIMITER } from './shared';
+import { EOL, SINGLE_QUOTE, DOUBLE_QUOTE, BACKTICK, UNKNOWN_DELIMITER, isTypeof } from './shared';
 
 type ASTNode = {
   type: string;
@@ -20,10 +19,6 @@ type JSXOpeningElementNameAsObject = {
   property: JSXOpeningElementNameAsString;
 };
 
-function isTypeof<T extends ZodTypeAny>(arg: unknown, expectedSchema: T): arg is ZodInfer<T> {
-  return expectedSchema.safeParse(arg).success;
-}
-
 function getElementName(
   param: JSXOpeningElementNameAsString | JSXOpeningElementNameAsObject,
 ): string {
@@ -2251,6 +2246,7 @@ export function findTargetClassNameNodesForSvelte(
   formattedText: string,
   ast: any,
   options: ResolvedOptions,
+  addon: Dict<(text: string, options: any) => any>,
 ): ClassNameNode[] {
   const supportedAttributes: string[] = ['class', 'className', ...options.customAttributes];
   const supportedFunctions: string[] = ['classNames', ...options.customFunctions];
@@ -2409,6 +2405,59 @@ export function findTargetClassNameNodesForSvelte(
     };
 
     switch (node.type) {
+      case 'RefinedScript': {
+        nonCommentNodes.push(currentASTNode);
+
+        if (
+          isTypeof(
+            node,
+            z.object({
+              content: z.object({
+                start: z.number(),
+                end: z.number(),
+                loc: z.object({
+                  start: z.object({
+                    line: z.number(),
+                  }),
+                }),
+                value: z.string(),
+              }),
+            }),
+          )
+        ) {
+          // Note: In fact, the script element is not a `keywordStartingNode`, but it is considered a kind of safe list to maintain the `classNameNode`s obtained from the code inside the element.
+          keywordStartingNodes.push(currentASTNode);
+
+          const textNodeInScript = node.content;
+
+          if (addon.parseTypescript && textNodeInScript) {
+            const openingTagEndingOffset = node.content.start;
+
+            const typescriptAst = addon.parseTypescript(textNodeInScript.value, {
+              ...options,
+              parser: 'typescript',
+            });
+            const targetClassNameNodesInScript = findTargetClassNameNodes(
+              typescriptAst,
+              options,
+            ).map<ClassNameNode>((classNameNode) => {
+              const [classNameNodeRangeStart, classNameNodeRangeEnd] = classNameNode.range;
+
+              return {
+                ...classNameNode,
+                range: [
+                  classNameNodeRangeStart + openingTagEndingOffset,
+                  classNameNodeRangeEnd + openingTagEndingOffset,
+                ],
+                startLineIndex: classNameNode.startLineIndex + node.content.loc.start.line - 1,
+              };
+            });
+
+            classNameNodes.push(...targetClassNameNodesInScript);
+          }
+        }
+        break;
+      }
       case 'CallExpression': {
         nonCommentNodes.push(currentASTNode);
 
diff --git a/src/packages/core-parts/index.ts b/src/packages/core-parts/index.ts
index 497c434..24eb27c 100644
--- a/src/packages/core-parts/index.ts
+++ b/src/packages/core-parts/index.ts
@@ -1,4 +1,5 @@
 import { createHash } from 'node:crypto';
+import { z } from 'zod';
 
 import { parseAndAssemble } from './experimental';
 import {
@@ -10,7 +11,7 @@ import {
   findTargetClassNameNodesForSvelte,
 } from './finder';
 import type { Dict, ClassNameNode } from './shared';
-import { EOL, PH, SPACE, TAB, SINGLE_QUOTE, DOUBLE_QUOTE, BACKTICK } from './shared';
+import { EOL, PH, SPACE, TAB, SINGLE_QUOTE, DOUBLE_QUOTE, BACKTICK, isTypeof } from './shared';
 
 type LineNode = {
   indentLevel: number;
@@ -156,6 +157,10 @@ function freezeClassName(input: string): string {
   return `${prefix}${rest}`;
 }
 
+function base64Decode(input: string): string {
+  return Buffer.from(input, 'base64').toString('utf8');
+}
+
 function replaceClassName({
   formattedText,
   indentUnit,
@@ -473,7 +478,7 @@ export function parseLineByLineAndReplace({
       break;
     }
     case 'svelte': {
-      targetClassNameNodes = findTargetClassNameNodesForSvelte(formattedText, ast, options);
+      targetClassNameNodes = findTargetClassNameNodesForSvelte(formattedText, ast, options, addon);
       break;
     }
     case 'angular': {
@@ -835,7 +840,7 @@ export async function parseLineByLineAndReplaceAsync({
       break;
     }
     case 'svelte': {
-      targetClassNameNodes = findTargetClassNameNodesForSvelte(formattedText, ast, options);
+      targetClassNameNodes = findTargetClassNameNodesForSvelte(formattedText, ast, options, addon);
       break;
     }
     case 'angular': {
@@ -873,3 +878,88 @@ export async function parseLineByLineAndReplaceAsync({
 
   return classNameWrappedText;
 }
+
+export function refineSvelteAst(preprocessedText: string, ast: any) {
+  if (!ast.instance) {
+    return ast;
+  }
+
+  const scriptTag = preprocessedText.slice(ast.instance.start, ast.instance.end);
+  const matchResult = scriptTag.match(/ ✂prettier:content✂="([^"]+)"/);
+
+  if (matchResult === null) {
+    return ast;
+  }
+
+  const [temporaryAttributeWithLeadingSpace, encodedContent] = matchResult;
+  const plainContent = base64Decode(encodedContent);
+
+  const restoreOffset =
+    plainContent.length - (temporaryAttributeWithLeadingSpace.length + '{}'.length);
+
+  function recursion(node: unknown): void {
+    if (!isTypeof(node, z.object({ type: z.string() }))) {
+      return;
+    }
+
+    Object.entries(node).forEach(([key, value]) => {
+      if (key === 'type') {
+        return;
+      }
+
+      if (Array.isArray(value)) {
+        value.forEach((childNode: unknown) => {
+          recursion(childNode);
+        });
+        return;
+      }
+
+      recursion(value);
+    });
+
+    if (
+      !isTypeof(
+        node,
+        z.object({
+          start: z.number(),
+          end: z.number(),
+        }),
+      )
+    ) {
+      return;
+    }
+
+    if (ast.instance.end <= node.start) {
+      node.start += restoreOffset;
+    }
+    if (ast.instance.end <= node.end) {
+      node.end += restoreOffset;
+    }
+  }
+
+  recursion(ast.html);
+
+  ast.instance = {
+    type: 'RefinedScript',
+    start: ast.instance.start,
+    end: ast.instance.end + restoreOffset,
+    loc: {
+      start: {
+        line: preprocessedText.slice(0, ast.instance.start).split(EOL).length,
+      },
+    },
+    content: {
+      type: 'RefinedScriptSource',
+      start: ast.instance.end + restoreOffset - ('</script>'.length + plainContent.length),
+      end: ast.instance.end + restoreOffset - '</script>'.length,
+      loc: {
+        start: {
+          line: ast.instance.content.body[0].loc.start.line,
+        },
+      },
+      value: plainContent,
+    },
+  };
+
+  return ast;
+}
diff --git a/src/packages/core-parts/shared.ts b/src/packages/core-parts/shared.ts
index 00a28af..1bff143 100644
--- a/src/packages/core-parts/shared.ts
+++ b/src/packages/core-parts/shared.ts
@@ -1,3 +1,5 @@
+import type { ZodTypeAny, infer as ZodInfer } from 'zod';
+
 /**
  * end of line
  */
@@ -93,3 +95,10 @@ type PreservingExpressionNode = ClassNameNodeBase & {
 };
 
 export type ClassNameNode = UnknownNode | AttributeNode | ExpressionNode | PreservingExpressionNode;
+
+export function isTypeof<T extends ZodTypeAny>(
+  arg: unknown,
+  expectedSchema: T,
+): arg is ZodInfer<T> {
+  return expectedSchema.safeParse(arg).success;
+}
diff --git a/src/packages/v2-plugin/parsers.ts b/src/packages/v2-plugin/parsers.ts
index 61b958b..b53eabe 100644
--- a/src/packages/v2-plugin/parsers.ts
+++ b/src/packages/v2-plugin/parsers.ts
@@ -1,4 +1,4 @@
-import { parseLineByLineAndReplace } from 'core-parts';
+import { parseLineByLineAndReplace, refineSvelteAst } from 'core-parts';
 import type { Parser, ParserOptions, Plugin } from 'prettier';
 import { format } from 'prettier';
 import { parsers as babelParsers } from 'prettier/parser-babel';
@@ -23,6 +23,24 @@ function addIndent(text: string, width = 2) {
     .join(EOL);
 }
 
+function advancedParse(
+  text: string,
+  parserName: SupportedParserNames,
+  defaultParser: Parser,
+  options: ParserOptions & ThisPluginOptions,
+): any {
+  const preprocessedText = defaultParser.preprocess
+    ? defaultParser.preprocess(text, options)
+    : text;
+  let ast = defaultParser.parse(preprocessedText, { [parserName]: defaultParser }, options);
+
+  if (parserName === 'svelte') {
+    ast = refineSvelteAst(preprocessedText, ast);
+  }
+
+  return ast;
+}
+
 function transformParser(
   parserName: SupportedParserNames,
   defaultParser: Parser,
@@ -94,7 +112,7 @@ function transformParser(
         endOfLine: 'lf',
       });
 
-      const ast = defaultParser.parse(firstFormattedText, { [parserName]: defaultParser }, options);
+      const ast = advancedParse(firstFormattedText, parserName, defaultParser, options);
       const classNameWrappedText = parseLineByLineAndReplace({
         formattedText: firstFormattedText,
         ast,
@@ -133,11 +151,7 @@ function transformParser(
         };
       }
 
-      const secondAst = defaultParser.parse(
-        secondFormattedText,
-        { [parserName]: defaultParser },
-        options,
-      );
+      const secondAst = advancedParse(secondFormattedText, parserName, defaultParser, options);
       const classNameSecondWrappedText = parseLineByLineAndReplace({
         formattedText: secondFormattedText,
         ast: secondAst,
diff --git a/src/packages/v3-plugin/parsers.ts b/src/packages/v3-plugin/parsers.ts
index 20cb100..47abea9 100644
--- a/src/packages/v3-plugin/parsers.ts
+++ b/src/packages/v3-plugin/parsers.ts
@@ -1,4 +1,4 @@
-import { parseLineByLineAndReplaceAsync } from 'core-parts';
+import { parseLineByLineAndReplaceAsync, refineSvelteAst } from 'core-parts';
 import type { Parser, ParserOptions, Plugin } from 'prettier';
 import { format } from 'prettier';
 import { parsers as babelParsers } from 'prettier/plugins/babel';
@@ -22,6 +22,24 @@ function addIndent(text: string, width = 2) {
     .join(EOL);
 }
 
+async function advancedParse(
+  text: string,
+  parserName: SupportedParserNames,
+  defaultParser: Parser,
+  options: ParserOptions & ThisPluginOptions,
+): Promise<any> {
+  const preprocessedText = defaultParser.preprocess
+    ? defaultParser.preprocess(text, options)
+    : text;
+  let ast = await defaultParser.parse(preprocessedText, options);
+
+  if (parserName === 'svelte') {
+    ast = refineSvelteAst(preprocessedText, ast);
+  }
+
+  return ast;
+}
+
 function transformParser(
   parserName: SupportedParserNames,
   defaultParser: Parser,
@@ -95,7 +113,7 @@ function transformParser(
         endOfLine: 'lf',
       });
 
-      const ast = await defaultParser.parse(firstFormattedText, options);
+      const ast = await advancedParse(firstFormattedText, parserName, defaultParser, options);
       const classNameWrappedText = await parseLineByLineAndReplaceAsync({
         formattedText: firstFormattedText,
         ast,
@@ -137,7 +155,12 @@ function transformParser(
         };
       }
 
-      const secondAst = await defaultParser.parse(secondFormattedText, options);
+      const secondAst = await advancedParse(
+        secondFormattedText,
+        parserName,
+        defaultParser,
+        options,
+      );
       const classNameSecondWrappedText = await parseLineByLineAndReplaceAsync({
         formattedText: secondFormattedText,
         ast: secondAst,


Test patch:
diff --git a/tests/v2-test/svelte/issue-76/__snapshots__/absolute.test.ts.snap b/tests/v2-test/svelte/issue-76/__snapshots__/absolute.test.ts.snap
new file mode 100644
index 0000000..2a3688c
--- /dev/null
+++ b/tests/v2-test/svelte/issue-76/__snapshots__/absolute.test.ts.snap
@@ -0,0 +1,17 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`'(1) Line wrapping must be performed even when including TypeScript syntax inside script tag.' > expectation 1`] = `
+"<!-- ------------------------------------------------------| printWidth=60 (in snapshot) -->
+<script lang="ts">
+  let inputElement: HTMLInputElement;
+</script>
+
+<div class="relative">
+  <input
+    class="text-primary-700 w-full rounded-md border
+border-gray-300 px-3 py-2 font-semibold focus:outline-none
+focus:ring-2 focus:ring-blue-500"
+  />
+</div>
+"
+`;
diff --git a/tests/v2-test/svelte/issue-76/__snapshots__/ideal.test.ts.snap b/tests/v2-test/svelte/issue-76/__snapshots__/ideal.test.ts.snap
new file mode 100644
index 0000000..e629902
--- /dev/null
+++ b/tests/v2-test/svelte/issue-76/__snapshots__/ideal.test.ts.snap
@@ -0,0 +1,17 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`'(1) Line wrapping must be performed even when including TypeScript syntax inside script tag.' > expectation 1`] = `
+"<!-- ------------------------------------------------------| printWidth=60 (in snapshot) -->
+<script lang="ts">
+  let inputElement: HTMLInputElement;
+</script>
+
+<div class="relative">
+  <input
+    class="text-primary-700 w-full rounded-md border
+      border-gray-300 px-3 py-2 font-semibold
+      focus:outline-none focus:ring-2 focus:ring-blue-500"
+  />
+</div>
+"
+`;
diff --git a/tests/v2-test/svelte/issue-76/__snapshots__/relative.test.ts.snap b/tests/v2-test/svelte/issue-76/__snapshots__/relative.test.ts.snap
new file mode 100644
index 0000000..650323f
--- /dev/null
+++ b/tests/v2-test/svelte/issue-76/__snapshots__/relative.test.ts.snap
@@ -0,0 +1,17 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`'(1) Line wrapping must be performed even when including TypeScript syntax inside script tag.' > expectation 1`] = `
+"<!-- ------------------------------------------------------| printWidth=60 (in snapshot) -->
+<script lang="ts">
+  let inputElement: HTMLInputElement;
+</script>
+
+<div class="relative">
+  <input
+    class="text-primary-700 w-full rounded-md border border-gray-300
+      px-3 py-2 font-semibold focus:outline-none focus:ring-2
+      focus:ring-blue-500"
+  />
+</div>
+"
+`;
diff --git a/tests/v2-test/svelte/issue-76/absolute.test.ts b/tests/v2-test/svelte/issue-76/absolute.test.ts
new file mode 100644
index 0000000..57d6136
--- /dev/null
+++ b/tests/v2-test/svelte/issue-76/absolute.test.ts
@@ -0,0 +1,13 @@
+import { baseOptions } from 'test-settings';
+
+import { thisPlugin, testSnapshotEach } from '../../adaptor';
+import { fixtures } from './fixtures';
+
+const options = {
+  ...baseOptions,
+  plugins: ['prettier-plugin-svelte', thisPlugin],
+  parser: 'svelte',
+  endingPosition: 'absolute',
+};
+
+testSnapshotEach(fixtures, options);
diff --git a/tests/v2-test/svelte/issue-76/fixtures.ts b/tests/v2-test/svelte/issue-76/fixtures.ts
new file mode 100644
index 0000000..1e6df68
--- /dev/null
+++ b/tests/v2-test/svelte/issue-76/fixtures.ts
@@ -0,0 +1,22 @@
+import type { Fixture } from 'test-settings';
+
+export const fixtures: Omit<Fixture, 'output'>[] = [
+  {
+    name: '(1) Line wrapping must be performed even when including TypeScript syntax inside script tag.',
+    input: `
+<!-- ------------------------------------------------------| printWidth=60 (in snapshot) -->
+<script lang="ts">
+  let inputElement: HTMLInputElement;
+</script>
+
+<div class="relative">
+  <input
+    class="text-primary-700 w-full rounded-md border border-gray-300 px-3 py-2 font-semibold focus:outline-none focus:ring-2 focus:ring-blue-500"
+  />
+</div>
+`,
+    options: {
+      printWidth: 60,
+    },
+  },
+];
diff --git a/tests/v2-test/svelte/issue-76/ideal.test.ts b/tests/v2-test/svelte/issue-76/ideal.test.ts
new file mode 100644
index 0000000..ae60cad
--- /dev/null
+++ b/tests/v2-test/svelte/issue-76/ideal.test.ts
@@ -0,0 +1,13 @@
+import { baseOptions } from 'test-settings';
+
+import { thisPlugin, testSnapshotEach } from '../../adaptor';
+import { fixtures } from './fixtures';
+
+const options = {
+  ...baseOptions,
+  plugins: ['prettier-plugin-svelte', thisPlugin],
+  parser: 'svelte',
+  endingPosition: 'absolute-with-indent',
+};
+
+testSnapshotEach(fixtures, options);
diff --git a/tests/v2-test/svelte/issue-76/relative.test.ts b/tests/v2-test/svelte/issue-76/relative.test.ts
new file mode 100644
index 0000000..0613daa
--- /dev/null
+++ b/tests/v2-test/svelte/issue-76/relative.test.ts
@@ -0,0 +1,13 @@
+import { baseOptions } from 'test-settings';
+
+import { thisPlugin, testSnapshotEach } from '../../adaptor';
+import { fixtures } from './fixtures';
+
+const options = {
+  ...baseOptions,
+  plugins: ['prettier-plugin-svelte', thisPlugin],
+  parser: 'svelte',
+  endingPosition: 'relative',
+};
+
+testSnapshotEach(fixtures, options);
diff --git a/tests/v3-test/svelte/issue-76/__snapshots__/absolute.test.ts.snap b/tests/v3-test/svelte/issue-76/__snapshots__/absolute.test.ts.snap
new file mode 100644
index 0000000..2a3688c
--- /dev/null
+++ b/tests/v3-test/svelte/issue-76/__snapshots__/absolute.test.ts.snap
@@ -0,0 +1,17 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`'(1) Line wrapping must be performed even when including TypeScript syntax inside script tag.' > expectation 1`] = `
+"<!-- ------------------------------------------------------| printWidth=60 (in snapshot) -->
+<script lang="ts">
+  let inputElement: HTMLInputElement;
+</script>
+
+<div class="relative">
+  <input
+    class="text-primary-700 w-full rounded-md border
+border-gray-300 px-3 py-2 font-semibold focus:outline-none
+focus:ring-2 focus:ring-blue-500"
+  />
+</div>
+"
+`;
diff --git a/tests/v3-test/svelte/issue-76/__snapshots__/ideal.test.ts.snap b/tests/v3-test/svelte/issue-76/__snapshots__/ideal.test.ts.snap
new file mode 100644
index 0000000..e629902
--- /dev/null
+++ b/tests/v3-test/svelte/issue-76/__snapshots__/ideal.test.ts.snap
@@ -0,0 +1,17 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`'(1) Line wrapping must be performed even when including TypeScript syntax inside script tag.' > expectation 1`] = `
+"<!-- ------------------------------------------------------| printWidth=60 (in snapshot) -->
+<script lang="ts">
+  let inputElement: HTMLInputElement;
+</script>
+
+<div class="relative">
+  <input
+    class="text-primary-700 w-full rounded-md border
+      border-gray-300 px-3 py-2 font-semibold
+      focus:outline-none focus:ring-2 focus:ring-blue-500"
+  />
+</div>
+"
+`;
diff --git a/tests/v3-test/svelte/issue-76/__snapshots__/relative.test.ts.snap b/tests/v3-test/svelte/issue-76/__snapshots__/relative.test.ts.snap
new file mode 100644
index 0000000..650323f
--- /dev/null
+++ b/tests/v3-test/svelte/issue-76/__snapshots__/relative.test.ts.snap
@@ -0,0 +1,17 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`'(1) Line wrapping must be performed even when including TypeScript syntax inside script tag.' > expectation 1`] = `
+"<!-- ------------------------------------------------------| printWidth=60 (in snapshot) -->
+<script lang="ts">
+  let inputElement: HTMLInputElement;
+</script>
+
+<div class="relative">
+  <input
+    class="text-primary-700 w-full rounded-md border border-gray-300
+      px-3 py-2 font-semibold focus:outline-none focus:ring-2
+      focus:ring-blue-500"
+  />
+</div>
+"
+`;
diff --git a/tests/v3-test/svelte/issue-76/absolute.test.ts b/tests/v3-test/svelte/issue-76/absolute.test.ts
new file mode 100644
index 0000000..57d6136
--- /dev/null
+++ b/tests/v3-test/svelte/issue-76/absolute.test.ts
@@ -0,0 +1,13 @@
+import { baseOptions } from 'test-settings';
+
+import { thisPlugin, testSnapshotEach } from '../../adaptor';
+import { fixtures } from './fixtures';
+
+const options = {
+  ...baseOptions,
+  plugins: ['prettier-plugin-svelte', thisPlugin],
+  parser: 'svelte',
+  endingPosition: 'absolute',
+};
+
+testSnapshotEach(fixtures, options);
diff --git a/tests/v3-test/svelte/issue-76/fixtures.ts b/tests/v3-test/svelte/issue-76/fixtures.ts
new file mode 100644
index 0000000..1e6df68
--- /dev/null
+++ b/tests/v3-test/svelte/issue-76/fixtures.ts
@@ -0,0 +1,22 @@
+import type { Fixture } from 'test-settings';
+
+export const fixtures: Omit<Fixture, 'output'>[] = [
+  {
+    name: '(1) Line wrapping must be performed even when including TypeScript syntax inside script tag.',
+    input: `
+<!-- ------------------------------------------------------| printWidth=60 (in snapshot) -->
+<script lang="ts">
+  let inputElement: HTMLInputElement;
+</script>
+
+<div class="relative">
+  <input
+    class="text-primary-700 w-full rounded-md border border-gray-300 px-3 py-2 font-semibold focus:outline-none focus:ring-2 focus:ring-blue-500"
+  />
+</div>
+`,
+    options: {
+      printWidth: 60,
+    },
+  },
+];
diff --git a/tests/v3-test/svelte/issue-76/ideal.test.ts b/tests/v3-test/svelte/issue-76/ideal.test.ts
new file mode 100644
index 0000000..ae60cad
--- /dev/null
+++ b/tests/v3-test/svelte/issue-76/ideal.test.ts
@@ -0,0 +1,13 @@
+import { baseOptions } from 'test-settings';
+
+import { thisPlugin, testSnapshotEach } from '../../adaptor';
+import { fixtures } from './fixtures';
+
+const options = {
+  ...baseOptions,
+  plugins: ['prettier-plugin-svelte', thisPlugin],
+  parser: 'svelte',
+  endingPosition: 'absolute-with-indent',
+};
+
+testSnapshotEach(fixtures, options);
diff --git a/tests/v3-test/svelte/issue-76/relative.test.ts b/tests/v3-test/svelte/issue-76/relative.test.ts
new file mode 100644
index 0000000..0613daa
--- /dev/null
+++ b/tests/v3-test/svelte/issue-76/relative.test.ts
@@ -0,0 +1,13 @@
+import { baseOptions } from 'test-settings';
+
+import { thisPlugin, testSnapshotEach } from '../../adaptor';
+import { fixtures } from './fixtures';
+
+const options = {
+  ...baseOptions,
+  plugins: ['prettier-plugin-svelte', thisPlugin],
+  parser: 'svelte',
+  endingPosition: 'relative',
+};
+
+testSnapshotEach(fixtures, options);