@runtimestudio/tailwind-sort-php 0.2.1 → 0.3.0

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/dist/sorter.d.ts CHANGED
@@ -11,7 +11,7 @@ import type { SortFn } from './transform.ts';
11
11
  */
12
12
  export interface SorterOptions {
13
13
  /**
14
- * Tailwind v4 CSS entry point (`@import "tailwindcss"`, `@theme`, etc.).
14
+ * Tailwind v4 CSS entry point.
15
15
  */
16
16
  stylesheet: string;
17
17
  /**
@@ -20,8 +20,8 @@ export interface SorterOptions {
20
20
  base?: string;
21
21
  }
22
22
  /**
23
- * Create a `SortFn` backed by the official `prettier-plugin-tailwindcss` sorting engine, configured with
24
- * the project's Tailwind v4 stylesheet so custom `@theme` tokens and `@utility` classes sort correctly.
23
+ * Create a `SortFn` backed by the official `prettier-plugin-tailwindcss` sorting engine,
24
+ * configured with the project's Tailwind v4 stylesheet so custom tokens and classes sort correctly.
25
25
  *
26
26
  * Requires `prettier-plugin-tailwindcss` >= 0.8 (the `/sorter` entrypoint).
27
27
  *
package/dist/sorter.js CHANGED
@@ -6,8 +6,8 @@
6
6
  * @see transform.ts - consumes the `SortFn` produced here.
7
7
  */
8
8
  /**
9
- * Create a `SortFn` backed by the official `prettier-plugin-tailwindcss` sorting engine, configured with
10
- * the project's Tailwind v4 stylesheet so custom `@theme` tokens and `@utility` classes sort correctly.
9
+ * Create a `SortFn` backed by the official `prettier-plugin-tailwindcss` sorting engine,
10
+ * configured with the project's Tailwind v4 stylesheet so custom tokens and classes sort correctly.
11
11
  *
12
12
  * Requires `prettier-plugin-tailwindcss` >= 0.8 (the `/sorter` entrypoint).
13
13
  *
@@ -15,8 +15,7 @@
15
15
  * @returns A synchronous `SortFn` for use with `transform()`.
16
16
  */
17
17
  export async function createTailwindSortFn(opts) {
18
- // Dynamic import so the core package works without the dependency
19
- // installed (e.g., when only running tests with a mock sorter).
18
+ // Dynamic import so the core package works without the dependency installed.
20
19
  const { createSorter } = await import('prettier-plugin-tailwindcss/sorter');
21
20
  const sorter = await createSorter({
22
21
  base: opts.base ?? process.cwd(),
@@ -24,9 +24,13 @@ import { type HtmlScanOptions } from './html.ts';
24
24
  */
25
25
  export type SortFn = (classes: string[]) => string[];
26
26
  /**
27
- * Combined options for both lexer passes.
27
+ * Combined options for all lexer passes.
28
28
  */
29
29
  export interface TransformOptions extends IslandOptions, HtmlScanOptions {
30
+ /**
31
+ * Sort classes in PHP string-literal values too. Off by default; see `php-strings.ts` for eligibility.
32
+ */
33
+ sortPhpStrings?: boolean;
30
34
  }
31
35
  /**
32
36
  * Rewrite all class attribute values in the template source with sorted classes.
package/dist/transform.js CHANGED
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { findIslands } from "./islands.js";
20
20
  import { maskIslands, findClassAttributes } from "./html.js";
21
+ import { findSortablePhpStrings } from "./php-strings.js";
21
22
  /**
22
23
  * Rewrite all class attribute values in the template source with sorted classes.
23
24
  * Everything outside class attribute values is byte-identical in the result; the function is idempotent.
@@ -35,17 +36,32 @@ export function transform(src, sortFn, opts = {}) {
35
36
  const islands = findIslands(src, opts);
36
37
  const masked = maskIslands(src, islands);
37
38
  const attrs = findClassAttributes(masked, opts);
38
- // Apply replacements back-to-front so offsets stay valid.
39
- let out = src;
40
- for (let a = attrs.length - 1; a >= 0; a--) {
41
- const { valueStart, valueEnd } = attrs[a];
39
+ // Collect every edit as a {start, end, text} range, then apply them back-to-front so offsets stay valid.
40
+ // HTML class-attribute values live outside islands; PHP string values live inside them, so the two sets of
41
+ // ranges never overlap.
42
+ const edits = [];
43
+ for (const { valueStart, valueEnd } of attrs) {
42
44
  const original = src.slice(valueStart, valueEnd);
43
45
  const inner = islands.filter((isl) => isl.start >= valueStart && isl.end <= valueEnd);
44
46
  const rewritten = rewriteValue(original, valueStart, inner, sortFn);
45
- if (rewritten !== original) {
46
- out = out.slice(0, valueStart) + rewritten + out.slice(valueEnd);
47
+ if (rewritten !== original)
48
+ edits.push({ start: valueStart, end: valueEnd, text: rewritten });
49
+ }
50
+ if (opts.sortPhpStrings) {
51
+ for (const { start, end } of findSortablePhpStrings(src, islands)) {
52
+ const original = src.slice(start, end);
53
+ const tokens = original.split(/\s+/).filter(Boolean);
54
+ if (tokens.length < 2)
55
+ continue; // nothing to reorder; leave byte-identical
56
+ const rewritten = sortFn(tokens).join(' ');
57
+ if (rewritten !== original)
58
+ edits.push({ start, end, text: rewritten });
47
59
  }
48
60
  }
61
+ edits.sort((a, b) => b.start - a.start);
62
+ let out = src;
63
+ for (const e of edits)
64
+ out = out.slice(0, e.start) + e.text + out.slice(e.end);
49
65
  return out;
50
66
  }
51
67
  function rewriteValue(value, base, islands, sortFn) {
package/package.json CHANGED
@@ -1,57 +1,57 @@
1
1
  {
2
- "name": "@runtimestudio/tailwind-sort-php",
3
- "version": "0.2.1",
4
- "description": "Tailwind CSS Class Sorter for PHP",
5
- "keywords": [
6
- "class-sorting",
7
- "formatter",
8
- "php",
9
- "prettier",
10
- "tailwindcss",
11
- "wordpress"
12
- ],
13
- "homepage": "https://github.com/runtime-studio-au/tailwind-sort-php#readme",
14
- "bugs": "https://github.com/runtime-studio-au/tailwind-sort-php/issues",
15
- "license": "MIT",
16
- "author": "Greg Sevastos <greg@runtimestudio.com.au> (https://runtimestudio.com.au)",
17
- "files": [
18
- "dist",
19
- "src"
20
- ],
21
- "main": "dist/index.js",
22
- "types": "dist/index.d.ts",
23
- "type": "module",
24
- "bin": {
25
- "tailwind-sort-php": "dist/cli.js"
26
- },
27
- "repository": {
28
- "type": "git",
29
- "url": "git+https://github.com/runtime-studio-au/tailwind-sort-php.git"
30
- },
31
- "scripts": {
32
- "build": "tsc -p tsconfig.build.json",
33
- "format": "prettier --write .",
34
- "format:check": "prettier --check .",
35
- "prepublishOnly": "npm test && npm run build",
36
- "sort": "bun run src/cli.ts",
37
- "sort:check": "bun run src/cli.ts --check",
38
- "test": "node --test \"test/*.test.ts\""
39
- },
40
- "devDependencies": {
41
- "@types/bun": "latest",
42
- "prettier": "^3.8",
43
- "prettier-plugin-tailwindcss": "^0.8",
44
- "tailwindcss": "^4",
45
- "typescript": ">=5.7"
46
- },
47
- "peerDependencies": {
48
- "prettier": ">=3",
49
- "prettier-plugin-tailwindcss": ">=0.8"
50
- },
51
- "engines": {
52
- "node": ">=22.18"
53
- },
54
- "publishConfig": {
55
- "access": "public"
56
- }
2
+ "name": "@runtimestudio/tailwind-sort-php",
3
+ "version": "0.3.0",
4
+ "description": "Tailwind CSS Class Sorter for PHP",
5
+ "keywords": [
6
+ "class-sorting",
7
+ "formatter",
8
+ "php",
9
+ "prettier",
10
+ "tailwindcss",
11
+ "wordpress"
12
+ ],
13
+ "homepage": "https://github.com/runtime-studio-au/tailwind-sort-php#readme",
14
+ "bugs": "https://github.com/runtime-studio-au/tailwind-sort-php/issues",
15
+ "license": "MIT",
16
+ "author": "Greg Sevastos <greg@runtimestudio.com.au> (https://runtimestudio.com.au)",
17
+ "files": [
18
+ "dist",
19
+ "src"
20
+ ],
21
+ "main": "dist/index.js",
22
+ "types": "dist/index.d.ts",
23
+ "type": "module",
24
+ "bin": {
25
+ "tailwind-sort-php": "dist/cli.js"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/runtime-studio-au/tailwind-sort-php.git"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc -p tsconfig.build.json",
33
+ "format": "prettier --write .",
34
+ "format:check": "prettier --check .",
35
+ "prepublishOnly": "npm test && npm run build",
36
+ "sort": "bun run src/cli.ts",
37
+ "sort:check": "bun run src/cli.ts --check",
38
+ "test": "node --test \"test/*.test.ts\""
39
+ },
40
+ "devDependencies": {
41
+ "@types/bun": "latest",
42
+ "prettier": "^3.8",
43
+ "prettier-plugin-tailwindcss": "^0.8",
44
+ "tailwindcss": "^4",
45
+ "typescript": ">=5.7"
46
+ },
47
+ "peerDependencies": {
48
+ "prettier": ">=3",
49
+ "prettier-plugin-tailwindcss": ">=0.8"
50
+ },
51
+ "engines": {
52
+ "node": ">=22.18"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ }
57
57
  }
package/src/cli.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * Options:
10
10
  * --stylesheet <path> Tailwind v4 CSS entry
11
11
  * --attr <name> Extra attribute to sort (repeatable)
12
+ * --php-source <glob> Also sort class strings in PHP declarations in matching files (repeatable)
12
13
  * --check Don't write; exit 1 if any file needs sorting
13
14
  * --no-short-tags Don't treat bare `<?` as a PHP open tag
14
15
  *
@@ -24,11 +25,12 @@ import { runInit } from './init.ts';
24
25
  const IGNORE = ['node_modules', 'vendor', 'dist', '.git'];
25
26
 
26
27
  interface Cli {
27
- globs: string[];
28
- stylesheet?: string;
29
- attrs: string[];
30
- check: boolean;
31
- shortTags: boolean;
28
+ globs: string[];
29
+ stylesheet?: string;
30
+ attrs: string[];
31
+ phpSources: string[];
32
+ check: boolean;
33
+ shortTags: boolean;
32
34
  }
33
35
 
34
36
  /**
@@ -38,25 +40,27 @@ interface Cli {
38
40
  * @returns Parsed CLI options with defaults applied.
39
41
  */
40
42
  function parseArgs(argv: string[]): Cli {
41
- const cli: Cli = {
42
- globs: [],
43
- attrs: ['class', 'className'],
44
- check: false,
45
- shortTags: true,
46
- };
47
- for (let i = 0; i < argv.length; i++) {
48
- const a = argv[i];
49
- if (a === '--check') cli.check = true;
50
- else if (a === '--no-short-tags') cli.shortTags = false;
51
- else if (a === '--stylesheet') cli.stylesheet = argv[++i];
52
- else if (a === '--attr') cli.attrs.push(argv[++i]);
53
- else if (a.startsWith('--')) {
54
- console.error(`Unknown option: ${a}`);
55
- process.exit(2);
56
- } else cli.globs.push(a);
57
- }
58
- if (cli.globs.length === 0) cli.globs.push('**/*.php');
59
- return cli;
43
+ const cli: Cli = {
44
+ globs: [],
45
+ attrs: ['class', 'className'],
46
+ phpSources: [],
47
+ check: false,
48
+ shortTags: true,
49
+ };
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const a = argv[i];
52
+ if (a === '--check') cli.check = true;
53
+ else if (a === '--no-short-tags') cli.shortTags = false;
54
+ else if (a === '--stylesheet') cli.stylesheet = argv[++i];
55
+ else if (a === '--attr') cli.attrs.push(argv[++i]);
56
+ else if (a === '--php-source') cli.phpSources.push(argv[++i]);
57
+ else if (a.startsWith('--')) {
58
+ console.error(`Unknown option: ${a}`);
59
+ process.exit(2);
60
+ } else cli.globs.push(a);
61
+ }
62
+ if (cli.globs.length === 0) cli.globs.push('**/*.php');
63
+ return cli;
60
64
  }
61
65
 
62
66
  /**
@@ -65,17 +69,17 @@ function parseArgs(argv: string[]): Cli {
65
69
  * @param globs Glob patterns relative to `cwd`.
66
70
  */
67
71
  async function* scanFiles(globs: string[]): AsyncGenerator<string> {
68
- if (typeof (globalThis as any).Bun !== 'undefined') {
69
- const { Glob } = await import('bun');
70
- for (const pattern of globs) {
71
- for await (const f of new Glob(pattern).scan('.')) yield f;
72
- }
73
- } else {
74
- const { glob } = await import('node:fs/promises');
75
- for (const pattern of globs) {
76
- for await (const f of glob(pattern)) yield f as string;
72
+ if (typeof (globalThis as any).Bun !== 'undefined') {
73
+ const { Glob } = await import('bun');
74
+ for (const pattern of globs) {
75
+ for await (const f of new Glob(pattern).scan('.')) yield f;
76
+ }
77
+ } else {
78
+ const { glob } = await import('node:fs/promises');
79
+ for (const pattern of globs) {
80
+ for await (const f of glob(pattern)) yield f as string;
81
+ }
77
82
  }
78
- }
79
83
  }
80
84
 
81
85
  /**
@@ -87,89 +91,102 @@ async function* scanFiles(globs: string[]): AsyncGenerator<string> {
87
91
  const ignored = (file: string) => IGNORE.some((d) => file.includes(`${d}/`) || file.startsWith(d));
88
92
 
89
93
  /**
90
- * Best-effort read of the project's resolved Prettier config, so this tool shares one source of truth with
91
- * `prettier-plugin-tailwindcss`. Picks up `tailwindStylesheet` (resolved relative to the config file) and
92
- * `tailwindAttributes` (merged into the attribute list).
94
+ * Best-effort read of the resolved Prettier config the shared source of truth with `prettier-plugin-tailwindcss`.
95
+ * Picks up `tailwindStylesheet`, `tailwindAttributes`, and `tailwindPhpSources`.
93
96
  *
94
- * @returns The resolved stylesheet path and attributes, or an empty object if none are available.
97
+ * @returns The found settings, or an empty object if none are available.
95
98
  */
96
99
  async function fromPrettierConfig(): Promise<{
97
- stylesheet?: string;
98
- attributes?: string[];
100
+ stylesheet?: string;
101
+ attributes?: string[];
102
+ phpSources?: string[];
99
103
  }> {
100
- try {
101
- const prettier = await import('prettier');
102
- const { dirname, resolve } = await import('node:path');
103
- const configFile = await prettier.resolveConfigFile();
104
- if (!configFile) return {};
105
- const cfg = (await prettier.resolveConfig(configFile)) as Record<string, unknown> | null;
106
- if (!cfg) return {};
107
- const out: { stylesheet?: string; attributes?: string[] } = {};
108
- if (typeof cfg.tailwindStylesheet === 'string') {
109
- out.stylesheet = resolve(dirname(configFile), cfg.tailwindStylesheet);
110
- }
111
- if (Array.isArray(cfg.tailwindAttributes)) {
112
- out.attributes = cfg.tailwindAttributes.filter((a): a is string => typeof a === 'string' && !a.startsWith('/'));
104
+ try {
105
+ const prettier = await import('prettier');
106
+ const { dirname, resolve } = await import('node:path');
107
+ const configFile = await prettier.resolveConfigFile();
108
+ if (!configFile) return {};
109
+ const cfg = (await prettier.resolveConfig(configFile)) as Record<string, unknown> | null;
110
+ if (!cfg) return {};
111
+ const out: { stylesheet?: string; attributes?: string[]; phpSources?: string[] } = {};
112
+ if (typeof cfg.tailwindStylesheet === 'string') {
113
+ out.stylesheet = resolve(dirname(configFile), cfg.tailwindStylesheet);
114
+ }
115
+ if (Array.isArray(cfg.tailwindAttributes)) {
116
+ out.attributes = cfg.tailwindAttributes.filter(
117
+ (a): a is string => typeof a === 'string' && !a.startsWith('/'),
118
+ );
119
+ }
120
+ if (Array.isArray(cfg.tailwindPhpSources)) {
121
+ out.phpSources = cfg.tailwindPhpSources.filter((p): p is string => typeof p === 'string');
122
+ }
123
+ return out;
124
+ } catch {
125
+ return {}; // prettier not installed or config unreadable — flags only
113
126
  }
114
- return out;
115
- } catch {
116
- return {}; // prettier not installed or config unreadable — flags only
117
- }
118
127
  }
119
128
 
120
129
  async function main() {
121
- const argv = process.argv.slice(2);
122
- if (argv[0] === 'init') return runInit(argv.slice(1));
123
-
124
- const cli = parseArgs(argv);
125
-
126
- const pc = await fromPrettierConfig();
127
- const stylesheet = cli.stylesheet ?? pc.stylesheet;
128
- if (!stylesheet) {
129
- console.error(
130
- 'No Tailwind stylesheet found. Pass --stylesheet <path> or set ' +
131
- '`tailwindStylesheet` in your Prettier config.',
132
- );
133
- process.exit(2);
134
- }
135
- if (pc.attributes) {
136
- for (const a of pc.attributes) {
137
- if (!cli.attrs.includes(a)) cli.attrs.push(a);
130
+ const argv = process.argv.slice(2);
131
+ if (argv[0] === 'init') return runInit(argv.slice(1));
132
+
133
+ const cli = parseArgs(argv);
134
+
135
+ const pc = await fromPrettierConfig();
136
+ const stylesheet = cli.stylesheet ?? pc.stylesheet;
137
+ if (!stylesheet) {
138
+ console.error(
139
+ 'No Tailwind stylesheet found. Pass --stylesheet <path> or set ' +
140
+ '`tailwindStylesheet` in your Prettier config.',
141
+ );
142
+ process.exit(2);
143
+ }
144
+ if (pc.attributes) {
145
+ for (const a of pc.attributes) {
146
+ if (!cli.attrs.includes(a)) cli.attrs.push(a);
147
+ }
148
+ }
149
+
150
+ const sortFn = await createTailwindSortFn({ stylesheet });
151
+ const opts: TransformOptions = {
152
+ attributes: cli.attrs,
153
+ shortOpenTags: cli.shortTags,
154
+ };
155
+
156
+ // Pre-scan the opt-in `tailwindPhpSources` globs; a file gets `sortPhpStrings` only if it matches one.
157
+ const phpSources = [...cli.phpSources, ...(pc.phpSources ?? [])];
158
+ const phpSourceFiles = new Set<string>();
159
+ for await (const file of scanFiles(phpSources)) {
160
+ if (!ignored(file)) phpSourceFiles.add(file);
138
161
  }
139
- }
140
-
141
- const sortFn = await createTailwindSortFn({ stylesheet });
142
- const opts: TransformOptions = {
143
- attributes: cli.attrs,
144
- shortOpenTags: cli.shortTags,
145
- };
146
-
147
- let scanned = 0;
148
- let changed = 0;
149
-
150
- for (const pattern of cli.globs) {
151
- for await (const file of scanFiles([pattern])) {
152
- if (ignored(file)) continue;
153
- scanned++;
154
- const src = await readFile(file, 'utf8');
155
- const out = transform(src, sortFn, opts);
156
- if (out !== src) {
157
- changed++;
158
- if (cli.check) {
159
- console.log(`needs sorting: ${file}`);
160
- } else {
161
- await writeFile(file, out);
162
- console.log(`sorted: ${file}`);
162
+
163
+ let scanned = 0;
164
+ let changed = 0;
165
+ const seen = new Set<string>();
166
+
167
+ // Include php-source files so a designated holder is sorted even outside the main globs; `seen` dedupes.
168
+ for await (const file of scanFiles([...cli.globs, ...phpSources])) {
169
+ if (ignored(file) || seen.has(file)) continue;
170
+ seen.add(file);
171
+ scanned++;
172
+ const src = await readFile(file, 'utf8');
173
+ const out = transform(src, sortFn, { ...opts, sortPhpStrings: phpSourceFiles.has(file) });
174
+ if (out !== src) {
175
+ changed++;
176
+ if (cli.check) {
177
+ console.log(`needs sorting: ${file}`);
178
+ } else {
179
+ await writeFile(file, out);
180
+ console.log(`sorted: ${file}`);
181
+ }
163
182
  }
164
- }
165
183
  }
166
- }
167
184
 
168
- console.log(`${scanned} file(s) scanned, ${changed} ${cli.check ? 'need(s) sorting' : 'updated'}`);
169
- if (cli.check && changed > 0) process.exit(1);
185
+ console.log(`${scanned} file(s) scanned, ${changed} ${cli.check ? 'need(s) sorting' : 'updated'}`);
186
+ if (cli.check && changed > 0) process.exit(1);
170
187
  }
171
188
 
172
189
  main().catch((err) => {
173
- console.error(err);
174
- process.exit(1);
190
+ console.error(err);
191
+ process.exit(1);
175
192
  });