diff --git a/src/Highlighter.php b/src/Highlighter.php index da7b85c..aacf3dd 100644 --- a/src/Highlighter.php +++ b/src/Highlighter.php @@ -27,6 +27,7 @@ use Tempest\Highlight\Languages\Python\PythonLanguage; use Tempest\Highlight\Languages\Scss\ScssLanguage; use Tempest\Highlight\Languages\Sql\SqlLanguage; +use Tempest\Highlight\Languages\Svelte\SvelteLanguage; use Tempest\Highlight\Languages\Terminal\TerminalLanguage; use Tempest\Highlight\Languages\Terraform\TerraformLanguage; use Tempest\Highlight\Languages\Text\TextLanguage; @@ -86,7 +87,8 @@ public function __construct( ->addLanguage(new YamlLanguage()) ->addLanguage(new DotEnvLanguage()) ->addLanguage(new IniLanguage()) - ->addLanguage(new TwigLanguage()); + ->addLanguage(new TwigLanguage()) + ->addLanguage(new SvelteLanguage()); $this->parseTokens = new ParseTokens(); $this->groupTokens = new GroupTokens(); diff --git a/src/Languages/JavaScript/Patterns/JsMethodPattern.php b/src/Languages/JavaScript/Patterns/JsMethodPattern.php index b035af2..3a048c4 100644 --- a/src/Languages/JavaScript/Patterns/JsMethodPattern.php +++ b/src/Languages/JavaScript/Patterns/JsMethodPattern.php @@ -10,13 +10,15 @@ use Tempest\Highlight\Tokens\TokenTypeEnum; #[PatternTest(input: 'calcArea() {', output: 'calcArea')] +#[PatternTest(input: 'calc_area() {', output: 'calc_area')] +#[PatternTest(input: '$init() {', output: '$init')] final readonly class JsMethodPattern implements Pattern { use IsPattern; public function getPattern(): string { - return '(?[\w]+)\('; + return '(?[\w\$]+)\('; } public function getTokenType(): TokenTypeEnum diff --git a/src/Languages/Svelte/Injections/SvelteBlockExpressionInjection.php b/src/Languages/Svelte/Injections/SvelteBlockExpressionInjection.php new file mode 100644 index 0000000..77ab516 --- /dev/null +++ b/src/Languages/Svelte/Injections/SvelteBlockExpressionInjection.php @@ -0,0 +1,24 @@ +(?:[^{}]++|\{(?&match)\})*+)\}'; + } + + public function parseContent(string $content, Highlighter $highlighter): string + { + return $highlighter->parse($content, 'typescript'); + } +} diff --git a/src/Languages/Svelte/Injections/SvelteExpressionInjection.php b/src/Languages/Svelte/Injections/SvelteExpressionInjection.php new file mode 100644 index 0000000..be60d58 --- /dev/null +++ b/src/Languages/Svelte/Injections/SvelteExpressionInjection.php @@ -0,0 +1,29 @@ + count++}', output: '() => count++')] +#[PatternTest(input: 'onclick={() => { updateTab() }}', output: '() => { updateTab() }')] +final class SvelteExpressionInjection implements Injection +{ + use IsInjection; + + public function getPattern(): string + { + return '\{(?[^#@:\/{}](?:[^{}]++|\{(?&match)\})*+)\}'; + } + + public function parseContent(string $content, Highlighter $highlighter): string + { + return $highlighter->parse($content, 'typescript'); + } +} diff --git a/src/Languages/Svelte/Injections/SvelteTypeScriptInjection.php b/src/Languages/Svelte/Injections/SvelteTypeScriptInjection.php new file mode 100644 index 0000000..c62ecf8 --- /dev/null +++ b/src/Languages/Svelte/Injections/SvelteTypeScriptInjection.php @@ -0,0 +1,24 @@ +(?[\s\S]*?)<\/script>'; + } + + public function parseContent(string $content, Highlighter $highlighter): string + { + return $highlighter->parse($content, 'typescript'); + } +} diff --git a/src/Languages/Svelte/Patterns/SvelteBlockKeywordPattern.php b/src/Languages/Svelte/Patterns/SvelteBlockKeywordPattern.php new file mode 100644 index 0000000..95b9da0 --- /dev/null +++ b/src/Languages/Svelte/Patterns/SvelteBlockKeywordPattern.php @@ -0,0 +1,24 @@ +[#@:\/]\w+)\b'; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::KEYWORD; + } +} diff --git a/src/Languages/Svelte/Patterns/SvelteDirectiveArgumentPattern.php b/src/Languages/Svelte/Patterns/SvelteDirectiveArgumentPattern.php new file mode 100644 index 0000000..e26c4dc --- /dev/null +++ b/src/Languages/Svelte/Patterns/SvelteDirectiveArgumentPattern.php @@ -0,0 +1,24 @@ +[\w-]+)'; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::PROPERTY; + } +} diff --git a/src/Languages/Svelte/Patterns/SvelteDirectivePattern.php b/src/Languages/Svelte/Patterns/SvelteDirectivePattern.php new file mode 100644 index 0000000..4c33b76 --- /dev/null +++ b/src/Languages/Svelte/Patterns/SvelteDirectivePattern.php @@ -0,0 +1,24 @@ +(?:bind|use|transition|in|out|animate|on|class|style)):'; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::PROPERTY; + } +} diff --git a/src/Languages/Svelte/SvelteLanguage.php b/src/Languages/Svelte/SvelteLanguage.php new file mode 100644 index 0000000..79354ec --- /dev/null +++ b/src/Languages/Svelte/SvelteLanguage.php @@ -0,0 +1,46 @@ +(initial)', output: 'createList')] +#[PatternTest(input: "getRole<'user' | 'admin'>(initial)", output: 'getRole')] +#[PatternTest(input: 'getRole<"user" | "admin">(initial)', output: 'getRole')] +final readonly class TsMethodPattern implements Pattern +{ + use IsPattern; + + public function getPattern(): string + { + return '(?[\w\$]+)<[A-Z\'"][\w\s,\.\[\]\'"|]*>\('; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::PROPERTY; + } +} diff --git a/src/Languages/TypeScript/TypeScriptLanguage.php b/src/Languages/TypeScript/TypeScriptLanguage.php index 566c1b5..477059c 100644 --- a/src/Languages/TypeScript/TypeScriptLanguage.php +++ b/src/Languages/TypeScript/TypeScriptLanguage.php @@ -10,6 +10,7 @@ use Tempest\Highlight\Languages\TypeScript\Patterns\TsBuiltInTypePattern; use Tempest\Highlight\Languages\TypeScript\Patterns\TsDecoratorPattern; use Tempest\Highlight\Languages\TypeScript\Patterns\TsGenericPattern; +use Tempest\Highlight\Languages\TypeScript\Patterns\TsMethodPattern; use Tempest\Highlight\Languages\TypeScript\Patterns\TsTypeAnnotationPattern; class TypeScriptLanguage extends JavaScriptLanguage @@ -41,6 +42,7 @@ public function getPatterns(): array new TsTypeAnnotationPattern(), new TsDecoratorPattern(), new TsGenericPattern(), + new TsMethodPattern(), ]; } } diff --git a/tests/Bench/Fixtures/svelte.txt b/tests/Bench/Fixtures/svelte.txt new file mode 100644 index 0000000..e4bd449 --- /dev/null +++ b/tests/Bench/Fixtures/svelte.txt @@ -0,0 +1,93 @@ + + + + + +
{ e.preventDefault(); add(); }}> + + +
+ + + +
    + {#each visible as todo (todo.id)} +
  • + + {todo.text} +
  • + {:else} +
  • Nothing here.
  • + {/each} +
+ +{#if filter === 'all'} +

Showing everything.

+{:else if filter === 'active'} +

Showing active todos.

+{:else if filter === 'done'} +

Showing completed todos.

+{:else} +

Unknown filter.

+{/if} + + + +{#each entries as { key, value }} +
{key}
+
{value}
+{/each} + +{#each todos.filter((t) => ({ ...t, label: t.text.trim() })) as todo (todo.id)} +
  • {todo.label}
  • +{/each} + +{#each Object.entries({ a: 1, b: 2, c: 3 }) as [key, value]} +

    {key} = {value}

    +{/each} + +{#if items.some((x) => x.meta?.tags?.includes('urgent'))} + {@const summary = { count: items.length, urgent: true }} +

    {summary.count} urgent items

    +{/if} diff --git a/tests/Bench/HighlighterBench.php b/tests/Bench/HighlighterBench.php index 5ca7162..d242f0f 100644 --- a/tests/Bench/HighlighterBench.php +++ b/tests/Bench/HighlighterBench.php @@ -37,6 +37,7 @@ final class HighlighterBench 'python' => 'python.txt', 'scss' => 'scss.txt', 'sql' => 'sql.txt', + 'svelte' => 'svelte.txt', 'terminal' => 'terminal.txt', 'terraform' => 'terraform.txt', 'typescript' => 'typescript.txt', diff --git a/tests/Languages/Svelte/SvelteLanguageTest.php b/tests/Languages/Svelte/SvelteLanguageTest.php new file mode 100644 index 0000000..b4346f0 --- /dev/null +++ b/tests/Languages/Svelte/SvelteLanguageTest.php @@ -0,0 +1,301 @@ +assertSame( + $expected, + $highlighter->parse($content, 'svelte'), + ); + } + + public static function provide_highlight_cases(): iterable + { + return [ + [ + <<<'TXT' + + + + TXT, + <<<'TXT' + <script lang="ts"> + let count: number = 0; + const increment = (): void => count++; + </script> + + <button on:click={increment}> + Clicked {count} times + </button> + TXT + ], + [ + <<<'TXT' + + + +

    Hello {name}!

    + + + TXT, + <<<'TXT' + <!-- a counter component --> + <script lang="ts"> + let name: string = "world"; + </script> + + <h1>Hello {name}!</h1> + + <style> + h1 { color: red; } + </style> + TXT + ], + [ + '', + '<input type="text" bind:value={name} placeholder="enter name" />', + ], + [ + <<<'TXT' + {#if count > 5} +

    big

    + {:else if count > 0} +

    small

    + {:else} +

    zero

    + {/if} + TXT, + <<<'TXT' + {#if count > 5} + <p>big</p> + {:else if count > 0} + <p>small</p> + {:else} + <p>zero</p> + {/if} + TXT, + ], + [ + <<<'TXT' +
      + {#each items as item, i} +
    • {i}: {item.name}
    • + {/each} +
    + TXT, + <<<'TXT' + <ul> + {#each items as item, i} + <li>{i}: {item.name}</li> + {/each} + </ul> + TXT, + ], + [ + <<<'TXT' + {@const total = a + b} +

    {@html rawContent}

    + TXT, + <<<'TXT' + {@const total = a + b} + <p>{@html rawContent}</p> + TXT, + ], + [ + '

    Length: {items.length}

    ', + '<p>Length: {items.length}</p>', + ], + [ + '
    { e.preventDefault(); add(); }}>', + '<form onsubmit={(e) => { e.preventDefault(); add(); }}>', + ], + [ + <<<'TXT' + {#each entries as { key, value }} +
    {key}
    + {/each} + TXT, + <<<'TXT' + {#each entries as { key, value }} + <dt>{key}</dt> + {/each} + TXT, + ], + [ + <<<'TXT' + {#each todos.filter((t) => ({ ...t, label: t.text })) as todo (todo.id)} +
  • {todo.label}
  • + {/each} + TXT, + <<<'TXT' + {#each todos.filter((t) => ({ ...t, label: t.text })) as todo (todo.id)} + <li>{todo.label}</li> + {/each} + TXT, + ], + [ + <<<'TXT' + {#if items.some((x) => x.meta?.tags?.includes('urgent'))} +

    urgent

    + {/if} + TXT, + <<<'TXT' + {#if items.some((x) => x.meta?.tags?.includes('urgent'))} + <p>urgent</p> + {/if} + TXT, + ], + [ + '{@const summary = { count: items.length, urgent: true }}', + '{@const summary = { count: items.length, urgent: true }}', + ], + [ + <<<'TXT' + {#each Object.entries({ a: 1, b: 2 }) as [key, value]} +

    {key} = {value}

    + {/each} + TXT, + <<<'TXT' + {#each Object.entries({ a: 1, b: 2 }) as [key, value]} + <p>{key} = {value}</p> + {/each} + TXT, + ], + [ + <<<'TXT' + {#await promise} +

    waiting

    + {:then value} +

    {value}

    + {:catch error} +

    {error.message}

    + {/await} + TXT, + <<<'TXT' + {#await promise} + <p>waiting</p> + {:then value} + <p>{value}</p> + {:catch error} + <p>{error.message}</p> + {/await} + TXT, + ], + [ + <<<'TXT' + {#key id} +
    content
    + {/key} + TXT, + <<<'TXT' + {#key id} + <div>content</div> + {/key} + TXT, + ], + [ + <<<'TXT' + {#snippet greet(name)} +

    Hello {name}

    + {/snippet} + {@render greet('world')} + TXT, + <<<'TXT' + {#snippet greet(name)} + <p>Hello {name}</p> + {/snippet} + {@render greet('world')} + TXT, + ], + [ + '{@debug user, count}', + '{@debug user, count}', + ], + [ + '', + '<input class:active={isActive} bind:value={text} on:input={handle} />', + ], + [ + '', + '<Modal {title} {onclose} />', + ], + [ + <<<'TXT' + + TXT, + <<<'TXT' + <script lang="ts"> + let count = $state(0); + let doubled = $derived(count * 2); + </script> + TXT, + ], + [ + <<<'TXT' + {#if x === 1} +

    one

    + {:else if x === 2} +

    two

    + {:else if x === 3} +

    three

    + {:else} +

    other

    + {/if} + TXT, + <<<'TXT' + {#if x === 1} + <p>one</p> + {:else if x === 2} + <p>two</p> + {:else if x === 3} + <p>three</p> + {:else} + <p>other</p> + {/if} + TXT, + ], + [ + <<<'TXT' + {#each groups as group} +

    {group.name}

    + {#each group.items as item} +

    {item}

    + {/each} + {/each} + TXT, + <<<'TXT' + {#each groups as group} + <h2>{group.name}</h2> + {#each group.items as item} + <p>{item}</p> + {/each} + {/each} + TXT, + ], + ]; + } +} diff --git a/tests/Languages/TypeScript/TypeScriptLanguageTest.php b/tests/Languages/TypeScript/TypeScriptLanguageTest.php index 76916ac..46c8503 100644 --- a/tests/Languages/TypeScript/TypeScriptLanguageTest.php +++ b/tests/Languages/TypeScript/TypeScriptLanguageTest.php @@ -65,7 +65,15 @@ interface User { ], [ 'function identity(v: T): T {}', - 'function identity<T>(v: T): T {}', + 'function identity<T>(v: T): T {}', + ], + [ + "function getRole<'user' | 'admin'>() {}", + "function getRole<'user' | 'admin'>() {}", + ], + [ + 'function getRole() {}', + 'function getRole<user" | "admin">() {}', ], [ 'class Service {}', diff --git a/tests/targets/svelte.md b/tests/targets/svelte.md new file mode 100644 index 0000000..4ffc85d --- /dev/null +++ b/tests/targets/svelte.md @@ -0,0 +1,94 @@ +```svelte + + + + + { e.preventDefault(); add(); }}> + + + + + + +
      + {#each visible as todo (todo.id)} +
    • + + {todo.text} +
    • + {:else} +
    • Nothing here.
    • + {/each} +
    + +{#if filter === 'all'} +

    Showing everything.

    +{:else if filter === 'active'} +

    Showing active todos.

    +{:else if filter === 'done'} +

    Showing completed todos.

    +{:else} +

    Unknown filter.

    +{/if} + + + +{#each entries as { key, value }} +
    {key}
    +
    {value}
    +{/each} + +{#each todos.filter((t) => ({ ...t, label: t.text.trim() })) as todo (todo.id)} +
  • {todo.label}
  • +{/each} + +{#each Object.entries({ a: 1, b: 2, c: 3 }) as [key, value]} +

    {key} = {value}

    +{/each} + +{#if items.some((x) => x.meta?.tags?.includes('urgent'))} + {@const summary = { count: items.length, urgent: true }} +

    {summary.count} urgent items

    +{/if} +```