From ce2b10487724b63541015308fbf4d2c0e28947f1 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sun, 26 Jul 2026 00:21:29 -0600 Subject: [PATCH] Add TSRX syntax highlighting --- README.md | 2 +- docs/guides/octane.md | 3 +- docs/language-support.md | 1 + docs/reference/languages.md | 3 +- scripts/language-utils.mjs | 2 + scripts/test-package.mjs | 2 +- skills/_artifacts/domain_map.yaml | 2 +- .../references/languages.md | 1 + src/index.ts | 3 + src/internal/script.ts | 49 +++++++++--- src/languages/index.ts | 1 + src/languages/tsrx.ts | 49 ++++++++++++ test/fixtures.ts | 21 ++++++ test/generated/tanstack-doc-fixtures.json | 9 ++- test/modular.test.ts | 9 +++ test/regressions.test.ts | 74 +++++++++++++++++++ 16 files changed, 215 insertions(+), 16 deletions(-) create mode 100644 src/languages/tsrx.ts diff --git a/README.md b/README.md index cffe5a8..3339ce0 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ Available themes: Aurora X, Dracula, GitHub Dark, GitHub Light, Monokai, Nord, O ## Languages -`apache`, `css`, `diff`, `dockerfile`, `ejs`, `env`, `html`, `http`, `js`, `json`, `jsx`, `markdown`, `mermaid`, `nginx`, `plaintext`, `python`, `scheme`, `shell`, `sql`, `svelte`, `toml`, `ts`, `tsx`, `vue`, and `yaml`. +`apache`, `css`, `diff`, `dockerfile`, `ejs`, `env`, `html`, `http`, `js`, `json`, `jsx`, `markdown`, `mermaid`, `nginx`, `plaintext`, `python`, `scheme`, `shell`, `sql`, `svelte`, `toml`, `ts`, `tsrx`, `tsx`, `vue`, and `yaml`. Each language is available from `@tanstack/highlight/languages/`. The aggregate `@tanstack/highlight/languages` entry can tree-shake, while direct subpaths make isolation explicit. diff --git a/docs/guides/octane.md b/docs/guides/octane.md index 334018e..457817e 100644 --- a/docs/guides/octane.md +++ b/docs/guides/octane.md @@ -16,10 +16,11 @@ import { createHighlighter } from '@tanstack/highlight/core' import { css } from '@tanstack/highlight/languages/css' import { js } from '@tanstack/highlight/languages/js' import { ts } from '@tanstack/highlight/languages/ts' +import { tsrx } from '@tanstack/highlight/languages/tsrx' import { tsx } from '@tanstack/highlight/languages/tsx' export const highlighter = createHighlighter({ - languages: [css, js, ts, tsx], + languages: [css, js, ts, tsrx, tsx], }) ``` diff --git a/docs/language-support.md b/docs/language-support.md index cfb0cd8..f7de9bc 100644 --- a/docs/language-support.md +++ b/docs/language-support.md @@ -32,6 +32,7 @@ Every language is an isolated definition imported from `@tanstack/highlight/lang | Svelte | `svelte` | - | Markup plus optional script/style and expression delegation | | TOML | `toml` | - | Strings, comments, tables, properties | | TypeScript | `ts` | `typescript`, `angular-ts` | JavaScript scanner plus TypeScript keywords/types | +| TSRX | `tsrx` | `octane` | TypeScript, contextual JSX, Octane component shorthand and template directives | | TSX | `tsx` | - | TypeScript, contextual JSX, generic disambiguation | | Vue | `vue` | - | Template markup plus optional script/style delegation | | YAML | `yaml` | `yml` | Comment boundaries and block scalars | diff --git a/docs/reference/languages.md b/docs/reference/languages.md index 9da748e..2de8bee 100644 --- a/docs/reference/languages.md +++ b/docs/reference/languages.md @@ -43,10 +43,11 @@ const highlighter = createHighlighter({ | `svelte` | `@tanstack/highlight/languages/svelte` | None | | `toml` | `@tanstack/highlight/languages/toml` | None | | `ts` | `@tanstack/highlight/languages/ts` | `typescript`, `angular-ts` | +| `tsrx` | `@tanstack/highlight/languages/tsrx` | `octane` | | `tsx` | `@tanstack/highlight/languages/tsx` | None | | `vue` | `@tanstack/highlight/languages/vue` | None | | `yaml` | `@tanstack/highlight/languages/yaml` | `yml` | -`@tanstack/highlight/languages` re-exports `apache`, `css`, `diff`, `dockerfile`, `ejs`, `env`, `html`, `http`, `js`, `json`, `jsx`, `markdown`, `mermaid`, `nginx`, `plaintext`, `python`, `scheme`, `shell`, `sql`, `svelte`, `toml`, `ts`, `tsx`, `vue`, and `yaml`. The barrel is convenient but individual subpaths make bundle intent explicit. +`@tanstack/highlight/languages` re-exports `apache`, `css`, `diff`, `dockerfile`, `ejs`, `env`, `html`, `http`, `js`, `json`, `jsx`, `markdown`, `mermaid`, `nginx`, `plaintext`, `python`, `scheme`, `shell`, `sql`, `svelte`, `toml`, `ts`, `tsrx`, `tsx`, `vue`, and `yaml`. The barrel is convenient but individual subpaths make bundle intent explicit. See the [language support matrix](../language-support) for the context-aware behavior and current scope of each registration. diff --git a/scripts/language-utils.mjs b/scripts/language-utils.mjs index 0179a85..c31105c 100644 --- a/scripts/language-utils.mjs +++ b/scripts/language-utils.mjs @@ -24,6 +24,7 @@ export const supportedLanguages = [ 'svelte', 'toml', 'ts', + 'tsrx', 'tsx', 'vue', 'yaml', @@ -46,6 +47,7 @@ const aliases = { markdown: 'markdown', md: 'markdown', mjs: 'js', + octane: 'tsrx', sh: 'shell', shell: 'shell', text: 'plaintext', diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 24cb247..25a1979 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -42,7 +42,7 @@ if (typeof modularCore.createHighlighter !== 'function') { throw new Error('The core entry does not export createHighlighter()') } -if (!languages.tsx || !languages.shell) { +if (!languages.tsrx || !languages.tsx || !languages.shell) { throw new Error('The languages entry does not export language definitions') } diff --git a/skills/_artifacts/domain_map.yaml b/skills/_artifacts/domain_map.yaml index 64d3760..bbbfec9 100644 --- a/skills/_artifacts/domain_map.yaml +++ b/skills/_artifacts/domain_map.yaml @@ -43,7 +43,7 @@ skills: - 'Choose direct language imports and intentional fallback behavior' reference_candidates: - topic: 'shipped language names, aliases, and isolated imports' - reason: 'The package exposes 25 independently importable language definitions with aliases.' + reason: 'The package exposes 26 independently importable language definitions with aliases.' failure_modes: - mistake: 'Importing the root entry in size-sensitive clients' mechanism: 'The root entry constructs the all-language registry, so a bound helper retains every shipped language.' diff --git a/skills/configure-selective-highlighting/references/languages.md b/skills/configure-selective-highlighting/references/languages.md index 7db99da..657ba15 100644 --- a/skills/configure-selective-highlighting/references/languages.md +++ b/skills/configure-selective-highlighting/references/languages.md @@ -26,6 +26,7 @@ Import only the definitions the application registers. | Svelte | `svelte` | `@tanstack/highlight/languages/svelte` | - | | TOML | `toml` | `@tanstack/highlight/languages/toml` | - | | TypeScript | `ts` | `@tanstack/highlight/languages/ts` | `typescript`, `angular-ts` | +| TSRX | `tsrx` | `@tanstack/highlight/languages/tsrx` | `octane` | | TSX | `tsx` | `@tanstack/highlight/languages/tsx` | - | | Vue | `vue` | `@tanstack/highlight/languages/vue` | - | | YAML | `yaml` | `@tanstack/highlight/languages/yaml` | `yml` | diff --git a/src/index.ts b/src/index.ts index 1cf6190..5a231f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ import { sql } from './languages/sql.js' import { svelte } from './languages/svelte.js' import { toml } from './languages/toml.js' import { ts } from './languages/ts.js' +import { tsrx } from './languages/tsrx.js' import { tsx } from './languages/tsx.js' import { vue } from './languages/vue.js' import { yaml } from './languages/yaml.js' @@ -54,6 +55,7 @@ export type HighlightLanguage = | 'svelte' | 'toml' | 'ts' + | 'tsrx' | 'tsx' | 'vue' | 'yaml' @@ -121,6 +123,7 @@ export const allLanguages = [ svelte, toml, ts, + tsrx, tsx, vue, yaml, diff --git a/src/internal/script.ts b/src/internal/script.ts index 6e9f955..07639f4 100644 --- a/src/internal/script.ts +++ b/src/internal/script.ts @@ -41,13 +41,21 @@ const semanticPatterns = [ }, ] as const -export function collectScriptRanges(code: string, jsx = false) { - const lexical = collectScriptLexicalRanges(code, jsx) - const markup = jsx ? collectJsxRanges(code) : [] +export function collectScriptRanges( + code: string, + jsx = false, + jsxAtLineStart = false, +) { + const lexical = collectScriptLexicalRanges(code, jsx, jsxAtLineStart) + const markup = jsx ? collectJsxRanges(code, jsxAtLineStart) : [] return collectPatternRanges(code, semanticPatterns, [...lexical, ...markup]) } -function collectScriptLexicalRanges(code: string, jsx: boolean) { +function collectScriptLexicalRanges( + code: string, + jsx: boolean, + jsxAtLineStart: boolean, +) { const ranges: Array = [] let index = 0 @@ -78,7 +86,7 @@ function collectScriptLexicalRanges(code: string, jsx: boolean) { } if (character === '`') { - index = collectTemplateRanges(code, index, ranges, jsx) + index = collectTemplateRanges(code, index, ranges, jsx, jsxAtLineStart) continue } @@ -106,6 +114,7 @@ function collectTemplateRanges( start: number, ranges: Array, jsx: boolean, + jsxAtLineStart: boolean, ) { let segmentStart = start let index = start + 1 @@ -130,7 +139,12 @@ function collectTemplateRanges( const end = findInterpolationEnd(code, index + 2) const expressionEnd = end < 0 ? code.length : end const expression = code.slice(index + 2, expressionEnd) - ranges.push(...offsetRanges(collectScriptRanges(expression, jsx), index + 2)) + ranges.push( + ...offsetRanges( + collectScriptRanges(expression, jsx, jsxAtLineStart), + index + 2, + ), + ) if (end < 0) return code.length ranges.push({ start: end, end: end + 1, className: 'operator' }) @@ -194,7 +208,7 @@ function skipTemplate(code: string, start: number) { return code.length } -function collectJsxRanges(code: string) { +function collectJsxRanges(code: string, jsxAtLineStart: boolean) { const ranges: Array = [] let index = 0 let jsxDepth = 0 @@ -229,7 +243,10 @@ function collectJsxRanges(code: string) { index += 3 continue } - if (code.startsWith('<>', index) && (jsxDepth > 0 || isJsxStart(code, index))) { + if ( + code.startsWith('<>', index) && + (jsxDepth > 0 || isJsxStart(code, index, jsxAtLineStart)) + ) { jsxDepth++ index += 2 continue @@ -239,7 +256,12 @@ function collectJsxRanges(code: string) { const nameStart = index + (closing ? 2 : 1) const nameMatch = /^[A-Za-z][\w:.-]*/.exec(code.slice(nameStart)) const inJsxText = jsxDepth > 0 && expressionDepth === 0 - if (!nameMatch || (!closing && !inJsxText && !isJsxStart(code, index))) { + if ( + !nameMatch || + (!closing && + !inJsxText && + !isJsxStart(code, index, jsxAtLineStart)) + ) { index++ continue } @@ -286,7 +308,14 @@ function collectJsxAttributes( } } -function isJsxStart(code: string, index: number) { +function isJsxStart(code: string, index: number, jsxAtLineStart: boolean) { + if ( + jsxAtLineStart && + /^\s*$/.test(code.slice(code.lastIndexOf('\n', index - 1) + 1, index)) + ) { + return true + } + const before = code.slice(0, index).trimEnd() if (!before) return true if (/\b(?:return|case|yield)$/.test(before)) return true diff --git a/src/languages/index.ts b/src/languages/index.ts index 3b678c3..ef279c9 100644 --- a/src/languages/index.ts +++ b/src/languages/index.ts @@ -20,6 +20,7 @@ export { sql } from './sql.js' export { svelte } from './svelte.js' export { toml } from './toml.js' export { ts } from './ts.js' +export { tsrx } from './tsrx.js' export { tsx } from './tsx.js' export { vue } from './vue.js' export { yaml } from './yaml.js' diff --git a/src/languages/tsrx.ts b/src/languages/tsrx.ts new file mode 100644 index 0000000..8efe0b8 --- /dev/null +++ b/src/languages/tsrx.ts @@ -0,0 +1,49 @@ +import { defineLanguage, type TokenRange } from '../core.js' +import { collectScriptRanges } from '../internal/script.js' + +const templateDirectives = new Set([ + '@catch', + '@case', + '@default', + '@else', + '@empty', + '@for', + '@if', + '@pending', + '@switch', + '@try', +]) + +export const tsrx = defineLanguage({ + name: 'tsrx', + aliases: ['octane'], + tokenize(code) { + const ranges: Array = collectScriptRanges(code, true, true).map( + (range): TokenRange => + range.className === 'function' && + templateDirectives.has(code.slice(range.start, range.end)) + ? { ...range, className: 'keyword' } + : range, + ) + const shorthand = /@\{/g + let match: RegExpExecArray | null + + while ((match = shorthand.exec(code))) { + const range: TokenRange = { + start: match.index, + end: match.index + match[0].length, + className: 'keyword', + } + + if (!ranges.some((candidate) => overlaps(candidate, range))) { + ranges.push(range) + } + } + + return ranges + }, +}) + +function overlaps(left: TokenRange, right: TokenRange) { + return left.start < right.end && right.start < left.end +} diff --git a/test/fixtures.ts b/test/fixtures.ts index afdc9c9..f2ebe9b 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -8,6 +8,27 @@ export type LanguageFixture = { } export const languageFixtures: Array = [ + { + lang: 'octane', + normalized: 'tsrx', + code: `import { useState } from 'octane' + +export function Counter() @{ + const [count, setCount] = useState(0) + + +}`, + expectedClasses: [ + 'th-keyword', + 'th-string', + 'th-function', + 'th-tag', + 'th-attr', + 'th-number', + ], + }, { lang: 'tsx', normalized: 'tsx', diff --git a/test/generated/tanstack-doc-fixtures.json b/test/generated/tanstack-doc-fixtures.json index be4d558..c2a2ae4 100644 --- a/test/generated/tanstack-doc-fixtures.json +++ b/test/generated/tanstack-doc-fixtures.json @@ -1,7 +1,7 @@ { "perLanguageLimit": 20, "maxCodeLength": 3000, - "count": 333, + "count": 334, "fixtures": [ { "file": "router/docs/router/how-to/deploy-to-production.md", @@ -1914,6 +1914,13 @@ "lang": "ts", "code": "import { chat } from \"@tanstack/ai\";\nimport { geminiText } from \"@tanstack/ai-gemini\";\n\nconst stream = chat({\n adapter: geminiText(\"gemini-2.5-pro\"),\n messages: [{ role: \"user\", content: \"Hello!\" }],\n});" }, + { + "file": "markdown/docs/core-concepts/rendering.md", + "line": 21, + "rawLang": "tsrx", + "lang": "tsrx", + "code": "import { Markdown } from '@tanstack/markdown/octane'\n\n{source}" + }, { "file": "ai/docs/getting-started/devtools.md", "line": 36, diff --git a/test/modular.test.ts b/test/modular.test.ts index dc6f329..226ec49 100644 --- a/test/modular.test.ts +++ b/test/modular.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createHighlighter } from '../src/core' import { html } from '../src/languages/html' import { js } from '../src/languages/js' +import { tsrx } from '../src/languages/tsrx' import { tsx } from '../src/languages/tsx' describe('language registration', () => { @@ -24,6 +25,14 @@ describe('language registration', () => { expect(markupOnly.highlight(code, { lang: 'html' }).html).not.toContain('th-keyword') expect(withJavaScript.highlight(code, { lang: 'html' }).html).toContain('th-keyword') }) + + it('registers the Octane alias with TSRX only', () => { + const highlighter = createHighlighter({ languages: [tsrx] }) + + expect(highlighter.listLanguages()).toEqual(['tsrx']) + expect(highlighter.normalizeLanguage('octane')).toBe('tsrx') + expect(highlighter.normalizeLanguage('tsx')).toBe('plaintext') + }) }) describe('decorations', () => { diff --git a/test/regressions.test.ts b/test/regressions.test.ts index 03ddb51..2b2586e 100644 --- a/test/regressions.test.ts +++ b/test/regressions.test.ts @@ -9,6 +9,7 @@ import { markdown } from '../src/languages/markdown' import { python } from '../src/languages/python' import { shell } from '../src/languages/shell' import { ts } from '../src/languages/ts' +import { tsrx } from '../src/languages/tsrx' import { tsx } from '../src/languages/tsx' import { vue } from '../src/languages/vue' import { yaml } from '../src/languages/yaml' @@ -24,6 +25,7 @@ const highlighter = createHighlighter({ python, shell, ts, + tsrx, tsx, vue, yaml, @@ -97,6 +99,78 @@ describe('script context', () => { }) }) +describe('TSRX context', () => { + it('highlights Octane component shorthand and template directives', () => { + const result = highlighter.tokenize( + `@Component() +export function View(props) @{ + @if (props.loading) { +

Loading

+ } @else { + @for (const item of props.items; key item.id) { + {item.label as string} + } @empty { +

Empty

+ } + } + + @switch (props.state) { + @case ('ready') {

Ready

} + @default {

Unknown

} + } + + @try { + + } @pending { + + } @catch (error) { + + } +}`, + { lang: 'tsrx' }, + ) + + expect( + result.tokens + .filter((token) => token.value.startsWith('@')) + .map((token) => [token.value, token.className]), + ).toEqual([ + ['@Component', 'function'], + ['@{', 'keyword'], + ['@if', 'keyword'], + ['@else', 'keyword'], + ['@for', 'keyword'], + ['@empty', 'keyword'], + ['@switch', 'keyword'], + ['@case', 'keyword'], + ['@default', 'keyword'], + ['@try', 'keyword'], + ['@pending', 'keyword'], + ['@catch', 'keyword'], + ]) + expect(classesFor(result, 'Content')).toContain('tag') + expect(reconstruct(result)).toBe(result.code) + }) + + it('does not highlight TSRX syntax inside strings or comments', () => { + const result = highlighter.tokenize( + `const example = '@if (ready) { }' +// @for and @{ +export function View() @{

Ready

}`, + { lang: 'octane' }, + ) + + expect(classesFor(result, "'@if (ready) { }'")).toContain('string') + expect(classesFor(result, '// @for and @{')).toContain('comment') + expect( + result.tokens + .filter((token) => token.value.startsWith('@')) + .map((token) => token.value), + ).toEqual(['@{']) + expect(reconstruct(result)).toBe(result.code) + }) +}) + describe('docs language regressions', () => { it('keeps Python triple strings together and highlights literals and numbers', () => { const result = highlighter.tokenize(