feat(editor): syntax highlighting for Glimmer .gjs/.gts files (#13236)

Register glimmer-js and glimmer-ts Monaco languages that embed
JavaScript/TypeScript as the background script and tokenize each
<template> block as a Handlebars island. Embed entry is bounded by the
shared line budget so long lines cannot blow the Monarch recursion
limit. Mobile maps the new ids to javascript/typescript highlighting.

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Jorge Albarracín
2026-10-28 01:10:23 -07:00
committed by GitHub
co-authored by Neil
parent e3cabd5e3c
commit d155cfba05
7 changed files with 251 additions and 1 deletions
+3 -1
View File
@@ -50,7 +50,9 @@ const LANGUAGE_ALIASES: Record<string, string> = {
zsh: 'bash',
fish: 'bash',
yml: 'yaml',
'cuda-cpp': 'cpp'
'cuda-cpp': 'cpp',
'glimmer-js': 'javascript',
'glimmer-ts': 'typescript'
}
export function resolveMobileSyntaxLanguage(filePath: string, preferredLanguage?: string): string {
@@ -11,6 +11,7 @@ import {
tokenizeLines
} from './monarch-tokenizer-test-harness'
import { astroMonarchLanguage } from './register-astro'
import { glimmerTsMonarchLanguage } from './register-glimmer'
import { svelteMonarchLanguage } from './register-svelte'
import { vueMonarchLanguage } from './register-vue'
@@ -202,3 +203,42 @@ describe('vue embed-entry recursion', () => {
expect(measurement.maxNestedDepth).toBeGreaterThan(0)
})
})
describe('glimmer embed-entry recursion', () => {
const islandLine = (count: number): string =>
`const a = 1; ${'<template>a</template>'.repeat(count)}`
it('stays within the embed budget for a line of template islands', () => {
const ramp = [50, 200, 500, 900].filter(
(count) => islandLine(count).length < MAX_TOKENIZATION_LINE_LENGTH
)
expect(ramp.length).toBeGreaterThanOrEqual(3)
const depths = ramp.map((count) =>
measureNestedDepth(createMonarchTokenizer('glimmer-ts', glimmerTsMonarchLanguage), [
islandLine(count)
])
)
for (const measurement of depths) {
expect(measurement.error).toBeUndefined()
expect(measurement.maxNestedDepth).toBeLessThanOrEqual(EMBED_ENTRY_REST_OF_LINE_BUDGET)
}
})
it('re-embeds script and template after over-budget lines', () => {
const long = 'x'.repeat(EMBED_ENTRY_REST_OF_LINE_BUDGET)
const embeds = endEmbeddedLanguages(
tokenizeLines(createMonarchTokenizer('glimmer-ts', glimmerTsMonarchLanguage), [
`const s = "${long}"`,
'const b = 1',
`<template>${long}x`,
' {{b}}',
'</template>',
'const c = 1'
])
)
expect(embeds).toEqual([null, 'typescript', null, 'handlebars', null, 'typescript'])
})
})
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest'
import { formatTokenizedLines, tokenizeMonarchDocument } from './monarch-tokenizer-test-harness'
import {
glimmerJsMonarchLanguage,
glimmerLanguageConfiguration,
glimmerTsMonarchLanguage,
registerGlimmerLanguages
} from './register-glimmer'
describe('registerGlimmerLanguages', () => {
// Tokenizing cannot observe registration IDs/extensions/idempotence; structural checks cover that contract.
it('registers both Glimmer languages, tokenizers, and configuration once', () => {
const languages: { id: string }[] = [{ id: 'typescript' }]
const register = vi.fn((entry: { id: string }) => {
languages.push({ id: entry.id })
})
const setMonarchTokensProvider = vi.fn()
const setLanguageConfiguration = vi.fn()
const monacoMock = {
languages: {
register,
setMonarchTokensProvider,
setLanguageConfiguration,
getLanguages: () => languages
}
}
registerGlimmerLanguages(monacoMock)
registerGlimmerLanguages(monacoMock)
expect(register.mock.calls).toEqual([
[{ id: 'glimmer-ts', extensions: ['.gts'], aliases: ['Glimmer TS'] }],
[{ id: 'glimmer-js', extensions: ['.gjs'], aliases: ['Glimmer JS'] }]
])
expect(setMonarchTokensProvider.mock.calls).toEqual([
['glimmer-ts', glimmerTsMonarchLanguage],
['glimmer-js', glimmerJsMonarchLanguage]
])
expect(setLanguageConfiguration.mock.calls).toEqual([
['glimmer-ts', glimmerLanguageConfiguration],
['glimmer-js', glimmerLanguageConfiguration]
])
})
})
describe('glimmer tokenization', () => {
// Real Monarch tokenization: an embed popped without a matching push throws per line and drops highlighting.
it('alternates the script embed with Handlebars template islands', () => {
const fixture = `import Component from '@glimmer/component'
export default class Hello extends Component<Signature> {
<template>
<p>Hello {{this.name}}</p>
</template>
}
const Inline = <template>{{@a}}</template>; const Empty = <template></template>;`
expect(
formatTokenizedLines(tokenizeMonarchDocument('glimmer-ts', glimmerTsMonarchLanguage, fixture))
).toMatchInlineSnapshot(`
[
"import Component from '@glimmer/component' | 0:-@typescript | embed=typescript",
"export default class Hello extends Component<Signature> { | 0:-@typescript | embed=typescript",
" <template> | 0:-@typescript 2:tag.gts@glimmer-ts | embed=none",
" <p>Hello {{this.name}}</p> | 0:-@handlebars | embed=handlebars",
" </template> | 0:-@handlebars 2:tag.gts@glimmer-ts | embed=none",
"} | 0:-@typescript | embed=typescript",
"const Inline = <template>{{@a}}</template>; const Empty = <template></template>; | 0:-@typescript 15:tag.gts@glimmer-ts 25:-@handlebars 31:tag.gts@glimmer-ts 42:-@typescript 58:tag.gts@glimmer-ts 79:-@typescript | embed=typescript",
]
`)
})
it('starts a template-only component in Handlebars and uses the javascript embed for .gjs', () => {
const fixture = `<template>
<h1>{{@title}}</h1>
</template>
export const x = 1`
expect(
formatTokenizedLines(tokenizeMonarchDocument('glimmer-js', glimmerJsMonarchLanguage, fixture))
).toMatchInlineSnapshot(`
[
"<template> | 0:tag.gjs@glimmer-js | embed=none",
" <h1>{{@title}}</h1> | 0:-@handlebars | embed=handlebars",
"</template> | 0:tag.gjs@glimmer-js | embed=none",
"export const x = 1 | 0:-@javascript | embed=javascript",
]
`)
})
})
@@ -0,0 +1,109 @@
import type * as Monaco from 'monaco-editor'
import { restOfLineWithinEmbedBudget } from './monarch-embed-entry-budget'
type GlimmerMonaco = {
languages: Pick<
typeof Monaco.languages,
'getLanguages' | 'register' | 'setLanguageConfiguration' | 'setMonarchTokensProvider'
>
}
type GlimmerBaseLanguage = 'typescript' | 'javascript'
// Script is the background embed and each `<template>` a Handlebars island; returning through root re-pushes the script embed so `@pop` never runs without one. Like Vue/Svelte, a literal `<template>` in a JS string or comment still switches embeds.
function createGlimmerMonarchLanguage(
baseLanguage: GlimmerBaseLanguage
): Monaco.languages.IMonarchLanguage {
return {
defaultToken: '',
tokenPostfix: baseLanguage === 'typescript' ? '.gts' : '.gjs',
tokenizer: {
// Zero-width transitions require @rematch or Monarch silently drops nextEmbedded.
root: [
[
restOfLineWithinEmbedBudget,
{ token: '@rematch', switchTo: '@script', nextEmbedded: baseLanguage }
],
[/(?=.)/, { token: '@rematch', switchTo: '@scriptPlain' }]
],
script: [
[/<template\s*>/, { token: 'tag', switchTo: '@templateEnter', nextEmbedded: '@pop' }]
],
// Over-budget lines keep island boundaries without embeds to bound recursion; re-embed once the rest fits.
scriptPlain: [
[/<template\s*>/, { token: 'tag', switchTo: '@templateEnter' }],
[
restOfLineWithinEmbedBudget,
{ token: '@rematch', switchTo: '@script', nextEmbedded: baseLanguage }
],
[/[^<]+/, ''],
[/./, '']
],
templateEnter: [
[
restOfLineWithinEmbedBudget,
{ token: '@rematch', switchTo: '@templateBody', nextEmbedded: 'handlebars' }
],
[/(?=.)/, { token: '@rematch', switchTo: '@templatePlain' }]
],
templateBody: [
[/<\/template\s*>/, { token: 'tag', switchTo: '@root', nextEmbedded: '@pop' }]
],
templatePlain: [
[/<\/template\s*>/, { token: 'tag', switchTo: '@root' }],
[
restOfLineWithinEmbedBudget,
{ token: '@rematch', switchTo: '@templateBody', nextEmbedded: 'handlebars' }
],
[/[^<]+/, ''],
[/./, '']
]
}
}
}
export const glimmerTsMonarchLanguage = createGlimmerMonarchLanguage('typescript')
export const glimmerJsMonarchLanguage = createGlimmerMonarchLanguage('javascript')
export const glimmerLanguageConfiguration: Monaco.languages.LanguageConfiguration = {
comments: { lineComment: '//', blockComment: ['/*', '*/'] },
brackets: [
['{', '}'],
['[', ']'],
['(', ')'],
['<', '>']
],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
{ open: '`', close: '`' }
],
surroundingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
{ open: '`', close: '`' },
{ open: '<', close: '>' }
]
}
const GLIMMER_LANGUAGES = [
{ id: 'glimmer-ts', extension: '.gts', alias: 'Glimmer TS', language: glimmerTsMonarchLanguage },
{ id: 'glimmer-js', extension: '.gjs', alias: 'Glimmer JS', language: glimmerJsMonarchLanguage }
]
export function registerGlimmerLanguages(monaco: GlimmerMonaco): void {
const registeredIds = new Set(monaco.languages.getLanguages().map((language) => language.id))
for (const { id, extension, alias, language } of GLIMMER_LANGUAGES) {
if (registeredIds.has(id)) {
continue
}
monaco.languages.register({ id, extensions: [extension], aliases: [alias] })
monaco.languages.setMonarchTokensProvider(id, language)
monaco.languages.setLanguageConfiguration(id, glimmerLanguageConfiguration)
}
}
+2
View File
@@ -10,6 +10,7 @@ import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import { registerAstroLanguage } from './monaco-languages/register-astro'
import { registerCudaLanguage } from './monaco-languages/register-cuda'
import { registerGlimmerLanguages } from './monaco-languages/register-glimmer'
import { registerJsonlLanguage } from './monaco-languages/register-jsonl'
import { registerLeanLanguage } from './monaco-languages/register-lean'
import { registerMarkdownLanguage } from './monaco-languages/register-markdown'
@@ -72,6 +73,7 @@ runMonacoSetupSteps([
['Vue language registration', () => registerVueLanguage(monaco)],
['Svelte language registration', () => registerSvelteLanguage(monaco)],
['Astro language registration', () => registerAstroLanguage(monaco)],
['Glimmer language registration', () => registerGlimmerLanguages(monaco)],
['CUDA language registration', () => registerCudaLanguage(monaco)],
['Nim language registration', () => registerNimLanguage(monaco)],
['Lean language registration', () => registerLeanLanguage(monaco)],
@@ -14,6 +14,12 @@ describe('detectLanguage', () => {
expect(detectLanguage('src/routes/index.astro')).toBe('astro')
})
it('maps Glimmer files to distinct js/ts language ids', () => {
expect(detectLanguage('app/components/hello.gjs')).toBe('glimmer-js')
expect(detectLanguage('app/components/hello.gts')).toBe('glimmer-ts')
expect(detectLanguage('C:\\app\\components\\HELLO.GTS')).toBe('glimmer-ts')
})
it('maps Nim files to the nim language id', () => {
expect(detectLanguage('src/main.nim')).toBe('nim')
expect(detectLanguage('tasks/build.nims')).toBe('nim')
@@ -103,6 +103,8 @@ const EXT_TO_LANGUAGE: Record<string, string> = {
'.vue': 'vue',
'.svelte': 'svelte',
'.astro': 'astro',
'.gjs': 'glimmer-js',
'.gts': 'glimmer-ts',
'.sv': 'systemverilog',
'.svh': 'systemverilog',
'.v': 'verilog',