mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user