From df9acd83d407a2bef1178c1f70bb54afd449806f Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:52:00 +0900 Subject: [PATCH] fix(monaco): highlight Svelte block closers inside markup Return Svelte block closers to the existing markup tokenizer state. Co-authored-by: Wooseong Kim Co-authored-by: Cursor Co-authored-by: Neil --- .../monaco-languages/register-svelte.test.ts | 81 ++++++++++++++++++- .../lib/monaco-languages/register-svelte.ts | 6 +- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/lib/monaco-languages/register-svelte.test.ts b/src/renderer/src/lib/monaco-languages/register-svelte.test.ts index 866ecf21ad3..fc9304f8dc7 100644 --- a/src/renderer/src/lib/monaco-languages/register-svelte.test.ts +++ b/src/renderer/src/lib/monaco-languages/register-svelte.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest' +import { EMBED_ENTRY_REST_OF_LINE_BUDGET } from './monarch-embed-entry-budget' import { + createMonarchTokenizer, endEmbeddedLanguages, formatTokenizedLines, + measureNestedDepth, tokenizeMonarchDocument, tokenLanguages, tokenLanguagesPerLine, @@ -94,8 +97,8 @@ describe('svelte tokenization', () => { "

{count} clicked

| 0:-@html 5:delimiter.curly.svelte@svelte 6:-@typescript 11:delimiter.curly.svelte@svelte 12:-@html | embed=html", "{:else} | 0:keyword.control.svelte@svelte | embed=none", "

not yet

| 0:-@html | embed=html", - "{/if} | 0:-@html | embed=html", - " | 0:-@html | embed=html", + "{/if} | 0:keyword.control.svelte@svelte | embed=none", + " | | embed=html", " | 0:-@html 17:delimiter.curly.svelte@svelte 18:-@typescript 27:delimiter.curly.svelte@svelte 28:-@html 29:delimiter.curly.svelte@svelte 30:-@typescript 35:delimiter.curly.svelte@svelte 36:-@html | embed=html", "{@html 'raw'} | 0:keyword.control.svelte@svelte 6:-@typescript 21:delimiter.curly.svelte@svelte | embed=none", " | | embed=html", @@ -159,11 +162,83 @@ describe('svelte tokenization', () => { ['html'], ['svelte'], ['html'], - ['html'], + ['svelte'], ['html'] ]) }) + it.each(['if', 'each', 'await', 'key', 'snippet'])( + 'highlights %s closers and resumes HTML with or without whitespace', + (name) => { + for (const closer of [`{/${name}}`, `{ \t/${name} \t}`]) { + const lines = tokenizeSvelte(`

before

\n${closer}\n

after

`) + expect(tokenTypeAt(lines[1], 0)).toBe('keyword.control') + expect(tokenLanguages(lines[1])).toEqual(['svelte']) + expect(tokenLanguages(lines[2])).toEqual(['html']) + + const [sameLine] = tokenizeSvelte(`

before

${closer}

after

`) + expect(tokenTypeAt(sameLine, '

before

'.length)).toBe('keyword.control') + expect(tokenLanguages(sameLine)).toEqual(['html', 'svelte', 'html']) + + const [fileStartCloser] = tokenizeSvelte(closer) + expect(tokenTypeAt(fileStartCloser, 0)).toBe('keyword.control') + expect(tokenLanguages(fileStartCloser)).toEqual(['svelte']) + } + } + ) + + it('preserves a CSS block after a Svelte closer', () => { + expect( + languagesPerLine( + '{#each items as item}\n

{item}

\n{/each}\n' + ) + ).toEqual([ + ['svelte', 'typescript', 'svelte'], + ['html', 'svelte', 'typescript', 'svelte', 'html'], + ['svelte'], + ['svelte'], + ['css'], + ['svelte'] + ]) + }) + + it('resumes HTML, expressions, script and CSS after an inline closer', () => { + const source = + '

before

{/if}

{value}

' + expect(tokenLanguages(tokenizeSvelte(source)[0])).toEqual([ + 'html', + 'svelte', + 'html', + 'svelte', + 'typescript', + 'svelte', + 'html', + 'svelte', + 'typescript', + 'svelte', + 'css', + 'svelte' + ]) + }) + + it('keeps repeated closer reentry within the embed budget and recovers on the next line', () => { + // HTML text between closers forces repeated embed reentry after the fallback. + const closerWithText = '{/if}a' + const longLine = `

${closerWithText.repeat(1000)}

after

` + const tokenizer = createMonarchTokenizer('svelte', svelteMonarchLanguage) + const measurement = measureNestedDepth(tokenizer, ['

before

', longLine, '

next

']) + expect(measurement.error).toBeUndefined() + expect(measurement.maxNestedDepth).toBeGreaterThan(1) + expect(measurement.maxNestedDepth).toBeLessThanOrEqual(EMBED_ENTRY_REST_OF_LINE_BUDGET) + + const lines = tokenizeSvelte(`

before

\n${longLine}\n

next

`) + expect(tokenTypeAt(lines[1], '

'.length)).toBe('keyword.control') + expect(tokenTypeAt(lines[1], '

'.length + closerWithText.length * 999)).toBe( + 'keyword.control' + ) + expect(tokenLanguages(lines[2])).toEqual(['html']) + }) + it('keeps markup highlighted across a whole multi-line file', () => { // A grammar that drops the embed leaves plain `svelte` on these rows, which // is the silently-unhighlighted failure a rule-table walk cannot see. diff --git a/src/renderer/src/lib/monaco-languages/register-svelte.ts b/src/renderer/src/lib/monaco-languages/register-svelte.ts index 7e4b0cc0292..65df1ead6e9 100644 --- a/src/renderer/src/lib/monaco-languages/register-svelte.ts +++ b/src/renderer/src/lib/monaco-languages/register-svelte.ts @@ -79,7 +79,11 @@ export const svelteMonarchLanguage: Monaco.languages.IMonarchLanguage = { ], [/)/, { token: 'tag', switchTo: '@styleOpen.css', nextEmbedded: '@pop' }], [/