├── .github └── workflows │ └── autofix.yaml ├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── eslint.config.mjs ├── extension ├── configs.ts ├── getExampleFile.ts ├── getTokenizer.ts ├── index.ts ├── parsers.ts ├── useAutoStart.ts ├── usePreviewer.ts └── utils.ts ├── icon.png ├── index.html ├── package.json ├── pnpm-lock.yaml ├── src ├── App.vue ├── components │ ├── Button.vue │ ├── RenderToken.vue │ ├── Settings.vue │ └── TokenExplanation.vue ├── index.ts ├── states.ts ├── style.css └── tsconfig.json ├── tsconfig.json ├── types.ts ├── uno.config.mts └── vite.config.mts /.github/workflows/autofix.yaml: -------------------------------------------------------------------------------- 1 | name: autofix.ci 2 | on: 3 | pull_request: 4 | push: 5 | branches: [main] 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | autofix: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - uses: pnpm/action-setup@v2 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | cache: pnpm 21 | 22 | - run: pnpm i 23 | 24 | - run: pnpm lint:fix 25 | 26 | - uses: autofix-ci/action@d3e591514b99d0fca6779455ff8338516663f7cc 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | ui-dist 4 | .eslintcache 5 | *.vsix 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run Extension", 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "args": [ 9 | "--extensionDevelopmentPath=${workspaceFolder}" 10 | ], 11 | "outFiles": [ 12 | "${workspaceFolder}/dist/**/*.js" 13 | ] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 _Kerman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tmLanguage Previewer 2 | 3 | [![Reactive VSCode](https://img.shields.io/badge/Reactive-VSCode-%23007ACC?style=flat&labelColor=%23229863)](https://kermanx.github.io/reactive-vscode/) 4 | 5 | A VS Code extension for previewing tmLanguage grammar files. Created with [Reactive VS Code](https://kermanx.github.io/reactive-vscode). 6 | 7 | There is also a online version: [tmLanguage Playground](https://kermanx.github.io/tmLanguage-Playground/). 8 | 9 | ## Usage 10 | 11 | 1. Open a `.tmLanguage.json` file in VSCode. For example, `xyz.tmLanguage.json`. 12 | 2. Create a new file with the same name but with a `.example` infix. For example, `xyz.example.any`. 13 | 2. Click the icon on the top right of the editor to open the previewer. 14 | 15 | ## Development 16 | 17 | ### What's in the folder 18 | 19 | - `package.json` - this is the manifest file in which you declare your extension and command. 20 | - `src/*` - this is the folder containing the webview code. (DOM environment) 21 | - `extension/*` - this is the folder containing the extension code. (Node.js environment) 22 | 23 | ### Get up and running straight away 24 | 25 | - Run `pnpm install` to install all necessary dependencies. 26 | - Run `pnpm dev` in a terminal to compile the extension. 27 | - Press `F5` to open a new window with your extension loaded. 28 | 29 | ### Make changes 30 | 31 | - You can relaunch the extension from the debug toolbar after changing code in `src/extension.ts`. 32 | - You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes. 33 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import antfu from '@antfu/eslint-config' 3 | 4 | export default antfu() 5 | -------------------------------------------------------------------------------- /extension/configs.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigType } from 'reactive-vscode' 2 | import { computed, defineConfigs } from 'reactive-vscode' 3 | import { ViewColumn } from 'vscode' 4 | 5 | const { autoStart, grammarExts, exampleSuffixes, previewColumn: previewColumnRaw } = defineConfigs('tmlanguage-previewer', { 6 | autoStart: Boolean, 7 | grammarExts: Object as ConfigType>, 8 | exampleSuffixes: Object as ConfigType, 9 | previewColumn: Object as ConfigType<'active' | 'beside' | 'one' | 'two' | 'three'>, 10 | }) 11 | 12 | export { 13 | autoStart, 14 | grammarExts, 15 | exampleSuffixes, 16 | } 17 | 18 | export const previewColumn = computed(() => { 19 | switch (previewColumnRaw.value) { 20 | case 'active': return ViewColumn.Active 21 | case 'beside': return ViewColumn.Beside 22 | case 'one': return ViewColumn.One 23 | case 'two': return ViewColumn.Two 24 | case 'three': return ViewColumn.Three 25 | } 26 | }) 27 | -------------------------------------------------------------------------------- /extension/getExampleFile.ts: -------------------------------------------------------------------------------- 1 | import { basename } from 'node:path' 2 | import { RelativePattern, Uri, workspace } from 'vscode' 3 | import { exampleSuffixes } from './configs' 4 | 5 | export async function findExampleFile(grammarUri: Uri, grammarExt: string): Promise { 6 | const grammarDirectory = Uri.joinPath(grammarUri, '..') 7 | const grammarBasename = basename(grammarUri.fsPath, grammarExt) 8 | const exampleUris: Uri[] = [] 9 | for (const suffix of exampleSuffixes.value) { 10 | exampleUris.push(...await workspace.findFiles( 11 | new RelativePattern(grammarDirectory, grammarBasename + suffix), 12 | new RelativePattern(grammarDirectory, grammarBasename), 13 | )) 14 | } 15 | return exampleUris 16 | } 17 | -------------------------------------------------------------------------------- /extension/getTokenizer.ts: -------------------------------------------------------------------------------- 1 | import { bundledLanguages, createHighlighter } from 'shiki' 2 | 3 | export async function getTokenizer(grammars: any[], dark: boolean) { 4 | try { 5 | const theme = dark ? 'vitesse-dark' : 'vitesse-light' 6 | const grammarLangs = grammars.map(g => g.name?.toLowerCase()) 7 | const highlighter = await createHighlighter({ 8 | themes: [theme], 9 | langs: Object.keys(bundledLanguages) 10 | .filter(n => !grammarLangs.includes(n.toLowerCase())) 11 | .concat(grammars), 12 | }) 13 | 14 | return [(code: string, lang: string) => { 15 | try { 16 | return highlighter.codeToTokensBase(code, { 17 | lang: lang.toLowerCase() as any, 18 | theme, 19 | includeExplanation: true, 20 | }) 21 | } 22 | catch (e) { 23 | return String(e) 24 | } 25 | }, highlighter] as const 26 | } 27 | catch (e) { 28 | return [() => String(e), null] as const 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /extension/index.ts: -------------------------------------------------------------------------------- 1 | import { defineExtension, useCommand } from 'reactive-vscode' 2 | import { window } from 'vscode' 3 | import { useAutoStart } from './useAutoStart' 4 | import { usePreviewer } from './usePreviewer' 5 | import { logger } from './utils' 6 | 7 | // eslint-disable-next-line no-restricted-syntax 8 | export = defineExtension(() => { 9 | logger.info('Extension Activated') 10 | 11 | useCommand('tmlanguage-previewer.open', () => { 12 | const editor = window.activeTextEditor 13 | if (!editor) { 14 | window.showErrorMessage('No active text editor') 15 | return 16 | } 17 | logger.info('Opening Previewer') 18 | usePreviewer(editor) 19 | }) 20 | 21 | useAutoStart() 22 | }) 23 | -------------------------------------------------------------------------------- /extension/parsers.ts: -------------------------------------------------------------------------------- 1 | export const parsers = { 2 | json: async (source: string) => JSON.parse(source), 3 | yaml: async (source: string) => (await import('js-yaml')).load(source), 4 | } 5 | -------------------------------------------------------------------------------- /extension/useAutoStart.ts: -------------------------------------------------------------------------------- 1 | import { useActiveTextEditor, watchEffect } from 'reactive-vscode' 2 | import { autoStart } from './configs' 3 | import { usePreviewer } from './usePreviewer' 4 | 5 | export function useAutoStart() { 6 | const activeTextEditor = useActiveTextEditor() 7 | watchEffect(() => { 8 | if (!autoStart.value || !activeTextEditor.value) 9 | return 10 | const doc = activeTextEditor.value.document 11 | if (doc.languageId.includes('json') && doc.uri.fsPath.toLowerCase().endsWith('.tmlanguage.json')) 12 | usePreviewer(activeTextEditor.value) 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /extension/usePreviewer.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'node:buffer' 2 | import type { EffectScope } from 'reactive-vscode' 3 | import { effectScope, extensionContext, reactive, ref, shallowReactive, shallowRef, useDisposable, useDocumentText, useIsDarkTheme, watchEffect } from 'reactive-vscode' 4 | import type { TextDocument, TextEditor, WebviewPanel } from 'vscode' 5 | import { Uri, window, workspace } from 'vscode' 6 | import type { GrammarFile } from '../types' 7 | import { loadIndexHtml, logger } from './utils' 8 | import { getTokenizer } from './getTokenizer' 9 | import { findExampleFile } from './getExampleFile' 10 | import { grammarExts, previewColumn } from './configs' 11 | import { parsers } from './parsers' 12 | 13 | const grammarUriToPanel = new WeakMap() 14 | 15 | export function usePreviewer(editor: TextEditor) { 16 | const isDark = useIsDarkTheme() 17 | const entryUri = editor.document.uri 18 | if (grammarUriToPanel.has(entryUri)) { 19 | grammarUriToPanel.get(entryUri)!.reveal() 20 | return 21 | } 22 | 23 | const grammarExt = Object.keys(grammarExts.value) 24 | .find(ext => entryUri.fsPath.toLowerCase().endsWith(ext.toLowerCase())) ?? '' 25 | const parser = parsers[grammarExts.value[grammarExt] as keyof typeof parsers] 26 | 27 | if (!grammarExt || !parser) { 28 | window.showErrorMessage(`Unsupported file type for ${entryUri.fsPath}. Supported file types: ${Object.keys(grammarExts.value).join(', ')}`) 29 | return 30 | } 31 | 32 | const panel = useDisposable(window.createWebviewPanel( 33 | 'tmlanguage-previewer', 34 | 'TmLanguage Previewer', 35 | { 36 | viewColumn: previewColumn.value, 37 | preserveFocus: true, 38 | }, 39 | { 40 | enableScripts: true, 41 | localResourceRoots: [Uri.joinPath(extensionContext.value!.extensionUri, 'dist/webview')], 42 | retainContextWhenHidden: true, 43 | }, 44 | )) 45 | grammarUriToPanel.set(entryUri, panel) 46 | 47 | panel.iconPath = Uri.joinPath(extensionContext.value!.extensionUri, 'icon.png') 48 | 49 | loadIndexHtml(panel.webview) 50 | 51 | function postMessage(message: any) { 52 | // console.log('ext:postMessage', message) 53 | panel.webview.postMessage(message) 54 | } 55 | 56 | async function onReady() { 57 | logger.info(`Webview for ${entryUri.toString()} ready.`) 58 | const exampleUris = shallowRef(await findExampleFile(entryUri, grammarExt)) 59 | const exampleUri = shallowRef(exampleUris.value[0]) 60 | const exampleDoc = shallowRef() 61 | watchEffect(() => { 62 | const uri = exampleUri.value 63 | if (uri) 64 | workspace.openTextDocument(uri).then(d => exampleDoc.value = d) 65 | }) 66 | const exampleCode = useDocumentText(exampleDoc) 67 | const exampleLang = ref(null) 68 | 69 | const grammarFiles: Record = reactive({ 70 | [entryUri.toString()]: { 71 | name: null, 72 | scope: null, 73 | path: workspace.asRelativePath(entryUri), 74 | enabled: true, 75 | }, 76 | }) 77 | const grammarDocs: Record = shallowReactive({ 78 | [entryUri.toString()]: editor.document, 79 | }) 80 | const forceUpdateGrammars = ref(0) 81 | 82 | const tokenizer = shallowRef> | null>(null) 83 | watchEffect((onCleanup) => { 84 | // eslint-disable-next-line ts/no-unused-expressions 85 | forceUpdateGrammars.value 86 | let cancelled = false 87 | onCleanup(() => { 88 | cancelled = true 89 | }) 90 | const docs = { ...grammarDocs } 91 | const dark = isDark.value; 92 | (async () => { 93 | try { 94 | const grammars: any[] = [] 95 | for (const uri in docs) { 96 | const doc = docs[uri] 97 | if (!doc) 98 | continue 99 | const g = await parser(doc.getText()) 100 | if (cancelled) 101 | return 102 | if (g.name) 103 | grammarFiles[uri].name = g.name 104 | if (g.scopeName) 105 | grammarFiles[uri].scope = g.scopeName 106 | if (grammarFiles[uri].enabled) 107 | grammars.push(g) 108 | } 109 | const t = await getTokenizer(grammars, dark) 110 | if (cancelled) 111 | return 112 | tokenizer.value?.[1]?.dispose() 113 | tokenizer.value = t 114 | } 115 | catch (e) { 116 | if (cancelled) 117 | return 118 | tokenizer.value = [() => String(e), null] 119 | } 120 | })() 121 | }) 122 | 123 | useDisposable(workspace.onDidChangeTextDocument(({ document }) => { 124 | if (grammarFiles[document.uri.toString()]) 125 | forceUpdateGrammars.value++ 126 | })) 127 | 128 | watchEffect(() => { 129 | if (tokenizer.value && exampleCode.value && exampleUri.value) { 130 | postMessage({ 131 | type: 'ext:update-tokens', 132 | tokens: tokenizer.value[0](exampleCode.value, exampleLang.value || grammarFiles[editor.document.uri.toString()].name || 'plaintext'), 133 | code: exampleCode.value, 134 | grammarFiles: { ...grammarFiles }, 135 | examplePath: workspace.asRelativePath(exampleUri.value), 136 | }) 137 | } 138 | }) 139 | 140 | let writeExampleTimer: NodeJS.Timeout | null = null 141 | panel.webview.onDidReceiveMessage((message) => { 142 | if (message.type === 'ui:update-example') { 143 | exampleCode.value = message.code 144 | const uri = exampleUri.value 145 | if (uri) { 146 | if (writeExampleTimer) 147 | clearTimeout(writeExampleTimer) 148 | writeExampleTimer = setTimeout(async () => { 149 | await workspace.fs.writeFile(uri, Buffer.from(message.code)) 150 | }, 500) 151 | } 152 | } 153 | else if (message.type === 'ui:open-grammar-file') { 154 | window.showTextDocument(editor.document) 155 | } 156 | else if (message.type === 'ui:open-example-file') { 157 | if (exampleDoc.value) 158 | window.showTextDocument(exampleDoc.value) 159 | } 160 | else if (message.type === 'ui:choose-example-file') { 161 | window.showOpenDialog({ 162 | canSelectFiles: true, 163 | canSelectFolders: false, 164 | canSelectMany: false, 165 | defaultUri: exampleUri.value, 166 | }).then((uris) => { 167 | const uri = uris?.[0] 168 | if (uri) { 169 | exampleUri.value = uri 170 | } 171 | }) 172 | } 173 | else if (message.type === 'ui:add-grammar') { 174 | window.showOpenDialog({ 175 | canSelectFiles: true, 176 | canSelectFolders: false, 177 | canSelectMany: false, 178 | defaultUri: exampleUri.value, 179 | filters: { 180 | 'TmLanguage JSON': ['json'], 181 | }, 182 | }).then(async (uris) => { 183 | if (uris) { 184 | for (const uri of uris) { 185 | const doc = await workspace.openTextDocument(uri) 186 | grammarFiles[uri.toString()] = { 187 | name: null, 188 | scope: null, 189 | path: workspace.asRelativePath(uri), 190 | enabled: true, 191 | } 192 | grammarDocs[uri.toString()] = doc 193 | } 194 | } 195 | }) 196 | } 197 | else if (message.type === 'ui:remove-grammar') { 198 | const uri = message.uri 199 | delete grammarFiles[uri] 200 | delete grammarDocs[uri] 201 | } 202 | else if (message.type === 'ui:toggle-grammar') { 203 | const uri = message.uri 204 | if (grammarFiles[uri]) 205 | grammarFiles[uri].enabled = message.enabled 206 | } 207 | }) 208 | } 209 | 210 | let scope: EffectScope | null = null 211 | panel.webview.onDidReceiveMessage((message) => { 212 | if (message.type === 'ui:ready') { 213 | scope?.stop() 214 | scope = effectScope() 215 | scope.run(onReady) 216 | } 217 | }) 218 | 219 | panel.onDidDispose(() => { 220 | grammarUriToPanel.delete(entryUri) 221 | scope?.stop() 222 | }) 223 | } 224 | -------------------------------------------------------------------------------- /extension/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/prefer-global/process */ 2 | /// 3 | import { extensionContext, useLogger } from 'reactive-vscode' 4 | import type { Webview } from 'vscode' 5 | 6 | export function loadIndexHtml(webview: Webview) { 7 | webview.html = process.env.VITE_DEV_SERVER_URL 8 | ? __getWebviewHtml__(process.env.VITE_DEV_SERVER_URL) 9 | : __getWebviewHtml__(webview, extensionContext.value!) 10 | } 11 | 12 | export const logger = useLogger('tmLanguage Previewer') 13 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kermanx/tmLanguage-Previewer/40bee2ce5b0842dafbaf3e776110a6ca6e065ed9/icon.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | tmLanguage Previewer 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "publisher": "kermanx", 3 | "name": "tmlanguage-previewer", 4 | "displayName": "tmLanguage Previewer", 5 | "type": "commonjs", 6 | "version": "0.0.4", 7 | "private": true, 8 | "packageManager": "pnpm@9.15.4", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/KermanX/tmLanguage-Previewer" 12 | }, 13 | "categories": [ 14 | "Other" 15 | ], 16 | "main": "./dist/extension/index.js", 17 | "icon": "./icon.png", 18 | "files": [ 19 | "LICENSE", 20 | "dist", 21 | "icon.png", 22 | "package.json" 23 | ], 24 | "engines": { 25 | "vscode": "^1.89.0" 26 | }, 27 | "activationEvents": [ 28 | "onCommand:*", 29 | "onLanguage:json" 30 | ], 31 | "contributes": { 32 | "commands": [ 33 | { 34 | "command": "tmlanguage-previewer.open", 35 | "title": "Open Preview to the side", 36 | "icon": "./icon.png" 37 | } 38 | ], 39 | "configuration": { 40 | "title": "tmLanguage Previewer", 41 | "properties": { 42 | "tmlanguage-previewer.autoStart": { 43 | "type": "boolean", 44 | "default": false, 45 | "description": "Should the previewer automatically open when a tmLanguage file is opened?" 46 | }, 47 | "tmlanguage-previewer.grammarExts": { 48 | "type": "object", 49 | "properties": {}, 50 | "additionalProperties": { 51 | "type": "string" 52 | }, 53 | "default": { 54 | ".tmLanguage.json": "json" 55 | }, 56 | "description": "File extensions to parser mapping." 57 | }, 58 | "tmlanguage-previewer.exampleSuffixes": { 59 | "type": "array", 60 | "items": { 61 | "type": "string" 62 | }, 63 | "default": [ 64 | ".example.*", 65 | "-example.*", 66 | ".*" 67 | ], 68 | "description": "The possible suffixes for example files." 69 | }, 70 | "tmlanguage-previewer.previewColumn": { 71 | "type": "string", 72 | "enum": [ 73 | "active", 74 | "beside", 75 | "end", 76 | "one", 77 | "two", 78 | "three" 79 | ], 80 | "default": "beside", 81 | "description": "Controls where the preview will be shown when opening a tmLanguage file." 82 | } 83 | } 84 | }, 85 | "menus": { 86 | "editor/title": [ 87 | { 88 | "command": "tmlanguage-previewer.open", 89 | "group": "navigation", 90 | "when": "editorLangId == json && resourceFilename =~ /\\.tmLanguage\\..*$/i" 91 | } 92 | ] 93 | } 94 | }, 95 | "scripts": { 96 | "build": "vite build", 97 | "dev": "vite", 98 | "typecheck": "tsc --noEmit", 99 | "vscode:prepublish": "pnpm run build", 100 | "lint": "eslint . --cache", 101 | "lint:fix": "eslint . --cache --fix", 102 | "prepare": "simple-git-hooks" 103 | }, 104 | "devDependencies": { 105 | "@antfu/eslint-config": "^2.27.3", 106 | "@iconify-json/carbon": "^1.2.6", 107 | "@reactive-vscode/vueuse": "^0.2.10", 108 | "@tomjs/vite-plugin-vscode": "^2.6.0", 109 | "@types/js-yaml": "^4.0.9", 110 | "@types/node": "18.x", 111 | "@types/vscode": "^1.89.0", 112 | "@unocss/reset": "^0.61.9", 113 | "@vitejs/plugin-vue": "^5.2.1", 114 | "eslint": "^9.20.0", 115 | "floating-vue": "^5.2.2", 116 | "js-yaml": "^4.1.0", 117 | "lint-staged": "^15.4.3", 118 | "reactive-vscode": "^0.2.10", 119 | "shiki": "^1.29.2", 120 | "simple-git-hooks": "^2.11.1", 121 | "typescript": "^5.7.3", 122 | "unocss": "^0.61.9", 123 | "vite": "^5.4.14", 124 | "vue": "^3.5.13" 125 | }, 126 | "simple-git-hooks": { 127 | "pre-commit": "pnpm lint-staged" 128 | }, 129 | "lint-staged": { 130 | "*": "eslint --fix" 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 |