├── .gitignore ├── README.md ├── jest.config.js ├── package.json ├── src ├── helpers.ts ├── index.ts ├── language-service.test.ts ├── language-service.ts ├── log.ts ├── modules.d.ts └── tailwind.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deprecated! 2 | 3 | This plugin was written at a time when the [official VSCode extension](https://github.com/tailwindlabs/tailwindcss-intellisense) didn't have support for twin.macro. Recently, it added support for custom completion contexts, which gives support for tailwind intellisense outside of the class prop, _including_ for twin.macro. 4 | 5 | While it doesn't warn on unknown classes at the time of writing this, everything else the extension offers (hover info, color decorators, more speed) more than makes up for it, so I strongly recommend using it instead of this. For details on how to configure with twin.macro, [see this post](https://github.com/ben-rogerson/twin.macro/discussions/227). I'll keep this repo un-archived in case there are further updates. 6 | 7 | # typescript-plugin-tw-template 8 | 9 | Provides editor support for `tw`\`...\` tagged template syntax, like [twin.macro](https://github.com/ben-rogerson/twin.macro), including: 10 | 11 | - Autocomplete for tailwind classes 12 | - Warnings on unknown classes 13 | 14 | **Note:** the classes found by this plugin may be different from those supported by twin.macro or other solutions, so you may get false warnings, or some valid classes might be missing from autocomplete. YMMV 15 | 16 | ## Install 17 | 18 | ```bash 19 | # yarn 20 | yarn add -D typescript-plugin-tw-template tailwindcss 21 | 22 | # npm 23 | npm install -D typescript-plugin-tw-template tailwindcss 24 | ``` 25 | 26 | ## Usage 27 | 28 | Add it to the `"plugins"` section in `tsconfig.json` or `jsconfig.json` 29 | 30 | ```json 31 | { 32 | "compilerOptions": { 33 | // other options... 34 | "plugins": [{ "name": "typescript-plugin-tw-template" }] 35 | } 36 | } 37 | ``` 38 | 39 | The plugin will read from a custom `tailwind.config.js` file at the project root, or use the default config 40 | 41 | **Make sure you're using the workspace TS version!** In VSCode, you can do this by opening any TS file, then click on the TS version number in the bottom right. 42 | 43 | ## TODO 44 | 45 | - [ ] Config for custom tailwind config filename 46 | - [ ] Custom pragma config 47 | - [ ] Show CSS source for class names in autocomplete info 48 | - [ ] Show CSS source when hovering over class names 49 | - [x] Warnings on unknown tailwind classes 50 | - [x] Filter out already used class names in autocomplete 51 | - [x] Unit Tests 52 | - [ ] Support `tw` prop (?) 53 | - [ ] Integration / e2e tests (?) 54 | - [ ] Support `!` suffix from twin.macro 55 | - [x] Autocomplete classes and variants separately (?) 56 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | testPathIgnorePatterns: ["/node_modules/", "/build/"], 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-plugin-tw-template", 3 | "version": "2.1.0", 4 | "author": "kingdaro", 5 | "license": "MIT", 6 | "repository": "https://github.com/kingdaro/typescript-plugin-tw-template", 7 | "homepage": "https://github.com/kingdaro/typescript-plugin-tw-template#readme", 8 | "changelog": "https://github.com/kingdaro/typescript-plugin-tw-template/releases", 9 | "main": "build/index.js", 10 | "files": [ 11 | "build" 12 | ], 13 | "scripts": { 14 | "build": "trash build && tsc", 15 | "test": "jest", 16 | "prepublishOnly": "yarn test && yarn build", 17 | "release": "np" 18 | }, 19 | "dependencies": { 20 | "postcss": "^7.0.27", 21 | "typescript-template-language-service-decorator": "^2.2.0" 22 | }, 23 | "devDependencies": { 24 | "@types/jest": "^25.2.1", 25 | "@types/node": "^13.13.1", 26 | "@typescript-eslint/eslint-plugin": "^2.29.0", 27 | "@typescript-eslint/parser": "^2.29.0", 28 | "eslint": "^6.8.0", 29 | "eslint-config-prettier": "^6.10.1", 30 | "eslint-plugin-prettier": "^3.1.3", 31 | "jest": "^25.4.0", 32 | "np": "^6.5.0", 33 | "prettier": "^2.0.4", 34 | "tailwindcss": "^1.2.0", 35 | "trash-cli": "^3.0.0", 36 | "ts-jest": "^25.4.0", 37 | "typescript": "^3.8.3" 38 | }, 39 | "peerDependencies": { 40 | "tailwindcss": ">= 1.0.0", 41 | "typescript": ">= 3.0.0" 42 | }, 43 | "eslintConfig": { 44 | "env": { 45 | "es6": true, 46 | "node": true 47 | }, 48 | "extends": [ 49 | "eslint:recommended", 50 | "plugin:@typescript-eslint/eslint-recommended", 51 | "plugin:prettier/recommended" 52 | ], 53 | "globals": { 54 | "Atomics": "readonly", 55 | "SharedArrayBuffer": "readonly" 56 | }, 57 | "parser": "@typescript-eslint/parser", 58 | "parserOptions": { 59 | "ecmaVersion": 2018, 60 | "sourceType": "module" 61 | }, 62 | "plugins": [ 63 | "@typescript-eslint", 64 | "prettier" 65 | ], 66 | "rules": { 67 | "no-unused-vars": "off" 68 | } 69 | }, 70 | "prettier": { 71 | "semi": false, 72 | "trailingComma": "all", 73 | "quoteProps": "consistent" 74 | }, 75 | "publishConfig": { 76 | "registry": "https://registry.npmjs.org" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | export function uniqueBy( 2 | items: Iterable, 3 | getKey: (item: T) => unknown, 4 | ): T[] { 5 | const keyedItems = new Map() 6 | for (const item of items) { 7 | keyedItems.set(getKey(item), item) 8 | } 9 | return [...keyedItems.values()] 10 | } 11 | 12 | export function* regexExec(regex: RegExp, text: string) { 13 | const regexCopy = new RegExp(regex, "g") 14 | let match: RegExpExecArray | null 15 | while ((match = regexCopy.exec(text)) != null) { 16 | yield match 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path" 2 | import { decorateWithTemplateLanguageService } from "typescript-template-language-service-decorator" 3 | import ts from "typescript/lib/tsserverlibrary" 4 | import { 5 | createLanguageService, 6 | LanguageServiceContext, 7 | } from "./language-service" 8 | import { createLogFunction } from "./log" 9 | import { populateCompletions } from "./tailwind" 10 | 11 | export = function init(mod: { typescript: typeof ts }) { 12 | const context: LanguageServiceContext = { 13 | completionEntries: new Map(), 14 | } 15 | 16 | let initialized = false 17 | 18 | return { 19 | create(info: ts.server.PluginCreateInfo): ts.LanguageService { 20 | const log = createLogFunction(info) 21 | 22 | // TODO: make this configurable 23 | const configPath = join( 24 | info.project.getCurrentDirectory(), 25 | "tailwind.config.js", 26 | ) 27 | 28 | if (!initialized) { 29 | populateCompletions(context, configPath).catch((error) => { 30 | log("an error occured:", String(error)) 31 | }) 32 | initialized = true 33 | } 34 | 35 | return decorateWithTemplateLanguageService( 36 | mod.typescript, 37 | info.languageService, 38 | info.project, 39 | createLanguageService(context), 40 | { tags: ["tw"] }, 41 | ) 42 | }, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/language-service.test.ts: -------------------------------------------------------------------------------- 1 | import { TemplateContext } from "typescript-template-language-service-decorator" 2 | import { 3 | createLanguageService, 4 | LanguageServiceContext, 5 | } from "./language-service" 6 | import { addClassNameToCompletions } from "./tailwind" 7 | 8 | const mockContext = (classNames: string[]): LanguageServiceContext => ({ 9 | completionEntries: new Map(classNames.map((name) => [name, { name }])), 10 | }) 11 | 12 | const mockTemplateContext = (text = "") => 13 | ({ 14 | text, 15 | node: { getSourceFile: () => ({}) }, 16 | } as TemplateContext) 17 | 18 | const mockPosition = (line = 0, character = 0): ts.LineAndCharacter => ({ 19 | line, 20 | character, 21 | }) 22 | 23 | it("shows class names in autocomplete", () => { 24 | const ls = createLanguageService(mockContext(["a", "b", "c"])) 25 | 26 | const { entries = [] } = 27 | ls.getCompletionsAtPosition?.(mockTemplateContext(), mockPosition()) || {} 28 | 29 | expect(entries).toHaveLength(3) 30 | expect(entries).toContainEqual(expect.objectContaining({ name: "a" })) 31 | expect(entries).toContainEqual(expect.objectContaining({ name: "b" })) 32 | expect(entries).toContainEqual(expect.objectContaining({ name: "c" })) 33 | }) 34 | 35 | it("gives warnings on invalid class names", () => { 36 | const ls = createLanguageService(mockContext(["a", "b", "c"])) 37 | 38 | const diagnostics = ls.getSemanticDiagnostics?.( 39 | mockTemplateContext(`invalid1 a invalid2`), 40 | ) 41 | 42 | expect(diagnostics).toHaveLength(2) 43 | 44 | expect(diagnostics).toContainEqual( 45 | expect.objectContaining({ 46 | messageText: expect.stringContaining(`invalid1`), 47 | start: 0, 48 | length: 8, 49 | }), 50 | ) 51 | 52 | expect(diagnostics).toContainEqual( 53 | expect.objectContaining({ 54 | messageText: expect.stringContaining(`invalid2`), 55 | start: 11, 56 | length: 8, 57 | }), 58 | ) 59 | }) 60 | 61 | it("shows variants separate from class names", () => { 62 | const context = mockContext([]) 63 | 64 | addClassNameToCompletions("text-white", context) 65 | addClassNameToCompletions("hover:text-white", context) 66 | addClassNameToCompletions("md:hover:text-white", context) 67 | 68 | const ls = createLanguageService(context) 69 | 70 | const { entries = [] } = 71 | ls.getCompletionsAtPosition?.(mockTemplateContext(), mockPosition()) || {} 72 | 73 | expect(entries).toHaveLength(3) 74 | expect(entries).toContainEqual( 75 | expect.objectContaining({ name: "text-white" }), 76 | ) 77 | expect(entries).toContainEqual(expect.objectContaining({ name: "hover:" })) 78 | expect(entries).toContainEqual(expect.objectContaining({ name: "md:" })) 79 | }) 80 | -------------------------------------------------------------------------------- /src/language-service.ts: -------------------------------------------------------------------------------- 1 | import { TemplateLanguageService } from "typescript-template-language-service-decorator" 2 | import ts from "typescript/lib/tsserverlibrary" 3 | import { regexExec } from "./helpers" 4 | 5 | export type LanguageServiceContext = { 6 | completionEntries: Map 7 | } 8 | 9 | export function createLanguageService( 10 | languageServiceContext: LanguageServiceContext, 11 | ): TemplateLanguageService { 12 | return { 13 | getCompletionsAtPosition(templateContext) { 14 | const templateClasses = new Set( 15 | templateContext.text.split(/\s+/).filter(Boolean), 16 | ) 17 | 18 | const entries: ts.CompletionEntry[] = [] 19 | 20 | languageServiceContext.completionEntries.forEach((rule) => { 21 | if (!templateClasses.has(rule.name)) { 22 | entries.push({ 23 | name: rule.name, 24 | sortText: rule.name, 25 | kind: ts.ScriptElementKind.string, 26 | }) 27 | } 28 | }) 29 | 30 | return { 31 | entries, 32 | isGlobalCompletion: false, 33 | isMemberCompletion: false, 34 | isNewIdentifierLocation: false, 35 | } 36 | }, 37 | 38 | getSemanticDiagnostics(templateContext) { 39 | const diagnostics: ts.Diagnostic[] = [] 40 | 41 | for (const match of regexExec(/[^:\s]+:?/g, templateContext.text)) { 42 | const className = match[0] 43 | const start = match.index 44 | const length = match[0].length 45 | 46 | if (!languageServiceContext.completionEntries.has(className)) { 47 | diagnostics.push({ 48 | messageText: `unknown tailwind class or variant "${className}"`, 49 | start: start, 50 | length: length, 51 | file: templateContext.node.getSourceFile(), 52 | category: ts.DiagnosticCategory.Warning, 53 | code: 0, // ??? 54 | }) 55 | } 56 | } 57 | 58 | return diagnostics 59 | }, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript/lib/tsserverlibrary" 2 | 3 | export function createLogFunction(info: ts.server.PluginCreateInfo) { 4 | return (...stuff: unknown[]) => { 5 | const output = stuff 6 | .map((value) => JSON.stringify(value, null, 2)) 7 | .join(" ") 8 | 9 | return info.project.projectService.logger.info( 10 | `[twin-macro-autocomplete] ${output}`, 11 | ) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module "tailwindcss" 2 | -------------------------------------------------------------------------------- /src/tailwind.ts: -------------------------------------------------------------------------------- 1 | import postcss from "postcss" 2 | import tailwind from "tailwindcss" 3 | import { LanguageServiceContext } from "./language-service" 4 | 5 | // matches either an escaped colon or anything but a colon, 6 | // so we stop at the pseudo-class 7 | const classNameRegex = /\.((?:\\:|[^:\s])+)/gi 8 | 9 | export async function populateCompletions( 10 | context: LanguageServiceContext, 11 | configPath: string, 12 | ) { 13 | const result = await postcss(tailwind(configPath)).process( 14 | `@tailwind components; @tailwind utilities;`, 15 | ) 16 | context.completionEntries.clear() 17 | 18 | result.root?.walkRules((rule) => { 19 | rule.selector.match(classNameRegex)?.forEach((match) => { 20 | // remove the dot and escapes 21 | const className = match.slice(1).replace(/\\/g, "") 22 | if (className) { 23 | addClassNameToCompletions(className, context) 24 | } 25 | }) 26 | }) 27 | } 28 | 29 | export function addClassNameToCompletions( 30 | className: string, 31 | context: LanguageServiceContext, 32 | ) { 33 | // split the variants from the base class name 34 | // e.g. with `md:hover:text-white`, variants should be `md:` and `hover:` 35 | const parts = className.split(":") 36 | const variants = parts.slice(0, -1) 37 | const baseClassName = parts[parts.length - 1] 38 | 39 | context.completionEntries.set(baseClassName, { name: baseClassName }) 40 | for (const variant of variants) { 41 | context.completionEntries.set(`${variant}:`, { name: `${variant}:` }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es2019" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 6 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | "allowJs": true /* Allow javascript files to be compiled. */, 9 | "checkJs": false /* Report errors in .js files. */, 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "build" /* Redirect output structure to the directory. */, 16 | "rootDir": "src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true /* Enable all strict type-checking options. */, 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | 53 | /* Source Map Options */ 54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 58 | 59 | /* Experimental Options */ 60 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 61 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 62 | 63 | /* Advanced Options */ 64 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 65 | "maxNodeModuleJsDepth": 1 66 | }, 67 | "include": ["src"] 68 | } 69 | --------------------------------------------------------------------------------