@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 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
- # @runtimestudio/tailwind-sort-php
1
+ # Tailwind CSS Class Sorter for PHP
2
2
 
3
- Tailwind CSS class sorting for **mixed PHP/HTML templates** — the WordPress template-partial case that
4
- `@prettier/plugin-php` + `prettier-plugin-tailwindcss` can't handle.
3
+ [![npm version](https://img.shields.io/npm/v/@runtimestudio/tailwind-sort-php)](https://www.npmjs.com/package/@runtimestudio/tailwind-sort-php)
4
+ [![License: MIT](https://img.shields.io/npm/l/@runtimestudio/tailwind-sort-php)](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
- - **Bun**, or **Node ≥ 22.18** (native TypeScript type-stripping) — both run the CLI and the programmatic API.
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. In `prettier.config.mjs`:
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 `bunx` (Bun) or `npx` (Node ≥ 22.18):
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
- bunx tailwind-sort-php
61
+ npx tailwind-sort-php
57
62
 
58
63
  # specific globs
59
- bunx tailwind-sort-php "template-parts/**/*.php" "*.php"
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
- bunx tailwind-sort-php --check
67
+ npx tailwind-sort-php --check
63
68
 
64
69
  # explicit stylesheet (overrides the Prettier config)
65
- bunx tailwind-sort-php --stylesheet ./resources/css/main.css
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 # or: node --test "test/*.test.ts"
134
- bun run build # compile src → dist (tsc); the published artifact
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
- 46 tests: 41 core tests that are dependency-free (the sorter is injected, so they run against a mock `SortFn`), plus 5
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` (`"**" + "/*.php"`) when no globs are given.
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` (`"**" + "/*.php"`) when no globs are given.
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 cli = parseArgs(process.argv.slice(2));
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 `script`/`style` content may contain strings like `class="..."` that must not be touched.
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 `script`/`style` content may contain strings like `class="..."` that must not be touched.
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 (documented).
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.1.1",
4
- "description": "Tailwind CSS class sorting for mixed PHP/HTML templates (WordPress-friendly)",
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` (`"**" + "/*.php"`) when no globs are given.
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 cli = parseArgs(process.argv.slice(2));
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 `script`/`style` content may contain strings like `class="..."` that must not be touched.
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 (documented).
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;