fix(monaco): highlight Svelte block closers inside markup

Return Svelte block closers to the existing markup tokenizer state.

Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Wooseong Kim
2026-10-03 00:52:00 -07:00
committed by GitHub
co-authored by Wooseong Kim Cursor Neil
parent a5f28f265c
commit df9acd83d4
2 changed files with 83 additions and 4 deletions
@@ -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', () => {
" <p>{count} clicked</p> | 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",
" <p>not yet</p> | 0:-@html | embed=html",
"{/if} | 0:-@html | embed=html",
" | 0:-@html | embed=html",
"{/if} | 0:keyword.control.svelte@svelte | embed=none",
" | | embed=html",
"<button on:click={increment}>{count}</button> | 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 '<em>raw</em>'} | 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(`<p>before</p>\n${closer}\n<p>after</p>`)
expect(tokenTypeAt(lines[1], 0)).toBe('keyword.control')
expect(tokenLanguages(lines[1])).toEqual(['svelte'])
expect(tokenLanguages(lines[2])).toEqual(['html'])
const [sameLine] = tokenizeSvelte(`<p>before</p>${closer}<p>after</p>`)
expect(tokenTypeAt(sameLine, '<p>before</p>'.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 <p>{item}</p>\n{/each}\n<style>\n p { color: red; }\n</style>'
)
).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 =
'<p>before</p>{/if}<p>{value}</p><script>let a = 1</script><style>p {color:red}</style>'
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 = `<p>${closerWithText.repeat(1000)}<p>after</p>`
const tokenizer = createMonarchTokenizer('svelte', svelteMonarchLanguage)
const measurement = measureNestedDepth(tokenizer, ['<p>before</p>', longLine, '<p>next</p>'])
expect(measurement.error).toBeUndefined()
expect(measurement.maxNestedDepth).toBeGreaterThan(1)
expect(measurement.maxNestedDepth).toBeLessThanOrEqual(EMBED_ENTRY_REST_OF_LINE_BUDGET)
const lines = tokenizeSvelte(`<p>before</p>\n${longLine}\n<p>next</p>`)
expect(tokenTypeAt(lines[1], '<p>'.length)).toBe('keyword.control')
expect(tokenTypeAt(lines[1], '<p>'.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.
@@ -79,7 +79,11 @@ export const svelteMonarchLanguage: Monaco.languages.IMonarchLanguage = {
],
[/<style(?=\s|>)/, { token: 'tag', switchTo: '@styleOpen.css', nextEmbedded: '@pop' }],
[/<!--/, { token: 'comment', switchTo: '@comment', nextEmbedded: '@pop' }],
[/\{\s*\/(if|each|await|key|snippet)\s*\}/, 'keyword.control'],
// Only pop rules can interrupt the active HTML embed for a Svelte closer.
[
/\{\s*\/(if|each|await|key|snippet)\s*\}/,
{ token: 'keyword.control', switchTo: '@markupReenter', nextEmbedded: '@pop' }
],
[
/\{\s*#(if|each|await|key|snippet)\b/,
{ token: 'keyword.control', switchTo: '@svelteBlockExpressionEnter', nextEmbedded: '@pop' }