Skip to content

Commit 0bfe16f

Browse files
Initial extension code complete
0 parents  commit 0bfe16f

File tree

131 files changed

+17292
-0
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

131 files changed

+17292
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/node_modules

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License Copyright (c) 2025 Garrett Hopper
2+
3+
Permission is hereby granted, free
4+
of charge, to any person obtaining a copy of this software and associated
5+
documentation files (the "Software"), to deal in the Software without
6+
restriction, including without limitation the rights to use, copy, modify, merge,
7+
publish, distribute, sublicense, and/or sell copies of the Software, and to
8+
permit persons to whom the Software is furnished to do so, subject to the
9+
following conditions:
10+
11+
The above copyright notice and this permission notice
12+
(including the next paragraph) shall be included in all copies or substantial
13+
portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
16+
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
18+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
19+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
THE SOFTWARE.

README.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Tailwind CSS Clojure Class Sorter
2+
3+
A VSCode extension that provides a source action to sort Tailwind CSS classes within your Clojure code, including Hiccup templates.
4+
5+
## Features
6+
7+
- **Sort Tailwind Classes:** Provides a `source.sortTailwindClasses` source action that sorts Tailwind CSS classes.
8+
9+
## Prerequisites
10+
11+
While Tailwind CSS v4's standalone CLI can generate CSS without a `node_modules` folder for basic usage, installing Tailwind CSS via npm/yarn is required to use **Tailwind plugins** (like `@tailwindcss/forms`, `@tailwindcss/typography`, etc.). This extension relies on the standard Tailwind CSS library to correctly identify and sort classes provided by such plugins.
12+
13+
Ensure you have `tailwindcss` listed as a dependency in your `package.json` and installed in your `node_modules`:
14+
15+
```jsonc
16+
// package.json
17+
{
18+
"devDependencies": {
19+
"tailwindcss": "^4.0.0",
20+
// ... Tailwind plugins like @tailwindcss/forms, etc.
21+
},
22+
}
23+
```
24+
25+
## Configuration
26+
27+
### Workspace Settings (`.vscode/settings.json`)
28+
29+
It's recommended to configure the following settings in your project's `.vscode/settings.json` file:
30+
31+
```jsonc
32+
{
33+
// Optional: Specify the path to your Tailwind v4 CSS-first configuration file.
34+
// This is necessary if you use Tailwind plugins, so the sorter
35+
// can correctly recognize and order plugin-provided classes.
36+
// The file should contain `@import "tailwindcss";` and may contain `@plugin` imports
37+
// If this is not set, `node_modules/tailwindcss/theme.css` is used
38+
"tailwindCssClojureClassSorter.tailwindCssPath": "src/index.css",
39+
40+
// Optional: Automatically run the sorter on save.
41+
"editor.codeActionsOnSave": {
42+
"source.sortTailwindClasses": "always", // or "explicit"
43+
},
44+
}
45+
```
46+
47+
### Global VSCode Settings (for Tailwind CSS IntelliSense)
48+
49+
To enhance your development experience with Tailwind CSS in Clojure, it's highly recommended to configure the official [Tailwind CSS IntelliSense extension](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss). Add the following to your global `settings.json`:
50+
51+
```jsonc
52+
{
53+
// Tell the Tailwind extension to treat Clojure(Script) files like HTML
54+
"tailwindCSS.includeLanguages": {
55+
"clojure": "html",
56+
},
57+
// Configure class detection patterns for Clojure
58+
"[clojure]": {
59+
"tailwindCSS.experimental.classRegex": [
60+
// Matches: :class "cls-1 cls-2 ..."
61+
":class\\s+\"([^\"]+)\"",
62+
// Matches: ^:tw "cls-1 cls-2 ..."
63+
"\\^:tw\\s+\"([^\"]+)\"",
64+
// Matches classes in Hiccup-style vectors:
65+
// [:div#id.cls-1.cls-2 ...]
66+
// [:#id.cls-1.cls-2 ...]
67+
// [:.cls-1.cls-2 ...]
68+
["\\[:[\\w-]*(?:#[\\w-]+)?((?:\\.[\\w-]+)+)(?=[\\s\\]])", "\\.([\\w-]+)"],
69+
// Matches keyword selectors:
70+
// :.cls-1.cls-2
71+
[":((?:\\.[\\w-]+)+)", "\\.([\\w-]+)"],
72+
],
73+
},
74+
}
75+
```
76+
77+
## Supported Class Formats for Sorting
78+
79+
This extension specifically targets and sorts classes within strings matching the patterns used by the Tailwind CSS IntelliSense extension's `classRegex`. (Currently this is not dynamic. The 4 regexes used above are hard-coded in this extension.)
80+
81+
**Examples:**
82+
83+
1. **:class attribute:**
84+
85+
```clojure
86+
;; Before:
87+
[:div {:class "text-white p-4 m-2 border rounded bg-blue-500"}]
88+
;; After:
89+
[:div {:class "m-2 rounded border bg-blue-500 p-4 text-white"}]
90+
```
91+
92+
2. **^:tw metadata:**
93+
_Note: This is useful when using Tailwind class strings inside of arbitrary forms or even outside of hiccup templates altogether._
94+
95+
```clojure
96+
;; Before:
97+
(let [base-classes "p-4 m-2 border rounded" ; Will not be sorted due to missing ^:tw
98+
active-classes ^:tw "text-white bg-blue-500"] ; Strings marked with ^:tw will be sorted
99+
[:button {:class (str base-classes " " (when active? active-classes))}])
100+
;; After:
101+
(let [base-classes "p-4 m-2 border rounded" ; Unsorted
102+
active-classes ^:tw "bg-blue-500 text-white"] ; Sorted
103+
[:button {:class (str base-classes " " (when active? active-classes))}])
104+
```
105+
106+
3. **Hiccup class shorthand:**
107+
108+
```clojure
109+
;; Before:
110+
[:button#submit.text-white.p-4.m-2.border.rounded.bg-blue-500 "Save"]
111+
;; After:
112+
[:button#submit.m-2.rounded.border.bg-blue-500.p-4.text-white "Save"]
113+
114+
;; Before:
115+
[:.text-white.p-4.m-2.border.rounded.bg-blue-500]
116+
;; After:
117+
[:.m-2.rounded.border.bg-blue-500.p-4.text-white]
118+
```
119+
120+
4. **Keyword selectors:**
121+
```clojure
122+
;; Before:
123+
(let [button-class (if primary?
124+
:.text-white.p-4.m-2.border.rounded.bg-blue-500
125+
:.p-2.border)]
126+
[:button {:class button-class} "Action"])
127+
;; After:
128+
(let [button-class (if primary?
129+
:.m-2.rounded.border.bg-blue-500.p-4.text-white
130+
:.border.p-2)]
131+
[:button {:class button-class} "Action"])
132+
```
133+
134+
## License
135+
136+
This extension is licensed under the [MIT License](./LICENSE).

index.js

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
const vscode = require("vscode");
2+
const fs = require("node:fs");
3+
const { readFile } = require("node:fs/promises");
4+
const path = require("node:path");
5+
const { __unstable__loadDesignSystem } = require("tailwindcss");
6+
const { CachedInputFileSystem, ResolverFactory } = require("enhanced-resolve");
7+
8+
const outputChannel = vscode.window.createOutputChannel(
9+
"Tailwind CSS Clojure Class Sorter"
10+
);
11+
12+
const cssResolver = ResolverFactory.createResolver({
13+
fileSystem: new CachedInputFileSystem(fs, 4000),
14+
useSyncFileSystemCalls: true,
15+
extensions: [".css"],
16+
mainFields: ["style"],
17+
conditionNames: ["style"],
18+
});
19+
20+
const moduleResolver = ResolverFactory.createResolver({
21+
fileSystem: new CachedInputFileSystem(fs, 4000),
22+
useSyncFileSystemCalls: true,
23+
extensions: [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"],
24+
mainFields: ["main", "module"],
25+
conditionNames: ["node", "import", "require"],
26+
});
27+
28+
async function loadStylesheet(id, base) {
29+
outputChannel.appendLine(`Loading stylesheet: ${id} from ${base}`);
30+
return new Promise((resolve, reject) => {
31+
cssResolver.resolve({}, base, id, {}, async (err, resolvedPath) => {
32+
if (err || !resolvedPath) {
33+
return reject(err || new Error(`Could not resolve stylesheet: ${id}`));
34+
}
35+
try {
36+
resolve({
37+
content: await readFile(resolvedPath, "utf8"),
38+
base: path.dirname(resolvedPath),
39+
});
40+
} catch (error) {
41+
outputChannel.appendLine(
42+
`Error reading stylesheet: ${id} from ${resolvedPath}: ${error.message}`
43+
);
44+
reject(error);
45+
}
46+
});
47+
});
48+
}
49+
50+
async function loadModule(id, base) {
51+
outputChannel.appendLine(`Loading module: ${id} from ${base}`);
52+
return new Promise((resolve, reject) => {
53+
moduleResolver.resolve({}, base, id, {}, async (error, resolvedPath) => {
54+
if (error) {
55+
outputChannel.appendLine(
56+
`Error resolving module: ${id} from ${base}: ${error.message}`
57+
);
58+
return reject(error);
59+
}
60+
61+
outputChannel.appendLine(`Resolved module ${id} path: ${resolvedPath}`);
62+
try {
63+
const module = await import(resolvedPath);
64+
resolve({ module: module.default ?? module, base });
65+
} catch (error) {
66+
outputChannel.appendLine(
67+
`Error importing module: ${id} from ${resolvedPath}: ${error.message}`
68+
);
69+
reject(error);
70+
}
71+
});
72+
});
73+
}
74+
75+
function sortClasses(designSystem, classes) {
76+
return designSystem
77+
.getClassOrder(classes)
78+
.sort(
79+
([kA, vA], [kB, vB]) =>
80+
(vB === null) - (vA === null) || // sort nulls first
81+
(vA === null && vB === null
82+
? kA.localeCompare(kB) // both null, sort by key
83+
: (vA > vB) - (vA < vB) || // sort by BigInt value
84+
kA.localeCompare(kB)) // values equal, sort by key
85+
)
86+
.map(([k]) => k);
87+
}
88+
89+
async function getDesignSystem() {
90+
const tailwindCssPath = vscode.workspace
91+
.getConfiguration("tailwindCssClojureClassSorter")
92+
.get("tailwindCssPath", "");
93+
94+
const baseWs = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? "";
95+
const resolvedCss = tailwindCssPath
96+
? path.resolve(baseWs, tailwindCssPath)
97+
: path.join(baseWs, "node_modules", "tailwindcss", "theme.css");
98+
99+
let cssContent;
100+
try {
101+
cssContent = await readFile(resolvedCss, "utf8");
102+
outputChannel.appendLine(`Read Tailwind CSS from: ${resolvedCss}`);
103+
} catch (error) {
104+
outputChannel.appendLine(`Error reading Tailwind CSS file: ${error}`);
105+
vscode.window.showErrorMessage(
106+
`Failed to read Tailwind CSS file at ${resolvedCss}. Please check the path and permissions. Error: ${error.message}`
107+
);
108+
return null;
109+
}
110+
111+
const baseDir = path.dirname(resolvedCss);
112+
113+
const designSystem = await __unstable__loadDesignSystem(cssContent, {
114+
base: baseDir,
115+
loadStylesheet,
116+
loadModule,
117+
});
118+
outputChannel.appendLine("Successfully loaded Tailwind design system.");
119+
return designSystem;
120+
}
121+
122+
const RULES = [
123+
// outer, inner, delimiter
124+
[':class\\s+"([^"]+)"', "(\\S+)", " "],
125+
['\\^:tw\\s+"([^"]+)"', "(\\S+)", " "],
126+
[":((?:\\.[\\w-]+)+)", "\\.([\\w-]+)", "."],
127+
[
128+
"\\[:[\\w-]*(?:#[\\w-]+)?((?:\\.[\\w-]+)+)(?=[\\s\\]])",
129+
"\\.([\\w-]+)",
130+
".",
131+
],
132+
];
133+
134+
function getWorkspaceEdit(doc, designSystem) {
135+
const edit = new vscode.WorkspaceEdit();
136+
const text = doc.getText();
137+
for (const [_outer, _inner, delim] of RULES) {
138+
const outer = new RegExp(_outer, "g");
139+
const inner = new RegExp(_inner, "g");
140+
let match;
141+
while ((match = outer.exec(text))) {
142+
const [whole, captured] = match;
143+
const classes = Array.from(captured.matchAll(inner), (m) => m[1]);
144+
const prefix = delim === "." ? "." : "";
145+
const sorted = prefix + sortClasses(designSystem, classes).join(delim);
146+
if (sorted === captured) continue;
147+
const offset = match.index + whole.indexOf(captured);
148+
149+
edit.replace(
150+
doc.uri,
151+
new vscode.Range(
152+
doc.positionAt(offset),
153+
doc.positionAt(offset + captured.length)
154+
),
155+
sorted
156+
);
157+
}
158+
}
159+
return edit;
160+
}
161+
162+
class SortTailwindClassesCodeActionProvider {
163+
static kind = vscode.CodeActionKind.Source.append("sortTailwindClasses");
164+
165+
async provideCodeActions(doc) {
166+
if (doc.languageId !== "clojure") return;
167+
168+
const designSystem = await getDesignSystem();
169+
if (!designSystem) return;
170+
171+
const action = new vscode.CodeAction(
172+
"Sort Tailwind Classes",
173+
SortTailwindClassesCodeActionProvider.kind
174+
);
175+
action.edit = getWorkspaceEdit(doc, designSystem);
176+
return [action];
177+
}
178+
}
179+
180+
exports.activate = (context) => {
181+
outputChannel.appendLine(
182+
"Tailwind CSS Clojure Class Sorter extension activated."
183+
);
184+
185+
context.subscriptions.push(
186+
vscode.languages.registerCodeActionsProvider(
187+
"clojure",
188+
new SortTailwindClassesCodeActionProvider(),
189+
{
190+
providedCodeActionKinds: [SortTailwindClassesCodeActionProvider.kind],
191+
}
192+
)
193+
);
194+
195+
context.subscriptions.push(outputChannel);
196+
};

node_modules/.bin/jiti

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)