diff --git a/packages/typescript-plugin/CHANGELOG.md b/packages/typescript-plugin/CHANGELOG.md new file mode 100644 index 0000000..e4d87c4 --- /dev/null +++ b/packages/typescript-plugin/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. diff --git a/packages/typescript-plugin/LICENSE b/packages/typescript-plugin/LICENSE new file mode 100644 index 0000000..7da4830 --- /dev/null +++ b/packages/typescript-plugin/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 [these people](https://github.com/kenoxa/beamwind/graphs/contributors) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/typescript-plugin/README.md b/packages/typescript-plugin/README.md new file mode 100644 index 0000000..0cec614 --- /dev/null +++ b/packages/typescript-plugin/README.md @@ -0,0 +1,207 @@ +# @beamwind/typescript-plugincript-plugin + +> TypeScript language service plugin that adds IntelliSense for [beamwind](https://beamwind.js.org) + +[![MIT License](https://flat.badgen.net/github/license/kenoxa/beamwind)](https://github.com/kenoxa/beamwind/blob/main/LICENSE) +[![Latest Release](https://flat.badgen.net/npm/v/@beamwind/typescript-plugin?icon=npm&label)](https://www.npmjs.com/package/@beamwind/typescript-plugin) +[![Github](https://flat.badgen.net/badge/icon/kenoxa%2Fbeamwind?icon=github&label)](https://github.com/kenoxa/beamwind/blob/main/packages/typescript-plugin) + +> [Read the docs](https://beamwind.js.org) | +> [Change Log](https://github.com/kenoxa/beamwind/blob/main/packages/typescript-plugin/CHANGELOG.md) + +--- + + + + + + +- [Installation](#installation) +- [Usage](#usage) +- [Contribute](#contribute) +- [License](#license) + + + + +## Features + +Provides editor support for ```tw`...```` tagged template syntax including: + +- Autocomplete for tailwind, beamwind and oceanwin classes +- Warnings on unknown classes +- Quick fixes for misspelled property names. + + +## Installation + +```sh +npm install --save-dev @beamwind/typescript-plugin +``` + +## Usage + +> Please refer to the [main documentation](https://beamwind.js.org#usage) for further information. + +This plugin requires TypeScript 2.4 or later. It can provide intellisense in both JavaScript and TypeScript files within any editor that uses TypeScript to power their language features. This includes [VS Code](https://code.visualstudio.com), [Sublime with the TypeScript plugin](https://github.com/Microsoft/TypeScript-Sublime-Plugin), [Atom with the TypeScript plugin](https://atom.io/packages/atom-typescript), [Visual Studio](https://www.visualstudio.com), and others. + +### With VS Code + +Just install the [VS Code Beamwind extension](https://github.com/kenoxa/beamwind/packages/vscode). This extension adds syntax highlighting and IntelliSense for styled components in JavaScript and TypeScript files. + +If you are using a [workspace version of TypeScript]((https://code.visualstudio.com/Docs/languages/typescript#_using-newer-typescript-versions)) however, you must manually install the plugin along side the version of TypeScript in your workspace. + +Then add a `plugins` section to your [`tsconfig.json`](http://www.typescriptlang.org/docs/handbook/tsconfig-json.html) or [`jsconfig.json`](https://code.visualstudio.com/Docs/languages/javascript#_javascript-project-jsconfigjson) + +```json +{ + "compilerOptions": { + "plugins": [ + { + "name": "@beamwind/typescript-plugin" + } + ] + } +} +``` + +Finally, run the `Select TypeScript version` command in VS Code to switch to use the workspace version of TypeScript for VS Code's JavaScript and TypeScript language support. You can find more information about managing typescript versions [in the VS Code documentation](https://code.visualstudio.com/Docs/languages/typescript#_using-newer-typescript-versions). + +### With Sublime + +This plugin works with the [Sublime TypeScript plugin](https://github.com/Microsoft/TypeScript-Sublime-Plugin). + +And configure Sublime to use the workspace version of TypeScript by [setting the `typescript_tsdk`](https://github.com/Microsoft/TypeScript-Sublime-Plugin#note-using-different-versions-of-typescript) setting in Sublime: + +```json +{ + "typescript_tsdk": "/path/to/the/project/node_modules/typescript/lib" +} +``` + +Finally add a `plugins` section to your [`tsconfig.json`](http://www.typescriptlang.org/docs/handbook/tsconfig-json.html) or [`jsconfig.json`](https://code.visualstudio.com/Docs/languages/javascript#_javascript-project-jsconfigjson) and restart Sublime. + +```json +{ + "compilerOptions": { + "plugins": [ + { + "name": "@beamwind/typescript-plugin" + } + ] + } +} +``` + +### With Atom + +This plugin works with the [Atom TypeScript plugin](https://atom.io/packages/atom-typescript). + + +Then add a `plugins` section to your [`tsconfig.json`](http://www.typescriptlang.org/docs/handbook/tsconfig-json.html) or [`jsconfig.json`](https://code.visualstudio.com/Docs/languages/javascript#_javascript-project-jsconfigjson) and restart Atom. + +```json +{ + "compilerOptions": { + "plugins": [ + { + "name": "@beamwind/typescript-plugin" + } + ] + } +} +``` + +To get sytnax highlighting for styled strings in Atom, consider installing the [language-babel](https://atom.io/packages/language-babel) extension. + +### With Visual Studio + +This plugin works [Visual Studio 2017](https://www.visualstudio.com) using the TypeScript 2.5+ SDK. + +Then add a `plugins` section to your [`tsconfig.json`](http://www.typescriptlang.org/docs/handbook/tsconfig-json.html). + +```json +{ + "compilerOptions": { + "plugins": [ + { + "name": "@beamwind/typescript-plugin" + } + ] + } +} +``` + +Then reload your project to make sure the plugin has been loaded properly. Note that `jsconfig.json` projects are currently not supported in VS. + +## Configuration + +### Tags + +This plugin adds IntelliSense to any template literal [tagged](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals) with `tw`, `ow` or `bw`: + +```js +import { bw } from 'beamwind' + +bw` + sm:hover:( + bg-black + text-white + ) + md:(bg-white hover:text-black) +` +``` + +You can enable IntelliSense for other tag names by configuring `"tags"`: + +```json +{ + "compilerOptions": { + "plugins": [ + { + "name": "@beamwind/typescript-plugin", + "tags": [ + "tw", + "cx" + ] + } + ] + } +} +``` + +Now strings tagged with either `tw` and `cx` will have IntelliSense. + +## Contribute + +Thanks for being willing to contribute! + +> This project is free and open-source, so if you think this project can help you or anyone else, you may [star it on GitHub](https://github.com/kenoxa/beamwind). Feel free to [open an issue](https://github.com/kenoxa/beamwind/issues) if you have any idea, question, or you've found a bug. + +**Working on your first Pull Request?** You can learn how from this _free_ series [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github) + +We are following the [Conventional Commits](https://www.conventionalcommits.org) convention. + +### Manual testing the Language service plugin + +You can check manually language service plugin features with our example project. + +``` +yarn build +cd dist +yarn link +cd project-fixtures/react-apollo-prj +yarn install +yarn link @beamwind/typescript-plugin +code . # Or launch editor/IDE what you like +``` + +Of course, you can use other editor which communicates with tsserver . + +### Sponsors + +[![Kenoxa GmbH](https://images.opencollective.com/kenoxa/9c25796/logo/68.png)](https://www.kenoxa.com) [Kenoxa GmbH](https://www.kenoxa.com) + +## License + +[MIT](https://github.com/kenoxa/beamwind/blob/main/LICENSE) © [Kenoxa GmbH](https://kenoxa.com) diff --git a/packages/typescript-plugin/package-scripts.js b/packages/typescript-plugin/package-scripts.js new file mode 100644 index 0000000..e9135a7 --- /dev/null +++ b/packages/typescript-plugin/package-scripts.js @@ -0,0 +1 @@ +module.exports = require('../../package-scripts') diff --git a/packages/typescript-plugin/package.json b/packages/typescript-plugin/package.json new file mode 100644 index 0000000..ca0935c --- /dev/null +++ b/packages/typescript-plugin/package.json @@ -0,0 +1,50 @@ +{ + "name": "@beamwind/typescript-plugin", + "version": "0.0.0", + "description": "TypeScript language service plugin that adds IntelliSense for beamwind", + "keywords": [ + "TypeScript", + "beamwind", + "tailwind" + ], + "homepage": "https://github.com/kenoxa/beamwind#readme", + "bugs": { + "url": "https://github.com/kenoxa/beamwind/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/kenoxa/beamwind.git", + "directory": "packages/typescript-plugin" + }, + "license": "MIT", + "author": "Kenoxa GmbH ", + "source": "src/index.ts", + "main": "dist/node/typescript-plugin.js", + "browser": false, + "scripts": { + "build": "nps", + "prepublishOnly": "nps", + "test": "nps" + }, + "prettier": "@carv/prettier-config", + "eslintConfig": { + "extends": "@carv/eslint-config", + "root": true + }, + "jest": { + "preset": "@carv/jest-preset" + }, + "dependencies": { + "dlv": "^1.1.3", + "tailwindcss": "^2.0.1", + "typescript-template-language-service-decorator": "^2.2.0", + "vscode-languageserver-types": "^3.13.0" + + }, + "devDependencies": { + "nps": "^5.9.12" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/typescript-plugin/src/config.ts b/packages/typescript-plugin/src/config.ts new file mode 100644 index 0000000..b6b8c72 --- /dev/null +++ b/packages/typescript-plugin/src/config.ts @@ -0,0 +1 @@ +export const pluginName = 'typescript-beamwind-plugin' diff --git a/packages/typescript-plugin/src/configuration.ts b/packages/typescript-plugin/src/configuration.ts new file mode 100644 index 0000000..57dfecc --- /dev/null +++ b/packages/typescript-plugin/src/configuration.ts @@ -0,0 +1,41 @@ +export interface BeamwindPluginConfiguration { + readonly tags: ReadonlyArray; + // readonly validate: boolean; + // readonly lint: { [key: string]: any }; + // readonly emmet: { [key: string]: any }; +} + +export class ConfigurationManager { + + private static readonly defaultConfiguration: BeamwindPluginConfiguration = { + tags: ['bw', 'ow'], + // validate: true, + // lint: { + // emptyRules: 'ignore', + // }, + // emmet: {}, + }; + + private readonly _configUpdatedListeners = new Set<() => void>(); + + public get config(): BeamwindPluginConfiguration { + return this._configuration; + } + + private _configuration: BeamwindPluginConfiguration = ConfigurationManager.defaultConfiguration; + + public updateFromPluginConfig(config: BeamwindPluginConfiguration): void { + this._configuration = { + ...ConfigurationManager.defaultConfiguration, + ...config, + }; + + for (const listener of this._configUpdatedListeners) { + listener(); + } + } + + public onUpdatedConfig(listener: () => void): void { + this._configUpdatedListeners.add(listener); + } +} diff --git a/packages/typescript-plugin/src/index.ts b/packages/typescript-plugin/src/index.ts new file mode 100644 index 0000000..ce34b32 --- /dev/null +++ b/packages/typescript-plugin/src/index.ts @@ -0,0 +1,9 @@ +// https://github.com/kingdaro/typescript-plugin-tw-template/blob/master/src/index.ts +// https://github.com/microsoft/typescript-styled-plugin/blob/master/README.md +// https://github.com/Quramy/ts-graphql-plugin/tree/master/src +// https://github.com/tailwindlabs/tailwindcss-intellisense/tree/master/packages/tailwindcss-language-service + +import type * as ts from 'typescript/lib/tsserverlibrary' +import { BeamwindPlugin } from './plugin' + +export = (config: { typescript: typeof ts }): BeamwindPlugin => new BeamwindPlugin(config.typescript) diff --git a/packages/typescript-plugin/src/language-service.ts b/packages/typescript-plugin/src/language-service.ts new file mode 100644 index 0000000..a54420e --- /dev/null +++ b/packages/typescript-plugin/src/language-service.ts @@ -0,0 +1,586 @@ +// Original code forked from https://github.com/microsoft/typescript-styled-plugin/blob/master/src/_language-service.ts +// License MIT + +import type { + Logger, + TemplateContext, + TemplateLanguageService, +} from 'typescript-template-language-service-decorator' +import type * as ts from 'typescript/lib/tsserverlibrary' + +import type { ConfigurationManager } from './configuration' + +import * as vscode from 'vscode-languageserver-types' + +import { processPlugins } from './process-plugins' + +function arePositionsEqual(left: ts.LineAndCharacter, right: ts.LineAndCharacter): boolean { + return left.line === right.line && left.character === right.character +} + +function isAfter(left: vscode.Position, right: vscode.Position): boolean { + return right.line > left.line || (right.line === left.line && right.character >= left.character) +} + +function overlaps(a: vscode.Range, b: vscode.Range): boolean { + return !isAfter(a.end, b.start) && !isAfter(b.end, a.start) +} + +function pad(n: string): string { + return ('00000000' + n).slice(-8) +} + +function naturalExpand(value: number | string): string { + const string = typeof value === 'string' ? value : value.toString() + return string.replace(/\d+/g, pad) +} + +const emptyCompletionList: vscode.CompletionList = { + items: [], + isIncomplete: false, +} + +class CompletionsCache { + private _cachedCompletionsFile?: string + private _cachedCompletionsPosition?: ts.LineAndCharacter + private _cachedCompletionsContent?: string + private _completions?: vscode.CompletionList + + public getCached( + context: TemplateContext, + position: ts.LineAndCharacter, + ): vscode.CompletionList | undefined { + if ( + this._completions && + context.fileName === this._cachedCompletionsFile && + this._cachedCompletionsPosition && + arePositionsEqual(position, this._cachedCompletionsPosition) && + context.text === this._cachedCompletionsContent + ) { + return this._completions + } + } + + public updateCached( + context: TemplateContext, + position: ts.LineAndCharacter, + completions: vscode.CompletionList, + ): void { + this._cachedCompletionsFile = context.fileName + this._cachedCompletionsPosition = position + this._cachedCompletionsContent = context.text + this._completions = completions + } +} + +export class BeamwindTemplateLanguageService implements TemplateLanguageService { + private readonly typescript: typeof ts + private readonly configurationManager: ConfigurationManager + private readonly logger: Logger + private readonly _completionsCache = new CompletionsCache() + + private state: ReturnType + + constructor(typescript: typeof ts, configurationManager: ConfigurationManager, logger: Logger) { + this.typescript = typescript + this.configurationManager = configurationManager + this.logger = logger + this.state = processPlugins() + } + + public getCompletionsAtPosition( + context: TemplateContext, + position: ts.LineAndCharacter, + ): ts.WithMetadata { + const items = this.getCompletionItems(context, position) + + return translateCompletionItemsToCompletionInfo(context, items) + } + + public getCompletionEntryDetails( + context: TemplateContext, + position: ts.LineAndCharacter, + name: string, + ): ts.CompletionEntryDetails { + const item = this.getCompletionItems(context, position).items.find((x) => x.label === name) + + if (!item) { + return { + name, + kind: this.typescript.ScriptElementKind.unknown, + kindModifiers: '', + tags: [], + displayParts: toDisplayParts(name), + documentation: [], + } + } + + return translateCompletionItemsToCompletionEntryDetails(this.typescript, item) + } + + // public getQuickInfoAtPosition( + // context: TemplateContext, + // position: ts.LineAndCharacter, + // ): ts.QuickInfo | undefined { + // const doc = this.virtualDocumentFactory.createVirtualDocument(context) + // const stylesheet = this.scssLanguageService.parseStylesheet(doc) + // const hover = this.scssLanguageService.doHover( + // doc, + // this.virtualDocumentFactory.toVirtualDocPosition(position), + // stylesheet, + // ) + // if (hover) { + // return this.translateHover( + // hover, + // this.virtualDocumentFactory.toVirtualDocPosition(position), + // context, + // ) + // } + // } + + // public getSemanticDiagnostics(context: TemplateContext): ts.Diagnostic[] { + // const diagnostics: ts.Diagnostic[] = [] + + // // for (const match of regexExec(/[^:\s]+:?/g, templateContext.text)) { + // // const className = match[0] + // // const start = match.index + // // const length = match[0].length + + // // if (!languageServiceContext.completionEntries.has(className)) { + // // diagnostics.push({ + // // messageText: `unknown tailwind class or variant "${className}"`, + // // start: start, + // // length: length, + // // file: templateContext.node.getSourceFile(), + // // category: ts.DiagnosticCategory.Warning, + // // code: 0, // ??? + // // }) + // // } + // // } + + // return diagnostics + // // const doc = this.virtualDocumentFactory.createVirtualDocument(context) + // // const stylesheet = this.scssLanguageService.parseStylesheet(doc) + // // return this.translateDiagnostics( + // // this.scssLanguageService.doValidation(doc, stylesheet), + // // doc, + // // context, + // // context.text, + // // ).filter((x) => !!x) as ts.Diagnostic[] + // } + + // public getSupportedCodeFixes(): number[] { + // return [cssErrorCode] + // } + + // public getCodeFixesAtPosition( + // context: TemplateContext, + // start: number, + // end: number, + // _errorCodes: number[], + // _format: ts.FormatCodeSettings, + // ): ts.CodeAction[] { + // const doc = this.virtualDocumentFactory.createVirtualDocument(context) + // const stylesheet = this.scssLanguageService.parseStylesheet(doc) + // const range = this.toVsRange(context, start, end) + // const diagnostics = this.scssLanguageService + // .doValidation(doc, stylesheet) + // .filter((diagnostic) => overlaps(diagnostic.range, range)) + + // return this.translateCodeActions( + // context, + // this.scssLanguageService.doCodeActions(doc, range, { diagnostics }, stylesheet), + // ) + // } + + // public getOutliningSpans(context: TemplateContext): ts.OutliningSpan[] { + // const doc = this.virtualDocumentFactory.createVirtualDocument(context) + // const ranges = this.scssLanguageService.getFoldingRanges(doc) + // return ranges + // .filter((range) => { + // // Filter out ranges outside on last line + // const end = context.toOffset({ + // line: range.endLine, + // character: range.endCharacter || 0, + // }) + // return end < context.text.length + // }) + // .map((range) => this.translateOutliningSpan(context, range)) + // } + + private getCompletionItems( + context: TemplateContext, + position: ts.LineAndCharacter, + ): vscode.CompletionList { + this.logger.log( + `getCompletionItems[${context.fileName}:${position.line}:${ + position.character + }] ${JSON.stringify(context.text)}`, + ) + + // const cached = this._completionsCache.getCached(context, position) + + // if (cached) { + // return cached + // } + + const completions: vscode.CompletionList = { + isIncomplete: false, + items: [], + } + + // List of active groupings: either variant ('xxx:') or prefix + const groupings: string[] = [] + + const startGrouping = (value = ''): '' => { + groupings.push(value) + + return '' + } + + const endGrouping = (isWhitespace?: boolean): void => { + // If isWhitespace is true + // sm:hover:(mx-5 my-5) + // ['', ':sm', ':hover'] => [''] + // ['', ':sm', ':hover', ''] => ['', ':sm', ':hover', ''] + + // If isWhitespace is falsey + // ['', ':sm', ':hover'] => [''] + // ['', ':sm', ':hover', ''] => ['', ':sm', ':hover'] + + const index = groupings.lastIndexOf('') + + if (~index) { + /* eslint-disable unicorn/prefer-math-trunc */ + groupings.splice( + index + ~~(isWhitespace as boolean), + groupings.length - index + ~~(isWhitespace as boolean), + ) + /* eslint-enable unicorn/prefer-math-trunc */ + } + } + + const onlyPrefixes = (s: string): '' | boolean => s && s[0] !== ':' + const onlyVariants = (s: string): '' | boolean => s[0] === ':' + + const offset = context.toOffset(position) + + // We need an initial grouping + startGrouping() + + let char: string + let buffer = '' + let tokenStartOffset = 0 + const { text } = context + + for (let index = 0; index < offset; index++) { + switch ((char = text[index])) { + case ':': + if (buffer) { + tokenStartOffset = offset + buffer = startGrouping(':' + buffer) + } + + break + + case '(': + // If there is a buffer this is the prefix for all grouped tokens + if (buffer) { + tokenStartOffset = offset + buffer = startGrouping(buffer) + } + + startGrouping() + + break + + case ')': + case ' ': + case '\t': + case '\n': + case '\r': + tokenStartOffset = offset + buffer = '' + endGrouping(char !== ')') + + break + + default: + buffer += char + } + } + + const variants = groupings.filter(onlyVariants) + const prefix = groupings.filter(onlyPrefixes).join('-') + const token = buffer === '&' ? buffer : (prefix && prefix + '-') + buffer + + const directive = (variants.length === 0 ? '' : variants.join('').slice(1) + ':') + token + + if (buffer === '&') { + tokenStartOffset += 1 + } + + completions.items = [ + { + kind: vscode.CompletionItemKind.Text, + data: 'token', + label: directive, + sortText: `!${directive}`, + }, + ...Object.keys(this.state.screens) + .filter((screen) => variants.length === 0 && screen.startsWith(buffer)) + .map((screen) => ({ + kind: vscode.CompletionItemKind.EnumMember, + data: 'screens', + label: `${screen}:`, + sortText: `#${screen}`, + detail: `breakpoint @ ${this.state.screens[screen]}`, + documentation: { + kind: vscode.MarkupKind.PlainText, + value: `@media (min-width: ${this.state.screens[screen]})`, + }, + textEdit: { + newText: `${screen}:`.slice(buffer.length), + range: { + start: context.toPosition(tokenStartOffset + buffer.length), + end: position, + }, + }, + })), + ...this.state.variants + .filter((variant) => !variants.includes(':' + variant) && variant.startsWith(buffer)) + .map((variant) => ({ + kind: vscode.CompletionItemKind.Unit, + data: 'variant', + label: `${variant}:`, + sortText: `:${variant}`, + detail: `pseudo-class ${variant}`, + documentation: { kind: vscode.MarkupKind.PlainText, value: `Add ${variant} variant` }, + textEdit: { + newText: `${variant}:`.slice(buffer.length), + range: { + start: context.toPosition(tokenStartOffset + buffer.length), + end: position, + }, + }, + })), + // Start a new directive group + ...Object.keys(this.state.directives) + .filter((directive) => directive.startsWith(token)) + // tex + // text-current + // => text + // ring-off + // ring-offset-70 + // => ring-offset + .map((directive) => { + const nextDash = directive.indexOf('-', token.length) + return nextDash >= 0 ? directive.slice(0, nextDash) : '' + }) + .filter((group, index, groups) => group && groups.indexOf(group) === index) + .map((group) => ({ + kind: vscode.CompletionItemKind.Module, + data: 'directive-group', + label: prefix ? group.slice(prefix.length + 1) : group, + sortText: `=${group}`, + detail: `${group}(...)`, + documentation: { kind: vscode.MarkupKind.PlainText, value: `Start a new ${group} group` }, + textEdit: { + newText: (prefix ? group.slice(prefix.length + 1) : group).slice(buffer.length), + range: { + start: context.toPosition(tokenStartOffset + buffer.length), + end: position, + }, + }, + })), + // Insert directive + // tex + // text-current + // => text + // ring-off + // ring-offset-70 + // => ring-offset + ...Object.keys(this.state.directives) + .filter((directive) => directive.startsWith(token)) + .map((directive) => ({ + kind: + directive === 'text-black' + ? vscode.CompletionItemKind.Color + : vscode.CompletionItemKind.Property, + data: 'directive', + label: prefix ? directive.slice(prefix.length + 1) : directive, + sortText: `=${naturalExpand(directive)}`, + // VS Code bug causes '0' to not display in some cases + detail: directive === '0' ? '0 ' : directive, + // TODO https://github.com/tailwindlabs/tailwindcss-intellisense/blob/264cdc0c5e6fdbe1fee3c2dc338354235277ed08/packages/tailwindcss-language-service/src/util/color.ts#L28 + documentation: + directive === 'text-black' + ? '#ff0000' + : { + kind: vscode.MarkupKind.Markdown, + value: [ + '```css', + '.' + + (variants.length === 0 ? '' : variants.join('').slice(1) + ':') + + this.state.directives[directive].selector.slice(1) + + ' {', + ...Object.entries(this.state.directives[directive].properties).map( + ([property, value]) => ` ${property}: ${value};`, + ), + '}', + '```', + ].join('\n'), + }, + textEdit: { + newText: (prefix ? directive.slice(prefix.length + 1) : directive).slice(buffer.length), + range: { + start: context.toPosition(tokenStartOffset + buffer.length), + end: position, + }, + }, + })), + ] + + if (prefix && (!buffer || buffer === '&')) { + completions.items.push({ + kind: vscode.CompletionItemKind.Snippet, + label: `&`, + detail: prefix, + sortText: `=&`, + textEdit: { + newText: `&`.slice(buffer.length), + range: { + start: context.toPosition(tokenStartOffset + buffer.length), + end: position, + }, + }, + }) + } + + this._completionsCache.updateCached(context, position, completions) + + return completions + } +} + +function translateCompletionItemsToCompletionInfo( + context: TemplateContext, + items: vscode.CompletionList, +): ts.WithMetadata { + return { + metadata: { + isIncomplete: items.isIncomplete, + }, + isGlobalCompletion: false, + isMemberCompletion: false, + isNewIdentifierLocation: false, + entries: items.items.map((x) => translateCompletionEntry(context, x)), + } +} + +function translateCompletionItemsToCompletionEntryDetails( + typescript: typeof ts, + item: vscode.CompletionItem, +): ts.CompletionEntryDetails { + return { + name: item.label, + kind: item.kind + ? translateCompletionItemKind(typescript, item.kind) + : typescript.ScriptElementKind.unknown, + kindModifiers: getKindModifiers(item), + displayParts: toDisplayParts(item.detail), + documentation: toDisplayParts(item.documentation), + tags: [], + } +} + +function translateCompletionEntry( + context: TemplateContext, + item: vscode.CompletionItem, +): ts.CompletionEntry { + return { + name: item.label, + kind: item.kind + ? translateCompletionItemKind(context.typescript, item.kind) + : context.typescript.ScriptElementKind.unknown, + kindModifiers: getKindModifiers(item), + sortText: item.sortText || item.label, + insertText: item.textEdit && item.textEdit.newText, + replacementSpan: item.textEdit && { + start: context.toOffset(item.textEdit.range.start), + length: + context.toOffset(item.textEdit.range.end) - context.toOffset(item.textEdit.range.start), + }, + } +} + +function translateCompletionItemKind( + typescript: typeof ts, + kind: vscode.CompletionItemKind, +): ts.ScriptElementKind { + switch (kind) { + case vscode.CompletionItemKind.Module: + return typescript.ScriptElementKind.moduleElement + case vscode.CompletionItemKind.Property: + return typescript.ScriptElementKind.memberVariableElement + case vscode.CompletionItemKind.Unit: + case vscode.CompletionItemKind.Value: + return typescript.ScriptElementKind.constElement + case vscode.CompletionItemKind.Enum: + return typescript.ScriptElementKind.enumElement + case vscode.CompletionItemKind.EnumMember: + return typescript.ScriptElementKind.enumMemberElement + case vscode.CompletionItemKind.Keyword: + return typescript.ScriptElementKind.keyword + case vscode.CompletionItemKind.Constant: + return typescript.ScriptElementKind.constElement + case vscode.CompletionItemKind.Color: + return typescript.ScriptElementKind.primitiveType + case vscode.CompletionItemKind.Reference: + return typescript.ScriptElementKind.alias + case vscode.CompletionItemKind.Snippet: + case vscode.CompletionItemKind.Text: + return typescript.ScriptElementKind.string + default: + return typescript.ScriptElementKind.unknown + } +} + +function getKindModifiers(item: vscode.CompletionItem): string { + if (item.kind === vscode.CompletionItemKind.Color) { + return 'color' + } + + return '' +} + +function translateSeverity( + typescript: typeof ts, + severity: vscode.DiagnosticSeverity | undefined, +): ts.DiagnosticCategory { + switch (severity) { + case vscode.DiagnosticSeverity.Information: + case vscode.DiagnosticSeverity.Hint: + return typescript.DiagnosticCategory.Message + + case vscode.DiagnosticSeverity.Warning: + return typescript.DiagnosticCategory.Warning + + case vscode.DiagnosticSeverity.Error: + default: + return typescript.DiagnosticCategory.Error + } +} + +function toDisplayParts(text: string | vscode.MarkupContent | undefined): ts.SymbolDisplayPart[] { + if (!text) { + return [] + } + + return [ + { + kind: 'text', + text: typeof text === 'string' ? text : text.value, + }, + ] +} diff --git a/packages/typescript-plugin/src/logger.ts b/packages/typescript-plugin/src/logger.ts new file mode 100644 index 0000000..3d385f9 --- /dev/null +++ b/packages/typescript-plugin/src/logger.ts @@ -0,0 +1,17 @@ +import type { Logger } from 'typescript-template-language-service-decorator' + +import type * as ts from 'typescript/lib/tsserverlibrary' + +import { pluginName } from './config' + +export class LanguageServiceLogger implements Logger { + private readonly info: ts.server.PluginCreateInfo + + constructor(info: ts.server.PluginCreateInfo) { + this.info = info + } + + log(message: string): void { + this.info.project.projectService.logger.info(`[${pluginName}] ${message}`) + } +} diff --git a/packages/typescript-plugin/src/plugin.ts b/packages/typescript-plugin/src/plugin.ts new file mode 100644 index 0000000..99a50f6 --- /dev/null +++ b/packages/typescript-plugin/src/plugin.ts @@ -0,0 +1,73 @@ +import type { TemplateSettings } from 'typescript-template-language-service-decorator' +import type * as ts from 'typescript/lib/tsserverlibrary' + +import { decorateWithTemplateLanguageService } from 'typescript-template-language-service-decorator' +import { ConfigurationManager } from './configuration' +import { BeamwindTemplateLanguageService } from './language-service' +import { LanguageServiceLogger } from './logger' +import { getSubstitutions } from './substituter' +// import { StyledVirtualDocumentFactory } from './_virtual-document-provider' + +export class BeamwindPlugin { + private readonly typescript: typeof ts + private _logger?: LanguageServiceLogger + private readonly _configManager = new ConfigurationManager() + + public constructor(typescript: typeof ts) { + this.typescript = typescript + } + + public create(info: ts.server.PluginCreateInfo): ts.LanguageService { + this._logger = new LanguageServiceLogger(info) + this._configManager.updateFromPluginConfig(info.config) + + console.log('config: ' + JSON.stringify(this._configManager.config)) + + this._logger.log('config: ' + JSON.stringify(this._configManager.config)) + + if (!isValidTypeScriptVersion(this.typescript)) { + this._logger.log('Invalid typescript version detected. TypeScript 3.x required.') + return info.languageService + } + + return decorateWithTemplateLanguageService( + this.typescript, + info.languageService, + info.project, + new BeamwindTemplateLanguageService( + this.typescript, + this._configManager, + this._logger, + ), + getTemplateSettings(this._configManager, this._logger), + { logger: this._logger }, + ) + } + + public onConfigurationChanged(config: any): void { + if (this._logger) { + this._logger.log('onConfigurationChanged') + } + + this._configManager.updateFromPluginConfig(config) + } +} + +export function getTemplateSettings(configManager: ConfigurationManager, logger: LanguageServiceLogger): TemplateSettings { + return { + get tags() { + return configManager.config.tags + }, + enableForStringWithSubstitutions: true, + getSubstitutions(templateString, spans): string { + logger.log(`getSubstitutions: ${JSON.stringify(templateString)} (${JSON.stringify(spans)})`) + return getSubstitutions(templateString, spans) + }, + } +} + +function isValidTypeScriptVersion(typescript: typeof ts): boolean { + const [major] = typescript.version.split('.') + + return Number(major) >= 3 +} diff --git a/packages/typescript-plugin/src/process-plugins.d.ts b/packages/typescript-plugin/src/process-plugins.d.ts new file mode 100644 index 0000000..ec92b5b --- /dev/null +++ b/packages/typescript-plugin/src/process-plugins.d.ts @@ -0,0 +1,8 @@ +export declare function processPlugins(): { + screens: Record + variants: string[] + directives: Record }> + darkMode: false | 'media' | 'class' + prefix: string + separator: string +} diff --git a/packages/typescript-plugin/src/process-plugins.mjs b/packages/typescript-plugin/src/process-plugins.mjs new file mode 100644 index 0000000..f8d3158 --- /dev/null +++ b/packages/typescript-plugin/src/process-plugins.mjs @@ -0,0 +1,145 @@ +import dlv from 'dlv' + +import corePlugins from 'tailwindcss/lib/corePlugins.js' +import resolveConfig from 'tailwindcss/lib/util/resolveConfig.js' +import defaultConfig from 'tailwindcss/stubs/defaultConfig.stub.js' +import transformThemeValue from 'tailwindcss/lib/util/transformThemeValue.js' + +const identity = (value) => value + +export function processPlugins() { + const config = resolveConfig([defaultConfig]) + + const plugins = [...corePlugins(config), ...dlv(config, 'plugins', [])] + + const { + theme: { screens }, + variantOrder: variants, + darkMode, + prefix, + separator, + } = config + + const applyConfiguredPrefix = identity + + const getConfigValue = (path, defaultValue) => (path ? dlv(config, path, defaultValue) : config) + + const utilities = {} + + plugins.forEach((plugin) => { + if (plugin.__isOptionsFunction) { + plugin = plugin() + } + + const handler = typeof plugin === 'function' ? plugin : dlv(plugin, 'handler', () => {}) + + handler({ + config: getConfigValue, + + theme: (path, defaultValue) => { + const [pathRoot, ...subPaths] = path.split('.') + + const value = getConfigValue(['theme', pathRoot, ...subPaths], defaultValue) + + return transformThemeValue(pathRoot)(value) + }, + + corePlugins: (path) => { + if (Array.isArray(config.corePlugins)) { + return config.corePlugins.includes(path) + } + + return getConfigValue(`corePlugins.${path}`, true) + }, + + variants: (path, defaultValue) => { + if (Array.isArray(config.variants)) { + return config.variants + } + + return getConfigValue(`variants.${path}`, defaultValue) + }, + + // No escaping of class names as we use theme as directives + e: identity, + + prefix: applyConfiguredPrefix, + + addUtilities: (newUtilities) => { + // :const defaultOptions = { variants: [], respectPrefix: true, respectImportant: true } + + // options = Array.isArray(options) + // ? { ...defaultOptions, variants: options } + // : { ...defaultOptions, ...options } + + // .directive => CSS rule + Object.assign(utilities, ...(Array.isArray(newUtilities) ? newUtilities : [newUtilities])) + + // :pluginUtilities.push( + // wrapWithLayer(wrapWithVariants(styles.nodes, options.variants), 'utilities'), + // ) + }, + + addComponents: (_components, _options) => { + // :const defaultOptions = { variants: [], respectPrefix: true } + // options = Array.isArray(options) + // ? { ...defaultOptions, variants: options } + // : { ...defaultOptions, ...options } + // pluginComponents.push( + // wrapWithLayer(wrapWithVariants(styles.nodes, options.variants), 'components'), + // ) + }, + + addBase: (_baseStyles) => { + // :pluginBaseStyles.push(wrapWithLayer(parseStyles(baseStyles), 'base')) + }, + + addVariant: (_name, _generator, _options) => { + // :pluginVariantGenerators[name] = generateVariantFunction(generator, options) + }, + }) + }) + + const directives = { + // Marker directive used with group-* variants + group: { + selector: '.group', + properties: {}, + }, + } + + for (const selector of Object.keys(utilities)) { + // '@keyframes spin' + if (selector[0] === '@') { + continue + } + + // '.ordinal, .slashed-zero, .lining-nums, .oldstyle-nums' + if (selector.includes(',')) { + continue + } + + // '.placeholder-black::placeholder' + // '.divide-pink-50 > :not([hidden]) ~ :not([hidden])' + const [_, directive] = /^\.([^\s:]+)/.exec(selector) || [] + + if (directive) { + // Unescape + // '.w-4\\/5' + // '.space-x-2\\.5' + directives[directive.replace(/\\/g, '')] = { + selector, + properties: utilities[selector], + } + } + } + + return { + screens, + variants: ['dark', ...variants], + directives, + darkMode, + prefix, + separator, + } +} diff --git a/packages/typescript-plugin/src/substituter.ts b/packages/typescript-plugin/src/substituter.ts new file mode 100644 index 0000000..9f85cfc --- /dev/null +++ b/packages/typescript-plugin/src/substituter.ts @@ -0,0 +1,8 @@ +export function getSubstitutions( + contents: string, + spans: ReadonlyArray<{ start: number; end: number }>, +): string { + const parts: string[] = [] + + return parts.join('') +} diff --git a/packages/typescript-plugin/tsconfig.json b/packages/typescript-plugin/tsconfig.json new file mode 100644 index 0000000..c2b1edb --- /dev/null +++ b/packages/typescript-plugin/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "include": ["src", "../../types"], + "compilerOptions": { + "module": "commonjs" + } +} diff --git a/yarn.lock b/yarn.lock index b102cbd..f88e829 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12084,6 +12084,11 @@ typescript-eslint-language-service@^4.1.1: dependencies: read-pkg-up "^7.0.0" +typescript-template-language-service-decorator@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/typescript-template-language-service-decorator/-/typescript-template-language-service-decorator-2.2.0.tgz#4ee6d580f307fb9239978e69626f2775b8a59b2a" + integrity sha512-xiolqt1i7e22rpqMaprPgSFVgU64u3b9n6EJlAaUYE61jumipKAdI1+O5khPlWslpTUj80YzjUKjJ2jxT0D74w== + typescript@*, typescript@^4.1.0: version "4.1.2" resolved "https://registry.npmjs.org/typescript/-/typescript-4.1.2.tgz#6369ef22516fe5e10304aae5a5c4862db55380e9" @@ -12444,7 +12449,7 @@ vm-browserify@^1.0.1: resolved "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== -vscode-languageserver-types@^3.15.1: +vscode-languageserver-types@^3.13.0, vscode-languageserver-types@^3.15.1: version "3.15.1" resolved "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.15.1.tgz#17be71d78d2f6236d414f0001ce1ef4d23e6b6de" integrity sha512-+a9MPUQrNGRrGU630OGbYVQ+11iOIovjCkqxajPa9w57Sd5ruK8WQNsslzpa0x/QJqC8kRc2DUxWjIFwoNm4ZQ==