Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-rules-interpolate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@styled/typescript-styled-plugin': patch
---

Avoid reporting styled rules whose bodies contain a template interpolation as empty while preserving diagnostics for genuinely empty rules.
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,20 @@ properties, and Emmet completions.

## Editor Integration

The plugin can be loaded in two ways:

- **Project-local:** Install it in the project and add it to
`compilerOptions.plugins` in `tsconfig.json`.
- **Editor-global:** Install or bundle it once, then configure the editor's
TypeScript host to load it for every project. This adds no dependency or
configuration to each project.

The available loading modes depend on the editor integration below.

### With VS Code

**Loading mode: editor-global, with a project-local alternative.**

Install the [VS Code Styled Components extension](https://github.com/styled-components/vscode-styled-components).
It bundles this plugin and works with VS Code's bundled TypeScript version
without installing anything else. The extension depends on `^1.0.0`, so a new
Expand All @@ -78,6 +90,8 @@ use.

### With Sublime Text

**Loading mode: project-local.**

This plugin works with the [Sublime TypeScript plugin](https://github.com/Microsoft/TypeScript-Sublime-Plugin).
Complete the [Quick Start](#quick-start), then point Sublime at the workspace
TypeScript version by setting
Expand All @@ -94,6 +108,8 @@ its bundled Node runtime; that runtime must meet the requirements above.

### With Neovim

**Loading mode: editor-global, with a project-local alternative.**

Neovim talks to tsserver through a language server that wraps it. Two of them
load tsserver plugins without a `plugins` entry in `tsconfig.json`: install
the plugin globally, then point the server at npm's global folder, which
Expand Down Expand Up @@ -147,8 +163,70 @@ have the server use the workspace TypeScript version (for vtsls, set
`vtsls.autoUseWorkspaceTsdk` to `true`). The tsserver host must meet the
requirements above.

### With Helix

**Loading mode: editor-global, with a project-local alternative.**

Helix uses
[typescript-language-server](https://github.com/typescript-language-server/typescript-language-server)
for TypeScript and JavaScript by default. Install the server, TypeScript, and
this plugin globally, then print npm's global package folder:

```bash
npm install --global typescript-language-server typescript@6 @styled/typescript-styled-plugin
npm root -g
```

Add the following to `~/.config/helix/languages.toml`, replacing
`/usr/local/lib/node_modules` with the exact output of `npm root -g`:

```toml
[language-server.typescript-language-server.config]
hostInfo = "helix"

[[language-server.typescript-language-server.config.plugins]]
Comment thread
pullfrog[bot] marked this conversation as resolved.
name = "@styled/typescript-styled-plugin"
location = "/usr/local/lib/node_modules"

[language-server.typescript-language-server.config.typescript.inlayHints]
includeInlayEnumMemberValueHints = true
includeInlayFunctionLikeReturnTypeHints = true
includeInlayFunctionParameterTypeHints = true
includeInlayParameterNameHints = "all"
includeInlayParameterNameHintsWhenArgumentMatchesName = true
includeInlayPropertyDeclarationTypeHints = true
includeInlayVariableTypeHints = true

[language-server.typescript-language-server.config.javascript.inlayHints]
includeInlayEnumMemberValueHints = true
includeInlayFunctionLikeReturnTypeHints = true
includeInlayFunctionParameterTypeHints = true
includeInlayParameterNameHints = "all"
includeInlayParameterNameHintsWhenArgumentMatchesName = true
includeInlayPropertyDeclarationTypeHints = true
includeInlayVariableTypeHints = true
```

Keep the complete `config` table shown above so adding the plugin does not drop
Helix's built-in initialization options. For this global setup, use the exact
`npm root -g` output as `location`. Run `hx --health typescript` to confirm that
Helix finds `typescript-language-server`. The setup and host requirements were
verified with Helix 25.07.1 and `typescript-language-server` 6.0.1; see the
[host notes](docs/tsserver-host.md) for the version-specific details, and
validate them against the installed versions.

To change [plugin settings](docs/usage.md), add the plugin's entry to
`compilerOptions.plugins` in `tsconfig.json` as in the
[Quick Start](#quick-start); the global plugin registration still supplies the
implementation. To use a copy installed in the project instead, install
`typescript-language-server` globally, then complete the Quick Start in the
project. No `plugins` entry in Helix's `languages.toml` is needed for this
project-local setup.

### With Visual Studio

**Loading mode: project-local.**

This setup path requires validation against the installed Visual Studio
TypeScript Server host and runtime. Complete the [Quick Start](#quick-start) in
the project, then confirm Visual Studio loads the workspace TypeScript SDK. Its
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,12 @@ Spec for how the plugin turns a tsserver request inside a tagged template into a

- Validation: the SCSS service's `doValidation` over the virtual document, intersected with the value reading for a single-identifier `css` fragment (the "Virtual document" section), memoized by `RawValidationCache` (the "Caching" section). With `validate: false`: no diagnostics and no code fixes.
- Shown list (`DiagnosticsFeature.getShownDiagnostics`), the one list diagnostics and code fixes both read: each raw diagnostic mapped to template offsets under the diagnostics rule in "Mapping back to the template" (the "Virtual document" section: clamped at the closing wrapper, dropped inside the opening one, widened to whole escape runs), then the stray closing brace rule below.
- Empty rules: with the built-in virtual-document provider, an `emptyRules` lint diagnostic is dropped when the rule body contains a template interpolation. The interpolation may produce declarations at runtime, so the plugin cannot prove that rule empty. The diagnostic stays for a genuinely empty body, including when an interpolation appears only in the selector. Rule-body braces are found in the template's CSS text after JavaScript escapes are cooked, so a JavaScript escape can contribute a structural brace. The boundary scanner ignores braces in comments, strings, CSS escapes, and unquoted `url(...)` arguments; an unclosed body extends to the template end while the user is editing. A custom provider owns its document's structure, so its diagnostics are not filtered against the template text.
- Translation: the diagnostic's own `code` when numeric, `CSS_DIAGNOSTIC_CODE` (9999) otherwise; severity Error or none as Error, Warning as Warning, Information and Hint as Message; `source` is the plugin identity.
- Stray closing brace: `css-ruleorselectorexpected` ("at-rule or selector expected") at the template end reports the wrapper's own closing brace, which the template's content closed early.
- Causes: a stray `}`, or an unattached `;`. An empty template reports nothing.
- Always kept: never dropped because another diagnostic exists in the template (an unrelated lint warning, or one from the brace's own cascade).
- Re-anchored to the stray `}`: the first `}` in the template's CSS text (`getTemplateCssText`) with no matching opener, read with the boundary scanner, so a `}` in a string, comment, escape, or unquoted `url(...)` argument does not count; no placeholder fill holds a brace. The span is that one character.
- Re-anchored to the stray `}`: the first `}` in the template's CSS text (`getTemplateCssText`) with no matching opener, read after JavaScript escapes are cooked, so a JavaScript escape can contribute a structural brace. The boundary scanner ignores a `}` in a string, comment, CSS escape, or unquoted `url(...)` argument; no placeholder fill holds a brace. The span is that one character.
- No stray `}` (an unattached `;`): stays at the template end, zero length.
- The same code anywhere else in the template is a real diagnostic and stays where reported. Other end-of-template diagnostics (`} expected`, `property value expected`) carry other codes and stay.

Expand Down
7 changes: 4 additions & 3 deletions docs/tsserver-host.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# tsserver host knowledge

Verified facts about the environment this plugin runs in. Dated 2026-09-26; each item names the version it was checked against. Re-verify before relying on an item older than a few months.
Verified facts about the environment this plugin runs in. Updated 2026-09-28; each item names the version it was checked against. Re-verify before relying on an item older than a few months.

## How tsserver loads a plugin

Expand All @@ -10,13 +10,14 @@ Verified facts about the environment this plugin runs in. Dated 2026-09-26; each
- `typesVersions` (`package.json`) applies to this same classic-resolution code path, not only to a consumer's own type lookup: `tryResolveJSModuleWorker` shares the "resolve `main` or a subpath" logic with the types resolver, so a `typesVersions` entry keyed on the same relative path as `main` (here, `lib/index` or `lib/index.js`) redirects the plugin loader's own runtime `require()` to the declaration file instead of the implementation, which then throws `SyntaxError: Cannot use import statement outside a module` trying to execute it. Measured on TypeScript 5.0 by adding and removing such an entry against a real tsserver. A `typesVersions` entry for any OTHER subpath (one `main` itself never resolves to, such as `./api`, `./lib/api`, or `./lib/api.js` here) does not collide, since the plugin loader never requests those paths.
- The configuration a plugin receives at creation (`PluginCreateInfo.config`) is the whole tsconfig `plugins` entry, `name` included. A plugin an editor loads globally (`--globalPlugins`, as VS Code does for an extension's `typescriptServerPlugins`) receives `{ name, global: true }`, and a `configurePlugin` payload that arrives before the project loads replaces the entry, with `name` set on it. A later `configurePlugin` payload reaches `onConfigurationChanged` as sent. Checked in TypeScript 5.0.4 (`lib/tsserver.js`) and 6.0.3 (`lib/typescript.js`): `enableGlobalPlugins`, `endEnablePlugin`, `enableProxy`, `onPluginConfigurationChanged`.
- A plugin that fails to load or activate never surfaces to the user: the only trace is the tsserver log. An empty completion response from tsserver also reports `success: false`, so harnesses must read the log to distinguish "plugin skipped" from "no results".
- Helix plugin setup (Helix 25.07.1 and `typescript-language-server` 6.0.1, checked 2026-09-28): Helix merges `languages.toml` through three table levels, so a user-provided `language-server.typescript-language-server.config` replaces that server's built-in `config` table. A plugin example must repeat the built-in `hostInfo` and TypeScript/JavaScript inlay-hint settings to preserve them. `typescript-language-server` passes each initialization plugin's `location` directly to tsserver through `--pluginProbeLocations`; its accepted `location` forms resolve under the classic Node10 rules above.

## TypeScript versions (2026-09-26)

- `typescript@latest` on npm is 7.0.2, the native compiler. The package ships no `tsserver` and has no plugin API; VS Code's TypeScript 7 mode does not load `typescriptServerPlugins`. Plugins run only on TypeScript 5.x and 6.x hosts; an older host can still load the plugin module itself without erroring, since this plugin's own version gate (below) returns the host's language service untouched rather than activating.
- VS Code stable bundles TypeScript 6 (`npm:@typescript/typescript6`).
- Floor for this plugin: TypeScript 5.0 (`major >= 5`, `isSupportedTypeScriptVersion`, `src/tsserver/tsserver-plugin.ts`). 1.0.1 gates on `major >= 3`, but `typescript-template-language-service-decorator` 2.3.2 (the last release) binds `languageService.getSupportedCodeFixes` whenever the template service passed to it implements the same method, regardless of host version; that instance method exists on `ts.LanguageService` only from TypeScript 5.0 (confirmed via `ts.createLanguageService(...).getSupportedCodeFixes` returning `undefined` on 3.9, 4.0, 4.4, and 4.9, and a function from 5.0), so binding it on an older host throws `TypeError: Cannot read properties of undefined (reading 'bind')` before any feature activates. Reproduced against a real tsserver on TypeScript 3.9, 4.0, 4.4, and 4.9 with the published decorator's own logged message: "Plugin activation failed: TypeError: Cannot read properties of undefined (reading 'bind')". 1.0.1 crashes identically on these versions today, since a fresh install resolves the same final decorator release. This plugin's own gate never constructs the decorator below TypeScript 5.0, logging "Unsupported TypeScript version ... TypeScript 5.0 or newer required" and returning the host's own language service untouched instead of crashing there.
- Line terminators: `ts.computeLineStarts` starts a new line after `\r\n`, a lone `\n`, a lone `\r`, U+2028, and U+2029, and not after U+0085. Checked on TypeScript 4.9.5, 5.0.4, 5.9.3, and 6.0.3 (`computeLineStarts("a\r\nb\nc\rd
e
f\u0085g")` returns `[0,3,5,7,9,11]` on each).
- Line terminators: `ts.computeLineStarts` starts a new line after `\r\n`, a lone `\n`, a lone `\r`, U+2028, and U+2029, and not after U+0085. Checked on TypeScript 4.9.5, 5.0.4, 5.9.3, and 6.0.3 (`computeLineStarts("a\r\nb\nc\rd\u2028e\u2029f\u0085g")` returns `[0,3,5,7,9,11]` on each).
- A declaration file using a string export name (`export { x as 'module.exports' }`) fails to parse (TS1003) on TypeScript 5.0 through 5.5; 5.6 accepts it. This package no longer emits that syntax anywhere (the root entry's declaration uses `export =`, parseable since TypeScript's earliest CommonJS-module support); kept here as the reason a future root-entry declaration should avoid the string-export-name form.

## Node runtime for the tsserver plugin and the `./api` subpath
Expand All @@ -26,7 +27,7 @@ Verified facts about the environment this plugin runs in. Dated 2026-09-26; each
- `./api` is a real dual build: `import` gets `lib/esm/api.mjs`, `require` gets `lib/esm/api.cjs`, a genuine CommonJS bundle. Measured with the packed tarball's `./api`, both `require()` and `import()` succeed on Node 14.21.3, 16.20.2, 18.20.5, 20.18.1, 22.12.0, and 24.11.0 (the same Node floor as the package root, with no `require(esm)` dependency).
- `require()` of an ES module throws `ERR_REQUIRE_ASYNC_MODULE` if the module graph uses top-level `await`. Keep the `lib/esm/api.mjs` bundle free of it.
- `export { value as 'module.exports' }` sets what `require()` of an ES module returns; named exports are then invisible to CommonJS callers. Not used by this package (see the TypeScript-versions note above); documented here because `require(esm)` interop depends on it.
- Host runtimes: VS Code and Cursor run tsserver on their Electron Node (24.x in 2026). `typescript-language-server` requires Node 22.22 or newer. `@vtsls/language-server` declares Node 18 or newer, so a vtsls user on an older system Node cannot load the plugin. `typescript.tsserver.nodePath` in VS Code substitutes the user's Node.
- Host runtimes: VS Code and Cursor run tsserver on their Electron Node (24.x in 2026). `typescript-language-server` 6.0.1 requires Node 22.22.2 or newer. `@vtsls/language-server` declares Node 18 or newer, so a vtsls user on an older system Node cannot load the plugin. `typescript.tsserver.nodePath` in VS Code substitutes the user's Node.

## Dependencies

Expand Down
6 changes: 3 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ Add the plugin to the project `tsconfig.json` or `jsconfig.json`:
```

The editor must be configured to use that workspace TypeScript SDK, or, in
Neovim, load the plugin as a global plugin. VS Code, Sublime Text, Neovim, and
Visual Studio setup paths are described in the
Neovim or Helix, load the plugin as a global plugin. VS Code, Sublime Text,
Neovim, Helix, and Visual Studio setup paths are described in the
[README](../README.md#editor-integration); each requires validation against
the actual editor's tsserver host and Node runtime.

Expand Down Expand Up @@ -206,7 +206,7 @@ settings do not cause a host failure.
| `compatibleVendorPrefixes` | Missing related vendor-prefixed properties. | `ignore` |
| `vendorPrefix` | Vendor-prefixed properties without a standard equivalent. | `warning` |
| `duplicateProperties` | Duplicate style declarations. | `ignore` |
| `emptyRules` | Empty rulesets. | `ignore` |
| `emptyRules` | Provably empty rulesets; body interpolations are not reported. | `ignore` |
| `importStatement` | `@import` statements. | `ignore` |
| `boxModel` | Width or height used with padding or borders. | `ignore` |
| `universalSelector` | Universal selectors. | `ignore` |
Expand Down
3 changes: 3 additions & 0 deletions src/features/css-diagnostic-code.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export const CSS_DIAGNOSTIC_CODE = 9999

/** vscode-css-languageservice's stable lint-rule id for an empty ruleset. */
export const EMPTY_RULESET_DIAGNOSTIC_CODE = 'emptyRules'

/**
* vscode-css-languageservice's stable parse-error id for "at-rule or selector expected"
* (ParseError.RuleOrSelectorExpected, cssErrors.ts). At the template end it reports the wrapper's
Expand Down
Loading