@runtimestudio/tailwind-sort-php 0.2.0 → 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/src/cli.ts CHANGED
@@ -9,10 +9,11 @@
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
  *
15
- * Defaults to all `.php` files under `cwd` (`"**" + "/*.php"`) when no globs are given.
16
+ * Defaults to all `.php` files under `cwd` when no globs are given.
16
17
  * Skips `node_modules`, `vendor`, `dist` and `.git`. The `init` subcommand installs the pre-commit hook; see `init.ts`.
17
18
  */
18
19
 
@@ -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,18 +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
- // Use `Bun.Glob` when available, fall back to `node:fs` glob (Node 22+).
69
- if (typeof (globalThis as any).Bun !== 'undefined') {
70
- const { Glob } = await import('bun');
71
- for (const pattern of globs) {
72
- for await (const f of new Glob(pattern).scan('.')) yield f;
73
- }
74
- } else {
75
- const { glob } = await import('node:fs/promises');
76
- for (const pattern of globs) {
77
- 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
+ }
78
82
  }
79
- }
80
83
  }
81
84
 
82
85
  /**
@@ -88,89 +91,102 @@ async function* scanFiles(globs: string[]): AsyncGenerator<string> {
88
91
  const ignored = (file: string) => IGNORE.some((d) => file.includes(`${d}/`) || file.startsWith(d));
89
92
 
90
93
  /**
91
- * Best-effort read of the project's resolved Prettier config, so this tool shares one source of truth with
92
- * `prettier-plugin-tailwindcss`. Picks up `tailwindStylesheet` (resolved relative to the config file) and
93
- * `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`.
94
96
  *
95
- * @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.
96
98
  */
97
99
  async function fromPrettierConfig(): Promise<{
98
- stylesheet?: string;
99
- attributes?: string[];
100
+ stylesheet?: string;
101
+ attributes?: string[];
102
+ phpSources?: string[];
100
103
  }> {
101
- try {
102
- const prettier = await import('prettier');
103
- const { dirname, resolve } = await import('node:path');
104
- const configFile = await prettier.resolveConfigFile();
105
- if (!configFile) return {};
106
- const cfg = (await prettier.resolveConfig(configFile)) as Record<string, unknown> | null;
107
- if (!cfg) return {};
108
- const out: { stylesheet?: string; attributes?: string[] } = {};
109
- if (typeof cfg.tailwindStylesheet === 'string') {
110
- out.stylesheet = resolve(dirname(configFile), cfg.tailwindStylesheet);
111
- }
112
- if (Array.isArray(cfg.tailwindAttributes)) {
113
- 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
114
126
  }
115
- return out;
116
- } catch {
117
- return {}; // prettier not installed or config unreadable — flags only
118
- }
119
127
  }
120
128
 
121
129
  async function main() {
122
- const argv = process.argv.slice(2);
123
- if (argv[0] === 'init') return runInit(argv.slice(1));
124
-
125
- const cli = parseArgs(argv);
126
-
127
- const pc = await fromPrettierConfig();
128
- const stylesheet = cli.stylesheet ?? pc.stylesheet;
129
- if (!stylesheet) {
130
- console.error(
131
- 'No Tailwind stylesheet found. Pass --stylesheet <path> or set ' +
132
- '`tailwindStylesheet` in your Prettier config.',
133
- );
134
- process.exit(2);
135
- }
136
- if (pc.attributes) {
137
- for (const a of pc.attributes) {
138
- 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);
139
161
  }
140
- }
141
-
142
- const sortFn = await createTailwindSortFn({ stylesheet });
143
- const opts: TransformOptions = {
144
- attributes: cli.attrs,
145
- shortOpenTags: cli.shortTags,
146
- };
147
-
148
- let scanned = 0;
149
- let changed = 0;
150
-
151
- for (const pattern of cli.globs) {
152
- for await (const file of scanFiles([pattern])) {
153
- if (ignored(file)) continue;
154
- scanned++;
155
- const src = await readFile(file, 'utf8');
156
- const out = transform(src, sortFn, opts);
157
- if (out !== src) {
158
- changed++;
159
- if (cli.check) {
160
- console.log(`needs sorting: ${file}`);
161
- } else {
162
- await writeFile(file, out);
163
- 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
+ }
164
182
  }
165
- }
166
183
  }
167
- }
168
184
 
169
- console.log(`${scanned} file(s) scanned, ${changed} ${cli.check ? 'need(s) sorting' : 'updated'}`);
170
- 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);
171
187
  }
172
188
 
173
189
  main().catch((err) => {
174
- console.error(err);
175
- process.exit(1);
190
+ console.error(err);
191
+ process.exit(1);
176
192
  });
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
  */
@@ -19,28 +19,28 @@ import type { Island } from './islands.ts';
19
19
  * Location of a sortable class attribute value within the source.
20
20
  */
21
21
  export interface ClassAttr {
22
- /**
23
- * Attribute name as written (e.g. `class`, `className`).
24
- */
25
- name: string;
26
- /**
27
- * Offset of the first character inside the quotes.
28
- */
29
- valueStart: number;
30
- /**
31
- * Offset just past the last character inside the quotes (exclusive).
32
- */
33
- valueEnd: number;
22
+ /**
23
+ * Attribute name as written (e.g. `class`, `className`).
24
+ */
25
+ name: string;
26
+ /**
27
+ * Offset of the first character inside the quotes.
28
+ */
29
+ valueStart: number;
30
+ /**
31
+ * Offset just past the last character inside the quotes (exclusive).
32
+ */
33
+ valueEnd: number;
34
34
  }
35
35
 
36
36
  /**
37
37
  * Options controlling which attributes are collected.
38
38
  */
39
39
  export interface HtmlScanOptions {
40
- /**
41
- * Lowercase attribute names to collect; default `['class', 'classname']`.
42
- */
43
- attributes?: string[];
40
+ /**
41
+ * Lowercase attribute names to collect; default `['class', 'classname']`.
42
+ */
43
+ attributes?: string[];
44
44
  }
45
45
 
46
46
  const RAW_TEXT_TAGS = new Set(['script', 'style', 'textarea', 'title']);
@@ -56,16 +56,16 @@ const NUL = '\x00';
56
56
  * @returns Masked source of identical length.
57
57
  */
58
58
  export function maskIslands(src: string, islands: Island[]): string {
59
- if (islands.length === 0) return src;
60
- let out = '';
61
- let pos = 0;
62
- for (const isl of islands) {
63
- out += src.slice(pos, isl.start);
64
- out += NUL.repeat(isl.end - isl.start);
65
- pos = isl.end;
66
- }
67
- out += src.slice(pos);
68
- return out;
59
+ if (islands.length === 0) return src;
60
+ let out = '';
61
+ let pos = 0;
62
+ for (const isl of islands) {
63
+ out += src.slice(pos, isl.start);
64
+ out += NUL.repeat(isl.end - isl.start);
65
+ pos = isl.end;
66
+ }
67
+ out += src.slice(pos);
68
+ return out;
69
69
  }
70
70
 
71
71
  /**
@@ -76,58 +76,58 @@ export function maskIslands(src: string, islands: Island[]): string {
76
76
  * @returns Attribute value locations in document order. Offsets index into the original source.
77
77
  */
78
78
  export function findClassAttributes(masked: string, opts: HtmlScanOptions = {}): ClassAttr[] {
79
- const wanted = new Set((opts.attributes ?? ['class', 'classname']).map((a) => a.toLowerCase()));
80
- const out: ClassAttr[] = [];
81
- const len = masked.length;
82
- let i = 0;
83
-
84
- while (i < len) {
85
- const lt = masked.indexOf('<', i);
86
- if (lt === -1) break;
87
-
88
- // HTML comment.
89
- if (masked.startsWith('<!--', lt)) {
90
- const close = masked.indexOf('-->', lt + 4);
91
- i = close === -1 ? len : close + 3;
92
- continue;
93
- }
94
-
95
- // Doctype / CDATA / other declarations.
96
- if (masked[lt + 1] === '!') {
97
- const close = masked.indexOf('>', lt + 2);
98
- i = close === -1 ? len : close + 1;
99
- continue;
100
- }
101
-
102
- // Closing tag.
103
- if (masked[lt + 1] === '/') {
104
- const close = masked.indexOf('>', lt + 2);
105
- i = close === -1 ? len : close + 1;
106
- continue;
107
- }
108
-
109
- // Opening tag?
110
- if (lt + 1 < len && /[A-Za-z]/.test(masked[lt + 1])) {
111
- let j = lt + 1;
112
- while (j < len && /[A-Za-z0-9:-]/.test(masked[j])) j++;
113
- const tagName = masked.slice(lt + 1, j).toLowerCase();
114
-
115
- j = scanTagAttributes(masked, j, wanted, out);
116
-
117
- // Skip raw-text element content up to its closing tag.
118
- if (RAW_TEXT_TAGS.has(tagName)) {
119
- const closer = `</${tagName}`;
120
- const idx = masked.toLowerCase().indexOf(closer, j);
121
- j = idx === -1 ? len : idx;
122
- }
123
- i = j;
124
- continue;
79
+ const wanted = new Set((opts.attributes ?? ['class', 'classname']).map((a) => a.toLowerCase()));
80
+ const out: ClassAttr[] = [];
81
+ const len = masked.length;
82
+ let i = 0;
83
+
84
+ while (i < len) {
85
+ const lt = masked.indexOf('<', i);
86
+ if (lt === -1) break;
87
+
88
+ // HTML comment.
89
+ if (masked.startsWith('<!--', lt)) {
90
+ const close = masked.indexOf('-->', lt + 4);
91
+ i = close === -1 ? len : close + 3;
92
+ continue;
93
+ }
94
+
95
+ // Doctype / CDATA / other declarations.
96
+ if (masked[lt + 1] === '!') {
97
+ const close = masked.indexOf('>', lt + 2);
98
+ i = close === -1 ? len : close + 1;
99
+ continue;
100
+ }
101
+
102
+ // Closing tag.
103
+ if (masked[lt + 1] === '/') {
104
+ const close = masked.indexOf('>', lt + 2);
105
+ i = close === -1 ? len : close + 1;
106
+ continue;
107
+ }
108
+
109
+ // Opening tag?
110
+ if (lt + 1 < len && /[A-Za-z]/.test(masked[lt + 1])) {
111
+ let j = lt + 1;
112
+ while (j < len && /[A-Za-z0-9:-]/.test(masked[j])) j++;
113
+ const tagName = masked.slice(lt + 1, j).toLowerCase();
114
+
115
+ j = scanTagAttributes(masked, j, wanted, out);
116
+
117
+ // Skip raw-text element content up to its closing tag.
118
+ if (RAW_TEXT_TAGS.has(tagName)) {
119
+ const closer = `</${tagName}`;
120
+ const idx = masked.toLowerCase().indexOf(closer, j);
121
+ j = idx === -1 ? len : idx;
122
+ }
123
+ i = j;
124
+ continue;
125
+ }
126
+
127
+ i = lt + 1;
125
128
  }
126
129
 
127
- i = lt + 1;
128
- }
129
-
130
- return out;
130
+ return out;
131
131
  }
132
132
 
133
133
  const isTagWs = (c: string) => c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === NUL;
@@ -137,48 +137,48 @@ const isTagWs = (c: string) => c === ' ' || c === '\t' || c === '\n' || c === '\
137
137
  * Returns the offset after `>` (or EOF). Pushes matches into `out`.
138
138
  */
139
139
  function scanTagAttributes(masked: string, i: number, wanted: Set<string>, out: ClassAttr[]): number {
140
- const len = masked.length;
141
-
142
- while (i < len) {
143
- while (i < len && isTagWs(masked[i])) i++;
144
- if (i >= len) return len;
145
-
146
- const c = masked[i];
147
- if (c === '>') return i + 1;
148
- if (c === '/') {
149
- i++;
150
- continue;
151
- }
152
-
153
- // Attribute name.
154
- const nameStart = i;
155
- while (i < len && !isTagWs(masked[i]) && masked[i] !== '=' && masked[i] !== '>' && masked[i] !== '/') i++;
156
- const name = masked.slice(nameStart, i);
157
- if (name.length === 0) {
158
- i++;
159
- continue;
160
- }
161
-
162
- while (i < len && isTagWs(masked[i])) i++;
163
- if (masked[i] !== '=') continue; // boolean attribute
164
-
165
- i++;
166
- while (i < len && isTagWs(masked[i])) i++;
167
-
168
- const q = masked[i];
169
- if (q === '"' || q === "'") {
170
- const valueStart = i + 1;
171
- const close = masked.indexOf(q, valueStart);
172
- const valueEnd = close === -1 ? len : close;
173
- if (wanted.has(name.toLowerCase())) {
174
- out.push({ name, valueStart, valueEnd });
175
- }
176
- i = valueEnd + 1;
177
- } else {
178
- // Unquoted value — read it but never rewrite (too risky to widen).
179
- while (i < len && !isTagWs(masked[i]) && masked[i] !== '>') i++;
140
+ const len = masked.length;
141
+
142
+ while (i < len) {
143
+ while (i < len && isTagWs(masked[i])) i++;
144
+ if (i >= len) return len;
145
+
146
+ const c = masked[i];
147
+ if (c === '>') return i + 1;
148
+ if (c === '/') {
149
+ i++;
150
+ continue;
151
+ }
152
+
153
+ // Attribute name.
154
+ const nameStart = i;
155
+ while (i < len && !isTagWs(masked[i]) && masked[i] !== '=' && masked[i] !== '>' && masked[i] !== '/') i++;
156
+ const name = masked.slice(nameStart, i);
157
+ if (name.length === 0) {
158
+ i++;
159
+ continue;
160
+ }
161
+
162
+ while (i < len && isTagWs(masked[i])) i++;
163
+ if (masked[i] !== '=') continue; // boolean attribute
164
+
165
+ i++;
166
+ while (i < len && isTagWs(masked[i])) i++;
167
+
168
+ const q = masked[i];
169
+ if (q === '"' || q === "'") {
170
+ const valueStart = i + 1;
171
+ const close = masked.indexOf(q, valueStart);
172
+ const valueEnd = close === -1 ? len : close;
173
+ if (wanted.has(name.toLowerCase())) {
174
+ out.push({ name, valueStart, valueEnd });
175
+ }
176
+ i = valueEnd + 1;
177
+ } else {
178
+ // Unquoted value — read it but never rewrite (too risky to widen).
179
+ while (i < len && !isTagWs(masked[i]) && masked[i] !== '>') i++;
180
+ }
180
181
  }
181
- }
182
182
 
183
- return len;
183
+ return len;
184
184
  }
package/src/index.ts CHANGED
@@ -11,4 +11,5 @@
11
11
  export { transform, type SortFn, type TransformOptions } from './transform.ts';
12
12
  export { findIslands, type Island, type IslandOptions } from './islands.ts';
13
13
  export { maskIslands, findClassAttributes, type ClassAttr, type HtmlScanOptions } from './html.ts';
14
+ export { findSortablePhpStrings, type PhpStringRange } from './php-strings.ts';
14
15
  export { createTailwindSortFn, type SorterOptions } from './sorter.ts';