@runtimestudio/tailwind-sort-php 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +89 -18
- package/dist/cli.d.ts +3 -2
- package/dist/cli.js +8 -4
- package/dist/html.d.ts +1 -1
- package/dist/html.js +1 -1
- package/dist/init.d.ts +12 -0
- package/dist/init.js +169 -0
- package/dist/islands.js +1 -1
- package/package.json +2 -2
- package/src/cli.ts +8 -4
- package/src/html.ts +1 -1
- package/src/init.ts +186 -0
- package/src/islands.ts +1 -1
package/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2026 Runtime Studio
|
|
3
|
+
Copyright (c) 2026 The Trustee for G&J Sevastos Family Trust t/a Runtime Studio
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
package/README.md
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Tailwind CSS Class Sorter for PHP
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@runtimestudio/tailwind-sort-php)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
`@runtimestudio/tailwind-sort-php` sorts Tailwind CSS classes in **plain PHP files, WordPress themes and plugins, and
|
|
7
|
+
mixed PHP/HTML templates** — the case `prettier-plugin-tailwindcss` can't parse and `@prettier/plugin-php` mangles.
|
|
5
8
|
|
|
6
9
|
`prettier-plugin-tailwindcss` sorts classes beautifully, but it can't parse files that interleave PHP with HTML, and
|
|
7
10
|
`@prettier/plugin-php` reformats the entire PHP file as a side effect. This tool sorts **only** the class attribute
|
|
@@ -17,17 +20,17 @@ values, using a real PHP-aware lexer, and leaves everything else byte-identical.
|
|
|
17
20
|
|
|
18
21
|
## Requirements
|
|
19
22
|
|
|
20
|
-
- **
|
|
23
|
+
- **Node ≥ 22.18**, or **Bun** — both run the CLI and the programmatic API.
|
|
21
24
|
- `prettier` ≥ 3 and `prettier-plugin-tailwindcss` ≥ 0.8 (peer dependencies).
|
|
22
25
|
|
|
23
26
|
## Install
|
|
24
27
|
|
|
25
28
|
```sh
|
|
26
|
-
# Bun
|
|
27
|
-
bun add -D @runtimestudio/tailwind-sort-php prettier prettier-plugin-tailwindcss
|
|
28
|
-
|
|
29
29
|
# npm
|
|
30
30
|
npm install -D @runtimestudio/tailwind-sort-php prettier prettier-plugin-tailwindcss
|
|
31
|
+
|
|
32
|
+
# Bun
|
|
33
|
+
bun add -D @runtimestudio/tailwind-sort-php prettier prettier-plugin-tailwindcss
|
|
31
34
|
```
|
|
32
35
|
|
|
33
36
|
pnpm and yarn work the same (`pnpm add -D …` / `yarn add -D …`).
|
|
@@ -35,7 +38,9 @@ pnpm and yarn work the same (`pnpm add -D …` / `yarn add -D …`).
|
|
|
35
38
|
## Setup
|
|
36
39
|
|
|
37
40
|
Point Prettier at your Tailwind v4 entry stylesheet so both `prettier-plugin-tailwindcss` and this tool share one
|
|
38
|
-
vocabulary.
|
|
41
|
+
vocabulary. Any config format Prettier supports works (`.prettierrc`, `prettier.config.js`, a `package.json`
|
|
42
|
+
`"prettier"` key, …) — the tool reads the resolved config, exactly like the plugin does. For example, in
|
|
43
|
+
`prettier.config.mjs`:
|
|
39
44
|
|
|
40
45
|
```js
|
|
41
46
|
export default {
|
|
@@ -49,20 +54,23 @@ this in place, the CLI needs no flags.
|
|
|
49
54
|
|
|
50
55
|
## Usage
|
|
51
56
|
|
|
52
|
-
Run with `
|
|
57
|
+
Run with `npx` (Node ≥ 22.18) or `bunx` (Bun):
|
|
53
58
|
|
|
54
59
|
```sh
|
|
55
60
|
# sort every .php file under the cwd (stylesheet read from your Prettier config)
|
|
56
|
-
|
|
61
|
+
npx tailwind-sort-php
|
|
57
62
|
|
|
58
63
|
# specific globs
|
|
59
|
-
|
|
64
|
+
npx tailwind-sort-php "template-parts/**/*.php" "*.php"
|
|
60
65
|
|
|
61
66
|
# CI / pre-commit — write nothing, exit 1 if anything is unsorted
|
|
62
|
-
|
|
67
|
+
npx tailwind-sort-php --check
|
|
63
68
|
|
|
64
69
|
# explicit stylesheet (overrides the Prettier config)
|
|
65
|
-
|
|
70
|
+
npx tailwind-sort-php --stylesheet ./resources/css/main.css
|
|
71
|
+
|
|
72
|
+
# one-time: install the pre-commit hook (see "Pre-commit gate" below)
|
|
73
|
+
npx tailwind-sort-php init
|
|
66
74
|
```
|
|
67
75
|
|
|
68
76
|
### Options
|
|
@@ -76,6 +84,68 @@ bunx tailwind-sort-php --stylesheet ./resources/css/main.css
|
|
|
76
84
|
|
|
77
85
|
Default globs are all `.php` files under the cwd; `node_modules`, `vendor`, `dist`, and `.git` are always skipped.
|
|
78
86
|
|
|
87
|
+
## Editor integration
|
|
88
|
+
|
|
89
|
+
No IDE plugin is needed — two small setups cover the common workflows.
|
|
90
|
+
|
|
91
|
+
### Sort on save (PhpStorm / IntelliJ)
|
|
92
|
+
|
|
93
|
+
Add a File Watcher (Settings → Tools → File Watchers → `+` → Custom):
|
|
94
|
+
|
|
95
|
+
- **File type:** PHP
|
|
96
|
+
- **Program:** `$ProjectFileDir$/node_modules/.bin/tailwind-sort-php`
|
|
97
|
+
- **Arguments:** `$FilePathRelativeToProjectRoot$`
|
|
98
|
+
- **Working directory:** `$ProjectFileDir$`
|
|
99
|
+
|
|
100
|
+
Untick "Auto-save edited files to trigger the watcher" so it runs on explicit save (~130 ms per file). The definition
|
|
101
|
+
lives in `.idea/watcherTasks.xml`, which you can commit to share it with your team.
|
|
102
|
+
|
|
103
|
+
### Sort on save (VS Code)
|
|
104
|
+
|
|
105
|
+
Install the [Run on Save](https://marketplace.visualstudio.com/items?itemName=emeraldwalk.RunOnSave) extension, then add
|
|
106
|
+
to `.vscode/settings.json`:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"emeraldwalk.runonsave": {
|
|
111
|
+
"commands": [
|
|
112
|
+
{
|
|
113
|
+
"match": "\\.php$",
|
|
114
|
+
"cmd": "${workspaceFolder}/node_modules/.bin/tailwind-sort-php ${relativeFile}"
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Pre-commit gate
|
|
122
|
+
|
|
123
|
+
Keep unsorted classes from landing regardless of the editor. One command installs a dependency-free Git hook at
|
|
124
|
+
`.githooks/pre-commit` and points `core.hooksPath` at it:
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
# check-and-fail (default): names the unsorted files and blocks the commit
|
|
128
|
+
npx tailwind-sort-php init
|
|
129
|
+
|
|
130
|
+
# auto-fix: sorts the staged files in place, then blocks the commit for review and re-staging
|
|
131
|
+
npx tailwind-sort-php init --fix
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`init` is no-clobber by default: it refuses to overwrite a differing hook, repoint a `core.hooksPath` that's set
|
|
135
|
+
elsewhere (husky etc.), or disable hooks already living in `.git/hooks` — pass `--force` to override, `--dry-run` to
|
|
136
|
+
preview. Run it once per clone; commit the `.githooks/` directory to share the hook with your team.
|
|
137
|
+
|
|
138
|
+
Both variants check working-tree file contents, so with partial staging (`git add -p`) the hook can mis-report — and
|
|
139
|
+
under `--fix`, re-staging a fixed file can pull in unrelated unstaged hunks.
|
|
140
|
+
|
|
141
|
+
Wiring the gate into your own hook manager (husky, lefthook) instead? The staged-PHP check is this one-liner:
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 ./node_modules/.bin/tailwind-sort-php --check
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
In CI there's no staged diff — just sweep the whole project with `npx tailwind-sort-php --check`.
|
|
148
|
+
|
|
79
149
|
## How it handles mixed templates
|
|
80
150
|
|
|
81
151
|
PHP islands inside a class attribute are treated as opaque atoms that never move. Static text between islands is sorted
|
|
@@ -130,14 +200,15 @@ const out = transform(source, sortFn);
|
|
|
130
200
|
## Development
|
|
131
201
|
|
|
132
202
|
```sh
|
|
133
|
-
bun test
|
|
134
|
-
bun run build
|
|
203
|
+
bun test # or: node --test "test/*.test.ts"
|
|
204
|
+
bun run build # compile src → dist (tsc); the published artifact
|
|
135
205
|
```
|
|
136
206
|
|
|
137
|
-
|
|
207
|
+
54 tests: 41 core tests that are dependency-free (the sorter is injected, so they run against a mock `SortFn`), 5
|
|
138
208
|
integration tests that exercise the real `prettier-plugin-tailwindcss` sorter and skip automatically when the Tailwind
|
|
139
|
-
toolchain isn't installed
|
|
209
|
+
toolchain isn't installed, and 8 `init` tests that run against throwaway git repositories and skip when `git` is
|
|
210
|
+
unavailable.
|
|
140
211
|
|
|
141
212
|
## License
|
|
142
213
|
|
|
143
|
-
MIT © Runtime Studio
|
|
214
|
+
[MIT](LICENSE) © Runtime Studio
|
package/dist/cli.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
6
|
* tailwind-sort-php [options] [glob ...]
|
|
7
|
+
* tailwind-sort-php init [--fix] [--force] [--dry-run]
|
|
7
8
|
*
|
|
8
9
|
* Options:
|
|
9
10
|
* --stylesheet <path> Tailwind v4 CSS entry
|
|
@@ -11,7 +12,7 @@
|
|
|
11
12
|
* --check Don't write; exit 1 if any file needs sorting
|
|
12
13
|
* --no-short-tags Don't treat bare `<?` as a PHP open tag
|
|
13
14
|
*
|
|
14
|
-
* Defaults to all `.php` files under `cwd`
|
|
15
|
-
* Skips `node_modules`, `vendor`, `dist` and `.git`.
|
|
15
|
+
* Defaults to all `.php` files under `cwd` when no globs are given.
|
|
16
|
+
* Skips `node_modules`, `vendor`, `dist` and `.git`. The `init` subcommand installs the pre-commit hook; see `init.ts`.
|
|
16
17
|
*/
|
|
17
18
|
export {};
|
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
6
|
* tailwind-sort-php [options] [glob ...]
|
|
7
|
+
* tailwind-sort-php init [--fix] [--force] [--dry-run]
|
|
7
8
|
*
|
|
8
9
|
* Options:
|
|
9
10
|
* --stylesheet <path> Tailwind v4 CSS entry
|
|
@@ -11,12 +12,13 @@
|
|
|
11
12
|
* --check Don't write; exit 1 if any file needs sorting
|
|
12
13
|
* --no-short-tags Don't treat bare `<?` as a PHP open tag
|
|
13
14
|
*
|
|
14
|
-
* Defaults to all `.php` files under `cwd`
|
|
15
|
-
* Skips `node_modules`, `vendor`, `dist` and `.git`.
|
|
15
|
+
* Defaults to all `.php` files under `cwd` when no globs are given.
|
|
16
|
+
* Skips `node_modules`, `vendor`, `dist` and `.git`. The `init` subcommand installs the pre-commit hook; see `init.ts`.
|
|
16
17
|
*/
|
|
17
18
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
18
19
|
import { transform } from "./transform.js";
|
|
19
20
|
import { createTailwindSortFn } from "./sorter.js";
|
|
21
|
+
import { runInit } from "./init.js";
|
|
20
22
|
const IGNORE = ['node_modules', 'vendor', 'dist', '.git'];
|
|
21
23
|
/**
|
|
22
24
|
* Parse command-line arguments.
|
|
@@ -58,7 +60,6 @@ function parseArgs(argv) {
|
|
|
58
60
|
* @param globs Glob patterns relative to `cwd`.
|
|
59
61
|
*/
|
|
60
62
|
async function* scanFiles(globs) {
|
|
61
|
-
// Use `Bun.Glob` when available, fall back to `node:fs` glob (Node 22+).
|
|
62
63
|
if (typeof globalThis.Bun !== 'undefined') {
|
|
63
64
|
const { Glob } = await import('bun');
|
|
64
65
|
for (const pattern of globs) {
|
|
@@ -112,7 +113,10 @@ async function fromPrettierConfig() {
|
|
|
112
113
|
}
|
|
113
114
|
}
|
|
114
115
|
async function main() {
|
|
115
|
-
const
|
|
116
|
+
const argv = process.argv.slice(2);
|
|
117
|
+
if (argv[0] === 'init')
|
|
118
|
+
return runInit(argv.slice(1));
|
|
119
|
+
const cli = parseArgs(argv);
|
|
116
120
|
const pc = await fromPrettierConfig();
|
|
117
121
|
const stylesheet = cli.stylesheet ?? pc.stylesheet;
|
|
118
122
|
if (!stylesheet) {
|
package/dist/html.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - islands inside a quoted attribute value read as opaque atoms
|
|
9
9
|
*
|
|
10
10
|
* Skips HTML comments, doctype/CDATA, and the raw-text content of `script`/`style`/`textarea`/`title` elements —
|
|
11
|
-
* their
|
|
11
|
+
* their content may contain strings like `class="..."` that must not be touched.
|
|
12
12
|
*
|
|
13
13
|
* @see islands.ts - first pass; produces the islands consumed here.
|
|
14
14
|
*/
|
package/dist/html.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - islands inside a quoted attribute value read as opaque atoms
|
|
9
9
|
*
|
|
10
10
|
* Skips HTML comments, doctype/CDATA, and the raw-text content of `script`/`style`/`textarea`/`title` elements —
|
|
11
|
-
* their
|
|
11
|
+
* their content may contain strings like `class="..."` that must not be touched.
|
|
12
12
|
*
|
|
13
13
|
* @see islands.ts - first pass; produces the islands consumed here.
|
|
14
14
|
*/
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-sort-php init
|
|
3
|
+
*
|
|
4
|
+
* Installs the pre-commit hook: writes `.githooks/pre-commit` and points `core.hooksPath` at it.
|
|
5
|
+
* No-clobber unless `--force`; `--fix` installs the auto-fixing variant; `--dry-run` previews.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Run the `init` subcommand: install the pre-commit hook and point `core.hooksPath` at it.
|
|
9
|
+
*
|
|
10
|
+
* @param argv Arguments after the `init` subcommand name.
|
|
11
|
+
*/
|
|
12
|
+
export declare function runInit(argv: string[]): Promise<void>;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-sort-php init
|
|
3
|
+
*
|
|
4
|
+
* Installs the pre-commit hook: writes `.githooks/pre-commit` and points `core.hooksPath` at it.
|
|
5
|
+
* No-clobber unless `--force`; `--fix` installs the auto-fixing variant; `--dry-run` previews.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
const HOOKS_DIR = '.githooks';
|
|
11
|
+
const HOOK_PATH = `${HOOKS_DIR}/pre-commit`;
|
|
12
|
+
/**
|
|
13
|
+
* Check-and-fail hook (default): names the unsorted files and blocks the commit; never writes.
|
|
14
|
+
*/
|
|
15
|
+
const HOOK_CHECK = `#!/bin/sh
|
|
16
|
+
# Block commits with unsorted Tailwind classes in staged PHP files. Installed by \`tailwind-sort-php init\`.
|
|
17
|
+
# Note: checks working-tree file contents, so partial staging (\`git add -p\`) can mis-report; see README.
|
|
18
|
+
sorter=./node_modules/.bin/tailwind-sort-php
|
|
19
|
+
[ -x "$sorter" ] || exit 0
|
|
20
|
+
git diff --cached --name-only --diff-filter=ACMR -- '*.php' | grep -q . || exit 0
|
|
21
|
+
if git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 "$sorter" --check; then
|
|
22
|
+
exit 0
|
|
23
|
+
fi
|
|
24
|
+
echo >&2
|
|
25
|
+
echo "Unsorted Tailwind classes in staged PHP (see above)." >&2
|
|
26
|
+
echo "Fix with: npx tailwind-sort-php (or: bunx tailwind-sort-php), then re-stage." >&2
|
|
27
|
+
exit 1
|
|
28
|
+
`;
|
|
29
|
+
/**
|
|
30
|
+
* Auto-fix hook (--fix): sorts the staged files in place, then blocks the commit for review.
|
|
31
|
+
*/
|
|
32
|
+
const HOOK_FIX = `#!/bin/sh
|
|
33
|
+
# Sort Tailwind classes in staged PHP files, then abort the commit so the changes can be reviewed and re-staged.
|
|
34
|
+
# Rewrites working-tree files. Installed by \`tailwind-sort-php init --fix\`.
|
|
35
|
+
# Note: with partial staging (\`git add -p\`), re-staging can pull in unrelated unstaged hunks; see README.
|
|
36
|
+
sorter=./node_modules/.bin/tailwind-sort-php
|
|
37
|
+
[ -x "$sorter" ] || exit 0
|
|
38
|
+
git diff --cached --name-only --diff-filter=ACMR -- '*.php' | grep -q . || exit 0
|
|
39
|
+
if git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 "$sorter" --check >/dev/null 2>&1; then
|
|
40
|
+
exit 0
|
|
41
|
+
fi
|
|
42
|
+
git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 "$sorter"
|
|
43
|
+
echo >&2
|
|
44
|
+
echo "Sorted Tailwind classes in staged PHP (see above)." >&2
|
|
45
|
+
echo "Review the changes, re-stage, and commit again." >&2
|
|
46
|
+
exit 1
|
|
47
|
+
`;
|
|
48
|
+
/**
|
|
49
|
+
* Parse `init` subcommand arguments.
|
|
50
|
+
*
|
|
51
|
+
* @param argv Arguments after the `init` subcommand name.
|
|
52
|
+
* @returns Parsed flags with defaults applied.
|
|
53
|
+
*/
|
|
54
|
+
function parseArgs(argv) {
|
|
55
|
+
const cli = { fix: false, force: false, dryRun: false };
|
|
56
|
+
for (const a of argv) {
|
|
57
|
+
if (a === '--fix')
|
|
58
|
+
cli.fix = true;
|
|
59
|
+
else if (a === '--force')
|
|
60
|
+
cli.force = true;
|
|
61
|
+
else if (a === '--dry-run')
|
|
62
|
+
cli.dryRun = true;
|
|
63
|
+
else {
|
|
64
|
+
console.error(`Unknown init option: ${a}`);
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return cli;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Run a git command and capture its output.
|
|
72
|
+
*
|
|
73
|
+
* @param args Arguments passed to `git`.
|
|
74
|
+
* @returns Trimmed stdout, or `null` when git exits non-zero (unset config, not a repo, …).
|
|
75
|
+
*/
|
|
76
|
+
function git(args) {
|
|
77
|
+
try {
|
|
78
|
+
return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Print an error to stderr and exit with status 1.
|
|
86
|
+
*
|
|
87
|
+
* @param message Error text explaining why init refused to proceed.
|
|
88
|
+
*/
|
|
89
|
+
function fail(message) {
|
|
90
|
+
console.error(message);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Run the `init` subcommand: install the pre-commit hook and point `core.hooksPath` at it.
|
|
95
|
+
*
|
|
96
|
+
* @param argv Arguments after the `init` subcommand name.
|
|
97
|
+
*/
|
|
98
|
+
export async function runInit(argv) {
|
|
99
|
+
const cli = parseArgs(argv);
|
|
100
|
+
const hookBody = cli.fix ? HOOK_FIX : HOOK_CHECK;
|
|
101
|
+
const variant = cli.fix ? 'fix' : 'check';
|
|
102
|
+
// Anchor everything at the repository root: a relative `core.hooksPath` resolves there,
|
|
103
|
+
// and the hook's `./node_modules/...` path assumes hooks run from it (they do, for pre-commit).
|
|
104
|
+
const top = git(['rev-parse', '--show-toplevel']);
|
|
105
|
+
if (top === null)
|
|
106
|
+
fail('Not a git repository (or a bare one) — run init from inside a working tree.');
|
|
107
|
+
const gitDir = git(['rev-parse', '--absolute-git-dir']);
|
|
108
|
+
const hookAbs = join(top, HOOK_PATH);
|
|
109
|
+
// Decide whether `core.hooksPath` needs to change. Repointing it makes git ignore `.git/hooks` entirely,
|
|
110
|
+
// so refuse to silently disable hooks that already live there.
|
|
111
|
+
const hooksPath = git(['config', '--get', 'core.hooksPath']);
|
|
112
|
+
let setConfig = false;
|
|
113
|
+
if (hooksPath === null) {
|
|
114
|
+
const live = (await readdir(join(gitDir, 'hooks')).catch(() => [])).filter((f) => !f.endsWith('.sample'));
|
|
115
|
+
if (live.length > 0 && !cli.force) {
|
|
116
|
+
fail(`Found existing hook(s) in .git/hooks: ${live.join(', ')}.\n` +
|
|
117
|
+
`Setting core.hooksPath would disable them. Move them into ${HOOKS_DIR}/ first, ` +
|
|
118
|
+
'or re-run with --force to proceed anyway.');
|
|
119
|
+
}
|
|
120
|
+
setConfig = true;
|
|
121
|
+
}
|
|
122
|
+
else if (hooksPath !== HOOKS_DIR) {
|
|
123
|
+
if (!cli.force) {
|
|
124
|
+
fail(`core.hooksPath is already set to "${hooksPath}" — add the hook there yourself, or re-run ` +
|
|
125
|
+
`with --force to repoint it to ${HOOKS_DIR}. Hook body:\n\n${hookBody}`);
|
|
126
|
+
}
|
|
127
|
+
setConfig = true;
|
|
128
|
+
}
|
|
129
|
+
// Decide whether the hook file needs writing; overwriting a differing hook needs --force.
|
|
130
|
+
const current = await readFile(hookAbs, 'utf8').catch(() => null);
|
|
131
|
+
let writeHook = current === null;
|
|
132
|
+
let repairMode = false;
|
|
133
|
+
if (current !== null && current !== hookBody) {
|
|
134
|
+
if (!cli.force) {
|
|
135
|
+
const installed = current === HOOK_CHECK ? 'the check variant' : current === HOOK_FIX ? 'the --fix variant' : 'a custom hook';
|
|
136
|
+
fail(`${HOOK_PATH} already exists and differs (${installed} is installed). Re-run with --force to overwrite.`);
|
|
137
|
+
}
|
|
138
|
+
writeHook = true;
|
|
139
|
+
}
|
|
140
|
+
if (current === hookBody) {
|
|
141
|
+
// Content is already right; still repair a missing executable bit, or git silently skips the hook.
|
|
142
|
+
repairMode = ((await stat(hookAbs)).mode & 0o111) === 0;
|
|
143
|
+
}
|
|
144
|
+
const done = [];
|
|
145
|
+
if (writeHook) {
|
|
146
|
+
if (!cli.dryRun) {
|
|
147
|
+
await mkdir(join(top, HOOKS_DIR), { recursive: true });
|
|
148
|
+
await writeFile(hookAbs, hookBody);
|
|
149
|
+
await chmod(hookAbs, 0o755);
|
|
150
|
+
}
|
|
151
|
+
done.push(cli.dryRun ? `would install ${HOOK_PATH} (${variant} variant)` : `installed ${HOOK_PATH} (${variant} variant)`);
|
|
152
|
+
}
|
|
153
|
+
if (repairMode) {
|
|
154
|
+
if (!cli.dryRun)
|
|
155
|
+
await chmod(hookAbs, 0o755);
|
|
156
|
+
done.push(cli.dryRun ? `would make ${HOOK_PATH} executable` : `made ${HOOK_PATH} executable`);
|
|
157
|
+
}
|
|
158
|
+
if (setConfig) {
|
|
159
|
+
if (!cli.dryRun)
|
|
160
|
+
git(['config', 'core.hooksPath', HOOKS_DIR]);
|
|
161
|
+
done.push(cli.dryRun ? `would set core.hooksPath = ${HOOKS_DIR}` : `set core.hooksPath = ${HOOKS_DIR}`);
|
|
162
|
+
}
|
|
163
|
+
if (done.length === 0) {
|
|
164
|
+
console.log(`Nothing to do — ${HOOK_PATH} (${variant} variant) is already installed.`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
for (const line of done)
|
|
168
|
+
console.log(line);
|
|
169
|
+
}
|
package/dist/islands.js
CHANGED
|
@@ -73,7 +73,7 @@ function scanPhpBody(src, i) {
|
|
|
73
73
|
continue;
|
|
74
74
|
}
|
|
75
75
|
// Double-quoted string.
|
|
76
|
-
// Limitation: nested double quotes in complex `{$a["k"]}` interpolation can desync the lexer
|
|
76
|
+
// Limitation: nested double quotes in complex `{$a["k"]}` interpolation can desync the lexer; see README.
|
|
77
77
|
if (c === '"') {
|
|
78
78
|
i = scanQuoted(src, i + 1, '"');
|
|
79
79
|
continue;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtimestudio/tailwind-sort-php",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Tailwind CSS
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Tailwind CSS Class Sorter for PHP",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"class-sorting",
|
|
7
7
|
"formatter",
|
package/src/cli.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
6
|
* tailwind-sort-php [options] [glob ...]
|
|
7
|
+
* tailwind-sort-php init [--fix] [--force] [--dry-run]
|
|
7
8
|
*
|
|
8
9
|
* Options:
|
|
9
10
|
* --stylesheet <path> Tailwind v4 CSS entry
|
|
@@ -11,13 +12,14 @@
|
|
|
11
12
|
* --check Don't write; exit 1 if any file needs sorting
|
|
12
13
|
* --no-short-tags Don't treat bare `<?` as a PHP open tag
|
|
13
14
|
*
|
|
14
|
-
* Defaults to all `.php` files under `cwd`
|
|
15
|
-
* Skips `node_modules`, `vendor`, `dist` and `.git`.
|
|
15
|
+
* Defaults to all `.php` files under `cwd` when no globs are given.
|
|
16
|
+
* Skips `node_modules`, `vendor`, `dist` and `.git`. The `init` subcommand installs the pre-commit hook; see `init.ts`.
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
19
20
|
import { transform, type TransformOptions } from './transform.ts';
|
|
20
21
|
import { createTailwindSortFn } from './sorter.ts';
|
|
22
|
+
import { runInit } from './init.ts';
|
|
21
23
|
|
|
22
24
|
const IGNORE = ['node_modules', 'vendor', 'dist', '.git'];
|
|
23
25
|
|
|
@@ -63,7 +65,6 @@ function parseArgs(argv: string[]): Cli {
|
|
|
63
65
|
* @param globs Glob patterns relative to `cwd`.
|
|
64
66
|
*/
|
|
65
67
|
async function* scanFiles(globs: string[]): AsyncGenerator<string> {
|
|
66
|
-
// Use `Bun.Glob` when available, fall back to `node:fs` glob (Node 22+).
|
|
67
68
|
if (typeof (globalThis as any).Bun !== 'undefined') {
|
|
68
69
|
const { Glob } = await import('bun');
|
|
69
70
|
for (const pattern of globs) {
|
|
@@ -117,7 +118,10 @@ async function fromPrettierConfig(): Promise<{
|
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
async function main() {
|
|
120
|
-
const
|
|
121
|
+
const argv = process.argv.slice(2);
|
|
122
|
+
if (argv[0] === 'init') return runInit(argv.slice(1));
|
|
123
|
+
|
|
124
|
+
const cli = parseArgs(argv);
|
|
121
125
|
|
|
122
126
|
const pc = await fromPrettierConfig();
|
|
123
127
|
const stylesheet = cli.stylesheet ?? pc.stylesheet;
|
package/src/html.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - islands inside a quoted attribute value read as opaque atoms
|
|
9
9
|
*
|
|
10
10
|
* Skips HTML comments, doctype/CDATA, and the raw-text content of `script`/`style`/`textarea`/`title` elements —
|
|
11
|
-
* their
|
|
11
|
+
* their content may contain strings like `class="..."` that must not be touched.
|
|
12
12
|
*
|
|
13
13
|
* @see islands.ts - first pass; produces the islands consumed here.
|
|
14
14
|
*/
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-sort-php init
|
|
3
|
+
*
|
|
4
|
+
* Installs the pre-commit hook: writes `.githooks/pre-commit` and points `core.hooksPath` at it.
|
|
5
|
+
* No-clobber unless `--force`; `--fix` installs the auto-fixing variant; `--dry-run` previews.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const HOOKS_DIR = '.githooks';
|
|
13
|
+
const HOOK_PATH = `${HOOKS_DIR}/pre-commit`;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check-and-fail hook (default): names the unsorted files and blocks the commit; never writes.
|
|
17
|
+
*/
|
|
18
|
+
const HOOK_CHECK = `#!/bin/sh
|
|
19
|
+
# Block commits with unsorted Tailwind classes in staged PHP files. Installed by \`tailwind-sort-php init\`.
|
|
20
|
+
# Note: checks working-tree file contents, so partial staging (\`git add -p\`) can mis-report; see README.
|
|
21
|
+
sorter=./node_modules/.bin/tailwind-sort-php
|
|
22
|
+
[ -x "$sorter" ] || exit 0
|
|
23
|
+
git diff --cached --name-only --diff-filter=ACMR -- '*.php' | grep -q . || exit 0
|
|
24
|
+
if git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 "$sorter" --check; then
|
|
25
|
+
exit 0
|
|
26
|
+
fi
|
|
27
|
+
echo >&2
|
|
28
|
+
echo "Unsorted Tailwind classes in staged PHP (see above)." >&2
|
|
29
|
+
echo "Fix with: npx tailwind-sort-php (or: bunx tailwind-sort-php), then re-stage." >&2
|
|
30
|
+
exit 1
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Auto-fix hook (--fix): sorts the staged files in place, then blocks the commit for review.
|
|
35
|
+
*/
|
|
36
|
+
const HOOK_FIX = `#!/bin/sh
|
|
37
|
+
# Sort Tailwind classes in staged PHP files, then abort the commit so the changes can be reviewed and re-staged.
|
|
38
|
+
# Rewrites working-tree files. Installed by \`tailwind-sort-php init --fix\`.
|
|
39
|
+
# Note: with partial staging (\`git add -p\`), re-staging can pull in unrelated unstaged hunks; see README.
|
|
40
|
+
sorter=./node_modules/.bin/tailwind-sort-php
|
|
41
|
+
[ -x "$sorter" ] || exit 0
|
|
42
|
+
git diff --cached --name-only --diff-filter=ACMR -- '*.php' | grep -q . || exit 0
|
|
43
|
+
if git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 "$sorter" --check >/dev/null 2>&1; then
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 "$sorter"
|
|
47
|
+
echo >&2
|
|
48
|
+
echo "Sorted Tailwind classes in staged PHP (see above)." >&2
|
|
49
|
+
echo "Review the changes, re-stage, and commit again." >&2
|
|
50
|
+
exit 1
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
interface InitCli {
|
|
54
|
+
fix: boolean;
|
|
55
|
+
force: boolean;
|
|
56
|
+
dryRun: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse `init` subcommand arguments.
|
|
61
|
+
*
|
|
62
|
+
* @param argv Arguments after the `init` subcommand name.
|
|
63
|
+
* @returns Parsed flags with defaults applied.
|
|
64
|
+
*/
|
|
65
|
+
function parseArgs(argv: string[]): InitCli {
|
|
66
|
+
const cli: InitCli = { fix: false, force: false, dryRun: false };
|
|
67
|
+
for (const a of argv) {
|
|
68
|
+
if (a === '--fix') cli.fix = true;
|
|
69
|
+
else if (a === '--force') cli.force = true;
|
|
70
|
+
else if (a === '--dry-run') cli.dryRun = true;
|
|
71
|
+
else {
|
|
72
|
+
console.error(`Unknown init option: ${a}`);
|
|
73
|
+
process.exit(2);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return cli;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Run a git command and capture its output.
|
|
81
|
+
*
|
|
82
|
+
* @param args Arguments passed to `git`.
|
|
83
|
+
* @returns Trimmed stdout, or `null` when git exits non-zero (unset config, not a repo, …).
|
|
84
|
+
*/
|
|
85
|
+
function git(args: string[]): string | null {
|
|
86
|
+
try {
|
|
87
|
+
return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Print an error to stderr and exit with status 1.
|
|
95
|
+
*
|
|
96
|
+
* @param message Error text explaining why init refused to proceed.
|
|
97
|
+
*/
|
|
98
|
+
function fail(message: string): never {
|
|
99
|
+
console.error(message);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Run the `init` subcommand: install the pre-commit hook and point `core.hooksPath` at it.
|
|
105
|
+
*
|
|
106
|
+
* @param argv Arguments after the `init` subcommand name.
|
|
107
|
+
*/
|
|
108
|
+
export async function runInit(argv: string[]): Promise<void> {
|
|
109
|
+
const cli = parseArgs(argv);
|
|
110
|
+
const hookBody = cli.fix ? HOOK_FIX : HOOK_CHECK;
|
|
111
|
+
const variant = cli.fix ? 'fix' : 'check';
|
|
112
|
+
|
|
113
|
+
// Anchor everything at the repository root: a relative `core.hooksPath` resolves there,
|
|
114
|
+
// and the hook's `./node_modules/...` path assumes hooks run from it (they do, for pre-commit).
|
|
115
|
+
const top = git(['rev-parse', '--show-toplevel']);
|
|
116
|
+
if (top === null) fail('Not a git repository (or a bare one) — run init from inside a working tree.');
|
|
117
|
+
const gitDir = git(['rev-parse', '--absolute-git-dir'])!;
|
|
118
|
+
const hookAbs = join(top, HOOK_PATH);
|
|
119
|
+
|
|
120
|
+
// Decide whether `core.hooksPath` needs to change. Repointing it makes git ignore `.git/hooks` entirely,
|
|
121
|
+
// so refuse to silently disable hooks that already live there.
|
|
122
|
+
const hooksPath = git(['config', '--get', 'core.hooksPath']);
|
|
123
|
+
let setConfig = false;
|
|
124
|
+
if (hooksPath === null) {
|
|
125
|
+
const live = (await readdir(join(gitDir, 'hooks')).catch(() => [])).filter((f) => !f.endsWith('.sample'));
|
|
126
|
+
if (live.length > 0 && !cli.force) {
|
|
127
|
+
fail(
|
|
128
|
+
`Found existing hook(s) in .git/hooks: ${live.join(', ')}.\n` +
|
|
129
|
+
`Setting core.hooksPath would disable them. Move them into ${HOOKS_DIR}/ first, ` +
|
|
130
|
+
'or re-run with --force to proceed anyway.',
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
setConfig = true;
|
|
134
|
+
} else if (hooksPath !== HOOKS_DIR) {
|
|
135
|
+
if (!cli.force) {
|
|
136
|
+
fail(
|
|
137
|
+
`core.hooksPath is already set to "${hooksPath}" — add the hook there yourself, or re-run ` +
|
|
138
|
+
`with --force to repoint it to ${HOOKS_DIR}. Hook body:\n\n${hookBody}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
setConfig = true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Decide whether the hook file needs writing; overwriting a differing hook needs --force.
|
|
145
|
+
const current = await readFile(hookAbs, 'utf8').catch(() => null);
|
|
146
|
+
let writeHook = current === null;
|
|
147
|
+
let repairMode = false;
|
|
148
|
+
if (current !== null && current !== hookBody) {
|
|
149
|
+
if (!cli.force) {
|
|
150
|
+
const installed =
|
|
151
|
+
current === HOOK_CHECK ? 'the check variant' : current === HOOK_FIX ? 'the --fix variant' : 'a custom hook';
|
|
152
|
+
fail(`${HOOK_PATH} already exists and differs (${installed} is installed). Re-run with --force to overwrite.`);
|
|
153
|
+
}
|
|
154
|
+
writeHook = true;
|
|
155
|
+
}
|
|
156
|
+
if (current === hookBody) {
|
|
157
|
+
// Content is already right; still repair a missing executable bit, or git silently skips the hook.
|
|
158
|
+
repairMode = ((await stat(hookAbs)).mode & 0o111) === 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const done: string[] = [];
|
|
162
|
+
if (writeHook) {
|
|
163
|
+
if (!cli.dryRun) {
|
|
164
|
+
await mkdir(join(top, HOOKS_DIR), { recursive: true });
|
|
165
|
+
await writeFile(hookAbs, hookBody);
|
|
166
|
+
await chmod(hookAbs, 0o755);
|
|
167
|
+
}
|
|
168
|
+
done.push(
|
|
169
|
+
cli.dryRun ? `would install ${HOOK_PATH} (${variant} variant)` : `installed ${HOOK_PATH} (${variant} variant)`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (repairMode) {
|
|
173
|
+
if (!cli.dryRun) await chmod(hookAbs, 0o755);
|
|
174
|
+
done.push(cli.dryRun ? `would make ${HOOK_PATH} executable` : `made ${HOOK_PATH} executable`);
|
|
175
|
+
}
|
|
176
|
+
if (setConfig) {
|
|
177
|
+
if (!cli.dryRun) git(['config', 'core.hooksPath', HOOKS_DIR]);
|
|
178
|
+
done.push(cli.dryRun ? `would set core.hooksPath = ${HOOKS_DIR}` : `set core.hooksPath = ${HOOKS_DIR}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (done.length === 0) {
|
|
182
|
+
console.log(`Nothing to do — ${HOOK_PATH} (${variant} variant) is already installed.`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
for (const line of done) console.log(line);
|
|
186
|
+
}
|
package/src/islands.ts
CHANGED
|
@@ -104,7 +104,7 @@ function scanPhpBody(src: string, i: number): number {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
// Double-quoted string.
|
|
107
|
-
// Limitation: nested double quotes in complex `{$a["k"]}` interpolation can desync the lexer
|
|
107
|
+
// Limitation: nested double quotes in complex `{$a["k"]}` interpolation can desync the lexer; see README.
|
|
108
108
|
if (c === '"') {
|
|
109
109
|
i = scanQuoted(src, i + 1, '"');
|
|
110
110
|
continue;
|